In this post, we’re going to dissect a very simple challenge from Hack the Box, “Behind the Scenes”. We’ll also look at how to work with Unix signals and how to skip illegal instructions in executables. Buckle up!
Cracking the challenge
First of all, let’s try running the challenge executable.
The program expects a single argument with a password. Neat. Running strings
over the executable haven’t yielded anything interesting, so let’s look at its internals with Ghidra.
Looking at the main
function, we can see some Unix signals trickery at the beginning, and the endless loop with the invalidInstructionException();
in the end.
Let’s begin with the latter. While selecting the invalidInstructionException();
instruction in the decompilation, Ghidra highlights the following assembly instruction:
001012e6 0f 0b UD2
Referring to IntelĀ® 64 and IA-32 Architectures Software Developer Manual, Vol. 2B, UD2
“generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcodes for this instruction are reserved for this purpose.”
Ghidra stops disassembling the file after the first occurrence of UD2
. It makes total sense, by default the whole program will be terminated due to receiving a SIGILL
Unix signal. However, there’s clearly more code after that instruction:
So, first of all, let’s select the rest of the code that hasn’t been disassembled and disassemble it by pressing “D”. As we can see, it’s indeed the executable code:
Next, let’s replace all occurrences of the UD2
instruction with a NOP
(there are a few dozens of them scattered across the main
function). After doing so, we can immediately see that there are 4 strcmp
s comparing the user input with string constants. And the flag seems to be a concatenation of these constants:
By inputting the concatenated value into the program, we can verify that it’s the correct flag. Easy, huh?
But how does it work?
That’s a good question, let’s figure it out.
As I’ve mentioned earlier, the operating system sends a SIGILL
signal to the program trying to execute an illegal instruction. But can we skip the illegal instruction and continue the execution? The answer is yes, and that’s what the program does by registering a custom signal handler.
Installing a signal handler
Let’s take a look at the decompiled fragment of the main
once again:
Let’s break it down:
- Line 6, a property of type
sigaction
is defined. For convenience, I renamed it tosa
; - Line 10,
sa
gets filled with zeroes to clear the allocated memory. We can make an educated guess that initially, it looked likememset(&sa, 0, sizeof(struct sigaction))
, but the compiler was able to replace thesizeof
call with a constant value; - Line 11,
sa.sa_mask
is cleared out by calling thesigemptyset
function; - Line 12, a pointer to the local signal handler function
segill_sigaction
is assigned to thesa.sa_sigaction
(named by Ghidra assa.__sigaction_handler
); - Line 13, the value of
SA_SIGINFO
flag is assigned to thesa.sa_flags
(#define SA_SIGINFO 0x00000004
insignal.h
); - Line 14, the constructed
sa
structure is assigned as a handler for aSIGILL
signal (#define SIGILL 4
insignal.h
) by calling thesigaction
function (yes, the structure and the function are named identically).
Handling the received signal
The next piece of the puzzle is the signal handler function, segill_sigaction
in our case.
The signature of the handler function is void handler(int sig, siginfo_t *info, void *ucontext)
. As sigaction(2) page states, these three arguments are as follows:
- int sig: The number of the signal that caused invocation of the handler;
- siginfo_t *info: A pointer to a
siginfo_t
, which is a structure containing further information about the signal; - void *ucontext: This is a pointer to a
ucontext_t
structure, cast tovoid *
. The structure pointed to by this field contains signal context information that was saved on the user-space stack by the kernel.
Page sigaction(3p) also states that new applications should explicitly cast the third argument of the signal handling function to ucontext_t *
.
Let’s retype the 3rd parameter and take a look at the decompilation:
First of all, let’s take a look at the ucontext_t
structure contents. As The GNU C Library Reference Manual states, it should include at least the following fields:
- ucontext_t *uc_link: This is a pointer to the next context structure which is used if the context described in the current structure returns;
- sigset_t uc_sigmask: Set of signals which are blocked when this context is used;
- stack_t uc_stack: Stack used for this context. The value need not be (and normally is not) the stack pointer;
- mcontext_t uc_mcontext: This element contains the actual state of the process. The
mcontext_t
type is also defined in this header but the definition should be treated as opaque. Any use of knowledge of the type makes applications less portable.
Aha, we’re getting closer. Let’s take a look at the excerpt from the glibc
sources for the x86-64 architecture containing the mcontext_t
definition and some of its nested data types:
/* Context to describe the whole processor state. */
typedef struct
{
gregset_t __ctx(gregs);
fpregset_t __ctx(fpregs);
__extension__ unsigned long long __reserved1 [8];
} mcontext_t;
/* Container for all general registers. */
typedef greg_t gregset_t[__NGREG];
/* Number of each register in the `gregset_t' array. */
enum
{
REG_R8 = 0,
# define REG_R8 REG_R8
REG_R9,
# define REG_R9 REG_R9
REG_R10,
# define REG_R10 REG_R10
REG_R11,
# define REG_R11 REG_R11
REG_R12,
# define REG_R12 REG_R12
REG_R13,
# define REG_R13 REG_R13
REG_R14,
# define REG_R14 REG_R14
REG_R15,
# define REG_R15 REG_R15
REG_RDI,
# define REG_RDI REG_RDI
REG_RSI,
# define REG_RSI REG_RSI
REG_RBP,
# define REG_RBP REG_RBP
REG_RBX,
# define REG_RBX REG_RBX
REG_RDX,
# define REG_RDX REG_RDX
REG_RAX,
# define REG_RAX REG_RAX
REG_RCX,
# define REG_RCX REG_RCX
REG_RSP,
# define REG_RSP REG_RSP
REG_RIP,
# define REG_RIP REG_RIP
REG_EFL,
# define REG_EFL REG_EFL
REG_CSGSFS,
# define REG_CSGSFS REG_CSGSFS
REG_ERR,
# define REG_ERR REG_ERR
REG_TRAPNO,
# define REG_TRAPNO REG_TRAPNO
REG_OLDMASK,
# define REG_OLDMASK REG_OLDMASK
REG_CR2
# define REG_CR2 REG_CR2
};
As we can see, the field gregs
contains all the saved CPU registers.
At this point, let’s refer back to sigaction(3p) that states that “when the signal handler returns, the receiving thread resumes execution at the point it was interrupted unless the signal handler makes other arrangements”. In our case, we deliberately want to resume the execution after skipping the invalid instruction. Since the UD2
operation is 2 bytes long, we can achieve that by incrementing the RIP
register by 2.
In C, it would look like this:
ucontext->uc_mcontext.gregs[REG_RIP] += 2;
Important note: this solution is quite hacky and tied up to run strictly on x86-64 POSIX-compliant systems. Don’t do this in any production code.
The only possible exception is if you deliberately trying to do some bizarre obfuscation, this can be one of your tools - since you have the access to all the saved context, it’s possible to change the values of the general-purpose registers, jump to another address by modifying the instruction pointer, and do another weird stuff (:
Compare it with what the Ghidra decompiled. Looks quite similar, isn’t it? :)
The only difference is that Ghidra wasn’t able to figure out the name of the register and just provided its index in the array, 0x10
. We can manually verify that it’s the correct index of the REG_RIP
. Alternatively, we can print the index of REG_RIP
by running this snippet of code, which in turn prints 0x10
:
#define __USE_GNU
#define _GNU_SOURCE
#include <stddef.h>
#include <stdio.h>
#include <ucontext.h>
void main() {
printf("0x%x\n", REG_RIP);
}
Another way of doing that is to find the offset in the whole ucontext_t
structure. Imagine that we only have the assembly listing of the function, which looks like this:
After some argument shuffling, the address of the ucontext
gets stored in the local_ucontext
variable. Its value at the offset 0xa8
is loaded into the RAX
, incremented by 2, and written back into the structure.
For the reference, function decompilation looks like this:
By making an educated guess we assume that the function increments the RIP
register. We can find it’s offset in the structure by running the following snippet of code:
#define __USE_GNU
#define _GNU_SOURCE
#include <stddef.h>
#include <stdio.h>
#include <ucontext.h>
void main() {
size_t rip_offset = offsetof(ucontext_t, uc_mcontext.gregs[REG_RIP]);
printf("0x%x\n", rip_offset);
}
It prints 0xa8
, which matches the offset in both the assembly listing and the decompilation.
Combining it all together
Now let’s combine all the pieces of the puzzle together. Here’s a simple program which conceptually works the same as the challenge binary:
#include <stdio.h>
#define __USE_GNU
#include <signal.h>
#include <string.h>
void sighandler (int signo, siginfo_t *info, void *context) {
ucontext_t *uc = (ucontext_t *)context;
int instruction_length = 2; // The length of the "instruction" to skip
uc->uc_mcontext.gregs[REG_RIP] += instruction_length;
}
void main() {
struct sigaction sa;
memset(&sa, 0, sizeof(struct sigaction));
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = sighandler;
sigaction(SIGILL, &sa, NULL);
printf("%s", "Reachable\n");
asm("ud2"); // Adding an illegal opcode
printf("%s", "Unreachable\n");
}
All in all, the resulting code is quite similar to the accepted answer to this question on Stack Overflow.
After compiling and executing it prints the following:
Comparing the binaries
In conclusion, let’s compare the decompilation of our recreated binary (on the left, compiled with gcc (GCC) 12.1.0
) and the one from the challenge (on the right):
It’s surprising how similar they are, and how close the decompilation is to the source code!
That’s it for today. Thanks for reading, and happy hacking!