The following research outlines a vulnerability discovered in netmask npm package that is currently used by 278,722+ other projects. The vulnerability has been present for 9 years.
Since this package is so incredibly widespread, I would suggest every nodejs developer to check their package.jsons to see if they use netmask… and upgrade immediately!
Researchers
Victor Viale: https://github.com/koroeskohr || https://twitter.com/koroeskohr
Sick Codes: https://github.com/sickcodes || https://twitter.com/sickcodes
Kelly Kaoudis: https://github.com/kaoudis || https://twitter.com/kaoudis
John Jackson https://www.twitter.com/johnjhacking
Nick Sahler: https://github.com/nicksahler || https://twitter.com/tensor_bodega
CVE LINKS
https://sick.codes/sick-2021-011
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-28918
https://nvd.nist.gov/view/vuln/detail?vulnId=CVE-2021-28918
Project Links
https://github.com/rs/node-netmask & https://www.npmjs.com/package/netmask
Last week, 30,000,000,000+ nodejs packages were downloaded and installed, somewhere.
Obviously, most of those downloads were not done by a human, but by Continuous Integration and Continuous Delivery pipelines (CI/CD).
For readers who don’t know what that is, it is simply “automated code”.
While it may seem obvious that most of the 30,000,000,000+ downloads are part of someone’s Docker image or unit test, it should be equally as obvious that almost all of those packages were not “inspected” by the end user at runtime.
If everyone else is using it, it must be good.
A few months earlier, we discovered a different vulnerability (CVE-2020-28360) in a downstream package known as private-ip.
When we fixed the vulnerability, we added a new package called “netmask”. The package is very popular, with millions of downloads per week.
While I love regex, the amount of expressions that would be required to fix private-ip package at the time would have been quite lengthy so we decided to use something a little more robust: netmask.
The fix included adding another package called netmask and then defining IP address ranges/blocks using simpler notation.
It appeared to be exactly the package that would perfectly evaluate whether an IP was inside or outside any given ipv4 range.
The package lets you work with blocks, like these ARIN reserved ranges:
let privateRanges = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.0.0/29',
'192.0.0.8/32',
'192.0.0.9/32',
'192.0.0.10/32',
'192.0.0.170/32',
'192.0.0.171/32',
'192.0.2.0/24',
'192.31.196.0/24',
'192.52.193.0/24',
'192.88.99.0/24',
'192.168.0.0/16',
'192.175.48.0/24',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'240.0.0.0/4',
'255.255.255.255/32'
]
And then like this…
> var block = new Netmask('10.0.0.0/12');
> block.contains('10.0.8.10');
true
> block.contains('192.168.1.20');
false
It will return true
or false
, for a given IP, about whether or not the IP is in the defined “block”.
Netmask has many other functions, for example, it’ll show you how many IPs are inside that block:
> block.first;
10.0.0.1
>block.last;
10.15.255.254
block.size;
1048576
Really cool project, lightweight, and has no dependencies either.
It seemed absolutely perfect.
We defined all the ARIN reserved ranges, including loopback, NAT, and private networks…
New Discovery
A couple of months passed, and then about two weeks ago, two of the researchers involved in getting private-ip fixed, John Jackson & Nick Sahler, contacted myself, Sick Codes, in relation to a new SSRF bypass.
A developer & researcher, Victor Viale, had found yet another bypass, which he demonstrated to us as follows:
> const isPrivateIp = require('private-ip');
> isPrivateIp('127.0.0.1');
true
> isPrivateIp('0127.0.0.1');
true
What do you think private-ip is doing?
Have a think for a moment.
Well, it considers 0127.0.0.1
a private IP address.
If you’re wondering what the issue is here, it can be demonstrate in the following example:
$ ping 0127.0.0.01
PING 0127.0.0.01 (87.0.0.1) 56(84) bytes of data.
0127.0.0.1
is really 87.0.0.1
.
Type in 0127.0.0.1 in your browser, or simply visit the following link in your browser: http://0127.0.0.1.
You’ll actually go to 81.0.0.1
.
The problem is, private-ip thinks 0127
is 127
because it is not evaluating the first octet, which is in octal format, as the true decimal value 87
.
This is catastrophic.
private-ip thinks 0127.0.0.1
is localhost, but it’s really 87.0.0.1
.
Even worse, it goes the other way too!
If the input data is 0177.0.0.1
, private-ip thinks this is a public IP address.
private-ip would see 0177
as 177
, but the real location is… 127
in your loopback range!
If your browser recognizes octal literals, but a nodejs application does not, users can submit all kinds of malevolent URLs that seem internal, but really go to remote files.
On the other hand, users can ALSO submit URLs that seem public, but they’re actually very private!
Great… private-ip was susceptible to bypass, yet again, leading to potential server-side request forgery (SSRF), or remote file inclusion (RFI).
At first, I thought another developer had reverted private-ip back to regex after we added netmask.
Nick promptly inspected the code again and reminded us that only ipv6 was being filtered using regex.
So… the issue must be somewhere else in the code…
The bigger they come, the harder they fall
If you lose your car keys, someone will usually tell you to stand on something, to get a different perspective of the room.
Seeing code from a different perspective, like an Eagle Eyed approach, means sometimes you have to pinch yourself and revisit the obvious parts of your code, like the very fundamental packages. Remember Shellshock?
After Nick ruled out ipv4 regex issues, I eventually ruled out private-ip as a whole.
Digging deeper, we discovered the problem, which was upstream.
The SSRF bypass in private-ip was actually caused by the new upstream package we had introduced called netmask.
Netmask
Last week, the netmask npm package had 3,119,921 downloads.
netmask has no upstream dependencies.
On Github, you can see that it is used by 278,880 repositories.
More than a quarter of a million other projects use netmask to do something.
For almost a decade, netmask has been incorrectly reading octal input data as a string; just stripping the 0 at the front and using the rest of the data as legitimate.
Are all 270,000 projects vulnerable? Well, most likely no: it depends entirely on how the project uses it.
I personally don’t have the vigor to inspect 278,000 code-bases, but some of those projects are APIs, security software, crypto projects, back-end projects, and also front-end projects.
At this time, another developer, Kelly, joined us, as we had a major issue on our hands.
We needed to figure out who was at fault here: was netmask at fault, for not caring about octal input data?
Or was it up to each individual project that uses netmask, to properly strip the IP address or parse octal data into decimal value before being used in netmask?
It felt like a software ethical dilemma.
For one, we knew private-ip was vulnerable.
But should we have fixed private-ip to stop octal IP addresses?
What about every other project, and in what realm or scope of that project does netmask get used?
Exploiting octal input data
Working on the proof of concept, we worked backwards through some examples…
There are an infinite amount of issues, that nobody can really calculate, without inspecting all 270k downstream code-bases.
Here are some devastating examples, using JUST the ARIN reserved ranges:
This table might be confusing but, briefly:
“If you submit 012, netmask will see a 12 (public), but it’s really 10 (private)”
“If you submit 010, netmask will see a 10 (private), but it’s really an 8 (public)”
netmask sees this as –> | netmask sees this as –> | …then netmask defines the request | …or netmask allows the request (SSRF) | Notable Range |
---|---|---|---|---|
012 | 010 | 10 | 8 | 10.0.0.0/8 |
014 | 012 | 12 | 10 | 10.0.0.0/8 |
0144 | 0100 | 100 | 64 | 100.64.0.0/10 |
0220 | 0144 | 144 | 100 | 100.64.0.0/10 |
0177 | 0127 | 127 | 87 | 127.0.0.0/8 |
0261 | 0177 | 177 | 127 | 127.0.0.0/8 |
0251 | 0169 | 169 | Err:502 | 169.254.0.0/16 |
0373* | 0251 | 251 | 169 | 169.254.0.0/16 |
0254* | 0172 | 172 | 122 | 172.16.0.0/12 |
0376* | 0254 | 254 | 172 | 172.16.0.0/12 |
0300* | 0192 | 192 | Err:502 | 192.0.0.0/24 |
0306* | 0198 | 198 | Err:502 | 198.18.0.0/15 |
0313* | 0203 | 203 | 131 | 203.0.113.0/24 |
0360* | 0240 | 240 | 160 | 240.0.0.0/4 |
* Above 254, but some apps might still fail in some way.
Someone can input IP addresses from the first column, but actually get data from the second column, if netmask defines the ultimate destination.
For example, the Docker bridge might be running on 172.17.0.1/16
0254.17.0.1
is read as 254.17.0.1
. But it goes to the Docker bridge gateway, 172.17.0.1
(private) which is usually localhost!
Try it yourself: start nginx
locally and curl 0254.17.0.1
.
Likewise, anyone can submit IP addresses from the second/third column, and appear as private, but can actually deliver malware from public IPs by tricking netmask into thinking a request is the opposite.
Consider 0127
and 87
.
The individual who controls 87.0.0.1
, someone in Italy using Telecom Italia, can actually deliver malware to requests made through an application that uses netmask to see whether or not the request is local.
Obviously that is a rare scenario to say the least.
But consider the opposite…
Someone, right in the middle of Brazil somewhere, someone has the IP address: 177.0.0.1
, which is the real destination of 0177.0.0.1
.
Other interesting ones include 010.0.0.1
which is 8.0.0.1
and is controlled by Level 3 Communications.
Some VPC/VPN adaptation using netmask might consider 010.0.0.1
private, but really go through to 8.0.0.1
.
An attacker could easily offload malware using a catch-all web server.
Similarly, 012.0.0.1
(AT&T Services) looks public to netmask, but it is really a one-way ticket into your private network!
Catastrophic, to say the least.
You don’t need a special IP address to do this though, you can simply submit a public URL and get local files back.
There’s literally so many vulnerabilities cause by this that it will make your head spin.
“Encore, do you want more?”
How about tricking apps into thinking the origin OR destination is a Cloudflare IP?
Cloudflare ranges, as of March 2021:
https://www.cloudflare.com/ips/
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
103.31.4.0/22
141.101.64.0/18
108.162.192.0/18
190.93.240.0/20
188.114.96.0/20
197.234.240.0/22
198.41.128.0/17
162.158.0.0/15
104.16.0.0/12
172.64.0.0/13
131.0.72.0/22
How about whipping up a CNAME to some SPAMHAUS honeypot and tricking an application that uses netmask into emailing itself onto the naughty list?
Did you know Azure services can see other Azure services in the same region?
How about tricking an Azure app into serving up a request from a chatty neighbor!
Azure IP Information Center:
https://docs.microsoft.com/en-us/azure/virtual-network/public-ip-addresses
US Azure list (41,000 IP blocks)
https://www.microsoft.com/download/details.aspx?id=56519
Azure US Government List (3,316 IP blocks)
https://www.microsoft.com/download/details.aspx?id=57063
Azure China List (1,800 IP blocks)
https://www.microsoft.com/download/details.aspx?id=57062
Azure Germany List (368 IP blocks)
https://www.microsoft.com/download/details.aspx?id=57064
Kelly will join Sick Codes and explain the issues we faces patching this vulnerability.
The fix
The maintainer of the node-netmask package, which is actually written in Coffeescript, is the Director of Engineering at Netflix, which was unexpected! @rs was super responsive and worked with us on the fixes, especially in getting the first patch out literally days after we reported it.
ip2long
splits the input string, ip
, into an array of strings, then finally returns a single long
.
Each individual octet is then filtered in a decision tree.
The original Coffeescript code which is vulnerable is below:
ip2long = (ip) ->
b = (ip + '').split('.');
if b.length is 0 or b.length > 4 then throw new Error('Invalid IP')
for byte, i in b
if isNaN parseInt(byte, 10) then throw new Error("Invalid byte: #{byte}")
if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
return ((b[0] or 0) << 24 | (b[1] or 0) << 16 | (b[2] or 0) << 8 | (b[3] or 0)) >>> 0
Since we know base-8 integers in 0-prefixed JavaScript form wouldn’t parse correctly due to the explicit base call out in the use of parseInt(byte, 10)
, we initially chose to get them out of the way before handling input in base-10.
Our first fix attempt:
...
for byte, i in b
if isNaN parseInt(byte, 10) then throw new Error("Invalid byte: #{byte}")
if byte and byte[0] == '0'
# make sure 0 prefixed bytes are parsed as octal
byte = parseInt(byte, 8)
else
byte = parseInt(byte, 10)
if isNaN(byte) then throw new Error("Invalid byte: #{byte}")
if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
b[i] = byte
return ((b[0] or 0) << 24 | (b[1] or 0) << 16 | (b[2] or 0) << 8 | (b[3] or 0)) >>> 0
The above does not treat 0-prefixed octets the same as decimal. We validated with the following tests, then sent our proposed patch to @rs for consideration:
'block 31.0.0.0/8':
topic: -> new ('31.0.0.0/8')
'contains IP 31.5.5.5': (block) -> assert.ok block.contains('31.5.5.5')
'does not contain IP 031.5.5.5 (25.5.5.5)': (block) -> assert.ok not
block.contains('031.5.5.5')
'block 127.0.0.0/8':
topic: -> new ('127.0.0.0/8')
'contains IP 127.0.0.2': (block) -> assert.ok block.contains('127.0.0.2')
'contains IP 0177.0.0.2 (127.0.0.2)': (block) -> assert.ok block.contains('0177.0.0.2')
Victor quickly realized at this point we still had more work to do.
Why our first attempt wasn’t completely accurate:
We had previously considered how netmask interpreted base-8 integers the main issue, though there had been some debate around use of the builtin nodejs parseInt
function.
While our first attempt solved the problem that we initially saw, it also created another vulnerability in netmask.
Now the code would parse base-16 integers, which in JavaScript start with 0x
(i.e., 0xff
), as 0-prefixed octal…
This bug was only live for about a few hours, a billionth of the time that it would take 278,000+ projects to upgrade to the latest version.
Fix attempt #2:
...
# disable hexadecimal input
if /\D/.test(byte) then throw new Error("Invalid byte: #{byte}")
if isNaN parseInt(byte, 10) then throw new Error("Invalid byte: #{byte}")
if byte and byte[0] == '0'
# make sure 0 prefixed bytes are parsed as octal
byte = parseInt(byte, 8)
else
byte = parseInt(byte, 10)
if isNaN(byte) then throw new Error("Invalid byte: #{byte}")
if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
b[i] = byte
...
Now we are expressing IP addresses in traditional “dot-decimal” notation, which gets parsed into a long in hexadecimal, just as we can in base-8 and base-10. To comply with the way the rest of the nodejs ecosystem evaluates IP addresses, netmask must not disable IP-address or bitmask input in base-16. This would also have been a breaking change… since netmask previously allowed hexadecimal input (as seen in the README).
Around this point, I added a series of Mocha tests for the Node JS trans-piled code to the node-netmask repository, since we knew Coffeescript and Node can evaluate some things quite differently…
@rs rewrote our solution to misinterpreting hexadecimal input in a more targeted way:
...
if byte and byte[0] == '0'
if byte.length > 2 and (byte[1] == 'x' or byte[1] == 'x')
# make sure 0x prefixed bytes are parsed as hex
byte = parseInt(byte, 16)
else
# make sure 0 prefixed bytes are parsed as octal
byte = parseInt(byte, 8)
else
byte = parseInt(byte, 10)
...
This was still not enough to fix netmask.
We hadn’t yet accounted for the fact that parseInt, which is a nodejs builtin, strips white-space…
Victor and myself (Kelly) validated that parseInt certainly does remove white-space preceding or following an IP octet.
Sick Codes poked @rs one last time, to add the white-space checker:
else if byte and (byte[0] == ' ' or byte[byte.length-1] == ' ')
throw new Error('Invalid IP')
The final patched version of ip2long
in the Coffeescript version of netmask looks like this:
ip2long = (ip) ->
b = (ip + '').split('.');
if b.length is 0 or b.length > 4 then throw new Error('Invalid IP')
for byte, i in b
if byte and byte[0] == '0'
if byte.length > 2 and (byte[1] == 'x' or byte[1] == 'x')
# make sure 0x prefixed bytes are parsed as hex
byte = parseInt(byte, 16)
else
# make sure 0 prefixed bytes are parsed as octal
byte = parseInt(byte, 8)
else if byte and (byte[0] == ' ' or byte[byte.length-1] == ' ')
throw new Error('Invalid IP')
else
byte = parseInt(byte, 10)
if isNaN(byte) then throw new Error("Invalid byte: #{byte}")
if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
b[i] = byte
while b.length < 4
b.unshift(0)
return (b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]) >>> 0
Disclosure Timeline
- 2021-03-16 – Researchers discover vulnerability
- 2021-03-17 – Vendor notified
- 2021-03-17 – CVE requested
- 2021-03-19 – CVE assigned CVE-2021-28918
- 2021-03-28 – Vulnerability published
Links
https://github.com/sickcodes/security/blob/master/advisories/SICK-2021-011.md
https://sick.codes/sick-2021-011
https://www.npmjs.com/package/netmask
https://github.com/rs/node-netmask
Researchers
Victor Viale: https://github.com/koroeskohr || https://twitter.com/koroeskohr
Sick Codes: https://github.com/sickcodes || https://twitter.com/sickcodes
Kelly Kaoudis: https://github.com/kaoudis || https://twitter.com/kaoudis
John Jackson https://www.twitter.com/johnjhacking
Nick Sahler: https://github.com/nicksahler || https://twitter.com/tensor_bodega
CVE Links
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-28918
https://nvd.nist.gov/view/vuln/detail?vulnId=CVE-2021-28918
Once again, this package is used by 270,000 projects.
Or you could code to the standard…2001 POSIX spec.
Octal parsing of leading 0 in ip addresses is non-standard and undesirable
The 4.2BSD inet_aton() has been widely copied and imitated, and so is
a de facto standard for the textual representation of IPv4 addresses.
Nevertheless, these alternative syntaxes have now fallen out of use
(if they ever had significant use). The only practical use that they
now see is for deliberate obfuscation of addresses: giving an IPv4
address as a single 32-bit decimal number is favoured among people
wishing to conceal the true location that is encoded in a URL. All
the forms except for decimal octets are seen as non-standard (despite
being quite widely interoperable) and undesirable.
https://tools.ietf.org/html/draft-main-ipaddr-text-rep-02
Agreed, apart from long IP’s, http://2130706433 there’s really no point to even have them anymore. It just creates exponential additional attack vectors in the realm of DoS, SSRF, CSRF, RFI, LFI.
Maybe it only support octal format by design. Only the other people use it in wrong way ?
I just try java 8 and it only support octal format too.
Is there a reason for the double check in the hexadecimal condition `byte[1]==’x’ or byte[1]==’x’`?
I had the same question!!! Seems like they are trying to check both upper and lower case for “x”; but that didn’t get printed well in here… (hoping that this is an error in the article and not in the code itself)
try this : ping 0xC1EFD324
try this : ping 0xC1EFD324
Another thing you can have fun with is padding, IP address encoding is notoriously sloppy in many applications and you can go to huge lengths to abuse it. You can often zero pad your octal encoding, or mix encodings within a single IP address to confuse the hell out of these sort of parsing libraries, there’s even integer overflows within the parsing within linux.
0010.0010.0010.0010 is 8.8.8.8, but so is 0010.8.0010.8, and even http://00000000000000000010.8.8.8
The only reasonable fix is to consider that leading zeros make the IP invalid. Trying to convince some people that it should be considered octal or some other people that is should NOT be considered octal is futile.