-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.json
More file actions
1 lines (1 loc) · 325 KB
/
Copy pathcontent.json
File metadata and controls
1 lines (1 loc) · 325 KB
1
{"meta":{"title":"Benxi's blog","subtitle":"心如止水,乱则不明","description":null,"author":"Benxi Liu","url":"http://sec-lbx.tk"},"posts":[{"title":"Format string attack","slug":"format string attack","date":"2018-03-10T13:40:00.000Z","updated":"2018-03-11T06:09:45.000Z","comments":true,"path":"2018/03/10/format string attack/","link":"","permalink":"http://sec-lbx.tk/2018/03/10/format string attack/","excerpt":"","text":"Format String VulnerabilityFSV(格式字符串漏洞)是一类特殊的漏洞,它是由对格式化输出函数(例如C语言中的printf函数家族)的不当使用而造成的。如果攻击者能够控制格式化输出函数的输入,并且应用程序没有进行适当的验证,那么攻击者就能:通过把格式化参数(例如’%x’)插入到数据中,来改变格式化输出函数的行为,让它误认为有更多的参数。而这些参数却并不存在,于是乎攻击者就能够对栈上的数据进行读写了。 Capability格式化输出函数本身并没有漏洞,但FSV给了攻击者控制它们的能力。那么攻击者能够通过FSV做些什么呢?最简单的攻击就是利用FSV让程序崩溃,例如printf ("%s%s%s%s%s%s%s%s%s%s%s%s");极有可能读取到没有映射的地址,造成程序崩溃。再者,攻击者也可以通过%n来篡改栈上的地址来达到同样的效果。 information leakage如果格式化字符串的输出是可见的(或者间接可见的,例如返回给一个字符串给remote attacker),那么FSV就可能导致信息的泄露。和前面所说的类似,printf ("%08x.%08x.%08x.%08x.%08x\\n");能够揭露栈上的一部分数据(在64位下揭露的是寄存器和栈中的数据)。但某些情况下,FSV能够被用来揭露任何地址的数据,而不仅仅是栈上的数据。通常,格式化字符串被放在栈上,而格式化输出函数内部通常会有一个内部指针,它指向当前的格式化字符串参数。如果能够把目标地址嵌入到格式化字符串当中去,那么目标地址就会被保存在栈上了,这样一来就可以通过操纵内部指针的方式,把目标地址的数据给输出。下面这个例子中,user_input保存了一个格式化字符串。在32位下,printf ("\\x10\\x01\\x48\\x08 %x %x %x %x %s");能够揭露0x10014808地址的值。 int main(int argc, char *argv[]) { char user_input[100]; ... ... /* other variable definitions and statements */ scanf("%s", user_input); /* getting a string from user */ printf(user_input); /* Vulnerable place */ return 0; } 那么64-bit下有什么区别呢?我也自己做了一个实验。与32-bit不同的是,只有第七个之后的参数会被放在栈上,而前六个参数是保存在栈中的。但类似的攻击依然可以成功。printf(%p.%p.%p.%p.%p.%p)会输出0x70252e70252e7025(也即user_input),这说明用户的输入自身也被输出了。那么如何实现一个任意的读呢?值得注意的是在64-bit下,地址不可避免的含有\\x00,所以这里我把\\x00放在字符串的最后。例如,printf("%7s$lx\\x00\\x00\\x00\\x00\\x00\\x40\\x00\\x00");输出的结果是400000。如果要输出任意地址的数据,就要注意对齐:printf("%7$s\\x00\\x00\\x00\\x00\\x00\\x00\\x40\\x00\\x00\\x00\\x00\\x00")会按照%s输出地址0x400000地址的内容。 overwrite memoryFSV不仅能够用来泄露内存中的信息,还能被攻击者用来篡改内存中的数据。要达到这个目标,需要借助较为特殊的格式指定符号%n。它并不会打印任何东西,但它会存储它之前所打印的所有字符数。因此只要采用和information leakage相似的方法,只需要把%s替换为%n,就能够实现对任意地址的读写。例如在32-bit下,printf (“\\x10\\x01\\x48\\x08 %x %x %x %x %n”)就能够覆写0x10014808中的地址。然而,这个值是由%n之前的打印的字符数来决定的,那么用这种方法能够写一个任意值到指定的地址中去吗?通过指定宽度,可以节省字符串的长度,但是如果要设置一个较大的值,就需要进行多次写操作,例如利用%hn把一次%n分解成两次,这样一来printf就只用输出一小部分数据了。在64-bit下,我也做了一个类似的试验,printf("123%7$n\\x00\\x50\\x10\\x60\\x00\\x00\\x00\\x00\\x00")会把123写到0x605010处。如果要写多个数,则需要按照从小到大的顺序进行输入,因为%n获取的值是累加的。值得注意的是,64-bit下地址会不可避免的包含\\x00,它同时也是print家族函数的终止符,所以\\x00只能放在最后。这也同时限制了64-bit下FSV的能力,使其在许多情况下并不能实现指定地址的读写了。","raw":null,"content":null,"categories":[],"tags":[{"name":"系统安全","slug":"系统安全","permalink":"http://sec-lbx.tk/tags/系统安全/"}]},{"title":"关于中断虚拟化","slug":"详解中断虚拟化","date":"2017-07-30T13:40:00.000Z","updated":"2017-08-10T09:31:42.000Z","comments":true,"path":"2017/07/30/详解中断虚拟化/","link":"","permalink":"http://sec-lbx.tk/2017/07/30/详解中断虚拟化/","excerpt":"","text":"中断虚拟化做了什么?我们首先回忆一下中断的过程:中断由设备发送给了I/O APIC,随后交给CPU本地的LAPIC进行处理。LAPIC把它交给CPU,然后由操作系统根据相应的IDT表进行处理。很明显,如果虚拟机内的操作系统,要正常的去处理中断,就必须要由VMM来完成这两个部分:(1)虚拟化LAPIC,并虚拟化客户机对APIC寄存器的访问(2)虚拟化到达客户机的所有中断 APIC的虚拟化当虚拟机访问local APIC寄存器时,最终同样会对物理上的local APIC进行读写。那么VMM该如何对它进行控制,防止其干扰宿主机的环境呢?VMM有两种方式,来对local APIC进行限制:(1)通过EPT来进行保护,处理对local APIC的读写,但这样每次都会触发EPT violation,无疑会引入开销;(2)利用Virtual-APIC机制,guest对local APIC的访问实际上是对virtual-APIC页进行访问。 通过设置VMCS中的Virtualize APIC accesses字段为1,会开启第二种方式:当guest访问APIC-access page时,会根据APIC-register virtualization当中的设置,来决定这次访问的方式: (1)产生APIC access VM EXIT,或者APIC write VM EXIT(2)通过virtual-APIC机制 这里,我们详细讨论一下virtual-APIC方式。 APIC-access page和Virtual APIC accesses当Virtualize APIC accesses开启时,处理器会启用APIC-access page。此时APIC-access page对应着物理的local APIC页面,其地址保存在VMCS的APIC-access address当中。从硬件上,CPU能够帮助我们去监控这个页,这样一来,只要guest尝试线性访问local APIC,就会访问APIC-access page。那么此时,会根据“APIC-register virtualization”位的设置,进行不同的处理: 发生APIC access VM-exit,或者APIC write VM-exit 直接访问virtual-APIC page中的数据(某些寄存器) virtual-APIC page是什么?当“use TPR shadow”为1时,会引入virtual-APIC page,它是local APIC的影子页面,其地址保存在VMCS的virtual-APIC address当中。它保存了一份TPR数据,也即virtual TPR寄存器。实际上,virtual-APIC page的唯一用处就是对寄存器进行虚拟化,这样每个VCPU都能够有不同的TPR、VEOI、VLDR等寄存器,其中TPR寄存器保存在virtual-APIC page的80H位置。 那么APIC-access page和virtual-APIC page之间是什么关系呢?实际上是一种类似映射的关系,当guest尝试线性访问(注意,只有线性访问)APIC-access page页面时,会实际上访问vritual-APIC page页面中的数据。 在虚拟机访问APIC access page时:(1)如果线性访问了没有虚拟化支持的offset位置,会产生APIC-access VM-exit(2)采用guest-physical方式访问APIC-access page页中的任何offset位置,都会发生APIC-access VM-exit(3)采用physical方式访问APIC-access page,可能发生APIC-access VM-exit 这里我们主要关注线性访问的情况,来具体说明APIC-access page和virtual-APIC page的工作原理。读APIC-access page在如下情况时,访问APIC-access page时,都会产生APIC-access VM-exit:(1)“use TPR shadow”为0(2)尝试执行APIC-access page(3)访问数据超过32位(4)访问APIC-access page的动作发生在虚拟化写APIC-access page过程中(5)尝试进行跨过local APIC寄存器边界的访问在除上述原因之外的情况下,读取时,会根据APIC-register virtualizaion位的设置,来进行读取:(1)如果APIC-register virtualization为0,则如果线性读取TPR,直接从virtual-APIC page返回;否则直接产生APIC-access VM-exit(2)如果APIC-register virtualization为1,如果访问的是某些特定的偏移量,那么就会返回virtual-APIC page中的数据;否则直接产生APIC-access VM-exit写APIC-access page在如下情况时,访问APIC-access page时,都会产生APIC access VM-exit:(1)“use TPR shadow”为0(2)访问数据超过32位(3)访问APIC-access page的动作发生在虚拟化写APIC-access page过程中(4)尝试进行跨过local APIC寄存器边界的访问在除上述原因之外的情况下,读取时,会根据APIC-register virtualizaion和virtual-interrupt delivery位的设置,来进行写入:(1)如果APIC-register virtualization和virtual-interrupt deliverty都为0,那么如果线性写TPR时,会写入VTPR中(virtual-APIC page),其他位置都会产生APIC-access VM-exit(2)如果APIC-register virtualization为0,但virtual-interrupt deliverty为1,那么线性写TPR、EOI、ICR时,会写入virtual-APIC page中对应的虚拟寄存器当中,其他位置都会产生APIC-access VM-exit(3)如果APIC-register virtualization为1,则会允许更多的虚拟寄存器写入virtual APIC,其他位置则产生APIC-access VM-exit但是写入是一个更为复杂的过程,在写入对应的虚拟寄存器之后,处理器还要进行后续的处理,也即APIC-write emulation:根据virtual-interrupt deliverty的设置,产生APIC-write VM-exit,或者对local APIC进行虚拟化操作(TPR、EOI、Self-IPI等)。小结APIC虚拟化的过程,实际上也是借助了硬件的力量。根据CPU的设置,可以把不同VCPU的虚拟寄存器保存在不同的页中。这样不仅避免了虚拟机对素主机的污染,还能够减少VM-exit的发生,并且能够让每个VCPU拥有独立的寄存器,对性能是有极大提高的。 中断的evaluation和delivery现在,我们来看看VMM是如何判断一个中断是否应该交给虚拟机进行处理,并且将它转交给虚拟机的。VMCS中有一个External-interrupt exiting位。当这个位设置为1时,如果CPU处于non-root模式,那么所有中断都会产生VM-exit;如果设置为0,则所有中断都会直接交给虚拟机进行处理。这无疑是很不安全的,所以一般情况下外部中断都会造成VM-exit,并且在虚拟化层处理完反回虚拟机(VM-entry)时,就需要做中断的evaluation(判断是否有中断要交给虚拟机处理)和delivery(将中断交给IDT处理)了。在这几种情况下,会引发中断的evaluation和delivery: (1)TPR虚拟化、EOI虚拟化、Self-IPI虚拟化。(2)在VM-entry操作时引发。(3)在利用“post-interrupt processing”机制处理外部中断时引发。 中断的注入,是通过由VMM将中断写入VMCS对应的信息位来实现的,当中断完成后,通过读取中断的返回信息,来分析中断是否正确。在中断注入之后,处理器会完成evaluation和delivery的工作(还有另一种方式是posted interrupt 处理)那么evalutaion会根据什么来判断一个虚拟中断是否应该pending呢?处理器首先根据虚拟中断的优先级别进行判断,只有在RVI[7:4]大于VPPR[7:4]时,处理器才会组织一次虚拟中断pending。随后处理器还要检查虚拟中断是否被阻塞,只有不被阻塞打时候,会通过guest-IDT来进行deliver执行。在evaluation之后,虚拟中断就会通过guest-IDT进行delivery。这时处理器将会更新virtual-APIC的状态,包括VIRR、VISR、RVI、SVI状态等,随后处理器根据虚拟中断向量号,通过guest-IDT表进行deliver执行。 posted interrupt处理前面我们也提到了,外部中断通常都会造成VM-exit的下陷。posted interrupt处理,能够直接把中断注入到guest当中去,而不需要产生VM-exit。假如启用了posted interrupt处理,那么处理器对外部中断的处理就不同了:(1)一般情况下,如果external-interrupt exiting为1,那么外部中断产生VM-exit;否则直接通过guest IDT表处理;(2)在posted interrupt机制下,如果处理器接收到的外部中断向量号是预先定义的通知向量号时,不会产生VM-exit,而是通过PIR的pending处理。那么这个不产生VM-exit的过程是如何发生的呢?VMM会在posted-interrupt notification vector当中设置通知向量号,一旦local APIC接到对应的外部中断,那么就会通知guest进行post-interrupt处理。此时posted interrupt描述符内的PIR请求列表会复制到VIRR(virtual-APIC页的另一个用途),形成新的虚拟中断请求列表,然后再对这个虚拟中断请求表中的中断请求进行evaluation和delivery。而如果外部中断向量号不等于通告的向量,那么依然会触发VM-exit,处理器获取外部中断的信息,通过VMCS中的字段来进行注入中断。 小结可以看到,中断虚拟化方式的改变,是随着硬件特性的改变而改变的。硬件的支持,主要就是为了提升效率。我们知道,VM-exit无疑会给虚拟机的性能带来损耗,而中断的特殊性,又使得它极有可能造成VM-exit。所以intel的工作,一直都致力于减少VM-exit的发生,提升虚拟机的性能。让虚拟机直接接管硬件一直是虚拟化中极为重要的一种思想,不论是IO还是中断,或者是内存,都是如此。Ps:《处理器虚拟化技术》这本书极烂,不建议阅读。","raw":null,"content":null,"categories":[],"tags":[{"name":"intel","slug":"intel","permalink":"http://sec-lbx.tk/tags/intel/"},{"name":"虚拟化","slug":"虚拟化","permalink":"http://sec-lbx.tk/tags/虚拟化/"}]},{"title":"浅谈GCC编译优化","slug":"浅谈GCC编译优化","date":"2017-07-15T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/07/15/浅谈GCC编译优化/","link":"","permalink":"http://sec-lbx.tk/2017/07/15/浅谈GCC编译优化/","excerpt":"","text":"GCC Pass在GCC完成词法、语法分析,并获得源代码对应的抽象语法树AST之后,会将其转换为对应的GIMPLE序列。随后,GCC会对GIMPLE中间表示进行一系列的处理,包括GIMPLE的低级化、优化、RTL生成等、RTL优化等。为了方便管理,GCC采用了一种称为Pass的组织形式,把它们分成一个个的处理过程,把每个输出结果作为下一个处理过程的输入。GCC中,Pass可以分为4类,GIMPLE_PASS,RTL_PASS,SIMPLE_IPA_PASS,IPA_PASS。其中除了RTL_PASS之外,处理对象都是GIMPLE中间表示。名字包含IPA的两类Pass的功能主要是过程分析,也即函数间调用和传递。 Pass的执行是以链表的形式组织的,每个Pass还可以包含子Pass,并且以函数为执行的基本单元。如果把GCC中的Pass全部打印出来,可以看到数量有数百个,因此这里只选择几个代表性的pass研究一下。值得说明的是,在GCC 4.6之后,增加了插件功能,它同样是通过Pass的形式来进行管理,这使得我们自定义处理过程变得很容易。 去除无用表达式该Pass从GIMPLE序列中进行搜索,从中删除死代码。这些死代码主要包括:(1)空语句(2)无意义的语句块、条件表达式(3)目标地址就是下一条段GOTO表达式(4)无意义的malloc、free(5)…在GCC 4.4版本中,这个Pass是pass_remove_useless_stmts,现在这个函数已经被移除了;其功能被拆分到了别的函数中。但gcc的文档中还是说明了这个Pass。类似这样的结构,在这个Pass中都会被优化掉: if(1) //无意义的条件表达式 goto next; //无意义的goto next: //do sth 那么该Pass是如何进行判断的呢?我们来看看GCC 5.4版本中,类似函数的实现。在gcc/tree-ssa-dce.c当中,有这样一个函数eliminate_unnecessary_stmts()。这个函数会(逆序的)逐个遍历表达式,防止某些定义或名字被释放: for(gsi = gsi_last_bb (bb); !gsi_end_p (gsi); gsi = psi){ stmt = gsi_stmt (gsi); ... if (gimple_call_with_bounds_p (stmt)) { //如果有必要删除,那么会首先利用这个函数设置 gimple_set_plt(...) } //对于需要删除的表达式进行删除 if (!gimple_plf (stmt, STMT_NECESSARY)) ... } 如果进行了删除,那么这个函数还会对一部分CFG进行修改,并删除不可达的基本块。 控制流图的构造在GCC中,控制流图指的是函数内的控制流图,这和我平时在文章中看到的“CFG”是不同的。GCC中,CFG的节点为基本块,而边则是基本块之间的跳转关系。pass_build_cfg对函数对GIMPLE序列进行分析,完成基本块的划分,并且根据GIMPLE语义来构造基本块之间的跳转关系。控制流图的构造,主要是在build_gimple_cfg()当中完成的。 static void build_gimgple_cfg (gimple_seq seq) { gimple_register_cfg_hooks();//注册这个函数,要构造cfg了 init_empty_tree_cfg();//先构造一个空的cfg make_blocks(seq);//具体的基本块构造过程 ... make_edges();//创建基本块的边 } static void make_blocks (gimple_seq seq) { while (!gsi_end_p (i))//遍历seq中的每一条 { ... gimple_set_bb (stmt, bb);//把当前的语句添加到基本块 if(stmt_ends_bb_p(stmt)) { //如果stmt是基本块的终结,就进行处理,并且在下一次创建一个新的基本块 ... } } } 在基本块创建之后,就可以根据基本块,构造基本块中的边,对于函数中的不同情况,生成了不同的边,并且创建了对应的label。 static void make_edges (basic_block min, basic_block max, int updata_p) { ... //处理min和max之间的基本块 FOR_BB_BETWEEN (bb, min, max->next_bb, next_bb) { //在5.4版本中,是直接对RTL进行处理 rtx_insn *insn; insn = BB_END(bb); //如果为JUMP指令,还包括了条件跳转 if(code == JUMP_INSN) ... //如果为CALL指令,还包括了异常处理 else if(code == CALL_INSN || cfun->can_throw_non_call_exceptions) ... } } 指令调度前面介绍了GCC当中,GIMPLE表达式的部分优化过程,现在我们来说说RTL中的优化过程。指令调度是对当前函数中的insn序列进行重新排列,从而更充分地利用目标机器的硬件资源,提高指令执行的效率。指令调度主要考虑有数据的依赖关系、控制相关性、结构相关、指令延迟和指令代价等,通常指令调度与目标架构上的流水线有关。在GCC中,指令调度分为两个部分:寄存器分配之前的pass_sched和寄存器分配之后的pass_sched2。不过,在这里应该先阐述一下:为什么要进行指令调度?这是由硬件的特性决定的:(1)指令拥有不同的执行时间。对于指令来说,其实latency(指令开始执行后,多少时钟数能够为其他指令提供数据)、throughtput(指令完成计算需要的时钟数)、μops(指令包含的微操作数)都是不同的。(2)指令流水线能够并行执行指令的微操作,从而在等待时完成一部分工作。(3)超标量处理器,比如现在的i7处理器,能够同时运行多条指令,虽然硬件完成了一部分调度工作,但存在一个窗口的问题,依然需要编译器进行调度。正因如此,编译器需要对指令进行调度,来提高运行时的效率。指令在调度时,存在着以下限制:(1)数据依赖关系。包括flow(一条指令定义的值和寄存器在后面的指令用到)、anti(一条指令用的值或者寄存器会被后面的指令修改)、output(一条指令定义的值或寄存器在后面会被改;(2)循环限制,多次迭代之间的关系,循环内部的依赖关系等;(3)硬件资源的限制,例如能够同时并行的指令数;这些限制必须作为指令调度的输入被考虑到,通过这些依赖关系,可以把所有的指令,构成一个依赖关系图。指令调度是一个典型的NP-hard问题,而表调度算法就是一种典型的启发式算法。通常其调度的单元在基本块的内部,并把数据依赖关系图作为输入,并计算出指令的优先级。优先级的计算涉及:依赖图的根到节点的路径长度、某个节点的后继数量、节点的latency等。随后,保持两个表:ready表中保存了能够不延时执行的指令,它们根据优先级从高到低排列;active表包括了所有正在执行的指令。在算法的每一步:(1)从ready表中取出优先级最高的指令进行调度,将它移到active队列当中,它会在队列中呆上一个时延的时间;(2)更新指令的依赖关系,把新的就绪指令插入ready表中。这里我们用一个例子来说明:在每一轮,都取了ready队列中,优先级最高的指令,如果active中的指令没有执行完,导致依赖关系无法满足,那么ready可能是空的,此时就继续下一轮调度。 cycle ready active 1 a13, c12, e10, g8 a 2 c12, e10, g8 a, c 3 e10, g8 a, c, e 4 b10, g8 b, c, e 5 d9, g8 d, e 6 g8 d, g 7 f7 f, g 8 f, g 9 h5 h 10 h 11 i3 i GCC中默认的指令调度算法是表调度算法。其处理过程在/gcc/sched-rgn.c中定义。schedule_insns()是调度的主要函数,它会执行两次,分别在:(1)寄存器分配之前,在pass_sched中调用,实现以区域为调度范围的指令调度;(2)寄存器分配之后,在pass_sched2中调用,通常只在每个基本块内部进行指令调度; schedule_insns() { int rgn; //如果没有包含代码的基本块,直接退出 if (n_basic_blocks_for_fn (cfun) == NUM_FIXED_BLOCKS) return; //初始化指令调度信息 rgn_setup_common_sched_info (); //初始表调度的基本信息 rgn_setup_sched_infos (); //haifa表调度的数据结构初始化,进行数据流分析 haifa_sched_init (); //初始化区域信息,将CFG的区域提取出来 sched_rgn_init (reload_completed); bitmap_initialize (&not_in_df, 0); bitmap_clear (&not_in_df); //对每个区域内的基本块进行调度 for (rgn = 0; rgn < nr_regions; rgn++) if (dbg_cnt (sched_region)) schedule_region (rgn); //清除 sched_rgn_finish (); bitmap_clear (&not_in_df); haifa_sched_finish (); } 统一寄存器分配我们知道,RTL在生成和处理的过程中,大量地使用了虚拟寄存器,那么这些虚拟寄存器在转换为目标机器的汇编码之前,需要映射到目标机器中的物理寄存器上,该过程即为寄存器分配。那么合理地分配、使用物理寄存器,是GCC后端及其重要的一个任务。GCC中采用的是一种把寄存器合并、寄存器生存范围划分、寄存器优选、代码生成和染色整合在一起的算法,所以被称为统一寄存器分配。整个统一寄存器分配是这样一个过程: 第一步是建立IRA的中间表示,其代码在ira-build.c中,入口函数为ira-build();第二步是寄存器的着色。这一步按照自顶向下的顺序,遍历每个区域,对于区域中的分配元进行着色,由ira_color()函数完成;第三步是代码移动,解决父子区域中,寄存器被spill/store的问题,如果子区域需要spill到内存,那么父区域也可以直接spill到内存,这样就不用多次进行移入内存/存内存取值到寄存器的过程;第四步是代码修改,在着色处理后,父子区域中,相同虚拟寄存器可能会分配到不同的物理寄存器活着存储位置,而某个区域内部也可能会会对同一个虚拟寄存器分配不同的物理寄存器。此时IRA就分配新的虚拟寄存器进行取代,并且在区域边界实现新的分配元并进行交换;第五步:将所有区域的分配元合并到一个区域中;第六步:尝试对spill操作的分配元分配物理寄存器;第七步:Reload Pass。前面的过程可能会引入新的代码、虚拟寄存器,产生不能满足模版约束的情况,reload处理这些情况,因此要重新进行第二步开始的操作,直到不再产生新的代码。 这里只说明一下寄存器着色的过程。在第一步中,我们能够根据每个虚拟寄存器的生存范围,能够确定它们的冲突关系:生存范围有冲突的虚拟寄存器,不能被分配到同一个硬件寄存器当中去(ira_build_conflicts()的工作)。而寄存器着色,就是根据虚拟寄存器之间的冲突关系图,进行图的着色处理。在这个图中,每个节点代表需要着色的虚拟寄存器,而每条边都定义了冲突关系,也就是这两个虚拟寄存器不能着相同的颜色。那么如果有N个寄存器可以分配,就相当于用N个颜色来着色;如果N不够大,那么就需要物理内存来保存虚拟寄存器的值。 小结编译器的优化是一个及其复杂的过程,这里我只是从几个点出发,了解它的工作过程,对编译优化形成一个浅薄的认识。日后如果真的要从事编译优化方面的研究,再好好看看编译器的代码吧!","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译器","slug":"编译器","permalink":"http://sec-lbx.tk/tags/编译器/"}]},{"title":"从虚函数的实现,到虚表劫持攻击","slug":"虚函数,原理和攻击方式","date":"2017-06-27T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/06/27/虚函数,原理和攻击方式/","link":"","permalink":"http://sec-lbx.tk/2017/06/27/虚函数,原理和攻击方式/","excerpt":"","text":"虚函数与多态继承和多态,是面向对象中老生常谈的话题。C++中,我们也可以经常看到virtaul、override这样的关键字;这正是虚函数的标志。虚函数就是为了解决多态的问题:如果要使用一个基类的指针,根据对象的不同类型去调用相应的函数,就需要使用虚函数了。通俗的说也就是同一个入口,却能够调用不同的方法。通常,对于虚函数的调用,往往在运行时才能确定调用哪个版本的函数。这是由于基类的指针或者引用,其动态类型必须在运行的时候才能确定(它具体指向了什么类型)。而“动态绑定”就指的在运行时,根据对象的类型,调用具体方法的过程,这个过程正是通过虚函数表实现的。抽象基类指的是有纯虚函数的类。纯虚函数,指的是没有函数体的函数,通常通过在函数体的位置写上=0来表示。对于抽象基类,是不能直接创建一个对象的;但是可以创建它它们的派生类的对象:只要它们覆盖了纯虚函数。纯虚函数表示这个函数的具体实现全部交给派生类去做。 虚函数表与虚函数的调用那么,“动态绑定”是如何实现的呢?这便是借助于虚函数表来实现。对于每个具有虚函数的类,都会有一个对应的虚函数表vtable,其代码和对应内存结构如下所示: class A { int varA; public: virtual int vAfoo(int a, int *b){ return a + (*b); } virtual int vAbar(int a){ return a + 1; } virtual bool vAduh(){ return true; } virtual int vAtest(int a){ return 0; } void Afoo(){ this->vAduh(); } }; class B { int varB; public: virtual int vBfoo(int a) = 0; virtual bool vBbar(int b){ return b == 0; } char *Bfoo(char *c){ return c; } }; class C : public A, public B { int varC; public: int vAtest(int a){ return -(a); } int vAfoo(int a, int *b){ return *b; } int vBfoo(int a){ return a - 1; } virtual void vCfoo(){} bool vAduh(){ return false; } }; 这个表中的每一项,都是一个虚函数的地址,也就是虚函数的指针。而每个对象的第一个值都是虚标指针,它指向了了所对应虚函数表的第一个表项(也就是虚函数表的基址)。每次调用虚函数时,都会首先通过这个虚表指针,找到虚函数表,然后再在虚函数表中,找到真正的虚函数的地址,并进行调用。假设存在有多继承的情况,那么就会有多个vptr,分别放在对应的基类对象的开头位置。 虚继承对于“菱形继承”情况(也即两个子类继承同一个父类,而新的子类又同时继承这两个子类),则可能产生二义性问题。例如下面的情况,那么D中就会保存两次A中的变量和函数,并且在使用时也会很不方便,必须利用域作用符来使用变量和函数。 A / \\ B1 B2 \\ / D 虚继承是在继承时,在基类类型前面加上virtual关键字。虚继承能够解决基类多副本的问题:在任何派生类当中,虚基类都是通过一个共享对象来表示的,它们通过指针去访问这个基类中的内容;它不用去保存多份基类的拷贝,而是只需要多出一个指向基类子对象的指针。从内存布局上来说,在虚表的负offset位置,会保存一个指针指向虚基类对象。也就是说继承自A的虚函数和对象,全部只保存一份在D自身的子对象中,相比不使用虚继承,它删除了B1和B2当中的(2份)基类成员;它自己则需要保存一份基类成员和偏移指针;而如果要用B1和B2的指针或者引用去访问一个D对象时,那么访问A的成员则需要通过间接引用来访问;也就是说子对象需要有一个偏移量,指示在内存中,基类的位置。其内存布局一般如下: 内存 B1的虚表指针 B1的偏移指针 B1的数据成员 B2的虚表指针 B2的偏移指针 B2的数据成员 D的虚表指针 D的偏移指针 D的数据成员 A的虚表指针 A的数据成员 虚表与劫持攻击在C++程序中,%90以上的间接调用都是vcall。篡改程序中的虚函数调用,是劫持C++程序的一种常见手段。这里简单说说常见手段。一种方法是虚表注入。众所周知,虚表保存在程序的.rodata段中,它是可读,不可写的;而对象当中的虚表指针却是可读写的状态;因此篡改虚表指针是较为直接的方式。如图,如果利用漏洞(overflow、use-after-free等)在内存中构造一个虚假的虚表,并且将对象中的虚函数指针指向注入的虚假的虚表,那么在虚函数调用时,就会调用虚假的虚函数。甚至只需要一次虚函数调用就能够通过shellcode完成攻击。当然,如果程序进行了一定程度的保护,例如检查虚表指针是否属于.rodata段,攻击就只能依赖于现有的虚表来构造了。Counterfeit Object-oriented Programming就提出了这样一种方法。可以看到,这种方法没有注入新的虚表,而是将vptr的值,指向了虚表中的不同位置(而不是虚表的起始地址)。如果能够构造一系列的虚假对象,那么就可以在一次循环中(比如某个对象数组的依次析构),在调用同一个虚函数时,实际上调用不同的函数,从而构造一个虚假的执行链。看到这里,也许你会有疑问:仅仅用有限的虚函数,能够构造图灵计算的攻击吗?答案是肯定的:有兴趣的话可以阅读一下原文,通过拼凑虚函数,是能够组合出各种语义的。 小结可见,虚函数是面向对象语言中,十分巧妙而又必不可少的设计;但它的特点也使得它成为黑客滥用、攻击的目标。指的庆幸的是,目前已经有一些开销较小的方法,能够保护虚表和虚函数了。","raw":null,"content":null,"categories":[],"tags":[{"name":"内存安全","slug":"内存安全","permalink":"http://sec-lbx.tk/tags/内存安全/"},{"name":"虚函数","slug":"虚函数","permalink":"http://sec-lbx.tk/tags/虚函数/"},{"name":"C++","slug":"C","permalink":"http://sec-lbx.tk/tags/C/"}]},{"title":"编译链中的一环,静态链接详解","slug":"编译链中的一环,静态链接详解","date":"2017-06-19T13:40:00.000Z","updated":"2017-08-11T11:37:11.000Z","comments":true,"path":"2017/06/19/编译链中的一环,静态链接详解/","link":"","permalink":"http://sec-lbx.tk/2017/06/19/编译链中的一环,静态链接详解/","excerpt":"","text":"GCC的工作,到生成汇编代码为止。剩下的工作,交给了Binutils来完成:assembler和static linker。最近详细地研究了一下linker的工作过程。linker主要完成的是静态链接,目标文件合并的工作。例如,把多个.o文件合并成一个可执行文件。 两步链接两步链接指的是: 空间与地址的分配链接器会首先扫描所有的输入文件,获得各个段的长度、属性和位置,将段合并;并将输入目标文件中的符号表合并为全局符号表。 符号解析与重定位使用上一步中收集到的信息,进行符号解析和重定位,调整代码中的地址等。这一步也是链接过程的核心,特别是重定位过程。链接器首先获取各个段的虚拟地址;在确定段的虚拟地址之后,也就能确定各个符号的虚拟地址了。 重定位与符号解析在完成空间和地址的分配之后,链接器开始进行符号解析和重定位的过程。在链接之前,各个段中的符号地址,都是以0为基地址的,对于未知的地址,也通通用0进行替代。编译器在编译时,对于不知道的符号地址,全部用一个假值替代,把真正的工作留给链接器去做。而链接器在分配了虚拟地址之后,就可以修正每一个需要重定位的入口。这个工作是借助于重定位表来实现的。重定位表包括:重定位入口(也就是需要重定位的地方),偏移表示入口在被重定位的段中的位置。在x86_64下,重定位表的结构也很简单(定义在elf.h当中): typedef struct{ Elf64_Addr r_offset; Elf64_Xword r_info; }Elf64_Rel; typedef struct{ Elf64_Addr r_offset; Elf64_Xword r_info; ELF64_Sxword r_addend; }Elf64_Rela 这里,r_offset指定应用重定位操作的位置;r_info则指定必须对其进行重定位的符号表索引以及要应用的重定位类型。其低位表示重定位入口的类型;高位表示重定位入口的符号,在符号表中的下标。(不同处理器的格式不一样)符号解析则是为符号的重定位提供帮助,根据多个目标文件中的符号表,生成全局符号表,找到相应的符号并进行重定位。对于未定义的符号,链接器都应该能在全局符号表中找到,否则就报出符号未定义的错误。PS:x86_64只使用Elf64_Rela。 指令修正在x86_64中,call、jmp、mov、lea等指令的寻址方式千差万别。对于重定位来说,修正指令的寻址方式定义在binutils/elfcpp/x86_64.h当中。这其中主要包括:R_X86_64_64和R_X86_64_PC32两种。这是因为X86_64上,相对寻址依然只支持32位(实际上这也很科学;因为一个可执行文件通常不会有4G那么大)。两种寻址方式的修正方法分别为:符号地址 + 保存在被修正位置的值和符号地址 + 保存在被修正位置的值 - 被修正的位置相对于段开始的偏移量 源码分析第一步:初始化,parsing command line & script filelinker的入口,在ldmain.c当中(通常在链接的时候,通过编译器内部直接进行调用)。首先,linker会调用bfd库,识别二进制文件的格式,生成各个段的描述符,并且转换为canonical form。(例如linker中的符号表识别工作,就是首先由BFD来进行分析和转化,然后linker直接在canonical form上进行操作,再由BFD来进行输出)因此在ldmain.c的main中,首先进行的也是bfd的初始化bfd_init。随后linker进行了一系列的设置,包括路径,回调函数、初始化,加载插件、读取命令、linker script等。 第二步:文件和符号的加载随后,lang_process中,linker会对每个输入文件进行处理。对于每个输入文件,linker都会分配一个bfd,对输入文件进行扫描,识别出其中的符号。首先open_input_bfds为所有文件建立了bfd,随后载入文件中的所有符号。每个符号对应一个bfd_link_hash_entry,它们保存在bfd_link_hash_table当中。bfd_link_add_symbols将符号添加到hash_table当中。 第三步:输入文件的分析和合并在链接的第一部分完成后,第二部分开始前,链接器首先调用了ldctor_build_sets函数,它主要为C++中的constructor/dectructor提供支持。随后链接器lang_do_memory_region计算出内存区域(它们保存在lang_memory_region_list当中)。再通过lang_common处理全局符号,将它们添加到对应的section,移除没有被使用的sections等。随后链接器建立输入section和输出section之间的映射关系,并且将文件的section合并,以及设置段的属性等。 第四步:重定位第四步是符号的重定位工作。这里lang_size_section首先获取所有section的大小,然后lang_set_startof会修正section的大小和位置。在确定了sections的信息之后,就可以对符号进行重定位了,这便是lang_do_assignments和ldexp_finalize_syms的工作。它们会按照前面提到的方法,对符号进行重定位。最后链接器还会检查符号和section的正确性。 第五步:交给bfd,输出文件在完成重定位之后,如果没有出现异常,linker就把工作交给bfd了。ldwrite负责把链接好的文件输出。完成一些清理工作后,整个链接过程就结束了。","raw":null,"content":null,"categories":[],"tags":[{"name":"链接器","slug":"链接器","permalink":"http://sec-lbx.tk/tags/链接器/"},{"name":"binutils","slug":"binutils","permalink":"http://sec-lbx.tk/tags/binutils/"}]},{"title":"Hack GCC,在编译时构造新的函数","slug":"Hack gcc:添加新的函数","date":"2017-06-09T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/06/09/Hack gcc:添加新的函数/","link":"","permalink":"http://sec-lbx.tk/2017/06/09/Hack gcc:添加新的函数/","excerpt":"","text":"怎么做和为什么?首先对于GCC来说,添加一个新的函数是完全可以实现的,但我并没有看到任何相关的资料。我给gcc的mailing list发了封邮件,但没人给出回答。倒是有人私下给我发了封邮件,表示他对这个做法很好奇,希望我做出来之后能和大家分享一下方法。看来求人终究不如求自己,我看了好几天代码,终于能够在编译的过程中添加函数了。至于为什么要这么做,我认为如果编译时插桩的代码是大量重复的,那么去构造一个函数,并且插桩对这个函数的调用,是能够减少最后编译的可执行文件的体积的。 从函数的结构出发要构造一个新的函数,不妨先看看函数包含有哪些部分:这里说明了函数的四个核心部分:函数名、参数、返回值、函数体。那么生成一个函数,也就要构造这些部分;并且对于函数的某些property,也应该进行相应的设置。 有兴趣的朋友可以参考这几个函数:tree-parloops.c中的create_loop_fn(),omp-low.c当中的create_omp_child_function(),以及cgraphunit中的init_lowered_empty_function。 函数的构造总的来说,如果要构造一个函数,需要完成这些步骤: 构造函数的type 构造函数的name 构造函数的declaration 构造函数体 创建函数的cgraph_node 设置函数的attributes 构造函数的result 构造函数的paramater 添加新的函数 type&namebuild_function_type(tree return_type, …)`用来构造一个function的type。return_type是函数返回的类型。可变参数是用来设置额外的参数类型的,参数类型必须由NULL_TREE来终结。 那么这个这些type是从哪儿来的呢?build_complex_type用来生成一系列组合的type,比如unsigned long,而其他的基本类型实际上已经定义好了,例如void_type_node,ptr_type_node等。可见,数据的类型,就是通过这种方式来定义的,对于用户自己定义的数据类型,同样会通过build_complex_type来处理。 get_identifier会返回一个标识符id,如果name能够找到,它会返回原有值,如果找不到,就会创建一个新的表项并返回。随后decl会实际去构造一个声明。build_decl的内部如下: tree build_decl_stat(location_t loc, enum tree_code code, tree name, tree type MEM_STAT_DECL){ tree t; t = make_node_stat(code PASS_MEM_STAT); declarationbuild_fn_decl(const char *name, tree type)函数用来构造并且返回一个函数的声明。这里详细看看这个函数的内部: tree build_fn_decl(const char *name, tree type){ tree id = get_identifier(name); tree decl = build_decl(input_location, FUNCTION_DECL, id, type); DECL_EXTERNAL(decl) = 1; TREE_PUBLIC(decl) = 1; DECL_ARTIFICIAL(decl) = 1; TREE_NOTHROW(decl) = 1; } 函数体然而,这里仅仅是构造了一个声明,对于一个函数来说,不仅应该有它的声明,还应该有其函数体。allocate_struct_function(tree fndecl, bool abstract_p)会为一个声明生成一个function structure,并且把它设置为默认内容。abstract_p表示这个函数是一个抽象的(类似于函数模版)。在创建函数体之后,还要创建函数体的基本块:赋予函数最基本的结构,并且正确的连接ENTRY_BLOCK和EXIT_BLOCK。这个步骤可以通过create_basic_block来完成。 cgraph_nodecgraph_node定义在cgraph.h中,它是一个symtab_node的子类,也就是符号表的子类。它包含有函数声明的调用关系(这也是它的名字call graph node的由来),也就是callee和caller。cgraph_node::get_create()能够创建一个函数的call graph。这个函数会为某个函数声明,找到对应的的call graph。如果不存在这样的call graph,就为它构造一个call graph。这个过程还将函数加入符号表symtab,这是极其重要的。函数间调用的转换,也依赖于这个符号表。 attributestree_cons用来创建TREE_LIST结点。它根据purpose、value(实际上也就是tree_list的两个元素),创建一个结点,并设置这个node的tree_chain。对于函数来说,它的attributes通常是保存在一个TREE_LIST当中的。 paramaters&result对一个函数来说,它的参数和返回值,同样都是利用tree结构来表示的。对于一个函数,参数是可以不设置的,但返回值不行。这两者可以通过这样的形式来构造声明: t = build_decl(UNKNOWN_LOCATION, RESULT_DECL, NULL_TREE, void_type_node); DECL_CONTEXT(t) = fndecl; DECL_RESULT(fndecl) = t; t = build_decl(UNKNOWN_LOCATION, PARAM_DECL, NULL_TREE, void_type_node); DECL_CONTEXT(t) = fndecl; DECL_ARGUMENTS(fndecl) = t; 当然,还要设置这它们和函数之间的上下文关系。 添加新的函数cgraphunit.c当中,提供了一个函数cgraph_node::add_new_function。在编译过程中,编译器会插入一些新的函数,这个函数将新的函数添加到一个数组cgraph_new_nodes当中去。这个函数还会对所添加的函数,执行之前所有“错过”的pass。因此不论在任何时间点插入函数,其所经历的优化过程其实并没有减少;值得一提的是,这个函数只允许插入GIMPLE形式的函数(high,low,ssa),所以我想这个问题目前应该是不能实现的;因为如果直接写RTL,可能会无法恢复成GIMPLE,也就没法去进行某些优化了。cgraph_new_nodes中所保存的函数,会被process_new_functions处理,将它们添加到call graph当中去。现在这些函数就像原有的函数一样了。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"Hack GCC,在RTL上插桩","slug":"Hack GCC:insturment on RTL","date":"2017-05-16T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/16/Hack GCC:insturment on RTL/","link":"","permalink":"http://sec-lbx.tk/2017/05/16/Hack GCC:insturment on RTL/","excerpt":"","text":"insn的创建在emit-rtl.c当中,列举了一系列的emit_insn函数: /* 在before前插入x */ emit_insn_before_noloc(rtx x, rtx before) /* 在after后插入x */ emit_insn_after_noloc(rtx x, rtx after) /* 插入x的同时插入jump */ emit_jump_insn_before(rtx x, rtx before) emit_jump_insn_after(rtx x, rtx after) /* 插入x的同时插入call */ emit_call_insn_before(rtx x, rtx before) emit_call_insn_after(rtx x, rtx after) emit,也即发射。在指令生成之后,可以利用这一系列的API,把它“发射”到指令序列当中去。首先是要生成相应的指令,包括。其实insn-emit当中,已经提供了许多模版,比如gen_nop,就可以生成一条nop指令。 instrument bound check在gcc/config/i386/i386.c当中,ix86_expand_builtin函数包含了一系列“处理器内置函数”的expand过程。这个函数中,首先用DECL_FUNCTION_CODE()对对应的内置功能进行判断,随后根据内置功能的类型进行进一步判断。其中,IX86_BUILTIN_BNDCU/IX86_BUILTIN_BNDCL就是bound check指令对应的处理。不妨来看看这两类指令是如何生成的吧: case IX86_BUILTIN_BNDCU: arg0 = CALL_EXPR_ARG(exp, 0); arg1 = CALL_EXPR_ARG(exp, 1); op0 = expand_normal(arg0); op1 = expand_normal(arg1); if(!register_operand(op0, Pmode)) op0 = ix86_zero_expand_to_Pmode(op0); if(!register_operand(op1, BNDmode)) op1 = copy_to_mode_reg(BNDmode, op1); emit_insn(BNDmode == BND64mode ? gen_bnd64_cu(op1, op0):gen_bnd32_cu(op1, op0)); 可见,arg0和arg1是由exp得到的,随后被转化为op0和op1。这里对于op0和op1的模式进行了判断。这里的判断,是对操作数的machine mode进行了判断。对于op0来说,是判断它是否满足Pmode,也即指针使用的模式(比如x86_64用了48bit,这里的P表示partial);而BNDmode则表示的是指针”bounds”的模式。后面的gen_bnd64_cu(op1, op0)也表明,op1是指针指向的地址,op0是bound寄存器的值。 寄存器的申请对于op1来说,其值也就是相应的指令进行访问的地址。而op0则是具体的寄存器了,那么这个寄存器如何申请呢?在RTL当中,寄存器的选择是由gen_rtx_REG()来决定的。它的第一个参数,选择了这个寄存器的mode,第二个参数则是通过一个编号,来选择寄存器。这些寄存器的编号,定义在gcc/config/i386/i386.h当中。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"Hack GCC,改变函数的顺序","slug":"Hack GCC,修改二进制文件中函数的顺序","date":"2017-05-12T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/12/Hack GCC,修改二进制文件中函数的顺序/","link":"","permalink":"http://sec-lbx.tk/2017/05/12/Hack GCC,修改二进制文件中函数的顺序/","excerpt":"","text":"GCC的函数输出直观的来看,在cgraphunit.c当中,expand_all_functions()被用来输出所有需要输出的函数。这里,它首先进行了一次拓扑排序,在ipa_reverse_postorder()中完成。这样做的好处是,当一个函数被输出时,它所调用的所有函数都已经被输出了。这个函数还会先把内联的情况给处理掉。在expand_all_functions()中,随后在允许函数重排列的情况下,利用qsort,对其顺序进行了重新排列。其比较函数,是通过比较两个cgraph_node代表的函数第一次执行的时间来进行的。奇怪的是,在我修改了expand_all_functions()中的order之后,输出文件的格式并没有变。这里我出了一点小差错,结果在编译GCC的时候出现了问题,不过这也恰好说明GCC是通过自举来完成编译的。于是我继续在上一层函数进行查找,在symbol_table::compile当中,实际上通过了了一个flag_toplevel_reorder来进行判断。在该值为false时,函数的调用会直接调用:output_in_order(false)。在实验之后,我发现gcc默认使用的是-fno-toplevel-reorder的方式,也即使用的是output_in_order(false);但它自举的时候使用的是-ftoplevel-reorder的方式,也即output_in_order(ture)。这很自然:gcc优化自己的代码,这是它自身的需要。 if(!flag_toplevel_reorder) output_in_order(false); else{ output_in_order(true); expand_all_functions(); output_variables(); } 那么不妨看看output_in_order()中的代码。 FOR_EACH_DEFINED_FUNCTION(pf){ ... if(no_reorder && !pf->no_reorder) continue; i = pf->order; nodes[i].kind = ORDER_FUNCTION; nodes[i].u.f = pf; } 很明显,如果采用reorder的方式,也即output_in_order(ture),那么这里会跳过nodes的排序部分,并且进一步执行expand_all_functions()。而如果采用的是no_reorder,那么nodes[i]所对应的也就是通过FOR_EACH_DEFINED_FUNCTION遍历到的第i个函数了,也就是严格按照次序来决定的。 改变函数的输出次序那么如果想要更改汇编输出的函数次序,对于优化的情况,在expand_all_functions()中,在qsort对函数进行重排列之后,对order进行修改就可以了。那么GCC自身为什么要改变函数的顺序呢?因为把具有调用关系的函数,放在同一个页当中,是可以有效的减少缺页的情况的。不过现在内存的大小都很大,其实代码页并不会占据很多内存,所以从这个角度上来看,其实差异没有想象中那么大。另一种方式是采用和没有优化的方式相同的处理方法,但是在编译时加上-fno-toplevel-reorder选项。而假如没有进行优化,那么直接在output_in_order()当中,对nodes中的次序进行修改就行了。那么函数的随机化就可以在这些知识的基础之上实现了。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"RTL,指令和运算,识别特定指令","slug":"RTL详解","date":"2017-05-10T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/10/RTL详解/","link":"","permalink":"http://sec-lbx.tk/2017/05/10/RTL详解/","excerpt":"","text":"运算RTL中的运算,采用一种很直观、简单的方式来进行描述。通常,表达式都会借助于m mode。运算包含有算术、比较、向量操作、Bit域、类型转换等。 Side Effect 表达式之前所说的表达式代码,通常表示的是值,而非某种行为。但机器指令,只有在对机器的状态产生了改变时,才会有意义;因此对于这些对寄存器、执行状态产生影响的过程,是有对应的表达式的,被称为side effect表达式。一条指令的主体,一定是这些side effect之一;而之前表示值的表达式代码,只是作为它们的操作数出现的。set lval x:表示将x的止保存在lval当中。lval表示一个能够存放值的位置。它们包括(仅列出了比较常用的一部分):return/simple_return:表述函数返回。call function nargs:表示函数调用。clobber x,表示不可预期的,可能的存储,将不可描述的值保存到x,必须为一个reg,scratch,parallel或者mem。说明x的值可能被修改(也就是说值会被改变?)use x:x的值被使用。x必须为寄存器。parallel:并行发生的side effects。cond_exec [cond expr]:条件执行的表达式。sequence [insns]:顺序执行的insn序列。除此之外,还有一些和内存地址有关的side effects,例如pre_dec:m x表示x减少一个标准值,其中m必须是指针的对应machine mode。例如(mem:DF (pre_dec:SI (reg:SI 39)))表示,减少伪寄存器39的值(减少DFMODE的大小)并且把结果作为一个DFMODE值。 指令一个函数的代码的RTL形式,是保存在一个双向链表当中的,它被称为insns。insn是具有特殊代码的表达式,它们不作它用,其中一部分是用来生成真实的代码的,另一部分表示jump table,或者jump的labels。insn除了自身特定的数据之外,每一个insn还必须有一个独有的id-number,用来和当前函数中其他的insns进行区分,并且和其他的insns进行串联。在每个insns中,都有这样三个区域,分别用INSN_UID,PREV_INSN,NEXT_INSN这三个宏定义来取值。insn一定包含有如下的某一类表达式代码:insn:非jump/call的代码。jump_insn:可能会造成跳转的指令,包括ret。call_insn:可能进行函数调用的指令。call_insn也包含有一个域,CALL_INSN_FUNCTION_USAGE,它包含有一个链表,它记录了call调用所调用函数所需要利用的寄存器、内存地址等。code_label:表示jump_insn能够跳转到的label。它包含两个特殊的域,分别是CODE_LABEL_NUMBER和LABEL_NUSES,后者只在jump优化阶段结束之后,它包含目前这个函数中,该label被使用了多少次。jump_table_data:用来保存jumptable,位于tablejump_p insn之后。barrier:放在非条件jump指令之后。note:表示额外的调试信息。debug_insn:用来track的伪代码。 insn,jump_insn,call_insn这几类指令都含有额外的域:PATTERN(i):这条指令的side effect表达式。INSN_CODE(i):一个integer,表示了这个insn机器描述的pattern,但通常一般不会做匹配,因此一般都是-1,尤其是对于use,clobber,asm_inpu,addr_vec和addr_diff_vec表达式而言。LOG_LINK:insn_list链表,记录了instructions和basic block的依赖关系。只被schedulers和combine使用。REG_NOTES:一个链表,记录了和寄存器有关的信息。具体的,宏REG_NOTE_KIND返回了register note的类型。而PUT_REG_NOTE_KIND则用来修改类型。 一个例子那么,不妨来看看在gcc里面,一个insn到底是如何表示的: (insn 21 20 22 6 (set (reg:SI 59 [ D.1238 ]) (mem/c/i:SI (plus:SI (reg/f:SI 54 virtual-stack-vars) (const_int -8 [0xfffffffffffffff8])) [0 sum+0 S4 A32])) /home/kito/sum.c:7 -1 (nil)) 把它和”iuuBeiie”对应起来,那么其实也就是一对一的关系,这里解释的很到位: (insn 21 # 1. i 20 # 2. u 22 # 3. u 6 # 4. B (set (reg:SI 59 [ D.1238 ]) (mem/c/i:SI (plus:SI (reg/f:SI 54 virtual-stack-vars) (const_int -8 [0xfffffffffffffff8])) [0 sum+0 S4 A32])) # 5. e /home/kito/sum.c:7 # i -1 # 6. i (nil)) # 7. e i 代表该 INSN 的 uidu 上一道 INSN 的 uidu 下一道 INSN 的 uidB Basic Block的 编号e 该 INSN 的主要內容, 例如上面那道指令所描述的是從内存读取一個值到寄存器中i 该 INSN 对应源码的位置i 放 RTL pattern Namee 放 REG_NOTES 主要和寄存器有关 其中,这个“iuuBeiie”是打印的格式,每个字符的含义定义在rtl.c当中。这里,我比较关心的’e’指的是一个rtl表达式。再来看一个例子。call_insn对应的输出是“iuuBeiiee”,其输出为: (call_insn 16 #1. i 15 #2. u 17 #3. u 2 #4. B (call (mem:QI (symbol_ref:DI ("printf")[flags 0x41] <function_decl 0x7f1fd042e100 printf>)[0 __builtin_printf S1 A8]) const_int 0 [0])))) #5. e hello.c:12 #6. i 649 #7. i (nil) #8. i (expr_list (use (reg:QI 0 ax)) (expr_list:DI (use (reg:DI 5 di)) (expr_list:SI (use (reg:SI 4 si)) (nil))))) #9. e ) 和普通的insn相比,多出来的一项expr_list,是一个链表,它包含了用来传递参数的寄存器、内存的信息。 RTL中的寄存器从GCC官方的说明来看,RTL和llvm一样,首先是假设“有无限个寄存器”的,这也就是说,在RTL代码中,必然会有伪寄存器存在。在编译过程中,这些伪寄存器,要么被替换为真实的、硬件寄存器,要么被替换为内存引用。我也为此困扰过:那么在翻译时,是如何确定RTL具体对应的机器码的呢?RTL既然是一个和机器相关的语言,那么寄存器的选择,应该与最后的指令生成无关了。其实这个问题是在gcc内置的rtl pass当中解决了。具体的,register alloction这个(准确的说是一系列)pass,它保证了所有的伪寄存器被清除了,其方式正是将它们替换为硬件寄存器、常量或者栈上的值。这也就是说,假设想要做一些和寄存器的选择相关的优化,最好是在这个过程之后去做。在这个过程之后,rtx中的reg就表示的是实实在在的寄存器了。而相对的,subregs也不存在了,而mem毫无疑问也就表示了对表达式addr所示地址的主内存进行引用。其中mem:m addr alias中,m描述的是被访问内存单元的大小,alias代表这个引用点别名集合,只有引用相同内存地址的项,才会放在一个别名集合里面。 RTL,读取内存既然知道mem:m addr alias用来引用内存,那么addr这个东西到底是怎么表示的?它是用一个寄存器来保存指针吗?还是用一个变量名称呢?首先我们来看看有哪些表达式会读取内存吧:副作用表达式set lval x,表示将x对值放到由lval表示的地方。那么如果x是一个mem,无疑是会对内存进行引用的。parallel [x0 x1 ...],多个并行执行的副作用,那么其中的每一个set都会发生内存的引用。算术表达式在RTL中,算术表达式并不会改变一个值,它只是一个单独的运算,代表一个结果。举个例子,如果是一个ADD指令,那么它实际上在RTL中是通过一个parallel来表示,这个parallel包括了plus,还包含了set,只不过在汇编阶段生成的只是一条指令而已。所以说其实RTL里面的parallel,并不是说的概念上的并行,而是说某些表达式,会同时发生、生效。在mem:m addr alias当中,mem既可以是寄存器中存储的地址,也可以是常量指针中的地址。 指令的遍历在GCC,所有的pass,都是以函数为粒度来进行调用的。它是一种“run on function”的机制。在gcc内部,当前处理的函数用cfun来进行访问。而指令,是由特殊的rtx表达式:insn来表示的。那么如何访问每一条指令呢?我个人认为,首先遍历基本块,随后再在基本块的内部进行遍历,是更为有效的。也就是这样的方式: FOR_EACH_BB_FN(bb, cfun){ FOR_BB_INSNS(bb, insn){ ... //handle insn } } 但是,INSN中其实还包含有note,如果只想留下insn,那么用INSN_P(insn)进行过滤就可以。这样一来,剩下的就只有insn、jump_insn、call_insn三种情况了(还有debug_insn,这里不考虑这种情况)。这三种情况,实际上还可以用宏继续细分判断,分别是JUMP_P,CALL_P,NONJUMP_INSN_P。 需要关注哪些指令?这里,我想定义的内存读取,指的是“通过一个指针,从内存中取值,并且把它放在寄存器当中”,当然这个取值可能会在计算之后放在寄存器中,例如ADD,也可能是通过MOV直接放到寄存器里面。无论如何,只要涉及到访问内容的数据(通过指针实现),并且结果被放到了寄存器中,这个行为就被认为是内存读取了。当然,在RTL中,指令还没有那么的直观。那么如何识别出这些指令呢?首先,有一点是可以肯定的,那就是实际上只有nonjump_insn会产生这些指令。我在分析之后,发现:jump_insn、call_insn,即使是间接跳转的(比如c++里面,常常可以见到的vpointer),它们其实也只是代表控制流转移的过程而已,其指针的提取、计算,其实都是放在这条指令之前的insn中去执行的。 指令中的内存读取下一步,就是从这些指令中,把真正的指令读取给提取出来。对应一个insn,它的输出格式是"iuuBeiie",其中,e也即rtx表达式。在输出的时候,这部分表示的是这个insn的实际代码。GET_CODE能够用来提取出insn的代码部分,其实它只是一个enum:#define GET_CODE(RTX) ((enum rtx_code) (RTX)->code),而PATTERN则是获取这个insn的副作用。这里,不应该被其名字所迷惑:因为GET_CODE不过是获取rtx code的类型,它和这个insn的内容,反而没有联系;相反,PATTERN则说明了这个insn会带来什么副作用,也就是对值的影响。在看了var-tracking.c和gcse.c当中的源码之后,我注意到一条set指令中的src和dest,实际上都可以用宏提取,也即rtx src = SET_SRC(PATTERN(insn))和rtx dest = SET_DEST(PATTERN(insn))。而PATTERN的代码类型其实也很有限。在var-tracking之后,实际上sequence也已经被去除了。那么实际上我们需要关注的PATTERN也就只有两种:分别是set和parallel。(实际上还有一种,那就是asm_input,这里先不予考虑)那么对于insn,也就只有两种情况,一是一个单独的set,二是一个parallel,它可能包含了很多条insn,那么其中的指令就要逐条分析了。注意:在gcc 5.0之后点版本,专门的数据结构rtx_insn表示一条指令,而不是用rtx来一概而论了。 SET的分析一个set val x,其lval可以是reg,mem,pc,parallel或者cc0。其中,parallel用来表示一个函数通过多个寄存器来返回一个结构体的情况,这里不用关注这个情况,只需要关注lval是reg,而x是mem的情况。(目前只考虑了这个最基本的情况)对于x来说,set的源操作数可能是一个内存地址,还可能是一个表达式的,比如加减乘除、位操作等。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"RTL,类型和操作数","slug":"gcc rtx","date":"2017-05-07T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/07/gcc rtx/","link":"","permalink":"http://sec-lbx.tk/2017/05/07/gcc rtx/","excerpt":"","text":"RTXrtx(RTL expression),也即一个RTL的表达式。在coretypes.h中,其被定义为struct rtx_def *rtx,也即一个指向rtx_def的指针。那么rtx_def是一个什么结构呢?它同样定义在rtl.h当中: struct GTY((chain_next ("RTX_NEXT (&%h)"), chain_prev ("RTX_PREV (&%h)"), variable_size)) rtx_def { /* 表达式类型 */ ENUM_BITFIELD(rtx_code) code: 16; /* 表达式包含值的类型 */ ENUM_BITFIELD(machine_mode) mode : 8; /* 一系列位段,在不同情况下表示不同含义*/ unsigned int jump : 1; unsigned int call : 1; unsigned int unchanging : 1; unsigned int volatil : 1; unsigned int in_struct : 1; unsigned int used : 1; unsigned frame_related : 1; unsigned return_val : 1; /* rtx的第一个操作数,操作数的个数和类型是在code域中定义的*/ union u { rtunion fld[1]; HOST_WIDE_INT hwint[1]; struct block_symbol block_sym; struct real_value rv; struct fixed_value fv; } GTY ((special ("rtx_def"), desc ("GET_CODE (&%0)"))) u; }; https://dmalcolm.fedorapeople.org/gcc/2013-10-31/doxygen/html/structrtx__def.html中对每个位段进行了更详细的描述。 其中,rtx所包含的所有可能情况,都包含在了rtl.def当中;注意这里rtx和insn其实有一些微妙的关系:rtx表示一条中间指令的时候,它是一个insn;rtx不一定是insn,但insn一定是rtx。 RTL:对象如果想在RTL阶段上,进行编译的优化,那么首先就必须好好了解RTL。RTL使用五种类型的对象: expressions、integers、wide integers、strings和vectors。其中,RTX是最为重要的的一个,它是一个C数据结构,通常用指针的形式来调用,也就是前面所提到的rtx。interger和wide integer分别对应着机器上的整型和长整型。string则是一串非空的字符;vector则包含指向任意数量expression的指针,用[…]的形式来表示,并用空格分割。expressions被称为RTX code,它定义在rtl.def当中,其含义是与机器无关的。一个rtx的code部分,可以用GET_CODE(x)来获取,并且可以用PUTCODE(x, newcode)来替换。expression code决定了:表达式所包含的操作数个数、它们是什么类型的objdect。RTL不像Lisp,不能通过operand自身来确定它是什么类型的object,而必须知道它的上下文(例如一个subreg code,它的第一个操作数被当成表达式,而第二个被认为是一个整型;又例如plus code,两个operands都被当成了表达式)。expression写作:(),包含表达式类型、flags、机器码等,以及用空格分割的操作数。 RTL:类和格式expression code可被归为许多类,它们用一个字符来表示,GET_RTX_CLASS(code)能够用来获取RTX代码的类型,这些类定义在rtl.def当中。RTX_OBJ:表示一个具体的object,例如REG/MEM/SYMBOL等RTX_CONST_OBJ:表示一个常量objectRTX_COMPARE:表示不对称的比较,例如GEU/LT。RTX_COMM_COMPARE:表示对称的比较。(表示满足交换律的)RTX_UNARY:一元的算数操作,例如NEG、NOT、ABS、类型转换等RTX_COMM_ARITH:满足交换律的二进制操作,例如PLUS、AND。RTX_BIN_ARITH:不满足交换律的二进制操作,例如MINUX、DIV。RTX_BITFIELD_OPS:位操作,包含ZERO_EXTRACT,SIGH_EXTRACT。RTX_TERBARY:其他有三个输入的操作,例如IF_THEN_ELSE。RTX_INSN:表示整条INSN,包括INSN、JUMP_INSN、CALL_INSN。RTX_MATCH:表示insns当中的matches,例如MATCH_DUP。RTX_AUTOINC:表示寻址时的自增,例如POST_INC。RTX_EXTRA:所有其它的codes。这些class,能够让expression code采用很方便的方式来表示,比如subreg直接可以表示为’ei’。GET_RTX_LENGTH(code)和GET_RTX_FORMAT(code)分别会返回操作数的数量,以及格式(简写,例如一个比较的操作写成ee) 访问操作数通过宏定义,可以访问一个表达式的操作数,包含XEXP、XINT、XWINT、XSTR。这些宏都包含了两个参数:其一是一个RTX,其二是从0开始数的操作数编号。例如XEXP(x,2)访问了x的第二个操作数(作为expression来访问)。XINT(x,2)访问了x的第二个操作数(作为integer来访问)。使用者必须自己根据expression code来判断每个操作数是什么类型的。而对vector的访问则更复杂,XVEC能够获取vector指针,而XVECEXP和XVECLEN可以用来访问一个vector的元素和长度。对于某些的RTL节点,还有一些特殊的操作数访问方式。例如MEM、REG、SYMBOL_REF都能够用来获取不同的相关信息。 Machine ModesMachine Modes描述了数据对象的大小,以及它的表现形式。Machine mode以枚举的形式来表示,machine_mode,定义在machmode.def当中。每个RTL表达式,都包含有一个machine mode的区域。在dump文件、机器描述中,RTL的machine mode被写在表达式的后面,用一个冒号分隔。在GCC中,insn相当于rtx的组合。例如, insn: mov ax, 8 rtx: ax rtx: 8 rtx: mov ax, 8","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"GCC插件:注册、编译和使用","slug":"GCC插件编写","date":"2017-05-05T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/05/GCC插件编写/","link":"","permalink":"http://sec-lbx.tk/2017/05/05/GCC插件编写/","excerpt":"","text":"GCC插件:注册、编译、和使用GCC 4.5版本后,为用户提供了接口,允许用户去编写额外的代码优化过程、改造代码形式、对代码进行分析。在GCC中,插件是以共享库的形式工作的。在安装好GCC之后,如果想编写插件,首先可以确定API的位置: gcc -print-file-name=plugin 在gcc-plugin.h中,提供了以下的结构:struct plugin_name_args包含了GCC调用插件所需要的信息,plugin_info是插件的帮助信息,plugin_gcc_version包含了插件支持的GCC版本。 对于一个插件,首先要决定它是在编译的那个阶段执行的,并且通过注册回调的方式,在编译的特殊时间段调用这个插件。GCC中的插件可以在GIMPLE、IPA、RTL等阶段工作。插件必须定义一个对应的数据结构,并且将它的信息,传递给插件框架,由它来回调插件的功能。插件所能够完成多功能,被称为”events”,它们定义在plugin.def文件当中。 每一个gcc插件,都需要有一个初始化模块。对于一个gimple-pass插件,其初始化数据结构为: static struct gimple_opt_pass myplugin_pass = { .pass.type = GIMPLE_PASS, .pass.name = "myplugin", /* For use in the dump file */ /* Predicate (boolean) function that gets executed before your pass. If the * return value is 'true' your pass gets executed, otherwise, the pass is * skipped. */I .pass.gate = myplugin_gate, /* always returns true, see full code */ .pass.execute = myplugin_exec, /* Your pass handler/callback */ }; 在tree-pass.h中,定义了一系列的数据结构,用来设置编写pass的执行顺序。在gcc/passes.def当中,定义了所有的pass。http://gcc-python-plugin.readthedocs.io/en/latest/tables-of-passes.html上标注了GCC所有的passes,以及它们的所属的阶段。我注意到,在pass编写中,是这样标注pass的位置的: pass.reference_pass_name = "ssa"; pass.ref_pass_instance_number = 1; pass.pos_op = PASS_POS_INSERT_AFTER; 注释中写道,这个plugin将在GCC完成SSA表现形式之后,调用这个plugin,并且在第一个SSA之后调用。从tree-pass.h中,可以看到这样的定义: struct register_pass_info { opt_pass *pass; const char* reference_pass_name;/*引用新pass的原有pass的名字*/ int ref_pass_instance_number;/*在原有pass后的特定位置插入新pass*/ enum pass_positioning_ops pos_ops;/*具体的插入方式,替换,之前,还是之后*/ } 在pass的init过程中,还需要进行回调的注册,把这个info传递给插件框架。 register_callback("myplugin", PLUGIN_PASS_MANAGER_SETUP, NULL, &pass); register_callback("myplugin", PLUGIN_INFO, NULL, &myplugin_info); 当然,插件的运行也可以不用通过pass manager来进行hook,而是在某个特定的时间段运行。在gcc/plugins.text中,可以看到plugin_event的定义,它包含了一系列段时间点,于是乎在某些特定的时间点调用插件变得很方便,例如before gimplification, after compilation等。不过,如果想要更精确的设置调用的时机,那便还是要利用pass_manager来进行hook了。 插件的编译与一般的共享库编译并无不同。在老版本的GCC中,插件都是用C语言编写的,然而在我看到的版本(4.9.4)中,却一律变成了C++,看来用面向对象的语言来处理还是更为方便。 那么在编译好插件之后,就可以在gcc编译时调用它: gcc -fplugin=./myplugin.so -c -o test test1.c","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"GCC,Overview","slug":"GCC overview","date":"2017-05-01T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/05/01/GCC overview/","link":"","permalink":"http://sec-lbx.tk/2017/05/01/GCC overview/","excerpt":"","text":"前端:AST/GENERICAST:用tree来表示函数。其中,tree实际上是一个指针,它能够指向许多种类型的tree_node,这些tree_node定义在tree-core.h当中,就不一一赘述了。只需要明白,GCC当中最重要的数据就够就是tree,如果你想要添加一种tree_node,也可以在tree.def里面添加。 tree-core.h中定义了两种数据结构,用来直接表示tree节点,分别是tree_list和tree_vec。它们都包含有一个tree_common结构。tree_common包含有一个chain,用来和其他的tree链接起来。而tree_list则包含有purpose和value,它们都是tree_node。value包含了一个tree的主体。可以说,在GENERIC中,tree_node才是主角。正如前面所说的,tree_node根据表示内容,也有不同的类型,而不同类型的tree_node的代码也不同了。比如指针类型使用POINTER_TYPE代码,而数组使用ARRAY_TYPE代码。函数的声明、属性、表达式等,都是通过tree_node来表示的。在AST中,函数可以分为几个部分,分别是名字、参数、结果和函数体。这几个部分,都是通过tree_node来表示的。当然函数的内部还包含有许多属性。值得一提的是,有时候tree会为后端保留一些slot,用来在树被转化为RTL时,给GCC后端使用。 中端:GIMPLE目前C和C++的前端直接从tree转换为GIMPLE,不再先转换为GENERIC了。在GCC中,gimplifier过程将原始的GENERIC表达式,生成(不超过3个操作数)的GIMPLE元组。GIMPLE同样是基于tree结构的中间语言。其实GIMPLE还可以进行细分,它表现出了编译器在middle end的过程。没有完全下降的GIMPLE被称为“High GIMPLE”。“Low GIMPLE”完成了进一步下降,消除了隐式的跳转和异常表达式。在Low GIMPLE之后,是基于SSA的GIMPLE。SSA,“静态单赋值”保证程序中的变量,只在一个位置赋值(赋值多次就在每次赋值后产生新的变量),这样做的好处是利于数据流分析,大多数优化都是基于SSA来做的。基于GIMPLE的优化过程,都是与机器语言无关的,它包括变量的属性设置、数据流分析和优化、别名分析等。目前,大多数插桩的工作,也都是在GIMPLE上完成的,因为它属于与目标机器代码无关的中间语言;这也和LLVM IR类似。 后端:RTLRTL则是从GIMPLE进一步拓展而来,它与机器语言直接相关。它也是大部分pass依赖的中间表示,和Lisp很类似。RTL会在优化过程中,逐渐去掉伪寄存器、合并指令,删除控制流图等。最后GCC根据RTL语言,根据insn-output当中的的模版,一一对应的输出汇编格式,再由汇编翻译成二进制代码。RTL语言的定义位于rtl.def当中,在make过程中,GCC会根据机器描述文件,从对应到机器代码中提取寄存器、指令的配置,并生成相应的insn-out.c等文件,它们会在make install的时候进一步生成GCC。值得一提的是,不论是RTL还是GIMPLE,都是和编译器所维护的控制流图相关的。控制流图由基本块和边来表示,并在编译过程中保持更新。控制流图在树拓展为RTL的过程中被丢弃。 小结总体而言,GCC的编译过程可以归纳为:从源码到语法树、从语法树到GIMPLE、从GIMPLE到RTL,再从RTL到汇编。在每个阶段,而GCC的优化工作,主要在GIMPLE和RTL上面进行。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"}]},{"title":"深入理解进程通信","slug":"深入理解进程通信","date":"2017-04-30T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/30/深入理解进程通信/","link":"","permalink":"http://sec-lbx.tk/2017/04/30/深入理解进程通信/","excerpt":"","text":"管道管道是进程间的一个单向数据流;进程写入管道的数据都由内核定向到另一个进程,随后另一个进程由此从管道中读取数据,两个进程必须有亲缘关系,例如兄弟进程、父子进程。 //经常会使用到的 $ ls | more 管道可以被视为两个“打开的文件”,但它在文件系统中没有响应映象,其原理很简单:pipe()系统调用会返回两个FILE文件指针,分别用于读和写,两个进程分别能够读、写管道的内容。 管道的结构定义在pipe_fs_i.h中: struct pipe_inode_info { struct mutex mutex; wait_queue_head_t wait; //FIFO等待队列 unsigned int nrbufs, curbuf, buffers; unsigned int readers; //读进程编号 unsigned int writers; //写进程编号 unsigned int files; unsigned int waiting_writers; unsigned int r_counter; unsigned int w_counter; struct page *tmp_page; struct fasync_struct *fasync_readers; struct fasync_struct *fasync_writers; struct pipe_buffer *bufs; //管道缓冲区 struct user_struct *user; }; 管道是作为一组VFS对象来实现的,它们用特殊的文件系统pipefs来组织。在pipe系统调用发生时,首先会为pipefs分配一个索引节点对象,并且对其进行初始化;随后分别创建一个读文件对象,和一个写文件对象,并对其中的f_ops设置为定义好的操作表地址。随后分配一个dentry,用它把两个文件对象和索引节点对象连接在一起。随后这两个文件描述符就被返回给了用户态进程,于是乎两个进程就能够通过文件描述符来读写数据了。 命名管道FIFO管道是十分方便的一种结构,不过它存在一个缺点,那就是不能让任意两个进程共享一个管道。命名管道和管道一样,都是借助于文件系统实现的,它遵循“先进先出”的原则,同样依赖于一个内存缓冲区。不过与管道不同的地方在于,管道的索引节点在pipefs文件系统中,而命名管道的节点在系统的目录树里面,所以所有的进程都可以访问命名管道,而且能够用读/写方式来打开。命名管道首先需要通过mknod或mkfifo创建一个FIFO设备文件。一旦创建了FIFO文件,进程就可以用open、write、read等文件操作对FIFO进行操作。在进程使用系统调用open时,init_special_inode()会把FIFO相关的索引节点对象,进行初始化,并且把i_fop设置为定义好的表的地址,并执行fifo_open()。 IPCIPC是(Interprocess Communication)的缩写。它包含信号量、消息队列、共享内存三种方式。IPC在linux中被作为一种资源。semget()、msgget()、shmget()都以一个“关键字”作为参数,获得相应的IPC标识符,并且让进程能够通过标识符对资源进行访问。那么在不同的进程中,就可以通过同一关键字来访问IPC。每一类IPC都有一个ipc_ids数据结构进行管理,其数据结构如下: struct ipc_ids { int in_use; //已经分配的资源数 unsigned short seq; //下一个分配位置序号 struct rw_semaphore rwsem; struct idr ipcs_idr; //用基数树来保存,记录了所有IPC条目 int next_id; }; kern_ipc_perm对应一个IPC资源。ipc_addid()能够把一个kern_ipc_perm指针,添加到对应ipc_ids的基数树当中去。其结构如下,key是唯一的标识符。在对应的资源结构题中,都包含有一个kern_ipc_perm指针,它也是管理具体资源的关键所在。 struct kern_ipc_perm { spinlock_t lock; bool deleted; int id; key_t key; //IPC关键字 kuid_t uid; kgid_t gid; kuid_t cuid; kgid_t cgid; umode_t mode; unsigned long seq; //位置使用序号 void *security; } ____cacheline_aligned_in_smp; IPC信号量IPC信号量和内核中的信号量有相似之处,但实际上要比内核信号量复杂:首先IPC信号量可以包含多个信号量值,也就是保护多个独立的数据结构;其次IPC信号量还提供了失效的安全机制,用来处理进程意外死亡的情况。当然,它主要还是作为一种共享资源的访问控制手段,或者用于进程同步,不能传递大量的数据。其定义为sem_array。 struct sem_array { struct kern_ipc_perm sem_perm; //对应的kern_ipc_perm结构 time_t sem_ctime; /* last change time */ struct sem *sem_base; //第一个sem结构指针 struct list_head pending_alter; //阻塞替换数组的请求队列 /* that alter the array */ struct list_head pending_const; //阻塞没有替换数组的请求队列 /* that do not alter semvals */ struct list_head list_id; //用来取消信号量操作 int sem_nsems; //信号量的总数 int complex_count; /* pending complex operations */ unsigned int use_global_lock; }; 其中,struct sem的结构也很简单,除了信号量的值和上次操作的进程pid之外,它还有一个阻塞队列(在最新版本的linux中,这个队列被分成了两个) struct sem { int semval; //信号量的值 int sempid; //上次操作的pid spinlock_t lock; /* spinlock for fine-grained semtimedop */ struct list_head pending_alter; /* pending operations */ /* that alter the array */ struct list_head pending_const; /* pending complex operations */ /* that do not alter semvals */ time_t sem_otime; /* candidate for sem_otime */ } ____cacheline_aligned_in_smp; 具体的组织方式如图所示。每个IPC信号量,都分配了一个挂起请求队列,它标识等待数组中信号量的进程。这也是一个FIFO队列,新的挂起请求都被追加到链表的末尾。另一个值得注意的结构是list_id,它用来协助信号量的取消工作。它保存了某个进程对信号量所做的所有修改,如果说进程意外的退出了,那么就会把信号量的值恢复成正确的值。 IPC消息IPC消息保存在一个IPC消息队列中,直到某个进程把它读走为止。它是内核中的消息链表,用队列的形式进行发送和接收,可以保存格式化的数据,并且缓冲区大,读写顺序完全可控。 struct msg_queue { struct kern_ipc_perm q_perm; //对应的kern_ipc_perm结构 time_t q_stime; /* last msgsnd time */ time_t q_rtime; /* last msgrcv time */ time_t q_ctime; /* last change time */ unsigned long q_cbytes; //队列中的字节数 unsigned long q_qnum; //队列中的消息数 unsigned long q_qbytes; /* max number of bytes on queue */ pid_t q_lspid; /* pid of last msgsnd */ pid_t q_lrpid; /* last receive pid */ struct list_head q_messages; //消息链表 struct list_head q_receivers; //接收消息的进程链表 struct list_head q_senders; //发送消息的进程链表 }; 对于q_messages来说,每条消息都用一个struct msg_msg来表示: struct msg_msg { struct list_head m_list; long m_type; size_t m_ts; /* message text size */ struct msg_msgseg *next; void *security; /* the actual message follows immediately */ }; 真正的消息部分紧跟在msg_msg的内存区域之后: struct msgbuf { __kernel_long_t mtype; /* type of message */ char mtext[1]; /* message text */ }; 整个消息队列的工作机制如图所示。用户可以通过一个整数值来进行标识,这就允许进程有选择的从消息队列中获取消息。只要进程从消息队列中读消息,内核就会把读的消息删除。发送消息和接收消息分别使用msgsnd和msgrcv函数来完成。 =600x400 IPC共享内存共享内存是IPC里面,最快的一种方式。其本质就是一块物理内存同时映射到多个进程各自的进程空间当中。相应的,每个进程都要在自己的地址空间中,增加一个新的内存区,他映射与这个共享内存区相关的页框。共享内存区用shmid_kernel来表示: struct shmid_kernel /* private to the kernel */ { struct kern_ipc_perm shm_perm; //kern_ipc_perm数据结构 struct file *shm_file; //共享段的特殊文件 unsigned long shm_nattch; unsigned long shm_segsz; time_t shm_atim; time_t shm_dtim; time_t shm_ctim; pid_t shm_cprid; pid_t shm_lprid; struct user_struct *mlock_user; /* The task created the shm object. NULL if the task is dead. */ struct task_struct *shm_creator; struct list_head shm_clist; /* list by creator */ }; shmid_kernel中,一个很重要的域就是shm_file,因为共享段实际上是一个特殊的文件。我们知道在vm_area_struct当中,包含一个vm_file,也就是映射文件的域。不过shm文件系统并没有对应到目录树当中去,所以其操作只有mmap。对于共享内存映射来说,会通过address_space把页框包含在页高速缓存当中去。 SocketSocket与其它方法的不同之处在于,它能够用于不同机器之间的进程通信。它与网络相关,涉及到具体的协议;作为本机通信时,可以设置对应的参数为AF_UNIX或AF_INET。注意,这里有两种方法,第一种是依然利用网络socket,把地址设置为localhost,这样通信依然会经过网络协议栈;第二种是利用Donmain Socket,不需要经过打包拆包、计算校验等过程,其本质是创建一个socket类型的文件。在服务端:应用程序用系统调用socket创建一个套接字,并且用系统调用bind,将其绑定到IP地址和端口号上去,随后监听这个套接字,并且通过accept接收请求。在确定建立请求之后,通过send可以与客户端进行交互。 server_sockfd = socket(AF_INET, SOCK_STREAM, 0); server_addr.sin_family = AF_INET; //指定网络套接字 server_addr.sin_addr.s_addr = htonl(INADDR_ANY);//接受所有IP地址的连接 server_addr.sin_port = htons(9736); //绑定端口 bind(server_sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));//绑定套接字 listen(server_sockfd, 5);监听套接字,建立一个队列 while(1){ client_fd = accept(sockfd, (struct sockaddr*)&remote_addr, &sin_size)); //创建子进程处理连接 if(!fork()){ if(send(client_fd, "Hellp, you are connected!", 26, 0) == -1) ... } } 在客户端:同样用系统调用socket创建一个套接字,但是用connect函数来尝试建立连接。在连接之后,使用recv系统调用接收服务器的信息。 sockfd = socket(AF_INET, SOCK_STREAM,0)) == -1; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(SERVPORT); serv_addr.sin_addr = *((struct in_addr*)host->h_addr); bzero(&(serv_addr.sin_zero), 8); //面向连接的socket通信要用connect在客户端首先连接 if(connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(struct sockaddr)) == -1) { perror("connect 出错!"); exit(1); } //用于接收服务器的反馈信息 if((recvbytes = recv(sockfd, buf, MAXDATASIZE, 0)) == -1) { perror("recv出错!"); exit(1); } 内存映射&信号内存映射其实就是在两个进程中,同时映射一个文件,然后通过文件中的内容进行通信。信号也可以用于进程通信。之前的博文已经详细说明了,不再赘述。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解信号","slug":"深入理解信号","date":"2017-04-27T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/27/深入理解信号/","link":"","permalink":"http://sec-lbx.tk/2017/04/27/深入理解信号/","excerpt":"","text":"信号信号用来通知进程异步事件,可以把它理解为对中断的一种模拟。它是一个很小的消息,用来达到两个目的:(1)告知进程发生了一个特定的事件;(2)强迫进程执行自身所包含的信号处理程序。linux预先定义了一些常规信号,并为它们定义了一些缺省操作。除此之外,还有一类实时信号,它们需要排队进行处理,我们也可以自己定义信号和信号处理方式。既然信号是和进程相关的,那么task_struct中就必然包含有与信号相关的域了。 task_struct{ ... struct signal_struct *signal; //进程信号描述符 struct sighand_struct *sighand; //进程信号处理程序描述符 sigset_t blocked; //被阻塞信号掩码 sigset_t real_bloced; //被阻塞信号临时掩码 struct sigpending pending; //存放私有挂起信号 ... } 信号的产生信号是由内核函数产生的,它们完成信号处理的第一步,也即更新一个/多个进程的描述符。产生的信号并不直接传递,而是根据信号的类型、目标进程的状态唤醒进程,让它们来接收信号。内核提供了一组产生信号的函数,包括为进程、线程组产生信号等,但它们最终都会调用__send_signal()。当然,在调用__send_signal()之前,会检查这个信号是否应该被忽略(进程没有被跟踪、信号被阻塞,显示忽略信号) static int __send_signal(int sig, struct siginfo *info, struct task_struct *t, int group, int from_ancestor_ns) { struct sigpending *pending; struct sigqueue *q; int override_rlimit; int ret = 0, result; assert_spin_locked(&t->sighand->siglock); result = TRACE_SIGNAL_IGNORED; if (!prepare_signal(sig, t, from_ancestor_ns || (info == SEND_SIG_FORCED))) goto ret; //获取进程或线程组的私有挂起队列 pending = group ? &t->signal->shared_pending : &t->pending; //这个信号已经挂起了,忽略它 result = TRACE_SIGNAL_ALREADY_PENDING; if (legacy_queue(pending, sig)) goto ret; result = TRACE_SIGNAL_DELIVERED; //如果是kernel内部的某些强制信号,就立马执行 if (info == SEND_SIG_FORCED) goto out_set; //如果没有超过挂起信号的上限 if (sig < SIGRTMIN) override_rlimit = (is_si_special(info) || info->si_code >= 0); else override_rlimit = 0; //产生一个sigqueue对象,并把它加入到队列中去 q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE, override_rlimit); if (q) { list_add_tail(&q->list, &pending->list); switch ((unsigned long) info) { case (unsigned long) SEND_SIG_NOINFO: q->info.si_signo = sig; q->info.si_errno = 0; q->info.si_code = SI_USER; q->info.si_pid = task_tgid_nr_ns(current, task_active_pid_ns(t)); q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid()); break; case (unsigned long) SEND_SIG_PRIV: q->info.si_signo = sig; q->info.si_errno = 0; q->info.si_code = SI_KERNEL; q->info.si_pid = 0; q->info.si_uid = 0; break; default: copy_siginfo(&q->info, info); if (from_ancestor_ns) q->info.si_pid = 0; break; } //...... } 在信号产生之后,linux会调用signal_wake_up()通知进程,告知有新的挂起信号到来,如果当前进程占有了CPU,那么就可以立即执行;否则则要强制进行重新调度。 信号的传递在信号产生之后,如何确保挂起的信号被正确的处理呢?进程在信号产生时,可能并不在CPU上运行。在进程恢复用户态执行时,会进行检查,如果存在非阻塞的挂起信号,就调用do_signal(),这个函数会逐个助理挂起的非阻塞信号,而信号的处理则进一步调用handle_signal()。 handle_signal(struct ksignal *ksig, struct pt_regs *regs) { bool stepping, failed; struct fpu *fpu = &current->thread.fpu; //是否处于系统调用中 if (syscall_get_nr(current, regs) >= 0) { //系统调用被打断了,没有执行完,需要重新执行 switch (syscall_get_error(current, regs)) { case -ERESTART_RESTARTBLOCK: case -ERESTARTNOHAND: regs->ax = -EINTR; break; case -ERESTARTSYS: if (!(ksig->ka.sa.sa_flags & SA_RESTART)) { regs->ax = -EINTR; break; } /* fallthrough */ case -ERESTARTNOINTR: regs->ax = regs->orig_ax; regs->ip -= 2; break; } } //设置栈帧 failed = (setup_rt_frame(ksig, regs) < 0); if (!failed) { regs->flags &= ~(X86_EFLAGS_DF|X86_EFLAGS_RF|X86_EFLAGS_TF); /* * Ensure the signal handler starts with the new fpu state. */ if (fpu->fpstate_active) fpu__clear(fpu); } signal_setup_done(failed, ksig, stepping); } 这里存在一个问题:handle_signal()处于内核态中,但信号处理程序是在用户态定义的,因此这里存在着堆栈转换的问题。linux采用的方法是:把内核态堆栈中的硬件上下文,拷贝到当前进程的用户态堆栈中。而当信号处理程序完成时,会自动调用sigreturn()把硬件上下文拷贝回内核态堆栈中,并且恢复用户态堆栈中的内容。这里需要构造一个用户态栈帧: 首先内核需要把内核栈中的内容复制到用户态堆栈中去,把内核态堆栈的返回地址修改为信号处理程序的入口。注意,为了让信号处理程序结束时,能够清除栈上的内容,用户态堆栈还应该放入一个信号处理程序的返回地址,它指向__kernel_sigreturn(),把硬件上下文拷贝到内核态堆栈,然后把这个栈帧删除,随后从内核态返回到用户态继续执行。 信号的接口kill/tkill/kgill系统调用分别用来给某个进程、线程组发送信号。其中,kill(pid, sig)分别接受一个进程的pid号,以及一个所发送的信号。实时信号的发送则应该使用rt_sigqueueinfo()来进行发送。如果用户需要为信号指定一个操作,那么则应该使用sigaction(sig, &act, &oact)系统调用,act为指定的操作,而old_act用来记录以前的信号。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解进程地址空间","slug":"深入理解进程地址空间","date":"2017-04-25T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/25/深入理解进程地址空间/","link":"","permalink":"http://sec-lbx.tk/2017/04/25/深入理解进程地址空间/","excerpt":"","text":"线性区与内核中的内存分配不同,进程对内存的请求,被认为是不紧迫的。因此内核使用了一种新的资源,也就是线性区。应用程序申请动态内存时,并没有直接获得请求的页框,而是仅仅获得了一个新的线性区使用权。进程的地址空间,就是由所有的“线性区”来表示的,它的所有信息放在mm_struct当中(也就是我们在task_struct当中看到的),定义在mm_types.h当中。mm_struct包含了一个vm_area_struct *mmap,它也就是进程所拥有的所有线性区的链表。来看看vm_area_struct的结构: vm_area_struct{ struct mm_struct *vm_mm;//指向线性区所在的mm_struct unsigned long vm_start; //线性区的起始地址 unsigned long vm_end; //线性区的结束地址 struct vm_area_struct *vm_next, *vm_prev; pgprot_t vm_page_prot;//线性区的访问权限 unsigned long vm_flags;//标志位 struct{ struct rb_node rb; unsigned long rb_subtree_last; }shared; struct list_head anno_vma_chain; struct anon_vma *anon_vma; //以上均为链接到反映射所使用的数据结构 const struct vm_operations_struct *vm_ops; //处理这个结构体的函数指针 unsigned long vm_pgoff; struct file *vm_file; void *vm_private_data; //与映射文件、back store相关 } 线性区的组织方式如下。在内核中,查找包含制定线性地址的线性区是一个很频繁的操作,而进程的线性区可能有很多个,那么直接在链表上进行查找、插入会十分的麻烦。所以mm_struct当中有一个rb_root结构,它把所有的线性区组织了起来。我们知道,内存的管理是由page作为一个最小单元的,那么页和线性区是什么关系呢?每个线性区都是一组连续的页构成的。vm_flags中存放的启示就是和页相关的信息:比如这个页的读写权限,是否可以共享,是否映射一个可执行文件等。vm_page_prot会被用来初始化一个新的页表项的值。 线性区的查找、分配、释放假设给了一个虚拟地址addr,如何找到对应的vm_area_struct呢?这就需要在rb_root红黑树进行相应的搜索。find_vma函数就完成了这个工作,不过它会首先check之前最后访问的线性区,如果不在这个缓存中,再进行红黑树的查找。get_unmapped_area则会获取一个满足要求的空闲线性区。 线性区的分配调用了do_mmap函数(在内核符号表中也能查到)。实际上我们所调用的mmap最后都会走向这个函数。 unsigned long do_mmap(struct file *file, unsigned long addr, unsigned long len, unsigned long prot, unsigned long flags, vm_flags_t vm_flags, unsigned long pgoff, unsigned long *populate, struct list_head *uf) 如果指定一个file,则是把文件映射到内存中去,offset是偏移量;addr是希望从哪个地址开始查找一个空闲的区间。len则是线性区的长度。vm_flags指定了线性区的标志,而flags则是页的权限。具体的映射相关的工作由mmap_region函数来完成。 (1)参数的检查和设置;(2)获取新线性区的线性地址区间get_unmmaped_area;(3)检查是否超过了地址空间的限制;(4)如果有必要,会把旧的页映射关系给清除掉;(5)如果可以,调用vma_merge直接把原来的vma进行拓展;(6)调用kmem_cache_alloc()位新的线性区分配一个vma_area_struct();(7)调用vma_link()把新的线性区插入到线性区链表、红黑树中; 而释放线性地址区间是由do_munmap()来完成的。它可能会涉及把一个线性区拆分为两个较小区的操作,也即split_vma所完成的工作。 缺页异常的处理缺页异既可能是由进程地址空间还没有分配物理页框引起的,又可能是由变成错误所引起的异常。所以linux的缺页异常处理,必须能够对各种情况进行处理,而我们前面所说的vm_area_struct正方便了这个处理的过程。do_page_fault是缺页中断的服务程序,它把引起缺页的线性地址和当前进程的线性地址比较,并选择适当方法去处理这个异常。 缺页异常之所以复杂,是因为它涉及到内核态、用户态、中断等内容。do_page_fault接受两个参数:pt__regs *regs和unsigned long error_code。前者是异常发生时,寄存器的值;而error_code则说明了异常产生的状态:(1)访问了不存在的页(2)由读访问或执行访问引起(3)异常发生在内核态或用户态。这里,do_page_fault首先获取cr2寄存器的值,也即异常发生的地址,然后调用__do_page_fault。 static noinline void __do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address) { struct vm_area_struct *vma; struct task_struct *tsk; struct mm_struct *mm; int fault, major = 0; unsigned int flags = FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_KILLABLE; tsk = current; mm = tsk->mm; //mmio不应该发生缺页 if (unlikely(kmmio_fault(regs, address))) return; //缺页异常发生在内核态 if (unlikely(fault_in_kernel_space(address))) { if (!(error_code & (PF_RSVD | PF_USER | PF_PROT))) { //缺页是否发生在vmalloc区 //主要从内核页表向进程页表同步数据 if (vmalloc_fault(address) >= 0) return; if (kmemcheck_fault(regs, address, error_code)) return; } //由陈旧TLB造成的异常,TLB没有flush,就flush TLB if (spurious_fault(error_code, address)) return; return; } //这里是内核出现错误的情况 if (unlikely(smap_violation(error_code, regs))) { bad_area_nosemaphore(regs, error_code, address, NULL); return; } //异常处于用户态 //处于中断,没有用户上下文,属于错误情况 if (unlikely(faulthandler_disabled() || !mm)) { bad_area_nosemaphore(regs, error_code, address, NULL); return; } //开中断,因为cr2已经被保存了 if (user_mode(regs)) { local_irq_enable(); error_code |= PF_USER; flags |= FAULT_FLAG_USER; } else { if (regs->flags & X86_EFLAGS_IF) local_irq_enable(); } //读取产生异常的原因 if (error_code & PF_WRITE) flags |= FAULT_FLAG_WRITE; if (error_code & PF_INSTR) flags |= FAULT_FLAG_INSTRUCTION; if (unlikely(!down_read_trylock(&mm->mmap_sem))) { //异常发生在内核上下文,只能是异常表中预先定义好的异常 if ((error_code & PF_USER) == 0 && !search_exception_tables(regs->ip)) { bad_area_nosemaphore(regs, error_code, address, NULL); return; } retry: //如果在用户态、或者异常表中有对应的处理,说明不是内核异常 down_read(&mm->mmap_sem); } else { might_sleep(); } //在当前进程地址空间中,寻找发生异常的地址对应的VMA vma = find_vma(mm, address); //没有找到?说明是一个错误情况,要发出信号 if (unlikely(!vma)) { bad_area(regs, error_code, address); return; } //确认地址在有效的范围之内,是一个正常的缺页异常 if (likely(vma->vm_start <= address)) goto good_area; //异常不是堆栈区紧挨的区且没有VMA if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) { bad_area(regs, error_code, address); return; } if (error_code & PF_USER) { //超过了栈顶的范围 if (unlikely(address + 65536 + 32 * sizeof(unsigned long) < regs->sp)) { bad_area(regs, error_code, address); return; } } //需要拓展堆栈的情况 if (unlikely(expand_stack(vma, address))) bad_area(regs, error_code, address); return; } //终于,是一个正常的缺页异常,要进行调页 good_area: if (unlikely(access_error(error_code, vma))) { bad_area_access_error(regs, error_code, address, vma); return; } //分配物理内存,正常的处理函数 //1:请求调页 //2:COW //3:页在交换分区 fault = handle_mm_fault(vma, address, flags); major |= fault & VM_FAULT_MAJOR; //发送信号 up_read(&mm->mmap_sem); if (unlikely(fault & VM_FAULT_ERROR)) { mm_fault_error(regs, error_code, address, vma, fault); return; } //兼容环境的检查 check_v8086_mode(regs, address, tsk); } 可以看到,在处理正常的缺页异常之前,linux实际上已经做了很多检查了。handle_mm_fault()中,进一步调用了__hanle_mm_fault()。这个函数进行了一些页表的计算工作,然后把工作交给了handle_pte_fualt来处理。这是由于pte是最后一级页表项了,它的处理自然要特殊一些: static int handle_pte_fault(struct vm_fault *vmf) { pte_t entry; if (unlikely(pmd_none(*vmf->pmd))) { //暂时不填充pte,也许会申请大页 vmf->pte = NULL; } else { if (pmd_devmap_trans_unstable(vmf->pmd)) return 0; //设置pte vmf->pte = pte_offset_map(vmf->pmd, vmf->address); vmf->orig_pte = *vmf->pte; barrier(); if (pte_none(vmf->orig_pte)) { pte_unmap(vmf->pte); vmf->pte = NULL; } } //页表项不存在的情况 //如果是非匿名页,那么就要把文件映射的内容读入映射页 //如果是匿名页(堆栈),则分配全0的页 if (!vmf->pte) { if (vma_is_anonymous(vmf->vma)) return do_anonymous_page(vmf); else return do_fault(vmf); } //如果页不在内存中,但是页表项存在,说明这个页被换出了,现在应被换入 if (!pte_present(vmf->orig_pte)) return do_swap_page(vmf); vmf->ptl = pte_lockptr(vmf->vma->vm_mm, vmf->pmd); spin_lock(vmf->ptl); entry = vmf->orig_pte; if (unlikely(!pte_same(*vmf->pte, entry))) goto unlock; //写时复制的情况,这时要把页标识为脏页 //如果有多个进程拥有这个页,那么写时复制就是有必要的 //此时分配新的页框,并把内容复制到新的页框中去 if (vmf->flags & FAULT_FLAG_WRITE) { if (!pte_write(entry)) return do_wp_page(vmf); entry = pte_mkdirty(entry); } entry = pte_mkyoung(entry); if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry, vmf->flags & FAULT_FLAG_WRITE)) { update_mmu_cache(vmf->vma, vmf->address, vmf->pte); } else { if (vmf->flags & FAULT_FLAG_WRITE) flush_tlb_fix_spurious_fault(vmf->vma, vmf->address); } unlock: pte_unmap_unlock(vmf->pte, vmf->ptl); return 0; }","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解内存管理","slug":"深入理解内存管理","date":"2017-04-20T03:40:00.000Z","updated":"2017-08-07T08:44:23.000Z","comments":true,"path":"2017/04/20/深入理解内存管理/","link":"","permalink":"http://sec-lbx.tk/2017/04/20/深入理解内存管理/","excerpt":"","text":"页框管理与伙伴系统这里的内存管理,指的是内核如何分配(为自己)动态内存。linux把页框作为一个管理的基本单位,用数据结构page对其进行描述。而所有的page则放在一个mem_map数组当中,进行管理。但计算机存在着一些限制,因此linux把内存划分为了几个管理区,包括ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM等;而对页框的分配和释放,也是按照分区来进行管理的:在每个分区之内,页框由伙伴系统来进行处理。伙伴系统主要是为了解决“外碎片”的问题:当请求和释放不断发生的时候,就很有可能导致操作系统中发生存在空闲的小块页框,但是没有大块连续页框的问题。伙伴系统把空闲页分组成11个块链表,分别包含1,2,4,6,…,1024个连续的页框。每当有两个连续的大小为b的页框出现时(并且起始地址满足一个倍数条件),它们就被视为伙伴,伙伴系统就会把它们合并成大小为2b的页框。在页分配时,如果当前大小b的free_list中找不到空闲的页框,就会从2b的链表中寻找空闲页块,并且进行分割,将它分为两个大小为b的页块。每个伙伴系统,管理的是mem_map的一个子集。在管理区描述符中,有一个struct free_area,它用来辅助伙伴系统: struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; }; free_list是用来连接空闲页的链表数组,而nr_free则是当前内存区中空闲页块的个数。 反碎片当然,上面说到的只是最基本的伙伴系统,但它并没有完全解决碎片的问题。linux中还采用了一种反碎片的机制,它根据已内存页的类型来工作:(1)不可移动页:在内存中有固定的位置,不能移动到其他地方(kernel的大多数内存页)(2)可移动页:用户空间的页,只要更新页表项即可(3)可回收页:在内存缺少时,可以进行回收的页,例如从文件映射的页(以及一些其他类型)如果根据页的可移动性,将其进行分组,避免可回收页和不可回收页的交叉组织(例如在可移动页中间有不可移动页),并且在某个类型的页分配失败时,会从备用列表中寻找相应的页,这个顺序定义在page_alloc.c当中。 内存分配方法分配内存通常可以调用一下几个函数:alloc_pages/alloc_page:分配若干个页,返回第一个struct pageget_zeroed_page:分配一个struct page,并且将内存填0get_free_pages/get_free_page:返回值是虚拟地址get_dma_pages:分配一个适用于DMA的页还有一些基于伙伴系统的方法,它们可能会借助页表进行映射,例如vmalloc,kmalloc。内存分配时,通常要指定一个掩码gfp_mask,它定义了页所位于的区域、页在I/O和vfs上的操作,以及对分配操作的规定(阻塞、I/O、文件系统等)。 释放不再使用的页,同样可以采用struct page或者虚拟地址作为参数:free_page/free_pages:以struct page为参数__free_page/__free_pages:以虚拟地址为参数 页框高速缓存(为了避免混淆,我把所有硬件的高速缓存称为cache)内核经常会请求、释放单个页框,为了提高系统的性能,每个内存管理区都有一个每CPU的页框高速缓存,它包含一些预先分配的页框,能够用来满足CPU发出的单个页框请求。注意,这个页框高速缓存,和硬件上的cache的概念不同,但它们有一点小小的关联。由于每个CPU有自己的cache,那么假设一个进程刚刚释放了一个页,那么这个页就有很大概率还在cache当中。页框高速缓存保存热页(刚释放的,很可能在cache当中的页)和冷页(释放时间比较长的页)。其实对于分配热页来说,很好理解:用在cache中的页可以减少开销;但如果说是DMA设备使用,就要分配冷页了,因为它不会用到cache。 slab分配器前面所说的伙伴系统,是用“页”为单位来进行,显然太大了;所以需要把页进一步拆分,变成更小的单位。slab分配器不仅仅提供小内存块,它还作为一个缓存使用,主要是针对那些经常分配、释放的对象:例如内核中的fs_struct数据结构,可能经常会分配和释放;那么slab就将释放的内存块保存在一个列表里面,而不是返回给伙伴系统。这样一来,再次分配新的内存块时,就不需要经过伙伴系统了,而且这些内存块还很可能在cache里面。slab分配器包含几个部分:高速缓存kmem_cache,slab,以及slab中所包含的对象。每个高速缓存只负责一种对象类型,它由多个slab构成。kmem_cache当中有三个slab链表,分别对应用尽的slab、部分空闲的slab,和空闲的slab,还有一个array_cache *数组,它保存cpu最后释放的那些很可能处于“热”状态的对象。而对于每个slab,则组织了一系列的object;它包含了空闲对象,正在使用的对象。那么为什么不直接用kmem_cache管理对象,要增加出slab这一层呢?这明显是为了更好的管理内存:通过slab,可以让内存的使用更平均,或者能够更好的管理空闲的页。在新版本的内核中,slab由kmem_cache_node来管理,它包含3个链表slabs_partial,slabs_full和slabs_free。每个slab是一个或多个连续页帧的集合,每个objects由链表串联,现在slab中的object直接由page中的freelist来管理了。 struct kmem_cache_node { spinlock_t list_lock; #ifdef CONFIG_SLAB struct list_head slabs_partial; /* partial list first, better asm code */ struct list_head slabs_full; struct list_head slabs_free; unsigned long free_objects; unsigned int free_limit; unsigned int colour_next; /* Per-node cache coloring */ struct array_cache *shared; /* shared per node */ struct alien_cache **alien; /* on other nodes */ unsigned long next_reap; /* updated without locking */ int free_touched; /* updated without locking */ #endif #ifdef CONFIG_SLUB unsigned long nr_partial; struct list_head partial; #ifdef CONFIG_SLUB_DEBUG atomic_long_t nr_slabs; atomic_long_t total_objects; struct list_head full; #endif #endif }; 值得一提的是,kmalloc的实现也是也是基于slab来实现的,它包含一个数组,存放了一些用于不同长度的slab缓存,这也就是我们所说的“内存池”。 slab着色slab着色与颜色并没有关系,它要解决的问题与硬件高速缓存有关。硬件高速缓存倾向于把大小一样的对象放在高速缓存内的相同偏移位置;而不同slab当中相同偏移量的对象,就会映射在高速缓存的同一行当中;这样高速缓存可能就会频繁的对同一高速缓存行进行更新,从而造成性能损失。slab着色就是给每个slab分配一个随机的“颜色”,把它作为slab中对象需要移动的特定偏移量来使用,这样对象就会被放置到不同的缓存行。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解进程与调度","slug":"深入理解进程与调度","date":"2017-04-15T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/15/深入理解进程与调度/","link":"","permalink":"http://sec-lbx.tk/2017/04/15/深入理解进程与调度/","excerpt":"","text":"进程、线程、轻量级进程进程是程序执行的一个实例,也是操作系统分配资源(CPU、内存)的对象,而线程是CPU执行和调度的最小单位。轻量级进程是linux中实现线程的方式,其本质也是一个进程,它与一个内核线程相关联,因此可以像普通程序一样被调度,但它只有最小的执行和调度信息(与普通程序比很“轻量”)。在linux内核中,并没有线程的概念,每一个执行的实体,都是用linux的PCB: task_struct来表示,它包含了进程的所有信息。进程的状态、内存、文件系统等信息,都放在这个结构体当中。 进程创建在linux当中,进程的创建都是通过子进程的方式来实现的。当然,并不是每个子进程都需要拷贝父进程的所有资源,因此linux也提供了三种不同的机制,来实现进程创建的问题:(1)写时复制技术,允许父子进程读相同的物理页,只要两者中有一个试图写一个物理页时,就把这个页的内容拷贝到一个新的物理页中去,并分配给正在写的进程。(2)轻量级进程允许父子进程共享内核中的页表、打开文件表等信息。(3)vfork允许子进程共享副进程的内存地址空间,并通过阻塞父进程的方式防止数据被父进程修改。linux中,通过这几个系统调用来完成进程的创建:fork、vfork、clone。那么为什么它们有什么区别呢?sys_fork:创造的子进程是父进程的完整副本,复制所有的内容,运用了写时复制技术;sys_vfork:创造的进程和副进程共享内存地址空间,并且子进程先于副进程运行;sys_clone:创建线程,pthread库会间接地调用它; 进程调度进程调度,首先要对进程对状态有一个基本的了解。task_struct当中,state标识了一个进程的运行状态。 linux中的进程调度,采用的是一种多级反馈队列的算法。首先,在linux中,进程可以被分为三类:(1)需要经常跟用户交互的交互式进程;(2)不必与用户交互,经常在后台运行的批处理进程;(3)有很强调度需要的实时进程。根据进程类型的不同,linux用多级队列的形式,去组织待调度的程序,并且动态地调整进程的优先级。进程的调度按照这样一个优先级进行:SCHED_FIFO,SCHED_RR,SHED_NORMAL。那么进程的优先级是如何表示的呢?每个进程都有一个静态优先级,和一个动态优先级。静态优先级决定了进程的基本时间片;而动态优先级是调度新进程来运行时,所使用的数,它会根据进程的睡眠时间来进行改变。除此之外linux还用它来判断一个进程是交互式进程,还是批处理进程。在linux当中,所有处于TASK_RUNNING状态(运行,或者就绪)的进程,会被放在一个rq(运行队列runqueue)中,它是一个每CPU变量。 调度过程进程的调度,是通过时钟来触发的。时钟会调用一个函数:scheduler_tick()。这个函数会通过调用,curr->sched_class->task_tick,也即根据当前进程的调度器类型,进行调度:如果是实时的进程,那么根据它调度的类型为SCHED_RR/SCHED_FIFO进行处理;如果是公平队列中的进程,就根据动态优先级和时间片的情况进行调度。schedule()函数完成具体的调度过程。这个函数可以在进程不能获得必需的资源时直接调用,也可以在进程用完时间片,或者被抢占时延迟调用。如果说要唤醒一个睡眠或停止的进程,则会调用try_to_wake_up()函数,它把进程状态设置为TASK_RUNNING,并把进程插入rq。 进程切换在schedule()进行调度时,会发生进程的切换。该过程包括两部分:切换页全局目录和切换内核态堆栈与硬件上下文。这里,我们先关注第二部分:在进程切换时,内核态的堆栈和硬件上下文发生了什么。这个工作由switch_to()宏来完成,它是一段汇编代码,并且接受3个参数:prev,next和last,它们都是task_struct。这里last是一个输出的变量,它用来保存切换之前的进程task_struct。这时因为在切换之后,ebp的值变了,所以prev和next都变成新的进程中的值了,这时就要把prev给修改掉,因此schedule()中调用的实际上是: switch_to(prev, next, prev); 这里,把next中的栈地址装入rsp,就完成了切换。因为内核在next的内核栈上开始了操作。进程切换的一部分就是内核栈的切换;随后,内核跳转到__switch_to()函数继续执行。这个函数从完成保存和加载FPU、MMX、XMM、段寄存器(以及硬件上下文的切换),将TLS装入GDT表等一系列任务,最后返回schedule()中继续执行;在此之后进程还要修改rq的内容。 补充:僵死进程/守护进程僵死进程:在进程退出时,它并没有真正的被销毁,其进程描述符还在内核中;必须由父进程调用wait()或者waitpid()系统调用,来为它收尸。如果说父进程没有做这件事情,那么进程描述符就会一直在内核中,他就是一个僵死进程。守护进程:linux系统中常见的后台服务进程,它和任何终端无关,脱离了终端的控制;它也是一个常见的孤儿进程,其父进程是init。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解I/O体系结构(二)","slug":"深入理解I:O体系结构(二)","date":"2017-04-11T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/11/深入理解I:O体系结构(二)/","link":"","permalink":"http://sec-lbx.tk/2017/04/11/深入理解I:O体系结构(二)/","excerpt":"","text":"块设备的驱动和字符设备类似,操作系统中的块设备,也是以文件的形式来访问。这里有一个很拗口的问题:磁盘是一个块设备,块设备有一个块设备文件。那么访问块设备文件和访问普通的磁盘上的文件有什么关系呢?不论是块设备文件还是普通的文件,它们都是通过VFS来统一访问的。只不过对于一个普通文件,它可能已经在RAM中了(高速缓存机制),因此它的访问可能会直接在RAM中进行;但如果说要修改磁盘上的内容,或者文件内容不在RAM中,则也会间接地,通过块设备文件进行访问。这个驱动模型可以用这样一个图表示:这里我们只考虑最底层的情况:内核从块设备读取数据。为了从块设备中读取数据,内核必须知道数据的物理位置,而这正是映射层的工作。映射层的工作包括两步:(1)根据文件所在文件系统的块,将文件拆分成块,然后内核能够确定请求数据所在的块号;(2)映射层调用文件系统具体的函数,找到数据在磁盘上的位置,也就是完成文件块号,到逻辑块号的映射关系。随后的工作在通用块层进行,内核在这一层,启动I/O操作。通常一个I/O操作对应一组连续的块,我们把它称为bio,它用来搜集底层需要的信息。I/O调度层负责根据内核中的各种策略,把待处理的I/O数据传送请求,进行归类。它的作用是把物理介质上相邻的数据请求,进行合并,一并处理。最后一层也就是通过块设备的驱动来完成了,它向I/O接口发送适当的命令,从而进行实际的数据传送。 通用块层通用块层负责处理所有块设备的请求,其核心数据结构就是bio。它代表一次块设备I/O请求。 struct bio { struct bio *bi_next; //请求队列中的下一个bio struct block_device *bi_bdev; //块设备描述符指针 unsigned long bi_flags; /* status, command, etc */ unsigned long bi_rw; //rw位 struct bvec_iter bi_iter; unsigned int bi_phys_segments;//合并后有多少个段 unsigned int bi_seg_front_size; unsigned int bi_seg_back_size; atomic_t bi_remaining;//剩余的bio_vec bio_end_io_t *bi_end_io;//bio结束的回调函数 void *bi_private; unsigned short bi_vcnt; //bio中biovec的数量 unsigned short bi_max_vecs;//最多能有多少个 atomic_t bi_cnt; //结构体的使用计数 struct bio_vec *bi_io_vec; //bio_vec数组 }; 在这个数据结构中,还包含了一个bio_vec。这是什么意思呢?在linux中,相邻数据块被称为一个段,每个bio_vec对应一个内存页中的段。在io操作期间,bio是会一直更新的,其中的bi_iter用来在数组中遍历,按每个段来执行下一步的操作。 那么当通用块层收到一个I/O请求操作时,会发生什么呢?首先内核会为这次操作分配bio描述符,并对它进行填充。随后通用块层会调用generic_make_request,这个函数的作用很明确:它会进行一系列检查和设置,保证bio中的信息是针对整个磁盘,而不是磁盘分区的;随后获取这个块设备相关的请求队列q,调用q->make_request_fn,把bio插入请求队列中去。 I/O调度层在块设备上,每个I/O请求操作都是异步处理的,通用块层的请求会被加入块设备的请求队列中,每个块设备都会单独地进行I/O的调度,这样能够有效提高磁盘的性能。前面提到,通用块层会调用一个q->make_request_fn,向I/O调度程序发送一个请求,该函数会进一步调用__make_request()。这个函数的目的,就是把bio放进请求队列当中:(1)如果请求队列是空的,就构造一个新的请求插入;(2)如果请求队列不是空的,但是bio不能合并(不能合并到某个请求的头和尾),也构造一个新的请求插入;(3)请求队列不是空的,并且bio可以合并,就合并到对应的请求中去。注意,bio,请求和请求队列的关系如下: -- request_queue |-- request1 |-- bio0 |-- request2 |-- bio1 |-- bio2 而I/O的调度,就是对请求队列进行排序,针对磁盘的特点,降低寻道的次数。这里说说几个常见的算法:(1)CFQ完全公平队列:默认的调度算法,完全公平排队。每个进程/线程都单独创建一个队列,并且用上面提到的策略进行管理。队列间采用时间片的方式来分配I/O。(2)Deadline最后期限算法:在电梯调度的基础上,根据读写请求的“最后期限”进行排序,并通过读期限短于写期限来保证写操作不被饿死。(3)预期I/O算法:与最后期限类似,但是在读操作时,会预先判断当前的进程是否马上会有读操作,并且优先地进行处理。(4)NOOP:适用于固态硬盘,不进行任何优化。 总而言之,I/O调度层的作用,就是把请求的队列重新排序,并逐个交给块设备驱动程序进行处理。 块设备驱动程序I/O调度层排序好的请求,会由块设备的驱动程序来处理。同样,块设备也遵循着我们前面提到的驱动程序模型:块设备对应一个device,而驱动程序对应了一个device_driver。对于块设备来说,驱动程序也要通过register_blkdev()注册一个设备号。随后,驱动程序要初始化gendisk描述符,以及它所包含的设备操作表fops。在此之后,是“请求队列”的初始化,以及中断程序的设置:要为设备注册IRQ线。最后要把磁盘注册到内核(add_disk),并把它激活。当一个块设备文件被open()时,内核同样也要为它初始化操作。对于块设备来说,其默认的文件操作如下: const struct file_operations def_blk_fops = { .open = blkdev_open, .release = blkdev_close, .llseek = block_llseek, .read = new_sync_read, .write = new_sync_write, .read_iter = blkdev_read_iter, .write_iter = blkdev_write_iter, .mmap = generic_file_mmap, .fsync = blkdev_fsync, .unlocked_ioctl = block_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = compat_blkdev_ioctl, #endif .splice_read = generic_file_splice_read, .splice_write = iter_file_splice_write, }; dentry_open()方法会调用blkdev_open()。它(1)首先会获取块设备的描述符:如果块设备已经打开,则可以通过inode->i_bdev直接获取,否则则需要根据设备号去查找块设备描述符。(2)获取块设备相关的gendisk地址,get_gendisk是通过设备号来找到gendisk的。(3)如果是第一次打开块设备,则要根据它是整盘还是分区,进行相应的设置和初始化。(4)如果不是第一次打开,只需要按需要执行自定义的open()函数就行了。 补充:I/O的监控方式(1)轮询:CPU重复检查设备的状态寄存器,直到寄存器的值表明I/O操作已经完成了。(2)中断:设备发出中断信号,告知I/O操作已经完成了,数据放在对应的端口,当数据缓冲满了时,由CPU去取,CPU需要控制数据传输的过程。(3)DMA:由CPU的DMA电路来辅助数据的传输,CPU不需要参与内存和IO之间的传输过程,只需要通过DMA的中断来获取信息。DMA能够在所有数据处理完时才通知CPU处理。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解I/O体系结构(一)","slug":"深入理解I:O体系结构(一)","date":"2017-04-10T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/10/深入理解I:O体系结构(一)/","link":"","permalink":"http://sec-lbx.tk/2017/04/10/深入理解I:O体系结构(一)/","excerpt":"","text":"I/O体系结构虚拟文件系统利用底层函数,调用每个设备的操作,那么这些操作是如何在设备上执行的,操作系统又是如何知道设备的操作是什么的呢?这些是由操作系统决定的。我们知道,操作系统的工作,是依赖于数据通路的,它们让信息得以在CPU、RAM、I/O设备之间传递。这些数据通路称为总线。这就包括数据总线(PCI、ISA、EISA、SCSI等)、地址总线、控制总线。I/O总线,指的就是用于CPU和I/O设备之间通信的数据总线。I/O体系的通用结构如图所示: 那么CPU是如何通过I/O总线和I/O设备交互呢?这首先得从内存和外设的编址方式说起。第一种是“独立编址”,也就是内存和外设分开编址,I/O端口有独立的地址空间,这也被称为I/O映射方式。每个连接到I/O总线上的设备,都分配了自己的I/O地址集(在I/O地址空间中),它被称为I/O端口。in、out等指令用语CPU对I/O端口进行读写。在执行其中一条指令时,CPU使用地址总线选择所请求的I/O端口,使用数据总线在CPU寄存器和端口之间传送数据。这种方式编码逻辑清晰,速度快,但空间有限。第二种是“统一编址”,也被称为内存映射方式,I/O端口还可以被映射到内存地址空间(这也正是现代硬件设备倾向于使用的方式),这样CPU就可以通过对内存操作的指令,来访问I/O设备,并且和DMA结合起来。这种方式更加统一,易于使用。它实际上使用了ioremap()。自从PCI总线出现后,不论采用I/O映射还是内存映射方式,都需要将I/O端口映射到内存地址空间。 每个I/O设备的I/O端口都是一组寄存器:控制寄存器、状态寄存器、输入寄存器和输出寄存器。内核会纪录分配给每个硬件设备的I/O端口。 设备驱动程序模型在内核中,设备不仅仅需要完成相应的操作,还要对其电源管理、资源分配、生命周期等等行为进行统一的管理。因此,内核建立了一个统一的设备模型,提取设备操作的共同属性,进行抽象,并且为添加设备、安装驱动提供统一的接口。它们本身并不代表具体的对象,只是用来维持对象间的层次关系。这里首先要提的是sysfs文件系统。和/proc类似,安装于/sys目录,其目的是表现出设备驱动程序模型间的层次关系。在驱动程序模型当中,有三种重要的数据结构(旧版本),自上到下分别是subsystem、kset、kobject。如果要理解这个模型中,每个数据结构的作用,就必须理解它们和操作系统中的什么东西相对应。它们均对应着/sys中的目录。kobject是这个对象模型中,所有对象的基类。kset本身首先是一个kobject,而它又承担着一个kobject容器的作用,它把kobject组织成有序的目录;subsys则是更高的一层抽象,它本身首先是一个kset。驱动、总线、设备都能够用设备驱动程序模型中的对象表示。 设备驱动程序模型中的组件设备驱动程序模型建立在几个基本数据结构之上,它们描述了总线、设备、设备驱动器等等。这里,我们来看看它们的数据结构。首先,device用来描述设备驱动程序模型中的设备。 struct device { struct device *parent;//父设备 struct kobject kobj; //对应的kobject const char *init_name; //初始化名 const struct device_type *type;//设备的类型 struct mutex mutex; //驱动的互斥量 struct bus_type *bus; //设备在什么类型的总线 struct device_driver *driver; //设备的驱动 void *driver_data; //驱动私有数据指针 struct dev_pm_info power; struct dev_pm_domain *pm_domain; //dma相关变量 u64 *dma_mask; u64 coherent_dma_mask; unsigned long dma_pfn_offset; struct device_dma_parameters *dma_parms; struct list_head dma_pools; struct dma_coherent_mem *dma_mem; dev_t devt; //dev目录下的描述符 u32 id; spinlock_t devres_lock; struct list_head devres_head; struct klist_node knode_class; struct class *class; //类 void (*release)(struct device *dev);//释放设备描述符时候的回调函数 }; 首先,可以看到device中包含有一个kobject,还包含有它相关驱动对象。所有的device对象,全部收集在devices_kset中,它对应着/sys/devices中。设备的引用计数则是由kobject来完成的。device还会被嵌入到一个更大的描述符中,例如pci_dev,它除了包含dev之外,还有PCI所特有的一些数据结构。device_add完成了新的device的添加工作。我注意到,error = bus_add_device(dev);,也就是说device的添加会把它和bus关联起来。 再来看看驱动程序的结构。其数据结构为device_driver。相对于设备的数据结构来说,它相对较为简单:对于每个设备驱动,都有几个通用的方法,分别用语处理热插拔、即插即用、电源管理、探查设备等。同样,驱动也会被嵌入到一个更大的描述符中,例如pci_driver。 struct device_driver { const char *name; //驱动名 struct bus_type *bus; //总线描述符 struct module *owner; const char *mod_name; //模块名 bool suppress_bind_attrs; /* disables bind/unbind via sysfs */ const struct of_device_id *of_match_table; const struct acpi_device_id *acpi_match_table; int (*probe) (struct device *dev); //探测设备 int (*remove) (struct device *dev); //移除设备 void (*shutdown) (struct device *dev); //断电方法 int (*suspend) (struct device *dev, pm_message_t state);//低功率 int (*resume) (struct device *dev); //恢复方法 const struct attribute_group **groups; const struct dev_pm_ops *pm; //电源管理的操作 struct driver_private *p; }; 为什么这里没有kobject呢?它实际上保存在了driver_private当中,这个结构和device_driver是双向链接的。 struct driver_private { struct kobject kobj; struct klist klist_devices; struct klist_node knode_bus; struct module_kobject *mkobj; struct device_driver *driver; }; driver的添加,通过调用driver_register()来完成,它同样包含一个函数:bus_add_driver(),也就是将driver添加到某个bus。 再来看看总线的结构。bus是连接device和driver的桥梁,bus中的很多代码,都是为了让device找到driver来设计的。总线的数据结构如下: struct bus_type { const char *name; const char *dev_name; struct device *dev_root; struct device_attribute *dev_attrs; /* use dev_groups instead */ const struct attribute_group **bus_groups; const struct attribute_group **dev_groups; const struct attribute_group **drv_groups; //检查驱动是否支持特定设备 int (*match)(struct device *dev, struct device_driver *drv); //回调事件,在kobject状态改变时调用 int (*uevent)(struct device *dev, struct kobj_uevent_env *env); //探测设备 int (*probe)(struct device *dev); //从总线移除设备 int (*remove)(struct device *dev); //掉电 void (*shutdown)(struct device *dev); int (*online)(struct device *dev); int (*offline)(struct device *dev); //改变电源状态和恢复 int (*suspend)(struct device *dev, pm_message_t state); int (*resume)(struct device *dev); const struct dev_pm_ops *pm; const struct iommu_ops *iommu_ops; struct subsys_private *p; struct lock_class_key lock_key; }; 同样,总线也有一个subsys_private,它保存了kobject。but_type中定义了一系列的方法。例如,当内核检查一个给定的设备是否可以由给定的驱动程序处理时,就会执行match方法。可以用bus_for_each_drv()和bus_for_each_dev()函数分别循环扫描drivers和device两个链表中的所有元素,来进行match。 设备文件设备驱动程序使得硬件设备,能以特定方式,响应控制设备的编程接口(一组规范的VFS函数,open,read,lseek,ioctl等),这些函数都是由驱动程序来具体实现的。在设备文件上发出的系统调用,都会由内核转化为对应的设备驱动程序函数,因此设备驱动必须被注册,也即构造一个device_driver,并且加入到设备驱动程序模型中。在注册时,内核会试图进行一次match。注意,这个注册的过程基本driver_register通常不会在驱动中直接调用,但我们但驱动通常都会间接的调用它来完成注册。遵循linux“一切皆文件”的原则,I/O设备同样可以当作设备文件来处理,它和磁盘上的普通文件的交互方式一样,例如都可以通过write()系统调用写入数据。设备文件可以通过mknod()节点来创建,它们保存在/dev/目录下。linux当中,硬件设备可以花费为两种:字符设备和块设备。其中,块设备指的是可以随机访问的设备,例如硬盘、软盘等;而字符设备则指的是声卡、键盘这样的设备。设备文件同样在VFS当中,但它的索引节点没有指向磁盘数据的指针,相反地它对应一个标识符(包含一个主设备号和一个次设备号)。VFS会在设备文件打开时,改变一个设备文件的缺省文件操作,让它去调用和设备相关的操作。 字符设备驱动程序这里我们以字符设备驱动程序为例。首先,字符设备的驱动,在linux系统中,是以cdev结构来表示的: struct cdev { struct kobject kobj; struct module *owner; const struct file_operations *ops; struct list_head list; //包括的inode的devices dev_t dev; unsigned int count; }; 现在让我们回顾一下inode的数据结构: struct inode { ... union { struct pipe_inode_info *i_pipe; struct block_device *i_bdev; struct cdev *i_cdev; }; ... } 我们看到了cdev指针的影子,可见cdev和inode确实是直接相关的。要实现驱动,首先就要对cdev进行初始化,注册字符设备。驱动的安装,首先要分配cdev结构体、申请设备号并初始化cdev。注意,驱动程序是如何和刚才我们所说的设备驱动模型建立联系的呢?实际上在初始化cdev的时候,就调用了kobject_init(),在模型中添加了一个kobject。随后,驱动要注册cdev,也即调用cdev_add()函数。这个工作主要是由kobj_map()来实现的,它是一个数组。对于每一类设备,都有一个全局变量,例如字符设备的cdev_map,块设备的bdev_map。最后要进行硬件资源的初始化。 int cdev_add(struct cdev *p, dev_t dev, unsigned count) { int error; p->dev = dev; p->count = count; error = kobj_map(cdev_map, dev, count, NULL, exact_match, exact_lock, p); if (error) return error; kobject_get(p->kobj.parent); return 0; } kobj_map的结构如下,它用来保存设备号和kobject的对应关系 struct kobj_map { struct probe { struct probe *next; dev_t dev; unsigned long range; struct module *owner; kobj_probe_t *get; int (*lock)(dev_t, void *); void *data; } *probes[255]; struct mutex *lock; }; 不过到现在为止,我们都还没有说明,程序在访问字符设备时,是如何去调用正确的方法的。我们曾提到过,open()系统调用会改变字符文件对象的f_op字段,将默认文件操作替换为驱动的操作。在字符设备文件创建时,会调用init_special_inode来进行索引节点对象的初始化。其inode的操作(def_chr_fops)只包含一个默认的文件打开操作,也即chrdev_open。它会根据inode,首先利用cdev_map,找到对应的kobject,随后再进一步找到cdev,然后从中提取出文件操作的函数fops,并把它填充到file当中去。 static int chrdev_open(struct inode *inode, struct file *filp) { const struct file_operations *fops; struct cdev *p; struct cdev *new = NULL; int ret = 0; spin_lock(&cdev_lock); p = inode->i_cdev; if (!p) { struct kobject *kobj; int idx; spin_unlock(&cdev_lock); kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);//获取对应的kobject if (!kobj) return -ENXIO; new = container_of(kobj, struct cdev, kobj); spin_lock(&cdev_lock); /* Check i_cdev again in case somebody beat us to it while we dropped the lock. */ p = inode->i_cdev; if (!p) { inode->i_cdev = p = new; list_add(&inode->i_devices, &p->list);//将device加入到cdev的list中去 new = NULL; } else if (!cdev_get(p)) ret = -ENXIO; } else if (!cdev_get(p)) ret = -ENXIO; spin_unlock(&cdev_lock); cdev_put(new); if (ret) return ret; ret = -ENXIO; fops = fops_get(p->ops) if (!fops) goto out_cdev_put; replace_fops(filp, fops);//替换file当中的fops return ret; } 这里很奇怪的是,我们并没有看到类似前面提到的driver_register()、device_register()这样的函数。实际上这里并没有真正创建一个设备,而只是说创建了一个接口,所以有这样一个这个问题:为什么cdev_add没有产生设备节点?对于这个问题,我们应该理解为cdev和driver/device二者是配套工作的,cdev用来和用户交互,而device则是内核中的结构。另一个问题是,在上面的过程中,似乎没有提及设备文件的创建。实际上,作为一个rookie,那么设备文件常常是用mknod命令手动创建的。当然,linux自然也提供了自动创建的借口,那就是利用udev来实现,调用device_create()函数。当然,这个例子只是为了说明,操作系统的驱动程序是如何工作的,为什么对I/O设备的操作可以抽象成对设备文件的操作,程序在操作I/O文件时,是如何使用正确的操作的。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"IDAPython:入门","slug":"IDAPython","date":"2017-04-05T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/05/IDAPython/","link":"","permalink":"http://sec-lbx.tk/2017/04/05/IDAPython/","excerpt":"","text":"取地址ScreenEA()和here()是最常用的取地址函数,它们会返回一个整数值。MaxEA()和MinxEA()可以用来取最大和最小地址。GetDisasm() GetMnem() GetOpand() 可以用来取某个地址中的指令、操作符、操作数等。 遍历:Segments,Functions,and InstructionsIDAPython的强大之处在于迭代。从Segments()开始是一个好的选择。这段代码能够遍历所有的segments。 for seg in idautils.Segments(): print idc.SegName(seg), idc.SegStart(seg), idc.SegEnd(seg) 同理的,对于Functions的遍历,可以有: for func in idautils.Functions(): print hex(func), idc.GetFunctionName(func) 这里,Functions()是可以添加范围的,例如Functions(start_addr, end_addr),而get_func()返回的函数,则还具有startEA和endEA属性,也即起起始和结束地址。这里,get_func()实际上是返回了一个类。dir(class)能够返回这个类中的成员。idc.NextFunction(ea)和idc.PrevFunction(ea)能够用来访问毗邻的两个函数。这里,ea只要是某个函数边界中的任何一个地址就可以了。利用这种方式来遍历也存在问题,如果不是函数的话,那么代码块会被IDA跳过。那么如果想在函数中遍历指令怎么办呢?idc.NextHead会帮你找到下一条指令。但这依赖于函数的边界,而且可能被jump影响。更好的方式是使用idautils.FuncItems(ea),来遍历function当中的地址。一旦知道了一个function,就可以遍历其中的指令。idautils.FuncItems(ea)返回的是一个指向list的迭代器。这个list包含了每一条指令的起始地址。以下是一个综合的应用,它输出一段代码中的非直接跳转。 for func in idautils.Functions(): flags = idc.GetFunctionFlags(func) if flags & FUNC_LIB or flags & FUNC_THUNK: continue dism_addr = list(idautils.FuncItems(func)) for line in dism_addr: m = idc.GetMnem(line) if m == 'call' or m == 'jmp': op = idc.GetOpType(line, 0) if op == o_reg: print "0x%x %s" % (line, idc.GetDisasm(line)) 这里,GetOpType会返回一个整型值,它能被用来查看一个操作数是否为寄存器,内存引用。 Operands前面也有提到,GetOpType能够用来获取操作数的类型。一共有这些类型:o_void表示不包含操作数o_reg表示一个通用寄存器o_mem表示直接内存引用o_phrase操作数包含基址寄存器+偏移寄存器o_displ操作数是由一个寄存器和一个偏移量组成的o_imm操作数直接是一个整型数o_far和o_near在x86和x86_64下均不常见。指用立即数表示的far/near地址idaapi.decode_insn()可用来对某个地址的指令进行解码。而idaapi.cmd则能用来提取指令中的各部分结构。 Searching前文已经提到了一些搜索的方式,但有些时候可能会需要搜索一些特定的bytes序列,比如0x55 0x8b 0xec,这时就需要利用到FindBinary,对对应的格式进行搜索。其具体形式为FindBinary(ea, flag, searchstr, radix)。这其中,searchstr也即所搜索的pattern。radix是和CPU相关的,可以不写。 addr = MinEA() for x in range(0,5): addr = idc.FindBinary(addr, SEARCH_DOWN|SEARCH_NEXT, pattern); if addr != idc.BADADDR: print hex(addr), idc.Getdisasm(addr) 与之类似的,还有FindText(ea, flag, y, x, searchstr),这个函数可以用来搜索一些字符串,例如一些变量中的内容可能是一些特定的字符串,就可以利用这个函数进行搜索。此外,还有isCode()和isData(),isTail(),isUnknown()等函数,来判断一个地址的属性。FindCode()能够找到下一个被标记为代码的地址;而FindData()则能够返回下一个被标记为数据的地址。与之类似的FindUnexplored(),FindExplored()则是搜索下一个未识别/识别的地址。FindImmediate()则是找到特定的立即数。不过,并不是每一次都需要对data/code进行搜索的。有时候,我们已经知道了code或者data的位置了,只是需要选择对应的内容进行分析。这时就可以用SelStart()``SelEnd()等一系列的函数。对于数据、代码,还可以对其原始格式进行访问,也即它们的二进制形式。这些数据可以用Byte()``Word()``Dword()``Qword()``GetFloat()``GetDouble()来获取。","raw":null,"content":null,"categories":[],"tags":[{"name":"IDA pro","slug":"IDA-pro","permalink":"http://sec-lbx.tk/tags/IDA-pro/"},{"name":"python","slug":"python","permalink":"http://sec-lbx.tk/tags/python/"},{"name":"静态分析","slug":"静态分析","permalink":"http://sec-lbx.tk/tags/静态分析/"}]},{"title":"IDAPython:进阶(一)","slug":"IDAPython-advance1","date":"2017-04-05T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/04/05/IDAPython-advance1/","link":"","permalink":"http://sec-lbx.tk/2017/04/05/IDAPython-advance1/","excerpt":"","text":"IDAPython:进阶(一)Instruction feature在IDAPython中,时常可以看到使用insn.get_canon_feature()来获取指令“特性”。这里,instruc_t类中包含有两个变量,name和feature,其中feature是一系列的指令特征位。 IDA SDKIDA SDK分为很多hpp,不过它的文档不是特别友好,这里我把比较重要的header的作用给列出来,方便日后进行分析和使用: area:程序中地址范围的集合,它表示一段连续的地址范围,由起始地址和终止地址来表示,例如程序中的segments就是用area来表示的,它在IDA的数据库当中,是采用B树的形式来保存的。 bitrange:用来管理一段连续bits的容器,类似一个数组。 bytes:处理byte特性的函数,在程序中,每个byte都被识别成一个32-bit的值,他被IDA称为flags。对于bits和flags,都只能通过特定的函数去修改、访问。flags被保存在*.id1这样的文件当中。 diskio:IDA的文件IO函数,通常不使用标准的C来进行I/O,而使用这个hpp当中的函数。 entry:处理entry入口的函数,每个entry包含有地址、名称、序号。 fixup:处理地址、偏移量的修正等。 frame:处理栈桢,包括参数、返回值、保存的寄存器和局部变量等。 funcs:处理反汇编程序的函数,函数由多个chunk组成。 idp:包含IDP模块的接口,包括有目标的汇编器,以及当前处理器的信息。例如判断一条指令是否为jmp、ret等,都可以使用这种方式。 lines:处理反汇编text line的生成。 name:处理命名,给一个特定地址命名等,但是指令和数据的中间是不能命名的。 netnode:database的底层接口,程序被以B树的形式保存,而B树的信息则是存放在netnode当中的。 offset:用来处理offset的函数,一个操作数可能自身,或者一部分表示了程序中的偏移量。 search:中间层的搜索函数,包括寻找数据、代码等。 segments:用来和程序中段进行交互的函数,IDA需要程序中的所有地址,属于一个segment(每个地址都必须属于一个segment),如果地址不属于一个segment,那么这些bytes不能被转换为指令、不能用有名字、不能拥有注释。每个segment都有一个selector。 ua:处理程序指令的反汇编。它包含两种函数,第一类是通过kernel来反汇编,第二类是通过IDP模块来反汇编,它们是“helper”。反汇编可以分为三步:分析、仿真、转化为文字。 xref:处理交叉引用的情况。包括有CODE和DATA的引用。 获取文件名SDK提供了两个api,idaapi.get_root_filename能够用来获取当前的文件名,而idaapi.get_input_file_path能够用来获取包含路径的文件名。这两个函数定义在nalt当中。","raw":null,"content":null,"categories":[],"tags":[{"name":"系统安全","slug":"系统安全","permalink":"http://sec-lbx.tk/tags/系统安全/"},{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解文件系统","slug":"深入理解文件系统","date":"2017-03-30T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/30/深入理解文件系统/","link":"","permalink":"http://sec-lbx.tk/2017/03/30/深入理解文件系统/","excerpt":"","text":"虚拟文件系统(VFS)的思想和作用实际上,看到Virtaul,我们就应该想到VFS的作用,是虚拟出一个中间层,来实现某些部分的统一处理。实际上,VFS要解决的是,让应用能够“以一种通用的方式”,去访问不同类型的文件系统: =100 可以看到,对于不同类型的文件系统、网络文件系统、伪文件系统,应用对它们的操作接口都是一致的,而底层的驱动则会完成实际的区分工作。 通用文件模型VFS通过一个通用文件模型,来表示一个文件系统,它从顶层到底层,一共维护四个数据结构。这里我们通过磁盘文件系统来举例子说明:file:文件对象。如果一个进程要和一个对象进行交互,那么久要在访问期间存放一个文件对象在内核内存中。dentry:目录项对象。用来存放目录项和对应文件链接信息,每个磁盘文件系统,都有特殊的方式,将该类信息存在磁盘上。inode:索引节点对象。用来存放关于具体文件的一般信息,对基于磁盘的文件系统来说,通常对应于存放在磁盘上的文件控制块,其节点号唯一标识文件系统中的文件。superblock:超级块对象。对应安装的文件系统的信息,他对应磁盘上的文件系统控制块。 =100 这张图反映出了在一次访问中,各个对象是如何参与到过程中去的。这里我为什么要把对象给加粗呢?VFS实际采用的是一种面向对象的方式(虽然它是用C语言写的)。每个object,都有一系列的“操作方式”,VFS为这些对象提供了“统一的”接口,而具体的文件系统则提供了千差万别的实现方式,这是由一个函数指针表来实现的。 文件对象这里我们从文件对象开始说。如果留意进程的task_struct(定义在sched.h当中),你会发现一个fs_struct结构,以及一个files_struct结构。其中fs_struct保存了当前点工作目录和根目录: struct fs_struct { int users; spinlock_t lock; seqcount_t seq; int umask; int in_exec; struct path root, pwd; }; 而files_struct结构则保存了当前进程所打开的所有文件,它们保存在fdtable中: struct fdtable { unsigned int max_fds; struct file __rcu **fd; /* current fd array */ unsigned long *close_on_exec; unsigned long *open_fds; struct rcu_head rcu; }; 当open()系统调用发生时,文件描述符实际上是fd中的下标。现在我们终于看到file结构了,这里我只列出了重要的部分: struct file { union { struct llist_node fu_llist; struct rcu_head fu_rcuhead; } f_u; struct path f_path; struct inode *f_inode; /* cached value */ const struct file_operations *f_op; struct mutex f_pos_lock; loff_t f_pos; } __attribute__((aligned(4))); 这里,loff_t是平时我们用来在文件读写上用于定位的指针,显然这个值必须放在file当中,因为可能会有几个进程同时访问一个文件。而f_op正反映出了“通用文件模型的”特点,它保存了文件操作的对应指针;这个值是在进程打开文件时,从文件索引节点中的i_fop中复制的。当然在file中我们也看到了dentry和inode的影子:f_path包含了dentry *,而*f_indoe也是file的一个域,它们分别指向了对应文件点dentry对象和inode对象,我们将会在接下来对它们进行详细的说明。 目录项对象在上一节中,我们知道了文件对象描述了应用和文件的联系。这里,目录项是用来描述具体的文件系统中的目录项的,他的作用是:用来快速找到一个路径,并且与文件相关联。假设进程需要查找一个路径,那么路径中的每一个分量,都会有一个目录项与之对应。并且,目录项会把每个分量和它对应的索引节点联系起来。dentry的定义如下: struct dentry { unsigned int d_flags; seqcount_t d_seq; struct hlist_bl_node d_hash; /* 哈希链表 */ struct dentry *d_parent; /* 父目录项 */ struct qstr d_name; /* 目录名 */ struct inode *d_inode; /* 对应的索引节点 */ unsigned char d_iname[DNAME_INLINE_LEN]; /* small names */ struct lockref d_lockref; /* per-dentry lock and refcount */ const struct dentry_operations *d_op; /* dentry操作 */ struct super_block *d_sb; /* 文件的超级块对象 */ unsigned long d_time; void *d_fsdata; struct list_head d_lru; /* LRU list */ struct list_head d_child; /* child of parent list */ struct list_head d_subdirs; /* our children */ union { struct hlist_node d_alias; /* inode alias list */ struct rcu_head d_rcu; } d_u; }; 这里可以看到,除开自身的名称、引用计数等,目录项对象中有这些关键的结构:d_op描述了目录项所对应的操作(通用模型的特点);d_sb则是 文件的超级块对象,至于d_inode则是这个目录项关联的索引节点(当然它们并不是一一对应的关系)。注意,这里还有个很重要的变量:d_lockref,它其实就是一个计数器,说明了这个目录项对象的引用次数。 目录项对象保存在dentry cache中。linux操作系统为了提高目录项对象的处理效率,设计了这个高速缓存。这是因为,从磁盘中读取目录项,并且构造相应的目录项对象是需要花费大量的时间的,因此,在完成对目录项对象的操作之后,在内存中(尽量)保存它们具有很重要的意义。这个dentry cache,其本质是一个哈希链表,它定义在list_bl.h当中(它的hash计算在d_hash中完成)。我注意到,对于每一个dentry,都有一个d_flags,它的可能的值,定义在dcache.h当中。因为dentry cache的大小也是有限的,因此我们也不可能无限制地把dentry保存在cache中。因此linux首先把dentry的状态进行了定义: free:该状态目录项对象不包含有效信息,未被VFS使用unused:目前没有被内核使用,d_count的值为0,d_inode仍然指向相关的索引节点in use:正在被使用,d_count的值大于0,d_inode仍然指向相关的索引节点negative:与目录项关联的索引节点不存在,相应的磁盘索引节点已经被删除(d_inode为负数)。 那么对于unused和negative这一类目录项,linux使用了一个LRU(最近最少使用)的双向链表,一旦dentry cache的大小吃紧,就从这个LRU链表中删除dentry。 索引节点对象索引节点对象,是VFS当中最为重要的一个数据结构。它的作用是表示文件的相关信息。这里的相关信息,不包括文件本身的内容,而是诸如文件大小、拥有者、创建时间等信息。在一个文件被首次访问时,内核会在内存中构造它的索引节点对象。我们在操作系统中,可以任意修改一个文件的名字,但是索引节点和文件是一一对应的(由索引节点号标识),只要文件存在它就会存在(注意,超级块对象和索引节点对象在硬盘上都是有对应的实体数据结构的,在使用时利用硬盘上的内容,在内存中构造索引节点对象)。目录项是用来找到一个对应的索引节点实体,而具体与文件关联的工作则是由索引节点完成的。让我们来看看索引节点的数据结构(只取了重要的部分),其定义在fs.h当中: struct inode { struct hlist_node i_hash; /* 散列表,用于快速查找inode */ struct list_head i_list; /* 相同状态索引节点链表 */ struct list_head i_sb_list; /* 文件系统中所有节点链表 */ struct list_head i_dentry; /* 目录项链表 */ unsigned long i_ino; /* 节点号 */ atomic_t i_count; /* 引用计数 */ unsigned int i_nlink; /* 硬链接数 */ uid_t i_uid; /* 使用者id */ gid_t i_gid; /* 使用组id */ struct timespec i_atime; /* 最后访问时间 */ struct timespec i_mtime; /* 最后修改时间 */ struct timespec i_ctime; /* 最后改变时间 */ const struct inode_operations *i_op; /* 索引节点操作函数 */ const struct file_operations *i_fop; /* 缺省的索引节点操作 */ struct super_block *i_sb; /* 相关的超级块 */ struct address_space *i_mapping; /* 相关的地址映射 */ struct address_space i_data; /* 设备地址映射 */ unsigned int i_flags; /* 文件系统标志 */ void *i_private; /* fs 私有指针 */ unsigned long i_state; }; 可以看到,inode同样采用了多个链表来保存。i_hash用来快速查找inode,i_list则是相同状态索引结点形成的双链表,这包含有未用索引节点链表,正在使用索引节点链表和脏索引节点链表等。i_dentry是所有使用该节点的dentry链表。值得注意的是,inode不仅仅包含了自身索引节点的操作函数i_op,还有指向(缺省)文件操作的指针i_fop。当然,inode还会和super_block有联系。i_sb是索引节点所在的超级块,而i_sb_list则是超级块中的所有节点的链表。当在某个目录下创建、打开一个文件时,内核就会调用create()为这个文件创建一个inode。VFS通过inode的i_op->create()函数来完成这个工作;它将目录的inode、新打开文件的dentry、访问权限作为参数;lookup()函数用来查找指定文件的dentry,link()和symlink()分别用来创建硬链接和软链接。 超级块对象与前面几类对象不同的是,超级块对象表述的内容更加庞大一些:它表示的是一个“已安装的文件系统”。它在文件系统安装时建立,在文件系统卸载时删除。其定义在fs.h当中,这里我只列举出了较为关键的域。 struct super_block { struct list_head s_list; //超级块链表的指针 dev_t s_dev; //设备标识符 unsigned char s_blocksize_bits; unsigned long s_blocksize; loff_t s_maxbytes; //文件的最长长度 struct file_system_type *s_type; const struct super_operations *s_op; //超级块的操作 const struct dquot_operations *dq_op; const struct quotactl_ops *s_qcop; const struct export_operations *s_export_op; unsigned long s_flags; //安装标识 struct dentry *s_root; //根目录的目录项 int s_count; const struct xattr_handler **s_xattr; struct list_head s_inodes; //所有的inodes链(打开文件的inodes链) struct block_device *s_bdev; //块设备 char s_id[32]; //块设备名称 u8 s_uuid[16]; //UUID fmode_t s_mode; char *s_subtype; const struct dentry_operations *s_d_op; //default d_op for dentries void *s_fs_indo; //文件系统的信息指针 }; 在linux中,每个超级块代表一个已安装的文件系统。所有的超级块链表,是以一种双向环形链表的形式链接在一起的。其prev、next保存在list_head域中。超级块对象中,保存有其根目录的dentry,以及其所有的inode。s_fs_info则指向了文件系统的超级块信息。而对于超级块来说,同样定义有s_op,也即超级块的操作表。超级块一般是储存在磁盘的特定扇区当中,但如果是基于内存对文件系统,比如proc、sysfs,则是保存在内存当中,而超级块对象,则是在使用时创建的,它保存在内存中。 硬链接和软链接与复制linux里面,可以把文件分成三个部分:文件名(dentry),inode,数据。复制的定义很明确,就是为这三个部分,都创建一个新的副本。硬链接的本质是一个“文件名”,一个文件可能有多个“文件名”,inode并不包含文件名,而只是有一个索引节点号。硬链接实际上就是为链接文件创建一个新的dentry,并将dentry写入父目录的数据中,而硬链接所对应的inode依然没有变。所以删除硬链接只是删除了dentry,而inode结点数减少1而已。软链接就是一个普通的文件,只不过它的数据保存的是另一个文件的路径。软链接的创建,调用了__ext4_new_inode()来创建一个新的inode,并把dentry->name作为了它的内容。也就是说,软链接也同时创建了这三个部分。二者的区别在哪里呢?首先,硬链接共享了inode,因此它不能跨文件系统;但是软链接不受这个限制。由于相同的原因,硬链接只能对存在的文件进行创建,而软链接不是。而且硬链接有可能会在目录中引入循环,所以不能指向目录;但软链接不会,因为它有一个inode实体可以跟踪。不过不论删除软链接还是硬链接,都不会对原文件、具有相同inode号的文件造成影响。但如果原文件被删除,软链接会变成死链接,硬链接不会,因为inode的计数并没有变成0。 路径名查找每当进程需要识别一个文件时,就把它的文件路径名,传递给某个VFS系统调用,比如open()。在路径查找中,有个辅助的数据结构:nameidata,它用来向函数传递参数,并且保存查找的结果: struct nameidata { struct path path; struct qstr last; struct path root; struct inode *inode; /* path.dentry.d_inode */ unsigned int flags; unsigned seq, m_seq; int last_type; unsigned depth; struct file *base; char *saved_names[MAX_NESTED_LINKS + 1]; }; 在查找完成后,path中保存了目录项,depth表示了当前路径的深度,saved_names保存了符号链接处理中的路径名。路径查找的复杂性,主要体现在VFS系统的一些特点上:(1)必须对目录的访问权限进行检查;(2)文件名可能是符号链接;(3)要考虑符号链接可能带来的循环引用;(4)文件名可能是文件系统的安装点(5)路径名和进程的命名空间有关等。路径名查找的入口是path_lookup(),它调用了filename_lookup()。这个函数对nameidata进行了简单的填充,随后调用lookupat()。lookupat()函数中通过一个循环,和path_init()函数,逐级向下进行查找,检查目录的访问权限,并且考虑符号链接等情况。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"Ocaml笔记(二)","slug":"ocaml-3","date":"2017-03-27T14:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/27/ocaml-3/","link":"","permalink":"http://sec-lbx.tk/2017/03/27/ocaml-3/","excerpt":"","text":"模块OCaml把每一段代码,都包装成一个模块。例如两个文件amodule.ml和bmodule.ml都会定义一个模块,分别为Amodule和Bmodule。通常模块是一个个编译的,比如 ocamlopt -c amodule.ml ocamlopt -c bmodule.ml ocamlopt -o hello amodule.cmx bmodule.cmx 那么访问模块中的内容可以使用open,也可以使用module.func这样的方式。通常模块会定义为struct...end的形式,这样能够形成一个有效的闭包,防止命名的重复等,它需要和一个module关键字绑定。比如: module PrioQueue = struct ... end;; 接口、签名通常模块中的所有定义,都可以从外部进行访问。但实际中,模块只应该提供一系列接口,隐藏一些内容,这也是面向对象语言中所提倡的。模块是定义在.ml文件中的,而相应的接口,则是从.mli文件中得到的。它包含了一个带有类型的值的表。例如,对于一个模块来说,它的接口可以这样定义: (*模块定义*) let message = "Hello" let hello() = print_endline message (*接口定义*) val hello : uint -> unit 这样,接口的定义就隐藏了message。这里,.mli文件是在.ml文件之前编译的。.mli用ocamlc来编译,而.ml则是用ocamlopt来编译的。.mli文件就是所说的“签名”。 ocamlc -c amodule.mli ocamlopt -c amodule.ml 类型值可以通过把它们的名字和类型,放到.mli文件的方式来导出。 val hello : unit -> unit 但模块经常定义新的类型。例如, type date = { day : int; month : int; year : int } 这里其实有几种.mli文件的写法,例如,包括: 完全忽略类型 把类型定义拷贝到签名 把类型抽象,只给出名字type date 把域做成只读的:type date = private{...} 如果采用第三种方式,那么模块的用户就只能操作date对象,使用模块提供的函数去间接进行访问。 子模块一个给定的模块,可以在文件中显示的定义,成为当前模块的字模块。通过约束一个给定自模块的接口,是能够达到和写一对.mli/.ml文件一样的效果的。例如: module type Hello_type = sig val hello : unit -> unit end module Hello : Hello_type = struct ... end 仿函数(函子)OCaml中的仿函数,定义与其他语言不太一样,它是用另一个模块,来参数化的模块。它允许传入一个类型作为参数,但这在OCaml中直接做是不可能的。个人理解,这里的函子和C++中的STL比较类似,它接受不同类型的输入作为初始化。事实上在OCaml中,map和set模块都是要通过函子来使用的。例如,标准库定义的Set模块,就提供了一个Make函子。假如要使用不同类型的集合,可以这样这样利用函子: # module Int_set = Set.Make (struct type t = int let compare = compare end) # module String_set = Set.Make (String);; 至于函子的定义,则是这样: module F(X : X_type) = struct ... end X是作为参数被传递的模块,而X_type是它的签名,这种写法是强制。 module F(X:X_type) : Y_type = struct ... end 这种写法对于返回模块的签名,也能够进行约束。函子的操作也是比较难理解的,多使用set/map,并且阅读这两个库中的源码,是能够帮助理解和记忆的。 模式匹配OCaml能够把数据结构分开,并对其做模式匹配。这里举一个例子,表示一个数学表达式n * (x + y),并且分解公因式为n * x + n * y首先定义一个表达式类型: # type expr = | Plus of expr * expr (* means a + b *) | Minus of expr * expr (* means a - b *) | Times of expr * expr (* means a * b *) | Divide of expr * expr (* means a / b *) | Value of string (* "x", "y", "n", etc. *);; 那么,对于一个表达式,用模式匹配的方式,可以将其打印成对应的数学表达式: # let rec to_string e = match e with | Plus (left, right) -> "(" ^ to_string left ^ " + " ^ to_string right ^ ")" | Minus (left, right) -> "(" ^ to_string left ^ " - " ^ to_string right ^ ")" | Times (left, right) -> "(" ^ to_string left ^ " * " ^ to_string right ^ ")" | Divide (left, right) -> "(" ^ to_string left ^ " / " ^ to_string right ^ ")" | Value v -> v;; val to_string : expr -> string = <fun> # let print_expr e = print_endline (to_string e);; val print_expr : expr -> unit = <fun> 这样,使用print_expr,就能够把一个表达式打印成一个数学表达式。那么,模式匹配的通用形式是: match value with | pattern -> result | pattern -> result ... 或者对条件进行进一步的约束 match value with | pattern [ when condition ] -> result | pattern [ when condition ] -> result ... 注意,这里还有一种特殊的模式匹配,| _,它用来匹配剩下的任意情况。 奇奇怪怪的操作符OCaml中,还有许多有趣的操作符和表达式。在SO上,我也看到了类似的提问: let m = PairsMap.(empty |> add (0,1) "hello" |> add (1,0) "world") 这里有两个问题。第一个,module.(e)是啥意思?它其实等价于let open Module in e,它相当于一种简写的形式,同样是把module引入当前模块的方式。第二个|>表达式是什么意思?其实它是Pervasives中定义的一个操作符,其定义为let (|>) x f = f x。它被称为”reverse application function”(我不知道应该如何翻译),但它的作用,是把连续的调用去有效的串联起来(可以把函数放在参数之后,从而保证一个调用顺序,有一点类似管道的意思)。如果不使用|>符号,那么就必须写成: let m = PairsMap.(add(1,0) "world" (add(0,1) "hello" empty)) 在Uroboros当中,还看到有一个奇怪的操作符,那便是@,从manual上来看,这个操作符的意思是“串联List”。有这样的例子: # List.append [1;2;3] [4;5;6];; - : int list = [1; 2; 3; 4; 5; 6] # [1;2;3] @ [4;5;6];; - : int list = [1; 2; 3; 4; 5; 6]","raw":null,"content":null,"categories":[],"tags":[{"name":"静态分析","slug":"静态分析","permalink":"http://sec-lbx.tk/tags/静态分析/"},{"name":"Ocaml","slug":"Ocaml","permalink":"http://sec-lbx.tk/tags/Ocaml/"}]},{"title":"Ocaml笔记(一)","slug":"ocaml","date":"2017-03-27T10:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/27/ocaml/","link":"","permalink":"http://sec-lbx.tk/2017/03/27/ocaml/","excerpt":"","text":"Ocaml网上关于Ocaml的资料比较少,可见它是一门偏小众的语言。不过在DSL和程序分析方面,Ocaml是十分强大的。由于目前研究需要使用的Uroboros(https://github.com/s3team/uroboros)是由Ocaml来编写的,因此我也打算一探究竟,学一学这门语言。 函数的定义和调用Ocaml中,定义函数的语法很简单。这个函数是输入两个浮点数后计算它们的平均值。 let average a b = (a +. b) /. 2.0;; 在C当中,如果要定义一个相同的函数,其定义是这样的: double average(double a, double b){ return (a + b) / 2; } 可以看到,OCaml没有定义a和b的类型,而且也没有所谓的return,而且写的是2.0,没有用隐式转换。这其实是由Ocaml语言的特性决定的: Ocaml是强静态类型的语言 OCaml使用类型推倒,不需要注明类型 OCaml不做任何隐式转换(所以要写浮点数就必须是2.0) OCaml不允许重载,+.表示两个浮点数相加,也就是说操作符是和类型相关的 OCaml的返回值是最后的表达式,不需要return 和大多数基于C的语言不同,OCaml的函数调用,是没有括号的。例如定义了一个函数repeated,它的参数是一个字符串s和一个数n,那么它的调用形式会是: repeated "hello" 3 (*this is Ocaml code*) 可以看到既没有括号,也没有都好。不过reoeated ("hello", 3)也是合法的,只不过它的参数是一个含两个元素的对(pair)。 基本类型、转换与推倒 类型 范围 int 32bit:31位;64bit:63位 float 双精度,类似于C中的double bool true/flase char 8bit字符 string 字符串 unit 写作(),类似void 这里,Ocaml内部使用了int中的一位来自动管理内存(垃圾收集),因此会少一位。前面也提到,OCaml是没有隐式类型转换的。因此 1 + 2.5;; 1 +. 2.5;; 在OCaml中是会报错的。但是如果一定要让一个整数和浮点数相加,就必须显示的进行转换,比如: float_of_int i +. f;; float i +. f;; 许多情况下,不需要声明函数的变量和类型,因为Ocaml自己会知道,它会一直检查所有的类型匹配。比如前面的average函数,就能够自给判断出这个函数需要两个浮点数参数和返回一个浮点数。 函数的递归和类型和基于C的语言不同之处在于,OCaml中,函数一般不是递归的,除非用let rec代替let定义递归函数。这是一个递归函数的例子: let rec range a b = if a > b then [] else a :: range (a+1) b let和let rec的唯一区别,就是函数的定义域。举个例子,如果用let定义range,那么range会去找一个已经定义好的函数,而不是它自身。不过在性能上,let和let rec并没有太大的差异。所以即使全部用let rec来定义也可以。而OCaml的类型推倒,也使得几乎不用显式的写出函数的类型。不过Ocaml经常以这样的实行显示参数和返回值的类型: f:arg1 -> arg2 -> ... -> argn -> rettype f: 'a->int (*单引号表示人意类型*) 表达式在Ocaml当中,局部变量/全局变量其实都是一个表达式。例如,局部表达式有: let average a b = let sum = a +. b in sum /. 2.0;; 标准短语let name = expression in是用来定义一个命名的局部表达式的。name在这个函数当中,就可以代替expression,直到一个;;结束这个代码块。这里把let ... in视为一个整体。和C中不一样,OCaml中name只是expression的一个别名,我们是不能给name赋值或者改值的。而全局表达式,也可以像定义局部变量一样定义全局名,但这些也不是真正的变量,而只是缩略名。 let html = let content = read_whole_file file in GHtml.html_from_string content ;; let menu_bold () = match bold_button#active with | true -> html#set_font_style ~enable:[`BOLD] () | false -> html#set_font_style ~disable:[`BOLD] () ;; let main () = (* code omitted *) factory#add_item "Cut" ~key:_X ~callback: html#cut ;; 这里,html实际上是一个“小部件”,没有指针去保存它的地址,也不能赋值,而是在之后的两个函数中被引用。 Let-绑定绑定,let ...,能够在OCaml中,实现真正的变量。在OCaml中,引用使用关键字ref来进行定义。例如, let my_ref = ref 0;;(*引用保存着一个整数0*) myref := 100(*引用被赋值为100*) :=用来给引用赋值,而!用来取出引用的值。以下是一个C和OCaml的比较 OCaml C/C++ let my_ref = ref 0;; int a = 0; int *my_ptr = &a; my_ref := 100;; *my_ptr = 100; !my_ref *my_ptr 嵌套函数与C语言不同的是,OCaml是可以使用嵌套函数的。 let read_whole_channel chan = let buf = Buffer.create 4096 in let rec loop () = let newline = input_line chan in Buffer.add_string buf newline; Buffer.add_char buf '\\n'; loop () in try loop () with End_of_file -> Buffer.contents buf;; 这里,loop是只有一个嵌套函数,在read_whole_channel中,是可以调用loop()的,但它在read_whole_channel当中并没有定义,嵌套函数可以使用主函数当中的变量,它的格式和局部命名表达式是一致的。 模块和OPENOCaml也提供了很多模块,包括画图、数据结构、处理大数等等。这些库位于usr/lib/ocaml/VERSION。例如一个简单的模块Graphics,如果想使用其中的函数,有两种方法。第一种是在开头声明open Graphics,第二种是在函数调用之前加上前缀,例如Graphics.open_graph。如果想用Graphics当中的符号,也可以通过重命名的方式,简化前缀。 module Gr = Graphics;; 这个技巧在模块嵌套时十分有用。 ;;还是;,或者什么都不用?在OCaml中,有时候会使用;;,有时候会使用;,有时候却什么都不用,这就让初学者很容易迷惑。这里,OCaml实际上定义了一系列的规则。 #1 必须使用;;在代码的最顶端,来分隔不同语句(不同代码段之间的分隔),并且不要在函数定义或其他语句中使用 #2 可以在某些时候省略掉;;,包括let,open,type之前,文件的最后,以及OCaml能自动判断的地方 #3 let ... in是一条单独道语句,不能在后面加单独的; #4 所有代码块中其他语句后面,跟上一个单独的;,最后一个例外 看到这些规则,我依然没有完全理解这三者的用法。我想,只有实际接触过Ocaml代码,才能逐渐体会到其中的精髓吧。","raw":null,"content":null,"categories":[],"tags":[{"name":"静态分析","slug":"静态分析","permalink":"http://sec-lbx.tk/tags/静态分析/"},{"name":"Ocaml","slug":"Ocaml","permalink":"http://sec-lbx.tk/tags/Ocaml/"}]},{"title":"LLVM框架与Pass","slug":"LLVM笔记","date":"2017-03-23T07:27:32.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/23/LLVM笔记/","link":"","permalink":"http://sec-lbx.tk/2017/03/23/LLVM笔记/","excerpt":"","text":"LLVM简介传统的编译器,采用的是针对单语言源代码,生成对应平台机器码的方式,类似于:而LLVM则采用了一种多前端,多后端的方式,如下:在LLVM当中,LLVM IR是一种low-level的,虚拟指令集。所有的前端语言都能够生成LLVM,从而能够被统一的进行处理。在LLVM当中,还提供了对LLVM IR Optimization进行优化的方式,能够对现有的源码进行搜索,匹配对应的pattern,从而进行指令的替换、修改。LLVM中,每个过程都是从Pass继承来的,LLVM优化器提供了许多passes,每个都写的很简洁,它们被编译成了库文件,并且在编译的时候被调用。这些库提供了分析和变换的能力,并且既能独立运作,又能合作。 代码生成那么LLVM这种“多对多”的编译方式,是如何将LLVM IR转化为机器码的呢?LLVM将代码的生成划分成了多个独立的过程:指令选择、寄存器生成,调度,代码布局优化,生成汇编代码。这样不同的平台,也能够利用自己的优势,对相同的LLVM IR进行优化。LLVM采用了一种“mix and match”的方式,允许target作者,对于架构做出明确的指示,例如对寄存器的使用、限制做出明确的指示。LLVM利用Target-8description文件来指定对应架构的特性、使用的指令、寄存器。 X86Target 而LLVM利用tblgen工具从这些.md文件当中,能够读取出足够的信息,并且在instruction selection、register allocator等过程中,选择处理的过程。而.cpp文件,则是实现一些特定的过程,例如浮点指针栈。 LLVM passLLVM pass完成编译器的变换、优化工作;它们是Pass的子类,根据不同的需要,可以选择去继承ModulePass,CallGraphSCCPass,FunctionPass,或者LoopPass,RegionPass和BasicBlockPass等。llvm是需要编写、编译、加载和执行的,它相当于一个可以加载的模块。如果想编写一个模块,可以再llvm/lib/Transform当中创建对应的目录,并且在Transform以及目标文件夹下同时修改两个CMakeLists。在编译时,llvm的编译链会自动生成对应的pass。pass是基于中间语言llvm IR来进行的。因此它用来对.bc文件进行优化,例如: opt -load /lib/LLVMLbpass.so -lbpass <foo.bc> /dev/null","raw":null,"content":null,"categories":[],"tags":[{"name":"编译安全","slug":"编译安全","permalink":"http://sec-lbx.tk/tags/编译安全/"},{"name":"llvm","slug":"llvm","permalink":"http://sec-lbx.tk/tags/llvm/"},{"name":"编译器","slug":"编译器","permalink":"http://sec-lbx.tk/tags/编译器/"}]},{"title":"深入理解内核同步","slug":"深入理解内核同步","date":"2017-03-15T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/15/深入理解内核同步/","link":"","permalink":"http://sec-lbx.tk/2017/03/15/深入理解内核同步/","excerpt":"","text":"内核同步对于内核,其实有一个很形象的理解:我们可以把内核理解成一个服务器,它为自身和用户提供各种服务。因此它必须要保证每项服务在处理时,不会互相造成影响,也就是解决“并发”的问题。自身的请求,也即中断;客户的请求,也即用户态的系统调用或异常。内核的同步,就是对内核中的任务进行调度,使它们按照正确的方式运行。 内核抢占这里,“内核抢占”指的是进程A在内核态运行时,被具有更高优先级的进程B取代,也就是发生了进程上下文的切换。而我们知道,中断上下文是不包括进程信息的,不能被调度。所以只要在中断上下文中,就不能进行“进程切换”。因此硬中断和软中断在执行时都不允许内核抢占;只有在内核执行异常处理程序(尤其是系统调用),并且内核抢占没有被显示禁用时,才能进行内核抢占。CPU必须打开本地中断,才能完成内核抢占。从另一个角度来说,CPU在任何情况下,都处于三种上下文情况之一:(1)运行在用户空间,执行用户进程;(2)运行在内核空间,处于进程上下文;(3)运行在内核空间,处于中断上下文。 在关于中断的博文里,我已经写过,中断上下文是不属于任何进程的,它和current没有任何关系。由于没有任何进程背景,在中断上下文中也不能发生睡眠,否则是不能对它进行调度。因此中断上下文中只能用锁进行同步,中断上下文也叫做原子上下文。而异常和系统调用陷入内核时,是出于进程上下文的,因此可以通过current关联相应的任务。所以在进程上下文中,可以发生睡眠,也可以使用信号量;当然也可以使用锁。ps:以上说的是内核抢占的情况;用户抢占指的是另一个概念,指的是内核即将返回用户空间的时候,如果need_resched标志被设置,就会调用schedule(),选择一个更为合适的进程运行。 内核不能被抢占的情况有这些:(1)内核正在进行中断处理。在linux下,进程不能抢占中断(注意,中断是可以抢占、中止其他中断的),中断历程中不允许进行进程调度(schedule()会进行判断,如果在中断中会报错)。这也包括软中断的Bottom half部分。(2)当前的代码段持有自旋锁、读写锁,这些锁保证SMP系统CPU并发的正确性,此时不能进行抢占。(3)内核正在执行调度程序时,不应该进行抢占。(4)内核正在对每CPU数据进行操作。 除此之外的情况,都可以发生内核抢占。 每CPU变量把内核变量,声明为每个CPU所独有的,它是数组结构的数组,每个CPU对应数组的一个元素,CPU直接不能访问其他CPU对应的数组元素,只能读写自身的元素,因此也不会出现竞争条件。但这同样存在着限制:必须确定CPU上的数据是各自独立的。但是每CPU变量不能解决内核抢占的问题,他只能解决多CPU的问题,因此在访问时应当禁用抢占。 原子操作通过保证操作在芯片上是原子级的,保证“读-修改-写”指令不会引发竞争。任何一个这样的操作,都必须以单个指令执行,并且不能中断,避免其他CPU访问这个单元。除了常见的0或1次对齐内存访问的汇编指令、单处理器下的“读-修改-写”指令、前缀为lock的指令也是原子操作指令。 优化和内存屏障优化屏障主要是用来保证编译时,汇编语言指令按照原顺序来执行,而不进行重排。例如在linux中,barrier()的本质就是asm volatile("":::"memory")。而内存屏障则是保证原语前后的指令执行顺序,也即在执行原语后的指令时,原语前的指令必须已经执行完了。 自旋锁自旋锁是一类特别广泛使用的同步技术,如果内核控制路径必须访问共享数据结构,或者访问临界区,那么就需要为自己获取一个自旋锁;只有资源是空闲时,获取才能成功;当它释放了锁之后,其他内核控制路径就可以进入房间了。那么自旋锁的意义是什么?它是多处理器环境下一种特殊的锁;如果执行路径发现自旋锁是锁着的,或反复在周围进行“旋转”,反复执行循环,直到锁被释放(忙等)。自旋锁保护的临界区通常是禁止内核抢占的,如果在单CPU环境下,自旋锁仅仅能够禁止或启用内核抢占,并不能起到锁的作用。当然,忙等时还是可以被抢占的,只有上锁后才会禁止抢占。ps:阿里巴巴的面试官问过我一个问题,自旋锁的本质是什么?我当时猜测了一下,回答了原子操作,但没有能够进一步地进行解释。这里应该结合源码进行说明。可以看到对xadd就是一个标准的源子加操作。linux内核使用了两种实现。其一是“标签自旋锁”,raw_spin_lock最后会调用: static inline void __raw_spin_lock(raw_spinlock_t *lock) { preempt_disable(); //禁止了抢占 spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock); } arch_spin_lock(arch_spinlock_t *lock) { register struct __raw_tickets inc = {.tail = TICKET_LOCK_INC};//这个值是0 inc = xadd(&lock->tickets, inc); //xadd是原子加,在多CPU时会上锁 //获取标签,同时把序号+1 if(likely(inc.head == inc.tail)) //标签到自己了,取锁成功了 goto out; for(;;){ //否则就不断循环,直到轮到自己 unsigned count = SPIN_THRESHOLD; do{ inc.head = READ_ONCE(lock->tickets.head); if(__tickets_equal(inc.head, inc.tail))//判断是否到自己的标签了 goto clear_slowpath; cpu_relax(); }while(--count); __ticket_lock_spinning(lock, inc.tail); } clear_slowpath: __ticket_check_and_clear_slowpath(lock, inc.head); cout: barrier(); } arch_spinlock_t的结构如下,实际上就是一个u16数 typedef struct arch_spinlock { union { __ticketpair_t head_tail; struct __raw_tickets { __ticket_t head, tail; } tickets; }; } arch_spinlock_t; 另一种是一种更加复杂的实现,被称为“排队自旋锁”。排队自旋锁基于每CPU变量实现,其实现比基于标签对实现更公平。 读-拷贝-更新用来保护在多数情况下,被多个CPU读的数据结构,而设计的另一种同步技术,其特点是允许多个读和写并发执行,并且不使用锁。那么它如何在共享数据读前提下,实现同步呢?RCU只保护被动态分配,并且通过指针引用的数据结构,并且在RCU临界区内,禁止睡眠。RCU的做法是,在写操作时,拷贝一份原来的副本,在副本上进行修改,并且在修改完成后进行更新,将旧的指针更新为新的指针。 信号量在linux中,有两种信号量,一种是给内核使用的内核信号量,另一种是给用户态进程使用的IPC信号量。这里我们只讨论内核信号量。其实信号量和自旋锁在“上锁”这一点上是类似的,如果锁关闭了,那么就不允许内核控制路径继续执行;只不过它不会像自旋锁一样,在原地“忙等”,而是将相应的进程挂起;只有资源可用了,进程才能继续运行。也正因为“睡眠”的特性,信号量不能用在中断处理程序和延迟处理函数上,只有允许睡眠的情况下,才能够使用信号量。内核信号量的定义在semaphore.h当中: struct semaphore { raw_spinlock_t lock; //保护信号量的自旋锁 unsigned int count; struct list_head wait_list; }; 很神奇的,这里看到了raw_spinlock_t的影子。这其实是一个由Real-time linux引入的命名问题;这里我们只需要明白:尽可能使用spin_lock;绝对不允许被抢占和休眠的地方,使用raw_spin_lock,否则使用spin_lock,信号量的底层,使用了自旋锁来实现。 信号量的后两个域,count和wait_list分别是现有资源数和等待获取资源的进程序列。对于信号量,内核定义了这些API: void down(struct semaphore *sem); void up(struct semaphore *sem); int down_interruptible(struct semaphore *sem); int down_killable(struct semaphore *sem); int down_trylock(struct semaphore *sem); int down_timeout(struct semaphore *sem, long jiffies); 这里看看down函数: void down(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(sem->count > 0)) sem->count--; else __down(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); } 可以看到,这里自旋锁的作用实际上是保证count不被同时操作;而如果count大于0,则可以减少它的值,表示获取了这个锁,否则会__down_common,这个函数在不发生错误大情况下,会调用这样一段函数: raw_spin_unlock_irq(&sem->lock); timeout = schedule_timeout(timeout); raw_spin_lock_irq(&sem->lock); 这个函数是在timer.c代码中定义的。schedule_timeout函数将当前的任务置为休眠到设置的超时为止,这也就是信号量和自旋锁不同之处了,它允许进程的休眠。 而对于up函数来说,释放锁,增加count之后,会马上会检查是否有进程在等待资源: static noinline void __sched __up(struct semaphore *sem) { struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, struct semaphore_waiter, list); list_del(&waiter->list); waiter->up = true; wake_up_process(waiter->task); } 这样看来,其实信号量和自旋锁最大的不同就只有两个:自旋锁的忙等与信号量的休眠,资源的数量。 互斥量虽然《深入理解linux内核》这本书中没有写,但是内核中也是有互斥量的;实际上它相当于count = 1的信号量。互斥量的定义为: struct mutex { atomic_t count; spinlock_t wait_lock; struct list_head wait_list; #if defined(CONFIG_DEBUG_MUTEXES) || defined(CONFIG_MUTEX_SPIN_ON_OWNER) struct task_struct *owner; #endif #ifdef CONFIG_MUTEX_SPIN_ON_OWNER struct optimistic_spin_queue osq; #endif #ifdef CONFIG_DEBUG_MUTEXES void *magic; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif }; 可以看到它同样依赖于自旋锁实现,也包含一个进程的等待队列。我们来看看互斥量的上锁操作: void __sched mutex_lock(struct mutex *lock) { might_sleep(); __mutex_fastpath_lock(&lock->count, __mutex_lock_slowpath); mutex_set_owner(lock); } 这里__mutex_fastpath_lock最终会调用一段汇编代码: asm_volatile_goto(LOCK_PREFIX " decl %0\\n" " jns %l[exit]\\n" : : "m" (v->counter) : "memory", "cc" : exit); 也就是原子操作,修改mutex的counter,而mutex中的自旋锁,是为了保护wait_list而存在的,只是起到一个辅助作用,这点和信号量不太一样。 读写自旋锁/顺序锁/信号量为了增加内核到并发能力,操作系统还设置了读写自旋锁。读写自旋锁允许多个内存控制路径,同时读同一个数据结构,但如果相对这个结构进行写操作,那么它必须首先获取读写自旋锁的写锁,写锁能让当前的路径独占访问这个资源。顺序锁则是允许读者在读的同时进行写操作,因此写操作永远不会等待,但这样读操作有时候必须重复读多次,直到读到有效的副本为止。读写信号量则和读写自旋锁类似,只不过它以挂起代替自旋。 禁止本地中断/可延迟函数在前面提到的原语中,很多在实现的时候,都禁止了了本地的中断,这就保证了当前内核控制路径能够继续执行,例如raw_spin_lock_irqsave和raw_spin_lock_irqrestore。不过禁止本地中断不能阻止其他CPU访问共享数据,因此通常和自旋锁结合使用。而可延迟函数同样可以禁止和激活,这是由preempt_count字段中的值决定的。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解中断(二)","slug":"中断的发生","date":"2017-03-06T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/03/06/中断的发生/","link":"","permalink":"http://sec-lbx.tk/2017/03/06/中断的发生/","excerpt":"","text":"中断的发生中断指的是CPU在运行时,系统内发生了需要“急需处理”的事件,于是乎CPU暂停了当前正在执行对程序,转而去执行相应的时间处理程序,在处理完之后返回原来的地方执行。那么这些事件包含:(1)外部中断指的是那些CPU外的周边原件引发的中断,例如I/O中断,I/O设备异常(接下来我们把它称为“中断”);(2)内部中断指的是在CPU内部执行时,由程序自身、异常、陷阱(例如程序中的断点)产生的中断(包括硬件中断和软件中断,其中软件中断是指一系列的指令)(接下来我们把它称作“异常”)。这两种中断类型不同,产生方式也不一样:(1)中断因为是由外设硬件发出的,所以需要由中断控制器(APIC)参与其中。在中断发出后,首先由APIC来进行处理。这种方式解决两个问题:(a)有大量的外设,而CPU的引脚资源有限,所以不能满足所有的直连需要;(b)如果设备中断和CPU直连,那么在MP系统中,中断负载等需求就无法实现了。可以看到,在x86_64系统下,local APIC通过I/O APIC接受链接,I/O APIC把中断处理成中断消息,并按照规则转发给local APIC。APIC决定了由哪个CPU来处理中断,为某个引脚产生特定的中断向量(中断投递协议),并把中断请求发送给对应的CPU处理。CPU之间的中断通信,也是通过APIC来完成的。I/O设备通过IRQ线与APIC相连,APIC将信号转化为对应的向量,并把这个向量放在它的I/O端口上,允许CPU通过数据总线来读这个向量;随后它发送一个信息给CPU的INTR引脚,从而触发中断,当CPU通过把中断信号写进APIC的I/O端口时,把INTR线清除。目前,外部中断的编号是从32开始,这是由于0-31号中断是留给异常和内部中断使用的,INTEL手册上也给出了这样一个表,详细说明了中断号的对应关系(新的CPU确实用的编号也变多了,就比如#VE): (2)异常则是由CPU自身产生的中断。那么这种中断是否需要APIC介入呢?除了I/O APIC的中断信号外,local APIC还会接收其他来源的中断,例如CPU LINT0/LINT1中断(本地连接的I/O),性能计数器中断、APIC内部错误中断等。但这不意味着所有异常都需要中断控制器的参与;软件中断的中断号是可以由指令直接给出的,因此是不需要中断控制器的参与的。 中断的屏蔽注意,这里的中断屏蔽指的是对外部中断的屏蔽(mask)。CPU内部的中断(异常)是不能屏蔽的。在内核同步中,通常采用这种方式来屏蔽外部的中断;并结合自旋锁来保证中断不被打断。IRQ和NMI分别是可屏蔽和不可屏蔽中断(例子:打印机中断和电源掉电)。CPU在处理NMI中断时,不从外部硬件接收中断向量号,其对应中断向量号固定为2。NMI中断通常用于故障处理(协处理器运算出错、存储器校验出错等危急事件)。NMI处理程序通常应以IRET指令来结束。IRQ则是可以屏蔽的一类中断,通过设置CPU中的IF位,可以对IRQ进行屏蔽,这个标志位可以通过软件来设置。例如在处理某个高优先级中断时,CPU收到了低优先级的中断,那么就会对它进行屏蔽。而对于内核的同步,则是由操作系统内部来实现的。可以说,目前我们讨论的只是中断是如何被送到CPU的,而CPU把中断和异常送给操作系统,并操作系统做出反应的过程,则属于另一个范畴了(在另一片博文http://sec-lbx.tk/2017/02/15/中断相关)。 异常和中断的处理我们可以把内核,理解成一个服务器,它不断处理着用户的各种请求。因此,它需要保证每项服务在处理时,不会互相造成影响;其并发的来源包括内核的抢占和中断处理等。在内核态,中断处理程序也可以嵌套,这种情况下中断处理程序必须永不阻塞,在它运行的期间不能发生进程切换(不过缺页异常是一个例外,它不会引起进一步的异常,所以缺页异常可以切换进程,提高效率)。中断处理程序既可以抢占其他的中断处理程序,也可以抢占异常处理程序。相反的,异常处理程序从不会抢占中断处理程序。如果已经在内核态了,就只可能发生缺页异常(当然,也包含有现在的EPT缺页),但它们不会进一步的进行导致缺页的操作。异常处理程序通常包含三个部分:(1)在内核堆栈保存大多数寄存器的内容;(2)用高级的C函数对异常进行处理;(3)通过ret_from_exception()函数,从异常处理程序退出。中断处理程序与异常处理程序不同,因为当下运行的进程可能和中断完全无关。中断可以分为:I/O中断、时钟中断、处理器间中断。这里,以I/O中断为例。I/O中断必须能够为多个设备同时提供服务,而多个设备却可能会共享一个IRQ线。它往往包含四个部分:(1)在内核态堆栈中保存IRQ的值和寄存器的内容;(2)给为IRQ服务的PIC发一个应答,允许PIC进一步发出中断;(3)执行共享这个IRQ的所有设备的中断服务历程(do_IRQ()会执行与一个中断相关的所有中断服务历程,并且验证它的设备是否需要关注,这也与驱动注册相关);(4)跳转到ret_from_inrt()后终止。IRQ的动态分配:IRQ线可能在最后时刻才和一个设备驱动相关联,这样即使几个硬件设备不共享IRQ,也能够让几个设备在不同时刻使用同一个IRQ向量。 处理器间的中断(IPI)由某个CPU向系统中的其他CPU发送中断信号,它不由IRQ总线,而是由本地APIC的总线传递。Linux定义了这样几种处理器间中断。CALL_FUNCTION:强制所有剩余CPU执行发送者传递过来的函数RESCHEDULE_VECTOR:让被中断的CPU重新调度INVALIDATE_TLB_VECTOR:强制CPU清洗TLB 软中断和tasklet软中断是一种提高运行效率的手段,其核心思想是把不紧迫懂、可以延时处理的中断部分,在中断上下文外,由操作系统自行安排运行时机来运行。tasklet则是建立在软中断之上来实现的,它是I/O驱动中实现可延迟函数的主要方法。对于挂起的软中断,内核会用ksoftirqd进行检查和运行。 工作队列工作队列是内核中,另外一种将工作推后的形式。其特点在于,它允许重新调度和睡眠。其本质就是将工作交给内核线程处理。如果推后执行的任务需要睡眠、或者延时指定的时间再触发,则使用这种形式比较好;倘若推后的任务需要在一个tick内处理,那么还是选择软中断或者tasklet的形式比较好。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"深入理解中断(一)","slug":"中断相关","date":"2017-02-15T13:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/02/15/中断相关/","link":"","permalink":"http://sec-lbx.tk/2017/02/15/中断相关/","excerpt":"","text":"什么是中断?中断是能够打断CPU指令序列的事件,它是在CPU内外,由硬件产生的电信号。CPU接收到中断后,就会向OS反映这个信号,从而由OS就会对新到来的数据进行处理。不同的事件,其对应的中断不同,而OS则是通过中断号(也即IRQ线)来找到对应的处理方法。不同体系中,中断可能是固定好的,也可能是动态分配的。中断产生后,首先会告诉中断控制器。中断控制器负责收集所有中断源的中断,它能够控制中断源的优先级、中断的类型,指定中断发给哪一个CPU处理。中断控制器通知CPU后,对于一个中断,会有一个CPU来响应这个中断请求。CPU会暂停正在执行的程序,转而去执行相应的处理程序,也即OS当中的中断处理程序。这里,中断处理程序是和特定的中断相关联的。 中断描述符表那么CPU是如何找到中断服务程序的呢?为了让CPU由中断号去查找到对应的中断程序入口,就需要在内存中建立一张查询表,也即中断描述符(IDT)。在CPU当中,有专门的寄存器IDTR来保存IDT在内存中的位置。这里需要注意的是,常说的中断向量表,是在实模式下的,中断向量是直接指出处理过程的入口,而中断描述符表除了入口地址还有别的信息。IDTR有48位,前32位保存了IDT在内存中的线性地址,后16位则是保存IDT的大小。而IDT自身,则是一个最大为256项的表(对应了8位的中断码),表中的每个向量,是一个入口。这里IDT表项的异常类型可以分为三种,其表项的格式也不同:任务门:利用新的任务方式去处理,需要切换TSS。它包含有一个进程的TSS段选择符,其偏移量部分没有用,linux没有采用它来进行任务切换。中断门:适宜处理中断,在进入中断处理时,处理器会清IF标志,避免嵌套中断发生。中断门中的DPL(Descriptor privilege Level)为0,因此用户态不能访问中断门,中断处理程序都是用中断门来激活的,并且限制在内核态。陷阱门:适宜处理异常,和中断门类似,但它不会屏蔽中断。以下是32bit中的IDT表项。 IDT门 值得注意的是,CPU还提供一种门,调用门,它是linux内核特别设置的,通常通过CALL和JMP指令来使用,能够转移特权级。 实模式和保护模式在了解CPU是如何通过中断向量表调用具体的服务程序之前,首先需要了解CPU的工作方式。对于IA-32架构,它支持实模式、保护模式和系统管理模式。实模式以拓展对方式实现了8086CPU的程序运行环境,处理器在刚刚上电和重启后时,处于实模式,其寻址空间最大为1M(2^20)。实模式的主要意义,在于提供更好的兼容性,开发者能够直接使用BIOS中断,从而在boot阶段不必关注硬件的具体实现。实模式主要还是为进入保护模式进行准备。8086处理器有16-bit寄存器和16-bit的外部数据总线,但能够访问20-bit的地址,因为它引入了“分段机制”,一个16bit的段寄存器包含了一个64KB的段的基址。而段寄存器+16bit的指针,就能够提供20bits的地址空间。其计算方式为:16位基地址左移4位+16位偏移量=20位。保护模式是处理器的根本模式。保护模式可以直接为实模式程序提供保护的,多任务的环境,这种特性被称为虚拟8086模式,它实际上是保护模式的一种属性。保护模式能够为任何任务提供这种属性。在保护模式中,地址依然通过“段+偏移量”的形式来实现,但此时段寄存器中保存的不再是一个段的基址,而是一个索引。通过这个索引可以找到一个表项,里面存放了段基址等许多属性,这个表项也就是段描述符,而这个表也就是GDT表。保护模式的最大寻址是2^32次方,也即4G,并且可以通过PAE模式访问超过4G的部分。它有4个安全级别,内存操作时,有安全检查。其分页功能带来了虚拟地址和物理地址的区别。系统管理模式为操作系统或者执行程序提供透明的机制去实现平台相关的特性,例如电源管理、系统安全。对于Intel 64架构,它增加了两种子模式。兼容模式允许绝大部分16bit-32bit应用无需编译就能在64bit下运行,它类似于保护模式,有4G的地址空间限制。64bit模式在64bit线性地址空间上运行应用程序,通用寄存器被增加到64bits。它取消了分段机制,其默认地址长度为64bits。 x64寻址在保护模式下(32bit),物理地址的翻译分为两步:逻辑地址翻译(段)和线性地址翻译(页)。逻辑地址利用16bit segment selector和32bit offset来表示。处理器首先要将逻辑地址翻译为线性地址(32bit)。这个翻译过程如下: 通过segment selector,在对应的GDT或LDT中去找到段描述符; 检查段描述符,访问是否合法,段是否能够访问,偏移量是否在范围之内; 将段基地址和偏移量相加来获取线性地址的值。 在IA-32e模式下(64bit),逻辑地址的翻译步骤和上述过程类似,唯一不同的是,其段基地址和偏移量,都是64bit,而不是32bit的。线性地址同理也是32bit的。 段寻址,也即将内存分成不同的段,利用段寄存器能够找到其对应的段描述符,从而获得相关的段基址、大小、权限等信息。段选择子Segment selector的示意图如下: 段选择子 段选择子会被存在段寄存器当中,其中最低两位为RPL(cs寄存器不同,最低位位CPL)。而第三位Table Indicator则是表示该从GDT还是LDT寻找对应的段描述符,后面的bits就是对应的index了。为了减少地址翻译的开销,处理器提供了6个段寄存器,CS,SS,DS,ES,FS,GS。通常来说一个程序至少有CS、DS、SS三个selector。假设程序要使用段来访问地址,那么必须将segment selector载入段寄存器当中。对此,Intel是提供了特殊的指令的,直接载入的指令包括MOV,POP,LDS,LES等。而隐含的载入则包括CALL,JMP,RET,SYSENTER等等。它们会改变CS寄存器(有时也会改变其它段寄存器)的内容。 而在IA-32e模式下(64bit mode),ES,DS,SS段寄存器都不会使用了,因此它们的域会被忽视掉,而且某些load指令也被视为违法的,例如LDS。与ES,DS,SS段有关的地址计算,会被视为segment base为0。为了保证兼容性,在64bit mode当中,段load指令会正常执行,从GDT、LDT中读取时,也会读取寄存器的隐藏部分,并且值都会正常的载入。但是data、stack的segment selector和描述符都会被忽略掉。而FS和GS段在64bit mode能够手动使用,它们的计算方式为(FS/GS).base+index+displacement。用这种方式去进行内存访问时,是不会进行检查的。载入的时候不会载入Visible Part,也即Segment Selector,也就是把段机制给忽略了。 IDT,LDT和GDT中断向量表提供了一个入口,但这个入口还需要进一步的计算。这个入口的计算,是通过段寻址来实现的。而段的信息,则是保存在LDT和GDT当中。段描述符的结构如下图: 段描述符 段描述符最重要的部分是DPL位,它会在权限检查的时候使用。在进程需要装载一个新的段选择子时,会判断当前的CPL和RPL是否都比相应的DPL权限高,如果是则允许加载新的段选择子,否则产生GP。在操作系统中,全局描述符只有一张,也即一个CPU对应一个GDT。GDT可以存放在内存中的任何地址,但CPU必须知道GDT的入口,因此有一个寄存器GDTR用来存放GDT的入口地址,它存放了GDT在内存中的基址和表长。但是在64位系统当中,段机制就被取代了,而页表项也能够达到数据访问的保护目的。但是对于不同特权级之间的控制流转移,还是和原来的机制一样。在64-bit模式中,GDT依然存在,但不会改变,而其寄存器被拓展到了80bit。而GDT中会包含一个LDT段的段描述符,LDT是通过它的段描述符来访问的。 在IA-32e模式下,段描述符表可以包含2^13个8-byte描述符。这里,描述符分为两种,段描述符会占据一个entry(8bit),而系统描述符会占据两个entry(16bit)。而GDTR和LDTR被拓展为能够保存64bit的基地址。其中,IDT描述符、LDT、TSS描述符和调用门描述符都被拓展称为了16bytes。 64bit IDT描述符的格式如下 64bit LDT描述符的格式如下 中断的处理过程在intel 手册上看到的大图,很详细的解释了IA-32模式和IA-32e模式下的系统架构,它也就包含了中断处理和线性地址的翻译过程。 IA-32e 在中断产生之后,处理器会将中断向量号作为索引,在IDT表中找到对应的处理程序。IDT表将每个中断/异常向量和一个门描述符关联起来。在保护模式下,它是一个8-byte的描述符(与GDT,LDT类似),IDT最大有256项。IDT能够保存在内存中的任何位置,处理器用IDTR寄存器来保存它的值。在中断/陷阱门描述符中,segment selector指向了GDT或当前LDT中的代码段描述符,而offser域指向了exception/interrupt的处理过程。 IA-32e 在执行call这一步的时候,倘若handler过程会在一个更低的权限执行,那么就会涉及到stack switch。当stack switch发生时,segment selector和新的栈指针都需要通过TSS来获取,在这个栈上,处理器会把之前的segment selector和栈指针压入栈中。处理器还将保存当前的状态寄存器在新的栈上。如果handler过程会在相同的权限执行,处理器会把状态寄存器的值保存在当前的栈上。从中断处理程序返回时,handler必须使用IRET指令。它与RET类似,但它会将保存的标志位恢复到EFLAGS寄存器中。如果stack switch在调用过程中发生了那么IRET会切换到中断前的stack上。在中断过程中,权限级的保护与CALL调用过程类似,会对CPL进行检查。 64-bit模式下的中断处理在64bit模式下,中断和异常的处理与非64bit模式下几本一致,但也存在一些不同的地方。包括有: IDT所指向的代码是64bit代码 中断栈push的大小是64bit 栈指针(SS:RSP)在中断时,无条件的被push(保护模式下是由CPL来决定的) 当CPL有变化时,新的SS会被设置为NULL IRET的过程不同 stack-switch的机制不同 中断stack的对齐不同 其中,64bit的IDT门描述符在前面已经介绍了。IST(interrupt Stack Table)用于stack-switch。通过中断门来调用目标代码段时,它必须为一个64bit的代码段(CS.L=1,CS.D=0)。如果不是也会触发#GP。在IA-32e模式下,只有64bit的中断和陷阱门能够被调用,遗留的32bit中断/陷阱都被重新定义为64bit的。 在遗留模式中,IDT entry的大小是16/32bit,它决定了interrupt-stack-frame push时的大小。并且SS:ESP只在CPL发生改变时被压入stack中。在64bit模式下,interrupt-stack-frame push的大小被固定为8bytes(因为只有64bit模式的门能够被调用),而且SS:RSP是无条件压入栈中的。遗留模式下,Stack pointer能够在任何地址进行push,但是IA-32e模式之下,RSP必须是16-byte边界对齐的,而stack frame在中断处理程序被调用时也会对齐。而在中断服务结束时,IRET也会无条件的POP出SS:RSP,即使CPL=0。 IA-32e模式下,stack-switching机制被替代了,它被称为interrupt stack table(IST)。遗留模式下,在64bit中,中断如果造成了权限级的改变,那么stack就会switch,但是这时不会载入新的SS描述符,而只会从TSS中载入一个inner-level的RSP。新的SS selector被强制设置为NULL,这样就能够处理内嵌的far transfers。而旧的SS和RSP会被保存在新的栈上。也就是说stack-switch机制除了SS selector不会从TSS加载之外,其余都一样。而新的IST模式,则是无条件的进行stack switch。它是基于IDT表项中的一块区域实现的,它的设计目的,是为特殊的中断(NMI、double-fault、machine-check)等提供方法。在IA-32e模式下,一部分中断向量能够使用IST,另一部分能够使用遗留的方法。IST在TSS中,提供7个IST指针,在中断门的描述符当中,由一个3bit的IST索引位,它们用来找到TSS中IST的偏移量。通过这个机制,处理器将IST所指向的值加载到RSP当中。而当中断发生时,新的SS selector被设置为NULL,并且SS selector的RPL区域被设置为新的CPL。旧的SS、RSP、RFLAGS、CS和RIP被push入新的栈中。如果IST的索引为0,那么就会使用修改后的、旧的stack-switch机制。 保护机制Intel 64/IA-32架构提供了段/页级别的保护机制,它们利用权限级,来限制对于的段/页的访问,例如重要的OS代码和数据能够被放在更高权限级的段中,操作系统会保护它们不被应用程序访问。当保护机制启用时,每次内存访问都会被检查,这些检查包括: Limit Tyep Privilege level Restriction of Procedure entry-points Restriction of instruction set 通过CR0寄存器当中的PE flag能够开启保护模式,打开段保护机制;而页保护机制则是在分页机制启用时,自动开启的。虽然64bit中,不再使用分段机制了,但代码段依然存在。对于地址计算来说,其段地址被视为0,CS描述符当中的内容被忽略,但其余部分保持一致。代码段描述符、selector依然存在,它们在处理器的操作模式、执行权限级上依然发挥作用。其工作方式如下: CS描述符中会使用一个保留位,Bit 53被定义为64 bit flag位(L),并且被用来在64bit/兼容模式之间切换。当CS.L = 0时,CPU处于兼容模式,CS.D则决定了数据和地址的位数为16/32bit。如果CS.L为1,那么只有CS.D = 1是合法的,并且地址和数据的位数是64bit。在IA-32e模式下,CS描述符当中的DPL位被用来做执行权限的检查(与32bit模式一样)。 代码段 Limit Checking在段描述符当中,有一个limit field,它防止程序访问某个段之外的的内存位置,其有效值由G flag来决定,对于数据段来说,其limit还由E flag和B flag决定。在64bit模式下,处理器不会对代码段活着数据段进行limit check,但是会对描述符表的limit进行检查。 Type checking段描述符包含两个type 信息,S flag和type field。处理器会使用这个信息,来检查对段和门的不正确使用。S flag表示descriptor的类型,它包括系统/代码/数据三种类型。在处理一个段选择子时,处理器会在:将segment selector载入段寄存器:寄存器只能包含对应的描述符类型指令访问段时:段只能被相应的指令访问指令包含segment selector时:指令只能对某些特定类型的段/门进行访问进行某些具体操作时:far call、far jump,对调用门、任务门的call/jump等,会判断描述符中的类型是否符合要求。 Privilege levels处理器的段保护机制包含有4个privilege levels,从0到3,0最高,3最低。处理器利用这种机制,来防止一个低权限的进程,访问更高权限的部分。为了实现这个目的,处理器使用3种类型的权限级:CPL:当前执行任务的权限级。它保存在CS和SS段寄存器的bit 0-1中。通常,CPL和当前代码段的权限一致,当跳转到一个有不同权限的代码段时,CPL会发生变化。如果目标是一致代码段,则会延续当前的CPL。DPL:segment或者gate的权限级。它保存在段或者门的描述符当中,当当前的代码段执行,需要访问一个段或者gate的时候,这个段/门的DPL就会被拿来与CPL和RPL进行比较。在不同的环境下,DPL的意义也是不同的。RPL:与segment selector有关的,能够对权限进行覆盖的权限级。它保存在segment selector的bit 0-1中。处理器会通过CPL和RPL来判断对segment的访问是否合法。即使请求访问某个段的程序,拥有比段更高的权限,如果RPL不是有效的,访问还是会被拒绝。也就是说如果RPL把CPL高,那么RPL会覆盖CPL。RPL能够保证提权的代码,不能随意访问一个segment段,除非它自身有这个权限。直观的说,必须CPL和RPL都比DPL要高,只有这种情况下,才会允许这个段的访问。其主要目的,是允许高权限为低权限提供服务的时候,能够通过较低的权限来加载段。门调用符与权限检查: TSS处理器执行工作的单位,被称为task。一个task分为两个部分,task的执行空间和task-state segment(TSS)。前者指的是code/stack/data segment,而后者则定义了组成前者的各个段。task是由TSS的segment selector来识别的。当一个任务被加载到处理器中执行时,segment selector、基址、limit、TSS的段描述符等都会被加载到task register(TR)当中去。分页启动时,页目录的基址还会载入到控制寄存器CR3当中去。一个任务的状态,由一系列的寄存器和TSS来定义。这里,处理器定义了5个数据结构,来处理任务相关的活动。 TSS Task-gate描述符 TSS描述符 Task寄存器 EFLAGS寄存器中的NT flag 为了恢复一个task,处理器所需要的信息,保存在一个系统段中,它被称为TSS。在64bit模式下,它的格式如下: gate 而TSS描述符,则和其他的段一样,是由一个段描述符来定义的,它的结构在上文中已经给出了(与LDT是一致的),它只能放在GDT当中,不能放在LDT或者IDT当中。 Task寄存器保存了当前TSS的段选择子和整个段描述符。它包含可见和不可见两个部分(能否被软件修改)。段选择子位于可见部分,指向GDT当中的TSS描述符。不可见部分则是用来保存TSS的段描述符(能够提高执行效率)。","raw":null,"content":null,"categories":[],"tags":[{"name":"系统安全","slug":"系统安全","permalink":"http://sec-lbx.tk/tags/系统安全/"},{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"操作系统","slug":"操作系统","permalink":"http://sec-lbx.tk/tags/操作系统/"}]},{"title":"SGX虚拟化相关","slug":"kvm-sgx笔记","date":"2017-01-17T04:00:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2017/01/17/kvm-sgx笔记/","link":"","permalink":"http://sec-lbx.tk/2017/01/17/kvm-sgx笔记/","excerpt":"","text":"KVM-SGX虚拟机中的epc,使用sgx_vm_epc_buffer结构来管理的。其数据结构如下: struct sgx_vm_epc_buffer { struct list_head buf_list; struct list_head page_list; unsigned int nr_pages; unsigned long userspace_addr; __u32 handle; }; Kai Huang添加了sgx_vm.c。 __alloc_epc_buf:申请一个新的epc buffer,在这里对每一个页,都申请了一个iso_page。它调用sgx_alloc_vm_epc_page来完成具体的申请。这个函数定义在sgx_alloc_epc_page当中,实际上调用的就是sgx_alloc_epc_page。 __get_next_epc_buf_handle():每一个epc buffer都有一个handle,这个handle相当于一个标识,利用handle来找到对应的epc buffer,这个函数用来在申请一个epc_buf的时候,获取它的handle。 __free_epc_buf():释放epc buffer,这里同样也要完成对应的iso_pages的释放。 __sgx_map_vm_epc_buffer():按页实际完成按页的映射,它调用了vm_insert_pfn函数。它进而调用vm_insert_pfn_prot,这个函数位于mm/memory.c中,也即完成一个页的映射(pfn to pfn)。 在kvm_main.c中,hva_to_pfn函数被进行了修改,这是为了让pfn的映射,支持到EPC这一段内存,而epc并不是连续的,所以需要从页表中搜索到pfn。hva_to_pfn(),其参数address为host virtual address,通过一个guest页的hva,来找到对应的pfn。它调用了follow_pfn(使用user virtual address来查找一个页框)。 只有在hva_to_pfn_fast/hva_to_pfn_slow都无法找到pfn时,才会使用这种方法。因为EPC不是连续的内存段,所以会用这种方法特殊处理。 arch/x86/kvm/vmx.h/vmx.c中,做了VMX和SGX交互的修改。 首先对于一部分enclave中不允许使用的指令,例如CPUID,INVD,做了#GP处理。 vmx_exit_from_enclave():enclave中发生了VMEXIT的情况。这里使用了VM_EXIT_REASON的bit 27和GUEST_INTERRUPTIBILITY_INFO中的bit 4来表示VMEXIT的原因。 vmx_handle_exit是对exit的具体处理。利用VM_EXIT_REASON的bit 27,可以判断这个exit是在enclave当中发生的。这个函数确定exit的类型,再交由对应的handler进行处理。 QEMU-SGX在target-i386/kvm.c当中,qemu首先为isgx定义了一个设备node:/dev/sgx,并且定义了VM中SGX的状态SGXState。在kvm.c中,定义了epc的alloc、free #define SGX_IOC_ALLOC_VM_EPC _IOWR(SGX_MAGIC, 0x03, struct sgx_alloc_vm_epc) #define SGX_IOC_FREE_VM_EPC _IOW(SGX_MAGIC, 0x04, struct sgx_free_vm_epc) 这两个宏定义,最后交给了struct中对应的handle来处理,也即ISGX驱动中对应的函数。而qemu内部的接口则是kvm_alloc_epc/kvm_free_vm_epc。kvm.c中,还定义了epc的初始化和销毁。epc的计算是在vcpus创建之前完成的,这里epc的大小被限制在256M之内,它被放置在below_4g_memory_size的位置,位于PCI的基址之下。在target-i386/cpu.c中,添加了对cpuid对应功能的支持。 在/hw/i386/acpi-build.c当中,添加了EPC的ACPI table项。","raw":null,"content":null,"categories":[],"tags":[{"name":"SGX","slug":"SGX","permalink":"http://sec-lbx.tk/tags/SGX/"},{"name":"KVM","slug":"KVM","permalink":"http://sec-lbx.tk/tags/KVM/"},{"name":"QEMU","slug":"QEMU","permalink":"http://sec-lbx.tk/tags/QEMU/"}]},{"title":"Chrome隔离/SOP策略","slug":"Chrome浏览器安全","date":"2016-12-27T13:00:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/12/27/Chrome浏览器安全/","link":"","permalink":"http://sec-lbx.tk/2016/12/27/Chrome浏览器安全/","excerpt":"","text":"The “Web/Local” Boundary is FuzzyProcess-based隔离,是浏览器安全的基础。Chrome的设计,并不隔离每个web源,而是主要保证“本地系统”和“web”,但是由于Dropbox,Google Drive这样的web端云服务(这些服务和本地系统一体化了),浏览器不再能保证web和本地系统的隔离了。这篇文章提出了chrome中存在的一个问题,如果process-based隔离,忽略了同源策略,那么就很难保证web/local隔离的有效性。这篇文章提出,利用renderer中存在的漏洞,能够将可执行文件、脚本存放到本地文件系统中,肆意安装应用,滥用传感器等。这个攻击也是一个完全data-oriented的攻击,它不需要去劫持程序的控制流,或者引入外部代码。 浏览器隔离保护技术web浏览器,在对象、资源的划分上,一直存在漏洞,这也导致一个binary-level的漏洞,能够让攻击者劫持整个browser。为了对抗这些恶意的网站,现代的浏览器架构,普遍应用了sandbox技术来加强web域和local域之间的隔离。sandbox保证,即使浏览器中出现了memory errors,也不会影响到本地的系统。在此之上,现代浏览器还采用了process隔离的设计。这个设计的目标,是进一步将exploit的影响限制在边界之内,这样其它进程就不会受到影响。Google chrome和IE都应用了这种设计。Goolge chrome从两方面进行了隔离:其一是将浏览器的kernel process和renderer process隔离;其二是将website或tabs,划分到不同的renderer process当中去。为了对性能进行提升,chrome降低了sandbox的粒度,放弃了SOP(同源策略)。SOP的任务完全交给了renderer来承担。 chrome web/local隔离chrome中,进程分为两类: kernel,用来和本地系统交互; renderer,负责显示网页。 kernel process,负责网络请求,访问cookies,显示bitmaps;而renderer process则负责解释web文件(html,ccs,js)。每个renderer process都被限制在一个sandbox中,也只能通过kernel process来访问有限的资源。chrome的sandbox技术,是借助于操作系统的。例如在Linux当中,Chrome有两层sandbox,第一层为不同的renderer process创建不同的PID namespaces和网络资源;第二层保护browser kernel不受用户空间中恶意代码的影响。因此renderers只能通过IPC call,通过kernel来访问有限的资源。 chrome SOP策略在chrome中,SOP是由renderer process来全权负责的。它负责脚本只能在同源时,跨站进行访问。例如途中,当A的脚本访问B中对象时,首先要通过security monitor进行检查。对于process内部,chrome将无关的数据放置在不同的区域中。例如,对每个renderer,其heap都是分隔开的,chrome随机化每个部分的起始地址,并用guardpages进行保护。 绕过chrome SOPGoogle chrome采用了共享一个renderer process的架构,因此SOP检查必须放在同一个security monitor,它位于renderer进程中。如果能够篡改monitor中的关键数据,就能够绕过SOP,在A中获得B的权限来执行任意代码。在chrome中,security monitor是由一系列的函数调用来实现的。在内存中,保存了大量的flags、以及SOP检查的记过。而从A,或者B中,均能够对这些数据区域进行访问。在chrome当中,甚至还有能够对全局进行访问的标志位。 bool SecurityOrigin::canAccess(const SecurityOrigin* other) const { if (m_universalAccess) return true; if (this == other) return true; if (isUnique() || other->isUnique()) return false; return canAccess; } 而利用系统中的漏洞,对这些critical data进行修改,就能够实现肆意的访问、执行。攻击的第一步,是绕过ASLR。在攻击者的脚本中,是能够创建一个layout形式可以预测的object的。攻击者通过线性的扫描,是能够确认object的位置的。而这个object位置,就揭露了随机化的地址。第二步,是绕过浏览器内部的隔离。这里,chrome采用的是一种in-memory的隔离方式,也就是将无关的数据放在分隔的部分中去。这里,不同的隔离区被guard pages保护了,并且其位置也是随机化的。然而,通过利用指针,进行跨隔离区的引用,也能够识别出隔离区内的地址。","raw":null,"content":null,"categories":[],"tags":[{"name":"浏览器安全","slug":"浏览器安全","permalink":"http://sec-lbx.tk/tags/浏览器安全/"},{"name":"chrome","slug":"chrome","permalink":"http://sec-lbx.tk/tags/chrome/"}]},{"title":"内存攻击与保护","slug":"memory-war","date":"2016-12-05T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/12/05/memory-war/","link":"","permalink":"http://sec-lbx.tk/2016/12/05/memory-war/","excerpt":"","text":"memory corruptionStep 1: 一个指针变成非法的指针第一种:越过它所引用的对象的边界(out-of-bounds pointer),spatial error第二种:它所指向的对象已经被释放(dangling pointer),temporal error Bugs lead to Out-of-bounds: allocation failure –> null pointer 过多的增/减 array pointer –> buffer overflow/underflow indexing bugs –> pointer 指向任意位置 Bugs lead to dangling:objects释放了,但指向它的指针没有释放(大多数objects在堆上,但堆栈上多objects如果用了全局的pointer,也会出现dangling pointer) Step 2: dereferences the pointerdereferences the pointer: 读/写 Step 3: corruption/leakage of dataOut-Of-Bounds1 read from memorypointer指向了攻击者控制的位置,那么取的值就被攻击者操纵了 - 指针指向控制相关的数据,现在攻击者将它指向了恶意的转移目标 --> divert control-flow - 指针指向输出数据,现在攻击者将它指向了一些隐私数据 --> leaks information 2 write to memorypointer指向写的地址,那么攻击者就能够篡改内存中的任意数据 - 篡改一个控制相关的变量,例如vtable,返回地址 --> divert control-flow - 篡改一个输出的地址(另一个指针) --> leaks information Dangling pointer由deallocated object释放的memory,会被另一个object重新使用,而两种object的type是不同的,因此新的object会被解释为旧的object。1 read from memory旧的object的vpointer –> divert control-flow新的object中敏感数据可用旧的object输出 –> leaks information2 write to memory旧的object在栈上 –> divert control-flow(return address)double-free leads to double-alloc –> arbitraty wirtes attack type控制流劫持:divert control-flowdata-only攻击:gain more control,gain privilegesInformation leak:leaks information ProtectionsProbabilistic: Randomization/encryptionDeterministic: Memory Safety/Control-flow Integrity其中Deterministic protections又可以分为hardware/software两种其中,software的方法,可以通过静态/动态两种插桩方式来实现。 Probabilitic MethodsProbabilitic Method依赖于随机化,包括有:Address Space Randomization:代码和数据段的位置,在W⊕X后,主要应用于codeData Space Randomization:对所有的变量进行加密 Deterministic MethodsMemory Safety空间安全-指针边界:指针能指向的地址范围空间安全-对象边界:对象的地址范围,比指针边界的兼容性更好时序安全-特殊的allocators:申请内存时避免use-after-free,例如只使用相同类型的memory时序安全-基于对象:在shadow memory中标记释放内存,但如果这段内存被重新申请则无效时序安全-基于指针:在内存释放/申请时,更新指针信息 Generic Attatck DefensesData Integrity不关注时序安全,只保护memory写,不保护memory读。safe objects integrity:分析出不安全的指针和对象,利用shadow memory记录对应关系points-to sets integrity:分析出不安全的指针和对象,限制其points-to set的对应关系Data-Flow Integrity通过检查read指令,检测任何数据的corruption(上一次write是否合法),同样使用ID和set的方式。 Control-Flow Hijack DefensesCode Pointer Integrity防止Code Pointer被篡改,例如对Code pointer进行加密等。Control Flow Integrity动态return integrity:对返回值进行保护,如shadow stacks。static CFI:求出控制流转移的集合,在运行时检查控制流转移是否合法。","raw":null,"content":null,"categories":[],"tags":[{"name":"底层安全","slug":"底层安全","permalink":"http://sec-lbx.tk/tags/底层安全/"},{"name":"二进制","slug":"二进制","permalink":"http://sec-lbx.tk/tags/二进制/"}]},{"title":"SGX SDK相关","slug":"linux-sgx-sdk","date":"2016-11-24T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/11/24/linux-sgx-sdk/","link":"","permalink":"http://sec-lbx.tk/2016/11/24/linux-sgx-sdk/","excerpt":"","text":"Enclave Definition LanguageEDL文件,用来描述enclave当中的trusted和untrusted部分。在Linux中,Edger8r Tool通过这个文件来创建enclave的C wrapper函数,也即ECALL和OCALL所使用的函数。 //EDL Template enclave { //包含文件和作为参数的数据结构 trusted { //任何enclave_t.h中包含的文件 //trusted function原型 }; untrusted { //任何enclave_u.t中包含的文件 //untrusted function原型 }; }; EDL文件不允许include有自定义类型的头文件。对于全局的包含文件,也不会包含在enclave当中。这类情况会使用不同的头文件,例如SGX会利用SDK所提供的stdio.h,而应用会使用由编译器提供的stdio.h。 EDL中的数据EDL中可以使用基本的关键字,包括 char, short, long, int, float, double, void, int8_t, int16_t, int32_t, int64_t, size_t, wchar_t, uint8_t, uint16_t, uint32_t, uint64_t, unsigned, struct, enum, union 其它类型可以在头文件中包含。用户定义的数据类型可以在EDL中使用,但要遵守其编写的规范。正确的定义如下 enclave{ include "user_types.h" struct struct_foo_t { uint32_t struct_foo_0; uint64_t struct_foo_1; }; enum enum_foo_t { ENUM_FOO_0 = 0, ENUM_FOO_1 = 1 }; }; trusted { public void test_char(char val); public void test_int(int val); public void test_long(long long val); }; EDL中的指针EDL中定义有一些和指针一起使用的值,这些值是用在ECALL和OCALL时使用的参数上的。指针需要用in,out,或者user_check进行明确的修饰。其中[in]和[out]说明的是方向: [in]表示参数从调用方传递到被调用放,对ECALL来说,in是从应用程序传递到enclave中,对OCALL来说则表示参数从应用程序传递到enclave中。 [out]表示参数是从被调用方返回到调用方。对ECALL来说,out表示参数从enclave传递到应用中,对OCALL来说则是从应用传递给enclave。 [in]和[out]组合使用表示参数是双向传递的。 方向属性能够用来提供保护,但会降低性能。如果使用user_check,则表示在不可信内存中的数据会在使用前进行验证。但[in]和[out]不支持包含有指针的结构体,这种情况必须使用user_check,并进行手动的验证。为了保证copy指针指向数据的安全性,它们还会和size,count,sizefun等一起使用。 其它数据类型string和wstring表示参数是一个以NULL结束的字符串。 EDL支持用户定义的数据类型,但是不能定义在头文件中。任何使用typedef的基本类型,也都是用户定义的数据类型。有一些数据类型必须指定EDL属性,例如isptr,isary,readonly等,否则edger8r在编译时会报错。 propagate_error是OCALL的一个属性,如果使用这个属性,则enclave中的errno属性,会在OCALL返回之前被覆写为untrusted域中的errno中的值。在OCALL完成之后,无论OCALL是否成功,trusted域中的都会在OCALL完成之后更新。如果function失败了,那么errno就会检查是否出错,而如果函数成功了,那么OCALL就被允许修改errno的值。 ECALL访问/配置默认的情况下,ECALL函数是不能直接被任何untrusted functions调用的。为了允许应用程序直接调用一个ECALL函数,则这个ECALL必须用public关键字来修饰。Enclave配置文件是一个XML文件,它包含了用户定义的enclave参数,也是enclave项目的一部分。sgx_sign利用这个文件作为输入,来创建enclave的signature和metadata,它包括有这些项: <EnclaveConfiguration> <ProdID>100</ProdID> <ISVSVN>1</ISVSVN> <StackMaxSize>0x50000</StackMaxSize> <HeapMaxSize>0x100000</HeapMaxSize> <TCSNum>1</TCSNum> <TCSPolicy>1</TCSPolicy> <DisableDebug>0</DisableDebug> <MiscSelect>0</MiscSelect> <MiscMask>0xFFFFFFFF</MiscMask> </EnclaveConfiguration> Enclave加载Enclave的源代码被编译为一个共享对象,为了使用一个enclave,enclave.so应该通过调用sgx_create_enclave()函数来加载到受保护的内存中。在第一次加载一个enclave时,加载器会获取launch token并且将它保存到token参数当中,用户能够将它保存在一个文件中,并且在之后加载时,从文件中获取token。而卸载enclave则是由用户调用sgx_destory_enclave(sgx_enclave_id_t)来实现的。 #define ENCLAVE_FILE _T("Enclave.signed.so") sgx_enclave_id_t eid; sgx_status_t ret = SGX_SUCCESS; sgx_launch_token_t token = {0}; int updated = 0; //创建 sgx_create_enclave(ENCLAVE_FILE, SGX_DEBUG_FLAG, &token, &updated, &eid, NULL); //摧毁 sgx_destroy_enclave(eid); Untrusted/Trusted Library Functionsuntrusted函数只能在应用中,也就是enclave的外部调用。这些函数包括: Enclave的创建和摧毁 Quoting(用来确定处于SGX环境中) untrusted key交换 平台服务和启动控制 trusted库和enclave binary静态链接,它们只能在enclave内部使用。Trusted Runtime System是SDK的一个关键组件,它提供enclave的入口逻辑,其他的helper函数,以及自定义的异常处理。Trusted Service Library对数据进行保护,它包括有: - Wrapper函数- sealing/unsealing- Trusted平台服务函数 SDKSGX的SDK目前提供了两种模式,hardware模式,也即在物理上拥有sgx的计算机上能够使用;而simulation则是模拟拥有sgx的计算机。在DEBUG模式中,开发者能够直接使用被签名过的enclave.signed.so,而不需要自己去进行签名。","raw":null,"content":null,"categories":[],"tags":[{"name":"系统安全","slug":"系统安全","permalink":"http://sec-lbx.tk/tags/系统安全/"},{"name":"SGX","slug":"SGX","permalink":"http://sec-lbx.tk/tags/SGX/"}]},{"title":"PID namespace","slug":"PIDnamespace","date":"2016-10-10T13:00:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/10/10/PIDnamespace/","link":"","permalink":"http://sec-lbx.tk/2016/10/10/PIDnamespace/","excerpt":"","text":"PID NamespacePID namespace能够允许创建任务的集合,使得这样的集合像一个独立的机器一样运行。在不同的namespace中,任务能够拥有相同的ID。PID Namespace主要能够解决不同host之间,containers的移植问题,由于使用了一个独立的namespace,从一个host移植到另一个host时,是能够保持PID值不变的。如果没有这个特性,那么移植过程很可能失败,因为具有相同ID的进程可能在目标node上是存在的,这样就会造成冲突。PID namespaces也是层次结构的,在一个新的PID namespace被创建了,相同PID namespace中的所有task都能够互相可见,但是新的namespace不能看到之前namespace中的task,也就是说任务可能会有多个pid,每个namespace对应一个。 Userspace API为了创建一个新的namespace,进程需要调用clone系统调用,并且使用CLONE_NEWPID标识位。在一个新的namespace当中,第一个task的PID是1,它也就是这个namespace的init,以及child_reaper。但这个init是可以死亡的,此时这个namespace都会终止。在把tasks分割出来之后,还必须对proc进行处理,让它只显示当前task可见的PID。为了实现这个目的,procfs应该在每个namespace被使用一次。 Internal API一个task所拥有的所有PID都在struct pid中被描述了。这个数据结构如下: struct upid { int nr; /* moved from struct pid */ struct pid_namespace *ns; /* the namespace this value * is visible in */ struct hlist_node pid_chain; /* moved from struct pid */ }; struct pid { atomic_t count; struct hlist_head tasks[PIDTYPE_MAX]; struct rcu_head rcu; int level; /* the number of upids */ struct upid numbers[0]; }; 这里,struct upid表示PID值,它储存在hash当中,并且拥有PID值。为了转换得到这个pid值,可以使用task_pid_nr,pid_nr_ns(),find_task_byvpid等函数。这些函数的后缀有一些规律:\\_nr():对“全局”的PID进行操作,这里全局指的是在整个系统中也是独一无二的。pidnr会告诉你struct pid的global PID,这只在PID值不会离开kernel时使用。\\_vnr():对“virtual”PID进行操作,也就是进程可见的ID,例如task_pid_vnr会告诉你一个task的PID。_nr_ns():对指定namespace中的PID进行处理,如果希望得到某个task的PID,可以通过task_pid_nr_ns来获得pid number,在用find_task_by_pid_ns来找到这个task。这个方法在系统调用中很常见,特别是当PID来自用户空间时。在这种情况下,task可能是在另一个namespace中的。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux内核","slug":"linux内核","permalink":"http://sec-lbx.tk/tags/linux内核/"},{"name":"PID namespace","slug":"PID-namespace","permalink":"http://sec-lbx.tk/tags/PID-namespace/"}]},{"title":"namespace API","slug":"userspaceAPI","date":"2016-10-10T12:00:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/10/10/userspaceAPI/","link":"","permalink":"http://sec-lbx.tk/2016/10/10/userspaceAPI/","excerpt":"","text":"namespace APInamespace的API包含3个系统调用——clone,unshare,setns,以及一系列的/proc文件。为了指定操作的namespace类型,这3个系统调用都使用了一个CLONE_NEW常量(CLONE_NEWIPC,CLONE_NEWNS,etc)。 clone通过clone,可以创建一个namespace,它是一个创建新的process的系统调用。其函数原型为: int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg); clone可以看作fork()的通用版本,其功能能够通过flags参数CLONE_*来控制,这些参数包含了parent和child是否共享虚拟内存、打开文件描述符等。而如果参数中CLONE_NEW位被指定了,那么就会创建一个新的,对应类型的namespace,而新的进程则成为这个namespace中的一个成员。和大多数其他的namespaces一样,创建一个UTS namespace是需要特权的,例如CAP_SYS_ADMIN,这对于避免需要设置user ID的应用来说是有必要的:如果能够使用任意的hostname,那么一个非特权用户就能够破坏lock file的作用,或者能够改变应用的行为。 /proc文件对于每一个进程来说,都有一个/proc/PID/ns目录,这其中每一种类型的namespace,都对应了一个文件。从linux 3.9开始,这些文件都被符号链接,作为处理这个进程相关namespace的handler。 $ ls -l /proc/$$/ns # $$ is replaced by shell's PID total 0 lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 net -> net:[4026531956] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 pid -> pid:[4026531836] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 user -> user:[4026531837] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 uts -> uts:[4026531838] 这些符号链接的作用之一,就是用来检查两个进程是否处于同一个命名空间当中。kernel保证如果两个进程在同一个namespace当中,那么/proc/PID/ns中的inode number就会是一致的。inode numbers能够通过stat()系统调用来得到。但是,kernel还是会构造/proc/PID/ns的符号链接,并使得它指向字符串,这个字符串包含了namespace的类型和inode number。如果这个符号链接被打开,那么即使namespace中的进程全部终止了,namespace也不会消被清除。 setnssetns可以被用来加入一个已存在的namespace。保持一个没有任何进程的namespace,是因为随时可以加入新的进程到这个namespace当中去,这也是setns系统调用的作用。其函数原型为: int setns(int fd, int nstype); 更准确的说,setns解除一个进程和之前对应nstype的namespace的联系,并且将其关联到新的,对应类型的namespace中去。这里,fd指定了对应的namespace,它是/proc/PID/ns目录下的一个文件描述符。而nstype则会用来检查fd指向的namespace的类型。利用setns和execve,能够构造一个很有效的工具:一个加入指定namespace然后再namespace中执行一条命令的程序。从linux 3.8开始,setns能够加入任何类型的namespace。 unshareunshare用来离开namespace。unshare的功能类似于clone,它创建一个新的namespaces,并且让调用者称为这个命名空间的一部分。它的主要目的,是在不创建新的进程或线程的前提下,完成namespace的分离工作。 clone() 和 if(fork() == 0) unshare() 是等价的","raw":null,"content":null,"categories":[],"tags":[{"name":"linux内核","slug":"linux内核","permalink":"http://sec-lbx.tk/tags/linux内核/"},{"name":"namespace","slug":"namespace","permalink":"http://sec-lbx.tk/tags/namespace/"},{"name":"container","slug":"container","permalink":"http://sec-lbx.tk/tags/container/"}]},{"title":"namespace相关","slug":"namespace相关","date":"2016-10-10T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/10/10/namespace相关/","link":"","permalink":"http://sec-lbx.tk/2016/10/10/namespace相关/","excerpt":"","text":"namespace当前,linux实现了6种不同类型的namespaces。每种namespace,都用来包含一类特定的系统资源,这样从命名空间内部的进程来看,它们就拥有了隔离的全局资源。namespaces的一个目标就是容器,一种轻量级的虚拟化工具,让一组进程认为它们是系统上仅有的一组进程。 mount namespacesmount namespace(CLONE_NEWNS)隔离一组进程所能看到文件系统mount点。在不同mount namespaces中的进程,对于文件系统有不同的视图。在使用了mount namespaces之后,mount和umount系统调用不再对所有进程可见的,全局的mount points进行操作,而是只会影响和发起调用的进程相关的mount namespace。利用主从关系,还可以让一个mount namespace自动拥有另一个mount namespace的内容,例如一个硬盘设备挂在到某个namespace中后会自动显示在另一个namespace中。mount namespace是linux上实现的第一种namespace。 UTS namespacesUTS namespace(CLONE_NEWUTS)隔离两种系统标识符:nodename和domainname。在容器的上下文环境中,UTS namespaces特性允许每个容器拥有自身的hostname和NIS domain name。这允许了根据容器的name来定义它们的行为,uts指的是UNIX Time-sharing System,它是传递给uname系统调用的参数。 IPC namespacesIPC namespaces(CLONE_NEWIPC)隔离inter-process communication resources,也即跨进程的通讯资源,System V IPC,以及POSIX message queues。这些IPC机制的共性时,IPC objects是由特殊机制来进行识别的,而不是文件系统的路径。在每个namespaces当中,又有其自身所拥有的System V IPC标识符和POSIX message queue filesystem。 PID namespacesPID namespaces(CLONE_NEWPID)隔离进程ID空间,也就是说,不同PID命名空间的进程,可以拥有相同的PID。这样做的一个好处是,容器能够在不同的hosts之间转移,但是又能够保持其中的进程ID不变。而且PID namespace能够允许每个容器拥有自己的init(pid 1),对初始化、孤儿进程等事件进行处理。从一个PID namespace的角度来看,一个进程拥有两个PID:namespace内部的PID,以及namespace外部的,host上的PID。PID namespaces也是可以层叠的,从进程所归属的PID命名空间开始,一直到根PID namespace,它都有一个PID;一个进程只能看到处于它所在PID namespace当中的,以及更下层的其他进程。 network namespacesnetwork namespaces(CLONE_NEWNET)将系统中与网络相关的资源隔离。也就是说,每个namespace当中拥有自身的网络设备、IP地址、IP路由表,端口号等。network namespaces让containers能够被应用到网络的层面上。每个container能够拥有自身的网络设备、并且其应用能够被绑定到namespace中特有的端口号上,对于特定的container,还可以设置特殊的路由规则。例如,可以在同一个host系统上,运行多个用container包含的servers,并且它们都绑定了80端口。 user namespacesuser namespaces(CLONE_NEWUSER)将用户和group ID空间隔离。这也就是说,在一个user namespace内外,同一个进程点user和group id可以是不同的。例如,一个进程可以在一个user namespace外部,拥有一个普通的、无特权的user ID;而在在namespace中拥有UID 0。也就是说在namespace当中拥有root权限,但在namespace外部则不行。从Linux 3.8开始,无特权的进程能够创建它们自身的user namespaces,这为应用提供了新的可能:由于一个进程能够在其user namespaces中拥有root权限,那么它们就能够去使用那些本身只能由root用户使用的功能。但这确实会带来一些安全问题。 c++中的namespace编程语言中的namespace,虽然拥有相同的名称,其含义是完全不同的。但主要的思想是一致的,这里的命名空间也就是将空间内定义的内容放在一个盒子里,而命名空间也就是这个区域,using namespace 空间名,就将区域引入到了操作范围之内。这里,namespace是一种描述逻辑分组的机制,比如可以将某些属于同一个任务的类声明在同一个命名空间当中。标准C++库当中的所有内容,都被定义在命名空间std当中了。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux内核","slug":"linux内核","permalink":"http://sec-lbx.tk/tags/linux内核/"},{"name":"namespace","slug":"namespace","permalink":"http://sec-lbx.tk/tags/namespace/"}]},{"title":"SGX接口","slug":"SGX","date":"2016-09-18T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/09/18/SGX/","link":"","permalink":"http://sec-lbx.tk/2016/09/18/SGX/","excerpt":"","text":"1 SGXSGX:Software Guard Extensions在运行时,程序可以分为几个部分: Untrusted Run-Time System:在SGX enclave外部执行的部分,负责加载和管理一个enclave,并且Ecall enclave,接受enclave中的Ocall。 Trusted Run-Time System:在SGX enclave内部执行的部分,接受Ecall,进行Ocall,并对enclave自身进行管理。 Edge routines:指的是函数边的情况。 第三方库:为SGX定制的库。 ECall:Enclave call,调用enclave当中的函数。OCall:Out call,从enclave内部到外部的调用。在SGX中,enclave是用来减少信任基的,在运行时不可信域决定了可信域函数的调用顺序,也决定了起内部的上下文;并且ECall和OCall的返回值和参数也是不可信的。 文件格式:除了代码段、数据段之外,enclave文件还包含metadata,一个untrusted loader需要使用这个metadata来决定这个enclave如何被装载。 2 Enclave接口将一个应用划分为trusted和untrusted两部分之后,需要定义二者之间的接口。不可信的应用通过ISV接口函数,对共享库进行调用(ECall);而从Enclave对外部进行调用时(OCall),在函数执行完后会返回可信的区域继续执行;中断也不会破坏这个过程。Enclave需要暴露一部分接口给外部应用(ECalls),同时又要声明哪些外部提供的服务(OCalls)是必须的。Enclave的输入和输出都是不可信的代码可见的,因此enclave不能信任任何不可信域中的信息,检测ECall的输入参数和OCall的返回值。Enclave中的参数等,都保存在可信的环境中,并且其读写不会对ISV代码和数据的完整性造成影响;而参数的长度、返回值等都由ISV来指定。对于引用的输入,enclave会进行更特殊的处理,确定指针所指的内存区域是否在enclave的线性范围之内。对于操作系统的服务,Enclave是不能直接使用的,必须通过OCall作为接口,其return值作为输入传回给Encalve,这个值也是不可信的。如果在OCall中使用了ECall,这就是一个nested ECall,使用者应该避免这种情况的发生,如果必须使用,则要对接口进行限制。 3 Signature在Enclave中,可信环境的建立有三个主要的部分,分别是Measurement:当前环境下的,enclave的身份证明Attestation:向其他部分证明自身可信Sealing:能够在可信环境恢复时,恢复其相关的数据 MeasurementEnclave包含一个由author提供的证书,也即Enclave Signature,它能够让SGX来检测enclave文件是否被篡改了,从而证明这个enclave是可信的。但硬件只在装载的时候进行检验,因此enclave signature还会对author进行验证,它包含这些部分:Enclave Measurement、Enclave Author的公钥、Security Verision Number、Product ID AttestationAttestation指的是第三方能够证实软件是在SGX平台上运行的。Intel SGX架构支持两种验证:本地验证和远程验证。本地验证:一个enclave和另一个enclave协作,那么这两者之间就需要进行验证。enclave能够使用硬件来生成credential(report),用来发给另一个enclave进行验证远程验证:一个拥有enclave的应用需要使用一个平台外的服务时,能够使用enclave来制造一个report,并且将它给平台服务,来产生一个credential(quote),使用EPID技术来进行验证它来进行检测 Sealing在enclave被销毁时,需要识别出其中需要保护的data和state,以便在之后仍然能够在enclave中使用这些数据。这些数据只有被保存在enclave的外部。有两种情形:Seal到当前Enclave:在enclave创建时会有一个MRENCLAVE,只有拥有相同的MRENCLAVE才能unsealSeal到Enclave作者:在enclave创建时会有一个MRSIGNER,只有拥有相同MRSIGNER才能unseal 4 处理器特性enclave writer需要依赖编译器和库,他无法知道生成的enclave是否使用了任何特殊的CPU拓展特性。不可信的loader可能会允许所有的特性,但是通过设置Enclave Signature Structure,是能够指定重载这部分的设置的。在Enclave中,有一些指令是非法的,包括可能VMEXIT的,无法被软件处理的中断的,以及需要改变权限级别的,以及CPUID。 5 Power Management现代操作系统提供的一种机制,允许应用被能耗事件通知。当平台进入S3和S4状态时,密钥会被擦除,所有的enclave会被销毁。Intel SGX并不直接提供Power down事件到enclave当中。应用可以为这些事件注册相应的回调函数,在其被调用时,将secret state保存到磁盘上。但OS不保证enclave有足够的时间去做这件事情,因此enclave最好通过power transition events对enclave state data进行周期性的保护。 6 线程相关当多线程的程序运行时,thread Binding,TLS的使用都可能带来问题。对于enclave来说,开发者可以选择Non-Binding和Binding两种模式。Non-Binding模式会在不可信运行时,使用任意的TTC,并且使用一个root call进入enclave中,每次root call时,TLS都会被初始化;Binding模式下,不可信线程会和一个enclave中的可信线程绑定。","raw":null,"content":null,"categories":[],"tags":[{"name":"SGX","slug":"SGX","permalink":"http://sec-lbx.tk/tags/SGX/"}]},{"title":"EPT缺页异常源码分析","slug":"kvm内存虚拟化","date":"2016-06-27T03:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/06/27/kvm内存虚拟化/","link":"","permalink":"http://sec-lbx.tk/2016/06/27/kvm内存虚拟化/","excerpt":"","text":"EPT Violation与操作系统当中的缺页中断类似,EPT中的缺页被称为“EPT violation”。当GPA->HPA的映射不存在时,就会触发VM-EXIT,由KVM来捕获异常,并进行异常处理。 1 vmx.c当中,handle_ept_violation是EPT缺页中断的入口,其参数只有一个,struct kvm_vcpu *vcpu。它从VMCS中读取数据,判断OS能否解决这个异常;这里,提取了几个关键的值: //引发异常的客户机地址&页框号 gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS); gfn = gpa >> PAGE_SHIFT; //发生异常的状态信息,被转化为error_code exit_qualification = vmcs_read1(EXIT_QUALIFICATION); 2 随后,该函数进一步调用了kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gva_t cr2, u32 error_code, void *insn, int insn_len) 其中,cr2寄存器存放的为实际引起缺页异常的线性地址,不论是shadow page还是EPT模式,都是通过这个函数来进行处理,但在下一步中,实际调用的函数就不同了。在EPT模式开启的情况下,它会调用tdp_page_fault。 3 static int tdp_page_fault(struct kvm_vpu *vcpu, gva_t gpa, u32 error_code, bool prefualt) 这个函数位于mmu.c当中,它完成了具体的EPT建立工作。这个函数又有两个关键部分,一个是try_async_pf,它完成了从gfn获得pfn的过程;另一个是__direct_map,也就是根据gfn和pfn的对应关系,建立EPT表项的过程。 4 try_async_pf首先获取gfn对应的memslot结构,随后调用__gfn_to_pfn_memslot,利用memslot来获取pfn。 memslot的数据结构如下: struct kvm_memory_slot { gfn_t base_gfn; //对应虚拟机页框的地址 unsigned long npages; unsigned long *dirty_bitmap; struct kvm_arch_memory_slot arch; unsigned long userspace_addr; //对应HVA地址 u32 flags; short id; }; 可见memslot表示的是虚拟机中物理地址和宿主机中虚拟地址之间的映射关系。这里获取memslot,随后再通过__gfn_to_pfn_memslot,首先获得HVA,然后通过hva_to_pfn,获取对应的PFN。 5 __direct_map的主要目标是完成针对给定的gfb地址,填充EPT各级表项,最后将gfn与pfn的关系映射到EPT的过程。 这里的“direct”,和shadow相对应,表达的正是在EPT模式下的映射过程,其差别主要反映在kvm_mmu_get_page当中。在__direct_map中,首先通过宏定义for_each_shadow_entry,遍历了每一级EPT页表。它利用了一个迭代器结构,定义在mmu.c当中: struct kvm_shadow_walk_iterator { u64 addr; //目标帧 gfn<<PAGE_SHIFT hpa_t shadow_addr; //当前EPT页表项的物理地址 u64 *sptep; //下一级EPT页表的指针 int level; //当前页表级别 unsigned index; //当前页表索引 } shadow_walk_okay查询当前页表,获取下一级EPT页表的基址,或者最终的物理内存单元地址。mmu_set_spte设置当前请求level的EPT页表项: if (iterator.level == level) {//进入指定的level,为最后一级页表 mmu_set_spte(vcpu, iterator.sptep, ACC_ALL, write, &emulate, level, gfn, pfn, prefault, map_writable);//设置页表项 direct_pte_prefetch(vcpu, iterator.sptep); ++vcpu->stat.pf_fixed; break; } 这里“==”主要是判断当前的页表是否属于最后一级页表,也即内容是否直接指向一个宿主机的物理页面。如果指针对应的页表不存在,则会创建新的页表:首先通过kvm_mmu_get_page分配一个EPT页表页,随后通过link_shadow_page->mmu_spte_set设置页表项,并且刷新TLB。 if (!is_shadow_present_pte(*iterator.sptep)) { //如果在4---level之间该指针对应的页表不存在,则需要进行创建新的页表,并填充上去 u64 base_addr = iterator.addr; base_addr &= PT64_LVL_ADDR_MASK(iterator.level); pseudo_gfn = base_addr >> PAGE_SHIFT; sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr, iterator.level - 1, 1, ACC_ALL, iterator.sptep); link_shadow_page(iterator.sptep, sp, true); } 6 mmu_spte_update函数主要用来更新状态位,它表示映射的pfn并没有改变,而是更新状态位的情况(主要与读写执行权限、设置脏页有关系)。通常在更新后要刷新TLB。","raw":null,"content":null,"categories":[],"tags":[{"name":"kvm","slug":"kvm","permalink":"http://sec-lbx.tk/tags/kvm/"},{"name":"虚拟化","slug":"虚拟化","permalink":"http://sec-lbx.tk/tags/虚拟化/"}]},{"title":"VT-x/EPT解读","slug":"Intel VT-x:EPT解读","date":"2016-06-15T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/06/15/Intel VT-x:EPT解读/","link":"","permalink":"http://sec-lbx.tk/2016/06/15/Intel VT-x:EPT解读/","excerpt":"","text":"1 VT-xVT-x是intel针对硬件辅助虚拟化的技术,它解决x86指令集不能被虚拟化的问题,并且简化了VMM软件,减少了软件虚拟化的需求。Virtual Machine Extensions定义了一系列新的操作,称为VMX操作,来提供处理器级别的支持。同时它提供了一个新的特权等级VMX root给VMM,从而避免了ring deprivileging方法(让操作系统运行于ring 1,VMM使用ring 0)带来的虚拟化漏洞。VMX操作可以分为两类:root:VMM执行的VMX root 操作non-root:Guest执行的VMX non-root操作对于这两种模式之间的转换,VMX提供了确切的说法:VM Entry:转换到VMX non-root操作VM Exit:从VMX non-root操作转换到VMX root操作实际上,这个操作过程也就是VMM和虚拟机之间的转换过程。VMCS是一个用来管理VMX non-root操作和VMX转换的数据结构。它由VMM配置,指定guest OS状态,并在VM exits发生时进行控制。 2 MMU虚拟化第一代VT-x在每次VMX转换都会进行TLB冲洗,这会造成在所有VM exits和大部分VM entries时的性能流失,因此对TLB清洗必须有更好的VMM软件控制。VPID(virtual Processor Identifier)是VMCS中的一个16bit域,它用来缓存线性翻译的结果。VPID启动时,就不需要冲洗TLB,不同虚拟机的TLB项能在TLB中共存。既往的VMM维持一个shadow page table,将guest的virtual pages,直接映射到machine pages。同时,guest中的V->P表,与VMM中V->M shadow page table同步。为了维持guest page table和shadow page table之间的关系,会因为VMM traps造成额外的代价,每次切换都会丢失性能,并且为了复制guest page table,在内存上也会有额外花费。 3 EPT(Extended Page Table)EPT这样的硬件支持(在AMD架构中类似的技术是NPT,nested page table)能够有效解决传统shadow page table的花销问题。在KVM最新的内存虚拟化技术中,采用了两级页表映射。第一级页表,客户虚拟机采用的是传统操作系统的页表,也即guest page table,记录着客户机虚拟地址(GVA)到客户机物理地址(GPA)的映射。而在KVM中,维护的第二级页表是extended page table(EPT),记录的是虚拟机物理地址(GPA)到宿主机物理地址(HPA)的映射。在图中可以看到,包括guest CR3在内,一共有5个GPA,它们都要通过硬件走一次EPT,得到下一个HPA。那么如何通过EPT计算出对应的HPA呢,KVM是如何操作的呢?EPT和传统的页表一样,也分为4层(PML4、PDPT、PD、PT),一个gpa通过四级页表的寻址,再加上gpa最后12位的offset,得到了hpa。在这个架构中,页可以分为两种:物理页(physical page)和页表页(MMU page)。物理页就是真正存放数据的页,页表页是存放EPT页表的页。这两种页创建的方式也不同,物理页可以通过内核提供的__get_free_page来创建,而页表页则是通过mmu_page_cache获得。这个page cache是在KVM初始化vcpu时通过linux内核中的slab机制分配的,它作为之后的MMU pages的cache来使用。在KVM中,每个MMU page对应一个数据结构kvm_mmu_page,在EPT处理过程中,它是极为重要的一个数据结构。一条地址如何翻译?首先non-root状态下的CPU加载guest CR3,由于guest CR3是一条GPA,CPU需要通过EPT来实现GPA->HPA的转换。但首先,MMU会先查询硬件的TLB,来判断有没有GPA到HPA的映射。如果没有GPA到HPA的映射,那么在cache中查询EPT/NPT。如果cache里面没有缓存,则逐层向下层存储查询,最终获得guest CR3所映射的物理地址单元内容,作为下一级guest页表的索引基址。当CPU访问EPT页表,查找HPA,发现相应的页表项不存在时,就会抛出EPT Violation,由VMM截获处理它。随后通过GVA的偏移量,计算出下一条GPA,依次循环下去,直到最终获得客户机请求的页。整个过程如下图所示。","raw":null,"content":null,"categories":[],"tags":[{"name":"intel","slug":"intel","permalink":"http://sec-lbx.tk/tags/intel/"},{"name":"虚拟化","slug":"虚拟化","permalink":"http://sec-lbx.tk/tags/虚拟化/"}]},{"title":"Intel:针对ROP攻击的新硬件特性","slug":"Intel:针对ROP攻击的特性","date":"2016-06-13T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/06/13/Intel:针对ROP攻击的特性/","link":"","permalink":"http://sec-lbx.tk/2016/06/13/Intel:针对ROP攻击的特性/","excerpt":"","text":"1 针对ROP/JOP/COP的intel特性ROP/JOP/COP攻击通常有两个特性:第一是包含执行特权命令的代码片段,至少包含一条控制流转移指令,其转移目的由返回stack或者寄存器中的目标地址决定;第二是它们将控制流指令(RET,CALL,JMP)的地址修改到新的目标地址。intel提供Shadow Stack和Indirect branch tracking两种功能,来防御这一类攻击。Shadow Stack提供返回地址的保护,使程序免遭ROP攻击的影响;Indirect branch tracking提供分支保护,来防御JOP/COP攻击。 1.1 Shadow Stackshadow stack是仅仅用来实现控制流转移的“第二个”stack,它和data stack分离。当shadow stack被激活时,CALL指令会把返回地址同时push进data stack和shadow stack中。在RET指令执行时,两个stack中的值都会pop出来,并进行一次比较,如果这两个值不相同,那么处理器就会发出一个control protection exception。 1.2 Indirect branch trackingENDBRANCH是一个新的指令,它被用来标识合法的indirect call/jmp指令。在不支持CET的机器上,这个指令被翻译为一个NOP指令;在支持CET的处理器上,它依然是一个NOP指令,它主要被用做一个标记指令,用来标记处理器管道中有序的部分,来检测控制流的错误。CPU会实现一个状态及,来追踪jmp和call指令,一旦某个这类指令被发现了,状态机就从IDLE跳转到WAIT_FOR_ENDBRACH状态。在这个状态下,下一条指令必须是ENDBRANCH。如果下一条指令不是ENDBRANCH,那么处理器就制造一个control protection fault。 2 Shadow stacks环境shadow stack的读写是被严格限制的,它只能由控制流转移指令和shadow stack管理指令来完成。它能在user mode(CPL == 3)和supervisor mode(CPL < 3)中分别打开。shadow stacks必须在开启分页功能的保护模式下使用。使用shadow stack时,处理器会支持一个新的寄存器,shadow stack pointer(SSP),它不能被直接编码为指令中的源地址、目的地址、随机内存地址等。它指向shadow stack的顶部。它保存的是一个线性地址,并通过FAR RET,IRET和RSTORSSP指令来装载入寄存器中。它的大小根据当前的mode确定为32bit或64bit。 2.1 Near CALL/RET激活shadow stack时,Near CALL会将返回地址同时push到data stack和shadow stack中去,Near RET会将返回地址同时从data stack和shadow stack中pop出来。如果两者的返回地址不同,则触发near-ret异常。 2.2 Far CALL/RET激活shadow stack时,Far CALL会将CS,LIP(linear address of return address)以及SSP三者push到shadow stack上,并在far RET的时候按SSP,LIP和CS的顺序pop。如果CS和LIP的值和CS以及EIP中的返回地址值不符合,就会触发FAR-RET/IRET异常。far CALL到更高权限的过程如下:当far CALL处于user mode(CPL3)时,返回地址不被push到supervisor shadow stack。相似的,一个从更高权限(CPL<3)回到CPL3的far RET也不会对返回地址进行验证。CPL3到CPL<3的过程中,user space SSP会被保存在一个MSR寄存器中,在CPL<3到CPL3的过程中,利用这个寄存器可以恢复user space SSP。对于不同权限之间的call,call指令会进行一次stack的切换。supervisor程序的data stack位于当前的TSS段。相似的,shadow stack也进行这种切换。根据权限的不同(ring 0, ring 1, ring 2),supervisor程序会选择不同的MSR来获取SSP。从ring 2到ring 1,从ring 2 到ring 0或者从ring 1到ring 0被认为是“同样权限级别”之间的切换。也就是说,对于这样的calls,会将调用者程序短的CS,LIP和SSP push到被调用程序段的shadow stack上,而在far RET时,会验证其中的CS和LIP是否和data stack中的CS和EIP是一致的。对于一个不同权限级别之间的far CALL,CET会验证一个“supervisor shadow stack token”。这个token在shadow stacks创建的时候,由supervisor设置,是一个64bit的值。这个token中,有效部分有两个,其一是63:3bit,它们是这个token的地址;其二是第一个bit(busy bit),它表明了对应的shadow stack是否在使用。在far CALL时,首先根据IA32_Plx_SSP中的地址,获取对应的token;随后判断它的busy bit是否为0,并且检查MSR中的地址和token中的是否吻合。吻合的话要将busy bit设置为1,并且切换SSP。在far RET时,首先通过SSP加载token,随后检查token的busy bit是否为1,并且检查token是否和SSP一致。如果通过,则要清除busy bit位。 2.3 Interrupt/Exception64bit模式提供了一种stack切换机制,称为Interrupt stack table(IST),其中的64bit IDT能够被用来在无权限变化发生时,指定64bit TSS中7个data stack指针中的一个。如果IST索引是0,说明没有权限变化,stack会切换到同一个stack。为了支持这个stack切换机制,shadow stack提供了一个MSR,IA32_INTERRUPT_SSP_TABLE,用来存放一个表的线性地址,它包含7个shadow stack指针。如果是一个非0的IST值,并且在call的过程中没有发生权限变化,那么MSR就指向内存中的一个64byte表,这个表利用IST来作索引。 2.4 Shadow Stack&Task Switchtask切换能够通过三种方法完成: JMP/CALL GDT中的TSS描述符 JMP/CALL GDT中的task-gate描述符或当前的LDT 一个interrupt或exception向量,指向IDT中中的task-gate描述符 在激活shadow stack的情况下,一个新的task必须与一个32bit TSS关联,并且不能工作在8086虚模式。32bit的新task的SSP,位于32bit TSS的偏移量104bytes处。因此新task的TSS至少要在108bytes的位置。SSP必须是8bytes对齐的,并且指向shadow stack token。一个用CALL指令实现的嵌套task切换,旧task的SSP不会保存到旧进程的TSS,而是和CS与LIP一起,被push到新task的shadow stack。类似的,一个以IRET实现的非(解)嵌套task切换,新task的SSP被从旧task的shadow stack中恢复。如果旧shadow stack中CS和LIP与新taskCS和EIP所决定的返回地址一致,说明控制流正确,如果不吻合,就会出现异常。 3 Indirect branch环境当indirect branch traking特性被激活时,indirect jmp/call指令的过程会做出相应的改变。jmp:如果indirect jmp的下一条指令不是ENDBR32指令(在遗留以及兼容模式),或者ENDBR64指令,就产生#CP异常。只有以下形式的jmp指令会被跟踪: jmp r/m16, r/m32, r/m64 jmp m16:16, m16:32, m16:64 call:如果indirect call的下一条指令不是ENDBR32指令(在遗留以及兼容模式),或者ENDBR64指令,就产生#CP异常。只有以下形式的call指令会被跟踪: call r/m16, r/m32, r/m64 call m16:16, m16:32, m16:64 ENDBRANCH在支持/不支持CET的机器上,都不会造成任何执行上的影响。唯一的区别是,支持CET的处理器上,实现了一个2个状态的状态机,用来跟踪indirect call/jmp。在user mode和supervisor mode,各有一个这样的状态机。在除indirect call和jmp指令之外的指令时,状态机状态保持在IDLE;在indirect call和jmp指令时,状态机切换到WAIT_FOR_ENDBRANCH。在WAIT_FOR_ENDBRANCH状态下,只允许下一条指令是ENDBRANCH,或者兼容模式下的某些指令。当#CP(ENDBRANCH)异常发生时,高优先级的异常会先于#CP异常发生。在将控制流转交给异常handler时,高权限的状态机会保持原状态,指令指针压入堆栈中的是引发异常的indirect call/jmp的指令地址。 3.1 不追踪前缀:3EH使用寄存器的Near indirect call/jmp指令,如果有3EH前缀,就说明这个控制流不需要被追踪。但Far call/jmp,以及使用内存地址的Near indirect call/jmp,即使有3EH前缀,也会忽略掉这个前缀,总是被追踪。 3.2 CPL 3和CPL<3之间的控制流转移硬件实现了两个CET状态机:user mode和supervisor mode各有一个。当前使用哪个状态机,是根据此时CPL的值来决定的。当从CPL3切换到CPL<3时,会停用user mode的状态机,转而使用supervisor mode的状态机,反之亦然。在任何情况下,源状态机变成不激活状态,并且保存它的原有状态,如果没有异常情况,目标状态机激活。具体情况有以下几类: Far call/jmp,SYSCALL/SYSENTER:tracker被激活,解除禁止,并转移到WAIT_FOR_ENDBRANCH,强迫far call/jump之后必须跟随着ENDBRACH。 Hardware interrupt/trap/exception/NMI/Software interrupt/Machine Checks:traker被激活,解除禁止并转移到WAIT_FOR_ENDBRANCH。 iret:tracker被激活,并且保持原有状态。 3.3 CPL 3和CPL<3内部的控制流转移在同一个权限内发生控制流转移时,在控制流转移前后,不切换状态机。具体情况可分为以下几类: FAR CALL/JMP:tracker解除禁止,并转移到WAIT_FOR_ENDBRANCH Near indirect call/jmp:如果tracker没有被禁止,转移到WAIT_FOR_ENDBRANCH Hardware interrupt/trap/exception/NMI/Software interrupt/Machine Checks:tracker unsuppressed,转移到WAIT_FOR_ENDBRACH iret:激活的tracker保持其状态 3.4 INT3 处理INT3在WAIT_FOR_ENDBRANCH中被特别处理。INT3的出现不会将tracker移动到IDLE状态,而INT3引发的#BP trap被作为一个更高优先级的事件处理,从而忽略了ENDBRANCH。 3.5 与遗留系统兼容启用了CET的程序在满足条件的情况下,能够对遗留系统保持兼容性。首先,通过设置LEG_IW_EN位,可以开启遗留系统的兼容选项;其次,控制流的转移通过indirect call/jmp到non-endbranch的形式实现;第三,legacy code page bitmap被设置成指明目标控制流是遗留代码页。其中,legacy code page bitmap是一个程序内存中的数据结构,用来给硬件决定一个控制流转移是否是到遗留代码页的。这个bitmap的地址由EB_LEG_BITMAP_BASE,bitmap中的每个bit代表了线性内存中的一个4k页。如果bit是1,说明对应的代码页是遗留的代码页,否则它是一个启用了CET的代码页。线性地址的bits 31:12被用作这个bitmap的索引。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"intel","slug":"intel","permalink":"http://sec-lbx.tk/tags/intel/"},{"name":"硬件","slug":"硬件","permalink":"http://sec-lbx.tk/tags/硬件/"}]},{"title":"详解页缓存page cache","slug":"I:O与page cache","date":"2016-06-08T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/06/08/I:O与page cache/","link":"","permalink":"http://sec-lbx.tk/2016/06/08/I:O与page cache/","excerpt":"","text":"buffer cache/page cachelinux中存在有两个缓存,buffer cache是针对设备的缓存,而page cache是针对文件的缓存。对于一个ext4文件系统来说,每个文件都有一棵radix树管理文件的缓存页,这些缓存页就是page cache;而对于每个块设备来说,都有一棵radix树来管理数据的缓存块,这些缓存块被称为buffer cache。在常见的linux系统中,page cache通常以4kb为单位,而buffer cache的大小由块设备来决定,通常是512B。总的来说,page cache是对文件数据的缓存,而buffer cache是对设备数据的缓存。在linux 2.4之前,这两个cache是有区别的,但这明显会产生一些浪费。因此在2.4之后的内核版本中,这两个cache就被统一化了:使用page cache。如果一个缓存数据既代表文件又代表块,那么buffer cache就直接指向page cache。但是buffer cache依然是保留的。因为内核依然需要进行block的I/O。由于大部分block表示的是文件数据,因此它们都通过page cache的形式来缓存。但是剩下的小部分数据不是文件:它们是metadata活着原始的block I/O,这一部分依然由buffer cache来保存。linux当中,所有的文件I/O操作,都是通过page cache来实现的。写操作是通过将page cache中对应的页标记为脏页来实现的;读操作是通过从page cache中返回数据来实现的。如果数据还不在cache中,就先把它读到cache里面。如果只是研究一般文件的读写,那么就只需要在意page cache,不用去关心buffer cache。 关系现在我们知道,在linux中,大部分文件都采用了page cache的形式来进行缓存。但是块设备的读写,却是以块的形式来进行的。前面有提到,page cache通常以4kb为单位,而buffer cache则通常是512B的。实际上,一个或多个buffer cache组成了一个page cache。linux支持的文件系统,大多以块的形式组织文件。在文件以块的形式调入内存后,就以buffer cache的形式,对它们进行管理。buffer cache由两个部分组成,分别是缓冲区的首部buffer_head,和实际的缓冲区内容。buffer_head中,有一个指向数据的指针,和一个缓冲区长度的字段,这两个部分并不相邻。每当以块的形式,将数据读入内存时,它就要被存储在一个缓冲区当中,而buffer_head则起到一个描述符的作用。在从块设备中读写文件页的时候,会根据不同情况,来构造bio。bio中,io_vec中,bv_page字段,会指向page。在2.6版本后,buffer_head只给上层提供有关其描述的块的当前状态,描述磁盘块到物理内存的映射关系,而bio则负责所有块I/O操作。在linux中,mpage_readpage试图读取文件中一个page大小的数据。最理想的情况下,这个page大小的数据都在连续的物理磁盘上吗,函数只需要提交一个bio就可以获取所有的数据。这里使用get block函数,检查物理块是否连续。如果连续,则直接调用mpage_bio_submit函数请求整个page的数据,不连续则调用block_read_full_page逐个block读取,建立bh和bio之间的关系。mpage从来不回把不完整的页放进bio中,除非是文件的结尾。 页高速缓存到用户空间所谓的页高速缓存到用户空间,实际上分为两种:一种是read到用户空间,也就是复制到用户空间中的堆中去;第二种是映射,mmap是在堆外的空间。读取,要经过两次复制:第一次是从磁盘中读取来填充页缓存中的页;第二次是将是从内存中的页缓存,读取到进程堆空间的内存中。 read 映射,只有一次复制:从磁盘中复制到缓存中。mmap会创建一个虚拟内存区域vm_area_struct,进程的task_struct包含了进程页表项,让这些页表项指向页缓存所在的物理页page。由于程序的代码段必然是通过mmap来实现的,因此它们在使用时,其实是保存在页缓存中的。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"I/O","slug":"I-O","permalink":"http://sec-lbx.tk/tags/I-O/"}]},{"title":"vring前端","slug":"详解virtio","date":"2016-05-26T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/05/26/详解virtio/","link":"","permalink":"http://sec-lbx.tk/2016/05/26/详解virtio/","excerpt":"","text":"biobi_next其实只是用在request queue当中的,作为一个指向下一个bio结构体的指针。bi_bdev指向了设备。bi_iter的作用是用来对这个bio中的bio_vec进行迭代,这个bio_vec以数组的形式进行存放。每个bio都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目)和一个或多个描述与I/O操作相关的内存区的段。其中bvec_iter bi_iter包含了扇区信息。 struct bvec_iter { sector_t bi_sector; /* device address in 512 byte sectors */ unsigned int bi_size; /* residual I/O count */ unsigned int bi_idx; /* current index into bvl_vec */ unsigned int bi_bvec_done; /* number of bytes completed in current bvec */ }; 而bio_vec的数据结构如下: struct bio_vec { struct page *bv_page; unsigned int bv_len; unsigned int bv_offset; }; 每个bio包含了一个由bio_vec表示的片段链表,每一个片段都是一段连续的内存空间。 从bio构造request:blk_make_requeststruct request *blk_make_request(struct request_queue *q, struct bio *bio, gfp_t gfp_mask) blk_make_request:对于一个bio(或bio链),生成一个对应的request,并加入到队列中去。其中,q代表的是目标request队列。bio_data_dir返回的是bio的rw情况。blk_get_request返回队列q中的一个free request。for_each_bio(bio),这里处理的是一条bio队列中每一个bio。request_queue:定义在blkdev.h中。virtblk_req的数据结构如下: struct virtblk_req { struct request *req; struct virtio_blk_outhdr out_hdr; //out_hdr用来向后端描述这次请求 struct virtio_scsi_inhdr in_hdr; u8 status; struct scatterlist sg[]; }; 这个函数对一个bio,生成了一个request,然后把它(以及它所在bio链中的所有bio)放在同一个request中,并且将它加入队列。 virtblk_probevirtblk_probe里面,完成了:vblk的申请(并把它和vdev关联 vdev->priv = blk)disk的申请(vblk->disk = alloc_disk)queue的申请(blk_mq_init_queue)指明这个硬件队列所对应的virtio_blk对象(q->queuedata = vblk) 从request_queue到vring:virtio_queue_rq每个virtblk_req对应一个request,对应一个scatterlist链表。virtio_queue_rq以request为单位,将请求中的bio_vec映射成scatter-gather list,并将其添加到vring中,然后通过kick通知host。它包含这样两个个部分: blk_rq_map_sg,它根据request对象来设置scatterlist,并且返回scatterlist中存有多少项。这个函数有三个参数,分别是requestqueue,request和scatterlist。首先,我们知道request->bio是一个链表,这里**\\_blk_bios_map_sg完成了通过这个对象中的bio链表中的bio,来设置sglist的工作。这个函数判断是否读写同一个块,如果是同一个块,直接调用sg_set_page,bvec的内容交给sg。否则则对bio中的每一个bio_vec,调用__blk_segment_map_sg。这个函数将scatterlist根据bvec尽可能的合并,对于不能合并的,使用一个新的scatterlist项。也就是说,每一个request都包含一个bios链表,将这个bios链表,整理称为一个scatterlist**。 __virtblk_add_req,根据request构建一部分scatterlist,然后和之前构造好的scatterlist,一起放进vring中去。这个函数中,首先按顺序,构造一个scatterlist,包括out_hdr,request->cmd,data_sg,request->sense,in_hdr,status。这相当于一个储存信息的header。随后,这个函数调用了virtqueue_add_sgs,来完成进一步的工作:首先函数计算出总共的scatterlist数量(使用一个双重循环的方式),然后调用virtqueue_add。virtqueue_add首先判断是否支持indirect descriptor这种方式。这种方式是一种节约vring空间的方式,它只使用一个单独的vring.desc,然后由这一项指向一个内存中的desc数组,然后这个数组再指向每个scatterlist的描述符。如果不使用这个方式,那么每个scatterlisg都占用一个virng中的desc。随后按照先out_sgs,后in_sgs的方式,依次给vring中的描述符赋值,并且修改vring.avail和free list的head。递增vring_virtqueue的num_added,假设这个值等于(1<<16)-1,那么就调用virtqueue_kick函数通知后端来进行处理。vring可以分为三个部分:descriptor tables 用来描述vring中的scatterlistavailable ring 描述的是guest提供给host的所有buffersused ring 描述的是host已经使用过的buffers scatterlist中的固定信息scatterlist的结构是这样的: struct scatterlist { unsigned long page_link; unsigned int offset; unsigned int length; dma_addr_t dma_address; }; 那么ring中是如何传递和磁盘相关的信息的呢?在virtqueue_add_sgs中,首先申请了scatter几个scatterlist。这里对它们进行逐个的分析。首先是hdr。hdr中的内容是virtblk_req中的out_hdr。它是一个virtio_blk_outhdr。 struct virtio_blk_outhdr { /* VIRTIO_BLK_T* */ __virtio32 type; /* io priority. */ __virtio32 ioprio; /* Sector (ie. 512 byte offset) */ __virtio64 sector; }; 随后是virtblk_req->request->cmd,request的结构定义在blkdev.h中。cmd是一个字符串指针。data_sg指向的是数据的scatterlist。再者是virtblk_req->request->sense。它是sense data的指针。之后是virtblk_req->in_hdr,它是一个virtio_scsi_inhder。 struct virtio_scsi_inhdr { __virtio32 errors; __virtio32 data_len; __virtio32 sense_len; __virtio32 residual; }; 最后是virtblk_req->status,它是状态位。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"虚拟化","slug":"虚拟化","permalink":"http://sec-lbx.tk/tags/虚拟化/"}]},{"title":"初学爬虫","slug":"爬虫入门","date":"2016-05-13T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/05/13/爬虫入门/","link":"","permalink":"http://sec-lbx.tk/2016/05/13/爬虫入门/","excerpt":"","text":"工作原理爬虫,就是伪造一个用户身份,通过URL去访问某个网页,将这个网页上的某些感兴趣的内容,利用正则式的方式提取出来。对于google,baidu的搜索部门,爬虫可以说是核心技术之一。 python spiderpython为实现基本的爬虫,提供了十分方便的模块。通常利用urllib来实现简单的爬虫。首先确定访问的url,然后对这个url生成一个request。request并不仅仅是一个简单的访问请求,它可以采用post,get等方式,来实现数据的提交。 request = urllib2.Request(url, data, headers) response = urllib2.urlopen(request) print response.read() post和get的一个差别在于,get的方式是直接以链接形式访问,链接中包含了所有参数。这里简单写一下爬虫中,post和get的使用。 values = {"username": "user", "password": "pass"} #post data = urlencode(values) request = urllib2.Request(url, data) #get url = url + "?" + data request = urllib2.Request(url) 某些情况下,需要设置访问的headers,才能对网页进行正常的访问。例如防盗链,服务器有时候会检查headers的referer是不是自己。headers是放在request之中的。除了在初始化的时候赋值,还可以添加headers。 #设置headers headers = { 'User-Agent' : 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)','Referer':'http://www.zhihu.com/articles' } request = urllib2.Request(url, data, headers) request.add_header('cache-control', 'no-cache') 正则表达式正则表达式是用来提取网页中感兴趣部分的工具。当然,首先自己要对网页源代码有所了解才行。这里列出一个python正则式的匹配规则.python中自带了re模块,提供了对于正则表达式的支持。其中,最重要的是re.compile方法。 pattern = re.compile('string'); compile提供一个匹配模式pattern。re中提供了其他的方法,可以根据pattern,在目标中进行匹配/操作。 re.match(pattern, string[, flags]) re.search(pattern, string[, flags]) re.split(pattern, string[, maxsplit]) re.findall(pattern, string[, flags]) re.finditer(pattern, string[, flags]) re.sub(pattern, repl, string[, count]) re.subn(pattern, repl, string[, count]) 异常处理爬虫主要会遇到两种Error,URLError和HTTPError。利用try-except来捕捉相应的异常。 #URLError try: urllib2.urlopen(request) except urllib2.URLError, e: print e.reason HTTPError会对应一些状态码,例如404,400等。HTTPError实例化后会有一个code属性,也就是这个相关的错误号。 #HTTPError try: urllib2.urlopen(rep) except urllib2.HTTPError, e: print e.code print e.reason 如果捕获到了HTTPError,还可以加入hasattr,提前对属性进行判断: except urllib2.URLError, e: if hasattr(e,"code"): print e.code if hasattr(e,"reason"): print e.reason 使用cookie某些网站为了辨别用户身份,进行session跟踪,需要储存cookie在用户本地终端上。这样一来,某些网站需要登录后才能访问某个页面,此时就需要利用Urllib2库保存我们登陆的Cookie,然后再抓取其他页面。获取一个URL时,需要使用一个opener。之前使用的urlopen,实际上是opener的一个特殊实例。对于cookie,python也提供了支持的模块,cookielib。使用cookie分为两个部分,首先是把cookie保存到文件,第二是从文件中获取cookie并访问。cookie保存的过程如下: #创建MozillaCookieJar实例对象,保存cookie cookie = cookielib.MozillaCookieJar(filename) #利用HTTPCookieProcessor创建一个cookie处理器 handler = urllib2.HTTPCookieProcessor(cookie) req = urllib2.Request("http://www.baidu.com") #利用urllib2的buile_opener创建一个opener opener = urllib2.build_opener(handler) response = opener.open(req) #保存cookie到文件 cookie.save() 从文件中获取Cookie并访问的过程如下: cookie = cookielib.MozillaCookieJar() #读取cookie到变量 cookie.load('cookie.txt') req = urllib2.Request("http://www.baidu.com") handler = urllib2.HTTPCookieProcessor(cookie) opener = urllib2.build_opener(handler) response = opener.open(req) 小例子做了一个小爬虫,用来统计豆瓣电影TOP250名中,前166名的评分总和。这里运用了一些python中的相关知识。 import urllibimport urllib2import reclass DBPC: def __init__(self,baseUrl): self.baseURL = baseURL def getPage(self,pageNum): #python中全局变量的声明 global count global sum try: url = self.baseURL + '?start=' + str(pageNum * 25) + '&filter=' request = urllib2.Request(url) response = urllib2.urlopen(request) #指定编码格式,并获取内容 content = response.read().decode('utf-8') #获取规则 pattern = re.compile('<span class="rating_num" property="v:average">(\\d\\.\\d)</span>') #利用findall匹配所有 items = re.findall(pattern, content) #通过循环逐个得出 for item in items: print count ,item count += 1 if count > 167: break else: sum += float(item) except urllib2.URLError, e: if hasattr(e,"reason"): print e.reason return None count = 1 sum = 0 baseURL = 'https://movie.douban.com/top250' dbpc = DBPC(baseURL) for i in range(7): dbpc.getPage(i) print sum","raw":null,"content":null,"categories":[],"tags":[{"name":"爬虫","slug":"爬虫","permalink":"http://sec-lbx.tk/tags/爬虫/"}]},{"title":"virtio原理研究","slug":"virtio","date":"2016-05-06T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/05/06/virtio/","link":"","permalink":"http://sec-lbx.tk/2016/05/06/virtio/","excerpt":"","text":"virtio简介virtio是KVM虚拟环境下,针对I/O虚拟化的通用框架。virtio是一个半虚拟化驱动。首先说明一下全虚拟化和半虚拟化的区别。全虚拟化是指guest操作系统运行在物理机器上的hypervisor上,它不知道自己已被虚拟化,不需要任何更改就可以工作。半虚拟化指的是guest操作系统不仅知道它运行在hypervisor上,还包括让guest操作系统更高效与hyperviosr交互的代码(驱动程序)。 QEMU模拟I/O如果使用QEMU模拟I/O,当guest中的设备驱动程序发起I/O操作请求时,KVM中的I/O操作捕获代码会将这次I/O请求拦截,在经过处理后将这次I/O请求的信息放在I/O共享页,然后通知QEMU程序。QEMU获得I/O操作的具体信息后,交由硬件模拟代码模拟出本次I/O操作,完成后,将结果放回I/O共享页,并通知KVM模块中的I/O操作捕获代码。最后,KVM模块中的捕获代码读取I/O共享页中的操作结果,把结果返回到客户机中。倘若guest通过DMA访问大块I/O时,QEMU不会把操作结果放在I/O共享页中,而是通过内存映射的方式将结果直接写到guest的内存中。 virtio模拟I/O下图中,最上面一排(virtio_blk等)是前端驱动,它们是在客户机中存在的驱动程序模块,而后端处理程序是在QEMU中实现的。在前端和后端之间,定义了两层来支持guest和QEMU之间的通信。virtio层是虚拟队列借口,一个前端驱动程序可以使用多个队列。虚拟队列实际上是guest操作系统和hyperviosr的衔接点。而virtio-ring实现了环形缓冲区,它用来保存前端驱动和后端处理程序执行的信息,并且它可以一次性保存前端驱动的多次I/O请求,并且交由后端驱动去批量处理,最后实际调用host中设备驱动实现物理上的I/O操作,这样做就可以实现批量处理,而不是客户机中的每次I/O请求都需要处理一次,从而提高了guest和hypervisor信息交换的效率。 virtio_blk在linux中,对于块设备的访问,通常是用一个I/O队列,来维护一系列的bio数据结构,通常一个请求可能包含多个bio结构。bio是上层内核vfs与下层驱动连接的纽带。virtio_blk结构体中的gendisk结构多request_queue队列接收block层的bio请求,按照request_queue队列默认处理过程,bio请求会在io调度层转化为request,然后进入request_queue队列,最后调用virtblk_request将request转化为vbr结构,最后由QEMU接管处理。QEMU处理过vdr之后,会将它加入到virtio_ring的request队列,并发一个中断给队列,队列的中断响应函数vring_interrupt调用队列的回调函数virtblk_done。最后由request_queue注册的complete函数virtblk_request_done处理,通过blk_mq_end_io通告块设备层IO结束。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"虚拟化","slug":"虚拟化","permalink":"http://sec-lbx.tk/tags/虚拟化/"}]},{"title":"I/O体系结构","slug":"I:O体系结构","date":"2016-05-05T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/05/05/I:O体系结构/","link":"","permalink":"http://sec-lbx.tk/2016/05/05/I:O体系结构/","excerpt":"","text":"驱动程序操作系统对设备以及文件系统的抽象可以分为两层。第一层是在物理介质上的抽象,用“记录块”去抽象磁道、扇区;第二层则将记录块抽象成为文件系统。linux将设备分为块设备和字符设备两大类。块设备是以记录块或扇区为单位,成块进行输入/输出的设备,称为“字符设备”,例如磁盘。字符设备是以字符为单位,逐个进行输入/输出的设备,例如键盘。要使一项设备在系统中可见的,首先要在文件系统中有一个代表它的文件节点,它是通过系统调用mknod实现的。但是要对它进行访问,最重要的是在设备驱动层要有该设备的驱动程序。这个驱动程序是通过可安装模块来实现,它在系统运行时动态安装到内核中。而驱动程序要访问设备上的物理电路,需要通过特殊的I/O指令。这部分电路通常是以寄存器的形式出现的,这些指令有out/in等。 mknodmknod能够创造目录之外的所有文件,包括普通文件、特殊文件以及设备文件。但其他文件大多都有专门的系统调用,因此mknod主要用于创建设备文件。mknod会调用vfs_mknod,分配一个inode数据结构,然后对它进行设置,包括设备号以及文件操作表指针。随后将新创建对节点加进所在目录在磁盘上的目录文件中,由于这个inode被设置为脏,因此在内核同步时,会将其内容写到磁盘上的索引节点上(inode对应ext_node)。 可安装模块可安装模块(module)可以根据需要,在不必对内核重新编译的条件下,将可安装模块动态地插入运行中的内核,或者移除已经安装的模块。对于用户来说,通常通过insmod和rmmod来安装拆卸模块。所谓的模块,就是已经编译,但是未经连接的.o文件。insmod所做的事情包括:将模块读入用户空间,查询模块中未知符号的地址,对符号进行连接,在内核中创建一个module数据结构并预订所需内核空间,最后将模块映象装入内核空间,并调用初始化函数。每个已安装模块在内核中都有一个module数据结构,保存模块的有关信息。 PCI总线PCI总线的具体思路是,每个外部设备都通过某种途径高速系统:接口上的存储区间和I/O地址区间,每个区间多大,以及在本地的位置。在系统知道了一共有多少外设,各自有什么样的存储区间后,就统筹的为它们分配地址,建立起区间和总线之间的连接,随后就可以通过这些地址来访问。因此PCI总线需要首先知道配置寄存器的地址,然后通过配置寄存器来分配地址,而配置寄存器都采用相同的地址,在访问时,PCI桥通过附加条件(总线号,设备号,功能号等)进行区分。PCI总线在I/O地址空间保留了几个字节,用来构成两个寄存器(地址寄存器和数据寄存器),要访问某个设备中的某个配置寄存器时,就在地址寄存器写入目标地址,然后通过数据寄存器读写数据。宿主-PCI桥后是系统的第一条PCI总线,也即主PIC总线。在内核中,每条PCI总线都由一个pci_bus数据结构代表,这些数据结构互相连接在一起,形成PCI总线树,每棵树大根是一个代表着“宿主-PCI”的pci_bus结构。每个pci_bus都维持着两个队列,一个是这条总线上的设备的pci_dev队列,另一个是连接在这条总线上的次层PCI总线的pci_bus队列。在为PCI总线分配了数据结构后,就可以开始扫描,逐个发现连接在该总线上的PCI设备,为其建立起pci_dev数据结构并挂入相应队列。对于PCI-PCI桥这样的设备,还需要逐次递归下去,最后建立起完整的PCI树。然后再为这些设备分配地址空间。 块设备的驱动块设备是文件系统的物质基础。inode结构中的指针i_fop指向了具体块设备的file_operation数据结构。同时它还包含一个专用于块设备的指针i_bdev,指向代表具体块设备的block_device。每种具体的块设备都有一套具体的操作block_device_operations,这些操作就是由块设备驱动来提供的。对于具体的设备老说,通常都有内置的控制器,完成对块设备的操作。block_device结构中的指针bd_op就指向了块设备block_device_operations对应的fops。例如IDE硬盘多整个访问过程,由上到下就是从抽象到vfs层文件出发,逐层加以具体化,找到相应的数据结构,并把它们联系在一起,层层打通关节: file_operation结构使vfs具体化成为了特定的文件系统或文件类型。 block_device数据使代表着抽象意义文件的inode结构具体化成为了块设备。 block_device_operations结构使块设备文件操作进一步具体化成为了IDE设备操作。 ide_driver_t将笼统的IDE设备具体化为特定种类的IDE设备。 ide_driver_t将某种IDE设备的操作具体化成对特定IDE设备的操作。在对文件进行读取时,内核会计算出未读的字节数,并且计算出起始块号,块内位移,以及本次应该读取的块数。在内存中,块设备的缓冲区以内存页和记录块两种形式体现:每个页面都被划分成记录块的形式。在每个块设备来说,都有一个读写请求队列的结构。每当有一个读写操作时,就要找到对应的块设备队列,然后创建一个新的读写请求数据结构,并且将它挂入队列。由于对磁盘的访问是需要寻道的,因此在这个队列中,可以对请求进行优化,这里有两种优化策略,一种是因扇区连续而引起的操作合并,另一种是对操作路线所作的优化或者调度。在队列中有一种电梯调度的机制,通过两次扫描,来帮助新来的操作请求应如何与原有操作请求合并的指导,如果合并成功了,就不需要创建一个新的request数据结构挂入队列了。否则则继续产生一个request队列。那么这里第二次优化来了:这个request如何插入队列中呢?直接放在队列的结尾显然不是最好的选择。这里要对磁盘移动的线路进行优化,将请求插入到合适的位置。在此之后,根据程序控制I/O和DMA两种方式,内核来完成对硬盘的输入/输出。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"I/O","slug":"I-O","permalink":"http://sec-lbx.tk/tags/I-O/"}]},{"title":"linux文件系统","slug":"linux文件系统","date":"2016-04-28T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/04/28/linux文件系统/","link":"","permalink":"http://sec-lbx.tk/2016/04/28/linux文件系统/","excerpt":"","text":"虚拟文件系统(VFS)是linux的内核软件层,它能够为各种文件系统提供通用的接口,例如linux,unix,windows系统。它是一个位于应用程序和具体文件之间的中间层。VFS引入了一个通用文件模型,它能够表示所有支持的文件系统,它包含有超级块对象、索引节点对象、文件对象、目录项对象。 超级块对象super_block存放已安装文件系统的有关信息,对基于磁盘的文件系统,它通常对应于存放在磁盘上的文件系统控制块。对于一个特定的文件系统,超级块的格式是固定的。每一个文件系统都对应一个超级块,它们都会链接到一个超级块链表,而文件系统中的每个文件在打开时都需要在内存分配一个inode结构它们都要链接到超级块。 pic 索引节点对象文件系统处理文件所需要的所有信息,都放在索引节点的数据结构中。对于每个文件来说,索引节点对文件是唯一的,其数据结构是inode。要访问一个文件时,一定要通过它的索引才知道它是什么类型的文件(inode有一个union包含了这个信息)。inode包含了文件的各种信息。它还包含一个dentry结构的队列,可以通过它找到与这个文件相联系的所有dentry结构,指向超级块的指针等。每个索引节点对象也包含在文件系统的双向循环链表中,这个链表的头保存在超级块对象中。同时,inodes也存放在一个散列表中,用来加快对索引对象的搜索。 文件对象文件对象用来描述一个进程怎样与一个打开的文件进行交互,它是在文件被打开的时候创建的,由一个file数据结构来描述。其存放的主要信息是文件指针,即文件中当前的位置。使用中的文件对象,包含在超级块所确立的链表中。其中的f_list字段包含了前一个/后一个对象的指针。 目录项对象VFS把每个目录看作由若干子目录和文件组成的一个普通文件。在目录项被读入内存后,VFS就用一个dentry结构来表示它。对于进程查找路径中的每一个分量,都为其创建一个目录项分量。每个分量都和其对应的索引节点相联系。由于目录项对象可能会经常使用,因此linux使用目录项高速缓存,它包含一个不同状态的目录项对象集合,以及一个用于快速的散列表。 文件操作函数的调用由于每个文件系统都有其自身的文件操作集合,VFS将inode从磁盘装入内存时,会把文件操作的指针存放在数据结构file_operation中(定义在fs.h中),而该结构的地址存放在索引节点对象的i_fop字段中。进程打开文件时,VFS就用存放在索引节点中的地址初始化新文件对象的f_op字段,使得后续操作能够继续调用这些文件操作函数。f_op是指向文件操作表的指针。事实上除了file_operations,还有dentry_operations和inode_operations,super_operations,但它们通常只在打开文件的过程中使用,不像file_operations结构中那些函数常用。 进程与文件已打开的文件,是属于进程的一项“财产”,归具体的进程所有。在进程的task_struct中,包含两个数据结构. struct fs_struct *fs; struct files_struct *files; 其中,fs_struct是关于文件系统的信息,它反映的主要是带有全局性的,对具体进程而言的信息,与具体打开的文件关系不大。fs_struct里面包含有一个指针pwd,它总是指向进程的“当前工作目录”,每当进程进入不同目录时,内核就使进程的pwd指向这个目录在内存中的dentry,而root指针则指向进程的根目录(还有一个altroot,指向“替换跟目录”)。files_struct是关于已打开文件的信息,它的主体是一个file结构数组,每打开一个文件后,进程就通过一个fid来访问这个文件,这个fid实际上就是它在file数组中的下标。 文件系统的安装和拆卸每当将一个存储设备安装到现有文件系统中的某个节点时,内核都要为之建立一个vfsmount结构,它既包含了该设备的信息,又包含了有关安装点的信息。因此fs_struct中的pwdmnt总是指向一个vfsmount结构。与传统的Unix内核不同,linuc允许同一个文件系统被安装许多次。对于每个安装操作,内核通过一个vfsmount数据结构来保存安装点和安装标志等信息。这个vfsmount数据结构保存在几个双向循环链表中:父文件系统vfsmount描述符的地址和安装点dentry的地址索引散列表;属于每一命名空间的已安装文件系统描述符形成的双向循环链表;每一个已安装文件系统已安装的文件形成等双向循环链表。 安装普通文件系统mount系统调用用来安装一个普通文件系统。它的服务例程sys_mount()向下调用了do_mount,并最后由do_kern_mount()函数完成了安装操作的核心。do_kern_mount首先会查找对应的文件系统类型,然后分配一个新的已安装文件系统的描述符mnt,并初始化一个新的超级块(如果get_sb能够在链表中找到对应的超级块对象,说明这个设备被安装了多次,直接返回这个已有超级块的地址),以及mnt中的一些字段。最后,它会把这个描述符插入到合适的链表中去。在完成这些工作后,该函数将mnt返回。 安装根文件系统根文件系统的安装与普通文件系统安装是不同的,它是系统初始化的关键部分。它分为两个部分,首先是安装特殊rootfs文件系统,它提供一个作为初始安装点的空目录;随后内核在空目录上安装实际根文件系统。为什么要先安装rootfs?因为它允许内核轻易地改变实际根文件系统,而内核会逐个安装卸载许多个根文件系统。在rootfs安装阶段,do_kern_mount会调用rootfs_get_sb,为其分配特殊的超级块。随后为进程0分配namespace,将其根目录和当前工作目录设置为根文件系统。在实际安装阶段,内核调用mount_root函数,在rootfs初始根文件系统中创建设备文件/dev/root。调用sys_chdr(“root”)改变进程的当前目录,然后移动rootfs上的安装文件系统和安装点。而rootfs也并没有被卸载,只是隐藏在根目录下而已。 卸载文件系统unmount系统调用由来卸载一个文件系统。相应的sys_unmount例程对文件名和一组标志进行处理。首先需要对安装点路径名进行查找,随后调用do_unount,根据标志位进行相应的处理。unmount_tree会卸载文件系统(及其所有子文件系统)。最后,内核会减少相应文件系统根目录的目录项对象和已安装文件系统描述符的引用计数器值。 路径名查找进程识别一个文件时,需要分析路径名,并且把它拆分成一个文件名序列。首先需要区分这个路径是相对路径还是绝对路径。这个可以通过路径名的第一个字符是否是“/”来确定。绝对路径从进程的根目录开始搜索,否则从进程的当前目录开始搜索。在这个过程中,内核会检查依次检查序列中的每一项,找到与它匹配的目录项,以获得相应的索引节点;再读取这个索引节点的目录文件,继续检查下去。但这个过程没有想象的那么简单:每个目录的访问权都需要检查,文件名可能是与任意一个路径名对应的符号链接,符号链接会包括循环,查找可能会延伸到其他的文件系统。路径名查找接受一个文件名指针,一个标志,以及一个nameidata数据结构的地址,这个nameidata存放了查找操作的结果。nameidata中的dentry和mnt分别指向所解析的最后一个路径分量的目录项对象和已安装文件系统对象。 文件打开/读写文件打开和关闭,核心是对一个fd进行操作。这个fd也就是指向文件对象的指针数组task_struct->files->fd中分配给新文件的索引。创建一个新的文件对象,然后将它放在fd数组中。文件读写是对于文件自身来说的,因此其相关的信息存储在inode中。linux中,对于文件的读写,实际上是对缓冲区直接进行的,而不是直接在文件上操作。对文件的操作由内核中的线程kflushd来完成,它们相当于一道流水线上的两道工序。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"文件系统","slug":"文件系统","permalink":"http://sec-lbx.tk/tags/文件系统/"}]},{"title":"操作系统寻址","slug":"操作系统寻址","date":"2016-04-21T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/04/21/操作系统寻址/","link":"","permalink":"http://sec-lbx.tk/2016/04/21/操作系统寻址/","excerpt":"","text":"x32/x64内存寻址逻辑地址<–>线性地址<–>物理地址逻辑地址:段标识符和指定段内相对地址的偏移量线性地址:虚拟地址物理地址:物理硬盘上的地址 线性地址到物理地址的转换,采用的是多级映射的方式。其中,32bit采用的是三级映射,而64bit采用的四级映射。根据页的大小,采用的映射模型有所不同。但一般采用4k大小的页面,它使用使用PML4T,PDPT,PDT和PT 四级页转化表结构。 映射的全过程Intel意图将一个进程的映象分成代码段、数据段和堆栈段,但linux但内核中堆栈段和数据段是不分开的,通常linux内核只使用四种不同的段寄存器数值,其中两种用于内核本身,两种用于所有进程。在segment.h中,有: #define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8) #define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8) #define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3) #define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3) 但在linux操作系统中,内核对于段式映射只是应对cpu的检查而已,其本质并没有起到什么作用。在x64位linux中,这个机制则已经取消了。页式映射才是映射的主要部分。每个进程都有自己的PGD,内核为即将运行的程序设置好CR3,MMU的硬件则可以通过CR3取得当前页面目录的指针。对于进程的虚拟内存空间,linux内核中通过vm_area_struct数据结构进行描述。每个进程的虚拟内存空间,是由一连串的vm_area_struct链来描述的(它可能包含一连串的虚拟地址空间)。除了通过vm_next指针串成线性队列之外,还会在区间数量较大时建立一个AVL树,提高搜索的效率。这个结构体中还有一个mm_struct数据结构,它是比vm_area_struct高一层的数据结构。每个进程都有一个唯一的mm_struct结构。在每个进程的task_struct,都有一个指针指向它的mm_struct结构。mm_struct就包含了vm_area_struct的信息(链、AVL树、cache),进程代码段、数据段的存储位置等。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"memory","slug":"memory","permalink":"http://sec-lbx.tk/tags/memory/"}]},{"title":"Shell小记","slug":"shell小记","date":"2016-04-20T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/04/20/shell小记/","link":"","permalink":"http://sec-lbx.tk/2016/04/20/shell小记/","excerpt":"","text":"文件管理在某些场景中,需要统计一个文件夹下的所有文件大小,或者对它们排序。例如在一些安全软件中,常常有垃圾清理,文件整理这样的功能。这里做了一道题目,将一个文件夹中包含的所有文件(包含子文件夹下)按照大小进行排序。这里最后我的答案是 find . -type f -exec ls -lh {} \\;|sort -k 5 似乎并不是最好的答案。使用shell的话,利用ls是比较好的选择。在ls命令中,-S选项能够直接提供文件大小的排序,但问题在于,它不能把所有文件进行排序,只能在每个文件夹下进行排序;而且对于多个文件,ls无法显示文件的路径。所以这里使用了find。find -type能够指定文件的类型,这里指定查询的类型为文件(非目录)。然后sort -k指定了进行排序为第5列。这里exec的使用方法如下: -exec <command> {result} \\; 花括号{}代表find所查找出来的文件名,它的终止是以;为结束标志的,而这里的\\起到了转义字符的作用。除了我所使用的方法,我还参考了一下其他人的答案。有一种利用xargs的: find . -type f|xargs ls -lh|sort -k 5 xargs能够把不支持管道传递的参数传递给下一个命令,通常用空白字符作为分隔。 日志访客数量统计在网站的统计中,有时候需要通过日志来计算某一天的访客数量。这里有一个文件,它记录了某一天的访客记录,其中包含了访问时间,其第2项为访客的ID。那么如何确定访客数量呢?我做的答案是 grep "2015-08-24" log.txt|sort -k 2 -nu|wc -l wc命令用于统计某个文件中的字结束、字数和行数。其中-l为显示行数的命令。这里首先用grep筛选出2015-8-24的日志内容,然后对第二项进行排序,sort -u去除了重复项。然后用wc -l就统计出了这一天的访客数量。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"shell","slug":"shell","permalink":"http://sec-lbx.tk/tags/shell/"}]},{"title":"GCC内联汇编笔记","slug":"GCC内联汇编笔记","date":"2016-03-22T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/03/22/GCC内联汇编笔记/","link":"","permalink":"http://sec-lbx.tk/2016/03/22/GCC内联汇编笔记/","excerpt":"","text":"GCC汇编语法GCC使用的是AT&T语法。一些基本的语法如下: 源地址/目的地址语序:Opcode src dst 寄存器命名:寄存器添加前缀”%” 立即操作数:立即操作数和静态C变量要有前缀”$” 操作数大小:内存操作数大小取决于opcode对最后一个字母,例如”b”,”q”等 内存操作数:用”()”来进行内存引用 偏移量:写在”()”之前 tips:经过实验,发现寄存器的使用必须使用%%register,而变量可以使用%val,也就是说%的数量说明了操作数的类型 拓展内联汇编拓展汇编基本格式: asm ( assembler template : output operands /* optional */ : input operands /* optional */ : list of clobbered registers /* optional */ ); 其中,assembler template(汇编模板)即汇编指令。output operands为输出操作数,input为输入操作数。它们之间用冒号间隔开来。最后一部分是用来保护内联汇编中可能被污染的寄存器的。如果没有输出但是有输入操作数,则必须用两个冒号进行说明。多个操作数由逗号分开,而多个指令应由/n/t分隔开来。 操作数约束在汇编程序模板中,操作数通过编号来引用,从0开始,一直到n-1。常见的寄存器操作数约束如下: r Resigter a %rax b %rbx c %rcx d %rdx S %rsi D %rdi 其中,输入的格式为”r”(val),输出的格式为”=r”()。","raw":null,"content":null,"categories":[],"tags":[{"name":"GCC","slug":"GCC","permalink":"http://sec-lbx.tk/tags/GCC/"},{"name":"x64 assembly","slug":"x64-assembly","permalink":"http://sec-lbx.tk/tags/x64-assembly/"}]},{"title":"64bit-linux系统函数参数的传递","slug":"x64函数参数的传递","date":"2016-03-21T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/03/21/x64函数参数的传递/","link":"","permalink":"http://sec-lbx.tk/2016/03/21/x64函数参数的传递/","excerpt":"","text":"x64寄存器x64体系提供了16个通用寄存器,以及16个通用寄存器,以及16个浮点寄存器XMM/YMM寄存器。这些寄存器分为两类: 易失寄存器:由调用方假想的临时寄存器,并要在调用过程中销毁。 非易失寄存器:需要在整个函数调用过程中保留其值,一旦使用,必须由调用方保存。 也就是说,易失寄存器被定义为随时会改变,不用恢复它的初始值。但是如果要嵌入一些汇编语句,还是要对它们进行保护和恢复。而易失寄存器一旦使用,必须由调用方来对它们进行保存。也就是说在任何情况下使用它们,都必须进行保存。 寄存器 使用 是否在调用前保存 RAX 临时寄存器传递参数寄存器数量,第一返回值寄存器 否 RBX 被调用者保存寄存器,选择性的基址指针 是 RCX 传递第四个参数 否 RDX 传递第三个参数,第二返回值寄存器 否 RSP 栈指针 是 RBP 被调用者保存寄存器,选择性的栈帧寄存器 是 RSI 传递第二个参数 否 RDI 传递第一个参数 否 R8 传递第五个参数 否 R9 传递第六个参数 否 R10 临时寄存器,用于传递函数的静态链指针 否 R11 临时寄存器 否 R12-R15 被调用者保护寄存器 是 xmm0-xmm1 传递和返回浮点参数 否 xmm2-xmm7 传递浮点参数 否 xmm8-xmm15 临时寄存器 否 mmx0-mmx7 临时寄存器 否 st0-st1 临时寄存器,用来保存long double返回值 否 st2-st7 临时寄存器 否 fs 系统预留(线程特殊寄存器) 否 mxcsr SSE2控制和状态子寄存器 部分 x87 SW x87状态字 否 x87 CW x87控制字 是 参数传递可以看出,在Linux中,前6个参数都是利用寄存器来进行传递的。那么参数多于6个的情况下,是如何传递的呢?首先参数按照从左到右的顺序,依次使用寄存器,在寄存器被使用完后,参数从右到左依次入栈,使用堆栈进行参数的传递。此处有一个例子: typedef struct { int a, b; double d; } structparm; structparm s; int e, f, g, h, i, j, k; long double ld; double m, n; __m256 y; extern void func (int e, int f, structparm s, int g, int h, long double ld, double m, __m256 y, double n, int i, int j, int k); func (e, f, s, g, h, ld, m, y, n, i, j, k); 那么,在这个函数的调用中,寄存器的使用情况如下: 通用寄存器 浮点寄存器 栈帧偏移 %rdi:e %xmm0:s.d 0:ld %rsi:f %xmm1:m 16:j %rdx:s.a,s.b %xmm2:y 24:k %rcx:g %xmm3:n %r8:h %r9:i 此处存在两个疑问:第一、s.a,s.b为什么使用同一个寄存器;第二、ld为什么直接使用了栈帧传递?第一个是在结构体中,s.a,s.b是对齐可合并的,因此可以使用一个寄存器来传递这两个参数(此处存在疑问,是我自己的理解);第二个是因为long double被归为X87类,这类参数是必须通过内存来传递的。 Red zone在linux中,red zone是函数栈帧中,返回地址之下的一片区域,被调用函数可以使用red zone来储存局部变量,来避免对栈指针进行过多的修改。这大概就是在某些函数中,rsp直接被sub某个很大值的原因。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"x86-64","slug":"x86-64","permalink":"http://sec-lbx.tk/tags/x86-64/"}]},{"title":"nginx笔记2.md","slug":"nginx笔记2","date":"2016-03-14T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/03/14/nginx笔记2/","link":"","permalink":"http://sec-lbx.tk/2016/03/14/nginx笔记2/","excerpt":"","text":"nginx多站点配置nginx提供了一个配置文件:./nginx/cosnf/nginx.conf。在启动时,nginx也只能使用一个配置文件。那么如何把多个站点的配置文件分开呢?对于不同的站点,可以对每个站点分别编写一个conf文件,然后在nginx.conf中,用include命令把它们包含起来。通常将其放在两个文件夹下: mkdir ./nginx/sites-available/ mkdir ./nginx/sites-enabled/ cp ./nginx/conf/default.conf ./nginx/sites-available/<site> #link to the available site to enable it ln -s ./nginx/sites-available/<site> ./nginx/sites-enabled/<site> #include enabled sites in nginx.conf include ./nginx/sites-enabled/* 实际上配置文件可以放在任何位置,只要合理的去include就可以了。 php-fpm安装及配置php5和php5-fpm的安装: $ sudo apt-get install php5-cli $ sudo apt-get install php5-fpm 在与nginx配合使用时,需要对php-fpm的listen端口进行修改,这个值位于php5/fpm/pool.d/www.conf目录下,原始值为listen = /var/run/php5-fpm.sock。这里修改为listen = 127.0.0.1:9000。其实只要保证listen的值和nginx配置文件的地址:端口一致即可,也即在nginx.conf所包含的配置文件中,处理php的server及location中,设置fastcgi_pass php5-fpm.sock。配置完成后,启动php5-fpm,利用net-stat命令可以看到对目标地址:端口的监听是否存在,如果存在那么说明php5-fpm是正常工作的 $ php5-fpm $ net-stat -ano|grep 9000 nginx + php配置nginx和apache的不同之处在于,其本身不具有处理php的能力,它通过fastcgi来与php-fpm进行交互,来完成php文件的运行。因此为nginx和php设置好通讯端口,让两者能够正常交互是最基本的一步。在nginx.conf(及其所包含文件中),可以通过设置server和location,来处理URI中的php请求。 server { listen 80; server_name localhost; location ~ \\.php${ include fastcgi_params; root html; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } } 在配置完成之后,编写一个index.php文件进行测试。 #index.php <?php phpinfo(); ?> ./nginx -s reload php5-fpm curl localhost/index.php 如果能够显示php的版本信息,说明nginx+php的基本环境搭建成功了。 可能的问题不能正常处理路径时,修改: /etc/php5/fpm/php.ini中cgi.fix_pathinfo = 0; php5-fpm组和拥护设置不正确: ;/etc/php5/fpm/pool.d/www.conf user = www-data group = www-data 浏览器中以下载方式打开php文件时: /usr/local/nginx/conf/nginx.conf中default_type为text/html;","raw":null,"content":null,"categories":[],"tags":[{"name":"server","slug":"server","permalink":"http://sec-lbx.tk/tags/server/"},{"name":"nginx","slug":"nginx","permalink":"http://sec-lbx.tk/tags/nginx/"},{"name":"php","slug":"php","permalink":"http://sec-lbx.tk/tags/php/"}]},{"title":"nginx笔记1.md","slug":"nginx笔记1","date":"2016-03-11T09:40:00.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/03/11/nginx笔记1/","link":"","permalink":"http://sec-lbx.tk/2016/03/11/nginx笔记1/","excerpt":"","text":"nginx 启动/终止/重载入启动:运行主程序即启动控制: nginx -s [signal] signal:stop, quit, reload, ropen 应用新的配置: nginx -s reload 配置文件nginx包含由配置文件中的directives(simple, block)来控制的模块。包含其他directives的block被称为context。不被任何context包含的directives,被称为main context。例如:events和http在main context中,server在http中,location在server中。 几个context包括有:通常一个配置文件会包含有数个通过ports和server name区分的server。对于一个request,nginx会先确定用哪一个server,然后测试其header中的URI是否与server中的location中项一致。 http{ server{ location / { root /data/www; } location /imamges/ { root /data; } } } location指明了用来与URI比对的前缀,对于吻合的请求来说,URI会被添加上root中指定的根路径,来形成完整本地文件系统的路径。 FastCGI servernginx能够讲requests导向FastCGI server。FastCGI server能够运行使用不同框架和编程语言(例如PHP)编写的程序。最为基本的的FastCGI server配置方法是使用fastcgi_pass命令,SCRIPT_FILENAME参数指定了脚本文件的名称,QUERY_STRING参数用来传递request parameters。 server { location / { fastcgi_pass localhost:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param QUERY_STRING $query_string; } location ~ \\.(gif|jpg|png)$ { root /data/images; } } nginx如何处理requestnginx首先确定处理request的server,例如配置3个server: server { listen 80; server_name example.org www.example.org; ... } server { listen 80; server_name example.net www.example.net; ... } server { listen 80; server_name example.com www.example.com; ... } nginx检测request的header域“Host”来确定它应该被指向哪一个server。如果request不吻合任何一个server name,或者不包含header,那么会被导向该端口默认的port。对于没有header的request,可以设置一个空的server来专门drop他们,例如,其中code 444表示关闭connection。 server{ listen 80; server_name ""; return 444; } 在多个server监听不同IP地址的情况下,nginx首先会检测request的ip地址和port是否符合server中的listen命令。对于不同的ip,default server可以是不同的。 server { listen 192.168.1.1:80; server_name example.org www.example.org; ... } server { listen 192.168.1.1:80; server_name example.net www.example.net; ... } server { listen 192.168.1.2:80; server_name example.com www.example.com; ... } 那么nginx如何选择处理request的location呢?以一个简单的php网站为例。nginx首先搜索和字符串最长匹配的前缀。在下面的设置中,唯一的前缀位置时”/“,由于它会匹配任何request,所以它会被视为最靠后的选择。然后nginx检查配置文件中,通过正则表达式给出的locations列表。第一次成功匹配就会终止搜索,nginx会使用这个location。nginx只会检查request的URI部分。举例说明: request “/logo.gif”首先匹配了”/“,然后匹配了正则式”.(git|jpg|png)$”,因此它会选择后一个location,通过指令”root /data/www”request会被映射到文件/data/www/logo.gif中。 reqeust “/index.php”首先匹配了”/“,然后匹配了正则式”.(php)$”。因此request会被交给监听localhost:9000的FastCGI server。fastcgi_param命令将其脚本名设置”/data/www/index.php”,随后FastCGI server执行这个文件。($document_root变量和root命令指定的相同,$fastcgi_script_name与request的URI相同,也即/index.php)。 request “.about.html”只和前缀location”/“匹配,因此,它只会在这个location中被处理。使用“root /data/www”,request被映射到”data/www/about.html”,然后文件被发送到client。 request “/”的处理更为复杂,它只会匹配前缀location”/“,nginx会首先通过index指令来检测时候好存在index文件,如果/data/www/index.html不存在,但/data/www/index.php存在,那么FastCGI server会将其重定位到”index.php”,随后nginx会重新搜索这个location,就像这个request是被用户发送的一样。 server { listen 80; server_name example.org www.example.org; root /data/www; location / { index index.html index.php; } location ~* \\.(gif|jpg|png)$ { expires 30d; } location ~ \\.php$ { fastcgi_pass localhost:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }","raw":null,"content":null,"categories":[],"tags":[{"name":"server","slug":"server","permalink":"http://sec-lbx.tk/tags/server/"},{"name":"nginx","slug":"nginx","permalink":"http://sec-lbx.tk/tags/nginx/"}]},{"title":"寒假小结.md","slug":"寒假小结","date":"2016-02-17T06:16:04.000Z","updated":"2017-07-28T09:04:47.000Z","comments":true,"path":"2016/02/17/寒假小结/","link":"","permalink":"http://sec-lbx.tk/2016/02/17/寒假小结/","excerpt":"","text":"时间安排&进展春节休息八天,其余时间学习。实验室的工作: 掌握了glibc中切换到程序时的代码位置。 按照预想情况,切换EPT时会产生错误的缺页。 通过syscall在汇编代码中加入了输出语句,来证明切换确实执行了。 虽然切换完成了,但是仍存在错误的缺页。 glibc:程序的入口glibc中的csu/libc-start.c中,完成了从动态链接器到程序入口的工作。其中程序的入口和执行位于函数LIBC_START_MAIN(这个函数就是执行文件中都会有的libc_start_main)。该函数实现了应用程序的init、finit、main等工作,因此在这个函数中进行切换是较为直接的。具体到程序的入口来说, result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); 这句话就完成了程序的执行,程序执行完成后会回到这里执行。可以在此实现EPT的切换,但仍然会不可避免的使得某些页放在错误的EPT中。 x64汇编:字符串输出我想通过输出一些字符的方式,来验证一段trampoline是否执行了。和之前学的x86汇编不太一样的是,x64虽然支持32位的int中断方式,但使用这种方式地址也只能使用32位。x64采用的新的方法是syscall。 mov rax, n ;rax存放系统调用号 ... syscall ;执行syscall 这里采用把一个特定的字符(串)存放在栈里,然后利用1号syscall输出字符串到控制台,然后回复栈的方法,实现在trampoline里面输出信息,来帮助进行一些调试的工作。具体的代码(省略了寄存器的保护和恢复)如下: back[pos++] = 0x48; //mov rax, 0x1 back[pos++] = 0xc7; back[pos++] = 0xc0; back[pos++] = 0x01; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x48; //mov rdi, 0x1 back[pos++] = 0xc7; back[pos++] = 0xc7; back[pos++] = 0x01; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x48; //mov rsi, rsp back[pos++] = 0x89; back[pos++] = 0xe6; back[pos++] = 0x48; //mov rdx, 0x1 back[pos++] = 0xc7; back[pos++] = 0xc2; back[pos++] = 0x01; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x00; back[pos++] = 0x0f; //syscall back[pos++] = 0x05; back[pos++] = 0x48; //add rsp, 0x8 back[pos++] = 0x83; back[pos++] = 0xc4; back[pos++] = 0x08; Hexo闲暇时间里,用hexo在github pages上搭了这个博客。一直觉得对我们来说有个博客来放平时的收获是很不错的。Hexo确实十分简单,但这里还是有些东西值得记一下。首先在node.js的安装中,我发现nvm命令没有被定义,这是因为没有执行这么一句: $ source ~/.nvm/nvm.sh 在安装node.js之后,hexo本身的搭建和生成就很简单了,至于与git的关联,只需要在_config.yml中,最后加上 deploy: type: git repository: git@github.com:lbxl2345/lbxl2345.github.io.git branch: master 然后 $ hexo deploy 就能够将它直接发布到github提供的主页上了。发布文章也很方便: $ hexo new [layout] <title> 这里layout可以选择默认和自定义的布局(都存放在source/_post)文件夹中。在进行修改之后,要进行文件生成,然后再进行发布。 $ hexo generate $ hexo deploy 可见使用起来确实很方便。","raw":null,"content":null,"categories":[],"tags":[{"name":"linux","slug":"linux","permalink":"http://sec-lbx.tk/tags/linux/"},{"name":"x64 assembly","slug":"x64-assembly","permalink":"http://sec-lbx.tk/tags/x64-assembly/"}]}]}