DEFCON CTF Quals 2026 - rfc1149a Write-up
개요
rfc1149a는 DEFCON CTF Qualifier 2026(주최 Benevolent Bureau of Birds)의 misc 문제이다. 여느 포렌식 문제와 달리 배포물은 pcap도 바이너리도 아니었고, 공원 잔디밭에 흩어진 종이 조각을 촬영한 사진 14장이 전부였다. 각 조각에는 16진수 두 자리가 격자로 인쇄되어 있었으며, 사진 곳곳에는 회색 깃털이 놓여 있었다.

문제의 성격은 이름과 배포 설명이 그대로 규정한다. RFC 1149는 1990년 만우절에 발행된 A Standard for the Transmission of IP Datagrams on Avian Carriers, 즉 IP 데이터그램을 조류 운반체(비둘기)에 종이로 부착해 전송한다는 농담 표준이다. 이번 대회 주최가 Benevolent Bureau of Birds이고 flag 포맷이 bbb{...}라는 점, 그리고 사진 속 깃털과 찢긴 종이는 하나의 상황을 가리킨다. 새가 운반하던 데이터그램이 운반 중 분해되어 잔디밭에 흩어졌다. 따라서 이 문제의 분석 대상은 손실·분절된 전송 매체로부터 상위 계층(IPv4 → TCP → HTTP → 압축 본문) 페이로드를 재구성하는 것이다.
조각 대부분이 45로 시작한다는 점에서 데이터의 정체는 곧바로 확정된다. 0x45는 IPv4 버전(4) + IHL(5, 20바이트 헤더)에 대응하므로, 인쇄된 바이트열은 IPv4 데이터그램이다.
본 글에서는 조각을 HTTP 트랜잭션 단위로 재조립하는 방법, chunked·gzip 본문을 복원하는 과정, 그리고 복원된 HTML/CSS의 리소스 참조를 따라 최종 flag에 도달하는 체인을 순서대로 정리한다.
1. 배경 — 무엇을 복원하는가
배포된 것은 아래와 같이 14장의 사진이며, 조각들은 서로 다른 사진에 나뉘어 흩어져 있다.

각 조각은 8바이트 단위로 끊어 인쇄된 hex dump이다. 첫 조각의 선두가 45 00 ...이므로 IPv4 헤더이고, 이후 프로토콜 필드가 06(TCP), 페이로드가 HTTP/1.1 ... ASCII로 이어진다. 즉 이 캡처는 단일 패킷이 아니라 한 브라우저 세션의 HTTP 트래픽 전체이며, 조각을 재조립하면 요청·응답이 순서대로 복원된다.
여기서 분석의 지배적 비용은 상위 계층 로직이 아니라 물리 계층 복원에 있다. 조각은 손바닥만 하고 인쇄 해상도가 낮아, 단독 판독으로는 자리 구분이 어렵다.
2. 조각 재조립
복원은 두 층위로 진행된다. 먼저 14장의 오버헤드 사진 자체를, 겹쳐 찍힌 잔디·잎·조각 패턴을 기준으로 한 장의 지도처럼 공간 정합한다. 그다음 각 조각 위의 hex를 판독한다.

위는 사진 정합 단계의 작업 보드이다. 붉은 점선은 각 사진의 경계, 원 표시는 정합 앵커로 사용한 사진 간 중첩 지점이다. 이렇게 전체 배치를 복원해두면, 한 조각이 어느 사진들에 걸쳐 찍혔는지와 인접 조각과의 연결 관계가 드러난다.
개별 조각 단위에서도 같은 원리가 적용된다. 조각은 절단이 아니라 파열로 분리되어 있어 경계가 서로 중첩되며, 한 조각의 말단 hex 시퀀스가 다른 조각의 선두 시퀀스와 겹친다. 이 중첩 구간을 앵커로 삼아 스트립을 연결한다. 그럼에도 확대 후 8/9/6, b/8, e/c가 구분되지 않는 자리가 다수 남는다. 대비를 강하게 올려 개별 자리를 재판독하고, 그래도 확정되지 않는 바이트는 부록에서 추정/후보로 표기한다.
조각을 임의로 이어붙일 수는 없다. 다행히 헤더 필드 두 개가 정렬 기준을 제공한다.
- IP Identification. 재조립된 조각의 IP ID는
0x6196,0x619e,0x619f와 같이 연속값을 취한다. 브라우저가 순차 발행한 요청/응답은 IP ID가 인접하므로, IP ID 정렬은 각 조각이 귀속되는 HTTP 메시지와 그 순서를 결정한다. - TCP Sequence Number. 동일 스트림 내부의 페이로드 순서는 TCP Seq로 정렬한다.
이 두 필드가 14장에 분산된 조각을 소수의 HTTP 트랜잭션으로 축약하는 인덱스로 기능한다. 이것이 없었다면 조각의 귀속·정렬은 사실상 불가능하다.
3. 패킷 파싱 — 첫 완성 조각 (IP ID 0x6196)
가장 먼저 온전히 복원된 데이터그램이다. 대비를 올리면 한 조각에서 한 패킷이 거의 통째로 읽히는 경우도 있었다.

8바이트 단위 hex dump의 헤더 영역(row00–row06)은 다음과 같다.
row00: 45 00 01 80 61 96 40 00
row01: 40 06 17 b6 22 10 6f 0e
row02: 0a 0d 25 01 00 50 a5 86
row03: 01 0b 94 cf 9a ee b0 64
row04: 80 10 01 fe d8 c4 00 00
row05: 01 01 08 0a 00 18 ef 89
row06: 00 17 c9 1e 48 54 54 50 ; 뒤 4바이트 = "HTTP"
3.1 IPv4 Header (20 bytes)
| Offset | 필드 | 값 | 해석 |
|---|---|---|---|
| 0x00 | Version / IHL | 45 | IPv4, 헤더 20바이트 |
| 0x02 | Total Length | 01 80 | 384 |
| 0x04 | Identification | 61 96 | 0x6196 |
| 0x06 | Flags / Frag | 40 00 | Don’t Fragment |
| 0x08 | TTL | 40 | 64 |
| 0x09 | Protocol | 06 | TCP |
| 0x0c | Source IP | 22 10 6f 0e | 34.16.111.14 (서버) |
| 0x10 | Dest IP | 0a 0d 25 01 | 10.13.37.1 (클라이언트) |
목적지 주소 10.13.37.1은 3·4옥텟이 13.37(leet)로, 실제 캡처가 아니라 인위적으로 구성된 환경임을 시사한다.
3.2 TCP Header (32 bytes)
소스 포트가 0x0050 = 80이므로 이 데이터그램은 서버가 클라이언트에게 보낸 응답이다. Data Offset이 0x8이라 TCP 헤더는 32바이트(옵션에 NOP·NOP·Timestamp 포함), 플래그는 0x10 = ACK이다.
3.3 길이 정합
IP Total Length = 0x0180 = 384
- IPv4 header = 20
- TCP header = 32
= TCP payload = 332
길이가 정확히 맞아떨어진다. 이 정합이 헤더 재조립의 무결성을 검증하는 첫 체크포인트가 된다.
4. HTTP / gzip 디코딩
payload(row06 offset 4의 48 54 54 50)를 ASCII로 변환하면 응답 헤더가 그대로 복원된다.
HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Tue, 05 May 2026 14:47:10 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Content-Encoding: gzip
15d
<1f 8b 08 ...>
두 인코딩이 본문 복원 경로를 규정한다. Transfer-Encoding: chunked이므로 본문 선두 15d\r\n은 청크 길이(0x15d = 349바이트)이고, Content-Encoding: gzip이므로 그 청크 데이터는 1f 8b 08(gzip magic, DEFLATE)로 개시된다.
row30: 69 70 0d 0a 0d 0a 31 35 ; "ip\r\n\r\n15"
row31: 64 0d 0a 1f 8b 08 00 00 ; "d\r\n" + 1f 8b 08 ...
gzip 스트림(349바이트)은 단일 데이터그램의 페이로드(332바이트)를 초과한다. 따라서 본문은 여러 조각에 분산되며, 복원 절차는 분산된 청크 본문을 IP ID·Seq 순으로 결합한 뒤 일괄 gunzip 하는 것이다. 이것이 14장 전량을 재조립해야 하는 구조적 이유이다.
압축을 해제하면 다음 HTML을 얻는다.
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Home</title>
<link rel="icon" href="data:image/svg+xml,...">
<link rel="stylesheet" href="/static/style.css"> <!-- 후속 참조 -->
</head>
<body>
...
<a href="/login">Login</a>
<a href="/register">Register</a>
...
</body>
</html>
5. 리소스 참조 체인 추적
이 캡처는 리소스가 서로를 참조하는 체인이다. 도달 경로는 다음과 같이 정의된다.
GET /응답(HTML)을 복원한다. (IP ID 0x6196)- HTML에서 후속 참조를 추출한다. →
/static/style.css - 해당 요청 데이터그램을 식별한다.
- 대응 응답 데이터그램을 식별한다. (
IP ID 0x619e/0x619f) - 응답 본문에서 flag 또는 후속 참조를 복원한다.
/static/style.css 응답(IP ID 0x619e, 0x619f)을 동일 절차로 복원하면 다음과 같다.
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Content-Disposition: inline; filename=theme-light.css
...
:root {
--bg: #f5f5f5;
--fg: #333;
--heading: #1a1a2e;
--card-bg: #fff;
--card-shadow: rgba(0,0,0,0.1);
--border: #ccc;
--btn-bg: #1a1a2e;
--btn-hover: #1621...; /* 조각 경계에서 절단 */
}
이 지점에서 판단 우선순위가 결과를 가른다. 체인의 다음 노드는 :root 변수 블록이 아니라 style.css 선두의 @import 이다. 이 CSS는 prefers-color-scheme에 따라 서로 다른 테마 파일을 로드하는데, 그 import 대상이 이제껏 등장하지 않은 별도 호스트를 가리킨다.
@import url('http://supersecretflagserverdonttellanyoneaboutit.rfc1149.ctfwithbirds.com/static/theme-light.css') (prefers-color-scheme: light);
@import url('http://supersecretflagserverdonttellanyoneaboutit.rfc1149.ctfwithbirds.com/static/theme-dark.css') (prefers-color-scheme: dark);
호스트 이름 supersecretflagserverdonttellanyoneaboutit 자체가 노골적인 표식이다. 즉 조각 중에는 이 “비밀 서버”와 오간 요청·응답 데이터그램도 섞여 있으며, 같은 절차(IP ID·Seq로 묶고 chunked·gzip 해제)로 해당 호스트의 theme-dark.css(또는 theme-light.css) 응답을 복원하면 본문에서 flag가 나온다.
전체 체인을 정리하면 다음과 같다.
GET / (IP ID 0x6196) -> HTML, <link href="/static/style.css">
-> GET /static/style.css (IP ID 0x619e/f) -> @import url(… secret host …/theme-*.css)
-> GET http://supersecretflagserverdonttellanyoneaboutit.rfc1149.ctfwithbirds.com/static/theme-dark.css
-> 응답 본문에 flag
bbb{dear_bob_got_any_hot_goss_on_mallory_love_carol_ps_u_up?}

6. 정리
- 문제 유형 식별이 방향을 확정한다. 조각 선두 바이트
45와 제목의 RFC 1149, 이 둘이 “IPv4 hex dump 복원”이라는 결론을 즉시 준다. - IP Identification과 TCP Sequence는 분절 조각을 HTTP 트랜잭션으로 접는 인덱스이다. 두 필드 없이 14장을 귀속·정렬하는 것은 불가능하다.
- 본문 복원은 chunked 해제 → gzip 결합 →
gunzip의 순서를 요구한다. 청크가 데이터그램 경계를 넘으므로 조각 전량이 필요하다. - 종단 리소스는 값이 아니라 참조 간선으로 도달한다.
<link href>를 타 CSS까지 간 뒤에도, 색상 변수가 아니라@import url()을 먼저 확인했어야 한다. 리소스를 타고 들어가는 문제에서 우선순위는 언제나 링크(link/import)이지 노드 내부 값이 아니다. 값 복원을 앞세우면 종단 도달이 지연된다.
판독이 갈린 바이트는 부록에서
추정/후보로 표기한다. 최종 호스트와 flag는 style.css의@import를 사진 속 CSS 응답과 교차검증해 확정한 값이다.
부록 A. 메인 데이터그램 덤프 (IP ID 0x6196)
헤더 및 HTTP 헤더 영역(row00–row30)은 ASCII 정합이 성립하여 확정. gzip 본문(row31–)은 부분 복원이며 일부 바이트는 추정이다.
; ---- IPv4 + TCP + HTTP header (확정) ----
row00: 45 00 01 80 61 96 40 00 E·····@·
row01: 40 06 17 b6 22 10 6f 0e @···"·o·
row02: 0a 0d 25 01 00 50 a5 86 ··%··P··
row03: 01 0b 94 cf 9a ee b0 64 ·······d ; off4: 9a
row04: 80 10 01 fe d8 c4 00 00 ········
row05: 01 01 08 0a 00 18 ef 89 ········
row06: 00 17 c9 1e 48 54 54 50 ····HTTP
row07: 2f 31 2e 31 20 32 30 30 /1.1 200
row08: 20 4f 4b 0d 0a 53 65 72 OK··Ser
row09: 76 65 72 3a 20 6e 67 69 ver: ngi
row10: 6e 78 2f 31 2e 32 32 2e nx/1.22.
row11: 31 0d 0a 44 61 74 65 3a 1··Date:
row12: 20 54 75 65 2c 20 30 35 Tue, 05
row13: 20 4d 61 79 20 32 30 32 May 202
row14: 36 20 31 34 3a 34 37 3a 6 14:47:
row15: 31 30 20 47 4d 54 0d 0a 10 GMT··
row16: 43 6f 6e 74 65 6e 74 2d Content-
row17: 54 79 70 65 3a 20 74 65 Type: te
row18: 78 74 2f 68 74 6d 6c 3b xt/html;
row19: 20 63 68 61 72 73 65 74 charset
row20: 3d 75 74 66 2d 38 0d 0a =utf-8··
row21: 54 72 61 6e 73 66 65 72 Transfer
row22: 2d 45 6e 63 6f 64 69 6e -Encodin
row23: 67 3a 20 63 68 75 6e 6b g: chunk
row24: 65 64 0d 0a 43 6f 6e 6e ed··Conn
row25: 65 63 74 69 6f 6e 3a 20 ection:
row26: 6b 65 65 70 2d 61 6c 69 keep-ali
row27: 76 65 0d 0a 43 6f 6e 74 ve··Cont
row28: 65 6e 74 2d 45 6e 63 6f ent-Enco
row29: 64 69 6e 67 3a 20 67 7a ding: gz
row30: 69 70 0d 0a 0d 0a 31 35 ip····15
; ---- chunk size + gzip body (부분 복원 / 추정 포함) ----
row31: 64 0d 0a 1f 8b 08 00 00 d·· + gzip 1f 8b 08
row32: 00 00 00 04 03 65 52 b1
row33: 4e c3 30 10 dd f3 15 c6 ; off4: dd (추정)
row34: 12 13 34 4e cb 42 2b c7
row35: 03 43 c5 c0 80 10 88 d9
row36: 24 97 c4 c2 89 a3 f8 d4 ; off4: 89 (추정)
row37: 34 6c 7c 01 9f c0 2f f2
row38: b0 9c dd 94 82 18 ec 7b
row39: f6 7b b9 7b dd 8e 3c 2b ; off4: dd (추정)
row40: 5d 81 53 0f ac c1 d6 aa
row41: 4d 1e 03 e8 52 25 8c c9
row42: 16 50 b3 a2 d1 83 07 cc
row43: f9 d3 e3 76 dd cd 23 81 ; off4: dd (추정)
row44: 06 2d ?8 5b c7 82 14 07 ; off2: 미판독
row45: 1c e4 db 74 ?f 6c 00 9b ; off4: 미판독
row46: 73 53 b8 8e ?? ?? ?? ?? ; off0–3: 73 53 b8 8e (추정), 이후 미판독
row47: e7 a5 46 bd ?? ?? ?? ?? ; 후보 A: e7 a5 46 bd / 후보 B: e7 35 30 bd
row47 선두 4바이트는 두 판독(e7 a5 46 bd / e7 35 30 bd)으로 분기한다. gzip 스트림 중간이라 단독 검증이 불가하므로 후보를 병기한다. 종단 HTML은 인접 조각의 gzip 본문을 결합해 DEFLATE 디코딩으로 검증하는 것이 정합적이다.
부록 B. 리소스 체인 요약
| 리소스 | IP ID | 내용 | 상태 |
|---|---|---|---|
GET / 응답 (HTML) | 0x6196 | gzip+chunked, <link href="/static/style.css"> | 복원 |
/static/style.css 응답 | 0x619e, 0x619f | 선두 @import(미디어 조건부) → secret host, :root{…} | 복원 |
…/static/theme-dark.css (secret host) | — | 본문에 flag | 정답 |
- secret host:
supersecretflagserverdonttellanyoneaboutit.rfc1149.ctfwithbirds.com - flag:
bbb{dear_bob_got_any_hot_goss_on_mallory_love_carol_ps_u_up?}
부록 C. 재현 절차
# 1) 조각을 IP ID로 그룹핑, TCP Seq로 정렬해 각 HTTP 메시지 hex를 결합
# 2) HTTP 헤더/청크 파싱 후 청크 데이터만 결합해 gzip 해제
printf '<hex-of-gzip-body>' | xxd -r -p | gunzip - # -> HTML/CSS
# 3) HTML <link>, CSS @import url() 등 참조 간선을 우선 추출하여 다음 응답으로 이동
# style.css의 @import가 secret host를 가리키므로, 해당 호스트의 theme-*.css 응답을 복원하면 flag