【问题标题】:Writing a thunk to verify SysV ABI compliance编写 thunk 以验证 SysV ABI 合规性
【发布时间】:2017-10-24 08:04:20
【问题描述】:

SysV ABI 定义了 Linux 的 C 级和程序集调用约定。

我想编写一个通用 thunk 来验证一个函数是否满足 ABI 对被调用者保留寄存器的限制,并且(可能)尝试返回一个值。

所以给定一个像 int foo(int, int) 这样的目标函数,在汇编中编写这样一个 thunk 非常容易3,比如1:

foo_thunk:
push rbp
push rbx
push r12
push r13
push r14
push r15
call foo
cmp rbp, [rsp + 40]
jne bad_rbp
cmp rbx, [rsp + 32]
jne bad_rbx
cmp r12, [rsp + 24]
jne bad_r12
cmp r13, [rsp + 16]
jne bad_r13
cmp r14, [rsp + 8]
jne bad_r14
cmp r15, [rsp]
jne bad_r15
ret

现在我当然不想为每个调用编写一个单独的foo_thunk 方法,我只想要一个通用的方法。这个应该接受一个指向底层函数的指针(比如说rax),并且会使用间接调用call [rax]而不是call foo,但否则会相同。

我不知道如何在 C 级别(或在 C++ 中,似乎有更多元编程选项 - 但我们在这里坚持使用 C)实现 thunk 的透明使用。我想采取类似的东西:

foo(1, 2);

并将其转换为对thunk 的调用,但仍然在相同的位置传递相同的参数(这是thunk 工作所必需的)。

预计我会修改源代码,可能使用宏或模板魔术,因此上面的调用可以更改为:

CHECK_THUNK(foo, (1, 2));

为宏指定底层函数的名称。原则上它可以把它翻译成2

check_thunk(&foo, 1, 2);

我怎样才能声明 check_thunk 呢?第一个参数是“某种类型的”函数指针。我们可以试试:

check_thunk(void (*ptr)(void), ...);

所以一个“通用”函数指针(所有指针都可以有效地转换为这个,我们只会在语言标准的爪子之外真正称它为汇编),加上可变参数。

但这不起作用:... 的提升规则与正确原型化的函数完全不同。它适用于foo(1, 2) 示例,但如果您改为调用foo(1.0, 2),则可变参数版本只会将1.0 保留为double,并且您将调用foo 并使用完全错误的值(@987654339 @ 值双关语为整数。

上面还有一个缺点是将函数指针作为第一个参数传递,这意味着 thunk 不再按原样工作:它必须将函数指针保存在 rdi 某处,然后将所有值移动一个(即mov rdi, rsi)。如果有非注册参数,事情就会变得非常混乱。

有什么办法可以让这项工作顺利进行吗?

注意:这种类型的 thunk 基本上与堆栈上的任何参数传递都不兼容,这是这种方法的可接受限制(它不应该用于具有那么多参数的函数或MEMORY 类参数)。


1 这是检查被调用者保留的寄存器,但其他检查同样简单。

2 实际上,您甚至不需要宏来实现这一点 - 但它也在那里,因此您可以在发布版本中关闭 thunk 并直接调用。

3 好吧,“简单”我想我的意思是不是在所有情况下都有效。显示的 thunk 没有正确对齐堆栈(易于修复),如果 foo 有任何堆栈传递的参数(明显更难修复),则会中断。

【问题讨论】:

  • 我想知道您是否可以为此使用任何plt 基础架构。例如修改gcc 以通过thunk 包装器而不是通过PLT 调用?或者修改动态链接器解析的东西来解析PLT调用以通过thunk?并使用-fPIC 编译以强制所有(?)调用通过PLT。我猜你只希望它用于一些手写函数,而不是编译器输出,所以这将是矫枉过正,而且每个函数都可以。
  • @PeterCordes 即使使用PIC,我也很确定只有调用其他对象(例如,另一个.so)才会通过plt
  • 我认为同一对象中的外部可见函数通过 PLT,以允许符号插入。见Sorry state of dynamic libraries on Linux
  • 是的,我认为你是对的。我不确定它是否适用于带有 -fPIE 的可执行文件?我不是在写共享对象。
  • @PeterCordes - 可能有几十个函数。对于我当前的实现,我可以完全在 asm 中进行检查,例如,使用宏编译函数或检查代码(使用 thunk 或仅内联每个函数的代码。我也想要一个 C 解决方案虽然因为它似乎更普遍有用,但重新编译 asm 并不总是可行的(而且我用 C 编写的越多,asm 越少越好)。

标签: c linux x86 function-pointers interceptor


【解决方案1】:

以特定于 gcc 的方式执行此操作的一种方法是利用 typeofnested functions 创建一个函数指针,该指针嵌入对底层函数的调用,但是本身没有任何论据。

可以将此指针传递给 thunk 方法,该方法调用它并验证 ABI 合规性。

以下是使用此方法将调用转换为int add3(int, int, int) 的示例:

原来的调用是这样的:

int res = add3(a, b, c);

然后将调用包装在宏中,如下所示2

CALL_THUNKED(int res, add3, (a,b,c));

... 扩展为:

    typedef typeof(add3  (a,b,c)) ret_type; 

    ret_type closure() {              
        return add3  (a,b,c);         
    }                                 
    typedef ret_type (*typed_closure)(void);  
    typedef ret_type (*thunk_t)(typed_closure); 

    thunk_t thunk = (thunk_t)closure_thunk; 
    int res = thunk(&closure);

我们在堆栈上创建closure() 函数,它使用原始参数直接调用add3。我们可以毫无困难地获取这个闭包的地址并将其传递给 asm 函数:调用它的最终效果将是使用参数1调用add3

剩下的 typedef 基本上都是处理返回类型。我们只有一个 closure_thunk 方法,像这样声明 void* closure_thunk(void (*)(void)); 并在汇编中实现。它需要一个函数指针(任何函数指针都可以转换为任何其他指针),但返回类型是“错误的”。我们将其转换为thunk_t,这是一个动态生成的typedef,用于具有“正确”返回类型的函数。

当然,这对于 C 函数当然是不合法的,但我们是在 asm 中实现该函数,所以我们回避了这个问题(如果你想更合规一点,你可以向 asm 代码询问正确类型的函数指针,每次都可以“生成”它,超出标准范围:当然它每次都返回相同的指针)。

asm 中的closure_thunk 函数的实现方式如下:

GLOBAL closure_thunk:function

closure_thunk:

push rsi
push_callee_saved

call rdi

; set up the function name
mov rdi, [rsp + 48]

; now check whether any regs were clobbered
cmp rbp, [rsp + 40]
jne bad_rbp
cmp rbx, [rsp + 32]
jne bad_rbx
cmp r12, [rsp + 24]
jne bad_r12
cmp r13, [rsp + 16]
jne bad_r13
cmp r14, [rsp + 8]
jne bad_r14
cmp r15, [rsp]
jne bad_r15

add rsp, 7 * 8
ret

也就是说,将我们要检查的所有寄存器(连同函数名)压入堆栈,调用rdi 中的函数,然后进行检查。 bad_* 方法未显示,但它们基本上会吐出一条错误消息,例如“Function add3 overwrote rbp...naughty!”和abort() 进程。

如果在堆栈上传递任何参数,这会中断,但它确实适用于在堆栈上传递的返回值(因为这种情况下的 ABI 传递了一个指向 `rax 中返回值位置的指针)。


1 这是如何实现的有点神奇:gcc 实际上将几个字节的可执行代码写入堆栈,closure 函数指针指向那里。这几个字节基本上加载了一个带有指向包含捕获变量的区域的指针的寄存器(在这种情况下为a, b, c),然后调用实际的(只读)closure() 代码,然后可以访问捕获的变量指针(并将它们传递给add3)。

2 事实证明,我们可能可以use gcc's statement expression syntax 以更常用的函数(如语法)编写宏,例如int res = CALL_THUNKED(add3, (a,b,c))

【讨论】:

  • 我刚刚在 Godbolt 上尝试过,因为我试图准确地记住 gcc 是如何将 r10 用于嵌套函数的。 godbolt.org/g/aS4S5M IDK 如果它与“静态链指针”相同,但它仅用于堆栈上的蹦床和闭包之间,因此没有其他代码必须将其视为保留调用。顺便说一句,在 ARM 上,将蹦床写入堆栈后,它必须调用 __clear_cache,因为大多数非 x86 架构没有一致的 I-cache。
  • 是的,很难看到 Godbolt 中发生了什么,因为你只看到代码生成代码,而不是它构建的代码(你必须在脑海中计算出最终在堆栈和解码...)。我刚刚用了gdb,踏上了蹦床。
【解决方案2】:

在 C 源代码级别(无需修改 gcc 或链接器来为您插入 thunk),您可以为每个 thunk 定义不同的原型,但仍共享相同的实现。

您可以在 asm 源代码中的定义上放置多个标签,因此 check_thunk_foocheck_thunk_bar 具有相同的地址,但您可以为每个标签使用不同的 C 原型。

或者你可以像这样制作弱别名:

int check_thunk_foo(void*, int, int) 
    __attribute__ ((weak, alias ("check_thunk_generic")));
// or maybe this should be ((weakref ("check_thunk_generic")))

#define foo(...) check_thunk_foo((void*)&foo, __VA_ARGS__)

// or to put the args in their original slots,
// but then you'd need different thunks for different numbers of integer args.
#define foo(x, y) check_thunk_foo((x), (y), (void*)&foo)

这样做的主要缺点是您需要复制+修改每个函数的原始原型。您可以使用 CPP 宏来解决这个问题,因此 arg 列表有一个定义点,并且真正的原型(以及如果启用的 thunk)都使用它。可能通过重新包含相同的.h 两次,包装宏定义不同。一次用于真正的原型,再次用于 thunk。


顺便说一句,将函数指针作为额外的 arg 传递给通用 thunk 可能存在问题。我认为不可能可靠地删除第一个参数并在 x86-64 SysV ABI 中转发其余参数。对于需要超过 6 个整数参数的函数,您不知道有多少堆栈参数。而且你不知道在第一个整数堆栈arg之前是否有FP堆栈args。

这对于在寄存器中传递所有可能的寄存器参数的函数应该可以正常工作。 (即,如果有任何堆栈参数,它们是按值或其他不能进入整数寄存器的大型结构。)

为了解决这个问题,thunk 可以基于返回地址而不是额外的隐藏 arg 进行调度,如果你有调试信息之类的东西来将调用站点返回地址映射到调用目标。或者你可以让 gcc 在raxr11 中传递一个隐藏的参数。从内联 asm 运行 call 很糟糕,因此您可能需要自定义 gcc 以支持在额外寄存器中传递函数指针的某些特殊属性。


但是如果您改为调用foo(1.0, 2),可变参数版本只会将1.0 保留为double,并且您将使用完全错误的值调用foo(将double 值双关整数。

这并不重要,但不,你会用xmm0=(double)1.0 打电话给foo(2, garbage)。可变参数函数仍然使用与非可变参数函数相同的寄存器参数(或者可以选择在寄存器用完之前在堆栈上传递 FP 参数,并将 al= 设置为小于 8)。

【讨论】:

  • 对,问题是我也想要一个 C++ 级别的通用 thunk,不仅因为它方便,而且因为在一个主要用例中,被调用的函数实际上是一个模板参数,因此是未知的。关于double 的好点比我声称的还要糟糕。如果超出了一些合理数量的参数,如果它在编译时失败,我可以 - 但是找到一个更好的方法来传递函数指针也会很好(所以有多个 thunk 也可以,也许他们可以委托给主将函数指针填充到某个常见的地方后发出 thunk。
  • @BeeOnRope:我知道这不是一个 的答案,但发布答案似乎是输入我的第一个可行想法的正确位置。您可能需要破解工具链以使其对您描述的模板用例更通用。或者,如果您只有所有 asm 函数名称的列表,则符号插入可以做到这一点。您可以为它们生成包装器并 LD_PRELOAD。
  • 我绝对不会因为破解工具链而失望,它应该可以与标准工具一起使用。使用插入是一个有趣的想法。我意识到像我在 OP 中所拥有的 any 类型的 thunk 不能与堆栈参数一起使用,因为额外的 call 意味着堆栈不是被调用者所期望的:它将寻找堆栈参数在错误的地方。
  • 我写了一个纯 asm 版本的 thunk here。它不处理堆栈参数。
  • 我测试了register void *volatile fp asm("rax") = &func; 如果变量未使用,它根本不起作用。如果使用该变量,它可能在没有 volatile 的情况下工作,但 gcc 不会停止使用 eax 作为计算函数 arg 的临时变量。 (可能有一种方法可以在内联函数中使用它,因此寄存器变量至少具有有限的范围)。使用asm("" :: "a"(&ext)); 会在RAX 中留下一个函数指针,它可能 一直留在那里直到call thunkgodbolt.org/g/y9A44E。但这并不能避免需要原型。
猜你喜欢
  • 1970-01-01
  • 2018-06-18
  • 2019-12-24
  • 1970-01-01
  • 1970-01-01
  • 2020-11-27
  • 2019-02-18
  • 2019-11-15
  • 1970-01-01
相关资源
最近更新 更多