Challenge: PhantomCore - Codegate CTF 2026 Quals
TL;DR
┌──────────────┐ ioctl ┌──────────────┐ MMIO ┌──────────────┐ │ gpuctl-cli │ ───────────────▶ │ gpuctl.ko │ ──────────────▶ │ phantom-gpu │ │ (userspace) │ /dev/gpuctl │ (kernel) │ BAR0 │ (QEMU) │ └──────────────┘ └──────────────┘ └──────┬───────┘ │ ┌─────────────────────────────────────────────────────┴─────┐ │ │ ▼ ▼ ┌─────────────────┐ ┌────────────────────┐ │ INIT PATH │ │ RUNTIME PATH │ │ │ │ │ │ blob @ 0xb0a2d8│ │ UPLOAD_SHADER │ │ ↓ │ │ ↓ │ │ xorshift32 │ │ DISPATCH │ │(seed=0xfe170e07)│ │ ↓ │ │ ↓ │ │ Validate + Exec │ │ CRC + LZ4 │ │ ↓ │ │ ↓ │ │ opcode → hash │ │ 0x308 bytes ───┼──────────────────────────────────────▶ → handler table │ │ (state blob) │ │ ↓ │ └─────────────────┘ │ regs/stack/DMA │ └───────────────────PhantomCore는 커스텀 QEMU PCI 장치(phantom-gpu) 안에 구현된 VM을 분석해서, 도합 6개의 gate 조건을 모두 통과하는 shader bytecode를 작성하고, firmware blob에 숨겨진 hidden witness(레지스터 값 + buf1 32바이트)를 복원하여 최종 flag를 얻는 리버싱 문제이다.
풀이는 크게 두 단계로 나뉜다.
- Gate 통과: checker가 요구하는 6-bit 조건을 모두 만족하는 shader + buffer 조합을 찾는다
- Flag 복원: gate를 통과해도 success path가 읽는 추가 입력(reg0/reg3/reg4/reg5, buf1 raw 32바이트)이 정확해야만 올바른 flag가 출력된다
아래는 리버싱 자체보다는 전체적인 구조와 흐름에 중점을 두고 작성한 라업이다.
1. 전체 아키텍처
1-1. 배경: GPU와 CPU의 관계
GPU는 CPU와 PCIe 버스로 연결된 별도 물리 장치다. CPU는 자신의 DRAM을, GPU는 별도 VRAM을 갖고 있고, 둘 사이의 데이터 이동은 PCIe를 통한 DMA로 이루어진다.
Driver가 GPU와 통신하는 방법은 2가지다:
- MMIO (control plane): CPU가 BAR0에 매핑된 GPU 레지스터에 r/w해서 명령과 설정을 전달한다. 데이터 이동 없이 신호만 전달.
- DMA (data plane): GPU가 PCIe Bus Master가 되어 CPU 개입 없이 Host Memory ↔ Device Memory 사이에서 직접 데이터를 전송한다.
방향이 반대라서 분리된다. MMIO는 CPU → device, DMA는 device → RAM.
1-2. QEMU에서의 구현

기존 PCI는 CPU, Bridge/Memory Controller, Bus, 그리고 그 위에 연결된 장치들로 구성된 shared bus 구조였다. Host Bridge(노스브릿지)가 CPU/DMA와 버스를 연결하며, bus arbitration을 통해 버스 사용 권한이 결정된다.

이 문제에서는 -machine q35를 사용하여 PCIe 기반 플랫폼을 에뮬레이션한다. PCIe에서는 Root Complex가 기존의 Host Bridge 및 메모리 컨트롤러 역할을 통합하며, 별도의 노스브릿지 칩이 존재하지 않는다. 또한 shared bus 대신 각 장치가 Root Complex와 point-to-point 링크로 연결되는 구조를 사용한다.
/sys/bus/pci/devices로 확인한 장치 목록:
[0000:00] pcie.0 (Root Complex = q35 MCH) ├── 00:00.0 Intel 82G33 Host Bridge vendor=8086:29c0 class=0x060000 ├── 00:01.0 phantom-gpu (Accelerator) vendor=1de5:7001 class=0x0b4000 <- DRIVER=gpuctl ├── 00:1f.0 ICH9 LPC Controller vendor=8086:2918 class=0x060100 ├── 00:1f.2 ICH9 SATA Controller vendor=8086:2922 class=0x010601 └── 00:1f.3 ICH9 SMBus Controller vendor=8086:2930 class=0x0c0500bus 구조에서 별도의 bridge나 switch가 존재하지 않으므로, 문제 환경은 [PCI Express Topology] 그림과 같이 Root Complex에서 Endpoint로 직접 연결되는 단일 PCIe 토폴로지임을 알 수 있고, 또 [PCI System Block Diagram]의 Graphics 위치에 대응되는 장치가 phantom-gpu라고 볼 수 있다.
한편 phantom-gpu는 BAR0을 통해 MMIO 영역이 매핑되어 있고,
BAR0: 0xfebfe000 ~ 0xfebfefff (4KB MMIO 영역)이는 해당 디바이스의 레지스터 접근을 위한 메모리 공간을 의미한다.
QEMU 프로세스 메모리:

실제 GPU는 PCIe로 연결된 외부 물리 장치지만, phantom-gpu는 QEMU 프로세스 내부에서 C 코드로 구현된 가상 디바이스다. 물리적으로 분리된 VRAM이 여기서는 같은 Guest RAM의 일부 영역으로 표현되고, dma_memory_read/write()를 통해 접근한다.
QEMU 추상화 레이어:

QEMU 내부 모듈:
- TCG: Guest x86_64 명령어를 JIT으로 번역해서 Host CPU에서 실행하는 에뮬레이션 엔진
- MMU: Guest 가상주소 → 물리주소 변환. BAR0 MMIO 접근 시 MemoryRegion 디스패치로 콜백
- QDEV: PCI 장치 에뮬레이션 프레임워크. phantom-gpu가 여기 등록됨
문제 환경은 enable-kvm 없이 순수 TCG 모드로 실행된다. 실행 중 MMIO 주소(BAR0 영역)를 건드리면 해당 주소에 매핑된 MemoryRegion 콜백이 트리거돼서 phantom-gpu handler로 진입하는 구조다.
참고로 QEMU 공식 PCI 디바이스 구현 예제는 hw/misc/edu.c에서 확인할 수 있다.
phantom-gpu는 공식과 유사하게 BAR0 MemoryRegion 등록, DMA 접근, 인터럽트 처리 구조를 따르며, 그 위에 shader VM이 추가된 형태로 보인다.
1-3. phantom-gpu vs. 실제 GPU

실제 GPU Compute Core는 병렬 하드웨어지만, phantom-gpu VM executor는 단순히 instruction을 하나씩 읽고 실행하는 순차적인 소프트웨어 interpreter다. 별도의 MMU도 없고, memory isolation도 없다. firmware blob을 디코드해서 device 내부 struct에 S-box, constants, key material 등을 세팅하고, 이게 곧 VM 실행에 필요한 모든 global state가 된다.
2. 문제 파일 분석
├── bios/│ ├── bios-256k.bin <- 필요│ ├── efi-e1000e.rom <- 필요X (-nic none)│ ├── efi-virtio.rom <- 필요X│ ├── kvmvapic.bin <- 필요X (KVM 사용 안함, only TCG)│ ├── linuxboot_dma.bin <- 필요X│ └── vgabios-stdvga.bin <- 필요X (-vga none)├── bzImage # Guest 리눅스 커널├── initramfs.cpio.gz # Guest 루트파일시스템├── qemu-system-x86_64 # 커스텀 QEMU 바이너리 <- 분석 대상└── run.shbios/ 디렉토리에 6개 파일이 있지만 실제로 필요한 것은 bios-256k.bin 하나뿐이다.
-machine q35가 BIOS를 요구하고, 이것이 Guest Linux 커널 부팅 전 POST 과정을 담당한다. 나머지 5개 파일은 원본 run.sh에 -nic none, -vga none 옵션만 추가하면 전혀 필요가 없다.
initramfs 풀기:
mkdir initramfs_root && cd initramfs_rootgzip -dc ../initramfs.cpio.gz | cpio -idmv.├── bin│ ├── busybox│ ├── cat -> busybox│ ├── chmod -> busybox ├── hexdump -> busybox│ ├── cp -> busybox ├── insmod -> busybox│ ├── dd -> busybox ├── ls -> busybox│ ├── dmesg -> busybox ├── mount -> busybox│ ├── echo -> busybox ├── od -> busybox│ ├── grep -> busybox └── xxd -> busybox├── init├── lib│ └── modules│ └── gpuctl.ko <- 커널 모듈└── usr └── local └── bin └── gpuctl-cli <- 유저랜드 CLIinit 스크립트가 gpuctl.ko를 insmod하고, /dev/gpuctl 캐릭터 디바이스를 생성한다.
#!/bin/shmount -t proc none /procmount -t sysfs none /sysmount -t devtmpfs none /devmkdir -p /dev/ptsmount -t devpts none /dev/ptsmount -t tmpfs none /tmp
export PATH=/bin:/sbin:/usr/local/bin
# Load phantom-gpu driverinsmod /lib/modules/gpuctl.kosleep 0.5
echo "================================================"echo " PhantomCore"echo " /dev/gpuctl is ready"echo " Use gpuctl-cli to interact with the device"echo "================================================"
# Drop to shellexport PATH=/bin:/sbin:/usr/local/binexport HOME=/tmpcd /tmpsetsid cttyhack sh
poweroff -frun.sh 실행:
================================================ PhantomCore /dev/gpuctl is ready Use gpuctl-cli to interact with the device================================================BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) built-in shell (ash)Enter 'help' for a list of built-in commands.
~ # gpuctl-cliUsage: gpuctl-cli <command> [args...] upload <shader.bin> Upload shader bytecode setbuf <slot> <file.bin> Set buffer slot (0-3) dispatch [max_steps] Dispatch shader execution run <shader.bin> [buf0] [buf1] One-stop upload+set+dispatch log Read last execution log3. gpuctl-cli 리버싱 (Userland)
먼저 gpuctl-cli를 보자.
IDA에서 "Usage: %s <command>" 문자열 xref로 main 함수(sub_401BA0)를 찾았다.
함수를 보면 upload / setbuf / dispatch / log는 각각 ioctl 한 번 호출하는 단순 wrapper고, run 명령은 저 전부를 한번에 해준다는 것을 알 수 있다.
int cli_main(int argc, char **argv) { if (argc <= 1) { print_usage(); return 1; } int fd = open("/dev/gpuctl", O_RDWR); if (fd < 0) { perror("open(/dev/gpuctl)"); return 1; } const char *cmd = argv[1];
if (!strcmp(cmd, "upload")) { if (argc != 3) error("upload: need <shader.bin>"); else rc = upload_shader(fd, argv[2]); } else if (!strcmp(cmd, "setbuf")) { if (argc <= 3) error("setbuf: need <slot> <file>"); else { slot = parse_uint(argv[2]); if (slot > 3) error("slot must be 0-3"); else rc = set_buffer_from_file(fd, slot, argv[3]); } } else if (!strcmp(cmd, "dispatch")) { max_steps = (argc == 2) ? 0x100000 : parse_uint(argv[2]); out = calloc(1, 0x4000); rc = dispatch(fd, max_steps, out); free(out); } else if (!strcmp(cmd, "run")) { if (argc == 2) error("run: need at least <shader.bin>"); else { upload_shader(fd, argv[2]); if (argc >= 4) set_or_clear(fd, 0, argv[3]); // buf0 else clear_buf(fd, 0); if (argc >= 5) set_or_clear(fd, 1, argv[4]); // buf1 else clear_buf(fd, 1); clear_buf(fd, 2); // buf2: 16KB zeroed ← flag output 영역 clear_buf(fd, 3); // buf3: 16KB zeroed ← stack 영역 out = calloc(1, 0x4000); rc = dispatch(fd, 0x100000, out); if (!rc && read_log(fd, &logrec, 8) > 0) { print_log(logrec); if (logrec.status == 8) // GATE_TRIGGERED dump_buf2_as_flag(out); } free(out); } } else if (!strcmp(cmd, "log")) { if (read_log(fd, &logrec, 8) < 0) perror("read(log)"); else print_log(logrec); } else { print_unknown_command(cmd); print_usage(); rc = 1; } close(fd); return rc;}유저인 우리는 run명령어를 써서 shader, buf0, buf1을 올리면 된다. buf2(flag output)와 buf3(stack)은 run이 알아서 16KB zeroed 버퍼로 할당해준다.
코드 중에서 특히 logrec.status == 8 체크가 눈에 띄는데, 나중에 알게 되겠지만 이게 GATE_TRIGGERED 상태이다. 이 조건을 만족해야만 buf2가 flag로 dump된다.
또 ioctl 코드는 upload=0x40105001, setbuf=0x40105002, dispatch=0xC0185003이다.
4. gpuctl.ko 리버싱 (Kernel Module)
참고 자료
다음으로 gpuctl.ko의 구조를 이해하려면 PCIe DMA의 표준 흐름을 먼저 알아야 한다.

위 그림과 다르게 phantom-gpu에서는 물리적 PCIe TLP가 없다. iowrite32(bar0+0x30)이 guest MMIO trap을 유발하면, QEMU MemoryRegion 콜백이 dma_memory_read(cmd_ring_dma)로 guest RAM을 직접 읽는다. driver 관점에서의 인터페이스는 동일하지만 PCIe fabric 없이 동작한다.
gpuctl.ko는 이 흐름을 그대로 구현한 순수 전달 레이어다. PCIe driver의 전형적인 패턴 — DMA buffer 할당 → device에 주소 전달 → doorbell로 시작 → interrupt로 완료 수신 — 을 크게 초기화(probe), 커맨드 전달(ioctl), 완료 수신(IRQ) 세 파트로 구현한다.
4-1. init_modul에서 probe까지
unk_11A0이 pci_driver 구조체다. IDA에서 해당 주소를 보면:
__int64 init_module() { return pci_register_driver(&pci_driver_struct, &_this_module, "gpuctl");}.data:11B0 dq offset "gpuctl" → .name.data:11B8 dq offset id_table → .id_table (vendor=1DE5, device=7001).data:11C0 dq offset sub_650 → .probe.data:11C8 dq offset sub_30 → .removepci_register_driver가 이 구조체를 커널에 등록하면, 커널이 PCI 버스를 스캔하다가 vendor=1DE5, device=7001인 장치(phantom-gpu)를 발견하면 .probe = sub_650함수를 자동 호출한다.
4-2. probe(sub_650) — PCI 초기화 및 DMA 할당
-
PCI 활성화 및 BAR0 매핑
pci_enable_device(pdev);pci_request_regions(pdev, "gpuctl");gd->bar0 = pci_iomap(pdev, 0, 4096);// Signature 검증sig = ioread32(gd->bar0 + 0x00);// sig == 0x5048544D ("PHTM") 이어야 함pci_iomap이 BAR0 물리주소를 커널 가상주소로 매핑한다. 이후ioread32/iowrite32로 이 주소를 읽고 쓰면 MMIO 접근이 발생하고, QEMU의 MemoryRegion 콜백이 트리거된다. -
DMA buffer 할당 및 주소 전달
PCIe에서 device가 host RAM에 직접 접근하려면, 먼저 OS가 contiguous physical memory를 잡아주고 그 물리주소를 device에 알려줘야 한다. 이때, 주소를 BAR0에 기록한다고 데이터 전송이 시작되는 게 아니고 device에게 나중에 여기를 읽으라고 위치를 알려주는 것뿐이다.
// OS에 DMA buffer 할당 요청gd->cmd_ring = dma_alloc_attrs(&pdev->dev, 0x1000, &gd->cmd_ring_dma, ...);gd->log_ring = dma_alloc_attrs(&pdev->dev, 0x1000, &gd->log_ring_dma, ...);gd->shader = dma_alloc_attrs(&pdev->dev, 0x2000, &gd->shader_dma, ...);for (i = 0; i < 4; i++)gd->slot[i] = dma_alloc_attrs(&pdev->dev, 0x4000, &gd->slot_dma[i], ...);dma_alloc_attrs의 세 번째 인자(&gd->cmd_ring_dma)가 물리주소를 받아오는 포인터다. device는 이 물리주소로 guest RAM에 직접 접근한다.그 다음 이 주소들을 BAR0 레지스터에 기록해서 phantom-gpu에 알려준다:
// DMA 물리주소 → BAR0에 기록iowrite32(lower_32_bits(gd->cmd_ring_dma), gd->bar0 + 0x20); // cmd_ring loiowrite32(upper_32_bits(gd->cmd_ring_dma), gd->bar0 + 0x24); // cmd_ring hiiowrite32(lower_32_bits(gd->log_ring_dma), gd->bar0 + 0x40);iowrite32(upper_32_bits(gd->log_ring_dma), gd->bar0 + 0x44);// slot 0~3도 동일하게 0x50~0x6C에 기록iowrite32(0, gd->bar0 + 0x30); // doorbell 초기화이것이 PCIe DMA의 Source Address 프로그래밍 단계다. phantom-gpu는 이 주소들을 내부적으로 저장해두고, 이후 커맨드가 올 때 해당 guest 메모리 영역을 직접 읽는다.
-
IRQ 등록 및 Firmware init
// MSI interrupt handler 등록request_threaded_irq(irq, sub_5E0, NULL, 0x80, "gpuctl", gd);// Firmware init triggeriowrite32(1, gd->bar0 + 0x100);_mm_mfence();// QEMU가 firmware blob 디코드 완료할 때까지 pollingfor (i = 0; i < 100; i++) {if (ioread32(gd->bar0 + 0x104) == 1) break;udelay(4295);}pci_set_master(pdev); // device가 DMA 주체가 될 수 있음cdev_add(&gd->cdev, ...); // /dev/gpuctl 생성bar0+0x100에 1을 쓰는 것이 firmware init doorbell이다. QEMU가 이 write를 받아 내부 firmware blob을 디코드하고 gate 상수들을 세팅한 뒤,bar0+0x104를 1로 바꿔 완료를 알린다.
4-3. ioctl handler (sub_370) - 커맨드 전달
gpuctl-cli가 /dev/gpuctl에 ioctl을 보내면 이 함수가 받는다. sub_370이 ioctl handler인 건 probe에서 cdev_init(gd->cdev, &gpuctl_fops)로 등록한 파일 ops의 .unlocked_ioctl 필드에 이 함수가 들어있기 때문이다.
이 함수는 세 가지 커맨드를 처리한다:
mutex_lock(&gd->lock);switch (cmd) {
case 0x40105001: // UPLOAD_SHADER copy_from_user(gd->shader, user_ptr, size); // user → DMA buffer push_cmd(gd, CMD_UPLOAD, size, shader_dma); // cmd ring에 기록 + doorbell wait_event(gd->wq, gd->irq_done); // IRQ 올 때까지 대기 break;
case 0x40105002: // SET_BUF copy_from_user(gd->slot[slot], user_ptr, size); push_cmd(gd, CMD_SETBUF, slot|(size<<32), slot_dma[slot]); wait_event(gd->wq, gd->irq_done); break;
case 0xC0185003: // DISPATCH push_cmd(gd, CMD_DISPATCH, max_steps, 0); wait_event(gd->wq, gd->irq_done); copy_to_user(user_out, gd->slot[2], min(out_size, 0x4000)); // 결과 반환 break;}mutex_unlock(&gd->lock);이 때 push_cmd(sub_160)의 역할이 중요하다:
// 1. cmd ring의 다음 슬롯에 64바이트 커맨드 기록 (DMA buffer에 씀)memcpy(&cmd_ring[tail], &cmd, 64);
// 2. doorbell write → QEMU MMIO write handler 트리거iowrite32(next_idx, gd->bar0 + 0x30);cmd_ring은 streaming DMA ring buffer 패턴으로 동작한다. driver가 tail(Producer index)을 올리며 커맨드를 채우고, phantom-gpu가 head(Consumer index)를 소비한다.
이때 DMA buffer를 먼저 채운 뒤 doorbell로 device를 깨우는 것이 PCIe DMA의 표준적인 순서이고, iowrite32(next_idx, bar0+0x30)이 그 신호라고 할 수 있다. doorbell write 이후로는 phantom-gpu는 DMA로 cmd_ring을 읽어 커맨드를 처리한다.
4-5. IRQ handler (sub_5E0) - 완료 통보
irqreturn_t irq_handler(int irq, void *gd) { u32 status = ioread32(gd->bar0 + 0x14); // completion status 확인 if (status) { gd->log_record = *(u64 *)gd->log_ring; // log 읽기 gd->has_log = 1; iowrite32(status, gd->bar0 + 0x18); // IRQ ACK wake_up(&gd->wq); // ioctl handler 깨움 } return IRQ_HANDLED;}DISPATCH 완료 후 phantom-gpu가 MSI를 발생시키면 이 ISR이 깨어난다. wait_event로 block 중이던 ioctl handler를 깨우고, 이후 copy_to_user로 결과(slot2 = flag output 영역)가 user 쪽으로 전달된다.
4-6. 전체 흐름
gpuctl-cli │ ioctl(fd, DISPATCH, ...) ▼sub_370 (ioctl handler) ├─ copy_from_user → DMA buffer (slot[0], slot[1]) ├─ push_cmd │ ├─ cmd_ring[tail]에 커맨드 기록 ← streaming ring buffer │ └─ iowrite32(next_idx, bar0+0x30) ← doorbell │ │ │ 실제 HW: └─ PCIe TLP → device DMA engine → host RAM 읽기 │ QEMU: └─ MMIO trap → MemoryRegion 콜백 │ └─ dma_memory_read(cmd_ring_dma) ← guest RAM 직접 │ │ │ shader validation + VM execution │ MSI interrupt 발생 │ │ └─ wait_event ◀─────┘ │ sub_5E0 (IRQ handler) ioread32(bar0+0x14) completion status iowrite32(bar0+0x18) ACK wake_up(&gd->wq) │ ioctl handler 재개 ◀┘ copy_to_user(slot[2]) → flag output 반환앞서 설명했지만 gpuctl.ko는 모든 요청을 DMA 버퍼에 복사하고 doorbell을 울릴 뿐인 순수한 전달 레이어다. 진짜 분석해야할 대상은 qemu-system-x86_64 바이너리이다.
5. QEMU phantom-gpu 리버싱
qemu의 경우는 우선 IDA에서 "phantom-gpu" 문자열 xref를 추적해서 가장 주요한 함수들을 찾았다:
| 함수 | 설명 |
|---|---|
sub_4277B0 | PCI class init; "Phantom GPU (CTF Challenge Device)" |
sub_427D10 | device realize; MMIO region 등록, BAR 활성화 |
sub_427C10 | MMIO read handler; signature/version 반환 |
sub_428AB0 | MMIO write handler |
sub_428AB0는 약 6천 줄짜리 함수로, firmware init / DMA command 처리 / shader validation / VM execution / post-gate success path 까지 핵심 로직들이 전부 들어있다.
5-1. Firmware Blob 추출 및 디코드
앞서 언급한 함수인 sub_428AB0에서 offset == 0x100 분기가 firmware init 경로다. 0xB0A2D8의 embedded blob을 참조한다.
firmware 디코딩 체인은 단순하다:
; ① xorshift32 — 0x428FE8mov edx, 0FE170E07h ; seedloc_428FE8: mov eax, edx shl eax, 0Dh ; state ^= state << 13 xor eax, edx mov edx, eax shr edx, 11h ; state ^= state >> 17 xor eax, edx mov edx, eax shl edx, 5 ; state ^= state << 5 xor edx, eax xor [rcx], dl ; blob[i] ^= low_byte(state) add rcx, 1 cmp rcx, rbp ; 0x2E1 bytes jnz loc_428FE8
; ② CRC32 — 0x42901Cmov eax, 0FFFFFFFFh ; CRC initloc_429028: movzx edx, byte ptr [rsi] xor edx, eax shr eax, 8 movzx edx, dl xor eax, [rcx+rdx*4] ; table @ 0x19EEF00 cmp rsi, rbp jnz loc_429028cmp eax, 0AEF1393Ah ; expected CRC
; ③ LZ4 raw block — 0x429080movzx ebx, byte ptr [rdx] ; token byte shr al, 4 ; literal_length = token >> 4 cmp al, 0Fh ; if 0xF → extended length loop jz loc_429522아래 스크립트로 firmware blob을 추출할 수 있다.
extract.py
#!/usr/bin/env python3import struct, zlib, sys, lz4.blockfrom pathlib import Path
qemu = Path("qemu-system-x86_64").read_bytes()enc = bytearray(qemu[0xB0A2D8 : 0xB0A2D8 + 0x2E1])
# xorshift32 decrypt (seed=0xFE170E07)state = 0xFE170E07for i in range(len(enc)): state ^= (state << 13) & 0xFFFFFFFF state ^= state >> 17 state ^= (state << 5) & 0xFFFFFFFF state &= 0xFFFFFFFF enc[i] ^= (state & 0xFF)
# CRC32 verify — pre-final XOR value == 0xAEF1393Acrc_raw = zlib.crc32(bytes(enc)) ^ 0xFFFFFFFF & 0xFFFFFFFFassert crc_raw == 0xAEF1393A, f"CRC mismatch: {crc_raw:#x} vs 0xAEF1393A"
# LZ4 raw block decompress -> firmware blobsfirmware = lz4.block.decompress(bytes(enc), uncompressed_size=0x308)Path("firmware_decompressed.bin").write_bytes(firmware)print(f"OK: {len(firmware)} bytes → firmware_decompressed.bin")5-2. Firmware → Device State 복사
LZ4 압축해제 이후의 0x308바이트 blob은 device state 구조체의 여러 오프셋으로 복사된다. IDA에서 추적한 mov/rep movsq 어셈으로 구조를 복원하면 이와 같다:
typedef struct PhantomGPUState { uint8_t _pci_header[0xC00];
uint8_t key_seed[8]; uint8_t blob_copy[0x308];
uint64_t blob_tail_qword;
uint8_t trace_pattern[8]; uint32_t gate_reg7; uint32_t gate_buf2_dw0; uint32_t gate_crc_buf1; uint32_t gate_xor_r0r3;
uint32_t post_f28; uint32_t post_f2c;
uint64_t dispatch_table[256];
uint8_t _dma_addrs[];
uint32_t reg[16]; uint32_t pc; uint32_t sp; uint32_t cmp_flag;
uint8_t active_shader[0x2000];
uint8_t trace_ring[16]; uint32_t trace_count;
uint32_t gate_armed; uint32_t vm_status; uint32_t fault_code;
} PhantomGPUState;5-3. Phase 1 Transform — Grammar A / Grammar B
firmware blob → gate 상수 파생 과정을 Phase 1 Transform이라고 명명하겠다. 이때 IDA 0x429240~0x429472 구간에 두 가지 유의미한 패턴이 반복된다.
Grammar A (stream XOR) — trace pattern 생성
0x429240~0x429261 루프:
loc_429240: lea edx, [ecx+eax] ; edx = (seed + i) — seed는 blob[0x08]의 하위 바이트 movzx edx, dl ; dl = (seed + i) & 0xFF movzx edx, byte ptr [rdi+rdx+0D0Ch] ; dev[0xD0C + dl] = sbox[dl] xor dl, [rax+0C07h] ; ^ blob[i-1] (dev[0xC08+i-1]) mov [rax+0F0Fh], dl ; → dev[0xF10+i-1] (trace pattern) add rax, 1 cmp rax, rsi ; 8회 반복 jnz loc_429240Pseudo-code:
// Grammar A: sbox byte XORfor (i = 0; i < 8; i++)
dev[0xF10 + i] = sbox[(seed + i) & 0xFF] ^ blob[i];seed는 blob[0x08:0x0C]의 하위 바이트(= dev[0xC10]), sbox는 dev[0xD0C] = blob[0xFC:0x1FC]이다. 그리고 결과가 50 11 21 40 20 11 51 41 — gate bit 0의 trace 기대값이다.
Grammar B (pack_perm XOR) — 32-bit 상수 생성
0x42926B~0x4292C1이 첫 번째 Grammar B 블록이다:
mov edx, [rbx+0C18h] ; edx = selector (blob 내 4바이트 값)lea eax, [rdx+1] ; idx+1lea ecx, [rdx+2] ; idx+2movzx eax, byte ptr [rbx+rax+0D0Ch] ; sbox[(selector+1) & 0xFF]movzx ecx, byte ptr [rbx+rcx+0D0Ch] ; sbox[(selector+2) & 0xFF]shl eax, 8 ; byte1 << 8shl ecx, 10h ; byte2 << 16or eax, ecxmovzx ecx, dl ; sbox[(selector+0) & 0xFF]movzx ecx, byte ptr [rbx+rcx+0D0Ch]add edx, 3movzx edx, byte ptr [rbx+rdx+0D0Ch] ; sbox[(selector+3) & 0xFF]or eax, ecx ; | byte0shl edx, 18h ; byte3 << 24or eax, edx ; = pack_perm 결과xor eax, [rbx+0C14h] ; blob에서 가져온 상수랑 XORmov [rbx+0F18h], eax ; → dev[0xF18] (gate_reg7)Pseudo-code:
// Grammar B: 4-byte S-box lookup → u32 → XORuint32_t pack_perm(uint8_t *sbox, uint32_t idx) { return sbox[(idx+0) & 0xFF] | (sbox[(idx+1) & 0xFF] << 8) | (sbox[(idx+2) & 0xFF] << 16) | (sbox[(idx+3) & 0xFF] << 24);}
dev[0xF18] = pack_perm(sbox, blob_dword(selector_offset)) ^ blob_dword(xor_offset);이 패턴이 6번 반복되면서 gate 상수 6개를 생성한다:
// selector 오프셋 → XOR 상수 → 저장 주소dev[0xF18] = pack_perm(sbox, blob[0x10]) ^ blob[0x0C] // gate_reg7 = 0xA5A5A5A5dev[0xF1C] = pack_perm(sbox, blob[0x18]) ^ blob[0x14] // gate_buf2 = 0xC0DEF00Ddev[0xF20] = pack_perm(sbox, blob[0x20]) ^ blob[0x1C] // gate_crc = 0x2A8DED84dev[0xF24] = pack_perm(sbox, blob[0x58]) ^ blob[0x54] // gate_xor = 0xCC99E897dev[0xF28] = pack_perm(sbox, blob[0x28]) ^ blob[0x24] // post_f28 = 0x13579BDFdev[0xF2C] = pack_perm(sbox, blob[0x30]) ^ blob[0x2C] // post_f2c = 0x2468ACE0대부분 blob[0x0C]부터 8바이트 간격으로 순차 배치되지만, f24(gate_xor)만 blob[0x54]/blob[0x58]로 떨어져 있다.
5-4. Dispatch Table 초기화
Phase 1 Transform 직후 dispatch table이 초기화된다. unk_1719400에 15개 (opcode, handler) 쌍이 정적으로 정의되어 있고, firmware blob의 scramble 값으로 최종 slot 위치를 결정한다:
// 0x4294C2 — dispatch table 초기화 루프for (int i = 0; i < 15; i++) { uint8_t opcode = static_table[i].opcode; // unk_1719400 void *handler = static_table[i].handler; uint8_t slot = (13 * opcode + scramble_data[opcode] + blob[0x304]) & 0xFF; dev->dispatch_table[slot] = handler;}// scramble_data = blob[0x1FC:0x2FC], blob[0x304] = global constant이 scramble이 없으면 opcode → handler 매핑을 알 수 없으므로, firmware 디코딩이 VM 분석의 선행 조건이다.
디코딩 후 복원한 ISA — 15개 opcode, 4바이트 고정 길이 (byte0=opcode, byte1=Rd, byte2=Rs/Rb/buf, byte3=imm/offset):
| Opcode | Mnemonic | Operation |
|---|---|---|
0x01 | MOV Rd, Rs | reg[Rd] = reg[Rs] |
0x02 | LI Rd, imm8 | reg[Rd] = sign_extend(imm8) |
0x10 | ADD Rd, Rs | reg[Rd] += reg[Rs] |
0x11 | XOR Rd, Rs | reg[Rd] ^= reg[Rs] |
0x12 | AND Rd, Rs | reg[Rd] &= reg[Rs] |
0x13 | OR Rd, Rs | reg[Rd] |= reg[Rs] |
0x20 | LOAD Rd, [buf, Rb+off] | reg[Rd] = dma_buf[buf][(reg[Rb]+off)*4] |
0x21 | STORE Rs, [buf, Rb+off] | dma_buf[buf][(reg[Rb]+off)*4] = reg[Rs] |
0x30 | CMP Rd, Rs | flag = (reg[Rd] == reg[Rs]) |
0x31 | BEQ off8 | if flag: PC += off*4 |
0x32 | BNE off8 | if !flag: PC += off*4 |
0x40 | CALL off8 | SP--; stack[SP]=PC+4; gate_check(); PC+=off*4 |
0x41 | RET | if checker()==0x3F → GATE_TRIGGERED; else PC=pop() |
0x50 | PUSH Rs | SP--; stack[SP] = reg[Rs] |
0x51 | POP Rd | reg[Rd] = stack[SP]; SP++ |
5-5. Shader Validator (sub_428450)
dispatch 전에 업로드된 shader를 검사한다:
int validate_shader(PhantomGPUState *dev) { uint8_t *shader = dev->active_shader; int size = dev->shader_size;
// 1. 크기/정렬 if (size == 0 || (size & 3) || size > 0x1803) return FAIL;
// 2. opcode 유효성 — 모든 instruction을 순회 for (int pc = 0; pc < size; pc += 4) { uint8_t op = shader[pc]; if (op > 0x51) // switch 82 cases, default → FAIL return 7; // jump table: 0x01,0x02,0x10~0x13,0x20,0x21,0x30~0x32,0x40,0x41,0x50,0x51만 허용 // 나머지 → return 7 }
// 3. BFS reachability 마킹 (sub_4275B0) // PC=0부터 시작, CALL/BEQ/BNE target을 따라가며 reachable 블록 마킹 mark_reachable(shader, size); // sub_4275B0
// 4~5. reachable 블록에 대해서만 prologue/epilogue 검사 for (each reachable instruction) { if (op == 0x41) { // RET // RET 직전 2개 명령이 POP(0x51)이어야 함 // 추가로 POP의 대상 레지스터가 r15, r14 순이어야 함 word_m2 = read_insn(pc - 8); // byte[pc-12..pc-9] word_m1 = read_insn(pc - 4); // byte[pc-8..pc-5] if ((word_m2 & 0xFF) != 0x51) return 4; if ((word_m1 & 0xFF) != 0x51) return 4; // word_m1의 byte1 == 0x0E (r14) 확인 // word_m2의 byte1 == 0x0F (r15) 확인 } if (op == 0x40) { // CALL // CALL target의 첫 2개 명령이 PUSH r15(0x50 0x0F) + MOV r15,r14(0x01 0x0F 0x0E) target = pc + offset*4; word0 = read_insn(target); word1 = read_insn(target + 4); if ((word0 & 0xFF) != 0x50) return 4; // PUSH if ((word0 >> 8 & 0xF) != 0xF) return 4; // r15 if ((word1 & 0xFF) != 0x01) return 4; // MOV if ((word1 >> 8 & 0xF) != 0xF) return 4; // Rd=r15 if ((word1 >> 16 & 0xF) != 0xE) return 4; // Rs=r14 } } return 0; // pass}여기서 검증 4, 5는 sub_4275B0(BFS)이 reachable로 마킹한 블록에만 적용된다. BFS가 도달하지 못한 블록은 prologue/epilogue 검사를 완전히 건너뛴다.
이걸로 추후 gate 검증 우회를 할 수 있다.
5-6. VM Executor Loop & Fault Codes
dispatch 시작 시 memset(dev + 0x1730, 0, 0x20a8)로 레지스터/PC/SP/trace ring을 전부 초기화한 뒤, 아래 루프를 실행한다:
while (steps < max_steps) { if (pc >= shader_size) { fault = FAULT_BAD_PC; break; }
uint8_t opcode = shader[pc]; uint8_t slot = (13 * opcode + scramble_data[opcode] + blob[0x304]) & 0xFF; handler_fn *handler = dev->dispatch_table[slot];
if (!handler) { fault = FAULT_BAD_OPCODE; break; }
trace_ring[trace_count++ % 16] = opcode; handler(dev, &shader[pc]);
if (dev->fault_code) break; steps++;}dispatch_table[256]은 함수 포인터 배열로, 5-4에서 초기화된 15개 slot만 실제 handler가 채워져 있고 나머지는 NULL이다. 유효하지 않은 opcode가 들어오면 NULL handler → FAULT_BAD_OPCODE로 즉시 종료된다.
각 handler 내부 함수에서는 에러 조건을 만나면 dev->fault_code(offset 0x379C)에 상수를 쓰는데, 이 패턴을 추적해서 fault code를 식별할 수 있다:
| 코드 | 이름 | 트리거 조건 |
|---|---|---|
| 0 | OK | 정상 종료 |
| 1 | FAULT_OOB | DMA 버퍼 범위 초과 (LOAD/STORE) |
| 2 | FAULT_STACK_OVER | SP ≤ 0 (PUSH) |
| 3 | FAULT_STACK_UNDER | 스택 비어있음 (POP) |
| 4 | FAULT_BAD_OPCODE | NULL handler |
| 5 | FAULT_BAD_PC | PC 범위 초과 |
| 6 | STEPS_EXHAUST | 최대 스텝 소진 |
| 7 | HALTED | shader 끝 도달 |
| 8 | GATE_TRIGGERED | checker == 0x3F → success path |
요약:

6. Gate 검증
6-1. Gate 조건 분석
RET handler(sub_428370)가 매 RET 실행 시 sub_4280D0(checker)을 호출한다. 반환값이 정확히 0x3F(6비트 모두 set)이면 vm_status = 8 (GATE_TRIGGERED).
Bit 0 (0x01) — Trace History
최근 8개 opcode == 50 11 21 40 20 11 51 41(PUSH XOR STORE CALL LOAD XOR POP RET)Bit 1 (0x02) — Register Check
reg[7] == 0xA5A5A5A5reg[0] ^ reg[3] == 0xCC99E897Bit 2 (0x04) — buf2 First Dword
*(u32*)buf2 == 0xC0DEF00DBit 3 (0x08) — buf1 CRC32
CRC32(buf1[0:16]) == 0x2A8DED84gate는 buf1 앞 16바이트의 CRC만 검사한다. 바이트 자체는 고정하지 않는다.
Bit 4 (0x10) — Gate Armed
CALL 실행 시: reg[1] == 0x47415445 ("ETAG") + 직전 4개 trace == [LOAD, AND, STORE, CALL]Bit 5 (0x20) — Shader CRC32
reg[2] == CRC32(전체 shader bytecode)dead code 바이트도 CRC 계산에 포함된다.
6-2. Gate 검증 우회
문제:
Bit 0 history 50 11 21 40 20 11 51 41에서 RET 직전은 0x51(POP) 하나이고 그 앞은 0x11(XOR)이다. 그런데 validator는 reachable RET 직전 두 명령이 POP 두 개여야 한다고 강제한다. reachable code만으로는 두 조건을 동시에 만족할 수 없다.
해결:
Validator가 dead code 블록에 대해서 CALL prologue/RET epilogue 규칙을 검사하지 않는다는 점을 이용할 수 있다.
- Reachable 함수 내에서 STORE로 slot3(stack)의 return address를 dead block PC로 덮어쓴다
- Validator 규칙을 만족하는 POP + POP + RET로 함수를 종료한다
- RET가 덮어쓴 주소를 팝하여 dead block으로 점프한다
- Dead block에서 정확히
50 11 21 40 20 11 51 41history를 만들 수 있다 - Dead block의 마지막 RET에서 checker 호출 →
0x3F→ GATE_TRIGGERED
Dead Block Return Address Overwrite
gpuctl-cli run이 slot3을 16KB(SP=4096)로 할당한다. CALL이 return address를 stack[4095]에 push하는데, STORE의 offset(byte3)은 signed 8-bit이라 base가 0이면 stack[15]까지만 도달 가능하다.
이 때 CALL 전에 r14 = 4080을 미리 로드하면 된다. 함수 prologue의 MOV r15, r14가 r15 = 4080을 만들고, STORE r9, [buf3, r15+15]가 stack[4095]를 정확히 덮는다. validator는 함수 바이트코드 자체를 검사하므로, 동일한 바이트코드가 입력값(r14)에 따라 다른 위치를 overwrite하는 것까지는 막지 못한다.
6-3. Shader 레이아웃
최종 shader는 reachable region과 dead block 두 영역으로 구성된다. Reachable 영역에서 gate bit 1~5를 세팅한 뒤, return address overwrite로 dead block에 진입하여 bit 0(trace history)까지 만족시킨다.

7. Flag 복원
GATE_TRIGGERED까지 성공해도 buf2에 올바른 flag가 바로 나오지는 않는다. Gate 조건은 레지스터·버퍼의 관계(XOR, CRC 등)만 검사하므로 통과 가능한 입력 조합은 무수히 많지만, success path는 각 값을 직접 사용해 AES key를 파생한다. 정확한 입력이 아니면 의미 있는 출력이 나올 수 없다.
7-1. Gate 검사 범위
Gate가 고정하는 값:
- trace8 — 8바이트 일치
- reg7 — 값 일치
- reg0 ^ reg3 — XOR 결과만 (개별값은 자유)
- buf2[0:4] — 값 일치
- CRC32(buf1[:16]) — CRC만 (raw bytes는 자유)
- reg2 — CRC32(shader) 일치
Gate가 검사하지 않는 값:
- reg0, reg3 개별값
- reg4, reg5
- buf1 전체 32바이트 (앞 16바이트는 CRC만, 뒤 16바이트는 자유)
7-2. Success Path가 실제로 읽는 것
// sub_428AB0 success path, 0x4299d6..v132 = f28 ^ reg0 // reg0 개별값 직접 씀v134 = f2c ^ ((reg4 + reg5) & 0xFFFFFFFF) // reg4, reg5 직접 씀v135 = ROL32(reg3, 7) ^ v132 // reg3 개별값 직접 씀msg = trace8 || pack_le32(v135) || pack_le32(v134) || buf1[16:32]rounds = expand_round_material(buf1[0:16]) // buf1 전반 raw bytes 직접 씀Gate를 통과하더라도, 위 코드에서 직접 참조하는 reg0, reg3, reg4, reg5의 개별값과 buf1 32바이트의 정확한 내용을 별도로 복원해야 올바른 flag를 얻을 수 있다.
7-4. Success Path 분석
buf1[0:32] ──┐trace8 ──┤reg0~reg5 ──┼──▶ compress_states() ──▶ S0 (AES key), S1 (CTR counter)f28, f2c ──┘ ↓ AES-128-CTR(S0, S1) ──▶ keystream (78B) │ fixed_blob (78B) ──XOR───────┘──▶ flag (buf2 output)compress_states 메인 로직은 아래와 같다:
buf1[0:16]에서 round material 24개 생성 (MurmurHash3 fmix 변형)- GF(2^8) MixColumns (standard + custom matrix) + Feistel 12라운드
fold16(sub_4279B0): 16B → 64bit keyed digest (seed0xDEADBEEFCAFEBABE)- 역방향 Feistel 8라운드 + 최종 GF MixColumns → S0(AES key), S1(CTR counter)
compress_states 함수는 역연산이 불가능한 일방향 함수이다. 다만 올바른 입력값만 넣으면 VM 내부에서 알아서 연산되므로, 자세히 보지 않겠다.
7-6. Hidden Witness
자, Gate를 통과해도 flag가 나오지 않으니 이제 success path가 기대하는 정확한 입력값을 찾아야 한다. 그리고 다행히도 firmware blob 0x308바이트 중 아직 해석이 덜 된 영역이 남아있다.
여기부터는 약간의 게싱이 필요한데,

Hidden Register
Firmware blob 0x034..0x054 영역을 Grammar B (pack_perm XOR) 방식으로 디코드하면:
| 값 | 용도 |
|---|---|
0x12345678 | reg0 |
0xDEADBEEF | reg3 |
0x11111111 | reg4 |
0x22222222 | reg5 |
검증: 0x12345678 ^ 0xDEADBEEF = 0xCC99E897 = gate bit 1의 reg0 ^ reg3 기대값과 정확히 일치한다.
Hidden buf1
Firmware blob blob[0x5C:0x80] 영역:
[0x5C:0x7C] = raw 32바이트 (Grammar A로 인코딩된 데이터)[0x7C:0x80] = selector dword (0x0000009D)이 구조가 trace8 디코드에 사용된 Grammar A 블록과 완전히 동일한 [raw data] + [selector dword] 포맷이다. Grammar A를 적용하면:
decoded[i] = blob[0x5C + i] ^ sbox[(0x9D + i) & 0xFF]PHTM_CTF_KEY_128SECONDHALF_DATA! 라는 의미있는 문자열이 나온다!
이처럼 trace block과 동일한 [raw data] + [selector dword] , 의미있는 ASCII 문자열이 나온 데다가 앞 16바이트 CRC32 = 0x2A8DED84 = gate 상수 f20과 정확히 일치하는 점으로 미루어보았을 때 이게 의도된 buf1 값이라는 걸 알 수 있다.
이때 hidden reg 및 buf1은 QEMU success path에 의해 참조되진 않는다. Hidden marker는 runtime 중에 소비되는 정답이 아니라 숨겨진 값이다.
8. Solve

