August 22, 2017

Bitdefender: Heap Buffer Overflow via 7z LZMA

A few days after having published the post about the Bitdefender stack buffer overflow via 7z PPMD1, I discovered a new bug in Bitdefender’s product. While this is a 7z bug, too, it has nothing to do with the previous bug or with the PPMD codec. Instead, it concerns dynamic memory management. In contrast to the previous post2, which described an arbitrary free vulnerability in F-Secure’s anti-virus product, this post presents the first heap buffer overflow of this blog series.

Introduction

For the write-up on the 7z PPMD bug, I read a lot of the original 7-Zip source code and discovered a few new things that looked promising to investigate in anti-virus products. Therefore, I took another stab at analyzing Bitdefender’s 7z module.

I previously wrote about relaxed file processing. The Bitdefender 7z PPMD stack buffer overflow1 was a good example of relaxed file processing by removing a check (that is, removing code).

This bug demonstrates another fundamental difficulty that arises when incorporating new code into an existing code base. In particular, a minimal set of changes to the new code is often inevitable. Mostly, this affects memory allocation and code that is concerned with file access, especially if a totally different file abstraction is used. The presented bug is an example of the former type of difficulty. More specifically, an incorrect use of a memory allocation function that extends the 7-Zip source code in Bitdefender’s 7z module causes a heap buffer overflow.

Getting Into the Details

When Bitdefender’s 7z module discovers an EncodedHeader3 in a 7z archive, it tries to decompress it with the LZMA decoder. Their code seems to be based on 7-Zip, but they made a few changes. Loosely speaking, the extraction of a 7z EncodedHeader is implemented as follows:

  1. Read the unpackSize from the 7z EncodedHeader.
  2. Allocate unpackSize bytes.
  3. Use the C API of the LZMA decoder that comes with 7-Zip and let it decompress the stream.

The following snippet shows how the allocation function is called:

1DD02A845FA lea     rcx, [rdi+128h] //<-------- result
1DD02A84601 mov     rbx, [rdi+168h]
1DD02A84608 mov     [rsp+128h], rsi
1DD02A84610 mov     rsi, [rax+10h]
1DD02A84614 mov     [rsp+0E0h], r15
1DD02A8461C mov     edx, [rsi]      //<-------- size
1DD02A8461E call    SZ_AllocBuffer

Recall the x64 calling convention. In particular, the first two integer arguments (from left to right) are passed via rcx and rdx.

SZ_AllocBuffer is a function within the Bitdefender 7z module. It has two arguments:

  • The first argument result is a pointer to which the result (a pointer to the allocated buffer in case of success or NULL in case of a failure) is written.
  • The second argument size is the allocation size.

Let us look at the functions’s implementation.

260ED3025D0 SZ_AllocBuffer proc near
260ED3025D0
260ED3025D0 mov     [rsp+8], rbx
260ED3025D5 push    rdi
260ED3025D6 sub     rsp, 20h
260ED3025DA mov     rbx, rcx
260ED3025DD mov     edi, edx //<-------- edi holds size
260ED3025DF mov     rcx, [rcx]
260ED3025E2 test    rcx, rcx
260ED3025E5 jz      short loc_260ED3025EC
260ED3025E7 call    near ptr irrelevant_function
260ED3025EC
260ED3025EC loc_260ED3025EC:
260ED3025EC cmp     edi, 0FFFFFFFFh  //<------- {*}
260ED3025EF jbe     short loc_260ED302606
260ED3025F1 xor     ecx, ecx
260ED3025F3 mov     [rbx], rcx
260ED3025F6 mov     eax, ecx
260ED3025F8 mov     [rbx+8], ecx
260ED3025FB mov     rbx, [rsp+30h]
260ED302600 add     rsp, 20h
260ED302604 pop     rdi
260ED302605 retn
260ED302606 ; ------------------------------------
260ED302606
260ED302606 loc_260ED302606:                        
260ED302606 mov     rcx, rdi  //<------ set size argument for mymalloc
260ED302609 call    mymalloc
//[rest of the function omitted]

Note that mymalloc is just a wrapper function that eventually calls malloc and returns the result.

Apparently, the programmer expected the size argument of SZ_AllocBuffer to be of a type with size greater than 32 bits. Obviously, it is only a 32-bit value.

It is funny to see that the compiler failed to optimize away the comparison at {*}, given that its result is only used for an unsigned comparison jbe. If you have any hints on why this might happen, I’d be very interested to hear them.

After SZ_AllocBuffer returns, the function LzmaDecode is called:

LzmaDecode(Byte *dest, SizeT *destLen, const Byte *src, SizeT *srcLen, /* further arguments omitted */)

Note that dest is the buffer allocated with SZ_AllocBuffer and destLen is supposed to be a pointer to the buffer’s size.

In the reference implementation, SizeT is defined as size_t. Interestingly, Bitdefender’s 7z module uses a 64-bit type for SizeT in both the 32-bit and the 64-bit version, making both versions vulnerable to this bug. I suspect that this is the result of an effort to create identical behavior for the 32-bit and 64-bit versions of the engine.

The LZMA decoder extracts the given src stream and writes (up to) *destLen bytes to the dest buffer, where *destLen is the 64-bit unpackSize from the 7z EncodedHeader. This results in a neat heap buffer overflow.

Triggering the Bug

To trigger the bug, we create a 7z LZMA stream containing the data we want to write on the heap. Then, we construct a 7z EncodedHeader with a Folder that has an unpackSize of (1<<32) + 1. This should make the function SZ_AllocBuffer allocate a buffer of 1 byte.

That sounds nice, but does this actually work?

0:000> g
!Heap block at 1F091472D40 modified at 1F091472D51 past requested size of 1
(2f8.14ec): Break instruction exception - code 80000003 (first chance)
ntdll!RtlpNtMakeTemporaryKey+0x435e:
00007ff9`d849c4ce cc              int     3

0:000> db 1F091472D51
000001f0`91472d51  59 45 53 2c 20 54 48 49-53 20 57 4f 52 4b 53 ab  YES, THIS WORKS.

Attacker Control and Exploitation

The attacker can write completely arbitrary data to the heap without any restriction. A file system minifilter is used to scan all files that touch the disk, making this vulnerability easily exploitable remotely, for example by sending an e-mail with a crafted file as attachment to the victim.

Moreover, the engine runs unsandboxed and as NT Authority\SYSTEM. Hence, this bug is highly critical. However, since ASLR and DEP are in place, successful exploitation for remote code execution might require another bug (e.g. an information leak) to bypass ASLR.

Note also that Bitdefender’s engine is licensed to many different anti-virus vendors, all of which could be affected by this bug.

The Fix

The patched version of the function SZ_AllocBuffer looks as follows:

1E0CEA52AE0 SZ_AllocBuffer proc near
1E0CEA52AE0
1E0CEA52AE0 mov     [rsp+8], rbx
1E0CEA52AE5 mov     [rsp+10h], rsi
1E0CEA52AEA push    rdi
1E0CEA52AEB sub     rsp, 20h
1E0CEA52AEF mov     esi, 0FFFFFFFFh
1E0CEA52AF4 mov     rdi, rdx  //<-----rdi holds the size
1E0CEA52AF7 mov     rbx, rcx
1E0CEA52AFA cmp     rdx, rsi  //<------------{1}
1E0CEA52AFD jbe     short loc_1E0CEA52B11
1E0CEA52AFF xor     eax, eax
1E0CEA52B01 mov     rbx, [rsp+30h]
1E0CEA52B06 mov     rsi, [rsp+38h]
1E0CEA52B0B add     rsp, 20h
1E0CEA52B0F pop     rdi
1E0CEA52B10 retn
1E0CEA52B11 ; -----------------------------------
1E0CEA52B11
1E0CEA52B11 loc_1E0CEA52B11: 
1E0CEA52B11 mov     rcx, [rcx]
1E0CEA52B14 test    rcx, rcx
1E0CEA52B17 jz      short loc_1E0CEA52B1E
1E0CEA52B19 call    near ptr irrelevant_function
1E0CEA52B1E
1E0CEA52B1E loc_1E0CEA52B1E: 
1E0CEA52B1E cmp     edi, esi  //<------------{2}
1E0CEA52B20 jbe     short loc_1E0CEA52B29
1E0CEA52B22 xor     ecx, ecx
1E0CEA52B24 mov     [rbx], rcx
1E0CEA52B27 jmp     short loc_1E0CEA52B3B
1E0CEA52B29 ; -----------------------------------
1E0CEA52B29
1E0CEA52B29 loc_1E0CEA52B29:
1E0CEA52B29 mov     ecx, edi
1E0CEA52B2B call    near ptr mymalloc
//[rest of the function omitted]

Most importantly, we see that the function’s second argument size has been changed to a 64-bit type.

Note that at {1}, a check ensures that the passed size is not greater than 0xFFFFFFFF.

At {2}, the value of rdi is guaranteed to be at most 0xFFFFFFFF, hence it suffices to use the 32-bit register edi. However, just as in the original version (see above), it is useless to compare this 32-bit value once more to 0xFFFFFFFF and it is a mystery to me why the compiler does not optimize this away.

Using a full 64-bit type for the second argument size resolves the described bug.

Conclusion

In a nutshell, the discovered bug is a 64-bit value size being passed to the allocation function SZ_AllocBuffer which looks roughly like this4:

void* SZ_AllocBuffer(void *resultptr, uint32_t size);

Assuming that the size is not explicitly casted, the compiler should throw a warning of the following kind:

warning C4244: 'argument': conversion from 'uint64_t' to 'uint32_t', possible loss of data

Note that in Microsoft’s MSVC compiler, this is a Level2 warning (Level1 being the lowest and Level4 being the highest level). Hence, this bug most likely could have been avoided simply by taking compiler warnings seriously.

For a critical codebase such as the engine of an anti-virus product, it would be adequate to treat warnings as errors, at least up to a warning level of 2 or 3.

Nevertheless, the general type of bug shows that even if only few lines of additional code are necessary to incorporate external code (such as the 7-Zip code) into a code base, those very lines can be particularly prone to error.

Do you have any comments, feedback, doubts, or complaints? I would love to hear them. You can find my e-mail address on the about page.

Alternatively, you are invited to join the discussion on HackerNews or on /r/netsec.

Timeline of Disclosure

  • 2017-07-24 - Discovery
  • 2017-07-24 - Report
  • 2017-07-24 - “Thank you for your report, we will investigate and come back with an answer."
  • 2017-08-22 - “confirm that this vulnerability has been patched” and “will have an internal discussion about the reward”
  • 2017-09-12 - Bug bounty paid

Thanks & Acknowledgements

I want to thank Bitdefender and especially Marius for their response as well as for fixing the bug.


  1. https://landave.io/2017/07/bitdefender-remote-stack-buffer-overflow-via-7z-ppmd/ ↩︎

  2. https://landave.io/2017/08/f-secure-anti-virus-arbitrary-free-vulnerability-via-tnef/ ↩︎

  3. EncodedHeader is used to compress multiple headers in case the archive contains more than one file. More information about the 7z file format can be obtained from DOC/7zFormat.txt of the 7-Zip source package. ↩︎

  4. Note that the actual code most likely involves custom type definitions, making this less obvious than it seem to be here (see the comparisons with 0xFFFFFFFF in SZ_AllocBuffer). ↩︎

© 2023 | about