Authentication bypass in NodeJS application — a bug bounty story

Hello everyone,

In this post I am going to go through the process which leads to bypass an authentication in NodeJS application I’ve found in one of the private bug bounty programs. Also, I want to show what methodology I use when I come across similar web interface (with single login form available only) to find out if there is anything interesting to look into.

Methodology

If you have ever worked on the program with large scope (like GM, Sony, Oath (Yahoo!) or Twitter to name a few) the first thing you’ve probably done is to run subdomain discovery tool as a part of initial recon process. This allows you to obtain a list of potential targets and sometimes this list reaches literally hundreds (if not thousands) of different hosts. If you focus on web applications like me, there is a chance you will use e.g. Aquatone or similar tool which gives you the nice report in HTML contains discovered web servers working on popular ports (80, 443, 8080, 8443 etc.) along with response headers and printscreen of the website (this is exactly what Aquatone does and I strictly recommend to try this tool if you’ve never used it before).

But when you actually start to go through the results, you will notice that most of those web servers gives you either 404 Not Found, 401 Unauthorized, 500 Internal Server Error or default web interface to various services like VPN or network devices login screens, third-party software usually out of the scope of the program, cPanels, WordPress login pages etc. It’s very unlikely you will reach working web application with bunch of features where you can run your “arsenal” to find juicy Persistent XSS or SQL Injection. At least I had no such luck so far… :)

But sometimes you can actually find something which looks like custom made application, with login screen available and some other options to test as well, like registration or forgotten password link. There is couple of things you can do here. This is my methodology of how I deal when I get to one:

  1. First things first — I inspect source code of the page (I’ve posted some details how to perform such task, you can read it here). You can find links to resources like JavaScript files or CSS — this should allow you to discover some application directories (like /assets, /public, /scripts or similar — you should always check them for additional content or maybe even directory listing with some not linked anywhere files)
  2. Wappalyzer (available as an extension for all popular browsers) gives more than enough information about used technologies — web server, server-side technology, JavaScript libraries etc.). This gives you overall picture about the stack you are dealing with and choose the right approach for further tests (there is a little if no chance at all that your brand new, awesome RCE payload for JavaEE will work if application is built with Ruby on Rails… ;) )
  3. If there are JavaScript files, I perform some static analysis to figure out if there are any API endpoints exposed or if there is any client-side authentication and user input validation logic in place (if you are web developer — I hope you know that client-side only validation of user provided input is a big no-no :) )
  4. Having all information from above steps in place, I start to test actual logic of all revealed features (login, register, forgot password etc.) using Burp Suite and intercepting requests to the server. Then I send them to Repeater and start to play with requests (changing Content-Type between application/json, application/xml and other types, using several payloads as request body, switching between different HTTP methods or altering HTTP request headers and observe any symptoms of errors my changes cause on the server-side). If there is any chance of vulnerability in the application — this is the moment when you are able to spot it, so observe every response and try to notice every change — this might be something really small like lack of one of the headers in a particular request you’ve send with PUT instead of GET or some weird characters in response body if you send malformed JSON)
  5. Finally, I run wfuzz to discover files or folders abandoned on the server (or left intentionally, or just sitting there for any reason :) ) using my custom made “Starter Pack” dictionary, which contains list of the most common things one can find on any web server in the internet (source version control systems folders like .git or .svn, IDE directories like JetBrains’ .idea.DS_Store files, configuration files, paths to common application web interfaces and admin panels, files and folders specific to Tomcat, JBoss, Sharepoint and similar etc.) — in total this dictionary contains ~45k entries and I found that I almost always find something interesting which can help in further vulnerabilities discovery.

If none of above steps work, I assume that application is either well secured or there is just no vulnerability in place which will allow to bypass authentication or get into an application without having to bypass it.

But this time playing with request which handles authentication gave me a clue that something was up here. It was a simple login form to something which looks like a custom made application and quick investigation of HTTP response headers and Wappalyzer results reveals NodeJS application built with ExpressJS framework. As I work full-time as web developer, JavaScript is language I work with for several years and have already some good experiences with looking for vulnerabilities in JavaScript-related stacks (shameless plug goes here :D) — I’ve decided to dig deeper and see what can I do.

Discovery

I’ve started my tests against discovered endpoint with payload which should gives me an error about wrong credentials. Typical POST with JSON contains username and password:

POST /api/auth/login HTTP/1.1 
Host: REDACTED
Connection: close
Content-Length: 48
Accept: application/json, text/plain, */*
Origin: REDACTED
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3558.0 Safari/537.36 DNT: 1
Content-Type: application/json;charset=UTF-8
Referer: REDACTED/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,pl-PL;q=0.8,pl;q=0.7
Cookie: REDACTED
{“username”:”bl4de”,”password”:”secretpassword”}

I will omit HTTP headers later in this post as there was no change there related to the issue.

Response did not contain anything exciting, except one single detail which, to be honest, I did not spot immediately:

HTTP/1.1 401 Unauthorized 
X-Powered-By: Express
Vary: X-HTTP-Method-Override, Accept-Encoding
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: X-Requested-With,content-type, Authorization
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
Content-Length: 83
ETag: W/”53-vxvZJPkaGgb/+r6gylAGG9yaeoE”
Date: Thu, 11 Oct 2018 18:50:26 GMT
Connection: close
{“result”:”User with login [bl4de] was not found.”,”resultCode”:401,”type”:”error”}

This detail was that username I’ve sent was returned in square brackets. Square brackets in JavaScript mean an array and my username looked like an actual element of this array. To confirm that, I’ve sent another payload — an empty array:

{“username”:[],”password”:”secretpassword”}

Response from the server was somewhat surprising:

{“result”:”User with login [] was not found.”,”resultCode”:401,”type”:”error”}

An empty array? Or maybe square brackets were accepted as an username?

Ok, let’s try then with empty object as username and see what will happen:

{“username”:{},”password”:”secretpassword”}

And the response for this request has just confirmed that what I’ve just sent is used as username in authentication logic (there was an attempt to call {}.replace function, but there is no replace for JavaScript objects):

{"result":"val.replace is not a function","resultCode":500,"type":"error"} 

Here’s how it looks like: I create an empty object (which stands for val in response above) and then call replace() as its method. You can see the error is exactly the same:

let val = {}
val.replace()
VM188:1 Uncaught TypeError: val.replace is not a function
at <anonymous>:1:5

Exploitation

Having a confirmation that there is an error is one thing, being able to exploit it successfully is a different story. I’ve started to think what code is possibly run behind this error and the next test I did was to mess things as much as possible to trigger other errors. Nested arrays ([[]]) looked to me like a good shot:

{“username”:[[]],”password”:”secretpassword”}

A response from the server was not even close to what I was expecting:

{"result":"ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ') OR `Person`.`REDACTED_ID` IN ()) LIMIT 1' at line 1","resultCode":409,"type":"error"}

What’s in bug bounty hunter’s mind when one has seen error message like this one? SQL Injection of course :) But first, I had to find out how username is used in that query to craft the correct payload and bring the MySQL server to its knees. Having in mind that username is treat as some kind of an array element, I’ve sent a request where username was just first element of the array ([0]):

{“username”:[0],”password”:”secretpassword”}

This time application returns different error message:

{“result”:”User super.adm, Request {\”port\”:21110,\”path\”:\”/REDACTED? ApiKey=REDACTED\”,\”headers\”:{\”Authorization\”:\”Basic c3VwZXIuYWRtOnNlY3JldHBhc3N3b3Jk\”}, \”host\”:\”api-global.REDACTED\”}, Response {\”faultcode\”:\”ERR_ACCESS_DENIED\”,\”faultstring\”:\”User credentials are wrong or missing.\”, \”correlationId\”:\”Id-d5a9bf5b7ad73e0042191000924e3ca9\”}”,”resultCode”:401,”type”:”error”}

After a quick analysis I found that somehow I was able to use an user with ID equals 0 (or index 0 in some kind of data structure) to send another request (this time to internal API server listening on port 21110 at /REDACTED? path) which obviously has not authenticated me due to wrong password (you can actually see that Authorization header contains Base64 string super.adm:secretpassword which means application has used username of user with index 0 and password from my original request).

Next thing I did was to figure out if I can enumerate users from the database using following indexes (1, 2 and so on) and I was successfully found two other users. Also, I’ve found that I could pass any number of indexes, as an array in login request’s username, and they will be used in SQL query in IN() clause:

{"username":[0,1,2,30,50,100],"password":"secretpassword"}

This request has always returned me a valid user (I mean — application has tried to send this internal API request using username selected from database by the SQL query) whenever it found one of the indexes valid. But still my password was not accepted, so I haven’t done yet with bypassing authentication completely. So my next challenge was to find out the way to bypass password checking.

Having in mind I was dealing with JavaScript application, I’ve started with something as simple as I could think of: Boolean false:

{“username”:[0],”password”:false}

Response from the server was different this time:

{"result":"Please provide credentials","resultCode":500,"type":"error"}

I haven’t seen this error before, but I quickly confirmed it’s returned where either username or password was missing. As I’ve provided username, server just verified that “password”:false means no password at all. Sending null and 0 (those are examples of “falsy values” in JavaScript — values which are always evaluated to false in conditions) caused the same error message.

Final PoC

So…. If false as a password does not work, how about using true instead?

{“username”:[0],”password”:true}

And that was it. A combination of first element of an array ([0]) as an username and true keyword as a password has allowed me to successfully bypass authentication:

{"result":"Given pin is not valid.","resultCode":401,"type":"error"}

Disclaimer: this bypass wasn’t complete and did not allow me to log into an application due to third factor involved in the process: PIN number, which should be entered after login in. However, authentication bypass itself was a valid vulnerability and was fixed by the company.

Exploitation of SQL Injection was not possible due to regular expression which checks username and password and always returns syntax error when I’ve tried to craft payload contains characters not allowed by this expression.

Acknowledgements

I would like to thank The Company and their Security Team members triagging and resolving reports from HackerOne bug bounty program for an opportunity to write about this vulnerability.

Also, special thanks to the Member of the Security Team which has been working on this particular report for all his support and feedback.

Stay Safe,

bl4de