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は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 の結果でもデータフローの方向が確認できます