Linux 5.10中的静态调用

2020-12-17 02:28:38

我正在阅读有关KernelNewbies的Linux 5.10发行摘要,其中有一部分对我很突出:

静态调用代替了全局函数指针。他们使用代码修补程序来允许使用直接调用而不是间接调用。它们提供了函数指针的灵活性,但具有改进的性能。这对于否则要使用retpoline的情况尤其重要,因为retpoline会显着影响性能。

我花了很多时间研究Linux内核,但从未直接涉及它的间接调用设置或Spectre缓解措施。这些更改听起来很酷,因此,我将使用这篇文章尝试解释和理解它们(包括我自己和其他人)。

间接调用是C语言最强大的语言功能之一,对于编写没有附加对象或函数/方法调度系统的高阶代码至关重要。

由于标准和POSIX函数(例如qsort和pthread_create),大多数C程序员都熟悉间接调用的基础知识:每个函数都带有一个函数指针,然后它在内部调用以完成周围调用的功能:

#include< stdlib.h> #include< string.h> #include< stdio.h> / * qsort_strcmp只是普通的stdlib strcmp,带有一些额外的参数*需要匹配qsort的API。 * / static int qsort_strcmp(const void * a,const void * b){返回strcmp(*(const char **)a,*(const char **)b); } int main(void){const char * strings [] = {" foo" ," bar" ," baz" }; / * qsort是一个通用的排序函数:*给它一个指向要排序的事物的基本地址的指针,*它们的数量和单个大小,以及*一个可以比较*任何两个成员并在它们之间提供排序的函数。 * *在这种情况下,我们告诉qsort使用qsort_strcmp来对字符串数组进行排序。 * / qsort(& strings,3,sizeof(char *),qsort_strcmp); printf("%s%s%s \ n&#34 ;、字符串[0],字符串[1],字符串[2]);返回0; }

在这种情况下,间接调用发生在qsort中。但是,如果我们实现自己的函数进行间接调用,则可以直接看到它:

静态uint32_t good_rand(){uint32_t x; getrandom(& x,sizeof(x),GRND_NONBLOCK);返回x; } static uint32_t bad_rand(){return rand(); } / * munge使用函数指针rand_func,将其称为*作为其返回结果的一部分。 * /静态uint32_t munge(uint32_t(* rand_func)(void)){return rand_func()& 0xFF; } int main(void){uint32_t x = munge(good_rand); uint32_t y = munge(bad_rand); printf("%ul,%ul \ n",x,y);返回0; }

munge:push rbp mov rbp,rsp sub rsp,16 mov qword ptr [rbp-8],rdi;加载rand_func调用qword ptr [rbp-8];呼叫rand_func和eax,255添加rsp,16 pop rbp ret

观察:我们的调用通过内存或寄存器操作数([rbp-8])1来获得目标,而不是由操作数值本身指定的直接目标(例如,调用0xacabacab; @good_rand)。这就是使其间接的原因。

但是,我们可以做得更多!确实,C语言中的一个常见模式是声明整个操作的结构,使用每个结构来参数化独立实现上的较低级行为集(例如,核心POSIX I / O API)。

struct fuse_operations {int(* getattr)(const char *,struct stat *,struct fuse_file_info * fi); int(* readlink)(const char *,char *,size_t); int(* mknod)(const char *,mode_t,dev_t); int(* mkdir)(const char *,mode_t); int(*取消链接)(const char *); int(* rmdir)(const char *); int(* symlink)(const char *,const char *); int(*重命名)(const char *,const char *,unsigned int标志); int(*链接)(const char *,const char *); / * ... * / int(* open)(const char *,struct fuse_file_info *); int(* read)(const char *,char *,size_t,off_t,struct fuse_file_info *); int(*写)(const char *,const char *,size_t,off_t,struct fuse_file_info *); int(* statfs)(const char *,struct statvfs *); / * ... * /}

毫不奇怪,这种技术不仅限于用户空间:Linux内核本身积极使用间接调用,尤其是在与体系结构无关的接口(如VFS和诸如procfs之类的子专业化)以及特定于体系结构的子系统内部(如perf_events)。

这很整洁。如此整洁,以至于CPU工程师们全力以赴,试图从中获得额外的性能2,我们最终获得了Spectre v2。

Spectre v2(也称为CVE-2017-5715)利用的确切机制略微超出了本文的范围,但概括地说:

现代(x86)CPU包含间接分支预测器,该预测器试图猜测间接调用或跳转的目标。

正确的预测意味着间接调用的完成速度要快得多(因为它已经执行或已完成推测性执行);

错误的预测将导致较慢的(但仍然成功)间接调用,而不会因错误的推测而产生副作用。

换句话说:CPU负责回滚与任何错误预测和后续推测有关的任何副作用。错误推测是一个微体系结构细节,不应在体系结构更改中体现出来,例如修改后的寄存器。

回滚任何错误推测的状态是一个相对昂贵的操作,具有很多微体系结构的含义:需要修复高速缓存行和其他状态位,以使实际的程序控制流不会因失败的推测而受污染。

实际上,回滚整个推测的状态将消除最初推测的大多数优点。 x86和其他ISA不会这样做,只会将(许多)推测状态的位(例如高速缓存行)标记为陈旧。

这种修正行为(恢复或标记推测状态)导致出现旁通道:攻击者可以训练分支预测程序推测性地执行一些代码(与ROP小工具不同),从而修改数据相关的微体系结构状态中的一部分方式,例如缓存条目,其地址取决于以推测方式获取的秘密值。

然后,攻击者可以通过定时访问微体系结构状态来探测该微体系结构状态:快速访问指示经过推测性修改的状态,从而泄露了机密。

最初的Spectre v2攻击主要针对高速缓存行,因为它们相对容易设置时间,甚至来自无法访问x86上的clflush或其他高速缓存行基元的高级(和沙盒!)语言。但是这个概念很笼统:在不泄漏某些信息的情况下,很难进行推测性执行,随后的漏洞(例如MDS和ZombieLoad)已经暴露了其他微体系结构功能中的信息泄漏。

这是一个坏消息:运行最安全的上下文之一(在用户空间中的沙箱中,JavaScript或其他托管代码)的攻击者可能会训练间接分支预测器在内核空间中推测性地执行小工具,从而可能泄露内核内存。

为了缓解Spectre v2,内核需要防止CPU在攻击者控制的间接分支上进行推测。

retpoline(返回蹦床的简称)可以做到这一点:间接跳转和调用被一个小的thunk包围,可以有效地将推测的执行陷入一个无限循环中,旋转它直到错误预测被解决为止。

这是通过将间接控制流从间接分支转换为间接返回3来实现的,因此转换为retpoline中的“ ret”。还可以预测返回,但会优先考虑其他机制:返回堆栈缓冲区4。为确保RSB不会受到无限循环的恶意训练,retpoline以直接CALL开头,该CALL使RSB始终5预测无限循环。

这实际上是一个间接调用retpoline,看起来像6,从内核来源显着简化了:

如果能正确预测,它的运行速度很慢:我们已经用至少两个直接调用以及一个RET替换了一个间接调用。

如果预测错误,它的速度确实很慢:我们实际上是使用PAUSE和LFENCE旋转到位的。

它是ROP小工具,因此看起来像是漏洞利用原语。这意味着它将与英特尔的CET以及其他平台上的类似保护相结合。英特尔声称,较新的硬件将支持“ enhancedIBRS” 7,它将完全取代对repoline的需求,从而可以使用CET。

即使像上面这样被精简,也很难阅读和遵循:完全缓解还需要处理间接跳转,RSB填充以及人们从原始Spectre v2开始就发现的大量其他技巧。

让我们看看Linux 5.10做了些什么来减轻这种麻烦。

我们来看看这项新的“静态通话”技术。以下是直接来自Josh Poimboeuf的补丁系列的API:

/ *声明或定义一个新的静态调用为`name`,*最初与`func`相关联* / DECLARE_STATIC_CALL(name,func); DEFINE_STATIC_CALL(name,func); / *用`args`调用`name` * / static_call(name)(args ...); / *如果不为NULL则用`args`调用`name` * / static_call_cond(name)(args ...); / *更新基础函数* / static_call_update(name,func);

静态void _x86_pmu_add(struct perf_event * event){} / * ... * / DEFINE_STATIC_CALL(x86_pmu_add,_x86_pmu_add);静态void x86_pmu_static_call_update(void){/ * ... * / static_call_update(x86_pmu_add,x86_pmu。add); / * ... * /} static int __init init_hw_perf_events(void){/ * ... * / x86_pmu_static_call_update(); / * ... * /}

帮助程序使用static_call_update修改x86_pmu_add的基础函数(_x86_pmu_add),将其替换为x86_pmu.add(函数指针)

那很干净,而且(显然)避免了使用retpoline!让我们找出原因和方式。

#定义__ARCH_DEFINE_STATIC_CALL_TRAMP(名称,insns)\ asm(" .pushsection.static_call.text,\" ax \" \ n" \" .align 4 \ n&#34 ; \" .globl" STATIC_CALL_TRAMP_STR(name)" \ n" \ STATIC_CALL_TRAMP_STR(name)&#34 ;: \ n" \ insns" \ n&#34 ; \" .type" STATIC_CALL_TRAMP_STR(name)&#34 ;, @function \ n" \" .size" STATIC_CALL_TRAMP_STR(name)&#34 ;,。- " STATIC_CALL_TRAMP_STR(名称)" \ n" \" .popsection \ n")#定义ARCH_DEFINE_STATIC_CALL_TRAMP(名称,func)\ __ARCH_DEFINE_STATIC_CALL_TRAMP(名称," .byte 0xe9 ; .long" #func"-(。+ 4)")

.pushsection .static_call.text," ax" .align 4 .globl" __ SCT__x86_pmu_add_tramp" " __SCT__x86_pmu_add_tramp" :.byte 0xe9 .long _x86_pmu_add-(。+ 4).t​​ype" __ SCT__x86_pmu_add_tramp" ,@函数.size" __ SCT__x86_pmu_add_tramp" ,。 -" __ SCT__x86_pmu_add_tramp" .popsection

具体来说,它是.long _x86_pmu_add-(。+ 4)计算得出的地址的JMP,这是丑陋的GAS语法,表示“ _x86_pmu_add的地址减去当前地址(用。表示,再加上4)”。

为什么算术?这是一个相对的JMP,因此需要在JMP本身之后立即相对于RIP确定目的地。 JMP为1字节,但我们使用.align 4,so。 + 4确保我们减去当前位置并修复JMP和填充8。

这就是我们的静态呼叫的设置。让我们看看我们如何实际使用static_call_update安装功能。

我们将忽略BUILD_BUG_ON和__same_type,因为它们是编译时的完整性检查。查看此SO帖子以获取BUILD_BUG_ON;很有趣9。

静态内联void __static_call_update(struct static_call_key * key,void * tramp,void * func){cpus_read_lock(); WRITE_ONCE(键-> func,func); arch_static_call_transform(NULL,tramp,func,false); cpus_read_unlock(); }

首先,我们要跳过cpus_read_lock和cpus_read_unlock,因为它们确实是一样的。

#定义__WRITE_ONCE(x,val)\ do {\ *(volatile typeof(x)*)&(x)=(val); \} while(0)#定义WRITE_ONCE(x,val)\ do {\ compiletime_assert_rwonce_type(x); \ __WRITE_ONCE(x,val); \}而(0)

void arch_static_call_transform(void * site,void * tramp,void * func,bool tail){Mutex_lock(& text_mutex); if(流浪汉){__static_call_validate(tramp,true); __static_call_transform(tramp,__sc_insn(!func,true),func); } if(IS_ENABLED(CONFIG_HAVE_STATIC_CALL_INLINE)&& site){__static_call_validate(site,tail); __static_call_transform(site,__sc_insn(!func,tail),func); } Mutex_unlock(& text_mutex); }

mutex_lock和mutex_unlock只是为了确保没有其他人在修补内核的指令文本。我们同时位于cpus_read_lock内部,我想这是在阻止他人在补丁中间读取。

在这篇文章中,我将不再谈论CONFIG_HAVE_STATIC_CALL_INLINE。它与普通的静态调用机制非常相似,但是有更多的活动部件。因此,我们就好像该配置为false并且未在其中编译代码一样。

__sc_insn将两个布尔值func和tail映射到insn_type枚举。两个布尔值表示二进制位,表示四种可能的insn_type状态:

枚举insn_type {CALL = 0,/ *站点调用* / NOP = 1,/ *站点cond-call * / JMP = 2,/ *跟踪/站点尾调用* / RET = 3,/ *跟踪/站点cond-尾声* /};静态内联枚举insn_type __sc_insn(bool null,bool tail){/ * *对没有分支的下表进行编码:* * tail null insn * ----- + ------- + ------ * 0 | 0 |呼叫* 0 | 1 | NOP * 1 | 0 | JMP * 1 | 1 | RET * /返回2 * tail + null; }

静态void __static_call_validate(void * insn,bool tail){u8操作码= *(u8 *)insn; if(tail){if(操作码== JMP32_INSN_OPCODE ||操作码== RET_INSN_OPCODE)返回; } else {if(操作码== CALL_INSN_OPCODE ||!memcmp(insn,Ideal_nops [NOP_ATOMIC5],5))返回; } / * *如果我们触发了此操作,则说明文本已损坏,我们可能寿命不长。 * / WARN_ONCE(1,"意外的static_call insn操作码0x%x at%pS \ n",操作码,insn); }

请记住:因为我们不是CONFIG_HAVE_STATIC_CALL_INLINE,所以tail对我们永远都是对的。因此,insn始终是流浪汉,您会记得,它是__SCT__x86_pmu_add_tramp,它带有可爱的.byte 0xe9。

静态void __ref __static_call_transform(void * insn,枚举insn_type类型,void * func){int size = CALL_INSN_SIZE; const void *代码;开关(类型){case CALL:code = text_gen_insn(CALL_INSN_OPCODE,insn,func);休息;案例NOP:代码= Ideal_nops [NOP_ATOMIC5];休息;案例JMP:代码= text_gen_insn(JMP32_INSN_OPCODE,insn,func);休息;案例RET:代码= text_gen_insn(RET_INSN_OPCODE,insn,func);大小= RET_INSN_SIZE;休息; } if(memcmp(insn,code,size)== 0)返回;如果(不太可能(system_state == SYSTEM_BOOTING))返回text_poke_early(insn,code,size); text_poke_bp(insn,code,size,NULL); }

记住__sc_insn(我告诉过你!):tail对我们总是正确的,所以我们唯一的选择是(1、0)和(1、1),即JMP和RET。 (再次)这一点点很重要,但是两种情况的代码几乎相同,因此我们可以忽略差异。

在这两种情况下,我们都将其称为text_gen_insn,这是世界上最简单的JIT 12(略有简化):

union text_poke_insn {u8文本[POKE_MAX_OPCODE_SIZE]; struct {u8操作码; s32显示; } __attribute __((包装)); };静态__always_inline void * text_gen_insn(u8操作码,const void * addr,const void * dest){静态联合text_poke_insn insn; / *每个实例* / int size = text_opcode_size(opcode);旅馆opcode =操作码;如果(大小> 1){insn。 disp =(长)dest-(长)(addr + size); if(size == 2){BUG_ON((insn.disp>> 31)!=(insn.disp>> 7));返回&旅馆文字; }

其余的是机制:我们根据systemstate调用text_poke_early或text_poke_bp,但效果是相同的:我们的蹦床(__SCT__x86_pmu_add_tramp)有效地从以下位置重写到内核内存中:

实际的调用是static_call_cond(x86_pmu_add)(event),顾名思义,应该在将其分配给底层蹦床之前进行检查,对吗?

不!再次回想一下那个小的__sc_insn表生成器:如果我们的基础调用为NULL,则说明我们已经生成了一个RET并将其JIT插入了蹦床。

(__ADDRESSABLE只是编译器的另一招,以确保__SCK__x86_pmu_add在符号表中四处可见)。

就是这么多:我们实际的static_call归结为对__SCT__x86_pmu_add_tramp的调用,它可以是使我们跳回真实呼叫的蹦床,也可以是RET,可以完成呼叫。

致电__SCT__x86_pmu_add_tramp;在通话中+蹦床jmp x86_pmu.add;在蹦床之外,在目标(x86_pmu.add)中;目标最终返回,完成通话

这篇文章最终比我原本打算的要长,部分原因是因为我最终比预期更多地深入了为什么进行静态调用的原因。

通常,我一直都了解投机执行漏洞的工作原理,但是我之前并没有真正阅读过Spectre白皮书。比我想象的要简单得多!

这种实现是信息密集的,并且使用了高级宏,但实际上并没有那么复杂:手动扩展宏使我有95%的方法,其余的是代码理解和与Elixir的交叉引用。之前,我已经做了相当不错的内核开发工作,但是这使我对Linux内核的布局和范例有了新的认识。

我什至都没有尝试过CONFIG_HAVE_STATIC_CALL_INLINE,它占行数占该变更集的很大比例。这是一个更具侵略性的变革:无需重写蹦床,而是重写将要呼叫蹦床的每个呼叫站点,以直接呼叫蹦床的目标。这节省了一个JMP 14,显然它的性能差异足以使它值得单独配置的功能。

我不确定WRITE_ONCE(__ SCK__x86_pmu_add-> func,x86_pmu.add)实际完成的工作:它将函数指针的副本存储在我们的static_call_key中,然后我们就再也不会实际使用它了(因为我们总是调用蹦床,它具有相对函数的位移直接修补到其中)。调用此指针会破坏静态调用的全部目的,因为它是一个间接分支。有根据的猜测:!CONFIG_HAVE_STATIC_CALL案例就在这里,该案例使用通用的间接实现。但是,当我们有真正的静态调用实现可用时,为什么不放弃static_call_keyentirely?

我认为这里还有改进的余地,尤其是放松了一些锁定要求:最大文本补丁大小以POKE_MAX_OPCODE_SIZE == 5进行硬编码,应该使用WRITE_ONCE 15轻松地适合x86_64上的原子写入。换句话说:我不是 确定为什么需要cpus_read_lock和文本锁,尽管我可能对此不太了解。 Linux内核是自我修改的,并且使用了基本的指令编码器,这一事实证明了 ......