Codegate CTF 2026 Writeup (PhantomCore)

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를 얻는 리버싱 문제이다.

풀이는 크게 두 단계로 나뉜다.

  1. Gate 통과: checker가 요구하는 6-bit 조건을 모두 만족하는 shader + buffer 조합을 찾는다
  2. 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 vs PCIe - QEMU Wiki

5.png

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

6.png

참고: QEMU PCI 장치 구현 - owalle

이 문제에서는 -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=0x0c0500

bus 구조에서 별도의 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 시작하기 - velog

QEMU 프로세스 메모리:

7.png

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

QEMU 추상화 레이어:

8.png

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#

9.png

실제 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.sh

bios/ 디렉토리에 6개 파일이 있지만 실제로 필요한 것은 bios-256k.bin 하나뿐이다.

-machine q35가 BIOS를 요구하고, 이것이 Guest Linux 커널 부팅 전 POST 과정을 담당한다. 나머지 5개 파일은 원본 run.sh에 -nic none, -vga none 옵션만 추가하면 전혀 필요가 없다.

initramfs 풀기:

Terminal window
mkdir initramfs_root && cd initramfs_root
gzip -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 <- 유저랜드 CLI

init 스크립트가 gpuctl.koinsmod하고, /dev/gpuctl 캐릭터 디바이스를 생성한다.

#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
mkdir -p /dev/pts
mount -t devpts none /dev/pts
mount -t tmpfs none /tmp
export PATH=/bin:/sbin:/usr/local/bin
# Load phantom-gpu driver
insmod /lib/modules/gpuctl.ko
sleep 0.5
echo "================================================"
echo " PhantomCore"
echo " /dev/gpuctl is ready"
echo " Use gpuctl-cli to interact with the device"
echo "================================================"
# Drop to shell
export PATH=/bin:/sbin:/usr/local/bin
export HOME=/tmp
cd /tmp
setsid cttyhack sh
poweroff -f

run.sh 실행:

Terminal window
================================================
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-cli
Usage: 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 log

3. 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의 표준 흐름을 먼저 알아야 한다.

10.png

위 그림과 다르게 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_11A0pci_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 → .remove

pci_register_driver가 이 구조체를 커널에 등록하면, 커널이 PCI 버스를 스캔하다가 vendor=1DE5, device=7001인 장치(phantom-gpu)를 발견하면 .probe = sub_650함수를 자동 호출한다.

4-2. probe(sub_650) — PCI 초기화 및 DMA 할당#

  1. 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 콜백이 트리거된다.

  2. 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 lo
    iowrite32(upper_32_bits(gd->cmd_ring_dma), gd->bar0 + 0x24); // cmd_ring hi
    iowrite32(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 메모리 영역을 직접 읽는다.

  3. IRQ 등록 및 Firmware init

    // MSI interrupt handler 등록
    request_threaded_irq(irq, sub_5E0, NULL, 0x80, "gpuctl", gd);
    // Firmware init trigger
    iowrite32(1, gd->bar0 + 0x100);
    _mm_mfence();
    // QEMU가 firmware blob 디코드 완료할 때까지 polling
    for (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_4277B0PCI class init; "Phantom GPU (CTF Challenge Device)"
sub_427D10device realize; MMIO region 등록, BAR 활성화
sub_427C10MMIO read handler; signature/version 반환
sub_428AB0MMIO write handler

sub_428AB0는 약 6천 줄짜리 함수로, firmware init / DMA command 처리 / shader validation / VM execution / post-gate success path 까지 핵심 로직들이 전부 들어있다.

11.png

GPU 아키텍처 관점에서의 동작

5-1. Firmware Blob 추출 및 디코드#

앞서 언급한 함수인 sub_428AB0에서 offset == 0x100 분기가 firmware init 경로다. 0xB0A2D8의 embedded blob을 참조한다.

firmware 디코딩 체인은 단순하다:

; ① xorshift32 — 0x428FE8
mov edx, 0FE170E07h ; seed
loc_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 — 0x42901C
mov eax, 0FFFFFFFFh ; CRC init
loc_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_429028
cmp eax, 0AEF1393Ah ; expected CRC
; ③ LZ4 raw block — 0x429080
movzx 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 python3
import struct, zlib, sys, lz4.block
from pathlib import Path
qemu = Path("qemu-system-x86_64").read_bytes()
enc = bytearray(qemu[0xB0A2D8 : 0xB0A2D8 + 0x2E1])
# xorshift32 decrypt (seed=0xFE170E07)
state = 0xFE170E07
for 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 == 0xAEF1393A
crc_raw = zlib.crc32(bytes(enc)) ^ 0xFFFFFFFF & 0xFFFFFFFF
assert crc_raw == 0xAEF1393A, f"CRC mismatch: {crc_raw:#x} vs 0xAEF1393A"
# LZ4 raw block decompress -> firmware blobs
firmware = 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_429240

Pseudo-code:

// Grammar A: sbox byte XOR
for (i = 0; i < 8; i++)
dev[0xF10 + i] = sbox[(seed + i) & 0xFF] ^ blob[i];

seedblob[0x08:0x0C]의 하위 바이트(= dev[0xC10]), sboxdev[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+1
lea ecx, [rdx+2] ; idx+2
movzx 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 << 8
shl ecx, 10h ; byte2 << 16
or eax, ecx
movzx ecx, dl ; sbox[(selector+0) & 0xFF]
movzx ecx, byte ptr [rbx+rcx+0D0Ch]
add edx, 3
movzx edx, byte ptr [rbx+rdx+0D0Ch] ; sbox[(selector+3) & 0xFF]
or eax, ecx ; | byte0
shl edx, 18h ; byte3 << 24
or eax, edx ; = pack_perm 결과
xor eax, [rbx+0C14h] ; blob에서 가져온 상수랑 XOR
mov [rbx+0F18h], eax ; → dev[0xF18] (gate_reg7)

Pseudo-code:

// Grammar B: 4-byte S-box lookup → u32 → XOR
uint32_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 = 0xA5A5A5A5
dev[0xF1C] = pack_perm(sbox, blob[0x18]) ^ blob[0x14] // gate_buf2 = 0xC0DEF00D
dev[0xF20] = pack_perm(sbox, blob[0x20]) ^ blob[0x1C] // gate_crc = 0x2A8DED84
dev[0xF24] = pack_perm(sbox, blob[0x58]) ^ blob[0x54] // gate_xor = 0xCC99E897
dev[0xF28] = pack_perm(sbox, blob[0x28]) ^ blob[0x24] // post_f28 = 0x13579BDF
dev[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):

OpcodeMnemonicOperation
0x01MOV Rd, Rsreg[Rd] = reg[Rs]
0x02LI Rd, imm8reg[Rd] = sign_extend(imm8)
0x10ADD Rd, Rsreg[Rd] += reg[Rs]
0x11XOR Rd, Rsreg[Rd] ^= reg[Rs]
0x12AND Rd, Rsreg[Rd] &= reg[Rs]
0x13OR Rd, Rsreg[Rd] |= reg[Rs]
0x20LOAD Rd, [buf, Rb+off]reg[Rd] = dma_buf[buf][(reg[Rb]+off)*4]
0x21STORE Rs, [buf, Rb+off]dma_buf[buf][(reg[Rb]+off)*4] = reg[Rs]
0x30CMP Rd, Rsflag = (reg[Rd] == reg[Rs])
0x31BEQ off8if flag: PC += off*4
0x32BNE off8if !flag: PC += off*4
0x40CALL off8SP--; stack[SP]=PC+4; gate_check(); PC+=off*4
0x41RETif checker()==0x3F → GATE_TRIGGERED; else PC=pop()
0x50PUSH RsSP--; stack[SP] = reg[Rs]
0x51POP Rdreg[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를 식별할 수 있다:

코드이름트리거 조건
0OK정상 종료
1FAULT_OOBDMA 버퍼 범위 초과 (LOAD/STORE)
2FAULT_STACK_OVERSP ≤ 0 (PUSH)
3FAULT_STACK_UNDER스택 비어있음 (POP)
4FAULT_BAD_OPCODENULL handler
5FAULT_BAD_PCPC 범위 초과
6STEPS_EXHAUST최대 스텝 소진
7HALTEDshader 끝 도달
8GATE_TRIGGEREDchecker == 0x3F → success path

요약:

12.png


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] == 0xA5A5A5A5
reg[0] ^ reg[3] == 0xCC99E897

Bit 2 (0x04) — buf2 First Dword

*(u32*)buf2 == 0xC0DEF00D

Bit 3 (0x08) — buf1 CRC32

CRC32(buf1[0:16]) == 0x2A8DED84

gate는 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 규칙을 검사하지 않는다는 점을 이용할 수 있다.

  1. Reachable 함수 내에서 STORE로 slot3(stack)의 return address를 dead block PC로 덮어쓴다
  2. Validator 규칙을 만족하는 POP + POP + RET로 함수를 종료한다
  3. RET가 덮어쓴 주소를 팝하여 dead block으로 점프한다
  4. Dead block에서 정확히 50 11 21 40 20 11 51 41 history를 만들 수 있다
  5. 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, r14r15 = 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)까지 만족시킨다.

13.png


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 메인 로직은 아래와 같다:

  1. buf1[0:16]에서 round material 24개 생성 (MurmurHash3 fmix 변형)
  2. GF(2^8) MixColumns (standard + custom matrix) + Feistel 12라운드
  3. fold16 (sub_4279B0): 16B → 64bit keyed digest (seed 0xDEADBEEFCAFEBABE)
  4. 역방향 Feistel 8라운드 + 최종 GF MixColumns → S0(AES key), S1(CTR counter)

compress_states 함수는 역연산이 불가능한 일방향 함수이다. 다만 올바른 입력값만 넣으면 VM 내부에서 알아서 연산되므로, 자세히 보지 않겠다.

7-6. Hidden Witness#

자, Gate를 통과해도 flag가 나오지 않으니 이제 success path가 기대하는 정확한 입력값을 찾아야 한다. 그리고 다행히도 firmware blob 0x308바이트 중 아직 해석이 덜 된 영역이 남아있다.

여기부터는 약간의 게싱이 필요한데,

14.png

Hidden Register

Firmware blob 0x034..0x054 영역을 Grammar B (pack_perm XOR) 방식으로 디코드하면:

용도
0x12345678reg0
0xDEADBEEFreg3
0x11111111reg4
0x22222222reg5

검증: 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#

15.png

16.png

Codegate CTF 2026 Writeup (PhantomCore)
https://ma4the.github.io/posts/rev-phantomcore-codegate2026/
Author
m@the
Published at
2026-04-10
License
CC BY-NC-SA 4.0