W dniach od 18 do 22 czerwca 2018 odbyły się eliminacje do European Cyber Security Challenge. Konkurs został podzielony na 2 grupy wiekowe : Juniorzy (14-20 lat) oraz Seniorzy (21 - 25 lat). Ze względu na mój wiek rozwiązywałem zadania z pierwszej kategorii wiekowej. Zadanie które opiszę w tym poście to Cobra Ransomware za 300 pkt (RE) - link.

Do dyspozycji otrzymujemy zaszyfrowany plik oraz binarkę ransomware.

Zwróćmy uwagę na ikonę nawet przed uruchomieniem pliku. Od razu skojarzyło mi się to z jakimś bundlerem skryptów Pythona. Celem bundlerów jest utworzenie samowystarczalnego pliku wykonywalnego który zawiera nasz skrypt oraz całe środowisko uruchomieniowe. Kluczowe z pythonowych bundlerów to : Py2exe, cx_freeze oraz PyInstaller. Ikona naszego pliku wskazuje jakby był to plik spakowany przez ten ostatni.

Czas uruchomić naszą próbkę ransomware. Pamiętajmy aby prawdziwy malware testować tylko w izolowanym środowisku np. poprzez maszynę wirtualną.

Program podaje zawsze ten sam UserID oraz oczekuje na prawidłowy klucz którzy rzekomo mielibyśmy wpisać w celu odzyskania naszych plików. Wnioskując że mamy do czynienia z PyInstallerem który pakuje całość UPX’em a sam proces wykonywania kodu Pythona na poziomie kodu maszynowego może być zbyt skomplikowany więc odpuszczamy z korzystania z debuggera (straciłem na to 7 dni :) ). Hook’ując funkcje systemowe programem API Monitor trafimy na ścieżkę zawierającą pliki zapewniające środowisko uruchomieniowe (wypakowane z samego pliku cobra.exe).

Zawartość wypakowanego pliku:

Pliki exe tworzone przez PyInstaller mają specyficzną strukturę którą możemy poznać poprzez czytanie kodu źródłowego pyinstaller. Skrótowe przedstawienie tej struktury przedstawia obrazek:

Wszystkie pliki oraz obiekty code (wg. implementacji CPython jest to wykonywalny kod bajtowy pythona) są niejako doklejone do pliku .exe. W źródłach PyInstallera cała ta struktura jest nazwana CArchive.

Analizę tego archiwum podobnie jak plików .zip zaczynamy od końca pliku, gdzie znajduje się CArchive Cookie zawierająca m.in. spis zawartości archiwum. Jego strukturę możemy poznać czytając /bootloader/src/pyi_archive.h.

/* TOC entry for a CArchive */
typedef struct _toc {
    int  structlen;  /*len of this one - including full len of name */
    int  pos;        /* pos rel to start of concatenation */
    int  len;        /* len of the data (compressed) */
    int  ulen;       /* len of data (uncompressed) */
    char cflag;      /* is it compressed (really a byte) */
    char typcd;      /* type code -'b' binary, 'z' zlib, 'm' module,
                      * 's' script (v3),'x' data, 'o' runtime option  */
    char name[1];    /* the name to save it as */
    /* starting in v5, we stretch this out to a mult of 16 */
} TOC;

/* The CArchive Cookie, from end of the archive. */
typedef struct _cookie {
    char magic[8];      /* 'MEI\014\013\012\013\016' */
    int  len;           /* len of entire package */
    int  TOC;           /* pos (rel to start) of TableOfContents */
    int  TOClen;        /* length of TableOfContents */
    int  pyvers;        /* new in v4 */
    char pylibname[64]; /* Filename of Python dynamic library e.g. python2.7.dll. */
} COOKIE;

Każdy wpis TOC możemy traktować jako nagłówek pliku który mamy „wyeksportować” z pliku. Należy dodać że każde pole jest kodowane w big endian. Dla przykładu objaśnienie nagłówka dla pliku python27.dll:

pole typcd nagłówka TOC może przyjmować następujące wartości :

  • “m” - moduł języka Python (plik .pyc)
  • “M” - pakiet języka Python
  • ”s” - skrypt w postaci obiektu code zserializowany (marshal)
  • “b” - dane binarne (np. .dll .pyd)
  • “z” - ZlibArchive
  • “Z” - archiwum Zip
  • “d” - ścieżka do archiwum zawierające potrzebne zależności (dependencies)
  • “a” - archiwum CArchive
  • “x” - dane
  • “o” - opcje konfiguracyjne

Naturalnym planem działania w celu rozwiązania tego zadania jest rozpakowanie CArchive zawartego w cobra.exe. Plan działania jest prosty :

  1. Zparsowanie CArchiveCookie (znajduje się ono na samym końcu pliku)
  2. Parsowanie każdego TOC dopóki nie przeczytamy TOClen bajtów
  3. Eksportowanie każdego pliku, jeżeli w nagłówku flaga cflag jest ustawiono musimy użyć zlib’a aby wypakować dane.
  4. W archiwum zlib out00-PYZ.pyz zawarte są obiekty code modułów pythona. Aby umożliwić ich późniejszą dekompilacje dostępnymi programami doklejamy do każdego obiektu code nagłówek pliku .pyc (4 bajty wartość magiczna zależna od wersji języka python + 4 bajty timestamp) oraz zapisujemy wszystkie takie pliki .pyc na dysk, w celu dalszej dekompilacji i analizy.

Napisany przeze mnie skrypt pythona lepiej pomoże nam zrozumieć jak ugryźć całą tą strukturę plików. Skrypt pisany typowo pod ctf 😂

#!/usr/bin/python

import struct
import os
import sys
import zlib
import marshal 
import pprint
import uncompyle6

class TOCEntry :
  def __init__ (self,pos,lenC,lenU,cflag,typcd,name):
    self.pos = pos
    self.lenC = lenC
    self.lenU = lenU
    self.cflag = cflag
    self.typcd = typcd
    self.name = name

class CArchive :

  magic = "MEI\x0c\x0b\x0a\x0b\x0e"
  lenPackage = 0
  TOC = 0
  TOClen = 0
  pyvers = 0
  pylibname = ""
  CArchiveOffset = 0
  TOCEntries = []

  def __init__ (self,filename):
    self.filename = filename

  def parseArchiveHeaders (self):
    sizeHeader = 88
    # searching for CArchive headers
    self.fd = open (self.filename,"rb")
    self.filesize = os.fstat(self.fd.fileno()).st_size
    print "[*] exe file - {0} bytes.".format(hex(self.filesize))
    self.fd.seek (self.filesize - sizeHeader,0) # 0 means from start
    magicToCheck = self.fd.read (8)
    if (magicToCheck != self.magic):
      print "It is not valid CArchive Cookie, magic values do not match each other"
      sys.exit (-2)
    (self.lenPackage,self.TOC,self.TOClen,self.pyvers,self.pylibname) = struct.unpack("!iiii64s",self.fd.read(sizeHeader - 8))
    print "[*] Size of CArchive : {0}".format(hex(self.lenPackage))
    self.CArchiveOffset = self.filesize - self.lenPackage # CArchive is overlapped at the end of exe file
    print "[*] Offset of CArchive in exe file : {0}".format(hex(self.CArchiveOffset))

    if (self.pyvers != 10 * sys.version_info.major + sys.version_info.minor):
      print "Package was built upon other version of python than you are using right now, process may fail coz marshal"

  def parseTOCHeaders (self):
    self.fd.seek(self.CArchiveOffset + self.TOC,0)
    position = 0
    while (position < self.TOClen):
      size = struct.unpack("!i",self.fd.read(4))[0]
      sizeOfName = size - 18 # 18 is length of other variables in that struct
      arr = struct.unpack("!iiiBc",self.fd.read(size-4-sizeOfName))
      s = struct.unpack("{0}s".format(sizeOfName),self.fd.read(sizeOfName))[0]
      s = s.rstrip ("\0")
      print "[*] Found file {0} type : {1}".format(s,arr[4])
      entry = TOCEntry(arr[0],arr[1],arr[2],arr[3],arr[4],s)
      position = position + size
      self.TOCEntries.append(entry)

  def extractFiles (self):
    self.fd.seek(self.CArchiveOffset,0)
    for i in self.TOCEntries:
      data = ""
      if (i.cflag == 1):
        data = zlib.decompress (self.fd.read(i.lenC))
      else :
        data = self.fd.read(i.lenU)
      if (i.typcd == "z"):
        self.extractZlibArchive(data,i.name)
      else :
        f = open(i.name,"wb")
        f.write(data)


  def extractZlibArchive (self,data,name):
    newpath = os.getcwd() + "/" + name 
    if not os.path.exists(newpath):
      os.makedirs(newpath)
    os.chdir(newpath)

    if (data[:4] != "PYZ\x00"):
      print "[!] Magic value does not match PYZ\\x00"

    toc_off = struct.unpack ("!I", data[8:12])[0]

    # NEVER UNMARSHAL DATA FROM UNTRUSTED SOURCE, that's just ctf task

    # (name, (type , offset, size))

    arr = marshal.loads(data[toc_off:])
    for i in arr:
      f = open (i[0]+".pyc","wb")
      type = i[1][0]
      offset = i[1][1]
      size = i[1][2]
      name = i[0]
      d = zlib.decompress(data[offset:offset+size])
      f.write(data[4:8]) # adding magic value for .pyc file
      f.write("\x00\x00\x00\x00") # adding timestamp, nevermind 1970
      f.write(d) # decompressed data
      f.close()

def main():
  if (len(sys.argv) < 2):
    print "[!] Usage : ./unpack.py <exe file> "
    sys.exit(0xffffffff)
  archive = CArchive(sys.argv[1])
  archive.parseArchiveHeaders()
  archive.parseTOCHeaders()
  archive.extractFiles()

if __name__ == "__main__":
  main()

Wypakowywując wszystkie pliki wraz z out00-PYZ.pyz otrzymujemy:

Interesujący jest dla nas plik entry, który jest głównym obiektem code naszego programu.

Ja z początku otworzyłem go hexedytorem oraz zobaczyłem „import cobra”, więc zajrzałem do wyeksportowanego ZlibArchive w celu zobaczenia czy takowy istnieje. Gdy go znalazłem skorzystałem z narzędzia uncompyle6 aby przetworzyć ten plik na zwykły kod pythona. W tym momencie naszym oczom ukazuje się zobfuskowany kod pythona (prawdopodobnie przez pyobfuscate)

# uncompyle6 version 3.2.3
# Python bytecode 2.7 (62211)
# Decompiled from: Python 2.7.15 (default, Jun 21 2018, 01:24:29)
# [GCC 4.2.1 Compatible Apple LLVM 9.1.0 (clang-902.0.39.2)]
# Embedded file name: cobra.py
import os, base64
if 0:
    i11iIiiIii
OO0o = ("\n       ---_ ......._-_--.\n      (|\\ /      / /| \\  \\\n      /  /     .'  -=-'   `.\n     /  /    .'             )\n   _/  /   .'        _.)   /\n  / o   o        _.-' /  .'\n  \\          _.-'    / .'*|\n   \\______.-'//    .'.' \\*|\n    \\|  \\ | //   .'.' _ |*|\n     `   \\|//  .'.'_ _ _|*|\n      .  .// .'.' | _ _ \\*|\n      \\`-|\\_/ /    \\ _ _ \\*\\\n       `/'\\__/      \\ _ _ \\*\\\n      /^|            \\ _ _ \\*\n     '  `             \\ _ _ \\      ASH (+VK)\n                       \\_\n\n\n                        YOUR FILES ARE\n                   E  N  C  R  Y  P  T  E  D\n                     by >>>CobraRansom<<<\n\n> But don't worry - your data are secure now and totally safe!\n> If you want to decrypt your files, you need to send an e-mail\n> with your user id to [[[ cobrarans0m@asdf.pl ]]]\n\nUserID: {}\n\n> We'll respond to you in 72 hours with payment link.\n> Don't close that window, until you get a key! Good luck! )))\n\n").format(base64.b64encode(os.path.abspath(__name__)))
if 0:
    Iii1I1 + OO0O0O % iiiii % ii1I - ooO0OO000o
if 0:
    IiII1IiiIiI1 / iIiiiI1IiI1I1

def o0OoOoOO00(o0, eee):
    I11i, O0O, Oo = list(range(256)), 0, []
    if 0:
        o0 * i1 * ii1IiI1i % OOooOOo / I11iIi1I / IiiIII111iI
    for IiII in range(256):
        O0O = (O0O + I11i[IiII] + ord(eee[IiII % len(eee)])) % 256
        I11i[IiII], I11i[O0O] = I11i[O0O], I11i[IiII]
        if 0:
            Ii11111i * iiI1i1

    IiII = O0O = 0
    for i1I1ii1II1iII in o0:
        IiII = (IiII + 1) % 256
        O0O = (O0O + I11i[IiII]) % 256
        I11i[IiII], I11i[O0O] = I11i[O0O], I11i[IiII]
        Oo.append(chr(ord(i1I1ii1II1iII) ^ I11i[(I11i[IiII] + I11i[O0O]) % 256]))
        if 0:
            oO0o

    return ('').join(Oo)
    if 0:
        OOO0o0o / o0oO0 + i111I * oO0o.I11iIi1I % i111I
    if 0:
        oO0o


def IiiiIiI1iIiI1():
    global KEY
    KEY = raw_input('Key: ')
    return KEY
    if 0:
        o0


print OO0o
if 0:
    iiI1i1
while IiiiIiI1iIiI1() != o0OoOoOO00('\xa5"\xe8\xbc\x83\x1d\xca\xc4', 'cobracobra'):
    print '>>> WRONG <<<'
    if 0:
        OOooOOo % o0

for i1I1Iiii1111 in filter(lambda i11: i11.endswith('.cobra'), os.listdir('.')):
    print ('[*] Decrypting {} -> {}').format(i1I1Iiii1111, i1I1Iiii1111[:-6])
    with open(i1I1Iiii1111, 'rb') as (I11):
        Oo0o0000o0o0 = I11.read()
    Oo0o0000o0o0 = o0OoOoOO00(Oo0o0000o0o0, KEY)
    with open(i1I1Iiii1111[:-6], 'wb') as (I11):
        I11.write(Oo0o0000o0o0)
    if 0:
        i1 % IiII1IiiIiI1

print 'Thanks for payment :3’

Który przekonwertowałem do nieco bardziej czytelnej postaci

#!/usr/bin/python
import os, base64


#hello = ("\n       ---_ ......._-_--.\n      (|\\ /      / /| \\  \\\n      /  /     .'  -=-'   `.\n     /  /    .'             )\n   _/  /   .'        _.)   /\n  / o   o        _.-' /  .'\n  \\          _.-'    / .'*|\n   \\______.-'//    .'.' \\*|\n    \\|  \\ | //   .'.' _ |*|\n     `   \\|//  .'.'_ _ _|*|\n      .  .// .'.' | _ _ \\*|\n      \\`-|\\_/ /    \\ _ _ \\*\\\n       `/'\\__/      \\ _ _ \\*\\\n      /^|            \\ _ _ \\*\n     '  `             \\ _ _ \\      ASH (+VK)\n                       \\_\n\n\n                        YOUR FILES ARE\n                   E  N  C  R  Y  P  T  E  D\n                     by >>>CobraRansom<<<\n\n> But don't worry - your data are secure now and totally safe!\n> If you want to decrypt your files, you need to send an e-mail\n> with your user id to [[[ cobrarans0m@asdf.pl ]]]\n\nUserID: {}\n\n> We'll respond to you in 72 hours with payment link.\n> Don't close that window, until you get a key! Good luck! )))\n\n").format(base64.b64encode(os.path.abspath(__name__)))

def crypt(o0, eee):
    bytes, O0O, arr = list(range(256)), 0, []

    for i in range(256):
        O0O = (O0O + bytes[i] + ord(eee[i % len(eee)] )) % 256
        bytes[i], bytes[O0O] = bytes[O0O], bytes[i]


    i = 0
    O0O = 0
    for i1I1ii1II1iII in o0:
        i = (i + 1) % 256
        O0O = (O0O + bytes[i]) % 256
        bytes[i], bytes[O0O] = bytes[O0O], bytes[i]
        arr.append(chr(ord(i1I1ii1II1iII) ^ bytes[(bytes[i] + bytes[O0O]) % 256]))
        if 0:
            oO0o

    print ‚’.join(arr)

    return ('').join(arr)
 
   # Here the key is showing up 

def get_key():
    global KEY
    KEY = raw_input('Key: ')
    return KEY


#print hello

while get_key() != crypt('\xa5"\xe8\xbc\x83\x1d\xca\xc4', 'cobracobra'):
    print '>>> WRONG <<<'

# no need to make it readable, we know it's decrypting file in current directory

for i1I1Iiii1111 in filter(lambda i11: i11.endswith('.cobra'), os.listdir('.')):
    print ('[*] Decrypting {} -> {}').format(i1I1Iiii1111, i1I1Iiii1111[:-6])
    with open(i1I1Iiii1111, 'rb') as (I11):
        Oo0o0000o0o0 = I11.read()
    Oo0o0000o0o0 = o0OoOoOO00(Oo0o0000o0o0, KEY)
    with open(i1I1Iiii1111[:-6], 'wb') as (I11):
        I11.write(Oo0o0000o0o0)

print 'Thanks for payment :3’

Nazwana przeze mnie funkcja „crypt” zwraca zawsze taki sam klucz, który następnie jest porównywany z tym co wpisaliśmy. Zwyczajnie wypisujemy klucz poprzez linijkę „print ‚’.join(arr)”.

Uruchamiamy cobra.exe, wpisujemy nasz nowo poznany klucz oraz otrzymujemy odszyfrowany plik .pdf z flagą.

No i super ! Rozwiązaliśmy zadanie za 300 pkt ! Przechytrzyliśmy ransomware :)