.noise
welcome_individual (friendly)
Today we are looking into GDB, the Gnu Debugger. Gdb is relatively old software and good documented, but it can be a hassle sometimes, especially if you never really worked with it. So you might ask “Where do i even start?” - and that’s exactly where we’ll start.
At the time of this writing, Gdb’s current version number is 16.2. The first version of this software was released way back in 1986, and it features a whole bunch of languages, with Rust as it’s latest addition. Here is an overview of the stuff we can debug with gdb:
Ada
Assembly
C
C++
D
Fortran
Go
Objective-C
OpenCL
Modula-2
Pascal
Rust
We will look into an example c++ file here and try to patch the binary post-compilation. As a matter of fact, the files we are debugging with gdb are all in the same format, namely ELF (if we are on Linux or MacOS) or PE (on windows). This means that the bytecode we’re examining is always just as good as the compiler producing it. This also means that C language can produce the same (insecure) assembly that Rust would produce, in the end these are all ELF files, security measures have to be taken at top- or compiler-level.
This article builds up on my ELF article, so you might want to start reading there. Also, this article contains many images, because it’s better to show console in- and output directly imho.
I’m in the mood for some TripHop today, like rainy stoned Sunday morning vibes. Hope you’ll like it
Getting started
At first, we have to create an example program. I chose to include some global and local variables which we will hopefully find in the disassembly and on the stack frames later on.
#include <stdio.h>
int someVal = 44;
void secret(int rndVal){
int locale = rndVal;
fprintf(stdout, "this is a secret function\n");
}
void regular(int stackVar){
int local = stackVar;
fprintf(stdout, "this is a regular function\n");
}
int main(){
int localA = 1, localB = 2, localC = 3;
fprintf(stdout, "before call\n");
regular(localA);
fprintf(stdout, "after call\n");
return 0;
}
After we produced a binary and can load it, it’s time to actually load it into gdb
We just type gdb, then in the gdb prompt we can load the binary via [ file testProg ]. Most binaries won’t have debug symbols attached, so that’s ok. These binaries are called stripped binaries. As an alternative, you can just type [ gdb progName ].
The first thing we always want to do is setting a disassembly-flavor. I know you can set up gdb to always use a specific flavor, but this is meant to be a step-by-step tutorial.
So let’s just go ahead and [ set disassembly-flavor intel ] - unless you are familiar with AT&T syntax, I would highly recommend using intel syntax.
We aren’t interested in constructors or the start_ function, so let’s look at main first with the command [ disass main ]
We can see that after the function prologue, we are writing immediate values 1, 2 and 3 into memory addressed via base pointer [ rbp ]. Since these are local variables, they will live inside the main function’s stack frame, so let’s take a look at them.
Setting breaks to examine the stack frame
Our program isn’t running yet, so let’s take a moment and set up some break points.
The most common breakpoint in gdb would probably be [ b main ] - b short for break. This will halt the program at memory location 0x00000000000011bf, or [ main + 0 ]. Our local variables are placed in the stackframe on address …11cb to …11d9, so it’s smart to break after these.
Since I don’t like to type out or copy around large memory addresses all the time, we will use a pointer to reference address 0x…11e0 and break at mov rax,QWORD PTR [rip+0x2e31].
We can do this with [ b *(main+33) ] - setting a breakpoint relative to the function entry. After that, we can run to the break points by typing [ run ] and then, when we reach main’s entry point, typing [ continue ].
Now we can take a look at the stack variables with [ info locals ] and - bummer! We don’t have any debug symbols loaded.
What to do? We will have to print out the memory our self. First, let’s find out where our current stack frame is in memory with [ info frame ]. This will give us some information about various registers, pushed values on the stack etc. - and also, our locals. With this information, we can print out the memory directly.
In 64 bit systems, local variables are placed 8 byte below the current rbp. Our return address is 8 byte above our rbp, because the stack grows towards lower addresses. So if we want to examine 4 × 8 byte above the rbp, we would need to start printing memory at [ 0x…dda0 - 0×20 = 0x…dd80 ]. We can ignore the return address of main, because it’s not part of the actual program we wrote and handled by the OS.
But does this really mean that our local variables take up 8 bytes on the stack? Let’s examine the memory area using 8 byte big printouts, starting at …dd80.
This doesn’t look right. We can see a 1 in the upper half of the first doubleword, and numbers 2 and 3 clobbered together into the second one…. Something’s off here.
Let’s look at this memory section again, this time scoping to 4 byte long words. This seems counterintuitive at first, given we’re on a 64 bit machine. BUT! Remember which type of variables we declared for our numbers ..
There are our values, as expected.
If we paid better attention to the disassembly part of main, we’d found this out earlier:
The offset from base pointer are c, 8 and 4, meaning these variables are treated as words. Another hint on this is the use of DWORD PTR, meaning 4 byte long pointers. If we recompile this code using short instead of int, we suddenly get this output:
So unsurprisingly the type of variable used determines the size in memory. I highly doubt that the inventors of x86_64 expected us to always use long as standard datatype. Instead, if you want to specify how much memory your variables take in memory, use fixed size integers, aka uint64_t, uint32_t etc.
Although this requires the additional header cstdio, it suddenly makes our code platform-independent! One big mistake of newish coders is to assume that their system is peak and will never change. But what about exotic systems like solaris, or even raspberry pi? Never assume that an int is 32 bit set in stone!
Next, let’s have some fun with this (๑>◡<๑)
Overriding output in memory
Let’s start with fiddling around with the output of our program. Before we do a call to fwrite@got, which prints to console, we load the address of what we actually want to print:
If we examine this address, we will find out that here, indeed, lies our ASCII output text:
The [ 0×00 ] is our string terminator, usually this is \0. The last 3 byte in the picture are the beginning of the next string, so our total string is 13-1 = 12 byte long.
Now we can just use this space up and write something different there. You can write to a memory location via [ set {int}0x83040 = 4 ], in this case for an integer sized value. BUT! This is very cumbersome, so an easy way is just to write a char array with a string at the memory location:
This way, we can insert strings, shellcode, values, you name it. If we wanted to insert doublewords, we could write [ set {int[4]}0x55555555603b = {1,2,3,4} ].
An easy option is to write something like set {char[4]}*(main+44) = {0×14,0xa,0×15,0xb} and directly insert bytes (aka OPCodes).
Of course, this would destroy our string, so it doesn’t make any sense here.
Patching jump addresses for fun and profit
Finding a target
We found the stack frame and set a breakpoint inside main. If you where following up until this point, you should have a program counter at 0x00005555555551e0, right after we initialize our local variables. As a little reminder, here’s what we’ve done already:
gdb testProg
set disassembly-flavor intel
b main
b *(main+33)
run
continue
disass main
info frame
x/8w 0x7fffffffdd90
So far, so good. Next in our program is a fprintf statement, followed by a call to our function [ regular ]. Let’s analyze these calls for a moment:
We can see that the second call to [ regular ] is actually really near to our current position. Let’s have a look at 20 instructions, again showing them directly in memory at the given location:
Interestingly, we can see that this function is placed directly above main. You could achieve the same with [ info regular ], but let’s just assume we wouldn’t know anything about this binary. So let’s move up by 0×40 and print out code again:
Bingo! We found the [ secret function ], right before [ regular ]. Now, with this info, we can try to jump to this function. But where to start? After all, we need to write OPCode!
Patching memory
First, let’s take a quick look at the place where we call regular. Remember, it was in [ main+73 ] and also it is 5 byte long, since the next instruction starts at [ main + 78 ]. Let’s look directly at the bytes for this address:
That’s interesting. I wonder why all the 0xff are placed there. After all, [ 0×77FFFFFF ] doesn’t seem like the address we want to jump to, this should be 0×55 .. 5149 ! Let’s take a look at the call instruction:
As we can see, the OPCode starts with [ 0xe8 ], and the description tells us to “Call near, relative, displacement relative to next instruction. 32-bit displacement sign extended to 64-bits in 64-bit mode”.
But what does this even mean??? Let’s do quick math: What is [ 0×77FFFFFF ] in decimal? The answer is [ -137 ]. A displacement can point backwards, after all! The number 137 is 0×89, and if we subtract this from the next address after the call, we get 000055555555520d – 89 = 555555555184, the entry to [ regular ].
Entry point | Displacement from *(main+74) | In hexadecimal |
main | - 74 byte | 0xB6FFFFFF |
regular | - 137 byte | 0×77FFFFFF |
secret | - 196 byte | 0×3CFFFFFF |
Our displacement should be [ 3DFFFFFF ]. The number 196 is 0xC4 in hex, that gives us a calculation of 000055555555520d – C4 = 555555555149, the entry of [ secret ]. Chacka! Now we have recalculated the operands and can insert them into our binary the same way we inserted a string:
[ EzClap ]
Patching ELF file
Now, how can we make this patch permanent? Normally, you’d have to either open the binary with a hex-editor or write a patcher program which would fix the bytes in-file. Alternatively, you could use a more “professional” tool like GHidra.
Remember that we just patched the running binary. We would probably do this if we found an exploit somewhere and gained writing access to the process memory, for example with a Buffer overflow. To make the change permanent at file-level, we have to load the binary before it is run:
We can see here that the addresses have not yet been calculated, so we just find our code in the .code section at location 0x..1208. Let’s edit it with a hex editor
Fun fact: The hardest part on writing this article was googling “How to draw a straight line in GIMP”.
Afterwards, we have patched and cracked our example program:
A word on endbr64
If you followed along, you might have wondered why functions start with this ominous [ endbr64 ] instruction. This isn’t part of a normal function prologue, after all. As long as we are in legacy land, this compiles to just another [ nop ] instruction, although it’s 4 byte long. Waste of a few bytes but no further meaning.
However, in 64 bit mode, this means End Branch 64 bit. Whenever we do an indirect jump or call in our program, the processor sets a so called TRACKER-flag. If this flag isn’t cleared by the endpr64 instruction, then a #CP exception is raised. This means that if an attacker managed to bend a pointer in our program or overrode a displacement like we just did, and they didn’t jump to the beginning of a function, the program would most likely crash. That’s the reason why we didn’t jump directly to the function prologue, but rather to the endpr64 instruction.
If we wanted to jump directly to a piece of code, f.e. to code we inserted into memory, we would have to encode an endpr64 there (f.e. in a code cave), or use a direct jump to an absolute address.
Note that there is also a 32 bit version of this. It is, however, depending on compiler and OS version if this technique is invoked at all.
Epilogue
I hope you could follow along and learned something from this article. Congratz if you made it to the end since it’s rather long.
Lastly, here are some links that helped me calculate the addresses:
Integer encoder (remember to switch to 32 bit signed integer)
Also, props to Felix Cloutier’s x86 documentation.
That’s it, see you next time around