Beyond the good ol' LaunchAgents - 32 - Dock Tile Plugins

This is part 32 in the series of “Beyond the good ol’ LaunchAgents”, where I try to collect various persistence techniques for macOS. For more background check the introduction.

When you write a series about something, there are some episodes which are less interesting, many boring stuff, but sometimes there are some true gems. While doing some research yesterday, I run into the Dock Tile Plugin feature in macOS, which turned out to be truly amazing from persistence point of view.

Dock tile plugins are available since macOS Snow Leopard (10.6), which is considered by many people one of the best edition ever. Dock tiles are the small icons that appear on your Dock, when the application runs. Apple writes the following about these plugins in their Developer Documentation:

A set of methods implemented by plug-ins that allow an app’s Dock tile to be customized while the app is not running.

So it’s a plugin that will run when your app is not running, that sounds really - really interesting.

They also write:

The plugin is loaded in a system process at login time or when the application tile is added to the Dock. When the plugin is loaded, the principal class’ implementation of setDockTile: is invoked, passing an NSDockTile for the plug-in to customize. If the principal class implements dockMenu it is invoked whenever the user causes the application’s dock menu to be shown. When the dock tile is no longer valid (for example,. the application has been removed from the dock) -setDockTile: is invoked with nil.

“plugin is loaded in a system process at login time” - this is fantastic. It is persistence, no matter how it’s framed. Basically it’s a plugin that can react to various system events, and update the tile on the Dock, how it looks, add custom menus, etc… Let’s see how we can create one.

Creating a plugin Link to heading

The plugins are embedded inside an application, so first we need to create a macOS App that will host the plugin. There are two sample codes I found on GitHub, that I also used as a base to learn how to create them. One is CartBlanche/MacDockTileSample which is written in Objective-C and the other is rrroyal/AutomaticDockTile which is written in Swift. I will show here how to create a simple one in Objective-C.

I will release the app and the source code, but you might want to follow this guide.

To start we will need a macOS app. You can name it whatever you like, I gave it a name “DuckDockTile”. Once it’s created, add a new target to Xcode, and it should be a bundle. I used the name DuckDockTilePlugin.docktileplugin. I will show it later, but based on reversing the process which will load it, it can have any extension or name.

Once the new target is created we will need to add a header file first.

@interface DuckDockTilePlugIn : NSObject <NSDockTilePlugIn> {
    NSMenu *dockMenu;
}

@property(retain) id observer;

@end

There we define our class (DuckDockTilePlugIn in my case), which will implement the NSDockTilePlugIn protocol. We will also need a property for the class, which will hold an observer object. More on this soon.

Next we can create the main file, where we implement our class. According the documentation we need to implement the following functions.

- setDockTile: - Invoked when the plug-in is first loaded and when the application is removed from the Dock. This is required. - dockMenu - Invoked when the user causes the application’s dock menu to be shown. This is optional.

The best if we implement both. I used the previously mentioned samples to get an idea what we can do. So I did the following.

#include "DuckDockTilePlugIn.h"

@implementation DuckDockTilePlugIn

@synthesize observer;

static void doSomething(void) {
    NSLog(@"%@", @"BEYOND doSomething was called");
}

- (void)setDockTile:(NSDockTile *)dockTile {
        observer = [[NSDistributedNotificationCenter defaultCenter] addObserverForName:@"com.apple.screenIsLocked" object:nil queue:nil usingBlock:^(NSNotification *notification) {
            doSomething();
        }];
    NSLog(@"%@", @"BEYOND setDockTile was called");
}

@end

While implementing our logic in the setDockTile would be sufficient based on the documentation, I found the observer idea very interesting from the samples. Basically we can subscribe to various system notifications, and define a function that will be executed every single time that event is happening. This is awesome, as we can cause our plugin to be executed multiple times, and we can make it very granular based on the events we subscribe to. In theory if we provide “nil” for the observer name we subscribe to everything (but maybe I’m wrong here) but that didn’t work for me.

I have spent some time looking for a good name, and eventually found the com.apple.screenIsLocked event, which is called when the screen is getting locked. I think under normal use, we can expect it to be invoked a few times during the day. Also, it is invoked when the screen is locked, so the user doesn’t see what we are doing…. * evil laugh *.

Once we are done with this, we need to do a few more things, so our plugin ends up inside the app.

First for the plugin’s Info.plist file (which is now simply the Info menu in Xcode 15), we need to define the “Principal class”, which is the name of the class we used for our plugin. In this case it’s “DuckDockTilePlugin”.

Principal class

In the Info.plist file it will show up as:

<key>NSPrincipalClass</key>
<string>DuckDockTilePlugin</string>

Next we need to go to the Info tab of our main application and define the “Dock Tile plugin path”, which should be the bundle name we used.

Dock Tile plugin path

In the Info.plist it will show up as:

<key>NSDockTilePlugIn</key>
<string>DuckDockTilePlugin.docktileplugin</string>

Lastly we need to create a “Copy Files” action in the main app’s Build Phases menu. This is shown below.

Copy Files

We need to copy the bundle inside the app. The destination should be “PlugIns and Foundation Extensions”.

That’s it, we can build it.

Using the PlugIn Link to heading

As our app is built, we can copy it to wherever we want, it will be recognized by launch services, registered, and the plugin will be loaded!!!! We don’t even need to start the app, the Dock tile doesn’t need to show up, we don’t need to do anything. That is so cool.

There is one exception. If the app is downloaded and has the quarantine attribute the plugin won’t be started until the app is started and the user approves it, so this can’t be used to bypass GateKeeper. Also in my testing, it might require a reboot for the plugin to be started even if the user approved the app. So the best is to drop the app without the Q flags.

Once our plugin is loaded we can lock the screen, and observe the logs, that indeed our function is being called again and again.

csaby@max ~ % log stream | grep DuckDockTilePlugin
2023-09-28 22:43:31.633798+0200 0x1d67     Default     0x0                  948    0    com.apple.dock.external.extra.arm64: (DuckDockTilePlugin) BEYOND doSomething was called
2023-09-28 22:43:51.787426+0200 0x1d67     Default     0x0                  948    0    com.apple.dock.external.extra.arm64: (DuckDockTilePlugin) BEYOND doSomething was called

The process responsible for loading it is com.apple.dock.external.extra.arm64 which is located at /System/Library/CoreServices/Dock.app/Contents/XPCServices/com.apple.dock.external.extra.arm64.xpc/Contents/MacOS/com.apple.dock.external.extra.arm64.

Executable=/System/Library/CoreServices/Dock.app/Contents/XPCServices/com.apple.dock.external.extra.arm64.xpc/Contents/MacOS/com.apple.dock.external.extra.arm64
Identifier=com.apple.dock.external.extra.arm64
Format=bundle with Mach-O thin (arm64e)
CodeDirectory v=20400 size=924 flags=0x0(none) hashes=18+7 location=embedded
Platform identifier=15
Signature size=4442
Signed Time=Aug 11, 2023 at 08:05:59
Info.plist entries=32
TeamIdentifier=not set
Sealed Resources version=2 rules=2 files=0
Internal requirements count=1 size=84
[Dict]
	[Key] com.apple.private.responsibility.set-to-self.at-launch
	[Value]
		[Bool] true
	[Key] com.apple.security.cs.disable-library-validation
	[Value]
		[Bool] true

If we observe its code signing properties we will find that it’s not sandboxed, that it it has library validation disabled, that is why it can load our plugin.

The XPC Process Link to heading

As I mentioned before we can use any bundle name. Loading the com.apple.dock.external.extra.arm64 binary in IDA and looking at the load function reveals the following:

 id __cdecl -[DockExtraService _loadPluginAtPath:withSize:andScale:](
        DockExtraService *self,
        SEL a2,
        id a3,
        unsigned __int64 a4,
        float a5)
{

...

Value = (const __CFString *)CFDictionaryGetValue(v11, CFSTR("NSDockTilePlugIn"));
  v14 = Value;
  if ( !Value || TypeID != CFGetTypeID(Value) )
  {
    NSLog(&CFSTR("Could not get resource for NSDockTilePlugIn (check spelling?) for Dock Extra").isa);
LABEL_11:
    v26 = 0LL;
    v27 = 0LL;
    goto LABEL_12;
  }
  v16 = objc_msgSend_stringWithFormat_(&OBJC_CLASS___NSString, v15, CFSTR("/Contents/PlugIns/%@"), v14);
  v18 = objc_msgSend_URLByAppendingPathComponent_(v8, v17, v16);
  v19 = objc_retain(v18);
  v21 = objc_msgSend_defaultManager(&OBJC_CLASS___NSFileManager, v20);
  v23 = objc_msgSend_path(v19, v22);

...

It will use the NSDockTilePlugIn property of the main app to find the plugin name, and it will be appended to /Contents/PlugIns/. So the plugin must be placed inside that directory, but the name is arbitrary.

Detection Link to heading

I think there are a few trivial ways to detect such plugins.

First, if we scan through the applications, any app which has the NSDockTilePlugIn in their Info.plist is suspicious, or deserves a second look.

Second, using the Endpoint Security Framework, we have two further options.

The com.apple.dock.external.extra.arm64 process only loads external Dock tile plugins, so even its execution can raise suspicion, as it’s not typically loaded. We can catch this with monitoring ES_EVENT_TYPE_NOTIFY_EXEC events.

ES_EVENT_TYPE_NOTIFY_EXEC

Further more we can monitor the load of bundles with subscribing to the ES_EVENT_TYPE_NOTIFY_MMAP events. If an external library is loaded into com.apple.dock.external.extra.arm64 that will also require further investigation.

ES_EVENT_TYPE_NOTIFY_MMAP

Sum Up Link to heading

So basically we have a way to drop an app with a custom plugin, which:

  • is not visible to the user
  • can react to various system events, and will be loaded at each login, so persists across reboots
  • runs inside a legitimate system process which is not sandboxed
  • doesn’t show up in Sonoma’s new Background Task Management menu
  • is not detected (yet) by BlockBlock and KnockKnock

And detection is also trivial based on:

  • the main App’s Info.plist file
  • using ESF events to monitor the com.apple.dock.external.extra.arm64 process

I think this is pretty damn cool for both sides.

Sample on GitHub: theevilbit/DuckDockTile