Python library to automate gdb debugging
Project description
GDB+
Python library to automate gdb debugging
GDB+ is a wrapper around gdb powered by pwntools. The goal is automate your interactions with gdb and add some extra features.
Main features
- Bypass ptrace anti-debugging.
- Include python functions as callback to your breakpoint.
- Test / Bruteforce a specific functions of a binary with the parameters you want.
- Log the code of a self modifying function.
- Backup parts of your memory and restore it during future executions.
- For pwning don't worry about making a separate script to debug your exploit. The arguments
NOPTRACE
andREMOTE
make the exploit skip any action related to gdb or attack directly the remote server.
Installation
System requirements
First make sure to have gdb
and gdbserver
installed.
sudo apt install gdb gdbserver
If you want to debug ARM
and RISCV
binaries you will also need qemu
and gdb-multiarch
.
sudo apt install gdb-multiarch qemu-user
See the chapter about QEMU for more informations on how to set up the system libraries for the architecture you want to emulate and some limitations when debugging under the emulator.
gdb_plus
stable version
pip3 install gdb_plus
or dev branch
pip3 install git+https://github.com/Angelo942/gdb_plus.git@dev
Warning for pwndbg users:
Previous bugs in Pwndbg used to break the api for python. While most of GDB+ should work with the current version of pwndbg [19/12/2022] some problems may arise while emulating ptrace.
you are strongly advised to use GEF instead except if using QEMU for which pwndbg is currently a better option [18/01/25].
Script setup
GDB+ is using pwntools to work with the correct architecture. It is recommended to set the context
at the beginning of your script. You can directly use the binary if it's an ELF or the architecture (and if needed the bit size).
from gdb_plus import *
exe = ELF("./challenge")
context.binary = exe
dbg = Debugger(exe)
...
from gdb_plus import *
context.arch = "amd64"
dbg = Debugger("./challenge")
...
pwntools also gives you arguments that you can pass to the script to change the behaviour. In particular we use NOPTRACE
and REMOTE
. python3 ./script.py NOPTRACE
sets Debugger.debugging
to False
and skips all actions with gdb. python3 ./script.py REMOTE
connects to the server specified as Debugger.setup_remote(<host>, <port>)
to launch the attack instead of spawning a local process.
If you want here are a template for spwn template_spwn.py and pwninit template_pwninit.py
Debugging
You can debug a program using the path to the binary.
If you really have to you can also use a process or even just his pid.
For programs running in a container you can connect directly to a given gdbserver.
For pwn challenges set the remote address with Debugger.setup_remote(<host>, <port>)
and use the argument REMOTE
once you want to exploit the server
from gdb_plus import *
gdbinit = """
handle SIGALRM nopass
"""
dbg = Debugger("./rev_challenge", script=gdbinit, aslr=False)
dbg = Debugger("./pwn_challenge", script=gdbinit)
dbg.setup_remote("10.10.0.1", 1337)
p = process("./challenge", aslr=False)
dbg = Debugger(p, script=gdbinit)
#pidof process : 3134
dbg = Debugger(3134, script=gdbinit, binary="./challenge")
# gdbserver running on 192.168.1.234:2345
dbg = Debugger(("192.168.1.234", 2345), script=gdbinit, binary="./challenge")
By default your process will be analysed from the first instruction of the loader. If the process you are debugging has some checks you want to avoid use Debugger(..., debug_from=<address>)
to attach with gdb at a specific address.
This will block you script until you reach you address. If you need to execute code in between you can use the non blocking alternative:
Im_done = Event()
dbg = Debugger("./challenge").debug_from("main+0x34", event=Im_done)
dbg.p.sendline("this is my input")
Im_done.set()
dbg.debug_from_done.wait()
This should pass any check for a debugger, even searches for INT3, except a full check that the code hasn't been edited.
Calling your script with pwntools arguments NOPTRACE
or REMOTE
will allow you to disable all actions related to gdb and test your exploit locally or attack the remote target without having to comment anything. If you want a finer control you can use ìf dbg.debugging
to discriminate the code that should be executed when gdb is opened or not.
Note
Debugger can also take as parameter a dictionary for the environment variables. You CAN use it to preload libraries, but if you want to do it for the libc I would advise to patch the rpath of the binary instead (if you don't know how take a look at spwn or pwninit. This will prevent problems when running system("/bin/sh")
that will fail due to LD_PRELOAD and may hide other problems in your exploit.
Warning
Old versions of gdbserver (< 11.0.50) have problems launching 32bit binaries. If you see a crash trying to find the canary use from_start=False
as parameter for the debugger. This will launch the process and then attach to it once the memory has been correctly mapped. Letting from_start=True
may also cause problems if the environment variables are important to your exploit since they will be mixed with some set up by gdbserver.
Control Flow
The main actions for gdb are already wrapped in individual methods. For all commands not present you can reconstruct them by calling Debugger.execute(<command>)
as if you where using gdb. if your command will require you to call Debugger.wait()
make sure to use Debugger.execute_action(<command>)
instead.
dbg.jump() # set instruction pointer to specific address
dbg.step() # Single instruction (will enter function calls)
dbg.next() # Next instruction (will jump over function calls)
dbg.cont() # Continue execution
dbg.continue_until() # Continue until you reach a specific location
dbg.finish() # Finish current function
dbg.interrupt() # Stop the execution of your process
dbg.wait() # Wait until you have control of gdb
In the cases where you have to interact with the process before reaching the address you want you can use wait=False.
...
dbg.breakpoint("main+0x12", temporary=True)
done = dbg.continue_until("main+0x43", wait=False)
dbg.p.recvline()
dbg.p.sendline(b"4")
# Here the script will wait until you reach the offset 0x43 from main while gdb will break at offset 0x12 to let you analyse the process manually in gdb
done.wait()
...
Warning
finish
can only work if the stack frame hasn't been corrupted. With libdebug it will fail if the function doesn't use the base pointer.
If the function modifies itself you may find yourself unable to set breakpoints where you want. To analyse these function we can run them step by step
def MyCallback(dbg):
if dbg.next_inst.mnemonic == "int3":
dbg.step()
dbg.signal("SIGINT")
print(dbg.next_inst.toString())
dbg.step_until_ret(MyCallback)
In this example at each step the callback will be executed decrypting the next function chunk by chunk and logging the instructions. See this example for more details solving a real challenge.
You could also use dbg.step_until_address(<address>, <callback=None>)
if you just want to execute a limited area of code or dbg.step_until_condition(<check>)
if you are not sure where to stop.
Breakpoints
Breakpoints have three main features:
- if the address is smaller than the size of the binary the address will immediately be interpreted as relative for PIE binaries.
- you can use a symbol name instead of an address such as
"main"
or"main+0x12"
(so far only supported for binary and libc). - you can set callbacks to be executed when the breakpoint is reach and may choose to let the process continue after the execution.
- If multiple callbacks are set on the same address they will be executed in a LIFO order.
The callback is a function that takes the debugger as parameter and returns a boolean to tell gdb if it should stop or not. The callbacks shouldn't be limited in what you can code. If you find problems raise an issue. If you want to pass data from your callback function to your exploit you can use pointers (lists, dictionaries or queues)
Note
Setting a breakpoint requires the process to be interrupted.
from gdb_plus import *
from queue import Queue
# I let the process run in this example to reinforce the need for the interrupt later
gdbinit = """
handle SIGALRM nopass
"""
dbg = Debugger("./challenge", script=gdbinit).remote("leet.pwn.com", 31337)
pointer = Queue()
# step over an hypothetical call to a function, overwrite the return value and save it in a queue
def MyCallback(dbg):
dbg.next()
log.info(f"I tried to return {dbg.rax}")
pointer.put(dbg.rax)
dbg.rax = 0
# Return False to let the process run after executing the callback
return False
def SecondCallback(dbg):
log.info("now you can play around with gdb")
log.info("once you are done execute continue in gdb and your script will resume once we reach main+0x534")
# Return True to interrupt the process
return True
# You can not set a breakpoint while the process is running
dbg.breakpoint("main+0x42", callback=MyCallback, temporary = True)
dbg.breakpoint("main+0x124", callback=SecondCallback)
dbg.continue_until("main+0x534")
# Read the data set by the breakpoint
print(pointer_to_secret.get())
dbg.delete_breakpoint("main+0x124")
Warning
You can script anything inside your callbacks, but be careful not to break the execution flow of your script. A putting a callback with finish()
inside a function you are stepping over with ni()
may cause problems. The alternative would be to set a second callback on the return_pointer of your function.
from gdb_plus import *
from queue import Queue
# I let the process run in this example to reinforce the need for the interrupt later
gdbinit = """
handle SIGALRM nopass
"""
dbg = Debugger("./challenge", script=gdbinit).remote("leet.pwn.com", 31337)
# Does work, but you may lose control of your script if you try to step over a call to waitpid
def dangerous_callback(dbg):
status = dbg.args[1]
dbg.finish()
log.info(f"Child stopped with status : {hex(u32(dbg.read(status, 4)))}")
return False
def safe_callback(dbg):
status = dbg.args[1]
def second_callback(dbg):
log.info(f"Child stopped with status : {hex(u32(dbg.read(status, 4)))}")
return False
dbg.b(dbg.return_pointer, callback=second_callback, temporary=True)
return False
dbg.b("waitpid", callback=safe_callback)
You can allocate chunks on the heap (or the bss if you don't have the libc), write and read in the ram and read the canary from anywhere.
pointer = dbg.alloc(8)
dbg.write(pointer, p64(0xdeadbeef))
secret = dbg.read(dbg.elf.symbols["secret"], 0x10)
canary = dbg.canary
Note
While you can access the registers only when the process is at a stop, remember that you can read and write on the memory at any time
We can also use capstone to know what is the next instruction that will be executed
print(dbg.next_inst.toString()) # "mov rax, r12"
print(dbg.next_inst.mnemonic) # "mov"
Fork
You can set the debugger to spwn a new instance of gdb every time the process calls fork with dbg.set_split_on_fork()
. The child will stop as soon as the process is created by fork, but will wait for the debugger to stop before creating the new object. To allow gdb to attach to the second process though you need to set the ptrace scope to 0 ($sudo nano /etc/sysctl.d/10-ptrace.conf
).
dbg = Debugger("http_server").set_split_on_fork()
done = dbg.continue_until(0x40233)
dbg.p.sendline("input")
done.wait()
# Now that the process stopped at address 0x40233 a new debugger will attach to the child
pid_child = dbg.wait_split()
# all children have their debugger saved in dbg.children and can be accessed by the pid
child = dbg.children[pid_child]
If the program traces its child to make sure you aren't debugging it or to unpack a region of code, you should be able to emulate the calls to ptrace.
child.emulate_ptrace_slave(dbg)
dbg.emulate_ptrace_master(child)
This will interrupt the process at every call to waitpid for the master and SIGSTOP or INT3 for the child. You have to handle yourself when to let each one of them continue while you debug them. To help you a bit we print "Slave can continue" every time the tracer tries to send a continue to the tracee
Warning
- If the tracee stopped with a SIGSTOP gdb may bug a bit and you may need
force=True
to make it continue correctly - pwndbg can not handle multi-process applications and this section is only possible in native gdb or with GEF
- Handling multiple processes in the same debugger instead of splitting them may cause problems with the waits
Call functions
If you want to test the effects of a specific function you can directly call it with the parameters you want
pointer = dbg.alloc(100)
# Initialize data
dbg.write(pointer, bytes([i for i in range(100)]))
dbg.call(dbg.p.symbols["obfuscated_pbox"], [pointer, "user_1", 1])
dbg.read(pointer, 100)
See this example for more details
Note
You can pass parameters as strings or byte_arrays. By default they will be saved on the heap with a null terminator in the case of a string. If you can't use the heap set heap=False
Warning
If the stack frame has been corrupted finish() may not work. If this is the case set last address of your function in call(..., end_pointer= ...)
.
Ltrace, Ftrace and one day strace too.
ltrace is a useful program to get an idea of what your process is doing, but unfortunately it can not work if the process traces itself. Since we can emulate ptrace we can also offer an equivalent to ltrace that works even in that case.
from gdb_plus import *
dbg = Debugger(...)
# dbg.emulate_ptrace() # If needed
dbg.set_ltrace()
...
Adding set_ltrace() to your script will make it print all the library functions called by the process during the execution
If you only want to analyze a specific section of the binary you can also enable it for just that part.
from gdb_plus import *
dbg = Debugger(...)
dbg.until(target_function)
dbg.set_ltrace()
dbg.finish()
dbg.disable_ltrace()
This case would only log the functions called during the execution of the target function.
Similar to ltrace that logs the library functions using gdb allows us to also define an ftrace function that would log all functions from the binary.
Since some functions may be recursive or not use the return instruction at the end of the function, calling finish on each function to also log the return value may break the debugger. In that case add the argument no_return = True
to the call to set_ftrace()
.
from gdb_plus import *
dbg = Debugger(...)
dbg.set_ftrace() # dbg.set_ftrace(no_return = True)
dbg.c()
Tracing some basic function may be just a waste of time since the debugger has to stop every time they are called, so to improve the performances you can set the parameter exclude
with the functions you don't want to break onto.
from gdb_plus import *
dbg = Debugger(...)
dbg.set_ltrace(exclude=["malloc", "free", "strlen"])
dbg.set_ftrace(exclude=["FUN_126a", "FUN_13f1"])
dbg.c()
Libdebug
By default GDB+ uses a gdbserver to debug the process. This is very useful when you also have to check manually gdb while you are writing a script, but can be very slow. For this reason we now support libdebug (from version >= 0.4) as an alternative debugger. It is lacking a lot of features but can do the job for most tasks and it can be 50 times faster. NOTE libdebug has been refactored in october 2023. The new version doesn't allow yet the same control over events breaking a lot of features. We are therefore using the legacy version for now.
The debugger will always start with dbg, but you can switch back and forth
dbg = Debugger(<binary>)
# switch to libdebug
dbg.migrate(libdebug=True)
# switch to gdb
dbg.migrate(gdb=True)
To access properties of libdebug that haven't been wrapped you can simply use dbg.libdebug
as you would with dbg.gdb
.
When you switch the breakpoints and callbacks will be preserved except for those needed to emulate ptrace. Please migrate to libdebug before setting the emulator up or disable it and set it up again right after.
Since libdebug_legacy isn't on PyPI yet we could't include it in the dependencies.
You can install it manually:
pip3 install git+https://github.com/Angelo942/libdebug.git
QEMU
GDB+ can debug processes running under QEMU. The current supported architectures are arm and riscv, both 32 and 64 bits.
The problem running in qemu is that the base address may be off, we can't identify when the process forks and we can't attach to a running process. This limits the features we can use, but the rest is working fine.
If your binary requires system libraries, look at the (instructions)[https://docs.pwntools.com/en/stable/qemu.html#telling-qemu-where-libraries-are] from pwntools. The main idea will be to download the libraries for cross compilation sudo apt-get install libc6-<arch>-cross
and then copy them to QEMU sudo ln -s /usr/<arch>-linux-gnu /etc/qemu-binfmt/<arch>
.
Note
- don't forget to install qemu and gdb-multiarch.
sudo apt install qemu-user gdb-multiarch
- set context.arch == ... at the beginning of your script to make sure you are debugging your binary and not QEMU itself
- pwndbg may be better than GEF when using qemu.
TODO
- Improve ptrace emulation
- handle waitpid(-1) with multiple slaves
- emulate waitid too
- support multithread applications
- catch sigsegv as an exit instead of user interaction
- enable signal() with libdebug
- migrate to new version of libdebug
- force parent or child to stop tracing
- wrap follow-child
- specify relative addresses
- hide internal breakpoints also from gdb
- implement strace
- allow access to float registers or aliases
- write a decent documentation
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file gdb_plus-7.5.0.tar.gz
.
File metadata
- Download URL: gdb_plus-7.5.0.tar.gz
- Upload date:
- Size: 97.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.0.1 CPython/3.10.12
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 |
2ac23f3fed6406b455a0f37e4dd930cc521984b3281ad71ae32a0be0842d875f
|
|
MD5 |
a20178c126214beae2c3a52c943d2a76
|
|
BLAKE2b-256 |
445718cfeb7197910596b2886b77ef918b60026464a0c61e2199d68de56e818f
|
File details
Details for the file gdb_plus-7.5.0-py3-none-any.whl
.
File metadata
- Download URL: gdb_plus-7.5.0-py3-none-any.whl
- Upload date:
- Size: 85.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.0.1 CPython/3.10.12
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 |
beb09f26997c795e5ed80c9ff80b742a5d044c55bc2c813be39d1c0d8852735e
|
|
MD5 |
8dc8d1e0f78d24de3f3f98c7f17a27c0
|
|
BLAKE2b-256 |
06151758a4536b3671e67a84632f1f6fdbafdf72da653dfaeebcfdb031df06de
|