KiloEx $7.5M Case Research: A Trusted Forwarder That Let Anyone Become the Price Keeper
KiloEx is a clean example of why access control has to be checked at the real entry point, not somewhere in the middle of a call chain. The sensitive function was not obscure: setPrices() controlled the protocol's internal oracle for leveraged positions. The problem was that the "trusted" route into it started with a forwarder that could be convinced to speak as someone else.
This reconstruction uses Halborn, QuillAudits, OneKey, and Chainvestigate analyses. I am focusing on the Base path because it is the clearest public transaction trail, but the same pattern was repeated across Base, BNB Chain, opBNB, and related deployments.
The takeaway: a multi-contract permission chain is only as strong as its first hop. If the forwarder lets the attacker choose the identity, every downstream
onlyKeepercheck is theater.
Target architecture: oracle updates through a call chain
KiloEx is a perpetual trading protocol. Its vaults pay out leveraged positions based on protocol-controlled prices. The intended price update path was:
TrustedForwarder / MinimalForwarder
-> PositionKeeper
-> Keeper
-> KiloPriceFeed.setPrices()
The internal assumption was:
If setPrices() is reached through Keeper, the caller must be an authorized keeper.
That is the wrong invariant. The contract needed to prove who originated the call at the forwarder boundary and whether the signature actually authorized that exact action.
Root cause: identity injection at the forwarder
The vulnerable shape is a meta-transaction forwarder pattern. In a safe design, execute(req, sig) verifies:
- the signer,
- the nonce,
- the domain separator and chain,
- the target,
- the calldata,
- and that the recovered signer is allowed to execute the requested action.
The KiloEx path failed around that trust boundary. Analyses describe the forwarder as allowing a caller to supply a fabricated signature and a from address that downstream contracts treated as trusted.
Representative vulnerable shape:
function execute(ForwardRequest calldata req, bytes calldata sig)
external
payable
returns (bool, bytes memory)
{
// Verification is incomplete or contextless.
// The attacker controls req.from and req.data.
require(_verify(req, sig), "bad sig");
(bool ok, bytes memory ret) = req.to.call(
abi.encodePacked(req.data, req.from)
);
return (ok, ret);
}
The exact implementation details matter less than the boundary failure: the forwarder became an identity oracle for the rest of the system, but it did not enforce the identity strongly enough.
Privileged function reached: setPrices()
The attacker's goal was not to steal directly from the forwarder. The goal was to steer the oracle:
function setPrices(bytes[] calldata priceUpdates) external onlyKeeper {
for (uint256 i = 0; i < priceUpdates.length; i++) {
(address token, uint256 price) = decode(priceUpdates[i]);
prices[token] = price;
}
}
The downstream onlyKeeper style check relied on the call path. Once the forwarder let the attacker impersonate the keeper path, the attacker could update prices to arbitrary values.
That turns into a textbook perps drain:
Set ETH price very low -> open long
Set ETH price very high -> close long
Protocol pays the fake PnL from LP vaults
QuillAudits reports the ETH price was set to roughly $100, then later $10,000, allowing a leveraged long to realize artificial profit.
Attack chain
The Base exploit path is representative:
- Fund attacker wallet. Public analyses note Tornado Cash funding before the exploit.
- Call the trusted forwarder. The attacker submits a crafted
execute()call with controlledfrom, data, and signature material. - Traverse the keeper chain. The forwarded call reaches
PositionKeeper, thenKeeper, thenKiloPriceFeed. - Set a fake low price. The attacker updates the protocol price for ETH to a deeply discounted value.
- Open a leveraged long. The position is opened against LP vault liquidity at the manipulated low price.
- Set a fake high price. The attacker updates ETH to a much higher value.
- Close the long. The protocol calculates large artificial profit and pays it out.
- Repeat across chains. The same trust-boundary failure is used on other deployments.
Representative execution shape:
// 1. Spoof trusted keeper identity through the forwarder.
forwarder.execute(
ForwardRequest({
from: attackerChosenKeeper,
to: address(positionKeeper),
data: abi.encodeCall(PositionKeeper.updatePrices, (lowEthPrice))
}),
fabricatedSignature
);
// 2. Open long while oracle says ETH is cheap.
positionRouter.executeIncreasePosition(...);
// 3. Spoof keeper again and lift the oracle.
forwarder.execute(
ForwardRequest({
from: attackerChosenKeeper,
to: address(positionKeeper),
data: abi.encodeCall(PositionKeeper.updatePrices, (highEthPrice))
}),
fabricatedSignature
);
// 4. Close long and withdraw artificial PnL.
positionRouter.executeDecreasePosition(...);
The exploit is a combination of access control and oracle manipulation. The oracle was not moved through market trades; it was moved through unauthorized administrative write access.
Transaction trail
Public transaction references include:
- Base exploit tx:
https://basescan.org/tx/0x6b378c84aa57097fb5845f285476e33d6832b8090d36d02fe0e1aed909228edd - Base follow-up tx:
https://basescan.org/tx/0xde7f5e78ea63cbdcd199f4b109db2a551b4462dec7 - opBNB exploit tx:
https://opbnbscan.com/tx/0x79eb28ae21698733048e2dae9f9fe3d913396dc9d93a0e30d659df6065127964 - Attacker wallet cited by QuillAudits:
0x00fac92881556a90fdb19eae9f23640b95b4bcbd - Spoofed/related address cited by QuillAudits:
0x551f3110f12c763d1611d5a63b5f015d1c1a954c
What I would change
- Do not let a forwarder become a root of trust by accident. The forwarder must verify typed data, nonce, domain, target, calldata, and signer role.
- Authorize price writes at the price feed itself.
setPrices()should check a role that cannot be spoofed by calldata suffixes or trusted forwarder conventions. - Bind meta-transactions to intent. A signature for one forwarded call must not be usable for another target or calldata payload.
- Add price-change circuit breakers. Even a valid keeper should not be able to move ETH from
$100to$10,000without latency, quorum, or emergency checks. - Test the full call chain. Unit tests for
setPrices()are insufficient if the real entry point isForwarder.execute().
Confidence notes
- Firm: public reports attribute the exploit to a MinimalForwarder/TrustedForwarder access-control failure.
- Firm: the attacker manipulated KiloPriceFeed prices, opened longs at low prices, then closed at high prices.
- Firm: losses were about $7.4M-$7.5M across Base, BNB Chain, opBNB, and related deployments.
- Representative: code snippets reconstruct the published call-chain and trust-boundary failure rather than exact verified source.
Sources
- Halborn — Explained: The KiloEx Hack (April 2025):
https://www.halborn.com/blog/post/explained-the-kiloex-hack-april-2025 - QuillAudits — KiloEx Hack Breakdown:
https://www.quillaudits.com/blog/hack-analysis/kiloex-exploit-breakdown - OneKey — One Missing Check: How KiloEx Lost $7.5M:
https://onekeytest.com/blog/ecosystem/one-missing-check-how-kiloex-lost-75m-to-a-forged-call-chain/ - Chainvestigate — KiloEx Hack Analysis:
https://chainvestigate.com/en/kiloex-hack-analysis/
