使用的工具為Ghidra
。
!!! WARNING: RETROACTIVE CONSTRUCTION !!!
本教程是基於回溯性建構出來的,即,是在已經得知結果(答案)的前提下反向推導(猜測)正向思考時會有什麼樣的思維路徑,這意味著它可能無助於引導第一次面對這類問題時的思考過程。
- 首先,这三个单词多少带上点自行创造并排起来的成分,也就是没这等单词。
- 它们的意思,我定義為「重命名」,「更改变量类型」和「更改函数signature」。
- 在使用ghidra途中,你会遇到很多很多看不懂的结果。所以,为了加快对程序逻辑的理解,为每个局部变量重新命名是很重要的。
- 就比如,这是一个
main()
函数,我们都知道在C
的标准下,main()
的signature是int main(int argc, char* argv[])
,所以直接右键,Edit function signature,把这一条复制进去好了。
- 这里面有大量的看不懂的名字为
local_x
,cVar
,iVar
,lVar
的变量等等等等。在理解了程序逻辑之后,请务必将它们重命名,因为在脑中记忆这堆没规律的变量的含义太容易突然忘掉了。 - 我们这么做的目的在于降低理解程序逻辑的负担。自然,如果你觉得是一个不是很重要的变量,或者暂时无法得知其用途,可以暂时不重命名。
- 我们再着重看这两行,它使用了一个
strcpy()
函数,其中的一个参数local_8
被标记为undefined8
类型。要知道,标准库strcpy()
的两个参数都是char*
类型,所以我们就可以将local_8
的类型更改为char*
,或者char[8]
。注意,如果要更改为char
定长数组,它的长度需要被仔细推定。 - 下文中所有的截图都是我重命名后的,请自行判断和本来的情况的对应。
- 检查输入的密码
argv[1]
是否为"passwd"
,是,则将argv[2]
参数传入int_overflow()
int_overflow()
的逻辑:调用标准库atoi()
判断传入的参数是否可以直接读为数字:- 如果不能,或者轉換出來的值是0,失败退出;
- 如果能,将读取出来的数字强制类型转换为
unsigned short
,如果强转后的值为0,则给出下一关密码。 - 因
atoi()
返回类型为int
,强转为ushort
时只保留低2位,故只要输入数字满足0x????0000
即可,其中?
表示任意非0十六进制数。
- 密码为
help
,无论是从Level 1得出来的还是直接检查都能找到。 - 要求至少4个参数,也就是
./exploit64 help username password
这个格式,username
、password
传入stack_overflow()
,多出四个的参数部分直接忽略。
-
stack_overflow()
逻辑:- 传
username
给verify_user()
,判断username
字段是否正确,只有正确才能继续。 - 之后,创建buffer,长度8 byte,将传入的
password
copy进入buffer, - 正确的password是
funny
,会设置buffer[strlen("funny")] = '\0'
进行截短,然后判断是否一样。- 这个
strlen()
不是直接摆在那里的,它本来会显示是某种THUNK_FUNC
,根据程序逻辑/源代码可以判断出。- 然後呢你直接把它重命名為
strlen()
之後,你在後面會驚喜地發現到處都是這玩意,但我們暫且先不管這個
- 然後呢你直接把它重命名為
- 这个
verify_user()
逻辑,跟旁边那个几乎一模一样,安排一个8 byte的buffer,copy,截短buffer然后判断是否一致
- 传
-
但问题是,这玩意叫stack overflow,然后呢上面写了,你login success but你也fail了捏。。而且根据hint,我们要找到某种方法进入
level3password()
函数。 -
为了利用stack overflow,先要去了解stack的行为,然后又要去了解Arm64 function call时的行为:
-
几乎每次跳转进入一个新函数时,都会在新函数的开头发现这两条指令:
stp x29,x30,[sp, #??]
和mov x29,sp
-
stp
含义为store paired,是将一对register(一个register 8 byte,一共16 byte)存入目标。stp x29,x30,[sp,#-0x30]
做两件事情:- 将
x29
和x30
存入sp-0x30
的位置,其中x29
存在[sp-0x30,sp-0x28)
,x30
存在[sp-0x28,sp-0x20)
。 - 设置
sp = sp-0x30
- 将
x29
在arm64中为frame pointer。x30
是link register,也就是执行ret
指令,从函数返回时跳转到的目标地址。
-
mov x29,sp
是设置x29 = sp
,也就是更新frame pointer。0x0 ----------------- <-- before stp..., sp points here | stack area | | for | | local vars | -0x20 ----------------- | x30 | -0x28 ----------------- | x29 | -0x30 ----------------- <-- after stp..., sp points here
-
以
verify_user()
函数为例,上面是简单的图解,右侧分别表示出我们刚刚进入函数,但还未执行stp x29,x30,[sp,#-0x30]
,以及执行了这条指令后,sp
指针的位置。 -
我们以还未执行函数开头的
stp x29,x30,[sp,#-0x30]
时sp
的值作为参考值,就可以在图示左侧标示出stack的深度,在后面,不加说明时,sp
表示的值(应该)都是以此为参考。 -
执行
mov x29,sp
之后,x29
指向的位置和执行stp x29,x30,[sp,#-0x30]
之后的sp
指向的位置一致,图中没有标识出来。x29
,也就是frame pointer,就是当前函数内用到的stack的最深的地方,更深的地方只会是它调用别的函数时,别的函数会用到的地方。 -
其中,
-0x28
处存的x30
是verify_user()
返回至调用它的前置函数stack_overflow()
时需要用到的返回地址, -
其中,
-0x30
处存的x29
是前置函数,也就是stack_overflow()
函数的frame pointer,它的值在stack_overflow()
开头处由mov x29,sp
确定。 -
我们可以看到,arm64在进入函数开始就将存放了返回地址的值的register
x30
压入stack中很低位的地方,而所有在stack中初始化的局部变量都放在比这个地方高的位置([sp-0x20, sp)
)。- 到这里,还请问问自己,为什么
[sp-0x20, sp)
这个符号表示"stack area for local vars"区域,请在此确认理解了 "以还未执行函数开头的stp x29,x30,[sp,#-0x30]
时sp
的值作为参考值" 这一句,这也是上方图片 左侧起第二竖排蓝色数字的含义 。
- 到这里,还请问问自己,为什么
-
还请注意,这和x86的行为完全不一样。x86使用
CALL
跳转到别的函数,而这个指令自身会将返回地址压入当前的ESP
,也就是stack指针指向的地址,至于跳转到函数之后,它内部其他局部变量的初始化,都是在这之后进行分配,这不同于arm64中像是"预先计算一段stack地址空间用来放局部变量,将其安置在高位后再放入返回地址"。
-
-
该如何进行stack overflow攻击:返回地址被保存在stack之中,在返回时会被从stack中取出。但如果我们在返回之前通过溢出修改相关区域的数值,就可以改变它返回的地址,实现攻击。
-
为了实现stack overflow攻击,我们需要先知道变量都在stack的哪些地方被初始化了,这很重要。
-
-
上方是ghidra显示的数据,下方是简单的对变量排布在stack上的图解。
-
0x0 ----------------- <-- before stp..., sp points here | username_copy | -0x8 ----------------- | admin_str | -0x10 ----------------- | ......... | | ......... | | ......... | -0x20 ----------------- | x30 | -0x28 ----------------- | x29 | -0x30 ----------------- <-- after stp..., sp points here
-
上面的排布关系请务必理解,这很重要。
-
-
我们要知道,在对
char*
写入内容时,是往更加高位的地址进行写入,以verify_user()
中对username_copy
数组的写入为例,这个数组在stack上被分配在[sp-0x8, sp-0x0)
,在写入时,我们依序写入sp-0x8
,sp-0x7
,sp-0x6
....sp-0x1
。 -
这意味着,我们没法通过缓存溢出写入到图示中
-0x28
处的x30
位置来实现攻击,因为它在更低位的地址处,这个位置存放的是verify_user()
的返回地址,也就是说,我们没法通过溢出自己的返回地址实现返回地址修改。- 这不同于x86,根据之前的说明,我们可以在x86中溢出自己的返回地址。
-
但是,在更加高位的地方,是否有别的
x30
可以利用呢?verify_user()
被stack_overflow()
调用,这个函数的x30
肯定在比上图示还要高位的地方,所以我们可以尝试溢出写入到这个地址,然后当它返回时,我们的攻击就成功了。 -
检查
stack_overflow()
,x30
存在[sp-0x28,sp-0x20)
。图示中,从左起第二竖列,蓝色的数字表示sp
的深度变化,所以调用verify_user()
时深度为-0x30
。 -
以
stack_overflow()
函数的深度作为参考值,我们可以画出进入verify_user()
并完成它自身stack空间分配之后的stack的图解:-
0x0 --------------- |password_copy| <-- stack_overflow()'s local variables -0x8 --------------- |admin_pass...| -0x10 --------------- | ......... | | ......... | | ......... | --------------- | x30 | <-- stack_overflow()'s x30 -0x28 --------------- | x29 | <-- stack_overflow()'s x29 -0x30 --------------- <-- before call verify_user(), sp points here. |username_copy| <-- verify_user()'s local variables -0x38 --------------- | admin_str | -0x40 --------------- | ......... | | ......... | | ......... | -0x50 --------------- | x30 | <-- verify_user()'s x30 -0x58 --------------- | x29 | <-- verify_user()'s x29 -0x60 --------------- <-- after verify_user() saves its x29 and x30, sp points here.
-
在之前我们以
verify_user()
的stack情况画出了它的stack图示,在这个更大的图示中,之前的那个图示中的0x0
位置便是这个图中的-0x30
,在进入verify_user()
(执行bl verify_user
跳转到别的函数的开头)时sp
本身并不会变化。请注意这里因参考点的变化导致相对值的变化。
-
-
所以就可以看出,当我们写满8 byte
username_copy
,只要再多写8 byte就到stack_overflow()
的x30
的脚下了,这个时候再写入level3password()
的地址就好了。于是在stack_overflow()
返回时,它就不会跳转到本该跳转的地方,而是level3password()
的入口。
- 简单搜索一下,发现地址是
0x00401178
,剩下的就是编码问题了:- 这个文件用的是little endian,所以低位的
0x78
应该写在低位的地址,我们知道char写入从小地址逐渐往大地址写,所以实际的编码是\x78\x11\x40\x00
,反过来的! - 再然后,因为是arm64,我们地址得是64位对齐,所以得再加4个
\x00
。
- 这个文件用的是little endian,所以低位的
- 答案:
./exploit64 help ????????????????\x78\x11\x40\x00\x00\x00\x00\x00 test
- 这里有16个“?”,其中“?”只要不是
\x00
就可以,要规避strcpy
复制到一半不复制了。 test
也可以是任意字符,只要占个位置,因为函数会检查参数个数。
- 这里有16个“?”,其中“?”只要不是
- 为什么不能爆破
password
的buffer而只能爆破username
的:因为爆破password时溢出的对象是main()
的返回地址,然后你看看这里main()
返回了没:
这关跟上面那个几乎一模一样。
- password是
Velvet
,没了
- 函数套娃,明示你该溢出它的返回地址。
copy_array()
逻辑:它自带一个32 byte buffer,传入的参数分别指示修改哪一位、将哪一位修改为什么值,也就是target_arr[argv[2]] = argv[3]
,会在main()
里面用atoi()
转为数字先。- 很显然它不检查边界~ 那我们就要去找这个buffer相对于
array_overflow()
函数存在stack中的返回地址的距离了。
- 运气真好耶,写满buffer之后就到
copy_array
的sp+0
的位置了。
-
一模一样的位置,
bl copy_array
时,x30
的位置离那里就8 byte。 -
因为这是一个int数组,一个int 4 byte,所以
target_arr[32], target_arr[33]
对应x29
的位置,target_arr[34]
对应x30
的低位,target_arr[35]
对应高位。 -
但这里,我们只能修改buffer的一个值。这里选择34,因为我们看看就知道,即使是它正常返回的地址,也只需要32位就可以表示了,64位的部分全都是0,然后
level4password()
的地址好像高位也是0,我们直接改低位就完事了。- 理论上对Level 2同理
-
答案:
./exploit64 Velvet 34 4199112
,其中4199112也就是0x4012c8
,也就是level4password()
的入口地址。因为有一个atoi()
函数摆在最前面转化,我们选择输入十进制值。
- 我觉得找密码对这个binary file而言是最简单的,
mysecret
。
off_by_one()
逻辑:它使用strlen()
判断输入字符的长度,最多256,如果长度太长就不给写入buffer。- 很显然,我们还是要通过溢出攻击,把
target
由本来的\x01
写成\x00
,这样就成功拿到password了。
- 本关利用的设计:
strlen()
和strcpy()
的行为的不对应。 - 我们可以看到,
strlen()
不计算string末尾的\x00
作为长度的一部分,但strcpy()
会连带着把\x00
复制过去。 - 于是我们只要用一个256位的任意非
\x00
字符 +\x00
,总计257位,就可以成功。此时strlen()
结果仍为256,但可以通过strcpy()
写入257个字符。
- 我们再看看,我们的buffer就正好紧凑着要进行溢出攻击的
target
,多溢出一位就成功了,这俩设计放在一块简直是故意的(????????? - 答案:
./exploit64 mysecret ?????....???\x00
,其中“?”为非\x00
的ASCII字符,总计256个“?”。
- 密码是
freedom
,要给出第三个参数
-
stack_cookie()
逻辑:在sp-0x48
处有一个不检查边界的写入buffer,只要将sp-0x8
处的值覆盖成0x01
,同时保证[sp-0x4, sp-0x0)
处的值保持不变,即可拿到password。 -
答案:
./exploit64 freedom aaa...aaa\x01
,一共64个a,或者不是\x00
的ASCII就行。 -
这里很奇怪,因为标准答案上64偏移之后的是
\x01\x00\x00\x00\x37\x33\x00\x00
,我觉得它本意应该是在overflow到要修改的目标之前有一个会检查的数值,于是在溢出写入这个该保护的值时该用它本来的值去覆盖,而不是任意的字符。strcpy()
写入的单位是byte,要修改的目标又是个char没什么高位,甚至不要多余的部分都可以直接成功。。你把要修改的目标值设定为int 0xffffffff
之类的都比这难。。 -
而且又因为
strcpy()
+1 copy的特性,标准答案还会把前置函数的frame pointer写烂,虽然好像没大问题。。前置函数是main()
而且stack_cookie()
返回后就是exit(0)
根本不会烂
level的名字叫stack cookie,但我的小饼干根本不需要保护什么鬼
- 密码是
happyness
,只要提供密码就可以了
format_string()
逻辑:它会调用另一个funcgoodPassword()
,并判断它的返回是否为'Y'
,是就通过。
goodPassword()
逻辑:初始化一个int,值为ASCII的'N'
、以及它的指针在stack上。之后会从stdin
获取输入,并将输入写入保存在.bss
区域的buffer中。之后就没了,通过指针返回指向'N'
的int。- 我们的目标自然是把这个N硬改成Y了,但buffer都不在stack了,之前的老套路就不行了于是,何况
fgets()
也会限定写入buffer的字符数。 - 之后就是一个不Google可能半辈子都不会知道的攻击点:
printf()
可以用来攻击。虽然hints.txt里面写了要去看printf()
,你能模糊地猜到要这么去思考,但你肯定想不到具体怎么做对吧。printf()
的正确用法,大概类似于printf("hello\n")
,printf("hello, %s!\n",name)
之类的对吧,也就是你用格式化字符的时候得给出一个替换字符串中的格式化符号的变量作为参数,对吧对吧。- 但是,
printf()
并不检查传入参数的个数,这里它直接把Password
作为唯一参数传入其中, 如果我们故意让输入内容中包含格式化符号呢? 比如说prinf("%x,%x")
? - 查了一圈,
printf()
使用va_start()
和va_arg()
处理参数问题,如果参数不够时将会是Undefined behavior,但要是不掌握这个ub的规律,那想解决掉不就只能瞎猜吗。。什么深入理解Undefined behavior- 所以这个利用点被视作是programmer的编程失误,而不是设计缺陷,行为到现在还是一样。
- 检索资料,大部分文字会告诉你:
-
printf()
会将要打印的字符放在栈底(func call时sp
的实际值),紧跟随其后的高位将用来放入格式化的参数。如果sp+4
位置处有第一个int参数,它就会读取[sp+4, sp+8)
,如果这之后有第二个long long参数,它会读取[sp+8, sp+16)
,它有一个内置的栈指针来完成这些处理。- 如果格式化参数个数不够,
printf()
就会将这些部分看作自己的参数来使用,实际上根本不是它该访问的数据。 在最极端情况下,利用它可以将整个栈都泄漏。 - 最简单的
%x
格式化符号只是打印出栈的值,但printf()
有一个可以写入内容的格式化符号%n
,它的参数类型是int*
,用于保存已经输出到stdout
上的字符个数,自身不打印輸出任何內容。极端情况下可以实现任意地址的值写入。
-
- 这么一段描述既是正确的,也不正确,因为这是32位的function call convention,64位下会优先使用寄存器传参数,不够了才用stack。
- 实际检查也能发现,
printf()
的第一个参数放在x0
,而不是当前位置实际的sp
。 - 如果是32位,我们的计算将会是:
[sp-0x20, sp-1c)
保存了字符串地址,此时距离sp-0x8
20 byte,使用5个%x
(int)到达char_N_ptr
脚下,然后用%n
修改char_N
的值。 - 如果是64位,
x0
~x7
均为arm64的argument register,x0
中是字符串地址,计算为:x1
--->x7
+sp-0x20
--->sp-0x8
,总共7 * (8 byte register) + 24 byte stack = 80 byte,我们使用10个%llx
(long long)就可以到达目标脚下了。 - 下一个问题:写入什么。
%n
只是写入打印了多少个字符。%x
表示打印16进制值,80 byte可以表示160个16进制值,虽然\x00
只会打印一个0,而不是两个,但无论如何都超过了'Y'
的ASCII值(89),而且因为stack和register值不可预测,也不能用这种方法寻求修改成固定的值。
- 再看一眼代码,然后就会发现返回的不是
'N'
,是and0xff
后的值,那就可以操作了:'Y' : 0101 1001
0xff: 1111 1111
- 所以我们写入
345: 0001 0101 1001
个字符就好了,注意一个16进制字符占4个二进制,修改'Y'
的最高位(第八位)为1,也就是1101 1001
是不行的。 - 然后,我们可以通过在格式化符号上加个数字指定
printf()
最少打印多少个字符:%100llx
最少打印100个字符。这并不会影响stack位置的偏移,因为一个%
只会读一个参数,stack位置的改变由数据类型的size决定,而如果当前数据类型打印不了那么多时,它就会打印空白。
- 答案:
echo %201llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%n | ./exploit64 happyness
,其中9个%16llx
+1个%201llx
,共345写入 + 80 byte偏移,只要符合这个构造的答案都可以。
- 密码
mypony
-
heap_overflow()
逻辑:用new()
分配两个内存空间存放数据,heap_buffer
是大小为20 byte的char数组,target_var
是一个uint指针,然后将argv[2]
不检查边界复制进入heap_buffer
,最后检查另一个buffer是否为指定的值。 -
new()
本质上就是malloc()
的包装, 传给new()
的参数值是多少,在它自己的内部调用malloc()
时传入的值也会是多少。 这里要用到heap overflow,想理解的话要去理解malloc()
的内存分配机制就好。可以参考的内容很多,比如 1 2 3我自己都懒得看完,这里只说用到的点:-
在没有
free()
的情况下,先分配的变量存在低位地址,后分配的存在高位地址,而有free()
的情况下存在重复利用,会比较复杂。 -
现代glibc的
malloc()
分配的内存不是传入什么数字就分配多少内存空间,不是malloc(20)
然后再malloc(4)
就能在前者偏移20 byte处找到后者。 -
当使用
malloc()
分配内存时,它会将某一段heap分配为一个chunk
,但这还不是函数实际返回的地址,不过,chunk
的分配空间是连续的。 -
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ (低位地址) | Size of previous chunk [prev_size] | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Size of chunk, in bytes [size] |A|M|P| return-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ address | User data starts here... . . . . (malloc_usable_size() bytes) . . | next-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ chunk | (size of chunk, but used for application data) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Size of next chunk, in bytes |A|0|1| +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+(高位地址)
- 这是一个简单的
chunk
结构图解。 chunk
本身被分配的内存大小会是2 * SIZE_SZ
的正整数倍,对于64位,SIZE_SZ
值为8,也就是16 byte的整数倍,32位的SIZE_SZ
则为4。prev_size
:它在一个chunk
的开头部分,大小为SIZE_SZ
字节。- 如果这个
chunk
的前一部分(更低位的地址的地方)存在空闲的chunk
,那它存储这个空闲的chunk
的大小。 - 如果这个
chunk
的前一部分不是空闲的chunk
(被使用),那它会被前置chunk
用来存储自己的数据。 - 在图示中
next chunk
指示的部分,它的prev_size
就会被它上面的、第一个chunk
用来存储自己的数据(也就是"but used for application data"的含义)。
- 如果这个
size
:它跟随在prev_size
之后,存储自己这个chunk
的大小,大小也为SIZE_SZ
字节。它的最后三位(last 3 bits of this field)不是用来表示自己的大小,而是和这个chunk
有关的flag信息。prev_size
和size
合称chunk header
,剩下的部分叫做user data
,malloc()
返回的地址就指向user data
区域的开始。
-
chunk
的大小(size)和malloc()
request的值关系:-
/* pad request bytes into a usable size -- internal version */ //MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1 #define request2size(req) \ (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) \ ? MINSIZE \ : ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
-
我的理解就是,(64 bit)假定
chunk
大小为 $16n$ ,那么我们要找到 $\min{(n)}$ ,使得 $16n-16+8\ge req$ ,也就是,满足*“chunk
的大小去除掉chunk header
部分后,余下的user data
大小与prev_size
部分大小之和要能存下那么多数据,并保持对齐”*的最小值。
-
-
-
heap_buffer
的大小为0x20
,根据上述,它能申请到一个48 byte的chunk
,buffer写满时正好到达下一个chunk
的prev_size
脚下,然后紧邻的,这个chunk
正好也就是给target_var
分配的,于是我们再写入16 byte覆盖掉它的chunk header
就到它user data
区域脚下了,此时修改值就OK,总共的偏移为 32 byte buffer写满 + 16 bytechunk header
= 48 byte。 -
答案:
./exploit64 mypony aaaa....aaaa\x63\x67
,其中a有48个,依旧非\x00
ASCII都可。
- 密码
Exploiter
- 看看代码的开始几段就会发现是根本理解不了的pattern,我们双击20行
Msg::Msg(b_ptr)
中第二个Msg
看看是啥:
- 它是一个
_thiscall
,并且会把自己的Root
这种东西设置为某种vftable
??? - 实际上,
_thiscall
和vftable
是c++class
的特性,当然没人教的话也不会知道有这么一回事。使用ghidra自带的c++ class恢复脚本来处理一下:(实际上上面的截图都是执行完脚本之后的结果)
- 之后我们再使用上图的
GraphClassScript.java
查看class
继承关系:
-
所以实际上这个程序中有三个
class
,Root
作为base class
被Msg
和Run
继承。 -
为了解决这玩意,我们需要理解c++
class
的特性在assembly层原理,麻烦死了麻烦死了。。而且c++独立于c,又不是说c++ compile成binary时会有个中间的c代码(早年特性),就很难有参考的c代码了。。
-
这张图已经能提供很多很多信息了。首先,c++的
class
数据类型会被视作是一种struct
(图中未显示)。 -
而
struct
的底层实现:一个struct
对象其实是某种指针/数组。对它的地址的不同偏移就是这个struct
中各个元素的存储地址。-
struct SomeStruct { int a; char b; double c; }; SomeStruct S; // S is a pointer actually
-
S --> +-+-+-+-+-+-+-+ (低位地址) | int a | S+0x4 --> +-+-+-+-+-+-+-+ | char b | S+0x8 --> +-+-+-+-+-+-+-+ | double c | +-+-+-+-+-+-+-+ (高位地址)
-
这是一个简单图解。我们假定有一个
SomeStruct
类,之后我们又创建了它的一个对象S
,那么实际上S
是某种指针,这个指针指向的地址的不同偏移处存有这个struct
中的不同成员。注意图示不一定正确,因为当数据类型size不一样时存在地址对齐机制,但大体上是这种模式。
-
-
回到上面的图,我们来验证这种想法。我们看看右边的这个
b_ptr->Root = 0x0
,这其实就是在说,我们想要修改b_ptr
这个struct
中名为Root
的元素为0x0
。- 看左边,
x19
就是放b_ptr
的寄存器,str xzr,[x19]
意为将x19
的值解读为一个地址,并将0x0
写入这个地址,这也说明Root
这个成员在struct
指针偏移0x0
的位置处(也就是开头位置,或许可以理解为第一个成员)。
- 看左边,
-
class
类型被视作一个struct
,我们对这个struct
中各个元素的修改即为对这个对象的初始化。 -
下面,我们使用一段代码和它的decompile作为示例:
-
inherent的含義是「固有的」,不是「繼承的」:那個是inherit,我傻逼了,后面的都写错了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-
从ghidra的第10行开始看,我们先为新的
class
对象分配内存空间,之后我们将对象作为Base
类的constructor(Base::Base()
以及Base::Base(int,int)
)的一个参数,由这个constructor函数为我们实现类的构造。 -
之后,我们以
Base::Base(int,int)
的ghidra decompile结果作为示例进行说明constructor如何实现类的构造。- 这里我们就可以理解为什么
class
的成员函数中有this
指针这种玩意了,虽然源代码中函数参数没有这种东西,但编译的时候assembly会给加上。 - 先看ghidra的8、9行,我们设置
this+0x8
位置的值为参数a
,this+0xc
位置的值为参数b
(这里存在代码失误,本来这个位置的类成员应该是int,但我写成char了,然后这里的含义其实是将int强转为char),对照struct
底层实现和c++源代码,我们就可以知道 初始化/修改class
成员中的值这个过程其实就是为struct
成员进行值修改。 我们可以在vscode的55~60行找到constructor的定义。 - 之后是最关键的第7行,这相当于将
struct
第一个成员修改为0x11fd68
,这个东西便是所谓的虚函数表vftable
。
-
总结目前发现的内容:
- c++的
class
会被视作是struct
,class
的成员变量就是struct
的成员变量; class
中定义的constructor负责修改成员变量实现类对象的初始化;- 将其视作
struct
时,会发现它第一个成员十分特殊,它指向了一个表,这个表存有所有virtual function的入口地址。
- c++的
-
如何实现类方法的调用:
-
c++的继承类的方法调用规则:non-virtual function will be called according to its pointer type, but virtual function call is defined by initialized type。
- The definition of inherent class, it has method
inherentMethod()
,vMethod()
andvMethod2()
, both have definitions in its base class.
- The definition of inherent class, it has method
- Let's do some function call.
- Decompile result of these code.
- 我们重点关住decompile 35~39行,对应于源代码124~130行,上面我们可以看到,non-virtual function通过直接call实现方法调用,所以compiler就根据指针类型确定用哪个(35行),而virtual function是通过成员变量来call function的(38, 39),所以独立于指针类型而只和初始化时的类型关联。
-
继承类的constructor实现:
- 我们以
Next::Next(int,int)
为例,它首先调用base class的constructor,回忆一下,Base::Base(int,int)
会:- set
this+0x0
=vftable
of classBase
- set
this+0x8
=a
- set
this+0xc
=b
- set
- 但是
Base
constructor设置完自己的vftable
之后,相同的位置就会立刻被Next
的vftable
覆盖掉(第10行),没啥意义。 - 同时,
this+0x10
被设置为next_buffer
成员存放的地方,这个变量由继承类Next
所定义。这样,我们就可以看到constructor是如何处理base class, inherent class各自定义的成员变量在struct
中的排布情况。哦我甚至把代码写烂了,它应该是个buffer而不是单char喂
-
按理来说,inherent class的destructor实现也大同小异。
- 现在回来看原内容
- 首先是作为base class的
Root
,啥都没定义,不看(可自行check源代码)
- 之后是
Msg
类,它有一个constructorMsg::Msg()
和成员函数Msg::msg()
,这个constructor的定义上面放了图,现在能看懂了:
- but看看函数逻辑就会发现,我们并没有执行
Msg::msg()
,我们执行的是Run::run()
???
-
Run::run()
它没给我们打印密码,而是把打印的字符当作一个command直接执行??? -
不妨看看调用函数时的语句:
(**g_ptr->Root)(g_ptr, &buffer)
,解除引用了两次-
简单的图解:
-
+-+-+-+-+-+-+-+-+- <-- start of heap | | +-+-+-+-+-+-+-+-+- | | g_ptr -> +-+-+-+-+-+-+-+-+- |vftable address | ----> +-+-+-+-+-+-+-+-+-+-+-+-+ <-- start of vftable +-+-+-+-+-+-+-+-+- |address of Run::run() | ----> +-+-+-+-+-+-+-+ <-- start of Run::run() +-+-+-+-+-+-+-+-+-+-+-+-+ | *some code* | | | +-+-+-+-+-+-+-+
-
尝试解读「解除引用两次」:首先我们有一个
heap
内存分配,第一个*
表示读取存在heap
地址的数据(that is address of vftable),第二个*
表示将这个数据(address of vftable)理解成一个地址,读取这个地址的值(that is actual vftable value),之后因为是调用函数,这个值被理解为函数入口地址(entry ofRun::run()
),于是跳转到那里。
-
-
于是要做的事情就明确了:我们要想办法把
g_ptr
的值变成b_ptr
的值,这样在调用函数语句时,解引用得到的将是Msg
类的vftable
,同时,注意到Run()
在两个vftable
中的偏移地址是一样的,于是这时候调用到的就是Msg::Run()
了。或者,更歪门一点,直接找个地方执行Msg::msg()
函数。
-
上面的逻辑显示,我们会将
param_1
复制到一个buffer中,这不摆明了要通过stack overflow的方法将g_ptr
所对应的stack部分的值溢出写为b_ptr
的值,而这两个值均为通过new
在heap
上创建的空间的地址。 -
但要知道,linux存在保护机制
ASLR
,每次运行时动态分配所得到的地址是不同的,而我们overflow所用到的数值却是作为argv
传进去的,我们是没有办法在执行程序之前就知道这个heap
地址是什么的。 -
第一种方法,也是最简单的解决方法:关掉ASLR,此时
heap
地址就是可预测的了,然后随便执行几遍得到b_ptr
的地址(31行有printf()
会打印出来),此时构造stack overflow即可 -
不关掉
ASLR
的第二种方法:使用GDB
动态调试GDB
可以用来打断点、在运行时读取、修改程序的内存,因而可以满足我们的需求。- 基本思路:首先在41行的
strcpy()
函数处打断点,等到这个函数返回时,将g_ptr
所对应的区域的值覆盖为b_ptr
所对应的区域的值,所对应的地址上面已经很贴心提供打印语句了,GDB
里面应该能看到程序输出吧。
-
构造答案:
- 如果是关掉
ASLR
的做法的话,buffer
的地址在-0x60
,g_ptr
在-0x10
,于是得先塞80个垃圾再塞16进制的地址:aaa....aaa???????? := str
, then./exploit64 Exploiter str
。 - 如果是GDB的做法:不很简单吗随便输点东西只要进这道题的函数就完事了然后做什么上面不都说了吗不重复了。*本人没亲自测试过不保证一定可行
- 如果是关掉
- 到了最后delete的时候甚至删的是
g_ptr_copy
,也就是没被覆盖的g_ptr
,怕b_ptr
delete两次然后爆炸是吗。。但Msg::msg()
执行完打印出密码之后等它返回时哪管洪水滔天。。
- 密码是
Gimme
- 前期转化工作:使用
atoi()
将argv[3]
读取为数字,存入flag
;使用strtouq()
将argv[2]
字符串理解为十六进制值进行读取,保存在address
里面。之后将二者作为参数传入nullify()
- 其中
endptr
参数,当不为null
时,将被用来存储遇到的第一个invalid character的地址。如果nptr
完全不是数字,endptr
将会是nptr
的值。Ref
-
nullify()
的逻辑:在stack
上存有important_var
和它的地址指针important_ptr
,这个值会被预设置为2。 -
如果
flag
为1,则将传入的address
视作一个指向int
类型的指针,修改其值为0
; -
最后检测
important_var
是否发生了变化,如果变成0了就得到密码了。 -
其实思路很简单,只要传入的
address
的值就是important_var
分配在stack
上的地址,并且flag
为1就可以解决掉。但同样,在开启ASLR
的前提下这一地址是不确定的。 -
解法:
- 第一种:关掉
ASLR
,然后随便执行几遍得到important_var
的地址(第19行会打印出来),然后第三个参数是这个地址,第四个是1,没了。 - 不关掉
ASLR
的第二种方法:参照Level 使用GDB
调试,在printf()
函数上打断点,然后跳到它返回的地方,这个时候它已经把important_var
的地址打印出来了,只需要修改就是了。
- 第一种:关掉
-
我不是很明白为什么答案上面有第五个参数(也就是第二个flag),明明代码里面也没有读取这个的部分。
- 密码是,呃...
- 是
Fun
。 - 这里的
cmd_inject()
直接拿argc
和argv
作参数。
cmd_inject()
逻辑:- 本来,如果正常输入指令
some_cmd
,它就会帮我们执行man some_cmd
,我们需要做的是cmd injection,也就是想办法执行任意指令。hint也告诉你maybe要加一个;
,这是因为在linux中cmd1 ; cmd2
等于说同时执行两条指令。 - 最后我们也看到,只要它读到有
;
就把password给你了。 - 其实
&&
也能同时执行俩指令,所以设计得有点生硬。。 - 答案:
./exploit64 Fun "ls;echo hello"
,反正有;
就行。 - 注意,
ls;echo hello
部分我们用""
括起来,这是为了防止shell把它当成./exploit64 Fun ls
和echo hello
两条指令执行了。 其实最好用一个不存在的指令代替ls
部分,因为man
会占用stdout
,还得退出了man
之后才会打印密码。- 以及一些别的小细节。
strcat()
也像strcpy()
一样会在最后写入\x00
。所以,如果strlen(a) == 2
,strlen(b) == 3
,用strcat(b, a)
时b
被分配的空间至少要是strlen(a) + strlen(b) + 1 == 6
,这部分的逻辑也有off by one的问题。
- 密码是
Violet
path_traversal()
的逻辑:开头部分和Level 10一样,分配buffer组合出一个要执行的长指令cmd_buffer
,然后又是off by one- 之后,我们会用
strncmp()
对比path
参数的开头部分是否是dir1/dir2
或./dir1/dir2
,如果不是,就拒绝执行之后的指令(ls path
,含义是打印出指定目录下的文件、文件夹列表)。
strncmp()
做的事情:比较两个字符串前num
个字符是否一样,- 一旦遇到不一样,或者其中某个字符到末尾(
\x00
)也没比完,则返回非0,表示字符串不一样; - 或者,比较完
num
个字符之后,如果一切都一样,则返回0,表示比较结果是一样。
- 一旦遇到不一样,或者其中某个字符到末尾(
- 同样我们能看到突破点:这个函数只能比较前n个字符,但如果这部分之后的字符整了点魔法,那它是完全没有办法察觉到的,它会以为一切正常照旧执行。
- Linux常识:每个文件夹内都有两个特殊的文件夹
.
和..
,它们分别表示当前文件夹和上一个文件夹。 - 如果我们在
./dir1/dir2
执行cd ..
,会进入./dir1
文件夹;./dir1/dir2/..
实际上就是./dir1
文件夹。 - 在正常情况下,我们只能列出
dir1/dir2
文件夹内的内容,但使用..
这个技巧,就可以列出一切的文件夹的内容,只要exploit64
程序有权限。 - 所以就很无聊了。。在31行也能看到,只要我们提供的
path
是./dir1/dir2/../..
(等效于.
,也就是当前目录),那就给你密码,你看非常生硬是不是。。 - 答案:
./exploit64 Violet ./dir1/dir2/../..
- 还有很多很多爆破的思路,比如你可以像Level 10一样在后面加点
&&
或者;
,比如你可以多用点..
,但只有额外加俩..
才能拿到密码有点生硬。。
rop()
逻辑:首先printme(magic_stuff,0x1234)
,然后comp(0x1234)
,对比返回结果。
- 然后进入
printme()
之后,你会发现自己整个人都不好了:- 这里使用了两个
strlen()
函数,却不使用返回值?等于说什么都没做? - 我们去检查源代码,发现这里实际有一个
memcpy()
,但它为什么会被当作strlen()
啊??
- 这里使用了两个
- 我们随便点开一个
strlen()
,发现居然有一大堆的函数都叫strlen(char* param_1)
???为什么会这样??? - 也就是说在不知道的地方,有一堆本不该叫
strlen()
的也被当作strlen()
了??
- 复读:Thunk Function就是一个包装,把自身的控制传递给另一个函数。
-
上面是一个Thunk Function的示例,首先将
x16
加载为0x4ae000
,之后读取保存在这个地址中的值,将其存在x17
中,之后将x17
中的值理解为一个地址,直接跳转去了。 -
我们看到,这个过程中,有可能涉及参数的操作,也就是对
x0
~x7
register的修改,以及stack操作,都没有发生。我们只是跳到了另一个地方,并将参数原封不动地传过去,至于参数解读,那是函数自己的事情。 -
所以,这里ghidra它就有一个特性了(不知道别的有没有),你会发现这一串的Thunk Function全都跳转到同一个地址,那ghidra就把他们全当作是一个函数了,于是你改了其中一个的signature就把全部的都改了。
-
这里就有一种应对方法:Revert thunk function,这样它就会被当作独立函数,可以单独设置signature。
-
到目前为止,还是没解释这堆Thunk Function被用来做什么。它们长得非常像,但又有区别。
-
之后就是经典的,如果没去了解过就完全不知道的知识: 共享库的动态链接。 诸如
strcpy()
,memcpy()
的标准库函数,本身不会被集成在函数内,除非编译时加上-static
参数。所以,在二进制文件内,只有一个"占位符号",没有实际的代码,之后由动态链接器在执行时进行地址赋值,以便跳转到正确的函数地址。- 它之所以叫「共享庫」(shared library),是因為,其實很多進程都會用到它。
- 如果每個進程都單獨加載一份這些函數到RAM中,那就是一種極大浪費。
- 所以,不如讓動態鏈接器只加載一份,然後配置,讓用到它的進程都共享那唯一一個。
-
上面的内容出自《深入理解计算机系统》第三版 490~492页,仅供参考。
-
Arm64的与上面的描述大同小异,这里描述一下它的过程:
-
我们上面看到的那一堆Thunk Function类似于图片中提到的过程链接表(
PLT
);而从0x4ae000
开始的一堆东西,也就是每个Thunk Function的跳转地址存放的地方,类似于图片中提到的全局偏移量表(GOT
)。 -
GOT
的值在开始时全都是0x4002a0
,也就是第一个Thunk Function的地址(PLT[0]
)。这个函数实际上就是链接器函数。 -
回想一下,各Thunk Function(
PLT[i]
)都会将x16
的值设置为0x4ae000
+offset
(&GOT[i]
)。 所以,在进入链接器函数(PLT[0]
)时,它会通过x16
将真正的函数入口地址写在GOT[i]
。 这样,在之后,每次通过PLT
读取GOT
的值并跳转时,都会进入正确的共享库函数。 -
即使是链接器函数,它的
GOT
也被设置为了0x4ae000
,所以,程序运行了之后,它的值应该是被别的链接器设置的。 -
+-+-+-+-+- <-- start of PLT | Linker | <--------------------------------------------- Linker modifies +-+-+-+-+- +-+-+-+-+- <-- start of GOT | GOT[i] later(3) | | PLT[i] points | | | func call --> +-+-+-+-+- to GOT[i](1) +-+-+-+-+- | addr | PLT[i] | -------------> | GOT[i] | --------------------- GOT[i] points +-+-+-+-+- +-+-+-+-+- to linker(2) | | | | +-+-+-+-+- +-+-+-+-+-
-
-
上面是简单的图示,虽然多少有点不正确。
- 说了这么多总算把正确的函数逻辑整出来了(?):
- 在
sp-0x40
处有一个buffer。根据源代码,它的长度是60 byte,似乎能解释为什么有一堆uStack_xx = 0
。 - 之后,使用
memcpy()
,复制两倍的strlen(magic_stuff)
长度到buffer中。magic_stuff
是在main()
中被分配到stack上的。
- 不过这个两倍长度似乎也用不到什么,毕竟
main()
及其前置的stack都没什么用,maybe?
-
comp()
的函数逻辑:对比传入参数是否是0x5678
,是就打印密码。可问题是,前面我们调用它时用的参数就是0x1234
呀,这怎么改。。 -
解决方案1:歪门邪道
- 我们直接检查assembly,发现,程序从
printme()
返回之后,它是从stack读取参数保存进x0
,再进入comp()
的。 - 本来,
0x1234
是一个常量,会被直接载入x0
,不会存入stack,但assembly显示,它是在开头将x0
赋值为0x1234
,但在随后,因为要调用printme()
,占用了x0
,才将其放入stack的。 - 所以,我们只要将
sp-0x4
的地方写成0x5678
就可以达到目标了。 - buffer定义在
printme()
的sp-0x40
,64 byte写满到达sp-0x0
。写满时在rop()
的sp-0x30
处,距离sp-0x4
44 byte,总计 64 + 44 = 108 byte 距离。 - 构造
0x5678
:在开头时,我们使用scanf("%s")
读取输入,这意味着不能直接用\x78\x56
了,因为是%s
,它会被解读为\
,x
,7
,8
,\
,x
,5
,6
8个单独的ASCII字符。\x78
在ASCII中对应x
,\x56
对应V
,应该用这俩。
- 是否需要构造
\x78\x56\x00\x00
:不需要,因为本来这里存的值就是\x34\x12\x00\x00
,高位还是0不用动。 - 答案:
echo aaa....aaaxV | ./exploit64 ropeme
,一共108个a
,同样。
-
解决方案2: Return Oriented Programming(ROP)(标准答案思路)
-
什么是ROP: 回忆Level 2,Level 3的过程,我们都是将返回地址
x30
复写成别的函数的入口来拿到password的。ROP的思路也类似,都跳到别的函数or地方去,但不同的是,这次我们要取得对函数参数值的控制。- 也就是说,某种意义上我们不仅要修改让它跳进
comp()
,还得想办法传入参数0x5678
。
- 也就是说,某种意义上我们不仅要修改让它跳进
-
在32位下,根据call convention,所有的参数都保存在stack上。这是最简单的,通过stack overflow已经取得对每一处stack值的控制,自然也就控制了参数,不是难事。
-
在64位下,参数会优先保存在register,这会让难度大大提高。
- 我们需要先在当前程序内找到另一段代码,这段代码会恰好读取stack的值保存在register上,并在随后返回。
- 整体思路是,第一次返回时,跳到会把stack值存入register的代码片段,通过控制stack得以控制参数,之后将这个代码片段的返回值设为想进入的函数的入口。
- 跳的位置并不限定是某个函数的入口,还是它中间的某个地方,只需要符合条件,并地址对齐。
- 因为这样的片段很难找,说不定要跳好几次才能凑齐参数。
-
完整的思路如下:
-
- 通过overflow,将
rop()
的返回地址覆盖为ropgadgetstack()
的入口地址; ropgadgetstack()
会将stack中的值读入x0
register,找到stack中它的位置并覆盖为0x5678
;ropgadgetstack()
的返回地址也会被保存在stack上,将其覆盖为comp()
函数入口地址。
- 通过overflow,将
-
-
内容构造:
printme()
中,buffer在sp-0x40
,需要64 byte到达它的sp-0x0
,rop()
callprintme()
时stack深度为-0x30
,x29
距离它8 byte,- [1] ==> 64 + 8 = 72 byte 处有
0x400744
(8 byte) (ropgadgetstack()
入口)
- [1] ==> 64 + 8 = 72 byte 处有
rop()
返回时,stack深度归0,进入ropgadgetstack()
,x0
保存在sp
位置,- [2] ==> 64 + 48 = 112 byte 处有
0x5678
(4 byte)
- [2] ==> 64 + 48 = 112 byte 处有
ropgadgetstack()
的返回地址x30
保存在sp+0x18
- [3] ==> 112 + 24 = 136 byte 处有
0x400770
(8 byte)(comp()
入口地址)
- [3] ==> 112 + 24 = 136 byte 处有
- 最终结果==> 72 byte +
\x44\x07\x40\x00\x00\x00\x00\x00
+ 32 byte +\x78\x56\x00\x00
+ 20 byte +\x70\x07\x40\x00\x00\x00\x00\x00
-
sp+0x20 +-+-+-+-+-+-+-+ | gadget x30 | <-- [3] this is where ropgadgetstack()'s return sp+0x18 +-+-+-+-+-+-+-+ address saves, overwrite it to comp()'s | | address | | sp+0x8 +-+-+-+-+-+-+-+ | gadget x0 | <-- [2] after enter ropgadgetstack(), the func sp +-+-+-+-+-+-+-+ will store value here into x0, overwrite | | it to 0x5678 | | | | | | +-+-+-+-+-+-+-+ | rop() x30 | <-- [1] overwrite here to ropgadgetstack()'s sp-0x28 +-+-+-+-+-+-+-+ address, then rop() will return to there | rop() x29 | sp-0x30 +-+-+-+-+-+-+-+ <-- before rop() call printme(), sp points here | | | | | | | | | | | buffer | sp-0x70 +-+-+-+-+-+-+-+ <-- start of buffer in printme()
-
输入进程序:
- 之前也说了
scanf(%s)
的问题,得用其他方式输入,这里采用标准答案的方法,python的sys.stdout.buffer.write()
方法。
- 之前也说了
-
答案:
python -c "import sys; sys.stdout.buffer.write(b'a' * 72 + b'\x44\x07\x40\x00\x00\x00\x00\x00' + b'a' * 32 + b'\x78\x56\x00\x00' + b'a' * 20 + b'\x70\x07\x40\x00\x00\x00\x00\x00')" | ./exploit64 ropeme
- 标准答案与之有细微不同,最后它不是跳到
comp()
函数开头,而是它的内部、比较x0
和0x5678
是否一致的那条指令。
- 标准答案与之有细微不同,最后它不是跳到
-
- 密码是
Magic
- 传入
use_after_free()
的参数是argv[2]
,这个地方对应于<options>
参数,它是一串只包含0123的数字,比如3201,12,332,22,1。
use_after_free()
逻辑[1]:根据fgets()
可以确定,这里有一个512 byte的buffer。它要求我们输入command
,之后会将其存入buffer。
-
use_after_free()
逻辑[2]:循环依次读取argv[2]
这个数组的元素,然后将读取到的ASCII字符转化为数字,根据不同数字选择做不同的事情(图中各种if
分支)。 -
也就是说,有几个数字,就会依次做多少件事情。
-
option==0
:new_mapping()
-
option==1
:destroymapping()
-
option==2
:run()
-
option==3
:fillmapping()
-
这个程序,如果不是爆破它而是正常使用,它动作的顺序该是什么? 本来它应该做的事情,似乎是从
stdin
读取一串字符,把它当成一个指令去执行。为此: -
首先是option 0: 要先创建mapping,因为之后要用
run()
来执行命令。 -
然后是option 3:
run()
执行的指令在全局变量runcmd
上,要先准备好这个。 -
之后才是option 2: 执行
system(&runcmd)
。 -
最后是option 1: free掉。
-
也就是
./exploit64 Magic 0321
。- but我们在
fillmapping()
里面的那个malloc()
没free喂???
- but我们在
-
它的名字叫use after free,提示我们,要玩弄的是使用
free()
后再在heap上分配空间时的规则,在Level 7中提供的三个链接都有提到具体的规则~~,反正我都没看完~~。 -
仍旧只说用到的点:如果我们先用
malloc()
分配x
byte 空间,然后再free()
掉它,然后立刻我们再用malloc()
分配大小一模一样的x
byte 空间,那它们指向的是同一块内存,返回的指针都会一模一样的。 -
如果我们先
new_mapping()
,然后立刻destroy_mapping()
,因为上面所有的malloc()
都是分配512 byte 空间,这个时候再fillmapping()
,它内部的cmd_buffer_intermediate
被分配到的地址肯定和最开始mappingptr
被分配到的地址一摸一样,这样我们就取得了对这块空间的内容控制。 -
别忘了还有option 3,它会执行
mappingptr
offset0x40
处的函数,在assembly层,就是将那个地方的值解读为一个地址,并跳转到那个地址去。mappingptr
在被free()
之后它的值并没有被清成nullptr
,于是就可以实现任意地址跳转了。 -
构造:首先是参数,先
new_mapping()
,然后free()
掉,之后fillmapping()
控制内容,最后跳转,也就是0132
。输入的内容:0x40 == 64
,只需要先写64 byte,再写入0x4008c4
(level13password()
的入口地址)就可以了。- 我在想是不是应该叫
level14password()
,但Level 14的密码真的是它里面写的那个。
- 我在想是不是应该叫
-
答案:
python -c "import sys; sys.stdout.buffer.write(b'a' * 64 + b'\xc4\x08\x40\x00\x00\x00\x00\x00')" | ./exploit64 Magic 0132
- 因为还是存在
fgets()
把\xc4
当成4个单独字符的问题,不能直接echo
/ 输入,还是用了sys.stdout.buffer.write()
方法。
- 因为还是存在
- 密码
Jumper
,它会要求你给一个文件名称。
-
jop()
的流程:首先它在stacksp-0x8
上有一个function pointerfunc_ptr
。 -
之后,会读取参数里面给的文件,这个文件的开头4 byte标识文件的长度,决定后续从中读取多少字节(28,29行)。之后,从开头4 byte的位置开始读取二进制数据,并保存进stack里面的buffer。
-
之后,会通过
func_ptr
跳转到showflag()
: -
之后,会有一个在register内的变量
cookie
(我重命名的),它被初始化为0,却被要求和0x5678
对比。值一致时这一level就结束了。 -
仍旧,要想办法修改register的值。
-
思路1: Jump Oriented Programming (JOP)
-
这是标准答案的思路。既然这题叫这玩意那就用它吧。
剧透一下,标准答案疑似不对 -
什么是Jump Oriented Programming:之前我们提到过Return Oriented Programming,它利用的是覆盖
ret
指令的返回地址实现跳转;JOP与之类似,不过主要利用的跳转指令是jmp
,在Arm64中则为br
(branch register)。 -
JOP的主要思路还是和ROP一样,我们要找到各种各样的代码小片段(gadget),这个小片段会将stack内容存入register,或者别的需要用到的指令,最后会跳转到别的位置,通过控制stack完成整个跳转过程的控制。
-
先看看compare部分的assembly,它将
0x5678
和0
分别读入x0
跟w1
,然后再比较这俩。于是,我们可以先跳转到别的地方,把x0
和x1
的值写成一模一样的,再跳回到cmp w1,x0
的地方就好了。 -
怎么跳转:
func_ptr
的利用 -
跳转到哪里:
-
file_buffer
内容构造:file_buffer
在sp-0x38
,func_ptr
在sp-0x8
- [1] ==> 48 byte处有
jmpgadgetstack()
的入口地址(0x400758
)
- [1] ==> 48 byte处有
- 进入
jmpgadgetstack()
后,在sp+0x28
处的16 byte分别有x0
和x1
,jump到这里之前stack在sp-0x60
,-0x60 + 0x28 = -0x38
,刚好是file_buffer
开始的地方- [2] ==> 0 byte处有
\x78\x56\x00\x00\x00\x00\x00\x00\x78\x56\x00\x00\x00\x00\x00\x00
,对应于两个8 byte register长度,只需要值相等就可以。
- [2] ==> 0 byte处有
jmpgadgetstack()
使用br x2
跳转,x2
在sp+0x38
的位置,这里的值应该是0x400878
(cmp w1,x0
语句的地址),-0x60 + 0x38 = -0x28
,和file_buffer
差16 byte- [3] ==> 16 byte处有
\x78\x08\x40\x00\x00\x00\x00\x00
- [3] ==> 16 byte处有
- 至此,一共48 + 8 = 56 byte,文件长度为
0x38
。 - 所以,文件内容为
0x38
(4 byte) +\x78\x56\x00\x00\x00\x00\x00\x00\x78\x56\x00\x00\x00\x00\x00\x00
+\x78\x08\x40\x00\x00\x00\x00\x00
+ 24 byte 填充 +0x400758
(8 byte)
-
sp +-+-+-+-+-+-+-+-+-+ | func_ptr | <-- [1] here is func_ptr, overwrite it to jmpgadgetstack()'s sp-0x8 +-+-+-+-+-+-+-+-+-+ address and then we can jump to it. | | | | | | +-+-+-+-+-+-+-+-+-+ | x2 | <-- [3] in jmpgadgetstack(), value here is jump address, sp-0x28 +-+-+-+-+-+-+-+-+-+ overwrite it to 0x400878 so we will jump back to cmp | x1 | <-- jmpgadgetstack()'s x1 sp-0x30 +-+-+-+-+-+-+-+-+-+ | file_buffer(x0) | <-- jmpgadgetstack()'s x0 sp-0x38 +-+-+-+-+-+-+-+-+-+ <-- [2] in jmpgadgetstack(), value here will be stored in | | x0 and x1 | | | | sp-0x60 +-+-+-+-+-+-+-+-+-+ <-- before jop() jump to func_ptr, sp points here
-
构造答案:
python -c "import sys; sys.stdout.buffer.write(b'\x38\x00\x00\x00' + b'\x78\x56\x34\x12\x00\x00\x00\x00\x78\x56\x34\x12\x00\x00\x00\x00\x78\x08\x40\x00\x00\x00\x00\x00' + b'a' * 24 + b'\x58\x07\x40\x00\x00\x00\x00\x00')" > f.bin && ./exploit64 Jumper f.bin
,这个指令会将前面提到的内容写入f.bin
,之后exploit64
会读取这个文件。 -
但这个答案不对,在执行到
0x40075c
,也就是jmpgadgetstack()
中将stack值保存入x0
,x1
的指令时,会报出SIGBUS
错误。 -
主要原因是,在Arm64中,使用
ldp
指令时要求被读取的地址是16 byte对齐的。我们从0x400758
进入函数,如果执行ldp x8,x9,[sp],#0x28
时地址是16 byte对齐的,那sp+=0x28
之后它就一定不会对齐了,于是会在下一条指令出错。 -
反之,如果要求
0x40075c
位置处的sp
是16 byte对齐,那它上一条指令0x400758
就一定不对齐了,一样会SIGBUS
。 -
而如果我们不跳转到
0x400758
而是直接跳到0x40075c
,写入相关register的值来源又不在file_buffer
能溢出的范围之内,就没法利用了。 -
所以,如果不关掉严格检查16 byte对齐,那它就没法成功。我觉得这可能还是设计失误。
-
-
思路2:暴力跳过
- 上面把
func_ptr
值覆盖为gadget的地址跳转来跳转去,就是为了保证在比较x0
跟w1
时它俩的值是一样的,那别管这么多了我们直接跳过这个判断,直接跳入值相等时的分支不就好了啊??? - 值相等时会进入
0x400898
地址的分支,那就把func_ptr
覆盖成这个值好了。 file_buffer
内容构造:48 byte 处有0x400898
(8 byte),一共56 byte。- 答案:
python -c "import sys; sys.stdout.buffer.write(b'\x38\x00\x00\x00' + b'a' * 48 + b'\x98\x08\x40\x00\x00\x00\x00\x00')" > f.bin && ./exploit64 Jumper f.bin
- 上面把