├── LICENSE ├── README.md ├── assets └── airtag_frida.png ├── scripts ├── DurianFirmware_extract.py ├── hook_durian.js └── update │ ├── hook_durian_update_fud.js │ ├── hook_durian_update_locationd.js │ ├── hook_durian_update_searchpartyd.js │ └── overwrite_firmware.py └── woot22-paper.pdf /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AirTag Scripts & Resources 2 | 3 | 4 | ## FЯIDA Script Overview 5 | 6 | ![AirTag: Play custom sound, UWB, firmware version, downgrade](assets/airtag_frida.png) 7 | 8 | 9 | ### Prerequisites 10 | 11 | The scripts require a jailbroken iPhone, paired with an AirTag, and a host system, running 12 | [FЯIDA](https://frida.re). 13 | 14 | AirTag support was introduced as of iOS 14.5. So far, we tested the scripts provided here on various 15 | iOS versions (14.6, 14.7, 14.8) on [checkm8](https://checkra.in)-supported devices. 16 | The scripts might run on Fugu14 as well, but we didn't test that yet. 17 | 18 | ### Run custom tasks, enumerate commands 19 | 20 | Use [hook_durian.js](scripts/hook_durian.js) to 21 | play custom sounds aka [AirTechno](https://www.youtube.com/watch?v=z1DJ7z_LaUM), 22 | and also run and decode all other L2CAP commands. Commands are described by opcodes, 23 | and opcodes can be enumerated to list their meanings. 24 | 25 | 26 | It's possible to run raw commands. However, some commands require a mutex or special 27 | state. Thus, ideally, create a task, which will take care of creating a command, 28 | including mutex handling. Objective-C allows to extract a full command list, included 29 | in the comments of `hook_durian.js`, so that you don't need to enumerate them. 30 | For example, `unpairTask`, `stopSoundTask`, etc. 31 | 32 | ```JavaScript 33 | // run a predefined task 34 | d.performTaskByName('stopSoundTask'); 35 | 36 | // run a task with custom opcode 0x01 and payload 0x02030405 37 | d.performTaskWithCommand([1, 2, 3, 4, 5]); 38 | ``` 39 | 40 | Some tasks require parameters. These aren't fully reverse engineered yet. 41 | Depending on the command, you might need to add a custom function. 42 | For example, you can play custom sounds: 43 | 44 | ```JavaScript 45 | // play sound sequence id 1, twice, with 0 offset, and 0 pause 46 | d.playSoundSequence[1, 2, 0, 0]; 47 | ``` 48 | 49 | To set the `DurianService` etc. to call task, manually play a sound 50 | via the Find My app on the AirTag once. 51 | 52 | 53 | ### Hook the firmware update process for downgrades 54 | 55 | A detailed description of the downgrade process including script 56 | explanations is available on [YouTube](https://www.youtube.com/watch?v=C4JyI_WUNJ8). 57 | 58 | #### 1. Download the firmware version you want 59 | 60 | [The iPhone Wiki](https://www.theiphonewiki.com/wiki/OTA_Updates/AirTag) hosts an 61 | up-to-date list of all firmware updates released for the AirTag. Note that the very first 62 | stock version (1.0.225) was never released as OTA, so you cannot use the method here to 63 | downgrade to the very first version that did not have any anti-stalking protections. 64 | 65 | #### 2. Extract the U1 firmware (aka Rose) from the super binary (optional) 66 | 67 | If you want to downgrade the U1 firmware as well, you can extract it using 68 | [DurianFirmware_extract.py](scripts/DurianFirmware_extract.py). 69 | 70 | ```bash 71 | mkdir airtag_firmware_1A276d 72 | cd airtag_firmware_1A276d 73 | wget https://updates.cdn-apple.com/2021/patches/071-45785/4132D4FE-1C5A-498E-8A6D-678A026679AF/com_apple_MobileAsset_MobileAccessoryUpdate_DurianFirmware/ae34f4b8aec8a4d4562227109be101728b7bef20.zip 74 | python3 DurianFirmware_extract.py AssetData/DurianFirmware.acsw/DurianFirmwareMobileAsset.bin 75 | ``` 76 | 77 | This will extract the following files, with `ftab` being the U1 firmware. 78 | 79 | ```buildoutcfg 80 | tag : blap offset : 0x9c size : 0x38340 81 | tag : sftd offset : 0x383dc size : 0x1b400 82 | tag : bldr offset : 0x537dc size : 0x5f9c 83 | tag : basg offset : 0x59778 size : 0x47 84 | tag : sdsg offset : 0x597c0 size : 0x48 85 | tag : blsg offset : 0x59808 size : 0x47 86 | tag : ftab offset : 0x59850 size : 0x924c5 87 | ``` 88 | 89 | 90 | #### 3. Overwrite the U1 firmware while the downgrade is running (optional) 91 | 92 | Replace the `sha384sum` of `rkos` and `dsp1` in the [hook_durian_update_fud.js](scripts/update/hook_durian_update_fud.js) script 93 | with the matching ones. For this, you also have to split the `ftab` using the external [ftab_split.py](https://gist.github.com/matteyeux/c1018765a51bcac838e26f8e49c6e9ce) script. 94 | 95 | ```bash 96 | ftab_split.py ftab.bin 97 | sha384sum rkos 98 | 1fcb05b377eb405eeffc5ad60efce6aeed3b83d834e0403bd88a142d84c6082ea6c649ebf14ae05b1a87d159e9dc167c rkos 99 | sha384sum sbd1 100 | 928a226b85b52c75f07fb3cd89f1c38a783bb9834de647407b935a952359d36b243a58fa43a172d1e39c3d432d1a3030 sbd1 101 | ``` 102 | 103 | Now we can use the TOCTOU to overwrite the firmware. Double-check which firmware is being used for the update, 104 | the folder might differ. The following is running on the iPhone: 105 | 106 | ``` 107 | iPhone:/private/var/MobileAsset/AssetsV2/com_apple_MobileAsset_MobileAccessoryUpdate_DurianFirmware/[your_version].asset/AssetData/DurianFirmware.acsw root# 108 | while true; do cp /var/root/ftab_rose_airtag_old.bin ftab.bin; sleep 1; done 109 | ``` 110 | 111 | #### 4. Run the downgrade 112 | 113 | If you're not downgrading Rose and only the nRF parts, you might need to adapt [overwrite_firmware.py](scripts/update/overwrite_firmware.py). 114 | Otherwise, simply run: 115 | 116 | ```bash 117 | python3 overwrite_firmware.py 118 | ``` 119 | 120 | Now, remove your AirTag from your account and pair it again. The update should start within 5 minutes. 121 | If this wasn't the case, check your `idevicesyslog`. Possible reasons: 122 | 123 | * Concurrent interaction with the AirTag that delayed the update process by 2h 30min. Just pair again. 124 | * The current AirTag firmware version has a `deploymentLimit`. Can probably fixed by overwriting the `xml` file 125 | in the same folder as the asset location on the iPhone. 126 | 127 | 128 | ### L2CAP command opcodes 129 | 130 | The full list of L2CAP opcodes, since they might also be useful for reverse engineering and building 131 | clients independent of iOS. Note that Durian opcodes are for AirTags, and Hawkeye opcodes are likely 132 | for third-party Find My devices. 133 | 134 | 135 | ```buildoutcfg 136 | Durian opcode list: 137 | [d] 0: Acknowledge 138 | [d] 1: Rose Init 139 | [d] 2: Rose Ready 140 | [d] 3: Rose Start Ranging 141 | [d] 4: Rose Ranging Complete 142 | [d] 6: Rose Stop 143 | [d] 7: Get Firmware Version 144 | [d] 8: Stop Sound 145 | [d] 10: Leashing 146 | [d] 11: Set Max Connections 147 | [d] 12: Get Multi Status 148 | [d] 13: Set Obfuscated Identifier 149 | [d] 14: Set Mutex 150 | [d] 15: Set Near Owner Timeout 151 | [d] 18: Get Firmware Version (Deprecated) 152 | [d] 19: Unpair 153 | [d] 21: Rose Set Paramaters 154 | [d] 22: Rose Stop Ranging 155 | [d] 24: Get User Stats 156 | [d] 32: Abort FWDL 157 | [d] 34: Rose Error 158 | [d] 36: Rose P2P Timestamp 159 | [d] 37: Rose Debug P2P Timestamp 160 | [d] 38: Set Tag Type 161 | [d] 39: Get Battery Status 162 | [d] 40: Play Sound Sequence 163 | [d] 42: Set Wild Mode Configuration 164 | [d] 43: Roll Wild Key 165 | [d] 45: Set Absolute Wild Mode Configuration 166 | [d] 174: Fetch Current Key Index 167 | [d] 175: Play Unauthorized Sound 168 | [d] 177: Set Key Rotation Timeout 169 | [d] 180: Dump Logs 170 | [d] 181: Check Crashes 171 | [d] 185: Induce Crash 172 | [d] 195: Enable/Disable UT PlaySound Rate Limit 173 | [d] 197: Set Central Reference Time 174 | [d] 199: Set Accelerometeter Slope Mode Configuration 175 | [d] 200: Set Accelerometer Orientation Mode Configuration 176 | [d] 201: Get Accelerometer Slope Mode Configuration 177 | [d] 202: Get Accelerometer Orientation Mode Configuration 178 | [d] 203: Get Accelerometer Mode 179 | [d] 209: Fetch ProductData AIS 180 | [d] 210: Fetch ManufacturerName AIS 181 | [d] 211: Fetch ModelName AIS 182 | [d] 212: Fetch ModelColorCode AIS 183 | [d] 213: Fetch AccessoryCategory AIS 184 | [d] 214: Fetch AccessoryCapabilities AIS 185 | [d] 215: Fetch FirmwareVersion AIS 186 | [d] 216: Fetch FindMyVersion AIS 187 | [d] 217: Fetch BatteryTyp AIS 188 | [d] 218: Fetch BatteryLevel AIS 189 | [d] 219: Send UARP message to accessory 190 | [d] 220: Stop Unauthorized Sound 191 | 192 | Hawkeye opcode list: 193 | [h] 512: Sound Start 194 | [h] 513: Sound Stop 195 | [h] 514: Persistent Connection Status 196 | [h] 515: Nearby Timeout 197 | [h] 516: Unpair 198 | [h] 517: Configure Separated State 199 | [h] 518: Latch Separated Key 200 | [h] 519: Set Max Connections 201 | [h] 520: Set UTC 202 | [h] 521: Get Multi Status 203 | [h] 523: Command Response 204 | [h] 524: Multi Status Response 205 | [h] 525: Sound Complete 206 | [h] 768: Non-Owner Sound Start 207 | [h] 769: Non-Owner Sound Stop 208 | [h] 770: Non-Owner Command Response 209 | [h] 771: Non-Owner Sound Complete 210 | [h] 1024: Get Current Primary Key 211 | [h] 1025: Get iCloud Identifier 212 | [h] 1026: Get Current Primary Key Response 213 | [h] 1027: Get iCloud Identifier Response 214 | [h] 1028: Get Serial Number 215 | [h] 1029: Get Serial Number Response 216 | [h] 1280: Key Rotation 217 | [h] 1281: Retrieve Logs 218 | [h] 1282: Log Response 219 | [h] 1283: Debug Command Response 220 | [h] 1284: Reset 221 | [h] 1285: UT Motion Config 222 | ``` 223 | 224 | 225 | -------------------------------------------------------------------------------- /assets/airtag_frida.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seemoo-lab/airtag/18a5a7d9738bf9f52c57340e6f1a207a320304e3/assets/airtag_frida.png -------------------------------------------------------------------------------- /scripts/DurianFirmware_extract.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Split Durian firmware blob, similar to this one: 3 | # https://gist.github.com/woachk/6092f9ae950455dcdf8428c3ce2d639e 4 | 5 | # Most likely called "SuperBinary image" in Apple terminology, 6 | # split by DurianUpdaterService. 7 | 8 | import sys 9 | import struct 10 | import os 11 | 12 | def get_image_info(ftab, base_offset): 13 | # seek at the occurence which is the name of the image 14 | # first image should be rkos 15 | ftab.seek(base_offset) 16 | tag = ftab.read(4).decode() 17 | 18 | # get address of image 19 | ftab.seek(base_offset + 12) 20 | offset = struct.unpack('= 18.2 different offsets will be used 129 | 130 | this.log_verbose = true; // switch logging verbosity 131 | 132 | // global vars for the current device 133 | this.durian_service; 134 | this.durian_client; 135 | this.durian_device; 136 | } 137 | 138 | 139 | /* 140 | Script preparation, needs to be called in standalone usage. 141 | Separated from constructor for external script usage. 142 | */ 143 | prepare() { 144 | 145 | var self = this; 146 | 147 | // some basic addresses 148 | self._locationd_base = Module.getBaseAddress('locationd'); 149 | 150 | // Set the correct symbols 151 | self.setSymbols(self.ios_version); 152 | 153 | // hook sent/received bytes and show them 154 | self.showBytes(); 155 | self.decodeIncomingBytes(); 156 | 157 | // hook into exiting global vars 158 | self.hookServiceClientDevice(); 159 | 160 | // print opcode list 161 | //self.printOpcodes(); 162 | } 163 | 164 | // Backtrace helper function 165 | print_backtrace(ctx) { 166 | console.log('Backtrace:\n' + 167 | Thread.backtrace(ctx, Backtracer.ACCURATE) 168 | .map(DebugSymbol.fromAddress).join('\n') + '\n'); 169 | } 170 | 171 | 172 | /* 173 | Show raw bytes as sent/received within locationd. Already misses the L2CAP metadata. 174 | */ 175 | showBytes() { 176 | var self = this; 177 | 178 | var {CLDurianDeviceManager} = ObjC.classes; 179 | 180 | Interceptor.attach(CLDurianDeviceManager['- centralManager:didSendBytes:toPeripheral:withError:'].implementation, { 181 | onEnter: function(args) { 182 | console.log(" * Sent bytes!"); 183 | } 184 | }); 185 | 186 | Interceptor.attach(CLDurianDeviceManager['- centralManager:didReceiveData:fromPeripheral:'].implementation, { 187 | onEnter: function(args) { 188 | //self.print_backtrace(this.ctx); 189 | 190 | var len = args[3].add(8).readU32(); 191 | var op = args[3].add(0x10).readU8(); 192 | console.log(" * Received " + len + " bytes! Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 193 | 194 | // log acknowledged opcode 195 | if (op == 0) { 196 | op = args[3].add(0x11).readU8(); 197 | console.log(" > ACKed Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 198 | } 199 | else if (op == 255) { 200 | op = args[3].add(0x11).readU8(); 201 | console.log(" > NACKed Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 202 | } 203 | 204 | // also log raw bytes 205 | if (self.log_verbose) { 206 | console.log(args[3].add(0x10).readByteArray(len)); 207 | } 208 | } 209 | }); 210 | 211 | } 212 | 213 | /* 214 | Called via -[CLDurianTask opcodeDescription]. 215 | Opcode as integer. 216 | */ 217 | getDurianOpcodeDescription(opcode) { 218 | var self = this; 219 | 220 | // "Unknown" opcodes we actually know 221 | if (opcode == 255) { 222 | return "NACK"; 223 | } 224 | else if (opcode == 9) { 225 | return "Playing Sound" 226 | } 227 | 228 | var getOpcodeString = new NativeFunction(self._DurianOpcodeDescription.sign(), 'pointer', ['int64']); 229 | var desc = new ObjC.Object(getOpcodeString(opcode)); 230 | return desc.toString(); 231 | } 232 | 233 | getHawkeyeOpcodeDescription(opcode) { 234 | var self = this; 235 | var getOpcodeString = new NativeFunction(self._HawkeyeOpcodeDescription.sign(), 'pointer', ['int64']); 236 | var desc = new ObjC.Object(getOpcodeString(opcode)); 237 | return desc.toString(); 238 | } 239 | 240 | /* 241 | Prints a list of opcodes. 242 | */ 243 | printOpcodes() { 244 | 245 | var self = this; 246 | 247 | console.log("Durian opcode list: ") 248 | for (let op = 0; op <= 255; op++) { 249 | var desc = self.getDurianOpcodeDescription(op); 250 | if ("Unknown".localeCompare(desc) != 0) { 251 | console.log("[d] " + op + ": " + desc); 252 | } 253 | } 254 | 255 | console.log("Hawkeye opcode list: ") 256 | for (let op = 512; op <= 1300; op++) { //not iterating the full 2 bytes here 257 | var desc = self.getHawkeyeOpcodeDescription(op); 258 | if ("Unknown".localeCompare(desc) != 0) { 259 | console.log("[h] " + op + ": " + desc); 260 | } 261 | } 262 | } 263 | 264 | decodeIncomingBytes() { 265 | 266 | var self = this; 267 | 268 | // -[CLDurianTask initWithCommand:desiredLatency:expectsResponse:completeOnPreemption:requiresMutex:] 269 | var {CLDurianTask} = ObjC.classes; 270 | Interceptor.attach(CLDurianTask['- initWithCommand:desiredLatency:expectsResponse:completeOnPreemption:requiresMutex:'].implementation, { 271 | onEnter: function(args) { 272 | 273 | console.log(" * CLDurianTask init"); 274 | //console.log(args[0].readByteArray(0x10)); self 275 | var task = new ObjC.Object(args[0]); // CLDurianTask 276 | //console.log(" v " + task.toString()); // not helpful, uninitialized task is always an Acknowledgment task 277 | //console.log(args[1].readByteArray(0x10)); SEL: 'initWithCommand:' ... 278 | 279 | if (args[2] != 0) { // opcode can be 0, resulting in an access violation 280 | var opcode = args[2].add(8).readInt(); // direct access to the opcode via the CLDurianCommand w/o ObjC API 281 | console.log(" > opcode: " + opcode + " (" + self.getDurianOpcodeDescription(opcode) + ")"); // command is 0: super + 8: opcode + 0x10: payload 282 | console.log(" > desiredLatency: " + args[3]); // latency can also be negative 283 | console.log(" > expectsResponse: " + args[4]); 284 | console.log(" > completeOnPreemption: " + args[5]); 285 | console.log(" > requiresMutex: " + args[6]); 286 | // logging payload but length is defined by opcode so we don't know it here 287 | //console.log(args[2].add(0x10).readByteArray(0x10)); 288 | 289 | 290 | // The task is already initiated with a pre-filled DurianCommand. This has an uint8 opcode 291 | // as well as an NSData payload. The CLDurianCommand.-bytes returns both of these concatenated 292 | // to NSData. 293 | var command = new ObjC.Object(args[2]); // CLDurianCommand 294 | var {NSString} = ObjC.classes; 295 | var bytes = command['- bytes'].implementation(command, NSString['stringWithString:']('-bytes')); 296 | var b_len = bytes.add(8).readInt(); 297 | var raw_bytes = bytes.add(0x10).readByteArray(b_len); 298 | 299 | // also log raw bytes 300 | if (self.log_verbose) { 301 | console.log(raw_bytes); 302 | } 303 | 304 | //self.print_backtrace(this.ctx); 305 | } 306 | 307 | } 308 | }); 309 | 310 | var {CLDurianDevice} = ObjC.classes; 311 | 312 | // -[CLDurianDevice executeTask:](CLDurianDevice *self, SEL a2, id task) 313 | Interceptor.attach(CLDurianDevice['- executeTask:'].implementation, { 314 | onEnter: function(args) { 315 | console.log(" * CLDurianDevice executeTask"); 316 | //self.print_backtrace(this.ctx); 317 | 318 | } 319 | }); 320 | 321 | } 322 | 323 | /* 324 | We reuse these pointers so that we don't need to create a service. 325 | */ 326 | hookServiceClientDevice() { 327 | var self = this; 328 | 329 | var {CLDurianService} = ObjC.classes; 330 | Interceptor.attach(CLDurianService['- performTask:forClient:onDevice:'].implementation, { 331 | onEnter: function(args) { 332 | if (! self.durian_device) { 333 | console.log(" * Setting DurianService, DurianClient, DurianDevice..."); 334 | self.durian_service = args[0]; 335 | self.durian_client = args[3]; 336 | self.durian_device = args[4]; 337 | } 338 | else if (parseInt(self.durian_service) != parseInt(args[0]) || parseInt(self.durian_client) != parseInt(args[3]) || parseInt(self.durian_device) != parseInt(args[4])) { 339 | console.log(" * Updating DurianService, DurianClient, DurianDevice, values changed!"); 340 | self.durian_service = args[0]; 341 | self.durian_client = args[3]; 342 | self.durian_device = args[4]; 343 | } 344 | else { 345 | console.log(" - Performing a task, DurianService unchanged..."); 346 | } 347 | } 348 | }); 349 | } 350 | 351 | 352 | /* 353 | [CLDurianService dumpLogsOfType:forTag:forClient:] dumps logs, however, we can just create a task 354 | and then call -[CLDurianService performTask:forClient:onDevice:] ourselves. 355 | 356 | Types: 0 NordicLogs, 1 NordicCrashes, 2 RoseLogs, 3 RoseCrashes 357 | 358 | FIXME AirTag-side bug? Stops responding after this! 359 | */ 360 | dumpLogs() { 361 | var self = this; 362 | self.performTaskByName('dumpNordicLogsTask'); 363 | self.performTaskByName('dumpNordicCrashesTask'); 364 | self.performTaskByName('dumpRoseLogsTask'); 365 | self.performTaskByName('dumpRoseCrashesTask'); 366 | } 367 | 368 | /* 369 | Four test modes and a default defined for Hawkeye: 370 | 1 - fetchMultiStatus 371 | 2 - setNearOwnerTimeout 372 | 3+4 - stopSoundHawkeye 373 | default - nil task 374 | */ 375 | testMode() { 376 | var self = this; 377 | var name = 'testModeTask:'; 378 | var {CLDurianTask} = ObjC.classes; 379 | var {NSString} = ObjC.classes; 380 | var taskByName = CLDurianTask[name]; 381 | // last task parameter is type 382 | var task = taskByName.implementation(CLDurianTask, NSString['stringWithString:'](name), 2); 383 | 384 | self.runTask(task); 385 | } 386 | 387 | /* 388 | Rose task is initialized with an 11 byte payload. 389 | Looked the same over multiple measurement rounds, might configure the remote MAC addr or 390 | similar. 391 | */ 392 | initRoseWithParameters() { 393 | var self = this; 394 | 395 | // create Rose parameters 396 | // TODO reverse-engineer parameters, this one is taken from another log 397 | var params = self.allocNSData([0x0a, 0xe4, 0x97, 0xac, 0x4b, 0x6a, 0x02, 0x4e, 0xc5, 0x01, 0x01]); 398 | 399 | // last task parameter is payload appended to the opcode 400 | var task = self.createTaskByNameWithArg('initRoseTaskWithParameters:', params); 401 | self.runTask(task); 402 | 403 | } 404 | 405 | /* 406 | Rose ranging is then configured. 407 | This is called twice in an original log. 408 | */ 409 | setRoseRangingParameters() { 410 | var self = this; 411 | 412 | // create Rose ranging parameters 413 | // TODO reverse-engineer parameters, this one is taken from another log 414 | var params = self.allocNSData([0x00, 0x00, 0x01, 0xa5, 0x44, 0x00, 0x00, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00]); 415 | 416 | // last task parameter is payload appended to the opcode 417 | var task = self.createTaskByNameWithArg('setRoseRangingParametersTaskWithParameters:', params); 418 | self.runTask(task); 419 | 420 | // create Rose ranging parameters, round #2 421 | // needs a fresh object, is NACKed otherwise... 422 | // TODO reverse-engineer parameters, this one is taken from another log 423 | var params2 = self.allocNSData([0x04, 0x00, 0x00, 0x00, 0x00]); 424 | 425 | // last task parameter is payload appended to the opcode 426 | var task2 = self.createTaskByNameWithArg('setRoseRangingParametersTaskWithParameters:', params); 427 | self.runTask(task2); 428 | 429 | } 430 | 431 | /* 432 | Finally: start the actual Rose ranging 433 | */ 434 | startRoseRanging() { 435 | var self = this; 436 | 437 | // create Rose ranging parameters 438 | // TODO reverse-engineer parameters, this one is taken from another log 439 | var params = self.allocNSData([0x52, 0x00]); 440 | 441 | // last task parameter is payload appended to the opcode 442 | var task = self.createTaskByNameWithArg('startRoseRangingTaskWithParameters:', params); 443 | self.runTask(task); 444 | } 445 | 446 | /* 447 | Call all Rose stuff in a row. 448 | */ 449 | testRose() { 450 | var self = this; 451 | self.initRoseWithParameters(); 452 | self.setRoseRangingParameters(); 453 | self.startRoseRanging(); 454 | } 455 | 456 | /* 457 | Play Sound Sequence but with custom params. 458 | 459 | * Parameter format as follows: valid sound sequences are 4-byte dwords 460 | [0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00]: play sound 1 once 461 | [0x01, 0x04, 0x00, 0x00, 0x01, 0x04, 0x00, 0x00]: play sound 1 4x 462 | [0x02, 0x01, 0x23, 0x00] -> 1st byte is sound (0-7), 2nd byte is repetitions, 3rd byte is length, 4th byte is pause to next sound 463 | 464 | */ 465 | playSound() { 466 | var self = this; 467 | 468 | var startSoundTask = ""; 469 | if (parseFloat(this.ios_version.split("_")[1]) >= 18.2) { 470 | startSoundTask = "startSoundSequenceTaskWithEncodedSequence:"; 471 | } else { 472 | startSoundTask = "startSoundSequenceTaskWithSequence:"; 473 | } 474 | // create sound sequence 475 | // parameter format as follows: valid sound sequences are 4-byte dwords, 0x104 and 0x205 for the default sound sequence 476 | //var params = self.allocNSData([0x04, 0x01, 0x00, 0x00, 0x05, 0x02, 0x00, 0x00]); // original 477 | var params = self.allocNSData([0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // 2x short round of beeps 478 | 479 | // last task parameter is payload appended to the opcode 480 | var task = self.createTaskByNameWithArg(startSoundTask, params); 481 | self.runTask(task); 482 | 483 | } 484 | 485 | playSoundSequence(sequence) { 486 | var self = this; 487 | var startSoundTask = ""; 488 | if (parseFloat(this.ios_version.split("_")[1]) >= 18.2) { 489 | startSoundTask = "startSoundSequenceTaskWithEncodedSequence:"; 490 | } else { 491 | startSoundTask = "startSoundSequenceTaskWithSequence:"; 492 | } 493 | var params = self.allocNSData(sequence); 494 | var task = self.createTaskByNameWithArg(startSoundTask, params); 495 | self.runTask(task); 496 | 497 | } 498 | 499 | /* 500 | NSData helper function required to allocate parameters in CLDurianCommands. 501 | Takes input array in the form [1, 2, 3, 4, ...] as passed by JavaScript. 502 | */ 503 | allocNSData(bytes) { 504 | // alloc memory for raw bytes 505 | var params_raw = Memory.alloc(bytes.length); 506 | params_raw.writeByteArray(bytes); 507 | 508 | // NSData['- initWithBytes:length:'] 509 | var {NSData} = ObjC.classes; 510 | return NSData.alloc().initWithBytes_length_(params_raw, bytes.length); 511 | } 512 | 513 | 514 | /* 515 | Creates a task (CLDurianTask) only by its name. Works for tasks with only 2 arguments (self + name). 516 | Tested with: 517 | * getSerialNumberTask 518 | * dumpRoseLogsTask (does not work bc of state) 519 | */ 520 | createTaskByName(name) { 521 | var {CLDurianTask} = ObjC.classes; 522 | var {NSString} = ObjC.classes; 523 | var taskByName = CLDurianTask[name]; 524 | return taskByName.implementation(CLDurianTask, NSString['stringWithString:'](name)); 525 | } 526 | 527 | createTaskByNameWithArg(name, arg) { 528 | var {CLDurianTask} = ObjC.classes; 529 | var {NSString} = ObjC.classes; 530 | var taskByName = CLDurianTask[name]; 531 | return taskByName.implementation(CLDurianTask, NSString['stringWithString:'](name), arg); 532 | } 533 | 534 | /* 535 | Runs a task. 536 | */ 537 | runTask(task) { 538 | var self = this; 539 | 540 | if (self.durian_service) { 541 | 542 | console.log(" * Scheduling task..."); 543 | 544 | // -[CLDurianService performTask:forClient:onDevice:] 545 | var {CLDurianService} = ObjC.classes; 546 | var _CLDurianService_performTask_forClient_onDevice = CLDurianService['- performTask:forClient:onDevice:'].implementation; 547 | 548 | _CLDurianService_performTask_forClient_onDevice( 549 | self.durian_service, // CLDurianService *self 550 | Memory.allocUtf8String("performTask:forClient:onDevice:"), // SEL 551 | task, 552 | self.durian_client, 553 | self.durian_device, 554 | ); 555 | 556 | console.log(" * Scheduled task."); 557 | 558 | } else { 559 | console.log(' ! DurianService not set! Try playing a sound on your AirTag.'); 560 | } 561 | 562 | } 563 | 564 | /* 565 | Call task by its name, i.e., 'dumpRoseLogsTask'. 566 | Does not pass any parameters! 567 | */ 568 | performTaskByName(name) { 569 | var self = this; 570 | var task = self.createTaskByName(name); 571 | self.runTask(task); 572 | } 573 | 574 | /* 575 | Same but with one argument (for those that end with :). 576 | Argument types are not defined, though! 577 | */ 578 | performTaskByNameWithArg(name, arg) { 579 | var self = this; 580 | var task = self.createTaskByNameWithArg(name, arg); 581 | self.runTask(task); 582 | } 583 | 584 | /* 585 | Create a custom task. 586 | Requires creating a CLDurianCommand with a custom data (byte array) first, and then using this 587 | to initialize a new CLDurianTask. 588 | 589 | The -[CLDurianCommand initWithData:] will split NSData into opcode (first byte) and payload. 590 | */ 591 | performTaskWithCommand(data) { 592 | var self = this; 593 | 594 | // create custom command 595 | var {CLDurianCommand} = ObjC.classes; 596 | var command = CLDurianCommand.alloc().initWithData_(self.allocNSData(data)); 597 | 598 | // create and run task 599 | // TODO if needed, adjust latency, response, complete, mutex params here... depends a lot on the use case 600 | var {CLDurianTask} = ObjC.classes; 601 | var task = CLDurianTask.alloc().initWithCommand_desiredLatency_expectsResponse_completeOnPreemption_requiresMutex_(command, 1, 0, 0, 1); 602 | self.runTask(task); 603 | } 604 | 605 | 606 | /* 607 | Version-specific symbols, needs to be adjusted for every version. 608 | 609 | Currently using some automatic detection, might need adjustments for arm64 vs arm64e. 610 | */ 611 | setSymbols(ios_version) { 612 | 613 | console.log(" * Automatically detecting symbols..."); 614 | var self = this; 615 | 616 | var {CLDurianTask} = ObjC.classes; 617 | var opcodeDescription = new NativePointer(CLDurianTask['- opcodeDescription'].implementation); 618 | 619 | // check if we have PAC assembly for hacks below, starts with PACIBSP 620 | var is_pac = false; 621 | if ("pacibsp".localeCompare(Instruction.parse(opcodeDescription).mnemonic) == 0) { 622 | is_pac = true; 623 | console.log(" > Determining symbols with PAC enabled."); 624 | } 625 | 626 | /* 627 | At these offsets in -[CLDurianTask opcodeDescription] and -[CLHawkeyeTask opcodeDescription] 628 | there's a branch instruction to the function we need 629 | */ 630 | var durianOffset = 0; 631 | var hawkeyeOffset = 0; 632 | if (! is_pac) { 633 | // the offsets for newer iOS versions are slightly different 634 | if (parseFloat(ios_version.split("_")[1]) >= 18.2) { 635 | durianOffset = 20; 636 | hawkeyeOffset = 16; 637 | } else { 638 | durianOffset = 28; 639 | hawkeyeOffset = 24; 640 | } 641 | } else { 642 | // offsets for PAC assembly 643 | durianOffset = 48; 644 | hawkeyeOffset = 44; 645 | } 646 | 647 | self._DurianOpcodeDescription = new NativePointer(Instruction.parse(opcodeDescription.add(durianOffset)).operands.pop().value); 648 | console.log(" > _DurianOpcodeDesription " + self._DurianOpcodeDescription); 649 | 650 | 651 | var {CLHawkeyeTask} = ObjC.classes; 652 | var opcodeDescription = new NativePointer(CLHawkeyeTask['- opcodeDescription'].implementation); 653 | self._HawkeyeOpcodeDescription = new NativePointer(Instruction.parse(opcodeDescription.add(hawkeyeOffset)).operands.pop().value); 654 | console.log(" > _HawkeyeOpcodeDescription " + self._HawkeyeOpcodeDescription); 655 | } 656 | 657 | 658 | // Export class methods for Frida 659 | // TODO not used with an external script yet... 660 | makeExports() { 661 | var self = this; 662 | return { 663 | setsymbols: (ios_version) => {return self.setSymbols(ios_version)}, 664 | prepare: () => {return self.prepare()}, 665 | } 666 | } 667 | 668 | } 669 | 670 | var d = new Durian(); 671 | 672 | // Prepare the script 673 | d.prepare(); //TODO call this when standalone 674 | 675 | // Required to interact with Python ... 676 | rpc.exports = d.makeExports(); 677 | rpc.exports.d = Durian; 678 | -------------------------------------------------------------------------------- /scripts/update/hook_durian_update_fud.js: -------------------------------------------------------------------------------- 1 | /* 2 | Hooking into fud which does the personalization request 3 | 4 | Attach as follows: 5 | 6 | while true; do frida -U fud --no-pause -l hook_durian_update_fud.js; sleep 1; done 7 | 8 | TODO also overwrite the ftab.bin file! 9 | 10 | 11 | */ 12 | 13 | 14 | class Durian { 15 | 16 | constructor() { 17 | 18 | /*** INITIALIZE SCRIPT ***/ 19 | this.ios_version = "arm64_14.7"; // adjust iOS version here! (nothing version-specificc so far...) 20 | 21 | /* 22 | We want to fake this. The digests are SHA384 over rkos and sbd1 in the ftab.bin file. 23 | From the 3rd update: 24 | 25 | "Rap,RTKitOS" = { 26 | Digest = {length = 48, bytes = 0x08ce15c2 0f4f9a29 20a064cd 4dc08477 ... d317d5c7 2af38a40 }; 27 | EPRO = 1; 28 | ESEC = 1; 29 | Trusted = 1; 30 | }; 31 | "Rap,SoftwareBinaryDsp1" = { 32 | Digest = {length = 48, bytes = 0xb7ef1b89 53103c04 37dd4807 58733707 ... 994c0a80 bd036588 }; 33 | EPRO = 1; 34 | ESEC = 1; 35 | Trusted = 1; 36 | }; 37 | */ 38 | 39 | // TODO uncomment the following if you want to overwrite the values generated by the regular daemon 40 | // values from the 1st update (1.0.276 1A276d 3 Jun 2021 ae34f4b8aec8a4d4562227109be101728b7bef20) 41 | // TSS validates sha384 sums, no arbitrary values 42 | 43 | this.durian_digest_rtkitos = [0x1f, 0xcb, 0x05, 0xb3, 0x77, 0xeb, 0x40, 0x5e, 0xef, 0xfc, 0x5a, 0xd6, 0x0e, 0xfc, 44 | 0xe6, 0xae, 0xed, 0x3b, 0x83, 0xd8, 0x34, 0xe0, 0x40, 0x3b, 0xd8, 0x8a, 0x14, 0x2d, 0x84, 0xc6, 0x08, 0x2e, 0xa6, 45 | 0xc6, 0x49, 0xeb, 0xf1, 0x4a, 0xe0, 0x5b, 0x1a, 0x87, 0xd1, 0x59, 0xe9, 0xdc, 0x16, 0x7c]; 46 | this.durian_digest_dsp = [0x92, 0x8a, 0x22, 0x6b, 0x85, 0xb5, 0x2c, 0x75, 0xf0, 0x7f, 0xb3, 0xcd, 0x89, 0xf1, 47 | 0xc3, 0x8a, 0x78, 0x3b, 0xb9, 0x83, 0x4d, 0xe6, 0x47, 0x40, 0x7b, 0x93, 0x5a, 0x95, 0x23, 0x59, 0xd3, 0x6b, 0x24 48 | 0x3a, 0x58, 0xfa, 0x43, 0xa1, 0x72, 0xd1, 0xe3, 0x9c, 0x3d, 0x43, 0x2d, 0x1a, 0x30, 0x30]; 49 | 50 | this.durian_trusted = 1; 51 | this.durian_production_mode = 1; 52 | this.durian_security_mode = 1; 53 | 54 | 55 | this.object_info; 56 | 57 | 58 | } 59 | 60 | 61 | 62 | /* 63 | Script preparation, needs to be called in standalone usage. 64 | Separated from constructor for external script usage. 65 | */ 66 | prepare() { 67 | 68 | var self = this; 69 | 70 | // sha384sum and ecid applied here 71 | self.hookPersonalization(); 72 | 73 | // just overwrite the ftab 74 | self.hookFTAB(); 75 | 76 | } 77 | 78 | // Backtrace helper function 79 | print_backtrace(ctx) { 80 | console.log('Backtrace:\n' + 81 | Thread.backtrace(ctx, Backtracer.ACCURATE) 82 | .map(DebugSymbol.fromAddress).join('\n') + '\n'); 83 | } 84 | 85 | // Helper function to print hex 86 | print_hex(byte_array) { 87 | var bytes_string = ""; 88 | for (var i = 0; i < byte_array.length; i+=1) { 89 | bytes_string += ("00" + byte_array[i].toString(16)).substr(-2); 90 | } 91 | console.log('\t' + bytes_string); 92 | } 93 | 94 | 95 | hookPersonalization() { 96 | 97 | var {FudPersonalizationObjectInfo} = ObjC.classes; 98 | var {FudPersonalizer} = ObjC.classes; 99 | var {CFDictionarySetValue} = ObjC.classes; 100 | var {CFSTR} = ObjC.classes; 101 | var self = this; 102 | 103 | 104 | /* 105 | Input: FudPersonalizationObjectInfo, already contains all info 106 | Output: CFDictionarySetValue 107 | */ 108 | Interceptor.attach(FudPersonalizer['- createPersonalizationObjectCFDict:'].implementation, { 109 | onEnter: function(args) { 110 | console.log(" * createPersonalizationObjectCFDict"); 111 | self.object_info = new ObjC.Object(args[2]); // FudPersonalizationObjectInfo 112 | console.log(self.object_info); // 113 | 114 | 115 | var tag = self.object_info.objectTag().toString(); 116 | console.log(tag); // Rap,SoftwareBinaryDsp1 or Rap,RTKitOS 117 | console.log(self.object_info.digest()); // {length = 48, bytes = 0xb7ef1b89 53103c04 37dd4807 58733707 ... 994c0a80 bd036588 } 118 | 119 | // -[FudPersonalizationObjectInfo setDigest:] 120 | if (self.durian_digest_rtkitos && tag.localeCompare('Rap,RTKitOS') == 0 ) { 121 | self.object_info.setDigest_(self.allocNSData(self.durian_digest_rtkitos)); 122 | } else if (self.durian_digest_dsp) { 123 | self.object_info.setDigest_(self.allocNSData(self.durian_digest_dsp)); 124 | } 125 | console.log(" ! Digest set."); 126 | console.log(self.object_info.digest()); 127 | 128 | // -[FudPersonalizationObjectInfo setTrusted:] 129 | // -[FudPersonalizationObjectInfo setEffectiveProductionMode:] 130 | // -[FudPersonalizationObjectInfo setEffectiveSecurityMode:] 131 | // all of these take a char as argument (IDA) but are boolean 132 | if (self.durian_trusted) { 133 | self.object_info.setTrusted_(self.durian_trusted); 134 | } 135 | if (self.durian_production_mode) { 136 | self.object_info.setEffectiveProductionMode_(self.durian_production_mode); 137 | } 138 | if (self.durian_security_mode) { 139 | self.object_info.setEffectiveSecurityMode_(self.durian_security_mode); 140 | } 141 | console.log(" ! Trusted, EffectiveProductionMode and EffectiveSecurityMode set."); 142 | 143 | } 144 | }); 145 | 146 | Interceptor.attach(FudPersonalizer['- createPersonalizationManifestCFDict:'].implementation, { 147 | onEnter: function(args) { 148 | console.log(" * createPersonalizationManifestCFDict"); 149 | }, 150 | onLeave: function(r) { 151 | console.log(" * Final manifest:"); 152 | console.log(new ObjC.Object(r)); 153 | 154 | } 155 | }); 156 | 157 | } 158 | 159 | 160 | /* 161 | Hooking the ftab.bin readout... but we seem to attach too late :( 162 | */ 163 | hookFTAB() { 164 | 165 | var {CLDurianMobileAssetUpdater} = ObjC.classes; 166 | var self = this; 167 | 168 | Interceptor.attach(CLDurianMobileAssetUpdater['- getFTAB:'].implementation, { 169 | onEnter: function(args) { 170 | console.log(" * CLDurianMobileAssetUpdater getFTAB"); 171 | }, 172 | onLeave: function(r) { 173 | console.log(new ObjC.Object(r)); 174 | 175 | } 176 | }); 177 | } 178 | 179 | 180 | 181 | /* 182 | NSData helper function required to allocate parameters in CLDurianCommands. 183 | Takes input array in the form [1, 2, 3, 4, ...] as passed by JavaScript. 184 | */ 185 | allocNSData(bytes) { 186 | // alloc memory for raw bytes 187 | var params_raw = Memory.alloc(bytes.length); 188 | params_raw.writeByteArray(bytes); 189 | 190 | // NSData['- initWithBytes:length:'] 191 | var {NSData} = ObjC.classes; 192 | return NSData.alloc().initWithBytes_length_(params_raw, bytes.length); 193 | } 194 | 195 | 196 | /* 197 | Version-specific symbols, needs to be adjusted for every version. 198 | */ 199 | setSymbols(ios_version) { 200 | 201 | var self = this; 202 | 203 | // tested on an iPhone 8 204 | if (ios_version == "arm64_14.7") { 205 | console.log(" * Set symbols to pre-A12 iOS 14.7"); 206 | 207 | } 208 | else { 209 | console.log(" ! undefined symbols"); 210 | } 211 | 212 | 213 | } 214 | 215 | 216 | // Export class methods for Frida 217 | // TODO not used with an external script yet... 218 | makeExports() { 219 | var self = this; 220 | return { 221 | setsymbols: (ios_version) => {return self.setSymbols(ios_version)}, 222 | prepare: () => {return self.prepare()}, 223 | } 224 | } 225 | 226 | } 227 | 228 | var d = new Durian(); 229 | 230 | // Prepare the target function 231 | d.prepare(); //TODO call this when standalone 232 | 233 | // Required to interact with Python ... 234 | // Yep even the standalone fuzzer should use this because calling the fuzzer 235 | // directly will timeout on large payloads 236 | rpc.exports = d.makeExports(); 237 | rpc.exports.d = Durian; 238 | -------------------------------------------------------------------------------- /scripts/update/hook_durian_update_locationd.js: -------------------------------------------------------------------------------- 1 | /* 2 | Hooking into locationd, which handles most AirTag interaction. 3 | 4 | Attach as follows: 5 | 6 | frida -U locationd --no-pause -l hook_durian_update_locationd.js 7 | 8 | To set the DurianService etc. to call task, play a sound on the AirTag once. 9 | 10 | 11 | */ 12 | 13 | 14 | class Durian { 15 | 16 | constructor() { 17 | 18 | /*** INITIALIZE SCRIPT ***/ 19 | this.ios_version = "arm64_18.2"; // if iOS version is >= 18.2 different offsets will be used 20 | 21 | // intercepted from a packet log, starts with L2CAP 91 15 07 22 | this.durian_version_bytes = [0x13, 0x10, 0x0e, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x0f, 0x00]; //1.0.225 23 | this.durian_version_string = '1.0.225'; // matching string 24 | //this.durian_version_bytes = [0x13, 0x40, 0x11, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02 0f 00]; //1.0.276 25 | // ^-0x114 = 276 26 | //this.durian_version_string = '1.0.276'; 27 | this.durian_serial = 'TROLOLOLOLOL'; 28 | 29 | 30 | this.log_verbose = false; // switch logging verbosity 31 | 32 | // global vars for the current device 33 | this.durian_service; 34 | this.durian_client; 35 | this.durian_device; 36 | 37 | // global vars for the injected firmware 38 | this.replace_firmware = {}; 39 | 40 | } 41 | 42 | 43 | 44 | /* 45 | Script preparation, needs to be called in standalone usage. 46 | Separated from constructor for external script usage. 47 | */ 48 | prepare() { 49 | 50 | var self = this; 51 | 52 | // some basic addresses 53 | self._locationd_base = Module.getBaseAddress('locationd'); 54 | self._CoreBluetooth_base = Module.getBaseAddress('CoreBluetooth'); 55 | self._objc_msgSend_addr = Module.getExportByName('libobjc.A.dylib', 'objc_msgSend'); 56 | self._objc_msgSend = new NativeFunction(self._objc_msgSend_addr, 'pointer', ['pointer', 'pointer']); 57 | 58 | // Set the correct symbols 59 | self.setSymbols(self.ios_version); 60 | 61 | // hook sent/received bytes and show them 62 | self.showBytes(); // this one changes the version bytes in L2CAP 63 | self.debugStuff(); 64 | 65 | // hook into exiting global vars 66 | self.hookServiceClientDevice(); 67 | 68 | // also overwrite the version in a few more places 69 | self.overwriteSPBeacons(); 70 | self.interceptFirmwareUpdate(); 71 | 72 | // modify the maximul packet size 73 | self.setPacketSize(); 74 | 75 | // print opcode list 76 | //self.printOpcodes(); 77 | } 78 | 79 | // Backtrace helper function 80 | print_backtrace(ctx) { 81 | console.log('Backtrace:\n' + 82 | Thread.backtrace(ctx, Backtracer.ACCURATE) 83 | .map(DebugSymbol.fromAddress).join('\n') + '\n'); 84 | } 85 | 86 | // Helper function to print hex 87 | print_hex(byte_array) { 88 | var bytes_string = ""; 89 | for (var i = 0; i < byte_array.length; i+=1) { 90 | bytes_string += ("00" + byte_array[i].toString(16)).substr(-2); 91 | } 92 | console.log('\t' + bytes_string); 93 | } 94 | 95 | 96 | 97 | /* 98 | Show raw bytes as sent/received within locationd. Already misses the L2CAP metadata. 99 | */ 100 | showBytes() { 101 | var self = this; 102 | 103 | var {CLDurianDeviceManager} = ObjC.classes; 104 | 105 | Interceptor.attach(CLDurianDeviceManager['- centralManager:didSendBytes:toPeripheral:withError:'].implementation, { 106 | onEnter: function(args) { 107 | if (self.log_verbose) { 108 | console.log(" * Sent bytes!"); 109 | } 110 | 111 | // precise bytes are logged via tasks, not needed here 112 | 113 | } 114 | }); 115 | 116 | Interceptor.attach(CLDurianDeviceManager['- centralManager:didReceiveData:fromPeripheral:'].implementation, { 117 | onEnter: function(args) { 118 | //self.print_backtrace(this.ctx); 119 | 120 | var len = args[3].add(8).readU32(); 121 | var op = args[3].add(0x10).readU8(); 122 | console.log(" * Received " + len + " bytes! Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 123 | 124 | if (op == 7 && len > 2) { // overwrite replies with a version payload 125 | console.log(" ! OVERWRITING FIRMWARE VERSION REPLY"); 126 | args[3].add(0x11).writeByteArray(self.durian_version_bytes); 127 | } 128 | 129 | // log acknowledged opcode 130 | if (op == 0) { 131 | op = args[3].add(0x11).readU8(); 132 | console.log(" > ACKed Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 133 | } 134 | else if (op == 255) { 135 | op = args[3].add(0x11).readU8(); 136 | console.log(" > NACKed Opcode " + op + " (" + self.getDurianOpcodeDescription(op) + ")"); 137 | } 138 | 139 | if (self.log_verbose) { 140 | console.log(args[3].add(0x10).readByteArray(len)); 141 | } 142 | 143 | } 144 | }); 145 | 146 | } 147 | 148 | /* 149 | Called via -[CLDurianTask opcodeDescription]. 150 | Opcode as integer. 151 | */ 152 | getDurianOpcodeDescription(opcode) { 153 | var self = this; 154 | 155 | // "Unknown" opcodes we actually know 156 | if (opcode == 255) { 157 | return "NACK"; 158 | } 159 | else if (opcode == 9) { 160 | return "Playing Sound" 161 | } 162 | 163 | var getOpcodeString = new NativeFunction(self._DurianOpcodeDescription.sign(), 'pointer', ['int64']); 164 | var desc = new ObjC.Object(getOpcodeString(opcode)); 165 | return desc.toString(); 166 | } 167 | 168 | getHawkeyeOpcodeDescription(opcode) { 169 | var self = this; 170 | var getOpcodeString = new NativeFunction(self._HawkeyeOpcodeDescription.sign(), 'pointer', ['int64']); 171 | var desc = new ObjC.Object(getOpcodeString(opcode)); 172 | return desc.toString(); 173 | } 174 | 175 | /* 176 | Prints a list of opcodes. 177 | */ 178 | printOpcodes() { 179 | 180 | var self = this; 181 | 182 | console.log("Durian opcode list: ") 183 | for (let op = 0; op <= 255; op++) { 184 | var desc = self.getDurianOpcodeDescription(op); 185 | if ("Unknown".localeCompare(desc) != 0) { 186 | console.log("[d] " + op + ": " + desc); 187 | } 188 | } 189 | 190 | console.log("Hawkeye opcode list: ") 191 | for (let op = 512; op <= 1300; op++) { //not iterating the full 2 bytes here 192 | var desc = self.getHawkeyeOpcodeDescription(op); 193 | if ("Unknown".localeCompare(desc) != 0) { 194 | console.log("[h] " + op + ": " + desc); 195 | } 196 | } 197 | } 198 | 199 | debugStuff() { 200 | 201 | var self = this; 202 | 203 | // -[CLDurianTask initWithCommand:desiredLatency:expectsResponse:completeOnPreemption:requiresMutex:] 204 | var {CLDurianTask} = ObjC.classes; 205 | Interceptor.attach(CLDurianTask['- initWithCommand:desiredLatency:expectsResponse:completeOnPreemption:requiresMutex:'].implementation, { 206 | onEnter: function(args) { 207 | if (self.log_verbose) { 208 | console.log(" * CLDurianTask init"); 209 | } 210 | //console.log(args[0].readByteArray(0x10)); self 211 | var task = new ObjC.Object(args[0]); // CLDurianTask 212 | //console.log(" v " + task.toString()); // not helpful, uninitialized task is always an Acknowledgment task 213 | //console.log(args[1].readByteArray(0x10)); SEL: 'initWithCommand:' ... 214 | 215 | if (args[2] != 0) { // opcode can be 0, resulting in an access violation 216 | var opcode = args[2].add(8).readInt(); // direct access to the opcode via the CLDurianCommand w/o ObjC API 217 | console.log(" > opcode: " + opcode + " (" + self.getDurianOpcodeDescription(opcode) + ")"); // command is 0: super + 8: opcode + 0x10: payload 218 | if (self.log_verbose) { 219 | console.log(" > desiredLatency: " + args[3]); // latency can also be negative 220 | console.log(" > expectsResponse: " + args[4]); 221 | console.log(" > completeOnPreemption: " + args[5]); 222 | console.log(" > requiresMutex: " + args[6]); 223 | // logging payload but length is defined by opcode so we don't know it here 224 | //console.log(args[2].add(0x10).readByteArray(0x10)); 225 | } 226 | 227 | // The task is already initiated with a pre-filled DurianCommand. This has an uint8 opcode 228 | // as well as an NSData payload. The CLDurianCommand.-bytes returns both of these concatenated 229 | // to NSData. 230 | var command = new ObjC.Object(args[2]); // CLDurianCommand 231 | var {NSString} = ObjC.classes; 232 | var bytes = command['- bytes'].implementation(command, NSString['stringWithString:']('-bytes')); 233 | var b_len = bytes.add(8).readInt(); 234 | var raw_bytes = bytes.add(0x10).readByteArray(b_len); 235 | 236 | if (self.log_verbose) { 237 | console.log(raw_bytes); 238 | } 239 | 240 | //self.print_backtrace(this.ctx); 241 | } 242 | 243 | } 244 | }); 245 | 246 | // -[CLDurianService playSoundSequence:onTag:forClient:](CLDurianService *self, SEL a2, id soundsequence, id ontag, id forclient) 247 | var {CLDurianService} = ObjC.classes; 248 | Interceptor.attach(CLDurianService['- playSoundSequence:onTag:forClient:'].implementation, { 249 | onEnter: function(args) { 250 | console.log(" * CLDurianService playSoundSequence"); 251 | //self.print_backtrace(this.ctx); 252 | } 253 | }); 254 | 255 | // +[CLDurianService performSyncOnSilo:invoker:] 256 | Interceptor.attach(CLDurianService.performSyncOnSilo_invoker_.implementation, { 257 | onEnter: function(args) { 258 | console.log(" * CLDurianService performSyncOnSilo"); 259 | //self.print_backtrace(this.ctx); 260 | } 261 | }); 262 | 263 | var {CLDurianDevice} = ObjC.classes; 264 | 265 | // -[CLDurianDevice executeTask:](CLDurianDevice *self, SEL a2, id task) 266 | Interceptor.attach(CLDurianDevice['- executeTask:'].implementation, { 267 | onEnter: function(args) { 268 | console.log(" * [" + Date() + "] CLDurianDevice executeTask"); 269 | //self.print_backtrace(this.ctx); 270 | 271 | } 272 | }); 273 | 274 | } 275 | 276 | 277 | 278 | /* 279 | Further hook needed to fake the version in the search party beacon. 280 | 281 | Every time a SPBeacon is created, all its setters are called: 282 | 283 | -[SPBeacon setIdentifier:0x149d8fb00] 284 | -[SPBeacon setModel:0x1fe7b4080] 285 | -[SPBeacon setShares:0x1fea03120] 286 | -[SPBeacon setSystemVersion:0xb19247ee008b482b] 287 | -[SPBeacon setVendorId:0x4c] 288 | -[SPBeacon setProductId:0x5500] 289 | - ... (and more) 290 | 291 | Since this is no plain copy but calls all these functions, we can hook the functions 292 | to set our own version. 293 | */ 294 | overwriteSPBeacons() { 295 | 296 | // SearchParty Beacon from searchpartyd, defined in SPOwner 297 | var {SPBeacon} = ObjC.classes; 298 | var {NSString} = ObjC.classes; 299 | var self = this; 300 | 301 | 302 | 303 | 304 | // all setters are called upon SPBeacon creation, overwrite these 305 | Interceptor.attach(SPBeacon['- setSystemVersion:'].implementation, { 306 | onEnter: function(args) { 307 | var version = new ObjC.Object(args[2]); 308 | args[2] = NSString['stringWithString:'](self.durian_version_string); 309 | version = new ObjC.Object(args[2]); 310 | if (self.log_verbose) { 311 | console.log(" > observed version: " + version); 312 | console.log(" > new version: " + version); 313 | } 314 | } 315 | }); 316 | 317 | Interceptor.attach(SPBeacon['- setSerialNumber:'].implementation, { 318 | onEnter: function(args) { 319 | var serial = new ObjC.Object(args[2]); 320 | args[2] = NSString['stringWithString:'](self.durian_serial); 321 | serial = new ObjC.Object(args[2]); 322 | if (self.log_verbose) { 323 | console.log(" > observed serial: " + serial); 324 | console.log(" > new serial: " + serial); 325 | } 326 | } 327 | }); 328 | } 329 | 330 | 331 | 332 | 333 | /* 334 | When fud and DurianUpdaterService are done, they just call locationd with the according asset 335 | URL. 336 | */ 337 | interceptFirmwareUpdate() { 338 | var self = this; 339 | 340 | // -[CLDurianService updateFirmwareForDevice:withAssetURL:forClient:] 341 | var {CLDurianService} = ObjC.classes; 342 | Interceptor.attach(CLDurianService['- updateFirmwareForDevice:withAssetURL:forClient:'].implementation, { 343 | onEnter: function(args) { 344 | console.log(' ! ENTERED FIRMWARE UPDATE'); 345 | // withAssetURL as ObjC prints: file:///private/var/MobileAsset/AssetsV2/com_apple_MobileAsset_MobileAccessoryUpdate_DurianFirmware/eba889b5f77e7aa5fb27e24adcf44b20a62c6dc1.asset/AssetData/DurianFirmware.acsw/ 346 | console.log(new ObjC.Object(args[3])); 347 | } 348 | }); 349 | 350 | // all sub binaries (blap.bin etc.) are sent separately by the asset packetizer 351 | var {CLDurianFirmwareAssetPacketizer} = ObjC.classes; 352 | Interceptor.attach(CLDurianFirmwareAssetPacketizer['- initWithAssetType:assetData:maxPacketSize:'].implementation, { 353 | onEnter: function(args) { 354 | var assetType = parseInt(args[2]); 355 | console.log(' * Creating new asset data packet with type ' + assetType); 356 | var assetData = new ObjC.Object(args[3]); 357 | console.log(assetData); //NSData 358 | 359 | //TODO 360 | // send to Python script for further processing 361 | //var blob = assetData.bytes().readByteArray(assetData.length()); 362 | //send({msgType: "asset", assetType: assetType}, blob); 363 | 364 | // also overwrite if asset is set 365 | if (self.replace_firmware[assetType]) { 366 | this.context.x3 = self.replace_firmware[assetType]; 367 | console.log(' ! FOUND BLOB FOR ' + assetType + ', REPLACED FIRMWARE!'); 368 | 369 | } 370 | } 371 | }); 372 | 373 | } 374 | 375 | /* 376 | Get more granularity into the update. (sloooow!) 377 | */ 378 | setPacketSize() { 379 | var self = this; 380 | 381 | // [CLDurianFirmwareAssetPacketizer initWithAssetType:assetData:maxPacketSize:] calls the function 382 | // -[CLDurianFirmwareAssetPacketizer setMaxPayloadSize:] and sets it to maxPacketSize - 6 (type + offset) 383 | var {CLDurianFirmwareAssetPacketizer} = ObjC.classes; 384 | Interceptor.attach(CLDurianFirmwareAssetPacketizer['- setMaxPayloadSize:'].implementation, { 385 | onEnter: function(args) { 386 | var packetizer = new ObjC.Object(args[0]); // CLDurianFirmwareAssetPacketizer 387 | var assetType = packetizer.assetType(); 388 | var limit = 4; // new payload size limit 389 | 390 | console.log(' * Original max payload size for asset type ' + assetType + ': ' + args[2]); 391 | 392 | // check for asset type as well, signatures should not be fragmented, only 393 | // the main binaries 394 | /* 395 | //if (assetType == 1 || assetType == 3 || assetType == 5) { 396 | if (assetType == 5) { // only blap.bin 397 | //if (assetType == 1) { // only bldr.bin 398 | 399 | this.context.x2 = limit; 400 | console.log(' ! Packet size set to ' + limit); 401 | } 402 | */ 403 | } 404 | }); 405 | } 406 | 407 | 408 | /* 409 | Call a firmware update. Requires that everything is already unpacked. 410 | Probably only useful for extensive firmware update testing. 411 | */ 412 | triggerFirmwareUpdate() { 413 | var self = this; 414 | var {CLDurianService} = ObjC.classes; 415 | var {NSURL} = ObjC.classes; 416 | 417 | var url = NSURL['fileURLWithPath:']('/private/var/MobileAsset/AssetsV2/com_apple_MobileAsset_MobileAccessoryUpdate_DurianFirmware/eba889b5f77e7aa5fb27e24adcf44b20a62c6dc1.asset/AssetData/DurianFirmware.acsw/'); 418 | console.log(url); 419 | // TODO causes an abort, doesn't work :( 420 | CLDurianService['- updateFirmwareForDevice:withAssetURL:forClient:'](self.durian_device, url, self.durian_client); 421 | } 422 | 423 | /* 424 | We reuse these pointers so that we don't need to create a service. 425 | */ 426 | hookServiceClientDevice() { 427 | var self = this; 428 | 429 | var {CLDurianService} = ObjC.classes; 430 | Interceptor.attach(CLDurianService['- performTask:forClient:onDevice:'].implementation, { 431 | onEnter: function(args) { 432 | if (! self.durian_device) { 433 | console.log(" * Setting DurianService, DurianClient, DurianDevice..."); 434 | self.durian_service = args[0]; 435 | self.durian_client = args[3]; 436 | self.durian_device = args[4]; 437 | } 438 | else if (parseInt(self.durian_service) != parseInt(args[0]) || parseInt(self.durian_client) != parseInt(args[3]) || parseInt(self.durian_device) != parseInt(args[4])) { 439 | console.log(" * Updating DurianService, DurianClient, DurianDevice, values changed!"); 440 | self.durian_service = args[0]; 441 | self.durian_client = args[3]; 442 | self.durian_device = args[4]; 443 | } 444 | else { 445 | console.log(" - Performing a task, DurianService unchanged..."); 446 | } 447 | } 448 | }); 449 | } 450 | 451 | 452 | 453 | /* 454 | NSData helper function required to allocate parameters in CLDurianCommands. 455 | Takes input array in the form [1, 2, 3, 4, ...] as passed by JavaScript. 456 | */ 457 | allocNSData(bytes) { 458 | // alloc memory for raw bytes 459 | var params_raw = Memory.alloc(bytes.length); 460 | params_raw.writeByteArray(bytes); 461 | 462 | // NSData['- initWithBytes:length:'] 463 | var {NSData} = ObjC.classes; 464 | return NSData.alloc().initWithBytes_length_(params_raw, bytes.length); 465 | } 466 | 467 | 468 | /* 469 | Creates a task (CLDurianTask) only by its name. Works for tasks with only 2 arguments (self + name). 470 | Tested with: 471 | * getSerialNumberTask 472 | * dumpRoseLogsTask (does not work bc of state) 473 | */ 474 | createTaskByName(name) { 475 | var {CLDurianTask} = ObjC.classes; 476 | var {NSString} = ObjC.classes; 477 | var taskByName = CLDurianTask[name]; 478 | return taskByName.implementation(CLDurianTask, NSString['stringWithString:'](name)); //TODO sometimes hangs? ObjC might still be wrong here! 479 | } 480 | 481 | createTaskByNameWithArg(name, arg) { 482 | var {CLDurianTask} = ObjC.classes; 483 | var {NSString} = ObjC.classes; 484 | var taskByName = CLDurianTask[name]; 485 | return taskByName.implementation(CLDurianTask, NSString['stringWithString:'](name), arg); //TODO could hang on wrong arg types but it's undefined 486 | } 487 | 488 | /* 489 | Runs a task. 490 | */ 491 | runTask(task) { 492 | var self = this; 493 | 494 | if (self.durian_service) { 495 | 496 | console.log(" * Scheduling task..."); 497 | 498 | // -[CLDurianService performTask:forClient:onDevice:] 499 | var {CLDurianService} = ObjC.classes; 500 | var _CLDurianService_performTask_forClient_onDevice = CLDurianService['- performTask:forClient:onDevice:'].implementation; 501 | 502 | _CLDurianService_performTask_forClient_onDevice( 503 | self.durian_service, // CLDurianService *self 504 | Memory.allocUtf8String("performTask:forClient:onDevice:"), // SEL 505 | task, 506 | self.durian_client, 507 | self.durian_device, 508 | ); 509 | 510 | console.log(" * Scheduled task."); 511 | 512 | } else { 513 | console.log(' ! DurianService not set! Try playing a sound on your AirTag.'); 514 | } 515 | 516 | } 517 | 518 | /* 519 | Call task by its name, i.e., 'dumpRoseLogsTask'. 520 | Does not pass any parameters! 521 | */ 522 | performTaskByName(name) { 523 | var self = this; 524 | var task = self.createTaskByName(name); 525 | self.runTask(task); 526 | } 527 | 528 | /* 529 | Same but with one argument (for those that end with :). 530 | Argument types are not defined, though! 531 | */ 532 | performTaskByNameWithArg(name, arg) { 533 | var self = this; 534 | var task = self.createTaskByNameWithArg(name, arg); 535 | self.runTask(task); 536 | } 537 | 538 | /* 539 | Create a custom task. 540 | Requires creating a CLDurianCommand with a custom data (byte array) first, and then using this 541 | to initialize a new CLDurianTask. 542 | 543 | The -[CLDurianCommand initWithData:] will split NSData into opcode (first byte) and payload. 544 | */ 545 | performTaskWithCommand(data) { 546 | var self = this; 547 | 548 | // create custom command 549 | var {CLDurianCommand} = ObjC.classes; 550 | var command = CLDurianCommand.alloc().initWithData_(self.allocNSData(data)); 551 | 552 | // create and run task 553 | // TODO if needed, adjust latency, response, complete, mutex params here... depends a lot on the use case 554 | var {CLDurianTask} = ObjC.classes; 555 | var task = CLDurianTask.alloc().initWithCommand_desiredLatency_expectsResponse_completeOnPreemption_requiresMutex_(command, 1, 0, 0, 1); 556 | self.runTask(task); 557 | } 558 | 559 | 560 | /* 561 | Version-specific symbols, needs to be adjusted for every version. 562 | */ 563 | setSymbols(ios_version) { 564 | 565 | console.log(" * Automatically detecting symbols..."); 566 | var self = this; 567 | 568 | // at offset 28 in -[CLDurianTask opcodeDescription] there's a branch instruction to the function we need 569 | var {CLDurianTask} = ObjC.classes; 570 | var opcodeDescription = new NativePointer(CLDurianTask['- opcodeDescription'].implementation); 571 | 572 | // check if we have PAC assembly for hacks below, starts with PACIBSP 573 | var is_pac = false; 574 | if ("pacibsp".localeCompare(Instruction.parse(opcodeDescription).mnemonic) == 0) { 575 | is_pac = true; 576 | console.log(" > Determining symbols with PAC enabled."); 577 | } 578 | 579 | /* 580 | At these offsets in -[CLDurianTask opcodeDescription] and -[CLHawkeyeTask opcodeDescription] 581 | there's a branch instruction to the function we need 582 | */ 583 | var durianOffset = 0; 584 | var hawkeyeOffset = 0; 585 | if (! is_pac) { 586 | // the offsets for newer iOS versions are slightly different 587 | if (parseFloat(ios_version.split("_")[1]) >= 18.2) { 588 | durianOffset = 20; 589 | hawkeyeOffset = 16; 590 | } else { 591 | durianOffset = 28; 592 | hawkeyeOffset = 24; 593 | } 594 | } else { 595 | // offsets for PAC assembly 596 | durianOffset = 48; 597 | hawkeyeOffset = 44; 598 | } 599 | 600 | self._DurianOpcodeDescription = new NativePointer(Instruction.parse(opcodeDescription.add(durianOffset)).operands.pop().value); 601 | console.log(" > _DurianOpcodeDesription " + self._DurianOpcodeDescription); 602 | 603 | 604 | var {CLHawkeyeTask} = ObjC.classes; 605 | var opcodeDescription = new NativePointer(CLHawkeyeTask['- opcodeDescription'].implementation); 606 | self._HawkeyeOpcodeDescription = new NativePointer(Instruction.parse(opcodeDescription.add(hawkeyeOffset)).operands.pop().value); 607 | console.log(" > _HawkeyeOpcodeDescription " + self._HawkeyeOpcodeDescription); 608 | } 609 | 610 | setFirmwareAsset(index, blob) { 611 | var self = this; 612 | index = parseInt(index); // index is always integer 613 | 614 | if (! self.replace_firmware[index]) { 615 | // gnaaaaah types -.- 616 | var a = self.hex_to_array(blob); // JSON to Array 617 | a = a.readByteArray(blob.length/2); // Array to ByteArray (not sure what's the difference here) 618 | a = new Uint8Array(a); // convert to JavaScript Array 619 | a = self.allocNSData(a); // convert to NSData 620 | self.replace_firmware[index] = a; // save to firmware blobs 621 | console.log(" * Added firmware blob for asset type " + index); 622 | 623 | } 624 | } 625 | 626 | // Conversion needed for the firmware 627 | hex_to_array(payload) { 628 | 629 | // create null pointer if needed 630 | if (payload.length == 0) { 631 | return new NativePointer(0x0); 632 | } 633 | 634 | const payload_array = []; 635 | var target_array = Memory.alloc(payload.length / 2); 636 | for (var i = 0; i < payload.length; i += 2) { 637 | payload_array.push(parseInt(payload.substring(i, i + 2), 16)); 638 | } 639 | 640 | // Copy to buffer 641 | Memory.writeByteArray(target_array, payload_array); 642 | 643 | return target_array; 644 | } 645 | 646 | 647 | // Export class methods for Frida 648 | // Required to inject firmware via python from a local machine 649 | makeExports() { 650 | var self = this; 651 | return { 652 | setsymbols: (ios_version) => {return self.setSymbols(ios_version)}, 653 | setfirmwareasset: (index, blob) => {return self.setFirmwareAsset(index, blob)}, 654 | prepare: () => {return self.prepare()}, 655 | } 656 | } 657 | 658 | } 659 | 660 | var d = new Durian(); 661 | 662 | // Prepare the target function 663 | d.prepare(); //TODO call this when standalone 664 | 665 | // Required to interact with Python ... 666 | // Yep even the standalone fuzzer should use this because calling the fuzzer 667 | // directly will timeout on large payloads 668 | rpc.exports = d.makeExports(); 669 | rpc.exports.d = Durian; 670 | -------------------------------------------------------------------------------- /scripts/update/hook_durian_update_searchpartyd.js: -------------------------------------------------------------------------------- 1 | /* 2 | Hooking into searchpartyd and other daemons related to AirTags. Never ending story :( 3 | These hooks are so generic that they work on all daemons that use a SPBeacon that 4 | contains the AirTag info like serial number, version, etc. 5 | 6 | Attach as follows: 7 | 8 | frida -U searchpartyd --no-pause -l hook_durian_update_searchpartyd.js 9 | 10 | Can trigger a new firmware update by setting a wrong version number. 11 | 12 | 13 | TODO trigger directly 14 | 15 | Aug 16 01:18:30 searchpartyd[14857] : Scheduling firmware update check with frequency: 9000.0, grace period: 1800.0 16 | Aug 16 01:18:30 searchpartyd[14857] : Schedule a firmware update check 300 seconds later (reason: paired) 17 | 18 | -> most likely possible via XPC 19 | 20 | Aug 10 22:11:59 searchpartyd[1454] : Opened [TXN:com.apple.icloud.searchpartyd.BeaconManagerService.firmware-update-after-pairing.7D3FEC81-A882-4A7A-BDAE-FF494D67B6F5] 21 | 22 | All the swift stuff etc. complicates hooking anything manually :( 23 | 24 | 25 | */ 26 | 27 | 28 | class Durian { 29 | 30 | constructor() { 31 | 32 | /*** INITIALIZE SCRIPT ***/ 33 | this.ios_version = "arm64_14.7"; // adjust iOS version here! (nothing version-specificc so far...) 34 | 35 | // 1.0.276 latest version 36 | this.durian_version_string = '1.0.225'; // outdated firmware version 37 | 38 | this.durian_serial = 'TROLOLOLOLOL'; // some serial, whatever 39 | 40 | this.log_verbose = false; // switch logging verbosity 41 | 42 | // global vars for the current device 43 | this.beacon; 44 | 45 | } 46 | 47 | 48 | 49 | /* 50 | Script preparation, needs to be called in standalone usage. 51 | Separated from constructor for external script usage. 52 | */ 53 | prepare() { 54 | 55 | var self = this; 56 | 57 | // some basic addresses 58 | self._searchpartyd_base = Module.getBaseAddress('searchpartyd'); 59 | 60 | // Set the correct symbols 61 | self.setSymbols(self.ios_version); 62 | 63 | // overwrite version etc. in the SPBeacon 64 | self.overwriteSPBeacons(); 65 | 66 | // fake that we're an intenralBuild 67 | self.fakeInternalBuild(); 68 | 69 | // debug some stuff 70 | self.hookSchedule(); 71 | 72 | } 73 | 74 | // Backtrace helper function 75 | print_backtrace(ctx) { 76 | console.log('Backtrace:\n' + 77 | Thread.backtrace(ctx, Backtracer.ACCURATE) 78 | .map(DebugSymbol.fromAddress).join('\n') + '\n'); 79 | } 80 | 81 | // Helper function to print hex 82 | print_hex(byte_array) { 83 | var bytes_string = ""; 84 | for (var i = 0; i < byte_array.length; i+=1) { 85 | bytes_string += ("00" + byte_array[i].toString(16)).substr(-2); 86 | } 87 | console.log('\t' + bytes_string); 88 | } 89 | 90 | 91 | /* 92 | Faking that this is an internalBuild :) 93 | Much more logging on every tag found, is requested for sooo many log entries. 94 | */ 95 | fakeInternalBuild() { 96 | 97 | var self = this; 98 | 99 | var {FMSystemInfo_ios} = ObjC.classes; 100 | 101 | self.t = Memory.alloc(32); // "true" pointer that works with NSDictionary or so 102 | //self.t.writeInt(1); 103 | // okay whatever this one works... 104 | self.t.writeByteArray([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); 105 | 106 | Interceptor.attach(FMSystemInfo_ios['- isInternalBuild'].implementation, { 107 | onEnter: function(args) { 108 | //console.log(" ! Faking isInternalBuild=True"); 109 | }, 110 | onLeave: function(r) { 111 | this.context.x0 = self.t; 112 | } 113 | }); 114 | 115 | } 116 | 117 | 118 | /* 119 | Further hook needed to fake the version in the search party beacon. 120 | 121 | Every time a SPBeacon is created, all its setters are called: 122 | 123 | -[SPBeacon setIdentifier:0x149d8fb00] 124 | -[SPBeacon setModel:0x1fe7b4080] 125 | -[SPBeacon setShares:0x1fea03120] 126 | -[SPBeacon setSystemVersion:0xb19247ee008b482b] 127 | -[SPBeacon setVendorId:0x4c] 128 | -[SPBeacon setProductId:0x5500] 129 | - ... (and more) 130 | 131 | Since this is no plain copy but calls all these functions, we can hook the functions 132 | to set our own version. 133 | */ 134 | overwriteSPBeacons() { 135 | 136 | // SearchParty Beacon from searchpartyd, defined in SPOwner 137 | var {SPBeacon} = ObjC.classes; 138 | var {NSString} = ObjC.classes; 139 | var self = this; 140 | 141 | 142 | 143 | // all setters are called upon SPBeacon creation, overwrite these 144 | Interceptor.attach(SPBeacon['- setSystemVersion:'].implementation, { 145 | onEnter: function(args) { 146 | var version = new ObjC.Object(args[2]); 147 | args[2] = NSString['stringWithString:'](self.durian_version_string); 148 | version = new ObjC.Object(args[2]); 149 | if (self.log_verbose) { 150 | console.log(" > observed version: " + version); 151 | console.log(" > new version: " + version); 152 | } 153 | } 154 | }); 155 | 156 | Interceptor.attach(SPBeacon['- setSerialNumber:'].implementation, { 157 | onEnter: function(args) { 158 | var serial = new ObjC.Object(args[2]); 159 | args[2] = NSString['stringWithString:'](self.durian_serial); 160 | serial = new ObjC.Object(args[2]); 161 | if (self.log_verbose) { 162 | console.log(" > observed serial: " + serial); 163 | console.log(" > new serial: " + serial); 164 | } 165 | } 166 | }); 167 | } 168 | 169 | 170 | 171 | 172 | /* 173 | NSData helper function required to allocate parameters in CLDurianCommands. 174 | Takes input array in the form [1, 2, 3, 4, ...] as passed by JavaScript. 175 | */ 176 | allocNSData(bytes) { 177 | // alloc memory for raw bytes 178 | var params_raw = Memory.alloc(bytes.length); 179 | params_raw.writeByteArray(bytes); 180 | 181 | // NSData['- initWithBytes:length:'] 182 | var {NSData} = ObjC.classes; 183 | return NSData.alloc().initWithBytes_length_(params_raw, bytes.length); 184 | } 185 | 186 | /* 187 | [iOS Device::searchpartyd]-> Scheduling update! Reason: 0x1 188 | 0x16fd4e7a0 189 | 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 190 | 00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 191 | 00000010 01 63 7c 23 00 00 00 c0 ac 63 7c 23 02 00 00 c0 .c|#.....c|#.... 192 | Backtrace: 193 | 0x100780038 searchpartyd!0x44038 (0x100044038) 194 | 0x10078009c searchpartyd!0x4409c (0x10004409c) 195 | 0x10090c880 searchpartyd!0x1d0880 (0x1001d0880) 196 | 0x1008256b0 searchpartyd!0xe96b0 (0x1000e96b0) 197 | 0x1ab55a2b0 libdispatch.dylib!_dispatch_call_block_and_release 198 | 0x1ab55b298 libdispatch.dylib!_dispatch_client_callout 199 | 0x1ab537344 libdispatch.dylib!_dispatch_lane_serial_drain$VARIANT$armv81 200 | 0x1ab537e60 libdispatch.dylib!_dispatch_lane_invoke$VARIANT$armv81 201 | 0x1ab54166c libdispatch.dylib!_dispatch_workloop_worker_thread 202 | 0x1f3e555bc libsystem_pthread.dylib!_pthread_wqthread 203 | 204 | Then repeated 2x for: 205 | Scheduling update! Reason: 0x7 206 | 207 | then some wait time then 208 | Scheduling update! Reason: 0x6 209 | 210 | 211 | */ 212 | hookSchedule() { 213 | var self = this; 214 | 215 | /* 216 | Interceptor.attach(self._schedule_update_addr, { 217 | onEnter: function(args) { 218 | console.log("Scheduling update! Reason: " + args[0]); 219 | //this.context.x0 = 6; // doesn't help to set it to 6... :/ 220 | console.log(args[1]); // TODO no idea what this is but no ObjC? 221 | //console.log(args[1].readByteArray(0x20)); 222 | //console.log(new ObjC.Object(args[1])); 223 | 224 | //self.print_backtrace(this.context); 225 | 226 | } 227 | }); 228 | */ 229 | 230 | /* 231 | 232 | Interceptor.attach(self._searchpartyd_base.add(0x44088), { 233 | onEnter: function(args) { 234 | console.log("Beacon observed..."); 235 | console.log(args[1]); // ?? 236 | console.log(new ObjC.Object(args[1])); //searchpartyd.FirmwareUpdateService 237 | 238 | } 239 | }); 240 | */ 241 | 242 | } 243 | 244 | 245 | 246 | 247 | /* 248 | Version-specific symbols, needs to be adjusted for every version. 249 | */ 250 | setSymbols(ios_version) { 251 | 252 | var self = this; 253 | 254 | // tested on an iPhone 8 255 | if (ios_version == "arm64_14.7") { 256 | console.log(" * Set symbols to pre-A12 iOS 14.7"); 257 | 258 | /* 259 | self._schedule_update_addr = self._searchpartyd_base.add(0x41640); 260 | self._schedule_update = new NativeFunction(self._schedule_update_addr, 'pointer', ['char', 'pointer']); 261 | */ 262 | 263 | 264 | } 265 | else { 266 | console.log(" ! undefined symbols"); 267 | } 268 | 269 | 270 | } 271 | 272 | 273 | // Export class methods for Frida 274 | // TODO not used with an external script yet... 275 | makeExports() { 276 | var self = this; 277 | return { 278 | setsymbols: (ios_version) => {return self.setSymbols(ios_version)}, 279 | prepare: () => {return self.prepare()}, 280 | } 281 | } 282 | 283 | } 284 | 285 | var d = new Durian(); 286 | 287 | // Prepare the target function 288 | d.prepare(); //TODO call this when standalone 289 | 290 | // Required to interact with Python ... 291 | // Yep even the standalone fuzzer should use this because calling the fuzzer 292 | // directly will timeout on large payloads 293 | rpc.exports = d.makeExports(); 294 | rpc.exports.d = Durian; 295 | -------------------------------------------------------------------------------- /scripts/update/overwrite_firmware.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import frida 4 | import sys 5 | import time 6 | import binascii 7 | 8 | class FirmwareLoader: 9 | """ 10 | Overwrite a complete firmware during the update. 11 | 12 | TODO 13 | currently you also need to 14 | 15 | * continuously overwrite the ftab.bin file on the iPhone (15 seconds between unpacking and personalization) 16 | * wait until fud spawns and overwrite the personalization (again ~15 seconds until it requests sth from the TSS) 17 | 18 | iPhone:/private/var/MobileAsset/AssetsV2/com_apple_MobileAsset_MobileAccessoryUpdate_DurianFirmware/eba889b5f77e7aa5fb27e24adcf44b20a62c6dc1.asset/AssetData/DurianFirmware.acsw root# 19 | while true; do cp /var/root/ftab_rose_airtag_old.bin ftab.bin; sleep 1; done 20 | 21 | """ 22 | 23 | def __init__(self): 24 | """ 25 | Script configuration options. 26 | """ 27 | 28 | print("\n\n\n!!!!!! HOOKING fud AND OVERWRITING ftab.bin MUST BE STARTED BEFORE THIS SCRIPT!\n\n\n") 29 | 30 | #self.firmware_dir = '../extracted_firmware_update/DurianFirmware_downgrade.acsw/' # local directory (not on iPhone) 31 | self.firmware_dir = '../extracted_firmware_update/DurianFirmware_1A276d.acsw/' # local directory (not on iPhone) 32 | self.firmware_intercept_prefix = "durian_fw_" # TODO not implemented 33 | 34 | # only add the parts of the firmware that you want to be replaced 35 | self.replace_firmware = { 36 | 1: "bldr.bin", # boot loader 37 | 2: "blsg.bin", # boot loader signature 38 | 3: "sftd.bin", # soft device 39 | 4: "sdsg.bin", # soft device signature 40 | 5: "blap.bin", # bluetooth app 41 | 6: "basg.bin", # bluetooth app signature 42 | #7: "r1md.bin" # r1md.bin is TSS signed ftab.bin, signature must match current nonce returned via GATT 43 | } 44 | 45 | self.overwrite_r1_signature = True 46 | # TODO sha384 sum currently hardcoded in the fud.js 47 | 48 | 49 | # Start Frida 50 | self.device = frida.get_usb_device() 51 | 52 | # Attach to searchpartyd to fake old versions in the SPBeacon 53 | frida_session_searchpartyd = self.device.attach("searchpartyd") 54 | self.searchpartyd_script = frida_session_searchpartyd.create_script(open("hook_durian_update_searchpartyd.js", "r").read()) 55 | self.searchpartyd_script.load() 56 | print(" * Attached to searchpartyd.") 57 | 58 | # Attach to locationd with callback to replace the firmware. 59 | frida_session_locationd = self.device.attach("locationd") 60 | self.locationd_script = frida_session_locationd.create_script(open("hook_durian_update_locationd.js", "r").read()) 61 | self.locationd_script.on("message", self.on_locationd_message) # required for feedback 62 | self.locationd_script.load() 63 | print(" * Attached to locationd.") 64 | 65 | 66 | # And now hook into firmware loading :) 67 | self.load_firmware() 68 | 69 | # Don't quit the script... 70 | self.fud_attached = False 71 | while True: 72 | 73 | # the fud is only started on demand, hook it as soon as it becomes alive 74 | if self.overwrite_r1_signature and not self.fud_attached: 75 | try: 76 | #print(" * Waiting for FUD...") 77 | frida_session_fud = self.device.attach("fud") 78 | fud_script = frida_session_fud.create_script(open("hook_durian_update_fud.js", "r").read()) 79 | fud_script.load() 80 | print(" * Attached to FUD, update starting soon!") 81 | self.fud_attached = True 82 | except: 83 | pass 84 | else: 85 | time.sleep(120) # just sleep, otherwise we attach multiple times to the fud 86 | self.fud_attached = False 87 | 88 | def on_locationd_message(self, message, data): 89 | """ 90 | Handle locationd script feedback. 91 | """ 92 | 93 | return 94 | 95 | # TODO 96 | print(message) 97 | 98 | payload = message['payload'] 99 | message_type = payload['msgType'] 100 | 101 | # Firmware interception 102 | if message_type == 'asset': 103 | asset_type = payload['assetType'] 104 | print(" * Intercepted Durian firmware asset type " + asset_type) 105 | f = open(self.firmware_intercept_prefix + asset_type + ".bin", "wb") 106 | f.write(data) 107 | f.close() 108 | return 109 | 110 | def file_to_hex(self, filename): 111 | """ 112 | Helper function to parse files as hex string for JSON serialization. 113 | :return: 114 | """ 115 | 116 | return binascii.hexlify(open(filename, 'rb').read()).decode('ascii') 117 | 118 | def load_firmware(self): 119 | """ 120 | Run all steps to trigger loading firmware and then also replace it. 121 | 122 | :return: 123 | """ 124 | 125 | print(" * Sending custom firmware to the Frida script.") 126 | print(self.replace_firmware) 127 | for index in self.replace_firmware: 128 | asset = self.replace_firmware[index] 129 | firmware_file = self.firmware_dir + asset 130 | self.locationd_script.exports.setfirmwareasset(index, self.file_to_hex(firmware_file)) 131 | 132 | 133 | 134 | # run everything... 135 | FirmwareLoader() -------------------------------------------------------------------------------- /woot22-paper.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seemoo-lab/airtag/18a5a7d9738bf9f52c57340e6f1a207a320304e3/woot22-paper.pdf --------------------------------------------------------------------------------