◀ Back to NotesPublished on March 28, 2023

Can we hide the orange dot without disabling SIP?

A bit of background.

When macOS Monterey was announced, Apple added an orange dot indicator that appears on top of everything whenever the microphone is in use.

This has made a lot of people very angry and been widely regarded as a bad move.

Kidding, it was quite a nice privacy addition actually. We could finally see in realtime when an app used the microphone, and what app that is.

But this wasn't something that everyone wanted.

For example, when projecting live visuals on a large screen, you might want to make the screen background black to give the impression of floating effects. That vibrant colored dot in the corner breaks that illusion.

It can also be a distraction for a specific group of people struggling with attention deficit.

Oh, and it also appears in screen recordings, which annoys me as well whenever I want to demo something.

# First solution

After news got out, Sydney San Martin posted a command-line implementation in a HackerNews thread called undot which could grab the dot and move it off screen, since it was just a normal window.

And I decided to make an easy to use app for it called YellowDot (in my eyes, it looks yellow ¯\_(ツ)_/¯)

# macOS 12.2

Apple took note of this, saw it as a vulnerability, and promptly updated their code to disable this method of hiding the dot.

Indeed, it is a vulnerability. Any app with Accessibility and Microphone Permissions could record audio without your knowledge.

The problem was that there was no way for the user to opt out of this.

A Privacy Indicator toggle that asks for password or biometric authentication could solve this problem for people that want a Mac to be a tool that should work for them as they want.

# Second solution

Tyshawn Cormier, another developer annoyed by this, came up with a different solution a few days after macOS 12.2 was released: RecordingIndicatorUtility.

It needed disabling System Integrity Protection (SIP) because it injected code inside the WindowServer and ControlCenter processes. There, it had access to methods that handled the drawing of this privacy indicator, and it could just set its opacity to 0 to hide it.

Quite a clever solution, but a bit too convoluted and apparently risky for most people.

Anyway, with no better idea in hand, I updated the YellowDot page to point people to Tyshawn's app and proceeded to forget about it.

# Still no real solution...

After a while I noticed something odd in my Plausible Analytics dashboard:

Even if the app was no longer working, and I wasn't making any noise about it, it was still the second most accessed page on The low-tech guys.

And most traffic was coming from Google. People were constantly looking for a way to hide this dot.

I couldn't stop thinking that maybe there is a way to do this with SIP enabled.


# What the dot?

So if it was no longer a simple window, how was this dot drawn on the screen?

Was it drawn on the GPU using Metal? Was it pixels pushed directly into the framebuffer?

No, it was still a damn window.

// WindowInfo.swift
import Foundation
import Cocoa

let info = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID)
print(info as! [[String: AnyObject]])



 swift WindowInfo.swift

[
    "kCGWindowName": StatusIndicator,
    "kCGWindowOwnerName": Window Server
    "kCGWindowBounds": {
        Height = 16; Width = 8;
        X = 1953; Y = 16;
    },
    "kCGWindowOwnerPID": 403,
    "kCGWindowNumber": 713,
    "kCGWindowMemoryUsage": 2288,
    "kCGWindowStoreType": 1,
    "kCGWindowAlpha": 1,
    "kCGWindowSharingState": 1,
    "kCGWindowLayer": 0,
    "kCGWindowIsOnscreen": 1,
]

But this time, it was a special kind of window. Accessibility APIs could no longer find it, and thus there was no way to move the window around.

Looking for the "StatusIndicator" string inside SkyLight.framework points us to:

int system_status_indicator_get_window(PKGSystemStatusIndicator_t*) {
    // ...
    _WSWindowSetProperty(r20, @"kCGSWindowTitle", @"StatusIndicator");
    // ...
}

Looks like the window is created with an internal method called WSWindowCreate. This creates the window but doesn’t insert it in the CGSLocalWindows hashmap.

This prevents user facing APIs from seeing the window, as they do a CGSWindowGetMapped to fetch the window object, which looks inside that hashmap.

I was wondering if we could call these methods directly. They’re not exported so we can’t link them in Swift, but we can call them directly by address.

system_status_indicator_set_shape looks interesting, it expects a CGRect, a CGPoint and a double. These are most likely the draw region, the origin and the size of the dot.

Let’s see if we can move the dot offscreen by altering the x coordinate. I’m intercepting the method call using Frida and setting the d0 register to 9999999.

Because structures like CGRect are passed by value, d0 corresponds to the first double field of the first non-pointer argument: that is the x coordinate of the drawing region

yellowdot.js

const symbols = Module.enumerateSymbols('SkyLight')
function addr(symname) {
  return ptr(symbols.find((sym) => sym.name == symname).address)
}

const system_status_indicator_set_shape = new NativeFunction(
  addr('_ZL33system_status_indicator_set_shapeP26PKGSystemStatusIndicator_t6CGRect7CGPointd'),
  'int', ['pointer', ['double', 'double', 'double', 'double'], ['double', 'double'], 'double']
)

Interceptor.attach(system_status_indicator_set_shape, {
  onEnter: function (args) {
    console.log(
      `system_status_indicator_set_shape(${args[0]}, rx: ${this.context.d0}, ry: ${this.context.d1}, rw: ${this.context.d2}, rh: ${this.context.d3}, x: ${this.context.d4}, y: ${this.context.d5}, size: ${this.context.d6})`
    )

	// Move the dot offscreen
    this.context.d0 = 9999999
  },
})

Load the script, attach to WindowServer and start an audio recording:

> sudo frida --load yellowdot.js WindowServer

// Dot in ControlCenter icon
system_status_indicator_set_shape(0x600002f28540, rx: 1953, ry: 16, rw: 8, rh: 16, x: 1957, y: 24, size: 3)

// Dot in Full Screen
system_status_indicator_set_shape(0x600002f28540, rx: 0, ry: 0, rw: 0, rh: 0, x: 2010, y: 16, size: 4)

Ok looks promising.

To call the function we would need a pointer to a PKGSystemStatusIndicator struct which we don’t know the layout of. I asked ChatGPT about it and came up with a good enough answer:

Now, how would one call a function by address? Isn’t Address space layout randomization a problem here?

The dyld cache (where SkyLight resides) gets loaded at a random address at startup, but thankfully that stays the same until restart.

We can use _dyld_get_image_vmaddr_slide to get the base memory address of a specific module, then add to that the address of the symbol that we got from disassembling SkyLight.

Then we cast that address to a function using unsafeBitCast and just call it with the right parameters.

yellowdot.swift

import Cocoa
import Foundation
import MachO

func getDotWindowID() -> CGWindowID? {
    let info = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID)
    guard let windows = info as? [[String: AnyObject]],
          let window = windows.first(where: { ($0["kCGWindowName"] as? String) == "StatusIndicator" })
    else {
        return nil
    }

    return window["kCGWindowNumber"] as? CGWindowID
}

struct PKGSystemStatusIndicator {
    var windowID: CGWindowID
    var field_78: UInt32
    var field_unk1: UnsafeMutablePointer<UInt64>?
    var field_unk2: UnsafeMutablePointer<Any>?
}

guard let id = getDotWindowID() else {
    exit(1)
}
var pkg = PKGSystemStatusIndicator(windowID: id, field_78: 0, field_unk1: nil, field_unk2: nil)


let libraryName = "/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight"
var slide: Int = 0

let imageCount = _dyld_image_count()
let skyLightIndex = (0 ..< imageCount).first { index in
    _dyld_get_image_name(index) == libraryName
}
guard let skyLightIndex, _dyld_get_image_vmaddr_slide(skyLightIndex) != 0 else {
    print("The \(libraryName) library is not loaded in the dyld cache")
    exit(1)
}

let slide = _dyld_get_image_vmaddr_slide(skyLightIndex)
print("The slide of the \(libraryName) library is 0x\(String(slide, radix: 16))")

typealias SystemStatusIndicatorSetShapeFunction = @convention(c) (UnsafeMutableRawPointer?, CGRect, CGPoint, Double) -> Void
let systemStatusIndicatorSetShape = unsafeBitCast(0x18514d130 + slide, to: SystemStatusIndicatorSetShapeFunction.self)

systemStatusIndicatorSetShape(&pkg, .zero, CGPoint(x: 999999, y: 16), 4)
> swift yellowdot.swift

The slide of the /System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight library is 0x1c98c000
fish: Job 1, 'sudo ./yellowdot' terminated by signal SIGSEGV (Address boundary error)

Hmm ok, it’s not so easy.

Debugging this with lldb drops us into WSWindowCreate where we are trying to load something from the 0x0 address. We reach that address because the internal SkyLight code expects to find a data structure filled with windows at a specific address in memory.

Obviously, we don’t have such a thing, that data only exists inside the WindowServer process and we have no access to it.

# systemstatusd

The “call by address” idea doesn’t work. But something else jumped out to me when I was trying to find a rogue LaunchDaemon.

/System/Library/LaunchDaemons/com.apple.systemstatusd.plist

This seems to run some kind of server for handling these indicators. What happens if we just disable it?

❯ sudo launchctl bootout system/com.apple.systemstatusd

That worked! Starting an audio recording no longer shows the dot!

It even stops showing the newly added blue location dot when I open the Weather app. Not exactly what I wanted.

But does it work with SIP enabled?

❯ sudo launchctl bootout system/com.apple.systemstatusd
Boot-out failed: 150: Operation not permitted while System Integrity Protection is engaged

❯ sudo killall systemstatusd
killall: warning: kill -term 448: Operation not permitted

❯ sudo rm /System/Library/LaunchDaemons/com.apple.systemstatusd.plist
override rw-r--r-- root/wheel restricted,compressed for /System/Library/LaunchDaemons/com.apple.systemstatusd.plist? y
rm: /System/Library/LaunchDaemons/com.apple.systemstatusd.plist: Operation not permitted

Nope, they thought of everything.

Looks like with SIP enabled, these software rendered colored dots are just as effective as the green hardware-wired webcam LED.

Without a 0-day exploit, an attacker has no way of recording audio or location without you noticing it. But it also means you are stuck with this dot on your screen even when you fully trust the software.

The only solution is still disabling SIP, and using RecordingIndicatorUtility. Looks like Tyshawn just replaced the code injection method with toggling this systemstatusd server instead, so it is more likely to survive macOS updates.