批处理技术内幕:预处理

标签: , , , , ,

我一直认为,批处理水平的高低,并不决定于你是否熟悉所有命令的用法,是否了解各个命令的开关,毕竟这些都能在帮助文档中找到;而决定于你是否能把批处理中那些乱七八糟的符号搞懂,也就是所谓的“预处理”,准确的说应该是批处理的解析过程。

@echo off
set ^&=setlocal enabledelayedexpansion
set ^^^^^hero=^^^^^&p
set ^au=^^^au
set ^^^^^^^^^=障眼法
%&%
set ^^^^^se=^^^se!
echo %^^^^%!%^^hero%!au%^se%

传说这段代码出自英雄的“预处理”教程,这让我想起《C陷阱与缺陷》里有的一段话:

有一次,一个程序员与我交谈一个问题。他当时正在编写一个独立运行于某微处理机上的C程序。当计算机启动时,硬件将调用首地址为0为位置的子程序。

为了模拟开机启动时的情形,我们必须设计出一个C语句,以显示调用该子例程。经过一段时间的考虑,我们最后得到的语句如下:

(*(void(*)())0)();

像这样的表达式恐怕会令每个C程序员的内心都“不寒而栗”。

不知道你看到上面的批处理代码会不会“不寒而栗”,反正我即便是看完了英雄的预处理教程,小心翼翼如履薄冰地分析出了代码运行的结果,仍然对所谓的“预处理”一知半解,也许是我智商太低的缘故。如果你智商比较高,能够很轻松的对付上面的代码,那么下面的内容就没有必要看了。

我一直很反感“预处理”这个词,但是由于这个错误的概念已经深入人心了,我不得不沿用这种说法。还有一点要说明的是,由于CMD解析批处理脚本的过程比较复杂,我不可能在使用OllyDbg分析的过程一一截图,所以尽量用文字来讲解。

在《批处理技术内幕:Unicode》中我已经提到,CMD在解析批处理时每次会从当前文件指针的位置开始读取0x1FFF(8191)个字节(注意不是字符)到缓冲区(不妨起个名字,称之为AnsiBuf),然后将AnsiBuf以当前代码页转换成Unicode储存在另一个缓冲区(称之为UniBuf)。

事实上,在转换成Unicode之前,CMD还做了两件事:第一,在AnsiBuf中寻找"\n\r"或者"\r\n"的组合,如果找到的话就把它们之后的第一个字节修改为NUL(0x00)。第二,重新设定文件指针的位置,将文件指针指向刚才修改的那个字节的位置,这样下次CMD读取批处理时就从没有处理过的地方开始了。

由于AnsiBuf在某个地方被NUL给截断了,所以在调用MultiByteToWideChar函数时只有NUL之前的内容会被转成Unicode。

举个例子来说,如果将下面的代码以ANSI编码,PC格式换行符(\r\n)的形式保存:

@echo off
echo https://demon.tw
pause

那么CMD在第一次读取时,文件指针的位置是0,AnsiBuf的内容为全部的代码,但是由于存在"\r\n",所以AnsiBuf中"@echo off\r\n"后面的’e’会被修改为NUL,转换成Unicode之后UniBuf的内容为L"@echo off\r\n"(沿用C语言的写法,字符串前的L表示宽字符,即Unicode),文件指针会被重新设定为11(即第二行开头)。在对读入内存的第一行代码做完后续处理之后,CMD会再次打开批处理文件,从当前文件指针位置(别忘记已经被修改为11了)继续上面的过程,然后设定文件指针为33(0x21),然后……一直到处理完整个脚本为止。

如果你以Unix格式换行符(\n)保存上面的代码,那么情况会有所不同,因为不存在"\n\r"或者"\r\n"的组合,所以AnsiBuf中的内容会全部转成Unicode保存在UniBuf,CMD只需要读取一次文件(上面是三次)就能处理完整个脚本!是的,我知道这听起来很让人兴奋,减少IO的次数可以提高代码运行的效率,但是我强烈建议你不要这么做,Windows有Windows自己的规则,Unix有Unix自己的规则,在Windows下用Unix的规则恐怕不是一个很好的主意。如果你不听劝告,那么迟早有一天你会碰到莫名其妙的错误,并且百思不得其解。

OK,让我们回到UniBuf来,在转成Unicode之后,CMD又会把它复制到另一个缓冲区,该缓冲区主要用于词法分析,故称之为LexBuf。复制到LexBuf之后,会再次检查内容中是否存在’\n’或者’0x1A’(文件结束符),如果发现’0x1A’则将它修改为’\n’,如果发现’\n’(注意’0x1A已经改成’\n’了)则将它下一个字符修改为NUL(0x00),并且重新设定文件指针,使其指向文件中’\n’下一个字符的位置,那么下次读取时就能继续上次的位置了。这意味着如果在"\r\n"之前出现了’\n’或者’0x1A’字符,那么只会处理到’\n’或者’0x1A’,之后的代码只能等到下一次才能处理了。

如果说上面的过程都无关紧要,那么从这里开始就是重头戏了。接下来CMD会遍历LexBuf,看看是否存在百分号%,如果发现了%就会继续检测它的下一次字符,如果仍然是%,那么就替换成单个%;如果是星号*,并且开启了拓展(ENABLEEXTENSIONS),那么就替换成所有命令行参数;如果是数字("0123456789"),那么就替换成对应的命令行参数;如果以上都不符合,那么就认为是环境变量,继续寻找下一个%,如果找不到配对的%,那么会直接丢弃掉这个单独的%;如果找到了配对的%,那么会将两个%号之间的内容替换为对应的环境变量,如果环境变量不存在,则替换为空字符串。

也就是说,在进行任何的词法分析之前,%之间的内容已经被替换掉了,这就是网上那些批处理教程中所谓的“第一次预处理”。

替换完%之后,就可以进行词法分析了。词法分析(lexical analysis)是计算机科学中将字符序列转换为单词(Token)序列的过程。CMD的词法分析器会遍历LexBuf的字符,如果当前字符不是分隔符(Delimiter),就复制到另一个缓冲区中(称之为TokBuf)。

批处理的分隔符可以分为两种,一般分隔符和特殊分隔符。

一般分隔符为空白字符(Whitespace),包括空格(0x20)和0x09到0x0D之间的字符;还有逗号(,),分号(;),如果等号(=)没有特别意义的话也包括等号,此外还有C语言中的字符串结束符号NUL(0x00)。

特殊分隔符是指在批处理中有特殊意义的字符,包括&|<>,在某些特定语境中的()。

回车符(0x0D)会被直接丢弃掉,不会被复制到TokBuf。

扫描到双引号"的时候,会改变一个双引号标志位,表示之后的字符都是普通字符,没有特殊的含义,直到与之配对的"或者换行(\n)为止。

rem 正确,双引号中的字符没有特殊含义
set "$=&|<>()"
rem 正确,没有匹配的双引号也没关系
set "$=&|<>()
rem 错误,双引号外的特殊字符
set "$=&|<>()"&|<>()
pause

如果碰到转义字符^,就会直接复制它的下一个字符到TokBuf,而不会检测它是否为分隔符,是否有特殊意义。

所以set $=^&^|^<^>是不会有什么问题的。

转义字符^还有一个奇怪的特性,如果它的下一个字符是换行符(\n),那么会直接丢弃它,并且复制它(\n)后面的字符到TokBuf,这就是为什么我们可以这样获取换行符的原因:

@echo off
setlocal enabledelayedexpansion
set lf=^


echo http://!lf!demon.tw
pause

注意使用PC格式的换行符保存。

Offset      0  1  2  3  4  5  6  7   8  9  A  B  C  D  E  F

00000000   40 65 63 68 6F 20 6F 66  66 0D 0A 73 65 74 6C 6F   @echo off  setlo
00000010   63 61 6C 20 65 6E 61 62  6C 65 64 65 6C 61 79 65   cal enabledelaye
00000020   64 65 78 70 61 6E 73 69  6F 6E 0D 0A 73 65 74 20   dexpansion  set 
00000030   6C 66 3D 5E 0D 0A 0D 0A  0D 0A 65 63 68 6F 20 68   lf=^      echo h
00000040   74 74 70 3A 2F 2F 21 6C  66 21 64 65 6D 6F 6E 2E   ttp://!lf!demon.
00000050   74 77 0D 0A 70 61 75 73  65                        tw  pause

^后面是三个0x0A(换行符\n的十六进制,0x0D会被丢弃掉,故不考虑它),第一个0x0A会被丢弃掉,第二个0x0A会被当成set命令参数的一部分,而第三个0x0A是分隔符,表示命令结束。所以CMD最终运行的命令是set lf=[0x0A](换行符看不见,只好这样表示。)

当开启了变量延迟(ENABLEDELAYEDEXPANSION)的时候,如果命令中存在感叹号!,那么在命令解析完毕之后运行之前,会对^进行二次转义,并且会将!之间的内容替换为对应的环境变量,单独的!号会被丢弃,这就是所谓的“第二次预处理”。

最后让我们分析一下文章开头那段代码:

@echo off
set ^&=setlocal enabledelayedexpansion
:: 解析后为 set &=setlocal enabledelayedexpansion
set ^^^^^hero=^^^^^&p
:: 解析后为 set ^^hero=^^&p
set ^au=^^^au
:: 解析后为 set au=^au
set ^^^^^^^^^=障眼法
:: 解析后为 set ^^^^=障眼法
%&%
:: 替换%后为 setlocal enabledelayedexpansion
set ^^^^^se=^^^se!
:: set ^^se=^se!
:: 由于开启了变量延迟,会再转义一次
:: set ^se=se
echo %^^^^%!%^^hero%!au%^se%
:: 首先替换掉%再解析命令
:: echo 障眼法!^^&p!ause
:: 解析命令,&是连接操作符,连接两个命令
:: 第一个命令解析后为 echo 障眼法!^
:: 第二个命令解析后为 p!ause
:: 由于开启了变量延迟,替换掉!(找不到匹配的! 丢弃掉)
:: echo 障眼法
:: pause
赞赏

微信赞赏支付宝赞赏

随机文章:

  1. EditPlus的VBS语法高亮
  2. 火狐(FireFox)中的onkeydown事件
  3. 用VBS修改Windows用户密码
  4. VBS中存在两种不同类型的数组?
  5. 在VB中创建和使用VBS的内置WScript对象?

4 条评论 发表在“批处理技术内幕:预处理”上

  1. prophetk说道:

    太牛掰

  2. jakorzhang说道:

    博主 你的讲解是我到今天看到的最接近底层的 面上的教程可让我绕了个圈子 感谢博主!如果那天我转载一定表明博主文章的源地址

  3. wankoilz说道:

    “在对读入内存的第一行代码做完后续处理之后,CMD会再次打开批处理文件”里面的“后续处理”是指处理到执行完为止么,这样的话便可以解释“读一行执行一行”的说法了。

  4. amwfjhh说道:

    “^在碰到第一个0x0A后会将之丢弃”
    请问博主这个是跟踪出来的结果呢还是根据表现行为猜测的呢?

留下回复