当为x86_64-unknown-linux-musl目标编译此代码时,会生成.got节:

fn main() {
    println!("Hello, world!");
}
$ cargo build --release --target x86_64-unknown-linux-musl
$ readelf -S hello
There are 30 section headers, starting at offset 0x26dc08:

Section Headers:
[Nr] Name              Type             Address           Offset
   Size              EntSize          Flags  Link  Info  Align
...
[12] .got              PROGBITS         0000000000637b58  00037b58
   00000000000004a8  0000000000000008  WA       0     0     8
...

根据类似C代码的this answer.got部分是可以安全删除的工件.然而,对我来说:

$ objcopy -R.got hello hello_no_got
$ ./hello_no_got
[1]    3131 segmentation fault (core dumped)  ./hello_no_got

查看反汇编,我发现get基本上保存静态函数地址:

$ objdump -d hello -M intel
...
0000000000400340 <_ZN5hello4main17h5d434a6e08b2e3b8E>:
...
  40037c:       ff 15 26 7a 23 00       call   QWORD PTR [rip+0x237a26]        # 637da8 <_GLOBAL_OFFSET_TABLE_+0x250>
...

$ objdump -s -j .got hello | grep 637da8
637da8 50434000 00000000 b0854000 00000000  PC@.......@.....

$ objdump -d hello -M intel | grep 404350
0000000000404350 <_ZN3std2io5stdio6_print17h522bda9f206d7fddE>:
  404350:       41 57                   push   r15

数字404350来自50434000 00000000,这是一个小小的endian 0x00000000000404350(这并不明显;我必须在GDB下运行二进制才能弄清楚!)

这令人困惑,因为维基百科says

[GOT]被执行的程序用来在运行时查找编译时未知的全局变量的地址.全局偏移表由动态链接器在进程 bootstrap 中更新.

  1. 为什么有礼物?从反汇编来看,编译器似乎知道所有需要的地址.据我所知,动态链接器没有 bootstrap 功能:我的二进制文件中既没有INTERP个程序头,也没有DYNAMIC个程序头;
  2. 为什么存储函数有指针?维基百科称GOT只适用于全局变量,函数指针应该包含在PLT中.

推荐答案

TL;DR summary:GOT实际上是一个基本的构建工件,我可以通过简单的机器代码操作来摆脱它.

Breakdown

如果我们看看

$ objdump -dj .text hello

搜索GLOBAL,我们只看到四种不同类型的对GOT的引用(常数不同):

  40037c:       ff 15 26 7a 23 00       call   QWORD PTR [rip+0x237a26]        # 637da8 <_GLOBAL_OFFSET_TABLE_+0x250>
  425903:       ff 25 5f 26 21 00       jmp    QWORD PTR [rip+0x21265f]        # 637f68 <_GLOBAL_OFFSET_TABLE_+0x410>
  41d8b5:       48 3b 1d b4 a5 21 00    cmp    rbx,QWORD PTR [rip+0x21a5b4]    # 637e70 <_GLOBAL_OFFSET_TABLE_+0x318>
  40b259:       48 83 3d 7f cb 22 00    cmp    QWORD PTR [rip+0x22cb7f],0x0    # 637de0 <_GLOBAL_OFFSET_TABLE_+0x288>
  40b260:       00

所有这些都是读取指令,这意味着GOT在运行时不会被修改.这反过来意味着我们可以静态解析get所指的地址!让我们逐一考虑参考类型:

  1. call QWORD PTR [rip+0x2126be]只是说"转到地址[rip+0x2126be],从那里取8个字节,将它们解释为函数地址并调用函数".我们可以简单地用直接调用替换此指令:
  40037c:       e8 cf 3f 00 00          call   404350 <_ZN3std2io5stdio6_print17h522bda9f206d7fddE>
  400381:       90                      nop

注意末尾的nop:我们需要替换构成第一条指令的所有6字节机器代码,但是我们替换的指令只有5字节,所以我们需要填充它.从根本上说,当我们修补一个已编译的二进制文件时,只有在指令不再长的情况下,我们才能用另一条指令替换它.

  1. jmp QWORD PTR [rip+0x21265f]与前一个相同,但它不调用地址,而是跳转到地址.这就变成了:
  425903:       e9 b8 f7 ff ff          jmp    4250c0 <_ZN68_$LT$core..fmt..builders..PadAdapter$u20$as$u20$core..fmt..Write$GT$9write_str17hc384e51187942069E>
  425908:       90                      nop
  1. cmp rbx,QWORD PTR [rip+0x21a5b4]-这将从[rip+0x21a5b4]中提取8个字节,并将它们与rbx寄存器的内容进行比较.这是一个棘手的问题,因为cmp无法将寄存器内容与64位立即数进行比较.我们可以使用另一个寄存器来实现这一点,但我们不知道这条指令使用了哪些寄存器.一个谨慎的解决方案应该是
push rax
mov rax,0x0000006363c0
cmp rbx,rax
pop rax

但这将远远超出我们7字节的限制.真正的解决方案源于一种观察,即get只包含地址;我们的地址空间(大致)包含在[0x400000;0x650000]范围内,可以在程序头中看到:

$ readelf -l hello
...
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000035b50 0x0000000000035b50  R E    0x200000
  LOAD           0x0000000000036380 0x0000000000636380 0x0000000000636380
                 0x0000000000001dd0 0x0000000000003918  RW     0x200000
...

因此,我们(大部分情况下)只需比较一个get条目的4个字节,而不是8个字节.因此,替代是:

  41d8b5:       81 fb c0 63 63 00       cmp    ebx,0x6363c0
  41d8bb:       90                      nop
  1. 最后一个由两行objdump个输出组成,因为8个字节不适合一行:
  40b259:       48 83 3d 7f cb 22 00    cmp    QWORD PTR [rip+0x22cb7f],0x0    # 637de0 <_GLOBAL_OFFSET_TABLE_+0x288>
  40b260:       00

它只是将GET的8个字节与一个常量(在本例中为0x0)进行比较.事实上,我们可以静态地进行比较;如果操作数比较相等,我们用

  40b259:       48 39 c0                cmp    rax,rax
  40b25c:       90                      nop
  40b25d:       90                      nop
  40b25e:       90                      nop
  40b25f:       90                      nop
  40b260:       90                      nop

显然,寄存器总是等于它自己.这里需要很多填充物!

如果左操作数大于右操作数,我们用

  40b259:       48 83 fc 00             cmp    rsp,0x0 
  40b25d:       90                      nop
  40b25e:       90                      nop
  40b25f:       90                      nop
  40b260:       90                      nop

实际上,rsp总是大于零.

如果左操作数比右操作数小,事情会变得更复杂,但因为我们有很多字节(8!)我们可以做到:

  40b259:  50                      push   rax
  40b25a:  31 c0                   xor    eax,eax
  40b25c:  83 f8 01                cmp    eax,0x1
  40b25f:  58                      pop    rax
  40b260:  90                      nop

请注意,第二条和第三条指令使用eax而不是rax,因为涉及eaxcmpxor比涉及rax的指令占用的字节少一个.

Testing

我已经编写了一个Python脚本来自动执行所有这些替换(这有点粗糙,但需要解析objdump个输出):

#!/usr/bin/env python3

import re
import sys
import argparse
import subprocess

def read_u64(binary):
    return sum(binary[i] * 256 ** i for i in range(8))

def distance_u32(start, end):
    assert abs(end - start) < 2 ** 31
    diff = end - start
    if diff < 0:
        return 2 ** 32 + diff
    else:
        return diff

def to_u32(x):
    assert 0 <= x < 2 ** 32
    return bytes((x // (256 ** i)) % 256 for i in range(4))

class GotInstruction:
    def __init__(self, lines, symbol_address, symbol_offset):
        self.address = int(lines[0].split(":")[0].strip(), 16)
        self.offset = symbol_offset + (self.address - symbol_address)
        self.got_offset = int(lines[0].split("(File Offset: ")[1].strip().strip(")"), 16)
        self.got_offset = self.got_offset % 0x200000  # No idea why the offset is actually wrong
        self.bytes = []
        for line in lines:
            self.bytes += [int(x, 16) for x in line.split("\t")[1].split()]

class TextDump:
    symbol_regex = re.compile(r"^([0-9,a-f]{16}) <(.*)> \(File Offset: 0x([0-9,a-f]*)\):")

    def __init__(self, binary_path):
        self.got_instructions = []
        objdump_output = subprocess.check_output(["objdump", "-Fdj", ".text", "-M", "intel",
                                                  binary_path])
        lines = objdump_output.decode("utf-8").split("\n")
        current_symbol_address = 0
        current_symbol_offset = 0
        for line_group in self.group_lines(lines):
            match = self.symbol_regex.match(line_group[0])
            if match is not None:
                current_symbol_address = int(match.group(1), 16)
                current_symbol_offset = int(match.group(3), 16)
            elif "_GLOBAL_OFFSET_TABLE_" in line_group[0]:
                instruction = GotInstruction(line_group, current_symbol_address,
                                             current_symbol_offset)
                self.got_instructions.append(instruction)

    @staticmethod
    def group_lines(lines):
        if not lines:
            return
        line_group = [lines[0]]
        for line in lines[1:]:
            if line.count("\t") == 1:  # this line continues the previous one
                line_group.append(line)
            else:
                yield line_group
                line_group = [line]
        yield line_group

    def __iter__(self):
        return iter(self.got_instructions)

def read_binary_file(path):
    try:
        with open(path, "rb") as f:
            return f.read()
    except (IOError, OSError) as exc:
        print(f"Failed to open {path}: {exc.strerror}")
        sys.exit(1)

def write_binary_file(path, content):
    try:
        with open(path, "wb") as f:
            f.write(content)
    except (IOError, OSError) as exc:
        print(f"Failed to open {path}: {exc.strerror}")
        sys.exit(1)

def patch_got_reference(instruction, binary_content):
    got_data = read_u64(binary_content[instruction.got_offset:])
    code = instruction.bytes
    if code[0] == 0xff:
        assert len(code) == 6
        relative_address = distance_u32(instruction.address, got_data)
        if code[1] == 0x15:  # call QWORD PTR [rip+...]
            patch = b"\xe8" + to_u32(relative_address - 5) + b"\x90"
        elif code[1] == 0x25:  # jmp QWORD PTR [rip+...]
            patch = b"\xe9" + to_u32(relative_address - 5) + b"\x90"
        else:
            raise ValueError(f"unknown machine code: {code}")
    elif code[:3] == [0x48, 0x83, 0x3d]:  # cmp QWORD PTR [rip+...],<BYTE>
        assert len(code) == 8
        if got_data == code[7]:
            patch = b"\x48\x39\xc0" + b"\x90" * 5  # cmp rax,rax
        elif got_data > code[7]:
            patch = b"\x48\x83\xfc\x00" + b"\x90" * 3  # cmp rsp,0x0
        else:
            patch = b"\x50\x31\xc0\x83\xf8\x01\x90"  # push rax
                                                     # xor eax,eax
                                                     # cmp eax,0x1
                                                     # pop rax
    elif code[:3] == [0x48, 0x3b, 0x1d]:  # cmp rbx,QWORD PTR [rip+...]
        assert len(code) == 7
        patch = b"\x81\xfb" + to_u32(got_data) + b"\x90"  # cmp ebx,<DWORD>
    else:
        raise ValueError(f"unknown machine code: {code}")
    return dict(offset=instruction.offset, data=patch)

def make_got_patches(binary_path, binary_content):
    patches = []
    text_dump = TextDump(binary_path)
    for instruction in text_dump.got_instructions:
        patches.append(patch_got_reference(instruction, binary_content))
    return patches

def apply_patches(binary_content, patches):
    for patch in patches:
        offset = patch["offset"]
        data = patch["data"]
        binary_content = binary_content[:offset] + data + binary_content[offset + len(data):]
    return binary_content

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("binary_path", help="Path to ELF binary")
    parser.add_argument("-o", "--output", help="Output file path", required=True)
    args = parser.parse_args()

    binary_content = read_binary_file(args.binary_path)
    patches = make_got_patches(args.binary_path, binary_content)
    patched_content = apply_patches(binary_content, patches)
    write_binary_file(args.output, patched_content)

if __name__ == "__main__":
    main()

现在我们可以真正摆脱GOT了:

$ cargo build --release --target x86_64-unknown-linux-musl
$ ./resolve_got.py target/x86_64-unknown-linux-musl/release/hello -o hello_no_got
$ objcopy -R.got hello_no_got
$ readelf -e hello_no_got | grep .got
$ ./hello_no_got
Hello, world!

我还在我的~3k LOC应用程序上测试了它,它似乎工作正常.

另外,我不是组装专家,所以上面的一些可能不准确.

Rust相关问答推荐

为什么是!为Rust中的RwLockReadGuard和RwLockWriteGuard实现的发送特征?

在一个tauri协议处理程序中调用一个rectuc函数的推荐技术是什么?

如果A == B,则将Rc A下推到Rc B

修改切片/引用数组

MPSC频道在接收器处阻塞

如何删除Mac Tauri上的停靠图标?

如何实现Serde::Ser::Error的调试

如何防止Cargo 单据和Cargo 出口发布( crate )项目

找不到 .has_func 或 .get_func 的 def

Rust 重写函数参数

Rust 1.70 中未找到 Trait 实现

无法将`&Vec>`转换为`&[&str]`

注释闭包参数强调使用高阶排定特征界限

为什么 &i32 可以与 Rust 中的 &&i32 进行比较?

改变不实现克隆的 dioxus UseState struct

在 RefCell 上borrow

以下打印数组每个元素的 Rust 代码有什么问题?

C++ 中的 CRTP 是一种表达其他语言中特征和/或 ADT 的方法吗?

为什么 u64::trailing_zeros() 在无分支工作时生成分支程序集?

函数参数的 Rust 功能标志