USB drives are useful for transferring files on macOS systems, providing a quick and simple way to share documents, photos, and more between devices. They’re plug-and-play, allowing users to easily access and transfer data. However, it’s essential to be cautious about security. USB drives can carry malware, posing a risk to your macOS systems. Organizations should proactively implement real-time tracking and analysis of USB drive activities in their macOS systems to effectively address potential security threats.

Wazuh is a robust solution for enhancing macOS security by monitoring USB drives. With its capabilities, Wazuh can track USB drive activities, detect anomalies, and generate real-time alerts. By offering detailed insights into USB-related events, Wazuh empowers organizations to establish strict security policies, respond promptly to potential threats, and maintain the integrity of their macOS systems.

Previously, we demonstrated monitoring USB drives in Linux using Wazuh and monitoring USB drives in Windows using Wazuh. In this blog post, we explore how Wazuh boosts your protection against threats from USB drives on macOS systems.

Infrastructure

  • A pre-built, ready-to-use Wazuh OVA 4.7.2. Follow this virtual machine (OVA) guide to download and set up the Wazuh virtual machine.
  • A macOS Sonoma 14.3 endpoint with Wazuh agent 4.7.2 installed and enrolled to the Wazuh server. Follow this installing Wazuh agents on macOS endpoints guide to perform the installation.

Configuration

We configure the macOS endpoint using a custom Swift script to extract information about the plugged-in USB drives from the system. The script then correlates this information with the Linux USB ID repository file usb.ids and logs the data to a file. The Wazuh agent forwards these logs to the Wazuh server for analysis. Finally, we configure the Wazuh server to generate security alerts based on the USB drive log data.

macOS endpoint

Perform the steps below to configure the monitored macOS endpoint to generate USB drive information and log the data in JSON format into a file.

Configuring the Swift script

1. Run the following command to install Xcode command line tools, which include the Swift compiler:

# xcode-select --install

2. Verify that the Swift compiler is installed correctly:

# swiftc --version

Output:

Apple Swift version 5.9.2 (swiftlang-5.9.2.2.56 clang-1500.1.0.2.5)
Target: x86_64-apple-darwin23.3.0

3. Create a usb_monitor.log file in the /var/log directory to log USB events:

# touch /var/log/usb_monitor.log

4. Set the right permission to usb_monitor.log file:

# chmod 640 /var/log/usb_monitor.log

5. Download the usb.ids file and save it in the /Library/Ossec/etc directory:

# curl http://www.linux-usb.org/usb.ids -o /Library/Ossec/etc/usb.ids

6. Create a USBMonitor.swift file in the /Library/Ossec/etc directory:

# touch /Library/Ossec/etc/USBMonitor.swift

This script uses the I/O Kit framework in macOS systems to capture USB events and extract device details. It also parses the usb.ids file to establish a mapping of vendor and product IDs to their corresponding names and appends this information as JSON to the usb_monitor.log file.

7. Add the following content to the newly created USBMonitor.swift file:

import Foundation
import IOKit
import IOKit.usb

struct USBEvent: Codable {
    var eventType: String
    var timestamp: String
    var deviceInfo: [String: String]

    init(eventType: String, deviceInfo: [String: String]) {
        self.eventType = eventType
        self.deviceInfo = deviceInfo
        self.timestamp = USBEvent.currentLocalTimestamp()
    }

    private static func currentLocalTimestamp() -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" // Updated format
        formatter.timeZone = TimeZone.current
        return formatter.string(from: Date())
    }
}

var usbVendorProductMap = [String: [String: String]]()

func parseUSBIDs(from filePath: String) {
    do {
        let content = try String(contentsOfFile: filePath, encoding: .utf8)
        var currentVendorID: String?
        for line in content.split(whereSeparator: \.isNewline) {
            let trimmedLine = line.trimmingCharacters(in: .whitespaces)
            if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { continue }

            if line.first!.isNumber {
                let components = trimmedLine.split(separator: " ", maxSplits: 1)
                if components.count == 2 {
                    currentVendorID = String(components[0])
                    let vendorName = String(components[1])
                    usbVendorProductMap[currentVendorID!] = ["": vendorName]  // Vendor name
                }
            } else if line.first == "\t", let vendorID = currentVendorID {
                let components = trimmedLine.split(separator: " ", maxSplits: 1)
                if components.count == 2 {
                    let productID = String(components[0])
                    let productName = String(components[1])
                    usbVendorProductMap[vendorID]?[productID] = productName
                }
            }
        }
    } catch {
        print("Failed to read or parse USB IDs file: \(error)")
    }
}

func logUSBEvent(event: USBEvent, to filePath: String) {
    let encoder = JSONEncoder()
    if let jsonData = try? encoder.encode(event), let jsonString = String(data: jsonData, encoding: .utf8) {
        do {
            try jsonString.appendLineToURL(fileURL: URL(fileURLWithPath: filePath))
        } catch {
            print("Failed to write to log file: \(error)")
        }
    }
}

extension String {
    func appendLineToURL(fileURL: URL) throws {
        try (self + "\n").appendToFile(fileURL: fileURL)
    }

    func appendToFile(fileURL: URL) throws {
        let data = self.data(using: .utf8)!
        try data.append(fileURL: fileURL)
    }
}

extension Data {
    func append(fileURL: URL) throws {
        if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) {
            defer {
                fileHandle.closeFile()
            }
            fileHandle.seekToEndOfFile()
            fileHandle.write(self)
        } else {
            try write(to: fileURL, options: .atomic)
        }
    }
}

func extractDeviceInfo(device: io_object_t) -> [String: String] {
    var deviceInfo = [String: String]()
    let vendorIDKey = kUSBVendorID as String
    let productIDKey = kUSBProductID as String
    let serialNumberKey = kUSBSerialNumberString as String

    if let vendorIDRef = IORegistryEntrySearchCFProperty(device, kIOServicePlane, vendorIDKey as CFString, kCFAllocatorDefault, IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)),
       CFGetTypeID(vendorIDRef) == CFNumberGetTypeID() {
        var vendorID: Int = 0
        CFNumberGetValue((vendorIDRef as! CFNumber), CFNumberType.intType, &vendorID)
        let hexVendorID = String(format: "%04x", vendorID)
        deviceInfo[vendorIDKey] = hexVendorID
        if let vendorName = usbVendorProductMap[hexVendorID]?[""] {
            deviceInfo["vendorName"] = vendorName
        }
    }

    if let productIDRef = IORegistryEntrySearchCFProperty(device, kIOServicePlane, productIDKey as CFString, kCFAllocatorDefault, IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)),
       CFGetTypeID(productIDRef) == CFNumberGetTypeID() {
        var productID: Int = 0
        CFNumberGetValue((productIDRef as! CFNumber), CFNumberType.intType, &productID)
        let hexProductID = String(format: "%04x", productID)
        deviceInfo[productIDKey] = hexProductID
        if let vendorID = deviceInfo[vendorIDKey],
           let productName = usbVendorProductMap[vendorID]?[hexProductID] {
            deviceInfo["productName"] = productName
        }
    }

    if let serialNumberRef = IORegistryEntrySearchCFProperty(device, kIOServicePlane, serialNumberKey as CFString, kCFAllocatorDefault, IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)) as? String {
        deviceInfo[serialNumberKey] = serialNumberRef
    }

    // Debug: Print the mapping results including Serial Number
    print("Mapped Vendor ID: \(deviceInfo[vendorIDKey] ?? "Not Found")")
    print("Mapped Product ID: \(deviceInfo[productIDKey] ?? "Not Found")")
    print("Mapped Serial Number: \(deviceInfo[serialNumberKey] ?? "Not Found")")
    print("Mapped Vendor Name: \(deviceInfo["vendorName"] ?? "Not Found")")
    print("Mapped Product Name: \(deviceInfo["productName"] ?? "Not Found")")
    
    return deviceInfo
}

func usbDeviceCallback(context: UnsafeMutableRawPointer?, iterator: io_iterator_t, eventType: String) {
    var device: io_object_t
    repeat {
        device = IOIteratorNext(iterator)
        if device != 0 {
            let deviceInfo = extractDeviceInfo(device: device)
            let event = USBEvent(
                eventType: eventType,
                deviceInfo: deviceInfo
            )
            logUSBEvent(event: event, to: "/var/log/usb_monitor.log")
            IOObjectRelease(device)
        }
    } while device != 0
}

let notifyPort = IONotificationPortCreate(kIOMainPortDefault)
let runLoopSource = IONotificationPortGetRunLoopSource(notifyPort).takeRetainedValue()
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, CFRunLoopMode.defaultMode)

let scriptPath = URL(fileURLWithPath: #file).deletingLastPathComponent().path
parseUSBIDs(from: "/Library/Ossec/etc/usb.ids")

var deviceAddedIter = io_iterator_t()
var deviceRemovedIter = io_iterator_t()

let matchingDict = IOServiceMatching(kIOUSBDeviceClassName) as NSMutableDictionary
IOServiceAddMatchingNotification(notifyPort, kIOFirstMatchNotification, matchingDict, { (context, iterator) in
    usbDeviceCallback(context: context, iterator: iterator, eventType: "USBConnected")
}, nil, &deviceAddedIter)
usbDeviceCallback(context: nil, iterator: deviceAddedIter, eventType: "USBConnected")

IOServiceAddMatchingNotification(notifyPort, kIOTerminatedNotification, matchingDict, { (context, iterator) in
    usbDeviceCallback(context: context, iterator: iterator, eventType: "USBDisconnected")
}, nil, &deviceRemovedIter)
usbDeviceCallback(context: nil, iterator: deviceRemovedIter, eventType: "USBDisconnected")

CFRunLoopRun()

Where:

  • /var/log/usb_monitor.log is the log file where the USB drive log data is stored.
  • /Library/Ossec/etc/usb.ids is the text file containing a list of assigned Vendor and Product IDs for USB devices. It serves as a reference for operating systems and software to properly identify and interact with USB devices based on their unique identifiers.

Note

For macOS 11 and earlier versions, replace this line: let notifyPort = IONotificationPortCreate(kIOMainPortDefault) with let notifyPort = IONotificationPortCreate(kIOMasterPortDefault) in the above Swift script.

8. Compile the USBMonitor.swift script and make it executable:

# swiftc /Library/Ossec/etc/USBMonitor.swift -o /Library/Ossec/etc/USBMonitor

9. Test the executable to see if it runs without any errors:

# /Library/Ossec/etc/USBMonitor

Note 

Press Ctrl + C keys on your keyboard to abort the script execution.

You may encounter the following error due to an encoding issue of usb.ids file.

Failed to read or parse USB IDs file: Error Domain=NSCocoaErrorDomain Code=261 "The file “usb.ids” couldn’t be opened using text encoding Unicode (UTF-8)." UserInfo={NSFilePath=/Library/Ossec/etc/usb.ids, NSStringEncoding=4}

Run the following command to resolve the encoding issue within the usb.ids file:

# iconv -f iso-8859-1 -t utf-8 /Library/Ossec/etc/usb.ids > usb-utf8.ids && mv usb-utf8.ids /Library/Ossec/etc/usb.ids

After resolving the encoding issue, repeat step 9 to ensure the executable runs without any errors.

Forwarding USB logs to the Wazuh server

We configure the Wazuh agent installed on the monitored macOS endpoint to read the usb_monitor.log file and forward the log data to the Wazuh server. 

Perform the following steps to proceed with the configuration process.

1. Add the following configuration within the <ossec_config> block of the Wazuh agent /Library/Ossec/etc/ossec.conf file:

<localfile>
    <log_format>json</log_format>
    <location>/var/log/usb_monitor.log</location>
</localfile>

2. Restart the Wazuh agent for the changes to take effect:

# /Library/Ossec/bin/wazuh-control restart

Setting macOS startup script

In this section, we automate the USBMonitor script execution by creating a Property List (.plist) and loading it to the LaunchDaemons. LaunchDaemons are responsible for starting and stopping system-wide services, such as background processes at boot time. 

Perform the steps below to configure the macOS startup script.

1. Create a new file com.user.usbmonitor.plist in the /Library/LaunchDaemons directory:

# touch /Library/LaunchDaemons/com.user.usbmonitor.plist

2. Add the following content to the /Library/LaunchDaemons/com.user.usbmonitor.plist file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.usbmonitor</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Library/Ossec/etc/USBMonitor</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
</dict>
</plist>

3. Load the com.user.usbmonitor.plist file to execute USBMonitor during boot-up:

# launchctl load /Library/LaunchDaemons/com.user.usbmonitor.plist

4. Reboot the monitored macOS endpoint:

# shutdown -r now

Wazuh server

We now configure the Wazuh server to generate security alerts based on the USB drive logs received from the Wazuh agent. Since the received log data is in JSON format, Wazuh decodes them using an out-of-the-box JSON decoder. We only need to write custom rules to generate security alerts.

Perform the steps below to proceed with the Wazuh server configuration.

1. Create a custom rule file macos_usb_rules.xml in the /var/ossec/etc/rules directory:

# touch /var/ossec/etc/rules/macos_usb_rules.xml

2. Add the following detection rules to the macos_usb_rules.xml file:

<!-- rules for USB drives in macOS -->
<group name="macOS,usb-detect,">

  <!-- rule for connected USB drives -->
  <rule id="111060" level="7">
    <decoded_as>json</decoded_as>
    <field name="eventType">^USBConnected$</field>
    <description>macOS: USB drive $(deviceInfo.productName) was connected.</description>
  </rule>
  
  <!-- rule for disconnected USB drives -->
  <rule id="111062" level="7">
    <decoded_as>json</decoded_as>
    <field name="eventType">^USBDisconnected$</field>
    <description>macOS: USB drive $(deviceInfo.productName) was disconnected.</description>
  </rule>
</group>

Where:

  • Rule ID 111060 triggers a security alert when a new USB drive is connected to a macOS endpoint.
  • Rule ID 111062 triggers a security alert when a USB drive is disconnected from a macOS endpoint.

3. Restart the Wazuh manager to apply the changes:

# systemctl restart wazuh-manager

Testing the configuration

To test the configuration, connect a USB drive to the monitored macOS endpoint and disconnect it afterward.

Detection result

Once a USB drive is connected to the monitored macOS endpoint, we see a security alert with rule ID 111060 generated on the Wazuh dashboard. Similarly, upon unplugging the USB drive from the macOS endpoint, we see a security alert with rule ID 111062 generated on the Wazuh dashboard.

USB drives alerts
Figure 1: USB drive alerts on the Wazuh dashboard.
Detailed USB drive alert
Figure 2: Detailed USB drive alert when a USB drive is connected.

Filtering authorized and unauthorized USB drives

This section shows how to create a CDB (constant database) list in the Wazuh server for detecting unauthorized USB drives connected to the monitored macOS endpoint. The CDB list contains the serial numbers of all authorized USB drives, which we later use to enhance the custom rules created earlier in this blog post. 

Perform the following steps to proceed with the configuration on your Wazuh server.

1. Create a CDB list, usb-drives in the /var/ossec/etc/ directory:

# touch /var/ossec/etc/lists/usb-drives

2. Add the serial numbers of the authorized USB drives followed by a colon (:) to the CDB list:

234567890126:

Note

To extract serial numbers, you should consider the deviceInfo.kUSBSerialNumberString field in the USB drive log data.

3. Add the configuration <list>etc/lists/usb-drives</list> to the <ruleset> block of the /var/ossec/etc/ossec.conf file:

<ruleset>
  <!-- Default ruleset -->
  <decoder_dir>ruleset/decoders</decoder_dir>
  <rule_dir>ruleset/rules</rule_dir>
  <rule_exclude>0215-policy_rules.xml</rule_exclude>
  <list>etc/lists/audit-keys</list>
  <list>etc/lists/amazon/aws-eventnames</list>
  <list>etc/lists/security-eventchannel</list>

  <!-- User-defined ruleset -->
  <decoder_dir>etc/decoders</decoder_dir>
  <rule_dir>etc/rules</rule_dir>
  <list>etc/lists/usb-drives</list>
</ruleset>

4. Clear the previously added custom rules in /var/ossec/etc/rules/macos_usb_rules.xml and add the following content to the same file:

<!-- rules for USB drives in macOS -->
<group name="macOS,usb-detect,">

  <!-- rule for connected USB drives -->
  <rule id="111060" level="5">
    <decoded_as>json</decoded_as>
    <field name="eventType">^USBConnected$</field>
    <description>macOS: USB drive $(deviceInfo.productName) was connected.</description>
  </rule>
  
  <!-- rule for connected unauthorized USB drives -->
  <rule id="111061" level="8">
    <if_sid>111060</if_sid>
	<list field="deviceInfo.kUSBSerialNumberString" lookup="not_match_key">etc/lists/usb-drives</list>
    <description>macOS: Unauthorized USB drive $(deviceInfo.productName) was connected.</description>
  </rule>
  
  <!-- rule for disconnected USB drives -->
  <rule id="111062" level="5">
    <decoded_as>json</decoded_as>
    <field name="eventType">^USBDisconnected$</field>
    <description>macOS: USB drive $(deviceInfo.productName) was disconnected.</description>
  </rule>
  
  <!-- rule for disconnected unauthorized USB drives -->
  <rule id="111063" level="8">
    <if_sid>111062</if_sid>
	<list field="deviceInfo.kUSBSerialNumberString" lookup="not_match_key">etc/lists/usb-drives</list>
    <description>macOS: Unauthorized USB drive $(deviceInfo.productName) was disconnected.</description>
  </rule>
</group>

Where:

  • 111061 triggers security alerts when the serial numbers of the plugged-in USB drives don’t match the authorized USB drive serial numbers on the CDB list.
  • 111063 triggers security alerts when unauthorized USB drives are disconnected from the monitored macOS endpoint.

5. Restart the Wazuh manager for the changes to take effect:

# systemctl restart wazuh-manager

Testing the configuration

To test the configuration, connect a USB drive to the monitored macOS endpoint and disconnect it afterward. Ensure that the serial number of the connected USB drive is not present in the earlier created CDB list.

Detection result

Once the unauthorized USB drive is connected to the monitored macOS endpoint, we should see a security alert with rule ID 111061 generated on the Wazuh dashboard. Similarly, when we unplug the USB drive from the macOS endpoint, we should see a security alert with rule ID 111063 generated on the Wazuh dashboard.

Unauthorized USB Drives
Figure 3: Security alerts of unauthorized USB drives.
Unauthorized USB connected
Figure 4: Detailed USB drive alert when an unauthorized USB is connected.

Conclusion

Safeguarding sensitive data on macOS systems is an essential aspect of maintaining a secure computing environment. Wazuh deployment for monitoring USB drives adds an extra layer of defense against potential threats and unauthorized access. In this blog post, we discovered how Wazuh excels in the vigilant monitoring of USB devices across monitored macOS endpoints and contributes to a resilient and efficient security framework.

Wazuh stands as a robust, open source Security Information and Event Management (SIEM) and Extended Detection and Response (XDR) solution. Explore our community for ongoing support and the latest news.

Reference