GHES CVE-2026-17556 ヘッダがパスになる脆弱性

自社運用の GitHub、GitHub Enterprise Server(GHES)に、認証なしでインスタンス上のファイルとディレクトリを削除できる脆弱性が見つかっている。CVE-2026-17556 である。

読み取られるのではない。消される。 しかも、外部に見せない設定(private mode)を有効にしていても成立する。

この記事では、なぜ「公開していないから安全」が効かないのかを仕組みから読み解き、最後に Node.js で同じ形の誤りを再現し、正しい検査の書き方を確かめるスクリプトを置く。

何が起きるのか

NVD の記述を整理すると、成立の筋道はこうなる。

GHES はアップロード用のバッファディレクトリを決めるとき、**X-GitHub-Request-Id というリクエストヘッダの値を、サニタイズせずにファイルシステムのパスの一部として使っていた。** そのためヘッダに上位ディレクトリへ遡る値を入れると、バッファの位置を任意のパスへ向けられる。

GHES はアップロード後にこのバッファを遅延して片付ける処理を持っている。その片付けが再帰的に対象を削除する。

つまり流れは2段構えだ。

  1. ヘッダで「置き場所」を本来と違う場所に向ける
  2. あとから走る掃除処理が、その場所を丸ごと消す

削除対象には、Git LFS のオブジェクト、リリースの成果物、添付ファイル、アバターを収めた利用者ストレージのディレクトリ全体が含まれる。

成立条件が緩すぎる

CVSS は NVD の主評価で 3.1 の 9.1(Critical)、ベクタは AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H

このベクタは読み方が大事なので分解しておく。

  • PR:N … 権限不要。アカウントが要らない
  • UI:N … 利用者の操作不要
  • AC:L … 攻撃条件の複雑さが低い。条件が揃うのを待つ必要がない
  • C:N … 機密性への影響は無い。データは外へ出ない
  • I:H / A:H … 完全性と可用性が High

必要なのはインスタンスへネットワーク到達できることだけである。そして NVD は、private mode を有効にしていても成立したと明記している。

ここが一番効く。「社外に公開していないから優先度は低い」という判断が、この件では成り立たない。アプリケーション側の公開設定は迂回されるので、効くのはネットワーク層で到達そのものを断つことだけだ。

C:N にも注意がいる。データが漏れないぶん、気づくのが遅れる。 侵入検知は「持ち出された」ことを探すように作られていることが多く、「消えた」は静かに起きる。

影響範囲と修正版

  • 影響: 3.22 より前のすべてのバージョン
  • 修正版: 3.21.4 / 3.20.6 / 3.19.10 / 3.18.13 / 3.17.19

系列ごとに修正版が出ているので、系列をまたぐ必要は無い。 いま 3.19 系なら 3.19.10 へ上げれば足りる。GHES の版上げは互換性の確認が要る作業だが、この件は同一系列内のパッチ更新で塞げる。大きな移行計画を待つ理由はない。

GitHub の Bug Bounty プログラム経由で報告されたもので、2026-08-29 時点で CISA の KEV(悪用が確認された脆弱性カタログ)には収載されていない。ただし PR:N かつ AC:L なので、KEV に載っていないことを安全の根拠にはできない。

実際に試す: なぜヘッダをパスに使ってはいけないか

ここからは手元で動かせる。Node.js 20 以降があれば依存は要らない。GHES を用意する必要もない。同じ形の誤りを、最小のコードで再現する。

trav.js として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const path = require("node:path");
const BASE = path.resolve("/var/lib/app/upload-buffer");

// BASE の外へ出たかを正しく判定する
function escapes(base, joined) {
const rel = path.relative(base, joined);
return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
}

// 受け取ってよい形だけを通す(allowlist)
function safeSegment(v) {
return /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;
}

const cases = ["abc123", "../../../../var/lib/app/storage", "..%2f..%2fstorage", "/etc", ".."];

console.log("header value".padEnd(36), "resolve した結果".padEnd(46), "BASE の外?");
for (const h of cases) {
const joined = path.resolve(BASE, h);
console.log(JSON.stringify(h).padEnd(36), joined.padEnd(46), escapes(BASE, joined) ? "YES" : "no");
}

console.log("\n--- allowlist で受け取る場合 ---");
for (const h of cases) {
const s = safeSegment(h);
console.log(JSON.stringify(h).padEnd(36), s === null ? "reject" : path.join(BASE, s));
}

実行する。

1
node trav.js

出力はこうなる。

1
2
3
4
5
6
7
8
9
10
11
12
13
header value                         resolve した結果                                   BASE の外?
"abc123" /var/lib/app/upload-buffer/abc123 no
"../../../../var/lib/app/storage" /var/lib/app/storage YES
"..%2f..%2fstorage" /var/lib/app/upload-buffer/..%2f..%2fstorage no
"/etc" /etc YES
".." /var/lib/app YES

--- allowlist で受け取る場合 ---
"abc123" /var/lib/app/upload-buffer/abc123
"../../../../var/lib/app/storage" reject
"..%2f..%2fstorage" reject
"/etc" reject
".." reject

この出力から読み取れることが3つある。

1つ目。path.resolve は絶対パスを渡されると基準を捨てる。 "/etc" を渡した行を見てほしい。結果は /etc であって、/var/lib/app/upload-buffer/etc ではない。path.join なら基準の下に付くが、resolve は付かない。どちらを使っているかで挙動が変わるので、既存コードを読むときはここを確認する。

2つ目。.. は1段でも十分に危ない。 4段遡らなくても、".." 単体で /var/lib/app に届いている。再帰削除がこの位置に走れば、バッファの隣にある本番データごと消える。

3つ目。文字列の前方一致だけの検査は取りこぼす。 "..%2f..%2fstorage" の行が no になっているのが正しい。これは URL エンコードされたままの文字列で、ファイルシステム上は ..%2f..%2fstorage という名前のディレクトリでしかなく、BASE の外には出ていない。

もし検査を path.relative(...).startsWith("..") とだけ書いていたら、この行は誤って YES になる。安全なものを危険と判定する誤りだが、これも実害がある。 正常なリクエストが落ちるので、運用側は「検査が厳しすぎる」と判断して緩めたくなる。上のコードが rel === ".."rel.startsWith(".." + path.sep) の2条件に分けているのはこのためだ。

そして根本の対処は下半分のほうである。遡る表記を弾くのではなく、通す形を決める。 safeSegment は英数字・ハイフン・アンダースコアの1〜64文字だけを通す。エンコードの解釈も、区切り文字の扱いも、OS ごとの差も、この検査の外側に出せる。

外部から来た値をパスに使う場面では、この allowlist の形を先に置くほうが、あとから抜け道を塞いで回るより短く済む。

更新の前に確認しておくこと

対応は修正版への更新だが、その前に見ておくべきものがある。

消えていないかを確かめる。 利用者ストレージのディレクトリ(Git LFS オブジェクト、リリース成果物、添付ファイル、アバター)が意図せず失われていないか。Git のリポジトリ本体は無事でも、LFS の実体だけが消えているという形になりうる点に注意する。リポジトリを clone できることは、この件の無事の証明にならない。

バックアップから戻せるかを確かめる。 復旧の可否は、更新作業とは別に、いま確認しておく。C:N の脆弱性は発覚が遅れるので、気づいた時点でバックアップの保持期間を過ぎている可能性がある。

更新までのつなぎ。 GHES の前段にリバースプロキシや WAF を置いているなら、X-GitHub-Request-Id ヘッダにパス区切りや遡る表記を含むリクエストを落とすことで到達を減らせる。ただしこれはベンダが正式な回避策として示したものではないので、更新の代わりにはならない。

まとめ

  • X-GitHub-Request-Id ヘッダをサニタイズせずにパスへ使っていたのが原因。遅延削除が再帰的に走るため、指した場所が丸ごと消える
  • 認証不要・利用者操作不要・private mode でも成立。 公開設定では塞げない
  • 3.22 より前の全バージョンが該当。修正版は 3.21.4 / 3.20.6 / 3.19.10 / 3.18.13 / 3.17.19 で、同一系列内で上げられる
  • 外部由来の値をパスに使うなら、遡る表記を弾くのではなく 通す形を allowlist で決める

この CVE の対応状況(AWS / GCP / Azure / Linux 別)は CVE Watch にまとめてある。

出典は NVD の CVE-2026-17556(2026-08-05 公開・2026-08-18 最終更新・vulnStatus は Analyzed)と、GitHub Enterprise Server のリリースノート(3.21.4 / 3.20.6 / 3.19.10 / 3.18.13 / 3.17.19)である。本文中の出力例は、上記スクリプトを Node.js 20.17.0 で実際に実行した結果をそのまま貼っている。


GHES CVE-2026-17556 ヘッダがパスになる脆弱性
https://blog.hashito.biz/2026/08/29/ghes-cve-2026-17556-request-header-path-traversal/
著者
hashito
作成日
2026年8月29日
著作権