先看结论与判断条件
- 检查对象必须是最终 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 标记只能证明被检查字节的声明与布局;动态链接器是否采用预期映射、应用是否能装载、异常是否可还原以及保护后的业务路径是否正常,都需要其他证据。
| 信号 | 静态检查重点 | 通过能说明 | 不能据此断言 |
|---|---|---|---|
| LOAD 同时含 W 与 E | 段类型与权限位 | 策略未发现同段写执行并存 | 进程不存在其他可执行写映射 |
| GNU_STACK 含 E | PT_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 去重又会掩盖不同模块中的内容差异。
| 字段 | 来源 | 用途 | 失败处理 |
|---|---|---|---|
| 容器 SHA-256 | 最终 APK 或拆分包 | 绑定发布候选 | 摘要变化即重审 |
| 容器内路径 | ZIP 条目 | 区分模块与 ABI | 路径异常即拒绝 |
| SO SHA-256 | 提取文件 | 绑定扫描结果 | 不接受同名替换 |
| ELF class 与 machine | readelf header | 确认架构身份 | 与目录不符即失败 |
| SONAME 与 build ID | dynamic 和 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 与脚本查明形成原因。
| 权限组合 | 常见角色 | 默认结论 | 后续动作 |
|---|---|---|---|
| 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 为 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_RELRO | ELF 是否生成 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,先用已批准样本验证解析,再更新构建镜像和回执。
#!/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 报告、链接参数、例外记录、设备覆盖、日志和同构建符号身份,再通过御盾中央平台提交。