From 232e126a89697c6c9b601697f8539e1906cd36b0 Mon Sep 17 00:00:00 2001 From: Chistopher Huntwork Date: Mon, 31 Jul 2023 11:35:34 -0700 Subject: [PATCH 01/10] added logging of message effects --- demo.py | 2 +- imessage.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/demo.py b/demo.py index 8632445..19afdc0 100644 --- a/demo.py +++ b/demo.py @@ -130,7 +130,7 @@ current_participants = [] while True: msg = im.receive() if msg is not None: - print(f'[{msg.sender}] {msg.text}') + print(msg.to_string()) if len(INPUT_QUEUE) > 0: msg = INPUT_QUEUE.pop() diff --git a/imessage.py b/imessage.py index 08c9476..5c0b59d 100644 --- a/imessage.py +++ b/imessage.py @@ -53,6 +53,8 @@ class iMessage: """Group ID of the message, will be randomly generated if not provided""" body: BalloonBody | None = None """BalloonBody, may be None""" + effect: str | None = None + """iMessage effect sent with this message, may be None""" _compressed: bool = True """Internal property representing whether the message should be compressed""" @@ -103,9 +105,8 @@ class iMessage: sender=message.get("p", [])[-1] if message.get("p", []) != [] else None, _id=uuid.UUID(message.get("r")) if "r" in message else None, group_id=uuid.UUID(message.get("gid")) if "gid" in message else None, - body=BalloonBody(message["bid"], message["b"]) - if "bid" in message - else None, + body=BalloonBody(message["bid"], message["b"]) if "bid" in message else None, + effect=message["iid"] if "iid" in message else None, _compressed=compressed, _raw=message, ) @@ -138,6 +139,12 @@ class iMessage: return d + def to_string(self) -> str: + message_str = f"[{self.sender}] '{self.text}'" + if self.effect is not None: + message_str += f" with effect [{self.effect}]" + return message_str + class iMessageUser: """Represents a logged in and connected iMessage user. From 0049f1178353bf921cbac230f222fd1fcb530a88 Mon Sep 17 00:00:00 2001 From: Chistopher Huntwork Date: Mon, 31 Jul 2023 11:47:27 -0700 Subject: [PATCH 02/10] added sending effects --- demo.py | 13 ++++++++++++- imessage.py | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/demo.py b/demo.py index 19afdc0..faa412c 100644 --- a/demo.py +++ b/demo.py @@ -127,6 +127,7 @@ threading.Thread(target=input_thread, daemon=True).start() print("Type 'help' for help") current_participants = [] +current_effect = None while True: msg = im.receive() if msg is not None: @@ -140,10 +141,18 @@ while True: print('quit (q): quit') #print('send (s) [recipient] [message]: send a message') print('filter (f) [recipient]: set the current chat') + print('effect (e): adds an iMessage effect to the next sent message') print('note: recipient must start with tel: or mailto: and include the country code') print('\\: escape commands (will be removed from message)') elif msg == 'quit' or msg == 'q': break + elif msg.startswith("effect ") or msg.startswith("e "): + msg = msg.split(" ") + if len(msg) < 2 or msg[1] == "": + print("effect [effect namespace]") + else: + print(f"next message will be sent with [{msg[1]}]") + current_effect = msg[1] elif msg.startswith('filter ') or msg.startswith('f '): # Set the curernt chat msg = msg.split(' ') @@ -158,8 +167,10 @@ while True: im.send(imessage.iMessage( text=msg, participants=current_participants, - sender=user.handles[0] + sender=user.handles[0], + effect=current_effect )) + current_effect = None else: print('No chat selected, use help for help') diff --git a/imessage.py b/imessage.py index 5c0b59d..4d3d839 100644 --- a/imessage.py +++ b/imessage.py @@ -125,6 +125,7 @@ class iMessage: "pv": 0, "gv": "8", "v": "1", + "iid": self.effect } # Remove keys that are None From 7347de14d7340293e04529b52eb7b2af8a23da2d Mon Sep 17 00:00:00 2001 From: JJTech Date: Mon, 31 Jul 2023 15:08:34 -0400 Subject: [PATCH 03/10] Create README.md --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7684e47 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# pypush +`pypush` is a POC demo of my recent iMessage reverse-engineering. +It can currently register as a new device on an Apple ID, set up encryption keys, and ***send and recieve iMessages***! + +`pypush` is completely platform-independent, and does not require a Mac or other Apple device to use! + +## Installation +It's pretty self explanatory: +1. `git clone https://github.com/JJTech0130/pypush` +2. `pip3 install -r requirements.txt` +3. `python3 ./demo.py` + +## Operation +`pypush` will generate a `config.json` in the repository when you run demo.py. DO NOT SHARE THIS FILE. +It contains all the encryption keys necessary to log into you Apple ID and send iMessages as you. + +Once it loads, it should prompt you with `>>`. Type `help` and press enter for a list of supported commands. + +## Special Notes +### Unicorn dependency +`pypush` currently uses the Unicorn CPU emulator and a custom MachO loader to load a framework from an old version of macOS, +in order to call some obfuscated functions. + +This is only necessary during initial registration, so theoretically you can register on one device, and then copy the `config.json` +to another device that doesn't support the Unicorn emulator. Or you could switch out the emulator for another x86 emualtor if you really wanted to. + +### Public key caching +iMessage will cache public keys. If you get decryption errors in pypush or can only send and not recive messages from another device, +try logging out and back into iMessage on that device, forcing it to refresh it's key cache. Alternatively, you can wait and the cache should +expire eventually. From 84f9c114f773ecf0cdcaf8dda8e520d2f6c1bce0 Mon Sep 17 00:00:00 2001 From: JJTech Date: Mon, 31 Jul 2023 15:11:20 -0400 Subject: [PATCH 04/10] Add note about Discord --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 7684e47..29a50e3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ It's pretty self explanatory: 2. `pip3 install -r requirements.txt` 3. `python3 ./demo.py` +## Troubleshooting +If you have any issues, please join [the Discord](https://discord.gg/BVvNukmfTC) and ask for help. + ## Operation `pypush` will generate a `config.json` in the repository when you run demo.py. DO NOT SHARE THIS FILE. It contains all the encryption keys necessary to log into you Apple ID and send iMessages as you. From 31102d6b53d4b77ed41c518745803014b34a8710 Mon Sep 17 00:00:00 2001 From: JJTech Date: Mon, 31 Jul 2023 15:12:47 -0400 Subject: [PATCH 05/10] fix spelling lol --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29a50e3..cda11af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pypush `pypush` is a POC demo of my recent iMessage reverse-engineering. -It can currently register as a new device on an Apple ID, set up encryption keys, and ***send and recieve iMessages***! +It can currently register as a new device on an Apple ID, set up encryption keys, and ***send and receive iMessages***! `pypush` is completely platform-independent, and does not require a Mac or other Apple device to use! @@ -28,6 +28,6 @@ This is only necessary during initial registration, so theoretically you can reg to another device that doesn't support the Unicorn emulator. Or you could switch out the emulator for another x86 emualtor if you really wanted to. ### Public key caching -iMessage will cache public keys. If you get decryption errors in pypush or can only send and not recive messages from another device, +iMessage will cache public keys. If you get decryption errors in pypush or can only send and not receive messages from another device, try logging out and back into iMessage on that device, forcing it to refresh it's key cache. Alternatively, you can wait and the cache should expire eventually. From eedefbab8a55adf3a088dbe076bfc54d085eeee1 Mon Sep 17 00:00:00 2001 From: JJTech Date: Mon, 31 Jul 2023 15:13:42 -0400 Subject: [PATCH 06/10] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cda11af..423611c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Once it loads, it should prompt you with `>>`. Type `help` and press enter for a in order to call some obfuscated functions. This is only necessary during initial registration, so theoretically you can register on one device, and then copy the `config.json` -to another device that doesn't support the Unicorn emulator. Or you could switch out the emulator for another x86 emualtor if you really wanted to. +to another device that doesn't support the Unicorn emulator. Or you could switch out the emulator for another x86 emulator if you really wanted to. ### Public key caching iMessage will cache public keys. If you get decryption errors in pypush or can only send and not receive messages from another device, From 74fff8b7f89693dba57b742a976d2d6c297a6622 Mon Sep 17 00:00:00 2001 From: JJTech0130 Date: Mon, 31 Jul 2023 15:23:09 -0400 Subject: [PATCH 07/10] fix sender not displaing correctly --- imessage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/imessage.py b/imessage.py index 08c9476..c6f78ea 100644 --- a/imessage.py +++ b/imessage.py @@ -85,7 +85,7 @@ class iMessage: return True - def from_raw(message: bytes) -> "iMessage": + def from_raw(message: bytes, sender: str | None = None) -> "iMessage": """Create an `iMessage` from raw message bytes""" compressed = False try: @@ -100,7 +100,7 @@ class iMessage: text=message.get("t", ""), xml=message.get("x"), participants=message.get("p", []), - sender=message.get("p", [])[-1] if message.get("p", []) != [] else None, + sender=sender if sender is not None else message.get("p", [])[-1] if "p" in message else None, _id=uuid.UUID(message.get("r")) if "r" in message else None, group_id=uuid.UUID(message.get("gid")) if "gid" in message else None, body=BalloonBody(message["bid"], message["b"]) @@ -333,7 +333,7 @@ class iMessageUser: decrypted = self._decrypt_payload(payload) - return iMessage.from_raw(decrypted) + return iMessage.from_raw(decrypted, body['sP']) KEY_CACHE: dict[bytes, tuple[bytes, bytes]] = {} """Mapping of push token : (public key, session token)""" From 5c592c9dee04e16479ff1c5289913b2721036121 Mon Sep 17 00:00:00 2001 From: JJTech0130 Date: Mon, 31 Jul 2023 16:30:06 -0400 Subject: [PATCH 08/10] implement handle selection --- demo.py | 22 +++++++++++++++++++--- ids/__init__.py | 4 +++- imessage.py | 7 +++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/demo.py b/demo.py index faa412c..45bb3b0 100644 --- a/demo.py +++ b/demo.py @@ -143,17 +143,18 @@ while True: print('filter (f) [recipient]: set the current chat') print('effect (e): adds an iMessage effect to the next sent message') print('note: recipient must start with tel: or mailto: and include the country code') + print('handle : set the current handle (for sending messages)') print('\\: escape commands (will be removed from message)') elif msg == 'quit' or msg == 'q': break - elif msg.startswith("effect ") or msg.startswith("e "): + elif msg == 'effect' or msg == 'e' or msg.startswith("effect ") or msg.startswith("e "): msg = msg.split(" ") if len(msg) < 2 or msg[1] == "": print("effect [effect namespace]") else: print(f"next message will be sent with [{msg[1]}]") current_effect = msg[1] - elif msg.startswith('filter ') or msg.startswith('f '): + elif msg == 'filter' or msg == 'f' or msg.startswith('filter ') or msg.startswith('f '): # Set the curernt chat msg = msg.split(' ') if len(msg) < 2 or msg[1] == '': @@ -161,13 +162,28 @@ while True: else: print(f'Filtering to {msg[1:]}') current_participants = msg[1:] + elif msg == 'handle' or msg.startswith('handle '): + msg = msg.split(' ') + if len(msg) < 2 or msg[1] == '': + print('handle [handle]') + print('Available handles:') + for h in user.handles: + print(f'\t{h}') + else: + h = msg[1] + if h in user.handles: + print(f'Using {h} as handle') + user.current_handle = h + else: + print(f'Handle {h} not found') + elif current_participants != []: if msg.startswith('\\'): msg = msg[1:] im.send(imessage.iMessage( text=msg, participants=current_participants, - sender=user.handles[0], + sender=user.current_handle, effect=current_effect )) current_effect = None diff --git a/ids/__init__.py b/ids/__init__.py index 5d7226b..b0a74d2 100644 --- a/ids/__init__.py +++ b/ids/__init__.py @@ -45,6 +45,8 @@ class IDSUser: self._auth_keypair, self._push_keypair, ) + self.current_handle = self.handles[0] + # Uses an existing authentication keypair def restore_authentication( @@ -79,5 +81,5 @@ class IDSUser: self._id_keypair = id_keypair def lookup(self, uris: list[str], topic: str = "com.apple.madrid") -> any: - return query.lookup(self.push_connection, self.handles[0], self._id_keypair, uris, topic) + return query.lookup(self.push_connection, self.current_handle, self._id_keypair, uris, topic) diff --git a/imessage.py b/imessage.py index 45fb528..7314b5d 100644 --- a/imessage.py +++ b/imessage.py @@ -343,12 +343,19 @@ class iMessageUser: return iMessage.from_raw(decrypted, body['sP']) + KEY_CACHE_HANDLE: str = "" KEY_CACHE: dict[bytes, tuple[bytes, bytes]] = {} """Mapping of push token : (public key, session token)""" USER_CACHE: dict[str, list[bytes]] = {} """Mapping of handle : [push tokens]""" def _cache_keys(self, participants: list[str]): + # Clear the cache if the handle has changed + if self.KEY_CACHE_HANDLE != self.user.current_handle: + self.KEY_CACHE_HANDLE = self.user.current_handle + self.KEY_CACHE = {} + self.USER_CACHE = {} + # Check to see if we have cached the keys for all of the participants if all([p in self.USER_CACHE for p in participants]): return From ea0e72ac2bb1f059deb64b2d5533c68dafc64cbe Mon Sep 17 00:00:00 2001 From: JJTech0130 Date: Mon, 31 Jul 2023 16:45:45 -0400 Subject: [PATCH 09/10] bugfix --- ids/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ids/__init__.py b/ids/__init__.py index b0a74d2..7e561c4 100644 --- a/ids/__init__.py +++ b/ids/__init__.py @@ -55,6 +55,7 @@ class IDSUser: self._auth_keypair = auth_keypair self.user_id = user_id self.handles = handles + self.current_handle = self.handles[0] # This is a separate call so that the user can make sure the first part succeeds before asking for validation data def register(self, validation_data: str): From c99998dcd9f5151cc235883046b05eed90f0774c Mon Sep 17 00:00:00 2001 From: JJTech0130 Date: Mon, 31 Jul 2023 19:19:16 -0400 Subject: [PATCH 10/10] add handle auto fixup and loop delay --- demo.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/demo.py b/demo.py index 45bb3b0..265784f 100644 --- a/demo.py +++ b/demo.py @@ -126,6 +126,26 @@ threading.Thread(target=input_thread, daemon=True).start() print("Type 'help' for help") +def fixup_handle(handle): + if handle.startswith('tel:+'): + return handle + elif handle.startswith('mailto:'): + return handle + elif handle.startswith('tel:'): + return 'tel:+' + handle[4:] + elif handle.startswith('+'): + return 'tel:' + handle + # If the handle starts with a number + elif handle[0].isdigit(): + # If the handle is 10 digits, assume it's a US number + if len(handle) == 10: + return 'tel:+1' + handle + # If the handle is 11 digits, assume it's a US number with country code + elif len(handle) == 11: + return 'tel:+' + handle + else: # Assume it's an email + return 'mailto:' + handle + current_participants = [] current_effect = None while True: @@ -160,17 +180,21 @@ while True: if len(msg) < 2 or msg[1] == '': print('filter [recipients]') else: - print(f'Filtering to {msg[1:]}') - current_participants = msg[1:] + print(f'Filtering to {[fixup_handle(h) for h in msg[1:]]}') + current_participants = [fixup_handle(h) for h in msg[1:]] elif msg == 'handle' or msg.startswith('handle '): msg = msg.split(' ') if len(msg) < 2 or msg[1] == '': print('handle [handle]') print('Available handles:') for h in user.handles: - print(f'\t{h}') + if h == user.current_handle: + print(f'\t{h} (current)') + else: + print(f'\t{h}') else: h = msg[1] + h = fixup_handle(h) if h in user.handles: print(f'Using {h} as handle') user.current_handle = h @@ -189,6 +213,8 @@ while True: current_effect = None else: print('No chat selected, use help for help') + + time.sleep(0.1) # elif msg.startswith('send') or msg.startswith('s'): # msg = msg.split(' ')