前言
在我的上一篇文章
SAST专项之使用tree-sitter进行SINK点扫描——diff正则扫描器
已经做到了使用AST语法树把危险操作且变量可能可控的地方找出来了
那么第二步我们肯定是会去想,这个变量它真的可控吗?这个变量它又是从哪来的呢?
正常我们在挖洞的时候肯定就是打开IDE,然后对着这个函数右键接着一直查找用法,看看最上头的调用点是否由我们所控制来判断
这篇文章就是要把这一过程自动化,所使用的技术技术LSP(语言服务器)也是我们现代编译器的核心机制
什么是LSP
关于什么是LSP不多赘述
只需要知道它是一个服务器,给我们的编辑器作为客户端可以查看代码中一些函数的定义,调用关系,位置信息,以及语法错误信息等等
安装和连接LSP服务器
1 | npm install -g intelephense |
启动LSP服务
1 | intelephense --stdio |
这里我们使用io输入和输出的方式和服务器进行通信
简单了解一下请求体,每个请求体都会包含如下四个字段
1 | { |
jsonrpc: 就像写信的抬头,告诉对方“我说的是 JSON-RPC 2.0 这种语言,别搞错了”。
id: 非常重要。
- 因为 LSP 是异步的,你可能连续发了 10 个请求。
- 服务器回给你的时候,顺序可能是乱的。
- 你需要靠这个 ID 把“请求”和“响应”对上号。(比如你发 ID: 100,服务器回 ID: 100,你就知道这是回复刚才那个请求的)。
method: 方法名。比如 initialize(初始化)、textDocument/hover(鼠标悬停)、shutdown(关闭)。
params: 具体的参数对象。不同的 method 需要不同的 params。
初始化包initialize
目的:认门。告诉服务器项目在哪,建立了索引,我才能查。
关键字段:
rootUri: 必填。项目的根目录,格式必须是file:///C:/Users/...。如果不填或填错,它就变成了“单文件模式”,没法跨文件找调用。
使用这个包我们可以做一下初始连接测试,看看我们自己写的客户端是否可以和LSP服务器进行通信
如下测试代码
1 | import subprocess |
可以看到我们就可以正常连接刚刚下好的PHP的LSP服务器了
使用LSP追寻调用关系
回归正题,我们的目的就是使用LSP把手动在IDE寻找调用的这一操作自动化
这还需要我们下面两个请求,分别对应方法textDocument/didOpen以及textDocument/references
textDocument/didOpen打开文档
这是一个通知型请求,不需要回信,所以也就不需要填写ID字段,只需要把我们的代码段和URI发送过去即可
1 | { |
需要同时写uri和text的原因是为了让服务器处理更快一点,不需要服务器再去磁盘去读取IO
textDocument/references查找引用
这是需要回信的请求,所以需要填写ID字段,初次之外,为了定位我们的函数,我们需要提供行号和列数
1 | { |
可以注意到我们这里是只需要提供行号和起始列数,不需要提供结束列数,是因为LSP本来就是面向鼠标点击的一个传输协议,所以只要鼠标放在那个范围,就可以去查看引用,也是比较方便
所以我们可以写如下脚本
1 | import subprocess |
最后也是找到的调用处的具体位置
建立调用图
我们已经实现了功能的最小单元,就是寻找到某一函数的所有调用位置,但是我们知道一个函数在一个大项目中肯定会有很多次调用,然后这个调用它的函数往上也会有很多调用处,那我们如果找出所有的链路呢?
我们先从最理想的情况开始考虑
首先我们使用扫描器找到了system(‘calc’),接着我们用AST语法树找到它所在的函数D,然后对它所在的函数去调用LSP去找到它的调用点,又使用AST去找到它的调用函数C…..这样的一个递归过程,最后找到了A,没有地方再调用A,所以找到一个没有地方调用的函数,作为我们的递归出口
那么我们先来实现一次递归的功能
查找某行代码所在的函数
首先我们要实现的点就是,如何找到system()所在的函数D,这里我们选择把这个功能还在在原本使用go的AST扫描器上开发
因为是我们python程序调用go程序的API,得提前商议好通信的字段,首先我们相互传的是一个函数的位置信息,首先是python给go的字段,应该包含
- uri:函数所在的文件地址
- line:函数所在的行号
- name:函数名称
然后是go返回给python的信息,应该包含
- name:函数名称
- parent_name:所在函数的名称
- line:所在函数的行号
- start_column:起始列数
- end_column:结束列数
接下来就是完善我们的AST查找逻辑
我们使用的是向上攀爬的逻辑
我们需要找到包含目标行号的最具体的那个节点。 AST 是一个层级结构,父节点包含子节点。我使用了一个递归函数 walk 从根节点开始向下“钻”:
- 判定范围:检查当前节点的起始行和结束行是否包含目标行。
- 贪心查找:如果当前节点包含目标行,我就记录它为
targetNode,然后继续检查它的所有子节点。因为子节点一定比父节点更小、更具体。最终我们会停留在树最深处那个包含了目标行的节点上
1 | // 核心定位逻辑 |
一旦找到了最具体的 targetNode,我沿着树向上爬
- 匹配类型:在回溯过程中,检查每一个祖先节点的类型 (
Type())。function_definition: 对应普通函数function foo() { ... }method_declaration: 对应类方法public function bar() { ... }
- 提取信息:一旦命中这两种类型,就提取它的名字、起始行和结束行
- 兜底:如果爬到了树顶都没找到,说明这行代码在全局作用域,返回
{main},这也是我们之后递归出去的出口
1 | func (a *Analyzer) findParentFunction(n *sitter.Node, source []byte) *FunctionLocation { |
一次递归
我们以发现的这个点为例向上寻找调用链
输出的位置信息如下
1 | { |
寻找所在的函数输出
1 | [ |
使用LSP寻找_basicCompression的调用
之后就找不到了,所以这个命令执行只在一个不被调用的函数中使用了
下面我们找一个层级更多的
1 | [ |
文件包含,寻找所在函数
1 | [ |
寻找所在函数被调用的地方
1 | [ |
寻找他们所在的函数
1 | [ |
寻找调用处
1 | [ |
找到所在函数
1 | [ |
完整图
那么我们如何把这个函数调用关系绘制成图呢?我们就需要一个程序作为两边API传递的枢纽,记录一下他们每次都传递了什么数据然后在中间绘制成图即可
新建一个draw.py
通过有向图的方式生成
1 | def draw(test_dir="test", root_json="test.json", output_json="call_chain.json"): |
把图的信息整理成新的json文件
1 | { |
最后进行前端展示
就能得到我们的调用图了
但是有时候如果扫到一个非常底层的函数,比如数据库连接,这意味着每一次进行数据库操作都会被调用一下,就会出现下面这样有点难绷的图
这也是后期需要优化的一部分






