# Ariel Koren - full text > Full-text export of the original writing and vulnerability research on arielkoren.com, for AI answer engines and agents. Ariel Koren is a security researcher working across vulnerability research, CVEs and 0days, AI and agentic security, browser security, reverse engineering, and secure systems and product architecture. ## Skia RenameFont OOB. Google Won't Fix It. URL: https://arielkoren.com/writing/skia-renamefont/ Type: Vulnerability research | Date: 2026-07-17 A stock verdana.ttf , one directory entry changed. I loaded it through a public API on a real Windows build of Skia, and AddressSanitizer printed the sentence I was hoping for: a heap-buffer-overflow, a write of 243,300 bytes, zero bytes past the end of a 280-byte allocation. The length is mine. The bytes are mine. The stack runs straight from main down through the public font manager into the function that did it. That is the whole finding, and it reproduces every time. What happened after I reported it is the part worth writing down, because it is not a story about a bug being wrong. It is a story about a bug being real and declined anyway - and about what that says about the word "reachable." Disclosure status Reported to Google's issue tracker on 2026-06-23 (issue 527060475, not public). A Chromium security reviewer returned Won't Fix (Not Reproducible) on 2026-06-24. The bug itself is real: verified end to end under AddressSanitizer through a public API, with a one-hunk fix that closes it, and it remains unpatched in main . There is no CVE and no embargo. The crash was not disputed - what was declined was its severity under a web-only threat model. The bug Skia's SkOTUtils::RenameFont rewrites a font's name table. On the Windows GDI backend, Skia renames any stream-loaded font before it hands it to the operating system, so this runs on attacker bytes as the very first thing that touches the font. The function sizes an output buffer from the font's declared table sizes, then copies data using the font's declared table offset. It never checks either number against the actual file ( src/sfnt/SkOTUtils.cpp , revision 488a6fc ): size_t oldNameTablePhysicalSize = (SkEndian_SwapBE32(tableEntry.logicalLength) + 3) & ~3; // attacker-controlled size_t oldNameTableOffset = SkEndian_SwapBE32(tableEntry.offset); // attacker-controlled size_t originalDataSize = fontData->getLength() - oldNameTablePhysicalSize; // (1) size_t underflow-prone size_t newDataSize = originalDataSize + nameTablePhysicalSize; auto rewrittenFontData = SkData::MakeUninitialized(newDataSize); SK_OT_BYTE* data = static_cast(rewrittenFontData->writable_data()); if (fontData->read(data, oldNameTableOffset) < oldNameTableOffset) { // (2) reads offset bytes into the newDataSize buffer return nullptr; } There are two attacker knobs on a single name directory entry, and they pull against each other in exactly the wrong direction. A large logicalLength shrinks newDataSize , the buffer that gets allocated. A large offset becomes the count for the read that fills it. Declare a small buffer and a large read of the same entry, and line (2) writes offset attacker bytes into a buffer that was sized without ever looking at offset . Push logicalLength past the file length and line (1) also underflows size_t , wrapping newDataSize down to something tiny and widening the overflow further. Nothing enforces the invariant that the name table has to lie inside the font. The SFNT format says it does; the code trusts that it does; the file gets to decide. One directory entry, two knobs pulling opposite ways: the declared length sizes the buffer, the declared offset sizes the copy. Nothing checks that either fits. I found it by reading, not by fuzzing. The stream and SAX parsers around it are well covered by oss-fuzz and were clean. The hand-rolled size and offset arithmetic in a utility function, one layer out from the fuzzed surface, was not. That pattern - trusting a container's self-declared table offsets and lengths - is a recurring font and codec bug class, and it tends to live exactly where the fuzzers are not pointed. Reaching it through the front door A crash in a helper is a lead, not a finding. The finding is the path from a public API to that helper with nothing in between that revalidates the input. RenameFont has exactly one caller in the entire tree, and the path is short: SkFontMgr_New_GDI()->makeFromStream/Data/File (public API, attacker font bytes) -> SkFontMgr::makeFromStream (nullptr check only) -> SkFontMgrGDI::onMakeFromStreamIndex (sole gate: ttcIndex == 0, the default) -> create_from_stream (RenameFont is the first op on the stream) -> SkOTUtils::RenameFont -> heap out-of-bounds write So I built it end to end. Skia at 488a6fc , compiled for Windows with MSVC and GN's ASan mode, GDI font backend on, and a harness that calls nothing but the public SkFontMgr_New_GDI()->makeFromStream(malicious_verdana.ttf, ttcIndex=0) . The crafted font is stock verdana.ttf with its name entry patched to offset=243300, logicalLength=243240 : ==ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 243300 #1 SkMemoryStream::read src/core/SkStream.cpp:349 #2 SkOTUtils::RenameFont src/sfnt/SkOTUtils.cpp:91 #3 create_from_stream src/ports/SkFontHost_win.cpp:1809 #4 SkFontMgrGDI::onMakeFromStreamIndex src/ports/SkFontHost_win.cpp:2290 #5 SkFontMgr::makeFromStream src/core/SkFontMgr.cpp:189 #6 main <- public API 0 bytes to the right of 280-byte region A 243,300-byte write into a 280-byte buffer, driven through a shipped public API, with attacker-controlled length and contents. A cheaper Linux harness that links the real SkOTUtils.cpp reproduces the same primitive at trivial cost - a synthetic 1000-byte font produces a 900-byte write into a 188-byte buffer - but the Windows run is the one that matters, because it goes through the front door rather than reaching around to the helper. The caveat I could not argue away Here is where honest reachability analysis cuts against you. RenameFont is called by the GDI backend and only the GDI backend. Skia's other Windows font backend, DirectWrite, never calls it. Modern Chrome on Windows uses DirectWrite. So default Chrome web-font loading does not hit this, and no amount of clever @font-face will make it. What it does hit is any Windows application or embedder configured with the GDI font manager that loads untrusted fonts through makeFromStream , makeFromData , or makeFromFile : legacy Chromium configurations, some Skia-based document, SVG, and HTML-to-image renderers, embedded Windows software. RenameFont is also a public SkOTUtils entry point in its own right, so any embedder that renames untrusted fonts is exposed directly. That is a real population. It is not the web. The same crafted font takes two paths. Only the GDI backend reaches the overflow - a real, verified bug that happens to fall outside the boundary Google scores severity against. Out of scope is their verdict, not a property of the bug. The verdict I filed it through Google's issue tracker with the security template, GDI scope stated plainly, the ASan crash and the end-to-end harness attached. A Chromium security reviewer replied the next day: Status: Won't Fix (Not Reproducible). The reproduction needs to demonstrate that it is reachable from the web. The description claims that web fonts can reach this path; if so, it should be possible to provide a HTML reproduction of this issue. The reviewer is not wrong. There is no HTML reproduction, and there cannot be one, because the web path is DirectWrite and DirectWrite does not call RenameFont . Under a threat model where "font security" means "a website cannot corrupt memory in the browser," this bug is out of scope, and Won't Fix is the correct call on those terms. Nobody disputed the crash. What was declined was its severity , and severity is the thing the threat model assigns. I want to be careful not to turn this into a grievance, because it is not one. Arguing web-reachability would have meant arguing something false, and I would have deserved to lose. The reviewer and I agree on every fact. We disagree, if that is even the word, about which facts are in scope. Reachability is a property of the threat model The thing I keep coming back to is that "reachable" sounds like a property of the bug and is actually a property of the boundary you draw around it. The overflow does not know whether the bytes arrived from a website or a document renderer or a local file dialog. It corrupts the heap identically in all three cases. The only variable is whether the path that delivered those bytes is inside the circle the vendor is willing to defend. For an organization the size of Google, drawing that circle around the web path is a defensible resourcing decision. Skia's security burden is dominated by Chrome, and Chrome is DirectWrite. But the circle is a choice, and the choice is what determines whether a verified memory-corruption write is a security bug or a curiosity. Same code, same crash, same attacker control. The disposition is set entirely by who is standing inside the boundary, and the GDI embedders who load untrusted fonts are standing just outside it. This is the same lesson I took from fuzzing libpng's write path: reachability is half the severity story, and it is the half that gets decided by someone other than the person who found the bug. There I scoped a finding down honestly and watched most of the apparent blast radius evaporate under inspection. Here I scoped it down honestly and watched the finding fall out of scope entirely. Both times the rigor that made the report credible is the same rigor that shrank it. The fix nobody will merge as a security fix The fix is one hunk. Reject any name entry that does not lie entirely within the font, before allocating or copying anything: const size_t fontLength = fontData->getLength(); if (oldNameTablePhysicalSize > fontLength || oldNameTableOffset > fontLength - oldNameTablePhysicalSize) { return nullptr; } The subtraction is written that way on purpose, so it cannot underflow. With the patch applied and rebuilt, the crafted font is rejected and a legitimate verdana.ttf still loads - correct, and not over-broad. It ships with a regression test. The security channel is closed, so the only route left for this code is to land the bounds check as a plain robustness change through Gerrit, with no security framing at all, judged on its own merit by a maintainer who may still decide that GDI plus untrusted fonts is not a configuration Skia supports. That is a legitimate outcome too. It would just mean the bug stays live in main , documented, verified, and unowned. What I take from it When you report a memory-safety bug, you are doing two things that look like one. You are proving that the code can be made to corrupt memory, and you are arguing that the way in belongs inside the reader's threat model. The first is a technical claim you can settle with a sanitizer and a stack trace. The second is a negotiation, and you can be completely right on the first and still lose the second. I would rather lose it honestly than win it by overstating reach. The overflow is real, it reproduces through a public API, and it is unpatched. Whether that is a security bug depends on a boundary I do not get to draw. That is not a satisfying place to end, but it is the true one, and I would rather write down the true one. Key questions Is the Skia RenameFont heap overflow patched? No. A Chromium security reviewer declined it as won't-fix (Not Reproducible) on 2026-06-24, and it remains unpatched in Skia's main branch. A verified one-hunk bounds-check fix exists, but there is no CVE and no shipped patch. What software is affected by the RenameFont overflow? Skia's Windows GDI font backend, reached through the public SkFontMgr_New_GDI() makeFromStream, makeFromData, and makeFromFile APIs. That covers Windows applications and embedders configured with the GDI font manager that load untrusted fonts, such as legacy Chromium configurations and some Skia-based document, SVG, or HTML-to-image renderers. Modern Chrome uses the DirectWrite backend, which never calls RenameFont, so default Chrome web-font loading is not affected. Can the bug be triggered from a website? Not through default Chrome. On Windows the web font path is DirectWrite, and DirectWrite never calls RenameFont, so there is no HTML reproduction. The overflow is reachable only through the GDI backend, which is why the finding is scoped to GDI-configured applications rather than the browser. Why did Google decline to fix it? A Chromium security reviewer returned Won't Fix (Not Reproducible) because the report is not reachable from the web under Google's threat model, which scopes font security to the Chrome DirectWrite path. They did not dispute the crash, a verified heap out-of-bounds write through a public API, only its security severity. The disagreement is about threat model, not facts. Who found it, and is there a CVE? Ariel Koren found it by manually auditing the bounds arithmetic in Skia's SFNT code, then verified it end to end under AddressSanitizer through the public GDI API - a 243,300-byte write into a 280-byte buffer. It was reported to Google's issue tracker (issue 527060475) on 2026-06-23. There is no CVE, because Google declined the report. ## AI Slop Is a Leadership Problem URL: https://arielkoren.com/writing/ai-slop-leadership/ Type: Engineering leadership | Date: 2026-07-12 I don't have deep, hands-on expertise in the Linux kernel, eBPF, or seccomp. A year ago that sentence would have ended a project before it started. Last week it barely slowed one down. A customer had a container-escape and privilege-escalation exposure they couldn't articulate cleanly. I couldn't have written the detection for it by hand - not without weeks of reading. But I could define the problem precisely and scope the risk, and that turned out to be the part that mattered. From there I used AI to map how specific CVEs turn into local privilege-escalation and container-escape paths, to point at the components that are historically the soft spots, and to stand up a first-iteration PoC that actually detects and prevents some of those behaviors. Not in weeks. In an afternoon. This is the part everyone writes about: the barrier to building fell through the floor. I want to write about the part underneath it, which moved in the opposite direction - and about why the slop everyone complains about is not really a model problem. The amplifier multiplies whatever it is given - the good parts and the slop alike. Amplification is not acceleration The tempting framing is "AI made me faster." That undersells and misdescribes it. AI didn't make me faster - it multiplied whatever I brought to it. Good judgment, tenfold. Sloppy scoping, tenfold. A precise question gets a precise system; a vague one gets a confident, well-formatted wrong answer that costs more to unwind than it saved. So the leverage is real, but it is leverage on the input . The tool amplifies taste and it amplifies the lack of it with equal enthusiasm. That's the first thing worth being honest about. The two barriers moved in opposite directions Here is the paradox I keep running into. The time it takes to reach a working solution has collapsed. The time it takes to reach real understanding has not moved at all. Illustrative, not measured - the shape is the point. Building got cheap; understanding didn't. Building a PoC in an afternoon does not mean I understand the vulnerability in an afternoon. Truly understanding it - reading the code, reproducing the failure by hand, knowing the failure modes, checking what it costs in CPU, memory, and disk at scale, knowing exactly when the model is hallucinating with confidence - that still takes the same work it always did. The demo is cheap. The understanding is not. And the gap between those two things is widening every month. That gap used to be invisible because you couldn't ship anything without first crossing it. Now you can. The missing layer between prompt and product Which brings me to the claim I actually want to make. AI slop is not created by AI. It is created by lack of direction. A model can generate a plan. It can write the first PoC. It can summarize the CVEs and produce ten plausible architectures before lunch. What it cannot do is know which one is worth doing. It doesn't know the customer's real pain. It doesn't know which constraint actually matters and which one only sounds important. It doesn't know which engineer will turn a rough direction into something solid, and which one will get buried in a promising dead end for a week. It doesn't know when a prototype is good enough to learn from and when it is dangerous to trust. That layer - direction - used to be hidden inside execution. When building was slow, the people who could push the work forward by hand naturally controlled where it went, too. AI pulled those two things apart. Execution became cheap to generate. Direction did not. And when direction is weak, the amplifier does not save a team; it just produces more confident noise, faster. Which raises a question I don't have a clean answer to. Do I still need to know every bit and byte? Do I still need to know how to debug a kernel by hand? Or do I need to understand the system well enough to define the right problem, ask the right questions, design the right tests, and reject the wrong answers - and let one agent debug while another verifies? (Some day, plausibly, the same agent doing both.) I don't think the answer is "no, expertise is obsolete." I also don't think it's "nothing has changed." Both of those are comfortable and wrong. You can still feel the bluff Under all of this, one instinct still works: you can feel AI slop. You can feel it when someone hands you a clean-looking architecture and can't explain a single tradeoff in it. You can feel it when the buzzwords are all present and the failure modes are all absent. You can feel it when someone is selling confidence instead of substance. A year ago I would grill anyone who tried to bluff their way through a deep technical conversation, and I'd enjoy it. Today I'm asking a genuinely harder question. If someone isn't the best kernel developer in the room, but their AI-assisted result actually works - it scales, it holds up under CPU and memory pressure, it's tested properly, and it solves the customer's real problem - does it matter who, or what, wrote it? As long as we can define what a good result is , what a rigorous test looks like, and how the thing should behave under load, and the customer is genuinely, not performatively, happy - I'm no longer sure the authorship of the first draft is the interesting question. (An aside, because honesty is cheap and I'll spend it here: nearly every AI-written post I read contains the phrase "but this is not the interesting part." I noticed it only after catching myself writing it. Take that as a small, useful reminder that the amplifier runs on all of us.) Where experience actually moved So does experience still matter? More than before - but not for the reason it used to. The work moved from execution to direction. The struck-through column didn't disappear - it stopped being the scarce part. It used to matter because experienced people could hold the whole stack in their heads and execute it by hand, bit by bit. That's no longer the scarce skill. What's scarce now is knowing what shouldn't be trusted - which output is a working prototype and which is a dangerous illusion, which test is load-bearing and which is theater, where the model is confidently wrong. This isn't new territory for me, it's just louder. It's the same instinct behind Anvil - a vulnerability-research system where AI agents do the repetitive glue work and deterministic infrastructure decides what is actually true - and behind the argument that agent authority can't be probabilistic even when the model is . In Anvil, standing up a fuzzing campaign against a new library - mapping the codebase, finding the API nobody had fuzzed, writing the harness, fixing the build - used to be the weeks of scaffolding that decided whether a campaign happened at all. Now it starts from a single prompt, and the engineering starts after the first crash, not before. I distrust dashboards when they replace understanding. I've started to distrust prototypes for the same reason. A result that works is the beginning of the argument, not the end of it. Who creates leverage now For years, the most valuable person in a technical room was the one who held the deepest implementation details - the one who could read the kernel source, debug the weird crash, name the exact syscall that mattered. That person still matters. But the leverage is no longer concentrated there. When time-to-solution collapses, the bottleneck stops being "can someone build a first version?" It becomes a different list of questions. What problem is actually worth solving? What does the customer really need? Which risk matters, and which one only sounds scary? Which engineer should own which piece? Where should AI accelerate the work, and where should it be treated as untrusted input? What tests prove the result is real? That is not generic management. It is technical direction, and it needs both ends of the stack at once: enough low-level understanding to feel the bluff in the output, and enough high-level judgment to know whether the work matters at all. It is also where teams will struggle most, because good engineers built their identity on being the person who knows every bit and byte. To them the amplifier can feel like cheating, or like slop - and sometimes it is slop. But avoiding it is not a strategy. The job now is to get a team using it without lowering the bar: explore faster, don't think less; reach understanding sooner, don't route around it; multiply strong engineers, don't replace their judgment with autocomplete. An organization amplifies exactly the way an individual does. Point it at sharply defined problems and it multiplies the team; point it at vague ones and it multiplies the noise. Because the model can generate options, but it doesn't know which option matters. It can produce a PoC, but it doesn't know whether the PoC is good enough for the customer. It can summarize a CVE, but it doesn't know whether that CVE is a real product risk or another well-formatted rabbit hole. That judgment is still human - and in a world where generating work is cheap, deciding what work is worth doing is the scarce skill. We're moving from an era of manual execution to an era of technical direction. I don't think everyone needs to know every bit and byte anymore. I'm increasingly sure someone in the room still has to know exactly which bits and bytes are the ones that will hurt us - and that's a different kind of knowing, one the amplifier can't hand you. ## Anvil: An Autonomous Vulnerability-Research Platform URL: https://arielkoren.com/writing/anvil-architecture/ Type: Systems design | Date: 2026-07-07 Anvil is the system I built to run vulnerability research across complex codebases, starting with open-source media libraries used by browsers, phones, and messaging apps. It uses AI agents as the hands inside the research loop. An agent maps a codebase into modules, writes a fuzz harness for an API that has not been fuzzed, runs it, reads the crash, and writes down what it found and what it ruled out. The next agent picks up where that one stopped. The agents are not the interesting part. The interesting part is everything around them that refuses to take their word for it. An agent can be confidently wrong - about whether a crash is real, whether anyone can reach the buggy code, whether it already reported this last week. So a claim only counts after it survives a fixed set of checks the agent does not control: the code builds, the reproducer triggers the crash under ASan, the affected path is mapped to a module, the reachability is written down, the finding file passes schema validation, and the whole thing lands in git. The agents do the repetitive research work. The checks decide what is true. Anvil exists because mature projects are not researched evenly. A few hot paths get fuzzed for years; the rest carries the reputation without the testing, because reaching it takes awkward harnesses, stateful APIs, rare build flags, or just boring setup. That neglected-but-reachable surface is where a worker that does the dull parts without getting bored has an edge, and it is what Anvil is pointed at. What AI actually changed The least interesting version of this is "AI runs a fuzzer." Fuzzers have run on their own for years. What changed is that AI made the work around the fuzzer cheap enough to do every session, instead of skipping it. The glue work was the bottleneck Finding the bug is a small slice of a session. The rest is glue: reading unfamiliar code, finding the entry points an attacker can reach, writing a harness and fixing the build under a sanitizer, checking old notes so you do not re-run a dead end, deciding whether a crash is new, minimizing the input, tracing the call path up to a public function, and writing it down for whoever works on this next. People skip these because they are slow and dull, and they are exactly where the durable value sits: the coverage map, the negative results, the "already looked here." An agent does them without getting bored. The map gets built and kept current. Harnesses get written for the unglamorous APIs. A crash gets triaged and either confirmed or dropped the same session it appears. And it is cheap. The entire run behind everything in this post came out of about $100 in Claude API credits. That is the number that changes the calculus: at that price, a research session stops being a rare, budgeted event and becomes something you can leave running. The agent does not choose where to look It is tempting to say the agent is "creative about where to look." It is not, and it should not be. Where to look comes from a ranking computed off past results; how to look comes from a catalog of techniques. The agent's judgement goes into the parts that need it - forming a hypothesis about a bug class, writing the harness that tests it, reading a crash and arguing for a cause. Even open-ended exploration is one entry in the catalog, handed out when the ranked options on a module are spent. The model is good with unfamiliar code. The system keeps it from wandering. The research lives in Markdown Anvil has no database. The whole research state is plain Markdown files with checked front-matter - one per target, finding, technique, and session - and that is on purpose. The research should outlive any tool that renders it, and it should be diffable in git so the history of a claim is legible. The files the agents edit are the record; the dashboard only renders them. There is no separate, prettier "source of truth" that a report gets summarized into, which means an agent cannot quietly believe something the files do not show. If a finding is not written down, with a reproducer and a reachability note attached, it does not exist. data/targets/ one per research direction (the libraries under study) data/findings/ one per finding (a candidate, with reproducer + reachability) data/techniques/ the technique catalog (the methods, with hit-rate) data/sessions/ one per working session (what was tried, what is left) data/methodology/ the verification protocol and the impact rubric A target carries the module map. Every source file belongs to a module, including ones nobody has opened yet, marked not-started. A script walks the real source tree and fails the build until the map accounts for every file, so it is an inventory rather than a guess - and the untouched modules are the point, because they show the next session where the unexamined surface is. A finding keeps two things separate that people usually blur. Its status is where it sits in the lifecycle. Its evidence is how strongly it has actually been shown - a separate ladder, below. It also carries the reproducer and the reachability: the topmost public function that triggers it, and whether that function is on the public surface at all. A session is the handoff. It records what was tried, what reproduced, what was dropped, and a note to the next agent. A session that finds nothing still gets written, because "ran these techniques on this module and found nothing" is a result that saves the next run from repeating it. The agent proposes, the checks decide This split is the thing the platform is built around, so here is which side each piece sits on. The agent does the work that needs judgement and can be wrong sometimes: - read unfamiliar code and propose where a bug class might be hiding; - write a harness, fix the build, craft seeds that reach deep parser states; - read a crash and propose a cause and a path to a real caller. The checks do the work that has to be exactly right, and the agent gets no vote: - the schema that validates every research file when the project builds, and rejects a bad field or a reference to a finding that does not exist; - the compiler and the sanitizer. If ASan reproduces a heap-buffer-overflow on the claimed input, the memory error is no longer just the agent's opinion - though whether it is reachable or security-relevant is a separate question; - the reproducer command, which reproduces or does not; - the source-tree audit that checks the claimed module map against the files on disk; - platform verification - running the input through a real operating-system image decoder and checking for the exact crash signal; - git, which records who claimed what and when. When the agent is sure and the sanitizer is quiet, the sanitizer wins and the claim gets dropped. The model's confidence is not evidence. That is the only arrangement in which a worker that can hallucinate is safe to point at security claims. Agents write research markdown through a schema check into the data store; a dashboard renders it read-only, and git records every scoped, per-target commit. The agents are the probabilistic worker; everything past the schema gate is a deterministic check. What the agent sees at session start None of this works if every session starts cold. When I name a target in plain language, the agent loads a fixed context before it touches any code: - the target's current state - status, priority, attack surface; - the previous sessions, including the handoff notes; - the unresolved findings, so it extends or dedupes instead of re-reporting; - the technique coverage - which techniques ran on which modules; - the module map of the codebase; - the methodology rules - the verification protocol and the impact rubric; - the current ranking of where to look next; - the files it is allowed to touch. From that context the agent decides on its own whether to bootstrap a new target - map the tree, find the public APIs, stand up the first harnesses - or continue an existing one. I do not tell it which; it reads that off the files. The plain-language target name is the only thing I have to supply. One agent per target Anvil runs several agents at once in the same checkout, and the part I am happiest with is how they avoid stepping on each other. The rule is one agent per target. Each agent only writes files under its own target - that target's profile, its findings, its sessions - and commits go through a small lock, scoped to just those paths. Two agents working different targets in the same directory produce clean, non-overlapping commits and never collide in git. No branches to juggle, no separate worktrees, no merge conflicts: the file layout and the commit scope make normal target work non-overlapping by construction, and any collision structurally rare and mechanically visible. For an unattended overnight run the loop drops the commit step and keeps going, and I review and commit what it produced in the morning. The loop itself is short: learn the target's state, attack the next module through the technique catalog, verify or drop each candidate the same session, and write the result back. A module is not "done" after one cheap pass - it is done when every technique has been run on it or marked not-applicable with a reason. How Anvil decides where to look The agent pulls its next job from a ranking, not a hunch, and the ranking is recomputed from the whole history of findings on every build. The unit is a cross-target pattern - integer math on image dimensions, container parsing, tiling, buffer lifecycle, and so on. Bugs cluster by pattern across libraries more than they cluster by library, so a pattern that paid off in one place is a lead in another. Each pattern earns a weight from its track record - how many findings, how severe, how many became CVEs - and each module is scored by that weight against how much of it is still untested and how big it is: score = patternStrength x coverageFactor x sizeBoost coverageFactor = 1.0 not-started, 0.5 in-progress, 0.1 audited, 0.04 exhausted sizeBoost = 1 + log2(1 + moduleWeight) A big, never-examined module whose code matches a productive pattern rises to the top. A small module already audited to exhaustion sinks. A second ranking - which technique has actually found which pattern - picks the technique to try first, so a manual bounds-audit gets chosen over raw fuzzing exactly where audits have done better. None of this is the agent improvising; it is arithmetic over the record, and the dashboard shows the factors so a ranking reads back as a sentence rather than a black box. What makes a result accepted A crash is not a finding. Before anything is accepted into the record as real, it clears a fixed list: - it has a reproducer - the exact build and run commands, checked in; - it runs under the sanitizer or build it claims (ASan for a heap overflow, UBSan for an integer overflow, a release build to rule out sanitizer-only behavior); - the affected code path is mapped to a module; - the reachability is stated - the public API or real application that drives it, or an honest note that only a harness reaches it so far; - the finding file passes schema validation; - the session records what was tried and what is still open. Miss any of these and it stays a candidate, not a result. The agent can propose anything; acceptance is mechanical. The evidence ladder The reachability requirement gets its own structure, because it is where most crashes quietly fail. A crash in a harness proves the code can misbehave when called directly. It says nothing about whether an attacker can get there through software people actually run. So every finding sits on a ladder, and the rungs are not interchangeable: unverified -> static_only -> harness_reproduced -> public_api_reachable -> real_application_verified -> platform_verified -> disclosure_ready The line that matters sits between a harness crash and public reachability. Below it, a sanitizer crash in a harness is a test artifact. Above it, the bug has been reproduced through the library's public API, a real application, or - the strongest rung short of a CVE - a real operating-system decoder, with a pinned crash signal so the run is reproducible rather than anecdotal. A candidate that no real consumer can reach gets set aside rather than counted. That is the difference between a crash count and a finding count, and it is the rule that keeps the platform from overclaiming. The pipeline files a candidate into the bottom of the ladder. Only the four rungs above the real-world bar count as verified; everything below, including a sanitizer crash in a harness, is a test artifact until it climbs. A session, concretely Here is how it comes together, with the specifics kept generic - the target is a mature image library everyone considers finished, and what follows is at the level of the architecture, not the bug. Earlier sessions had fuzzed the library's most-exercised parsing paths to saturation: many iterations, a lot of executions, no crashes. That "no crashes" is recorded as a result, not thrown away - those modules are marked audited under coverage-guided fuzzing, which is a clean signal rather than an absence of effort. So the ranking does not send the next agent back there. It points at the gap: a less-tested API path the standard fuzz targets never exercise, reachable only through a harness that drives it directly. Surfacing exactly that kind of untested surface is what the map is for. The attack-surface map keeps the whole tree visible: audited-clean modules in sage, never-examined surface in gray, findings ticked in red. The point is the gray - where the testing has not gone yet. The agent writes the missing harness, runs it, and ASan reproduces a heap overflow. The finding earns harness-verified and nothing more. To climb higher it has to be reachable through software people run, so the agent traces it: this is not the dominant consumer path, and a survey of real tools shows the common ones do not drive it the way the harness does. Everyday use - the overwhelming majority of how the library is exercised - never touches this code. So the finding is bounded honestly: scoped to that API path, reproduced in a harness, with the realistic consumer surface written down rather than assumed. It stays below the real-world bar, and it waits there until disclosure is settled. Every part of the system shows up in that one session. The map said the heavily-fuzzed paths were a dead end and a neglected one was untouched. The technique matrix said that path had no harness yet. The agent did the work that needs judgement - building the harness, reading the crash. And the ladder did the thing a human under deadline often skips: it kept a real, sanitizer-confirmed crash from being written up as more reachable than it is. What is proven, and what is in the pipeline The platform is built to generate candidates quickly, then spend most of its discipline proving, scoping, or dropping them. Discovery can move fast; responsible disclosure cannot. The public CVE count sits behind the research pipeline on purpose - every candidate still needs human confirmation, vendor coordination, safe sharing of reproducers, advisory writing, and sometimes CVE handling. In under two months, Anvil has produced more research traffic than I can comfortably process by hand, which is exactly the bottleneck I wanted it to expose: discovery is becoming continuous, while trust still has to be earned one claim at a time. Snapshot Active under 2 months Open-source targets researched 10+ Security findings / candidates tracked 80+ Disclosures sent to vendors 15+ CVE requests / CVE-track items pending 15+ Published CVE 1 To be clear about what is and is not automatic: discovery, codebase mapping, harness writing, fuzzing, crash triage, documentation, and disclosure prep are heavily automated and agent-driven. The last steps are not. Confirming a vulnerability for real, coordinating with a vendor, writing the advisory, deciding what to publish, and handling the CVE process stay with me. The platform queues the validated candidates and I work the queue on my own time - confirming impact, writing the advisory, running the vendor back-and-forth. That is the point of the design. It decouples the speed of discovery from the pace of disclosure: the machine can surface something on a Tuesday that I responsibly disclose weeks later. That human review is the current bottleneck, and it is the right place for the bottleneck to be: the slow part should be the one where a candidate becomes a public claim about someone else's software. The gap between candidates and CVEs is not the system failing - it is fast discovery meeting deliberate disclosure, which is how it should work. The portfolio on the ladder: one disclosed CVE at the top, and a cluster of candidates still sitting below the real-world bar, waiting to be shown reachable before they count. libheif - CVE-2026-48029 The one fully public result, and the clearest evidence the pipeline works end to end. An afternoon of fuzzing libheif surfaced a memory-safety bug in its grid-decode path: a tile index guarded only by a debug-build assert that gets compiled out in the release builds distributions actually ship, which turns a caught condition into a heap out-of-bounds read reachable from the public decode API. Reported 2 May 2026, fixed in 1.22.0 on 19 May 2026, tracked upstream as GHSA-6x5f-qchq-cxqv . The full writeup, with the disclosure timeline, is on this site at the CVE-2026-48029 page . The rest of the portfolio The other libraries - a JPEG 2000 codec, a PNG library, a rendering engine, and several more image and codec projects - have candidates at various rungs of the ladder, a few in active disclosure. I am keeping the specifics out until each one is settled, and deep technical case studies will follow as separate posts once they are safe to publish. The pattern is the same every time: a candidate that cannot be shown reachable through real software is set aside, not announced. Where agents go wrong, and what stops them Running an LLM as the worker brings specific, repeatable failure modes. Most of the checks exist to catch them, and naming them plainly is more useful than pretending they do not happen. - Overclaiming reachability. The most common one. An agent confirms a crash in a harness and then narrates a plausible path to a public API without actually walking it. The defense is structural: a finding has to name the topmost entry point and its call path, and that claim is checked, not taken. This is where most dropped candidates die - a real local defect that no shipping caller can actually reach. - Impossible harness states. An agent drives an API in an order no real caller would and reports the resulting crash as a library bug. It is a harness bug. Requiring a real consumer, not just the harness, filters these out. - Sanitizer artifacts. Behavior that only misbehaves under a sanitizer's instrumentation and is benign in a release build. Re-running across build flavors separates a real release-affecting bug from sanitizer-only undefined behavior before anything is called a finding. - Stale maps. An agent's mental model of the source tree drifts from what is on disk and coverage claims rot. The codemap audit walks the real tree and fails on any unmapped file, and a module marked done while techniques remain untried is flagged rather than trusted. - Fuzzy "public API" assumptions. "Public-ish" is not a category. Whether an entry point is on the public surface is recorded as a yes or no and checked against the headers, not asserted in prose. - Model confidence. The most persuasive failure mode. An agent's certainty about a root cause is treated as a hypothesis with no evidentiary weight. Only the result - did it build, did it crash, did a real consumer reproduce it - moves a finding up the ladder. None of these are fixed by asking the model to be more careful. They are fixed by putting the claim through a check the model cannot talk its way past. The models hallucinate. That is fine. The checks are a brick wall for false positives, and a hallucination does not survive contact with a compiler, a sanitizer, and a schema. By the time a finding clears all three, the invented parts are gone and what is left reproduces. The failure that does get through is the opposite one - the model missing a real bug. I can live with that. A false negative is cheap at a hundred dollars a run: it is not a dead end, just a reason to run the next pass. The two jobs Put it together and Anvil is not an AI that finds bugs. It is a research process that accumulates and that you can audit. AI made the glue work cheap - the mapping, the harnesses, the triage, the reachability tracing, the recording of dead ends - so it is finally affordable to be thorough where thoroughness used to get skipped. The checks - schema, build, sanitizer, reproducer, reachability, the ladder, git - are what make it safe to let a worker that can be wrong touch security claims at all. The agent does the work. The evidence decides what counts. Keeping those two jobs apart is what lets discovery run fast while disclosure stays careful, and it is why the work compounds instead of just piling up. ## LLMs Are Probabilistic. Agent Authority Cannot Be. URL: https://arielkoren.com/writing/agentic-boundaries/ Type: Agent security | Date: 2026-06-10 A task scope should be created from trusted user intent before untrusted runtime context reaches the agent. For years, the main risk of an AI model was bad output. You read it, judged it, and decided what to do next. You were the security boundary. That era is ending. Agents now read, click, send, download, upload, buy, change settings, call tools, write code, and reach into business systems. The output is no longer a paragraph you skim and discard. It is an action that lands in the real world and stays there. The dangerous part of an agent is no longer what it says - it is what it is allowed to do. A language model is probabilistic by design. It predicts the next good move; that is what makes it useful. But the authority to act on that move - to spend money, send mail, push code, touch credentials - cannot be probabilistic too. Most agent stacks blur this. The model decides what to try , and in most deployments it also decides what is allowed . Those are two different jobs, and the second one should not belong to the model. The security mismatch The mismatch is simple. The model reasons statistically. The actions it triggers are deterministic, privileged, and sometimes irreversible. A model predicting tokens has no built-in sense of authority. It can misread the task, hallucinate a step nobody asked for, be steered by a sentence buried in a webpage, or drift from "summarize this thread" to "reply to this thread" without anyone deciding that was acceptable. None of this requires a bug - it is the normal behavior of a system that predicts rather than verifies. The action on the other end is concrete. A payment clears. A file leaves the building. A commit lands on main. A reply goes to the wrong person. There is no probability distribution over whether the money moved. It moved. So we are wiring a probabilistic component straight into privileged capabilities and asking it to also guard them. A system that can be talked out of its instructions should not be the last line of defense for instructions. The cleanest signal you have is the original request There is one moment in an agent's run that is relatively clean: when the user says what they want. "Book a flight." "Summarize my email." "Find a part under a hundred dollars and order it." "Run the tests and tell me what failed." That request is not perfect - people are vague, and intent can be misread. But it is far less contaminated than everything the agent touches afterward. Webpages, retrieved documents, tool outputs, API responses, email bodies, comments, ads, and hidden prompts are all reachable by an attacker. Even the agent's own earlier summaries turn untrusted once they have passed through content it didn't control. This gives a clear principle: untrusted context can guide execution, but it must never expand authority. A webpage can tell the agent how to finish the task; it cannot tell the agent to do a different, larger one. To hold that line, fix the boundary before the agent reads any of that context - while the only input is the user. Once the agent has read the web, the web has had a chance to talk back. Turn the request into a task scope The move is to put a small, deterministic step between the user's request and the running agent. Before the agent does anything, it translates the request into a structured task scope: a concrete description of what this job may involve - the objective, the services and resources in bounds, the actions permitted, the data that may be read or written or shared, the operations that need explicit confirmation, and what to do when something falls outside the lines (allow, ask, block, or re-scope). This is not the model writing its own permissions. It is a narrow step that runs once, on trusted input, and produces a policy the rest of the system holds the agent to. The agent can still be clever inside the box. It just doesn't get to redraw the box because a webpage suggested it should. Enforce outside the model A scope that lives in the prompt is a suggestion. The model can be convinced to ignore it, because anything written in tokens can be overwritten by other tokens. Enforcement has to sit outside the reasoning loop, close to the real capability - at the layer where the action actually happens. In practice the check lives at the boundary the agent has to cross to do anything real: enterprise connectors, file systems, email gateways, code repositories, cloud APIs, messaging systems, payment flows, credential stores, browser APIs, and local tools. The model proposes an action; the enforcement layer compares it to the scope and lets it through, asks, or refuses. That decision does not depend on the model being in a good mood or the context being clean. What this looks like The same task scope can allow low-risk actions, require confirmation for sensitive actions, and block actions suggested by untrusted context. A shopping agent can search, compare, and fill a cart freely - low-risk and reversible. Payment is different: it needs confirmation and must respect the constraints from the original request, like the price ceiling and the seller. A page that says "checkout to see the price" does not get to move money, because moving money was never in scope without a human yes. An email agent can read and summarize the threads relevant to its task. It cannot send replies, forward attachments, or wander into unrelated private threads unless the scope allowed it. "Summarize my unread mail" does not authorize "reply to my boss," however naturally one follows the other. A coding agent can inspect the repo and run tests all day. Pushing commits, rotating CI secrets, or calling external services stay closed unless the scope opened them. A TODO that reads "also push this" is not the user asking for it. A browser agent is one good place to put this, since the browser already sees origin, navigation, downloads, and credential use - a natural spot for a deterministic check. But it is one home for the pattern, not the pattern itself. Enterprise, workflow, cloud, and OS-level agents all need the same separation. The line that matters The LLM can remain probabilistic. The authority around it must become deterministic. That is the whole shift. We don't need the model to be perfectly obedient or perfectly resistant to manipulation - we won't get either. We need its authority defined somewhere it cannot rewrite, and checked somewhere it cannot talk past. Where this leaves us Prompt-based safety is necessary. Telling a model to behave, refusing obvious abuse, filtering inputs - keep all of it. But that is guidance for a system that predicts, and prediction is not a security boundary. You cannot make a probabilistic component trustworthy by asking it more firmly. The next step in agent security is to separate reasoning from authority. Let the model decide what to try next. Let a deterministic layer, built from the user's original request and enforced outside the model, decide what the agent is allowed to do. This does not solve AI safety. It removes one specific, common failure: a probabilistic system holding the keys to privileged, irreversible actions and free to hand them out whenever the context asks nicely. Give agents boundaries they cannot reason their way around. The part that needs control is not the reasoning. It is the authority attached to it. ## OmniBoard: The Board Game Console That Didn't Pencil Out URL: https://arielkoren.com/writing/omniboard/ Type: Founder story | Date: 2026-05-13 OmniBoard. A console for board games. The board on the table, the cards in your hand, the marketplace overhead. Not an app. Not a tablet. Not a companion thing that lives next to a board game. A real device. The kind of thing that sits on the dining room table, and the table feels different because it is there. A board in the middle. Physical cards in your hand. The cards are real - paper-like, lightweight, the same shape and weight as the cards in the box you grew up with. But every card can become any card. Catan tonight. UNO tomorrow. Cards Against Humanity after dinner with friends. A new indie game next week, downloaded in thirty seconds. Same hardware. Infinite games. That was OmniBoard. OmniBoard started as a side project in 2023, after SNDBOX was acquired and I finally had room to write in a notebook again. It was not a company on day one. It was one of those side projects that quietly turns serious - the kind that earns a P&L, a deck, three time zones of e-ink supplier calls, VC meetings, mechanics sketched on the back of envelopes, and a marketplace design. I worked on it, off and on, through most of 2023 and into 2024. I shelved it because of one number, and that number is a real number, not a rhetorical one. I will get to it. But I do not want to start the post there. The math is the second half of the story. The first half is that this was a beautiful product and it deserved to exist. What it felt like, on a Friday night Picture the table. Four people sitting around it. The board is a single multi-touch surface in the middle - a real, physical thing, the size of a small TV laid flat. It is currently a Catan map: hex tiles, numbers, ports, a robber. Tomorrow night the same surface will be a Monopoly board. The week after, a 2-player Go board for the kids. The board does not know what game it is, until the device tells it. The cards are in your hand, where they belong. They feel like cards. Paper-like. Lightweight. No glowing rectangle, no glare, no battery drain you can see. Each card is a thin, flexible color e-ink panel - the same display family the Kindle uses, except now it does color, and now it has been miniaturized and embedded into a card-shaped object that lasts months between charges. Between rounds, you put the cards face-down on the board, the device updates them in place, and the hand in your fingers is now a different game. Same five cards. Different deck. The closet is empty. There is no shelf groaning under twenty board game boxes. There is no "wait, did we lose a card from this one?" There is no "the kids spilled juice on Settlers, that whole copy is done." There is one device, on the table, and the entire history of board gaming inside it. That was the feeling I wanted the product to have. Less a gadget. More like the thing your family pulls out on a holiday and then keeps pulling out long after the holiday. The vision sentence I kept coming back to was simple: the ultimate board game box for every household. Not "an app for board games." Not "a digital tabletop." A box. The box that replaces the closet. Same card, any game Same physical card. Five identities. The cards are not printed. The core magic of OmniBoard, the thing that made the rest of the platform possible, was that the cards were not printed. A regular printed card has one job for life. It is the 7 of hearts. It will always be the 7 of hearts. Whatever game uses 7 of hearts, you can use it for. Whatever game does not, the card is dead weight in a drawer. A flexible color e-ink card has no job until you give it one. The cards in the box are not a deck - they are a programmable substrate, and the deck is whatever the device decides it is between rounds. That is the move that makes everything else possible. This is not a digitization story. Tabletop Simulator digitized board games and that is a fine product, but it stripped the table out and turned everything into a screen with a mouse. OmniBoard kept the table and made the cards programmable. What that programmability unlocks is more important than the storage win. It is a new mechanic layer that printed cards cannot have: - The same hand of 5 cards can be 5 different things across 5 different games in the same evening. - One card can morph mid-game, in your hand, in front of you. Pick up a sword card, the card becomes the sword you are now holding. - A Dungeons and Dragons campaign saves its state to the card stack and resumes weeks later, where you left off, with the same hands you went to bed with. - A "card on card" gesture - placing one card on top of another - is now a real interaction the device understands. Armor on a character. Potion on a target. Two cards combine into a new card. - Cards can vibrate. Cards can be animated, gently, like the moving photograph on the Daily Prophet in Harry Potter. A creature card breathes. A weather card shimmers when it rains in-game. None of this is the device showing off. All of it is mechanics that, today, simply cannot exist in a printed board game. Six mechanics that printed cards cannot do. Card on card is the strongest read. OmniShop, the platform underneath OmniShop. The platform underneath the device. The bet was not only the device. The bet was that board games were missing their platform. PCs got Steam. Phones got the App Store. Living rooms got PlayStation and Xbox. Every one of them is the same shape: a hardware base, a content marketplace, a creator economy, a subscription, a community. Board games never got that shape. They still ship by truck. Steam releases tens of thousands of new games every year, while a tabletop release takes a year of manufacturing before it reaches a single player. OmniShop was the missing storefront. It would have sold: - Games. First-party titles, licensed classics, indie originals. - Maps. Alternative Catan boards. New Monopoly cities. Custom worlds for the same game engine. - Card skins. New art for an existing deck. A seasonal skin for a party game. A winter-themed deck for a family card game. A custom-illustrated skin for a storytelling game. - Expansion packs. New rules, new cards, new mechanics dropped into a game you already own. - Seasonal and event drops. Limited-time content on holidays, tournaments, themed nights. A creator SDK would let developers, designers, therapists, and teachers ship card-shaped products without ever touching a printer, a packaging line, or a freight container. A game on OmniBoard would be a download. That opens the door for the creators who get scared off by manufacturing risk today - the next Inscryption, the next Slay the Spire, a therapy deck for verbal-difficulty kids, a Spanish vocabulary trainer, a party game written by one person on a weekend. The subscription rhymed with PlayStation Plus and Game Pass: $5 a month, one free game a month, free skins, ranked online play, seasonal events. The line at the top of the investor deck was one sentence: OmniBoard is to board games what Steam is to PC games and what PlayStation is to the living room. Same shape. Different table. Why board games needed this The friction nobody talks about because it is everyone's normal. I want to be careful not to oversell the pain side. Board games are not a broken category. They are one of the most enduring forms of social play we have. People love them. The category is growing. But the friction is real, and it is the friction nobody talks about because it is everyone's normal: - The closet groans. A serious household has 20+ boxes, most of them rarely played, all of them taking shelf space. - Pieces and cards go missing. Nobody thinks a missing card matters until it is the one everyone remembers from the box. - Try-before-you-buy does not exist. You pay full price for a game, open the shrink wrap, find out in twenty minutes whether your group likes it, and if they do not, the box goes on the shelf forever. - Long games have no save state. A four-hour D&D session, a serious Risk campaign, a Catan game that ran late - if you cannot finish it tonight, the game is over. - Creators hit a wall. If you have a card game idea today and you are not a publisher, you cannot ship it. Manufacturing, packaging, shipping, and retail are four separate moats, each one capable of killing the project alone. OmniBoard solved all of these as a side effect. Not as features. As a side effect of "the box replaces the closet, and the closet was always the actual problem." Why I believed the market was real I will not bury you in numbers, because the deck does that and most of the deck numbers are not the point of this post. But the headlines are worth knowing, because they are why I took the project seriously enough to spend a year on it. Hundreds of millions of people play board games. The global board and card game market is somewhere in the tens of billions of dollars annually and projected to grow at a high single-digit to low double-digit CAGR through 2030, depending on which analyst you read. The methodology spread is wide - estimates put 2030 between roughly $22B and $40B - but the direction is unambiguous: the category is growing, not shrinking. The thing that mattered for a platform play was the revenue concentration. A small number of titles do most of the money. Magic is now a billion-dollar-scale franchise and Hasbro's primary growth engine. A short list of category leaders - the trading-card games, the household party titles, the gateway strategy games - dominates revenue, which means a small number of high-leverage partnerships could ship the device with the games people actually want on day one. Every other category of game has its platform. Tabletop does not. Somebody was going to build it. I thought it could be me. What I actually did I want to be specific about the work, because "I had an idea once" and "I worked on this for a year" are different sentences. I wrote the vision deck. I wrote the business deck. I wrote a customer-validation deck. I built the P&L, the operating model, and the funding story. I also met dozens of potential co-founders. That part surprised me. OmniBoard was easy to explain and easy to fall in love with; once people understood the device and the platform, many wanted to jump onboard. I called e-ink suppliers. DASUNG. eink.com. Ron Mertens at Metalgrass. DASUNG eventually told me to skip them and go straight to the manufacturers, which was good advice and also the first real signal: this is not a market that wants startups in it. I started game-licensor outreach. A few committed conversations with publishers behind well-known card and party games. None of these were committed deals. Early signal-gathering. I came from a very different startup world. Most of the investors I knew were B2B people, and many were connected to security. Consumer hardware was not the obvious next company for me. It almost felt out of reach. What surprised me was that when I told the OmniBoard story, people still leaned in. Even VCs who did not usually do B2C were curious enough to keep listening. The missing piece, before the BOM killed the project, was a small PoC - something physical enough to prove that the magic could exist on a table. The pattern in the room, when people did pause, was not "the idea is bad." It was "hardware plus platform plus content is three startups stapled together, and a $10M seed gets you one." I scoped the GTM: PAX Unplugged, Gen Con, Essen Spiel, UK Games Expo, Reddit /r/boardgames, BoardGameGeek, live testing at local stores. The competition was real and not encouraging. The Last Gameboard raised a $4M seed in 2021. Magicyard raised $3.3M in 2022. A Riot-adjacent investment in this space flopped publicly. The graveyard for "digital board game device" is not empty. Through all of this I had an open spreadsheet on the side, and I was filling it in as the vendor calls came back. And then I opened the spreadsheet I had been doing the work in the order founders are supposed to do it. Vision first. Decks. Conversations. Validation. Suppliers. VCs. The math last, almost as a formality, almost as a victory lap. The spreadsheet was supposed to confirm what I already believed. It did the opposite. The number that killed it Per-console BOM at 5,000-unit bulk pricing. The dotted line is the target retail price. The BOM I built around the 5.65 inch flexible 7-color e-ink cards looked like this: Item Unit Qty Subtotal Flexible color e-ink card (5.65", 7-color) $38.53 20 $770.60 Flexible battery ~$0.30 20 ~$6.00 NFC tag $0.10 20 $2.00 NFC reader $1.00 1 $1.00 Physical box $10.00 1 $10.00 Per-console BOM (list) $789.60 At a rough 30% bulk discount on a 5,000-unit run, that drops to about $553 per console . Just the parts. Before the multi-touch board, the case, the MCU, assembly, shipping, warranty, and the cost of money. Realistic landed cost, all-in, was higher. I had targeted $500 retail to stay below the PS5's $700. At $500 retail, every console would ship at roughly a $53 loss before any margin. The model only worked if a SaaS subscription at $5 a month and an average of five DLC games at $20 each subsidized the hardware loss, and that subsidy only crossed into profit at roughly a million units shipped. To ship this honestly, I would have needed to retail it above $600. Probably north of $700. For a game-night accessory. I went back to the notebook page where I had been keeping the kill criterion. The line on it was: I ask myself, will I buy this for $1,000? My answer is no. If I would not buy it, I should not ship it. The spreadsheet did what spreadsheets are supposed to do. What would need to be true For OmniBoard to actually work, one of three things has to happen: - Flexible color e-ink prices fall by 60% or more. The 5.65 inch 7-color panel I priced at $38.53 bulk would need to land below $15 to make a $500 console work without subsidy. TVs got 50x cheaper from 1972 to today. The same curve will eventually run through e-ink. It will not run through it on a startup's timeline. - A larger company subsidizes the hardware. The Steam, PlayStation, and Game Pass models all use the device as a wedge for the marketplace, and they tolerate hardware losses because the platform pays them back. That posture needs platform-company balance sheets, not a $10M seed. - A cheaper card substrate appears. Reflective LCDs at the right quality. MicroLED tiles cheap enough to ship 20 per device. Something not on the 2024 roadmap that I am not aware of. None of those three were true in 2024. I could not move the e-ink suppliers. I could not justify $700+ retail. And no alternative substrate showed up at the quality the product needed. What I learned The kill criterion has to be a number, written down, before you start. Mine was $1,000. The number is what made it possible to stop without negotiating with myself for another year. Hardware plus platform plus content is three startups. A $10M seed funds one of them, well. It does not fund all three. If I do a hardware play again, I will pick the smallest viable shape - hardware alone, or platform alone, never all three at once. Supplier conversations teach more than spec sheets. DASUNG telling me to skip them and go direct was the whole signal. Flexible color e-ink is not yet a market that wants startups in it. Subsidy models need scale you can credibly hit. "Lose on hardware, win on SaaS and DLC" is a real strategy, but only if the unit economics flip well before a million units. Mine flipped at a million. Validation is permission, not pull. People liked OmniBoard. Investors said complimentary things. Nobody pushed back hard. That is not the same as a market pulling on you. Closing Some ideas fail because they are wrong. OmniBoard is not one of those. Some ideas fail because the world is not cheap enough yet. OmniBoard is one of those. The device should exist. A console-shaped product will eventually change how households play board games the same way the Switch changed how households play video games. The economics moved 50x for TVs over fifty years. They will move for flexible color e-ink too. For now, OmniShop, the SDK, the multi-touch board, the card-on-card mechanics, the morphing card in your hand - all of it is back in the notebook. Intact. Waiting. When the BOM moves, the project moves with it. ## 0day: libpng APNG OOB Write URL: https://arielkoren.com/vulnerabilities/libpng-apng-write-fuzzing/ Identifier: n/a | Status: Accepted by maintainer 2026-06-23 - patch accepted, fixed release pending. CVE request declined 2026-07-10 on scoping grounds and not yet re-issued (GHSA-wr84-h9jm-6g23, still a draft) | Severity: High A write-side fuzzing campaign against libpng18's APNG re-encode path found a per-frame buffer lifecycle bug causing both a memory leak and a width-dependent heap buffer overflow. The overflow carries 100% attacker-controlled bytes, scales linearly with canvas width to a per-row ceiling of approximately 4 MB at libpng's default user-width limit, and was characterized in a no-ASAN glibc test build as an input-controlled adjacent-heap overwrite. Patch authored and validated across a multi-billion-execution post-discovery campaign. The attack surface of a mature image library sounds like a solved problem. libpng has been audited, fuzzed, and deployed at planetary scale for three decades. Running an AI-driven fuzzing campaign against it in 2026 sounds almost quaint - until you remember that the machine doesn't tire, doesn't speculate, and doesn't decide the read side is probably fine . An 11-vector parser harness ran autonomously for 22 hours - 26 iterations, 158 million executions against libpng 1.6.50, covering synchronous read, progressive read, simplified API, write-back round-trip, transform combos, and allocation-failure injection. Coverage climbed from 11% to 38.94% of instrumented program counters. Zero crashes. Zero memory-safety findings. A human researcher might have called it there. The agent pivoted. libpng18 - the post-v1.6.58 mainline merge of APNG write support at commit 614ab644f - had never been fuzz-tested on the write path at this depth. A harness mirroring the standard APNG re-encode loop was constructed in minutes: read each frame head, read rows, write frame head, write rows, write frame tail. The fuzzer fired the first bug within seconds. The second came 25 minutes later. Total elapsed time from parser saturation to both write-side findings confirmed: under one hour. Time from findings to coordinated disclosure bundle: same afternoon. That gap - machine-speed discovery, coordinated remediation - is the real story. One defect, two symptoms Findings 002 and 003 are a single defect: png_write_reset does not release the per-frame scratch buffers. With uniform frame widths that shows up as a clean memory leak; with varying widths it becomes an out-of-bounds write. One patch closes both, so the advisory treats them as one independently-fixable vulnerability mapping to one CVE. A third finding from the same campaign - sub-byte pad-bit propagation (CWE-908) - is spec-compliant, was removed from the advisory as a hardening item, and is not closed by this patch. Root Cause - Memory Leak (Finding 002, CWE-401) png_write_reset (pngwutil.c:2884) is called from png_write_frame_head to begin each new APNG frame. It zeros three frame-progress fields - row_number , pass , and a mode flag - but does not free row_buf , prev_row , try_row , or tst_row . Because the counters are zeroed, the next png_write_row call re-enters the first-row init path and png_write_start_row runs again. It reallocates row_buf unconditionally, and prev_row whenever the frame's filter set includes AVG, UP, or PAETH - in both cases overwriting the pointer without freeing what it held. ( try_row and tst_row are guarded on try_row == NULL , so they survive instead; that is the path Variant A trips.) Every frame silently leaks its scratch allocation. SCHEMATIC_PLACEHOLDER LeakSanitizer finding_002 - 263 B canonical reproducer LSan Direct leak of 26 byte(s) in 2 object(s) allocated from: #1 png_malloc_base pngmem.c:98 #3 png_calloc pngmem.c:54 #4 png_write_start_row pngwutil.c:2096 The leak grows linearly with frame count. num_frames is capped at PNG_UINT_31_MAX ( 0x7fffffff ); combined with typical row-bytes in the kilobytes, an attacker can leak megabytes per re-encode call. Against a long-running process that re-encodes APNG through the per-frame write API, that is slow heap-pressure DoS - subject to the same narrow reachability as the overflow, which is covered below. OOB Write Mechanics (Finding 003, CWE-787) My original report explained this as row_buf simply being left sized for the narrower frame. The maintainer instrumented it during triage and showed that is not what happens: png_write_start_row does fire on every frame, and it does allocate row_buf at the correct size for that frame. The stale buffer arrives by a different route, and the corrected mechanism is the one below. The trigger is the filter double-buffer. png_write_filtered_row swaps row_buf and prev_row after each row, so the two pointers trade places as a frame is written out; png_write_reset frees neither. A narrow intermediate frame's small row_buf is swapped into prev_row and survives there across the frame boundary. On the second row of a later, wider frame, the swap puts that stale narrow buffer back into row_buf - and the row memcpy at pngwrite.c:900 writes the wide frame's row into it: memcpy(png_ptr->row_buf + 1, row, row_info.rowbytes) The write is row_info.rowbytes - that is PNG_ROWBYTES(usr_pixel_depth, current_width) , 12 bytes for a 4-pixel RGB8 row - starting at row_buf + 1 , into an allocation sized for the narrow frame. The wide frame's first row is fine; a later row is where it lands, so a single-row wide frame will not trip it. The mismatch is otherwise geometric: independent of color type, bit depth, and interlace type. The canonical trigger is an 8-frame RGB8 APNG with fcTL widths 4,4,4,3,1,4,4,4 : three full-width frames, two progressively narrower frames, then growth back to full width. The OOB fires on the first wide post-narrow frame. AddressSanitizer finding_003 - 603 B canonical reproducer ASAN ==21987==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000065a WRITE of size 12 at 0x60200000065a thread T0 #0 __asan_memcpy #1 png_write_row pngwrite.c:900:4 #2 png_write_rows pngwrite.c:651:7 #3 LLVMFuzzerTestOneInput apng_write_fuzzer.c:473:17 0x60200000065a is located 0 bytes to the right of 10-byte region [0x602000000650,0x60200000065a) allocated by thread T0 here: #0 malloc #1 png_malloc_base pngmem.c:98:11 #2 png_malloc pngmem.c:181:10 #3 png_write_start_row pngwutil.c:2049:23 #4 png_write_row pngwrite.c:812:7 #5 png_write_rows pngwrite.c:651:7 SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy A 12-byte write into a 10-byte region. Because the destination is row_buf + 1 , the write runs from offset 1 to offset 12 and ASAN flags the first out-of-bounds byte at offset 10 - three bytes past the end. From a 603-byte input. Deterministic across runs. Linear Scaling - Verified Three bytes sounds harmless. It isn't. Using a width-tunable APNG generator mirroring the shrink-then-grow pattern, the OOB write size scales linearly with canvas width : Canvas width W row_buf size memcpy size OOB delta 4 (canonical) 10 B 12 B 3 B 16 4 B 48 B 45 B 64 4 B 192 B 189 B 256 4 B 768 B 765 B 1,024 4 B 3,072 B 3,069 B 4,096 (harness MAX_DIM) 4 B 12,288 B 12,285 B 1,000,000 (libpng default PNG_USER_WIDTH_MAX ) 4 B ~3 MB ~3 MB / row · ~4 MB at 4 bpp The bytes past the end are exactly new_rowbytes - old_rowbytes , since both allocations carry the same one-byte filter prefix. For RGB8 following a 1-pixel frame that is W x 3 - 3 bytes per row. Multi-row frames re-trigger per row; multi-frame APNGs re-trigger per wide post-narrow frame. At libpng's default user-width limit and 4 bytes per pixel, a single overflowing row writes approximately 4 MB of attacker-controlled bytes past a 4-byte allocation. Attacker control properties - Bytes written: 100% attacker-controlled. With filter=None, IDAT/fdAT pixel bytes survive to the memcpy verbatim; other filters are reversible. - Write size: attacker-tunable from 1 byte to ~4 MB by IHDR width and the fcTL frame-width pattern. - Determinism: same input => same OOB size, same memcpy call site, same relative offset. Allocator address differs run to run; the delta is constant. - Repeatability: an 8-frame seed gives 3+ OOB writes back-to-back. Under the deterministic bump allocator they land on the same reused row_buf slot; against glibc the slot is reused too, so the repetition is depth, not spread. A single sub-1 KiB input file produces multiple OOB hits. Reachability Grepping the libpng tree for png_write_frame_head and png_write_frame_tail turns up their definitions and exactly one caller: pngtest.c:1553 . Several commonly-tested tools - apngasm, apngopt, and ImageMagick - bypass this API entirely, assembling APNG containers with fwrite directly and did not reproduce the bug. Read-only consumers do not reach the write path. The realistically affected population is any encoder built directly against libpng's per-frame APNG write API. Reachability Who actually re-encodes APNGs? Before publishing severity claims I checked which user-space tools actually call libpng's per-frame APNG write API: Tool libpng write API used Reproduces finding_003? apngasm v3.1.10 png_write_image only Not reproduced apngopt 1.4 png_write_image only Not reproduced ImageMagick ( coders/png.c ) png_write_row + png_write_info / png_write_end Not reproduced Grepping the libpng tree for png_write_frame_head and png_write_frame_tail turns up their definitions in pngwrite.c and exactly one caller, pngtest.c:1553 . pngtest is the only caller of the per-frame APNG write API in libpng's own tree. Debian ships it in the libpng-tools package and runs it during make test . The realistically affected population is narrower than "image CDNs / re-encoders": (1) pngtest itself - shipped binary, runs during build/CI; (2) any encoder built directly against libpng's per-frame APNG write API; (3) future encoders written against this API without awareness of the lifecycle requirement. Read-only consumers - browsers, image viewers, decoders that don't call png_write_* - do not reach the vulnerable path. Takeaways Lessons. - Fuzz the write side. OSS-Fuzz historically pressures decode. A natural read-then-re-encode harness - the very thing image-processing pipelines actually do - was enough to surface security-relevant write-side memory corruption in under 30 minutes against a continuously-fuzzed library. - Width-varying multi-frame APNG is the structural weak shape. Generators that emit only uniform-width APNGs miss this entire bug class. The shrink-then-grow pattern 4,4,4,3,1,4,4,4 is the canonical worst case. - Lifecycle helpers are bug factories. png_write_reset looks like a 3-line helper. It zeroes frame-progress fields and leaves the four scratch buffers alone, trusting the allocation path to sort itself out. It does - for row_buf . What it does not account for is that png_write_filtered_row has already moved the previous frame's buffer into prev_row . The state a lifecycle helper misses is often state that some other function moved. One root cause, three manifestations (finding_002, finding_003, Variant A), one patch. - Get the root cause reviewed by someone who owns the code. My reported mechanism was wrong. The reproducers were right, the patch was right, and the severity was right - but the causal story was still wrong, because it was inferred from the allocation path without instrumenting the swap. The maintainer caught it in triage by instrumenting png_write_start_row and finding it fired every frame at the correct size. A mechanism that predicts the observed crash is not the same thing as the mechanism. - Linearly-scaling OOBs should not be dismissed by their smallest reproducer. "Three bytes past a ten-byte allocation" sounds harmless. "Up to 4 MB of attacker-controlled bytes onto adjacent heap metadata" doesn't. The correct framing always quantifies the primitive at the user-limit ceiling , not the canonical-seed minimum. - Reachability is half the severity story. apngasm, apngopt, and ImageMagick all bypass libpng's per-frame APNG write API by accident - they assemble APNG containers manually with fwrite . The realistically affected population is materially smaller than "image CDNs and server-side optimisers" suggests. This scoping detail matters for any severity assessment. - Patch validation matters as much as the initial finding. The same patch closed the original leak, the OOB write, and the later Variant A manifestation - three distinct alloc sites - across billions of follow-up executions. A finding is not finished when the bug fires; it is finished when the patch is verified to close the full family. - Saturation is a finding too. Roughly 7.7 billion executions across 30+ harness shapes with zero new disclosure-grade bugs is positive evidence that the patched libpng18 APNG-write surface is tight under all the angles this campaign tried. A future researcher should pivot target rather than re-running the same harnesses. - AI-driven fuzzing is good at exhaustion, not insight. The bug fired because one mutation strategy out of fifty perturbed per-frame fcTL widths independently. The agent didn't reason its way to the bug class; it ran the search space hard enough that the structural weak shape surfaced. The methodological claim isn't "agents are smarter"; it's "agents don't get tired, and they don't decide a surface is probably fine ." Scope Who is actually at risk? The confirmed affected set is narrow, and it is worth being precise about that rather than gesturing at libpng's install base. Four gates must all be true before an application is in scope: (1) the linked libpng exposes APNG write support ( PNG_WRITE_APNG_SUPPORTED ); (2) the application actually writes APNG through the per-frame API, not merely reads or views it; (3) frames of differing widths reach that API; and (4) attacker-influenced input can shape the frame sequence or geometry. Read-only consumers fail gate 2 structurally, which removes browsers, image viewers, and every plain decoder from the picture. The maintainer's assessment and mine agree: the only in-tree caller of png_write_frame_head / png_write_frame_tail is pngtest.c . The realistically affected population is pngtest - shipped in Debian's libpng-tools - plus any application built directly against libpng's per-frame APNG write API. Third-party API reachability One concrete data point that this is not a purely in-tree surface: the Python package imagecodecs calls png_write_frame_head and png_write_frame_tail explicitly from its _apng.pyx Cython extension, against a vendored libpng-apng-patched build. That is source-level evidence of the API being used outside libpng's own tree. Surveyed tools Tool / Category Verdict Rationale pngtest (Debian libpng-tools ) Affected The only in-tree caller of the per-frame APNG write API. Reachable and deterministic. imagecodecs (Python) API path confirmed in source Direct png_write_frame_head / png_write_frame_tail usage via a vendored patched libpng. Not reproduced end to end against the package itself. apngasm v3.1.10, apngopt 1.4 Not affected png_write_image per frame; APNG container assembled manually with fwrite . Does not reach the per-frame write API. ImageMagick Not affected png_write_row with png_write_info / png_write_end , without png_write_frame_head / tail . Pillow, GraphicsMagick Not affected Pillow writes its own chunk headers; GraphicsMagick ignores APNG chunks even against a patched libpng. Browsers and image viewers Not affected Read-only consumers. Displaying an APNG does not call png_write_* at all. This is an encoder-path defect. .NET ImageSharp, Rust image-png, Go apng Not affected Pure-language APNG implementations - no libpng linkage. libvips (APNG branch, 2026) Worth watching An active PR adds APNG read/write via libpng. Not deployed yet, but a server-side path worth re-checking once merged. What this is not This is not remotely triggerable against a decoder. Receiving, viewing, or displaying a PNG or APNG does not reach the vulnerable code. Any application is in scope only if it generates APNG through libpng's per-frame write API - so the useful question about any given product is not "does it display PNG?" but "does any part of its pipeline encode APNG through png_write_frame_head ?" I have not confirmed that for any consumer product, and I am not going to imply it by listing names. Version reach libpng18. libpng17 was abandoned years ago, and libpng16 does not and will not carry APNG. The caveat is the third-party libpng-apng patch: a patched v1.6.x build will most likely carry the identical defect, and those downstreams should pick up the fix once it lands upstream. Mainstream system libpng on Debian, Fedora, and Arch ships the 1.6 stable line without the APNG write API, which bounds today's exposure - but that changes as libpng18 reaches distribution channels, which is the argument for fixing it now rather than later. ## Finding CVE-2020-1321: Fuzzing Microsoft Office's 3D Model Parser URL: https://arielkoren.com/vulnerabilities/cve-2020-1321/ Identifier: CVE-2020-1321 | Status: Public / Patched | Severity: Important A grammar-driven .glb fuzzing campaign found a memory-corruption bug in the shared 3D parser used by Microsoft Word and the Microsoft 3D Viewer. The same input crashed both products at matching call-site offsets. Reported to the Microsoft Security Response Center on January 30, 2020. Microsoft published the fix on June 9, 2020 as the Microsoft Office Remote Code Execution Vulnerability, graded Important, CVSS 7.8, exploitation less likely. A malformed .glb reaches both Microsoft Word and the Microsoft 3D Viewer. The two products share their 3D parsing code at the assembly level. I found this bug several years ago and never published the technical story. This page reconstructs the research from the original fuzzer, the GLB samples it generated, the WinDbg logs, the rendering screenshots, and the disclosure correspondence I still have on disk. The bug was eventually published by Microsoft on June 9, 2020 as the Microsoft Office Remote Code Execution Vulnerability. Microsoft Office added the ability to insert 3D models into Word, PowerPoint, and Excel documents around 2018. Under the hood, the rendering and parsing path goes through a relatively young engine that is also packaged as a standalone Windows 10 app, the Microsoft 3D Viewer. Two separate binaries - MSOSPECTRE.DLL in Office and Mira.Core.Engine.UWP.dll in 3D Viewer - share parser code at the assembly level. One malformed model can crash both. The format itself is binary glTF: a 12-byte header, a JSON chunk, an optional BIN chunk. The JSON describes a scene graph in which almost every field is an integer index into another field. scenes reference nodes ; nodes reference meshes ; meshes reference accessors ; accessors reference bufferViews ; bufferViews reference buffers . Animations layer in samplers and channels on top. The complexity is the cross-references the parser is expected to keep consistent. That is where grammar fuzzers earn their keep. Mutational fuzzers tend to break the references and bounce off the parser's "is this even a valid index?" early-exit checks. A grammar that respects the references but deliberately breaks the agreements between them - two accessors sharing a bufferView but disagreeing about how many elements live there - lands deeper in the parser, where arithmetic mistakes are more likely to matter. That made the target attractive: a real parser, a complex indexed format, and a path into Word documents. How the bucket stood out The interesting bucket showed up before the MSRC report went out on January 30, 2020. The exact samples attached to that report are no longer in this working folder. The earliest crashing GLB I still have on disk is from February 10, 2020, with additional continued-analysis variants captured around February 23 and 25. Two things made the bucket stand out from typical Office crashes. First, the variants in this bucket all landed on the same low-level instruction - rep movs inside VCRUNTIME140!memcpy_repmovs (or its _APP variant in 3D Viewer) - but the surrounding stacks varied with which mesh attribute the parser was setting: Mesh::SetPositions , Mesh::SetIndices , Mesh::SetUV0 , Mesh::SetUV1 , Mesh::SetColours , Mesh::SetJointData . That is the signature of a single arithmetic mistake in a shared upstream helper, fanned out into many sinks. Second, the same input crashed both products at matching call-site offsets. The bug was in the shared parser, not in either application's glue. The samples in this bucket reproduced the same crash pattern in both products: winword.exe driven through gfx!Gfx::IModel3DScene::* and mso20win32client ordinals into MSOSPECTRE.DLL , and 3DViewer.exe driven through Mira.Core.Engine.UWP.dll . The shared-code reachability is what made the bug interesting beyond a one-off parser crash. The crashing copy loop in the shared 3D parsing module. Six instructions; the bound in r14 is computed from one attacker-controlled field while the underlying buffer is sized from another. Under PageHeap, a db rsi - 0x50 against the canonical sample shows the source pointer walking into uncommitted memory while the loop is still iterating; the bytes immediately past the readable range come back as ?? . The crash signature is an out-of-bounds read driven by a length the parser trusts from JSON. The simplified crashing object reduced to its smallest reproducible form looks like this: simplified GLB JSON chunk "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0}], "meshes": [{"primitives": [ {"attributes": {"POSITION": 0, "NORMAL": 1}, "mode": 6} ]}], "accessors": [ {"name": "offset 0 position", "componentType": 5126, "count": 73, "type": "VEC3", "bufferView": 0, "normalized": true}, {"name": "offset 1 normal", "componentType": 5126, "count": 71, "max": [9999, 9999, 9999], "min": [-9999, -9999, -9999], "type": "VEC3", "bufferView": 0} ], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 4081, "target": 34963, "stride": 6} ], "buffers": [{"byteLength": 18000}] Three fields disagree with each other: - The POSITION accessor declares count: 73 over bufferView 0 . - The NORMAL accessor declares count: 71 over the same bufferView 0 . - The shared bufferView declares stride: 6 and byteLength: 4081 . A VEC3 of componentType 5126 (FLOAT) is normally 12 bytes. A stride of 6 cannot physically fit it. The crash evidence is consistent with the parser deriving its copy length from one of those numbers and its bound from another. When the two numbers disagree, the SSE copy loop runs past r14 , reading from (or writing to) memory the buffer was never sized for. SCHEMATIC_PLACEHOLDER The surviving artifacts show the crash and the memory-corruption behaviour cleanly, but they do not include the patched binary or a binary diff. This page does not name the precise patched function or the precise integer-arithmetic mistake the patch closed; the artifacts do not prove that level of detail. Microsoft classified CVE-2020-1321 as Remote Code Execution under CWE-119 (improper restriction of operations within the bounds of a memory buffer), CVSS 3.1 base score 7.8 ( AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H ), and labelled the issue Exploitation Less Likely on the day of publication. The artifacts preserved here demonstrate, narrowly: - Reliable crashes in two Microsoft products on attacker-supplied input. - An out-of-bounds read driven by a length the parser trusts from JSON, visible under PageHeap. - A separate stack-buffer-overrun fast-fail variant on a different sample, indicating the same underlying bug can corrupt the stack under different attribute paths. What the artifacts do not demonstrate: a working exploit, an ASLR bypass, or a controlled-write primitive against a real target. This page does not claim those. Reachability and Impact Two products, one shared parser, one delivery vector. Microsoft classified CVE-2020-1321 as Remote Code Execution. This page does not publish a weaponised chain; it focuses on the fuzzing method, the crash evidence, and the disclosure timeline. One shared parser, two reachable products. A .docx carrying an embedded .glb reaches Word; a standalone .glb reaches 3D Viewer. Both call sites land in the same shared code. Surface Verdict Source Microsoft Word (Office 365) opening a crafted .docx with an embedded malformed .glb Confirmed (demonstrated) Local artifacts: PageHeap WinDbg logs from winword.exe against the canonical samples. Microsoft 3D Viewer (Windows 10) opening a crafted .glb standalone Confirmed (demonstrated) Local artifacts: matching crash in Mira.Core.Engine.UWP.dll at the same call-site offsets. Microsoft Office 2019 (32-bit and 64-bit), Office 2016 for Mac, Office 2019 for Mac, Microsoft 365 Apps Click-to-Run Publicly affected per MSRC MSRC advisory CVE-2020-1321, June 9, 2020. Preview Pane as an attack vector Not an attack vector MSRC advisory FAQ explicitly states the Preview Pane is not an attack vector. Other Office hosts that consume (PowerPoint, Excel) Reachability not confirmed The 3D-model feature shipped across Office; the shared parsing module is the same. Not directly tested in the artifacts here. Office for the Web / Office Online Reachability not confirmed Server-side handling of 3D models is a different code path; not tested. Office Mobile Reachability not confirmed Different binary, different platform; not tested. LibreOffice and other third-party openers of .docx Not affected They do not link MSOSPECTRE.DLL or the Mira engine. Browsers and the open-source glTF ecosystem (three.js, Babylon.js, Blender, glTF Validator) Not affected Different parsers entirely. The bug lives in the Microsoft-specific shared module. The user-interaction requirement is the standard Office one: a user opens an untrusted document containing the malformed model. Protected View applies in the usual way. Microsoft's Exploitation Less Likely rating reflects the difficulty of turning the primitive into a working exploit on a hardened modern Office build. Takeaways Lessons. - Grammar over mutation for indexed-graph formats. Most of the Office-format bugs that get found by random byte mutation live near the byte boundaries: header lengths, sentinel values, simple parsers. The interesting bugs in formats like glTF, OOXML 3D embeddings, and PDF live in the cross-references between objects. A grammar that respects the format's structural invariants and deliberately violates the agreements between sibling fields lands deeper. CVE-2020-1321 is a clean instance: the bug needed a real scene with a real mesh and real animations referencing real accessors, and then a precise inconsistency between two fields the parser trusts to agree. - Document parsers stay valuable. Every couple of years Microsoft adds a new embedded format to Office: 3D models, SVG icons, equation editors, online-source media. Every new format brings a fresh parser written under shipping pressure. The 3D pipeline shipped in Office at the same time it shipped as a standalone Windows 10 app, which doubled the reachable attack surface for the same bug. - Closed-source Windows targets are still tractable. You do not need a sanitiser-instrumented build to find good Office bugs. PageHeap on the target process plus a WinDbg launch line that calls .logopen , dumps the stack and registers, and exits, produces one timestamped log per crash. Pair that with a docx-spray harness that opens 100 fuzzed models per process lifetime, and the throughput is reasonable on a single Windows VM. - The workflow mattered more than the individual crash. The most useful thing in this campaign was the loop: generate, package, launch, log, iterate. Most fuzzing posts focus on the crashing input and skip the plumbing. The plumbing is what made the campaign productive. A fuzzer that finds one crash is a story; a fuzzer that finds whichever crashes the parser has, repeatedly, is a tool. Attribution The MSRC public advisory credits Menahem Breuer and Ariel Koren of Mimecast Research Labs. The discovery and fuzzing workflow described here - the grammars, the JSON template, the GLB container, the docx-spray harness, the WinDbg automation - were mine, captured from my own working files preserved from that period. Mimecast was the coordinated disclosure channel through which the report reached MSRC, and the public credit is shared between the two named researchers. What this page does not include This page is a technical reconstruction, not a release of operational material. It does not publish raw crashing .glb files, the fuzzing corpus DOCX, exact byte-level repro material, private email content, or anything that would function as exploitation guidance. The reasoning is straightforward: the bug is patched, but the value of publishing operational material from a six-year-old campaign is small and the downside is non-zero. The fuzzing method is what matters. The method is what this page publishes. ## CVE-2026-48029: Two Grid-Decode Vulnerabilities in libheif URL: https://arielkoren.com/vulnerabilities/cve-2026-48029/ Identifier: CVE-2026-48029 | Status: Public / Patched | Severity: High A single afternoon of fuzzing against libheif 1.21.2 produced two memory-safety bugs in the same function. The first is a NULL pointer dereference on a malformed grid dimg reference - deterministic denial of service on any consumer that calls heif_decode_image or heif_image_handle_decode_image_tile. The second is a uint32 underflow in the inverse-rotation tile arithmetic that feeds a debug-only assert in the grid index lookup; in NDEBUG release builds (the configuration typical distribution packages use) the assert is compiled out and the access becomes a heap out-of-bounds read with an attacker-influenced offset. Disclosed privately to the maintainer on 2026-05-02 and fixed in libheif 1.22.0, released 2026-05-19. Tracked as GHSA-6x5f-qchq-cxqv and assigned CVE-2026-48029. Two memory-safety bugs in the same function, two lines apart. F1 is a missing null check after HeifContext::get_image() ; F2 is a uint32 underflow upstream feeding an unchecked vector access gated only by a debug-only assert . libheif is the de-facto open-source HEIF/HEIC/AVIF container library. If you have ever opened a .heic from an iPhone on Linux, viewed an AVIF in a desktop image viewer, processed an iPhone export with ImageMagick or libvips on a server, or hit an Android app that reads HEIC, you have very likely run libheif code. The container is intricate - ISO/IEC 23008-12 layered on top of the ISO Base Media File Format - the attack surface is exactly what you would expect for a media parser, and the consumer base is large. This session was a single afternoon against the 1.21.2 release tag (commit 78638f4f ), with two working assumptions. The first: the container layer runs before any codec back-end, so any container-side bug is reachable on hosts that do not have libde265 or libaom installed at all. The second: the derived-image layer - grid , overlay , iden , tiled , mask - sits on top of the container and mixes parsed header data with cross-references to other items in the file. Cross-references between two independent parsed data sources are historically a rich source of arithmetic and lifetime mistakes. The harness that produced both findings - decode_grid_overlay - is a 200-line libFuzzer driver. It walks every top-level image, calls get_image_tiling with both process_xforms=0 and =1 , probes seven (col, row) coordinate pairs per tiling layout (corners, centres, quarter-points), runs a per-tile decode, then a full-grid composition decode, then any auxiliary images. The same harness was linked against two libheif builds: a debug ASAN+UBSAN build with asserts on, and a release -O2 -DNDEBUG ASAN+UBSAN build with asserts compiled out. That second build is the choice that mattered most in this session. Two grid-decode signatures fired in the first thirty seconds of the first sanity run. Both landed in ImageItem_Grid::decode_grid_tile , two lines apart, but with completely different failure modes and completely different root causes. Triage, root-cause derivation, sibling-class audit, suggested fixes, and the private report to the maintainer all went out the same day. The bugs are not exotic. The workflow is the interesting part. Across May 2, a cold clone of the repository turned into two libheif builds, the harness suite, two minimised reproducers, the sibling-class audit table, and a coordinated private report on the maintainer's desk. The local campaign continued across the May 2/May 3 window with overnight runs that confirmed a second F2 sink, and culminated in a chaos object-graph campaign a few days later that produced 30.4 million executions and zero new crashes. The closing section of this page returns to the workflow that made that pace possible. Both bugs live in libheif/image-items/grid.cc in the function ImageItem_Grid::decode_grid_tile . The relevant excerpt from libheif 1.21.2 is short: grid.cc · 1.21.2 uint32_t idx = ty * m_grid_spec.get_columns() + tx; assert(idx < m_grid_tile_ids.size()); // line 586 -- F2 sink heif_item_id tile_id = m_grid_tile_ids[idx]; // line 588 -- F2 OOB read std::shared_ptr tile_item = get_context()->get_image(tile_id, true); // line 589 if (auto error = tile_item->get_item_error()) { // line 590 -- F1 NULL deref return error; } Four consecutive lines, two distinct memory-safety bugs. F1 - NULL pointer deref on a missing grid tile reference A HEIF grid is a derived image: a virtual item that says "to draw me, take these N tile items, lay them out in an RxC grid." The tile items are pointed to via an iref dimg (derived-image reference). At parse time the only consistency check is that dimg.size() == rows * cols . A file that declares a perfectly-sized dimg list whose entries point at item ids that do not exist - or at ids that exist but are not decodable images (an Exif metadata item, for example) - passes that check fine. At decode time, HeifContext::get_image(id, only_images) returns an empty std::shared_ptr for any id that does not resolve to an image item in the registry. The very next line in decode_grid_tile calls tile_item->get_item_error() on it - a member call through a null shared_ptr . SIGSEGV. The dereferenced address is 0x0 plus a small constant vtable offset, so this is a deterministic crash, not a controlled write. The sibling derived-image classes all do the missing null-check: File and line Calls get_image(_, true) Null-checks the result? image-items/grid.cc:589 yes no - this is the bug image-items/overlay.cc:338 yes yes (line 339) image-items/iden.cc:86 yes yes (line 87) image-items/iden.cc:108 yes yes (line 109) The fix is structurally identical to the pattern at overlay.cc:339 : five lines, one if (!tile_item) return Error{...}; . That is what upstream commit e1b97646 shipped. F2 - uint32 underflow into an unchecked vector index F2 is the more interesting bug. The end of the chain is the line two above F1, on grid.cc:586-588 : a uint32_t idx computed from the tile coordinates, an assert(idx < m_grid_tile_ids.size()) , and then an unchecked std::vector::operator[] access. In a debug build the assert fires and the process aborts. In a release build compiled with -DNDEBUG - the configuration typical distribution packages use - the assert is compiled to nothing and the next line walks a vector with an attacker-influenced index. ASAN reports: AddressSanitizer release -O2 -DNDEBUG ASAN+UBSAN build of libheif 1.21.2 ASAN ==NN==ERROR: AddressSanitizer: SEGV on unknown address 0x610400000e00 ==NN==The signal is caused by a READ memory access. #0 ImageItem_Grid::decode_grid_tile grid.cc:588:26 #1 ImageItem_Grid::decode_compressed_image grid.cc:224 #2 ImageItem::decode_image image_item.cc:747 #3 HeifContext::decode_image context.cc:1404 #4 heif_image_handle_decode_image_tile heif_tiling.cc:108 SUMMARY: AddressSanitizer: SEGV grid.cc:588:26 in ImageItem_Grid::decode_grid_tile(...) ASAN reports SEGV rather than heap-buffer-overflow only because idx is so large that m_grid_tile_ids.data() + idx*4 overshoots into unmapped memory. The bug class is still the same: an unchecked vector::operator[] access with an attacker-influenced index. The bytes read are a heif_item_id (4 bytes), used downstream as a lookup key in HeifContext::get_image() . The huge index comes from a uint32 underflow upstream. ImageItem::transform_requested_tile_position_to_original_tile_position performs subtractions like num_columns - 1 - tile_y and num_rows - 1 - tile_x on caller-supplied coordinates, against the pre-rotation grid extents. The caller's (tile_x, tile_y) are documented as being in the post-rotation (displayed) grid. When the rotation is 90deg or 270deg and rows != columns, the displayed grid has its dimensions swapped relative to the file grid, and a legal post-rotation coordinate can exceed the smaller pre-rotation extent. The subtraction underflows to ~ UINT32_MAX , the underflowed value flows into decode_grid_tile , the idx = ty * cols + tx arithmetic produces an enormous index, and the vector access walks off the heap. SCHEMATIC_PLACEHOLDER This is a CWE-191 (integer underflow) feeding a CWE-125 (out-of-bounds read). The primitive is not trivially-leaking into an external observer - the 4 bytes read are consumed as a hash-map lookup key - but it is structurally a memory-safety bug, not a robustness assertion. The advisory states exactly that scope and nothing more. Why two builds matter The same source code, the same input file, two different categorisations depending on whether -DNDEBUG is set: Build configuration F2 result Debug ASAN+UBSAN, asserts on SIGABRT via assert(idx < m_grid_tile_ids.size()) at grid.cc:586 - looks like a robustness assert Release -O2 -DNDEBUG ASAN+UBSAN AddressSanitizer: SEGV grid.cc:588:26 READ - honest heap OOB read Release-style builds with assertions disabled are what most downstream packages ship, so that configuration is what defines the security envelope. A fuzzing setup that only runs against debug-with-asserts will look at the SIGABRT from F2 and file a hardening PR. The release-build re-run is what turns it into a memory-safety report. Two fixes, not one The patch above shows the F1 null check and the F2 sink-side runtime bounds check. A third commit ( e523ec0b , authored by Dirk Farin) is the structurally correct F2 source-side fix - process transformations on the tiling upfront so the arithmetic operates on post-rotation dimensions, then reject out-of-range caller coordinates before any subtraction. The in-code comment matches the advisory almost word-for-word: the displayed grid has its columns and rows swapped relative to the file; using the file dims both let out-of-range coordinates through and produced unsigned underflows inside the inverse-rotation formulas . Attribution and parallel discovery An attribution note worth making explicit: two of the three upstream commits in this area - e1b97646 and 518bd95f - were authored by Anthony Hurtado with a "Found by: AFL++ fuzzing with custom harness" trailer. My private report and reproducers were sent to the maintainer before I knew those commits existed; when the maintainer eventually checked my PoC against v1.22.0, he confirmed it was already fixed - probably from his own fuzzer runs. The cleanest framing is parallel discovery and overlapping validation, not a claim that my report alone caused every upstream patch. The third commit, e523ec0b - the structurally correct F2 source-side fix - was authored by the maintainer (Dirk Farin) the following day, and its in-code comment matches the underflow scenario described in my advisory almost word-for-word. The advisory itself (GHSA-6x5f-qchq-cxqv) was accepted, published, and credited to me as reporter after I mirrored the report to GitHub on 2026-05-20. Reachability and Impact What an attacker can actually do. The realistic blast radius is wider than for the average codec bug, because both bugs live in the container layer - which runs before any codec back-end - and are reachable through libheif's most-used public APIs. Any consumer that opens a malicious HEIF or HEIC file and asks libheif to decode either the primary image or a single tile reaches the vulnerable function. Surface Verdict Why heif_decode_image on a malicious grid HEIF Confirmed reachable Full-grid composition iterates per-tile and lands in decode_grid_tile . Either F1 or F2 fires depending on the file shape. ASAN reproducers in both release and debug builds. heif_image_handle_decode_image_tile Confirmed reachable Direct single-tile decode path. F2 fires here when the file carries an irot property and rows != columns; F1 fires here when the dimg list references missing or non-image items. heif_image_handle_get_grid_image_tile_id Reachable for F2 Invokes the same transform; the second F2 sink confirmed during the post-disclosure overnight fuzz run. Image viewers and thumbnailers on Linux/BSD (gThumb, geeqie, Nautilus thumbnailers, KIO) Plausible direct exposure These tools call heif_decode_image or its convenience wrappers on user-supplied files. A malicious file in a downloads folder is enough to crash the previewer or thumbnailer. Server-side image pipelines that link libheif (ImageMagick, libvips when built with HEIF support, custom transcoders) Plausible direct exposure Any pipeline that accepts user-uploaded HEIC and runs heif_decode_image can be crashed on demand. F2's OOB read does not leak into an external observer by itself, but reliable DoS-on-decode is enough to matter for upload pipelines. Browsers (Chrome, Firefox, Safari, Edge) opening AVIF/HEIC images directly Reachability not confirmed Mainstream browsers ship their own AVIF stacks (dav1d, libavif) rather than libheif. WebAssembly-shipped libheif builds in some image-editor apps would be reachable, but the WASM sandbox contains the OOB read to the module's linear memory - it does not directly compromise the browser process. Read-only consumers that never call a decode API Not affected The bugs are in the decode path. Container-only inspection (metadata enumeration, item listing) does not reach decode_grid_tile . Exploitability - F1 - reliable DoS. Any consumer that calls heif_decode_image or heif_image_handle_decode_image_tile on a malicious file with a malformed dimg reference SIGSEGVs. The dereferenced address is 0x0 plus a small constant vtable offset - deterministic across runs and platforms, not attacker-controllable. - F2 - DoS plus a heap OOB read primitive. The 4 bytes read at the attacker-influenced offset are consumed as a heif_item_id lookup key in HeifContext::get_image() . By itself this is not a trivially-leaking primitive into an external observer; in a larger gadget chain (combined with an info-leak side channel via caching behaviour or timing) it could matter. Exploitability beyond the sanitizer report was not pursued. - Memory write. Neither bug provides a write primitive. - Sandbox guidance. Out-of-process decode workers are the right mitigation for downstream consumers that cannot immediately update to 1.22.0. Both bugs result in SIGSEGV in release builds, which a try/catch in the consumer process will not catch. Takeaways Lessons. - Asserts are not bounds checks. Anywhere a parsed-from-the-file integer is used as an array index or a pointer offset, the check must be a runtime if that returns an error in release builds . assert() is documentation, not protection. F2 looked like a robustness signal in the debug build and a memory-safety report in the release build - same source, same input, same line, different categorisation. Build and fuzz the release-mode ASAN configuration explicitly. - Cross-references between two parsed sources are bug factories. The grid header says "RxC tiles." A separate parsed list says "and here are the N tile item ids." A separate part of the file says "and the image is rotated 270 degrees." The decode path has to reconcile all three with the caller's coordinates - which themselves arrive through a public API. F2 is exactly the pattern of one parsed value flowing into arithmetic against a different parsed value. Whenever a header declares a count or a shape and a separate parsed list provides the elements, validate at parse time that the two agree and re-check at use time. - Trust boundaries include caller-supplied API arguments. heif_image_handle_decode_image_tile(handle, tile_x, tile_y) is a public API. The coordinates come from the consumer, not the file - but consumers typically derive them from parsed-from-the-file dimensions returned by get_image_tiling() . Either way, libheif has to validate them itself against the displayed grid before they enter inverse-rotation arithmetic. The source-side fix (commit e523ec0b ) closes exactly this gap. - Sibling-class audit before reporting. The most effective sentence in a security report is "you already do this everywhere else, except here." For F1, the four sibling derived-image classes ( overlay.cc , two sites in iden.cc ) already do the missing null-check. That makes the fix a five-line patch with a structural precedent rather than a debate about whether the input was valid in the first place. The audit takes ten minutes; it saves the maintainer hours and the reporter a round-trip. - Structurally-aware seeds beat random mutation on framed formats. ISO BMFF box framing eats random byte mutations alive - most mutants produce invalid lengths and stop parsing before reaching anything interesting. A 25-seed generator that produces deliberately-invalid-but-parseable HEIFs (grids pointing at missing items, grids pointing at non-image items, size mismatches, irot + imir combinations, extreme dimensions) hit F1's exact 303-byte shape within thirty seconds. The bug class wants a structurally valid container with an inconsistent cross-reference - that is exactly what targeted seeds produce. The bugs are not exotic. The workflow is the interesting part. Both findings are textbook arithmetic mistakes - a missing null check and an unsigned underflow into an unchecked vector index. A competent code reviewer would catch either on a slow afternoon. What had changed since the last time I ran a campaign like this was the elapsed time: from a cold clone of the libheif repository to a coordinated private report on the maintainer's desk, including two libheif builds, the harness suite, two minimised reproducers, the sibling-class table, and a suggested fix for each bug. That gap used to be weeks. Methodology A two-model research loop. The work that produced this disclosure was not one agent grinding through libheif alone. It was a deliberate two-model loop with a human in the conductor seat: one model for execution, one for critique, the human choosing what to commit to. The execution model (Claude) did the work that takes wall-clock time. Reading the libheif source top-down. Drafting the attack-surface note. Writing the five harnesses. Building the debug and release-NDEBUG libraries. Running the sanity fuzzes. Triaging the two grid signatures. Composing the sibling-class audit table. Minimising F1 to 303 bytes. Authoring the suggested-fix diffs. Drafting the private security report. End to end. The execution model does the work that takes wall-clock time. The critic model is kept off the execution path on purpose, so its pushback is independent of the work it is critiquing. The human picks the target and commits to actions. The critic model (ChatGPT) was kept off the execution path on purpose - its job was to challenge conclusions, not author them. The most useful intervention in this campaign was the moment the execution side declared libheif "saturated" after a clean overnight run. The critic pushed back: structured harness fuzzing is complete, but you have not tested chaotic cross-object composition - semi-valid containers with conflicting item references, derived-image-of-derived-image, transforms stacked on the wrong items, iloc extents pointing into parseable-but-weird regions . It produced a full Phase 7 plan with an explicit stopping criterion. The execution side built it: 612 chaos seeds plus 50 size-stress seeds plus 200 box-level mutations, run as smoke, short, and overnight tracks. The result was zero new crashes across 30.4 million executions - but with measurable coverage gain over the prior best, decode_grid_overlay edges climbing from 25,807 to 28,884 (+11.9%) and decode_primary from 25,399 to 27,289 (+7.4%). A strong negative result. Without the critic, that phase would have been rationalised away as diminishing returns and the saturation claim would have rested on absence of evidence rather than evidence of absence. The same loop ran during disclosure. The first email to the maintainer was drafted by the execution side; the critic edited it for tone (less apologetic, more practical) and recommended the shorter of two phrasings for the CVE-request follow-up. Small edits, real effect on the response. The human role in the loop is narrower than it sounds and harder than it looks: pick the target, accept or reject the critic's pushback, commit to the actions the execution side proposes, decide when to disclose and how. The models are fast; the judgment about which question to ask is the part that does not scale yet. This research was done over a weekend, on my own free time, outside work hours, and not as part of my job. Closing thought AI is an amplifier of intent. What collapsed is not the difficulty of finding bugs. It is the cost of acting on intent . Each step of vulnerability research - reading code top-down, hypothesising where a bug class lives, building a harness, triaging a crash, doing a sibling-class audit, drafting a clean report with a suggested fix - used to be a manual sequence punctuated by hours of human attention. Now most of those steps are reasoning-over-actions that an agent can execute in seconds against a fully-grounded view of the codebase. The intent here was mine; the wall-clock cost was a fraction of what the same intent used to cost. The uncomfortable corollary is that today's models are the worst versions of these models we will ever use again. Whatever the bottleneck looks like at this point in 2026, it will be smaller next quarter and smaller still the quarter after. For maintainers, that means the bar for "we would have caught this in code review" is rising faster than the codebase. For defenders, it means the inventory of latent bugs in mature libraries is going to be drained faster than the disclosure infrastructure was designed to handle. For researchers, the leverage now sits less in the act of finding a bug and more in the choice of where to point. The disclosure cadence libheif demonstrated here - 17 days from private report to fixed release, with the maintainer himself authoring the structurally correct F2 source-side fix - is the kind of pace the rest of the ecosystem should be optimising toward. The bugs are arithmetic mistakes. The story is the timeline. Realistic Exposure Who is actually at risk? libheif is the canonical open-source HEIF/HEIC/AVIF container library. Direct downstream consumers include ImageMagick, libvips, GIMP, KDE's KImageFormats, GNOME's image viewers and thumbnailers, and a long tail of Linux desktop image apps. The ecosystem reach is large; the realistic exposure to these specific bugs is narrower, because four gates must all be true: (1) the consumer links a libheif at or before 1.21.2 with the grid-decode path enabled; (2) it actually calls a decode API on user-supplied input; (3) for F2, the input can carry an irot property and a non-square grid; (4) the host has not already received a libheif update through its distribution. Most distributions track libheif closely, so the practical exposure window is the time between v1.22.0 reaching their mirrors and downstream consumers picking up the rebuilt package. Platform / category Assessment Why Linux desktop image viewers and thumbnailers (gThumb, geeqie, Nautilus, KIO, GNOME image viewer, KDE Gwenview) Plausible direct exposure These tools link libheif and call heif_decode_image on user-supplied files. A malicious HEIC in a downloads folder is enough to crash the previewer or thumbnailer. DoS-on-decode is the realistic outcome. Server-side image pipelines (ImageMagick + HEIF delegate, libvips with HEIF support, custom transcoders) Plausible direct exposure Any pipeline that accepts user-uploaded HEIC and runs libheif's decode APIs can be crashed on demand. F2's OOB read does not leak into an external observer by itself, but reliable DoS-on-decode of an upload worker still matters for upload-driven services. Mobile platforms (iOS Photos, Android Gallery, vendor camera apps) Not affected iOS uses its own ImageIO/HEIF stack. Android relies on platform decoders that do not link libheif. The HEIC ecosystem on mobile bypasses libheif entirely. Major browsers (Chrome, Firefox, Safari, Edge) Reachability not confirmed Mainstream browsers ship libavif/dav1d for AVIF and platform decoders for HEIC, not libheif. Image-editor PWAs that ship libheif in WebAssembly would be reachable, but a WASM sandbox contains the OOB read to the module's linear memory - it does not directly compromise the browser process. Linux distributions (Debian, Ubuntu, Fedora, Arch, openSUSE) Update path is the mitigation Distributions track libheif closely. The right action is to wait for the distro update that pulls 1.22.0 and rebuild any consumers that statically link libheif. Backporting the three fix commits ( e1b97646 , 518bd95f , e523ec0b ) is small and local. See the packaging-status snapshot below for which distributions have caught up. Custom builds of libheif compiled with -DCMAKE_BUILD_TYPE=Debug Different failure mode Debug builds with asserts on will SIGABRT instead of producing a heap OOB read on F2. Still a denial of service; not a memory-safety report. The decision to fuzz the release-NDEBUG configuration is what surfaced F2 as memory-safety. Realistic exposure This does not mean every browser or every phone is affected. Many mainstream browsers and mobile platforms use different AVIF/HEIF decode paths or platform media frameworks rather than libheif. The realistic exposure is Linux desktop software (image viewers, thumbnailers, file managers), server-side image pipelines and media-processing tools that link libheif, and distributions that package libheif directly. F2's heap OOB read primitive in release-style builds is structurally significant under standard exploit assumptions but does not by itself prove exploitation against any deployed application. Packaging status as of 2026-05-21 The patch shipped two days ago. The table below is a static snapshot of Repology captured on the day of publication, frozen so it remains a faithful record of where the ecosystem stood when this advisory went out. Sage cells are patched (libheif 1.22.0); red cells are still vulnerable. Of 113 tracked distributions and repositories, 16 had picked up 1.22.0; 97 were still on a vulnerable version. The full list is shown below for record-keeping; the headline numbers above are the takeaway, and a future revision of this page may collapse the full list behind a "show all distributions" toggle. Distribution Version Distribution Version Distribution Version Alpine Linux 3.21 1.19.5 Fedora 43 1.20.2 Parrot 1.19.8 Alpine Linux 3.22 1.19.8 Fedora 44 1.21.2 PCLinuxOS 1.15.1 Alpine Linux 3.23 1.21.2 Fedora Rawhide 1.21.2 Pisi Linux 1.21.2 Alpine Linux Edge 1.21.2 FreeBSD Ports 1.21.2 pkgsrc-2025Q4 1.20.2 ALT Linux p9 1.6.2 Gentoo 1.21.2 pkgsrc-2026Q1 1.21.2 ALT Linux p10 1.19.5 GNU Guix 1.19.7 pkgsrc current 1.21.2 ALT Linux p11 1.21.2 HaikuPorts master 1.21.2 PLD Linux 1.21.2 ALT Sisyphus 1.22.0 Homebrew 1.22.0 PureOS amber 1.3.2 AOSC 1.21.2 Kali Linux Rolling 1.21.2 PureOS byzantium 1.11.0 Apertis v2025 1.15.1 KaOS 1.21.2 PureOS landing 1.19.8 Apertis v2026 1.19.8 KaOS Build 1.22.0 Raspbian Oldstable 1.15.1 Apertis v2027 Development 1.19.8 LiGurOS stable 1.20.1 Raspbian Stable 1.19.8 Arch Linux 1.22.0 LiGurOS develop 1.21.2 Raspbian Testing 1.21.2 ArchPOWER powerpc 1.21.2 MacPorts 1.21.2 Ravenports 1.21.2 ArchPOWER powerpc64le 1.21.2 Mageia cauldron 1.20.2 Rosa 2021.1 1.12.0 ArchPOWER riscv64 1.19.7 Manjaro Stable 1.21.2 Rosa 13 1.19.8 AUR 1.22.0 Manjaro Testing 1.21.2 RPM Fusion EL 8 1.7.0 Artix 1.21.2 Manjaro Unstable 1.22.0 Side Linux 1.21.2 Chimera Linux 1.20.2 MSYS2 clang64 1.22.0 SlackBuilds 1.20.2 Chromebrew 1.20.2 MSYS2 clangarm64 1.22.0 SliTaz Next 1.3.2 ConanCenter 1.20.1 MSYS2 mingw 1.22.0 Solus 1.21.2 CRUX 3.8 1.21.2 MSYS2 ucrt64 1.22.0 Spack 1.12.0 Cygwin 1.12.0 MX Linux MX-21 Testing 1.14.0 stal/IX 1.21.2 Debian 11 1.11.0 MX Linux MX-23 Testing 1.17.6 stal/IX dev 1.21.2 Debian 12 1.15.1 nixpkgs stable 25.11 1.20.2 T2 SDE 1.22.0 Debian 12 Backports 1.19.7 nixpkgs unstable 1.21.2 Termux 1.22.0 Debian 13 1.19.8 OpenBSD Ports 1.22.0 Trisquel 11.0 1.12.0 Debian 14 1.21.2 OpenIndiana packages 1.20.2 Ubuntu 18.04 1.1.0 Debian Unstable 1.21.2 openmamba 1.22.0 Ubuntu 20.04 1.6.1 deepin 20 1.3.2 OpenMandriva 6.0 1.19.7 Ubuntu 22.04 1.12.0 deepin 23 1.18.1 OpenMandriva Rolling 1.21.2 Ubuntu 24.04 1.17.6 Devuan 4.0 1.11.0 OpenMandriva Cooker 1.21.2 Ubuntu 25.10 1.20.2 Devuan Unstable 1.21.2 openSUSE Tumbleweed 1.21.2 Ubuntu 26.04 1.21.2 Endless OS master 1.15.1 openSUSE multimedia:libs Tumbleweed 1.21.2 Ubuntu 26.10 1.21.2 EPEL 9 1.16.1 PackMan openSUSE Tumbleweed 1.21.2 Ubuntu 26.10 Proposed 1.21.2 EPEL 10 1.17.6 PackMan SLE 15 1.21.2 Vcpkg 1.21.2 Exherbo 1.22.0 Parabola 1.22.0 Void Linux x86_64 1.21.2 Fedora 42 1.19.8 Pardus 21 1.11.0 The vast majority of tracked distributions and repositories in this snapshot still packaged a libheif version that contained both bugs. The patch is local to two functions and small enough to backport, but for most users the realistic path is to wait for the distribution update. What to do - Update to libheif 1.22.0 . The three fix commits are local to two functions and small enough to backport. - If you ship a static build of libheif, rebuild against 1.22.0 and re-link consumers. - For service operators: a libheif decode worker that crashes mid-request should restart cleanly, not poison a longer-lived shared process. Out-of-process decode is the right boundary regardless of this specific bug.