-
-
[翻译]Lua 5.2 字节码和虚拟机
-
发表于: 2017-11-15 17:58 36830
-
by Dirk Laurie
Copyright © 2013. Freely available under the terms of the Lua license.
原文:http://files.catwell.info/misc/mirror/lua-5.2-bytecode-vm-dirk-laurie/lua52vm.html
我写此文是因为只有如此我才能理解它。此篇适合给非常熟悉Lua5.2参考手册(LRM5.2),在至少一台机器(Knuth’s MIX就行了)上有一点机器指令经验,并且想要对Lua5.2源码做些修修补补的读者阅读。
感谢:我从Elijah Frederickson的网站上借鉴了许多(https://github.com/mlnlover11/LuaAssemblyTools) (LAT) ,上面不仅有Lua5.1和Lua5.2的汇编工具,还有一份超完整的极其有用的其他人早期工作的集合,特别是,Kein-Hong Man的No-Frills Introduction to Lua5.1 VM Instructions(NFI),虽然是为Lua5.1而写但仍具极大的参考价值。当然,早晚你都得阅读Lus5.2源码的简练而权威的注释。
一个区块可以被存储在文件或者宿主程序的字符串中。为了执行一个区块,Lua首先为一台虚拟机将区块预编译成指令,然后它用一个解释器为虚拟机执行编译后的代码。
区块也可以被预编译成二进制形式,细节请参考luac。程序的源码形式和编译后的形式可以互转,Lua自动检测文件类型并自动执行。——§3.3.2
上面是LRM5.2中唯一提到”虚拟机”字眼的段落。三个标准库函数处理二进制区块:load、loadfile和string.dump.这就是用户能从官方获取的全部信息。对闭源代码来说这也就是用户能知道的全部了。
但是Lua是开源的。它的源代码有很好的注释,同时我们也可以做逆向工程。让我们从luac开始。
嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。
我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。
现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。
luac也可以接受二进制区块。我们可以用它来看看load做了什么。
能观察到区别吗?
即load和luac基本上生成完全一致的代码。
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
然后是一个指令列表,我们稍后会详细讨论其形式。
然后是常量列表。
紧随着是函数原型列表:
最后是外部局部变量列表:
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
在未剥离的版本中,给出了以下信息:
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
以下几乎逐字抄录自lopcodes.h
你绝对要先读过NFI,然后把这部分仅仅当成文档其它必须理解的部分的要点总结。实际上,它仅包含了一些帮助我自己读懂上面操作指令的东西,所以继续之前重读一下前面是个好主意。
我发现反编译后的Lua代码片段很有帮助,可以弄清楚为什么虚拟机指令可以达到预期的效果。例如:
Lua的C API在一个虚拟堆栈上做一切的事情。从某个角度看,虚拟机仅仅只是一个通向API的接口,提供了一个Lua源码和API间的抽象层。然而,VM会访问lua_State的一个私有域,而C API完全通过文档描述的方式工作。
虚拟机本身并不是基于栈的。它允许独占使用叫做帧(frame)的栈的一部分,但是在帧的内部,VM的寄存器数量是固定的,没有pushing和poping。一个重要的使用上的区别是负索引跟C API的含义不同。它们指向别处存储的常量,不在栈帧里面。
VM可以访问其它活跃栈帧上的外部局部变量。
局部变量是寄存器的别名。luac列表从调试信息中抓取他们的名字,但是指令反编译器做不到这点。创建它们不涉及任何开销。
当我有时间和兴趣的时候,我会添加更多的东西。欢迎各位指正和评论。如果我还活跃的话,你能在lua-l上找到我。
$ luac luac: no input files given usage: luac [options] [filenames] Available options are: -l list (use -l -l for full listing) -o name output to file 'name' (default is "luac.out") -p parse only -s strip debug information -v show version information -- stop handling options - stop handling options and process stdin
嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。
$ luac -l -l -v -s - Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio local hello = "Hello" print (hello.." World!") main <stdin:0,0> (7 instructions at 0x9984970) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [2] GETTABUP 1 0 -2 ; _ENV "print" 3 [2] MOVE 2 0 4 [2] LOADK 3 -3 ; " World!" 5 [2] CONCAT 2 2 3 6 [2] CALL 1 2 1 7 [2] RETURN 0 1 constants (3) for 0x9984970: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x9984970: 0 hello 2 8 upvalues (1) for 0x9984970: 0 _ENV 1 0
我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。
现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。
$ lua Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) io.open("hello.luac","wb"):write(chunk)
luac也可以接受二进制区块。我们可以用它来看看load做了什么。
$ luac -l -l -v hello.luac Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio main <(string):0,0> (7 instructions at 0x90d0b50) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [1] GETTABUP 1 0 -2 ; _ENV "print" 3 [1] MOVE 2 0 4 [1] LOADK 3 -3 ; " World!" 5 [1] CONCAT 2 2 3 6 [1] CALL 1 2 1 7 [1] RETURN 0 1 constants (3) for 0x90d0b50: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x90d0b50: 0 hello 2 8 upvalues (1) for 0x90d0b50: 0 _ENV 1 0
能观察到区别吗?
- (string)不同于stdin
- 四个地方的0x90d0b50和0x9984970不同
- 除了其中一行,其它都有[1]和[2]的差别
即load和luac基本上生成完全一致的代码。
字节码解剖
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
$ luac luac: no input files given usage: luac [options] [filenames] Available options are: -l list (use -l -l for full listing) -o name output to file 'name' (default is "luac.out") -p parse only -s strip debug information -v show version information -- stop handling options - stop handling options and process stdin
嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。
$ luac -l -l -v -s - Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio local hello = "Hello" print (hello.." World!") main <stdin:0,0> (7 instructions at 0x9984970) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [2] GETTABUP 1 0 -2 ; _ENV "print" 3 [2] MOVE 2 0 4 [2] LOADK 3 -3 ; " World!" 5 [2] CONCAT 2 2 3 6 [2] CALL 1 2 1 7 [2] RETURN 0 1 constants (3) for 0x9984970: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x9984970: 0 hello 2 8 upvalues (1) for 0x9984970: 0 _ENV 1 0
我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。
现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。
$ lua Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) io.open("hello.luac","wb"):write(chunk)
luac也可以接受二进制区块。我们可以用它来看看load做了什么。
$ luac -l -l -v hello.luac Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio main <(string):0,0> (7 instructions at 0x90d0b50) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [1] GETTABUP 1 0 -2 ; _ENV "print" 3 [1] MOVE 2 0 4 [1] LOADK 3 -3 ; " World!" 5 [1] CONCAT 2 2 3 6 [1] CALL 1 2 1 7 [1] RETURN 0 1 constants (3) for 0x90d0b50: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x90d0b50: 0 hello 2 8 upvalues (1) for 0x90d0b50: 0 _ENV 1 0
能观察到区别吗?
- (string)不同于stdin
- 四个地方的0x90d0b50和0x9984970不同
- 除了其中一行,其它都有[1]和[2]的差别
即load和luac基本上生成完全一致的代码。
字节码解剖
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
$ luac -l -l -v -s - Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio local hello = "Hello" print (hello.." World!") main <stdin:0,0> (7 instructions at 0x9984970) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [2] GETTABUP 1 0 -2 ; _ENV "print" 3 [2] MOVE 2 0 4 [2] LOADK 3 -3 ; " World!" 5 [2] CONCAT 2 2 3 6 [2] CALL 1 2 1 7 [2] RETURN 0 1 constants (3) for 0x9984970: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x9984970: 0 hello 2 8 upvalues (1) for 0x9984970: 0 _ENV 1 0
我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。
现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。
$ lua Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) io.open("hello.luac","wb"):write(chunk)
luac也可以接受二进制区块。我们可以用它来看看load做了什么。
$ luac -l -l -v hello.luac Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio main <(string):0,0> (7 instructions at 0x90d0b50) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [1] GETTABUP 1 0 -2 ; _ENV "print" 3 [1] MOVE 2 0 4 [1] LOADK 3 -3 ; " World!" 5 [1] CONCAT 2 2 3 6 [1] CALL 1 2 1 7 [1] RETURN 0 1 constants (3) for 0x90d0b50: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x90d0b50: 0 hello 2 8 upvalues (1) for 0x90d0b50: 0 _ENV 1 0
能观察到区别吗?
- (string)不同于stdin
- 四个地方的0x90d0b50和0x9984970不同
- 除了其中一行,其它都有[1]和[2]的差别
即load和luac基本上生成完全一致的代码。
字节码解剖
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
$ lua Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) io.open("hello.luac","wb"):write(chunk)
luac也可以接受二进制区块。我们可以用它来看看load做了什么。
$ luac -l -l -v hello.luac Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio main <(string):0,0> (7 instructions at 0x90d0b50) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [1] GETTABUP 1 0 -2 ; _ENV "print" 3 [1] MOVE 2 0 4 [1] LOADK 3 -3 ; " World!" 5 [1] CONCAT 2 2 3 6 [1] CALL 1 2 1 7 [1] RETURN 0 1 constants (3) for 0x90d0b50: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x90d0b50: 0 hello 2 8 upvalues (1) for 0x90d0b50: 0 _ENV 1 0
能观察到区别吗?
- (string)不同于stdin
- 四个地方的0x90d0b50和0x9984970不同
- 除了其中一行,其它都有[1]和[2]的差别
即load和luac基本上生成完全一致的代码。
字节码解剖
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
$ luac -l -l -v hello.luac Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio main <(string):0,0> (7 instructions at 0x90d0b50) 0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions 1 [1] LOADK 0 -1 ; "Hello" 2 [1] GETTABUP 1 0 -2 ; _ENV "print" 3 [1] MOVE 2 0 4 [1] LOADK 3 -3 ; " World!" 5 [1] CONCAT 2 2 3 6 [1] CALL 1 2 1 7 [1] RETURN 0 1 constants (3) for 0x90d0b50: 1 "Hello" 2 "print" 3 " World!" locals (1) for 0x90d0b50: 0 hello 2 8 upvalues (1) for 0x90d0b50: 0 _ENV 1 0
能观察到区别吗?
- (string)不同于stdin
- 四个地方的0x90d0b50和0x9984970不同
- 除了其中一行,其它都有[1]和[2]的差别
即load和luac基本上生成完全一致的代码。
字节码解剖
字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 30 00 |ld!...........0.| 00000070 00 00 6c 6f 63 61 6c 20 20 68 65 6c 6c 6f 20 3d |..local hello =| 00000080 20 22 48 65 6c 6c 6f 22 20 70 72 69 6e 74 20 28 | "Hello" print (| 00000090 68 65 6c 6c 6f 2e 2e 22 20 57 6f 72 6c 64 21 22 |hello.." World!"| 000000a0 29 00 07 00 00 00 01 00 00 00 01 00 00 00 01 00 |)...............| 000000b0 00 00 01 00 00 00 01 00 00 00 01 00 00 00 01 00 |................| 000000c0 00 00 01 00 00 00 06 00 00 00 68 65 6c 6c 6f 00 |..........hello.| 000000d0 01 00 00 00 07 00 00 00 01 00 00 00 05 00 00 00 |................| 000000e0 5f 45 4e 56 00 |_ENV.|
这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
00000000 1b 4c 75 61 52 00 01 04 04 04 08 00 19 93 0d 0a |.LuaR...........| 00000010 1a 0a 00 00 00 00 00 00 00 00 00 01 04 07 00 00 |................| 00000020 00 01 00 00 00 46 40 40 00 80 00 00 00 c1 80 00 |.....F@@........| 00000030 00 96 c0 00 01 5d 40 00 01 1f 00 80 00 03 00 00 |.....]@.........| 00000040 00 04 06 00 00 00 48 65 6c 6c 6f 00 04 06 00 00 |......Hello.....| 00000050 00 70 72 69 6e 74 00 04 08 00 00 00 20 57 6f 72 |.print...... Wor| 00000060 6c 64 21 00 00 00 00 00 01 00 00 00 01 00 00 00 |ld!.............| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |..............|
这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。
代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。
字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
1 x00: 1b 4c 75 61 LUA_SIGNATURE ,来自 lua.h. 5 x04: 52 00 二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。 5+2 x06: 01 04 04 04 08 00 六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating- point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。 7+6 x0c: 19 93 0d 0a 1a 0a 到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。
这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
13+6 x12: 00 00 00 00 源码中区块开始的行号。0是主区块。 19+4 x16: 00 00 00 00 源码中区块终止的行号。0是主区块。 23+4 x1a: 00 01 04 参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)
然后是一个指令列表,我们稍后会详细讨论其形式。
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
27+3 x1d: 07 00 00 00 列表有7个指令(小端字节序,就像文件头所说) 30+4*1 x21: 01 00 00 00 字节码,LOADK 0 -1. 30+4*2 x25: 46 40 40 00 字节码,GETTABUP 1 0 2. 30+4*3 x29: 80 00 00 00 字节码,MOVE 2 0. 30+4*4 x2d: c1 80 00 00 字节码,LOADK 3 -3. 30+4*5 x31: 96 c0 00 01 字节码,CONCAT 2 2 3. 30+4*6 x35: 5d 40 00 01 字节码,CALL 1 2 1. 30+4*7 x39: 1f 00 80 00 字节码,RETURN 0 1.
然后是常量列表。
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
58+4 x3d: 03 00 00 00 共3个常量。 62+4 x41: 04 第一个常量是string 66+1 x42: 06 00 00 00 含6字节, 67+4 x46: 48 65 6c 6c 6f 00 包含了”Hello”和一个终止的0字节。 71+6 x4c: 04 06 00 00 00 70 72 69 6e 74 00 第二个常量是6字节字符串”print”。 77+5+6 x57: 04 08 00 00 00 20 57 6f 72 6c 64 21 00 第三个常量是8字节字符串” World!”。
紧随着是函数原型列表:
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
88+5+8 x64: 00 00 00 00 无函数原型
最后是外部局部变量列表:
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
101+4 x68: 01 00 00 00 有1个外部局部变量 105+4 x6c: 01 00 可以在上1级的堆栈中找到,在堆栈的位置0。
从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
109+2 x6e: 00 00 00 00 无源代码 111+4 x72: 00 00 00 00 行号列表空 115+4 x76: 00 00 00 00 局部变量名空 119+4 x7a: 00 00 00 00 外部局部变量名空
在未剥离的版本中,给出了以下信息:
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
111 x6e: 30 00 00 00 源码长度48字节。忽略旧的源码, 111+4+48 xa2: 07 00 00 00 7个行号,忽略旧行号, 163+4+28 xc2: 01 00 00 00 列出了1个局部变量名。 199 xc6: 06 00 00 00 68 65 6c 6c 6f 00 变量名hello 199+4+6 xd0: 01 00 00 00 在指令1进入其作用域 209+4 xd4: 07 00 00 00 在指令7跳出其作用域 213+4 xd8: 01 00 00 00 列出1个外部局部变量 217+4 xdc: 05 00 00 00 5f 45 4e 56 00 变量名_ENV
此后的函数也会以同种方式编码。
目前我的探索发现到此。以下领域尚未覆盖:
- 非字符串常量如何存储
- 函数原型的形式为何
NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。
全面窥探字节码
伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。
所以让我们来看一下。$ lua -l vm52 Lua 5.2.1 Copyright (C) 1994-2012 Lua.org, PUC-Rio func = load 'local hello = "Hello" print (hello.." World!")' chunk = string.dump(func) function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end function nstack(chunk) return chunk:sub(29,29):byte() end function ninstr(chunk) return vm52.numberAt(chunk,30) end function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end ns = nstack(chunk) print ("This function" .. (hasvarargs(chunk) and " has a variable number of arguments and" or "") .. " uses "..ns.." register"..(ns~=1 and "s" or "")) for i=1,ninstr(chunk) do print(string.format("%08X",instr(chunk,i))) end 00000001 00404046 00000080 000080C1 0100C096 0100405D 0080001F
你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。
模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。
继续之前的Lua会话,
for i=1,vm52.ninstr(code) do print(vm52.disassemble(vm52.instr(code,i))) end LOADK 0 -1 GETTABUP 1 0 -2 MOVE 2 0 LOADK 3 -3 CONCAT 2 2 3 CALL 1 2 1 RETURN 0 1
指令解剖
以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。
所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:
就像我们看过的,指令是:
00 00 00 01 LOADK 0 -1 00 40 40 46 GETTABUP 1 0 -2 00 00 00 80 MOVE 2 0 00 00 80 c1 LOADK 3 -3 01 00 c0 96 CONCAT 2 2 3 01 00 40 5d CALL 1 2 1 00 80 00 1f RETURN 0 1
我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。
B Bx C A OP 000000000000000000 00000000 000001 LOADK 0 -1 (A,Bx) 000000000 100000001 00000001 000110 GETTABUP 1 0 -2 (A,B,C) 000000000 000000000 00000010 000000 MOVE 2 0 (A,B) 000000000000000010 00000011 000001 LOADK 3 -3 (A,Bx) 000000010 000000011 00000010 010110 CONCAT 2 2 3 (A,B,C) 000000010 000000001 00000001 011101 CALL 1 2 1 (A,B,C) 000000001 000000000 00000000 011111 RETURN 0 1 (A,B)
上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。
1、 OP是操作码(opcode),在0+6(看下面)。
2、 A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。
3、 B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。
第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。
4、 C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。
5、 Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。
6、 sBx意思是“Bx视为18为有符号数字”。
7、 Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。
指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。
R(A), R(B), R(C) 寄存器A,B或者C的数值。 Kst(Bx) 常数|Bx|。 KPROTO[Bx] 函数原型|Bx|。 RK(B), RK(C) 寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。 FPF 每次刷新的Fields。阅读NFI的解释。 UpValue 外部局部变量B的值 Pc 程序计数器:下一条被执行的指令的编号 closure 创建一个闭包(函数加上外部局部变量)。
加载参数
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
赞赏
- [翻译]渗透测试备忘单 17160
- [翻译]为编程和逆向搭建RISC-V开发环境 13815
- [翻译]状态机的状态 10992
- [原创]看雪CTF.TSRC 2018 团队赛 第一题 初世纪 writeup 2908
- [原创]京东AI CTF大挑战Writeup 7134