Sorting Yourself Into a Use-After-Free: a Forever-Day in the BlackBerry Bold 9780's 2010 WebKit
contents
It is 2026 and this is a browser bug from 2010 - a comparator-reentrancy use-after-free in
Array.prototype.sort, in the decade-old JavaScriptCore that still ships on a BlackBerry Bold
9780. The rest of WebKit fixed this shape years ago; this frozen fork never will. A forever-day,
confirmed crashing the real browser from a webpage, reversed to the stale write-back, and driven
to an attacker-controlled cross-object write - with no ASLR and no NX, and an honest account of
exactly where it stops.
The bug is from 2010. I confirmed it in 2026. Nothing in the sixteen years between ever touched it.
Not that it was discovered in 2010 - it has been sitting in a shipping browser, untouched, that entire time. Every other WebKit on the planet got the fix for the shape below more than a decade ago. This one never will, because the only place it still runs is a phone whose vendor switched the whole platform off in January 2022. The jargon for that is a forever-day: unpatched, unpatchable, and immortal for as long as the hardware keeps booting.
The patient is a BlackBerry Bold 9780 - BBOS 6.0.0.294, a 624 MHz Marvell PXA930
(ARMv5TE), and the “Olympia” browser on a 2010-era JavaScriptCore (it reports
AppleWebKit/534.1+). No ASLR. No DEP/NX. A single-core CPU that predates almost every mitigation
a modern exploit has to fight.
The premise was almost lazy: a sixteen-year-old JavaScript engine cannot have survived sixteen
years of public WebKit bug-hunting intact. It has not - and the reason is less flattering than
“discovery”. The fixes for bugs exactly like this one landed in mainline WebKit years ago and
simply never reached this frozen fork, so finding one here is closer to archaeology than research.
The vulnerability was always sitting there. It just outlived everyone who cared to look. This is
the first one taken all the way down - a use-after-free in Array.prototype.sort with a very old,
very clean shape - what it is, that it crashes the real device from a webpage, and how far a single
controlled heap write gets you when there is nothing in the way.
Everything here is on a device I own, on a dead platform with no live users and no vendor left to report to. It is a historical/curiosity teardown, not a live-fire disclosure. And to be clear up front, because pwn.dog does not do “PoC || GTFO” theatre with the important part blurred: this never reached code execution. It reached a confirmed, controlled memory-corruption primitive. The distinction is the whole back half of the post.
TL;DR:
- The bug:
Array.prototype.sortcaches the array’s backing-store pointer and element count, then calls your comparator. Your comparator grows the array, which reallocates (and frees) that backing store.sortkeeps using the stale pointer - it reads from it and writes the sorted result back into it. Classic comparator-reentrancy UAF, patched in mainline WebKit years ago, alive and well here. - It is real on the hardware: a webpage that triggers it crashes and restarts
net_rim_bb_browser_daemonon the physical 9780, with a clean control differential (the benign comparator does not). - The write-back is bounded but attacker-controlled: it writes exactly
min(length, vectorLength)values - decided before your comparator runs - into the freed block, so it fills the block precisely and never overflows. The values it writes are your sorted array elements. - Driven to a cross-object write: groom the heap so a controlled object reclaims the freed
block inside the comparator, and
sort’s stale write-back stamps your chosen values straight into a different live object. Demonstrated in the simulator: an array we filled entirely withMath.PIcame back holding1000000, 1000001- values that only ever existed in the sort array. Browser survives; JavaScript reads them back. - No ASLR, no NX means the remaining chain (an info leak via 8-bit string bytes, a fake backing store for arbitrary read/write, a jump to a benign marker) is unusually tractable - but it is not built here, and the honest reason it stopped is in the last section.
The patient
| Device | BlackBerry Bold 9780 |
| OS | BlackBerry OS 6.0.0.294 |
| SoC | Marvell PXA930, ARMv5TE, single core |
| Browser | “Olympia” BlackBerry Browser |
| Engine | WebKit 534.1+ / JavaScriptCore, ~2010 vintage |
| JSValue model | JSVALUE32_64 (32-bit payload + 32-bit tag) |
| Mitigations | no ASLR, no DEP/NX |
A quick capability probe from JavaScript sets the exploitation constraints, and they are firmly pre-2011:
ArrayBuffer = undefined Float64Array = undefined
Uint32Array = undefined DataView = undefined
WeakMap = undefined JSON = object
Object.getOwnPropertyNames = function __proto__ = object
No typed arrays at all - so the modern Float64Array/Uint32Array trick for reading a pointer’s
raw bits is off the table. Strings are 8-bit (Latin1), one byte per character, which matters
later: charCodeAt over a corrupted string buffer is the era-appropriate way to read raw bytes
back as numbers.
The JSValue encoding is the standard 32-bit split - an 8-byte value is a 32-bit payload followed by a 32-bit tag:
Tag (@+4) | Meaning | Payload (@+0) |
|---|---|---|
0xffffffff | Int32 | the integer |
0xfffffffe | Cell | pointer to a JSCell |
0xfffffffb | Null | - |
0xfffffffa | Undefined | - |
0xfffffff9 | Empty (hole) | - |
0xfffffff8 | Deleted | - |
< 0xfffffff8 | it is a double | the low 32 bits of the IEEE-754 value |
The one consequence to hold onto: to forge an object (fakeobj) you need to write a value whose
tag is 0xfffffffe and whose payload is an address you choose. You cannot just store such a
double from JavaScript - a double with a high dword of 0xfffffffe is a NaN, and the engine
purifies NaNs on store to 0x7ff8000000000000. The only way to place that bit pattern is to
corrupt it in. Which is exactly what a heap UAF is for.
Five ways to corrupt the heap
A pass over this JavaScriptCore and its WebCore surface turned up five distinct memory-corruption bugs, all long-since fixed upstream:
| # | Bug | Class |
|---|---|---|
| 1 | Array.prototype.sort comparator reentrancy | use-after-free |
| 2 | Range::processContents mutation reentrancy | use-after-free |
| 3 | Range boundary-point reentrancy (variant) | use-after-free |
| 4 | Range container reentrancy (variant) | use-after-free |
| 5 | SVG SMIL animation teardown | use-after-free |
They share a decade-old theme: callback reentrancy against a cached raw pointer. JavaScript runs in the middle of a native operation that assumed the world would hold still, mutates the structure the native code cached, and the native code keeps going against freed memory.
The sort bug is the one worth taking all the way down. It is the cleanest to trigger (no DOM,
no Range, no SVG timeline - just an array and a comparator), the write it produces is
attacker-valued rather than incidental, and the whole thing fits on one screen.
The bug: sort trusts a pointer it already freed
Here is the sort routine (OlympiaWebKitLDLL.dll @ FUN_1009b940), cleaned up from Ghidra. I have
trimmed the merge/tree bookkeeping to expose the shape; the pointer discipline is exact:
void JSArray_sort(JSArray *this, JSValue *comparator, ...)
{
ArrayStorage *S = this->m_storage; // <-- cached ONCE, up front
unsigned len = S->m_length;
if (len == 0 || len > 0x7fffffff) return;
// nProcess = min(m_length, m_vectorLength), captured NOW, before your code runs
unsigned nProcess = (len <= this->m_vectorLength) ? len : this->m_vectorLength;
// ---- compaction: read S's vector element by element, INVOKING THE COMPARATOR ----
// (this is where your JS runs; this is where S gets freed under the engine's feet)
build_sorted_run(this, comparator, S /* stale after the first grow */, nProcess, buffer);
// ---- write-back: three loops, all into the CACHED S ----
for (i = 0; i < reals; i++) S->m_vector[k++] = buffer[sorted_index(i)]; // your values
for (i = 0; i < undefineds; i++) S->m_vector[k++] = jsUndefined(); // 0xfffffffa
for (; k < nProcess; ) S->m_vector[k++] = jsHole(); // 0xfffffff9
S->m_numValuesInVector = reals + undefineds;
}
Two facts do all the work:
SandnProcessare captured before the comparator is ever called. Nothing that happens inside your comparator updates them.sortwill read fromSand write toSfor the rest of the call, no matter what you do to the array.- The engine invokes your comparator during compaction - it reads an element out of
S’s vector, then calls you to compare it. So your JavaScript is running whilesortis midway through walking a pointer it cached and you are about to invalidate.
The trigger is four lines:
var arr = [];
for (var i = 0; i < 64; i++) arr[i] = 1000000 + i; // dense small array
arr.sort(function (a, b) {
arr[512] = 2000000; // grow past vectorLength -> realloc -> the OLD storage S is freed
return a - b; // sort keeps reading/writing the freed S for the rest of the call
});
arr[512] = 2000000 forces increaseVectorLength, which allocates a new, larger backing store
and frees the old one - the very block S still points at. From here sort is reading its
comparison inputs out of freed memory and, at the end, writing the sorted result back into it.
A subtle and useful property: because nProcess was fixed at entry (min(length, vectorLength) = 64 for our array), the write-back writes exactly 64 values into the freed block - filling it
precisely, never one slot past it. This is not a linear overflow. It is a precisely bounded
write into a freed allocation, which is a much friendlier primitive to groom than a smash.
(One honest caveat on provenance: the decompilation above is from the standalone
OlympiaWebKitLDLL.dll, which is where the routine is cleanest to read. The browser on the device
and the Jvm.dll inside the Fledge simulator are different builds of the same WebKit source - the
compiled bytes differ, so a byte-signature from one does not match the other - but the sort logic,
and the min(length, vectorLength)-captured-at-entry write-back, are the same source and behave
identically. Where I claim device behaviour below, it is from the device; where I claim
byte-level layout, it is from the reversed binary.)
Confirming it on real hardware
A minidump-and-Ghidra story is only worth anything if the bug actually fires on the metal. It does.
The test harness is a webpage served over the LAN that beacons its progress out as image
requests - (new Image()).src = "http://host:8080/b?s=<stage>" - so the sequence of stages shows
up in the web server’s access log even when the browser dies. Two pages: the trigger above, and a
control with a benign comparator that never grows the array.
- Control: reaches its final stage every time. The browser is fine.
- Trigger: the last beacon before it goes dark is the sort entry. The page never returns from
sort. On the device,net_rim_bb_browser_daemonfaults and the OS restarts it (visible in the event log as the daemon respawning) - you get kicked back to a fresh browser.
That control differential - identical page, identical spray, benign-vs-growing comparator, and only the growing one takes the daemon down at the sort - is the confirmation that the crash is the UAF and not incidental memory pressure. The bug is live on a stock 9780.
The object model, reversed
To turn the crash into a controlled write you need the shape of the two structures the write-back touches. Both came out of the reversed engine.
ArrayStorage - the malloc’d backing store. The vector is inline at +0x18; the block is
0x18 + vectorLength * 8 bytes:
+0x00 m_length (unsigned)
+0x04 m_numValuesInVector (unsigned) <- write-back sets this
+0x08 m_sparseValueMap (pointer; NULL => DENSE => vulnerable path)
+0x10 m_allocBase (pointer)
+0x18 m_vector[0..] (inline JSValue array) <- write-back writes here
JSArray - the GC-allocated cell that points at the storage:
+0x00 m_structure
+0x30 m_vectorLength
+0x34 m_indexBias
+0x38 m_storage (-> ArrayStorage above)
The dense path (m_sparseValueMap == NULL) is the one the write-back takes and the one that keeps
the stale S; the sparse path re-reads m_storage and would defuse the bug, so grooming has to
stay dense (which small, contiguously-indexed arrays do).
One layout detail that shaped every experiment: a 64-element array built by
for (i<64) arr[i]=v does not end up with vectorLength == 64. WebKit over-allocates on
growth (roughly 1.5x), so a freshly-filled 64-element array sits at vectorLength ~= 94. That is
why arr[64] = x does not free anything (it fits) but arr[512] = x does. It is also why
same-construction victims (a 64-element array of some sentinel) match the freed block’s size class
exactly, for free - which is what makes the reclaim reliable without knowing the number.
From crash to control
A UAF that only crashes is a denial of service. The interesting question is whether you can make
your object land on the freed block and have sort’s stale write-back stamp your chosen bytes
into it. That is the pivot from “crash” to “primitive”.
The obstacle is timing. sort frees S inside the first comparator call, then keeps reading S
for the remaining elements during compaction. If nothing valid is sitting on S when it reads,
you get garbage cells and a crash before the write-back. So the reclaim has to happen inside
that first comparator call, immediately after the free, before compaction reads S again:
var PI = Math.PI;
function mk64() { var a = []; for (var j=0;j<64;j++) a[j]=PI; return a; } // matches freed size
var victims = [], nc = 0;
var arr = []; for (var i=0;i<64;i++) arr[i] = 1000000 + i;
arr.sort(function (a, b) {
if (++nc === 1) {
arr[512] = 2000000; // free S
for (var s=0; s<500; s++) victims.push(mk64()); // reclaim S right now, same call
}
return a - b;
});
One of those 500 same-sized arrays reclaims the freed block. The subsequent stale reads land on
valid PI doubles instead of freed garbage, so sort runs to completion and returns control to
JavaScript. Then the write-back has already stamped the sorted values into whichever victim took
the block. Read the victims back and look for one that no longer holds PI:
o3_ret_nc346 // sort returned - browser alive, we have control back
o4_changedVics1_first0 // one PI-filled victim was modified
o5_ 62:1000000 63:1000001
That last line is the primitive. victims[0] is an array we filled entirely with Math.PI.
After the sort, slots 62 and 63 hold 1000000 and 1000001 - values that only ever existed in
the sort array (arr[0] and arr[1], read into the sort buffer before the free, sorted to the
top of the PI run). sort wrote attacker-chosen data, through a dangling pointer, into the
storage of a completely separate live object, and the browser stayed up to let us read it out.
That is a controlled cross-object write - the hard pivot every heap-UAF exploit has to make.
Honest scope of this result: the bug firing and crashing the browser is confirmed on the physical 9780. The controlled reclaim-and-stamp above is confirmed in the Fledge 9800 simulator, which runs the same JavaScriptCore and the identical object model. It is not yet reproduced on the device - and the last section is the honest reason why.
Why the simulator fights back (and the device is the real target)
The natural next move is to develop the rest of the chain in the simulator, where you can attach a debugger. The simulator turns out to be a hostile place to do it, in instructive ways:
- It is an assert-enabled build. Most inconsistent-ArrayStorage states trip a “Catastrophic Assertion Failure” that takes the entire VM down. The one recipe above threads the asserts; the variations you need for the next steps mostly do not. The device, a release build, has no such checks.
- One shared heap. Fledge crams the OS, the JVM, and the browser into a single process with
one allocator, so an over-aggressive groom reclaims the simulator’s own structures and it dies
natively rather than at the JS layer. The device runs the browser as an isolated process
(
net_rim_bb_browser_daemon) with its own heap - the corruption stays where you put it. USE_SYSTEM_MALLOC, invisible to!heap. The blocks we care about are not in a heap the Windows debugger extensions track, and the GC reclaims measurement sentinels out from under you, so even reading a block size fights you.
The takeaway is a genuinely counter-intuitive one: the real device is the friendlier exploitation target than its official simulator. Release build, isolated heap, no asserts. It is also, of course, the target that actually matters.
Where it stops, and why
With no ASLR and no NX, the remaining chain is the textbook classic-era sequence, and each step has a clear route on this engine:
- Info leak /
addrof. Reclaim the freed block with an 8-bit string instead of an array; let the write-back stamp a real object’s{pointer, CellTag}into the string’s character buffer; read the pointer’s bytes back withcharCodeAt. No typed arrays needed - this is how you read raw memory as numbers in a 2010 engine. - Arbitrary read/write. Use the same primitive to forge a fake
ArrayStorage(afakeobjwith avectorLengthyou control), turning it into a relative-then-absolute read/write. The NaN-purification problem from the intro is exactly why this has to come through the UAF’s corrupting write rather than a plain store. - Execution + a benign marker. With no NX, data pages are executable and addresses are static; redirecting one call target to a small, harmless “I ran” marker is the demonstration - no payload, no persistence, no C2. Just proof the last box is checked.
That chain is not built in this post, and I want to be straight about the reason rather than dress it up as scope discipline.
Developing the back half needs the crash-prone experiments - the ones that lose the reclaim race and take the browser daemon down. On this aging device that is not free: repeated daemon crashes, plus the forced reboots they trigger, wedged the OS filesystem badly enough to produce an App Error 603 boot loop (the JVM failing to load OS modules). It recovered - a security wipe rebuilt the object store and the browser module re-registered - but it was one torn flash write away from needing a full OS reflash, on hardware whose OS you can no longer download from the vendor.
To be equally clear about what that episode was not: it was not the exploit escaping its sandbox and corrupting system files. We never reached code execution, so there was never a primitive that could write to an OS module. The corruption lived entirely in the browser process’s heap; the daemon crashed and got restarted, which is the OS’s normal fault handling. The 603 was mundane flash-filesystem damage from many unclean shutdowns on old NAND, not a boundary breach - a distinction worth stating plainly, because “browser JS bug bricks the phone by escaping the sandbox” would be a far bigger and different claim than the one actually earned here.
So this is where the honest line is drawn: a confirmed, real-hardware, comparator-reentrancy use-after-free, reversed to the exact write-back semantics, and driven to an attacker-controlled cross-object write - stopping short of code execution because finishing it safely means not risking the only device it runs on. The remaining steps are scoped, not hand-waved; if they get built, it will be on a second unit kept purely for the crash-prone work.
Bug map
| Component | Location | Role |
|---|---|---|
Array.prototype.sort | OlympiaWebKitLDLL.dll @ FUN_1009b940 | comparator-reentrancy UAF; caches S + nProcess at entry |
| write-back loops | within FUN_1009b940 | min(len,vectorLength) values into stale S; reals, then jsUndefined (0xfffffffa), then jsHole (0xfffffff9) |
ArrayStorage | +0x18 inline vector | reclaim target; block size 0x18 + vectorLength*8 |
JSArray | +0x38 m_storage, +0x30 m_vectorLength | the cell whose storage gets freed and reused |
| trigger | arr[512]=x inside comparator | grow past vectorLength ~= 94 -> realloc -> free S |
| reclaim | 500x same-size array in comparator call #1 | wins the block before compaction re-reads S |
| other bugs | Range::processContents (x3), SVG SMIL | same reentrancy-vs-cached-pointer class; not developed here |
Static RE of OlympiaWebKitLDLL.dll in Ghidra; dynamic confirmation on a physically-owned
BlackBerry Bold 9780 (BBOS 6.0.0.294) and in the BlackBerry 9800 Fledge simulator, over a LAN
beacon channel and (simulator only) a dbgeng attach. BlackBerry OS has been end-of-life since
January 2022 - no live users, no vendor to disclose to. Research and documentation only; no code
execution was achieved and no weaponized payload was built.