Last weekend we ran the 3rd edition of PwnSec CTF , Humans vs Cyborgs, this is my second year authoring a challenge for PwnSec CTF. I made a blockchain/EVM challenge called Meridian, shipped in Misc category, and this is its writeup.
Here is the path a player should walk the challenge: read the protocol, notice the one lever that inflates borrowing power, work out why the gate on that lever is forgeable, then build the two tools that forge it. Everything you need is in this post: all the contracts, both off-chain grinders, and the on-chain solver. The only thing you don’t start with is the backdoor scalar k, and recovering kis the challenge.
Before anything else: none of this ships without the rest of the crew. Admins, leads, the other authors, design. I only built one challenge, and these people built the whole thing around it. The event is run by PwnSec , and all three editions are on CTFtime if you want to see what else we have put out.
The PwnSec CTF 2026 team
Alright. Let me show you what I did to you.
The Challenge
Meridian is a self-contained, isolated lending market, in the same family as Morpho, Euler or Jigsaw. It’s admin-less and immutable: everything is fixed at deploy, there’s no oracle, no external RPC dependency, just mock ERC20s and fixed prices. The design philosophy was to hide the bug in volume rather than in obfuscation. Roughly a dozen contracts (Router, MarketCore, StrategyManager, InterestModel, PriceModule, RiskParams, LiquidationModule, strategies, libs…), every one of them reading clean and being individually correct. No weird assembly tricks screaming “look here”. I wanted the hard part to be which module, not what’s wrong with this line.
Each team gets its own dedicated instance over RPC, with a per-instance backdoor derived one-way from the team’s static flag. I’ll be honest, that part was mostly anti-cheat paranoia on my end. A k you recover on someone else’s instance will not open your commitment, so there’s no shortcut in passing numbers around. It matters later, so keep it in mind.
The handout
The README.md players receive is short and to the point:
1 2 3 4 5 6 7
# Meridian
An isolated lending market. You interact with your own dedicated instance over RPC.
## Goal
Make your instance's `Setup.isSolved()` return `true`.
You’re given an RPC endpoint, the deployed Setup address (from which you read every module address), and a player account funded with 20,000 COL to work with.
So the objective is bad debt: get the player position holding at least 100 units of value more debt than the collateral it actually put up. The normal deposit/borrow loop can’t get you there. The health check gates every borrow, and I spent real effort, plus a pile of invariant tests, making sure it holds. The only way through is to make the market believe your collateral is worth far more than it is.
Here’s the full Setup, so you can see exactly how the instance is wired and where the commitment constants come from:
// SPDX-License-Identifier: MIT pragmasolidity 0.8.24;
import {MockERC20} from "./mocks/MockERC20.sol"; import {PriceModule} from "./PriceModule.sol"; import {RiskParams} from "./RiskParams.sol"; import {InterestModel} from "./InterestModel.sol"; import {StrategyManager} from "./StrategyManager.sol"; import {AaveLikeStrategy} from "./strategies/Strategies.sol"; import {MarketCore} from "./MarketCore.sol"; import {Router} from "./Router.sol"; import {LiquidationModule} from "./LiquidationModule.sol"; import {RewardsController} from "./RewardsController.sol"; import {EmissionsDistributor} from "./EmissionsDistributor.sol"; import {FlashLender} from "./FlashLender.sol"; import {FeeController} from "./FeeController.sol"; import {ReserveVault} from "./ReserveVault.sol"; import {IStrategy} from "./interfaces/IStrategy.sol";
contractSetup { MockERC20 public col; MockERC20 public usd; PriceModule public price; RiskParams public risk; InterestModel public interest; StrategyManager public sm; AaveLikeStrategy public strat; MarketCore public market; Router public router; LiquidationModule public liq; RewardsController public rewards; EmissionsDistributor public emissions; FlashLender public flash; FeeController public fees; ReserveVault public vault; MockERC20 public reward;
uint256constant COL = 1e18; uint256constant USDU = 1e6;
constructor(address _player) { player = _player;
col = new MockERC20("Collateral", "COL", 18); usd = new MockERC20("USD", "USD", 6);
PriceModule.Init[] memory pin = new PriceModule.Init[](2); pin[0] = PriceModule.Init(address(col), 1e18); pin[1] = PriceModule.Init(address(usd), 1e18); price = new PriceModule(pin);
RiskParams.Init[] memory rin = new RiskParams.Init[](1); rin[0] = RiskParams.Init(address(col), 8000, 9000, 500, 0); risk = new RiskParams(rin);
interest = new InterestModel(0); sm = new StrategyManager(); strat = new AaveLikeStrategy(address(sm), address(col)); market = new MarketCore(); router = new Router(); liq = new LiquidationModule(); rewards = new RewardsController(); emissions = new EmissionsDistributor(); reward = new MockERC20("Protocol Reward", "RWD", 18); flash = new FlashLender(address(usd), 9); vault = new ReserveVault(20828735128985083106809102469836454533339647188769407363916492215150528834129, 16893508830174735210096944744733716441606454720372950309160256768972409752777, 7609027849750344412992105734680007068528542162550367288897682810516822088679, 11069003885991064450329734655988969107489800184559199075750145244031508026270); fees = new FeeController(address(0xFEE), 10, 50, 9);
Two of those three are traps I built to eat your time. The boost is capped and bound to real deposits. I know it looks juicy, and an earlier version of this challenge genuinely did have an exploitable boost bug. I fixed it and left the shape behind on purpose. The strategy price-per-share only ever moves in the depositor’s favor by fractions, so the share-donation idea that every DeFi player reaches for reflexively goes nowhere. And I’d bet good money someone burned an hour on a cross-function reentrancy in the withdraw/borrow path before conceding the accounting there is airtight, because I hardened that path knowing how tempting it looks.
The odd one out, and the one you shouldn’t trust, is the reserve. It’s a value credited to a position by a separate module, added at full weight, with no relationship to anything the market itself controls. If we can write a large number into reserveOf(player), collateralValueWad inflates one-for-one and the borrow follows.
So the whole challenge collapses to a single question: how do we write an arbitrary reserve for ourselves?
Here is the full MarketCore if you want to confirm the reserve really is added raw and that the withdraw/liquidation paths are tight:
contractMarketCoreisICollateralHook, IMarketCore { using FixedPointMath foruint256;
uint256internalconstant WAD = 1e18;
IPriceModule public priceModule; IRiskParams public riskParams; IInterestModel public interestModel; IStrategyManager public strategyManager; IRewards public rewards; IReserveSource public vault; addresspublic router; addresspublic liquidation; addresspublic baseAsset; boolprivate _init;
ReserveVault is small, and if I’m being honest that’s the one place my “hide it in volume” plan leaks a little. In a codebase this size, the tiny module is the tell. If you’ve solved a few of these you learn to be suspicious of the short file that does something cryptographic, and this is that file. It’s short enough to read in full, so here it is:
function _cosigned(address who, uint256 r, uint256 a, uint256 b) privatepurereturns (bool) { bytes6 ta = bytes6(keccak256(abi.encode(who, r, a))); bytes6 tb = bytes6(keccak256(abi.encode(who, r, b))); return (ta ^ tb) == AUTH_TAG; }
functionclaimReserve(uint256 reserveWad, uint256 r, uint256 a, uint256 b) external { require(_opens(reserveWad, r), "bad-opening"); require(a != b, "same-cosigner"); require(_cosigned(msg.sender, r, a, b), "bad-cosign"); creditedReserveWad[msg.sender] = reserveWad; } }
_mul/_add are just wrappers around the bn254 precompiles (0x07 scalar-mul and 0x06 point-add on the alt_bn128 curve), and G = (1, 2) is the standard bn254 G1 generator. So _opens is a textbook Pedersen commitment check:
1
reserveWad * G + r * H == C
where C = (cx, cy) is a fixed commitment baked into the deployment. To credit ourselves an arbitrary reserve, claimReserve needs two things from us:
an opening (reserveWad, r) of C to the value we want, and
a cosign pair (a, b), a != b, whose two truncated keccak hashes XOR to AUTH_TAG.
Two gates, two completely different worlds of math bolted onto the same function call. That was very much the point. Let’s take them one at a time.
Insight 1: the Pedersen setup is subverted
A Pedersen commitment is supposed to be binding: given C, you shouldn’t be able to produce two different openings, because doing so requires knowing the discrete log of H with respect to G, i.e. the k such that H = k*G. In a proper deployment, H comes out of a “nothing-up-my-sleeve” process, hash-to-curve from a public seed, precisely so that nobody knows that k.
H is just handed to the contract as constructor data. Those first two big numbers in the new ReserveVault(...) line back in Setup, which I’d bet your eyes slid right past the first time. There is no independent derivation anywhere: no hash-to-curve, no seed, nothing. In a real audit that’s a red flag the size of a billboard, but the trick of the challenge is that it’s a billboard hidden three modules deep behind a lot of correct-looking DeFi. It means H = k*G for some specific k, and whoever ran the setup (hi, that’s me) knows it. Binding is gone.
And if binding is gone, we can open C to whatever we want. The genesis opening is public, because GENESIS_V and GENESIS_R are constants and the commitment was built as C = GENESIS_V*G + GENESIS_R*H. For a target value v', we want an r' with:
1
v'*G + r'*H == C == GENESIS_V*G + GENESIS_R*H
Substitute H = k*G and everything collapses from curve points to plain scalars mod the group order N:
That’s the malleation: one modular inverse and we can open C to any value we like. The catch is that the formula needs k, and k was not shipped. Recovering it is the price of admission. I lost some sleep over whether that price was set correctly, which brings me to the next bit.
Why the “free” opening is useless: the overflow trap
When I was designing this, the first thing I worried about was the cheap shortcut, because if one existed the whole challenge falls over in five minutes. Here’s the shortcut a smart player reaches for: skip k entirely. You already know one valid opening, (GENESIS_V, GENESIS_R), so why not just call claimReserve(GENESIS_V, GENESIS_R, a, b)? It sails through _opens for free.
It doesn’t work, and I had to make sure of that. GENESIS_V is 424242, a reserve of 424242 wei, which is nothing. To cross the 100-WAD bad-debt threshold with any margin you need a reserve up in the 1e24 range.
Could we open C to something enormous without k? There is technically another public opening if you let the value get near the group order. But a reserve that size flows straight into collateralValueWad, and then into the borrow, where openBorrow multiplies it by a threshold in basis points via FixedPointMath.bps:
1 2 3 4 5 6 7
// in openBorrow: require(debtValueWad(pos) <= collateralValueWad(pos).bps(riskParams.ltvBps(p.collAsset)), "ltv");
// FixedPointMath.bps: functionbps(uint256 x, uint256 b) internalpurereturns (uint256) { return (x * b) / 10_000; // multiplies by up to 10,000 BEFORE dividing }
A ~2^254 reserve overflows that x * b multiply and the transaction reverts. So the free opening is boxed in from both sides: either negligible (GENESIS_V) or unusable (overflow). Any reserve that’s both large enough to matter and small enough to survive bps has to come from the malleation formula, and the malleation needs k. That squeeze is the load-bearing part of the design. It’s what turns the discrete log from an optional flex into the only way in, and I wrote a test whose only job is to prove that a large genesis-r opening reverts, because if it didn’t the challenge was broken.
I named it _cosigned and gave it two parameters called a and b on purpose. It’s dressed up to read like a two-party signature or authorization check, the kind of thing you assume you can’t forge without a private key, so that your instinct is to go looking for the key somewhere else in the codebase. That framing is a costume. Look at what you actually control: AUTH_TAG is a fixed 48-bit constant, and you choose both a and b. This isn’t a preimage search over 48 bits (2^48, painful), it’s a meet-in-the-middle. Build a table of bytes6(keccak(caller, r, a)) over ~2^24 values of a, then vary b and for each one look up bytes6(keccak(caller, r, b)) ^ AUTH_TAG in the table. Birthday math says a collision shows up around 2^24 on each side instead of the full 2^48.
The part I’m happiest with, structurally, is that the table is keyed on (caller, r), and r is the malleated scalar from Insight 1. You cannot precompute this. Grind 2 can’t even start until Grind 1 has handed you r, so the two grinds are welded in series. No front-running, no precomputing overnight, no borrowing a friend’s r. You do the discrete log, then the MITM, in that order, every time.
Exploitation
Everything above was reconnaissance. The actual solve is two off-chain grinds feeding four on-chain calls, and the grinds are where you’ll spend basically all of your time. Let’s build it.
Grind 1: recovering the 60-bit k
k is a discrete log on bn254 G1: find k such that k*G == H, where k is 60 bits.
The instinct is baby-step giant-step, and I picked 60 bits precisely because that’s where BSGS stops being convenient. BSGS wants a table of about 2^(60/2) = 2^30 points, each one tens of bytes, so call it tens of gigabytes. That is not fitting in your RAM. The memory wall is deliberate: it pushes you off the easy table-lookup algorithm and onto a constant-memory one, Pollard’s rho, or a kangaroo if you want to exploit the 60-bit interval. There’s also no pip install bn254-dlog waiting for you, so you end up writing the curve arithmetic by hand. Honestly, that’s the real barrier, more than the raw ~2^30 operation count. I wanted a challenge you couldn’t just download your way out of.
The reference below implements bn254 G1 in pure Python and walks a bounded Pollard rho. The partition is on the x-coordinate mod 3, and it’s a Floyd cycle-find (tortoise one step, hare two) until the two walks collide. At the meeting point you have a1*G + b1*H == a2*G + b2*H, which gives you k = (a2-a1)/(b1-b2) mod N. Read hx, hy off your own deployed ReserveVault and drop them in as HX, HY:
recover_k.py: Grind 1, bn254 discrete log via Pollard's rho
#!/usr/bin/env python3 # GRIND 1: recover the 60-bit backdoor k = dlog_G(H) by Pollard's rho (constant memory). # a BSGS table at 60 bits is ~2^30 entries (tens of GB) and will not fit; rho is ~2^30 group ops, O(1) mem. # requires bn254 G1 arithmetic (implemented here; the on-chain verifier does not export a library). import sys p = 21888242871839275222246405745257275088696311157297823662689037894645226208583 n = 21888242871839275222246405745257275088548364400416034343698204186575808495617 definv(a,m): returnpow(a%m,m-2,m) defpadd(P,Q): if P isNone: return Q if Q isNone: return P (x1,y1),(x2,y2)=P,Q if x1==x2 and (y1+y2)%p==0: returnNone if P==Q: mm=(3*x1*x1)*inv(2*y1,p)%p else: mm=(y2-y1)*inv(x2-x1,p)%p x3=(mm*mm-x1-x2)%p; y3=(mm*(x1-x3)-y1)%p return (x3,y3) defpmul(k,P): R=None;k%=n while k: if k&1: R=padd(R,P) P=padd(P,P);k>>=1 return R G=(1,2) # read hx, hy off YOUR deployed vault HX=20828735128985083106809102469836454533339647188769407363916492215150528834129 HY=16893508830174735210096944744733716441606454720372950309160256768972409752777
# bounded Pollard rho for dlog when k < 2^B (uses the interval to bound the walk). defrho(H, B): # partition function on x-coord; walk P = a*G + b*H, find collision -> k = (a1-a2)/(b2-b1) mod n deff(P, a, b): if P isNone: r=0 else: r=P[0] % 3 if r==0: return padd(P,G), (a+1)%n, b if r==1: return padd(P,H), a, (b+1)%n return padd(P,P), (2*a)%n, (2*b)%n import random whileTrue: a=random.randrange(n); b=random.randrange(n) T=padd(pmul(a,G),pmul(b,H)); a1,b1=a,b H1=T; a2,b2=a,b; H2=T for _ inrange(1<<24): H1,a1,b1=f(H1,a1,b1) H2,a2,b2=f(*f(H2,a2,b2)) # hare: two steps if H1==H2: if (b1-b2)%n==0: break k=( (a2-a1)*inv(b1-b2,n) )%n if pmul(k,G)==H and k < (1<<B): return k break
if __name__=="__main__": # self-test on a small planted key (fast); the shipped k is 60-bit (~2^30 rho, minutes-to-tens-of-min). kb=int(sys.argv[1]) iflen(sys.argv)>1else24 import random ks=random.randrange(1<<kb); Ht=pmul(ks,G) got=rho(Ht, kb+2) print("selftest kb=%d planted=%d recovered=%d ok=%s"%(kb,ks,got,got==ks))
The script self-tests on a small planted key before you point it at the real H. I added that because when I was writing the walk myself I got a sign wrong in the collision formula and spent an embarrassing amount of time convinced the challenge was broken when it was my own arithmetic. A self-test that finishes in a second saves you from that particular flavor of madness. In pure Python at 60 bits the real run is minutes to tens of minutes of CPU. The arithmetic is embarrassingly parallel, so a batched bn254 kangaroo on a mid-range GPU clears the same interval in about 28 seconds. Either way the work is bounded and real. One warning: small mistakes in the curve arithmetic or the byte packing fail silently. The walk just never converges, which is exactly the kind of thing that eats both human and machine solvers alive.
Once k is out, verify it before you build anything on top of it. pmul(k, G) == H, one line, do it every time. There is nothing worse than grinding the MITM against a subtly wrong r for twenty minutes. Then malleate for our target value v' = 1e24:
1 2 3 4 5 6 7 8 9 10 11
G = (1, 2) H = (HX, HY) C = (7609027849750344412992105734680007068528542162550367288897682810516822088679, 11069003885991064450329734655988969107489800184559199075750145244031508026270)
# sanity: reserveWad*G + rprime*H == C assert padd(pmul(vprime, G), pmul(rprime, H)) == C
rprime is a ~254-bit scalar. That’s the r we hand to claimReserve.
Grind 2: the cosign MITM
Now that we have r, we run the meet-in-the-middle over (caller, r). The table is keyed on the malleated r, so, to say it once more, this cannot start until Grind 1 is done:
caller=int(sys.argv[1],16); r=int(sys.argv[2]) head=caller.to_bytes(32,'big')+r.to_bytes(32,'big') # abi.encode(who, r, .) T=1<<24; table={} for a inrange(T): table.setdefault(k6(head+a.to_bytes(32,'big')), a) b=T whileTrue: a=table.get(k6(head+b.to_bytes(32,'big')) ^ AUTH_TAG) if a isnotNoneand a!=b: print("a",a,"b",b); break b+=1
A representative run of the pure-Python version, from my own machine while I was testing:
1 2
a 12738017 b 82613215 found in 1035.3s
Seventeen minutes of watching a terminal do nothing is exactly the kind of dead air that makes you doubt your own code, so I also wrote a small C port with a self-contained keccak. Same algorithm, but with an open-addressed table and a hardcoded absorb for the fixed 96-byte input, and it finishes in a couple of minutes instead. If you’re solving this live under a clock, this is the one you want:
mitm_cosign.c: the C port of Grind 2 (self-contained keccak, open-addressed table)
// SPDX-License-Identifier: MIT pragmasolidity 0.8.24; import {Setup} from "../src/Setup.sol"; import {ReserveVault} from "../src/ReserveVault.sol"; import {Router} from "../src/Router.sol"; import {MarketCore} from "../src/MarketCore.sol"; import {PriceModule} from "../src/PriceModule.sol"; import {MockERC20} from "../src/mocks/MockERC20.sol";
contractAttacker { functionsolve(Setup st, uint256 reserveWad, uint256 r, uint256 a, uint256 b) external { ReserveVault v = st.vault(); Router router = st.router(); MockERC20 col = st.col(); col.approve(address(router), type(uint256).max); router.depositCollateral(1e18); // a real 1.0 collateral position v.claimReserve(reserveWad, r, a, b); // forged: credits the phantom reserve MarketCore market = st.market(); PriceModule price = st.price(); uint256 cv = market.collateralValueWad(address(this)); router.borrow(price.fromWad(address(st.usd()), cv * 79 / 100)); } }
After all that cryptography the on-chain finish is almost anticlimactic, and I like that it is. We deposit a genuine 1.0 so the position actually exists, claimReserve writes the phantom reserve which lifts collateralValueWad to ~1,000,001, then we borrow 79% of that. The borrowed USD is real value walking out of the market against collateral that is honestly worth one dollar. The 79% is just me leaving headroom under the LTV so the borrow doesn’t clip the limit and revert on you.
If you’d rather drive it straight over RPC with cast, read the constants off your own instance first, run the two grinders, then submit:
forged reserve (wad): 1000000.000000000000000000 player debt (wad): 790000.790000000000000000 player true collateral (wad): 1.000000000000000000
Debt of ~790,000 against real collateral of 1.0 is bad debt of ~790,000, far past the 100-WAD threshold. isSolved() flips to true and the platform releases the flag:
1
pwnsec{dynamic_flag}
How it actually went
Because this edition was scored Humans vs Cyborgs, every challenge ended up with two solve counts and a gap between the first human solve and the first cyborg solve. That turns the scoreboard into a difficulty readout, which is a luxury you do not normally get as an author:
PwnSec CTF 2026 solve statistics, humans versus cyborgs
Meridian landed here:
Human solves
11
Cyborg solves
87
Human lag on first solve
+23 min
Eleven human solves put it in the harder half of the board, which is roughly where I wanted it. The number I keep looking at, though, is that +23 minutes.
Closing thoughts
What I was chasing with Meridian is a challenge where recognizing either bug isn’t enough on its own, where you can be completely right about half of it and still be stuck. The first half is a subverted Pedersen setup that you only catch by noticing H has no honest origin, and it forces a real discrete log because I nailed the free openings shut from both sides. The second is an XOR of two attacker-controlled hashes wearing a signature-check costume, a meet-in-the-middle in disguise, bolted to the output of the first so there’s no precomputing your way around the sequencing.
In blind testing it took a strong solver real time on both grinds and made them build two separate tools from scratch, which is the shape I was aiming for. Not “gotcha” hard, just two pieces of crypto in series. If you solved it, especially if you solved it the intended way rather than by finding some hole I missed, I’d love to hear how it went. I only ever get to see this challenge from the side that already knows the answer.
Big thanks to everyone who played this edition, and to my teammates for stress-testing the deploy until it stopped falling over. If you want to catch the next one, PwnSec is on CTFtime . See you next time~