Reproducing a million-dollar bug: WhatsApp CVE-2019-11932 (with AFL & Frida)

Closeup on a woman's hands holding a smartphone and texting in front of a yellow shirt

Authors

Ruben Boonen

CNE Capability Lead, Adversary Services

IBM X-Force

Introduction

This post is partially an analysis of a double-free vulnerability (CVE-2019-11932) in an image processing library used by WhatsApp and partially a reference for on-device harness development when fuzzing native libraries on Android. I first learned of this vulnerability by reading a blog post from Awakened, the researcher who disclosed the issue. The author did not elaborate on how this issue was found, and I wanted to understand how hard it would be to rediscover the bug. As we will see, the vulnerability itself is fairly shallow and is easy to reproduce by fuzzing the vulnerable library with AFL++.

This CVE is particularly interesting because the vulnerable library code (android-gif-drawable < v1.2.18) could be triggered remotely by sending someone a malformed GIF file. This primitive was not perfect as it relied on the target taking some manual actions, like opening the WhatsApp image gallery. Additionally, this vulnerability would only be part of a larger component chain that would include additional vulnerabilities, for example, to perform information leaks and to escalate privileges. Still, these types of vulnerabilities are rare and expensive because of the potential human intelligence value they provide. This case also illustrates why it is so important that applications audit the libraries they include in their code base. Large enterprises should perhaps do more to contribute to and improve the security of Open-Source Software (OSS) they employ in their products. A more recent, analogous example resulted in the disclosure of five vulnerabilities in libxml2.

CVE-2019-11932 root cause analysis (RCA)

Based on Awakened’s vulnerability writeup, I focused my efforts on the GIF decoding routine. A GIF file is structured as a header and logical screen descriptor followed by a stream of records for each frame. These records consist of an image descriptor (width, height, position and palette), optional extension blocks (transparency, delays, etc), and compressed pixel data. In decoding.c there is a function, DDGifSlurp, which walks the GIF record streams and builds up per-frame metadata. If decode=true, it extracts raw per-frame pixels. Normally, frames are the same size. This makes sense because when you look at a GIF, you see a series of frames playing in a loop. When frames are the same size, the function will keep reusing the allocation it has created to store the buffer (rasterBits). However, the function does handle cases where the frames are of a different size by calling reallocarray to allocate a new buffer. The realloc function is a combination of free and malloc. If no size is provided, it simply frees the pointer.

Commit df309bb - decoding.c here

void DDGifSlurp(GifInfo *info, bool decode, bool exitAfterFrame) {
    GifRecordType RecordType;
    GifByteType *ExtData;
    int ExtFunction;
    GifFileType *gifFilePtr;
    gifFilePtr = info->gifFilePtr;
    uint_fast32_t lastAllocatedGCBIndex = 0;

...

if (decode) {
    int_fast32_t widthOverflow = gifFilePtr->Image.Width - info->originalWidth;
    int_fast32_t heightOverflow = gifFilePtr->Image.Height - info->originalHeight;
    const uint_fast32_t newRasterSize = gifFilePtr->Image.Width * gifFilePtr-
->Image.Height;
    if (newRasterSize > info->rasterSize || widthOverflow > 0 || heightOverflow > 0) {
        void *tmpRasterBits = reallocarray(info->rasterBits, newRasterSize,
sizeof(GifPixelType));
        if (tmpRasterBits == NULL) {
            gifFilePtr->Error = D_GIF_ERR_NOT_ENOUGH_MEM;
            break;
        }
        info->rasterBits = tmpRasterBits;
        info->rasterSize = newRasterSize;
    }

...

 

If we imagine the first frame has some normal dimensions,40*10, a buffer of 400 bytes is allocated. The second frame has some malformed dimensions, 0*20. In this case, the following is true:

  • widthOverflow is false
  • heightOverflow is true
  • newRasterSize is 0

When reallocarray is called, the allocation size is calculated as 0*20=0; this causes rasterBits to be free'd. If the third frame has similarly malformed dimensions, it frees the same pointer again, resulting in a double-free.

android-gif-drawable

Symbols

Symbols are important; they make it easier to interpret what a piece of code is doing. If you analyze libraries that were extracted from an Android Package Kit (APK), they will most likely be stripped of symbols. In our particular case, for android-gif-drawable, this is not an issue because we have access to the source. However, if you need to reverse engineer a closed-source binary, you should at least apply the Java Native Interface (JNI) types to make the research process more straightforward. There is a post by @Ch0pin you can read here to give you some more background. In my case, I am using Binary Ninja, and I found a working type header file that can be imported here.

To understand how to apply the types, you can search the decompiled APK for native declarations. In the screenshot below, we can see some of the android-gif-drawable declarations from the APK in JEB.

screenshot of JEB Dalvik bytecode
JEB Dalvik bytecode

Take getFrameDuration as an example:

.method public static native getFrameDuration(J, I)I
.end method

 

Here, J translates to jlong and I translates to jint. Notice that the function also has a return type of jint. If we combine these values with the standard calling convention for native JNI invocation, we end up with:

jint getFrameDuration(JNIEnv* env, jclass clazz, jlong arg0, jint arg1)

 

You can apply some automation to this process (using androguard, JEB API, etc). With proper type mappings you can programmatically walk every class and apply all identified types to the library you are analyzing.

Extracting and applying JNI symbols to a Binary Ninja database
Extracting and applying JNI symbols to a Binary Ninja database

Some engineering is required to parse the APK, extract the call definitions and apply them in your preferred decompiler. This effort is worth it because it reduces the amount of manual labor, and it can give you a high-level overview of native library use in the APK as a whole.

screenshot of Binary Ninja decompiled native code
Binary Ninja decompiled native code
Man looking at computer

Strengthen your security intelligence 


Stay ahead of threats with news and insights on security, AI and more, weekly in the Think Newsletter. 


Fuzzing DDGifSlurp

How can we reach our target function?

The first thing I did (since the library is open-source) was build my own version of android-gif-drawable from the v1.2.17 release package using the Android NDK. Then, I reviewed what exports were available in the binary:

➜ nm -D lib/libpl_droidsonroids_gif_fixed.so | awk '$3 ~ /^D/ || $3 ~ /^Java_pl_/'
 0000000000006214 T DDGifSlurp
 000000000000ad14 T DGifCloseFile
 000000000000ac58 T DGifExtensionToGCB
 000000000000a91c T DGifGetCodeNext
 000000000000aa68 T DGifGetExtension
 000000000000ab20 T DGifGetExtensionNext
 0000000000009cc0 T DGifGetImageDesc
 000000000000a254 T DGifGetLine
 0000000000009b98 T DGifGetRecordType
 000000000000989c T DGifGetScreenDesc
 0000000000009644 T DGifOpen
 000000000000bb90 T DetachCurrentThread
 000000000000d53c T Java_pl_droidsonroids_gif_GifInfoHandle_bindSurface
 0000000000009314 T
Java_pl_droidsonroids_gif_GifInfoHandle_createTempNativeFileDescriptor
 00000000000091ac T
Java_pl_droidsonroids_gif_GifInfoHandle_extractNativeFileDescriptor
 0000000000006d20 T Java_pl_droidsonroids_gif_GifInfoHandle_free
 000000000000c238 T Java_pl_droidsonroids_gif_GifInfoHandle_getAllocationByteCount
 000000000000bd74 T Java_pl_droidsonroids_gif_GifInfoHandle_getComment
 000000000000c578 T Java_pl_droidsonroids_gif_GifInfoHandle_getCurrentFrameIndex
 000000000000c528 T Java_pl_droidsonroids_gif_GifInfoHandle_getCurrentLoop
 000000000000c008 T Java_pl_droidsonroids_gif_GifInfoHandle_getCurrentPosition
 000000000000bf00 T Java_pl_droidsonroids_gif_GifInfoHandle_getDuration
 000000000000caa4 T Java_pl_droidsonroids_gif_GifInfoHandle_getFrameDuration
 000000000000cbc0 T Java_pl_droidsonroids_gif_GifInfoHandle_getHeight
 000000000000be68 T Java_pl_droidsonroids_gif_GifInfoHandle_getLoopCount
 000000000000c15c T Java_pl_droidsonroids_gif_GifInfoHandle_getMetadataByteCount
 000000000000c4d4 T Java_pl_droidsonroids_gif_GifInfoHandle_getNativeErrorCode
 000000000000cc14 T Java_pl_droidsonroids_gif_GifInfoHandle_getNumberOfFrames
 000000000000c5cc T Java_pl_droidsonroids_gif_GifInfoHandle_getSavedState
 000000000000bfb4 T Java_pl_droidsonroids_gif_GifInfoHandle_getSourceLength
 000000000000cb6c T Java_pl_droidsonroids_gif_GifInfoHandle_getWidth
 000000000000cc68 T Java_pl_droidsonroids_gif_GifInfoHandle_glTexImage2D
 000000000000cd50 T Java_pl_droidsonroids_gif_GifInfoHandle_glTexSubImage2D
 000000000000ce38 T Java_pl_droidsonroids_gif_GifInfoHandle_initTexImageDescriptor
 000000000000bde4 T Java_pl_droidsonroids_gif_GifInfoHandle_isAnimationCompleted
 000000000000cb10 T Java_pl_droidsonroids_gif_GifInfoHandle_isOpaque
 00000000000084cc T Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray
 00000000000087a4 T Java_pl_droidsonroids_gif_GifInfoHandle_openDirectByteBuffer
 0000000000008278 T Java_pl_droidsonroids_gif_GifInfoHandle_openFile
 0000000000009340 T Java_pl_droidsonroids_gif_GifInfoHandle_openNativeFileDescriptor
 0000000000008ab8 T Java_pl_droidsonroids_gif_GifInfoHandle_openStream
 000000000000e354 T Java_pl_droidsonroids_gif_GifInfoHandle_postUnbindSurface
 00000000000057dc T Java_pl_droidsonroids_gif_GifInfoHandle_renderFrame
 000000000000597c T Java_pl_droidsonroids_gif_GifInfoHandle_reset
 000000000000611c T Java_pl_droidsonroids_gif_GifInfoHandle_restoreRemainder
 000000000000c9d8 T Java_pl_droidsonroids_gif_GifInfoHandle_restoreSavedState
 000000000000603c T Java_pl_droidsonroids_gif_GifInfoHandle_saveRemainder
 0000000000005f6c T Java_pl_droidsonroids_gif_GifInfoHandle_seekToFrame
 000000000000d4d0 T Java_pl_droidsonroids_gif_GifInfoHandle_seekToFrameGL
 0000000000005ccc T Java_pl_droidsonroids_gif_GifInfoHandle_seekToTime
 000000000000beb8 T Java_pl_droidsonroids_gif_GifInfoHandle_setLoopCount
 000000000000b948 T Java_pl_droidsonroids_gif_GifInfoHandle_setOptions
 00000000000059e4 T Java_pl_droidsonroids_gif_GifInfoHandle_setSpeedFactor
 000000000000cfa0 T Java_pl_droidsonroids_gif_GifInfoHandle_startDecoderThread
 000000000000d374 T Java_pl_droidsonroids_gif_GifInfoHandle_stopDecoderThread

 

This is helpful information because we know we can call DDGifSlurp directly, and we can also see the set of JNI-exported functions. If we look at DDGifSlurp again, we see that the first argument is a pointer to some complex type, GifInfo.

void DDGifSlurp(GifInfo *info, bool decode, bool exitAfterFrame)

 

Commit df309bb - gif.h here

struct GifInfo {
    void (*destructor)(GifInfo *, JNIEnv *);
    GifFileType *gifFilePtr;
    GifWord originalWidth, originalHeight;
    uint_fast16_t sampleSize;
    long long lastFrameRemainder;
    long long nextStartTime;
    uint_fast32_t currentIndex;
    GraphicsControlBlock *controlBlock;
    argb *backupPtr;
    long long startPos;
    unsigned char *rasterBits;
    uint_fast32_t rasterSize;
    char *comment;
    uint_fast16_t loopCount;
    uint_fast16_t currentLoop;
    RewindFunc rewindFunction;
    jfloat speedFactor;
    uint32_t stride;
    jlong sourceLength;
    bool isOpaque;
    void *frameBufferDescriptor;
};

 

We could manually create a fake GifInfo object; however, the object is quite big and is itself a composite of other complex types (like GifFileType). Instead, it makes more sense to investigate the other native functions to see how GifInfo objects are usually created. We can quickly find some potential candidates.

Commit df309bb - gif.c here

Java_pl_droidsonroids_gif_GifInfoHandle_openFile
Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray
Java_pl_droidsonroids_gif_GifInfoHandle_openDirectByteBuffer
Java_pl_droidsonroids_gif_GifInfoHandle_openStream

 

Of these, the byte variants seem to have the least overhead; in particular, openByteArray only requires us to create a jbyteArray object, which we can do easily in C.

__unused JNIEXPORT jlong JNICALL
Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray(JNIEnv *env, jclass __unused
class, jbyteArray bytes) {
    if (isSourceNull(bytes, env)) {
        return NULL_GIF_INFO;
    }
    ByteArrayContainer *container = malloc(sizeof(ByteArrayContainer));
    if (container == NULL) {
        throwException(env, OUT_OF_MEMORY_ERROR, OOME_MESSAGE);
        return NULL_GIF_INFO;
    }
    container->buffer = (*env)->NewGlobalRef(env, bytes);
    if (container->buffer == NULL) {
        free(container);
        throwException(env, RUNTIME_EXCEPTION_BARE, "NewGlobalRef failed");
        return NULL_GIF_INFO;
    }
    container->length = (unsigned int) (*env)->GetArrayLength(env, container->buffer);
    container->position = 0;
    GifSourceDescriptor descriptor = {
            .rewindFunc = byteArrayRewind,
            .sourceLength = container->length
    };
    descriptor.GifFileIn = DGifOpen(container, &byteArrayRead, &descriptor.Error);
    descriptor.startPos = container->position;

    GifInfo *info = createGifInfo(&descriptor, env);

    if (info == NULL) {
        (*env)->DeleteGlobalRef(env, container->buffer);
        free(container);
    }
    return (jlong) (intptr_t) info;
}

 

Note that the GifInfo object itself is created by createGifInfo.

Commit df309bb - init.c here

GifInfo *createGifInfo(GifSourceDescriptor *descriptor, JNIEnv *env) {
    if (descriptor->startPos < 0) {
        descriptor->Error = D_GIF_ERR_NOT_READABLE;
    }
    if (descriptor->Error != 0 || descriptor->GifFileIn == NULL) {
        bool readErrno = descriptor->rewindFunc == fileRewind && (descriptor->Error ==
D_GIF_ERR_NOT_READABLE || descriptor->Error == D_GIF_ERR_READ_FAILED);
        throwGifIOException(descriptor->Error, env, readErrno);
        DGifCloseFile(descriptor->GifFileIn);
        return NULL;
    }

    GifInfo *info = malloc(sizeof(GifInfo));

...

    DDGifSlurp(info, false, false);
    info->rasterBits = NULL;
    info->rasterSize = 0;
    info->originalHeight = info->gifFilePtr->SHeight;
    info->originalWidth = info->gifFilePtr->SWidth;

...

In the above code snippet, you can see that the initialization function also calls DDGifSlurp, but it is not able to trigger the vulnerable code because decode=false. Setting this flag to false triggers the isInitialPass case within DDGifSlurp, which only records per-frame metadata without parsing the frames.

At this point, we have a pretty good understanding of how to call the vulnerable code path, and we can put together a series of calls to reach the function we want to fuzz.

Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray
   |_ DDGifSlurp

 

However, we are missing two elements here. First, if we create thousands of these call chains, we will run out of memory and crash our harness, so we need to make sure to free any resources we create. To achieve this, we can use another of the JNI-exported functions.

Commit df309bb - dispose.c here

Java_pl_droidsonroids_gif_GifInfoHandle_free(JNIEnv *env, jclass __unused handleClass, jlong gifInfo)

 

The second missing element is much less obvious. When DDGifSlurp initializes the GIF, it walks the list of frames, modifying the GifInfo object as it goes. Before we can process the GIF again, we need to rewind it to its initial state. Doing so resets our position in the ByteArrayContainer to the start position and resets some properties of the GifInfo object, as can be seen below.

Commit df309bb - controle.c here

bool reset(GifInfo *info) {
    if (info->rewindFunction(info) != 0) {
        return false;
    }
    info->nextStartTime = 0;
    info->currentLoop = 0;
    info->currentIndex = 0;
    info->lastFrameRemainder = -1;
    return true;
}

__unused JNIEXPORT jboolean JNICALL
Java_pl_droidsonroids_gif_GifInfoHandle_reset(JNIEnv *__unused  env, jclass  __unused
class, jlong gifInfo) {
    GifInfo *info = (GifInfo *) (intptr_t) gifInfo;
    if (info != NULL && reset(info)) {
        return JNI_TRUE;
    }
    return JNI_FALSE;
}

 

Our final call chain looks like this:

Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray
   |_ Java_pl_droidsonroids_gif_GifInfoHandle_reset
     |_ DDGifSlurp
       |_ Java_pl_droidsonroids_gif_GifInfoHandle_free

Testing our assumptions

We can create a test binary that will take a GIF from disk and pass it through our call chain. Notice that we include a jenv header file (sourced from this Quarkslab post), and we also include the gif header directly from the android-gif-drawable library itself.

Typically, for fuzzing, we would be in one of three scenarios:

  • We are fuzzing pure native library code; we have no dependencies.
  • We have a dependence on the JNINativeInterface (JNIEnv), but we can manually create all function arguments we need to call our native code (e.g., jbyteArray).
  • We have a dependence on some complex Java object that we can’t create in C. In this case, we must load the APK or a custom-compiled Java class to build our arguments.

In our case, openByteArray has a pretty straightforward prototype, so we are in this second category, where we are able to create the function arguments from C without additional dependencies.

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include "../include/jenv.h"
#include "../android-gif-drawable-1.2.17/android-gif-drawable/src/main/c/gif.h"

/* JNI symbols we call directly */
extern jlong Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray(JNIEnv *env, class
clazz, jbyteArray bytes);
extern void  Java_pl_droidsonroids_gif_GifInfoHandle_free(JNIEnv *env, jclass clazz,
jlong gifInfo);
extern jboolean Java_pl_droidsonroids_gif_GifInfoHandle_reset(JNIEnv *env, class
clazz, jlong gifInfo);
extern jint JNI_OnLoad(JavaVM *vm, void *reserved);

static JavaCTX ctx;

/**
 * read_file - read an entire file into a memory buffer
 * @path: path to the input file
 * @out_len: pointer to size_t where the number of bytes read will be stored
 * Returns a pointer to the data, or NULL on error.
 * Caller is responsible for freeing the returned buffer.
 */
static uint8_t *read_file(const char *path, size_t *out_len) {
    FILE *f = fopen(path, "rb");
    if (!f) return NULL;
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    if (sz <= 0) { fclose(f); return NULL; }
    uint8_t *buf = malloc((size_t)sz);
    if (!buf) { fclose(f); return NULL; }
    if (fread(buf, 1, (size_t)sz, f) != (size_t)sz) { free(buf); fclose(f); return
NULL; }
    fclose(f);
    *out_len = (size_t)sz;
    return buf;
}

/**
 * main - read a GIF file, run DDGifSlurp to trigger CVE-2019-11932, and print results
 * @argc: number of command-line arguments
 * @argv: argument vector, expects GIF file path as argv[1]
 * Returns exit code, 0 on success, non-zero on error
 */
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <gif_path>\n", argv[0]);
        return 1;
    }
    const char *input_path = argv[1];
    /* 1. Read GIF from disk */
    size_t len = 0;
    uint8_t *data = read_file(input_path, &len);
    if (!data) {
        fprintf(stderr, "[-] Failed to read %s\n", input_path);
        return 1;
    }


    /* 2. Initialize Java VM without custom options */
    if (init_java_env(&ctx, NULL, 0) != 0) {
        fprintf(stderr, "[-] init_java_env failed\n");
        free(data);
        return 1;
    }

    /* get JNIEnv */
    JNIEnv *e = ctx.env;

    /* 3. Ensure GIF library's JNI_OnLoad runs to set up g_jvm */
    JNI_OnLoad(ctx.vm, NULL);

    /* 4. Single open-decode-free cycle */

    /* Create Java byte[] */
    jbyteArray arr = (*e)->NewByteArray(e, (jsize)len);
    if (!arr) {
        fprintf(stderr, "[-] NewByteArray failed\n");
        free(data);
        return 1;
    }
    (*e)->SetByteArrayRegion(e, arr, 0, (jsize)len, (const jbyte *)data);

    /* open */
    jlong gptr = Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray(e, NULL, arr);
    (*e)->DeleteLocalRef(e, arr);
    if ((*e)->ExceptionCheck(e) || gptr == 0) {
        fprintf(stderr, "[-] openByteArray failed\n");
        (*e)->ExceptionClear(e);
        free(data);
        return 1;
    }

    /* rewind */
    Java_pl_droidsonroids_gif_GifInfoHandle_reset(e, NULL, gptr);

    /* decode (vulnerable) */
    DDGifSlurp((GifInfo *)(intptr_t)gptr, true, false);

    /* print some info */
    GifInfo *info = (GifInfo *)(intptr_t)gptr;
    printf("[+] GifInfo at %p: %lu x %lu, %lu frames, loop %lu, error %d\n",
           (void*)info,
           (unsigned long)info->originalWidth,
           (unsigned long)info->originalHeight,
           (unsigned long)info->gifFilePtr->ImageCount,
           (unsigned long)info->loopCount,
           (int)info->gifFilePtr->Error);

    /* free */
    Java_pl_droidsonroids_gif_GifInfoHandle_free(e, NULL, gptr);
    (*e)->ExceptionClear(e);
    free(data);
    return 0;
}

The code above will read an image from disk, initialize the Java Virtual Machine (JVM), create a jbyteArray and pass the image through our call chain. At the end, we print some properties from the GifInfo object so we can get some metadata and confirm the code ran to completion without error. Later, we can use this test binary to debug any crashes we may find.

screenshot of DDGifSlurp test execution
DDGifSlurp test execution

Building a harness

With the difficult part behind us, we can create a fuzzing harness by wrapping the test code in some AFL++ boilerplate. In main, we initialize the Java VM and then create a function which takes a byte array as input. This function, fuzz_one_input, will perform all the actions necessary to step through our call chain once with the input provided. We will use Frida to hook this function so AFL can pass inputs to it and collect coverage.

#include <errno.h>
#include <stdio.h>
#include <stdint.h>
#include <jni.h>
#include "../include/jenv.h"
#include "../android-gif-drawable-1.2.17/android-gif-drawable/src/main/c/gif.h"

#define BUFFER_SIZE 65536

/* JNI symbols we invoke directly */
extern jlong Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray(JNIEnv *, class,
jbyteArray);
extern jboolean Java_pl_droidsonroids_gif_GifInfoHandle_reset(JNIEnv *, class,
jlong);
extern void Java_pl_droidsonroids_gif_GifInfoHandle_free(JNIEnv *, jclass, jlong);
extern jint JNI_OnLoad(JavaVM *, void *);

static JavaCTX ctx;

/*
 * fuzz_one_input – entry used by Frida-AFL persistent mode.
 * Creates a GifInfo from the fuzz data, rewinds the stream, runs a full
 * decode with DDGifSlurp, then frees the structure.
 */
__attribute__((visibility("default")))
void fuzz_one_input(const uint8_t *data, size_t len) {
  JNIEnv *env = ctx.env;
  if (len < 6) return; /* need at least GIF header */

  /* Create Java byte[] */
  jbyteArray arr = (*env)->NewByteArray(env, (jsize)len);
  if (!arr) return;
  (*env)->SetByteArrayRegion(env, arr, 0, (jsize)len, (const jbyte *)data);

  /* GifInfoHandle.openByteArray */
  jlong gptr = Java_pl_droidsonroids_gif_GifInfoHandle_openByteArray(env, NULL, arr);
  (*env)->DeleteLocalRef(env, arr);
  if ((*env)->ExceptionCheck(env) || gptr == 0) {
    (*env)->ExceptionClear(env);
    return;
  }

  /* Rewind so decode starts at offset 0 */
  Java_pl_droidsonroids_gif_GifInfoHandle_reset(env, NULL, gptr);

  /* Full decode – this is where CVE-2019-11932 triggers */
  GifInfo *info = (GifInfo *)(intptr_t)gptr;
  DDGifSlurp(info, true, false);

  /* Cleanup */
  Java_pl_droidsonroids_gif_GifInfoHandle_free(env, NULL, gptr);
  (*env)->ExceptionClear(env);
}

/**
 * main - AFL-Frida fuzzing harness entry point
 *
 * Reads up to BUFFER_SIZE bytes from stdin, initializes the Java VM,
 * invokes fuzz_one_input for persistent-mode decoding, and exits.
 *
 * Return: 0 on success (no crash or crash handled by AFL),
 *         non-zero on initialization or read error.
 */
int main(void) {
  uint8_t buffer[BUFFER_SIZE];
  ssize_t rlen = fread(buffer, 1, sizeof buffer, stdin);
  if (rlen == -1) return errno;

  /* Start JVM with default options */
  if (init_java_env(&ctx, NULL, 0) != 0) return 1;

  /* Ensure gif library initialises its global JVM pointer */
  JNI_OnLoad(ctx.vm, NULL);

  fuzz_one_input(buffer, (size_t)rlen);
  return 0;
}

 

The small Frida script below lets AFL++ pass inputs to the harness. It injects a tiny C-hook that copies each AFL test case straight into the function input buffer, tells AFL++ exactly where to restart on each iteration, and leverages Frida's instrumentation to collect coverage.

Afl.print(`[*] Starting FRIDA config for PID: ${Process.id}`);

// Modules to be instrumented by Frida
const MODULE_WHITELIST = [
  "fuzz_DDGifSlurp",
  "libpl_droidsonroids_gif_fixed.so",
];

// Persistent hook
const hook_module = new CModule(`
  #include <string.h>
  #include <gum/gumdefs.h>

  #define BUF_LEN 65536

  void afl_persistent_hook(GumCpuContext *regs, uint8_t *input_buf,
    uint32_t input_buf_len) {

    uint32_t length = (input_buf_len > BUF_LEN) ? BUF_LEN : input_buf_len;
    memcpy((void *)regs->x[0], input_buf, length);
    regs->x[1] = length;
  }
  `,
  {
    memcpy: Module.getExportByName(null, "memcpy")
  }
);

// Persistent loop start address
const pPersistentAddr = DebugSymbol.fromName("fuzz_one_input").address;

// Exclude from instrumentation
Module.load("libandroid_runtime.so");
new ModuleMap().values().forEach(m => {
  if (!MODULE_WHITELIST.includes(m.name)) {
    Afl.print(`Exclude: ${m.base}-${m.base.add(m.size)} ${m.name}`);
    Afl.addExcludedRange(m.base, m.size);
  }
});

Afl.setEntryPoint(pPersistentAddr);
Afl.setPersistentHook(hook_module.afl_persistent_hook);
Afl.setPersistentAddress(pPersistentAddr);
Afl.setPersistentCount(3000); // Limit how many iterations before reinitializing
                              // the environment
Afl.setInMemoryFuzzing();
Afl.setInstrumentLibraries();
Afl.done();
Afl.print("[*] All done!");

Finally, we can kick off fuzzing on the phone.

panther: # su
panther: # cd /sys/devices/system/cpu
panther:/sys/devices/system/cpu # echo performance | tee cpu*/cpufreq/scaling_governor
performance
panther:/sys/devices/system/cpu # cd /data/local/tmp/whatsapp
panther:/data/local/tmp/whatsapp # ./afl-fuzz -O -G 4096 -i in -o out
./fuzz_DDGifSlurp

 

I let the fuzzer run for about 7 hours before ending the run. We can see from the AFL output below, we executed over 200M test cases and recorded 29.1k crashes, of which 42 were saved. AFL applies some heuristics based on signal type, faulting address and edges in the coverage map to determine if a crash is sufficiently interesting to keep. This doesn't mean that each of these crashes is unique.

screenshot of AFL++ on-device fuzzing
AFL++ on-device fuzzing

Crash triage

If we want to triage crashes, we can augment the vulnerable library by adding some additional print statements that will give us more insights into what happens inside the DDGifSlurp decode condition.

if (decode) {
    // Print raw values used in calculations
    printf("[DEBUG] Image.Width=%u, Image.Height=%u, originalWidth=%u,
originalHeight=%u, currentRasterSize=%u\n",
           (unsigned)gifFilePtr->Image.Width,
           (unsigned)gifFilePtr->Image.Height,
           (unsigned)info->originalWidth,
           (unsigned)info->originalHeight,
           (unsigned)info->rasterSize);

    int_fast32_t widthOverflow = gifFilePtr->Image.Width - info->originalWidth;
    int_fast32_t heightOverflow = gifFilePtr->Image.Height - info->originalHeight;
    const uint_fast32_t newRasterSize = gifFilePtr->Image.Width * gifFilePtr->Image.Height;

    // Pretty-print computed overflow and new raster size
    printf("[DEBUG] widthOverflow=%d, heightOverflow=%d, newRasterSize=%u\n",
           (int)widthOverflow,
           (int)heightOverflow,
           (unsigned)newRasterSize);

    if (newRasterSize > info->rasterSize || widthOverflow > 0 || heightOverflow > 0) {
        void *tmpRasterBits = reallocarray(info->rasterBits, newRasterSize,
sizeof(GifPixelType));
        if (tmpRasterBits == NULL) {
            gifFilePtr->Error = D_GIF_ERR_NOT_ENOUGH_MEM;
            break;
        }
        info->rasterBits = tmpRasterBits;
        info->rasterSize = newRasterSize;
    }

...

 

For our convenience, we can also recompile test_DDGifSlurp with ASan, which will give us more verbose information on what went wrong at runtime without necessarily having to dive into LLBD immediately.

➜  cmake \                                                          
    -DANDROID_PLATFORM=31 \
     -DCMAKE_TOOLCHAIN_FILE=/opt/homebrew/share/android-ndk/build/cmake/android.toolchain.cmake \
     -DANDROID_ABI=arm64-v8a \
     -DANDROID_ENABLE_ASAN=ON \
     -DCMAKE_BUILD_TYPE=Debug \
     -DCMAKE_C_FLAGS="-g -fsanitize=address -fno-omit-frame-pointer" \
     -DCMAKE_CXX_FLAGS="-g -fsanitize=address -fno-omit-frame-pointer" \
     -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" \
     -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
     .
 ➜  make

 

There are a few variations of this vulnerability that can be triggered depending on the size and composition of the GIF frames.

screenshot of android-gif-drawable DDGifSlurp double-free
android-gif-drawable DDGifSlurp double-free

In this example, we can see a variation that is almost identical to the one Awakened has in their blog post.

  • Initial State: originalWidth has a value of 16697, and originalHeight has a value of 65530.
  • Frame 0: Image.Width has a value of 32768, which is larger than originalWidth. Because of this, we trigger reallocarray. However, because Image.Height has a value of 0, the final rasterSize becomes 32768*0 = 0. Due to the internal behavior of reallocarray, we instead free the pointer, leave the dangling reference and break.
  • Frame 1: Image.Width has a value of 65535, again larger than originalWidth and Image.Height is again 0, resulting in the same behavior, and we free the same pointer again, triggering ASan.

Conclusion

Parsers are obviously tricky to get right, and it is easy to make mistakes or have mismatched assumptions about what data the parser will process. This vulnerability was very easy to rediscover using our harness. In fact, the very first crash was reported just a few minutes after the start of the run.

In cases where these types of libraries are included in highly security-critical applications, like messaging apps, they should definitely be subjected to extensive manual and automated testing. If application engineers don't validate library code, it's clear that researchers will (and they may or may not report their findings, no judgments).

Addendum

This bug was reported and fixed in 2019, but I was curious and did some investigation into the issue history of the repository. To my surprise, I found an issue from 2016 that was closed due to inactivity, which almost certainly relates to this same vulnerability.

screenshot of a GitHub renderFrame crash
GitHub renderFrame crash

The user reports a crash from Java_pl_droidsonroids_gif_GifInfoHandle_renderFrame, which is the typical way the library uses the vulnerable DDGifSlurp call. In our harness, we do not call this function because:

  • We would have to create a Java Bitmap object, either for each iteration or by reusing a single one with some tricks.
  • For each fuzz iteration, we would need to lock and unlock pixels.

These actions are compute intensive if we perform them thousands of times per second, and we don’t need them to exercise the vulnerable function.

__unused JNIEXPORT jlong JNICALL
Java_pl_droidsonroids_gif_GifInfoHandle_renderFrame(JNIEnv *env, jclass __unused
handleClass, jlong gifInfo, jobject jbitmap) {
    GifInfo *info = (GifInfo *) (intptr_t) gifInfo;
    if (info == NULL)
        return -1;

    long renderStartTime = getRealTime();
    void *pixels;
    if (lockPixels(env, jbitmap, info, &pixels) != 0) {
        return 0;
    }
    DDGifSlurp(info, true, false);
    if (info->currentIndex == 0) {
        prepareCanvas(pixels, info);
    }
    const uint_fast32_t frameDuration = getBitmap(pixels, info);
    unlockPixels(env, jbitmap);
    return calculateInvalidationDelay(info, renderStartTime, frameDuration);
}

 

I expect many researchers in the vulnerability research (VR) community are monitoring open and closed GitHub issues from open-source libraries that are loaded by sensitive applications. Given how high-profile WhatsApp is as a target, it’s doubtful that I am the first to look at this specific issue, and others, in the android-gif-drawable library. I would not be surprised at all if this bug was known before 2019.

References

1. How a double-free bug in WhatsApp turns to RCE - here

  • The original blog post by Awakened where they describe the vulnerability and the method for exploitation. The researcher has some other interesting articles on their blog, but strangely, no new entries after the post about the WhatsApp vulnerability. I'm sure one of the research labs acquired an excellent addition to their team!

2. Patched GIF Processing Vuln Still Affects Mobile Apps - here

  • A really good breakdown by Trend Micro, where they also analyze the impact on other Android applications running out-of-date versions of the library.

3. Android greybox fuzzing with AFL++ Frida mode - here

  • Foundational background on fuzzing JNI functions in various setups: native, weakly linked and strongly linked. Written by Eric Le Guevel @quarkslab.

4. Fuzzing Redux, leveraging AFL++ Frida-Mode on Android native libraries - here

  • This is a post I wrote in 2024, which provides more background on AFL++ Frida-Mode with build information and example usage.

5. android-gif-drawable - here

  • The latest vulnerable library version v1.2.17 here
Think Keynotes

Win the enterprise AI race

Join Arvind Krishna to see how IBM is enabling AI-first enterprises through hybrid cloud and emerging quantum capabilities.

Related solutions
IBM Verify

Build a secure, vendor-agnostic identity framework that modernizes IAM, integrates with existing tools, and enables seamless hybrid access without added complexity.

Explore IBM verify
Threat detection response solutions

Accelerate response by prioritizing high-impact risks and automating remediation across teams.

Explore threat detection response solutions
IBM Cyber Threat Management

Predict, prevent, and respond to modern threats to strengthen business resilience.

Explore IBM cyber threat management
Take the next step

Discover how IBM Verify modernizes IAM by integrating with your existing tools to deliver secure, seamless hybrid identity access.

  1. Discover IBM Verify
  2. Explore threat detection response solutions