Codeql 原理概述#
codeql 整個漏洞掃描可以分為兩部分:
- AST 資料庫創建,通過命令行工具即可
- 規則編寫,類似 SQL 的語法來編寫漏洞查詢規則
分為編譯型語言和非編譯型語言
- 非編譯型語言,如 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
函數都會被認為是存在漏洞
所以我們面臨一個問題,即如何區分正常的代碼和有污點的代碼 sink
污點分析原理#
如圖,我們定義
- source:數據源,代表直接引入不受信任的數據或者機密數據到系統中
- sink:污點匯聚點,代表直接產生安全敏感操作或者洩露隱私數據到外界
至於其中經過的一個個 Node,可以認為是詞法分析裡最小的一個個 Token,代表著 if、while、for、函數調用等
污點分析的步驟:
只要我們標記了 source 和 sink,codeql 會判斷只要滿足有一條路徑能夠從 source 流向 sink ,就會認為這是一個漏洞
污點分析的局限#
官方沒有公開原理,但是經過多輪測試我們發現 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 所經過的代碼路徑為如下四種(代碼特性的除外需要額外做判斷)
- 經過一系列正常的 if、while 等正常流程到達 sink
- 經過一個 filter 函數過濾到達 sink
- 經過一個 check 函數,如果不滿足條件,則無法到達 sink,但形式上還是可以流向 sink 的
- 經過一個正常的 join 等函數到達 sink
- 數據源通過無害處理到達污點 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 一下看看結果:
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 的結果中也可以看到數據流向