Behind the Bubbles: The Privacy and Security of Apple's iMessage Lookup
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.
| 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).
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.
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:
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.
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
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.