banner
raye~

Raye's Journey

且趁闲身未老,尽放我、些子疏狂。
medium
tg_channel
twitter
github
email
nintendo switch
playstation
steam_profiles

Codeql 原理分析(一)

Codeql 原理概述#

codeql 整个漏洞扫描可以分为两部分:

  • AST 数据库创建,通过命令行工具即可
  • 规则编写,类似 SQL 的语法来编写漏洞查询规则

DraggedImage

分为编译型语言和非编译型语言

  • 非编译型语言,如 python、js
codeql database create --language=javascript --source-root <folder-to-extract> databaseName
  • 编译型语言,如 cpp、go
codeql database create "xxx" --language=cpp -c "gcc main.c"

编译出来的数据库长这样:

.
├── baseline-info.json
├── codeql-database.yml
├── db-javascript
│   ├── default
│   ├── semmlecode.javascript.dbscheme
│   └── semmlecode.javascript.dbscheme.stats
├── diagnostic
│   ├── cli-diagnostics-add-20231128T030607.340Z.json
│   ├── extractors
│   └── tracer
├── log
│   └── database-create-20231128.110453.457.log
└── src.zip
  • 重点是 db-javascript 文件夹,包含了对应的 AST 结构数据库结构

  • log 目录和日志相关

  • src.zip是源代码的备份

规则编写 —— 打点#

一个简单的规则#

比如任意文件读取漏洞,如下 fs.readFile 函数存在任意文件读取漏洞

const express = require('express');
const fs = require('fs');
const app = express();

app.get('/getFile', (req, res) => {
  const fileName = req.query.fileName;
  fs.readFile(fileName, 'utf8', (err, data) => {
    if (err) {
      res.status(500).send(err.toString());
    } else {
      res.send(data);
    }
  });
});

app.listen(3000);

我们可以将代码中含有 fs.readFile 函数的代码都提取出来,编写规则如下:

/**
 * @name fs-read-file
 * @kind problem
 * @problem.severity warning
 * @tags correctness
 * @id js
 */

import javascript

from CallExpr fsReadFile
where
  fsReadFile.getCalleeName() = "readFile"
select fsReadFile, "This is a call to fs.readFile."

codeql 查询本质是通过 AST 树来查找的,写法类似 SQL 的写法

  • from 定义变量,比如我们想查询 readFile ,这是一个函数调用表达式 CallExpr
  • 还有一些限制条件,这个函数调用表达式的被调用函数名称,类似 sql 的 where 条件写法
  • 最后 select 出查询的结果

当然很明显,存在大量的误报,所有的 fs.readFile 函数都会被认为是存在漏洞

DraggedImage-1

所以我们面临一个问题,即如何区分正常的代码和有污点的代码 sink

污点分析原理#

DraggedImage-2

如图,我们定义

  • source:数据源,代表直接引入不受信任的数据或者机密数据到系统中
  • sink:污点汇聚点,代表直接产生安全敏感操作或者泄露隐私数据到外界

至于其中经过的一个个 Node,可以认为是词法分析里最小的一个个 Token,代表着 if、while、for、函数调用等

污点分析的步骤:

只要我们标记了 source 和 sink,
codeql 会判断只要满足有一条路径能够从 source 流向 sink ,就会认为这是一个漏洞

DraggedImage-3

污点分析的局限#

官方没有公开原理,但是经过多轮测试我们发现 codeql 追踪最准的还是赋值语句,路径更多的是形式上的,并且没有考虑到语言特性:

代码 1:

let x = process.argv[0]
let ctrl = 1
let y

if(ctrl > 0){
    y = 1
}else{
    y = x
}
eval(y)
  • source 定义为 process.argv[0]
  • sink 定义为 eval(y)

尽管 ctrl 永远是大于 0 的,但 codeql 还是会判断存在从 source 到 sink 的路径

代码 2

let x = process.argv[0]
Object.prototype.a = x

let y = {}
eval(y.a)

// 或者
let x = process.argv[0]

let c = {}
c.a = x

let y = {
    b:c
}
eval(y.b.a)

实际上这里都存在问题,但 codeql 均无法召回

细化污点规则#

codeql 的判断是比较有限的,如果仅仅标记 source 和 sink 会出现大量的误报和漏报情况

分析这些情况,大致可以归纳从 source 到 sink 所经过的代码路径为如下四种(代码特性的除外需要额外做判断)

  1. 经过一系列正常的 if、while 等正常流程到达 sink
  2. 经过一个 filter 函数过滤到达 sink
  3. 经过一个 check 函数,如果不满足条件,则无法到达 sink,但形式上还是可以流向 sink 的
  4. 经过一个正常的 join 等函数到达 sink

DraggedImage-4

  • 数据源通过无害处理到达污点 sink
const express = require('express');
const fs = require('fs');
const app = express();
  
function sanitizePath(path) {
  // 简单的清理逻辑,例如移除路径导航字符
  return path.replace(/(\.\.\/|\/\.\.)/g, '');
}
  
  
app.get('/getFile', (req, res) => {
  const fileName = req.query.fileName;
  const safeFileName = sanitizePath(fileName);
  fs.readFile(safeFileName, 'utf8', (err, data) => {
    if (err) {
      res.status(500).send(err.toString());
    } else {
      res.send(data);
    }
  });
});
  
app.listen(3000);
  • 数据源检查不通过直接返回
const express = require('express');
const fs = require('fs');
const app = express();
  
  
const SAFE_DIRECTORY = '/path/to/safe/directory';
  
app.get('/getFile', (req, res) => {
  const fileName = req.query.fileName;
  if (!fileName.startsWith(SAFE_DIRECTORY)) {
    // 不允许读取
    return res.status(403).send('Access denied');
  }
  fs.readFile(fileName, 'utf8', (err, data) => {
    if (err) {
      res.status(500).send(err.toString());
    } else {
      res.send(data);
    }
  });
});
  
app.listen(3000);
  • 数据源直接到达污点,或者经过多个处理到达污点(实际是两种)
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.get('/getLog', (req, res) => {
  const logFile = req.query.logFile;
  const logPath = path.join(__dirname, 'logs', logFile); // 经过path.join 
  fs.readFile(logPath, 'utf8', (err, data) => {
    if (err) {
      res.status(500).send(err.toString());
    } else {
      res.send(data);
    }
  });
});

app.listen(3000);

因此我们需要做额外的处理,除了定义 source 和 sink 之外,还需要增加 barrier、sanitizer、AdditionTaintStep

  • filter:无害处理 (sanitizer),代表通过数据加密或者移除危害操作等手段使数据传播不再对软件系统的信息安全产生危害
  • barrier:屏障,不同于 sanitizer,barrier 不清理或改变数据,而是作为条件检查或决策点,阻止数据流的某些路径。
  • AdditionTaintStep: source 在传递的过程中可能断开,这时需要手动连接

编写污点分析规则#

codeql 中提供了两种方式来分析

  • 静态规则,即 AST 树查询,以 AST 树节点的方式呈现,属于静态分析
  • 动态规则,即数据流 DataFlow 查询,抽象为 DataFlow::Node 基类,属于动态分析

AST 树很好理解,DataFlow 会有点抽象,主要分为几种不同的节点

  • DataFlow::Node 可以表示程序里的任何元素,基类,即词法分析里的 Token
var x = 10; // 'x' 和 '10' 都可以是 DataFlow::Node 的实例
var y = x + 5; // 'y', 'x + 5', 'x', 和 '5' 也都是 DataFlow::Node 的实例
  • DataFlow::ValueNode 用于表示程序中的值或者表达式
var name = "Alice"; // 'name' 和 "Alice" 都是 DataFlow::ValueNode 的实例
function greet() {
    return "Hello, " + name; // 'return "Hello, " + name;' 是 DataFlow::ValueNode 的实例
}
  • DataFlow::SourceNode 表示程序的输入点,如用户输入,文件读取等
  • DataFlow::SinkNode 表示可能存在 sink 的点
  • DataFlow::PathNode 用于敏感数据分析的变量,没有特殊的用途了

编写规则一般的流程是:

  • 定义 source,即输入数据源是什么,可以理解为是外部输入的数据
  • 定义 sink,即污点代码,比如 readFile
  • 定义 isBarrier、isSanitizer、isAdditionalTaintStep (非必需,只是为了减少误报和漏报)

一般框架如下:

import javascript
import DataFlow::PathGraph
import Express

class FileReadFromUserInput extends TaintTracking::Configuration {
  FileReadFromUserInput() { this = "FileReadFromUserInput" }

  override predicate isSource(DataFlow::Node source) {
		// 定义Source
  }
  override predicate isSink(DataFlow::Node sink) {
		// 定义Sink
  }
}

codeql 通过类似函数的写法,结合 AST 语法树的查询来打点标记,比如标记 eval 处的 sink 点

   override predicate isSink(DataFlow::Node sink) {
    // Eval is a sink
    exists(CallExpr call |
        call.getCalleeName() = "eval" and
        sink.asExpr() = call.getArgument(0)
      )
   }

最后查询:

from FileReadFromUserInput cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink.getNode(), source, sink, "$@ flows to $@ ", source.getNode(), source.toString(), sink.getNode(), sink.toString()

Source#

express 框架里,Source 一般比较固定,即 req.query.xxxx 就是外部输入的数据

Source 的编写本质也是通过 AST 语法树来查找,对于现成的框架如 express,codeql 有编写好的规则可以直接用

override predicate isSource(DataFlow::Node source) {
    exists(Express::RouteHandler rh, DataFlow::SourceNode sn |
        sn = rh.getARequestSource() and
        source = sn.getAPropertyRead("query").getAPropertyRead()
    )
}
  • exists 也是一个谓词,这里是为了简单地引入临时变量
  • Express::RouteHandler 是内置的一个规则,能查找出 express 对应的路由处理代码

注意这里的 = 要从右到左看,本质上是赋值的意思

写完之后就可以马上 select 一下看看结果:

DraggedImage-5

Sink#

由于是处理任意文件读取漏洞,sink 就是我们最开始编写的查找 readFile 调用。额外注意的是,还需要标注对应的 sink 点

fs.readFile 的第一个参数是可能被控制的点,因此通过

sink.asExpr() = call.getArgument(0) (这里的 sink 要做下数据类型转换,DataFlow 和 AST 节点是两个模型)

  override predicate isSink(DataFlow::Node sink) {
    exists(CallExpr call |
        call.getCalleeName() = "readFile" and
        sink.asExpr() = call.getArgument(0)
      )
  }

AdditionalTaintStep#

但是这种情况下会存在漏洞,比如这个就无法找到,因为经过了 path.join ,source 被传递了

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

app.get('/getLog', (req, res) => {
  const logFile = req.query.logFile;
  const logPath = path.join(__dirname, 'logs', logFile);
  fs.readFile(logPath, 'utf8', (err, data) => {
    if (err) {
      res.status(500).send(err.toString());
    } else {
      res.send(data);
    }
  });
});

app.listen(3000);

所以需要额外编写一个 isAdditionalTaintStep,即如果遇到了 join 这种函数,可以视作 source 经过了一次传递,后继节点可以继续连接上

  override predicate isAdditionalTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
    exists(CallExpr call |
        call.getCalleeName() = "join" and (
            pred.asExpr() = call.getAnArgument() and succ.asExpr() = call
        )
    )
  }

最终汇总的规则与结果分析#

最后的规则汇总,不过还是存在一些误报,即没有处理 sanitizer

/**
 * @name file-read-from-user-input
 * @kind path-problem
 * @problem.severity warning
 * @tags correctness
 * @id js
 */

import javascript
import DataFlow::PathGraph
import Express

class FileReadFromUserInput extends TaintTracking::Configuration {
  FileReadFromUserInput() { this = "FileReadFromUserInput" }

  override predicate isSource(DataFlow::Node source) {
      exists(Express::RouteHandler rh, DataFlow::SourceNode sn |
          sn = rh.getARequestSource() and
          source = sn.getAPropertyRead("query").getAPropertyRead()
      )
  }
  override predicate isSink(DataFlow::Node sink) {
    exists(CallExpr call |
        call.getCalleeName() = "readFile" and
        sink.asExpr() = call.getArgument(0)
      )
  }

  override predicate isAdditionalTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
    exists(CallExpr call |
        call.getCalleeName() = "join" and (
            pred.asExpr() = call.getAnArgument() and succ.asExpr() = call
        )
    )
  }

}

from FileReadFromUserInput cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink.getNode(), source, sink, "$@ flows to $@ ", source.getNode(), source.toString(), sink.getNode(), sink.toString()

Select 的结果中也可以看到数据流向

DraggedImage-6

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.