Back to Blog 

DeFiWeb3 SecurityAuditBridges
The Asset You Price Is Not Always the Asset You Release
17 min
In this article: How the September 2026 Demex bridge exploit passed a US$50,000 rolling withdrawal limit, the two separate defects that made it possible, the invariants that would have caught it, and how to test them as one system rather than two.
Demex's bridge had a withdrawal limit of US$50,000 per 24 hours. On 25 September 2026, two withdrawals released approximately US$191,929 of WETH and WBNB from its reserves. The guard recorded them as US$10.47 and passed them as routine.[1]
The limit did not malfunction in the usual sense. Nothing suggests the arithmetic was wrong, the oracle was manipulated, or the threshold was set too high. The guard measured a real price of a real asset. It was simply the wrong asset.
In Demex's own words: "the guard priced what was deducted rather than what was released."[1]
That sentence is the subject of this article. It describes a class of failure that appears whenever a control is evaluated against the instruction a user submits instead of the effect the system produces. Bridges are where this class is most expensive, because the step that turns an instruction into an effect, the asset registry, is itself mutable state.
All incident facts and figures below come from Demex's post-mortem, published 29 September 2026.[1] Code snippets are explanatory and do not reproduce Demex's implementation.
TL;DR
- The exploit combined two independent defects: an asset registry that accepted an unauthenticated deployment report, and a withdrawal limit that priced the source asset instead of the destination asset.
- A false mapping bound nLEND, a Demex token worth roughly twelve cents, to the canonical WETH contract on Ethereum, and the same sequence was then repeated against WBNB on BNB Chain.
- Withdrawing 87.27 nLEND released 64.78 WETH (about US$174,495) and 22.49 WBNB (about US$17,434).
- No administrator key was stolen and no validator network was compromised. The attacker's direct request to register the pairing was rejected. The successful path exploited trust between two authorized components.
- Fixing either defect alone is not enough. Correct valuation caps the loss from a corrupted registry; registry authentication prevents the corruption. A bridge needs both.
- The vulnerability is only visible when both sides of the bridge, and the messages between them, are reviewed and tested as one system.
One withdrawal, two economic identities
A bridge withdrawal has at least four distinct objects that are easy to collapse into one:
| Object | In the Demex withdrawal | Role |
|---|---|---|
| Source asset deducted | 87.27 nLEND burned or debited on Demex | The user's instruction |
| Registry mapping | nLEND → WETH (Ethereum), nLEND → WBNB (BNB Chain) | Translates instruction into effect |
| Destination asset released | 64.78 WETH, 22.49 WBNB | The effect |
| Custody value leaving the bridge | ≈ US$191,929 | What the bridge can actually lose |
In a healthy bridge, the first and third rows describe the same economic value, so it does not appear to matter which one a control measures. The registry guarantees that equivalence. Once the registry can be corrupted, the equivalence disappears, and every control that measures the first row instead of the third becomes blind.
The source amount is an instruction. The destination release is the security-sensitive effect. Every economic control on a bridge, whether a rate limit, a per-transaction cap, a circuit breaker or an anomaly alert, has to be evaluated on the effect.
How the false mapping was installed
Demex's bridge has two halves:[1]
- A contract system on each connected network that holds the bridged assets.
- Demex's bridge module, which keeps a registry pairing each Demex token with its contract on each network.
New pairings are created when the contract system deploys a native token and reports the deployment back to the bridge module. Demex describes the trust relationship directly: "The bridge module trusts these reports because they originate from the contract deployer. The contract system, in turn, trusts instructions that originate from the bridge module." Neither side independently verified what the other reported.[1]
According to the post-mortem, the sequence was:
- The attacker bought the 95 nLEND they needed on Demex's own order book for about US$60 and deployed a helper contract on Ethereum.
- They made four attempts to have a token deployment reported back to Demex. All four failed.
- They tried the legitimate route, a direct request to register the pairing themselves. Demex rejected it as unauthorized, "exactly as designed."
- On the fifth attempt, they used Demex's generic execute flow to trigger a native token deployment on the contract system.
- Their helper contract falsified the resulting deployment report, naming the canonical WETH contract as the "newly" deployed nLEND contract.
- Both the contract system and Demex accepted the false report and registered
nLEND → WETH. - A withdrawal of 87.27 nLEND released 64.78 WETH, matching the Ethereum gateway's entire WETH balance.
- They repeated the sequence on BNB Chain against WBNB, where it took four minutes from first request to payout.
Demex's admin multisig executed an emergency chain halt 52 minutes after the first exploit withdrawal. Demex reports that this protected the approximately US$976,000 still held across its bridge reserves.[1] Those funds were protected from further withdrawal, not recovered.
Two details matter for anyone generalizing from this incident.
First, this is not a plain access-control bug. The unauthorized registration request was rejected. The successful path went through components that were each permitted to do what they did: the generic execute flow could trigger deployments, the contract system could report deployments, and the bridge module could register what was reported. The weakness was the content of a message that passed between authorized parties.
Second, Demex notes that "nLEND itself was not the weakness — a number of other Demex tokens would have served just as well."[1] The attacker needed a token that was cheap to buy and that used the same eighteen decimal places as WETH and WBNB, because the bridge releases assets against the registry "unit for unit." Given that, almost any cheap token would do.
Defect 1: Unauthenticated registry binding
Accepting the report meant accepting, without proof, several separate claims:
- The destination token was actually deployed by the approved factory.
- The deployed contract represents the claimed source asset.
- The destination address was newly created for this registration, not an existing contract.
- The code at that address matches the expected token implementation.
- The report's parameters are bound to the original authorized deployment request.
Canonical WETH fails almost every item on that list. It was not deployed by the bridge's factory, it is not new, and it was never requested. A report naming it could only be accepted because nothing checked the report against the request that supposedly produced it.
Treat registry mutation as a privileged state transition
An asset registry entry is economically equivalent to a signer, an oracle or a collateral listing. It decides what the bridge pays out. Registry writes deserve the same scrutiny as those changes. Depending on the architecture, useful controls include:
- Factory provenance. Accept only addresses produced by an approved factory, ideally by recomputing a
CREATE2address from the factory, a salt derived from the source asset, and the expected init code, rather than trusting a reported address. - Request/report correlation. Give each deployment request a unique identifier and require the report to carry it, along with the source asset, destination chain and factory from the original request. Reject reports that do not match an open request, and close the request once it is used.
- Code identity. Compare
extcodehashof the reported address with the expected implementation's code hash. For proxy or minimal-clone deployments this hash belongs to the proxy, so the check must also verify the implementation slot or clone target. - Freshness. Reject reports that name an address that already held code before the request, or that is already registered for another asset.
- Monitoring and delay. Emit a uniquely attributable event for every registry write, route it to alerting, and consider a cooldown or a reduced limit for newly registered assets.
Not every bridge needs every control. A bridge whose registry is immutable after genesis has a very different risk profile from one that registers tokens on demand through generic execution. The point is that whoever designs the registration path has to decide explicitly which of these claims are proven and how.
Defect 2: Wrong-asset valuation in the withdrawal limit
Conceptually, the vulnerable guard behaved like this:
1// Illustrative only — not Demex's implementation.2uint256 value = oracle.usdValue(sourceAsset, sourceAmount);3require(withdrawnValue24h + value <= withdrawalLimit, "limit");
With
sourceAsset = nLEND and sourceAmount = 87.27e18, this computes roughly US$10 and passes easily, regardless of what the registry says will be released.The security-relevant calculation resolves the release first and prices that:
1// Illustrative only — not Demex's implementation.2Destination memory dst = registry.resolve(sourceAsset, destinationChain);34uint256 releasedAmount = applyFees(5 normalizeDecimals(sourceAmount, sourceAsset, dst.token),6 dst.token7);89uint256 releasedValue = oracle.usdValue(dst.token, releasedAmount);1011require(12 releasedValue24h[destinationChain] + releasedValue13 <= withdrawalLimit[destinationChain],14 "limit"15);
Written as a ledger update, the difference is one line:
1Incorrect: limit_usage += price(source_asset) × source_amount2Required: limit_usage += price(released_asset) × released_amount
That one line has several consequences that implementers need to handle explicitly.
- Decimals. The released amount must be expressed in the destination token's decimals before pricing. A source token with 18 decimals and a destination with 6 differ by a factor of 10¹².
- Chain-specific identity. "USDC" on two chains can be two different contracts with different risk. The limiter should key on the destination token address and chain, not a symbol.
- Wrapped and canonical assets. A wrapped representation and the canonical asset it tracks may have different liquidity and different oracle coverage.
- Fees and conversion ratios. Price what leaves custody after fees and any rate conversion, not the gross request.
- Unpriceable destination assets. If the destination token has no valid price, the safe default is to block the withdrawal or route it through an explicitly approved fallback. Assigning a value of zero turns every unpriced asset into an unlimited one.
The strongest version of this control does not compute the released value from the request at all. It measures the change in custody balances and prices that. A limiter that reads the reserve delta is indifferent to bugs in decimal handling, fee logic and registry resolution, because it observes what actually moved.
Why you need both fixes
It is tempting to treat one of the two defects as the "real" root cause. Both are necessary to explain the loss, and both are needed for a robust fix.
If only valuation is fixed, a forged mapping can still release valuable assets, up to the limit. At US$50,000 per day, that is a slower drain rather than a prevented one, and every day of an unnoticed corrupted mapping costs the full limit.
If only registry authentication is fixed, the limiter still measures the wrong thing. The next path that changes the relationship between source and destination value, whether a decimal mismatch, a mispriced wrapped asset or a future registry bug, will again pass through the limit unseen.
The two controls defend different properties. Registry authentication protects correctness of the mapping. Destination-side valuation provides bounded loss when correctness fails. A withdrawal limit exists precisely for the case where something upstream is already wrong, so it cannot depend on the upstream being right.
The invariants
Two properties capture both defects.
Invariant 1: The limit bounds value actually released
For every completed withdrawal in the rolling window:
where
destinationAsset and releasedAmount describe what left custody after registry resolution, decimal normalization, fees and conversion, and is the configured limit.Invariant 2: Every registry entry has an authenticated origin
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →For every accepted mapping
registry[sourceAsset][destinationChain] = destinationToken, the destination token was created or explicitly approved for that source asset, and its address, code identity, deployer and initialization data match an authorized registration request.How to test this class
Both invariants are cross-system properties, so the tests have to execute both sides of the bridge together: the registry, the message path and the destination custody contracts. In a mixed Cosmos/EVM stack that usually means a test harness that simulates the module's registry logic alongside the EVM contracts, or an end-to-end environment that runs both.
The following Foundry-style harness is a generic sketch. It shows the essential design choice: the ghost variable is computed from custody balance deltas, not from the arguments of the withdrawal call.
1// Generic sketch of a stateful invariant harness — not Demex code.2contract BridgeHandler is Test {3 Bridge bridge;4 Custody custody;5 IOracle oracle;6 address[] public trackedTokens;7 uint256 public ghostReleasedUsd; // within the current window89 function withdraw(uint256 assetSeed, uint256 amount, uint256 chainSeed) external {10 address src = _pickSourceAsset(assetSeed);11 uint256 chain = _pickChain(chainSeed);1213 uint256[] memory before = _custodyBalances();14 try bridge.withdraw(src, bound(amount, 1, type(uint96).max), chain) {} catch {}15 uint256[] memory afterBal = _custodyBalances();1617 for (uint256 i; i < trackedTokens.length; i++) {18 if (before[i] > afterBal[i]) {19 ghostReleasedUsd += oracle.usdValue(trackedTokens[i], before[i] - afterBal[i]);20 }21 }22 }2324 function reportDeployment(/* fuzzed report fields */) external {25 // Mutate token address, source asset, chain, factory, request id,26 // implementation and init data independently.27 }28}2930contract BridgeInvariants is StdInvariant, Test {31 function invariant_limitBoundsReleasedValue() public view {32 assertLe(handler.ghostReleasedUsd(), limiter.limit());33 assertEq(limiter.usedInWindow(), handler.ghostReleasedUsd());34 }3536 function invariant_everyMappingHasAuthenticatedOrigin() public view {37 uint256 n = registry.length();38 for (uint256 i; i < n; i++) {39 Registration memory r = registry.at(i);40 assertTrue(requests.wasAuthorized(r.requestId));41 assertEq(r.deployer, approvedFactory);42 assertEq(r.sourceAsset, requests.get(r.requestId).sourceAsset);43 assertEq(r.destinationToken.codehash, r.expectedCodeHash);44 }45 }46}
On the vulnerable design,
invariant_limitBoundsReleasedValue fails as soon as the fuzzer pairs a cheap source token with a valuable destination token, because the ghost variable sees WETH leave custody while the limiter records the value of the source token.Four properties are worth encoding explicitly.
Property 1: Released value consumes the limit. Generate arbitrary mappings, decimal combinations and withdrawal amounts. Assert that limit usage increases by the value of assets leaving custody, not by the value of assets deducted.
Property 2: A report cannot change the meaning of its request. Starting from a valid request/report pair, mutate one field at a time: destination address, source asset, chain identifier, factory, implementation, initialization data, request identifier. Every altered report must be rejected.
Property 3: Existing contracts cannot be registered as new representations. Attempt to bind a cheap test token to a canonical wrapped asset, an existing high-value ERC-20, a token already registered for another asset, and a contract deployed outside the approved factory. All four must fail.
Property 4: Registry mutations are always observable. Every successful mapping creation or change must emit a uniquely attributable event. Assert that the count of registry writes equals the count of those events, so no path, including generic execution and callbacks, can mutate the registry silently.
Why isolated reviews missed it
Demex is candid about this. Its contracts were professionally audited in late 2024. In its words: "The audit surfaced no security flaw that would have allowed this attack." Demex also ran AI-assisted reviews over the codebase while preparing it to be open sourced, "and those did not flag it either." Its conclusion: "the vulnerability only appears when both systems and the exchange that passes between them are reviewed holistically."[1]
The wrong lessons to draw are "audits do not work" or "AI cannot find cross-chain bugs." One incident cannot support either generalization. The defensible lesson is narrower:
The review boundary must contain every component required to establish the invariant.
Invariant 1 depends on the registry, the message path, the limiter and the destination custody contracts. Invariant 2 depends on the deployment flow, the factory, the report handler and the registry. If any of those sits outside the scope of a review, the review cannot prove the property, however careful it is inside its boundary. Each side of the Demex bridge could look correct given its assumption about the other side. The flaw was in the assumptions.
Demex's own list of what it should have done differently maps closely onto the two defects: audit both systems as a whole, monitor bridge balances independently "rather than relying on a guard only on Demex," and alert on changes to the bridge registry itself, which Demex says "would have surfaced this within minutes."[1]
For teams commissioning reviews, the practical takeaway is to scope by invariant, not by repository. Ask which properties the system must uphold, list every component each property depends on, and make sure the review, the test harness and the monitoring all cover that full set.
Bridge withdrawal review checklist
Use these questions when reviewing any bridge, gateway or cross-domain withdrawal path:
- What asset is deducted on the source side?
- What asset is released from destination custody?
- Where is the mapping between them stored, and on which side of the boundary?
- Who and what can create or modify that mapping, including through generic execution, callbacks and deployment reports?
- How is a reported destination address authenticated against the original request?
- Is the destination contract's code identity checked, including for proxies and clones?
- Which asset and which amount consume the withdrawal limit?
- Are decimals normalized and fees applied before valuation?
- What happens when the released asset has no valid price?
- Are registry changes independently monitored and alerted on?
- Does any test execute both sides of the bridge together?
Bridge assurance requires reviewing the registry, the message path, the destination contracts, custody accounting and operational controls as one system. A clean result for any isolated component cannot establish the end-to-end value-conservation property.
FAQ
1. Was the Demex exploit an oracle manipulation attack?
No. Demex does not describe any manipulation of a legitimate price. The guard used a real price for nLEND. The failure was that it priced nLEND at all, when the asset leaving custody was WETH or WBNB.
2. Did the attacker bypass access control?
Not in the usual sense. A direct request to register the pairing was rejected as unauthorized. The successful path used the generic execute flow and a falsified deployment report, exploiting the trust between two components that were each acting within their permissions.
3. Would a lower withdrawal limit have prevented the loss?
No. The guard valued both withdrawals at about US$10.47, so any limit above that would have passed them. The limit's size was not the problem; what it measured was.
4. Is pricing the destination asset enough on its own?
It bounds the loss but does not prevent it. With a corrupted mapping and correct valuation, an attacker can still drain up to the limit in every window. Registry authentication is needed to stop the corruption itself.
5. How much did the attacker profit?
Demex reports about US$191,929 of assets released from bridge reserves and about US$60 spent acquiring nLEND. It does not report realized proceeds after movement or liquidation, so the released value should not be read as net profit.
Glossary
| Term | Definition |
|---|---|
| Cross-chain | Communication or value transfer between separate blockchain domains. |
| Property-based testing | Testing that asserts a property holds across many generated inputs rather than a few fixed examples. |
| Oracle | A mechanism that supplies external data, such as asset prices, to on-chain logic. |
| Invariant testing | Stateful testing that checks whether defined properties remain true across operation sequences. |
| Audit scope | The code, assumptions, integrations and exclusions covered by a bounded security review. |
Sources
[1] https://blog.dem.exchange/25-sep-2026-demex-exploit-post-mortem/ - Demex, "25 Sep 2026 Demex Exploit Post-Mortem" (29 September 2026)
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →