[🥉 Bronze 2] 6523개 CAN 프레임 중 튀는 하나 — DreamHack Read My Car 풀이

2026-07-24·1분 읽기·

[🥉 Bronze 2] 6523개 CAN 프레임 중 튀는 하나 — DreamHack Read My Car 풀이

정비소에서 받아온 차량 주행 로그라며 candump 파일 하나만 준다. 열어 보면 1953가지 CAN ID가 뒤섞인 난수 덩어리인데, 등장 횟수를 세면 딱 하나만 다른 ID들보다 4배 넘게 튄다. 그 ID의 페이로드를 ASCII로 읽으면 flag가 나온다.

문제: DreamHack — Read My Car 분류: Misc 난이도: 🥉 Bronze 2 FLAG: DH{my_car_is_br0ken!!!!}

문제 설명은 짧다. 차에 문제가 생겨 정비소에 갔더니 정비사가 주행 데이터를 기록해 로그 파일로 줬는데, 알아볼 수가 없다는 것.

받은 건 candump-2024-04-19_143417.log 하나. 확장자와 파일명에서 이미 정체가 드러난다. candump는 리눅스 SocketCAN 툴셋(can-utils)에 들어 있는 CAN 버스 스니퍼고, 이 이름 규칙은 그 툴이 -l 옵션으로 남기는 로그 파일 그대로다.

이 글의 명령은 모두 extracted/ 에서 실행한다.

file candump-2024-04-19_143417.log && wc -l candump-2024-04-19_143417.log && head -6 candump-2024-04-19_143417.log
file과 head로 확인한 candump 로그 — 6523줄, 타임스탬프+인터페이스+ID#페이로드 형식
file과 head로 확인한 candump 로그 — 6523줄, 타임스탬프+인터페이스+ID#페이로드 형식

한 줄이 CAN 프레임 하나다. (1713504859.236545) vcan0 747#FDB3655488F4 — 괄호 안이 epoch 타임스탬프, vcan0이 인터페이스, # 앞이 프레임 ID, 뒤가 페이로드 hex.

참고로 첫 타임스탬프 1713504859를 KST로 환산하면 2024-04-19 14:34:19다. 파일명의 143417과 맞는다. 로그는 그럴듯하게 만들어졌다.


문제 개요

항목내용
문제명Read My Car
난이도🥉 Bronze 2
분류Misc (차량 포렌식)
제공 파일candump-2024-04-19_143417.log (6523줄, 271KB)
서버없음 — 파일만으로 완결
핵심 기법CAN ID 빈도 이상치 탐지 → 페이로드 ASCII 복원

풀이 흐름은 세 단계다. 로그를 파싱해 ID별 등장 횟수를 세고, 통계적으로 튀는 ID 하나를 골라내고, 그 ID의 페이로드만 이어 붙여 ASCII로 읽는다.



🚗 배경 — CAN 버스와 candump 로그 포맷

CAN(Controller Area Network)은 차량 내부 ECU들이 서로 떠드는 2선식 브로드캐스트 버스다. 엔진 ECU, ABS, 계기판, 도어 모듈이 전부 같은 선에 물려 있고, 각자 자기 메시지를 주기적으로 뿌린다. 수신자 주소 같은 건 없다. 프레임에 붙는 건 ID 하나뿐이고, 그 ID가 "이 메시지가 무슨 내용인지"를 뜻한다.

CAN 표준(base) 프레임 구조 — 11비트 Arbitration ID, 4비트 DLC, 최대 8바이트 Data
CAN 표준(base) 프레임 구조 — 11비트 Arbitration ID, 4비트 DLC, 최대 8바이트 Data

출처: Wikimedia Commons, CC BY-SA 3.0

이 문제에 필요한 필드는 셋이다.

  • Arbitration ID (11비트) — 표준 프레임이면 0x000~0x7FF, 총 2048가지. 메시지 종류를 뜻하는 동시에 버스 중재 우선순위로도 쓰인다. 두 노드가 동시에 송신하면 ID를 비트 단위로 비교해 숫자가 작은 쪽이 이긴다. 즉 0x000이 버스에서 가장 센 ID다.

  • DLC (4비트) — 데이터 길이. 0~8바이트.

  • Data (0~8바이트) — 실제 내용. 무슨 바이트가 무슨 뜻인지는 제조사 DBC 파일을 알아야 해석되고, 그 매핑은 공개돼 있지 않다.

candump 로그는 이걸 텍스트 한 줄로 눕힌 것이다.

(<epoch.microsec>) <interface> <ID(hex)>#<DATA(hex)>
(1713504859.236545)   vcan0        747  # FDB3655488F4

vcan0은 리눅스 커널의 가상 CAN 인터페이스다. 실차 하네스에 물리지 않고도 CAN 프레임을 주고받게 해주는 루프백 장치라, 이런 로그를 나중에 그대로 재생해볼 수 있다. 뒤에서 실제로 써먹는다.


🔬 정찰 — 로그가 무슨 모양인가

먼저 로그 전체를 파싱해 형태를 본다. 프레임 수, ID 종류, DLC 분포, 그리고 ID별 등장 횟수까지.

#!/usr/bin/env python3
"""candump 로그 1차 정찰 — 프레임 수 / ID 종류 / DLC 분포 / 시간 범위."""
import re
from collections import Counter
 
LOG = "candump-2024-04-19_143417.log"
FRAME = re.compile(r"\((\d+\.\d+)\)\s+(\S+)\s+([0-9A-F]{3})#([0-9A-F]*)$")
 
ts, ids, dlc, iface = [], Counter(), Counter(), Counter()
bad = 0
for line in open(LOG):
    m = FRAME.match(line.strip())
    if not m:
        bad += 1
        continue
    t, ifn, cid, payload = m.groups()
    ts.append(float(t))
    ids[cid] += 1
    dlc[len(payload) // 2] += 1
    iface[ifn] += 1
 
print(f"parsed frames : {len(ts)}  (unparsed {bad})")
print(f"interface     : {dict(iface)}")
print(f"unique CAN ID : {len(ids)}")
print(f"duration      : {ts[-1] - ts[0]:.2f} s  ({len(ts) / (ts[-1] - ts[0]):.0f} frame/s)")
print(f"DLC 분포      : {dict(sorted(dlc.items()))}")
print()
print("--- ID 등장 횟수 상위 8개 ---")
for cid, n in ids.most_common(8):
    print(f"  0x{cid} : {n:3d} 회")
print()
print("--- 등장 횟수별 ID 개수 ---")
hist = Counter(ids.values())
for n, k in sorted(hist.items()):
    print(f"  {n:2d}회 등장한 ID : {k:4d} 개")
recon.py 실행 — 6523프레임 / 1953 unique ID / 등장 횟수 히스토그램
recon.py 실행 — 6523프레임 / 1953 unique ID / 등장 횟수 히스토그램

숫자를 보면 답이 거의 나온다.

38.81초 동안 6523프레임, 초당 168프레임. 그런데 ID 종류가 1953가지다. 11비트 표준 ID 공간이 2048칸인데 그중 95%가 최소 한 번씩 쓰였다는 뜻이다.

실차 CAN은 이렇게 안 생겼다. 실제 차량은 ID를 수십 개 정도만 쓰고, 각 ID가 10ms·50ms·100ms 같은 고정 주기로 계속 반복된다. ID 종류가 2048칸을 거의 채운다는 건 ID를 난수로 뽑았다는 얘기다.

그리고 마지막 히스토그램. 대부분 ID가 1~10회 등장하는데 43회짜리가 딱 하나 있다. 2위(10회)의 4.3배다.


🐛 삽질

정찰 스크립트를 짜기 전에 먼저 해본 것들. 둘 다 헛다리였지만 문제 구조를 파악하는 데는 도움이 됐다.

▶🐛 삽질 1 — 무작정 전 프레임을 ASCII로 찍어보기

flag가 어딘가 문자열로 박혀 있을 수 있으니 grep "DH{"부터 해봤다. 0줄. 로그는 순수 hex 텍스트라 당연했다.

그래서 페이로드를 전부 디코딩해서 출력 가능한 ASCII만 뽑아 이어 붙여봤다.

#!/usr/bin/env python3
"""삽질 1 — 무작정 전 프레임을 ASCII 로 찍어보기. 우연히 출력가능한 조각이 쏟아진다."""
import re
 
LOG = "candump-2024-04-19_143417.log"
FRAME = re.compile(r"\((\d+\.\d+)\)\s+\S+\s+([0-9A-F]{3})#([0-9A-F]*)$")
 
hits = []
for line in open(LOG):
    m = FRAME.match(line.strip())
    if not m:
        continue
    _, cid, payload = m.groups()
    b = bytes.fromhex(payload)
    if len(b) >= 2 and all(0x20 <= c < 0x7F for c in b):
        hits.append((cid, b.decode()))
 
print(f"출력가능 ASCII 프레임 : {len(hits)} 개  <- 노이즈까지 전부 걸린다")
print("이어붙이면:")
print("".join(s for _, s in hits[:60])[:260])
try_ascii_all.py 실행 — 노이즈에 섞여 flag 조각이 반복되지만 쓰레기 문자가 함께 딸려온다
try_ascii_all.py 실행 — 노이즈에 섞여 flag 조각이 반복되지만 쓰레기 문자가 함께 딸려온다

my_car_is_br0ken!!!!가 보이긴 한다. 하지만 그 사이사이에 tJoEhA>/G(EgHAG;;%f{A:3j0 같은 쓰레기가 잔뜩 낀다. 28바이트짜리 난수가 우연히 전부 0x200x7E 범위에 들어갈 확률이 꽤 되기 때문이다(2바이트면 약 37%).

140개가 걸렸는데 진짜는 40개뿐. 어떤 조각이 진짜인지 구분할 기준이 없으면 이 방법은 못 쓴다. 그 기준이 결국 "어느 ID에서 왔나"였다.

▶🐛 삽질 2 — 진짜 주행 데이터로 보고 OBD-II 해석 시도

문제 설명이 "정비사가 주행 데이터를 기록해 줬다"고 하니, 진짜 차량 신호로 보고 속도·RPM을 뽑아내야 하나 싶었다. 정비소에서 쓰는 건 보통 OBD-II 진단 통신이고, 그건 요청 0x7DF / 응답 0x7E8~0x7EF라는 고정 ID를 쓴다.

#!/usr/bin/env python3
"""삽질 2 — 이게 정말 '주행 데이터'인가? OBD-II 표준 ID / 페이로드 규칙성 확인."""
import re
from collections import Counter
 
LOG = "candump-2024-04-19_143417.log"
FRAME = re.compile(r"\((\d+\.\d+)\)\s+\S+\s+([0-9A-F]{3})#([0-9A-F]*)$")
 
rows = []
for line in open(LOG):
    m = FRAME.match(line.strip())
    if m:
        t, cid, p = m.groups()
        rows.append((float(t), cid, p))
 
# (1) OBD-II 진단 통신은 요청 0x7DF, 응답 0x7E8~0x7EF 를 쓴다.
obd = {"7DF"} | {f"7E{c:X}" for c in range(0x0, 0x10)}
hit = [r for r in rows if r[1] in obd]
print(f"[1] OBD-II 표준 ID(7DF / 7E0~7EF) 프레임 : {len(hit)} 개")
for r in hit[:5]:
    print(f"      {r[1]}#{r[2]}")
 
# (2) 실차 CAN 은 같은 ID 가 10~100ms 주기로 규칙적으로 반복된다.
per = {}
for t, cid, p in rows:
    per.setdefault(cid, []).append(t)
multi = {c: v for c, v in per.items() if len(v) >= 5}
print(f"\n[2] 5회 이상 등장한 ID : {len(multi)} 개 / 전체 {len(per)} 개")
jit = []
for cid, v in multi.items():
    gaps = [b - a for a, b in zip(v, v[1:])]
    avg = sum(gaps) / len(gaps)
    spread = (max(gaps) - min(gaps)) / avg
    jit.append((spread, cid, avg))
jit.sort()
print("      주기 흔들림(=(max-min)/avg)이 작은 순 상위 5개:")
for spread, cid, avg in jit[:5]:
    print(f"        0x{cid}  평균주기 {avg*1000:7.1f} ms  흔들림 {spread:6.2f}")
 
# (3) 페이로드 바이트 분포 — 실차 신호면 특정 바이트가 고정/서서히 변한다.
allb = Counter()
for _, _, p in rows:
    allb.update(bytes.fromhex(p))
print(f"\n[3] 페이로드 바이트 종류 : {len(allb)}/256, 최빈 5개 {allb.most_common(5)}")
try_obd.py 실행 — OBD-II ID는 있지만 페이로드가 규격에 안 맞고, 주기성도 없고, 바이트 분포는 완전 균등
try_obd.py 실행 — OBD-II ID는 있지만 페이로드가 규격에 안 맞고, 주기성도 없고, 바이트 분포는 완전 균등

세 가지가 전부 아니라고 말한다.

0x7E8대 프레임이 45개 걸리긴 했다. 그런데 페이로드가 7EB#D551A329C25A0268이다. 진짜 OBD-II 응답이면 첫 바이트가 길이(보통 0x03~0x06), 둘째가 응답 모드(0x41)여야 하는데 D5 51은 규격 밖이다. 그냥 난수가 우연히 그 ID로 떨어진 것.

주기성도 없다. 흔들림이 가장 작은 ID조차 (max-min)/avg가 0.28이다. 실차 신호라면 이 값이 0에 가까워야 한다. 게다가 "평균 주기 3.9초"는 차량 신호로는 말이 안 되는 간격이다.

페이로드 바이트도 0~255가 전부 나오고 최빈값조차 211회로 평평하다. 난수다.

결론: 이건 주행 데이터가 아니라 난수 위에 메시지 하나를 숨긴 로그다. "주행 데이터"는 문제 설명의 연출이었다.


💣 핵심 — 0x000만 통계에서 벗어난다

"43회는 튀는 값이다"를 눈대중이 아니라 수치로 못 박아 두자. ID가 균등 랜덤이라면 6523개를 2048칸에 뿌리는 문제이고, 이건 포아송 분포로 근사된다.

#!/usr/bin/env python3
"""ID 분포가 '균등 랜덤'인지, 0x000 이 정말 이상치인지 수치로 확인."""
import math
import re
from collections import Counter
 
LOG = "candump-2024-04-19_143417.log"
FRAME = re.compile(r"\((\d+\.\d+)\)\s+\S+\s+([0-9A-F]{3})#([0-9A-F]*)$")
 
ids = Counter()
for line in open(LOG):
    m = FRAME.match(line.strip())
    if m:
        ids[m.group(2)] += 1
 
N, BINS = sum(ids.values()), 0x800          # 11비트 표준 ID = 2048개
lam = N / BINS
exp_distinct = BINS * (1 - (1 - 1 / BINS) ** N)
print(f"프레임 {N}개를 표준 ID {BINS}칸에 균등 랜덤으로 뿌렸다면")
print(f"  기대 ID 종류 : {exp_distinct:.0f} 개   /  실제 : {len(ids)} 개")
print(f"  칸당 평균    : {lam:.2f} 회")
 
# 균등 랜덤 가정에서 '한 칸이 k회 이상' 나올 기대 칸 수 (포아송 근사)
def pois_ge(k, lam):
    cdf = sum(math.exp(-lam) * lam ** i / math.factorial(i) for i in range(k))
    return max(0.0, 1 - cdf)
 
print("\n  k회 이상 등장하는 ID의 기대 개수(2048칸 중)")
for k in (10, 11, 12, 15, 20, 43):
    e = BINS * pois_ge(k, lam)
    real = sum(1 for v in ids.values() if v >= k)
    print(f"    k={k:2d} : 기대 {e:11.3e} 개   실제 {real} 개")
 
top, n = ids.most_common(1)[0]
print(f"\n→ 0x{top} 의 {n}회는 균등 랜덤으로는 설명이 안 된다. 누가 일부러 넣은 것.")
stats_check.py 실행 — 기대 ID 종류 1963개 vs 실제 1953개, k=43 기대값 2.3e-13개
stats_check.py 실행 — 기대 ID 종류 1963개 vs 실제 1953개, k=43 기대값 2.3e-13개

균등 랜덤 가정이 나머지 데이터를 정확히 설명한다. 기대 ID 종류 1963개에 실제 1953개, 10회 이상 나올 ID 기대치 3.49개에 실제 3개, 11회 이상 기대치 0.98개에 실제 1개.

그런데 43회는 기대치가 2.3e-13개다. 우연히 나올 물건이 아니다. 노이즈는 진짜 난수고, 0x000 하나만 손으로 심어졌다.

하필 0x000인 것도 우연이 아니다. 앞서 본 대로 CAN 중재에서 ID 숫자가 작을수록 우선순위가 높으니, 0x000은 버스에서 가장 강한 ID다. 실차라면 이 ID로 아무 데이터나 계속 쏘는 것 자체가 다른 ECU를 밀어내는 공격이 된다.

그 43개 프레임을 직접 본다.

grep -n -m 14 ' 000#' candump-2024-04-19_143417.log
grep으로 뽑은 0x000 프레임 원본 — 4개 묶음이 약 3초 간격으로 반복
grep으로 뽑은 0x000 프레임 원본 — 4개 묶음이 약 3초 간격으로 반복

패턴이 바로 보인다. 6D795F6361725F → 69735F → 6272306B656E → 21212121 네 개가 1ms 간격으로 붙어 나오고, 그 묶음이 약 3초 간격으로 다시 나온다. 줄 번호도 446·947·1449·1953으로 500줄쯤씩 뛴다.

hex를 ASCII로 옮기면 my_car_ / is_ / br0ken / !!!!. 순서대로 이으면 flag 본문이다.

노이즈 속 0x000 신호 추출 흐름 — ID 히스토그램으로 걸러 4조각을 이어 붙인다
노이즈 속 0x000 신호 추출 흐름 — ID 히스토그램으로 걸러 4조각을 이어 붙인다

43개 중 40개만 ASCII고 3개(9FD2, EE4CF127E1485E, E2C06F40B3B1A4)는 쓰레기다. 난수 생성기가 우연히 0x000을 뽑은 것들이라 걸러내야 한다. 출력 가능 ASCII만 남기면 자연스럽게 빠진다.



🛠️ can-utils로 실기 재현

파싱만으로 답은 나왔지만, 이 로그는 원래 재생하라고 만든 포맷이다. 커널 가상 CAN 인터페이스를 만들고 로그를 그대로 버스에 흘려서 실제 스니퍼로 받아보면 문제 상황이 그대로 재현된다.

먼저 vcan 모듈을 올리고 인터페이스를 만든다.

sudo apt-get install -y can-utils
sudo modprobe vcan && sudo ip link add dev vcan0 type vcan && sudo ip link set up vcan0 && ip -details link show vcan0
ip link로 만든 가상 CAN 인터페이스 vcan0 — link/can, mtu 72
ip link로 만든 가상 CAN 인터페이스 vcan0 — link/can, mtu 72

mtu 72가 CAN 소켓 특유의 값이다(struct canfd_frame 크기). 이제 canplayer로 로그를 타임스탬프까지 살려 재생하고, candump로 받는다. -a 옵션을 주면 페이로드를 hex와 ASCII 양쪽으로 보여준다.

canplayer -I candump-2024-04-19_143417.log vcan0=vcan0 & candump -n 16 -a vcan0
can-utils candump -a 실행 — 필터 없이 받으면 랜덤 ID와 깨진 ASCII만 쏟아진다
can-utils candump -a 실행 — 필터 없이 받으면 랜덤 ID와 깨진 ASCII만 쏟아진다

필터 없이 받으면 이 화면이다. ASCII 칸이 '..eT..', '.w...J.k' 같은 점 투성이라 눈으로는 아무것도 못 건진다. 초당 168프레임이 흐르니 실시간으로 보면 더 안 보인다.

여기에 ID 필터를 건다. candump의 필터 문법은 인터페이스,ID:마스크다. vcan0,000:7FF는 11비트 전체를 마스크로 걸어 ID가 정확히 0x000인 프레임만 통과시킨다.

canplayer -I candump-2024-04-19_143417.log vcan0=vcan0 & candump -n 12 -a 'vcan0,000:7FF'
can-utils candump -a에 ID 필터 적용 — 0x000만 남기니 ASCII 칸에 flag 조각이 그대로 보인다
can-utils candump -a에 ID 필터 적용 — 0x000만 남기니 ASCII 칸에 flag 조각이 그대로 보인다

필터 한 줄로 끝난다. ASCII 칸에 'my_car_' 'is_' 'br0ken' '!!!!'가 3초 주기로 반복해서 찍힌다. DLC도 각각 7/3/6/4로 원본 그대로다.

파이썬으로 파싱한 결과와 실제 CAN 소켓으로 받은 결과가 같으니, 로그 해석이 틀리지 않았다는 것까지 확인된 셈이다.


🚀 Full Exploit

전체 과정을 하나로 묶는다. 로그만 있으면 어디서든 돌아간다.

#!/usr/bin/env python3
"""Read My Car — candump 로그에서 이상치 CAN ID 를 찾아 페이로드를 ASCII 로 복원."""
import re
from collections import Counter, OrderedDict
 
LOG = "candump-2024-04-19_143417.log"
FRAME = re.compile(r"\((\d+\.\d+)\)\s+\S+\s+([0-9A-F]{3})#([0-9A-F]*)$")
 
frames = []
for line in open(LOG):
    m = FRAME.match(line.strip())
    if m:
        t, cid, payload = m.groups()
        frames.append((float(t), cid, bytes.fromhex(payload)))
 
# 1) 등장 횟수 이상치 = 노이즈 위에 얹힌 진짜 메시지
count = Counter(cid for _, cid, _ in frames)
top_id, top_n = count.most_common(1)[0]
second_n = count.most_common(2)[1][1]
print(f"[*] 최다 출현 ID : 0x{top_id} ({top_n}회) / 2위 {second_n}회 → {top_n/second_n:.1f}배 이상치")
 
# 2) 그 ID 의 페이로드 중 전부 출력가능 ASCII 인 것만 추린다 (노이즈 프레임 배제)
target = [(t, p) for t, cid, p in frames if cid == top_id]
ascii_only = [(t, p) for t, p in target if p and all(0x20 <= b < 0x7F for b in p)]
print(f"[*] 0x{top_id} 프레임 {len(target)}개 중 출력가능 ASCII {len(ascii_only)}개, 노이즈 {len(target)-len(ascii_only)}개")
 
# 3) 한 주기(반복 단위)를 추출 — 등장 순서를 유지한 중복 제거
cycle = list(OrderedDict.fromkeys(p for _, p in ascii_only))
print(f"[*] 반복 주기 {len(cycle)}조각 × {len(ascii_only)//len(cycle)}회")
for i, chunk in enumerate(cycle, 1):
    print(f"      {i}. {chunk.hex().upper():<16} -> {chunk.decode()!r}")
 
body = b"".join(cycle).decode()
print(f"\n[+] FLAG: DH{{{body}}}")

ID를 0x000으로 하드코딩하지 않은 게 요점이다. 최빈 ID를 자동으로 찾고, 그중 ASCII 프레임만 남기고, 순서를 유지한 중복 제거(OrderedDict.fromkeys)로 한 주기를 뽑는다.

solve.py 실행 — 최빈 ID 0x000 자동 검출, 4조각 복원, FLAG 출력
solve.py 실행 — 최빈 ID 0x000 자동 검출, 4조각 복원, FLAG 출력
[*] 최다 출현 ID : 0x000 (43회) / 2위 10회 → 4.3배 이상치
[*] 0x000 프레임 43개 중 출력가능 ASCII 40개, 노이즈 3개
[*] 반복 주기 4조각 × 10회
      1. 6D795F6361725F   -> 'my_car_'
      2. 69735F           -> 'is_'
      3. 6272306B656E     -> 'br0ken'
      4. 21212121         -> '!!!!'
 
[+] FLAG: DH{my_car_is_br0ken!!!!}

DH{my_car_is_br0ken!!!!} 제출해서 정답.



📝 결론

노이즈를 뚫는 기준은 "내용"이 아니라 "구조"였다.

처음엔 페이로드를 ASCII로 읽는 쪽부터 손댔는데, 난수 2바이트가 우연히 출력 가능 범위에 들어갈 확률이 37%나 돼서 진짜와 가짜가 뒤섞였다. 내용을 보는 대신 ID별 등장 횟수라는 메타데이터를 세니 한 방에 갈렸다. 데이터가 시끄러울 때는 값을 해석하기 전에 분포부터 보는 게 빠르다.

"이상치다"는 눈대중 대신 수치로 확인할 수 있다.

43회가 튄다는 건 히스토그램만 봐도 알지만, 균등 랜덤을 가정하고 포아송으로 기대치를 계산하면 나머지 ID들이 그 가정에 정확히 들어맞는 것까지 같이 보인다. 나머지가 전부 설명되는데 하나만 안 되면, 그 하나가 사람이 넣은 것이다.

CAN에는 인증도 암호화도 없다.

이 문제가 flag를 0x000으로 브로드캐스트한 건 연출이지만, 구조 자체는 실제 그대로다. CAN 프레임에는 송신자를 확인할 수단이 없고 페이로드는 평문이다. OBD-II 포트나 인포테인먼트를 통해 버스에 한 발만 걸치면 임의 ID로 프레임을 뿌릴 수 있고, 0x000처럼 우선순위가 높은 ID를 계속 점유하면 정상 ECU 메시지를 밀어내는 것도 된다.

방어는 버스 밖에서 온다. 게이트웨이로 도메인을 분리해 진단 포트에서 파워트레인 버스로 임의 프레임이 넘어가지 못하게 막고, 안전 관련 메시지에는 CAN-FD 기반 MAC(SecOC 같은)을 붙이고, IDS로 "이 ID는 원래 10ms 주기인데 왜 갑자기 다른 주기로 오나", "존재하지 않아야 할 ID가 왜 보이나"를 감시한다. 결국 이번 풀이에서 쓴 것과 같은 통계적 이상치 탐지다. 공격자가 로그에서 신호를 찾아내는 방법이 그대로 방어자의 탐지 규칙이 된다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥉 브론즈 1] CAN 버스 로그에서 훔친 차의 정체 — DreamHack Grand Theft Auto 풀이
blog

[🥉 브론즈 1] CAN 버스 로그에서 훔친 차의 정체 — DreamHack Grand Theft Auto 풀이

자동차 CAN 버스 캡쳐 candump.txt 한 개가 주어진다. 로그는 UDS 진단 세션으로, 진단기가 ReadDataByIdentifier(0x22)로 DID 0xF190(VIN)을 요청하고 ECU가 ISO-TP 멀티프레임으로 응답한다. 프레임을 재조립해 VIN을 얻고, 그 VIN을 연도·제조사·모델로 디코드해 플래그를 완성했다.
#dreamhack#ctf#forensics+4
2026-07-13#dreamhack +5
[🥉 Bronze 2] 400개 난수 파일 더미에서 플래그 한 줄 찾기 — DreamHack Random String 풀이
blog

[🥉 Bronze 2] 400개 난수 파일 더미에서 플래그 한 줄 찾기 — DreamHack Random String 풀이

1024글자짜리 base62 난수 문자열 파일이 400개 쏟아진다. "이 많은 파일 속에서 플래그를 찾을 수 있겠냐"는 도발이 곧 풀이다. 통계로 파일 더미가 균등 난수임을 확인하고 쪼개져 있지 않다는 걸 검증한 뒤, 플래그 포맷을 재귀 grep 한 방으로 뽑는다. 포맷을 몰라도 base62에 없는 글자가 박힌 파일을 역추적하면 같은 파일이 나온다.
#dreamhack#ctf#misc+4
2026-10-11#dreamhack +4
[🥉 Bronze 4] 반투명 덧칠은 지우개가 아니다 — DreamHack QR Code 풀이
blog

[🥉 Bronze 4] 반투명 덧칠은 지우개가 아니다 — DreamHack QR Code 풀이

QR 코드 위에 검은 낙서가 덮여 있다. 그런데 픽셀값이 열 종류밖에 없고 255, 63, 16, 4, 1 이라는 등비 사슬을 이룬다. 알파 75% 검정을 겹칠 때마다 밑의 밝기가 1/4 씩만 남은 흔적이다. "픽셀값이 0보다 크면 흰 모듈" 한 줄로 29x29 격자를 되살리고, 재인코딩본과 841칸 전수 대조까지 했다.
#dreamhack#ctf#misc+5
2026-08-22#dreamhack +4