Deep Dives

Are Your Employees Slack Messages Leaking While Their Screen Is Locked?

Notification Preferences: Interpreting Bit fields with Osquery

Fritz Ifert-Miller

Customers ask us all the time about ways sensitive information can leak from an unattended Mac. While this discussion is usually centered around Screensaver & Screen Lock policies, there is an additional vector that often gets missed; chat notifications which appear on your Mac's locked screen.

While showing message previews on your locked Mac may seem innocuous, imagine breezing by your co-worker's locked computer and them glancing at the following...

It's not just a matter of potentially embarrassing information being leaked or even messages that would violate MNDA's. Things like SMS 2FA codes can be subverted with physical access to a device that has improperly configured notification preferences.

It's imperative then, to understand how you can mitigate the risk of unintentional data disclosure by practicing good notification hygiene.

You may be wondering with COVID-19 and working from home, is this something I should care about? The answer is yes. While your cat is unlikely to write down your MFA codes or take issue with your social calendar, you want your device's security posture to be robust for when your device is not in a controlled environment.

Quick Background on Notification Preferences

Since macOS 10.9 Mavericks, Apple has allowed per-application configuration of notifications. Using this Preference Pane you can tailor each application's notification style, allow previews and even mute notifications entirely.

Identifying misconfigured Preferences across thousands of Macs

Imagine working at an organization with over 2,000 macOS devices spread across 4 continents. You can't simply walk up to each and physically launch their System Preferences to check them. That's where the beauty of osquery comes in. Using some clever SQL (which I dive into at the bottom of this blog post) we can craft a Live Query to inspect all of our devices simultaneously.

Notify Users on How-To-Fix

Finding the problem is only the first step. That's why we turned this query into a default Check for all of our Kolide customers.

Checks are the power-feature of Kolide, allowing you to notify your end-users directly when there are security issues with their device.

Kolide's interactive Slack App reaches out to users with self-fix instructions, educating them on their security posture and guiding them through remediation.

When a user fixes an issue they can click a button in Slack to recheck the device in real-time. No tedious back and forth with the call-center, just resolution.

Why not just force this setting using MDM and profiles?

Kolide supports tech-savvy and privacy oriented organizations that allow users to be administrators on their own machine. We believe in educating rather than enforcing.

After we shipped this notification, several customers reached out expressing relief at learning how to disable this behavior on other apps they used for personal communication like Messages, Mail and Discord.

With a managed profile approach, users do not have insight into the rationale of IT policies and have no idea how to secure themselves on their own non-company devices.

How many other apps are configured this way by default?

While we were excited to have a new Check that addressed the most troublesome apps:

  • Slack
  • Google Chrome (Chromium & Brave)
  • Messages
  • Outlook
  • Mail

We wanted to extend this visibility to our customers across ALL of their apps. So we created a new Inventory Item: Notification Center Preferences.

This way, administrators can review all of their users apps at once and filter the data as they chose.

TL;DR Wrap-up

In the end, we thought it was such a great thing to look for that we have added it today as a new default Check for all of our other customers: Sensitive Notification Previews Enabled While Screen Locked

If you aren't already a Kolide customer but want to check your fleet, sign up today for a Free 14-day Trial (no payment information necessary).

How did we do it?

Kolide utilizes osquery to gather device data. Osquery is an open-source, multi-platform, endpoint agent designed to provide device visibility at scale. The osquery agent uses native API's to gather device data and exposes it in a relational database format which you can query using standard SQL.

Osquery ships with over 200 virtual tables that allow you to see things like what processes are running, what apps are installed, what network ports are actively listening and more.

To gather the data on Notification Preferences we dug through the internals of the plist which the OS uses to cache these settings.

How is this setting stored on macOS:

macOS stores the settings for Notification Preferences in the following plist:


Let's take a look at the actual data within the plist...

We can see a number of arrays that contain several common fields: flags, bundle-id, and path. While bundle-id and path are self-explanatory, this flags value's purpose is not entirely obvious at first blush. It turns out flags is where the magic happens.

Preferences for the various check-boxes, dropdowns and toggles are stored within this value flags. How you might ask? The answer is what are called: Bit Fields, Bit Masks and Bitwise Operations.

What are Bit Fields?

A Bit field is a sort of storage array that allows a number of boolean values to be stored in a binary sequence.

Let's take our Slack app for example, the flags output is listed as:


Flags is storing the values of several checkboxes as a Bit field in a decimal format. Let's convert it to binary and examine the various fields:


We can now see the various bits representing the settings of the individual Notifications Preferences.

Remember, because this is in binary, *the values are read from right to left**.

To identify what each bit position corresponds to each setting we can toggle the settings and record the change. In this example, I change only the Sound Notifications checkbox and observe the difference in values:

As we can see the 3rd position (Position 2) is the only value that changed when toggling the checkbox, we now know the corresponding bit position which handles the Sound Notifications preference.

The rest of these preference bit positions can be found below: (The allow_notifications preference does not exist in Mojave or earlier)

| preference                       | position | 1 equals |
| allow_notifications              | 25       | TRUE     |(≥ 10.15*)
| style_banners                    | 3        | TRUE     |
| style_alerts                     | 4        | TRUE     |
| show_notifications_on_lockscreen | 12       | FALSE    |
| show_notification_preview        | 14       | FALSE    |
| show_preview_always              | 13       | TRUE     |
| show_in_notification_center      | 0        | FALSE    |
| badge_app_icon                   | 1        | TRUE     |
| sound_notifications              | 2        | TRUE     |

You might be thinking to yourself, that is all well and good, but there is no Decimal to Binary conversion in osquery, and you'd be right! The good news however, is we don't even need to convert the flags value thanks to built-in Bitwise operation support!

Performing Bitwise operations in Osquery SQL:

Osquery includes several helpful SQLite functions including Bitwise Operations. Utilizing bitwise operators you can read the value of an arbitrary binary position within a bitfield mask like the flags value without first having to convert the value from decimal.

A valid query might have the following structure:

SELECT source_bitfield >> position_number_from_right & desired_match

Bitwise operations take the following operators:

  • >> : Binary right shift operator will move left hand operand to right by the number of bits defined in right side operand.

  • << : Binary left shift operator will move left hand operand to left by the number of bits defined in right side operand.

  • & : Binary AND operator copies bit if exists in both operands

  • | : Binary OR operator copies bit if exists in either operands

Continuing with our earlier example of the Sound Notifications. Let's use bitwise operators to examine the third position (Position 2, starting from 0).

We know from our earlier decimal conversion that we can expect to see a 1 in this position, which controls the checkbox "Play sound for notifications" and that our original flags value was 41943374.

Using osqueryi we can run the following query based on the syntax above:

osquery> SELECT 41943374 >> 2 & 1 AS sound_notifications;
| sound_notifications |
| 1                   |

In English we can translate the above query to the following:

  1. SELECT decimal representation (flags = 41943374) of binary value
  2. Take the bit in position 2 (Third position from the right)
  3. If position 2 matches (&) the binary value 1, then return 1, else 0

Using the following approach we can construct a query that examines the value of each desired position and cleverly cases those values to a corresponding column.

osquery> SELECT
CASE (41943374 >> 25) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS allow_notifications,
CASE (41943374 >> 3) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS style_banners,
CASE (41943374 >> 4 ) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS style_alerts,
CASE (41943374 >> 12 ) & 1
    WHEN 1 THEN 'false' ELSE 'true'
  END AS show_notifications_on_lockscreen,
CASE (41943374 >> 14 ) & 1
    WHEN 1 THEN 'false' ELSE 'true'
  END AS show_notification_preview,
CASE (41943374 >> 13 ) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS show_preview_always,
CASE (41943374 >> 0 ) & 1
    WHEN 1 THEN 'false' ELSE 'true'
  END AS show_in_notification_center,
CASE (41943374 >> 1 ) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS badge_app_icon,
CASE (41943374 >> 2 ) & 1
    WHEN 1 THEN 'true' ELSE 'false'
  END AS sound_notifications;
allow_notifications = true
style_banners = true
style_alerts = false
show_notifications_on_lockscreen = true
show_notification_preview = true
show_preview_always = false
show_in_notification_center = true
badge_app_icon = true
sound_notifications = true

Fantastic! Our preferences are being mapped to intelligible column names and we can see where we stand from a potential data leakage perspective. The only problem is we are statically supplying our flags value. To take this query to completion we will need to dynamically supply this value.

Preserving Data Hierarchy in Plist Arrays

The osquery plist table suffers from a data-flattening bug which results in the parent/child relationship of arrays being discarded. An example of this behavior is below:

osquery> SELECT key, subkey, value FROM plist WHERE path LIKE '/Users/%/Library/Preferences/' AND subkey IN ('bundle-id', 'flags', 'path') LIMIT 10;
| key  | subkey    | value                             |
| apps | bundle-id |                    |
| apps | flags     | 41951254                          |
| apps | path      | /System/Applications/ |
| apps | bundle-id |                |
| apps | path      | /System/Applications/ |
| apps | flags     | 41951246                          |
| apps | path      | /System/Applications/     |
| apps | bundle-id |                    |
| apps | flags     | 41943054                          |
| apps | bundle-id |                   |

We we can see there is no way to tell which flags value belongs to which Application. Thankfully, we have a Kolide Launcher table which addresses this flattening problem and gives us an index to associate values:

osquery> SELECT parent, key, value FROM kolide_plist WHERE path LIKE '/Users/%/Library/Preferences/' AND parent != '' AND parent NOT LIKE '%src%' LIMIT 10;
| parent | key       | value                             |
| apps/0 | path      | /System/Applications/ |
| apps/0 | bundle-id |                    |
| apps/0 | flags     | 41951254                          |
| apps/1 | path      | /System/Applications/ |
| apps/1 | flags     | 41951246                          |
| apps/1 | bundle-id |                |
| apps/2 | bundle-id |                    |
| apps/2 | flags     | 41943054                          |
| apps/2 | path      | /System/Applications/     |
| apps/3 | path      | /System/Applications/ |

As we can see, each entry now has its own unique identifier, indicated by the parent column (eg.apps/2) which we can use to correlate the results. But wait! It gets even cooler!

The kolide_plist table has a super-power which is the added query column. By supplying a valid query we can specify which bits of data we want to return from the plist AND how to format them!

Given our example above, we could return any key:value in place of our parent apps/3. Let's say that instead we wanted to return the bundle_identifier of the Application We could run the following:

FROM kolide_plist
WHERE path LIKE '/Users/%/Library/Preferences/'
AND query = 'apps/#bundle-id'
| parent                        |
| apps/           |
| apps/     |
| apps/     |
| apps/       |
| apps/ |

We have now appended the bundle-id values into the parent column. We can then pull only results that have a flags value to return our Notification bitmasks:

    value AS 'flags',
    SUBSTR(parent, 6) AS bundle_identifier
FROM   kolide_plist
WHERE  path LIKE '/Users/%/Library/Preferences/'
AND    query = 'apps/#bundle-id/flags' LIMIT 5;
| flags     | parent                   | bundle_identifier   |
| 41951254  | apps/      |      |
| 41951246  | apps/  |  |
| 41943054  | apps/      |      |
| 310902798 | apps/     |     |
| 310910998 | apps/ | |

This query functionality is loosely based on XPath and we can use it to selectively retrieve results and to append data.

There are 3 levels of hierarchy being acted upon: Parent/Key/Value

query = 'apps/#bundle-id/flags'

Here we have asked for results that are in the /apps array object, to have their parent appended (denoted by #) with the key:value labeled bundle-id and to return values whose key = flags.

By using the query to return only these results which have a flags value we can begin to pivot our data. If you are interested in how PIVOT's can be accomplished, you can learn more about rebuilding these arrays with my previous blog post on Pivoting Plist EAV Data in Osquery.

Let's take a look at the Final Query below:

-- Collect plist raw output
plist_raw AS (
  SELECT *, '/Users/' || SPLIT(path, '/', 1) AS directory
  FROM   kolide_plist
  AND parent NOT LIKE 'apps/%/%'),
-- Reduce user accounts
user_accounts AS (
  SELECT username, description, uid, directory
  FROM users WHERE SUBSTR(uuid, 0, 8) != 'FFFFEEE'),
-- Pivot output and user append
plist_pivot AS (
  MAX(CASE WHEN key = 'bundle-id' THEN value
  END) AS bundle_identifier,
  MAX(CASE WHEN key = 'flags' THEN value END) AS flags,
  MAX(CASE WHEN key = 'path' THEN value END) AS path,
  MAX(CASE WHEN key = 'auth' THEN value END) AS auth
  FROM plist_raw, user_accounts ua USING(directory)
  GROUP BY parent, username),
-- Collect all results and trim bundle ids
app_flags AS (
   TRIM(bundle_identifier, '_SYSTEM_CENTER_:') AS bundle_identifier,
   directory, username, description, uid, flags, path, auth
  FROM plist_pivot
  WHERE bundle_identifier NOT NULL),
-- Perform bitwise operations
notification_preferences AS (
   CASE WHEN (app_flags.flags >> 25) & 1 = 0
         AND (SELECT 1 FROM os_version WHERE minor >= 15) = 1
        THEN 'false' ELSE 'true'
    END AS allow_notifications,
   CASE (app_flags.flags >> 3 ) & 1 WHEN 1 THEN 'true' ELSE 'false'
    END AS style_banners,
   CASE (app_flags.flags >> 4 ) & 1 WHEN 1 THEN 'true' ELSE 'false'
    END AS style_alerts,
   CASE (app_flags.flags >> 12 ) & 1 WHEN 1 THEN 'false' ELSE 'true'
    END AS show_notifications_on_lockscreen,
   CASE (app_flags.flags >> 13 ) & 1 WHEN 1 THEN 'true' ELSE 'false'
    END AS show_preview_always,
   CASE (app_flags.flags >> 14 ) & 1 WHEN 1 THEN 'false' ELSE 'true'
    END AS show_notification_preview,
   CASE (app_flags.flags >> 0 ) & 1 WHEN 1 THEN 'false' ELSE 'true'
    END AS show_in_notification_center,
   CASE (app_flags.flags >> 1 ) & 1 WHEN 1 THEN 'true' ELSE 'false'
    END AS badge_app_icon,
   CASE (app_flags.flags >> 2 ) & 1 WHEN 1 THEN 'true' ELSE 'false'
    END AS sound_notifications
  FROM  app_flags
  ORDER BY path),
-- Specify apps that can leak sensitive information
chat_apps AS (
  FROM notification_preferences
  WHERE bundle_identifier IN
    'org.chromium.Chromium', 'com.tinyspeck.slackmacgap',
-- Final query
SELECT * FROM chat_apps
WHERE allow_notifications = 'true'
AND show_preview_always = 'true'
AND show_notifications_on_lockscreen = 'true';

As you can see, we now return data only for devices that have leaky sensitive notifications.

All this work or you could get the same Check live in your environment with the click of a button today in Kolide.

Don't have Kolide? No problem!

Sign up now for your free trial! (no payment info required)

References and Credit:

A huge thank you to my coworker seph who made all of this possible.

He significantly reduced my toil by first recognizing the likelihood that the values were bitfield masks and subsequently demonstrated how we could manipulate them in osquery. He also built a kick-ass Kolide Launcher table: kolide_plist to overcome data-flattening issues present in the osquery plist table which allowed us to preserve the arrays.

Credit also to jacobsalmela who did the same leg-work. My coworker Jonathan tipped me off that prior art existed for these Notification Preferences after it was already too late, but at least it's nice to have an existing reference to sanity-check!

His work manifested as a utility to manage these Preferences which you can find on GitHub @ acobsalmela/NCutil. (Jacob if you read this blog post there is a new preference in position 25 introduced in Catalina)

SQLite bitwise operators definitions were taken from Tutlane Tutorials

Explanations of Bitfields taken from Wikipedia.

Share this story:

More articles you
might enjoy:

How to Find a Mac's Manufacture Date Using Osquery
Fritz Ifert-Miller
How to Monitor macOS Hosts With Osquery
How to Spotlight Search Across Every Mac With Osquery
Fritz Ifert-Miller
Try Kolide Free
Try Kolide Free