PwnSec CTF 2026: Meridian

Foued SAIDI Lv5

PwnSec CTF 2026: Humans vs Cyborgs
PwnSec CTF 2026: Humans vs Cyborgs

Overview

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 k is 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
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.

The win condition

Everything is anchored on Setup.isSolved():

1
2
3
4
5
6
7
8
9
10
11
12
uint256 public constant SOLVE_THRESHOLD_WAD = 100e18;

function trueCollateralWad(address who) public view returns (uint256) {
// only what `who` actually deposited, priced honestly
return price.toWad(address(col), strat.convertToAssets(_shares(who)));
}

function isSolved() external view returns (bool) {
uint256 debt = market.debtValueWad(player);
uint256 trueColl = trueCollateralWad(player);
return debt > trueColl && (debt - trueColl) >= SOLVE_THRESHOLD_WAD;
}

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:

Setup.sol (full source)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// SPDX-License-Identifier: MIT
pragma solidity 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";

contract Setup {
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;

address public immutable player;
uint256 public constant SOLVE_THRESHOLD_WAD = 100e18;

uint256 constant COL = 1e18;
uint256 constant 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);

sm.init(address(col), address(strat), address(market), address(rewards), address(emissions), address(router));
market.init(address(price), address(risk), address(interest), address(sm), address(rewards), address(vault), address(router), address(liq), address(usd));
vault.init(address(market));
router.init(address(sm), address(market), address(col), address(usd));
liq.init(address(market), address(usd));
rewards.init(address(sm), address(market), address(col));
emissions.init(address(sm), address(market), address(col), address(reward), 1e12);

reward.mint(address(emissions), 1_000_000 * COL);
usd.mint(address(flash), 500_000 * USDU);

usd.mint(address(market), 1_000_000 * USDU);

col.mint(address(this), 1_000 * COL);
col.approve(address(router), type(uint256).max);
router.depositCollateral(1_000 * COL);

col.mint(address(strat), 2_500 * COL);

col.mint(player, 20_000 * COL);
}

function _shares(address who) internal view returns (uint256 s) {
(, s, , , ) = market.positions(who);
}

function trueCollateralWad(address who) public view returns (uint256) {
return price.toWad(address(col), strat.convertToAssets(_shares(who)));
}

function isSolved() external view returns (bool) {
uint256 debt = market.debtValueWad(player);
uint256 trueColl = trueCollateralWad(player);
return debt > trueColl && (debt - trueColl) >= SOLVE_THRESHOLD_WAD;
}
}

Note the vault = new ReserveVault(...) line: four big numbers handed straight in as constructor arguments. Hold that thought.

Finding the Lever

The health check and the borrow both price collateral through one function in MarketCore:

1
2
3
4
5
6
function collateralValueWad(address pos) public view returns (uint256) {
Pos storage p = positions[pos];
if (p.collAsset == address(0)) return 0;
return priceModule.toWad(p.collAsset, p.principal + rewards.boostOf(pos))
+ vault.reserveOf(pos);
}

Three inputs feed borrowing power:

  1. the deposited principal,
  2. a loyalty boost from RewardsController,
  3. and vault.reserveOf(pos).

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:

MarketCore.sol (full source)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ICollateralHook, IMarketCore, IPriceModule, IRiskParams, IInterestModel, IStrategyManager} from "./interfaces/IProtocol.sol";
import {IStrategy} from "./interfaces/IStrategy.sol";
import {FixedPointMath} from "./libraries/FixedPointMath.sol";

interface IERC20Min2 {
function transfer(address to, uint256 v) external returns (bool);
function transferFrom(address f, address t, uint256 v) external returns (bool);
}

interface IStrategyManagerAsset {
function collateralAsset() external view returns (address);
}

interface IReserveSource {
function reserveOf(address pos) external view returns (uint256);
}

interface IRewards {
function boostOf(address pos) external view returns (uint256);
function rebase(address pos, uint256 pps) external;
function reduceForWithdraw(address pos, uint256 sharesRemoved, uint256 sharesBefore) external;
function reduceForSeize(address pos, uint256 sharesSeized, uint256 sharesBefore) external;
}

contract MarketCore is ICollateralHook, IMarketCore {
using FixedPointMath for uint256;

uint256 internal constant WAD = 1e18;

IPriceModule public priceModule;
IRiskParams public riskParams;
IInterestModel public interestModel;
IStrategyManager public strategyManager;
IRewards public rewards;
IReserveSource public vault;
address public router;
address public liquidation;
address public baseAsset;
bool private _init;

struct Pos {
address collAsset;
uint256 collShares;
uint256 principal;
uint256 debtPrincipal;
uint256 debtSnap;
}

mapping(address => Pos) public positions;

modifier onlyManager() { require(msg.sender == address(strategyManager), "only-manager"); _; }
modifier onlyRouter() { require(msg.sender == router, "only-router"); _; }

function init(
address _price, address _risk, address _interest, address _sm, address _rewards,
address _vault, address _router, address _liq, address _base
) external {
require(!_init, "init"); _init = true;
priceModule = IPriceModule(_price);
riskParams = IRiskParams(_risk);
interestModel = IInterestModel(_interest);
strategyManager = IStrategyManager(_sm);
rewards = IRewards(_rewards);
vault = IReserveSource(_vault);
router = _router;
liquidation = _liq;
baseAsset = _base;
}

function collSharesOf(address pos) external view returns (uint256) {
return positions[pos].collShares;
}

function onCollateralDeposit(address pos, uint256 assets, uint256 shares) external onlyManager {
Pos storage p = positions[pos];
if (p.collAsset == address(0)) p.collAsset = IStrategyManagerAsset(address(strategyManager)).collateralAsset();
p.principal += assets;
p.collShares += shares;
rewards.rebase(pos, IStrategy(strategyManager.strategyFor(p.collAsset)).pricePerShare());
}

function onCollateralWithdraw(address pos, uint256, uint256 shares) external onlyManager {
Pos storage p = positions[pos];
uint256 before = p.collShares;
if (before > 0) {
p.principal -= p.principal.mulDivDown(shares, before);
rewards.reduceForWithdraw(pos, shares, before);
p.collShares -= shares;
}
}

function collateralValueWad(address pos) public view returns (uint256) {
Pos storage p = positions[pos];
if (p.collAsset == address(0)) return 0;
return priceModule.toWad(p.collAsset, p.principal + rewards.boostOf(pos)) + vault.reserveOf(pos);
}

function debtValueWad(address pos) public view returns (uint256) {
return priceModule.toWad(baseAsset, _debtNow(pos));
}

function _debtNow(address pos) internal view returns (uint256) {
Pos storage p = positions[pos];
if (p.debtPrincipal == 0) return 0;
uint256 idx = interestModel.borrowIndex(address(this));
if (p.debtSnap == 0) return p.debtPrincipal;
return p.debtPrincipal.mulDivUp(idx, p.debtSnap);
}

function healthy(address pos) public view returns (bool) {
Pos storage p = positions[pos];
uint256 debt = debtValueWad(pos);
if (debt == 0) return true;
return collateralValueWad(pos).bps(riskParams.liqThresholdBps(p.collAsset)) >= debt;
}

function openBorrow(address pos, uint256 amount) external onlyRouter {
Pos storage p = positions[pos];
uint256 idx = interestModel.accrue(address(this));
p.debtPrincipal = _settle(p, idx);
p.debtSnap = idx;
p.debtPrincipal += amount;
require(debtValueWad(pos) <= collateralValueWad(pos).bps(riskParams.ltvBps(p.collAsset)), "ltv");
IERC20Min2(baseAsset).transfer(msg.sender, amount);
}

function repay(address pos, uint256 amount) external onlyRouter {
Pos storage p = positions[pos];
uint256 idx = interestModel.accrue(address(this));
uint256 debt = _settle(p, idx);
p.debtSnap = idx;
uint256 pay = amount > debt ? debt : amount;
p.debtPrincipal = debt - pay;
IERC20Min2(baseAsset).transferFrom(msg.sender, address(this), pay);
}

function applyLiquidation(address pos, uint256 repay_, address to) external returns (uint256 seizedShares) {
require(msg.sender == liquidation, "only-liq");
Pos storage p = positions[pos];
uint256 idx = interestModel.accrue(address(this));
uint256 debt = _settle(p, idx);
p.debtSnap = idx;
uint256 pay = repay_ > debt ? debt : repay_;
p.debtPrincipal = debt - pay;

uint256 bonus = riskParams.params(p.collAsset).liqBonusBps;
uint256 payWad = priceModule.toWad(baseAsset, pay);
uint256 seizeNative = priceModule.fromWad(p.collAsset, payWad + payWad.bps(bonus));
IStrategy strat = IStrategy(strategyManager.strategyFor(p.collAsset));
uint256 sharesBefore = p.collShares;
uint256 maxNative = strat.convertToAssets(sharesBefore);
if (seizeNative > maxNative) seizeNative = maxNative;
seizedShares = strat.withdraw(seizeNative, to);
if (seizedShares > sharesBefore) seizedShares = sharesBefore;
p.principal -= p.principal.mulDivDown(seizedShares, sharesBefore);
rewards.reduceForSeize(pos, seizedShares, sharesBefore);
p.collShares -= seizedShares;
}

function _settle(Pos storage p, uint256 idx) internal view returns (uint256) {
if (p.debtPrincipal == 0) return 0;
if (p.debtSnap == 0) return p.debtPrincipal;
return p.debtPrincipal.mulDivUp(idx, p.debtSnap);
}
}

And the Router, which is the only public entrypoint to deposit and borrow. Nothing exotic here, it just forwards to StrategyManager and MarketCore:

Router.sol (full source)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IStrategyManager, IMarketCore} from "./interfaces/IProtocol.sol";

interface IERC20Min4 {
function transfer(address to, uint256 v) external returns (bool);
function transferFrom(address f, address t, uint256 v) external returns (bool);
function approve(address s, uint256 v) external returns (bool);
function balanceOf(address w) external view returns (uint256);
}

contract Router {
IStrategyManager public sm;
IMarketCore public market;
address public collateralAsset;
address public baseAsset;
bool private _init;

mapping(address => mapping(address => bool)) public operatorApproved;

function init(address _sm, address _market, address _coll, address _base) external {
require(!_init, "init"); _init = true;
sm = IStrategyManager(_sm);
market = IMarketCore(_market);
collateralAsset = _coll;
baseAsset = _base;
}

function setOperator(address operator, bool ok) external {
operatorApproved[msg.sender][operator] = ok;
}

function _auth(address pos) internal view {
require(pos == msg.sender || operatorApproved[pos][msg.sender], "not-authorized");
}

function depositCollateral(uint256 amount) external {
_pullAndDeposit(msg.sender, amount);
}

function _pullAndDeposit(address pos, uint256 amount) internal {
IERC20Min4(collateralAsset).transferFrom(msg.sender, address(this), amount);
IERC20Min4(collateralAsset).approve(address(sm), amount);
sm.depositFor(pos, collateralAsset, amount);
}

function withdrawCollateral(uint256 amount) external {
sm.withdrawFor(msg.sender, collateralAsset, amount);
IERC20Min4(collateralAsset).transfer(msg.sender, amount);
require(market.healthy(msg.sender), "unhealthy");
}

function borrow(uint256 amount) external {
market.openBorrow(msg.sender, amount);
IERC20Min4(baseAsset).transfer(msg.sender, amount);
}

function repay(uint256 amount) external {
IERC20Min4(baseAsset).transferFrom(msg.sender, address(this), amount);
IERC20Min4(baseAsset).approve(address(market), amount);
market.repay(msg.sender, amount);
}

function multicall(bytes[] calldata calls) external returns (bytes[] memory results) {
results = new bytes[](calls.length);
for (uint256 i; i < calls.length; ++i) {
(bool ok, bytes memory ret) = address(this).delegatecall(calls[i]);
require(ok, "multicall");
results[i] = ret;
}
}
}

The Gate: ReserveVault

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract ReserveVault {
uint256 internal constant N = 21888242871839275222246405745257275088548364400416034343698204186575808495617;

uint256 public constant GENESIS_V = 424242;
uint256 public constant GENESIS_R = 133731;
bytes6 public constant AUTH_TAG = 0x5E1F2A3B4C6D;
uint256 public immutable hx;
uint256 public immutable hy;
uint256 public immutable cx;
uint256 public immutable cy;

address public market;
bool private _init;
mapping(address => uint256) public creditedReserveWad;

constructor(uint256 _hx, uint256 _hy, uint256 _cx, uint256 _cy) {
hx = _hx; hy = _hy; cx = _cx; cy = _cy;
}

function init(address _market) external {
require(!_init, "init"); _init = true;
market = _market;
}

function reserveOf(address pos) external view returns (uint256) {
return creditedReserveWad[pos];
}

function _mul(uint256 x, uint256 y, uint256 s) private view returns (uint256, uint256) {
uint256[3] memory inp = [x, y, s];
uint256[2] memory out;
bool ok;
assembly { ok := staticcall(gas(), 0x07, inp, 0x60, out, 0x40) }
require(ok, "mul");
return (out[0], out[1]);
}

function _add(uint256 x1, uint256 y1, uint256 x2, uint256 y2) private view returns (uint256, uint256) {
uint256[4] memory inp = [x1, y1, x2, y2];
uint256[2] memory out;
bool ok;
assembly { ok := staticcall(gas(), 0x06, inp, 0x80, out, 0x40) }
require(ok, "add");
return (out[0], out[1]);
}

function _opens(uint256 reserveWad, uint256 r) private view returns (bool) {
(uint256 ax, uint256 ay) = _mul(1, 2, reserveWad); // reserveWad * G
(uint256 bx, uint256 by) = _mul(hx, hy, r); // r * H
(uint256 lx, uint256 ly) = _add(ax, ay, bx, by);
return lx == cx && ly == cy; // == C
}

function _cosigned(address who, uint256 r, uint256 a, uint256 b) private pure returns (bool) {
bytes6 ta = bytes6(keccak256(abi.encode(who, r, a)));
bytes6 tb = bytes6(keccak256(abi.encode(who, r, b)));
return (ta ^ tb) == AUTH_TAG;
}

function claimReserve(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.

Here’s the crack. Look at where H comes from:

1
2
3
constructor(uint256 _hx, uint256 _hy, uint256 _cx, uint256 _cy) {
hx = _hx; hy = _hy; cx = _cx; cy = _cy;
}

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:

1
2
3
v' + r'*k  ==  GENESIS_V + GENESIS_R*k   (mod N)

=> r' = (GENESIS_V + GENESIS_R*k - v') * k^-1 (mod 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:
function bps(uint256 x, uint256 b) internal pure returns (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.

FixedPointMath.sol (full source)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

library FixedPointMath {
uint256 internal constant WAD = 1e18;

function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
return (x * y) / d;
}

function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
return (x * y + (d - 1)) / d;
}

function wadMulDown(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * y) / WAD;
}

function wadDivDown(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * WAD) / y;
}

function bps(uint256 x, uint256 b) internal pure returns (uint256) {
return (x * b) / 10_000;
}
}

Insight 2: the cosign is a meet-in-the-middle, not a signature

Passing _opens is only half the job. _cosigned wants two values a != b with:

1
2
bytes6(keccak256(abi.encode(msg.sender, r, a)))
^ bytes6(keccak256(abi.encode(msg.sender, r, b))) == AUTH_TAG

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/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
def inv(a,m): return pow(a%m,m-2,m)
def padd(P,Q):
if P is None: return Q
if Q is None: return P
(x1,y1),(x2,y2)=P,Q
if x1==x2 and (y1+y2)%p==0: return None
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)
def pmul(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).
def rho(H, B):
# partition function on x-coord; walk P = a*G + b*H, find collision -> k = (a1-a2)/(b2-b1) mod n
def f(P, a, b):
if P is None: 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
while True:
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 _ in range(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]) if len(sys.argv)>1 else 24
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)

GENESIS_V, GENESIS_R = 424242, 133731
vprime = 10**24
rprime = (GENESIS_V + GENESIS_R*k - vprime) * inv(k, n) % n

# 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env python3
# GRIND 2: given (caller, r) find (a,b), a!=b, with
# bytes6(keccak(caller,r,a)) ^ bytes6(keccak(caller,r,b)) == AUTH_TAG -- 48-bit MITM (~2^24)
import sys
from Crypto.Hash import keccak
AUTH_TAG=int("5E1F2A3B4C6D",16)
def k6(x): return int.from_bytes(keccak.new(digest_bits=256,data=x).digest()[:6],'big')

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 in range(T):
table.setdefault(k6(head+a.to_bytes(32,'big')), a)
b=T
while True:
a=table.get(k6(head+b.to_bytes(32,'big')) ^ AUTH_TAG)
if a is not None and 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
typedef uint64_t u64; typedef uint8_t u8;
#define ROTL64(x,y) (((x)<<(y))|((x)>>(64-(y))))
static const u64 RC[24]={0x0000000000000001ULL,0x0000000000008082ULL,0x800000000000808aULL,0x8000000080008000ULL,
0x000000000000808bULL,0x0000000080000001ULL,0x8000000080008081ULL,0x8000000000008009ULL,0x000000000000008aULL,
0x0000000000000088ULL,0x0000000080008009ULL,0x000000008000000aULL,0x000000008000808bULL,0x800000000000008bULL,
0x8000000000008089ULL,0x8000000000008003ULL,0x8000000000008002ULL,0x8000000000000080ULL,0x000000000000800aULL,
0x800000008000000aULL,0x8000000080008081ULL,0x8000000000008080ULL,0x0000000080000001ULL,0x8000000080008008ULL};
static const int rr[24]={1,3,6,10,15,21,28,36,45,55,2,14,27,41,56,8,25,43,62,18,39,61,20,44};
static const int piln[24]={10,7,11,17,18,3,5,16,8,21,24,4,15,23,19,13,12,2,20,14,22,9,6,1};
static void keccakf(u64 st[25]){u64 t,bc[5];for(int rnd=0;rnd<24;rnd++){
for(int i=0;i<5;i++)bc[i]=st[i]^st[i+5]^st[i+10]^st[i+15]^st[i+20];
for(int i=0;i<5;i++){t=bc[(i+4)%5]^ROTL64(bc[(i+1)%5],1);for(int j=0;j<25;j+=5)st[j+i]^=t;}
t=st[1];for(int i=0;i<24;i++){int j=piln[i];bc[0]=st[j];st[j]=ROTL64(t,rr[i]);t=bc[0];}
for(int j=0;j<25;j+=5){for(int i=0;i<5;i++)bc[i]=st[j+i];for(int i=0;i<5;i++)st[j+i]^=(~bc[(i+1)%5])&bc[(i+2)%5];}
st[0]^=RC[rnd];}}
static void kec(const u8*in,size_t len,u8 out[32]){u64 st[25];memset(st,0,sizeof(st));u8*p=(u8*)st;
for(size_t i=0;i<len;i++)p[i]^=in[i];p[len]^=0x01;p[135]^=0x80;keccakf(st);memcpy(out,st,32);}
static u64 t6f(const u8*o){return ((u64)o[0]<<40)|((u64)o[1]<<32)|((u64)o[2]<<24)|((u64)o[3]<<16)|((u64)o[4]<<8)|o[5];}
static void rdhex(const char*s,u8*b,int n){for(int i=0;i<n;i++){unsigned v;sscanf(s+2*i,"%2x",&v);b[i]=v;}}
int main(int argc,char**argv){
if(!strcmp(argv[1],"h96")){u8 in[96];rdhex(argv[2],in,96);u8 o[32];kec(in,96,o);for(int i=0;i<32;i++)printf("%02x",o[i]);printf("\n");return 0;}
u8 head[64]; rdhex(argv[1],head,32); rdhex(argv[2],head+32,32);
u64 tag=strtoull(argv[3],NULL,16); int tb=atoi(argv[4]);
size_t TS=(size_t)1<<(tb+1), mask=TS-1, T=(size_t)1<<tb;
u64*key=calloc(TS,8); uint32_t*val=calloc(TS,4); if(!key||!val){fprintf(stderr,"oom\n");return 1;}
u8 msg[96]; memcpy(msg,head,64); u8 o[32];
for(size_t a=0;a<T;a++){ memset(msg+64,0,32); for(int i=0;i<4;i++)msg[95-i]=(a>>(8*i))&0xff;
kec(msg,96,o); u64 t6=t6f(o); u64 s=(t6*0x9E3779B97F4A7C15ULL)&mask;
while(val[s])s=(s+1)&mask; key[s]=t6; val[s]=(uint32_t)(a+1); }
for(u64 b=T;;b++){ memset(msg+64,0,32); for(int i=0;i<8;i++)msg[95-i]=(b>>(8*i))&0xff;
kec(msg,96,o); u64 want=t6f(o)^tag; u64 s=(want*0x9E3779B97F4A7C15ULL)&mask;
while(val[s]){ if(key[s]==want){u64 a=val[s]-1; if(a!=b){printf("A %llu\nB %llu\n",(unsigned long long)a,(unsigned long long)b);fflush(stdout);return 0;}} s=(s+1)&mask; } }
}

Build and run it as ./mitm <head32_hex> <r32_hex> 5E1F2A3B4C6D 24, where the two 32-byte hex blobs are abi.encode(caller) and abi.encode(r).

On-chain: putting it together

With (vprime, rprime, a, b) in hand, the on-chain half is four calls. As a Foundry solver:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity 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";

contract Attacker {
function solve(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:

1
2
3
4
5
6
7
8
9
10
11
12
VAULT=$(cast call $SETUP "vault()(address)")
cast call $VAULT "hx()(uint256)"; cast call $VAULT "hy()(uint256)" # H -> recover_k.py
cast call $VAULT "cx()(uint256)"; cast call $VAULT "cy()(uint256)" # C
cast call $VAULT "GENESIS_V()(uint256)"; cast call $VAULT "GENESIS_R()(uint256)"
cast call $VAULT "AUTH_TAG()(bytes6)"

# ... run recover_k.py (grind 1) then mitm_cosign.py (grind 2) ...

cast send $ROUTER "depositCollateral(uint256)" 1000000000000000000 --private-key $PK
cast send $VAULT "claimReserve(uint256,uint256,uint256,uint256)" $VPRIME $RPRIME $A $B --private-key $PK
cast send $ROUTER "borrow(uint256)" $AMOUNT --private-key $PK
cast call $SETUP "isSolved()(bool)" # true

Result

Running the whole thing end to end:

1
2
3
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
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~

  • Title: PwnSec CTF 2026: Meridian
  • Author: Foued SAIDI
  • Created at : 2026-09-17 18:10:44
  • Updated at : 2026-09-19 23:01:52
  • Link: https://kujen5.github.io/2026/09/17/PwnSec-CTF-Meridian/
  • License: This work is licensed under CC BY-NC-SA 4.0.