先看结论与判断条件

  • 检查对象必须是最终 APK 或 AAB 交付链中实际打包的 SO,不能拿中间产物、旧构建或另一个 ABI 的结果替代。
  • 可写可执行 LOAD 段与可执行 GNU_STACK 是两个独立问题,应分别解析 program header 的类型、权限和映射关系。
  • GNU_STACK 不带 E 是必要的静态门禁,但它不等同于进程中所有线程栈和匿名映射都已经动态验证。
  • RELRO 与立即绑定处理的是重定位相关写窗口,不能用 PT_GNU_RELRO 存在来抵消 W+X 段或可执行栈。
  • 例外必须绑定具体库摘要、段索引、原因、责任人和到期时间,不能仅凭第三方库名称长期放行。
  • 修复后要重新打包、提取、静态扫描并在真实设备执行装载与关键路径测试,旧回执不能继承给新二进制。

先把三类 ELF 结论分开

SO 的权限审计至少要区分 LOAD 段是否同时可写和可执行、PT_GNU_STACK 是否请求执行权限、PT_GNU_RELRO 是否覆盖预期的重定位区域。三者都来自 program header,却回答不同问题。W+X 关注同一映射能否一边写一边执行,可执行栈关注栈权限请求,RELRO 关注装载器完成重定位后能否收紧特定区域,不能互相替代。

静态扫描的输入应是发布候选中的最终字节。编译目录里的未剥离 libfoo.so、加固前备份库或单独下载的供应商样本,即使检查结果干净,也不能代表 APK 中同路径文件。工程上应先记录 APK 摘要,再提取每个 ABI 的 SO,记录文件摘要、ELF class、machine、SONAME 和 build ID,最后再解析段权限。

门禁结论应写成可复核断言,例如某摘要文件没有 W+X LOAD,GNU_STACK 权限为 RW,而不是写成安全、已防住逆向或兼容通过。静态 ELF 标记只能证明被检查字节的声明与布局;动态链接器是否采用预期映射、应用是否能装载、异常是否可还原以及保护后的业务路径是否正常,都需要其他证据。

三类 program header 信号的判读边界
信号静态检查重点通过能说明不能据此断言
LOAD 同时含 W 与 E段类型与权限位策略未发现同段写执行并存进程不存在其他可执行写映射
GNU_STACK 含 EPT_GNU_STACK 权限目标文件未请求可执行栈所有线程栈动态权限已核实
GNU_RELRO 存在RELRO 覆盖范围链接器生成了对应段运行时必定已完整只读
BIND_NOW 或 NOW动态标志与链接参数装载时立即解析被请求业务代码和密钥受到保护
Build ID 存在构建身份字段可辅助关联调试产物来源和发布审批自动可信
符号表可读导出与动态符号可观察符号仍存在装载和调用一定成功

从最终包提取对象并固定身份

审计从候选包开始,而不是从源码仓库开始。APK 可以作为 ZIP 容器按 lib/ABI/name.so 定位库;AAB 则要基于实际交付配置生成对应 APK 集,再确认设备将取得哪一个拆分包。每个被检查对象都要登记容器摘要、容器内路径、解压后摘要、文件大小和 ABI,避免同名库在不同目录或架构间被误认。

文件身份固定后先运行 readelf -hW 与 readelf -lW。前者确认 ELF class、数据编码、machine 和文件类型,后者给出 program header 及 section-to-segment 映射。若 readelf 报错、输出被截断、文件不是预期 shared object 或 machine 与目录 ABI 不一致,应立即失败,不能继续把空结果解释为没有风险。

同一个库名可能由主模块、动态特性模块或第三方 AAR 分别携带。发布构建的合并规则还可能选择与本地预想不同的副本。因此门禁要枚举包内全部 SO,并以路径和摘要作为主键;只检查高风险名单会漏掉新依赖,只按 basename 去重又会掩盖不同模块中的内容差异。

最终 SO 身份回执的最低字段
字段来源用途失败处理
容器 SHA-256最终 APK 或拆分包绑定发布候选摘要变化即重审
容器内路径ZIP 条目区分模块与 ABI路径异常即拒绝
SO SHA-256提取文件绑定扫描结果不接受同名替换
ELF class 与 machinereadelf header确认架构身份与目录不符即失败
SONAME 与 build IDdynamic 和 note关联版本及符号缺失时记录限制
工具版本受控构建环境保证解析可复现版本漂移需复核

正确读取 LOAD 段的权限和映射

readelf -lW 的 LOAD 行描述装载器可映射的段,权限通常由 R、W、E 组合表示。审计关注同一个 LOAD 是否同时出现 W 与 E,而不是看到某个文件既有可写段又有可执行段就报警。正常共享库通常会把代码放入 R E 段,把可变数据放入 RW 段;这两个分离的段并不构成 W+X。

判读时不能只搜索字符串 RWE。不同 binutils 版本可能用空格分隔权限,列宽也会随地址宽度变化;十六进制字段中的字符不应被误当权限。可靠解析应先识别 LOAD 或 GNU_STACK 记录,再从该记录的权限区域判断 W 与 E,或使用能够输出结构化字段的受控工具。任何格式无法识别都应失败关闭,而不是返回通过。

section-to-segment 映射可以帮助定位哪些 section 落入异常 LOAD,但修复不能停在删除 section 名称。最终权限由链接脚本、输入 section flags、段对齐和链接器布局共同决定。定位时要记录异常段的索引、文件偏移、虚拟地址、文件大小、内存大小、对齐和所含 section,再回到链接 map 与脚本查明形成原因。

LOAD 权限组合的发布判断
权限组合常见角色默认结论后续动作
R只读常量或元数据允许核对映射内容
R E代码与只读指令数据允许确认不含可变数据
RW可变数据和部分重定位区域允许但需结合 RELRO核对写窗口
R W E写执行同段默认拒绝定位链接脚本与输入 section
W E异常写执行组合拒绝检查解析和产物完整性
未知或缺列工具格式不受支持拒绝判定升级解析器或人工复核

GNU_STACK 表达的是栈权限请求

PT_GNU_STACK 是链接器汇总输入对象栈属性后写入的 program header。审计时看到 GNU_STACK 的权限包含 E,应视为可执行栈请求并阻止发布;常规目标应为 RW 且不含 E。缺失 GNU_STACK 也不能自动等同安全,因为不同平台、工具链和装载器可能采用不同默认处理,需要按照项目支持的平台策略明确判定。

可执行栈常由汇编源缺少正确的 GNU-stack 注记、旧预编译对象携带执行标记、定制链接脚本或显式链接选项引入。修复应找到具体输入对象,而不是在最终文件上直接改一个标志后交付。直接修改产物会脱离可重复构建,下一次编译仍可能复发,也可能破坏签名、调试身份或其他 ELF 结构。

如果业务声称必须执行栈上生成的代码,应先要求可审查的设计依据、平台兼容说明和替代方案分析。移动 App 的一般业务逻辑很少需要可执行栈;运行时生成代码若确有合规用途,也应采用受控的内存权限转换与平台接口,而不是把整个线程栈长期标为可执行。本文不提供生成或注入可执行代码的实现。

GNU_STACK 检查结果与处置
观测结果含义发布决定调查入口
GNU_STACK 为 RW未请求执行栈通过该静态项继续做动态与兼容验证
GNU_STACK 为 RWE请求可执行栈拒绝追溯汇编对象和链接参数
GNU_STACK 缺失无法按统一标记判断按策略拒绝或人工复核核对平台默认和工具链
输出无法解析证据不完整拒绝固定 readelf 版本与格式
仅中间对象干净未覆盖最终 SO不采信重新扫描最终包内文件
例外已过期旧批准不再有效拒绝重新提供设计与设备证据

RELRO 和立即绑定需要单独核对

GNU ld 选项文档说明,-z relro 会创建 PT_GNU_RELRO,-z now 要求动态链接器在程序启动或共享库装载时解析全部符号。它们针对重定位完成前后的可写窗口和绑定时机,适合作为独立发布字段记录。即使两者存在,也不能把一个 RWE LOAD 判为可接受,更不能改变 GNU_STACK 的执行位。

检查 RELRO 不应只确认 program header 名称。工程上还要核对动态段中的 NOW 相关标志、section-to-segment 映射以及最终链接命令,确认预期区域确实位于 GNU_RELRO 覆盖范围。对于不同 linker 和 NDK 版本,输出细节可能变化,门禁应依据已验证的工具版本解析,并为无法识别的组合保留明确失败。

链接参数属于构建意图,最终 ELF 才是交付事实。CMake、ndk-build、Bazel 或供应商脚本都可能在不同变体追加、覆盖或遗漏参数。发布回执应同时保存最终 ELF 观测和链接命令摘要;两者不一致时,以最终产物失败为准,再追查构建配置,而不是用配置文件中写过某个选项来覆盖扫描结果。

链接意图和最终产物的证据关系
证据回答的问题优先级局限
链接命令包含 -z relro构建是否请求 RELRO辅助不证明最终段仍存在
存在 PT_GNU_RELROELF 是否生成 RELRO 段主要静态证据不证明动态状态已观测
链接命令包含 -z now构建是否请求立即绑定辅助可能被变体覆盖
动态标志包含 NOW最终 ELF 是否保留请求主要静态证据不代表业务兼容通过
设备 maps 快照运行时映射权限动态补充只覆盖观测时点
关键路径测试加载与功能是否正常运行证据不证明不存在未覆盖路径

用受控脚本把异常变成构建失败

下面的 Bash 脚本读取已经由受控 readelf -lW 生成的文本报告,逐行识别 LOAD 与 GNU_STACK。它拒绝任何同时含 W 和 E 的 LOAD,拒绝带 E 的 GNU_STACK,也拒绝缺失 GNU_STACK、没有 LOAD 或无法读取的输入。脚本不修改二进制,不下载依赖,不接触密钥,只把静态策略转为可重复的构建退出码。

代码有意把工具执行与报告判读分开。构建系统先对最终提取的 SO 运行固定版本 readelf,并把工具版本、命令、标准错误和返回码纳入回执;只有命令成功且报告非空才调用解析器。这样可避免 readelf 缺失或文件损坏时,空管道仍被误判为无异常。每个 SO 单独执行并保存路径与摘要关联。

文本格式会随工具版本变化,因此脚本将未知情况判为失败,而不是追求兼容所有历史输出。正式工程可增加基于样本的解析测试,覆盖 32 位与 64 位地址、R E 中间空格、多个 LOAD、缺失 GNU_STACK 和损坏文本。若升级 binutils,先用已批准样本验证解析,再更新构建镜像和回执。

检查 readelf 报告中的 W+X LOAD 和可执行 GNU_STACK
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
  printf '%s\n' 'usage: audit-elf-segments REPORT' >&2
  exit 2
fi
report=$1
if [ ! -r "$report" ]; then
  printf '%s\n' 'report is not readable' >&2
  exit 2
fi
if [ ! -s "$report" ]; then
  printf '%s\n' 'report is empty' >&2
  exit 2
fi
awk '
BEGIN { load_count = 0; stack_count = 0; violations = 0 }
/^[[:space:]]*LOAD[[:space:]]/ {
  load_count++
  flags = $0
  sub(/^[[:space:]]*LOAD[[:space:]]+/, "", flags)
  if (flags ~ /W/ && flags ~ /E/) {
    printf "W+X LOAD: %s\n", $0 > "/dev/stderr"
    violations++
  }
}
/^[[:space:]]*GNU_STACK[[:space:]]/ {
  stack_count++
  flags = $0
  sub(/^[[:space:]]*GNU_STACK[[:space:]]+/, "", flags)
  if (flags ~ /E/) {
    printf "executable GNU_STACK: %s\n", $0 > "/dev/stderr"
    violations++
  }
}
END {
  if (load_count == 0) {
    print "no LOAD segment found" > "/dev/stderr"
    exit 3
  }
  if (stack_count != 1) {
    print "GNU_STACK count is not one" > "/dev/stderr"
    exit 4
  }
  if (violations != 0) {
    exit 5
  }
}
' "$report"
printf '%s\n' 'ELF segment policy passed'

静态通过后还要观察运行时映射

设备端验证要使用与静态扫描相同摘要的候选包。安装后先确认实际加载的库路径和 ABI,再执行能触发该 SO 装载的最小业务路径。若库按需加载,只启动首页并不能覆盖它。测试记录应包含候选包摘要、设备型号、系统版本、ABI、操作步骤、进程状态和结果,不能用另一次构建的截图补齐。

运行时映射观测用于发现装载器、JIT、第三方运行库或应用逻辑产生的额外权限状态,但读取一个时点的 maps 仍有局限。短时映射可能在采样前已经消失,不同线程栈可能按需建立,厂商系统行为也可能不同。因此动态检查应围绕关键路径设置可重复触发点,并将未覆盖路径明确标成限制。

Android instrumented tests 适合驱动依赖真实运行时、组件和系统 API 的验证。测试可以加载目标库、调用公开入口、触发异常路径并确认应用未因权限修复而崩溃。单台设备或单个 API 版本通过不能代表全部 ABI 与厂商矩阵,发布结论必须列出实际覆盖,不把局部结果扩张为全面兼容。

静态与动态门禁的职责分工
阶段主要输入应验证保留限制
包内枚举最终 APK 条目全部 SO 路径和摘要AAB 分发组合需另取样
ELF 静态扫描最终 SO 字节LOAD、GNU_STACK、RELRO 和动态标志不观察运行时瞬态
设备装载同摘要候选包目标库确实加载未触发路径不计覆盖
映射观测运行进程采样时点权限不能覆盖全部时间窗口
关键路径测试业务输入和异常路径功能与失败语义仅限已执行用例
矩阵汇总设备与 ABI 回执覆盖范围和缺口不能推断未测组合

调试信息用于定位,不是放行证据

Native 崩溃定位需要把 tombstone、目标 ABI、未剥离符号与同一构建产物关联。权限修复可能改变段布局、地址或 build ID;如果调试团队使用旧符号,即使函数名看似合理,回溯也可能指向错误位置。发布链应把 stripped SO、unstripped SO、符号文件和 build ID 一起登记,但公开报告不应暴露私有符号内容。

当移除可执行栈或拆分 W+X 段后出现装载失败,应先确认失败来自权限变化、链接布局、重定位还是依赖版本,不能直接恢复危险标记求得启动。保留变更前后 readelf 报告、链接 map、构建参数、日志和 tombstone,按同一候选身份比对。只有找到根因并形成可重复修复,才进入新一轮候选验证。

调试成功不等于发布通过。能在本地还原堆栈只说明诊断材料匹配,不能证明所有设备装载成功,也不能证明 W+X 或执行栈风险已经消除。反过来,符号被剥离也不代表更安全;权限、完整性、授权和抗逆向属于不同控制,应该各自保留证据与结论。

建立例外、修复和发布闭环

默认策略应拒绝 W+X LOAD 和可执行 GNU_STACK。若第三方闭源库暂时无法修改,例外申请至少要包含库来源、版本、文件摘要、受影响 ABI、具体段记录、业务必要性、替代方案、风险接受人、到期时间和退出计划。仅写供应商要求或历史一直可用,不足以形成可审计例外。

NIST SSDF 强调把来源、构建、验证与变更证据纳入开发流程。对应到 SO 权限问题,修复提交要能追溯到输入对象和链接配置;CI 回执绑定最终文件;审批记录说明已知限制;发布后仍保留可复现材料。OWASP MASVS-RESILIENCE 可用于说明抗篡改与抗逆向属于纵深控制,但控制目录本身不证明某个二进制已达到特定强度。

准备安全评估时,应提交最终候选包、包与 SO 摘要、ABI 清单、readelf 完整报告、链接参数摘要、例外台账、设备覆盖和失败回执。申请入口由御盾中央平台统一承接。没有同一候选的静态与动态证据,就只登记待验证,不能写成风险已消除、攻击已阻断、性能已提升或生产兼容已经确认。

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
readelf 可以查看 ELF header、program header、dynamic section、符号、重定位和 unwind 信息。GNU readelf 说明 readelf 的 ELF 检查选项与输出范围。静态字段存在不证明动态装载、映射权限、异常传播或业务调用成功。
-z relro 会生成 PT_GNU_RELRO,-z now 会请求装载时完成符号解析。GNU ld options 说明 RELRO 与 NOW 相关链接选项。这些选项不能抵消 W+X LOAD 或可执行 GNU_STACK,也不保护业务算法和运行时明文。
移动端抗篡改与抗逆向控制属于纵深防御的一部分。OWASP MASVS-RESILIENCE 给出移动端韧性控制的验证方向。控制目录不证明某个候选包已经实施控制或达到任何防护强度。
安全发布需要保留来源、构建、验证与变更证据。NIST SP 800-218 SSDF 将安全软件开发与供应链风险纳入组织实践。组织级框架不定义某个 App、SO 或加固产品的具体功能和验收结果。
依赖真实 Android 运行时和系统 API 的语义应通过设备端测试验证。Android instrumented tests 说明 instrumented test 在真实或模拟 Android 设备环境中运行。单一设备、ABI 或系统版本通过不能代表完整兼容矩阵。
Native 崩溃还原需要匹配架构、符号和构建产物。Debug Android native code 说明 Native 调试、符号和相关构建材料。调试材料可用不等于发布兼容通过,也不应转化为公开攻击复现链。
发布门禁应拒绝同一个 LOAD 同时带 W 和 E,并单独拒绝 GNU_STACK 的 E 权限。工程判断:两个 program header 信号描述不同权限风险,需要分别解析和记录。静态通过只约束被检查文件,不能证明进程运行期间不存在其他写执行映射。
权限例外必须绑定文件摘要、具体段、批准范围和到期时间。工程判断:二进制身份或风险范围变化后,旧批准不能可靠覆盖新的发布候选。例外是风险接受记录,不是安全证明,也不能替代修复和设备验证。

工程常见问题

看到一个 R E 段和一个 RW 段,是否就是 W+X?

不是。W+X 指同一个 LOAD 同时包含 W 与 E。代码段 R E、数据段 RW 的分离布局不能仅因文件同时出现 W 和 E 就被误报。

GNU_STACK 显示 RW 是否足以证明所有栈都不可执行?

不足。它证明该 ELF 没有通过此段请求执行栈,仍需在目标设备和关键路径观察实际映射,并说明未覆盖的线程与时间窗口。

存在 PT_GNU_RELRO 能否允许一个 RWE LOAD?

不能。RELRO 处理部分重定位区域的写窗口,RWE LOAD 是同段写执行权限问题,两项必须分别满足发布策略。

为什么必须检查 APK 里的 SO,而不是编译目录文件?

打包、依赖合并、strip、加固和模块拆分都可能改变或替换文件。只有包内路径与摘要才能把扫描结论绑定到真实发布候选。

第三方闭源库需要执行栈时应该怎样处理?

默认阻止发布,并要求供应商依据、具体摘要、受影响 ABI、替代方案、风险接受人、到期时间和退出计划;不能用库名称永久放行。

申请 SO 段权限评估需要准备哪些材料?

准备最终候选包与摘要、全部 SO 路径和摘要、ABI 清单、readelf 报告、链接参数、例外记录、设备覆盖、日志和同构建符号身份,再通过御盾中央平台提交。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单