指针审计:一组新的 SyntaxFlow Native Call

· 2026-09-03 18:27 · 8 阅读

原创 Yak 2026-09-03 18:27 四川

新增指针相关的syntaxflow native call,通过控制流助力分析c语言指针问题

在审计 C 时,我们常常会关注指针相关的问题,名字大家其实都熟:UAF、Double Free、NPD、内存泄漏。 以前写 SyntaxFlow,得自己把 free 捞出来,再去后面翻有没有人还在用。分支一多,规则就不好写。

SyntaxFlow 这边控制流分析一直在补:if 怎么分叉、从 A 能不能走到 B、中间必不经过某次判断,规则里已经能问了。顺着这条能力往下,指针审计也有了新的写法——引擎会跟着这些路径,把每个指针此刻还能不能用、是不是空记下来。规则直接问 <uaf()><npd()> 就行,不用自己在规则里把控制流再走一遍。

目前先覆盖 C 的 malloc / calloc / realloc 和 free

一、整体流程

源码先被编译。malloc 记成一次分配,free 记成一次释放,*p 记成一次解引用。if、return 会把函数拆成一段段,连成一张「代码会怎么往下走」的图。

接着引擎就顺着这张图走。每走到一块代码,就更新这块内存的状态:还活着、已经释放、空还是非空。碰到 if 就分叉记;两条路再并到一起。

规则这边只负责提问。写 <uaf()> 或 $p<npd()>,拿到的是已经走完路径的结论,不是在源码里搜字符串。告警一般落在真正出错的那一次使用上。UAF 还会尽量带上是哪次 free 把它放掉的,数据流图里可以从使用点点回释放点。

二、控制流怎么走进指针审计

程序的执行过程可以看作对一张共享状态表的持续演化。执行流依次进入各个函数,每个函数在被调用时读取当前状态,执行其逻辑,并将变更写回状态表。函数返回后,控制流并不重置或回溯,而是基于更新后的状态决定后续分支的走向。

换言之,这是一条单向推进的执行链:状态驱动决策,决策驱动路径,路径再次修改状态——循环往复,直至每个函数都会被走到。

查的东西不一样,表上记的也不一样:

你在查

沿路记什么

两条路并到一起时

UAF、Double Free

这块内存还活着,还是已经释放

有一条路已经释放,并起来就算已经释放

内存泄漏

同样记活着 / 已释放

有一条路还活着,并起来就算可能还没释放

NPD

空、非空,还是说不准

if (p) 的两边先分开改,再继续走

沿路记什么 : 这块内存还活着,还是已经释放 ;记活着 / 已释放 ; 空、非空,还是说不准

YAK

2.1 UAF

来看下面这个和 UAF 相关的案例:

    #include<stdlib.h>
    intmain(int abrt){
        int *p = (int*)malloc(sizeof(int));
        *p = 10;
        if (abrt) {
            free(p);
            *p = 20;   // 这条路上:刚 free 完又写,UAF
            return 1;
        }
        free(p);       // 另一条路:free 完直接返回,没有再用
        return 0;
    }

    malloc 之后 p 指向一块活着的内存,因此 *p = 10 没问题。然后 if (abrt) 分成两叉。走进 then:先 free,再 *p = 20。状态已经是「释放了」,这次写入就是 UAF,接着 return 1,这条路结束。另一边只 free 再返回。后面没人再用,不报 UAF,也不报泄漏。

    YAK

    2.2 NPD

    NPD 跟「有没有走过判空」绑在一起。if (p)、if (!p)、if (p == NULL) 会把后面分成两路:这一边可以当非空,那一边必须当空。

      if (p) { 
          *p = 1
      }           // 大括号里当非空,不报
      if (!p) { 
          return

      *p = 1;  // 判过空再解引用,一般安全


      p = 0
      p->x = 1;             // 明明是空还解引用,要报
      if (c) { 
          p = 0

      p->x = 1;  // 有的分支置空,并起来说不准,也要报

      函数调用时以指针作为参数,仅发生值拷贝(复制地址本身),不涉及对所指对象的访问,因此不构成解引用。解引用的语义边界在于:是否通过该地址发起了内存访问。具体触发解引用的操作包括:

      • 间接访问运算符 *p —— 读取/写入所指对象的值

      • 箭头运算符 p->field —— 基于地址偏移访问结构体成员

      • 下标 p[i]—— 等价于 *(p + i),同样触发访存

      在此之前,指针只是一个整数值的容器,传参阶段不会触碰它指向的内存。

      YAK

      2.3 跨过程怎么跟进

      函数里面的 ifreturn 是过程内的事。真正写规则时,释放和解引用经常不在同一个函数里:main 里 mallocfreep(p) 里才 free。引擎不会把 callee 整份控制流再嵌进 caller 走一遍,而是先给每个函数做一份参摘要,再在调用点上用。

      第一步:每个函数自己报一遍。 

      扫这个函数的指令,看形参后来发生了什么,记三件事:

      • 这个形参会不会被 free

      • 会不会 free(*p) 这种「顺着指针再释放里面那块」

      • 会不会被解引用(*pp->field

      只记「第几个参数」,不把整条路径展开。void freep(int *p) { free(p); } 就会记成:第 0 个形参会被释放。

      第二步:摘要互相传。 

      A 调用 B,B 已经知道自己会 free 第 0 个参数,那 A 里对应的那个实参,对 A 来说也算「会被释放」。包装再套一层也一样:wrap 调 freepfreep 调 free,摘要会往上传。这一步会多走几轮,直到没有新的「谁释放了谁」为止。

      第三步:真正顺着 caller 走的时候,碰到一次调用。  

      比如 freep(p),先查出 freep 的摘要,再决定这次调用对 p 意味着什么:

      • 摘要说会 free 这个参数:这次调用就当成一次释放。后面再 *p,报 UAF;再 free(p),报 Double Free。泄漏那边则当成这块内存已经交出去了。

      • 摘要说会解引用:若 p 此时已经释放,这次调用本身就是 UAF;若可能是空,NPD 仍只在真正的 *p / p->field 上报,单把指针当参数传进去不算解引用

      • 有函数体,但只是把指针存起来、既不 free 也不解引用:不当成一次使用,避免 hold(p) 这种误报。

      • 找不到函数体(extern、函数指针、间接调用):偏保守,可能当成会使用。

      入口处不会假设「调用者进来之前就已经 free 过了」。形参先当成还活着,只有这个函数里(或摘要认定的调用)真的释放了,才变成已释放。这样跨函数不容易误报。

      NPD 还有一种跨过程:callee 里写了 *a = NULL,caller 会看到这次写入,之后再解引用就会按空来算。

      对照一段很常见的包装:

        voidfreep(int *p) { free(p); }


        intmain() {
            int *p = (int*)malloc(sizeof(int));
            *p = 10;
            freep(p);   // 摘要:freep 会释放第 0 个参数 → 这里当成 free
            *p = 20;    // UAF
            return 0;
        }

        流程是:先给 freep 打上「释放形参 0」;走 main 时碰到 freep(p),把 p 标成已释放;后面的 *p = 20 就是 UAF。规则仍然写 $p<uaf()> 即可,不必自己把 freep 展开。

        跟不深的情况也要心里有数:函数指针、只在头文件里看到的声明、绕很多层才 free,摘要可能跟丢,结果是漏报,或者按 extern 走得偏保守。泄漏同样用这份摘要——包装函数里已经 free 了,caller 出口一般不再当泄漏。

        三、指针审计和控制流查询

        结合控制流相关的 native callgetCfg、cfgReachable、cfgDominates、cfgGuards 这类)可以实现从图上 A 走到 B,对中间过程进行更加准确的审计。


        四:规则的使用

          *<uaf()>                 // 整个程序扫一遍,先看看有没有
          $p<uaf()>                // 只问已经拿到的 $p
          <uaf(target=$p)>         // 和上一行一类,种子写在参数里

          日常排查内存问题,最顺手的方式是先找到指针,再针对它提问。

          <uaf()> 不带主语,适合先全局扫一眼,快速看看有没有释放后使用的风险。但真正写规则时,用 $p<uaf()> 更干净——把指针变量明确写在前面,报告结果直接绑定到这个具体的指针上,省得从一堆候选里再手动定位。

          追问一个指针指向哪里,用 <pointsTo()>,写法是 $p<pointsTo()>。它必须跟在指针后面,因为分析器需要知道你问的是谁的指向关系。

          判断两个指针是否指向同一块内存,用 a<aliases(target=$$b)>。这在检测双重释放时特别有用——先确认两个指针共享同一块内存,再看是否存在同一条路径上先后释放了两次。

          一句话总结:先锁定指针,再下诊断;<pointsTo()> 跟在后面;比别名用 aliases,双 $ 不能省。

          写法

          解决什么问题

          结果落在哪

          <uaf()>

          有没有 UAF(Double Free 也算一类)

          出错的那次使用

          <doubleFree()>

          有没有第二次 free

          第二次 free

          <npd()>

          可能为空的时候有没有解引用

          解引用的位置

          <memLeak()>

          函数结束时这块堆内存是不是还可能没放、也没交出去

          那次分配

          PS:只要 Double Free,可以写成 <uaf(kind="double-free")>。

          写法

          在找什么

          <heapAlloc()>

          malloc 这类分配

          <freeCall()>

          free

          <derefSite()>

          写成 *p 的读取(p->x 不一定在这里)

          <nullCheck()>

          if (p) 这种判空

          <pointsTo()>

          这个指针可能指向哪

          <aliases(target=$b)>

          会不会和 $b 是同一块内存

          五、规则使用案例 

          全项目扫一遍

            *<heapAlloc()> as $alloc
            *<freeCall()> as $free
            *<uaf()> as $uaf
            *<doubleFree()> as $df
            *<npd()> as $npd
            *<memLeak()> as $leak


            alert $uaf for { title: "Use After Free", title_zh: "释放后使用", level: "high" }
            alert $df  for { title: "Double Free",    title_zh: "重复释放",   level: "high" }
            alert $npd for { title: "Null Deref",     title_zh: "空指针解引用", level: "high" }
            alert $leak for { title: "Memory Leak",   title_zh: "内存泄漏",   level: "low" }

            UAF

            不用自己判断 free 后面还有没有用。找到被 free 的指针,交给引擎:

              free(* as $free_arg)
              $free_arg #-> as $ptr
              $ptr<uaf()> as $uaf


              alert $uaf for {
                  title: "Use After Free",
                  title_zh: "释放后使用",
                  level: "high",
                  risk: "释放后使用",
              }

              NPD

              找到解引用,再问当时空不空:

                *<npd()> as $npd


                alert $npd for {
                    title: "Null Pointer Dereference",
                    title_zh: "空指针解引用",
                    level: "high",
                }

                只针对某一个指针

                  pa as $pa
                  pb as $pb
                  *<doubleFree(target=$pa)> as $df_pa
                  *<doubleFree(target=$pb)> as $df_pb

                  泄漏

                  看函数结束时还在不在:

                    as $focus
                    $focus<memLeak()> as $leak

                    • malloc 完直接 return:还活着,也没交出去,报泄漏

                    • free 完再 return:不报

                    • if (abrt) free(p); 然后返回:有一条路没释放,报泄漏

                    • then、else 里都 free:不报

                    • return p;:所有权交给调用者,不报

                    别名

                    两个指针是不是同一块:

                      as $focus
                      as $alias
                      as $other


                      $focus<pointsTo()> as $obj
                      $alias<aliases(target=$focus)> as $hit
                      $other<aliases(target=$focus)> as $miss

                      q = p 时,hit 会留下 q、r 是另一次 malloc、miss 应为空。

                      和 CFG 拼在一起

                      <uaf()> 给出的命中已经考虑过分支。如果报告上还想标路径,可以再问:

                        as $p
                        $p<freeCall()> as $free
                        $p<uaf()> as $use
                        $use<cfgReachable(target=$free)> as $use2
                        alert $use2

                        六、使用时要注意

                        1. 现在主要是 C。别的语言如果没有同样的分配、释放信息,问了也可能是空的。

                        2. 跨函数只跟一层。freep(p) 这种包装通常没问题;函数指针、绕很深的释放,可能会漏,或者偏保守。

                        3. 先全项目扫可以,定规则的时候尽量绑到具体指针。$p<uaf()> 比 *<uaf()> 干净。

                        4. <derefSite()> 只管写成 *p 的读取。p->field 或 *p = ... 不一定出现在这个列表里,但<uaf()>、<npd()> 还是可能从成员访问报出来。

                        5. <nullCheck()> 请跟在目标指针后面。写成 *<nullCheck()> 时,if (abrt) 这种普通条件有时也会被收进来。

                        6. 指针当参数传出去,NPD 不会把它当成解引用。

                        END

                        • Memfit:是万径安全旗下Yaklang团队研发的面向长程渗透测试、漏洞挖掘、应急响应等全链路攻防任务的生产级安全领域Agent系统

                        • Yakit:是万径安全旗下Yaklang 团队开源的一款完全国产的交互式安全测试平台

                        YAK官方资源

                        Yakit官网下载地址:

                        https://yaklang.com/

                        Github地址:

                        Yakit 视频教程:

                        跳转微信打开