Behind the Bubbles: The Privacy and Security of Apple's iMessage Lookup

Apple's iMessage Screenshot

Background

Often times when I examine unified log endpoints for research in relation to artifacts within the Messages application, I notice that on some regular basis my phone number is added to a “ghost” iMessage chat. Unified logs seem to indicate that my device – or more particularly my iCloud email address – is added to a group chat which, without any UI indication to me (the end user), is formed but in which no messages are ever actually sent.

macOS Console window showing iMessage-related system logs from the imagent process, with timestamps and status messages in a scrolling list.
Here are some key unified log messages to look out for from a digital forensic and incident response point of view1:
Unified Log Message Definitions
… Received incoming iMessage … for service … account … fromID: … command: 100 … An external device has begun a conversation, is typing, or has sent a message
… Received incoming iMessage … for service … account … fromID: … command: 101 … An external device has recognized the incoming message and responded with a “message received” status
… Received incoming iMessage … for service … account … fromID: … command: 102 … Followed by: In delivery receipt method for messageID: … needsDeliveryReceipt :0 An external device device has issued a read receipt and the device has noticed that the message has been read
Posting notification id: … ; section: com.apple.MobileSMS; thread: … ; category: MessageExtension-RCS; timestamp: … A notification is being queued for an incoming RCS text message2

I have always wondered why my phone might be added to these group chats and for what purpose might people want to add my device to such group chats? I believe there are two potential reasons. Keep in mind that adding a device to a chat allows the second party to determine if the end user has iMessage (‘am’ in the Apple ecosystem) or if they are using another text technology (SMS/RCS = non-Apple ecosystem) by viewing if they have a “Green bubble” (SMS/RCS) or “Blue bubble” (Apple ecosystem).

Screenshot of an iPhone Messages thread showing gray SMS/RCS bubbles and blue iMessage bubbles labeled “iMessage Response” and “Blue Bubbles :D.” The chat is with a contact named “Test.”
  1. A third party may want to determine if the user is using an iPhone or an Android based device before sending out targeted advertisements or phishing links. As someone who changes primary mobile devices fairly often, I have personally noticed changes in targeted phishing campaigns depending on my current device. For example, when on an iPhone I may receive a higher influx of fabricated messages claiming I need to change my Apple account password.

  2. An attacker may have an exploit built to target only Android or only iOS. By fingerprinting the end user, the attacker can ensure that they are only attempting to exploit users on the proper platform.

How might someone automate the process of determining if phone numbers are using iMessage or RCS technology? Again, two methods come to mind:

  1. Easy (+ less efficient and less clean) method: Automate a macOS or iOS device to open the Messages application and actually “type” out a phone number. Check the colour of the actual pixels which show the “Green” or “Blue” box to determine the device status.

  2. Advanced method: Send targeted network requests to the proper Apple endpoints requesting the status of the phone number. More difficult to develop due to the need to bypass SSL pinning in order to identify network request contents.

In this example, I will examine method #1.

iMessageChecker

To automate the process of checking iMessage status using an automated bot which will interact with the screen, we can use AppleScript. The only issue is that AppleScript alone does not have the ability to determine what the colours within pixels are on a screen. For this purpose, we can use Python as the primary scripting language and have Python call AppleScript when necessary. The following script (https://github.com/hexordia/iMessageBubbleChecker) will achieve this. Note the “PIXEL_TO_CHECK” variable must be replaced with the coordinates of the mouse which should be checked for the green/blue pixel. To find the mouse coordinates, run the program in debug mode (-d) and jot down the coordinates3:
import pyautogui
import csv
import time
import subprocess
import os
import sys

def run_applescript(script_command):
    """Executes a given AppleScript command via a shell subprocess."""
    try:
        # text=True handles encoding for Python 3
        subprocess.run(['osascript', '-e', script_command],
                       check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as e:
        print(f"AppleScript Error: {e.stderr}")
        return False
    return True

def get_applescript_output(script_command):
    """Executes AppleScript and returns its standard output."""
    try:
        result = subprocess.run(
            ['osascript', '-e', script_command],
            check=True,
            capture_output=True,
            text=True
        )
        return result.stdout.strip()
    except subprocess.CalledProcessError as e:
        print(f"AppleScript Error: {e.stderr}")
        return None

def get_pixel_color_status(x, y):
    """
    Checks the pixel at (x, y) and returns 'iMessage', 'SMS/RCS/Non-iMessage', or 'Unknown'.
    """
    try:
        pixel_color = pyautogui.pixel(x, y)
        r, g, b = pixel_color

        # Color logic: Tune these values if they aren't accurate
        # iMessage = Blue
        if b > r and b > g and b > 100:
            return "iMessage"
        # SMS/RCS = Green
        elif g > r and g > b and g > 100:
            return "SMS/RCS"
        # Other (e.g., grey 'unknown' bubble)
        else:
            return f"Unknown ({r},{g},{b})"

    except Exception as e:
        print(f"CRITICAL ERROR: Could not read pixel color: {e}")
        print("---!!! DID YOU ENABLE 'SCREEN RECORDING' PERMISSIONS? !!!---")
        # Stop the whole script if we can't read the screen
        raise SystemExit

def main():
    # Config:
    # The X, Y coordinate you find (e.g., 374, 63)
    PIXEL_TO_CHECK = (746, 128)  # <--- REPLACE WITH COORDINATE YOU FIND IN DEBUG MODE

    # The name of your phone number list file
    PHONE_NUMBER_FILE = "numbers.txt"

    # The name of the CSV file to save results
    CSV_RESULT_FILE = "imessage_results.csv"

    # This is the "description", "name", or "title" of the New Message button.
    # Your debug log confirmed this is "compose"
    NEW_MESSAGE_BUTTON_ID = "compose"

    # DEBUG MODE CHECK "RGB FINDER"
    debug_mode = "-d" in sys.argv

    if debug_mode:
        print("--- DEBUG MODE (RGB Value Finder) ---")

        # 1. Read the first phone number
        try:
            with open(PHONE_NUMBER_FILE, 'r') as f:
                first_number = f.readline().strip()
            if not first_number:
                print(f"Error: '{PHONE_NUMBER_FILE}' is empty. Cannot run debug mode.")
                return
            print(f"Using first number from list: {first_number}")
        except FileNotFoundError:
            print(f"Error: Could not find file '{PHONE_NUMBER_FILE}'.")
            return

        # Activate Messages
        print("Activating Messages...")
        activate_script = 'tell application "Messages" to activate'
        run_applescript(activate_script)
        time.sleep(1)

        # Click Compose
        print(f"Clicking '{NEW_MESSAGE_BUTTON_ID}' button...")
        new_msg_script = f"""
        tell application "System Events" to tell process "Messages"
            set found_button to null
            try
                set found_button to (button 1 of toolbar 1 of window 1 whose description is "{NEW_MESSAGE_BUTTON_ID}")
            on error
                try
                    set found_button to (button 1 of toolbar 1 of window 1 whose name is "{NEW_MESSAGE_BUTTON_ID}")
                on error
                    try
                        set found_button to (button 1 of toolbar 1 of window 1 whose title is "{NEW_MESSAGE_BUTTON_ID}")
                    end try
                end try
            end try
            if found_button is not null then
                click found_button
            else
                error "Could not find any button with ID '{NEW_MESSAGE_BUTTON_ID}'."
            end if
        end tell
        """
        if not run_applescript(new_msg_script):
            print("Failed to click compose button. Exiting debug mode.")
            return
        time.sleep(1.5)

        # Type number and highlight
        print(f"Typing '{first_number}' and highlighting...")
        safe_number = first_number.replace('"', '`"\"`')
        type_script = f'tell application "System Events" to tell process "Messages" to keystroke "{safe_number}"'
        run_applescript(type_script)
        time.sleep(1.5) # Wait for lookup

        run_applescript('tell application "System Events" to tell process "Messages" to key code 36') # Enter
        time.sleep(0.2)
        run_applescript('tell application "System Events" to tell process "Messages" to key code 51') # Backspace
        time.sleep(0.2)

        # Start the RGB finder loop
        print("\n--- RGB Finder Active ---")
        print("Move your mouse over the colored number bubble.")
        print("Press Ctrl+C to quit and save the coordinate.")

        try:
            while True:
                x, y = pyautogui.position()
                r, g, b = pyautogui.pixel(x, y)

                position_str = f"X: {str(x).rjust(4)}  Y: {str(y).rjust(4)}   RGB: ({str(r).rjust(3)}, {str(g).rjust(3)}, {str(b).rjust(3)})    "
                print(position_str, end='')
                print('\b' * len(position_str), end='', flush=True)
                time.sleep(0.1)
        except KeyboardInterrupt:
            print("\nDebug mode finished.")
        except Exception as e:
            print(f"\nError: {e}")
            print("Did you enable 'Screen Recording' permissions?")

        # Clean up the window
        print("Closing debug message window...")
        close_script = """
        tell application "System Events" to tell process "Messages"
            keystroke "w" using command down
            delay 0.5
            if exists sheet 1 of window 1 then
                click button "Delete" of sheet 1 of window 1
            end if
        end tell
        """
        run_applescript(close_script)
        return  # Stop the script after debug info

    # MAIN SCRIPT LOGIC (Only runs if not in debug mode)
    print("Starting iMessage check...")
    print("!!! DO NOT TOUCH MOUSE OR KEYBOARD !!!")
    print("!!! To stop, move mouse to top-left corner (failsafe) !!!")
    time.sleep(3)

    # Activate Messages
    print("Activating Messages...")
    activate_script = """
    tell application "Messages" to activate
    delay 0.5
    """
    if not run_applescript(activate_script):
        print("Could not activate Messages. Exiting.")
        return
    time.sleep(1)

    # Read Phone Numbers
    try:
        with open(PHONE_NUMBER_FILE, 'r') as f:
            phone_numbers = [line.strip() for line in f.readlines() if line.strip()]
        print(f"Found {len(phone_numbers)} numbers to check in {PHONE_NUMBER_FILE}.")
    except FileNotFoundError:
        print(f"FATAL ERROR: Could not find file '{PHONE_NUMBER_FILE}'")
        return

    # SETUP: CLICK COMPOSE *ONCE*
    print(f"Clicking '{NEW_MESSAGE_BUTTON_ID}' button one time...")
    new_msg_script = f"""
    tell application "System Events" to tell process "Messages"
        set found_button to null
        try
            set found_button to (button 1 of toolbar 1 of window 1 whose description is "{NEW_MESSAGE_BUTTON_ID}")
        on error
            try
                set found_button to (button 1 of toolbar 1 of window 1 whose name is "{NEW_MESSAGE_BUTTON_ID}")
            on error
                try
                    set found_button to (button 1 of toolbar 1 of window 1 whose title is "{NEW_MESSAGE_BUTTON_ID}")
                end try
            end try
        end try

        if found_button is not null then
            click found_button
        else
            error "Could not find any button with ID '{NEW_MESSAGE_BUTTON_ID}'."
        end if
    end tell
    """
    if not run_applescript(new_msg_script):
        print(f"\n--- ERROR ---")
        print(f"Could not find button with ID: '{NEW_MESSAGE_BUTTON_ID}'")
        print(f"Run this script with -d to find the button ID.")
        print("Stopping.")
        return

    time.sleep(1.5)

    # Open CSV and Start Loop
    try:
        with open(CSV_RESULT_FILE, 'w', newline='') as f:
            writer = csv.writer(f)
            writer.writerow(["Phone Number", "iMessage Status"])

            for i, number in enumerate(phone_numbers):
                print(f"Checking: {number}...")

                if i > 0:
                    print("  -> Clearing previous number...")
                    run_applescript('tell application "System Events" to tell process "Messages" to key code 51')
                    time.sleep(0.5)

                safe_number = number.replace('"', '`"\"`')
                type_script = f'tell application "System Events" to tell process "Messages" to keystroke "{safe_number}"'
                run_applescript(type_script)
                time.sleep(1.5)

                print("  -> Pressing Enter...")
                run_applescript('tell application "System Events" to tell process "Messages" to key code 36')
                time.sleep(0.2)

                print("  -> Highlighting number...")
                run_applescript('tell application "System Events" to tell process "Messages" to key code 51')
                time.sleep(0.2)

                x, y = PIXEL_TO_CHECK
                status = get_pixel_color_status(x, y)
                print(f"  -> Result: {status}")

                writer.writerow([number, status])
                f.flush()

    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    finally:
        print("\n---")
        print(f"Check complete. Results saved to '{CSV_RESULT_FILE}'.")

        print("Closing message window...")
        close_script = """
        tell application "System Events" to tell process "Messages"
            keystroke "w" using command down
            delay 0.5
            if exists sheet 1 of window 1 then
                click button "Delete" of sheet 1 of window 1
            end if
        end tell
        """
        run_applescript(close_script)

if __name__ == "__main__":
    main()

Mitigations

What can Apple do to prevent this? Honestly not much. Apple likely already has checks in place to ban iCloud accounts which request the status of too many phone numbers in a short amount of time (and if this is not in place it should be!). And although rate limiting may slow down the checking process, this is nothing which could not be easily overcome by scaling virtual devices, fabricating network requests, or using VPNs.

Even if Apple decides to only provide the status after a message is actually sent, messages with invalid bodies (e.g., which would be blocked by the Blast Door service) could be sent without ever providing indication to the end user. As long as this feature exists at all, it will remain a useful trick for attackers.

Footnotes

1 All tested on an iPad 7th gen running iOS 18.2. Note there are MANY more important unified logs related to messaging on iOS and macOS.
2 Note this can even be seen on other iDevices on the users’ ecosystem if the “Text Message Forwarding” setting is enabled .
3 Note if using a retina display, the coordinates might be calculated from the bottom right instead of the top left. If the coordinates do not work at first, try multiplying both the x and y coordinates by 2.
Nicholas Dubois

Nicholas Dubois is a digital forensic examiner and educational content writer. Nicholas has spoken at several conferences on forensic findings and the offensive security of educational institutions including HTCIA, DFRWS, and NCCC.

Next
Next

The One Conversation You Must Have With Your Child: Understanding and Preventing Sextortion