跳过正文

某安全评估工具授权链路分析

·7 分钟· loading
asm2apex
作者
asm2apex
目录

正文开始
#

这次记录的是一个 Go 编译的命令行工具授权链路分析。

最开始看起来像是普通的 license 校验问题:加载证书、解密、验签、判断有效期,然后决定功能等级。真正拆下去以后,难点反而不在某个单独的判断,而在 Go runtime、跨架构 ABI、全局状态副作用和展示逻辑之间的互相牵制。

我把整条链路按几个问题拆:

  1. 怎么从一条 banner 输出摸到授权主线
  2. 怎么从访问偏移反推出 license 结构体
  3. 为什么全局授权等级不能粗暴改成高阶值
  4. 为什么同一套思路跨架构后要重写返回值构造
  5. 为什么 Go 的 GC 写屏障会影响二进制 patch 稳定性

一、切入点:从 banner 往回追
#

启动输出里通常会带版本、构建信息和授权等级,形态类似:

Version: <version>/<build>/<tier>

这类输出点很适合当入口,因为它满足两个条件:

  • 位置足够靠前,通常在主流程刚启动不久
  • 它已经拿到了授权对象,或者至少拿到了授权等级字段

目标是 Go 编译产物,gopclntab 信息比较完整。先恢复函数名,再从 banner 字符串交叉引用切进去,可以定位到一个展示函数:

main
  -> load_license()
  -> show_banner(license)

继续往 load_license() 里面看,授权链路会展开成一个更完整的状态机:

public_loader
  -> internal_loader
       -> once_callback
            -> read local license file
            -> decrypt payload
            -> verify signature
            -> decode license object
  -> license_is_valid
  -> write global tier
  -> build-time / system-time sanity check
  -> start background checker
  -> return license object

这一步的关键是把问题从“找一个判断分支”改成“梳理完整状态流”。后面所有稳定性问题,基本都来自这张图里的副作用节点。

二、结构体还原:从字段访问顺序入手
#

license 对象没有源码,但它的使用点会暴露布局。

展示函数、日志函数、字符串化函数最有价值,因为它们通常会按字段顺序读取 idusertimetier 这类信息。

Go 里的 string 是二元组:

string = pointer + length

所以反编译里遇到这种连续访问时,要成对看:

; 示意:读取 UserName
mov     r0, [license + 0x20]   ; string.ptr
mov     r1, [license + 0x28]   ; string.len
call    print_string

; 示意:读取 Tier
mov     r0, [license + 0x40]   ; string.ptr
mov     r1, [license + 0x48]   ; string.len
call    print_string

整理下来,结构大概是:

type License struct {
    ID        string
    UserID    string
    UserName  string
    NotBefore int64
    NotAfter  int64
    Tier      string
}

这里不需要字段名完全一致,只要偏移、类型和读取语义能对上,后面就能判断哪些字段参与展示,哪些字段参与功能分支。

比较容易出错的是把 string 当成 C 字符串处理。Go string 的长度独立存储,单改指针不改长度,输出就会截断或越界读;单改长度不改指针,则可能把旁边的只读数据一起打出来。

三、授权等级:展示状态和执行状态要拆开
#

最直觉的思路是把全局授权等级改成高阶值。

这个思路会得到一个看起来很漂亮的短期结果:banner 变了,部分入口也可能显示出来。但继续执行核心命令时,程序可能直接崩在空指针或缺失表项上。

原因在于:同一个 tier 字段在不同位置的语义不同。

展示语义:
  about/banner/log 里显示当前授权等级

执行语义:
  决定进入 free path 还是 premium path
  决定调用哪组函数表
  决定是否解引用某些能力模块

很多工具的社区构建和商业构建不是只差一个 license。商业能力对应的函数表、模块入口、资源下发逻辑,可能在当前构建里根本没有初始化。

伪代码大概是这样:

if (global_tier == TIER_FREE) {
    return run_free_path(ctx);
}

return premium_table->run(ctx);

如果 premium_table 在当前构建里是空的,那么把 global_tier 改成高阶值,本质上是在主动把程序推到一条不存在的路径上。

所以这里的突破点不是“怎么让全局状态变高”,而是先分类所有读取点:

读取点 语义 处理方式
banner/about/log 展示 可单独处理
功能分支判断 控制流 保持在稳定路径
模块表选择 能力调度 需要确认表项是否存在
后台复核 状态同步 需要确认是否会覆盖

这个拆分非常关键。只要展示路径和执行路径混在一起,就很容易出现“看起来解锁,实际一跑就崩”的情况。

四、加载器:真正的总闸不止 is_valid
#

license_is_valid() 很显眼,但它不是唯一关卡。

完整加载器里至少有三类返回状态:

internal_loader() -> (license, error)
license_is_valid(license) -> (ok, reason)
public_loader() -> license

因此只让 license_is_valid() 返回 true,还不够。前面 internal_loader() 如果返回 error,后面根本走不到有效性判断;后面 public loader 如果继续做全局覆写、时间检查、后台调度,也可能把当前状态再次改掉。

拆成伪代码就是:

func publicLoader() *License {
    lic, err := internalLoader()
    if err != nil {
        return fallbackLicense()
    }

    ok, reason := lic.IsValid()
    if !ok {
        return fallbackWithReason(reason)
    }

    globalTier = lic.Tier
    checkBuildTime()
    go backgroundCheck(lic)

    return lic
}

分析重点自然分成四块:

  1. internalLoader() 怎么返回结构完整的 license
  2. IsValid() 的返回值怎么表达 true 和空 reason
  3. globalTier = lic.Tier 会不会污染执行路径
  4. backgroundCheck() 会不会后续覆盖状态

这里比较有意思的是 error。

Go 的 error 是 interface,不是一个简单整数。表达 nil error 时,需要同时处理 interface 的 type/table 部分和 data 部分:

error interface:
  tab  = nil
  data = nil

只清 data 不清 tab,或者只清 tab 不清 data,都可能在高层逻辑里表现成非空接口。这类 bug 在反编译里不一定醒目,但运行时分支会非常诚实。

五、ABI:同一逻辑,跨架构完全不是同一组字节
#

这次同一套 Go 逻辑在两个架构上表现差异很大。

差异首先体现在返回值。

一个函数如果高层语义是:

func LoadLicense() (*License, error)

机器层可能要同时构造:

license pointer
error.tab
error.data

另一个函数如果语义是:

func (lic *License) IsValid() (bool, string)

机器层又要构造:

bool
string.ptr
string.len

在不同架构和不同 Go ABI 下,这些值可能放在:

  • caller 预留的栈槽
  • 通用寄存器
  • 特定低位寄存器
  • 栈和寄存器混合区域

所以跨架构迁移时,不能复制机器码,只能复制问题列表:

这个函数返回几个值?
每个返回值的机器表示是什么?
bool 落在哪里?
string 的 ptr/len 落在哪里?
interface 的 tab/data 落在哪里?
caller 后续从哪里读取?

寻址方式也完全不同。

; 架构 A:分页寻址 + 页内偏移
adrp    x0, literal@PAGE
add     x0, x0, literal@PAGEOFF

; 架构 B:指令指针相对寻址
lea     r0, [rip + literal_disp]

同样是取一个字符串地址,指令长度、可达范围、对齐和可塞入空间都不一样。某个架构上能原地塞下的逻辑,另一个架构上可能就需要找代码洞、跳板或改写更短的路径。

六、GC 写屏障:裸写全局指针的隐形坑
#

二进制 patch 里很容易想当然:

; 示意:把伪造的 license 指针写进全局
store [global_license], fake_license

在普通 native 程序里,这种写法多数时候只要地址对就能跑。但 Go 程序多一层 runtime 约束:写入 GC 跟踪的全局指针或堆对象字段时,可能需要经过写屏障。

如果裸写这类指针,表面上可能短时间正常,后面在 GC、栈扫描或 runtime 检查点附近突然异常。更麻烦的是,这种异常不一定有清晰 panic 输出。

更稳的做法是少碰受管全局状态:

不要直接写 GC 跟踪的全局 License*
在当前函数返回路径构造返回值
让 caller 继续按原本的数据流接收 license
需要的静态字符串和临时结构放到稳定的非堆区域

也就是说,与其把“结果”塞进全局变量,不如把“返回值”伪装成加载器自然返回的结果。

这一步很能体现 Go 逆向和普通 C/C++ patch 的差别:你改的不是纯机器状态,还要尊重 runtime 对对象引用的管理方式。

七、副作用裁剪:哪些要跳,哪些不能跳
#

授权加载器后半段的副作用需要逐个判断。

global tier write
  可能污染执行路径

build-time sanity check
  可能触发反向时间异常

background checker
  可能异步覆盖状态

banner display
  只影响展示输出

这几个节点不能一刀切。

如果直接跳得太早,caller 拿不到预期的 license 对象;如果跳得太晚,全局状态已经被污染;如果后台检查不处理,启动时看起来正常,过一会儿状态又被改回去。

比较稳的控制流是:

internal_loader
  -> 返回结构完整的 license

license_is_valid
  -> 返回 ok=true, reason=""

public_loader
  -> 跳过危险副作用
  -> 保留正常返回

banner
  -> 展示路径单独读取展示 tier

command path
  -> 继续走当前构建稳定分支

这里的核心判断是“最小影响面”。

只影响展示的地方可以局部处理;会改变功能分支的地方要谨慎;会触发 runtime 管理规则的地方尽量绕开;异步逻辑必须确认不会二次覆盖。

八、验证:不能只看 banner
#

banner 只是第一层验证。真正要确认链路稳定,至少要跑过这些点:

启动输出正常
license object 字段读取正常
IsValid 成功分支可达
public loader 正常返回
核心命令路径不崩
等待后台周期后状态不回滚
多架构样本行为一致

尤其是“核心命令路径不崩”这一点,比 banner 更重要。

如果全局 tier 被改成当前构建不支持的值,banner 会先变好看,然后核心路径立刻暴露空表、空函数指针或缺失模块。这个现象反过来也能帮助判断:某个字段到底只是展示字段,还是参与了真实能力调度。

九、技术小结
#

这次比较有价值的不是某个单点,而是整套分析顺序:

从输出点切入
  -> 还原调用链
  -> 反推结构体布局
  -> 区分展示状态和执行状态
  -> 处理 loader 返回值
  -> 裁剪全局副作用
  -> 适配跨架构 ABI
  -> 避开 Go runtime 写屏障
  -> 用核心命令路径验证稳定性

几个经验可以复用:

  • Go string 永远按 ptr + len 成对看
  • Go error/interface 要同时看 tab + data
  • 授权等级字段要区分展示语义和执行语义
  • is_valid() 往往不是唯一总闸
  • 后台 goroutine 可能会异步覆盖状态
  • 跨架构迁移要重建 ABI,不要移植字节
  • Go runtime 约束会影响二进制 patch 的稳定性

逆向里最容易误判的地方,往往不是“不知道怎么改”,而是“不知道哪些地方不能改”。

这次的核心突破也在这里:先把数据流和副作用拆干净,再决定最小改动面。这样得到的结果通常比直接硬改全局状态稳定得多。