<?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: Daniel</title>
    <description>The latest articles on DEV Community by Daniel (@f00dat).</description>
    <link>https://dev.to/f00dat</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%2F4061021%2F02eedf53-d6bf-4cff-905e-e7c1b375f7bb.png</url>
      <title>DEV Community: Daniel</title>
      <link>https://dev.to/f00dat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/f00dat"/>
    <language>en</language>
    <item>
      <title>VOLOSC 194: How an Accepted Direct Theft Primitive Ended as a USD 500 Medium</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:50:45 +0000</pubDate>
      <link>https://dev.to/f00dat/volosc-194-how-an-accepted-direct-theft-primitive-ended-as-a-usd-500-medium-39am</link>
      <guid>https://dev.to/f00dat/volosc-194-how-an-accepted-direct-theft-primitive-ended-as-a-usd-500-medium-39am</guid>
      <description>&lt;h2&gt;
  
  
  A technical account of a direct theft primitive, 151 days of review, and the incentive problem behind responsible disclosure in Web3
&lt;/h2&gt;

&lt;p&gt;On 27 March 2026, at 00:05, I submitted &lt;code&gt;VOLOSC 194&lt;/code&gt; to the Volo Smart Contracts bug bounty program through HackenProof. The report was titled &lt;strong&gt;Curator valuation updater can overwrite the principal slot and reopen direct NAV manipulation&lt;/strong&gt;, and I submitted it as Critical because the issue did not stop at an incorrect number inside the vault. The proof of concept showed a complete accounting path in which an ordinary caller could redirect a legitimately approved curator value into the vault principal slot, inflate NAV, inflate the share ratio, and ultimately cause the normal withdrawal logic to pay substantially more real principal for the same number of shares.&lt;/p&gt;

&lt;p&gt;The numbers in the proof were deliberately simple. In the honest control path, &lt;code&gt;100000000000&lt;/code&gt; shares returned &lt;code&gt;100000000000&lt;/code&gt; units of principal. In the exploit path, the same &lt;code&gt;100000000000&lt;/code&gt; shares returned &lt;code&gt;1100000000000&lt;/code&gt;. The vault ended with &lt;code&gt;free_principal = 0&lt;/code&gt;. That was the reason I did not view the issue as a theoretical accounting discrepancy. The corrupted value was consumed by a real withdrawal calculation and reached a real balance split.&lt;/p&gt;

&lt;p&gt;The first review response recognized essentially the same root cause and consequence. The vulnerable code was later fixed, and months afterward the final HackenProof triage comment described the report as an &lt;strong&gt;accepted direct theft primitive&lt;/strong&gt;. Even so, after 150 days, 23 hours, and 40 minutes of review, the report ended as Medium.&lt;/p&gt;

&lt;p&gt;What makes this case interesting is not simply that I disagreed with the final severity. The technical facts changed as the review progressed. HackenProof eventually produced an onchain chronology showing that the vulnerable package had been fixed shortly before the first curator position was created. I also had to correct one of my own later conclusions about version 10. By the end, the disagreement was no longer about whether the code defect existed. It had become a much narrower question about how severity should be measured when vulnerable code is already deployed, but responsible disclosure allows the team to fix it before the final legitimate state required for exploitation becomes active.&lt;/p&gt;

&lt;p&gt;That is the question I want to document here, together with the complete technical path and the evidence that led both sides to their final positions.&lt;/p&gt;

&lt;p&gt;To keep the article readable, I am reproducing only four screenshots from the larger evidence archive. The technical and chronological details below still reflect the complete record I reviewed, but the images are limited to the points that are most useful for a reader following the dispute from beginning to end.&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%2Fgz34sbydf6r66q0b8yja.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%2Fgz34sbydf6r66q0b8yja.png" alt="Initial VOLOSC 194 report state showing Critical severity and Triaged status" width="797" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Recorded result&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VOLOSC 194&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;Volo Smart Contracts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;HackenProof&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Category&lt;/td&gt;
&lt;td&gt;Business Logic Errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submitted&lt;/td&gt;
&lt;td&gt;27 March 2026 at 00:05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submitted severity&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final severity&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first review response&lt;/td&gt;
&lt;td&gt;1 day, 9 hours, and 34 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time from submission to Medium decision&lt;/td&gt;
&lt;td&gt;150 days, 23 hours, and 40 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before going into the code, there is one communication detail worth clarifying. The screenshots preserve two different channels. At several points, an account identified by the HackenProof interface as &lt;code&gt;company admin&lt;/code&gt; responded directly inside the report, mainly on technical and operational questions. At other points, especially around goodwill, severity, payout, escalation, and the final decision, the messages came from HackenProof triage. I keep those roles separate throughout the article because attributing every statement to the same party would make the chronology less accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the vulnerability lived
&lt;/h2&gt;

&lt;p&gt;The flaw was inside &lt;code&gt;update_curator_position_value&lt;/code&gt;. The function existed to take a curator position value that had already gone through the intended curator process and update the value stored by the vault. The dangerous part was not the existence of the curator value itself. The dangerous part was that the function accepted an &lt;code&gt;asset_type&lt;/code&gt; string from the caller and never proved that this string represented the curator position being updated.&lt;/p&gt;

&lt;p&gt;That detail matters because the principal coin type already exists as an accounting slot when the vault is created. The report highlighted the initialization that registers the principal asset inside the vault:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vault.set_new_asset_type(
    type_name::get&amp;lt;PrincipalCoinType&amp;gt;().into_string()
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curator position is also paired with a vault before it later becomes a supported vault asset. The relevant creation path stores that relationship and initializes the value record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self.curator_position_to_vault.add(
    id_address,
    vault_id
);

self.curator_position_values.add(
    id_address,
    CuratorPositionValue {
        position_value: 0,
        position_value_updated: 0,
        position_value_valid_time: 0,
        valid: false,
    },
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction became important in the proof of concept. The curator position could already be paired with the vault while the curator asset was still not a normal supported vault asset. The attack therefore did not require adding the curator position as a supported asset first and then corrupting its own value. The state confusion happened through the destination selected by the public updater.&lt;/p&gt;

&lt;p&gt;The value used by the exploit also did not need to be fabricated. A curator could submit a legitimate value through the intended privileged path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self.assert_valid_curator_cap(
    curator_position_id,
    curator_cap.id
);

self.update_position_value(
    curator_position_id,
    position_value,
    now,
    valid_time
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An operator could then validate that value normally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (approved) {
    curator_position_value.valid = true;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker comes after those legitimate steps. That distinction is one of the reasons I considered the bug serious: the exploit did not require compromising the curator, forging an approval, or obtaining one of the privileged capability objects. It reused a valid approved value and abused where the public function allowed that value to be written.&lt;/p&gt;

&lt;p&gt;The vulnerable updater looked like this in the relevant path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public fun update_curator_position_value&amp;lt;PrincipalCoinType&amp;gt;(
    self: &amp;amp;mut CuratorConfig,
    vault: &amp;amp;mut Vault&amp;lt;PrincipalCoinType&amp;gt;,
    asset_type: String,
    curator_position_id: address,
    oracle_config: &amp;amp;OracleConfig,
    clock: &amp;amp;Clock,
) {
    let now = clock.timestamp_ms();

    assert!(
        self.curator_position_to_vault[curator_position_id]
            == vault.vault_id(),
        ERR_CURATOR_POSITION_NOT_PAIRED_WITH_VAULT
    );

    self.assert_valid_curator_position_value(
        curator_position_id,
        now
    );

    let principal_based_position_value =
        self.position_value(curator_position_id);

    let principal_price =
        vault_oracle::get_normalized_asset_price(
            oracle_config,
            clock,
            type_name::with_defining_ids&amp;lt;PrincipalCoinType&amp;gt;()
                .into_string(),
        );

    let position_value =
        vault_utils::mul_with_oracle_price(
            principal_based_position_value,
            principal_price,
        );

    vault.finish_update_asset_value(
        asset_type,
        position_value,
        now
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function correctly checked that the curator position belonged to the supplied vault, but that check protected a different invariant from the one I reported. It prevented a curator position paired to one vault from simply being used against another vault. It did not prove that the caller supplied &lt;code&gt;asset_type&lt;/code&gt; represented the curator position inside the correct vault.&lt;/p&gt;

&lt;p&gt;That is why I described the root cause as same vault slot confusion rather than cross vault access. The caller could be operating on the correct vault and still select the wrong registered accounting slot.&lt;/p&gt;

&lt;p&gt;The destination string then flowed directly into the generic storage write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun finish_update_asset_value&amp;lt;PrincipalCoinType&amp;gt;(
    self: &amp;amp;mut Vault&amp;lt;PrincipalCoinType&amp;gt;,
    asset_type: String,
    usd_value: u256,
    now: u64,
) {
    let last_update_time =
        &amp;amp;mut self.assets_value_updated[asset_type];

    *last_update_time = now;

    let position_value =
        &amp;amp;mut self.assets_value[asset_type];

    *position_value = usd_value;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once execution reached this function with the principal asset type, the stored principal valuation was replaced. The real principal balance did not increase with it, so the vault accounting could now claim a principal value that was much larger than the actual backing.&lt;/p&gt;

&lt;p&gt;There was another detail in the codebase that made the missing check particularly notable. A separate coin asset updater already prevented the principal coin type from going through the generic asset valuation path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    type_name::get&amp;lt;CoinType&amp;gt;()
        != type_name::get&amp;lt;PrincipalCoinType&amp;gt;(),
    ERR_INVALID_COIN_ASSET_TYPE,
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, the codebase already recognized that the principal slot should not be treated like an ordinary generic asset slot. The curator updater reopened a path to that same storage without enforcing the equivalent identity constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an accounting overwrite became a withdrawal of real principal
&lt;/h2&gt;

&lt;p&gt;The next question was whether corrupting the stored principal value actually mattered financially. In this case, the value did not remain isolated. The vault aggregated stored asset values through &lt;code&gt;get_total_usd_value&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self.asset_types.do_ref!(|asset_type| {
    let usd_value =
        *self.assets_value.borrow(*asset_type);

    total_usd_value =
        total_usd_value + usd_value;
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That total then fed into &lt;code&gt;get_share_ratio&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let total_usd_value =
    self.get_total_usd_value(clock);

let share_ratio =
    vault_utils::div_d(
        total_usd_value,
        self.total_shares
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The withdrawal path consumed the resulting ratio to calculate the amount of principal associated with the shares being withdrawn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let ratio =
    self.get_share_ratio(clock);

let usd_value_to_withdraw =
    vault_utils::mul_d(
        shares_to_withdraw,
        ratio
    );

let amount_to_withdraw =
    vault_utils::div_with_oracle_price(
        usd_value_to_withdraw,
        vault_oracle::get_normalized_asset_price(
            config,
            clock,
            type_name::get&amp;lt;PrincipalCoinType&amp;gt;()
                .into_string(),
        ),
    ) as u64;

assert!(
    amount_to_withdraw
        &amp;lt;= self.free_principal.value(),
    ERR_NO_FREE_PRINCIPAL
);

let mut withdraw_balance =
    self.free_principal.split(
        amount_to_withdraw
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That created a direct chain from the public corruption primitive to a real balance movement. The approved curator value could be written into the principal accounting slot, the forged value could inflate total NAV, the inflated NAV could increase the share ratio, and the withdrawal calculation could then split more actual principal from &lt;code&gt;free_principal&lt;/code&gt; than the same shares should receive.&lt;/p&gt;

&lt;p&gt;The proof of concept made that difference visible by running an honest control and the exploit from equivalent state:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Honest control&lt;/th&gt;
&lt;th&gt;Exploit path&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free principal before withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Principal slot before overwrite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Principal slot before withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;12100000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share ratio before withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;11000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shares withdrawn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Principal payout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100000000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1100000000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free principal after exploit&lt;/td&gt;
&lt;td&gt;Residual backing remains&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same number of shares therefore received eleven times more principal in the exploit path. The Move test completed successfully with one test passed and zero failures. The curator value had been legitimately approved, the corruption step was performed by an ordinary caller, and the financial consequence appeared when the normal withdrawal path trusted the manipulated share ratio.&lt;/p&gt;

&lt;p&gt;That is the technical basis on which I submitted the report as Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  From technical validation to a dispute over prior knowledge
&lt;/h2&gt;

&lt;p&gt;The report received its first review response on 28 March at 09:39, 1 day, 9 hours, and 34 minutes after submission. The account visible in the report recognized the core issue and described essentially the same sequence: arbitrary asset type selection, generic asset value storage, principal slot overwrite, inflated total USD value, inflated share ratio, and drainage of &lt;code&gt;free_principal&lt;/code&gt; through a normal withdrawal. It also referenced the honest and exploit payout difference from the proof of concept.&lt;/p&gt;

&lt;p&gt;At that point, the main technical primitive was understood. The first major dispute appeared on 6 April at 16:30, when a participant identified by the interface as &lt;code&gt;company admin&lt;/code&gt; responded directly. The message said the report was a valid issue but claimed that a similar problem had already been received from an audit firm. A screenshot was attached showing another reviewer questioning the same public function and the ability to associate a curator position value with another asset type.&lt;/p&gt;

&lt;p&gt;The screenshot was technically relevant, but it did not visibly establish the chronology required for a duplicate determination. It did not show the audit firm name, a report date, a finding identifier, an affected version range, or other metadata that would prove the material predated my 27 March submission. I therefore asked for objective prior discovery evidence rather than treating the screenshot alone as conclusive.&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%2F36mg6ikpd1rgo24ewkch.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%2F36mg6ikpd1rgo24ewkch.png" alt="Prior audit screenshot supplied during the duplicate discussion" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the same time, I separated the duplicate question from another issue that would eventually become far more important: production reachability. If the report was not going to be treated as a proven duplicate, what exact production condition prevented the demonstrated path from being reachable when I submitted it?&lt;/p&gt;

&lt;p&gt;That question changed the direction of the review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The argument moved to production reachability
&lt;/h2&gt;

&lt;p&gt;On 16 April at 00:29, the &lt;code&gt;company admin&lt;/code&gt; account responded again. The protocol side argued that the attack was not feasible because creating the curator capability required an administrator, because a curator position could only affect the vault to which it was paired, and because an operator remained involved in the withdrawal process.&lt;/p&gt;

&lt;p&gt;I disagreed with the way those points were being applied to the reported root cause. The same vault pairing assertion did not prevent the caller from choosing the wrong slot inside that same vault, which was the actual issue. I also did not see the privileged curator and operator steps as equivalent to an attacker needing those privileges. The curator could approve a legitimate value, and the operator could execute the protocol's normal withdrawal flow. The malicious state corruption itself still happened through a public updater that did not require &lt;code&gt;AdminCap&lt;/code&gt;, &lt;code&gt;OperatorCap&lt;/code&gt;, or &lt;code&gt;CuratorCap&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On 18 April at 02:26, HackenProof triage made an important clarification. It said the available audit material was not sufficient by itself to conclusively prove prior discovery of the same root cause and exploit path before my submission, so the review would not rely on duplicate classification on that evidence alone. The report was described as a meaningful code level issue, but production reachability remained the central concern. HackenProof triage also communicated that the protocol team wanted to provide a goodwill payment while the severity dispute remained unresolved.&lt;/p&gt;

&lt;p&gt;I did not consider that goodwill payment a resolution of the severity question. I asked instead for the concrete onchain state that prevented the exploit path from being reachable during the vulnerable deployment window.&lt;/p&gt;

&lt;h2&gt;
  
  
  A separate security incident, and why I did not attribute it to this bug
&lt;/h2&gt;

&lt;p&gt;On 21 April, while the report was still under discussion, Volo publicly disclosed a separate security incident involving approximately USD 3.5 million in WBTC, XAUm, and USDC removed from Volo Vaults. The protocol said the affected vaults were frozen and that it was prepared to absorb the loss. A later update said approximately half a million dollars associated with the incident had been frozen, and NAVI Protocol also announced a precautionary pause while reviewing the situation.&lt;/p&gt;

&lt;p&gt;I added those public statements to the report because the discussion at the time was still focused on whether the reported issue could be practically relevant in production. I did not claim that the April incident used &lt;code&gt;VOLOSC 194&lt;/code&gt;, and I do not make that claim now. The later onchain chronology established that the principal overwrite path from my report had already been closed on 3 April, before the first curator position existed. Without transaction level evidence connecting the April incident to the same root cause, attributing it to this finding would go beyond the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence that finally narrowed the dispute
&lt;/h2&gt;

&lt;p&gt;After my 21 April update, the report became quiet. I followed up again on 5 May at 12:15. The next substantive technical response visible in the evidence did not arrive until 12 August at 17:43, which was another 99 days, 5 hours, and 28 minutes later. Measured from my 18 April request for concrete production state evidence, the detailed answer took 116 days, 9 hours, and 23 minutes.&lt;/p&gt;

&lt;p&gt;That August response was important because it finally supplied the onchain history needed to answer the reachability question. The vulnerable package was identified as:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0x7a663d394fa9ea6bac32a860957014ec951730f79f3fa1ec73d261463f13b1b1&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It had been deployed on 2 March 2026 at 07:24 UTC. My report was submitted on 27 March at 00:05 in the interface timezone, which means the vulnerable package had already been live for 24 days, 19 hours, and 41 minutes before submission after timezone normalization.&lt;/p&gt;

&lt;p&gt;The corrected package was:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0x8d9b38f82fcfc70a869eac1f7cefa871e9f22360aab94224f6bf751c1b9d7a2b&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It was deployed on 3 April at 04:18:06 UTC. The first curator position was then created at 05:25:56 UTC, the first curator value was submitted at 07:09:12 UTC, and the first approved curator value appeared at 07:11:31 UTC.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;UTC time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 04:18:06&lt;/td&gt;
&lt;td&gt;Corrected package deployed&lt;/td&gt;
&lt;td&gt;The reported caller selected principal overwrite path was closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 05:25:56&lt;/td&gt;
&lt;td&gt;First curator position created&lt;/td&gt;
&lt;td&gt;The first curator position required by the path appears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 05:55:02&lt;/td&gt;
&lt;td&gt;Curator cap added&lt;/td&gt;
&lt;td&gt;Additional curator configuration occurs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 06:49:55&lt;/td&gt;
&lt;td&gt;Curator position added as DeFi asset&lt;/td&gt;
&lt;td&gt;The position becomes a supported vault asset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 07:09:12&lt;/td&gt;
&lt;td&gt;First curator value submitted&lt;/td&gt;
&lt;td&gt;A value enters the intended curator flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 07:11:31&lt;/td&gt;
&lt;td&gt;First curator value approved&lt;/td&gt;
&lt;td&gt;The approved value used by the proof of concept now exists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The corrected package therefore preceded the first curator position by 1 hour, 7 minutes, and 50 seconds. It preceded the first approved curator value by 2 hours, 53 minutes, and 25 seconds.&lt;/p&gt;

&lt;p&gt;This is the strongest fact supporting HackenProof's eventual downgrade. The vulnerable package and the legitimate curator state required by the proof of concept did not coexist on mainnet. Before a curator position was registered, the required &lt;code&gt;curator_position_to_vault&lt;/code&gt; lookup could not satisfy the attack path.&lt;/p&gt;

&lt;p&gt;I accept that chronology.&lt;/p&gt;

&lt;p&gt;The same August review also corrected something I had previously gotten wrong. During my own follow up investigation on 3 April and 4 April, I concluded that version 10 still preserved the original vulnerability. That conclusion was incorrect. The later source and bytecode analysis showed that the new validation was an equality allowlist that bound &lt;code&gt;asset_type&lt;/code&gt; to the curator asset type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let curator_asset_type =
    vault_utils::parse_key&amp;lt;CuratorPosition&amp;gt;(0);

assert!(
    self.curator_position_to_vault[curator_position_id]
        == vault.vault_id(),
    ERR_CURATOR_POSITION_NOT_PAIRED_WITH_VAULT
);

assert!(
    asset_type == curator_asset_type,
    ERR_INVALID_CURATOR_ASSET_TYPE
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing the principal coin type used by the original proof of concept now aborts with &lt;code&gt;ERR_INVALID_CURATOR_ASSET_TYPE&lt;/code&gt;, value &lt;code&gt;8013&lt;/code&gt;, before the write can occur. The later bytecode comparison also showed that version 10 and the subsequent package contained the same corrected curator module.&lt;/p&gt;

&lt;p&gt;I accepted that correction as well. It means the approximately USD 25.7 million in publicly displayed vault TVL that I mapped during my interim analysis cannot be presented as capital that remained exposed to this specific path after version 10. That was an error in my follow up analysis, and a complete public account should say so plainly.&lt;/p&gt;

&lt;p&gt;By this point, most of the factual disagreement had disappeared. The earlier deployed code contained the defect. Version 10 fixed it. The fix arrived before the first curator position. Had the curator feature been active under the vulnerable package, the demonstrated path would have been reachable. The question left was how those facts should translate into severity.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Informational to an independent escalation
&lt;/h2&gt;

&lt;p&gt;On 13 August at 02:22, HackenProof triage communicated that the report would remain Informational. The reasoning was that the required curator state did not exist before the corrected package was deployed, so the reported path had not been reachable against live funds during the relevant production window. The payment being discussed at that stage was described as discretionary recognition for the usefulness and quality of the report rather than as a normal severity based bounty.&lt;/p&gt;

&lt;p&gt;That sequence mattered to me because the payment had been presented as goodwill while the report itself was considered Informational. I opened a HackenProof support ticket and asked for an independent review. My question was not simply whether the payment could be increased. I wanted to understand the rule being applied. Specifically, I asked what published criterion applicable on 27 March made a valid vulnerability in deployed, in scope code Informational because a legitimate prerequisite protocol state had not yet been instantiated before the vulnerability was fixed.&lt;/p&gt;

&lt;p&gt;The support discussion continued through August. I repeatedly separated the payment question from the severity question because the central issue was not whether the bounty amount was a nice gesture. It was whether the report qualified for a normal bounty tier and, if so, how the demonstrated impact should be classified.&lt;/p&gt;

&lt;p&gt;On 24 August at 23:45, HackenProof triage said the matter had been reviewed with the project team and that the report would be reclassified as Medium. The previously discussed payment would be retained and processed. From the original submission to that message, 150 days, 23 hours, and 40 minutes had passed.&lt;/p&gt;

&lt;p&gt;I appreciated that the Informational classification had been withdrawn, but the technical question was still open. I asked why the final tier was Medium rather than High or Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final technical basis for Medium
&lt;/h2&gt;

&lt;p&gt;On 26 August at 08:39, HackenProof finally gave the most precise explanation of the severity decision. The response said the program considered three dimensions together: caller privilege for the corruption step, reachability at the time of submission, and the structure of the monetization path.&lt;/p&gt;

&lt;p&gt;On caller privilege, HackenProof agreed with an important part of the original report. &lt;code&gt;update_curator_position_value&lt;/code&gt; could be reached without &lt;code&gt;AdminCap&lt;/code&gt;, &lt;code&gt;OperatorCap&lt;/code&gt;, or &lt;code&gt;CuratorCap&lt;/code&gt;, which meant the state corruption itself was unprivileged. The response explicitly said this was what placed the report in a bounty tier at all.&lt;/p&gt;

&lt;p&gt;The decisive issue was reachability. HackenProof relied on the August chronology and noted that the vulnerable code and an activated curator position with an approved value never coexisted in production. In its explanation, Critical was calibrated to a demonstrated path against live funds at the time of submission, while High required a reachable path with material fund loss. Because the curator state required by the proof of concept did not exist during the vulnerable deployment window, HackenProof treated that gap as preventing promotion into either tier.&lt;/p&gt;

&lt;p&gt;The third factor was monetization structure. HackenProof also argued that even in a reachable state, the transfer from corrupted NAV to the attacker did not complete in one entirely unprivileged call. &lt;code&gt;execute_withdraw&lt;/code&gt; was operator gated, and the response also referenced the &lt;code&gt;MAX_UPDATE_INTERVAL = 0&lt;/code&gt; freshness condition. In their view, that was a separate structural constraint that further prevented the finding from clearing High.&lt;/p&gt;

&lt;p&gt;This was the first time the final Medium classification had been explained with that level of specificity, and it allowed the remaining disagreement to be stated much more precisely.&lt;/p&gt;

&lt;h2&gt;
  
  
  I asked where those requirements were written
&lt;/h2&gt;

&lt;p&gt;After receiving that explanation, I reviewed the publicly available Volo Smart Contracts rules and HackenProof's public Smart Contract Vulnerability Classification. I could find broad impact categories covering direct financial loss, unauthorized fund access, unauthorized extraction capable of draining protocol treasury or user deposits, direct theft, and protocol insolvency. I could also find a general statement that privileged actions may justify a downgrade.&lt;/p&gt;

&lt;p&gt;What I could not find was an express rule saying that Critical required the complete exploit path to be reachable against live funds at the exact moment of submission, or that High or Critical required a fully unprivileged single caller monetization primitive. I therefore asked HackenProof to identify the specific publicly applicable rule containing those requirements, or to clarify whether they were part of the platform's internal severity methodology.&lt;/p&gt;

&lt;p&gt;HackenProof responded at 10:30 on 26 August. The answer did not point to a separate rule containing the exact wording I had requested. Instead, it explained that this was standard smart contract triage practice and not a novel or retrospective methodology. According to the response, severity is tied to demonstrated impact, and impact is evaluated against what the exploit chain can actually accomplish against protocol state at the time of submission rather than against a state that had not yet been instantiated.&lt;/p&gt;

&lt;p&gt;The most important sentence in that response was that the &lt;strong&gt;reachability gap was dispositive for tier calibration&lt;/strong&gt;. That clarified the hierarchy of the final reasoning. Operator gated settlement remained a secondary constraint, but even without relying on it, HackenProof considered the production reachability gap enough to prevent High or Critical.&lt;/p&gt;

&lt;p&gt;The determination was described as having been made at senior review level. HackenProof also acknowledged that reasonable disagreement was possible about how much weight should be placed on the reachability gap compared with the code level validity of the primitive. That is where the private severity dispute effectively ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  The phrase that defined the final outcome
&lt;/h2&gt;

&lt;p&gt;At 10:32, HackenProof triage added a final report comment after further review with the project team. That comment confirmed the core root cause in direct terms: the caller supplied &lt;code&gt;asset_type&lt;/code&gt; was not bound to the passed &lt;code&gt;curator_position_id&lt;/code&gt;, so an approved curator value could be written into another registered &lt;code&gt;assets_value&lt;/code&gt; slot, including the vault principal slot. It also confirmed that the version 10 remediation directly closed the reported path and matched the type of fix proposed in the report.&lt;/p&gt;

&lt;p&gt;The comment confirmed Medium severity and described the finding as an &lt;strong&gt;accepted direct theft primitive&lt;/strong&gt; whose classification remained constrained by the production reachability gap.&lt;/p&gt;

&lt;p&gt;For me, that phrase captures the entire case. By the end of the review, the disagreement was not whether the primitive could produce direct theft under the demonstrated state. It was whether the absence of that legitimate state during the vulnerable production window should prevent the demonstrated impact from controlling the severity.&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%2F34wd8a3v1qgatax2ox2m.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%2F34wd8a3v1qgatax2ox2m.png" alt="Final HackenProof triage comment confirming Medium and describing the finding as an accepted direct theft primitive" width="764" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bounty was paid
&lt;/h2&gt;

&lt;p&gt;The USD 500 bounty was subsequently paid, and the report moved to a final Medium and Paid state.&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%2F1k9fjr5whpcv4nhqxgzr.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%2F1k9fjr5whpcv4nhqxgzr.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is one economic comparison that deserves to be put into perspective. During my investigation, I had mapped more than USD 25 million in TVL across the vaults I was analyzing. The later review established that the vulnerable package was fixed before the curator state required by the proof of concept became active, so I am not presenting that TVL as capital proven to have been immediately exploitable through &lt;code&gt;VOLOSC 194&lt;/code&gt;. The important point is that responsible disclosure prevented the vulnerable implementation and the intended curator state from ever coexisting in production.&lt;/p&gt;

&lt;p&gt;Against a reference base of USD 25 million in TVL, the USD 500 bounty I received represents roughly &lt;code&gt;0.002%&lt;/code&gt;. Even if the report had been classified as Critical and the program had paid its advertised maximum bounty of USD 150,000, that maximum would still represent only about &lt;code&gt;0.6%&lt;/code&gt; of USD 25 million.&lt;/p&gt;

&lt;p&gt;The contrast is difficult to ignore. A researcher can spend weeks understanding a protocol, finding a vulnerability, building an executable proof of concept, demonstrating a path to real financial loss, documenting the root cause, and giving the team enough time to remediate the issue before users are exposed, while the economic reward for choosing responsible disclosure remains tiny compared with the scale of capital the protocol manages.&lt;/p&gt;

&lt;p&gt;Unfortunately, this is still the incentive structure researchers frequently face in Web3. The ecosystem asks researchers to choose responsible disclosure over exploitation, yet the financial reward for protecting protocol capital can be negligible compared with both the capital under management and the potential upside available to an attacker. If the goal is to make researchers report vulnerabilities early, before every prerequisite is live and before users lose funds, then responsible disclosure has to make sense economically as well as ethically.&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%2Fvgs0vuitor1ofnew8n4c.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%2Fvgs0vuitor1ofnew8n4c.png" alt="Final VOLOSC 194 report state showing Medium severity and Paid status" width="800" height="80"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The chronology in context
&lt;/h2&gt;

&lt;p&gt;A compact timeline makes the progression easier to see without replacing the narrative above.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 March 2026&lt;/td&gt;
&lt;td&gt;The vulnerable package was deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27 March 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VOLOSC 194&lt;/code&gt; was submitted as Critical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28 March 2026&lt;/td&gt;
&lt;td&gt;The first review response recognized the root cause and withdrawal consequence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 at 04:18:06 UTC&lt;/td&gt;
&lt;td&gt;The corrected package was deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 at 05:25:56 UTC&lt;/td&gt;
&lt;td&gt;The first curator position was created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 April 2026 at 07:11:31 UTC&lt;/td&gt;
&lt;td&gt;The first curator value was approved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 April 2026&lt;/td&gt;
&lt;td&gt;The protocol side said the issue was valid but already known&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 April 2026&lt;/td&gt;
&lt;td&gt;HackenProof stopped relying on the available audit screenshot alone as conclusive duplicate evidence and communicated a goodwill proposal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21 April 2026&lt;/td&gt;
&lt;td&gt;Volo disclosed a separate security incident, which I documented without attributing it to this finding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 May 2026&lt;/td&gt;
&lt;td&gt;I followed up after the report became quiet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 August 2026&lt;/td&gt;
&lt;td&gt;The detailed technical and onchain review arrived and corrected my version 10 analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 August 2026&lt;/td&gt;
&lt;td&gt;Informational plus a goodwill payment was communicated as the position at that stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 August 2026&lt;/td&gt;
&lt;td&gt;The report was reclassified to Medium while retaining the previously discussed payment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 August 2026 at 08:39&lt;/td&gt;
&lt;td&gt;HackenProof provided the detailed privilege, reachability, and monetization rationale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 August 2026 at 10:30&lt;/td&gt;
&lt;td&gt;The senior review response said the reachability gap was dispositive for severity calibration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 August 2026 at 10:32&lt;/td&gt;
&lt;td&gt;The final report comment confirmed Medium, the root cause, the fix, and the accepted direct theft primitive characterization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final state&lt;/td&gt;
&lt;td&gt;The bounty was paid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where I agree with the final review
&lt;/h2&gt;

&lt;p&gt;There is no value in pretending the later evidence did not change the case. It did. Version 10 closed the reported principal overwrite path. The corrected package was already live before the first curator position existed, and the first approved curator value appeared only after the fix. My earlier version 10 conclusion was wrong, and the TVL I mapped during that interim analysis cannot be used as evidence that the same path remained exploitable after the correction. The final withdrawal was also operator mediated, which is a real structural characteristic of the monetization path.&lt;/p&gt;

&lt;p&gt;I also accept that HackenProof did ultimately arbitrate the dispute rather than simply leaving the protocol's initial position untouched. The case was reopened, Informational became Medium, a senior review was performed, and a detailed technical rationale was finally provided. Any fair account of the process has to acknowledge those facts.&lt;/p&gt;

&lt;p&gt;At the same time, those concessions do not erase the original primitive. The vulnerable implementation was deployed before my report. The caller controlled asset type confusion was real. The principal slot could be selected as the destination for an approved curator value. The proof of concept demonstrated the resulting NAV inflation, share ratio inflation, and withdrawal of real &lt;code&gt;free_principal&lt;/code&gt; once the legitimate curator state existed. The protocol changed the exact write target, regression coverage was added, and the final HackenProof comment itself described the finding as an accepted direct theft primitive.&lt;/p&gt;

&lt;p&gt;That is the factual foundation of my remaining disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I still consider VOLOSC 194 Critical
&lt;/h2&gt;

&lt;p&gt;The strongest argument against Critical is no longer ambiguous: the legitimate curator state required by the proof of concept did not exist while the vulnerable package was live. HackenProof considers that reachability gap decisive. I understand the reasoning, but I do not reach the same severity conclusion.&lt;/p&gt;

&lt;p&gt;The vulnerable implementation was already deployed, and the missing state was not an invented configuration unrelated to the protocol. It was the intended curator functionality that the system was about to use. The first curator position appeared only 1 hour, 7 minutes, and 50 seconds after the fixed package went live, and the first approved curator value appeared less than three hours after the fix. Had remediation not landed first, the same public corruption primitive demonstrated in the proof of concept would have become reachable as soon as that intended state appeared.&lt;/p&gt;

&lt;p&gt;When reachable, the consequence was direct principal loss. The bug did not merely cause a stale display value or a temporary mismatch that could not affect funds. The manipulated principal slot entered total NAV, the inflated NAV changed the share ratio, and the withdrawal path used that ratio to split real principal from the vault. The final HackenProof comment's own description, &lt;strong&gt;accepted direct theft primitive&lt;/strong&gt;, is consistent with the impact I demonstrated.&lt;/p&gt;

&lt;p&gt;This is where my methodology differs from HackenProof's final methodology. I assess the severity of the deployed vulnerable primitive from the demonstrated consequence under the intended protocol state that was about to become active. HackenProof calibrated the tier against the exact production state that existed while the vulnerable package remained deployed. Under that interpretation, successful remediation before curator activation is precisely what prevents the direct theft consequence from reaching High or Critical.&lt;/p&gt;

&lt;p&gt;Under mine, the same sequence demonstrates that responsible disclosure worked before the vulnerable code and the intended state could coexist.&lt;/p&gt;

&lt;p&gt;That is why my own assessment remains Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters beyond this one bounty
&lt;/h2&gt;

&lt;p&gt;Beyond the severity label itself, this case raises a broader question about incentives. Bug bounty programs exist because researchers sometimes discover vulnerabilities before attackers exploit them. At that moment, the researcher may already understand how the issue could be monetized. The program asks the researcher to choose disclosure instead: build the proof, document the root cause, avoid harming users, provide the details privately, and give the protocol time to fix the problem.&lt;/p&gt;

&lt;p&gt;That model works only when early disclosure is rewarded rather than inadvertently penalized. &lt;code&gt;VOLOSC 194&lt;/code&gt; sits in an uncomfortable edge case because the disclosure happened while the vulnerable implementation was already deployed but before the final legitimate curator state existed. The protocol then fixed the root cause, and only afterward did the first curator position and approved value appear.&lt;/p&gt;

&lt;p&gt;From a defensive security perspective, that is close to the ideal outcome. The vulnerability was found early enough that the dangerous combination of vulnerable code and active curator state never existed on mainnet. No user needed to lose funds through this path for the code to be fixed.&lt;/p&gt;

&lt;p&gt;The incentive problem appears if that success is also what lowers the severity. Had a researcher intentionally waited for the curator state to be activated before reporting, the same vulnerable implementation would have had a stronger contemporaneous reachability argument. Reporting earlier made the protocol safer. Waiting would have made the severity case stronger.&lt;/p&gt;

&lt;p&gt;I do not believe a researcher should wait, and I am not advocating exploitation. Draining a protocol is harmful, unlawful, and incompatible with responsible security research. The point is exactly the opposite: the bounty model should make the safe behavior economically rational as well as ethically correct. Researchers should never have to wonder whether disclosing before the last legitimate prerequisite becomes active will make the vulnerability they prevented worth less than it would have been if they had waited.&lt;/p&gt;

&lt;p&gt;That is the incentive inversion I think Web3 bounty programs need to take seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I think a platform should provide
&lt;/h2&gt;

&lt;p&gt;The final stage of the review also changed how I would describe HackenProof's role. It would be inaccurate to say the platform never arbitrated the dispute. The report was reopened, the Informational classification was changed to Medium, a senior review was performed, and HackenProof eventually provided a detailed explanation of the methodology used.&lt;/p&gt;

&lt;p&gt;My remaining concern is predictability rather than the absence of review. If production reachability at the exact moment of submission can be dispositive even when the missing state is an intended feature activated immediately after remediation, that principle has a major effect on how researchers should understand severity. The same is true if operator mediated settlement materially caps a finding even when the operator is executing the protocol's normal flow after an unprivileged corruption.&lt;/p&gt;

&lt;p&gt;Those may be familiar considerations inside professional smart contract triage, but the more decisive they are for classification and payout, the more useful it is for researchers to understand them before disclosure. The platform sits between a protocol that wants vulnerabilities fixed while controlling cost and a researcher who gives up the information advantage as soon as the report is submitted. Clear, predictable severity treatment is part of what makes that intermediary relationship valuable.&lt;/p&gt;

&lt;p&gt;In my view, the strongest bounty programs are not simply the ones with large maximum rewards. They are the ones where a researcher can reasonably predict how the demonstrated exploit, required privileges, production state, and settlement path will be translated into severity before spending weeks or months defending the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I report the vulnerability again?
&lt;/h2&gt;

&lt;p&gt;Yes. Exploitation is not an alternative, and users should not become collateral damage in a disagreement about bounty economics. But the ecosystem should not take that answer for granted from every researcher forever.&lt;/p&gt;

&lt;p&gt;From a security perspective, the report achieved the outcome responsible disclosure is supposed to achieve. A reproducible proof was delivered, the root cause was understood, the vulnerable write target was fixed, regression coverage was added, and the intended curator state became active only after remediation. The vulnerable code never got the opportunity to coexist with the final prerequisite needed by the proof of concept.&lt;/p&gt;

&lt;p&gt;The unresolved question is how a bounty system should value that outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;After almost five months, &lt;code&gt;VOLOSC 194&lt;/code&gt; ended in a much more precise place than where it began. The report was not dismissed as invalid, the available prior audit screenshot was not enough on its own to conclusively establish a duplicate, the version 10 fix was verified, and I corrected my own mistaken follow up analysis. The production reachability gap was established onchain, the Informational classification was withdrawn, and a senior review ultimately concluded Medium. The final report comment confirmed the root cause, confirmed that the remediation closed the reported path, and described the vulnerability as an accepted direct theft primitive.&lt;/p&gt;

&lt;p&gt;I respect the fact that HackenProof eventually provided a detailed technical rationale and explicitly acknowledged that reasonable disagreement was possible over the weight assigned to the reachability gap. I still disagree with the severity. To me, a deployed public corruption primitive capable of redirecting a legitimately approved curator value into principal accounting, inflating NAV and the share ratio, and producing direct principal loss under the intended curator state is Critical in technical impact. HackenProof reached a different conclusion because the required curator state was not instantiated until after the vulnerable package had been fixed, making that reachability gap decisive under its impact based methodology.&lt;/p&gt;

&lt;p&gt;That is the final disagreement, and it matters because it touches the core incentive behind responsible disclosure. The safest outcome is not for a researcher to wait until every prerequisite is live and capital is immediately exposed. It is to report one step earlier, while the protocol still has time to remove the dangerous primitive before the final state becomes active.&lt;/p&gt;

&lt;p&gt;If early disclosure achieves exactly that, the reward system should not leave researchers wondering whether waiting would have produced a better economic outcome. Web3 security depends on researchers choosing disclosure over exploitation, and the ecosystem should make that choice obvious by rewarding the people who find vulnerabilities, prove their impact, report them early, and give protocols the opportunity to protect users before an attacker arrives.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>web3</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-40021: How Invalid XML Properties Could Silently Drop Log Events in Apache Log4net</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 23:11:56 +0000</pubDate>
      <link>https://dev.to/f00dat/cve-2026-40021-how-invalid-xml-properties-could-silently-drop-log-events-in-apache-log4net-abl</link>
      <guid>https://dev.to/f00dat/cve-2026-40021-how-invalid-xml-properties-could-silently-drop-log-events-in-apache-log4net-abl</guid>
      <description>&lt;p&gt;While researching the Apache Log4j Bug Bounty Program on YesWeHack, I came across an issue in Apache Log4net that initially looked like a fairly ordinary XML serialization problem. The vulnerable code was located in &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt;, a layout responsible for formatting logging events as XML.&lt;/p&gt;

&lt;p&gt;What made the issue interesting was not simply that malformed data could cause XML serialization to fail. The more important behavior appeared when I followed the failure through the rest of the logging pipeline. If a forbidden XML character reached a structured logging property, the XML writer could throw an exception. Log4net would catch that exception internally and allow the application to continue running, but the logging event that triggered the failure would never be written.&lt;/p&gt;

&lt;p&gt;The result was a subtle loss of audit information. Data influenced by an attacker could potentially cause the log entry associated with that data to disappear from the XML output. Instead of producing malformed XML or crashing the application, the logging system could silently lose the individual event that was supposed to record what happened.&lt;/p&gt;

&lt;p&gt;The issue was eventually assigned &lt;strong&gt;CVE 2026 40021&lt;/strong&gt; and remained classified as &lt;strong&gt;Medium&lt;/strong&gt; throughout the disclosure process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the problem started
&lt;/h2&gt;

&lt;p&gt;The vulnerable code was inside &lt;code&gt;XmlLayoutSchemaLog4J.FormatXml()&lt;/code&gt;, where Log4net processes the properties associated with a &lt;code&gt;LoggingEvent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When properties were present, the layout iterated over the property dictionary and created an XML element for each entry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;PropertiesDictionary&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loggingEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperties&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteStartElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"log4j:properties"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"log4j"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"log4net"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KeyValuePair&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteStartElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"log4j:data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"log4j"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"log4net"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;valueStr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
      &lt;span class="n"&gt;loggingEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;RendererMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAndRender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valueStr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valueStr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteEndElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteEndElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part of this code is the way the property name and rendered property value are passed directly to the XML writer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valueStr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither value passed through Log4net's invalid character masking logic before reaching &lt;code&gt;WriteAttributeString()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This distinction is important because XML escaping and XML validity are two different problems. Characters such as &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;amp;&lt;/code&gt; can be represented safely in XML through escaping. Some control characters cannot.&lt;/p&gt;

&lt;p&gt;A character such as &lt;code&gt;U+0001&lt;/code&gt;, for example, is forbidden by XML 1.0. It is not a matter of changing it into an entity or escaping it differently. The character itself cannot legally appear in the XML document.&lt;/p&gt;

&lt;p&gt;When XML character validation is enabled, attempting to write such a character can cause the XML writer to throw an exception. Because the property serialization path passed the data directly to that writer, a forbidden character inside a property could reach exactly that condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protection was already present elsewhere
&lt;/h2&gt;

&lt;p&gt;One of the reasons the behavior stood out was that Log4net already had a mechanism designed specifically to deal with invalid XML characters.&lt;/p&gt;

&lt;p&gt;The layout hierarchy exposes the following property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;InvalidCharReplacement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"?"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The purpose of this setting is to replace characters that cannot be represented safely in XML with another character. By default, the replacement is &lt;code&gt;?&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This meant the application already had an expected way of handling this type of input. Rather than failing to serialize the entire event, Log4net could replace the unsupported character and continue producing valid XML.&lt;/p&gt;

&lt;p&gt;That behavior was already used in other parts of the XML layout. The rendered log message, for instance, passed through a transformation helper before being written.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteEscapedXmlString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;loggingEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RenderedMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;InvalidCharReplacement&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property path behaved differently. Property names and rendered values were written directly into XML attributes without equivalent invalid character handling.&lt;/p&gt;

&lt;p&gt;That made the root cause relatively small. Log4net did not lack the necessary protection. One serialization path simply bypassed a protection that the rest of the layout already understood how to use.&lt;/p&gt;

&lt;p&gt;The consequence of that small inconsistency, however, only became clear after following the exception beyond the serializer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after XML serialization fails
&lt;/h2&gt;

&lt;p&gt;The next part of the investigation was understanding how Log4net behaves when an appender encounters an exception.&lt;/p&gt;

&lt;p&gt;Appender execution is protected by exception handling around the code that ultimately writes the logging event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_recursiveGuard&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;FilterEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loggingEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;PreAppendCheck&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loggingEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsFatal&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ErrorHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed in DoAppend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_recursiveGuard&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This behavior makes sense from a reliability perspective. Logging infrastructure generally should not bring down the application simply because one event could not be formatted correctly.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt; throws during formatting, the exception is caught by the appender pipeline. The application can therefore continue running normally.&lt;/p&gt;

&lt;p&gt;The problem is that catching the exception does not recover the logging event.&lt;/p&gt;

&lt;p&gt;Once serialization has failed, that event has not been successfully written. The exception is handled, execution continues, and the original record is absent from the XML output.&lt;/p&gt;

&lt;p&gt;This was the point where the issue stopped looking like a simple formatting bug.&lt;/p&gt;

&lt;p&gt;The application remained available. The log file itself did not necessarily become corrupted. There was no requirement for a malicious downstream XML parser. Instead, the individual event containing the problematic data simply disappeared.&lt;/p&gt;

&lt;p&gt;If the event represented a request, an authentication attempt, a suspicious action or another security relevant operation, the logging system could lose exactly the record that investigators would later expect to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the behavior
&lt;/h2&gt;

&lt;p&gt;I reproduced the issue with a minimal Log4net configuration using a &lt;code&gt;FileAppender&lt;/code&gt; together with &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The property used for the test contained the forbidden XML 1.0 character &lt;code&gt;U+0001&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"A\u0001B"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The event was then passed through the normal Log4net logging path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LoggingEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the expected behavior, Log4net should have recognized the invalid character and replaced it using &lt;code&gt;InvalidCharReplacement&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Since the default replacement value is &lt;code&gt;?&lt;/code&gt;, the property could have been represented in the XML output as something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A?B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logging event would still exist, the XML would remain valid, and the surrounding audit trail would remain intact.&lt;/p&gt;

&lt;p&gt;Instead, the raw value reached the XML attribute writer. Serialization failed when the forbidden character was processed, and the exception propagated into the appender error handling path.&lt;/p&gt;

&lt;p&gt;The application continued running, but the corresponding event was not present in the expected logging output.&lt;/p&gt;

&lt;p&gt;This detail was important for establishing the security impact. The proof did not depend on editing an existing log file, corrupting previously written entries or exploiting another system after the XML had been generated.&lt;/p&gt;

&lt;p&gt;The record was lost before successful log emission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this can matter in real applications
&lt;/h2&gt;

&lt;p&gt;Modern applications frequently enrich logging events with structured properties. These properties can contain usernames, request identifiers, authentication information, correlation values, request metadata, client supplied fields and other contextual information that helps explain what was happening when the event was generated.&lt;/p&gt;

&lt;p&gt;Some of that information can ultimately originate from an external user.&lt;/p&gt;

&lt;p&gt;If attacker influenced data reaches a property that is later serialized through &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt;, a forbidden XML character can cause the serialization of that event to fail.&lt;/p&gt;

&lt;p&gt;The impact is therefore best understood as a loss of audit trail integrity.&lt;/p&gt;

&lt;p&gt;This vulnerability does not allow an attacker to modify arbitrary application data. It does not provide arbitrary file access, and it does not allow previously written log entries to be rewritten.&lt;/p&gt;

&lt;p&gt;The demonstrated behavior is narrower.&lt;/p&gt;

&lt;p&gt;An individual event containing the triggering data can be prevented from reaching the XML logging output.&lt;/p&gt;

&lt;p&gt;That becomes relevant when the missing event is itself important from a security perspective. A malicious request might generate exactly the information that an administrator, security analyst or incident responder would later use to understand what happened.&lt;/p&gt;

&lt;p&gt;If that record disappears while the rest of the application continues functioning normally, the resulting gap can be difficult to notice.&lt;/p&gt;

&lt;p&gt;This is also why logging failures deserve more attention than they sometimes receive. A logging system is not merely an output mechanism. In many environments it is part of the evidence collection process used for monitoring, detection, investigations and incident response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity, disclosure and CVE assignment
&lt;/h2&gt;

&lt;p&gt;I initially submitted the vulnerability as &lt;strong&gt;Medium&lt;/strong&gt; with a CVSS 3.1 score of &lt;strong&gt;5.3&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During triage, the impact model was adjusted. The scope was considered changed because the security consequence affected the logging environment rather than directly affecting the system where the original input was processed.&lt;/p&gt;

&lt;p&gt;Integrity impact was classified as Low, while availability impact was removed.&lt;/p&gt;

&lt;p&gt;The resulting YesWeHack vector became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This resulted in a final CVSS 3.1 score of &lt;strong&gt;5.8&lt;/strong&gt;, keeping the vulnerability within the &lt;strong&gt;Medium&lt;/strong&gt; severity range.&lt;/p&gt;

&lt;p&gt;The vulnerability was later assigned &lt;strong&gt;CVE 2026 40021&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Apache's final advisory described a broader affected surface that included both &lt;code&gt;XmlLayout&lt;/code&gt; and &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt;. The advisory covers MDC property keys, property values and the identity field, and confirms that versions before &lt;code&gt;3.3.0&lt;/code&gt; are affected.&lt;/p&gt;

&lt;p&gt;Apache Log4net &lt;code&gt;3.3.0&lt;/code&gt; contains the fix, and the Apache advisory credits the discovery to &lt;code&gt;f00dat&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The report also received a &lt;strong&gt;€600 bounty through YesWeHack&lt;/strong&gt;.&lt;br&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%2Fshh40kerzs41g7xxgmgw.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%2Fshh40kerzs41g7xxgmgw.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How the issue can be fixed
&lt;/h2&gt;

&lt;p&gt;The underlying remediation follows the protection that Log4net already used elsewhere in its XML serialization logic.&lt;/p&gt;

&lt;p&gt;Before property names or rendered values are passed to the XML writer, characters that are invalid under XML 1.0 should be replaced according to the configured &lt;code&gt;InvalidCharReplacement&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;Instead of passing the property name directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the value can first be processed with the existing transformation logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaskXmlInvalidCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InvalidCharReplacement&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendered property values require the same treatment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAttributeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaskXmlInvalidCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;valueStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InvalidCharReplacement&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this behavior, an unsupported character no longer causes the entire event to fail serialization. The invalid character is replaced, the XML remains valid, and the logging record can still be preserved.&lt;/p&gt;

&lt;p&gt;A regression test can verify the fix by creating a logging event that contains a forbidden XML character in one of its properties and passing that event through &lt;code&gt;XmlLayoutSchemaLog4J&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important assertion is not only that the generated XML is valid. The test should also confirm that the logging event itself remains present and that the unsupported character has been replaced according to the configured behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part of the bug that mattered most
&lt;/h2&gt;

&lt;p&gt;What I found most interesting about this vulnerability was how easy it would have been to stop the analysis too early.&lt;/p&gt;

&lt;p&gt;Looking only at the vulnerable lines, the issue appears to be a small XML validation inconsistency. A property contains a character that XML 1.0 does not allow, the serializer rejects it, and the immediate conclusion could simply be that the output cannot be generated correctly.&lt;/p&gt;

&lt;p&gt;Following the execution path further changes the picture.&lt;/p&gt;

&lt;p&gt;The serializer throws an exception. The appender catches it. The application keeps running. The logging event disappears.&lt;/p&gt;

&lt;p&gt;That sequence is what gives the bug its security relevance.&lt;/p&gt;

&lt;p&gt;The important failure was not that Log4net could produce invalid XML. In fact, the XML writer prevented exactly that from happening.&lt;/p&gt;

&lt;p&gt;The important failure was that the system responded to an invalid value by losing the complete event instead of replacing the unsupported character and preserving the record.&lt;/p&gt;

&lt;p&gt;Security research often comes down to following these small inconsistencies far enough to understand what they actually mean in the context of the complete system.&lt;/p&gt;

&lt;p&gt;In this case, a few property serialization calls were enough to affect the reliability of the audit trail.&lt;/p&gt;

&lt;p&gt;That behavior ultimately became &lt;strong&gt;CVE 2026 40021&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>bug</category>
      <category>bounty</category>
      <category>csharp</category>
    </item>
    <item>
      <title>How Scalelite Playback Broke Tenant Isolation in BigBlueButton Multitenancy</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 22:47:36 +0000</pubDate>
      <link>https://dev.to/f00dat/how-scalelite-playback-broke-tenant-isolation-in-bigbluebutton-multitenancy-1o9b</link>
      <guid>https://dev.to/f00dat/how-scalelite-playback-broke-tenant-isolation-in-bigbluebutton-multitenancy-1o9b</guid>
      <description>&lt;p&gt;While researching the BigBlueButton bug bounty program on YesWeHack, I found an access control issue in Scalelite’s recording playback flow.&lt;/p&gt;

&lt;p&gt;The problem appeared when multitenancy was enabled.&lt;/p&gt;

&lt;p&gt;Scalelite already derived tenant context from the request hostname for BigBlueButton API requests and scoped recording queries to that tenant. The playback controller did not follow the same model. It resolved recordings using only &lt;code&gt;record_id&lt;/code&gt; and playback format.&lt;/p&gt;

&lt;p&gt;As a result, a recording assigned to Tenant A could also be requested through Tenant B’s hostname if the same &lt;code&gt;record_id&lt;/code&gt; was known.&lt;/p&gt;

&lt;p&gt;I submitted the finding as &lt;strong&gt;High&lt;/strong&gt;, with CVSS 3.1 score &lt;strong&gt;7.5&lt;/strong&gt;. Triage later classified it as &lt;strong&gt;Medium&lt;/strong&gt;, with CVSS score &lt;strong&gt;5.3&lt;/strong&gt;. The report received a &lt;strong&gt;€250 bounty&lt;/strong&gt;.&lt;br&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%2Fvwa14fsttwvgmba9mcrg.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%2Fvwa14fsttwvgmba9mcrg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The intended tenant boundary
&lt;/h2&gt;

&lt;p&gt;When Scalelite multitenancy is enabled through &lt;code&gt;MULTITENANCY_ENABLED=true&lt;/code&gt;, tenant identity is derived from the request hostname.&lt;/p&gt;

&lt;p&gt;The helper responsible for that behavior was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_tenant_name_from_url&lt;/span&gt;
  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multitenancy_enabled&lt;/span&gt;

  &lt;span class="n"&gt;tenant_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presence&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fetch_tenant_name_from_url&lt;/span&gt;
  &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;ChecksumError&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;

  &lt;span class="n"&gt;tenant&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, these hostnames represent different tenant contexts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenantA.sl.example.com
tenantB.sl.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project documentation also states that each tenant should have access only to its own meetings and recordings.&lt;/p&gt;

&lt;p&gt;The BigBlueButton API controller followed this model. When multitenancy was enabled, it resolved the current tenant and used that context when querying recordings.&lt;/p&gt;

&lt;p&gt;The recording query included a metadata filter matching &lt;code&gt;tenant-id&lt;/code&gt; to the current tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Recording&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;playback_formats: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:thumbnails&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;metadata: &lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;left_joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distinct&lt;/span&gt;

&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;metadata: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="s2"&gt;"tenant-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;value: &lt;/span&gt;&lt;span class="vi"&gt;@tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the tenant boundary already existed for recording discovery through the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playback used a global lookup
&lt;/h2&gt;

&lt;p&gt;The vulnerable behavior was inside &lt;code&gt;PlaybackController&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both playback actions resolved a recording using its identifier and playback format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="vi"&gt;@playback_format&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;PlaybackFormat&lt;/span&gt;
                   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:recording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                     &lt;span class="ss"&gt;format: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:playback_format&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                     &lt;span class="ss"&gt;recordings: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                       &lt;span class="ss"&gt;record_id: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:record_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                     &lt;span class="p"&gt;}&lt;/span&gt;
                   &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="vi"&gt;@recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@playback_format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recording&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was no tenant lookup from the hostname.&lt;/p&gt;

&lt;p&gt;There was no &lt;code&gt;before_action :set_tenant&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There was no condition requiring the recording metadata to contain a &lt;code&gt;tenant-id&lt;/code&gt; matching the current request.&lt;/p&gt;

&lt;p&gt;The query therefore answered only whether a recording with the requested identifier and format existed.&lt;/p&gt;

&lt;p&gt;It did not verify whether that recording belonged to the tenant represented by the hostname.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the issue
&lt;/h2&gt;

&lt;p&gt;I reproduced the problem with an RSpec request test while multitenancy was enabled.&lt;/p&gt;

&lt;p&gt;The test created a published recording and explicitly associated it with Tenant A:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;:metadatum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;recording: &lt;/span&gt;&lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="s2"&gt;"tenant-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;value: &lt;/span&gt;&lt;span class="s2"&gt;"tenantA-id"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A presentation playback resource was then created for the same recording:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;:playback_format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;recording: &lt;/span&gt;&lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;format: &lt;/span&gt;&lt;span class="s2"&gt;"presentation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="s2"&gt;"/presentation/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index.html"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request used Tenant A’s hostname and returned HTTP 200 as expected.&lt;/p&gt;

&lt;p&gt;The second request used Tenant B’s hostname while keeping the same path and &lt;code&gt;record_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That request also returned HTTP 200.&lt;/p&gt;

&lt;p&gt;Both responses exposed the same &lt;code&gt;X-Accel-Redirect&lt;/code&gt; target for the recording resource.&lt;/p&gt;

&lt;p&gt;The recording ownership never changed. Only the request hostname changed.&lt;/p&gt;

&lt;p&gt;The test completed successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finished in 0.19804 seconds
2 examples, 0 failures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This demonstrated that playback authorization depended on possession of the &lt;code&gt;record_id&lt;/code&gt;, not on tenant ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The affected playback routes can serve recording pages and their associated resources.&lt;/p&gt;

&lt;p&gt;Depending on the generated recording format and meeting content, those resources may include presentation material, media, captions, notes, chat content, participant related information, and other recording assets.&lt;/p&gt;

&lt;p&gt;The attacker still needs to know or obtain the victim recording’s &lt;code&gt;record_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The vulnerability is not that every identifier is automatically discoverable. The issue is that once an identifier is known, the playback path does not enforce the tenant boundary that multitenancy is supposed to provide.&lt;/p&gt;

&lt;p&gt;Protected recordings also have their own token and cookie mechanism. That mechanism is separate from tenant ownership and does not replace tenant isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is an access control flaw
&lt;/h2&gt;

&lt;p&gt;The key difference is between object existence and object ownership.&lt;/p&gt;

&lt;p&gt;The vulnerable lookup effectively used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record_id + format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under multitenancy, the authorization decision needed to include the current tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant + record_id + format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API side already enforced that distinction through &lt;code&gt;tenant-id&lt;/code&gt; metadata.&lt;/p&gt;

&lt;p&gt;Playback did not.&lt;/p&gt;

&lt;p&gt;That is why the issue fits &lt;strong&gt;CWE 284, Improper Access Control&lt;/strong&gt;. It also matches the authorization bypass pattern described by &lt;strong&gt;CWE 639&lt;/strong&gt;, where a user controlled object identifier can reach data outside the caller’s authorization boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity
&lt;/h2&gt;

&lt;p&gt;I originally submitted the issue with this CVSS 3.1 vector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produced a base score of &lt;strong&gt;7.5&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;During triage, the confidentiality impact was reduced from High to Low, resulting in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final score was &lt;strong&gt;5.3&lt;/strong&gt;, and the finding was classified as &lt;strong&gt;Medium&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The public writeup preserves both values because the first reflects the submitted assessment and the second reflects the final program classification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;p&gt;The playback path should enforce the same tenant ownership rule already used by the recording API.&lt;/p&gt;

&lt;p&gt;When multitenancy is enabled, &lt;code&gt;PlaybackController&lt;/code&gt; should resolve the tenant from the request hostname and scope the recording lookup to metadata where &lt;code&gt;tenant-id&lt;/code&gt; matches that tenant.&lt;/p&gt;

&lt;p&gt;The expected behavior should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tenant A requesting Tenant A recording = HTTP 200
Tenant B requesting Tenant A recording = HTTP 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cross tenant response should not receive an &lt;code&gt;X-Accel-Redirect&lt;/code&gt; for the recording resource.&lt;/p&gt;

&lt;p&gt;Legacy recordings without tenant metadata also need an explicit policy. The safer behavior under multitenancy is to deny access until ownership has been established or migrated.&lt;/p&gt;

&lt;p&gt;A regression test should keep this boundary covered by creating a recording for one tenant and confirming that the same identifier cannot be served through another tenant hostname.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Multitenancy failures often appear when different application surfaces reach the same object through different lookup paths.&lt;/p&gt;

&lt;p&gt;In this case, the recordings API understood tenant ownership.&lt;/p&gt;

&lt;p&gt;Playback reached the same underlying data without carrying that context forward.&lt;/p&gt;

&lt;p&gt;That inconsistency was enough to break the isolation model.&lt;/p&gt;

&lt;p&gt;The useful question when reviewing a multitenant application is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does every path that can return this object enforce the same tenant boundary?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For Scalelite recording playback, the answer was no.&lt;/p&gt;

&lt;p&gt;The recording belonged to one tenant in metadata, but playback resolved it globally by &lt;code&gt;record_id&lt;/code&gt; and format.&lt;/p&gt;

&lt;p&gt;That was enough to allow cross tenant access to recording playback resources.&lt;/p&gt;

</description>
      <category>security</category>
      <category>bug</category>
      <category>bounty</category>
      <category>ruby</category>
    </item>
    <item>
      <title>How TipRun’s Liquidation Path Could Force Healthy Accounts Into Arbitrary Terms</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:42:49 +0000</pubDate>
      <link>https://dev.to/f00dat/how-tipruns-liquidation-path-could-force-healthy-accounts-into-arbitrary-terms-3n3m</link>
      <guid>https://dev.to/f00dat/how-tipruns-liquidation-path-could-force-healthy-accounts-into-arbitrary-terms-3n3m</guid>
      <description>&lt;p&gt;During the TipRun audit on HackenProof, I found a flaw in the perpetual trading liquidation flow that was easy to misread as a signature issue.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TYPE_LIQUIDATE&lt;/code&gt; contained a signed &lt;code&gt;LimitOrder&lt;/code&gt;, but that signature belonged to the liquidator. The account being liquidated did not sign the forced terms, which can be completely valid in a liquidation design.&lt;/p&gt;

&lt;p&gt;The actual security requirement was different.&lt;/p&gt;

&lt;p&gt;If the victim does not consent, the contracts must prove that the account is eligible for liquidation and that the forced terms stay inside an economically safe range.&lt;/p&gt;

&lt;p&gt;TipRun did not enforce those conditions before resizing the target account.&lt;/p&gt;

&lt;p&gt;I originally submitted the finding as &lt;strong&gt;High&lt;/strong&gt;. HackenProof later validated it as &lt;strong&gt;Critical&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signature protected only one side
&lt;/h2&gt;

&lt;p&gt;The liquidation payload was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;struct Liquidate {
    LimitOrder liquidatorOrder;
    uint64 liquidatedUid;
    int256 actualCollateral;
    int256 actualSynthetic;
    uint256 actualLiquidatorFee;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only signed object is &lt;code&gt;liquidatorOrder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That signature can authenticate the liquidator side of the transaction. It does not establish that &lt;code&gt;liquidatedUid&lt;/code&gt; is below maintenance, that the target has an exposure that should be reduced, or that the forced collateral amount is consistent with the oracle price.&lt;/p&gt;

&lt;p&gt;Those properties require separate on chain checks.&lt;/p&gt;

&lt;p&gt;Without them, a valid liquidator signature does not make the forced transition safe for the account being affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The liquidation request had no eligibility gate
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PerpPlugin&lt;/code&gt; decoded &lt;code&gt;TYPE_LIQUIDATE&lt;/code&gt; and forwarded it to &lt;code&gt;LiquidateTransLib&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (txType == TYPE_LIQUIDATE) {
    Liquidate memory liquidate = abi.decode(payload, (Liquidate));
    CollateralAssetInfo memory collateralInfo = generalConfig.getCollateralAssetInfo();

    liquidate.process(
        position,
        funding,
        generalConfig,
        order,
        signer,
        collateralInfo.assetId,
        generalConfig.getFeeAccountId(),
        transactionProcessor
    );

    emit LiquidateProcessed(liquidate);
    return true;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was no check at this point to determine whether the target account was actually liquidatable.&lt;/p&gt;

&lt;p&gt;Inside the library, the victim deltas were derived directly from the submitted values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;int256 liquidatedCollateralDelta = 0;
int256 liquidatedSyntheticDelta = 0;

if (liquidate.liquidatorOrder.isBuyingSynthetic) {
    liquidatedCollateralDelta = liquidate.actualCollateral;
    liquidatedSyntheticDelta = -liquidate.actualSynthetic;
} else {
    liquidatedCollateralDelta = -liquidate.actualCollateral;
    liquidatedSyntheticDelta = liquidate.actualSynthetic;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those deltas were then applied to the target account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;position.updatePosition(
    liquidatedPosition,
    liquidatedUid,
    liquidatedCollateralDelta,
    syntheticAssetId,
    liquidatedSyntheticDelta
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path checked basic conditions such as a nonzero target account, collateral asset identity, and whether the synthetic asset was tradable.&lt;/p&gt;

&lt;p&gt;The missing checks were the ones that defined whether a forced liquidation was legitimate.&lt;/p&gt;

&lt;p&gt;There was no explicit requirement that the original account was below maintenance, no requirement that an existing exposure was being reduced, and no oracle bound on &lt;code&gt;actualCollateral&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the generic margin check did not solve it
&lt;/h2&gt;

&lt;p&gt;TipRun already performed generic risk validation around position transitions, so I checked whether that logic indirectly enforced liquidation eligibility.&lt;/p&gt;

&lt;p&gt;It did not.&lt;/p&gt;

&lt;p&gt;The relevant risk path returned when the updated position was healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(int256 updatedTotalValue, int256 updatedTotalRisk) =
    getAccountPositionStatusWithBoundaryCheck(fullUpdated);

if (updatedTotalRisk &amp;lt;= updatedTotalValue * FXP_32_ONE) {
    return;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check asks whether the resulting state satisfies a generic margin condition.&lt;/p&gt;

&lt;p&gt;Liquidation eligibility asks a different question: was the original account below maintenance before the forced transition?&lt;/p&gt;

&lt;p&gt;A healthy final state cannot answer that.&lt;/p&gt;

&lt;p&gt;This distinction allowed the liquidation path to accept a forced change against an account that should never have entered liquidation in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the issue through real protocol paths
&lt;/h2&gt;

&lt;p&gt;I built the proof through the real &lt;code&gt;TYPE_TRADE&lt;/code&gt;, &lt;code&gt;TYPE_LIQUIDATE&lt;/code&gt;, and &lt;code&gt;TYPE_WITHDRAWAL&lt;/code&gt; flows.&lt;/p&gt;

&lt;p&gt;The victim deposited &lt;code&gt;1000&lt;/code&gt; collateral and opened a small short through signed orderbook trades.&lt;/p&gt;

&lt;p&gt;Immediately before liquidation, the account state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 1001
Synthetic: -1
Normalized value: 1000
Normalized risk: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account was far above maintenance.&lt;/p&gt;

&lt;p&gt;The liquidator then signed its own order, and the authorized batch path submitted a liquidation with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Synthetic amount: 1
Submitted collateral: 100
Oracle fair collateral: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oracle fair value for one synthetic unit was &lt;code&gt;1&lt;/code&gt;, while the submitted liquidation used &lt;code&gt;100&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The transaction succeeded.&lt;/p&gt;

&lt;p&gt;After the forced transition, the victim had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 901
Synthetic: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The liquidator had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 100
Synthetic: -1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relative to oracle fair value, the victim lost &lt;code&gt;99&lt;/code&gt; units of normalized value and the liquidator gained &lt;code&gt;99&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This was the central impact demonstrated by the proof.&lt;/p&gt;

&lt;p&gt;A healthy account was forced through liquidation using economic terms that were not bounded by the oracle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving that the value was realizable
&lt;/h2&gt;

&lt;p&gt;I also tested whether the liquidator could externalize the resulting collateral without relying on another vulnerability.&lt;/p&gt;

&lt;p&gt;After liquidation, the protocol reported &lt;code&gt;98&lt;/code&gt; collateral as safely removable from the liquidator account.&lt;/p&gt;

&lt;p&gt;The liquidator submitted a valid signed &lt;code&gt;TYPE_WITHDRAWAL&lt;/code&gt; for exactly &lt;code&gt;98&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The withdrawal succeeded, &lt;code&gt;98&lt;/code&gt; ERC20 units reached the external recipient, and the &lt;code&gt;LoadingZone&lt;/code&gt; system balance decreased by the same amount.&lt;/p&gt;

&lt;p&gt;The liquidator remained at maintenance afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 2
Synthetic: -1
Normalized value: 1
Normalized risk: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This demonstrated realizable economic impact while keeping the withdrawal inside the protocol’s normal margin rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liquidation could create exposure instead of reducing it
&lt;/h2&gt;

&lt;p&gt;The missing eligibility logic was not limited to the first scenario.&lt;/p&gt;

&lt;p&gt;I also tested a healthy account with collateral and no synthetic exposure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 1000
Synthetic: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TYPE_LIQUIDATE&lt;/code&gt; still executed.&lt;/p&gt;

&lt;p&gt;The resulting state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collateral: 900
Synthetic: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of reducing an existing risky position, the liquidation path created a new synthetic exposure.&lt;/p&gt;

&lt;p&gt;That behavior showed why a forced liquidation mechanism needs an explicit exposure reduction rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control cases
&lt;/h2&gt;

&lt;p&gt;The proof included controls around the neighboring security boundaries.&lt;/p&gt;

&lt;p&gt;A zero &lt;code&gt;liquidatedUid&lt;/code&gt; reverted.&lt;/p&gt;

&lt;p&gt;A collateral asset mismatch reverted.&lt;/p&gt;

&lt;p&gt;A nontradable synthetic asset reverted.&lt;/p&gt;

&lt;p&gt;A mutated liquidator order reverted.&lt;/p&gt;

&lt;p&gt;A direct caller outside the authorized batch path reverted.&lt;/p&gt;

&lt;p&gt;A withdrawal beyond the safely removable amount reverted.&lt;/p&gt;

&lt;p&gt;I also tested a genuinely undercollateralized account after a large oracle price movement. A bounded liquidation still succeeded.&lt;/p&gt;

&lt;p&gt;That final control was important because the intended fix should preserve valid liquidation while rejecting forced transitions against accounts that are not eligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate from the earlier deleverage finding
&lt;/h2&gt;

&lt;p&gt;I had previously reported a different vulnerability in &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt;, so I isolated this proof from that issue.&lt;/p&gt;

&lt;p&gt;This test never executed &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It did not depend on the deleverage nonce problem or on deleverage replay.&lt;/p&gt;

&lt;p&gt;The affected transaction here was &lt;code&gt;TYPE_LIQUIDATE&lt;/code&gt;, the main affected library was &lt;code&gt;LiquidateTransLib&lt;/code&gt;, and the liquidator order was validly signed.&lt;/p&gt;

&lt;p&gt;The failure was the absence of protections for the third party account receiving the forced state change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test results
&lt;/h2&gt;

&lt;p&gt;The full Foundry suite completed successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests covered the healthy account path, oracle price bounds, realizable withdrawal, zero exposure behavior, valid liquidation, guard conditions, arithmetic, and isolation from the deleverage flow.&lt;/p&gt;

&lt;p&gt;The proof used local execution through real protocol components. It did not depend on RPC access, governance action, direct mutation of vulnerable state, or external oracle manipulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the final classification was Critical
&lt;/h2&gt;

&lt;p&gt;I submitted the report as &lt;strong&gt;High&lt;/strong&gt;, while HackenProof ultimately accepted it as &lt;strong&gt;Critical&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The final impact went beyond a missing precondition.&lt;/p&gt;

&lt;p&gt;The liquidation mechanism could apply unfavorable forced terms to a healthy account, move collateral to the opposite side, and produce value that could be withdrawn through a legitimate protocol path.&lt;/p&gt;

&lt;p&gt;It could also create exposure in an account that had no position to liquidate.&lt;/p&gt;

&lt;p&gt;That made the issue a realizable collateral loss path inside an authorized protocol operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contest economics
&lt;/h2&gt;

&lt;p&gt;The financial outcome was very different from the technical severity.&lt;/p&gt;

&lt;p&gt;The issue was independently reported by &lt;strong&gt;31 researchers&lt;/strong&gt;, so the Critical reward was shared across the valid submissions.&lt;/p&gt;

&lt;p&gt;My payout for this report was &lt;strong&gt;$4.84&lt;/strong&gt;.&lt;br&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%2Fdtfydfmaxncvyyjo6aks.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%2Fdtfydfmaxncvyyjo6aks.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The submission fee was &lt;strong&gt;$5&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Across the TipRun contest, my total loss was &lt;strong&gt;$18&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This individual Critical report therefore did not recover its own submission fee. It is a useful example of how duplicate density and reward sharing can make contest economics very different from technical impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would fix it
&lt;/h2&gt;

&lt;p&gt;The liquidation flow should remain forced. Requiring the victim to sign would undermine the purpose of liquidation.&lt;/p&gt;

&lt;p&gt;Instead, the protocol should validate the forced action before changing the target account.&lt;/p&gt;

&lt;p&gt;At minimum, the contracts should require:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The target account is below maintenance before liquidation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The target has an existing exposure in the direction being reduced.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The liquidation cannot cross the position through zero and create opposite exposure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;actualCollateral&lt;/code&gt; stays within an oracle derived range that includes only the configured liquidation penalty.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These conditions should be checked before &lt;code&gt;position.updatePosition()&lt;/code&gt; applies the target deltas.&lt;/p&gt;

&lt;p&gt;The existing liquidator signature can continue to authenticate the liquidator order. The missing protection is the on chain validation for the account that does not sign.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Forced protocol actions require explicit invariants.&lt;/p&gt;

&lt;p&gt;When user consent is intentionally absent, the contract has to define exactly when the action is allowed and how far it can go.&lt;/p&gt;

&lt;p&gt;A valid signature from one side does not authorize arbitrary consequences for another account.&lt;/p&gt;

&lt;p&gt;The useful security question is therefore not only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Who signed this operation?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is also:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What on chain rule proves that forcing these terms onto the affected account is legitimate?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In TipRun’s liquidation path, that second guarantee was missing.&lt;/p&gt;

&lt;p&gt;That gap was enough to turn a liquidation mechanism intended for risk management into a path capable of forcing economically unfair state transitions onto healthy accounts.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How TipRun’s Unsigned Deleverage Path Allowed Arbitrary Collateral Transfers</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:06:01 +0000</pubDate>
      <link>https://dev.to/f00dat/how-tipruns-unsigned-deleverage-path-allowed-arbitrary-collateral-transfers-edg</link>
      <guid>https://dev.to/f00dat/how-tipruns-unsigned-deleverage-path-allowed-arbitrary-collateral-transfers-edg</guid>
      <description>&lt;p&gt;During the TipRun audit on HackenProof, I found a flaw in the perpetual trading deleverage flow that initially looked like a missing authorization check.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt; payload did not contain a user signature.&lt;/p&gt;

&lt;p&gt;That observation alone was not enough to prove a vulnerability. A deleverage mechanism can legitimately be forced if it exists to reduce protocol risk. In that design, requiring the affected user to approve every operation could defeat the purpose of the mechanism.&lt;/p&gt;

&lt;p&gt;The real issue was that TipRun did not replace user consent with objective safety rules enforced by the contracts.&lt;/p&gt;

&lt;p&gt;The deleverage path accepted concrete economic terms without a signature or nonce, did not require the affected account to be unsafe, did not constrain the collateral amount to the oracle price, and did not prevent the same payload from being processed again.&lt;/p&gt;

&lt;p&gt;That combination allowed a healthy account to lose collateral through the authorized batch execution path.&lt;/p&gt;

&lt;p&gt;I reported the issue during the audit. HackenProof later validated it as &lt;strong&gt;Critical&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol model
&lt;/h2&gt;

&lt;p&gt;TipRun processes protocol operations through a sequencer and batch execution architecture.&lt;/p&gt;

&lt;p&gt;Normal user actions rely on signed data. The contracts verify the relevant authorization before applying changes to user accounts.&lt;/p&gt;

&lt;p&gt;The deleverage transaction followed a different pattern.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;struct Deleverage {
    address deleveragableAddress;
    uint64 deleveragableAccountId;
    address deleveragerSigner;
    uint64 deleveragerAccountId;
    uint64 syntheticAssetId;
    int256 amountSynthetic;
    int256 amountCollateral;
    int256 deleveragerIsBuyingSynthetic;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The struct contains account identifiers, signer addresses, an asset identifier, two amounts, and a direction.&lt;/p&gt;

&lt;p&gt;It does not contain a signature, nonce, deadline, or signed hash that binds those terms to either user.&lt;/p&gt;

&lt;p&gt;That can still be valid if deleverage is intentionally forced. The problem begins when the contract also fails to define the limits of what may be forced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signer registration was treated as sufficient authorization
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;DeleverageTransLib&lt;/code&gt;, the protocol checked whether the supplied addresses were registered signers for the two accounts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    signerManager.checkSigner(deleveragable, self.deleveragableAddress),
    "DeleverageTransLib: deleveragable signer unauthorized"
);

require(
    signerManager.checkSigner(deleverager, self.deleveragerSigner),
    "DeleverageTransLib: deleverager signer unauthorized"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underlying check was simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function checkSigner(uint64 accountId, address signer) public view returns (bool) {
    uint256 permissions = signerPermissions[accountId][signer];
    return permissions != 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This proves that an address has permissions for an account.&lt;/p&gt;

&lt;p&gt;It does not prove that the address approved the current deleverage terms.&lt;/p&gt;

&lt;p&gt;That distinction matters because the transaction supplied all economically relevant values directly. The account identifiers, synthetic asset, synthetic amount, collateral amount, and direction were never cryptographically bound to user approved data.&lt;/p&gt;

&lt;p&gt;The protocol therefore knew that the addresses were registered, but it did not know whether those addresses had authorized the operation being executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why forced deleverage still needed stronger checks
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt; is meant to be voluntary, the missing signatures and nonces are already enough to make the authorization model incomplete.&lt;/p&gt;

&lt;p&gt;But I wanted to test the stronger interpretation: assume deleverage is intentionally forced.&lt;/p&gt;

&lt;p&gt;Under that model, the contracts should enforce the conditions that make a forced operation legitimate.&lt;/p&gt;

&lt;p&gt;The affected account should actually require intervention.&lt;/p&gt;

&lt;p&gt;The direction should reduce existing exposure.&lt;/p&gt;

&lt;p&gt;The operation should not push a position through zero into the opposite side.&lt;/p&gt;

&lt;p&gt;The amount of collateral exchanged should remain within a valid range relative to the oracle price and any configured penalty.&lt;/p&gt;

&lt;p&gt;The same instruction should not be executable twice.&lt;/p&gt;

&lt;p&gt;The implementation did not enforce those properties.&lt;/p&gt;

&lt;p&gt;That changed the issue from a simple missing signature into an unconstrained forced deleverage path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state update trusted the supplied amounts
&lt;/h2&gt;

&lt;p&gt;The state transition used the values from the payload directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (self.deleveragerIsBuyingSynthetic == 1) {
    deleveragerSyntheticDelta = self.amountSynthetic;
    deleveragerCollateralDelta = -self.amountCollateral;
    deleveragableSyntheticDelta = -self.amountSynthetic;
    deleveragableCollateralDelta = self.amountCollateral;
} else {
    deleveragerSyntheticDelta = -self.amountSynthetic;
    deleveragerCollateralDelta = self.amountCollateral;
    deleveragableSyntheticDelta = self.amountSynthetic;
    deleveragableCollateralDelta = -self.amountCollateral;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was no independent calculation that derived the collateral amount from the oracle price.&lt;/p&gt;

&lt;p&gt;That created a direct question for the proof of concept:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happens if the oracle says one synthetic unit is worth one collateral unit, but the deleverage payload requests one thousand collateral units?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Building the proof through real protocol paths
&lt;/h2&gt;

&lt;p&gt;I wanted the proof to demonstrate the issue without direct storage mutation or artificial calls to internal accounting functions.&lt;/p&gt;

&lt;p&gt;The test created two accounts with registered signers.&lt;/p&gt;

&lt;p&gt;The victim deposited &lt;code&gt;100000&lt;/code&gt; collateral.&lt;/p&gt;

&lt;p&gt;The recipient deposited &lt;code&gt;1&lt;/code&gt; collateral.&lt;/p&gt;

&lt;p&gt;A real signed &lt;code&gt;TYPE_TRADE&lt;/code&gt; then opened opposing perpetual positions.&lt;/p&gt;

&lt;p&gt;After the trade, the victim held &lt;code&gt;100001&lt;/code&gt; collateral and a synthetic position of &lt;code&gt;0 − 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The recipient held &lt;code&gt;0&lt;/code&gt; collateral and a synthetic position of &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The victim was healthy before deleverage. Its normalized account value was &lt;code&gt;100000&lt;/code&gt;, while its normalized risk value was only &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This established that the test was not exercising a legitimate emergency action against an insolvent account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Executing arbitrary deleverage terms
&lt;/h2&gt;

&lt;p&gt;The test then submitted a normal &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt; payload through the batch execution path.&lt;/p&gt;

&lt;p&gt;The synthetic amount was &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The collateral amount was &lt;code&gt;1000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The oracle price for one synthetic unit was &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The economically fair collateral value was therefore &lt;code&gt;1&lt;/code&gt;, but the payload requested &lt;code&gt;1000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The contract accepted it.&lt;/p&gt;

&lt;p&gt;After execution, the victim held &lt;code&gt;99001&lt;/code&gt; collateral and no remaining synthetic position.&lt;/p&gt;

&lt;p&gt;The recipient held &lt;code&gt;1000&lt;/code&gt; collateral and no remaining synthetic position.&lt;/p&gt;

&lt;p&gt;The victim had lost &lt;code&gt;1000&lt;/code&gt; collateral units while the recipient had gained the same amount.&lt;/p&gt;

&lt;p&gt;After accounting for the synthetic exposure that was removed, the victim lost &lt;code&gt;999&lt;/code&gt; units of value and the recipient gained &lt;code&gt;999&lt;/code&gt; units of excess value.&lt;/p&gt;

&lt;p&gt;The important point is not merely that the transaction was unsigned. The contract accepted economically arbitrary terms against a healthy account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the internal gain into external tokens
&lt;/h2&gt;

&lt;p&gt;A balance change inside a protocol is not always equivalent to realizable loss.&lt;/p&gt;

&lt;p&gt;So the next step was to test whether the recipient could actually remove the gained collateral.&lt;/p&gt;

&lt;p&gt;After receiving &lt;code&gt;1000&lt;/code&gt; collateral through deleverage, the recipient submitted a normal signed &lt;code&gt;TYPE_WITHDRAWAL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The withdrawal succeeded.&lt;/p&gt;

&lt;p&gt;The recipient received &lt;code&gt;1000&lt;/code&gt; ERC20 units externally, and the system balance inside &lt;code&gt;LoadingZone&lt;/code&gt; decreased accordingly.&lt;/p&gt;

&lt;p&gt;This confirmed that the impact was not limited to internal accounting. The value gained through the malformed deleverage operation could leave the protocol through its legitimate withdrawal path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay and position crossing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt; also lacked transaction level replay protection.&lt;/p&gt;

&lt;p&gt;The payload contained no nonce or unique identifier that would mark it as already processed.&lt;/p&gt;

&lt;p&gt;I executed the exact same payload a second time.&lt;/p&gt;

&lt;p&gt;It succeeded again.&lt;/p&gt;

&lt;p&gt;The victim collateral fell to &lt;code&gt;98001&lt;/code&gt;, while the recipient collateral increased to &lt;code&gt;2000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second execution also pushed the synthetic positions through zero and into the opposite direction.&lt;/p&gt;

&lt;p&gt;The victim synthetic balance became &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The recipient synthetic balance became &lt;code&gt;0 − 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This proved that the same unsigned instruction could be reused and that the path did not enforce exposure reducing behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control tests
&lt;/h2&gt;

&lt;p&gt;The proof also checked neighboring security boundaries so that the result could not be explained by unrelated broken protections.&lt;/p&gt;

&lt;p&gt;An unregistered signer still reverted.&lt;/p&gt;

&lt;p&gt;A raw attempt to withdraw more collateral than allowed still reverted.&lt;/p&gt;

&lt;p&gt;An external account could not directly call the protected transaction processor path.&lt;/p&gt;

&lt;p&gt;Those controls passed.&lt;/p&gt;

&lt;p&gt;The finding therefore did not depend on an arbitrary externally owned account bypassing the batcher.&lt;/p&gt;

&lt;p&gt;The vulnerable behavior happened after a deleverage payload reached the legitimate authorized execution path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the test suite proved
&lt;/h2&gt;

&lt;p&gt;The Foundry suite passed with nine tests and no failures.&lt;/p&gt;

&lt;p&gt;The proof established all of the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Real signed &lt;code&gt;TYPE_TRADE&lt;/code&gt; transactions created the initial market state.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The victim was healthy before deleverage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &lt;code&gt;Deleverage&lt;/code&gt; payload contained no signature and no nonce.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The protocol accepted &lt;code&gt;1000&lt;/code&gt; collateral for &lt;code&gt;1&lt;/code&gt; synthetic while the oracle price was &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The victim lost collateral and the recipient gained it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The recipient externalized the gained collateral through a real signed withdrawal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The same deleverage payload executed twice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Unregistered signer validation still worked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Raw over withdrawal protection still worked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Direct unauthorized access to the transaction processor still failed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The proof used local Foundry execution and real protocol paths. It did not depend on an RPC fork, governance action, direct vulnerable state mutation, or owner compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the proof
&lt;/h2&gt;

&lt;p&gt;The complete suite was executed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;forge &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--match-contract&lt;/span&gt; DeleveragePoC &lt;span class="nt"&gt;-vvv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Suite result: ok.
9 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also separated the core properties into dedicated tests for cash out, healthy account deleverage, replay, price fairness, guards, and arithmetic.&lt;/p&gt;

&lt;p&gt;This made it easier to verify each part of the exploit independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HackenProof classified it as Critical
&lt;/h2&gt;

&lt;p&gt;I originally reported the issue as &lt;strong&gt;High&lt;/strong&gt; because I wanted to remain conservative around the authorized batch trust boundary.&lt;/p&gt;

&lt;p&gt;HackenProof later validated it as &lt;strong&gt;Critical&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That classification makes sense when the full impact is considered.&lt;/p&gt;

&lt;p&gt;The contracts accepted attacker chosen economic terms against a healthy account, allowed value to be transferred to another account, and allowed that value to be withdrawn from the system.&lt;/p&gt;

&lt;p&gt;The same payload could also be replayed.&lt;/p&gt;

&lt;p&gt;This was not a harmless mismatch between documentation and implementation. It was a realizable collateral loss path inside a legitimate protocol transaction flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contest economics
&lt;/h2&gt;

&lt;p&gt;The technical severity and the financial outcome were very different.&lt;/p&gt;

&lt;p&gt;The submission fee for this report was &lt;strong&gt;$5&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The same vulnerability was independently reported by &lt;strong&gt;27 researchers&lt;/strong&gt;, so the Critical reward was shared across the valid reports.&lt;/p&gt;

&lt;p&gt;My payout was &lt;strong&gt;$8.48&lt;/strong&gt;.&lt;br&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%2Fhdyrqyer3cb9gh7qe22u.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%2Fhdyrqyer3cb9gh7qe22u.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the TipRun contest, my total loss reached &lt;strong&gt;$18&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is one of the realities of competitive audit programs. Finding a Critical vulnerability does not guarantee a large payout when many researchers reach the same issue and the reward is divided among them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would fix it
&lt;/h2&gt;

&lt;p&gt;The correct remediation depends on the intended semantics of &lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If deleverage is voluntary, every economically relevant field should be bound to typed signed data, and the protocol should consume nonces before applying state changes.&lt;/p&gt;

&lt;p&gt;The signed message should cover the relevant account identifiers, asset, synthetic amount, collateral amount, direction, and any applicable deadline.&lt;/p&gt;

&lt;p&gt;If deleverage is intentionally forced, victim consent is not required, but the contract must enforce the conditions that make the operation safe.&lt;/p&gt;

&lt;p&gt;The affected account should be unsafe before forced deleverage is allowed.&lt;/p&gt;

&lt;p&gt;The direction should reduce an existing synthetic exposure.&lt;/p&gt;

&lt;p&gt;The operation should not cross the position through zero.&lt;/p&gt;

&lt;p&gt;The collateral amount should be bounded by the oracle price together with any explicitly configured premium or penalty.&lt;/p&gt;

&lt;p&gt;The protocol should also include replay protection so that the same deleverage instruction cannot be processed more than once.&lt;/p&gt;

&lt;p&gt;These guarantees should exist on chain.&lt;/p&gt;

&lt;p&gt;A trusted sequencer deciding that an operation is reasonable is not equivalent to the contract enforcing the financial invariants itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Privileged execution paths deserve the same economic scrutiny as public entry points.&lt;/p&gt;

&lt;p&gt;It is easy to stop at the question of who is allowed to call a function.&lt;/p&gt;

&lt;p&gt;But access control does not guarantee that the data supplied by an authorized caller is safe.&lt;/p&gt;

&lt;p&gt;A registered signer is not the same thing as a signer who approved the current message.&lt;/p&gt;

&lt;p&gt;A forced protocol action is not automatically safe merely because the caller is trusted.&lt;/p&gt;

&lt;p&gt;The more important question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once an authorized operation reaches the contract, what prevents economically impossible terms from being accepted?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this case, the contract did not enforce enough.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TYPE_DELEVERAGE&lt;/code&gt; accepted supplied terms after checking signer registration, but without cryptographic consent and without the objective constraints required for safe forced deleverage.&lt;/p&gt;

&lt;p&gt;That gap was enough to turn a risk management mechanism into a direct collateral transfer primitive.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a Constructor Bypass Let Malicious CROSS Makers Block Native Pair Matching</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:28:38 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-constructor-bypass-let-malicious-cross-makers-block-native-pair-matching-3n64</link>
      <guid>https://dev.to/f00dat/how-a-constructor-bypass-let-malicious-cross-makers-block-native-pair-matching-3n64</guid>
      <description>&lt;p&gt;A contract can have no runtime code and still already be executing as a contract.&lt;/p&gt;

&lt;p&gt;That construction window is enough to bypass one of CROSS DEX V3's maker restrictions.&lt;/p&gt;

&lt;p&gt;The router tries to reject contract accounts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _checkAccountCode(address account) private view {
    if (whitelistedCodeAccounts.contains(account)) return;

    if (account.code.length != 0) {
        revert RouterContractAccountBlocked(account);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During a constructor, however:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address.code.length == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A malicious maker can therefore submit a sell order while it is still being deployed. After deployment, the same address has runtime code and can reject native CROSS in &lt;code&gt;receive()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That becomes exploitable on native CROSS quote pairs because CROSS WETH automatically unwraps payouts to non pair recipients.&lt;/p&gt;

&lt;p&gt;When an honest buyer matches the malicious sell order, the maker payout becomes a native CROSS transfer. The maker rejects it, the WETH transfer reverts, and the buyer's entire matching transaction rolls back.&lt;/p&gt;

&lt;p&gt;The malicious order remains live with the same amount, so later buyers can hit the same failure again.&lt;/p&gt;

&lt;p&gt;I reported this finding as &lt;strong&gt;Medium&lt;/strong&gt; through the CertiK SkyShield CROSS bug bounty program. It earned &lt;strong&gt;$109.40&lt;/strong&gt;.&lt;br&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%2F7d45viivdx9op0lc9fm0.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%2F7d45viivdx9op0lc9fm0.png" alt=" " width="741" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/f00dat/f4cd5aaaebc649999cb730606c31f17b" rel="noopener noreferrer"&gt;Public report and PoC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact is localized order book griefing, not theft or global DEX unavailability.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constructor bypass creates a valid contract owned order
&lt;/h2&gt;

&lt;p&gt;Order submission uses the router's &lt;code&gt;checkSubmit&lt;/code&gt; path, which eventually calls &lt;code&gt;_checkAccountCode(_msgSender())&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The restriction works for contracts that already have runtime code. The PoC confirms that with a post deployment control.&lt;/p&gt;

&lt;p&gt;The problem is the constructor case.&lt;/p&gt;

&lt;p&gt;The PoC uses &lt;code&gt;CREATE2&lt;/code&gt; so the attacker's future maker address is known before deployment. The attacker approves that predicted address to spend the required BASE tokens.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;RejectMaker&lt;/code&gt; is deployed.&lt;/p&gt;

&lt;p&gt;Inside its constructor, it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pulls BASE from the attacker
approves the router
submits a sell limit order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that moment the router sees no runtime code and accepts the maker.&lt;/p&gt;

&lt;p&gt;After deployment, the contract has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;receive() external payable {
    revert RejectNative();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order itself remains a normal stored sell order owned by that now deployed contract.&lt;/p&gt;

&lt;p&gt;This matters because matching does not later revalidate whether the owner can safely receive the configured payout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native CROSS settlement turns the maker into a revert point
&lt;/h2&gt;

&lt;p&gt;When an honest buyer crosses a sell order, &lt;code&gt;PairImplV3&lt;/code&gt; pays the maker in the pair's QUOTE token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _exchangeSellOrder(
    uint256 orderId,
    address _owner,
    uint256 amount,
    uint256 fee
) private {
    if (fee == 0) {
        QUOTE.safeTransfer(_owner, amount);
    } else {
        uint256 value = amount - fee;
        QUOTE.safeTransfer(_owner, value);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a normal ERC20 quote asset, the malicious contract can receive the token and matching succeeds.&lt;/p&gt;

&lt;p&gt;Native CROSS pairs are different because QUOTE is CROSS WETH.&lt;/p&gt;

&lt;p&gt;Its transfer path automatically unwraps tokens sent to non pair recipients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _update(
    address from,
    address to,
    uint256 value
) internal override {
    super._update(from, to, value);

    if (to != address(0)) {
        if (!IRouterV3(ROUTER).isPair(to)) {
            _burn(to, value);
            payable(to).sendValue(value);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The maker is not a registered pair.&lt;/p&gt;

&lt;p&gt;So what looks like a WETH payout becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WETH transfer
→ burn
→ native CROSS transfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The malicious maker rejects that native transfer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sendValue&lt;/code&gt; reverts, which propagates through the WETH transfer and through the buyer's match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failed match does not consume the malicious order
&lt;/h2&gt;

&lt;p&gt;Because the buyer transaction reverts atomically, the attempted trade does not partially complete.&lt;/p&gt;

&lt;p&gt;The PoC verifies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;buyer receives BASE
0

maker receives native CROSS
0

buyer match
REVERTED

malicious order remains live
YES

order amount unchanged
YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That persistence is what makes the issue a repeatable liveness problem rather than an isolated failed trade.&lt;/p&gt;

&lt;p&gt;While the order remains in the book, later matching attempts that reach it can encounter the same revert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four controls isolate the exact cause
&lt;/h2&gt;

&lt;p&gt;The Foundry PoC contains four tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&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;&lt;code&gt;testBug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A constructor submitted maker order is accepted, an honest native CROSS match reverts, and the order remains unchanged.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testEOA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same native CROSS path succeeds with an EOA maker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same malicious contract works when QUOTE is a normal ERC20.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testBlock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A contract submitting after deployment is correctly rejected by the router.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reproduced run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These controls rule out the main false positives.&lt;/p&gt;

&lt;p&gt;The issue is not that native pair matching is generally broken.&lt;/p&gt;

&lt;p&gt;It is not that contract makers inherently break every quote asset.&lt;/p&gt;

&lt;p&gt;And it is not that the router completely fails to block deployed contracts.&lt;/p&gt;

&lt;p&gt;The failure requires this specific chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor submission
→ contract maker stored
→ native CROSS quote
→ WETH auto unwrap
→ rejecting receive()
→ settlement revert
→ full rollback
→ order remains live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is Medium, not Low
&lt;/h2&gt;

&lt;p&gt;The demonstrated impact is griefing and matching liveness failure, not theft.&lt;/p&gt;

&lt;p&gt;That limitation is real and is why I do not classify the issue as High or Critical. But it does not reduce the finding to an informational or minor implementation quirk.&lt;/p&gt;

&lt;p&gt;The downgrade rationale identifies several constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the order must be submitted during construction
the pair must use CROSS on the maker payout side
the attacker must lock their own BASE into the malicious order
the impact is localized rather than protocol wide
&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%2Fqpfc513nrrftvu522efc.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%2Fqpfc513nrrftvu522efc.png" alt=" " width="754" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those are valid exploit preconditions and they should be included in the severity analysis.&lt;/p&gt;

&lt;p&gt;They do not, however, change what happens once the malicious order has been accepted.&lt;/p&gt;

&lt;p&gt;The PoC reaches a deterministic state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor submitted order accepted
→ contract finishes deployment
→ maker rejects native CROSS
→ honest buyer reaches the order
→ CROSS WETH auto unwraps the maker payout
→ receive() reverts
→ buyer matching transaction reverts
→ the order and its amount roll back unchanged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the malicious order survives the failed transaction, the failure is repeatable. A later matching attempt that reaches the same order can hit the same revert again.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attack requires attacker supplied inventory
&lt;/h3&gt;

&lt;p&gt;The attacker must fund the sell order with BASE.&lt;/p&gt;

&lt;p&gt;That is a meaningful constraint and it makes the attack less attractive than a zero cost denial of service.&lt;/p&gt;

&lt;p&gt;The article should not hide that fact.&lt;/p&gt;

&lt;p&gt;But requiring the attacker to place real inventory into a valid looking maker order does not eliminate the impact on honest takers. The protocol accepts that order into the book, and once settlement reaches it, the buyer cannot complete the match because the protocol's own payout path invokes a hostile native receive hook.&lt;/p&gt;

&lt;p&gt;The report does not need to assume that the attacker's locked BASE is stolen, lost, or recoverable through some unproven path. The relevant demonstrated fact is narrower: the funded order exists, remains live after the failed match, and can continue to poison settlement while it remains in the book.&lt;/p&gt;

&lt;h3&gt;
  
  
  The taker failure is caused by protocol settlement
&lt;/h3&gt;

&lt;p&gt;The honest buyer is not directly choosing to send native CROSS to the maker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PairImplV3&lt;/code&gt; pays the sell maker through &lt;code&gt;QUOTE.safeTransfer&lt;/code&gt;, and CROSS WETH itself converts that transfer into native CROSS for a non pair recipient.&lt;/p&gt;

&lt;p&gt;There is no recovery path around the reverting &lt;code&gt;sendValue&lt;/code&gt; inside the matching transaction shown by the PoC. The revert propagates and rolls the transaction back.&lt;/p&gt;

&lt;p&gt;That makes the failure part of the normal in scope matching path, not merely a malicious contract reverting an unrelated call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Localized does not mean negligible
&lt;/h3&gt;

&lt;p&gt;The downgrade correctly notes that the issue does not compromise protocol wide solvency, ownership, or accounting.&lt;/p&gt;

&lt;p&gt;I agree.&lt;/p&gt;

&lt;p&gt;The PoC also does not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;direct theft
unauthorized minting
permanent user fund loss
full DEX unavailability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limitations are exactly why the report was submitted as Medium griefing.&lt;/p&gt;

&lt;p&gt;But the affected path is still a real user facing trading path. An order accepted by the router can become persistently unmatchable on a native CROSS pair and make honest buyer transactions revert whenever matching reaches it.&lt;/p&gt;

&lt;p&gt;That is more than a cosmetic inconsistency or a best practice observation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The contract restriction is intentionally enforced by the router
&lt;/h3&gt;

&lt;p&gt;CROSS explicitly blocks non whitelisted accounts that already have runtime code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (account.code.length != 0) {
    revert RouterContractAccountBlocked(account);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC confirms this policy with &lt;code&gt;testBlock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The constructor path defeats that restriction only because runtime code has not been installed yet. The accepted address later becomes exactly the type of deployed contract the router normally refuses as a maker.&lt;/p&gt;

&lt;p&gt;That bypass would still be incomplete as a report if no downstream effect existed.&lt;/p&gt;

&lt;p&gt;Here there is one: the stored contract maker can make the native payout revert and abort honest matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  The controls support Medium griefing
&lt;/h3&gt;

&lt;p&gt;The four tests isolate the impact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post deployment contract submission blocked
YES

constructor submitted contract order accepted
YES

EOA maker on native CROSS succeeds
YES

same malicious maker on non native quote succeeds
YES

native CROSS match against malicious maker reverts
YES

buyer receives BASE
NO

maker receives native CROSS
NO

malicious order remains live after revert
YES

order amount remains unchanged
YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue is therefore conditional and narrow, but it is also concrete and repeatable.&lt;/p&gt;

&lt;p&gt;That maps cleanly to the report's original framing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Smart Contract Medium: Griefing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I therefore consider Medium technically better supported by the demonstrated behavior than Low.&lt;/p&gt;

&lt;p&gt;The downgrade's prerequisites reduce exploitability and blast radius. They do not turn a repeatable failure of honest matching against an accepted order into a non impactful implementation detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the settlement dependency
&lt;/h2&gt;

&lt;p&gt;The most direct fix is to prevent arbitrary contract receive hooks from controlling DEX matching.&lt;/p&gt;

&lt;p&gt;The report recommends auto unwrapping WETH only for EOAs.&lt;/p&gt;

&lt;p&gt;Contract recipients should receive WETH instead and explicitly unwrap it later if they can safely accept native CROSS.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (
    !IRouterV3(ROUTER).isPair(to)
    &amp;amp;&amp;amp; to.code.length == 0
) {
    _burn(to, value);
    payable(to).sendValue(value);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate explicit &lt;code&gt;withdraw()&lt;/code&gt; path can let contract recipients unwrap voluntarily.&lt;/p&gt;

&lt;p&gt;That changes the security property from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maker receive() decides
whether matching succeeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;matching completes in WETH
maker decides later
whether to unwrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router's account restriction should also not assume that &lt;code&gt;code.length == 0&lt;/code&gt; proves an address will permanently behave like an EOA.&lt;/p&gt;

&lt;p&gt;But the settlement fix is more important because it removes the ability of an untrusted recipient hook to revert an otherwise valid match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broader audit lesson
&lt;/h2&gt;

&lt;p&gt;This finding comes from combining two behaviors that look manageable on their own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor:
code.length == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WETH transfer:
automatic native unwrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerability appears only when the entire lifecycle is followed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maker validation
→ constructor order
→ stored owner
→ later matching
→ quote payout
→ auto unwrap
→ receive() revert
→ transaction rollback
→ order survives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the broader lesson.&lt;/p&gt;

&lt;p&gt;If a protocol validates an account only when state is created, ask whether the account can behave differently when that state is consumed later.&lt;/p&gt;

&lt;p&gt;And if settlement sends native value to an arbitrary recipient, ask whether that recipient can make unrelated users' transactions fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CROSS DEX V3 tries to block contract makers by checking &lt;code&gt;account.code.length&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A constructor can bypass that restriction because runtime code is still absent.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;CREATE2&lt;/code&gt;, the PoC funds a predicted maker address and submits a sell order from inside the maker's constructor. After deployment, the same maker rejects native CROSS.&lt;/p&gt;

&lt;p&gt;When an honest buyer later matches that order on a native CROSS quote pair, CROSS WETH automatically unwraps the maker payout into native CROSS.&lt;/p&gt;

&lt;p&gt;The maker rejects the transfer.&lt;/p&gt;

&lt;p&gt;The entire buyer match reverts, and the malicious order remains live with the same amount.&lt;/p&gt;

&lt;p&gt;The core invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A maker's payout behavior should not be able to make honest taker matching revert.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A stored order should be safe to settle even if its owner is a contract with hostile native receive behavior.&lt;/p&gt;

&lt;p&gt;That repeatable, user facing matching failure is why I classify the issue as Medium rather than Low.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a Positive offsetSeconds Bug Let a CROSS Forge Mint Twice the Intended ERC20 Period Limit</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:32:35 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-positive-offsetseconds-bug-let-a-cross-forge-mint-2x-the-intended-erc20-period-limit-eon</link>
      <guid>https://dev.to/f00dat/how-a-positive-offsetseconds-bug-let-a-cross-forge-mint-2x-the-intended-erc20-period-limit-eon</guid>
      <description>&lt;p&gt;&lt;code&gt;ERC20MintLimited&lt;/code&gt; is not an unlimited forge minter.&lt;/p&gt;

&lt;p&gt;It authorizes a forge to mint only while that forge remains inside the configured capacity for the current period.&lt;/p&gt;

&lt;p&gt;This finding breaks that second authorization boundary.&lt;/p&gt;

&lt;p&gt;With a positive &lt;code&gt;offsetSeconds&lt;/code&gt;, CROSS calculates the current period start incorrectly. At the native UTC boundary, &lt;code&gt;PeriodManager.getPeriodStartForTime()&lt;/code&gt; can return the next offset boundary even though that boundary is still in the future.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; interprets the changed timestamp as a new period and restores a full &lt;code&gt;LIMIT&lt;/code&gt; of capacity before the real configured period has ended.&lt;/p&gt;

&lt;p&gt;The PoC demonstrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Configured limit
100,000 tokens per period

First mint
100,000 tokens

Remaining capacity
0

Configured +9h period ended?
NO

Capacity at UTC boundary
100,000 tokens

Second mint
100,000 tokens

Total inside the same intended period
200,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reported the finding as &lt;strong&gt;Critical&lt;/strong&gt; through the CertiK SkyShield CROSS bug bounty program. CROSS finalized it as Low, and the report earned &lt;strong&gt;$109.40&lt;/strong&gt;.&lt;br&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%2F5vk92mysafqto5t2wrzk.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%2F5vk92mysafqto5t2wrzk.png" alt=" " width="731" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/f00dat/fe7084f0045a3e69434ad973ad2a21d2" rel="noopener noreferrer"&gt;Public report and PoC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The severity dispute turns on whether forge membership alone authorizes the amount being minted. The contract does not support that interpretation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mint policy has two gates
&lt;/h2&gt;

&lt;p&gt;The base mint path requires &lt;code&gt;onlyForge&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier onlyForge() {
    if (!isForge(_msgSender())) {
        revert TokenBase__OnlyForge(_msgSender());
    }
    _;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;ERC20MintLimited&lt;/code&gt; routes minting through &lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt;, which also checks the available period capacity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (periodCapacity &amp;lt; amount) {
    revert ERC20PeriodMintLimit__ExceedsPeriodLimit(
        amount,
        periodCapacity
    );
}

_periodCapacity = periodCapacity - amount;

super.mint(to, amount);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The effective policy is therefore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;caller is an authorized forge

AND

amount &amp;lt;= remaining capacity
for the current configured period
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC does not claim that a random EOA bypasses &lt;code&gt;onlyForge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It proves that a forge with zero remaining capacity can receive a new full capacity before the period actually ends and mint supply that the period limit should reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  The positive offset formula is wrong
&lt;/h2&gt;

&lt;p&gt;For positive offsets, the vulnerable implementation effectively computes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(timestamp / duration)
× duration
+ offsetSeconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an offset period, the timestamp must be shifted before flooring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(
    (timestamp - offsetSeconds)
    / duration
)
× duration
+ offsetSeconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;duration = 86,400 seconds
offsetSeconds = 32,400 seconds
LIMIT = 100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intended period is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1000 days + 9 hours,
 1001 days + 9 hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One second before the UTC boundary, the forge mints the full &lt;code&gt;LIMIT&lt;/code&gt;. Capacity becomes zero, and an additional one wei mint is rejected.&lt;/p&gt;

&lt;p&gt;Then the timestamp reaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1001 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configured &lt;code&gt;+9h&lt;/code&gt; period still has nine hours remaining.&lt;/p&gt;

&lt;p&gt;The correct current period start is still:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1000 days + 9 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;block.timestamp
86486400

expected period start
86432400

actual period start
86518800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So CROSS returns a current period start that is greater than &lt;code&gt;block.timestamp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That impossible timestamp is the root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The future timestamp restores capacity
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; resets capacity whenever the calculated period start changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 currentPeriodStart =
    _period.getCurrentPeriodStart();

if (currentPeriodStart != _periodStartTime) {
    _periodStartTime = currentPeriodStart;
    periodCapacity = _limit;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the UTC boundary, the bad positive offset calculation changes &lt;code&gt;currentPeriodStart&lt;/code&gt; even though the real configured period is still active.&lt;/p&gt;

&lt;p&gt;Capacity is restored to &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same forge then mints another full &lt;code&gt;LIMIT&lt;/code&gt;, and the call reaches the real ERC20 mint path.&lt;/p&gt;

&lt;p&gt;This is not merely an incorrect view value. &lt;code&gt;totalSupply&lt;/code&gt; increases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC isolates the bug
&lt;/h2&gt;

&lt;p&gt;The Foundry proof uses the real &lt;code&gt;ERC20MintLimited&lt;/code&gt; preset, &lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt;, &lt;code&gt;PeriodManager&lt;/code&gt;, &lt;code&gt;onlyForge&lt;/code&gt;, and ERC20 mint path.&lt;/p&gt;

&lt;p&gt;It contains three tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&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;&lt;code&gt;testBug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The full limit is consumed, capacity resets too early, and the same forge mints a second full limit before the actual period ends.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An extra mint is correctly rejected while capacity is legitimately zero before the false reset.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testAccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A non forge caller cannot mint.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant result is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Non forge mint
REJECTED

First forge mint
LIMIT

Capacity afterward
0

Extra forge mint before UTC
REJECTED

Configured +9h period still active at UTC
YES

Returned period start in future
YES

Capacity at UTC
LIMIT

Second forge mint
LIMIT

Total minted in intended period
2 × LIMIT

Excess over configured limit
1 × LIMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the issue cannot be reduced to a generic access control discussion. Access control is working. The quantitative mint authorization is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I classified it as Critical
&lt;/h2&gt;

&lt;p&gt;At the time of submission, the CROSS SkyShield scope listed &lt;strong&gt;Unauthorized mint, burn, or transfer of crypto assets&lt;/strong&gt; as a Critical smart contract impact.&lt;/p&gt;

&lt;p&gt;My report maps the second full &lt;code&gt;LIMIT&lt;/code&gt; mint to that category because the supply exceeds the amount authorized by the preset for the active period.&lt;/p&gt;

&lt;p&gt;After the first mint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;remaining period capacity = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the false boundary, another mint correctly reverts.&lt;/p&gt;

&lt;p&gt;The real configured period has not changed one second later. Only the incorrect &lt;code&gt;PeriodManager&lt;/code&gt; result changes.&lt;/p&gt;

&lt;p&gt;That result creates capacity that should not exist and allows another full supply increase.&lt;/p&gt;

&lt;p&gt;If forge membership alone authorized every amount, &lt;code&gt;ERC20MintLimited&lt;/code&gt; would add no security boundary beyond &lt;code&gt;onlyForge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But it does add one: a per period quantitative limit.&lt;/p&gt;

&lt;p&gt;The PoC demonstrates one full &lt;code&gt;LIMIT&lt;/code&gt; of supply above that limit.&lt;/p&gt;

&lt;p&gt;That is the technical basis for my Critical assessment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Low rationale is not supported by the PoC
&lt;/h2&gt;

&lt;p&gt;The downgrade explanation raised four points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Downgrade point&lt;/th&gt;
&lt;th&gt;What the code and PoC show&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Minting remains restricted to forges&lt;/td&gt;
&lt;td&gt;Correct, but the finding is an amount authorization bypass, not a forge identity bypass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The effect is bounded and timing dependent&lt;/td&gt;
&lt;td&gt;The bound is still one full extra &lt;code&gt;LIMIT&lt;/code&gt; in the demonstrated period. Timing is deterministic at the native boundary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native aligned windows remain near &lt;code&gt;LIMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The affected positive offset configuration is supported. A different configuration working correctly does not repair this one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DEFAULT_ADMIN_ROLE&lt;/code&gt; can adjust &lt;code&gt;LIMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A manual parameter response does not fix the incorrect period calculation or restore automatic enforcement.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of those points changes the core state transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;period not ended
capacity already 0
extra mint should revert
future period start returned
capacity reset to LIMIT
second full LIMIT mint succeeds
totalSupply increases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I therefore consider the Low rationale technically inconsistent with the behavior demonstrated by the contract and PoC.&lt;/p&gt;

&lt;p&gt;That conclusion does not require speculating about motive. The public evidence supports a strong technical disagreement. It does not establish bad faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded does not mean harmless
&lt;/h2&gt;

&lt;p&gt;The PoC uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LIMIT = 100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intended maximum
100,000 tokens

actual minted
200,000 tokens

excess
100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue does not need infinite minting to violate the security policy.&lt;/p&gt;

&lt;p&gt;The entire purpose of the rate limit is to prevent the forge from creating that additional supply before the configured period ends.&lt;/p&gt;

&lt;p&gt;The relevant question is whether the contract creates supply that its own limit logic should reject.&lt;/p&gt;

&lt;p&gt;The PoC shows that it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;The offset must be applied before the timestamp is floored.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shifted =
timestamp - offset

periodStart =
shifted
- (shifted % duration)
+ offset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The period calculation should always preserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;periodStart &amp;lt;= timestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timestamp &amp;lt; periodStart + duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the PoC configuration, the regression should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mint LIMIT before UTC boundary
SUCCEEDS

mint 1 extra wei
REVERTS

reach UTC boundary
capacity remains 0

mint LIMIT
REVERTS

reach actual +9h boundary
capacity resets to LIMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report also recommends normalizing offsets by the period duration so equivalent offsets map to the same canonical boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Role authorization and quantitative authorization are separate controls.&lt;/p&gt;

&lt;p&gt;A protocol can correctly enforce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;who may call
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while incorrectly enforcing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;how much that caller may do
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same reasoning applies to mint quotas, withdrawal ceilings, bridge caps, spending limits, epoch budgets, and rate limits.&lt;/p&gt;

&lt;p&gt;When a quantity is part of the policy, bypassing that quantity is an authorization failure even if the caller identity is valid.&lt;/p&gt;

&lt;p&gt;Time based controls add another useful invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A function returning the start of the current period must never return a timestamp in the future.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That invariant would have exposed this bug directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The CROSS bug is a positive offset period accounting failure in the mint limit path.&lt;/p&gt;

&lt;p&gt;After an authorized forge consumes its full &lt;code&gt;LIMIT&lt;/code&gt;, capacity should remain zero until the actual offset boundary.&lt;/p&gt;

&lt;p&gt;Instead, at the earlier UTC boundary, &lt;code&gt;PeriodManager&lt;/code&gt; can report the next &lt;code&gt;+9h&lt;/code&gt; boundary as the current period start.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; treats that future timestamp as a new period, restores a full &lt;code&gt;LIMIT&lt;/code&gt;, and allows another full mint while the same configured period is still active.&lt;/p&gt;

&lt;p&gt;The PoC proves that non forge callers remain blocked, exhausted capacity normally rejects extra minting, the period has not ended, capacity resets early, and total minting reaches &lt;code&gt;2 × LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The relevant authorization question is not only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;is the caller a forge?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;is the caller a forge
and does it still have capacity
inside the current configured period?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second condition fails because of CROSS's period calculation.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as Critical, and why I do not consider the Low downgrade technically supported by the demonstrated behavior.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Double Subtracting cash_reserve Reopened Full CurrentSui Markets and Diverted Liquidity Mining Rewards</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:48:00 +0000</pubDate>
      <link>https://dev.to/f00dat/how-double-subtracting-cashreserve-reopened-full-currentsui-markets-and-diverted-liquidity-mining-160m</link>
      <guid>https://dev.to/f00dat/how-double-subtracting-cashreserve-reopened-full-currentsui-markets-and-diverted-liquidity-mining-160m</guid>
      <description>&lt;p&gt;A deposit cap is supposed to answer one simple question:&lt;/p&gt;

&lt;p&gt;Is this market already full?&lt;/p&gt;

&lt;p&gt;In CurrentSui, that answer can become wrong once the protocol has accumulated reserve.&lt;/p&gt;

&lt;p&gt;The reason is a double subtraction of &lt;code&gt;cash_reserve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The protocol’s &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; value already represents depositor backing net of reserve. But &lt;code&gt;deposit_limit_breached()&lt;/code&gt; subtracts &lt;code&gt;cash_reserve&lt;/code&gt; again before comparing the result with &lt;code&gt;max_deposit_amount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That makes current utilization look smaller than it really is.&lt;/p&gt;

&lt;p&gt;Once positive reserve exists, a market that is already full according to the protocol’s own depositor backing metric can admit another deposit that should have been rejected.&lt;/p&gt;

&lt;p&gt;The impact becomes more interesting when deposit liquidity mining is active. An extra deposit accepted through the broken cap receives cTokens, enters the reward share set, and starts receiving campaign emissions.&lt;/p&gt;

&lt;p&gt;The PoC demonstrated this with an exact reserve state: a market already at its configured cap accepted an additional deposit equal to 8,000 USDC of reserve, and that new depositor later captured more than 1,000 USDC of rewards that would otherwise have remained with the original lender.&lt;/p&gt;

&lt;p&gt;I reported this finding as Medium in the Sherlock CurrentSui contest. It earned $92.24.&lt;br&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%2Fsjc3jfohcna3xhv4ozri.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%2Fsjc3jfohcna3xhv4ozri.png" alt=" " width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1256/voting/244" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flash loan path used in the PoC is only a deterministic way to create positive reserve. The exploit itself is the ordinary public deposit path.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cap is supposed to measure total market deposits
&lt;/h2&gt;

&lt;p&gt;CurrentSui defines &lt;code&gt;max_deposit_amount&lt;/code&gt; as the maximum amount that can be deposited across the market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/// max amount this asset can be deposited across the market
max_deposit_amount: u64,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/asset.move#L17-L20" rel="noopener noreferrer"&gt;asset.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The intended invariant is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
&amp;lt;=
max_deposit_amount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If current backing is already equal to the configured cap, a new deposit should fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depositor backing already removes cash_reserve
&lt;/h2&gt;

&lt;p&gt;The important accounting path begins in &lt;code&gt;reserve.move&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun cash_plus_borrows_minus_reserves&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    self.debt.add_u64(self.cash).sub(self.cash_reserve)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exchange rate uses that value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun exchange_rate&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    if (self.total_supply == 0) {
        return float::from_quotient(1, 1)
    };

    let numerator = self.cash_plus_borrows_minus_reserves();
    let denominator = float::from(self.total_supply);

    numerator.div(denominator)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; is calculated from the exchange rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun total_deposit_plus_interest&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    self.exchange_rate().mul_u64(self.total_supply)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L74-L100" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the resulting depositor backing already reflects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debt
+
cash
-
cash_reserve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reserve subtraction has already happened before the deposit cap check runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deposit gate subtracts the same reserve again
&lt;/h2&gt;

&lt;p&gt;The bug is in &lt;code&gt;deposit_limit_breached()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun deposit_limit_breached&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;,
    increment: u64,
    limit: u64
): bool {
    let total_deposit_plus_interest =
        self.total_deposit_plus_interest();

    total_deposit_plus_interest.ceil()
        + increment
        - self.cash_reserve.ceil()
        &amp;gt; limit
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L82-L89" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conceptually, the intended check is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
&amp;gt;
cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the implementation behaves like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
-
cash_reserve
&amp;gt;
cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;current depositor backing&lt;/code&gt; was already net of reserve, the gate understates utilization by the reserve term again.&lt;/p&gt;

&lt;p&gt;In the PoC, the broken check reopens apparent capacity equal to the 8,000 USDC reserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public deposit path trusts the broken result
&lt;/h2&gt;

&lt;p&gt;The market deposit flow directly relies on that helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    !reserve.deposit_limit_breached&amp;lt;MarketType&amp;gt;(
        deposit_amount,
        asset_config.max_deposit_amount(),
    ),
    error::market_deposit_limit_exceeded(),
);

let ctoken = reserve.mint_ctokens(coin);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/market.move#L264-L300" rel="noopener noreferrer"&gt;market.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is no later cap reconciliation.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;deposit_limit_breached()&lt;/code&gt; returns false, the deposit is accepted and cTokens are minted.&lt;/p&gt;

&lt;p&gt;That is what turns the accounting mistake into a real public state transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positive reserve is a normal protocol state
&lt;/h2&gt;

&lt;p&gt;The PoC uses flash loan fees because they make it easy to construct an exact reserve amount without changing depositor backing or depositor shares.&lt;/p&gt;

&lt;p&gt;But the vulnerability is not specific to flash loans.&lt;/p&gt;

&lt;p&gt;The protocol can accumulate reserve through ordinary mechanisms, including borrow interest accrual, liquidation revenue, over repayment donated to reserve, and retained flash loan fees.&lt;/p&gt;

&lt;p&gt;For example, borrow interest accrual increases &lt;code&gt;cash_reserve&lt;/code&gt; here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let interest_accumulated =
    self.debt.mul(simple_interest_factor);

self.debt =
    self.debt.add(interest_accumulated);

self.cash_reserve =
    self.cash_reserve.add(
        reserve_factor.mul(interest_accumulated)
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L139-L143" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So an ordinary user can encounter the vulnerable state after reserve has accumulated naturally.&lt;/p&gt;

&lt;p&gt;The attacker does not need to control the mechanism that created the reserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the flash loan setup does not make the exploit privileged
&lt;/h2&gt;

&lt;p&gt;The test uses the real flash loan fee path because it creates a clean state where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depositor backing
unchanged

depositor shares
unchanged

protocol reserve
positive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flash loan borrowing itself requires a permissioned caller, but that permission is only used to build the test state.&lt;/p&gt;

&lt;p&gt;The action that exploits the broken cap is the normal public &lt;code&gt;deposit()&lt;/code&gt; entry point.&lt;/p&gt;

&lt;p&gt;Once positive reserve already exists, the attacker only needs an ordinary position and the ability to deposit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accepted deposit immediately affects liquidity mining
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;handle_mint()&lt;/code&gt; succeeds, the deposit entry point updates the user’s deposit reward share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let (
    ctoken_amount,
    total_ctoken_amount
) = market.handle_mint(
    obligation_owner_cap.id(),
    coin,
    now
);

let miner =
    market.borrow_liquidity_mining_mut&amp;lt;MarketType&amp;gt;();

miner.update_obligation_reward_manager&amp;lt;
    MarketType,
    CoinType
&amp;gt;(
    get_deposit_reward_type(),
    obligation_owner_cap.id(),
    total_ctoken_amount,
    clock
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/entry_points/lending/deposit.move#L49-L57" rel="noopener noreferrer"&gt;deposit.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That update changes the reward manager share accounting used to distribute campaign emissions.&lt;/p&gt;

&lt;p&gt;So the broken cap does not stop at market capacity.&lt;/p&gt;

&lt;p&gt;It changes who participates in reward distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC proves the full chain
&lt;/h2&gt;

&lt;p&gt;The Move PoC uses seven tests, each isolating a different part of the finding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&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;&lt;code&gt;cap_fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The exploit sized deposit correctly fails when no reserve has created false capacity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gate_lies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After reserve exists, the market is still economically full but &lt;code&gt;deposit_limit_breached()&lt;/code&gt; returns false for the extra deposit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The attacker successfully deposits the full reopened amount through the public path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reserve_8k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The setup proves the exact reserve amount and computes the expected reward diversion.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mine_full&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Without the attacker, the original lender receives the full campaign reward.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mine_1k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The accepted extra deposit produces a measurable attacker reward and an equal lender loss.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;live_1k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same diversion works after the reward campaign is already active.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The root cause is demonstrated especially clearly by &lt;code&gt;gate_lies&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before the attacker deposits, the test proves that depositor backing remains equal to the cap and that the new deposit would push backing above it.&lt;/p&gt;

&lt;p&gt;Yet the helper still says the limit is not breached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    !r.deposit_limit_breached&amp;lt;MainMarket&amp;gt;(
        target,
        cap0
    ),
    37
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The later reward tests then show why that incorrect admission matters economically.&lt;/p&gt;

&lt;p&gt;Without the exploit, the lender receives the full campaign. With the extra deposit, the attacker receives more than 1,000 USDC, and the lender’s reduction matches the attacker’s gain.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;live_1k&lt;/code&gt; test also starts the campaign before the attacker enters, proving that the attacker can dilute an already active reward program rather than merely joining some future campaign.&lt;/p&gt;

&lt;p&gt;The complete test run passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test result: OK.
Total tests: 7; passed: 7; failed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evidence therefore forms one continuous path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;full market
→ positive reserve
→ utilization undercounted
→ extra public deposit accepted
→ reward shares increased
→ attacker receives campaign rewards
→ original lender loses the same value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I reported it as Medium
&lt;/h2&gt;

&lt;p&gt;The impact demonstrated by the PoC is direct user yield loss.&lt;/p&gt;

&lt;p&gt;The attacker enters a market that should reject new deposits, becomes part of the active deposit reward share set, and receives emissions that would otherwise have remained with existing lenders.&lt;/p&gt;

&lt;p&gt;The test demonstrates a four figure attacker reward and an equal lender loss.&lt;/p&gt;

&lt;p&gt;At the same time, exploitation depends on positive protocol reserve and an active deposit liquidity mining campaign.&lt;/p&gt;

&lt;p&gt;Those are realistic protocol states, but they are still required conditions.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as Medium.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is straightforward
&lt;/h2&gt;

&lt;p&gt;The cap should use one consistent measure of depositor backing.&lt;/p&gt;

&lt;p&gt;Since &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; already excludes &lt;code&gt;cash_reserve&lt;/code&gt;, the second reserve subtraction should be removed.&lt;/p&gt;

&lt;p&gt;A safe check is conceptually equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self.total_deposit_plus_interest().ceil()
    + increment
    &amp;gt; limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key invariant is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if current depositor backing
is already equal to max_deposit_amount,

any additional deposit must revert
regardless of cash_reserve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regression coverage should exercise every normal reserve growth path that can expose the bug, including interest accrual, liquidation revenue, over repayment, and flash loan fees.&lt;/p&gt;

&lt;p&gt;It is also worth keeping a reward focused regression test. A deposit cap bug can become a yield ownership bug when accepted deposits feed directly into liquidity mining shares.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;This finding is a good example of why derived accounting values need clear semantics.&lt;/p&gt;

&lt;p&gt;If one helper already represents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cash + borrows - reserves
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then a downstream caller must not silently assume reserve still needs to be removed.&lt;/p&gt;

&lt;p&gt;The arithmetic error here is small in code but crosses subsystem boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;market risk control
        ↓
deposit admission
        ↓
reward share accounting
        ↓
user yield distribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful audit question is not only whether a cap computes the correct number.&lt;/p&gt;

&lt;p&gt;It is also what the rest of the protocol trusts once that cap says yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CurrentSui’s deposit cap gate used &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt;, a value already net of &lt;code&gt;cash_reserve&lt;/code&gt;, and then subtracted &lt;code&gt;cash_reserve&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;That understated utilization whenever protocol reserve was positive.&lt;/p&gt;

&lt;p&gt;The PoC showed that a market already at its configured cap could accept an additional 8,000 USDC after exactly 8,000 USDC of reserve had accumulated.&lt;/p&gt;

&lt;p&gt;Because a successful deposit immediately enters the liquidity mining share accounting, the extra depositor could then capture more than 1,000 USDC from an active reward campaign, with the original lender losing the same amount.&lt;/p&gt;

&lt;p&gt;The underlying rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A reserve accounting component should be removed exactly once from the metric used to enforce a market wide deposit cap.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When that rule is broken, a limit bypass can propagate into a completely different subsystem and become direct user yield loss.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How an Expired CurrentSui Reward Pool Could Refund Yield Borrowers Had Already Earned</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:13:06 +0000</pubDate>
      <link>https://dev.to/f00dat/how-an-expired-currentsui-reward-pool-could-refund-yield-borrowers-had-already-earned-32aa</link>
      <guid>https://dev.to/f00dat/how-an-expired-currentsui-reward-pool-could-refund-yield-borrowers-had-already-earned-32aa</guid>
      <description>&lt;p&gt;An expired reward pool is not necessarily an economically empty reward pool.&lt;/p&gt;

&lt;p&gt;That distinction caused this finding in CurrentSui’s borrow liquidity mining flow.&lt;/p&gt;

&lt;p&gt;A borrower can already be active before a new reward campaign is created. Their participation is reflected in the global reward share accounting, but the borrower specific tracker for the new pool is created only when that borrower interacts again or claims.&lt;/p&gt;

&lt;p&gt;If the campaign expires before that tracker is materialized, the close path can see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and treat the pool as safe to refund.&lt;/p&gt;

&lt;p&gt;The problem is that zero materialized trackers does not mean zero economically accrued rewards.&lt;/p&gt;

&lt;p&gt;The proof of concept showed both sides of that mismatch. If the borrower claims first, the borrower receives the reward and a later close refunds zero. If the pool is closed first, the close recipient receives the same value and the borrower can no longer claim it.&lt;/p&gt;

&lt;p&gt;I reported the finding as High in the Sherlock CurrentSui contest. Sherlock ultimately classified it as Medium, and it earned $92.24.&lt;br&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%2Fw00e7b2iwpu73htragv9.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%2Fw00e7b2iwpu73htragv9.png" alt=" " width="800" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1256/voting/154" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bug is therefore not simply about claim timing. It is about using lazy materialization as a proxy for reward ownership.&lt;/p&gt;
&lt;h2&gt;
  
  
  The accounting model creates economic rewards before claim
&lt;/h2&gt;

&lt;p&gt;Claim is not the moment the reward first comes into existence.&lt;/p&gt;

&lt;p&gt;CurrentSui tracks borrow liquidity mining participation globally through &lt;code&gt;total_shares&lt;/code&gt;, and the pool later updates &lt;code&gt;cumulative_rewards_per_share&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means an active borrower can participate economically in a campaign even if the borrower specific reward tracker for that pool has not yet been created.&lt;/p&gt;

&lt;p&gt;This matters because lazy accounting is only safe when every lifecycle function understands that some obligations may exist economically before they exist as explicit per user state.&lt;/p&gt;

&lt;p&gt;The close path does not preserve that distinction.&lt;/p&gt;
&lt;h2&gt;
  
  
  A borrower can already be active before the campaign exists
&lt;/h2&gt;

&lt;p&gt;When a user borrows, the borrow path updates the obligation reward manager immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let miner = market.borrow_liquidity_mining_mut&amp;lt;MarketType&amp;gt;();

miner.update_obligation_reward_manager&amp;lt;MarketType, CoinType&amp;gt;(
    get_borrow_reward_type(),
    obligation_owner_cap.id(),
    total_borrow.floor(),
    clock
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/entry_points/lending/borrow.move#L51-L66" rel="noopener noreferrer"&gt;borrow.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So a borrower can already have active borrow reward shares when a later reward pool is created.&lt;/p&gt;

&lt;p&gt;That is the setup used by the PoC: the borrower exists first, then the campaign is added.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new pool starts with no materialized borrower trackers
&lt;/h2&gt;

&lt;p&gt;A newly created reward pool starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;along with zero allocated rewards and zero cumulative rewards per share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let pool_reward = PoolReward {
    id,
    reward_type: object::id::&amp;lt;TypeName&amp;gt;(&amp;amp;RewardType&amp;lt;CoinType&amp;gt; {}),
    start_time_ms,
    end_time_ms,
    total_rewards: rewards.value(),
    allocated_rewards: float::from(0),
    cumulative_rewards_per_share: float::from(0),
    num_obligation_reward_managers: 0,
    additional_fields
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L161-L194" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Starting at zero is not itself a problem.&lt;/p&gt;

&lt;p&gt;The problem appears later when the close path interprets that counter as evidence that no borrower reward remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rewards accrue through the global share total
&lt;/h2&gt;

&lt;p&gt;The pool update logic distributes unlocked rewards across &lt;code&gt;total_shares&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (pool_reward_manager.total_shares == 0) {
    pool_reward_manager.last_update_time_ms = cur_time_ms;
    return
};

let unlocked_rewards =
    float::from(pool_reward.total_rewards).mul(
        float::from(time_passed_ms)
    ).div(
        float::from(pool_reward.end_time_ms - pool_reward.start_time_ms)
    );

pool_reward.allocated_rewards =
    pool_reward.allocated_rewards.add(unlocked_rewards);

pool_reward.cumulative_rewards_per_share =
    pool_reward.cumulative_rewards_per_share.add(
        unlocked_rewards.div(
            float::from(pool_reward_manager.total_shares)
        )
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L281-L336" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As time passes, value becomes attributable to active shares.&lt;/p&gt;

&lt;p&gt;So the system can already have borrower yield that is economically earned even though the pool still has no materialized borrower tracker for that user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The borrower tracker is created lazily
&lt;/h2&gt;

&lt;p&gt;The user specific tracker is filled later inside &lt;code&gt;update_obligation_reward_manager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That same path increments &lt;code&gt;num_obligation_reward_managers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let optional_reward = obligation_reward_manager.rewards.borrow_mut(i);

if (optional_reward.is_none()) {
    if (obligation_reward_manager.last_update_time_ms &amp;lt;= pool_reward.end_time_ms) {
        optional_reward.fill(ObligationReward {
            pool_reward_id: object::id(pool_reward),
            earned_rewards: {
                if (obligation_reward_manager.last_update_time_ms &amp;lt;= pool_reward.start_time_ms) {
                    pool_reward.cumulative_rewards_per_share.mul(
                        float::from(obligation_reward_manager.share)
                    )
                } else {
                    float::from(0)
                }
            },
            cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share
        });

        pool_reward.num_obligation_reward_managers =
            pool_reward.num_obligation_reward_managers + 1;
    };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L338-L399" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates the dangerous state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;borrower has active economic participation
YES

borrower specific tracker for this pool
NO

num_obligation_reward_managers
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter describes materialization state, not necessarily economic obligation state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expired pool close path relies on that counter
&lt;/h2&gt;

&lt;p&gt;The close function extracts the pool, checks that the campaign has ended, and then requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    num_obligation_reward_managers == 0,
    error::liquidity_mining_not_all_rewards_claimed()
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relevant code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun close_pool_reward&amp;lt;CoinType&amp;gt;(
    pool_reward_manager: &amp;amp;mut PoolRewardManager,
    index: u64,
    clock: &amp;amp;Clock
): Balance&amp;lt;CoinType&amp;gt; {
    let optional_pool_reward =
        pool_reward_manager.pool_rewards.borrow_mut(index);

    let PoolReward {
        id,
        reward_type: _,
        start_time_ms: _,
        end_time_ms,
        total_rewards: _,
        allocated_rewards: _,
        cumulative_rewards_per_share: _,
        num_obligation_reward_managers,
        mut additional_fields,
    } = optional_pool_reward.extract();

    object::delete(id);

    let cur_time_ms = clock.timestamp_ms();

    assert!(
        cur_time_ms &amp;gt;= end_time_ms,
        error::liquidity_mining_pool_reward_period_not_over()
    );

    assert!(
        num_obligation_reward_managers == 0,
        error::liquidity_mining_not_all_rewards_claimed()
    );

    let reward_balance =
        additional_fields.remove&amp;lt;
            RewardBalance&amp;lt;CoinType&amp;gt;,
            Balance&amp;lt;CoinType&amp;gt;
        &amp;gt;(RewardBalance&amp;lt;CoinType&amp;gt;{});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L106-L136" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The close path does not first refresh the pool and materialize the reward state of already active borrowers.&lt;/p&gt;

&lt;p&gt;As a result, it can interpret a zero tracker count as permission to refund a pool whose global share accounting would produce a nonzero borrower claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim path exposes the mismatch
&lt;/h2&gt;

&lt;p&gt;The normal claim path does something the close path does not.&lt;/p&gt;

&lt;p&gt;Before reading the borrower’s reward tracker, it calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;update_obligation_reward_manager(
    pool_reward_manager,
    obligation_id,
    clock,
    false
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L227-L262" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That update materializes the borrower’s reward state.&lt;/p&gt;

&lt;p&gt;So the same expired campaign can produce two mutually exclusive ownership outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Borrower claims first
→ reward state is materialized
→ borrower receives the accrued reward
→ later close has nothing to refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pool closes first
→ tracker count is still zero
→ reward balance is refunded
→ borrower cannot realize the reward afterward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The borrower’s economic participation is the same in both paths. Only the timing of materialization changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC proves the value is the borrower’s reward, not leftover dust
&lt;/h2&gt;

&lt;p&gt;The Move PoC uses five tests that answer different questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&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;&lt;code&gt;ctl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An already active borrower can claim more than 10 USDC after the campaign expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If the borrower claims first, the later pool close refund is exactly zero.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If the pool closes first, the close recipient receives more than 10 USDC.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cmp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The normal borrower claim is exactly equal to the close first refund.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After the pool closes first, the borrower can no longer claim the reward.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strongest comparison is in &lt;code&gt;cmp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(a &amp;gt; TEN, 5);
assert!(b &amp;gt; TEN, 6);
assert!(t == 0, 7);
assert!(a + t == REWARD, 8);
assert!(a == b, 9);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a = borrower claim in the normal path
t = refund after the normal claim
b = refund when the pool closes first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assertions prove:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t = 0
a = b
a + t = REWARD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the close first path is not collecting unrelated campaign dust.&lt;/p&gt;

&lt;p&gt;It receives the same value that the borrower receives in the normal path.&lt;/p&gt;

&lt;p&gt;The PoC defines &lt;code&gt;REWARD&lt;/code&gt; as 1,000 USDC, and the comparison test accounts for that entire amount through the control claim and zero tail refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full Move test suite passes
&lt;/h2&gt;

&lt;p&gt;The PoC lives in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/integration/test_cases/poc_close.move
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sui move test protocol::poc_close -i 100000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reported result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ PASS    ] protocol::poc_close::cmp
[ PASS    ] protocol::poc_close::ctl
[ PASS    ] protocol::poc_close::dead
[ PASS    ] protocol::poc_close::exp
[ PASS    ] protocol::poc_close::tail

Test result: OK.
Total tests: 5; passed: 5; failed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Together, these tests establish the full lifecycle problem: the borrower can claim the reward normally, the post claim refund is zero, closing first redirects the same value, and the borrower cannot recover it afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impact and severity context
&lt;/h2&gt;

&lt;p&gt;The demonstrated impact is direct loss of borrower yield.&lt;/p&gt;

&lt;p&gt;The finding does not depend on malicious privileged behavior. Closing an expired pool is part of the intended reward lifecycle, and there is no contract rule requiring every already active borrower to claim before closure.&lt;/p&gt;

&lt;p&gt;The problem is that the close function itself treats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as its safety condition even though that counter only reflects materialized trackers.&lt;/p&gt;

&lt;p&gt;My original High assessment was based on the fact that the PoC demonstrates user yield loss greater than 10 USDC and shows that the same value moves to the close recipient instead.&lt;/p&gt;

&lt;p&gt;The official Sherlock classification is Medium, which is the severity I use in my public record.&lt;/p&gt;

&lt;p&gt;Regardless of severity, the technical failure is clear: pool closure can refund value that the normal claim path proves was economically attributable to an eligible borrower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the lifecycle, not only the counter
&lt;/h2&gt;

&lt;p&gt;The core invariant should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Closing a reward campaign must not destroy or refund rewards that have already accrued economically to eligible users.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are several possible directions.&lt;/p&gt;

&lt;p&gt;The pool can be fully refreshed before refund logic runs.&lt;/p&gt;

&lt;p&gt;Existing obligations can have their reward trackers materialized when a new pool is created.&lt;/p&gt;

&lt;p&gt;A cleaner long term design is to separate campaign finalization from reward ownership: closing the campaign stops new accrual, while rewards already earned before expiry remain claimable.&lt;/p&gt;

&lt;p&gt;Only value that is provably not attributable to eligible users should become refundable.&lt;/p&gt;

&lt;p&gt;A regression test should preserve the control and close first comparison from the PoC. If a borrower can claim &lt;code&gt;X&lt;/code&gt; immediately before closure, closing the campaign must not make that same &lt;code&gt;X&lt;/code&gt; refundable to another recipient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Lazy accounting is useful because it avoids updating every user whenever global state changes.&lt;/p&gt;

&lt;p&gt;But it creates a strict design requirement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;not materialized
does not mean
not economically owed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any close, sweep, refund, cleanup, or migration path that treats lazy state as complete state risks erasing obligations that would have appeared on the next user interaction.&lt;/p&gt;

&lt;p&gt;Reward systems are especially sensitive because time can create economic entitlement while user specific state remains untouched.&lt;/p&gt;

&lt;p&gt;The safest review question is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If a user can claim X immediately before closure,
can closure make X disappear or become refundable elsewhere?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer is yes, the lifecycle is not preserving reward ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The CurrentSui reward pool close path used materialized borrower trackers as a proxy for outstanding economic obligations.&lt;/p&gt;

&lt;p&gt;That proxy was incomplete.&lt;/p&gt;

&lt;p&gt;An already active borrower could accrue rewards through the global share accounting while the pool still reported zero materialized obligation reward managers. If the borrower interacted first, the normal claim path materialized the reward and paid it to the borrower. If the expired pool closed first, the same value could be refunded and the borrower could no longer claim it.&lt;/p&gt;

&lt;p&gt;The core issue is not reward calculation accuracy. It is reward ownership being decided by the timing of lazy materialization.&lt;/p&gt;

&lt;p&gt;The engineering rule is broader than this one protocol:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lifecycle functions must reason about economic obligations, not only the objects that have already been materialized to represent them.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a 1 USDC Position Let an Attacker Withdraw 800 USDC From Fluid MoneyMarket’s Shared Liquidity</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 02:28:46 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-1-usdc-position-let-an-attacker-withdraw-800-usdc-from-fluid-moneymarkets-shared-liquidity-4pcn</link>
      <guid>https://dev.to/f00dat/how-a-1-usdc-position-let-an-attacker-withdraw-800-usdc-from-fluid-moneymarkets-shared-liquidity-4pcn</guid>
      <description>&lt;p&gt;A withdrawal cap only protects funds if every downstream layer uses the capped amount.&lt;/p&gt;

&lt;p&gt;That was the problem in Fluid MoneyMarket’s normal supply withdrawal path.&lt;/p&gt;

&lt;p&gt;When a user requests more than their position balance, MoneyMarket converts the request into raw units and caps &lt;code&gt;withdrawAmountRaw_&lt;/code&gt; to the position’s actual &lt;code&gt;tokenRawSupply_&lt;/code&gt;. This keeps the internal position update within the user’s balance.&lt;/p&gt;

&lt;p&gt;But the protocol then calls the Liquidity layer with the original, uncapped &lt;code&gt;supplyAmount_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is a mismatch between ownership accounting and the real token transfer.&lt;/p&gt;

&lt;p&gt;In the proof of concept, a victim supplied 1,000 USDC and an attacker supplied only 1 USDC. The attacker then requested an 800 USDC withdrawal. MoneyMarket exhausted the attacker’s 1 USDC position in storage, while Liquidity transferred the full 800 USDC. The attacker ended with a 799 USDC net profit, and the victim’s later withdrawal reverted.&lt;/p&gt;

&lt;p&gt;I reported the finding as &lt;strong&gt;High&lt;/strong&gt; in the Sherlock Fluid DEX V2 contest, where it earned &lt;strong&gt;$34&lt;/strong&gt;.&lt;br&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%2F033sp6msmm89s02fkz5q.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%2F033sp6msmm89s02fkz5q.png" alt=" " width="799" height="179"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1225/voting/860" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No oracle manipulation, reentrancy, governance compromise, privileged role, or unusual ERC20 behavior was required. The issue came from one value being capped for storage while another value was used for the external withdrawal.&lt;/p&gt;
&lt;h2&gt;
  
  
  The broken accounting invariant
&lt;/h2&gt;

&lt;p&gt;MoneyMarket and Liquidity use different accounting layers, but they still need to agree on the economic amount being withdrawn.&lt;/p&gt;

&lt;p&gt;The relevant invariant is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Value withdrawn from Liquidity
&amp;lt;=
Value debited from the withdrawing user’s MoneyMarket position
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerable path violates that invariant because the storage debit is based on &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;, while the actual Liquidity call still depends on the original &lt;code&gt;supplyAmount_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means MoneyMarket can correctly determine that a user owns only a small position and still authorize a much larger withdrawal against its pooled Liquidity balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the mismatch begins
&lt;/h2&gt;

&lt;p&gt;The vulnerable logic is in &lt;code&gt;_processNormalSupplyAction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a normal withdrawal, the user provides a negative &lt;code&gt;supplyAmount_&lt;/code&gt;. MoneyMarket converts that amount into raw units and calculates &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the requested raw withdrawal exceeds the position’s current supply, the code caps it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (withdrawAmountRaw_ &amp;gt; tokenRawSupply_) {
    withdrawAmountRaw_ = tokenRawSupply_;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/operateModule/helpers.sol#L389" rel="noopener noreferrer"&gt;MoneyMarket operate helper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cap itself is reasonable. It allows the protocol to consume at most the available position instead of passing an excessive raw amount into the storage update.&lt;/p&gt;

&lt;p&gt;The vulnerability is that this new effective withdrawal amount is not propagated to the value later sent to Liquidity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage uses the capped amount
&lt;/h2&gt;

&lt;p&gt;The final raw amount reaches &lt;code&gt;_updateStorageForWithdraw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That helper rejects a raw withdrawal larger than the stored position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (tokenRawSupply_ &amp;lt; withdrawAmountRaw_) revert();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then subtracts only the amount passed to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokenRawSupply_ =
    tokenRawSupply_ - withdrawAmountRaw_;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/other/helpers.sol#L92" rel="noopener noreferrer"&gt;MoneyMarket storage withdrawal helper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;_processNormalSupplyAction&lt;/code&gt; already capped &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;, this storage update succeeds even when the original user request was larger than the position.&lt;/p&gt;

&lt;p&gt;So the internal accounting remains bounded by what the attacker actually owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liquidity still receives the uncapped request
&lt;/h2&gt;

&lt;p&gt;After the storage update, MoneyMarket calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LIQUIDITY.operate(
    token_,
    supplyAmount_,
    0,
    to_,
    ...
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/operateModule/helpers.sol#L480" rel="noopener noreferrer"&gt;MoneyMarket Liquidity call&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important detail is that &lt;code&gt;supplyAmount_&lt;/code&gt; is still the original user supplied withdrawal request.&lt;/p&gt;

&lt;p&gt;It is not recomputed from the capped &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The protocol therefore applies one amount to the attacker’s position and another amount to the real asset movement.&lt;/p&gt;

&lt;p&gt;This is not a precision or rounding edge case. The raw conversion successfully detects that the request exceeds the position and the code intentionally caps it. The failure happens afterward, when the external call goes back to the original input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Liquidity can pay the larger amount
&lt;/h2&gt;

&lt;p&gt;Liquidity tracks supply using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_userSupplyData[msg.sender][token_]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/liquidity/userModule/main.sol#L25" rel="noopener noreferrer"&gt;Liquidity user module&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During this flow, &lt;code&gt;msg.sender&lt;/code&gt; at the Liquidity layer is the MoneyMarket contract.&lt;/p&gt;

&lt;p&gt;Liquidity therefore does not see separate balances for Alice, Bob, or each MoneyMarket NFT. It sees MoneyMarket’s aggregated supply position.&lt;/p&gt;

&lt;p&gt;That separation is expected. MoneyMarket is responsible for tracking how much of that aggregate position belongs to each user.&lt;/p&gt;

&lt;p&gt;The problem is that after limiting the attacker’s internal debit, MoneyMarket still asks Liquidity to release the larger requested amount.&lt;/p&gt;

&lt;p&gt;As long as MoneyMarket has enough aggregate supply at Liquidity and the configured withdrawal constraints permit the operation, Liquidity can fulfill it from the pooled position.&lt;/p&gt;

&lt;p&gt;That is how funds backing other MoneyMarket suppliers become available to the attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC demonstrates the full economic impact
&lt;/h2&gt;

&lt;p&gt;The Foundry proof uses a listed USDC normal supply market and two ordinary users.&lt;/p&gt;

&lt;p&gt;The victim supplies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 victimSupply = 1_000e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker supplies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 attackerDeposit = 1e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker then chooses an explicit withdrawal larger than their position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 overWithdraw = 800e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exploit uses the normal &lt;code&gt;operate()&lt;/code&gt; path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;moneyMarket.operate(
    attackerNftId,
    attackerPosIndex,
    abi.encode(
        -int256(overWithdraw),
        alice
    )
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test confirms three separate facts.&lt;/p&gt;

&lt;p&gt;First, the attacker receives the full requested amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assertEq(
    attackerBalAfterExploit - attackerBalAfterDeposit,
    overWithdraw,
    "attacker got overWithdraw"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the attacker’s internal MoneyMarket supply is fully exhausted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assertEq(
    attackerRawAfterExploit,
    0,
    "attacker raw supply should be exhausted in storage"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, the victim can no longer complete a full withdrawal afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vm.expectRevert(stdError.arithmeticError);

moneyMarket.operate(
    victimNftId,
    victimPosIndex,
    abi.encode(
        type(int256).min,
        bob
    )
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting state is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Victim supplied
1,000 USDC

Attacker supplied
1 USDC

Attacker received
800 USDC

Attacker net profit
799 USDC

Attacker remaining raw supply
0

Victim withdrawal afterward
REVERTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the important proof boundary. The test does not merely show inconsistent storage. It shows an actual token transfer above the attacker’s deposited amount and downstream failure for another supplier.&lt;/p&gt;

&lt;p&gt;The validated Foundry run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 passed; 0 failed; 0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC did not modify the protocol contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the protocol’s amount limits do not stop it
&lt;/h2&gt;

&lt;p&gt;MoneyMarket also checks amounts through &lt;code&gt;_verifyAmountLimits(int256)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That validation enforces configured minimum and maximum magnitudes. It does not enforce the user specific condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requested withdrawal
&amp;lt;=
position balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker can therefore request more than their own position while still staying within protocol configured amount limits.&lt;/p&gt;

&lt;p&gt;The exact maximum extractable amount per call depends on available pooled liquidity and Liquidity withdrawal constraints.&lt;/p&gt;

&lt;p&gt;The PoC does not establish an unlimited drain under every configuration, and the finding does not require that claim. It proves that the protocol can transfer more underlying than it debits from the attacker’s position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the withdraw all branch is different
&lt;/h2&gt;

&lt;p&gt;The issue is specific to the normal explicit withdrawal amount path.&lt;/p&gt;

&lt;p&gt;The report distinguishes it from the sentinel branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supplyAmount_ == type(int256).min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That branch represents withdrawing the full available position.&lt;/p&gt;

&lt;p&gt;There, MoneyMarket rewrites &lt;code&gt;supplyAmount_&lt;/code&gt; to the actual withdrawable amount before calling Liquidity. The value used for the external transfer therefore follows the position balance.&lt;/p&gt;

&lt;p&gt;The vulnerable branch instead behaves like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User provides explicit negative withdrawal
        ↓
withdrawAmountRaw_ is capped
        ↓
Storage uses the capped amount
        ↓
supplyAmount_ remains unchanged
        ↓
Liquidity receives the original request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction matters because it isolates the bug to one concrete value propagation path rather than every MoneyMarket withdrawal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity
&lt;/h2&gt;

&lt;p&gt;The exploit is permissionless once normal system conditions exist.&lt;/p&gt;

&lt;p&gt;The target token must be listed, MoneyMarket must have pooled supply for that token at Liquidity, and the attacker needs a MoneyMarket NFT with a normal supply position. Those are ordinary prerequisites for using the feature.&lt;/p&gt;

&lt;p&gt;The attack does not rely on an oracle, callback, governance action, special token behavior, or compromised role.&lt;/p&gt;

&lt;p&gt;The demonstrated impact is direct extraction from MoneyMarket’s pooled Liquidity position followed by a failed withdrawal for another supplier.&lt;/p&gt;

&lt;p&gt;The maximum amount is still bounded by available pooled liquidity and configured Liquidity limits, so the PoC should not be read as proving that any arbitrary amount can always be drained in one transaction.&lt;/p&gt;

&lt;p&gt;What it does prove is that an individual user’s balance is no longer the effective authorization boundary for the withdrawal.&lt;/p&gt;

&lt;p&gt;That is the basis for the High severity assessment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the effective withdrawal amount once
&lt;/h2&gt;

&lt;p&gt;The protocol should ensure that the value forwarded to Liquidity is derived from the same final amount used for the MoneyMarket storage debit.&lt;/p&gt;

&lt;p&gt;If partial fulfillment is intentional, the safe flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User requests withdrawal
        ↓
Convert to raw units
        ↓
Cap raw amount to position balance
        ↓
Convert the final capped raw amount
back into normal units
        ↓
Use that final amount for Liquidity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report recommends following the same conversion style used by the withdraw all path and rounding down when converting the capped raw amount back into normal units so the protocol cannot over withdraw.&lt;/p&gt;

&lt;p&gt;The final adjusted amount should also be the amount checked by &lt;code&gt;_verifyAmountLimits&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A simpler alternative is to revert whenever the requested withdrawal exceeds the position balance. That changes the user experience from partial fulfillment to rejection, but it also prevents the two layers from executing different withdrawal amounts.&lt;/p&gt;

&lt;p&gt;The regression test should recreate a large victim position and a small attacker position, attempt an over balance withdrawal, and assert either that the operation reverts or that the attacker receives no more than the economic value removed from their position. The victim should remain able to withdraw afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;This finding is a useful example of a value propagation bug.&lt;/p&gt;

&lt;p&gt;A security check can be correct where it is written and still fail to protect funds if the corrected value is not carried through the rest of the call chain.&lt;/p&gt;

&lt;p&gt;For multi layer accounting flows, it is worth tracing the same user controlled value through every transformation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input
→ normal amount
→ raw amount
→ capped raw amount
→ storage debit
→ external protocol argument
→ actual token transfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, MoneyMarket correctly discovered that the attacker did not own the full requested amount and protected its own storage accordingly.&lt;/p&gt;

&lt;p&gt;The protection failed because the final Liquidity call returned to the original request instead of using the adjusted value.&lt;/p&gt;

&lt;p&gt;The cap protected the accounting record, but not the pooled assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Fluid MoneyMarket’s normal supply withdrawal path used two different effective withdrawal amounts inside the same transaction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;withdrawAmountRaw_&lt;/code&gt; was capped to the user’s real &lt;code&gt;tokenRawSupply_&lt;/code&gt;, so MoneyMarket storage consumed only the available position. But &lt;code&gt;LIQUIDITY.operate()&lt;/code&gt; still received the original &lt;code&gt;supplyAmount_&lt;/code&gt;, allowing the external transfer to exceed the value debited from that user.&lt;/p&gt;

&lt;p&gt;Because Liquidity accounts for the MoneyMarket contract as an aggregated supplier, the excess can be paid from pooled funds backing other MoneyMarket positions.&lt;/p&gt;

&lt;p&gt;The PoC demonstrated that this mismatch was economically exploitable and could leave another supplier unable to withdraw.&lt;/p&gt;

&lt;p&gt;The invariant for the fix is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The amount transferred by Liquidity must never exceed the economic amount debited from the withdrawing user’s MoneyMarket position.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once a withdrawal amount is adjusted, every downstream accounting and transfer operation must use that same effective value.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Transaction-Scoped 0xMarkets Oracle Prices Could Freeze CarthaVault After GM Settlement</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:10:55 +0000</pubDate>
      <link>https://dev.to/f00dat/how-transaction-scoped-0xmarkets-oracle-prices-could-freeze-carthavault-after-gm-settlement-1n1c</link>
      <guid>https://dev.to/f00dat/how-transaction-scoped-0xmarkets-oracle-prices-could-freeze-carthavault-after-gm-settlement-1n1c</guid>
      <description>&lt;p&gt;The bug was not a bad oracle price.&lt;/p&gt;

&lt;p&gt;It was a mismatch in how long that price was supposed to exist.&lt;/p&gt;

&lt;p&gt;CarthaVault included the value of its settled 0xMarkets GM balance in total value locked. To calculate that value, &lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt; queried the 0xMarkets Oracle for the market’s primary prices.&lt;/p&gt;

&lt;p&gt;That looks reasonable until you follow the lifetime of those prices.&lt;/p&gt;

&lt;p&gt;0xMarkets primary prices are transaction scoped execution values. &lt;code&gt;OracleModule.withOraclePrices&lt;/code&gt; sets them before keeper execution and clears them before the transaction ends.&lt;/p&gt;

&lt;p&gt;CarthaVault, however, can continue holding the GM token long after that execution transaction is over.&lt;/p&gt;

&lt;p&gt;The resulting steady state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault holds GM
YES

0xMarkets primary prices exist
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, the next CarthaVault operation that needed TVL could reach &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; and revert with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The proof reproduced that failure across:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depositAndLock

lockTopUp

release

processQueuedWithdrawals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also broke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked

deployedGmBalance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reported the finding as &lt;strong&gt;High&lt;/strong&gt; through the 0xMarkets program on HackenProof.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The important part was not that a view could revert. The same valuation dependency blocked state changing deposit and withdrawal paths while the vault held real GM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle mismatch
&lt;/h2&gt;

&lt;p&gt;CarthaVault calculates TVL from two components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle vault asset
+
Value of settled GM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _totalValueLocked()
    internal
    view
    returns (uint256)
{
    CarthaVaultStorage storage $ =
        _getCarthaVaultStorage();

    return
        IERC20($.asset)
            .balanceOf(address(this))
        +
        _gmValueInUsdc($);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GM valuation is delegated to &lt;code&gt;PoolDeployLib&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _gmValueInUsdc(
    CarthaVaultStorage storage $
)
    internal
    view
    returns (uint256)
{
    return
        PoolDeployLib.gmValueInUsdc(
            address($.poolToken),
            $.reader,
            $.dataStore,
            $.marketToken,
            $.oracle
        );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L938-L945" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is safe only if the configured price source is available whenever CarthaVault needs to value its GM holdings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt; takes an early return while the GM balance is zero. Once GM is present, it reaches &lt;code&gt;_getGmPrice&lt;/code&gt;, which reads the 0xMarkets Oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _getGmPrice(
    address reader,
    address dataStore,
    address market,
    address oracle
)
    private
    view
    returns (
        int256 gmPrice
    )
{
    I0xMReader r =
        I0xMReader(reader);

    I0xMOracle o =
        I0xMOracle(oracle);

    I0xMReader.MarketProps memory mkt =
        r.getMarket(
            dataStore,
            market
        );

    I0xMReader.PriceProps memory indexPrice =
        o.getPrimaryPrice(
            mkt.indexToken
        );

    I0xMReader.PriceProps memory longPrice =
        o.getPrimaryPrice(
            mkt.longToken
        );

    I0xMReader.PriceProps memory shortPrice =
        o.getPrimaryPrice(
            mkt.shortToken
        );

    (
        gmPrice,
    ) =
        r.getMarketTokenPrice(
            dataStore,
            mkt,
            indexPrice,
            longPrice,
            shortPrice,
            MAX_PNL_FACTOR_FOR_DEPOSITS,
            false
        );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/libraries/PoolDeployLib.sol#L97-L127" rel="noopener noreferrer"&gt;PoolDeployLib.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The integration therefore assumes that &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; behaves like a persistent price feed.&lt;/p&gt;

&lt;p&gt;It does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  0xMarkets clears the prices by design
&lt;/h2&gt;

&lt;p&gt;The 0xMarkets execution model makes the lifetime of &lt;code&gt;primaryPrices&lt;/code&gt; explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier withOraclePrices(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPrices(params);
    _;
    oracle.clearAllPrices();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomic-action variant follows the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier withOraclePricesForAtomicAction(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPricesForAtomicAction(
        params
    );
    _;
    oracle.clearAllPrices();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/oracle/OracleModule.sol#L26-L40" rel="noopener noreferrer"&gt;OracleModule.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The prices are available while the keeper action is executing.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;clearAllPrices&lt;/code&gt; removes them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function clearAllPrices()
    external
    onlyController
{
    uint256 length =
        tokensWithPrices.length();

    for (
        uint256 i;
        i &amp;lt; length;
        i++
    ) {
        address token =
            tokensWithPrices.at(0);

        _removePrimaryPrice(
            token
        );
    }

    minTimestamp = 0;
    maxTimestamp = 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later, &lt;code&gt;getPrimaryPrice&lt;/code&gt; explicitly rejects an empty value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getPrimaryPrice(
    address token
)
    external
    view
    returns (
        Price.Props memory
    )
{
    if (
        token == address(0)
    ) {
        return
            Price.Props(
                0,
                0
            );
    }

    Price.Props memory price =
        primaryPrices[
            token
        ];

    if (
        price.isEmpty()
    ) {
        revert
            Errors.EmptyPrimaryPrice(
                token
            );
    }

    return price;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/oracle/Oracle.sol#L131-L168" rel="noopener noreferrer"&gt;Oracle.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So seeing &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt; after an execution is not evidence that 0xMarkets itself failed to clean up.&lt;/p&gt;

&lt;p&gt;The cleanup is the expected behavior.&lt;/p&gt;

&lt;p&gt;The integration failure is using that transaction scoped state as though it were available for persistent vault accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  A normal deposit creates the frozen steady state
&lt;/h2&gt;

&lt;p&gt;The PoC did not manufacture the condition by directly minting GM or directly clearing the Oracle.&lt;/p&gt;

&lt;p&gt;It used the real 0xMarkets deposit execution path.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DepositHandler.executeDeposit&lt;/code&gt; runs under &lt;code&gt;withOraclePrices&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function executeDeposit(
    bytes32 key,
    OracleUtils.SetPricesParams calldata oracleParams
)
    external
    globalNonReentrant
    onlyOrderKeeper
    withOraclePrices(
        oracleParams
    )
{
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/exchange/DepositHandler.sol#L88-L95" rel="noopener noreferrer"&gt;DepositHandler.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During execution, the market token is minted to the request receiver.&lt;/p&gt;

&lt;p&gt;For this integration, the receiver is the real CarthaVault.&lt;/p&gt;

&lt;p&gt;The transaction therefore legitimately performs both of these actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mint GM to CarthaVault

Clear Oracle primary prices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the transaction ends, the PoC observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault GM balance
50000000000000000000000

Oracle tokensWithPrices count
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That raw GM balance is &lt;code&gt;50,000e18&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;CarthaVault now has an asset it must value across transactions, but the price state selected by the integration has already completed its lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before GM existed, the vault worked normally
&lt;/h2&gt;

&lt;p&gt;The proof first established a healthy control state.&lt;/p&gt;

&lt;p&gt;User A started with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and deposited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User A balance after deposit
800,000 USDC

CarthaVault idle USDC
200,000 USDC

CarthaVault GM balance
0

totalValueLocked
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because &lt;code&gt;gmValueInUsdc&lt;/code&gt; skips the oracle path while GM is zero.&lt;/p&gt;

&lt;p&gt;The vault only enters the broken state after a real 0xMarkets deployment has settled and GM is actually held.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keeper deployed only 50,000 USDC
&lt;/h2&gt;

&lt;p&gt;The PoC then deployed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from the vault into 0xMarkets.&lt;/p&gt;

&lt;p&gt;That deliberately left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;150,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;inside CarthaVault.&lt;/p&gt;

&lt;p&gt;The real 0xMarkets deposit request was created and executed.&lt;/p&gt;

&lt;p&gt;GM was minted to the vault.&lt;/p&gt;

&lt;p&gt;The Oracle finished the keeper transaction with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokensWithPrices
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produced the exact state needed to test whether CarthaVault could continue operating after normal settlement.&lt;/p&gt;

&lt;p&gt;It could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  TVL and deployed GM accounting reverted
&lt;/h2&gt;

&lt;p&gt;Once GM was positive, the early-return condition in &lt;code&gt;gmValueInUsdc&lt;/code&gt; no longer applied.&lt;/p&gt;

&lt;p&gt;CarthaVault attempted to value the GM.&lt;/p&gt;

&lt;p&gt;The valuation reached &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; after the prices had been cleared.&lt;/p&gt;

&lt;p&gt;The PoC observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked
REVERTS EmptyPrimaryPrice

deployedGmBalance
REVERTS EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that were the full impact, this could be dismissed as a reporting or UI problem.&lt;/p&gt;

&lt;p&gt;The same dependency sits inside state changing user paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  New deposits were blocked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;depositAndLock&lt;/code&gt; calculates shares through &lt;code&gt;_convertToShares&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _convertToShares(
    uint256 assets
)
    internal
    view
    returns (
        uint256 shares
    )
{
    uint256 totalShares =
        totalSupply();

    uint256 totalAssets_ =
        _totalValueLocked();

    if (
        totalShares == 0
            ||
        totalAssets_ == 0
    ) {
        CarthaVaultStorage storage $ =
            _getCarthaVaultStorage();

        return
            assets
            *
        10 ** (
            decimals()
                -
            IERC20Metadata(
                $.asset
            ).decimals()
        );
    }

    return
        (
            assets
                *
            totalShares
        )
            /
        totalAssets_;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L912-L922" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;User B had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;before the attempted deposit.&lt;/p&gt;

&lt;p&gt;While GM was held, &lt;code&gt;depositAndLock&lt;/code&gt; reverted with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User B balance
1,000,000 USDC

Position created
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failed call did not consume the user’s assets, but new deposits were unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing positions could not be topped up
&lt;/h2&gt;

&lt;p&gt;The PoC then exercised &lt;code&gt;lockTopUp&lt;/code&gt; on User A’s existing position.&lt;/p&gt;

&lt;p&gt;That path also required TVL based share conversion.&lt;/p&gt;

&lt;p&gt;It reverted with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user balance and position values remained unchanged.&lt;/p&gt;

&lt;p&gt;So the deposit side was blocked for both new users and existing positions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct release failed despite abundant idle liquidity
&lt;/h2&gt;

&lt;p&gt;This was the strongest control in the entire proof.&lt;/p&gt;

&lt;p&gt;A withdrawal failure after deploying funds externally could otherwise be explained as an ordinary liquidity problem.&lt;/p&gt;

&lt;p&gt;The PoC made that explanation impossible.&lt;/p&gt;

&lt;p&gt;Before the release probe, CarthaVault still held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;150,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user attempted to release only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vault had fifteen times the probe amount available locally.&lt;/p&gt;

&lt;p&gt;After the lock and cooldown requirements were satisfied, User A called the real &lt;code&gt;release&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release
REVERTS EmptyPrimaryPrice

User release delta
0 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is that &lt;code&gt;release&lt;/code&gt; needs &lt;code&gt;_convertToAssets&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _convertToAssets(
    uint256 shares
)
    internal
    view
    returns (
        uint256 assets
    )
{
    uint256 totalShares =
        totalSupply();

    if (
        totalShares == 0
    ) {
        return 0;
    }

    return
        (
            shares
                *
            _totalValueLocked()
        )
            /
        totalShares;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L928-L933" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The vault had enough USDC.&lt;/p&gt;

&lt;p&gt;What it lacked was a price source that survived long enough to calculate the payout.&lt;/p&gt;

&lt;p&gt;That isolates the liveness failure from ordinary external illiquidity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queued withdrawal processing was blocked too
&lt;/h2&gt;

&lt;p&gt;The proof also exercised the keeper-driven withdrawal flow.&lt;/p&gt;

&lt;p&gt;User A successfully called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requestRelease
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the pending withdrawal was set.&lt;/p&gt;

&lt;p&gt;The keeper then called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processQueuedWithdrawals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That operation also depended on TVL and reverted with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pending withdrawal was not processed.&lt;/p&gt;

&lt;p&gt;The two tested withdrawal modes therefore failed for the same root cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct release
FAILED

Queued withdrawal processing
FAILED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A full GM recall restored normal accounting
&lt;/h2&gt;

&lt;p&gt;The issue had a valid recovery path.&lt;/p&gt;

&lt;p&gt;The keeper created a real 0xMarkets withdrawal for the vault’s complete GM balance and executed it through the real withdrawal handler.&lt;/p&gt;

&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault GM balance
0

deployedGmBalance
0

totalValueLocked
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the GM balance reaches zero, &lt;code&gt;gmValueInUsdc&lt;/code&gt; no longer needs to query the cleared 0xMarkets primary prices.&lt;/p&gt;

&lt;p&gt;This is why the demonstrated impact is a temporary freeze rather than a permanent one.&lt;/p&gt;

&lt;p&gt;The proof establishes that normal accounting resumes after a full recall. It does not rely on keeping stale primary prices alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC used the real integration
&lt;/h2&gt;

&lt;p&gt;The test was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FreezeFlow.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and used the real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault

CarthaVault proxy and factory

Cartha AccessControl

0xMarkets Oracle

0xMarkets Reader

0xMarkets ExchangeRouter

0xMarkets DepositHandler

0xMarkets WithdrawalHandler

0xMarkets MarketToken

OracleModule.withOraclePrices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mock the 0xMarkets Oracle

Mock the 0xMarkets Reader

Mock the ExchangeRouter

Mint GM directly

Call clearAllPrices directly

Depend on mainnet

Depend on testnet

Depend on public RPC

Require governance compromise

Require malicious admin behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validated run ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ freezes (611575ms)

1 passing (10m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The runtime evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User A initial deposit&lt;/td&gt;
&lt;td&gt;200,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC before deploy&lt;/td&gt;
&lt;td&gt;200,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USDC deployed to 0xMarkets&lt;/td&gt;
&lt;td&gt;50,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC after deploy&lt;/td&gt;
&lt;td&gt;150,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GM before execution&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GM after execution&lt;/td&gt;
&lt;td&gt;50,000e18 raw units&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle tokens with prices after execution&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;totalValueLocked&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deployedGmBalance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New &lt;code&gt;depositAndLock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lockTopUp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct release probe&lt;/td&gt;
&lt;td&gt;10,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC available for release&lt;/td&gt;
&lt;td&gt;150,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;processQueuedWithdrawals&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full GM recall&lt;/td&gt;
&lt;td&gt;Restored TVL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most important comparison is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle liquidity
150,000 USDC

Release probe
10,000 USDC

Liquidity sufficient
YES

Release result
EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user was not blocked because the vault lacked assets.&lt;/p&gt;

&lt;p&gt;The user was blocked because the vault could not value the GM it already held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I reported it as High
&lt;/h2&gt;

&lt;p&gt;The proof did not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft

Insolvency

Permanent freezing of funds

No honest recovery path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A complete GM recall restored accounting.&lt;/p&gt;

&lt;p&gt;That bounded the severity.&lt;/p&gt;

&lt;p&gt;But the issue was clearly more than a failed view.&lt;/p&gt;

&lt;p&gt;While GM remained held, the same root cause blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New deposits

Position top-ups

Direct releases

Queued withdrawal processing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing users could not release through either tested withdrawal route even though enough idle USDC was already available.&lt;/p&gt;

&lt;p&gt;Recovery required a keeper to unwind the complete GM position before normal TVL based operations resumed.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as &lt;strong&gt;High&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The HackenProof reward allocated to the report was &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not the GM decimal mismatch
&lt;/h2&gt;

&lt;p&gt;A separate CarthaVault finding also involved settled GM valuation, but the failure mode is different.&lt;/p&gt;

&lt;p&gt;The decimal-normalization issue has this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM exists

Price exists

Valuation returns

Returned value uses the wrong scale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This finding has this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM exists

Primary price has been cleared

Valuation reverts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One corrupts the number returned by TVL.&lt;/p&gt;

&lt;p&gt;The other prevents TVL from being calculated at all.&lt;/p&gt;

&lt;p&gt;They require different fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not the pending-request accounting issue
&lt;/h2&gt;

&lt;p&gt;The pending-request finding exists before 0xMarkets settlement.&lt;/p&gt;

&lt;p&gt;This issue exists after settlement.&lt;/p&gt;

&lt;p&gt;Here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deposit request
EXECUTED

GM minted to CarthaVault
YES

Oracle price cache
CLEARED

Steady-state TVL
BROKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug does not depend on value being in transit.&lt;/p&gt;

&lt;p&gt;It depends on using transaction scoped oracle state to value an asset held persistently across transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the price lifetime mismatch
&lt;/h2&gt;

&lt;p&gt;CarthaVault should not use &lt;code&gt;Oracle.primaryPrices&lt;/code&gt; as its persistent GM valuation source.&lt;/p&gt;

&lt;p&gt;Those prices belong to the 0xMarkets execution lifecycle.&lt;/p&gt;

&lt;p&gt;The report proposes a dedicated GM pricing adapter backed by a persistent token price source.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault
    ↓
Persistent GM price adapter
    ↓
Persistent token prices
    ↓
0xMarkets Reader.getMarketTokenPrice
    ↓
GM valuation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault
    ↓
0xMarkets Oracle.primaryPrices
    ↓
Transaction scoped execution state
    ↓
EmptyPrimaryPrice after execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets CarthaVault continue using the market valuation logic without assuming that execution-only Oracle state survives into later transactions.&lt;/p&gt;

&lt;p&gt;The integration should enforce a simple invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If CarthaVault can hold GM across transactions, it must have a valuation source that remains available across transactions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lifetime of the asset and the lifetime of its pricing dependency must be compatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression coverage
&lt;/h2&gt;

&lt;p&gt;A corrected implementation should reproduce the same normal lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User deposits into CarthaVault

2. Keeper calls deployToPool

3. Real 0xMarkets deposit executes

4. GM is minted to CarthaVault

5. 0xMarkets clears primary prices

6. CarthaVault continues holding GM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that point, none of the CarthaVault operations should depend on the cleared cache.&lt;/p&gt;

&lt;p&gt;The regression suite should require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked
PASS

deployedGmBalance
PASS

depositAndLock
PASS

lockTopUp
PASS

release with sufficient idle USDC
PASS

processQueuedWithdrawals
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test should not preserve or restore 0xMarkets &lt;code&gt;primaryPrices&lt;/code&gt; to make these checks pass.&lt;/p&gt;

&lt;p&gt;The fix should remove the persistent dependency on that transient state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Cross-protocol integrations need compatible state lifetimes, not just compatible interfaces.&lt;/p&gt;

&lt;p&gt;A function called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;getPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can look like a conventional oracle read.&lt;/p&gt;

&lt;p&gt;That name alone says nothing about whether the value exists outside an execution transaction.&lt;/p&gt;

&lt;p&gt;When integrating another protocol’s state, I now treat these as separate questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who sets the value?

When does it become valid?

Who clears it?

How long is it expected to exist?

Does my protocol need it after that lifetime ends?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0xMarkets cleared its primary prices as part of the normal execution lifecycle.&lt;/p&gt;

&lt;p&gt;CarthaVault held the resulting GM after that lifecycle had finished.&lt;/p&gt;

&lt;p&gt;The integration crossed those two lifetimes without a persistent pricing layer in between.&lt;/p&gt;

&lt;p&gt;That was the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CarthaVault valued its settled 0xMarkets GM through &lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt;, which queried &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A real &lt;code&gt;DepositHandler.executeDeposit&lt;/code&gt; call set the required prices for execution, minted GM to CarthaVault, and then cleared the Oracle primary prices through &lt;code&gt;OracleModule.withOraclePrices&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the transaction, the vault was left in a normal but incompatible state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM held by CarthaVault
YES

0xMarkets primary prices available
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any CarthaVault operation that needed TVL then reached the cleared price cache and reverted with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The proof demonstrated failures in &lt;code&gt;depositAndLock&lt;/code&gt;, &lt;code&gt;lockTopUp&lt;/code&gt;, &lt;code&gt;release&lt;/code&gt;, and &lt;code&gt;processQueuedWithdrawals&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The release control was especially strong: the vault had &lt;code&gt;150,000 USDC&lt;/code&gt; of idle liquidity while the user attempted to release only &lt;code&gt;10,000 USDC&lt;/code&gt;, yet the call still reverted.&lt;/p&gt;

&lt;p&gt;A full GM recall restored accounting, so the demonstrated impact was a temporary freeze rather than a permanent one.&lt;/p&gt;

&lt;p&gt;I reported the finding as High through HackenProof and received &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;br&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%2F1vdcm3n31d4m81bag7ot.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%2F1vdcm3n31d4m81bag7ot.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core engineering lesson is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Persistent accounting cannot depend on transaction scoped oracle state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a vault holds an external asset after the execution transaction ends, the valuation source for that asset must still exist when the next transaction begins.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Successful 0xMarkets Requests Could Permanently Orphan Users’ WNT Execution Fees</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:44:09 +0000</pubDate>
      <link>https://dev.to/f00dat/how-successful-0xmarkets-requests-could-permanently-orphan-users-wnt-execution-fees-2hcg</link>
      <guid>https://dev.to/f00dat/how-successful-0xmarkets-requests-could-permanently-orphan-users-wnt-execution-fees-2hcg</guid>
      <description>&lt;p&gt;A successful request is usually the path auditors worry about the least.&lt;/p&gt;

&lt;p&gt;The user submits the request, a keeper executes it, the expected protocol action completes, a success event is emitted, and the request disappears from storage. From the outside, everything looks finished.&lt;/p&gt;

&lt;p&gt;In 0xMarkets, that assumption was wrong for several request types.&lt;/p&gt;

&lt;p&gt;A user could supply a nonzero WNT execution fee through the normal production flow. The protocol accepted and recorded that fee, stored it in the request, executed the request successfully, and removed the request from storage.&lt;/p&gt;

&lt;p&gt;But the success path never settled the fee.&lt;/p&gt;

&lt;p&gt;The keeper received no WNT execution payment.&lt;/p&gt;

&lt;p&gt;The user received no refund of the original fee.&lt;/p&gt;

&lt;p&gt;The WNT remained inside the request vault and remained included in the vault’s &lt;code&gt;StrictBank&lt;/code&gt; accounting baseline.&lt;/p&gt;

&lt;p&gt;A later request then recorded only newly transferred WNT, so the old fee was not rediscovered through the normal request lifecycle.&lt;/p&gt;

&lt;p&gt;I reported this finding as &lt;strong&gt;High&lt;/strong&gt; through the 0xMarkets program on HackenProof.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reward was small, but the underlying lifecycle failure affected five distinct production request paths.&lt;/p&gt;

&lt;p&gt;The proof reproduced the same behavior across five successful production paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Withdrawal

Order

GLV Deposit

GLV Withdrawal

Shift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those requests failed.&lt;/p&gt;

&lt;p&gt;None depended on cancellation.&lt;/p&gt;

&lt;p&gt;None depended on a frozen order.&lt;/p&gt;

&lt;p&gt;Every mandatory request executed successfully.&lt;/p&gt;

&lt;p&gt;That is what made the bug interesting: the requested action completed, while value associated with that request did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The execution fee has its own lifecycle
&lt;/h2&gt;

&lt;p&gt;0xMarkets request flows can carry a WNT execution fee intended to support keeper execution.&lt;/p&gt;

&lt;p&gt;A simplified expected lifecycle is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request
        ↓
GasUtils.payExecutionFee
        ↓
Keeper receives the execution payment
        ↓
Any remaining amount is refunded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected success paths instead behaved like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request successfully
        ↓
Request is removed
        ↓
Execution fee payout is skipped
        ↓
WNT remains in the request vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol finished the request lifecycle.&lt;/p&gt;

&lt;p&gt;It did not finish the fee lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Withdrawal shows the mismatch clearly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WithdrawalUtils.createWithdrawal&lt;/code&gt; records incoming WNT through the real &lt;code&gt;WithdrawalVault&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address wnt = TokenUtils.wnt(dataStore);
uint256 wntAmount =
    withdrawalVault.recordTransferIn(
        wnt
    );

if (
    wntAmount
        &amp;lt;
    params.executionFee
) {
    revert Errors.InsufficientWntAmount(
        wntAmount,
        params.executionFee
    );
}

params.executionFee =
    wntAmount;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/withdrawal/WithdrawalUtils.sol#L82-L117" rel="noopener noreferrer"&gt;WithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important point is that this is not a stray token accidentally sent to a vault.&lt;/p&gt;

&lt;p&gt;The protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receives the WNT

Records the WNT

Checks the amount against executionFee

Stores the resulting value in the request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem appears after successful execution.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ExecuteWithdrawalUtils.executeWithdrawal&lt;/code&gt; removes the request, performs the withdrawal, emits &lt;code&gt;WithdrawalExecuted&lt;/code&gt;, executes the callback, calculates the oracle price count, and then reaches a disabled payout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.withdrawalVault,
//     params.key,
//     withdrawal.callbackContract(),
//     withdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     withdrawal.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/withdrawal/ExecuteWithdrawalUtils.sol#L91-L168" rel="noopener noreferrer"&gt;ExecuteWithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The request succeeds.&lt;/p&gt;

&lt;p&gt;The request is gone.&lt;/p&gt;

&lt;p&gt;The fee remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orders had the same lifecycle break
&lt;/h2&gt;

&lt;p&gt;Normal user orders could also store a nonzero execution fee.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;OrderUtils.createOrder&lt;/code&gt; records WNT and stores the resulting amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 wntAmount =
    orderVault.recordTransferIn(
        cache.wnt
    );

if (
    wntAmount
        &amp;lt;
    params.numbers.executionFee
) {
    revert Errors.InsufficientWntAmountForExecutionFee(
        wntAmount,
        params.numbers.executionFee
    );
}

params.numbers.executionFee =
    wntAmount;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 executionFee;

(
    executionFee,
    cache.executionFeeDiff
) =
    (
        params.numbers.executionFee,
        0
    );

order.setExecutionFee(
    executionFee
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/order/OrderUtils.sol#L113-L179" rel="noopener noreferrer"&gt;OrderUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The success path then skips the production payout routine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.contracts.dataStore,
//     params.contracts.eventEmitter,
//     params.contracts.orderVault,
//     params.key,
//     params.order.callbackContract(),
//     params.order.executionFee(),
//     params.startingGas,
//     GasUtils.estimateOrderOraclePriceCount(
//         params.order.swapPath().length
//     ),
//     params.keeper,
//     params.order.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/order/ExecuteOrderUtils.sol#L87-L110" rel="noopener noreferrer"&gt;ExecuteOrderUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code comments correctly note that liquidation and ADL orders can have zero execution fees.&lt;/p&gt;

&lt;p&gt;That does not resolve normal user orders that actually stored a nonzero fee before successful execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both GLV directions were affected
&lt;/h2&gt;

&lt;p&gt;The same pattern existed in GLV request processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  GLV Deposit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GlvDepositUtils.createGlvDeposit&lt;/code&gt; either separates WNT from the deposited token amount or records a separate WNT transfer and stores the result as the request’s &lt;code&gt;executionFee&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After successful execution, the payout call is disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvDeposit.callbackContract(),
//     glvDeposit.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvDeposit.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/glv/glvDeposit/GlvDepositUtils.sol#L284-L306" rel="noopener noreferrer"&gt;GlvDepositUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  GLV Withdrawal
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GlvWithdrawalUtils.createGlvWithdrawal&lt;/code&gt; records WNT in &lt;code&gt;GlvVault&lt;/code&gt; and stores that amount as &lt;code&gt;executionFee&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its successful execution path reaches the same disabled settlement pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvWithdrawal.callbackContract(),
//     glvWithdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvWithdrawal.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/glv/glvWithdrawal/GlvWithdrawalUtils.sol#L190-L206" rel="noopener noreferrer"&gt;GlvWithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different request direction.&lt;/p&gt;

&lt;p&gt;Same orphaned fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift makes the lifecycle problem especially visible
&lt;/h2&gt;

&lt;p&gt;Shift performs an internal withdrawal followed by an internal deposit.&lt;/p&gt;

&lt;p&gt;Those internal requests deliberately use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;executionFee
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because the outer Shift request carries the user-funded fee.&lt;/p&gt;

&lt;p&gt;That is internally consistent.&lt;/p&gt;

&lt;p&gt;The problem is what happens when the Shift finishes.&lt;/p&gt;

&lt;p&gt;After the internal withdrawal and deposit complete, &lt;code&gt;ShiftExecuted&lt;/code&gt; is emitted and the callback runs.&lt;/p&gt;

&lt;p&gt;The outer fee settlement is then disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.shiftVault,
//     params.key,
//     shift.callbackContract(),
//     shift.executionFee(),
//     params.startingGas,
//     GasUtils.estimateShiftOraclePriceCount(),
//     params.keeper,
//     shift.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/shift/ShiftUtils.sol#L158-L318" rel="noopener noreferrer"&gt;ShiftUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entire Shift can therefore succeed while its original nonzero WNT execution fee remains in &lt;code&gt;ShiftVault&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  StrictBank is why the old fee does not become the next fee
&lt;/h2&gt;

&lt;p&gt;The strongest part of the proof was not simply showing WNT sitting in the vault.&lt;/p&gt;

&lt;p&gt;It showed why a later request does not naturally recover it.&lt;/p&gt;

&lt;p&gt;Each request vault inherits &lt;code&gt;StrictBank&lt;/code&gt;, which keeps an accounting baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mapping(
    address =&amp;gt;
    uint256
) public tokenBalances;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;recordTransferIn&lt;/code&gt; calculates only the balance increase since the previous accounting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _recordTransferIn(
    address token
)
    internal
    returns (
        uint256
    )
{
    uint256 prevBalance =
        tokenBalances[
            token
        ];

    uint256 nextBalance =
        IERC20(
            token
        ).balanceOf(
            address(
                this
            )
        );

    tokenBalances[
        token
    ] =
        nextBalance;

    return
        nextBalance
            -
        prevBalance;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/bank/StrictBank.sol#L24-L57" rel="noopener noreferrer"&gt;StrictBank.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Suppose the first request adds one execution fee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vault actual WNT
old balance + fee

StrictBank tokenBalances
old balance + fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request then succeeds without paying or refunding that fee.&lt;/p&gt;

&lt;p&gt;Nothing removes it from either the real vault balance or the accounting baseline.&lt;/p&gt;

&lt;p&gt;When another request transfers new WNT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prevBalance
already includes old fee

nextBalance
old fee + newly transferred fee

recordTransferIn
new fee only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old fee is not picked up again.&lt;/p&gt;

&lt;p&gt;The PoC explicitly created a later request for every affected path and verified that only newly transferred WNT was recorded.&lt;/p&gt;

&lt;p&gt;That is why the issue is stronger than “the vault has leftover tokens.”&lt;/p&gt;

&lt;p&gt;The original execution fee becomes detached from the request that owned it and invisible to later request accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “permanent” means in this report
&lt;/h2&gt;

&lt;p&gt;The word &lt;strong&gt;permanent&lt;/strong&gt; should be read precisely.&lt;/p&gt;

&lt;p&gt;The PoC proves that the fee is permanently lost from the &lt;strong&gt;normal request lifecycle&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original request
Removed

Keeper settlement
Absent

User refund
Absent

Old fee discovered by later request
No

Normal request-based recovery
Gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof does not establish that no privileged migration, contract upgrade, or extraordinary administrative recovery could ever move the WNT.&lt;/p&gt;

&lt;p&gt;That is not required for the demonstrated bug.&lt;/p&gt;

&lt;p&gt;The security failure is that after a successful request is removed, the protocol leaves no normal request state through which that user’s fee is settled or recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production settlement routine already existed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GasUtils.payExecutionFee&lt;/code&gt; contains the expected settlement logic.&lt;/p&gt;

&lt;p&gt;It calculates the keeper portion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 executionFeeForKeeper =
    adjustGasUsage(
        dataStore,
        gasUsed,
        oraclePriceCount
    )
        *
    tx.gasprice;

if (
    executionFeeForKeeper
        &amp;gt;
    executionFee
) {
    executionFeeForKeeper =
        executionFee;
}

bank.transferOutNativeToken(
    keeper,
    executionFeeForKeeper
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then computes the remainder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache.refundFeeAmount =
    executionFee
        -
    executionFeeForKeeper;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/gas/GasUtils.sol#L114-L150" rel="noopener noreferrer"&gt;GasUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This matters because the report is not inventing a hypothetical fee destination.&lt;/p&gt;

&lt;p&gt;The protocol already has a production routine designed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pay the keeper

Refund the remainder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancellation paths use fee settlement behavior.&lt;/p&gt;

&lt;p&gt;The affected successful paths do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC used real routers, handlers, vaults, and accounting
&lt;/h2&gt;

&lt;p&gt;The proof was a single TypeScript test built on the repository’s production deployment fixture.&lt;/p&gt;

&lt;p&gt;It exercised these complete paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Creation entrypoint&lt;/th&gt;
&lt;th&gt;Execution entrypoint&lt;/th&gt;
&lt;th&gt;Vault&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WithdrawalHandler.executeWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WithdrawalVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createOrder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrderHandler.executeOrder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrderVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLV Deposit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvRouter.createGlvDeposit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvHandler.executeGlvDeposit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLV Withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvRouter.createGlvWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvHandler.executeGlvWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shift&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createShift&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ShiftHandler.executeShift&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ShiftVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It did not depend on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mock affected contracts

Custom harnesses

Direct storage writes

Direct vault accounting mutation

Failed requests

Cancellation

Frozen orders

Mainnet

Testnet

Public RPC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test execution fee was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1000000000000000 wei
0.001 WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five mandatory paths passed.&lt;/p&gt;

&lt;p&gt;The exact test run finished with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ locks (614607ms)

1 passing (10m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What was verified after each successful execution
&lt;/h2&gt;

&lt;p&gt;A commented-out function alone would not prove impact.&lt;/p&gt;

&lt;p&gt;For every path, the PoC followed the state before and after execution.&lt;/p&gt;

&lt;p&gt;It verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nonzero executionFee stored before success

Request created through the production router

Production handler execution succeeded

Expected success event emitted

Cancel event absent

Freeze event absent where applicable

Request removed after success

KeeperExecutionFee event absent

ExecutionFeeRefund event absent

ExecutionFeeRefundCallback event absent

Keeper WNT delta zero

Original fee remained in the request vault

Original fee remained in StrictBank accounting

Later request registered only newly transferred WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one important measurement nuance.&lt;/p&gt;

&lt;p&gt;Withdrawal-like operations may legitimately send WNT to the receiver as protocol output.&lt;/p&gt;

&lt;p&gt;For that reason, the proof did not identify the missing execution-fee refund merely by looking at the receiver’s raw WNT delta.&lt;/p&gt;

&lt;p&gt;It relied on the absence of the production refund events together with the fact that the original fee remained in the vault and in &lt;code&gt;StrictBank&lt;/code&gt; accounting.&lt;/p&gt;

&lt;p&gt;That prevents normal withdrawal output from being mistaken for a fee refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not merely an unpaid keeper
&lt;/h2&gt;

&lt;p&gt;The keeper receiving zero WNT is one symptom.&lt;/p&gt;

&lt;p&gt;The user-funded value is the more important part.&lt;/p&gt;

&lt;p&gt;If successful execution was intentionally supposed to be fee exempt, the protocol had safe design options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do not accept a nonzero fee

or

Return the supplied fee

or

Settle it explicitly under another documented policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the affected flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept the WNT

Validate the WNT

Store executionFee

Execute successfully

Delete the request

Leave the WNT in the vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fee exemption can explain why the keeper is not paid.&lt;/p&gt;

&lt;p&gt;It does not explain why user-supplied WNT should become detached from the request that supplied it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I reported it as High
&lt;/h2&gt;

&lt;p&gt;The submitted report mapped the issue to three impact categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Theft of gas or execution fee

Permanent freezing of funds

Functional correctness failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected value is not the user’s principal position or LP pool principal.&lt;/p&gt;

&lt;p&gt;The proof also did not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Protocol insolvency

Protocol-wide unrecoverable loss

Direct theft of market principal

Attacker-controlled extraction of the locked WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limits are important.&lt;/p&gt;

&lt;p&gt;But the affected WNT is still user-supplied value accepted by production request flows.&lt;/p&gt;

&lt;p&gt;The protocol stores it specifically as an execution fee and then loses the normal settlement path after successful request deletion.&lt;/p&gt;

&lt;p&gt;The same root cause reproduced across five distinct request classes.&lt;/p&gt;

&lt;p&gt;That is why I submitted the finding as High.&lt;/p&gt;

&lt;p&gt;The HackenProof reward allocated to my report was &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reward amount reflects contest payout mechanics. It does not alter what the proof demonstrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is separate from other 0xMarkets fee findings
&lt;/h2&gt;

&lt;p&gt;Several 0xMarkets findings involved execution fees, but the lifecycle and root cause here are different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frozen order execution fee lock
&lt;/h3&gt;

&lt;p&gt;That issue occurs in the freeze path.&lt;/p&gt;

&lt;p&gt;This report requires successful execution and does not use &lt;code&gt;freezeOrder&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Callback fee-cap behavior
&lt;/h3&gt;

&lt;p&gt;Those findings concern oversized execution fees and callback-based recovery behavior.&lt;/p&gt;

&lt;p&gt;This report uses normal nonzero execution fees that remain in the vault after success.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pyth Lazer provider drain
&lt;/h3&gt;

&lt;p&gt;That finding concerns ETH held by the oracle provider and permissionless verification calls.&lt;/p&gt;

&lt;p&gt;This report concerns user-supplied WNT stored in request vaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cartha fee routing
&lt;/h3&gt;

&lt;p&gt;The Cartha integration issue sends WNT to the wrong destination before 0xMarkets can record the request fee.&lt;/p&gt;

&lt;p&gt;Here, WNT reaches the correct 0xMarkets vault and is correctly recorded.&lt;/p&gt;

&lt;p&gt;The failure happens after successful execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the lifecycle, not just the symptom
&lt;/h2&gt;

&lt;p&gt;The safest correction is to make every success path that accepts a nonzero execution fee settle that fee before returning.&lt;/p&gt;

&lt;p&gt;The commented &lt;code&gt;GasUtils.payExecutionFee&lt;/code&gt; calls show the intended settlement structure and already contain the required request-specific parameters.&lt;/p&gt;

&lt;p&gt;However, the fix should be applied consistently with the protocol’s intended fee policy.&lt;/p&gt;

&lt;p&gt;For example, order creation also contains disabled execution-fee validation and capping logic. A production patch should review creation and settlement together rather than blindly uncommenting one line in isolation.&lt;/p&gt;

&lt;p&gt;The invariant should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A successful request that accepts a nonzero execution fee must not leave that fee orphaned from the request lifecycle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If successful execution is intentionally subsidized, creation should reject unnecessary nonzero fees or return them explicitly.&lt;/p&gt;

&lt;p&gt;What should never be possible is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept fee
Store fee
Execute successfully
Delete request
Leave fee without a normal owner-specific settlement path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should cover all five affected request families:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Withdrawal success
Fee settled
No orphaned WNT

Order success
Fee settled
No orphaned WNT

GLV Deposit success
Fee settled
No orphaned WNT

GLV Withdrawal success
Fee settled
No orphaned WNT

Shift success
Fee settled
No orphaned WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suite should also verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Later requests record only their own incoming fee

StrictBank accounting matches the intended post-settlement balance

Cancellation behavior remains correct

Liquidation and ADL zero-fee behavior remains correct

Zero-fee requests remain valid where intended

Receiver protocol output is not mistaken for execution-fee refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Request-based protocols have two lifecycles that need to be reviewed independently.&lt;/p&gt;

&lt;p&gt;The first is the action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create
↓
Execute
↓
Complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is every asset attached to that action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fund
↓
Account
↓
Store
↓
Settle
↓
Release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A success event only proves the first lifecycle completed.&lt;/p&gt;

&lt;p&gt;It says nothing about whether every associated balance was settled correctly.&lt;/p&gt;

&lt;p&gt;This finding existed precisely in that gap.&lt;/p&gt;

&lt;p&gt;The request succeeded.&lt;/p&gt;

&lt;p&gt;The fee did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;0xMarkets accepted nonzero WNT execution fees in successful Withdrawal, Order, GLV Deposit, GLV Withdrawal, and Shift flows.&lt;/p&gt;

&lt;p&gt;The protocol recorded the fee and stored it in each request.&lt;/p&gt;

&lt;p&gt;The production handler then executed the requested operation successfully and removed the request.&lt;/p&gt;

&lt;p&gt;But the corresponding execution-fee payout was disabled.&lt;/p&gt;

&lt;p&gt;The keeper received no execution-fee WNT.&lt;/p&gt;

&lt;p&gt;No production refund was emitted for the original fee.&lt;/p&gt;

&lt;p&gt;The WNT remained in the request vault and remained part of &lt;code&gt;StrictBank&lt;/code&gt; accounting.&lt;/p&gt;

&lt;p&gt;Later requests recorded only newly transferred WNT, so the old fee did not return through subsequent request processing.&lt;/p&gt;

&lt;p&gt;The proof reproduced this behavior across all five mandatory paths using real routers, handlers, vaults, and accounting. It required no affected-contract mocks, custom harnesses, direct storage writes, failed requests, cancellation, freeze path, live RPC, mainnet, or testnet.&lt;/p&gt;

&lt;p&gt;I reported the finding as High through HackenProof and received &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;br&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%2Fz9l2zzduvvcazxsaewej.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%2Fz9l2zzduvvcazxsaewej.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The engineering invariant is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Successful execution must settle every piece of value that the request accepted before the request is deleted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A success event should never be the moment a user-funded fee becomes orphaned from the protocol’s normal lifecycle.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
  </channel>
</rss>
