client-supplied server-run javascript again

Wkhtmltopdf is a project that renders html documents as pdfs. For a while now it has been unmaintained and comes with multiple warnings to the effect of "don't run this software on untrusted html or else...". This post details wkhtmltopdf exploitation for arbitrary code execution.

Despite the warnings it is still used by some web applications to produce dynamic pdfs. Some projects are not aware of the issue, understandably since this is often a vulnerabilty in the dependency (webkit) of a dependency (wkhtmltopdf wrapper such as wicked pdf)'s dependency (wkhtmltopdf). Or even one layer deeper if we see webkit as qt dependency. Other projects are aware of the issue and perform input sanitization before rendering the pdf. There's also the possibility of using wkhtmltopdf with an up-to-date shared library version of qt, in which case the content of this blog post does not apply.

To put things simply, years of client-side browser security lessons get flipped server side.

browser archeology

It's time for some browser archeology. Wkhtmltopdf is based on a version of webkit that has been frozen in time and gets statically linked into the release binaries. Looking at the changelog from the source repo it dates back to 2012. After enough web searches and historical poc attemps, we end up with CVE-2012-3748. It comes with a blog post from Chris Evans targeting the Konqueror browser.

It is sufficient to replace the offsets from the Konqueror poc with the ones from wkhtmltopdf to pop xcalc. In the next section we try to improve on this poc.

getting rid of offsets

The base poc relies on multiple file offsets within the wkhtmltopdf and libc build:

This is a hassle when wkhtmltopdf provides 50 different binaries on their download page. Can't we get rid of file offsets?

arbitrary address read

Maybe instead of trying to get an arbitrary read/write right away we could try to first get an arbitrary read and then use it to read the vtable pointer from var obj = new Uint8Array.

The solution here is to alias a StringImpl. This is the string implementation behind javascript string objects. StringImpl contain a data pointer to UTF-16 chars that will be readable from javascript!

arbitrary address write

In practice we won't need the arbitrary write for our final payload, but it was our intermediate goal so I left it in jsArr[0] as in the original poc.

arbitrary command execution

To go from our current primitives to arbitrary code execution, a common technique is to first have a function be jit compiled by calling it multiple times. Then, an arbitrary read/write can be used to find the writable and executable memory section for this jit-compiled function and overwrite it with shellcode. This would meets our goal of requiring no file offsets but it is not available since webkit is compiled without jit.

An alternative would be to use the arbitrary read/write to search for rop gadgets and write a rop chain on the stack. We did not opt for this solution mainly because scripts can only run for a short period of time in wkhtmltopdf.

Instead we rely on a single magic jump address within the binary. For example, if system was imported from libc for example we could jump to system from a context where we control rdi. execv is imported but one would need to find a context where rdi and rsi are controlled in addition to rip. This is why we target QProcess::startDetached(QString*).

As apparent from our previous read/write vtable offset conundrum, this version of webkit still uses javascript object with vtables that we can hijack. The plan is to find a virtual function call where this (rdi) is at the same time our fake object and a QString. Replacing the corresponding vtable offset with QProcess::startDetached will then allow us to run arbitrary commands.

A QString is a pointer to an internal Data structure. This will match the first eight bytes of our fake object, its vtable. At the vtable/data struct address, this will succeed as long as the internal QString Data struct does not overwrite the vtable offset for startDetached.

When calling fakeobj() from javascript, the JSCell getCallData virtual function is called at offset 0x28 in the vtable. This is plenty of space for the data struct.

    QString Data struct        fakeobj vtable
     ------------------     ---------------------
+0x0 |                |     |                   |
+0x8 |char length     |     |                   |
+0x10|pointer to +0x30|     |                   |
    ...              ...   ...                 ...
+0x28|                |     |startDetached ptr  |
+0x30|utf-16 char     |     |                   |
    ...              ...   ...                 ...

As a last note here are three methods to find the QProcess::startDetached offset. First, there is an xref chain from "firefox" to QProcess::startDetached("firefox" + url). Second, the build process is available so the exact bytes minus relocation can be found in qprocess.o. Third, worst case it should be bruteforceable.

poc source



June 12ᵗʰ 2024