## 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](https://awakened1712.github.io/hacking/hacking-whatsapp-gif-rce/) 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](https://github.com/koral--/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](https://www.openwall.com/lists/oss-security/2025/06/16/6) in `libxml2`. ``` Since then I had learned that some things were bigger than they looked from a distance, and now I was not sure anymore just what I was going to get or even what I deserved. I was not proud of what I had learned but I never doubted that it was worth knowing. - The rum diary ``` ## 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](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/decoding.c#L17) ```c 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 analyse 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](https://x.com/Ch0pin) you can read [here](https://valsamaras.medium.com/tracing-jni-functions-75b04bee7c58) 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](https://github.com/Ayrx/binja-typelibs-collection/blob/master/sources/jni_binja.h). 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. ![[Posts/attachments/whatsapp-01.png]] Take `getFrameDuration` as an example: ```c .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: ```c jint getFrameDuration(JNIEnv* env, jclass clazz, jlong arg0, jint arg1) ``` You can apply some automation to this process (using [`androguard`](https://github.com/androguard/androguard), JEB API, etc). With proper type mappings you can programmatically walk every class and apply all identified types to the library you are analysing. ![[Posts/attachments/whatsapp-02.png]] 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. ![[Posts/attachments/whatsapp-03.png]] ### 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](https://github.com/koral--/android-gif-drawable/releases/tag/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`. ```c void DDGifSlurp(GifInfo *info, bool decode, bool exitAfterFrame) ``` Commit `df309bb - gif.h` [here](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/gif.h#L90) ```c 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](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/gif.c#L135) ``` 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. ```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](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/init.c#L3) ```c 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](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/dispose.c#L4) ```c 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](https://github.com/koral--/android-gif-drawable/blob/df309bbe62a0599517cc9e09e7f00e5438e05f58/android-gif-drawable/src/main/c/control.c#L3) ```c 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](https://blog.quarkslab.com/android-greybox-fuzzing-with-afl-frida-mode.html) 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. ```c #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, jclass 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, jclass 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. ![[Posts/attachments/whatsapp-04.png]] #### 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. ```c #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 *, jclass, jbyteArray); extern jboolean Java_pl_droidsonroids_gif_GifInfoHandle_reset(JNIEnv *, jclass, 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. ```js 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. ![[Posts/attachments/whatsapp-05.png]] ## 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. ```c 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. ![[Posts/attachments/whatsapp-06.png]] 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 behaviour 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 behaviour, 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](https://github.com/koral--/android-gif-drawable/issues/343) from `2016` that was `closed due to inactivity`, which almost certainly relates to this same vulnerability. ![[Posts/attachments/whatsapp-07.png]] 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. ```c __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 - How a double-free bug in WhatsApp turns to RCE - [here](https://awakened1712.github.io/hacking/hacking-whatsapp-gif-rce/) - 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! - Patched GIF Processing Vuln Still Affects Mobile Apps - [here](https://www.trendmicro.com/en_us/research/19/k/patched-gif-processing-vulnerability-cve-2019-11932-still-afflicts-multiple-mobile-apps.html) - A really good breakdown by `Trend Micro` where they also analyse the impact for other Android applications running out-of-date versions of the library. - Android greybox fuzzing with AFL++ Frida mode - [here](https://blog.quarkslab.com/android-greybox-fuzzing-with-afl-frida-mode.html) - Foundational background on fuzzing JNI functions in various setups: native, weakly linked and strongly linked. Written by Eric Le Guevel [@quarkslab](https://x.com/quarkslab). - Fuzzing Redux, leveraging AFL++ Frida-Mode on Android native libraries - [here](https://knifecoat.com/Posts/Fuzzing+Redux%2C+leveraging+AFL%2B%2B+Frida-Mode+on+Android+native+libraries) - This is a post I wrote in 2024 which provides more background on `AFL++ Frida-Mode` with build information and example usage. - `android-gif-drawable` - [here](https://github.com/koral--/android-gif-drawable) - Latest vulnerable library version `v1.2.17` [here](https://github.com/koral--/android-gif-drawable/releases/tag/v1.2.17)