Oct 15, 2026BY y260613
publiccar-hackingcanudsctf

CarHacking Village Part1

Setup

C to C를 통해 배지에 연결하면 /dev경로에 USB 장치가 생긴다. screen명령을 통해 /dev/cu.usbmodem1101을 열어 배지 콘솔에 접속한다.
배지 콘솔
chvhelp()로 명령어를 확인하고 list_modules()로 문제 목록을 본다. enter_module(index)로 시작한다.

00. CAN Bus Basics

cansend()로 0x123 ID에 ABCD를 보내본다.
cansend(0x123, 8, b'\x41\x42\x43\x44')
다음 단계에서 canrecv()로 0x666 응답을 기다렸다. HEARD_U가 돌아왔다.
canrecv(0x666, 1000)
0x666에서 받은 HEARD_U 응답
다음으로 29비트 확장 ID로도 프레임을 보내본다.
cansend(0x12345678, 4, b'\xDE\xAD\xBE\xEF')
확장 ID 프레임 송신 결과
마지막으로 dump()를 실행해 앞에서 주고받은 프레임을 확인한다.
CAN 프레임 덤프
0x123과 0x666은 11비트 표준 ID이고, 0x12345678과 0x12347856은 29비트 확장 ID다. 덤프의 ASCII 열에서도 처음 보낸 ABCD와 응답으로 받은 HEARD_U, GOODJOB을 확인할 수 있다.

01. UDS: Build, Parse, Show

UDS는 차량을 진단할 때 사용하는 프로토콜이다. 이번에는 UDS_RDBI로 VIN(차대번호)을 읽어본다.
ISO-TP, UDS, RDBI 계층을 쌓아 DID 0xF190 요청을 만든다.
from scapy_lite.isotp import ISOTP
from scapy_lite.uds import UDS, UDS_RDBI

req = ISOTP() / UDS() / UDS_RDBI(did=0xF190)
bytes(req)로 요청을 바이트로 바꾼 뒤 0x7E0에 보냈다.
resp_raw = cansr(0x7E0, 8, bytes(req))
resp_raw[2]에서 수신 데이터를 가져와 ISO-TP와 UDS 응답을 확인한다.
resp = ISOTP(resp_raw[2])
UDS(resp.payload).show()
VIN 조회 결과
요청 바이트는 03 22 F1 90이다. 03은 뒤에 오는 UDS 데이터가 3바이트라는 뜻이고, 22는 데이터를 읽는 명령, F190은 VIN을 가리키는 번호다. 응답의 62는 요청이 정상 처리됐다는 표시이고 record에 VIN 값이 들어 있다.

02. Sniffing & Delta Analysis

sniff()로 CAN 버스에 들어오는 메시지를 10초 동안 확인한다.
sniff(seconds=10)
다음으로 delta_only=True를 넣어 데이터가 바뀐 메시지만 확인했다.
sniff(seconds=20, delta_only=True)
실행한 동안에는 바뀐 메시지가 출력되지 않았다.
변경된 프레임이 출력되지 않은 delta sniff
마지막으로 dump()를 통해 이전에 주고받은 프레임을 확인한다.
이전 CAN 송수신 기록
앞에서 보낸 0x123, 0x7E0과 응답으로 받은 0x666, 0x7E8 등이 남아 있다. Age는 마지막으로 해당 ID를 본 뒤 지난 시간이다.

03. Functional Logic & Lambdas

이번 모듈에서는 들어온 CAN ID에 따라 함수를 실행하는 구조를 확인한다. hub._mock_registry를 출력해 등록된 함수를 봤다.
registry = hub._mock_registry
print(registry)
0x7E0에 등록된 lambda 함수
출력된 2016은 16진수로 0x7E0이다. 즉 0x7E0으로 요청이 들어오면 lambda 함수가 실행된다.
다음으로 0x7E0에 UDS 요청을 보낸다. 0x22는 데이터를 읽는 명령이고 0xF190은 VIN을 뜻하는 데이터 식별자(DID)다.
cansend(0x7E0, 8, b'\x03\x22\xF1\x90')
dump()로 실제 응답이 왔는지 확인한다.
dump()
0x7E8에서 확인한 UBER 응답
0x7E8에 UBER가 들어 있는 것을 확인했다. 앞의 VIN 응답과 값이 다른 것은 이 모듈에서 등록한 함수가 응답을 만들기 때문이다.

04. Arbitration & Bit Stuffing

CAN에서는 두 장치가 동시에 메시지를 보내려고 할 때 ID로 우선순위를 정한다. 0이 우세한 비트라 숫자가 작은 ID가 먼저 전송된다.
0x001 ID로 프레임을 보내본다.
cansend(0x001, 8, b'\x42' * 8)
다음으로 0으로 채운 데이터로 bit stuffing을 확인한다.
cansend(0x123, 8, b'\x00' * 8)
0으로 채운 프레임 송신 결과
같은 비트가 5개 연속되면 CAN 컨트롤러가 반대 비트를 하나 넣어 통신 타이밍을 맞춘다. 사진의 True는 프레임을 보냈다는 결과다. 중간에 추가된 비트는 통신할 때만 쓰이므로 이 화면이나 수신 데이터에는 따로 나타나지 않는다.

05. UDS Security Access

이번에는 UDS SecurityAccess를 통해 잠긴 데이터를 읽어본다. 먼저 0x27 01로 ECU에 seed를 요청한다.
from scapy_lite.uds import UDS, UDS_SA

req = UDS(sid=0x27) / UDS_SA(securityType=0x01)
resp_raw = cansr(0x7E0, 8, bytes(req), r_id=0x7E8)
resp = UDS(resp_raw[2])
받은 seed에 0x1122를 XOR해 key를 만들고 0x27 02로 보낸다.
seed_int = int.from_bytes(resp.securitySeed, 'big')
key_int = seed_int ^ 0x1122
key_bytes = key_int.to_bytes(2, 'big')
key_req = UDS(sid=0x27) / UDS_SA(securityType=0x02, securityKey=key_bytes)
cansr(0x7E0, 8, bytes(key_req), r_id=0x7E8)
67 02 인증 성공 응답
67 02가 돌아와 key가 맞는 것을 확인했다.
다음으로 잠겨 있던 DID 0xDEAD를 읽는다.
from scapy_lite.uds import UDS, UDS_RDBI

final_req = UDS() / UDS_RDBI(did=0xDEAD)
resp_raw = cansr(0x7E0, 8, bytes(final_req))
resp = UDS(resp_raw[2])
print(resp.record)
보호된 DID에서 읽은 SECRET_FLAG
SECRET_FLAG가 출력됐다.

06. ISO-TP & UDS Scapy-lite

일반 CAN 프레임에는 데이터를 최대 8바이트까지만 담을 수 있다. 이번에는 20바이트 데이터를 ISO-TP로 나눠본다.
from scapy_lite.isotp import generateUDSPayload
payloads = generateUDSPayload(b'\xAA' * 20)
20바이트 데이터가 세 개의 프레임으로 나뉜다. 첫 프레임에 6바이트, 다음 두 프레임에 각각 7바이트씩 들어간다.
다음으로 VIN을 요청해 여러 프레임으로 오는 응답을 확인한다. 요청은 앞에서 사용한 0x22 F190과 같다.
from scapy_lite.isotp import ISOTP
from scapy_lite.uds import UDS, UDS_RDBI

req = ISOTP() / UDS() / UDS_RDBI(did=0xF190)
resp_raw = cansr(0x7E0, 8, bytes(req))
resp_raw[2]에서 수신 데이터를 가져와 ISO-TP 헤더를 벗기고 UDS 응답을 확인한다.
from scapy_lite.uds import UDS_RDBIPR
iso = ISOTP(resp_raw[2])
resp = UDS(iso.payload).getlayer(UDS_RDBIPR)
print(resp.record)
첫 프레임에서 확인한 VIN 앞부분
record에는 b'UBE'까지만 나왔다. 아직 첫 번째 프레임만 읽은 결과다. 첫 프레임의 10 14는 응답이 여러 프레임으로 이어지고 전체 길이가 0x14, 즉 20바이트라는 뜻이다.

07. UDS Firmware Flashing

펌웨어 데이터를 보내기 전에 ECU에 어느 주소로 얼마만큼 보낼지 알려준다. 0x34 요청으로 주소 0x8000에 1024바이트를 쓰겠다고 요청한다.
from scapy_lite.uds import UDS, UDS_RD

req = UDS() / UDS_RD(address=0x8000, size=1024)
cansr(0x7E0, 8, bytes(req))
0x34 요청에 대한 0x74 응답
0x74 응답이 돌아와 다운로드 요청이 받아들여졌다. 다음으로 0x36을 사용해 데이터를 블록 단위로 보낸다. 첫 번째 블록이므로 번호를 1로 설정하고 CC 바이트 64개를 보낸다.
from scapy_lite.uds import UDS_TD

block = UDS() / UDS_TD(blockSequenceCounter=1, transferRequestParameterRecord=b'\xCC' * 64)
cansr(0x7E0, 8, bytes(block))
첫 번째 데이터 블록에 대한 0x76 01 응답
76 01이 돌아와 첫 번째 블록이 받아들여진 것을 확인했다. 마지막으로 0x37 요청을 보내 전송을 끝낸다.
exit_req = UDS(sid=0x37)
cansr(0x7E0, 8, bytes(exit_req))
전송 종료 요청에 대한 0x77 응답
0x77 응답을 받았다.
여기서는 1024바이트를 보내겠다고 요청했지만 실제로 보낸 것은 64바이트 블록 하나다.

Conclusion

ECU가 기본적으로 통신하는 방법들과 개념에 대해 살펴보았다. CAN 프레임을 보내고 받는 것부터 ISO-TP로 데이터를 나누고 UDS 요청·응답을 읽는 과정까지 확인할 수 있었다. 이 글에 추가로 혹은 다음글에 이 개념들을 이용한 문제 3개에 대한 내용을 추가로 작성해볼 예정이다.

Comments (0)

Loading comments...

Please log in to leave a comment.

Log In