首页
社区
课程
招聘
[原创]某安卓FPS外挂逆向:自解压ELF脱壳到内核硬件断点(上)
发表于: 21小时前 624

[原创]某安卓FPS外挂逆向:自解压ELF脱壳到内核硬件断点(上)

21小时前
624

前言

本文分析的是某款Android FPS游戏的外挂,该外挂在/data/local/tmp/ 下独立运行,采用了自解压ELF壳,并配合内核驱动挂载硬件断点篡改寄存器,从而实现了FPS游戏常规的底层功能(如无后座 范围)
由于涉及自解压脱壳、驱动通信和硬件断点等多个技术环节,文章分为二篇连载:
(上)自解压ELF分析于与脱壳(本篇)
(下)硬件断点与寄存器修改分析

样本信息

图片描述
路径:/data/local/tmp/文件/exe
架构:64-bit ARM64
类型:ELF shared object
编译:NDK r30(壳子r30脱壳发现本体r17)
符号:stripped

分析文件整体

  • 先运行文件 (pid=15526)
  • 图片描述
  • 然后打开 proc/15526/maps
  • 图片描述
  • 可以看到地址 617c680000就是我们要dump的起始地址,可以看到617c6cf000就是我们要的尾部地址,那个780009500看权限和地址明显不是我们要的
/memdumper64 -i 15526 -n dump文件 -l -r -m -s 617c680000 -e 617c6cf000 -o ./

有字符串了
图片描述
把这个dump的文件放到ida反编译看看
图片描述
可以看到字符串都有调用 如果只是分析目标驱动通信和硬件断点就可以直接分析了 不过我为了学习还是去看看这个是什么壳

分析壳脱壳

把样本用ida反编译 找到入口
图片描述
图片描述
壳子没有混淆,明显自解压壳子 拿到自解压ELF第一反应都是改过linker,它竟然没有魔改

证据一:.interp 原文(文件 0x2E0):

/system/bin/linker64

标准 NDK 产物 真正的linker壳会把它换成自己的 ld-xxx.so,或者删掉 .interp 自己 mmap 但是这里什么都没做
证据二:_start是 bionic 的标准骨架(0xB1CC):

0xB1CC  bti     j
0xB1D8  mov     x0, sp              ; raw_args
0xB1F0  adrp    x8, #0x20000
0xB1FC  ldr     x8, [x8, #0xb20]    ; __init_array_start
0xB200  ldr     x9, [x9, #0xb28]    ; __init_array_end
0xB21C  adr     x8, #0x24d88        ; structors
0xB220  str     x8, [sp, #0x10]
0xB230  ldr     x2, [x2, #0xb30]    ; x2 = 0xB28C
0xB234  bl      #0x1C290           ; __libc_init(raw_args, 0, 0xB28C, structors)

壳只改了一个地方——__libc_init 的第三个参数

证据三:那两个 GOT 槽不是 R_AARCH64_RELATIVE 这是我一开始判错的地方
0x20B30 = 0xB28C、0x20B38 = 0x43F0,看着像经典的 RELATIVE 重定位 但外壳的 .rela.dyn 只有 3 条

0x207C0  type=0x101 (ABS64)      memset
0x20B40  type=0x401 (GLOB_DAT)   environ
0x20B70  type=0x401 (GLOB_DAT)   __sF

真正给这两个槽打值的是 DT_RELR——.relr.dyn 在 0xDA8,48 字节,展开后覆盖 91 个地址,0x20B30 和 0x20B38 都在里面

RELR 是 021年后才铺开的压缩相对重定位:一串 uint64偶数项是绝对地址,奇数项是 63 位的位图,描述"从上一个地址往后每 8 字节一个" bionic从API 30开始支持 用它说明这份壳是用比较新的NDK链的
结论:没有魔改 linker 也没有劫持 .dynamic 没有伪造 DT_INIT 壳就是一段普通用户态代码

1.1 壳入口在干什么

0xB28C 开栈 0x11A80 然后第一件事不是解密 是检查环境:

0xB28C  stp     x29, x30, [sp, #-0x60]!
0xB2A8  sub     sp, sp, #0x11, lsl #12
0xB2AC  sub     sp, sp, #0xa80
0xB2C0  bl      #0xDA48              ; 环境白名单,返回 bool
0xB2C4  tbz     w0, #0, #0xB3E4      ; 不合格直接跳去报错

过了就先把整个配置区搬到栈上再验:

0xB2D4  add     x0, x0, #0x598
0xB2DC  mov     w2, #0x4d0            ; 栈上留 0x4D0
0xB2EC  bl      #0x1C2A0             ; memset
0xB2F8  ldr     x8, [x8, #0xb38]      ; x8 = .shell_config (0x43F0)
0xB2FC  ldrb    w10, [x8, x9]         ; 逐字节搬 0x490 次
0xB300  strb    w10, [x27, x9]
0xB30C  b.ne    #0xB2FC

为什么先搬到栈上? 因为后面所有校验都对着栈上那份副本做,不信任可写段,先做快照,再校验快照 改 .shell_config 这种静态补丁 在它眼里是同一个进程内存里的两个副本不一致

然后是 magic 这里没有 memcmp:

0xB310  mov     x9, #0xa3e5
0xB31C  movk    x9, #0xe5b3, lsl #16
0xB320  movk    x9, #0x90ad, lsl #32   ; x9 = 0x90ADE5B3A3E5
0xB314  ldr     x8, [x28, #0x1050]     ; config[0x00..0x08]
0xB324  eor     x8, x8, x9
0xB328  orr     x8, x8, x10            ; 并上 config[0x08..0x0C]
0xB32C  cbnz    x8, #0xB4F0            ; 非零 = 失败

0x90ADE5B3A3E5 就是 壳子 六个字节 + 两个字节的 pad,拼成一个 48 位立即数。一次 8 字节比较 同时验了 magic 和 pad。0x08..0x0C 那 4 个保留字节必须为0

接着验版本号 0xB334 处 cmp w8, #0x4302——0x43 是 'C' 2 是主版本
再往下一段是这篇文章之前没写到的部分:

0xB344  ldr     w20, [x28, #0x10d0]    ; config[0x80] = ext_len
0xB348  cmp     w20, #0x400
0xB34C  b.hi    #0xB4F0                ; ext_len > 0x400 → 失败
0xB350  ...                            ; config[0x84..0x90] 必须全零
0xB3BC  add     x21, x8, #0x90         ; 扩展区从 config[0x90] 开始
0xB3C8  sub     w9, w8, w20            ; 待检查长度 = 0x400 - ext_len
0xB3D4  ...                            ; OR 归约,要求全零
0xB4E8  tst     w12, #0xff
0xB4EC  b.eq    #0xB548                ; 通过

所以 .shell_config 的真实结构是:

[0x000 .. 0x090)   固定头
[0x090 .. 0x090+ext_len)   扩展区,0x80 处的 ext_len 指定长度,上限 0x400
[0x090+ext_len .. 0x490)   必须全零

当前样本 ext_len = 0这是一个为后续版本预留的扩展位:壳的格式已经设计成加字段不用改解析器,只要新版本把 ext_len 抬起来就行了,反过来说,看到 ext_len != 0 就说明你手上是另一个版本
0x4D0 - 0x490 = 0x40,栈上多留的 0x40 是给运行时状态用的

2. 文件布局

先看段表和节表 这一节的数据全部来自 readelf 解析,因为后面所有偏移都从这里推

12 个phdr 其中只有 4 个 LOAD:

#  类型            权限  文件偏移    VA          filesz    memsz
 2  LOAD            R     0x000000   0x000000    0x071CC   0x071CC
 3  LOAD            RX    0x0071CC   0x00B1CC    0x11484   0x11484
 4  LOAD            RW    0x018680   0x020680    0x00708   0x00980
 5  LOAD            RW    0x018D88   0x024D88    0x00070   0x02B58

配四个 p_align = 0x4000 的LOAD,VA与文件偏移的换算就变成四段:

VA < 0x71CC          off = VA
0x71CC .. 0x18680    off = VA - 0x4000
0x18680 .. 0x18D88   off = VA - 0x8000
>= 0x24D88           off = VA - 0xC000

(第二段的上界其实是 0x1C650,第三段上界是 0x24D88)
29 个节:

.interp             0x0002E0  0x015
.note.android.ident 0x0002F8  0x098
.note.gnu.build-id  0x000390  0x024
.dynsym             0x0003B8  0x5E8   63 项
.gnu.version*       0x0009A0  0x0DE
.dynstr             0x000A9C  0x2BE
.rela.dyn           0x000D60  0x048   3 条
.relr.dyn           0x000DA8  0x030   48 字节 → 91 个地址
.rela.plt           0x000DD8  0x5A0   60 条
.gcc_except_table   0x001378  0x5C0
.rodata             0x001940  0x2AAC  ← 字符串、CRC 表、LD 白名单、KDF label
.shell_config       0x0043F0  0x490   ← 配置区
.eh_frame_hdr       0x004880  0x5E4
.eh_frame           0x004E68  0x2364
.text               0x0071CC  0x11098  VA 0xB1CC
.plt                0x018270  0x3E0    VA 0x1C270
.data.rel.ro        0x018680  0x2E0    VA 0x20680
.init_array         0x018960  0x010    VA 0x20960 → [0x1807C, 0x18328]
.dynamic            0x018970  0x1B0
.got                0x018B20  0x070
.got.plt            0x018B90  0x1F8
.relro_padding      0x018D88  0x278
.data               0x018D88  0x070    VA 0x24D88
.bss                0x018DF8  0x2AE0   NOBITS
.shstrtab           0x018DF8  0x125

真 .text 从 0x71CC 起 长度是 0x11098 够装一个完整的C++运行

**注意 .text 只有 0x11098 字节,而 .rodata 有 0x2AAC。**一个430KB的文件 代码只占68KB剩下的全是数据,这个比例本身就是信号

再看整个文件的宏观排布:

0x000000 .. 0x18F20   外壳本体(ELF 头 + 代码 + 数据 + 节表)
0x18F20 .. 0x19660    29 × 64 = 0x740 的节表,最后一节结束在 0x1965F
0x19660 .. 0x68E08    container(0x4F7A8)
0x68E08 .. 0x68EC8    footer(0xC0)

恰好:

  • 节表末尾 0x19660 正好是 container 起点
  • .shstrtab 结束在 0x18F1D,到 0x18F20 只有3字节填充
  • footer 占 0xC0,0x68E08 + 0xC0 = 0x68EC8正好文件尾

这不是巧合能凑出来的 说明壳是"最后一步append上去"的 原始 ELF 编译好,加密后的 payload 追加到文件尾,配置结构塞进原有的可写段,再补 shdr 和 footer 整个过程没有重新链接,所以外壳的段布局看着畸形——但是它必须让原文件的段表继续有效 又要在尾部腾地方

反证如果重链过,.text 不会只有68KB却带一整套libunwind和libc++abi的异常处理表

3. 三个结构体

三个结构体共用同一个6字节 magic靠 rev 的低8位区分类型 rev 在 0x0C,不是 0x08——0x08..0x0C 是 4 个必须为零的保留字节

3.1 .shell_config(0x43F0,0x490)

struct ShellConfig {                 // 文件 0x43F0
    char     magic[6];               // "壳子"
    uint16_t pad;                    // 0
    uint32_t reserved0;              // 0x08,必须为 0
    uint32_t rev;                    // 0x0C = 0x00004302  'C'
    uint8_t  pubkey[32];             // 0x10  Ed25519 公钥
    uint8_t  key_a[32];              // 0x30
    uint8_t  key_b[32];              // 0x50
    uint64_t cont_off;               // 0x70 = 0x19660
    uint64_t cont_len;               // 0x78 = 0x4F7A8
    uint32_t ext_len;                // 0x80,当前 0,上限 0x400
    uint32_t reserved1[3];           // 0x84,必须为 0
    uint8_t  ext[0x400];             // 0x90  扩展区,ext_len 有效
    // 0x490 结束,ext_len 之后必须全零
};

实测:

pubkey  afe1ec8f 5370d34d 2a57b385 fe50ebb6
        b2b33ebc 6f0ac417 223cd37d 94dae2ef
key_a   3edfccb9 56709adc 410ebf07 1bf6da1a
        01684868 07a9c083 79da74da c62cc6c7
key_b   c8ab354d f129ed48 52d6bb14 4bf6afaa
        5eea6f33 5313cbd3 4c7e2eaa 56cf572c

rev 的三个取值是配置 高字节是类型 低字节 0x02 是格式版本

3.2 container 头(0x19660 0xC0)

struct ContainerHeader {
    char     magic[6];
    uint16_t pad;
    uint32_t reserved0;              // 0
    uint32_t rev;                    // 0x0C = 0x4D02
    uint64_t payload_len;            // 0x10 = 0x4F5F8   明文字节数
    uint32_t chunk_size;             // 0x18 = 0x10000
    uint32_t chunk_cnt;              // 0x1C = 5
    uint8_t  manifest[32];           // 0x20  参与 KDF
    uint8_t  payload_sha[32];        // 0x40  明文 SHA-256
    uint8_t  header_sha[32];         // 0x60  文件 [0, 0x19660) 的 SHA-256
    uint8_t  sig[64];                // 0x80  Ed25519(container[0x00:0x80])
};

header_sha 的定义域是从文件开头到 container 起点,把 ELF 头、.text、.dynsym、.shell_config 全覆盖了

签名消息是 container[0x00:0x80],不是 header_sha[0:0x80]——这是两个不同的东西

3.3 chunk 头(每块 0x30)

struct ChunkHeader {
    uint32_t index;                  // 0..4,必须连续
    uint32_t length;                 // 0x10000,末块 0xF5F8
    uint8_t  iv[12];                 // GCM nonce
    uint8_t  tag[16];                // GCM tag
    uint32_t crc32c;                 // 0x24
    uint64_t crc64;                  // 0x28
};

4+4+12+16+4+8 = 0x30,无padding 密文紧跟

实测5块:

i  len      iv                            crc32c
0  0x10000  b1def6bb 994178b2 738f71b0    0xFEEAC3C9
1  0x10000  23059424 d97e32ad 4ac13a4d    0x65574245
2  0x10000  d4054165 e88a9d2b 5c5a2fae    0x0C325872
3  0x10000  d8476b68 1dfa9385 261fbd81    0x7DB3311B
4  0x0F5F8  d1fa5a7d 1a86decf 55d811d5    0x9276E0BC

0xC0 + 4×0x10030 + 0xF628 = 0x4F7A8与 cont_len 一致

3.4 footer(0x68E08,0xC0)

struct Footer {
    char     magic[6];
    uint16_t pad;
    uint32_t reserved0;
    uint32_t rev;                    // 0x0C = 0x4602
    uint64_t cont_off;               // 0x10 = 0x19660
    uint64_t cont_len;               // 0x18 = 0x4F7A8
    uint8_t  header_sha[32];         // 0x20  与 container[0x60] 相同
    uint8_t  container_sha[32];      // 0x40  container 整体 SHA-256
    uint32_t crc32c;                 // 0x60 = 0xBFA5593E
    uint64_t crc64;                  // 0x64 = 0x4389C48B937C2357   ← 注意非 8 字节对齐
    uint8_t  pad2[0x14];             // 0x6C..0x80
    uint8_t  sig[64];                // 0x80  Ed25519(footer[0x00:0x80])
};

crc64 落在 0x64,没有对齐 这不是编译器问题 是 __attribute__((packed)) 或者手写序列化的结果

footer 是二级摘要,自己签名覆盖自己的头,同时记录 container 的 SHA-256 和两种 CRC

4. 加密原理

4.1 密钥链

        .shell_config[0x30..0x50]  (A)
                    XOR
        .shell_config[0x50..0x70]  (B)
                    ||
                    \/
  master = A ^ B
         = f674f9f4 a7597794 13d80413 500075b0
           5f82275b 54ba0b50 35a45a70 90e391eb

  for i in 0 .. chunk_cnt-1:
      K_i = HMAC-SHA256(key = master,
                        msg = "SHELL-ELF-KEY-v1\0"   (17 B, 含结尾 NUL)
                            || header_sha              (32 B)
                            || manifest                (32 B)
                            || LE32(i)                 (4 B))
          = 32 B = AES-256 key

master 不是随机数,是两段常量异或出来的 壳作者把"密钥"和"密文"放在同一个文件里 只加了一层异或 憋不住了

真正需要动脑的是KDF的三个输入header_sha、manifest、i 前两个都在文件里,i 是块号,所以只要把拼接顺序和 label搞对

label 在 .rodata 的 0x22B7:

000022B7  53 48 45 4C 4C 2D 45 4C 46 2D 4B 45 59 2D 76 31 00
          S  H  E  L  L  -  E  L  F  -  K  E  Y  -  v  1  \0

17 字节 这个 NUL 必须算进去,少一个字节5个块的 key 全错
manifest 是唯一一个我没能反推出来的字段,它不是 sha256(tags)、不是 sha256(ivs)、不是 sha256(ciphertext),也不是tag的 Merkle 根——这几种我都试过,都对不上 它只做两件事 参与 KDF、被Ed25519覆盖 不还原它的生成算法 也能脱壳

4.2 每块的 AAD

AAD = container[0x00 .. 0x80]        // 128 B:magic/rev/长度/manifest/两个 SHA
    + chunk_header[0x00 .. 0x14]     // 20 B:index + length + iv
// 合计 148 = 0x94

把 iv 放进 AAD 是好习惯

AAD 取 chunk_header[0:0x14],也就是 index + length + iv,不含 tag 和两个 CRC ,CRC单独校验的

4.3 四层校验

层 算法 覆盖范围 在哪
1 Ed25519 footer[0:0x80] footer[0x80:0xC0]
2 Ed25519 container[0:0x80] container[0x80:0xC0]
3 SHA-256 文件 [0, 0x19660);container 整体 两处互相比对
4 CRC32C + CRC64 chunk_hdr[0:0x24] + ciphertext 每块头尾各一份

意图:Ed25519防伪造,SHA-256 防错位,CRC 防传输损坏 ,CRC 在 GCM 之前校验,所以解不开时能立刻区分是"数据坏了"还是"密钥错了"

4.4 CRC 表是静态的

壳里没用 zlib.crc32,而是自带两张查表

// CRC32C (Castagnoli, poly 0x1EDC6F41, 反射)
uint32_t crc32c(const uint8_t *p, size_t n, const uint32_t *t) {
    uint32_t s = 0xFFFFFFFF;
    while (n--) s = t[(s ^ *p++) & 0xFF] ^ (s >> 8);
    return ~s;
}

// CRC64 (ECMA-182, poly 0x42F0E1EBA9EA3693, 非反射)
uint64_t crc64(const uint8_t *p, size_t n, const uint64_t *t) {
    uint64_t s = 0;
    while (n--) s = (s << 8) ^ t[((s >> 56) ^ *p++) & 0xFF];
    return s;
}

两张表是静态嵌在 .rodata 里的,不在 .text,也不是运行时生成

0x2970  256 × 4 = 0x400    CRC32C 表
0x2D70  256 × 8 = 0x800    CRC64 表

表首验证

0x2970: 00000000 F26B8303 E13B70F7 ...
0x2D70: 00000000 42F0E1EBA9EA3693 85E1C3D753D46D26 ...

注意两者初值和处理方式不同:CRC32C初值 0xFFFFFFFF、末尾取反;CRC64初值 0、左移、不取反,Python的 zlib.crc32 是 CRC-32/ISO-HDLC,跟这两个都不一样

4.5 壳自己带了一套 AES

aese / aesmc / aesd / aesimc 在 .text 里分别出现 16 / 13 / 14 / 14 次,集中在 0x13960..0x13ADC,这是 ARMv8 Crypto 扩展的指令,壳自己实现了一遍 AES

配套的字符串在 .rodata:

0x1952  AES-192-ECB
0x19E1  AES-256-ECB
0x19ED  AES-192-GCM
0x19F9  AES-256-GCM
0x1C90  AES-128-GCM
0x2014  AES-128-ECB

也就是 AES-128/192/256 × ECB/GCM 全档位,一个只解自己5个 GCM 块的壳,不需要这么多模式,应该是为了适配

0x1807C 的构造函数给了一个很具体的:

0x1807C  paciasp
0x18088  mov     w0, #0x10
0x1808C  bl      #0x1C4B0          ; getauxval(AT_HWCAP = 0x10)
0x18090  tbnz    w0, #8, #0x180AC  ; bit 8 = HWCAP_AES
0x18094  adrp    x9, #0x27000
0x1809C  strb    wzr, [x9, #0x90]  ; 没有 AES 指令 → 置 0
0x180AC  adr     x0, #0x1BEE       ; "ro.arch"
0x180B8  bl      #0x1C5F0          ; __system_property_get("ro.arch", buf)
0x180C4  adrp    x1, #0x1000
0x180C8  add     x1, x1, #0xD90    ; "exynos9810"
0x180D4  bl      #0x1C450          ; strncmp(buf, "exynos9810", 0xA)
0x180D8  cset    w8, ne
0x180E8  strb    w8, [x9, #0x90]

逻辑是:有HWCAP_AES就用硬件指令,但如果SoC是 Exynos 9810 就强制关掉,这是三星那颗 SoC 的历史坑,它的 AES 指令有errata,AOSP里有一模一样的规避代码,壳作者把这段抄进来了

在 .text 里还能看到 libunwind: %s - %s、DW_EH_PE_funcrel pointer encoding not supported、_Unwind_Resume() can't return、out/llvm-project/libcxxabi/src/private_typeinfo.cpp壳静态链了 libunwind + libc++abi 这就解释了 .gcc_except_table 和 .eh_frame 为什么占了 0x2924——不是壳自己的 try/catch,是把整个 C++ 异常运行时塞进来了
一个68KB代码段的壳,里面有完整的 AES 和完整的 C++ 异常处理

5. 运行时行为

5.1 导入表

60 个 PLT 项,去掉 libc++abi / libunwind 那堆(pthread_*、malloc、vsnprintf、__stack_chk_fail) 真正有用的只有这些:

open openat fstat mmap munmap close readlink
memfd_create pwrite pread fchmod fcntl geteuid
syscall execve setenv
dl_iterate_phdr realpath stat
getauxval __system_property_get

没有 ptrace,没有 process_vm_readv,没有 personality,没有 prctl 整个导入表里找不到一个反调试,反调试都没有

作者:文件是不是我解出来的、执行的是不是那个文件、环境里有没有人塞东西,你就说是不是吧

5.2 用 execveat 而不是 execve

0xD310:

0xD310  adrp    x2, #0x1000
0xD314  add     x2, x2, #0xc31        ; x2 = ""  (空路径)
0xD318  mov     w0, #0x119            ; __NR_execveat = 281
0xD31C  mov     w1, w21               ; dirfd
0xD320  mov     x3, x20               ; argv
0xD324  mov     x4, x19               ; envp
0xD328  mov     w5, #0x1000           ; AT_EMPTY_PATH
0xD32C  bl      #0x1c410             ; syscall()

execveat(fd, "", argv, envp, AT_EMPTY_PATH) —— 直接执行一个fd不需要路径 这是整个壳的技术核心,明文从头到尾没有落过盘,直接从这个fd建 mmap

AT_EMPTY_PATH 要求调用者对 fd 有执行权限,且 fd 指向普通文件,壳在之前已经把 fchmod(fd, 0500) 做掉了

失败回退:

0xD33C  cmp     w8, #0x26            ; ENOSYS = 38
0xD340  b.eq    #0xd34c
0xD344  cmp     w8, #0x16            ; EINVAL = 22
0xD348  b.ne    #0xd388

ENOSYS(内核太老没有 execveat)或 EINVAL(不支持 AT_EMPTY_PATH)→ 0xD638 把fd还原成 /proc/self/fd/%d 路径,再 execve(0xD380),这里有个容易绕晕的点:MFD_CLOEXEC 在 execveat 路径上根本不起作用,CLOEXEC 管的是子进程继承fd表,而 execveat 是把fd当参数直接递给内核,不走继承,整个壳里 fcntl 只出现 F_ADD_SEALS / F_GET_SEALS / F_GETFL 三种,一次 F_SETFD 都没有,只有回退到 execve("/proc/self/fd/%d") 时才需要fd,而那条路本来就在同一个进程里

5.3 memfd 的三层回退

0xC4EC 开始:

0xC4EC  adr     x0, #0x19d4          ; "gemini-image"
0xC4F0  mov     w1, #0x13            ; CLOEXEC|ALLOW_SEALING|MFD_EXEC
0xC4F4  bl      #0x1c3a0             ; memfd_create
0xC4F8  str     w0, [x19]
0xC4FC  tbnz    w0, #0x1f, #0xc518   ; 失败 → 看 errno

0x13 = MFD_CLOEXEC(1) | MFD_ALLOW_SEALING(2) | MFD_EXEC(0x10)

MFD_EXEC 是 Linux 6.3 才加的(配合 vm.memfd_noexec 策略),不是 Android 11 的东西

0xC518  bl      #0x1c2e0             ; __errno
0xC524  cmp     w8, #0x16            ; EINVAL
0xC528  b.ne    #0xc544
0xC530  adr     x0, #0x19d4
0xC534  mov     w1, #3              ; 去掉 MFD_EXEC 再来一次
0xC538  bl      #0x1c3a0

还是不行(ENOSYS,内核根本没有 memfd)就退到 O_TMPFILE:

0xC554  ldr     w20, [x19, #4]       ; 目录 fd
0xC558  adrp    x21, #0x1000
0xC55C  add     x21, x21, #0xc2f     ; "."
0xC560  mov     w2, #0x4082
0xC56C  movk    w2, #0x48, lsl #16   ; 0x484082 =
0xC570  mov     w3, #0x1c0           ; 0700
0xC574  bl      #0x1c3b0             ; openat

0x484082 = O_TMPFILE(0x404000) | O_CLOEXEC(0x80000) | O_EXCL | O_RDWR **无名临时文件 同样不落盘 **
EINTR 单独处理(0xC588 cmp w8,#4 → b.eq 重试),其余错误走 0xC38C 建错误串
三层:memfd+MFD_EXEC → memfd → O_TMPFILE,覆盖 Linux 4.x 到 6.3+ 全线

5.4 加封与执行前复核

写出明文后(0xC79C __pwrite_chk),0xC8F8:

0xC8F0  mov     w0, w21
0xC8F4  mov     w1, #0x140           ; 0500  r-x------
0xC8F8  bl      #0x1c3d0             ; fchmod

然后加封(0xC9E0):

0xC9E0  mov     w1, #0x409           ; F_ADD_SEALS
0xC9E4  mov     w2, #0xf             ; SEAL|SHRINK|GROW|WRITE
0xC9E8  bl      #0x1c3e0             ; fcntl
0xC9F0  mov     w0, [x19]
0xC9F4  mov     w1, #0x40a           ; F_GET_SEALS
0xC9F8  bl      #0x1c3e0
0xCA00  mov     w8, #0xf
0xCA04  bics    wzr, w8, w0           ; 要求低 4 位全置
0xCA08  b.ne    #0xcaa0

bics wzr, w8, w0 是个很干净的写法w8 & ~w0,只要返回值里缺任何一位就非零,不是"调用成功就行",是"四位必须都在"
加封之后fd不可写、不可缩、不可扩,F_SEAL_WRITE 还会让现有的可写映射失
0xCF10 是执行前最后一次 fstat 复核 逐字段比:

检查 位置 含义
st_mode & 0xF000 == 0x8000 0xCF14 普通文件
st_uid == geteuid() 0xCF24 属主是自己
st_nlink == 0 0xCF30 无目录项
st_size == payload_len 0xCF38 长度对得上
atim / mtim / ctim 0xCF44..0xCF60 与写入前一致
[sp+0x9C] / [sp+0xD8..0xF0] 0xCF6C..0xCFC0 其余时间/尺寸字段

st_nlink == 0 memfd 和 O_TMPFILE 都满足,任何真实路径都不满足,注意这条只约束壳自己的这次 execveat

5.5 环境白名单

0xDA48 扫 environ 先按首字节快速过滤:

0xDAC4  ldrb    w8, [x0]
0xDAC8  cmp     w8, #0x4c           ; 'L'
0xDAD0  ldrb    w8, [x0, #1]
0xDAD4  cmp     w8, #0x44           ; 'D'
0xDADC  ldrb    w8, [x0, #2]
0xDAE0  cmp     w8, #0x5f           ; '_'

命中 LD_ 前缀的,取 = 后面的值,然后:

0xDB04  mov     x1, x19             ; "LD_LIBRARY_PATH="
0xDB08  mov     w2, #0x10
0xDB10  bl      #0x1c450            ; strncmp(entry, "LD_LIBRARY_PATH=", 16)
0xDB14  cbnz    w0, #0xdb60         ; 不是它 → 失败
0xDB18  mov     x0, x23             ; value
0xDB1C  mov     x1, x20             ; 硬编码串
0xDB20  bl      #0x1c460            ; strcmp
0xDB24  cbz     w0, #0xdabc         ; 相等 → 继续扫

两条规则:

  1. LD_ 开头但不是 LD_LIBRARY_PATH → 直接失败,LD_PRELOAD、LD_AUDIT、LD_DEBUG 全在这里
  2. LD_LIBRARY_PATH 允许存在,但值必须逐字节等于硬编码的那串(0x2887):
/system/lib64:/system_ext/lib64:/odm/lib64:/vendor/lib64:/product/lib64:
/apex/com.android.i18n/lib64:/apex/com.android.art/lib64:
/apex/com.android.runtime/lib64/bionic:/apex/com.android.os.statsd/lib64:
/odm/lib64/hw:/vendor/lib64/hw

紧接着 0xDB2C 是 dl_iterate_phdr,回调在 0xDBA4,先按 phdr 范围把主程序自己跳过,再放行 [vdso] / linux-vdso.so.1 / linux-vdso64.so.1(三串在 0x1A2E / 0x1A35 / 0x1940),剩下的每个已加载对象都要过:

  • 名字以 / 开头,realpath 能解开;
  • 落在 /apex/、/system/、/system_ext/、/vendor/、/odm/、/product/ 六个前缀下;
  • stat 是普通文件;
  • 属主和权限位干净:
0xDDC4  ldr     w9, [sp, #0x18]     ; st_uid
0xDDC8  cbz     w9, #0xddd4         ; uid == 0    → 放行
0xDDCC  cmp     w9, #0x3e8          ; uid == 1000 → 放行
0xDDD4  mov     w9, #0x12
0xDDD8  tst     w8, w9              ; st_mode & (S_IWGRP|S_IWOTH)
0xDDDC  b.eq    #0xde10             ; 必须为 0

也就是 uid ∈ {0, 1000} 且组/其他不可写,任何一条不满足 → 0xDB60
失败路径统一收口:

0xDB60  mov     w0, wzr
0xDB64  mov     w8, #1
0xDB6C  adr     x9, #0x24e00
0xDB70  stlrb   w8, [x9]            ; 粘滞位:以后直接返回 0

[0x24E00] 是 .bss 里的粘滞标志,0xDA6C 一进来就读它,第一次失败 入口拿到 w0 = 0 后跳到 0xB3E4,最终 exit(78) —— 78 是 sysexits.h 的 EX_CONFIG,对应那句:

0x20E4  "壳子: invalid configuration (78)."

6. 脱壳脚本

6.1 流程

读 .shell_config  (0x43F0, 0x490)
  ├─ 验 magic "壳子" / rev 0x4302
  ├─ 取 pubkey / key_a / key_b / cont_off / cont_len
  └─ master = key_a ^ key_b

读 container      (cont_off, cont_len)
  ├─ 验 magic / rev 0x4D02
  └─ 取 payload_len / chunk_size / chunk_cnt / manifest / payload_sha / header_sha

读 footer         (文件尾 0xC0)
  ├─ 验 magic / rev 0x4602
  └─ 验 cont_off / cont_len 与 config 一致

校验
  ├─ Ed25519(footer[0:0x80])      ← footer[0x80:0xC0]
  ├─ Ed25519(container[0:0x80])   ← container[0x80:0xC0]
  ├─ sha256(file[0:cont_off]) == header_sha == footer[0x20:0x40]
  ├─ sha256(container)        == footer[0x40:0x60]
  └─ crc32c/crc64(container)  == footer[0x60] / footer[0x64]

解块              (i = 0 .. chunk_cnt-1)
  ├─ K_i = HMAC-SHA256(master, label || header_sha || manifest || LE32(i))
  ├─ crc32c/crc64(hdr[0:0x24] + ciphertext) 先过
  ├─ AES-256-GCM(K_i, iv), AAD = container[0:0x80] + hdr[0:0x14]
  └─ decrypt_and_verify(ciphertext, tag)

收尾
  └─ sha256(明文) == payload_sha → 落盘

顺序不能换

6.2 全文

import hashlib, hmac, struct, sys
from Crypto.Cipher import AES
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

MAGIC = b"\xe5\xa3\xb3\xe5\xad\x90"
CFG_OFF, CFG_LEN = 0x43F0, 0x490
CRC32C_TAB_OFF, CRC64_TAB_OFF = 0x2970, 0x2D70
KDF_LABEL = b"SHELL-ELF-KEY-v1\x00"


def crc32c(data, tab):
    st = 0xFFFFFFFF
    for b in data:
        st = tab[(st ^ b) & 0xFF] ^ (st >> 8)
    return (~st) & 0xFFFFFFFF


def crc64(data, tab):
    st = 0
    for b in data:
        st = ((st << 8) & 0xFFFFFFFFFFFFFFFF) ^ tab[((st >> 56) ^ b) & 0xFF]
    return st


def main(src, dst):
    d = open(src, "rb").read()
    cfg = d[CFG_OFF:CFG_OFF + CFG_LEN]
    assert cfg[:6] == MAGIC, "bad .shell_config magic"
    assert struct.unpack_from("<I", cfg, 0x0C)[0] == 0x4302, "bad config version"
    c_off, c_len = struct.unpack_from("<QQ", cfg, 0x70)
    c = d[c_off:c_off + c_len]
    f = d[len(d) - 0xC0:]
    assert c[:6] == MAGIC and struct.unpack_from("<I", c, 0x0C)[0] == 0x4D02
    assert f[:6] == MAGIC and struct.unpack_from("<I", f, 0x0C)[0] == 0x4602
    assert struct.unpack_from("<QQ", f, 0x10) == (c_off, c_len), "footer/config offset mismatch"

    pub = Ed25519PublicKey.from_public_bytes(cfg[0x10:0x30])
    for name, sig, msg in (("footer", f[0x80:0xC0], f[0:0x80]),
                           ("container", c[0x80:0xC0], c[0:0x80])):
        try:
            pub.verify(sig, msg)
            print("[+] %-9s Ed25519 VALID" % name)
        except InvalidSignature:
            raise SystemExit("[-] %s Ed25519 INVALID" % name)

    payload_len = struct.unpack_from("<Q", c, 0x10)[0]
    chunk_size = struct.unpack_from("<I", c, 0x18)[0]
    chunk_cnt = struct.unpack_from("<I", c, 0x1C)[0]
    manifest = c[0x20:0x40]
    payload_sha = c[0x40:0x60]
    header_sha = c[0x60:0x80]
    print("[i] payload_len=%#x chunk_size=%#x chunks=%d" % (payload_len, chunk_size, chunk_cnt))
    assert hashlib.sha256(d[:c_off]).digest() == header_sha == f[0x20:0x40], "header SHA-256 mismatch"
    assert hashlib.sha256(c).digest() == f[0x40:0x60], "container SHA-256 mismatch"

    print("[+] header SHA-256 / container SHA-256 OK")

    t32 = [struct.unpack_from("<I", d, CRC32C_TAB_OFF + 4 * i)[0] for i in range(256)]
    t64 = [struct.unpack_from("<Q", d, CRC64_TAB_OFF + 8 * i)[0] for i in range(256)]
    assert crc32c(c, t32) == struct.unpack_from("<I", f, 0x60)[0], "container CRC32C mismatch"
    assert crc64(c, t64) == struct.unpack_from("<Q", f, 0x64)[0], "container CRC64 mismatch"
    print("[+] container CRC32C/CRC64 OK")

    master = bytes(a ^ b for a, b in zip(cfg[0x50:0x70], cfg[0x30:0x50]))
    print("[i] master key = %s" % master.hex())

    out = bytearray()
    off = 0xC0
    for i in range(chunk_cnt):
        idx, ln = struct.unpack_from("<II", c, off)
        hdr = c[off:off + 0x30]
        iv, tag = c[off + 8:off + 0x14], c[off + 0x14:off + 0x24]
        ct = c[off + 0x30:off + 0x30 + ln]
        assert idx == i, "chunk order broken"
        assert ln == min(chunk_size, payload_len - i * chunk_size), "chunk length broken"
        body = hdr[:0x24] + ct
        assert crc32c(body, t32) == struct.unpack_from("<I", hdr, 0x24)[0], "chunk %d CRC32C" % i
        assert crc64(body, t64) == struct.unpack_from("<Q", hdr, 0x28)[0], "chunk %d CRC64" % i
        key = hmac.new(master, KDF_LABEL + header_sha + manifest + struct.pack("<I", idx),
                       hashlib.sha256).digest()
        g = AES.new(key, AES.MODE_GCM, nonce=iv)
        g.update(c[0:0x80] + hdr[:0x14])
        out += g.decrypt_and_verify(ct, tag)
        print("[+] chunk %d len=%#x CRC32C/CRC64/GCM OK" % (i, ln))
        off += 0x30 + ln

    ok = hashlib.sha256(out).digest() == payload_sha
    print("[i] payload SHA-256 %s -> %s" % ("match" if ok else "MISMATCH", hashlib.sha256(out).hexdigest()))
    if not ok:
        raise SystemExit(2)
    assert out[:4] == b"\x7fELF" and out[4] == 2 and struct.unpack_from("<H", out, 0x12)[0] == 183
    open(dst, "wb").write(out)
    print("[i] wrote %s (%d bytes)" % (dst, len(out)))


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

依赖:pip install pycryptodome cryptography 用法:python -X utf8 unpack_v2.py exe out.elf

6.3 实跑

[+] footer    Ed25519 VALID
[+] container Ed25519 VALID
[i] payload_len=0x4f5f8 chunk_size=0x10000 chunks=5
[+] header SHA-256 / container SHA-256 OK
[+] container CRC32C/CRC64 OK
[i] master key = f674f9f4a759779413d80413500075b05f82275b54ba0b5035a45a7090e391eb
[+] chunk 0 len=0x10000 CRC32C/CRC64/GCM OK
[+] chunk 1 len=0x10000 CRC32C/CRC64/GCM OK
[+] chunk 2 len=0x10000 CRC32C/CRC64/GCM OK
[+] chunk 3 len=0x10000 CRC32C/CRC64/GCM OK
[+] chunk 4 len=0xf5f8 CRC32C/CRC64/GCM OK
[i] payload SHA-256 match -> f45e6f08c92c8c9c9249c220ccd922a5c50a4f2ef31650fb1d7b17236e14381b
[i] wrote _art_run3.elf (325112 bytes)

payload SHA-256 -> f45e6f08... 这一行是整条链的自证,解出来的325112字节,哈希和文件里写死的 payload_sha 一致就行了


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
点赞 ・4
打赏
分享
最新回复 (1)
雪    币: 1901
活跃值: (6128)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
2 楼
66
9小时前
0
游客
登录 | 注册 方可回帖
返回