<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amartya Jha</title>
    <description>The latest articles on DEV Community by Amartya Jha (@codeant-security).</description>
    <link>https://dev.to/codeant-security</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4084475%2F03b63cf6-6b60-4533-92e0-240657addd8b.png</url>
      <title>DEV Community: Amartya Jha</title>
      <link>https://dev.to/codeant-security</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codeant-security"/>
    <language>en</language>
    <item>
      <title>Dissecting FOIS: The Log4j2 Filter That Only Does Half Its Job</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:56:17 +0000</pubDate>
      <link>https://dev.to/codeant-security/dissecting-fois-the-log4j2-filter-that-only-does-half-its-job-5c0a</link>
      <guid>https://dev.to/codeant-security/dissecting-fois-the-log4j2-filter-that-only-does-half-its-job-5c0a</guid>
      <description>&lt;h2&gt;
  
  
  __Log4j2's FilteredObjectInputStream checks which classes can be rebuilt, but not how large or deep the data can be. Here's how that gap enables an RCE and two gadget-free crashes on a serialized log receiver.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Log4j2's &lt;code&gt;FilteredObjectInputStream&lt;/code&gt; (FOIS) checks the class names in a serialized stream and nothing else. It never limits object size or depth.&lt;/li&gt;
&lt;li&gt;That one gap gives three problems on a single network log receiver: an unauthenticated RCE, a crash that pins a CPU core, and a 44-byte out-of-memory crash. Two of them need no exploit gadget.&lt;/li&gt;
&lt;li&gt;The RCE (Log4j2 #4255) is prior work. CodeAnt's contribution is dissecting the two crashes, the contrast with logback, and the lab numbers.&lt;/li&gt;
&lt;li&gt;Exposure is narrow. You need a serialized log receiver on the network, which is not a Log4j default. High severity, low prevalence.&lt;/li&gt;
&lt;li&gt;Fix: send logs as text, or add the size and depth caps logback already ships.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a Glance
&lt;/h2&gt;

&lt;p&gt;A security filter is meant to do one job completely. FOIS does about half of one. It reads the name of every class arriving over the network and rejects anything not on an approved list.&lt;/p&gt;

&lt;p&gt;That is a real check, but it is the only check. FOIS never asks how large an object is or how deeply nested, and it never turns on the size and depth limits Java already ships. Those two missing checks turn one log receiver into three problems.&lt;/p&gt;

&lt;p&gt;The RCE below (Log4j2 #4255) is not our discovery; it is prior work, credited in full at the end.&lt;/p&gt;

&lt;p&gt;What our team adds is the breakdown of the filter itself: the two crashes hiding in the limits FOIS forgot to set, the comparison with the library that got it right, and numbers from a real lab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codeant.ai/security-research/dissecting-log4j2-filtered-object-input-stream" rel="noopener noreferrer"&gt;Read the full CodeAnt security research&lt;/a&gt; →&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lab:&lt;/strong&gt; Log4j 2.26.1 on JDK 17. Run it yourself with one Docker command.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What FOIS Was Built To Do
&lt;/h2&gt;

&lt;p&gt;Log4j can ship log events between machines as Java objects, and the receiver rebuilds them from raw bytes. Rebuilding attacker-controlled bytes is the flaw behind a decade of Java RCE, so Apache wrapped that step in FOIS in Log4j 2.8.2 (the fix for CVE-2017-5645). FOIS keeps the class names it trusts and throws out the rest.&lt;/p&gt;

&lt;p&gt;Two details of that trusted list matter. It allows a class called &lt;code&gt;java.rmi.MarshalledObject&lt;/code&gt;, and it trusts whole Java packages by prefix, so everything under &lt;code&gt;java.util&lt;/code&gt; and &lt;code&gt;java.lang&lt;/code&gt; is waved through. Each one becomes a way in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Mistake
&lt;/h2&gt;

&lt;p&gt;FOIS checks which class is being rebuilt. It never checks how big or how deep. Java can cap array size, nesting depth, and reference count; FOIS turns none of that on. Everything below follows from that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Impacts
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypegx20yyfe5i4awwcx2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypegx20yyfe5i4awwcx2.gif" alt=" " width="660" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RCE&lt;/strong&gt; — a &lt;code&gt;java.rmi.MarshalledObject&lt;/code&gt;-wrapped gadget hits the FilteredObjectInputStream receiver; the receiver logs &lt;code&gt;msg=null&lt;/code&gt; while &lt;code&gt;whoami&lt;/code&gt; returns &lt;code&gt;root&lt;/code&gt;, unauthenticated code execution as &lt;code&gt;root&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8ubs2wvb0cqpbdmrsl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8ubs2wvb0cqpbdmrsl.gif" alt=" " width="660" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DoS&lt;/strong&gt; — the same receiver, no gadget: a 5.7 KB nested &lt;code&gt;HashSet&lt;/code&gt; pins &lt;code&gt;readObject&lt;/code&gt; at 100% CPU permanently, and a 44-byte &lt;code&gt;Object&lt;/code&gt; array triggers &lt;code&gt;OutOfMemoryError: Requested array size exceeds VM limit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: remote code execution, smuggled inside an allowed class.&lt;/strong&gt; &lt;code&gt;MarshalledObject&lt;/code&gt; is trusted, but it is a container: it carries a second batch of bytes inside itself, and when Log4j opens it, those inner bytes are rebuilt on a fresh stream that FOIS is not watching.&lt;/p&gt;

&lt;p&gt;The outer check passes; the real payload rides in behind it. Log4j opens the container on its own during reconstruction, so if the server has a usable gadget library on its classpath, this is unauthenticated RCE.&lt;/p&gt;

&lt;p&gt;In the lab against real Log4j 2.26.1 on JDK 17, a wrapped payload ran &lt;code&gt;whoami&lt;/code&gt; and returned &lt;code&gt;root&lt;/code&gt;, the server stayed up and logged a blank message, and the same payload sent without the container was correctly rejected. (On a modern JDK fewer gadgets survive, but the ones that run commands still work where the right library is present, so the requirement is just a usable gadget on the classpath.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: a CPU bomb the size of a text message.&lt;/strong&gt; &lt;code&gt;HashSet&lt;/code&gt; is allowed, and rebuilding a set recomputes the hash of everything inside it.&lt;/p&gt;

&lt;p&gt;A 2015 trick called SerialDOS (Wouter Coekaerts) builds a small nested structure where each layer is shared and referenced twice, so a shape a few dozen levels deep is reachable by an exponential number of paths, and the runtime walks every one. With no depth limit, the receiver runs it to completion:&lt;/p&gt;

&lt;p&gt;A few kilobytes freezes the machine, one worker per packet, and it is silent: while the receiver spins there is no log line. The only sign is a thread stuck at 100% CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: a 44-byte packet that crashes the process.&lt;/strong&gt; A serialized array states its length before any data, and the runtime reserves that space immediately. With no size limit, that number can be anything.&lt;/p&gt;

&lt;p&gt;A 44-byte packet can claim an array of billions of entries, and the process dies with an out-of-memory error before it even tries to allocate. No gadget, no library, just a lie about size.&lt;/p&gt;

&lt;p&gt;The full research walks through the FOIS implementation, reproduces all three impact paths, and includes the lab setup and measurements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codeant.ai/security-research/dissecting-log4j2-filtered-object-input-stream" rel="noopener noreferrer"&gt;Read the complete analysis&lt;/a&gt; →&lt;/p&gt;

&lt;h2&gt;
  
  
  How logback Solved the Same Problem
&lt;/h2&gt;

&lt;p&gt;logback, Spring Boot's default logger, had the identical socket receiver, and its guard shows what the finished job looks like. &lt;code&gt;HardenedObjectInputStream&lt;/code&gt; does everything FOIS does, then the part FOIS skipped: an exact-name allowlist instead of trusting whole packages, a maximum array size and nesting depth (which kills both crashes), and a closed proxy path.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Facopdlcz3ptizt75x9t6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Facopdlcz3ptizt75x9t6.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is immune to all three. The difference between the two libraries is essentially one extra line of filter, the one that turns on the size and depth caps, which FOIS never wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Exposed Is This, Really?
&lt;/h2&gt;

&lt;p&gt;Less than three impacts suggests, and this is the honest part. Every one needs a serialized log receiver exposed on the network, which is not how Log4j runs by default; Apache removed the built-in socket server years ago. When one researcher tested 53 product builds in default configuration, none were exploitable.&lt;/p&gt;

&lt;p&gt;Call it high severity, low prevalence: trivial to hit where a receiver exists, uncommon to have one. Where they do appear, it tends to be logback's own socket receiver, an older Elastic Logstash log4j input, or legacy Log4j 1.x, which has no filter at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To Fix It
&lt;/h2&gt;

&lt;p&gt;If you cannot remove a serialized receiver right away, one runtime setting closes the gaps. It denies the gadget package and adds the size and depth caps at once. The catch: only one such filter applies per process and a later setting silently replaces an earlier one, so every rule lives in a single string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-Djdk&lt;/span&gt;.serialFilter&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'!org.apache.commons.collections.**;maxarray=10000;maxdepth=16'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a stopgap. The durable fix is to stop sending Java objects across a trust boundary: carry log events as JSON or plain text, or remove the serialized receiver. Log4j 3.x already dropped this pattern; the 2.x line still in production keeps it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are You Affected?
&lt;/h2&gt;

&lt;p&gt;Only if you run a serialized log receiver reachable on the network, which is a deliberate setup, not a default. Look for a process listening on a log-ingest port (historically 4560) that rebuilds log events off the socket.&lt;/p&gt;

&lt;p&gt;If you find one, treat it as high severity, apply the filter above, then move that transport to text or retire it. On Log4j 3.x the pattern is already gone.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Original research:&lt;/strong&gt; U-Sec / Wujie Security, first credited with the &lt;code&gt;MarshalledObject&lt;/code&gt; allowlist bypass behind #4255. The original report was later deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent PoCs:&lt;/strong&gt; joanbono's &lt;code&gt;log4j2-4255-exploit&lt;/code&gt; and dinosn's &lt;code&gt;log4j-4255&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU-crash technique:&lt;/strong&gt; Wouter Coekaerts, "SerialDOS," 2015.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prior writeup:&lt;/strong&gt; Jeff McJunkin's post, whose 53-product test and detection guidance inform the exposure and fix sections.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credit: Prior Work
&lt;/h2&gt;

&lt;p&gt;The RCE (issue #4255), the property that any gadget works once the inner stream is unchecked, and the gadgets themselves are prior work. Our contribution is the breakdown of the filter around that RCE, the two crashes from its missing limits, the logback comparison, and the lab numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;A class-name allowlist is not a deserialization boundary. Restricting which objects can be rebuilt may reduce the risk of code execution, but without limits on payload size, object depth, or resource consumption, the same endpoint can remain vulnerable to denial of service.&lt;/p&gt;

&lt;p&gt;The bigger lesson is simple: &lt;strong&gt;a security control is only as strong as the attack surface it actually closes.&lt;/strong&gt; An endpoint can look hardened against one exploit path while still being wide open to another.&lt;/p&gt;

&lt;p&gt;If your application relies on deserialization, APIs, or other security-sensitive boundaries, automated checks alone may not expose these gaps. That is where adversarial testing matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want to know what your application exposes before an attacker does?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CodeAnt AI helps teams identify security weaknesses across their codebase and infrastructure. For deeper coverage, &lt;strong&gt;CodeAnt's pentesting services&lt;/strong&gt; put those controls to the test from an attacker's perspective, helping uncover exploitable paths that static analysis and automated scanners can miss.&lt;/p&gt;

&lt;p&gt;If you want to see the kind of security research behind that approach, &lt;a href="https://codeant.ai/security-research/dissecting-log4j2-filtered-object-input-stream" rel="noopener noreferrer"&gt;read the full Log4j2 FOIS analysis&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find the weakness before someone else does.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apache Log4j2 issue #4255&lt;/li&gt;
&lt;li&gt;logback HardenedObjectInputStream&lt;/li&gt;
&lt;li&gt;ysoserial · log4j2-4255-lab · Jeff McJunkin&lt;/li&gt;
&lt;li&gt;CVEs: CVE-2017-5645, CVE-2017-5929, CVE-2019-17571, CVE-2023-6378&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This breakdown is part of an ongoing effort by CodeAnt AI Security Research to analyse the trust boundaries in widely deployed infrastructure. The lab ran in isolated local Docker containers with synthetic payloads. The underlying issue and the gadgets are prior work, credited above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have a receiver like this, or want a deserialization boundary reviewed? Reach us at &lt;a href="mailto:securityresearch@codeant.ai"&gt;securityresearch@codeant.ai&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>pentesting</category>
      <category>vulnerabilities</category>
    </item>
    <item>
      <title>How a GitHub Triage Role Hijacked an Already-Authorized Claude Code Action Run</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:14:02 +0000</pubDate>
      <link>https://dev.to/codeant/how-a-github-triage-role-hijacked-an-already-authorized-claude-code-action-run-368j</link>
      <guid>https://dev.to/codeant/how-a-github-triage-role-hijacked-an-already-authorized-claude-code-action-run-368j</guid>
      <description>&lt;p&gt;&lt;em&gt;A GitHub collaborator with only the triage role could shift Claude Code Action's authorized trigger boundary and inject post-authorization input into a repository-writing run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HackerOne #3918594 · Claude Code Action v1.0.185, commit &lt;code&gt;9db594c7a0e82298c121c18b7f08aa1579ce7341&lt;/code&gt; · CVSS 4.0 score 7.5, High&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Authorizing an automated job is really two promises: who gets to start it, and which data it's allowed to see once running. Most security reviews focus on the first and barely glance at the second, which is exactly where CodeAnt AI Security Research went looking in Claude Code Action, the GitHub bot that lets Claude respond to issues and pull requests and push changes back to a repository.&lt;/p&gt;

&lt;p&gt;What we found: a collaborator holding nothing more than GitHub's triage role, no write access, no ability to launch Claude directly, could wait for a maintainer to authorize a run and quietly shift that run's own idea of when it had been authorized, so the already-privileged job treated data the collaborator posted afterward as if it had always been there.&lt;/p&gt;

&lt;p&gt;This is not ordinary prompt injection. The data in question was created after the cutoff meant to exclude it, and it only got in because that cutoff itself was silently reassigned to a different person's action.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Code Action is supposed to guarantee
&lt;/h2&gt;

&lt;p&gt;Two controls sit between a GitHub event and a repository-writing Claude run.&lt;/p&gt;

&lt;p&gt;The first is a write-permission gate: before Claude gets any repository-writing tools, the Action checks whether the actor behind the triggering webhook actually has write access to the repo.&lt;/p&gt;

&lt;p&gt;The second is a temporal cutoff: comments posted or edited after the trigger moment are filtered out of the context Claude sees, so nobody can sneak extra instructions in after a maintainer has already greenlit the run.&lt;/p&gt;

&lt;p&gt;Both controls sound airtight individually. We went looking for whether the actor the first control checks and the timestamp the second control uses actually describe the same underlying event. They didn't, at least not for one common trigger type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconstructing a timestamp from the wrong source
&lt;/h2&gt;

&lt;p&gt;For most trigger events, Claude Code Action reads its timestamp from the webhook payload that fired. &lt;code&gt;issues.labeled&lt;/code&gt; events work differently: the Action instead queries GitHub's API for the issue's full label history, filters to every event where a matching label was applied, and picks whichever happened most recently.&lt;/p&gt;

&lt;p&gt;The resolver, in essence, does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;labeled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;labelName&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byCreatedAtDescending&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub's label-event data includes both an actor and a unique event identifier. Neither gets used here. The resolver treats "an event of this type, with this label name, that happened most recently" as if that were enough to identify one specific, authorized event. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decisions, checking two different things
&lt;/h2&gt;

&lt;p&gt;Split this into the two questions the Action is actually answering.&lt;/p&gt;

&lt;p&gt;The first: is this actor allowed to start a write-capable run? Answered by checking &lt;code&gt;context.actor&lt;/code&gt; on the original webhook. A maintainer passes; a triage-level collaborator does not.&lt;/p&gt;

&lt;p&gt;The second: which repository context belongs to that now-authorized run? Answered, for label triggers, by a live query that ignores actor entirely and just grabs the newest matching event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permission identity: the original webhook's actor and event
content identity:    whichever live event matches type and label most recently
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two answers are supposed to describe the same moment. Once they don't, anyone who fails the first question can still shape the outcome of the second, as long as they have enough GitHub permissions to produce a matching event of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching the boundary actually move
&lt;/h2&gt;

&lt;p&gt;We built a deterministic timeline to prove this out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;09:59  A maintainer-authored task already exists on the issue
10:01  Maintainer applies the "claude" label
10:02  Triage-level collaborator comments RELEASE_APPROVAL=attacker-controlled
10:03  Same collaborator removes the "claude" label, then reapplies it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 10:01, when the run is supposed to be authorized, the 10:02 comment doesn't exist yet, so it has to stay out of scope no matter what.&lt;/p&gt;

&lt;p&gt;Run the resolver with only the original 10:01 event visible, and it correctly returns 10:01, comment excluded. Add the 10:03 relabel event back in, and the resolver instead returns 10:03. The exact same comment, unchanged, now reads as pre-trigger content and lands in the prompt.&lt;/p&gt;

&lt;p&gt;None of this required forging a webhook or impersonating the maintainer. The collaborator used only the ordinary label-management permissions GitHub already grants that role. The authorized job did the substitution itself, by trusting a live, mutable history over the event it was actually triggered by.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvod9kd71unrvxmgum1lf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvod9kd71unrvxmgum1lf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was and wasn't the trusted boundary
&lt;/h2&gt;

&lt;p&gt;To be clear: issue comments were never treated as trusted input, they're expected to be arbitrary and untrusted. Triage collaborators managing labels isn't a flaw either, GitHub deliberately gives that role exactly that capability.&lt;/p&gt;

&lt;p&gt;The actual protected boundary is narrower: the snapshot of context that existed at the moment a maintainer's authorization fired. Claude Code Action explicitly filters out anything added after that snapshot so a privileged run's inputs can't shift after the fact.&lt;/p&gt;

&lt;p&gt;That distinction, control over post-authorization input rather than simple access to untrusted content, is what separates this from a garden-variety prompt injection report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the mutation all the way to a commit
&lt;/h2&gt;

&lt;p&gt;We split verification into two independent layers so neither claim depended on the other.&lt;/p&gt;

&lt;p&gt;The first layer isolated the resolver and prompt-building logic itself: &lt;code&gt;resolveTriggerTimestamp()&lt;/code&gt;, &lt;code&gt;fetchGitHubData()&lt;/code&gt;, the permission check, and prompt generation, fed scripted responses standing in for a maintainer and a triage-level actor. It confirmed the maintainer's webhook passes the gate cleanly, the triage actor's own webhook fails it every time, and only a later same-label event from the triage actor moves the cutoff.&lt;/p&gt;

&lt;p&gt;The second layer went further, into the Action's bundled runtime and its real tag-mode tool access: edit, commit, push, loopback-only, against a local bare repository. The issue held an ordinary maintainer task: take the latest &lt;code&gt;RELEASE_APPROVAL&lt;/code&gt; value, write it to a file, commit and push it. The attacker's comment held nothing but a plain value, no jailbreak language. Wherever the rebinding took effect, Claude wrote, committed, and pushed that value; wherever it didn't, the value was simply absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolating exactly what changed
&lt;/h2&gt;

&lt;p&gt;We designed five controls so each one changed a single security-relevant variable at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Permission check&lt;/th&gt;
&lt;th&gt;Cutoff selected&lt;/th&gt;
&lt;th&gt;Attacker's value&lt;/th&gt;
&lt;th&gt;Repository sink&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Triage actor triggers directly&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Never reached&lt;/td&gt;
&lt;td&gt;Never reached&lt;/td&gt;
&lt;td&gt;No write occurs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only the maintainer's original event exists&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;10:01&lt;/td&gt;
&lt;td&gt;Excluded&lt;/td&gt;
&lt;td&gt;No attacker-chosen file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A later event with a different label exists&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;10:01&lt;/td&gt;
&lt;td&gt;Excluded&lt;/td&gt;
&lt;td&gt;No attacker-chosen file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A later same-label event from the triage actor exists&lt;/td&gt;
&lt;td&gt;Allowed, under the maintainer's webhook&lt;/td&gt;
&lt;td&gt;10:03&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;File committed and pushed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same later event, but actor binding is enforced&lt;/td&gt;
&lt;td&gt;Allowed, under the maintainer's webhook&lt;/td&gt;
&lt;td&gt;10:01&lt;/td&gt;
&lt;td&gt;Excluded&lt;/td&gt;
&lt;td&gt;No attacker-chosen file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every case reproduced across three clean runs each. Notably, the sink-level proof recorded zero permission denials the entire time, because as far as the Action's own logic was concerned, it was still faithfully executing the maintainer's original, authorized job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a repository actually loses
&lt;/h2&gt;

&lt;p&gt;Any repository using &lt;code&gt;issues.labeled&lt;/code&gt; with a Claude trigger label, combined with a write-capable workflow token, was exposed to having its authorized job's inputs quietly mutated by a collaborator well below write access.&lt;/p&gt;

&lt;p&gt;Depending on the maintainer-authored task, admitted data could shape generated source or config, release metadata, the content of Claude's commits, which branches got pushed, and anything downstream that trusted those commits without a second look.&lt;/p&gt;

&lt;p&gt;We rated this High rather than Critical: exploitation needs a label-triggered workflow, a collaborator with triage permissions, a timing window before the authorized job finishes its live lookup, and a maintainer task where the admitted data meaningfully changes the output.&lt;/p&gt;

&lt;p&gt;Anthropic's assigned score reflects that conditionality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;Underneath the GitHub-specific details, this is a case of mutable semantic attributes standing in for a real event identity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;what should have been bound: the webhook's own, immutable event
what actually got bound:     the newest live event sharing type and label name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;event === "labeled"&lt;/code&gt; and &lt;code&gt;label.name === "claude"&lt;/code&gt; describe a whole class of events, not one specific event. Picking the newest member of that class makes the boundary sensitive to anything that happens after the original authorization, as long as it matches type and label.&lt;/p&gt;

&lt;p&gt;The permission check was correct the moment it ran. The comment filter was correct for whatever timestamp it was handed. The failure lived entirely in the handoff between those two steps, where the timestamp's actual origin got lost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lrje9up3fveq9mo4wds.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lrje9up3fveq9mo4wds.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How we'd fix it
&lt;/h2&gt;

&lt;p&gt;The durable solution carries one authenticated event identity, unbroken, from authorization through context collection through execution.&lt;/p&gt;

&lt;p&gt;Where GitHub exposes a trusted, immutable delivery identifier, use that instead of reconstructing anything from live history.&lt;/p&gt;

&lt;p&gt;Where a live lookup is unavoidable, require the selected event's actor to match the original webhook's actor, anchor candidates to a job-receipt timestamp so nothing afterward can drag the cutoff forward, and fail closed on ambiguity.&lt;/p&gt;

&lt;p&gt;Preserve the original webhook payload for trigger-time state, and add regression coverage for a different actor relabeling the issue mid-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for agentic CI more broadly
&lt;/h2&gt;

&lt;p&gt;Authorizing a job isn't only a decision about who may start it. It's also, implicitly, a decision about which inputs it's allowed to consume, and if that snapshot can shift after approval, the authorization can be correct while the resulting behavior isn't.&lt;/p&gt;

&lt;p&gt;Two events sharing an action type and a label name are not the same event; they can belong to different actors and trust levels even when every field looks identical.&lt;/p&gt;

&lt;p&gt;A role check reviewed in isolation ("can this role trigger the workflow") misses the more useful question: what can a role that fails it still influence once a stronger role has already passed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxftqxj0ip6ermkxykvqb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxftqxj0ip6ermkxykvqb.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;August 5, 2026&lt;/td&gt;
&lt;td&gt;Report submitted to Anthropic through HackerOne (#3918594)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 13, 2026&lt;/td&gt;
&lt;td&gt;Anthropic validated the issue, assigned High severity, CVSS 4.0 score 7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 14, 2026&lt;/td&gt;
&lt;td&gt;Bounty of $XXXX awarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 24, 2026&lt;/td&gt;
&lt;td&gt;Editorial cutoff; report still triaged and private, no confirmed fixed version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Checking your own exposure
&lt;/h2&gt;

&lt;p&gt;Start by seeing whether your workflow even uses this trigger pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"anthropics/claude-code-action|issues.*labeled|types:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\?&lt;/span&gt;&lt;span class="s2"&gt;labeled"&lt;/span&gt; .github/workflows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're in scope if your workflow triggers on &lt;code&gt;issues.labeled&lt;/code&gt; with a Claude trigger label, runs in tag mode or otherwise grants workspace edits plus commit and authenticated push, and hands the job a write-capable token.&lt;/p&gt;

&lt;p&gt;Anyone with GitHub's triage role, or any other sub-write role, can add and remove labels freely without writing code, generating the newer same-label event this bug depends on.&lt;/p&gt;

&lt;p&gt;Until a fixed version is confirmed, restrict who can apply your Claude trigger label to write-and-above roles, treat anything a label-triggered run commits or pushes as unreviewed until a human looks at it, and keep downstream CI or deploy steps from auto-consuming those commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;Claude Code Action's write-permission gate worked exactly as intended: it rejected the triage collaborator's own attempts to start a run, every time. The vulnerability sat one layer deeper: an already-authorized job later reconstructed its own trigger boundary from mutable history, and accepted a newer event belonging to the very principal it had just rejected.&lt;/p&gt;

&lt;p&gt;The invariant here is simple to state and easy to violate: the event that defines a privileged agent's input boundary has to be the same event that authorized the run. Keep that identity intact, and later activity, however legitimate on its own, can't quietly rewrite an earlier authorization decision.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published by &lt;a href="https://codeant.ai/security-research/claude-code-action-post-authorization-injection" rel="noopener noreferrer"&gt;CodeAnt AI's Security Research Team&lt;/a&gt;. Read the complete breakdown here →&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>github</category>
      <category>claude</category>
      <category>ai</category>
    </item>
    <item>
      <title>Escaping Claude Code's Sandbox: A TOCTOU Bug That Let Repo Code Overwrite Host Files</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:08:39 +0000</pubDate>
      <link>https://dev.to/codeant/escaping-claude-codes-sandbox-a-toctou-bug-that-let-repo-code-overwrite-host-files-50hh</link>
      <guid>https://dev.to/codeant/escaping-claude-codes-sandbox-a-toctou-bug-that-let-repo-code-overwrite-host-files-50hh</guid>
      <description>&lt;p&gt;&lt;em&gt;A parent-directory swap timed against a permission check let sandboxed repo code redirect a host-owned file write. CVSS 7.7, High.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HackerOne #3882177 · Claude Code 2.1.217, macOS arm64 · CVSS 4.0 score 7.7, High&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Sandboxing runs on a simple promise: code inside it cannot reach outside it. Claude Code, Anthropic's coding agent, keeps that promise on macOS by boxing in Bash and everything it spawns, while leaving one door unlocked: a trusted editor process that applies the file changes Claude has permission to make.&lt;/p&gt;

&lt;p&gt;We at CodeAnt AI Security Research tested whether that door could be reached from the wrong side of the sandbox. It could. A process confined entirely to the Bash sandbox got the trusted, host-owned Edit tool to overwrite a file the sandbox had already been denied direct access to. No outside-write approval appeared anywhere, and no permission check failed. The file simply changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of trust sharing one filesystem
&lt;/h2&gt;

&lt;p&gt;Claude Code splits authority during a session. Bash commands and their children run inside an OS-level sandbox with restricted filesystem access, so a repository's tests and builds can execute without full run of your machine. Built-in tools like Edit run in the trusted host process, the part actually allowed to touch your project.&lt;/p&gt;

&lt;p&gt;Permissions decide whether Claude may call a tool at all. Sandboxing then restricts what Bash and its descendants can do at the OS level regardless of permission. In &lt;code&gt;acceptEdits&lt;/code&gt; mode, edits auto-approve only inside the working directory; Seatbelt enforces that boundary for sandboxed processes, which is why a direct outside write returns &lt;code&gt;EPERM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of that is the problem. The problem sits one layer up: the assumption that the file a permission check looked at is still the file the trusted writer touches a moment later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A file path is a set of directions, not a destination
&lt;/h2&gt;

&lt;p&gt;A path like &lt;code&gt;/workspace/project/config.json&lt;/code&gt; isn't a pointer to one fixed object. It's a sequence of instructions: enter this directory, then this one, then open this file. Every step resolves fresh, at the moment something reads it.&lt;/p&gt;

&lt;p&gt;Change one directory partway through that walk, without touching the final filename, and you change where the whole path ends up. Turn &lt;code&gt;project&lt;/code&gt; into a symlink pointing elsewhere, and the same string now means something entirely different.&lt;/p&gt;

&lt;p&gt;That's a time-of-check-to-time-of-use problem (TOCTOU for short): a gap between when something is verified and when it's used, wide enough for the underlying object to change in between. Claude Code's Edit tool had exactly that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two checks stopped agreeing
&lt;/h2&gt;

&lt;p&gt;Static analysis of Claude Code 2.1.217's embedded JavaScript showed how Edit authorizes and writes a file, in equivalent pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;checkParentResolutions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;replaceFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool checks the target's parent directories, checks again to catch ordinary symlink swaps, creates any missing directories, then writes the file.&lt;/p&gt;

&lt;p&gt;That second check narrows the window for simple symlink substitution but doesn't close it. It only compares path strings at one instant, never binding authorization to a stable directory handle or inode. And the recursive &lt;code&gt;mkdir&lt;/code&gt; call, which takes real wall-clock time, runs after that last check and before the write. That's the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning an ordinary developer workflow into the trigger
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yz92qi5trnvc1xre5cv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yz92qi5trnvc1xre5cv.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We didn't want to assume a repository could hand-pick an arbitrary Edit call, so we used a sequence any Claude Code user would recognize as normal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A developer opens a repository they don't fully control.&lt;/li&gt;
&lt;li&gt;They ask Claude to run the tests and fix whatever's failing.&lt;/li&gt;
&lt;li&gt;Claude runs the repo's own test command inside the sandbox, exactly as designed.&lt;/li&gt;
&lt;li&gt;A pretest step quietly launches a small helper process, still confined to the sandbox, and has it wait.&lt;/li&gt;
&lt;li&gt;The test fails, reporting a missing fixture at a path that looks entirely ordinary.&lt;/li&gt;
&lt;li&gt;Claude, being helpful, creates that fixture using its built-in Edit path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repository controls the test, the failure message, and the fixture path. It never has to guess what Claude would independently choose.&lt;/p&gt;

&lt;p&gt;A simplified target: &lt;code&gt;&amp;lt;workspace&amp;gt;/slot/signal/private/tmp/demo/outside/payload.txt&lt;/code&gt;. &lt;code&gt;signal&lt;/code&gt; starts as an ordinary directory; next to it sits &lt;code&gt;armed&lt;/code&gt;, already a symlink to &lt;code&gt;/&lt;/code&gt;. The helper never leaves the sandbox: a direct outside write gets &lt;code&gt;EPERM&lt;/code&gt; every time. Its only useful move: renaming two entries it already owns inside the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The swap itself
&lt;/h2&gt;

&lt;p&gt;The helper needed to know exactly when to move. The signal is the &lt;code&gt;mkdir&lt;/code&gt; call itself: the moment the first missing directory gets created, Claude Code's final resolution check has already run and passed.&lt;/p&gt;

&lt;p&gt;At that instant it calls &lt;code&gt;renameatx_np&lt;/code&gt; with &lt;code&gt;RENAME_SWAP&lt;/code&gt;, exchanging two directory entries atomically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before: signal = ordinary directory, armed = symlink to /
after:  signal = symlink to /, armed = ordinary directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh7gr11plz681z589fqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh7gr11plz681z589fqz.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The untouched, textually identical target path now resolves somewhere else entirely. Effective target: &lt;code&gt;/private/tmp/demo/outside/payload.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude Code's host-side writer, unrestricted by the sandbox, finishes exactly as it was going to: creates a temp file, renames it over the target. It has no reason to suspect anything changed. The sandboxed helper never touched the outside file itself. It only flipped an entry it already owned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only the last step of the path was actually guarded
&lt;/h2&gt;

&lt;p&gt;Claude Code's writer does refuse to follow a symlink in the final path component, a genuine defense that stops a simpler attack cold.&lt;/p&gt;

&lt;p&gt;It doesn't help here, because this race happens on an ancestor directory, not the final component. Guarding only the last link in the chain does nothing to guarantee the earlier links stayed the same object they were when permission was granted.&lt;/p&gt;

&lt;p&gt;The permission system reasoned about a string and a resolution it observed once. The writer, later, walked that same string again and got a different answer. That mismatch is the entire vulnerability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it beyond a single lucky run
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fbh99jwycu55xz836su.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fbh99jwycu55xz836su.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We built the reproduction to leave zero ambiguity about which boundary was crossed: isolated canary files, a throwaway home directory, a deterministic local model fixture, and no Anthropic credentials or live services involved.&lt;/p&gt;

&lt;p&gt;Five variations, each changing exactly one property, separated the bug from expected behavior. Each ran three consecutive times, clean every time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct &lt;code&gt;Edit&lt;/code&gt; call to a path outside the workspace&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;td&gt;The permission layer correctly recognizes outside writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandboxed helper tries writing the outside canary directly&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EPERM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Seatbelt correctly blocks the attacker from the real target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same directory layout, swap never triggered&lt;/td&gt;
&lt;td&gt;Canary untouched&lt;/td&gt;
&lt;td&gt;The path is safe as long as object identity doesn't change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap timed against the &lt;code&gt;mkdir&lt;/code&gt; signal&lt;/td&gt;
&lt;td&gt;Canary overwritten, zero permission denials logged&lt;/td&gt;
&lt;td&gt;The host writer crossed the workspace boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A sibling dev project watched by an autoreloader&lt;/td&gt;
&lt;td&gt;Modified source executed&lt;/td&gt;
&lt;td&gt;An ordinary downstream tool turns the overwrite into execution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this actually gets an attacker
&lt;/h2&gt;

&lt;p&gt;With this primitive, a malicious repository, a compromised dependency, or a prompt-injected command running inside the Bash sandbox could get the trusted host process to overwrite an attacker-chosen, user-writable file anywhere outside the workspace: modifying sibling projects, tampering with build configuration, corrupting files CI reads downstream, and in some cases running arbitrary code, when something already present picks up the changed file on its own.&lt;/p&gt;

&lt;p&gt;To be precise: this isn't an unconditional remote-code-execution bug. Getting from "overwritten" to "executed" needs an existing consumer already watching that location. On its own, the proven impact is arbitrary modification of a file outside the authorized workspace.&lt;/p&gt;

&lt;p&gt;Anthropic rated the report High, CVSS 4.0 score 7.7:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why checking twice wasn't enough
&lt;/h2&gt;

&lt;p&gt;The root cause is an authorization-to-use binding failure: the identity authorized at time T1 isn't the identity actually used at time T2. Rechecking the path narrows that gap but can't close it, since any later step that takes real time hands an attacker another window.&lt;/p&gt;

&lt;p&gt;We also found the identical check-then-mkdir-then-write ordering in Claude Code's Write tool during static review. We're flagging the pattern, not claiming a second confirmed exploit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it properly
&lt;/h2&gt;

&lt;p&gt;The durable fix ties authorization and the eventual write to the same filesystem objects, not to a string re-resolved twice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the trusted workspace root once and hold its descriptor for the whole operation.&lt;/li&gt;
&lt;li&gt;Walk every ancestor relative to that descriptor using &lt;code&gt;openat&lt;/code&gt;-style calls, never by re-resolving the full path.&lt;/li&gt;
&lt;li&gt;Open each directory with &lt;code&gt;O_DIRECTORY | O_NOFOLLOW&lt;/code&gt;, keeping descriptors alive until the write completes.&lt;/li&gt;
&lt;li&gt;Create missing directories with &lt;code&gt;mkdirat&lt;/code&gt;, relative to an already-verified parent.&lt;/li&gt;
&lt;li&gt;Perform the final write with &lt;code&gt;openat&lt;/code&gt; and &lt;code&gt;renameat&lt;/code&gt;, not plain pathname operations.&lt;/li&gt;
&lt;li&gt;If a component must be reopened across an async gap, verify its device and inode identity first.&lt;/li&gt;
&lt;li&gt;Apply the same pattern everywhere this boundary shows up: Edit, Write, and any shared helper.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A pathname recheck before the final write is still worth having as defense in depth. It just can't be the only defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson for agentic tools
&lt;/h2&gt;

&lt;p&gt;Sandboxing a process doesn't remove its ability to influence what a privileged process does next. It can still shape diagnostics, file paths, and filesystem state the trusted side later consumes.&lt;/p&gt;

&lt;p&gt;A pathname is not a durable identity; it's a recipe re-executed on demand. Any system that authorizes based on one resolution and acts on a later one needs to preserve object identity across that gap, not just recheck the string.&lt;/p&gt;

&lt;p&gt;The strongest proof here was the contrast between a write that returned &lt;code&gt;EPERM&lt;/code&gt; and a write, moments later, from the trusted side, that succeeded against the same target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you worry about this right now
&lt;/h2&gt;

&lt;p&gt;Check your version first: &lt;code&gt;claude --version&lt;/code&gt;. This finding was reproduced on Claude Code 2.1.217, macOS arm64. Confirm affected and fixed ranges directly with Anthropic.&lt;/p&gt;

&lt;p&gt;You're in the risk zone if: you're on macOS, you let Claude run repository commands in the Bash sandbox while using the built-in Edit/Write tools, you run in &lt;code&gt;acceptEdits&lt;/code&gt; mode, and you regularly open repositories you don't fully trust.&lt;/p&gt;

&lt;p&gt;Until a fixed build is confirmed, prefer explicit per-edit approval over &lt;code&gt;acceptEdits&lt;/code&gt; for code you don't control, and watch for sibling projects or build watchers running alongside your workspace, since that's what turns an overwrite into execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The invariant this bug violated is easy to state and hard to implement everywhere it needs to hold: the object that receives a write has to be the same object, reached through the same verified chain of ancestors, that the permission system actually authorized.&lt;/p&gt;

&lt;p&gt;If a system checks a path once and follows that same path again later, an attacker who can move what's underneath the path (without touching the string itself) can walk past every check while each one still reports success.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published by &lt;a href="https://codeant.ai/security-research/claude-code-macos-parent-directory-toctou-vulnerability" rel="noopener noreferrer"&gt;CodeAnt AI's Security Research Team&lt;/a&gt;. Read the complete breakdown here →&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>claude</category>
      <category>sandbox</category>
      <category>macos</category>
    </item>
    <item>
      <title>CVE-2026-71511 · Read a member's card, receive their password hash</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:20:02 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71511-read-a-members-card-receive-their-password-hash-1lcl</link>
      <guid>https://dev.to/codeant/cve-2026-71511-read-a-members-card-receive-their-password-hash-1lcl</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Sensitive data exposure (CWE-200)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every reply is supposed to pass a redactor that blacks out secrets. The Users redactor blacks out the password. The Members redactor, same job, next desk, forgot that one line, and hands the password verifier out with the card.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every API reply runs through a "cleaner"&lt;/strong&gt;. Its job is to strip secrets before anything is sent back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line the two cleaners up side by side&lt;/strong&gt;. The one for users against the one for members. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Users cleaner removes the stored password hash&lt;/strong&gt;. Exactly as it should.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Members cleaner doesn't&lt;/strong&gt;. One missing line, doing the identical job one desk over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "read members" key receives every member's password verifier&lt;/strong&gt;. And from the list route, all of them at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmed live, not a placeholder&lt;/strong&gt;. Matched to the database, and it validated the real password. &lt;strong&gt;→ credential exposure.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before an application sends any record back to a caller, something has to decide what to leave out. Passwords, keys, the private machinery of an account, all of it is supposed to be stripped on the way out, by a step whose only job is redaction. When that step is thorough, you never think about it. When it isn't, secrets walk out in plain sight.&lt;/p&gt;

&lt;p&gt;This is the story of two of those redactors, doing the same job for two similar kinds of record, where one remembered to black out the password and the other simply didn't, and how that one missing line handed every member's password verifier to anyone allowed to read a member card.&lt;/p&gt;

&lt;p&gt;It is a simple example of how sensitive data exposure can happen inside an otherwise legitimate API response. The endpoint itself works as designed. The problem is that the Dolibarr security check responsible for removing sensitive credential data was incomplete.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F354tiq8jv0eewrm6lp27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F354tiq8jv0eewrm6lp27.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funqmhw52hsfhebagxvtr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funqmhw52hsfhebagxvtr.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Found by holding two things side by side
&lt;/h2&gt;

&lt;p&gt;This finding didn't come from a clever attack. It came from comparison. Dolibarr redacts a &lt;em&gt;user&lt;/em&gt; record and a &lt;em&gt;member&lt;/em&gt; record with two separate pieces of code meant to do the same thing. So we put them next to each other and read them line for line.&lt;/p&gt;

&lt;p&gt;The user redactor removed the stored password verifier before sending, exactly right. The member redactor, doing the identical job one desk over, didn't. One missing line, in one of two near-twin functions.&lt;/p&gt;

&lt;p&gt;This is the kind of issue that can be missed by a straightforward application security assessment. Nothing needs to be injected or bypassed in the traditional sense. The vulnerability appears when two pieces of code responsible for protecting the same sensitive data are compared and found to enforce different rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that one line leaks
&lt;/h2&gt;

&lt;p&gt;The consequence is not subtle. An account holding nothing more than the right to read members, an ordinary, widely-granted permission, receives, along with each member's ordinary details, the stored verifier for that member's password. And because the same gap sits on the route that lists &lt;em&gt;all&lt;/em&gt; members, you don't get them one at a time. You get the whole set in a single reply.&lt;/p&gt;

&lt;p&gt;We confirmed the value was the real thing rather than a harmless placeholder by matching it back to the database and checking it corresponded to the member's actual password. It was live.&lt;/p&gt;

&lt;p&gt;For a security researcher or penetration tester, that distinction matters. This was not simply a field that looked sensitive in a source-code review. We verified that the API returned real credential material and traced it back to the underlying application data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2xove8jz3r29sfp4g5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2xove8jz3r29sfp4g5q.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole pattern in one line
&lt;/h2&gt;

&lt;p&gt;If you want the entire theme of this research compressed into a single example, it is this one. The safe way already existed. The correct redactor was written, working, and sitting a file away. The neighbouring code that did the same job just skipped it. Not a hard problem, not a clever oversight, just a copy that wasn't quite a copy.&lt;/p&gt;

&lt;p&gt;That is what “split-brain authorization” looks like at its smallest: two places responsible for the same rule, quietly disagreeing, with no one comparing them until someone does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; brings the member redactor in line with the user one, so the stored password material is stripped before any member record is sent, from single-record and list replies alike.&lt;/p&gt;

&lt;p&gt;The lesson is almost embarrassingly practical: when two parts of a codebase are supposed to enforce the same rule, the highest-value test is often not an exploit at all. It is reading them side by side and asking whether they actually match. One of them got it right. Diffing it against its neighbour is how you find the one that didn't.&lt;/p&gt;

&lt;p&gt;This is also why effective application penetration testing combines testing the running application with understanding how security controls are implemented. A response can look perfectly normal while still exposing sensitive data if one authorization or redaction path behaves differently from its counterpart.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;→ Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>opensource</category>
      <category>penetrationtesting</category>
    </item>
    <item>
      <title>CVE-2026-71510 · Ask enough yes/no questions and you know everyone's salary</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:18:01 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71510-ask-enough-yesno-questions-and-you-know-everyones-salary-3ja9</link>
      <guid>https://dev.to/codeant/cve-2026-71510-ask-enough-yesno-questions-and-you-know-everyones-salary-3ja9</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Authorization oracle → blind disclosure (CWE-863 / CWE-200)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You can't see the salary column, but the search box will happily answer “is this person's salary above 50,000? yes or no.” Ask twenty smart questions and you've read the exact number without ever seeing it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The user list has a search filter&lt;/strong&gt;: you can narrow it by a column's value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's safe from SQL injection&lt;/strong&gt;: the obvious attack was checked, and it holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unasked question: which columns may you filter on?&lt;/strong&gt;: the answer was any of them, including salary and the password hash. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Those columns are stripped from the reply&lt;/strong&gt;: so you never actually see the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But "filter salary above X" returns a row, or nothing&lt;/strong&gt;: that single yes/no is a leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary-search the answers to the exact number&lt;/strong&gt;: salaries recovered blind, plus a case-folded image of the hash. &lt;strong&gt;→ blind disclosure.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a whole class of secret you can steal without ever being shown it. You don't need the system to &lt;em&gt;hand&lt;/em&gt; you the value. You only need it to answer yes-or-no questions about it, honestly, as many times as you like. Twenty good questions are enough to pin down almost any number.&lt;/p&gt;

&lt;p&gt;This is the story of a search box that would answer exactly those questions about columns it was supposed to keep hidden, salaries, and even the stored password verifier, and how a low-privilege account read them precisely, blind, without the system ever displaying a single one.&lt;/p&gt;

&lt;p&gt;It is the most subtle finding in the set, because nothing here looks broken. No error, no leak on the screen, no obvious hole. Just a search filter answering a question it should have refused to consider.&lt;/p&gt;

&lt;p&gt;This is the kind of web application security issue that can slip past a straightforward vulnerability scan: the search feature is not injectable, the response does not expose the sensitive field, but the application's authorization logic still lets a low-privilege user interrogate it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yewm67dllirtodk5q5x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yewm67dllirtodk5q5x.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvflvmu79tgz7xxkk27dr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvflvmu79tgz7xxkk27dr.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious attack, and the one underneath it
&lt;/h2&gt;

&lt;p&gt;The user list has a search filter, so you can narrow the list by a column's value. The reflex is to test it for injection: can you break out of the query and run your own? We checked, and the answer was no. On that front it is safe, and a lot of reviews would stop there satisfied.&lt;/p&gt;

&lt;p&gt;The second, quieter question is not about breaking the query at all. It is about authorization: &lt;em&gt;which columns are you even allowed to filter on?&lt;/em&gt; The answer turned out to be all of them, including the salary fields and the password verifier that the results are so careful never to display.&lt;/p&gt;

&lt;p&gt;This is where security testing has to go beyond checking whether an endpoint returns the expected response. A penetration test also needs to ask whether a low-privilege user can use an otherwise legitimate feature to access information they are not authorized to interrogate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a filter into a yes/no oracle
&lt;/h2&gt;

&lt;p&gt;Those columns are stripped from what comes back, so you never &lt;em&gt;see&lt;/em&gt; a salary in the list. But you can still filter on one. And a filter you can apply to a hidden column is a question the system will answer: ask it to show everyone whose salary is above fifty thousand, and either a matching row comes back or it doesn't. Row or no row. Yes or no.&lt;/p&gt;

&lt;p&gt;That single bit is the whole leak. Ask “above 50,000?”: no. “above 25,000?”: yes. “above 37,000?”: and so on, each question halving the range that's left. In a couple of dozen questions you have squeezed the exact figure out of a value the screen never showed you. Point the same technique at the stored password verifier and you can walk it out piece by piece.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab8nlqhgvrefvqbdrnla.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab8nlqhgvrefvqbdrnla.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: what it is not
&lt;/h2&gt;

&lt;p&gt;A responsible finding has to say where the danger stops, and we were careful to. The password material you can recover this way is not a usable key to log in with: Dolibarr stores it in a form it will not accept back as a login, and the recovered image is case-folded rather than exact. So this is a serious &lt;em&gt;disclosure&lt;/em&gt;, real salaries, real hidden fields, read blind, but it is not, by itself, account takeover. We tested that boundary, confirmed it held, and wrote the disproof into the report so a reviewer could see we had actually checked rather than assumed.&lt;/p&gt;

&lt;p&gt;That distinction matters for trust. It is easy to inflate a finding by implying the worst. It is more useful, and more credible, to show exactly how far it goes and exactly where it stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss, and what closes it
&lt;/h2&gt;

&lt;p&gt;This is the kind of hole endpoint-by-endpoint testing walks straight past. The filter isn't injectable. The response doesn't leak. Nothing throws an error. The only thing wrong is that a question was allowed which never should have been, and you only see it if you ask, of a search feature, not “can I break this,” but “which fields am I permitted to ask about.”&lt;/p&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; restricts which columns the filter will accept, so hidden fields can no longer be interrogated through the back door of a yes/no search. The lesson is to treat a filter as an authorization surface, not just an injection surface: every column you let a caller filter on is a column you are willing to answer questions about, one bit at a time.&lt;/p&gt;

&lt;p&gt;This is also why security research needs to look beyond individual endpoints. In this case, the issue was not an obvious injection vulnerability. It was the interaction between filtering, authorization and sensitive fields, which is exactly the kind of behaviour that deeper application security testing can uncover.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? &lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;→ Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>opensource</category>
      <category>penetrationtesting</category>
    </item>
    <item>
      <title>CVE-2026-71509 · Approve your own expenses — and your whole team's</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:16:07 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71509-approve-your-own-expenses-and-your-whole-teams-1fbl</link>
      <guid>https://dev.to/codeant/cve-2026-71509-approve-your-own-expenses-and-your-whole-teams-1fbl</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Approval / access-control bypass (CWE-862)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The big “Approve” button correctly needs a manager. But the plain “edit this report” form lets you type approved and name anyone as the approver, straight into the fields, with no manager required.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expense reports need a second person to approve&lt;/strong&gt;: basic separation of duties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dedicated Approve button checks for it&lt;/strong&gt;: the front door is genuinely locked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But the generic "update report" route writes the same fields&lt;/strong&gt;: status and approver, set directly. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing checks the approver differs from the author&lt;/strong&gt;: or that you hold the approval right at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark your own claim approved and name anyone as approver&lt;/strong&gt;: from an account that can only file claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your claim, and a subordinate's, enter the payable queue&lt;/strong&gt;: the second signature never happened. &lt;strong&gt;→ self-approved payouts.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Expense approval is a textbook example of separation of duties: the person who files a claim must not be the person who approves it. That one rule is what stands between a company and an employee quietly signing off their own reimbursements. Dolibarr knows the rule, and the obvious button that approves a report enforces it properly.&lt;/p&gt;

&lt;p&gt;This is the story of a second, plainer path that writes the very same fields the Approve button does, without asking whether the person approving is the person who filed.&lt;/p&gt;

&lt;p&gt;The front door is locked. The side door was left open, and it leads to the same room.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft0jjrjkyrtegvswb4gc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft0jjrjkyrtegvswb4gc7.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkbj6bc0zr8xlpbhgrrt4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkbj6bc0zr8xlpbhgrrt4.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The question with an obvious answer, and a real one
&lt;/h2&gt;

&lt;p&gt;With any approval workflow, the first question to ask is the classic one: can the same person file a claim and approve it? We asked it of the dedicated Approve action, and the answer was a reassuring no. That action checks for the right permission, exactly as it should. A shallow security test stops here and calls it safe.&lt;/p&gt;

&lt;p&gt;The real answer lived one path over. Alongside the Approve button, Dolibarr has a generic “update this expense report” path, the kind of catch-all that lets you edit a report's details. And that path lets you write the report's &lt;em&gt;status&lt;/em&gt; and its &lt;em&gt;approver&lt;/em&gt; directly, as if they were ordinary fields like a date or a note.&lt;/p&gt;

&lt;p&gt;This is where API security testing and business logic testing matter. An endpoint can correctly protect its dedicated privileged action while another route exposes the same state change without enforcing the same authorization rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing your own name on the approval line
&lt;/h2&gt;

&lt;p&gt;So an account that can only file claims opens the generic update path, sets the status to approved, and writes itself, or anyone it likes, into the approver field. Nothing checks that the approver differs from the author. Nothing checks that the account holds the approval right at all. The claim moves into the payable queue, approved, with a name on the approval line that the filer chose.&lt;/p&gt;

&lt;p&gt;We did it to our own claim, and then to a subordinate's, from an account that was never supposed to approve anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuatqr4n1p6x8dl6unhmk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuatqr4n1p6x8dl6unhmk.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a correct check isn't enough
&lt;/h2&gt;

&lt;p&gt;The uncomfortable lesson here is that the Approve button being correct bought almost nothing. A control is only as strong as the &lt;em&gt;weakest&lt;/em&gt; path that can reach the same state. If one carefully guarded action sets a field, and a second, generic action sets the same field with no guard, then the second action is the real security boundary, and it is wide open.&lt;/p&gt;

&lt;p&gt;The damage is money leaving on approvals that never happened. In a company relying on Dolibarr to enforce the two-person rule on reimbursements, this quietly dissolves it: a single employee can file and approve, or approve for others, and push claims into the queue that pays them.&lt;/p&gt;

&lt;p&gt;For Dolibarr security testing, this is an important distinction. Testing only the intended approval workflow can miss a business logic vulnerability exposed through a generic update endpoint. Effective penetration testing has to trace sensitive state changes across every route that can create them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; brings the generic update path under the same rule the Approve action already enforced. The status and approver fields can no longer be written freely, and the separation between filing and approving holds on every path, not just the obvious one.&lt;/p&gt;

&lt;p&gt;The takeaway generalises to any workflow with a privileged step: find every path that can write the state, not just the one built for it. A locked front door means nothing while a back-office form writes the same fields with no check at all.&lt;/p&gt;

&lt;p&gt;This is also the kind of authorization gap that a web application penetration test should look for: not just whether a user can access an endpoint, but whether they can use an alternative endpoint to reach a privileged application state they were never meant to control.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;→ Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>penetrationtesting</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-71508 · Set your own salary — and the receipt hides it</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:14:38 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71508-set-your-own-salary-and-the-receipt-hides-it-3p2f</link>
      <guid>https://dev.to/codeant/cve-2026-71508-set-your-own-salary-and-the-receipt-hides-it-3p2f</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Mass assignment on payroll fields (CWE-915)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The form that updates your profile refuses to let you change your job title, but leaves “salary” wide open, then shows you a receipt with the salary line blanked out, so it looks like nothing happened.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The profile-update route copies your fields onto your record&lt;/strong&gt;: standard API behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It blocks a short list of obvious secrets&lt;/strong&gt;: but salary, bonus and cost rates aren't on it. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send a new salary for your own account&lt;/strong&gt;: from a user with zero payroll permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The write succeeds, but the reply hides it&lt;/strong&gt;: the response omits the field it just changed, so a quick security test looks like nothing happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the database before and after&lt;/strong&gt;: the only way to see the new number is really there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It flows into the official payroll export&lt;/strong&gt;: an employee rewrote their own pay. &lt;strong&gt;→ payroll tampering.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Payroll is one of the few things in a company everyone agrees must be locked down. What each person earns, salary, bonus, the internal rates used to cost their time, sits behind a permission only HR and finance are meant to hold. The interface Dolibarr shows a human respects that boundary completely.&lt;/p&gt;

&lt;p&gt;This is the story of how, underneath that respectable interface, the route that saves your own profile would quietly write your own salary, then hand you back a receipt with that very change hidden, so a casual API security test would swear nothing had happened.&lt;/p&gt;

&lt;p&gt;It is a mass-assignment vulnerability, the same family as the admin-takeover one, but with a nastier twist: this one covers its tracks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx074r1pd1z84uezjjz7o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx074r1pd1z84uezjjz7o.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwppwe5syd573wsih1bbo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwppwe5syd573wsih1bbo.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The short block-list that forgot the important fields
&lt;/h2&gt;

&lt;p&gt;When you save your profile, the route takes the fields you sent and writes them onto your record. It does try to be careful. It keeps out a short list of obviously dangerous fields. But a block-list is only ever as good as its imagination, and this one's imagination stopped short. Salary, bonus and the internal cost and daily rates, the four fields that actually decide what someone is paid, were not on it.&lt;/p&gt;

&lt;p&gt;So an ordinary user, holding no payroll permission at all, edits their own profile and includes a new salary. The route writes it. There is no check that this particular field belongs to a locked-down part of the system. It is simply another field in the pile, and the pile gets written.&lt;/p&gt;

&lt;p&gt;This is the core of the &lt;strong&gt;CWE-915 mass-assignment issue&lt;/strong&gt;: the API accepts fields based on what has not been explicitly blocked rather than limiting the request to fields the caller is actually authorized to modify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silence that hides it
&lt;/h2&gt;

&lt;p&gt;Here is what makes this one genuinely sneaky. The reply you get back is run through a “cleaner” whose job is to strip sensitive fields before sending anything out. So the response leaves the salary line blank, not because the write failed, but because the reply is not allowed to show it.&lt;/p&gt;

&lt;p&gt;The effect is that a quick API penetration test looks like a clean rejection. You send a new salary, the answer comes back with no salary in it, and every instinct says “nothing changed.” The write succeeded; the receipt just doesn't mention it. That gap between what happened and what the response admits is exactly where a bug like this hides for a long time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lms4yxwo9g2gbe7eovg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lms4yxwo9g2gbe7eovg.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the number all the way through
&lt;/h2&gt;

&lt;p&gt;Because the response gives nothing away, the only way to catch the full impact was to stop trusting it. We read the database directly, before and after: the new salary was really there, written by an account with zero payroll rights. Then we followed that number where it counts, into the official payroll export, the report finance actually uses, and there it was too.&lt;/p&gt;

&lt;p&gt;That is the difference between “a field can be set” and “an employee can rewrite their own pay and have it flow into the payroll run.” The first is a curiosity. The second is the finding.&lt;/p&gt;

&lt;p&gt;For Dolibarr security testing, that distinction matters. A penetration test cannot stop at whether an API returns &lt;code&gt;200 OK&lt;/code&gt; or &lt;code&gt;403 Forbidden&lt;/code&gt;. It has to establish what the request actually changed, what permissions the caller had, and where that changed data flows next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; stops treating payroll fields as ordinary profile fields: they now require the permission that governs pay, rather than riding in on a self-profile update, and the change is no longer hidden from the very response that would let someone notice it.&lt;/p&gt;

&lt;p&gt;There are two lessons stacked here. First, a block-list of “dangerous fields” is the wrong shape. It fails silently the moment someone adds a new sensitive field and forgets to list it; an explicit list of the fields a caller &lt;em&gt;may&lt;/em&gt; set fails safe instead. Second, hiding a value in the response is not the same as protecting it in the database. A write you can't see in the reply is still a write.&lt;/p&gt;

&lt;p&gt;For developers and security teams, this is also a reminder that API authorization testing needs to cover individual fields, not just endpoints. An endpoint can have authentication and authorization checks and still expose a sensitive field through mass assignment.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0, that closes this finding. Want to know where your own app disagrees with itself? &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;→ Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>opensource</category>
      <category>penetrationtesting</category>
    </item>
    <item>
      <title>CVE-2026-71507 · Change one number, and the payroll run pays you</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:12:30 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71507-change-one-number-and-the-payroll-run-pays-you-dfd</link>
      <guid>https://dev.to/codeant/cve-2026-71507-change-one-number-and-the-payroll-run-pays-you-dfd</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Object-level auth bypass → SEPA payment redirection (CWE-639)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You can't see a supplier's account, but you can quietly edit the bank number the company will pay. When payday's batch is generated, your account is sitting inside it as the payee.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You can't even read the three suppliers&lt;/strong&gt;. They're off-limits to your low-privilege account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But the bank-detail write route skips that ownership check&lt;/strong&gt;. The same gap, on the field that matters most. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite their bank numbers to accounts you control&lt;/strong&gt;. Three IBANs, swapped for three of your own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The business generates its payment batch&lt;/strong&gt;. The routine SEPA file it hands the bank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your accounts are the creditors in a real payment file&lt;/strong&gt;. Several thousand euros, in a byte-for-byte legitimate run. &lt;strong&gt;→ money redirected.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We stopped at the generated file&lt;/strong&gt;. Never sent it, but the attacker's account was already inside a live payment instruction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once a month, or once a week, a business does something almost mechanical: it generates a payment file and hands it to its bank, and the bank pays every supplier on the list. That file is one of the most consequential things the whole system produces. It is money leaving the building, and almost nobody reads it line by line. They trust that the numbers in it came from supplier records set up long ago.&lt;/p&gt;

&lt;p&gt;This is the story of how an attacker who could not even &lt;em&gt;read&lt;/em&gt; those supplier records rewrote the bank details inside them, so that when the next payment run was generated, three of the creditors were accounts the attacker controlled.&lt;/p&gt;

&lt;p&gt;The same missing authorization check we found across Dolibarr shows up here on the one field where it hurts most: the destination of a payment.&lt;/p&gt;

&lt;p&gt;This finding is a clear example of broken object-level authorization (BOLA) in an API: the application checks whether an account can perform a type of action, but fails to verify whether it can perform that action on the specific supplier record.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgwr5dunvoxqyf40dnvm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgwr5dunvoxqyf40dnvm.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftothzozxzkiv8lrn4nzr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftothzozxzkiv8lrn4nzr.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The write that shouldn't have worked
&lt;/h2&gt;

&lt;p&gt;The attacker's account is low-privilege by design. Ask it to read the three target suppliers and it is refused. It has no business seeing them. On the read side, Dolibarr holds the line.&lt;/p&gt;

&lt;p&gt;But editing a company's bank details goes through a different API path, and that path skips the ownership check its read sibling performs. It asks whether you are the sort of account that can edit company records at all, not whether &lt;em&gt;these&lt;/em&gt; companies are yours. So the attacker rewrites the account numbers of three suppliers it was never allowed to look at, swapping in three accounts of its own.&lt;/p&gt;

&lt;p&gt;This is the kind of authorization gap that API penetration testing is designed to uncover. A basic permission check can look correct while still allowing a low-privilege user to modify another object's sensitive data. Testing the read and write paths separately exposed the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chasing the value to where it actually goes
&lt;/h2&gt;

&lt;p&gt;A changed bank number, on its own, is the kind of thing a busy reviewer shrugs off. “So a field can be edited, so what.” The move that turned this from a shrug into a serious security finding was refusing to stop at the write, and asking the only question that matters: where does this value actually &lt;em&gt;go&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;It goes into the payment file. When the business next generates its supplier-payment batch, the routine SEPA payment file it hands its bank, Dolibarr reads each supplier's default bank account and writes it into the file as the creditor. The three accounts now sitting in those supplier records are the attacker's. So the generated batch, a file that is byte-for-byte a legitimate payment run, carries the attacker's accounts as the people about to be paid, for several thousand euros.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9i5r30bxpa11o8fdb30b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9i5r30bxpa11o8fdb30b.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we stopped, and why it still has teeth
&lt;/h2&gt;

&lt;p&gt;We stopped at the generated file. We never submitted it to a bank, and no money ever moved. That line is not one a security test gets to cross. But the finding does not need money to have moved to be real. The attacker's account was already &lt;em&gt;inside&lt;/em&gt; a live payment instruction, indistinguishable from a legitimate one, waiting for the ordinary approval such files get on their way out the door.&lt;/p&gt;

&lt;p&gt;Whether that last step would succeed in the real world depends on a company's own controls, whether a human reconciles the file, whether the bank checks that the account name matches the account number. Those are real backstops. But they are backstops against a file that should never have contained an attacker's account in the first place. The boundary meant to prevent this, you may not touch suppliers you don't own, was already bypassed several steps upstream.&lt;/p&gt;

&lt;p&gt;For Dolibarr security, the important lesson is that protecting a payment workflow cannot stop at the final payment-generation step. The supplier data feeding that workflow needs the same object-level authorization controls as the payment action itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; brings the bank-detail write under the same ownership check the rest of the supplier record already enforced, so an account that cannot read a supplier can no longer quietly edit where that supplier gets paid.&lt;/p&gt;

&lt;p&gt;The lesson we wrote down is about following data, not just guarding entry points. A tampered field is only as harmless as the least-careful place it eventually flows to. Ask, for every value a low-privilege caller can change, where does this end up? This one ended up in the file that moves the money.&lt;/p&gt;

&lt;p&gt;That is also why effective application security testing needs to trace an attack beyond the first successful request. The question is not only whether an API lets an unauthorized change through. It is what that changed value can eventually control.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;→ Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://codeant.ai/security-research/cve-2026-71503-dolibarr-reflected-xss-admin-account-takeover" rel="noopener noreferrer"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>penetrationtesting</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>CVE-2026-71506 · The wrong key on the lock</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:10:19 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71506-the-wrong-key-on-the-lock-49kb</link>
      <guid>https://dev.to/codeant/cve-2026-71506-the-wrong-key-on-the-lock-49kb</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 6.5 · Wrong-permission authorization (CWE-863)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The button labelled “delete a draft invoice” is wired to the vault that holds recorded payments. Press it and money that was actually paid shows up as owed again.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a payment should need the "manage payments" right&lt;/strong&gt;. A specific permission for a specific role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But the route checks the "delete invoices" right instead&lt;/strong&gt;. A different key for a different job. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The attacker holds only "delete invoices"&lt;/strong&gt;. We dumped its real rights first to prove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete a real, recorded customer payment&lt;/strong&gt; with no check on which invoice it belongs to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The payment row simply vanishes&lt;/strong&gt;. An invoice's "paid" total is just the sum of its payments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fully-paid invoice flips back to "owed"&lt;/strong&gt;. Phantom debt on the books. &lt;strong&gt;→ corrupted accounts.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In an accounting system, a recorded payment is not a note you can casually erase. It is part of the ledger, the line that says this invoice was settled, this money arrived. Deleting one should be a careful, privileged act, gated behind the specific right to manage payments and handed only to the people who reconcile the books.&lt;/p&gt;

&lt;p&gt;This is the story of a route that guarded that destructive act with the &lt;em&gt;wrong key&lt;/em&gt;, and how an account that could only ever “delete invoices” ended up destroying recorded payments and writing phantom debt onto the books.&lt;/p&gt;

&lt;p&gt;The two permissions sound close enough that the mix-up is easy to make. “Delete invoices” and “manage payments” are different rights, handed to different roles for different reasons. The delete-payment route checked the first when it should have checked the second, a swapped lock on a door that leads somewhere it shouldn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wdj5j78sfxih4me5c6e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wdj5j78sfxih4me5c6e.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frmgduqceh5vsvktah520.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frmgduqceh5vsvktah520.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the account wasn't secretly privileged
&lt;/h2&gt;

&lt;p&gt;Deleting money is destructive, so before anything else we did the unglamorous, essential step: we dumped the test account's &lt;em&gt;actual&lt;/em&gt; rights and wrote them down. That matters, because the easiest way for a finding like this to be waved away is “well, your account probably had extra permissions.” It did not. It held the right to read and delete invoices, and nothing to do with payments at all.&lt;/p&gt;

&lt;p&gt;Then we pointed that account at a real, recorded customer payment and asked Dolibarr to delete it. Two questions should have stopped the request. The first, “do you hold the right to manage payments?”, was never asked; the route checked the invoice-deletion right instead. The second, “are you even allowed to touch the specific invoice this payment belongs to?”, was not asked either. The payment simply vanished.&lt;/p&gt;

&lt;p&gt;That distinction is at the heart of effective authorization testing. It is not enough to confirm that an API checks &lt;em&gt;a&lt;/em&gt; permission. Security testing has to establish that the application checks the permission that actually governs the requested action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the phantom debt comes from
&lt;/h2&gt;

&lt;p&gt;Here is the quietly nasty part. In Dolibarr, an invoice's “paid” figure is not stored as a fact of its own. It is the &lt;em&gt;sum of its payments&lt;/em&gt;. Remove one, and the sum drops, automatically, silently, with no separate record that anything was deleted.&lt;/p&gt;

&lt;p&gt;So a fully-paid invoice, the moment its payment is destroyed, flips back to showing an outstanding balance. On the screen, and in every report that reads from it, money that genuinely arrived now looks owed. Delete the payments across a batch of invoices and you have manufactured debt that never existed, on books that will be trusted by whoever reads them next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7n7yo5kxcoh1d926cmu9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7n7yo5kxcoh1d926cmu9.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it lands harder than it looks
&lt;/h2&gt;

&lt;p&gt;On paper this is a medium-severity bug, and in isolation that is fair. It does not hand anyone the keys to the system. But sit with what it does to a set of real books. Payments disappear with no audit trail. Paid invoices reopen. Reconciliation, the monthly ritual of matching what the bank says against what the system says, stops adding up, and the people doing it have no deleted-payment record to point at, because there isn't one.&lt;/p&gt;

&lt;p&gt;The proof we kept coming back to was almost mundane, and all the more convincing for it: watching an outstanding balance &lt;em&gt;climb&lt;/em&gt; on an invoice that had been paid in full, driven entirely by an account that was never supposed to be anywhere near the payment ledger.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of application security issue that can look harmless when reviewed only at the endpoint level. The permission check appears to be there, but it governs the wrong operation. A penetration test that exercises the action with deliberately mismatched privileges can expose that gap before it reaches production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; is exactly what the description implies: the delete-payment route now checks the right that actually governs payments, not the one that happens to sit nearby, and it verifies access to the specific record before acting.&lt;/p&gt;

&lt;p&gt;The general lesson is one every codebase can use. When a destructive action is guarded, check that the lock on it is the &lt;em&gt;right&lt;/em&gt; lock, that the permission being tested is the permission that governs the thing being done. A guard that checks a plausible-but-wrong right feels like security and is worse than none, because it looks solved.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? → &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>penetrationtesting</category>
      <category>opensource</category>
    </item>
    <item>
      <title>CVE-2026-71505 · The read desk checks your ID. The write desk doesn't.</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:07:33 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71505-the-read-desk-checks-your-id-the-write-desk-doesnt-597h</link>
      <guid>https://dev.to/codeant/cve-2026-71505-the-read-desk-checks-your-id-the-write-desk-doesnt-597h</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 8.1 · Broken object-level authorization (CWE-639)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The clerk who shows you a file first checks it's yours. The clerk who resets the password on that file never checks, so you can change the lock on a company you're not even allowed to look at.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Try to read a company you don't own&lt;/strong&gt;. Your low-privilege key is correctly refused. &lt;em&gt;(403 Forbidden)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call the write route instead&lt;/strong&gt;. The path that sets that company's customer-portal password. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only asks: can you create companies?&lt;/strong&gt; Never "is this company yours?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the portal password to one you choose&lt;/strong&gt;. The write lands on a company you can't even see. &lt;em&gt;(200 OK)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign into the customer portal as them&lt;/strong&gt; with the password you just set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read their invoices&lt;/strong&gt;. Records you were forbidden to read directly. &lt;strong&gt;→ another company's private billing.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Dolibarr can hand each of a company's customers a small self-service portal, a place to log in and see their own invoices. Which means Dolibarr is holding, on behalf of every one of those customers, the keys to a private account. The entire point of that arrangement is that one customer can never see another customer's invoices.&lt;/p&gt;

&lt;p&gt;This is the story of a low-privilege account that could not so much as &lt;em&gt;look&lt;/em&gt; at a company's record, and used a second, unguarded door to reset that company's portal password, log in as them, and read their invoices anyway.&lt;/p&gt;

&lt;p&gt;The account in question is deliberately weak. It can create companies; it cannot read the ones it does not own. When it tries to read the victim's record, Dolibarr does exactly the right thing and refuses. On the read side, the walls are solid. The mistake is on the &lt;em&gt;write&lt;/em&gt; side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw01x7f0na4n75x4dbjfm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw01x7f0na4n75x4dbjfm.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F939x3qae8f4t23ytam1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F939x3qae8f4t23ytam1w.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The door that checks, and the door that doesn't
&lt;/h2&gt;

&lt;p&gt;Before this finding we had already gone over the customer-portal &lt;em&gt;read&lt;/em&gt; paths, the ones that hand information back, and found them careful. Ask to read a company you do not own and you are turned away, per company, correctly. It would have been easy to tick the box and call the whole area safe.&lt;/p&gt;

&lt;p&gt;The discipline that paid off was refusing to assume the &lt;em&gt;write&lt;/em&gt; paths behaved the same way. Reading and writing are different doors, even when they lead to the same room, and they have to be tested separately. That distinction is central to good &lt;strong&gt;API security testing&lt;/strong&gt;: an authorization control on a read endpoint does not prove that the corresponding write endpoint enforces the same boundary.&lt;/p&gt;

&lt;p&gt;The write path in question sets a company's portal password. And it asks a much lazier question at the entrance. Instead of “are you allowed to touch &lt;em&gt;this&lt;/em&gt; company?”, it asks only “are you the kind of account that can create companies at all?” The attacker's account can create companies, so it sails through. It never has to prove the company is its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting the lock on a door you can't see
&lt;/h2&gt;

&lt;p&gt;So the attacker takes a company it was just told it may not read, and writes a brand-new portal password onto it. Dolibarr accepts the write. Then the attacker walks around to the customer portal, signs in as that company with the password it just set, and the invoices, the ones meant to be private to that customer forever, open up on screen.&lt;/p&gt;

&lt;p&gt;The read wall was never breached. It was simply walked around, through a write path that forgot to ask the same question.&lt;/p&gt;

&lt;p&gt;That is the defining shape of a broken object-level authorization (BOLA) vulnerability. The API knows that access should be restricted at the object level, but one route checks only a broad permission instead of whether the caller is authorized to access the specific company identified in the request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdlxuytfr6fg2vvcnt32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdlxuytfr6fg2vvcnt32.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape keeps happening
&lt;/h2&gt;

&lt;p&gt;This is one of the cleanest examples of the pattern that runs through most of our &lt;strong&gt;Dolibarr security research&lt;/strong&gt;. Enforcement on the way &lt;em&gt;in&lt;/em&gt; tells you nothing about the way &lt;em&gt;out&lt;/em&gt;. A team hardens the read paths, watches them hold, and reasonably assumes the feature is safe, while a sibling write path, added at a different time by a different hand, checks a weaker condition, or none at all.&lt;/p&gt;

&lt;p&gt;The impact is concrete and it compounds. Every company with a portal account is exposed, not just one. An attacker who can create companies, a low bar in many deployments, can reset portal passwords across the whole customer base and read each customer's private billing in turn. For a business whose customers trust it to keep their invoices to themselves, that is exactly the kind of quiet &lt;strong&gt;authorization vulnerability&lt;/strong&gt; that does lasting damage.&lt;/p&gt;

&lt;p&gt;This is also why BOLA belongs in &lt;strong&gt;API penetration testing&lt;/strong&gt; rather than being treated as a purely theoretical code issue. The important question is not only whether an endpoint contains an authorization check, but whether the check actually protects the specific object the request is trying to modify.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; makes the write path ask the same per-company ownership question its read sibling already asked, the same check, applied to both doors instead of one.&lt;/p&gt;

&lt;p&gt;The takeaway we wrote down afterwards is worth repeating: test read and write as separate doors, always. A boundary that is enforced in the place you happen to look first is not proof of a boundary. It is proof of one door.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0, that closes this finding. Want to know where your own app disagrees with itself? → &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>penetrationtesting</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-71504 · The membership desk that could reset the admin's password</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:05:51 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71504-the-membership-desk-that-could-reset-the-admins-password-1dn8</link>
      <guid>https://dev.to/codeant/cve-2026-71504-the-membership-desk-that-could-reset-the-admins-password-1dn8</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 8.3 · Mass assignment (CWE-915)&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The front desk refuses to change the CEO's password. But the membership clerk's window, which nobody thought to lock will happily do it, if you slide the very same form through it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The front door says no&lt;/strong&gt;: Ask the account interface to change the admin's password and it refuses. &lt;em&gt;(403 Forbidden)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send the identical change to the membership desk&lt;/strong&gt;: A completely different window into the same data. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It checks only one thing: can you add members?&lt;/strong&gt;: A routine permission that has nothing to do with login accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It accepts every field you sent&lt;/strong&gt;: Including two that quietly belong to the linked account: the target and the new password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point them at the administrator&lt;/strong&gt;: The admin's stored password is overwritten with one you chose. &lt;em&gt;(200 OK)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log in as admin with your password&lt;/strong&gt;: Same instance, same key, opposite answer. &lt;strong&gt;→ full takeover.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every serious application guards one rule more carefully than any other: who is allowed to change a password. Get that wrong and everything else, every permission, every record, sits downstream of a single mistake. Dolibarr guards it well. Ask its user-management screen to change the administrator's password without the right to do so, and it says no, flatly.&lt;/p&gt;

&lt;p&gt;This is the story of how the &lt;em&gt;same&lt;/em&gt; request, same instance, same key, same target, gets a yes simply by being posted through a different window.&lt;/p&gt;

&lt;p&gt;Dolibarr is the back office for a lot of small clubs, associations and companies. Alongside its login accounts, it has a separate feature for managing &lt;em&gt;members&lt;/em&gt;: the people who belong to a club or association. Different feature, different screen, different permission. The person at the membership desk is trusted to add and edit members all day, and that was meant to be the whole of what they could do.&lt;/p&gt;

&lt;p&gt;The bug is that the membership desk quietly has a spare key to the login accounts, and nobody noticed it was on the ring.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8ryw9ujhs8f1ezy53f2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8ryw9ujhs8f1ezy53f2.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cu5372gjrvnaez9532b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cu5372gjrvnaez9532b.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors into the same room
&lt;/h2&gt;

&lt;p&gt;The rule we applied over and over in this security research is simple: when one part of an app guards something, go and find every &lt;em&gt;other&lt;/em&gt; part that can touch the same thing, and check the guard is there too. Applications grow in layers, and the second door into a room is the one that gets forgotten.&lt;/p&gt;

&lt;p&gt;Dolibarr's account-management screen treats a password with the respect it deserves. Before it will change one, it checks that the caller actually holds the specific right to manage credentials. A membership key does not, so through that door the answer is a clean refusal.&lt;/p&gt;

&lt;p&gt;The members screen is a different door into a database that, it turns out, overlaps. A member record can be &lt;em&gt;linked&lt;/em&gt; to a login account. And when you create or edit a member, the members screen does something careless: it takes every field you sent it and writes it onto the record, no questions asked. It checks one thing at the entrance, “are you allowed to manage members?”, and then trusts everything that follows.&lt;/p&gt;

&lt;p&gt;From an application security perspective, this is the dangerous shape of a mass-assignment vulnerability: a legitimate write endpoint accepts fields that belong to a more privileged part of the application's data model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fields that don't belong
&lt;/h2&gt;

&lt;p&gt;Buried in that pile of accepted fields are two that were never really member fields at all: one that says &lt;em&gt;which login account this member is attached to&lt;/em&gt;, and one that says &lt;em&gt;what that account's password should be&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So the attacker fills in a member form, points the “attached account” field at the administrator, sets the “password” field to a string of their choosing, and posts it to the membership desk. The desk checks that the caller can manage members, they can, and then dutifully writes the new password onto the linked account. The linked account is the administrator.&lt;/p&gt;

&lt;p&gt;The same password change the front door refused a moment ago is now done, through a window that only ever asked whether you could file paperwork for a club.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ta5gmpe4pz7wkzw9fsi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ta5gmpe4pz7wkzw9fsi.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The instant that write lands, the real administrator is locked out. Their old password simply stops working. The attacker logs straight in with the password they picked. No cracking, no stolen session, no clever timing. One ordinary request from an account that holds nothing but membership permissions, and the whole system changes hands.&lt;/p&gt;

&lt;p&gt;There is one soft edge worth being precise about. If a second administrator already exists, recovery is easy. If not, getting back in means an out-of-band reset from the database or the command line, which is why the availability impact is scored as a disruption rather than total destruction. Confidentiality and integrity, though, are at the top of the scale, because by this point the attacker owns everything.&lt;/p&gt;

&lt;p&gt;And the permission that makes it all possible is one an administrator hands out without a second thought. “You can manage our members” sounds harmless. It is exactly the access you would give a volunteer registrar at a club. Dolibarr even lists Members and Users as separate, unrelated modules, so nobody granting the one imagines they are also granting the other. A leaked membership key, a compromised registrar, an over-trusted volunteer, any of them is now a path to full control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes it
&lt;/h2&gt;

&lt;p&gt;Dolibarr already had the correct behaviour written down in the account-management screen, which refuses credential fields outright and demands the password right before it will touch one. The fix in &lt;strong&gt;24.0.0&lt;/strong&gt; brings the members screen under the same discipline: sensitive fields are rejected before they are ever written, the password right is required, and the quiet sync onto the linked account no longer fires by default.&lt;/p&gt;

&lt;p&gt;The lesson outlives this one screen. An interface should never assume that every field a caller &lt;em&gt;can&lt;/em&gt; send is a field they should be &lt;em&gt;allowed to set&lt;/em&gt;. The durable fix is a short, explicit list of the fields each screen accepts, not a scramble to block each dangerous one after it is found. When one part of the code already gets a sensitive check right, the next part that touches the same data is not a fresh design problem. It is a copy you owe yourself.&lt;/p&gt;

&lt;p&gt;That is also why API security testing needs to look beyond whether an endpoint has an authorization check. A permission can exist and still be applied to the wrong data, the wrong field, or the wrong route. Testing those alternate paths is what turns a code-level suspicion into a validated security vulnerability.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0. That closes this finding. Want to know where your own app disagrees with itself? → &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>penetrationtesting</category>
      <category>opensource</category>
    </item>
    <item>
      <title>CVE-2026-71503 · One link, opened once, mints a second admin</title>
      <dc:creator>Amartya Jha</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:02:37 +0000</pubDate>
      <link>https://dev.to/codeant/cve-2026-71503-one-link-opened-once-mints-a-second-admin-og7</link>
      <guid>https://dev.to/codeant/cve-2026-71503-one-link-opened-once-mints-a-second-admin-og7</guid>
      <description>&lt;p&gt;&lt;strong&gt;CVSS 9.3 · Reflected XSS (CWE-79), chained to admin creation&lt;/strong&gt; · Fixed in Dolibarr 24.0.0&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Think of a contractor's badge that, when scanned at reception, silently prints a second master key, and the guard hands it over, because the request came from inside the building.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attack in six steps&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A link that looks like ordinary work&lt;/strong&gt;: A URL pointing at a routine admin setup page, with a tiny instruction hidden in the web address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An admin opens it&lt;/strong&gt;: It arrives by email, ticket or chat and looks like an internal settings page; nothing on screen changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page runs the hidden instruction&lt;/strong&gt;: It drops that address value straight into its own scripting, missing one safety step, so the attacker's snippet runs as code. &lt;em&gt;(this is the break)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The script reads the page's own security token&lt;/strong&gt;: The anti-forgery token Dolibarr prints into every page for itself; the injected script just reads it, no theft required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It asks Dolibarr to create an admin&lt;/strong&gt;: Using that token and the admin's own logged-in session, a create-user request that looks completely legitimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second administrator now exists&lt;/strong&gt;: With a password and API key the attacker chose, surviving after the browser closes. &lt;strong&gt;→ full admin.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Dolibarr is the quiet software at the centre of a lot of small companies. It holds the invoices, the customer list, the supplier bank details, the payroll, the whole back office. The person who administers it can do anything: add users, change settings, read every record. So the single most valuable thing in the entire system is the administrator's account.&lt;/p&gt;

&lt;p&gt;This is the story of how an attacker walks away with a &lt;strong&gt;second&lt;/strong&gt; administrator account, one Dolibarr never knew it was creating, from a single link, opened once, by an admin who was only doing their job.&lt;/p&gt;

&lt;p&gt;The link looks utterly ordinary. It points at an internal setup page, the kind of screen an admin opens a dozen times a week to add a custom field. Nothing on that page looks wrong. Nothing flashes. The admin glances at it and moves on. And in that glance, a new administrator quietly appears in the database, with a password and an API key the attacker chose in advance.&lt;/p&gt;

&lt;p&gt;Here is the part we love about this one: the &lt;em&gt;obvious&lt;/em&gt; version of the attack failed, and that failure is exactly what made the dangerous version possible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwbxdz6gys8gtdeg0k74.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwbxdz6gys8gtdeg0k74.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljnxn3okxfhlsy1lsyxj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljnxn3okxfhlsy1lsyxj.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The reflex that didn't work
&lt;/h2&gt;

&lt;p&gt;When you find a bug that lets you run a snippet of code on someone else's page, the first instinct is boring and predictable: steal the login cookie, become that user. We tried. It came back empty.&lt;/p&gt;

&lt;p&gt;Dolibarr, sensibly, hides its session cookie from scripts, a setting that tells the browser "code running on this page may not read this." So the textbook move was dead on arrival. On most days, that is where the story ends and the bug gets filed as low severity: yes, you can run a script, but there is nothing here worth taking.&lt;/p&gt;

&lt;p&gt;From an application security testing perspective, this is an important distinction. Finding reflected XSS does not automatically tell you what the vulnerability can achieve. The real question is what security-sensitive actions the injected code can perform within the victim's authenticated session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment it turns
&lt;/h2&gt;

&lt;p&gt;Except there was something worth using, not &lt;em&gt;stealing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;To protect its own forms, Dolibarr prints a small security token into every page. It is an anti-forgery measure: the app's own buttons read that token and send it back, so the server can tell a genuine click from a forged one. It is a good defence. But a token printed into the page for the page's own scripts to read is, by definition, readable by &lt;em&gt;any&lt;/em&gt; script on the page, including the one the attacker just slipped in.&lt;/p&gt;

&lt;p&gt;So the injected script does not need to steal anything. It reads the token sitting right there in the page, and then it does the single thing that token unlocks: it asks Dolibarr to create a new user, as the admin, from the admin's own logged-in browser. To the server it looks like the administrator calmly filling in the "add user" form. The browser attaches the admin's session automatically, because the request is going to the very same site it came from. The request came from inside the building, so the guard waves it through.&lt;/p&gt;

&lt;p&gt;And no alarm sounds, for a mundane reason: a fresh Dolibarr install does not switch on the browser-level protection that would have refused to run the injected script in the first place. It is off by default, so nothing stands in the way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffly0otkqjo2av5824vc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffly0otkqjo2av5824vc7.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the attacker walks away with
&lt;/h2&gt;

&lt;p&gt;The important thing about that new account is what it &lt;em&gt;isn't&lt;/em&gt;. It is not a hijacked browser tab that vanishes when the admin logs out. It is a real administrator, a real row in the database, with a password the attacker chose and an API key the attacker chose. It stays there after the browser closes, after the laptop sleeps, after the admin goes home.&lt;/p&gt;

&lt;p&gt;That one detail changes the entire clean-up. Picture the team noticing something odd and doing the sensible thing: reset the admin's password. It does not help. The attacker was never using the admin's password, they made their own account. And the API key on that account keeps working quietly in the background, reading the whole user directory through Dolibarr's own interface, long after everyone assumes the incident is closed.&lt;/p&gt;

&lt;p&gt;There is a limit worth being honest about: some records, specific invoices, customers, payroll, stay behind Dolibarr's normal permission checks, which have no special "admins skip this" bypass. But a full administrator can grant &lt;em&gt;itself&lt;/em&gt; any permission it likes with one more request, because simply being marked an administrator is enough to unlock that. So the limit is a speed bump, not a wall.&lt;/p&gt;

&lt;p&gt;And it is not about one page or one feature. The vulnerable screen is built from a template Dolibarr reuses across &lt;strong&gt;80 different pages&lt;/strong&gt;. We confirmed the same trick on the Products, Agenda and Third-parties setup screens. Turning off one module only moves the unlocked door somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was there, and what closes it
&lt;/h2&gt;

&lt;p&gt;The frustrating part is that Dolibarr already owned the tool that would have stopped this cold. It has a function whose entire job is to make a value safe to drop into a script, and it is used correctly elsewhere in the same codebase. This one screen simply never called it. The value from the web address was &lt;em&gt;filtered&lt;/em&gt; on the way in, but filtering an input for a few bad characters is not the same as safely escaping it on the way out. One missed step, on one line, behind 80 pages.&lt;/p&gt;

&lt;p&gt;Dolibarr &lt;strong&gt;24.0.0&lt;/strong&gt; fixes it: the value now passes through the proper escaper before it ever reaches the page, and because the fix lives in the shared template, all 80 pages are protected at once. If you run an affected build, upgrading is the whole answer.&lt;/p&gt;

&lt;p&gt;This finding is the odd one out in our Dolibarr research. The rest of that work is about permission checks that quietly go missing between two different doors into the same room. This one is an encoding mistake, not an access-control one, yet it earns its place for the same underlying reason: the safe thing already existed, one function call away, and a single screen skipped it.&lt;/p&gt;

&lt;p&gt;For vulnerability research and penetration testing, that distinction matters. A source-code review can identify the unsafe sink, but testing the running application shows whether the flaw can actually be chained into a meaningful security impact.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Upgrade to Dolibarr 24.0.0, that closes this finding. Want to know where your own app disagrees with itself? → &lt;a href="https://codeant.ai/pentesting" rel="noopener noreferrer"&gt;Start with a free CodeAnt pentest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/codeant/the-back-office-break-in-nine-dolibarr-findings-5cp8"&gt;← Back to all nine findings&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>ai</category>
      <category>security</category>
      <category>penetrationtesting</category>
    </item>
  </channel>
</rss>
