From 9433b4dcca29ec93f5f8b57bca820cf294199513 Mon Sep 17 00:00:00 2001 From: bfloat16 <38366253+bfloat16@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:20:03 +0800 Subject: [PATCH 1/8] Initial commit --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From b1d3699df3c94fe20f018c1557b06b623786d3e4 Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 00:34:08 +0800 Subject: [PATCH 2/8] feat: add static Android il2cpp restoration --- .gitignore | 1 + Cargo.lock | 390 ++++++++ Cargo.toml | 46 + README.md | 87 ++ senbei-android-cli/Cargo.toml | 19 + senbei-android-cli/src/main.rs | 178 ++++ senbei-android-crypto/Cargo.toml | 14 + senbei-android-crypto/src/lib.rs | 598 ++++++++++++ senbei-android-elf/Cargo.toml | 19 + senbei-android-elf/src/artifact.rs | 105 +++ senbei-android-elf/src/error.rs | 35 + senbei-android-elf/src/hash.rs | 107 +++ senbei-android-elf/src/layout.rs | 301 ++++++ senbei-android-elf/src/lib.rs | 10 + senbei-android-elf/src/restore.rs | 1407 ++++++++++++++++++++++++++++ senbei-android-io/Cargo.toml | 19 + senbei-android-io/src/lib.rs | 154 +++ senbei-android-metadata/Cargo.toml | 14 + senbei-android-metadata/src/lib.rs | 421 +++++++++ 19 files changed, 3925 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 senbei-android-cli/Cargo.toml create mode 100644 senbei-android-cli/src/main.rs create mode 100644 senbei-android-crypto/Cargo.toml create mode 100644 senbei-android-crypto/src/lib.rs create mode 100644 senbei-android-elf/Cargo.toml create mode 100644 senbei-android-elf/src/artifact.rs create mode 100644 senbei-android-elf/src/error.rs create mode 100644 senbei-android-elf/src/hash.rs create mode 100644 senbei-android-elf/src/layout.rs create mode 100644 senbei-android-elf/src/lib.rs create mode 100644 senbei-android-elf/src/restore.rs create mode 100644 senbei-android-io/Cargo.toml create mode 100644 senbei-android-io/src/lib.rs create mode 100644 senbei-android-metadata/Cargo.toml create mode 100644 senbei-android-metadata/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5da0292 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,390 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "senbei-android-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "senbei-android-io", + "senbei-android-metadata", +] + +[[package]] +name = "senbei-android-crypto" +version = "0.1.0" +dependencies = [ + "aes", + "thiserror", +] + +[[package]] +name = "senbei-android-elf" +version = "0.1.0" +dependencies = [ + "memmap2", + "senbei-android-crypto", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "senbei-android-io" +version = "0.1.0" +dependencies = [ + "anyhow", + "senbei-android-elf", + "senbei-android-metadata", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "senbei-android-metadata" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e544dd7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +members = [ + "senbei-android-cli", + "senbei-android-crypto", + "senbei-android-elf", + "senbei-android-io", + "senbei-android-metadata", +] +default-members = ["senbei-android-cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "AGPL-3.0-only" + +[workspace.dependencies] +aes = "0.8" +anyhow = "1" +memmap2 = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" +thiserror = "2" + +senbei-android-crypto = { path = "senbei-android-crypto" } +senbei-android-elf = { path = "senbei-android-elf" } +senbei-android-io = { path = "senbei-android-io" } +senbei-android-metadata = { path = "senbei-android-metadata" } + +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +correctness = { level = "deny", priority = -1 } +suspicious = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..87ed4f8 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Senbei Android + +用于静态还原 Android 版受保护的 `libil2cpp.so` 和 IL2CPP +`global-metadata.dat`。生产路径已经完全 Rust 化,不执行保护器代码,也不依赖 +Unicorn、IDA 或 Python。 + +## 兼容性 + +| 游戏 | 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | +|------|------|------|------|--------------|---------------------| +| リバースブルー×リバースエンド | Android | 1.28.2 | AArch64 | 支持 | v31 MethodDef token | + +当前 SO 实现针对该版本的 Stage 2 模块格式,运行时会从模块产物中发现 +`0x9B` 的种子、AES-256 key schedule 和相关配置,不硬编码样本 offset。 +metadata 默认使用模块 `0x0C` 中确认的 seed `0xA6FAE968`。 + +## 构建 + +```powershell +cargo build --release +``` + +生成的程序为: + +```text +target\release\senbei-android.exe +``` + +## 还原 libil2cpp.so + +```powershell +senbei-android restore-so INPUT OUTPUT --index INDEX_JSON --report REPORT_JSON +``` + +示例: + +```powershell +senbei-android restore-so ` + Native\libil2cpp.so ` + Native\libil2cpp_restored.so ` + --index Native\libil2cpp_stage2_modules\index.json ` + --report Native\libil2cpp_restore_report.json +``` + +省略 `--index` 时,默认读取输入文件同目录下的: + +```text +libil2cpp_stage2_modules\index.json +``` + +可选参数: + +- `--dump-aux FILE`:保存解码后的辅助 ELF 数据。 +- `--outer-only`:只还原主容器,不物化辅助动态链接表。 +- `--preserve-entrypoint`:保留保护器入口点;正常干净输出不应使用此项。 + +完整还原会静态处理 `0x9B/0x9D/0x9E` 数据,恢复 ELF load image、隐藏动态 +符号、字符串、SysV/GNU hash、version、`.rela.dyn` 和 `.rela.plt`,移除 +`SHT_LOUSER` 私有区并将入口点归零。 + +## 还原 metadata + +```powershell +senbei-android restore-metadata INPUT OUTPUT --report REPORT_JSON +``` + +示例: + +```powershell +senbei-android restore-metadata ` + Package\base\assets\bin\Data\Managed\Metadata\global-metadata.dat ` + Package\base\assets\bin\Data\Managed\Metadata\global-metadata_restored.dat ` + --report metadata_restore_report.json +``` + +可用 `--seed 0xA6FAE968` 显式指定十六进制 seed,也支持十进制。还原操作是 +幂等的:已规范化的 image 会保持不变。 + +## Workspace + +| Crate | 职责 | +|-------|------| +| `senbei-android-cli` | 命令行参数解析与结果输出 | +| `senbei-android-io` | 路径推导、原地覆盖保护、原子写入与 JSON 报告 | +| `senbei-android-elf` | AArch64 ELF 还原与结构验证 | +| `senbei-android-crypto` | `0x9B/0x9D` 容器、AES、Huffman/LZ 和字变换 | +| `senbei-android-metadata` | v31 MethodDef token 静态逆变换与覆盖验证 | diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml new file mode 100644 index 0000000..3d6c2a2 --- /dev/null +++ b/senbei-android-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Command-line entry point for Senbei Android" + +[[bin]] +name = "senbei-android" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +senbei-android-io.workspace = true +senbei-android-metadata.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-cli/src/main.rs b/senbei-android-cli/src/main.rs new file mode 100644 index 0000000..c4203fc --- /dev/null +++ b/senbei-android-cli/src/main.rs @@ -0,0 +1,178 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use senbei_android_io::{RestoreMetadataJob, RestoreSoJob, run_restore_metadata, run_restore_so}; + +fn main() -> std::process::ExitCode { + match run(std::env::args_os().skip(1)) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error:#}"); + std::process::ExitCode::from(1) + } + } +} + +fn run(args: impl Iterator) -> Result<()> { + let mut args = args.peekable(); + let Some(command) = args.next() else { + print_help(); + bail!("missing command"); + }; + let command = command.to_string_lossy(); + match command.as_ref() { + "restore-so" => restore_so(args.collect()), + "restore-metadata" => restore_metadata(args.collect()), + "-h" | "--help" => { + print_help(); + Ok(()) + } + "-V" | "--version" => { + println!("senbei-android {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + _ => bail!("unknown command `{command}`; use --help for usage"), + } +} + +fn restore_so(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + let mut index = None; + let mut report = None; + let mut dump_auxiliary = None; + let mut outer_only = false; + let mut preserve_entrypoint = false; + let mut cursor = 0; + while cursor < args.len() { + match args[cursor].to_string_lossy().as_ref() { + "--index" => index = Some(option_path(&args, &mut cursor, "--index")?), + "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), + "--dump-aux" => { + dump_auxiliary = Some(option_path(&args, &mut cursor, "--dump-aux")?); + } + "--outer-only" => outer_only = true, + "--preserve-entrypoint" => preserve_entrypoint = true, + "-h" | "--help" => { + print_so_help(); + return Ok(()); + } + option if option.starts_with('-') => bail!("unknown restore-so option `{option}`"), + _ => positional.push(PathBuf::from(&args[cursor])), + } + cursor += 1; + } + let [input, output] = positional.as_slice() else { + bail!("restore-so requires INPUT and OUTPUT; use --help for usage"); + }; + let result = run_restore_so(&RestoreSoJob { + input: input.clone(), + output: output.clone(), + index, + report, + dump_auxiliary, + outer_only, + preserve_entrypoint, + })?; + println!("Restored {} bytes to {}", result.output_size, result.output); + println!("SHA-256 {}", result.output_sha256); + Ok(()) +} + +fn restore_metadata(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + let mut report = None; + let mut seed = senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED; + let mut cursor = 0; + while cursor < args.len() { + match args[cursor].to_string_lossy().as_ref() { + "--seed" => { + let value = option_string(&args, &mut cursor, "--seed")?; + seed = parse_u32(&value).with_context(|| format!("invalid --seed `{value}`"))?; + } + "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), + "-h" | "--help" => { + print_metadata_help(); + return Ok(()); + } + option if option.starts_with('-') => { + bail!("unknown restore-metadata option `{option}`"); + } + _ => positional.push(PathBuf::from(&args[cursor])), + } + cursor += 1; + } + let [input, output] = positional.as_slice() else { + bail!("restore-metadata requires INPUT and OUTPUT; use --help for usage"); + }; + let result = run_restore_metadata(&RestoreMetadataJob { + input: input.clone(), + output: output.clone(), + seed, + report, + })?; + println!( + "Restored {}/{} MethodDef tokens ({} already canonical)", + result.changed_tokens, result.methods, result.already_correct_before + ); + Ok(()) +} + +fn option_path(args: &[OsString], cursor: &mut usize, name: &str) -> Result { + *cursor += 1; + args.get(*cursor) + .map(PathBuf::from) + .with_context(|| format!("{name} requires a path")) +} + +fn option_string(args: &[OsString], cursor: &mut usize, name: &str) -> Result { + *cursor += 1; + args.get(*cursor) + .map(|value| value.to_string_lossy().into_owned()) + .with_context(|| format!("{name} requires a value")) +} + +fn parse_u32(value: &str) -> Result { + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + Ok(u32::from_str_radix(hex, 16)?) + } else { + Ok(value.parse()?) + } +} + +fn print_help() { + println!("senbei-android {}", env!("CARGO_PKG_VERSION")); + println!("Usage:"); + println!(" senbei-android restore-so INPUT OUTPUT [OPTIONS]"); + println!(" senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); + println!(" senbei-android --version"); +} + +fn print_so_help() { + println!("senbei-android restore-so INPUT OUTPUT [OPTIONS]"); + println!(" --index FILE Stage 2 module index.json"); + println!(" --report FILE Write a JSON restoration report"); + println!(" --dump-aux FILE Dump decoded auxiliary ELF data"); + println!(" --outer-only Skip auxiliary ELF table materialization"); + println!(" --preserve-entrypoint Keep the protector entrypoint"); +} + +fn print_metadata_help() { + println!("senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); + println!(" --seed VALUE Module 0x0C seed (decimal or 0x-prefixed hex)"); + println!(" --report FILE Write a JSON restoration report"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_decimal_and_hex_seeds() { + assert_eq!(parse_u32("42").unwrap(), 42); + assert_eq!(parse_u32("0xA6FAE968").unwrap(), 0xa6fa_e968); + } +} diff --git a/senbei-android-crypto/Cargo.toml b/senbei-android-crypto/Cargo.toml new file mode 100644 index 0000000..269e2aa --- /dev/null +++ b/senbei-android-crypto/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "senbei-android-crypto" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Protector container primitives for Senbei Android" + +[dependencies] +aes.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs new file mode 100644 index 0000000..e752863 --- /dev/null +++ b/senbei-android-crypto/src/lib.rs @@ -0,0 +1,598 @@ +//! Cryptographic and compression primitives used by the Android protector. + +use aes::Aes256; +use aes::cipher::{Block, BlockDecrypt, KeyInit}; + +const RECORD_SIZE: usize = 0x5c; + +/// Errors raised while parsing or decoding protector containers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Invalid(String), +} + +type Result = std::result::Result; + +fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} + +fn range(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = range(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = range(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn align_up(value: usize, alignment: usize) -> Result { + let mask = alignment + .checked_sub(1) + .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; + value + .checked_add(mask) + .map(|v| v & !mask) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} + +/// Multiply by the fixed element used by the native GF(2^32) transform. +#[must_use] +pub fn gf32_mul_fixed(mut value: u32) -> u32 { + let mut multiplier = 0x9451_1dd2_u32; + let mut result = 0_u32; + while multiplier != 0 { + if multiplier & 1 != 0 { + result ^= value; + } + let carry = value >> 31; + value = value.wrapping_shl(1); + if carry != 0 { + value ^= 0x5793_57eb; + } + multiplier >>= 1; + } + result +} + +fn mix_columns(block: [u8; 16]) -> [u8; 16] { + const fn xtime(value: u8) -> u8 { + (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } + } + + let mut output = [0_u8; 16]; + for offset in (0..16).step_by(4) { + let [a, b, c, d] = block[offset..offset + 4] else { + unreachable!("fixed four-byte AES column") + }; + output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; + output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; + output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); + output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); + } + output +} + +/// Static configuration recovered from module `0x9B`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Module9bConfig { + pub header_seed: u32, + pub container_seed: u32, + pub aes_key: [u8; 32], + pub skip_aes: bool, + pub schedule_offset: usize, +} + +impl Module9bConfig { + /// Parse the unique AES-256 decryption schedule and adjacent configuration. + pub fn parse(image: &[u8]) -> Result { + const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; + let mut matches = image + .windows(MARKER.len()) + .enumerate() + .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); + let schedule_offset = matches + .next() + .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; + if schedule_offset < 8 || matches.next().is_some() { + return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); + } + + let header_seed = read_u32(image, schedule_offset - 8)?; + let schedule_size = read_u32(image, schedule_offset - 4)?; + if schedule_size != 0xf4 { + return invalid(format!( + "unexpected 0x9B AES schedule size 0x{schedule_size:x}" + )); + } + let bits = read_u16(image, schedule_offset)?; + let rounds = read_u16(image, schedule_offset + 2)?; + if (bits, rounds) != (0x100, 14) { + return invalid(format!( + "unexpected AES schedule header 0x{bits:x}/{rounds}" + )); + } + + let schedule = range(image, schedule_offset + 4, 15 * 16)?; + let mut round_keys = [[0_u8; 16]; 15]; + for (round, output) in round_keys.iter_mut().enumerate() { + let source = &schedule[round * 16..round * 16 + 16]; + for word in 0..4 { + let start = word * 4; + for byte in 0..4 { + output[start + byte] = source[start + 3 - byte]; + } + } + } + let mut aes_key = [0_u8; 32]; + aes_key[..16].copy_from_slice(&round_keys[14]); + aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); + + let container_seed_offset = schedule_offset + .checked_add(0x100) + .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; + let skip_aes_offset = schedule_offset + .checked_add(0x240) + .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; + let skip_aes = *image.get(skip_aes_offset).ok_or_else(|| { + Error::Invalid("0x9B static configuration exceeds its image".to_owned()) + })? != 0; + + Ok(Self { + header_seed, + container_seed: read_u32(image, container_seed_offset)?, + aes_key, + skip_aes, + schedule_offset, + }) + } +} + +/// Decrypted header at the start of direct-data object `0x9D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProtectedDescriptor { + pub command_id: u32, + pub flags: u32, + pub outer_offset: u32, + pub outer_expected_size: u32, + pub auxiliary_offset: u32, + pub auxiliary_expected_size: u32, +} + +impl ProtectedDescriptor { + /// Decrypt the `0x5c`-byte descriptor with the module header seed. + pub fn decrypt(data: &[u8], seed: u32) -> Result { + if data.len() < RECORD_SIZE { + return invalid("0x9D descriptor is truncated"); + } + let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); + let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); + let mut words = [0_u32; RECORD_SIZE / 4]; + for (index, word) in words.iter_mut().enumerate() { + let cipher = read_u32(data, index * 4)?; + let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); + *word = cipher.wrapping_sub(subtractor) + ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); + } + if words[6..].iter().any(|&word| word != 0) { + return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); + } + let descriptor = Self { + command_id: words[0], + flags: words[1], + outer_offset: words[2], + outer_expected_size: words[3], + auxiliary_offset: words[4], + auxiliary_expected_size: words[5], + }; + if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { + return invalid("unexpected decrypted 0x9D descriptor"); + } + Ok(descriptor) + } +} + +/// One encrypted segment in a decoded `0x9D` container header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodedSegment { + pub offset: u32, + pub size: u32, +} + +/// Parsed primary or auxiliary `0x9D` container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerHeader { + pub start: usize, + pub output_size: u32, + pub skip_aes: bool, + pub tree: Vec, + pub segments: Vec, +} + +impl ContainerHeader { + /// Parse and decrypt a container header, Huffman tree, and segment table. + pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { + range(data, start, 12)?; + let seed_square = seed.wrapping_mul(seed); + let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); + let raw0 = read_u32(data, start)?; + let raw1 = read_u32(data, start + 4)?; + let raw2 = read_u32(data, start + 8)?; + let output_size = 0xa21d_fb3a_u32 + .wrapping_shl(state & 7) + .wrapping_add(state.wrapping_mul(0xf87b_337c)) + .wrapping_add(gf32_mul_fixed(raw0)); + let flag_word = gf32_mul_fixed(raw1) + ^ state + .wrapping_add(0xbd19_c63c) + .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); + let segment_count = (flag_word & 0xff) as usize; + let skip_aes = (flag_word >> 8) & 0xff == 1; + let tree_size = 0x643a_3a3b_u32 + .wrapping_shl(state & 0x0b) + .wrapping_sub(state ^ 0x3b2b_f538) + .wrapping_add(gf32_mul_fixed(raw2)) as usize; + if segment_count == 0 || tree_size > 0x1b00 { + return invalid(format!( + "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" + )); + } + + let tree_start = start + .checked_add(12) + .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; + let mut tree = range(data, tree_start, tree_size)?.to_vec(); + for offset in (0..tree_size & !3).step_by(4) { + let value = read_u32(&tree, offset)?; + tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); + } + let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); + let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); + for (index, byte) in tree.iter_mut().enumerate() { + let shift = u32::try_from(index & 0x1b) + .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; + let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); + let right = tree_delta.wrapping_shr((index & 0x17) as u32); + let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); + *byte = byte.wrapping_add(adjustment as u8); + } + + let table_start = start + .checked_add(align_up(12 + tree_size, 4)?) + .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; + let table_size = segment_count + .checked_mul(8) + .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; + let mut table = range(data, table_start, table_size)?.to_vec(); + let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); + let table_xor = table_state.wrapping_shl(3); + let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); + for offset in (0..table_size).step_by(4) { + let value = read_u32(&table, offset)?; + let decoded = gf32_mul_fixed(value ^ table_xor) + .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); + table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); + } + let mut segments = Vec::with_capacity(segment_count); + for index in 0..segment_count { + let offset = read_u32(&table, index * 8)?; + let size = read_u32(&table, index * 8 + 4)?; + let absolute = start + .checked_add(offset as usize) + .and_then(|value| value.checked_add(size as usize)); + if size == 0 || absolute.is_none_or(|end| end > data.len()) { + return invalid(format!("container segment {index} lies outside 0x9D")); + } + segments.push(EncodedSegment { offset, size }); + } + Ok(Self { + start, + output_size, + skip_aes, + tree, + segments, + }) + } + + /// End offset of the furthest encrypted segment. + pub fn encoded_end(&self) -> Result { + self.segments + .iter() + .map(|segment| { + self.start + .checked_add(segment.offset as usize) + .and_then(|value| value.checked_add(segment.size as usize)) + .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) + } +} + +/// Decoder for the protector's Huffman/LZ writer streams. +#[derive(Debug, Clone)] +pub struct HuffmanLzDecoder { + tree: Vec, + lookup_symbols: Vec, + lookup_bits: Vec, +} + +impl HuffmanLzDecoder { + /// Build the full 16-bit prefix lookup used by the static decoder. + pub fn new(tree: &[u8]) -> Result { + if tree.len() < 256 * 3 || tree.len() % 3 != 0 { + return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); + } + let mut result = Self { + tree: tree.to_vec(), + lookup_symbols: vec![0; 0x1_0000], + lookup_bits: vec![0; 0x1_0000], + }; + for word in 0..0x1_0000_u32 { + let (symbol, bits) = result.decode_symbol(word)?; + if bits <= 16 { + result.lookup_symbols[word as usize] = symbol; + result.lookup_bits[word as usize] = bits; + } + } + Ok(result) + } + + fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { + let offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; + let bytes = range(&self.tree, offset, 3)?; + let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); + Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) + } + + fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { + let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; + if leaf { + if extra == 0 { + return invalid("zero-width Huffman leaf"); + } + return Ok((value, extra)); + } + let mut bits = extra + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + let mut mask = 1_u32.wrapping_shl(u32::from(extra)); + loop { + let branch = usize::from(word & mask != 0); + let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; + value = next; + if is_leaf { + return Ok((value, bits)); + } + mask = mask.wrapping_shl(1); + bits = bits + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + if bits > 31 { + return invalid("Huffman code exceeds the native 32-bit window"); + } + } + } + + /// Decode one compressed writer payload to its exact expected size. + pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { + let mut output = vec![0_u8; output_size]; + let mut source_pos = 0_usize; + let mut bit_buffer = 0_u64; + let mut available = 0_u8; + let mut consumed_bits = 0_usize; + let mut output_pos = 0_usize; + let mut prefix = 0_usize; + + while output_pos < output_size { + while available < 24 && source_pos < source.len() { + bit_buffer |= u64::from(source[source_pos]) << available; + source_pos += 1; + available += 8; + } + let key = (bit_buffer & 0xffff) as usize; + let mut bits = self.lookup_bits[key]; + let symbol = if bits != 0 { + self.lookup_symbols[key] + } else { + let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; + let mut node = range(&self.tree, value_offset, 3)?; + let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + bits = node[2]; + raw & 0x7fff + } else { + let extra = node[2]; + bits = extra + 1; + let mut mask = 1_u64 << extra; + loop { + let branch = usize::from(bit_buffer & mask != 0); + let index = usize::from(raw & 0x7fff) + branch; + value_offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; + node = range(&self.tree, value_offset, 3)?; + raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + break raw & 0x7fff; + } + mask <<= 1; + bits += 1; + } + } + }; + if bits == 0 || bits > available { + return invalid("compressed stream ends inside a Huffman code"); + } + bit_buffer >>= bits; + available -= bits; + consumed_bits = consumed_bits + .checked_add(usize::from(bits)) + .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; + + let kind = symbol & 0x300; + let value = usize::from(symbol & 0xff); + match kind { + 0 => { + output[output_pos] = value as u8; + output_pos += 1; + } + 0x100 => { + if prefix > 0xff { + return invalid("compressed prefix exceeds 16 bits"); + } + prefix = if prefix == 0 { + value + } else { + value | (prefix << 8) + }; + } + 0x200 => { + if prefix == 0 { + prefix = 1; + } + let count = value + .checked_mul(prefix) + .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; + if !matches!(value, 1 | 2 | 4) + || value > output_pos + || output_pos + .checked_add(count) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed repeated-pattern command"); + } + let pattern = output[output_pos - value..output_pos].to_vec(); + for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { + chunk.copy_from_slice(&pattern); + } + output_pos += count; + prefix = 0; + } + 0x300 => { + let length = value; + let distance = prefix.checked_add(length).ok_or_else(|| { + Error::Invalid("back-reference distance overflow".to_owned()) + })?; + if distance > output_pos + || output_pos + .checked_add(length) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed back-reference"); + } + let source_start = output_pos - distance; + output.copy_within(source_start..source_start + length, output_pos); + output_pos += length; + prefix = 0; + } + _ => unreachable!("masked Huffman symbol kind"), + } + } + if consumed_bits.div_ceil(8) != source.len() { + return invalid(format!( + "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", + consumed_bits.div_ceil(8), + source.len() + )); + } + Ok(output) + } +} + +/// Apply the native word transform and optional AES-256-CBC decryption. +pub fn transform_segment( + data: &[u8], + seed: u32, + aes_key: &[u8; 32], + decrypt_aes: bool, +) -> Result> { + let mut transformed = data.to_vec(); + let mut state = seed; + let mut left = 0xe34e_ac63_u32; + let mut right = 0x07b4_8238_u32; + for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { + let index32 = u32::try_from(index) + .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; + left = state + .wrapping_add(0x72f6_fcbe) + .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) + .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); + right = state + .wrapping_sub(0x71b6_a98d) + .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) + .wrapping_shl(index32 & 7); + state = left ^ right; + let bytes: [u8; 4] = chunk + .try_into() + .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; + let mut value = u32::from_le_bytes(bytes); + value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); + value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); + value = value.wrapping_sub(state) ^ state; + chunk.copy_from_slice(&value.to_le_bytes()); + } + + if decrypt_aes { + let cipher = Aes256::new_from_slice(aes_key) + .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; + let aligned_size = transformed.len() & !0x0f; + let mut previous = [0_u8; 16]; + for chunk in transformed[..aligned_size].chunks_exact_mut(16) { + let mut ciphertext = [0_u8; 16]; + ciphertext.copy_from_slice(chunk); + cipher.decrypt_block(Block::::from_mut_slice(chunk)); + for (byte, prior) in chunk.iter_mut().zip(previous) { + *byte ^= prior; + } + previous = ciphertext; + } + } + Ok(transformed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_mix_columns_matches_fips_example() { + let input = [ + 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ]; + assert_eq!( + mix_columns(input), + [ + 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ] + ); + } + + #[test] + fn descriptor_rejects_truncated_input() { + assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); + } +} diff --git a/senbei-android-elf/Cargo.toml b/senbei-android-elf/Cargo.toml new file mode 100644 index 0000000..6d84996 --- /dev/null +++ b/senbei-android-elf/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-elf" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "AArch64 ELF restoration for Senbei Android" + +[dependencies] +memmap2.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +senbei-android-crypto.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-elf/src/artifact.rs b/senbei-android-elf/src/artifact.rs new file mode 100644 index 0000000..464e51d --- /dev/null +++ b/senbei-android-elf/src/artifact.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::error::{Error, Result, invalid}; + +const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Artifact { + pub path: PathBuf, + pub size: u64, +} + +pub(crate) fn load_artifacts(index_path: &Path) -> Result> { + let text = std::fs::read_to_string(index_path) + .map_err(|error| Error::io("read module index", index_path, error))?; + let document: Value = serde_json::from_str(&text)?; + let root = index_path.parent().unwrap_or_else(|| Path::new(".")); + let mut result = BTreeMap::new(); + + if let Some(items) = document.get("module_registry").and_then(Value::as_array) { + for item in items { + let Some(command_id) = item.get("command_id").and_then(Value::as_u64) else { + continue; + }; + let command_id = u32::try_from(command_id) + .map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?; + if !REQUIRED_IDS.contains(&command_id) { + continue; + } + let Some(path) = item.get("image_path").and_then(Value::as_str) else { + continue; + }; + let size = item + .get("size") + .and_then(Value::as_u64) + .ok_or_else(|| Error::Invalid(format!("module 0x{command_id:02X} lacks size")))?; + result.insert( + command_id, + Artifact { + path: root.join(path), + size, + }, + ); + } + } + if let Some(streams) = document.get("streams").and_then(Value::as_array) { + for stream in streams { + let Some(records) = stream.get("records").and_then(Value::as_array) else { + continue; + }; + for record in records { + let Some(command_id) = record.get("command_id").and_then(Value::as_u64) else { + continue; + }; + let command_id = u32::try_from(command_id) + .map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?; + if !REQUIRED_IDS.contains(&command_id) { + continue; + } + let Some(image) = record.get("image") else { + continue; + }; + let Some(path) = image.get("path").and_then(Value::as_str) else { + continue; + }; + let size = image.get("size").and_then(Value::as_u64).ok_or_else(|| { + Error::Invalid(format!("record 0x{command_id:02X} lacks image size")) + })?; + result.insert( + command_id, + Artifact { + path: root.join(path), + size, + }, + ); + } + } + } + + let missing = REQUIRED_IDS + .iter() + .filter(|id| !result.contains_key(id)) + .map(|id| format!("0x{id:02X}")) + .collect::>(); + if !missing.is_empty() { + return invalid(format!( + "module index lacks required IDs: {}", + missing.join(", ") + )); + } + for (&command_id, artifact) in &result { + let metadata = std::fs::metadata(&artifact.path) + .map_err(|error| Error::io("inspect artifact", &artifact.path, error))?; + if !metadata.is_file() || metadata.len() != artifact.size { + return invalid(format!( + "invalid artifact for module 0x{command_id:02X}: {}", + artifact.path.display() + )); + } + } + Ok(result) +} diff --git a/senbei-android-elf/src/error.rs b/senbei-android-elf/src/error.rs new file mode 100644 index 0000000..058db5e --- /dev/null +++ b/senbei-android-elf/src/error.rs @@ -0,0 +1,35 @@ +use std::path::{Path, PathBuf}; + +/// ELF restoration failure. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{action} `{path}`: {source}")] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cannot parse module index: {0}")] + Json(#[from] serde_json::Error), + #[error(transparent)] + Crypto(#[from] senbei_android_crypto::Error), + #[error("{0}")] + Invalid(String), +} + +impl Error { + pub(crate) fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self { + Self::Io { + action, + path: path.to_path_buf(), + source, + } + } +} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} diff --git a/senbei-android-elf/src/hash.rs b/senbei-android-elf/src/hash.rs new file mode 100644 index 0000000..550e012 --- /dev/null +++ b/senbei-android-elf/src/hash.rs @@ -0,0 +1,107 @@ +use crate::error::{Error, Result, invalid}; + +#[must_use] +pub(crate) fn elf_hash(name: &[u8]) -> u32 { + let mut value = 0_u32; + for &byte in name { + value = value.wrapping_shl(4).wrapping_add(u32::from(byte)); + let high = value & 0xf000_0000; + if high != 0 { + value ^= high >> 24; + value &= !high; + } + } + value +} + +#[must_use] +pub(crate) fn gnu_hash(name: &[u8]) -> u32 { + name.iter().fold(5381_u32, |value, &byte| { + value.wrapping_mul(33).wrapping_add(u32::from(byte)) + }) +} + +pub(crate) fn build_sysv_hash(names: &[Vec]) -> Result> { + if names.len() < 2 { + return invalid("dynamic symbol table is unexpectedly empty"); + } + let bucket_count = names.len(); + let symbol_count = names.len(); + let mut buckets = vec![0_u32; bucket_count]; + let mut chains = vec![0_u32; symbol_count]; + for (symbol_index, name) in names.iter().enumerate().skip(1) { + let bucket_index = elf_hash(name) as usize % bucket_count; + let symbol_index32 = u32::try_from(symbol_index) + .map_err(|_| Error::Invalid("dynamic symbol index exceeds u32".to_owned()))?; + if buckets[bucket_index] == 0 { + buckets[bucket_index] = symbol_index32; + continue; + } + let mut chain_index = buckets[bucket_index] as usize; + while chains[chain_index] != 0 { + chain_index = chains[chain_index] as usize; + } + chains[chain_index] = symbol_index32; + } + let mut output = Vec::with_capacity((2 + bucket_count + symbol_count) * 4); + output.extend_from_slice( + &u32::try_from(bucket_count) + .map_err(|_| Error::Invalid("SysV bucket count exceeds u32".to_owned()))? + .to_le_bytes(), + ); + output.extend_from_slice( + &u32::try_from(symbol_count) + .map_err(|_| Error::Invalid("SysV symbol count exceeds u32".to_owned()))? + .to_le_bytes(), + ); + for value in buckets.into_iter().chain(chains) { + output.extend_from_slice(&value.to_le_bytes()); + } + Ok(output) +} + +pub(crate) fn build_gnu_hash(names: &[Vec]) -> Result> { + let hashes = names + .iter() + .skip(1) + .map(|name| gnu_hash(name)) + .collect::>(); + if hashes.is_empty() { + return invalid("GNU hash requires at least one dynamic symbol"); + } + let bloom_shift = 5_u32; + let mut bloom_word = 0_u64; + for &value in &hashes { + bloom_word |= 1_u64 << (value & 63); + bloom_word |= 1_u64 << ((value >> bloom_shift) & 63); + } + let mut chains = hashes + .into_iter() + .map(|value| value & !1) + .collect::>(); + let last = chains + .last_mut() + .ok_or_else(|| Error::Invalid("GNU hash chain is empty".to_owned()))?; + *last |= 1; + let mut output = Vec::with_capacity(28 + chains.len() * 4); + for value in [1_u32, 1, 1, bloom_shift] { + output.extend_from_slice(&value.to_le_bytes()); + } + output.extend_from_slice(&bloom_word.to_le_bytes()); + output.extend_from_slice(&1_u32.to_le_bytes()); + for value in chains { + output.extend_from_slice(&value.to_le_bytes()); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_elf_hash_is_stable() { + assert_eq!(elf_hash(b"printf"), 0x0779_05a6); + assert_eq!(gnu_hash(b"printf"), 0x156b_2bb8); + } +} diff --git a/senbei-android-elf/src/layout.rs b/senbei-android-elf/src/layout.rs new file mode 100644 index 0000000..5ffc7e9 --- /dev/null +++ b/senbei-android-elf/src/layout.rs @@ -0,0 +1,301 @@ +use crate::error::{Error, Result, invalid}; + +pub(crate) const SHT_NOBITS: u32 = 8; +pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; +pub(crate) const SHF_ALLOC: u64 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LoadSegment { + pub offset: u64, + pub virtual_address: u64, + pub file_size: u64, + pub memory_size: u64, + pub flags: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SectionHeader { + pub name: u32, + pub section_type: u32, + pub flags: u64, + pub address: u64, + pub offset: u64, + pub size: u64, + pub link: u32, + pub info: u32, + pub alignment: u64, + pub entry_size: u64, +} + +impl SectionHeader { + pub const SIZE: usize = 0x40; + + fn parse(data: &[u8], offset: usize) -> Result { + Ok(Self { + name: read_u32(data, offset)?, + section_type: read_u32(data, offset + 4)?, + flags: read_u64(data, offset + 8)?, + address: read_u64(data, offset + 0x10)?, + offset: read_u64(data, offset + 0x18)?, + size: read_u64(data, offset + 0x20)?, + link: read_u32(data, offset + 0x28)?, + info: read_u32(data, offset + 0x2c)?, + alignment: read_u64(data, offset + 0x30)?, + entry_size: read_u64(data, offset + 0x38)?, + }) + } + + pub fn encode(self) -> [u8; Self::SIZE] { + let mut output = [0_u8; Self::SIZE]; + output[0..4].copy_from_slice(&self.name.to_le_bytes()); + output[4..8].copy_from_slice(&self.section_type.to_le_bytes()); + output[8..0x10].copy_from_slice(&self.flags.to_le_bytes()); + output[0x10..0x18].copy_from_slice(&self.address.to_le_bytes()); + output[0x18..0x20].copy_from_slice(&self.offset.to_le_bytes()); + output[0x20..0x28].copy_from_slice(&self.size.to_le_bytes()); + output[0x28..0x2c].copy_from_slice(&self.link.to_le_bytes()); + output[0x2c..0x30].copy_from_slice(&self.info.to_le_bytes()); + output[0x30..0x38].copy_from_slice(&self.alignment.to_le_bytes()); + output[0x38..0x40].copy_from_slice(&self.entry_size.to_le_bytes()); + output + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ElfLayout { + pub entrypoint: u64, + pub program_headers: Vec, + pub section_headers: Vec, + pub section_name_index: usize, + pub private_section_index: usize, +} + +impl ElfLayout { + pub fn parse(data: &[u8], require_private: bool) -> Result { + let ident = slice(data, 0, 6)?; + if ident[..4] != *b"\x7fELF" || ident[4] != 2 || ident[5] != 1 { + return invalid("input is not a little-endian ELF64 file"); + } + if read_u16(data, 0x12)? != 0xb7 { + return invalid("input is not an AArch64 ELF"); + } + let entrypoint = read_u64(data, 0x18)?; + let program_header_offset = usize_from_u64(read_u64(data, 0x20)?, "program header offset")?; + let section_header_offset = usize_from_u64(read_u64(data, 0x28)?, "section header offset")?; + let program_header_size = usize::from(read_u16(data, 0x36)?); + let program_header_count = usize::from(read_u16(data, 0x38)?); + let section_header_size = usize::from(read_u16(data, 0x3a)?); + let section_header_count = usize::from(read_u16(data, 0x3c)?); + let section_name_index = usize::from(read_u16(data, 0x3e)?); + if program_header_size != 0x38 || section_header_size != SectionHeader::SIZE { + return invalid("unexpected ELF program/section header size"); + } + + let mut program_headers = Vec::new(); + for index in 0..program_header_count { + let offset = checked_index(program_header_offset, index, program_header_size)?; + if read_u32(data, offset)? != 1 { + continue; + } + let segment = LoadSegment { + flags: read_u32(data, offset + 4)?, + offset: read_u64(data, offset + 8)?, + virtual_address: read_u64(data, offset + 0x10)?, + file_size: read_u64(data, offset + 0x20)?, + memory_size: read_u64(data, offset + 0x28)?, + }; + let file_end = segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid(format!("PT_LOAD {index} file range overflow")))?; + if file_end > data.len() as u64 { + return invalid(format!("PT_LOAD {index} exceeds input file")); + } + program_headers.push(segment); + } + if program_headers.is_empty() { + return invalid("input ELF contains no PT_LOAD segments"); + } + + let mut section_headers = Vec::with_capacity(section_header_count); + for index in 0..section_header_count { + let offset = checked_index(section_header_offset, index, section_header_size)?; + section_headers.push(SectionHeader::parse(data, offset)?); + } + if section_name_index >= section_headers.len() { + return invalid("ELF section-name index is out of range"); + } + let private = section_headers + .iter() + .enumerate() + .filter_map(|(index, section)| (section.section_type == SHT_LOUSER).then_some(index)) + .collect::>(); + let private_section_index = match private.as_slice() { + [index] => *index, + [] if !require_private => usize::MAX, + _ => { + return invalid(format!( + "expected {} SHT_LOUSER section, found {}", + if require_private { + "one" + } else { + "at most one" + }, + private.len() + )); + } + }; + Ok(Self { + entrypoint, + program_headers, + section_headers, + section_name_index, + private_section_index, + }) + } + + pub fn private_section(&self) -> Result { + self.section_headers + .get(self.private_section_index) + .copied() + .ok_or_else(|| Error::Invalid("ELF has no private section".to_owned())) + } + + pub fn load_end(&self) -> Result { + self.program_headers + .iter() + .map(|segment| { + segment + .virtual_address + .checked_add(segment.memory_size) + .ok_or_else(|| Error::Invalid("PT_LOAD memory end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("ELF has no PT_LOAD memory range".to_owned())) + } + + pub fn file_load_end(&self) -> Result { + self.program_headers + .iter() + .map(|segment| { + segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("ELF has no PT_LOAD file range".to_owned())) + } + + pub fn section_names(&self, data: &[u8]) -> Result> { + let table = self.section_headers[self.section_name_index]; + let strings = slice_u64(data, table.offset, table.size)?; + self.section_headers + .iter() + .map(|section| { + let offset = section.name as usize; + if offset >= strings.len() { + return Ok(String::new()); + } + let end = strings[offset..] + .iter() + .position(|&byte| byte == 0) + .map_or(strings.len(), |length| offset + length); + Ok(String::from_utf8_lossy(&strings[offset..end]).into_owned()) + }) + .collect() + } + + pub fn file_offset_to_virtual_address(&self, offset: u64, size: u64) -> Result { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("file range overflow".to_owned()))?; + for segment in &self.program_headers { + let segment_end = segment + .offset + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file range overflow".to_owned()))?; + if segment.offset <= offset && end <= segment_end { + return segment + .virtual_address + .checked_add(offset - segment.offset) + .ok_or_else(|| Error::Invalid("virtual address overflow".to_owned())); + } + } + invalid(format!( + "file range 0x{offset:x}..0x{end:x} is not in PT_LOAD" + )) + } +} + +pub(crate) fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +pub(crate) fn slice_u64(data: &[u8], offset: u64, size: u64) -> Result<&[u8]> { + slice( + data, + usize_from_u64(offset, "file offset")?, + usize_from_u64(size, "file size")?, + ) +} + +pub(crate) fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = slice(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +pub(crate) fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = slice(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +pub(crate) fn read_u64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = slice(data, offset, 8)? + .try_into() + .map_err(|_| Error::Invalid("invalid u64 range".to_owned()))?; + Ok(u64::from_le_bytes(bytes)) +} + +pub(crate) fn read_i64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = slice(data, offset, 8)? + .try_into() + .map_err(|_| Error::Invalid("invalid i64 range".to_owned()))?; + Ok(i64::from_le_bytes(bytes)) +} + +pub(crate) fn usize_from_u64(value: u64, field: &str) -> Result { + usize::try_from(value).map_err(|_| Error::Invalid(format!("{field} 0x{value:x} exceeds usize"))) +} + +pub(crate) fn checked_index(base: usize, index: usize, stride: usize) -> Result { + index + .checked_mul(stride) + .and_then(|value| base.checked_add(value)) + .ok_or_else(|| Error::Invalid("table index overflow".to_owned())) +} + +pub(crate) fn align_up(value: u64, alignment: u64) -> Result { + if alignment == 0 || !alignment.is_power_of_two() { + return invalid(format!("invalid alignment {alignment}")); + } + value + .checked_add(alignment - 1) + .map(|aligned| aligned & !(alignment - 1)) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} diff --git a/senbei-android-elf/src/lib.rs b/senbei-android-elf/src/lib.rs new file mode 100644 index 0000000..4c57c0a --- /dev/null +++ b/senbei-android-elf/src/lib.rs @@ -0,0 +1,10 @@ +//! Static restoration of the current protected AArch64 `libil2cpp.so`. + +mod artifact; +mod error; +mod hash; +mod layout; +mod restore; + +pub use error::Error; +pub use restore::{RestoreOptions, RestoreReport, restore_libil2cpp}; diff --git a/senbei-android-elf/src/restore.rs b/senbei-android-elf/src/restore.rs new file mode 100644 index 0000000..8aae28c --- /dev/null +++ b/senbei-android-elf/src/restore.rs @@ -0,0 +1,1407 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use memmap2::{Mmap, MmapMut, MmapOptions}; +use senbei_android_crypto::{ + ContainerHeader, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, transform_segment, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; + +use crate::artifact::load_artifacts; +use crate::error::{Error, Result, invalid}; +use crate::hash::{build_gnu_hash, build_sysv_hash}; +use crate::layout::{ + ElfLayout, SHF_ALLOC, SHT_LOUSER, SHT_NOBITS, SectionHeader, align_up, read_i64, read_u32, + read_u64, slice, slice_u64, usize_from_u64, +}; + +const CHUNK_SIZE: usize = 16 * 1024 * 1024; +const ELF64_SYMBOL_SIZE: usize = 0x18; +const ELF64_RELA_SIZE: usize = 0x18; +const R_AARCH64_ABS64: u32 = 0x101; +const R_AARCH64_GLOB_DAT: u32 = 0x401; +const R_AARCH64_JUMP_SLOT: u32 = 0x402; +const R_AARCH64_RELATIVE: u32 = 0x403; +const VER_NDX_GLOBAL: u16 = 1; + +const DT_PLTRELSZ: u64 = 2; +const DT_HASH: u64 = 4; +const DT_STRTAB: u64 = 5; +const DT_SYMTAB: u64 = 6; +const DT_RELA: u64 = 7; +const DT_RELASZ: u64 = 8; +const DT_STRSZ: u64 = 10; +const DT_JMPREL: u64 = 23; +const DT_GNU_HASH: u64 = 0x6fff_fef5; +const DT_VERSYM: u64 = 0x6fff_fff0; +const DT_RELACOUNT: u64 = 0x6fff_fff9; +const DT_VERNEED: u64 = 0x6fff_fffe; + +/// Inputs and optional diagnostics for one `libil2cpp.so` restoration. +#[derive(Debug, Clone)] +pub struct RestoreOptions { + pub input: PathBuf, + pub output: PathBuf, + pub index: PathBuf, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Container decoding counters. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct DecodeStatistics { + pub segments: usize, + pub writers: usize, + pub compressed_writers: usize, + pub encoded_bytes: u64, + pub decoded_bytes: u64, + pub file_bytes_written: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StaticConfigReport { + pub header_seed: String, + pub container_seed: String, + pub aes_key_sha256: String, + pub schedule_offset: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DescriptorReport { + pub command_id: String, + pub flags: String, + pub outer_offset: String, + pub outer_expected_size: String, + pub auxiliary_offset: String, + pub auxiliary_expected_size: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HiddenSymbolReport { + pub patch_blob_size: u32, + pub patched_symbols: u32, + pub copied_strings: usize, + pub secondary_record_count: u32, + pub first_target_index: u32, + pub last_target_index: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PlacementReport { + pub offset: u64, + pub size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ElfMaterializationReport { + pub hidden_symbols: HiddenSymbolReport, + pub old_symbol_count: usize, + pub auxiliary_symbol_count: u32, + pub appended_symbols: usize, + pub new_symbol_count: usize, + pub old_dynstr_size: usize, + pub auxiliary_dynstr_size: u32, + pub new_dynstr_size: usize, + pub rela_dyn_count: usize, + pub rela_plt_count: usize, + pub relative_prefix_count: usize, + pub metadata_start: u64, + pub metadata_end: u64, + pub metadata_capacity_end: u64, + pub metadata_slack: u64, + pub placements: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CleaningReport { + pub private_section_index: usize, + pub private_offset: u64, + pub private_size: u64, + pub input_entrypoint: u64, + pub output_entrypoint: u64, + pub output_section_count: usize, + pub retained_sections: Vec, + pub section_header_offset: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidationReport { + pub format: String, + pub machine: String, + pub sections: usize, + pub segments: usize, + pub dynamic_symbols: usize, + pub dynamic_relocations: usize, + pub pltgot_relocations: usize, + pub has_louser: bool, +} + +/// Machine-readable result of the restoration. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RestoreReport { + pub input: String, + pub input_sha256: String, + pub output: String, + pub output_sha256: String, + pub output_size: u64, + pub module_index: String, + pub static_config: StaticConfigReport, + pub descriptor: DescriptorReport, + pub primary: DecodeStatistics, + pub auxiliary: Option, + pub elf_materialization: Option, + pub cleaning: CleaningReport, + pub validation: ValidationReport, + pub elapsed_seconds: f64, +} + +fn map_read_only(file: &File, path: &Path) -> Result { + // SAFETY: the mapping is read-only and `file` remains open for the mapping's + // lifetime. The restoration never mutates or truncates the mapped source. + unsafe { MmapOptions::new().map(file) }.map_err(|error| Error::io("map", path, error)) +} + +fn map_mut(file: &File, length: usize, path: &Path) -> Result { + // SAFETY: `length` is set on the private temporary output immediately + // before this call. No other handle mutates or truncates it while mapped. + unsafe { MmapOptions::new().len(length).map_mut(file) } + .map_err(|error| Error::io("map temporary output", path, error)) +} + +fn read_file(path: &Path) -> Result> { + std::fs::read(path).map_err(|error| Error::io("read", path, error)) +} + +fn sha256_bytes(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|error| Error::io("open", path, error))?; + let mut digest = Sha256::new(); + let mut buffer = vec![0_u8; CHUNK_SIZE]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| Error::io("hash", path, error))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn copy_range(source: &[u8], output: &mut File, size: usize, path: &Path) -> Result<()> { + for chunk in source[..size].chunks(CHUNK_SIZE) { + output + .write_all(chunk) + .map_err(|error| Error::io("write temporary output", path, error))?; + } + Ok(()) +} + +fn checked_add(base: usize, value: usize, field: &str) -> Result { + base.checked_add(value) + .ok_or_else(|| Error::Invalid(format!("{field} overflow"))) +} + +struct FileLayoutWriter<'a> { + output: &'a mut [u8], + layout: &'a ElfLayout, + load_end: u64, +} + +impl FileLayoutWriter<'_> { + fn write(&mut self, virtual_address: u64, data: &[u8]) -> Result { + let data_len = u64::try_from(data.len()) + .map_err(|_| Error::Invalid("decoded write length exceeds u64".to_owned()))?; + let end = virtual_address + .checked_add(data_len) + .ok_or_else(|| Error::Invalid("decoded write range overflow".to_owned()))?; + if end > self.load_end { + return invalid(format!( + "decoded write 0x{virtual_address:x}..0x{end:x} exceeds target load image" + )); + } + let mut written = 0_u64; + let mut covered_memory = 0_u64; + for segment in &self.layout.program_headers { + let memory_end = segment + .virtual_address + .checked_add(segment.memory_size) + .ok_or_else(|| Error::Invalid("PT_LOAD memory end overflow".to_owned()))?; + let overlap_start = virtual_address.max(segment.virtual_address); + let overlap_end = end.min(memory_end); + if overlap_start >= overlap_end { + continue; + } + covered_memory = covered_memory + .checked_add(overlap_end - overlap_start) + .ok_or_else(|| Error::Invalid("covered memory count overflow".to_owned()))?; + let file_end_va = segment + .virtual_address + .checked_add(segment.file_size) + .ok_or_else(|| Error::Invalid("PT_LOAD file VA end overflow".to_owned()))?; + let file_overlap_end = overlap_end.min(file_end_va); + if overlap_start < file_overlap_end { + let source_offset = + usize_from_u64(overlap_start - virtual_address, "write source offset")?; + let file_offset = usize_from_u64( + segment + .offset + .checked_add(overlap_start - segment.virtual_address) + .ok_or_else(|| Error::Invalid("write file offset overflow".to_owned()))?, + "write file offset", + )?; + let count = usize_from_u64(file_overlap_end - overlap_start, "write size")?; + let destination = self + .output + .get_mut(file_offset..file_offset + count) + .ok_or_else(|| { + Error::Invalid("decoded write exceeds temporary output".to_owned()) + })?; + destination.copy_from_slice(&data[source_offset..source_offset + count]); + written += count as u64; + } + } + if covered_memory != data_len { + return invalid(format!( + "decoded write 0x{virtual_address:x}..0x{end:x} is not covered by PT_LOAD memory" + )); + } + usize_from_u64(written, "written byte count") + } +} + +fn decode_container( + payload: &[u8], + header: &ContainerHeader, + config: &Module9bConfig, + mut writer: F, +) -> Result +where + F: FnMut(u64, &[u8]) -> Result, +{ + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let mut statistics = DecodeStatistics { + segments: header.segments.len(), + ..DecodeStatistics::default() + }; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = checked_add( + header.start, + encoded.offset as usize, + "encoded segment start", + )?; + let encoded_data = slice(payload, start, encoded.size as usize)?; + let transformed = transform_segment( + encoded_data, + config.container_seed, + &config.aes_key, + decrypt_aes, + )?; + if transformed.len() < 16 { + return invalid(format!( + "decoded segment {segment_index} is shorter than its header" + )); + } + let base_offset = u64::from(read_u32(&transformed, 0)?); + let writer_count = read_u32(&transformed, 4)? as usize; + let table_offset = read_u32(&transformed, 8)? as usize; + let data_offset = read_u32(&transformed, 12)? as usize; + let table_end = table_offset + .checked_add( + writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?, + ) + .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; + if table_end > transformed.len() || data_offset > transformed.len() { + return invalid(format!( + "decoded segment {segment_index} has invalid writer offsets" + )); + } + let mut data_cursor = data_offset; + for writer_index in 0..writer_count { + let record = table_offset + writer_index * 16; + let output_offset = u64::from(read_u32(&transformed, record)?); + let output_size = read_u32(&transformed, record + 4)? as usize; + let encoded_size = read_u32(&transformed, record + 8)? as usize; + let reserved = read_u32(&transformed, record + 12)?; + let encoded_end = data_cursor + .checked_add(encoded_size) + .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; + if reserved != 0 || encoded_end > transformed.len() { + return invalid(format!( + "segment {segment_index} writer {writer_index} has invalid bounds" + )); + } + let source = &transformed[data_cursor..encoded_end]; + let decoded = if encoded_size == output_size { + None + } else { + statistics.compressed_writers += 1; + Some(decoder.decode(source, output_size)?) + }; + let decoded_slice = decoded.as_deref().unwrap_or(source); + let target = base_offset + .checked_add(output_offset) + .ok_or_else(|| Error::Invalid("writer target address overflow".to_owned()))?; + statistics.file_bytes_written += writer(target, decoded_slice)? as u64; + statistics.writers += 1; + statistics.encoded_bytes += encoded_size as u64; + statistics.decoded_bytes += output_size as u64; + data_cursor = encoded_end; + } + eprintln!( + "[{current:02}/{total:02}] writers={writer_count} encoded=0x{size:x}", + current = segment_index + 1, + total = header.segments.len(), + size = encoded.size, + ); + } + Ok(statistics) +} + +fn read_c_string(data: &[u8], offset: usize, limit: usize) -> Result<&[u8]> { + if offset >= limit || limit > data.len() { + return invalid(format!("invalid string offset 0x{offset:x}/0x{limit:x}")); + } + let relative_end = data[offset..limit] + .iter() + .position(|&byte| byte == 0) + .ok_or_else(|| Error::Invalid(format!("unterminated string at 0x{offset:x}")))?; + Ok(&data[offset..offset + relative_end]) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AuxiliaryElfImage { + dynstr_offset: u32, + dynstr_size: u32, + dynsym_offset: u32, + dynsym_count: u32, + relocation1_offset: u32, + relocation1_count: u32, + relocation2_offset: u32, + relocation2_count: u32, +} + +impl AuxiliaryElfImage { + fn parse(data: &[u8]) -> Result { + if data.len() < 0x40 { + return invalid("decoded auxiliary ELF image is truncated"); + } + let mut words = [0_u32; 16]; + for (index, word) in words.iter_mut().enumerate() { + *word = read_u32(data, index * 4)?; + } + if [1, 3, 12, 13, 15] + .into_iter() + .any(|index| words[index] != 0) + { + return invalid("unexpected nonzero auxiliary ELF header field"); + } + if words[14] != 0xb7 { + return invalid(format!( + "unexpected auxiliary ELF machine 0x{:x}", + words[14] + )); + } + let result = Self { + dynstr_offset: words[4], + dynstr_size: words[5], + dynsym_offset: words[6], + dynsym_count: words[7], + relocation1_offset: words[8], + relocation1_count: words[9], + relocation2_offset: words[10], + relocation2_count: words[11], + }; + if result.relocation1_offset != 0x40 { + return invalid("auxiliary relocation table does not follow its header"); + } + let relocation1_end = u64::from(result.relocation1_offset) + + u64::from(result.relocation1_count) * ELF64_RELA_SIZE as u64; + let relocation2_end = u64::from(result.relocation2_offset) + + u64::from(result.relocation2_count) * ELF64_RELA_SIZE as u64; + let dynsym_end = u64::from(result.dynsym_offset) + + u64::from(result.dynsym_count) * ELF64_SYMBOL_SIZE as u64; + let dynstr_end = u64::from(result.dynstr_offset) + u64::from(result.dynstr_size); + if relocation1_end != u64::from(result.relocation2_offset) + || relocation2_end != u64::from(result.dynsym_offset) + || dynsym_end != u64::from(result.dynstr_offset) + || dynstr_end != data.len() as u64 + { + return invalid("auxiliary ELF tables are not contiguous"); + } + if result.dynsym_count < 2 { + return invalid("auxiliary dynamic symbol table is empty"); + } + if slice(data, result.dynsym_offset as usize, ELF64_SYMBOL_SIZE)? + .iter() + .any(|&byte| byte != 0) + { + return invalid("auxiliary dynamic symbol zero entry is not empty"); + } + Ok(result) + } +} + +fn restore_hidden_symbols( + output: &mut [u8], + dynsym: SectionHeader, + dynstr: SectionHeader, + patch_data: &[u8], +) -> Result<(Vec, Vec, HiddenSymbolReport)> { + if dynsym.entry_size != ELF64_SYMBOL_SIZE as u64 || dynsym.size % ELF64_SYMBOL_SIZE as u64 != 0 + { + return invalid("unexpected .dynsym entry layout"); + } + let symbol_count = usize_from_u64( + dynsym.size / ELF64_SYMBOL_SIZE as u64, + "dynamic symbol count", + )?; + let mut symbols = slice_u64(output, dynsym.offset, dynsym.size)?.to_vec(); + let mut strings = slice_u64(output, dynstr.offset, dynstr.size)?.to_vec(); + if patch_data.len() < 0x18 { + return invalid("0x9E symbol patch data is truncated"); + } + let blob_size = read_u32(patch_data, 0)?; + let secondary_record_count = read_u32(patch_data, 4)?; + let table_base = 8_usize; + let table_end = table_base + .checked_add(blob_size as usize) + .ok_or_else(|| Error::Invalid("0x9E patch blob end overflow".to_owned()))?; + if table_end > patch_data.len() { + return invalid("0x9E primary symbol patch blob exceeds its artifact"); + } + let count = read_u32(patch_data, table_base)?; + let symbol_offset = read_u32(patch_data, table_base + 4)? as usize; + let index_offset = read_u32(patch_data, table_base + 8)? as usize; + let string_offset = read_u32(patch_data, table_base + 12)? as usize; + let count_usize = count as usize; + if symbol_offset + .checked_add(count_usize * ELF64_SYMBOL_SIZE) + .is_none_or(|end| end > blob_size as usize) + || index_offset + .checked_add(count_usize * 4) + .is_none_or(|end| end > blob_size as usize) + || string_offset >= blob_size as usize + { + return invalid("0x9E symbol patch table has invalid offsets"); + } + let mut cursor = table_base + string_offset; + let mut patched_indices = HashSet::with_capacity(count_usize); + let mut copied_strings = 0_usize; + for index in 0..count_usize { + let source_offset = table_base + symbol_offset + index * ELF64_SYMBOL_SIZE; + let source_symbol = slice(patch_data, source_offset, ELF64_SYMBOL_SIZE)?; + let target_index = read_u32(patch_data, table_base + index_offset + index * 4)?; + if target_index == 0 || target_index as usize >= symbol_count { + return invalid(format!( + "0x9E target symbol index {target_index} is invalid" + )); + } + if !patched_indices.insert(target_index) { + return invalid(format!("0x9E patches symbol {target_index} more than once")); + } + let name = read_c_string(patch_data, cursor, table_end)?; + cursor += name.len() + 1; + let name_offset = read_u32(source_symbol, 0)? as usize; + if name_offset + .checked_add(name.len() + 1) + .is_none_or(|end| end > strings.len()) + { + return invalid(format!("0x9E symbol {target_index} name exceeds .dynstr")); + } + let existing = read_c_string(&strings, name_offset, strings.len())?; + if existing.is_empty() { + strings[name_offset..name_offset + name.len()].copy_from_slice(name); + strings[name_offset + name.len()] = 0; + copied_strings += 1; + } else if existing != name { + return invalid(format!( + "0x9E symbol {target_index} conflicts with existing .dynstr data" + )); + } + let target_offset = target_index as usize * ELF64_SYMBOL_SIZE; + symbols[target_offset..target_offset + ELF64_SYMBOL_SIZE].copy_from_slice(source_symbol); + } + if cursor != table_end { + return invalid(format!( + "0x9E symbol strings end at 0x{cursor:x}, expected 0x{table_end:x}" + )); + } + let first_target_index = patched_indices + .iter() + .copied() + .min() + .ok_or_else(|| Error::Invalid("0x9E patch table is empty".to_owned()))?; + let last_target_index = patched_indices + .iter() + .copied() + .max() + .ok_or_else(|| Error::Invalid("0x9E patch table is empty".to_owned()))?; + Ok(( + symbols, + strings, + HiddenSymbolReport { + patch_blob_size: blob_size, + patched_symbols: count, + copied_strings, + secondary_record_count, + first_target_index, + last_target_index, + }, + )) +} + +fn dynamic_symbol_names(symbols: &[u8], strings: &[u8]) -> Result>> { + if symbols.len() % ELF64_SYMBOL_SIZE != 0 { + return invalid("dynamic symbol table is not entry-aligned"); + } + symbols + .chunks_exact(ELF64_SYMBOL_SIZE) + .map(|symbol| { + let name_offset = read_u32(symbol, 0)? as usize; + Ok(read_c_string(strings, name_offset, strings.len())?.to_vec()) + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Rela { + offset: u64, + info: u64, + addend: i64, +} + +impl Rela { + fn parse(data: &[u8], offset: usize) -> Result { + Ok(Self { + offset: read_u64(data, offset)?, + info: read_u64(data, offset + 8)?, + addend: read_i64(data, offset + 0x10)?, + }) + } + + fn kind(self) -> u32 { + self.info as u32 + } + + fn symbol(self) -> u64 { + self.info >> 32 + } + + fn encode(self, output: &mut Vec) { + output.extend_from_slice(&self.offset.to_le_bytes()); + output.extend_from_slice(&self.info.to_le_bytes()); + output.extend_from_slice(&self.addend.to_le_bytes()); + } +} + +#[derive(Clone, Copy)] +struct RelocationTable<'a> { + data: &'a [u8], + offset: usize, + count: usize, + remap: Option<(usize, u32)>, +} + +impl RelocationTable<'_> { + fn relocation(self, index: usize) -> Result { + if index >= self.count { + return invalid("relocation index is out of range"); + } + let offset = self + .offset + .checked_add( + index + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("relocation index overflow".to_owned()))?, + ) + .ok_or_else(|| Error::Invalid("relocation offset overflow".to_owned()))?; + let mut relocation = Rela::parse(self.data, offset)?; + if let Some((old_symbol_count, auxiliary_symbol_count)) = self.remap { + let symbol = relocation.symbol(); + if symbol >= u64::from(auxiliary_symbol_count) { + return invalid("auxiliary relocation references an invalid symbol"); + } + if symbol != 0 { + let base = u64::try_from(old_symbol_count.checked_sub(1).ok_or_else(|| { + Error::Invalid("old dynamic symbol table is empty".to_owned()) + })?) + .map_err(|_| Error::Invalid("old symbol count exceeds u64".to_owned()))?; + let remapped = symbol + .checked_add(base) + .ok_or_else(|| Error::Invalid("remapped symbol index overflow".to_owned()))?; + relocation.info = (remapped << 32) | u64::from(relocation.kind()); + } + } + Ok(relocation) + } + + fn validate(self, allowed: &[u32], description: &str) -> Result<()> { + for index in 0..self.count { + let kind = self.relocation(index)?.kind(); + if !allowed.contains(&kind) { + return invalid(format!( + "{description} contains unsupported relocation type 0x{kind:x}" + )); + } + } + Ok(()) + } + + fn append_where(self, output: &mut Vec, predicate: impl Fn(u32) -> bool) -> Result { + let mut count = 0_usize; + for index in 0..self.count { + let relocation = self.relocation(index)?; + if predicate(relocation.kind()) { + relocation.encode(output); + count += 1; + } + } + Ok(count) + } +} + +fn patch_dynamic_tags( + output: &mut [u8], + dynamic: SectionHeader, + values: &BTreeMap, +) -> Result<()> { + if dynamic.size % 0x10 != 0 { + return invalid(".dynamic size is not entry-aligned"); + } + let start = usize_from_u64(dynamic.offset, ".dynamic offset")?; + let size = usize_from_u64(dynamic.size, ".dynamic size")?; + let end = start + .checked_add(size) + .ok_or_else(|| Error::Invalid(".dynamic end overflow".to_owned()))?; + slice(output, start, size)?; + let mut found = HashSet::with_capacity(values.len()); + for offset in (start..end).step_by(0x10) { + let tag = read_u64(output, offset)?; + if let Some(&value) = values.get(&tag) { + if !found.insert(tag) { + return invalid(format!("dynamic tag 0x{tag:x} occurs more than once")); + } + output[offset + 8..offset + 0x10].copy_from_slice(&value.to_le_bytes()); + } + if tag == 0 { + break; + } + } + let missing = values + .keys() + .filter(|tag| !found.contains(tag)) + .map(|tag| format!("0x{tag:x}")) + .collect::>(); + if !missing.is_empty() { + return invalid(format!("missing dynamic tags: {}", missing.join(", "))); + } + Ok(()) +} + +fn required_section_indices(names: &[String]) -> Result> { + const REQUIRED: [&str; 10] = [ + ".dynsym", + ".gnu.version", + ".gnu.version_r", + ".gnu.hash", + ".hash", + ".dynstr", + ".rela.dyn", + ".rela.plt", + ".dynamic", + ".rodata", + ]; + let mut result = HashMap::with_capacity(REQUIRED.len()); + for required in REQUIRED { + let indices = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == required).then_some(index)) + .collect::>(); + match indices.as_slice() { + [index] => { + result.insert(required, *index); + } + [] => return invalid(format!("ELF lacks required section {required}")), + _ => return invalid(format!("ELF contains duplicate section {required}")), + } + } + Ok(result) +} + +fn materialize_static_elf_tables( + output: &mut [u8], + source: &[u8], + layout: &ElfLayout, + symbol_patch_data: &[u8], + auxiliary_data: &[u8], +) -> Result<(ElfLayout, ElfMaterializationReport)> { + let names = layout.section_names(source)?; + let indices = required_section_indices(&names)?; + let section = |name: &'static str| -> SectionHeader { layout.section_headers[indices[name]] }; + let dynsym = section(".dynsym"); + let dynstr = section(".dynstr"); + let versym = section(".gnu.version"); + let verneed = section(".gnu.version_r"); + let rela_dyn = section(".rela.dyn"); + let rela_plt = section(".rela.plt"); + let dynamic = section(".dynamic"); + let rodata = section(".rodata"); + + let (old_symbols, old_strings, hidden_symbols) = + restore_hidden_symbols(output, dynsym, dynstr, symbol_patch_data)?; + let old_symbol_count = old_symbols.len() / ELF64_SYMBOL_SIZE; + if versym.size != (old_symbol_count * 2) as u64 { + return invalid(".gnu.version count does not match .dynsym"); + } + let old_versions = slice_u64(output, versym.offset, versym.size)?.to_vec(); + let version_requirements = slice_u64(output, verneed.offset, verneed.size)?.to_vec(); + + let auxiliary = AuxiliaryElfImage::parse(auxiliary_data)?; + let auxiliary_strings = slice( + auxiliary_data, + auxiliary.dynstr_offset as usize, + auxiliary.dynstr_size as usize, + )?; + let appended_count = auxiliary.dynsym_count as usize - 1; + let mut appended_symbols = Vec::with_capacity(appended_count * ELF64_SYMBOL_SIZE); + for index in 1..auxiliary.dynsym_count as usize { + let offset = auxiliary.dynsym_offset as usize + index * ELF64_SYMBOL_SIZE; + let symbol = slice(auxiliary_data, offset, ELF64_SYMBOL_SIZE)?; + let name_offset = read_u32(symbol, 0)? as usize; + read_c_string(auxiliary_strings, name_offset, auxiliary_strings.len())?; + let section_index = u16::from_le_bytes([symbol[6], symbol[7]]); + if section_index != 0 { + return invalid("auxiliary dynamic symbol is unexpectedly defined"); + } + let merged_name_offset = old_strings + .len() + .checked_add(name_offset) + .ok_or_else(|| Error::Invalid("merged dynamic string offset overflow".to_owned()))?; + let merged_name_offset = u32::try_from(merged_name_offset) + .map_err(|_| Error::Invalid("merged dynamic string offset exceeds u32".to_owned()))?; + appended_symbols.extend_from_slice(&merged_name_offset.to_le_bytes()); + appended_symbols.extend_from_slice(&symbol[4..]); + } + let mut merged_symbols = Vec::with_capacity(old_symbols.len() + appended_symbols.len()); + merged_symbols.extend_from_slice(&old_symbols); + merged_symbols.extend_from_slice(&appended_symbols); + let mut merged_strings = Vec::with_capacity(old_strings.len() + auxiliary_strings.len()); + merged_strings.extend_from_slice(&old_strings); + merged_strings.extend_from_slice(auxiliary_strings); + let mut merged_versions = Vec::with_capacity(old_versions.len() + appended_count * 2); + merged_versions.extend_from_slice(&old_versions); + for _ in 0..appended_count { + merged_versions.extend_from_slice(&VER_NDX_GLOBAL.to_le_bytes()); + } + let merged_names = dynamic_symbol_names(&merged_symbols, &merged_strings)?; + let sysv_hash = build_sysv_hash(&merged_names)?; + let gnu_hash_table = build_gnu_hash(&merged_names)?; + let new_symbol_count = merged_names.len(); + + if rela_dyn.entry_size != ELF64_RELA_SIZE as u64 + || rela_plt.entry_size != ELF64_RELA_SIZE as u64 + || rela_dyn.size % ELF64_RELA_SIZE as u64 != 0 + || rela_plt.size % ELF64_RELA_SIZE as u64 != 0 + { + return invalid("unexpected relocation entry layout"); + } + let old_dyn = RelocationTable { + data: output, + offset: usize_from_u64(rela_dyn.offset, ".rela.dyn offset")?, + count: usize_from_u64(rela_dyn.size / ELF64_RELA_SIZE as u64, ".rela.dyn count")?, + remap: None, + }; + let old_plt = RelocationTable { + data: output, + offset: usize_from_u64(rela_plt.offset, ".rela.plt offset")?, + count: usize_from_u64(rela_plt.size / ELF64_RELA_SIZE as u64, ".rela.plt count")?, + remap: None, + }; + let auxiliary1 = RelocationTable { + data: auxiliary_data, + offset: auxiliary.relocation1_offset as usize, + count: auxiliary.relocation1_count as usize, + remap: Some((old_symbol_count, auxiliary.dynsym_count)), + }; + let auxiliary2 = RelocationTable { + data: auxiliary_data, + offset: auxiliary.relocation2_offset as usize, + count: auxiliary.relocation2_count as usize, + remap: Some((old_symbol_count, auxiliary.dynsym_count)), + }; + old_dyn.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_GLOB_DAT, R_AARCH64_ABS64], + "existing .rela.dyn", + )?; + old_plt.validate(&[R_AARCH64_JUMP_SLOT], "existing .rela.plt")?; + auxiliary1.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_GLOB_DAT, R_AARCH64_ABS64], + "auxiliary relocation table 1", + )?; + auxiliary2.validate( + &[R_AARCH64_RELATIVE, R_AARCH64_JUMP_SLOT], + "auxiliary relocation table 2", + )?; + + let estimated_dyn = (old_dyn.count + auxiliary1.count + auxiliary2.count) + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("merged .rela.dyn capacity overflow".to_owned()))?; + let mut merged_rela_dyn = Vec::with_capacity(estimated_dyn); + let mut relative_count = 0_usize; + for table in [old_dyn, auxiliary1, auxiliary2] { + relative_count += + table.append_where(&mut merged_rela_dyn, |kind| kind == R_AARCH64_RELATIVE)?; + } + for table in [old_dyn, auxiliary1] { + table.append_where(&mut merged_rela_dyn, |kind| kind != R_AARCH64_RELATIVE)?; + } + let mut merged_rela_plt = Vec::with_capacity( + (old_plt.count + auxiliary2.count) + .checked_mul(ELF64_RELA_SIZE) + .ok_or_else(|| Error::Invalid("merged .rela.plt capacity overflow".to_owned()))?, + ); + old_plt.append_where(&mut merged_rela_plt, |_| true)?; + auxiliary2.append_where(&mut merged_rela_plt, |kind| kind == R_AARCH64_JUMP_SLOT)?; + let rela_dyn_count = merged_rela_dyn.len() / ELF64_RELA_SIZE; + let rela_plt_count = merged_rela_plt.len() / ELF64_RELA_SIZE; + + struct TablePayload { + name: &'static str, + alignment: u64, + data: Vec, + } + let tables = vec![ + TablePayload { + name: ".dynsym", + alignment: 8, + data: merged_symbols, + }, + TablePayload { + name: ".gnu.version", + alignment: 2, + data: merged_versions, + }, + TablePayload { + name: ".gnu.version_r", + alignment: 4, + data: version_requirements, + }, + TablePayload { + name: ".gnu.hash", + alignment: 8, + data: gnu_hash_table, + }, + TablePayload { + name: ".hash", + alignment: 4, + data: sysv_hash, + }, + TablePayload { + name: ".dynstr", + alignment: 1, + data: merged_strings, + }, + TablePayload { + name: ".rela.dyn", + alignment: 8, + data: merged_rela_dyn, + }, + TablePayload { + name: ".rela.plt", + alignment: 8, + data: merged_rela_plt, + }, + ]; + let metadata_start = dynsym.offset; + let mut cursor = metadata_start; + let mut placements = BTreeMap::new(); + for table in &tables { + cursor = align_up(cursor, table.alignment)?; + placements.insert( + table.name.to_owned(), + PlacementReport { + offset: cursor, + size: table.data.len(), + }, + ); + cursor = cursor + .checked_add(table.data.len() as u64) + .ok_or_else(|| Error::Invalid("rebuilt ELF metadata end overflow".to_owned()))?; + } + if cursor > rodata.offset { + return invalid(format!( + "rebuilt ELF tables end at 0x{cursor:x}, beyond .rodata 0x{:x}", + rodata.offset + )); + } + let zero_start = usize_from_u64(metadata_start, "metadata start")?; + let zero_end = usize_from_u64(rodata.offset, ".rodata offset")?; + output + .get_mut(zero_start..zero_end) + .ok_or_else(|| Error::Invalid("metadata capacity exceeds output mapping".to_owned()))? + .fill(0); + + let mut updated_sections = layout.section_headers.clone(); + for table in &tables { + let placement = placements + .get(table.name) + .ok_or_else(|| Error::Invalid("table placement disappeared".to_owned()))?; + let offset = usize_from_u64(placement.offset, "table placement offset")?; + let end = offset + .checked_add(table.data.len()) + .ok_or_else(|| Error::Invalid("table placement end overflow".to_owned()))?; + output + .get_mut(offset..end) + .ok_or_else(|| Error::Invalid("table placement exceeds output mapping".to_owned()))? + .copy_from_slice(&table.data); + let index = indices[table.name]; + let mut updated = updated_sections[index]; + updated.address = + layout.file_offset_to_virtual_address(placement.offset, table.data.len() as u64)?; + updated.offset = placement.offset; + updated.size = table.data.len() as u64; + updated_sections[index] = updated; + } + + let section_address = |name: &'static str| -> u64 { updated_sections[indices[name]].address }; + patch_dynamic_tags( + output, + dynamic, + &BTreeMap::from([ + (DT_PLTRELSZ, (rela_plt_count * ELF64_RELA_SIZE) as u64), + (DT_HASH, section_address(".hash")), + (DT_STRTAB, section_address(".dynstr")), + (DT_SYMTAB, section_address(".dynsym")), + (DT_RELA, section_address(".rela.dyn")), + (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), + (DT_STRSZ, tables[5].data.len() as u64), + (DT_JMPREL, section_address(".rela.plt")), + (DT_GNU_HASH, section_address(".gnu.hash")), + (DT_VERSYM, section_address(".gnu.version")), + (DT_RELACOUNT, relative_count as u64), + (DT_VERNEED, section_address(".gnu.version_r")), + ]), + )?; + + let mut restored_layout = layout.clone(); + restored_layout.section_headers = updated_sections; + Ok(( + restored_layout, + ElfMaterializationReport { + hidden_symbols, + old_symbol_count, + auxiliary_symbol_count: auxiliary.dynsym_count, + appended_symbols: appended_count, + new_symbol_count, + old_dynstr_size: old_strings.len(), + auxiliary_dynstr_size: auxiliary.dynstr_size, + new_dynstr_size: tables[5].data.len(), + rela_dyn_count, + rela_plt_count, + relative_prefix_count: relative_count, + metadata_start, + metadata_end: cursor, + metadata_capacity_end: rodata.offset, + metadata_slack: rodata.offset - cursor, + placements, + }, + )) +} + +fn write_padding(file: &mut File, size: u64, path: &Path) -> Result<()> { + const ZEROES: [u8; 4096] = [0; 4096]; + let mut remaining = size; + while remaining != 0 { + let count = usize::try_from(remaining.min(ZEROES.len() as u64)) + .map_err(|_| Error::Invalid("padding size exceeds usize".to_owned()))?; + file.write_all(&ZEROES[..count]) + .map_err(|error| Error::io("write padding", path, error))?; + remaining -= count as u64; + } + Ok(()) +} + +fn finalize_clean_elf( + stream: &mut File, + temporary_path: &Path, + source: &[u8], + layout: &ElfLayout, + preserve_entrypoint: bool, +) -> Result { + let private = layout.private_section()?; + let names = layout.section_names(source)?; + if layout.private_section_index + 1 != layout.section_headers.len() { + return invalid("SHT_LOUSER section is not the final section"); + } + let retained = &layout.section_headers[..layout.private_section_index]; + let mut updated = Vec::with_capacity(retained.len()); + stream + .seek(SeekFrom::Start(private.offset)) + .map_err(|error| Error::io("seek temporary output", temporary_path, error))?; + for §ion in retained { + if section.section_type == SHT_NOBITS || section.flags & SHF_ALLOC != 0 || section.size == 0 + { + updated.push(section); + continue; + } + let section_data = slice_u64(source, section.offset, section.size)?; + let alignment = section.alignment.max(1); + let position = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + let padding = (alignment - position % alignment) % alignment; + write_padding(stream, padding, temporary_path)?; + let new_offset = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + stream + .write_all(section_data) + .map_err(|error| Error::io("append ELF section", temporary_path, error))?; + let mut relocated = section; + relocated.offset = new_offset; + updated.push(relocated); + } + let position = stream + .stream_position() + .map_err(|error| Error::io("query temporary output position", temporary_path, error))?; + write_padding(stream, (8 - position % 8) % 8, temporary_path)?; + let section_header_offset = stream + .stream_position() + .map_err(|error| Error::io("query section-header position", temporary_path, error))?; + for section in &updated { + stream + .write_all(§ion.encode()) + .map_err(|error| Error::io("write section header", temporary_path, error))?; + } + let mut elf_header = [0_u8; 0x40]; + stream + .seek(SeekFrom::Start(0)) + .and_then(|_| stream.read_exact(&mut elf_header)) + .map_err(|error| Error::io("read ELF header", temporary_path, error))?; + if !preserve_entrypoint { + elf_header[0x18..0x20].copy_from_slice(&0_u64.to_le_bytes()); + } + elf_header[0x28..0x30].copy_from_slice(§ion_header_offset.to_le_bytes()); + let section_count = u16::try_from(updated.len()) + .map_err(|_| Error::Invalid("output section count exceeds u16".to_owned()))?; + elf_header[0x3c..0x3e].copy_from_slice(§ion_count.to_le_bytes()); + stream + .seek(SeekFrom::Start(0)) + .and_then(|_| stream.write_all(&elf_header)) + .map_err(|error| Error::io("patch ELF header", temporary_path, error))?; + stream + .flush() + .and_then(|_| stream.sync_all()) + .map_err(|error| Error::io("flush temporary output", temporary_path, error))?; + Ok(CleaningReport { + private_section_index: layout.private_section_index, + private_offset: private.offset, + private_size: private.size, + input_entrypoint: layout.entrypoint, + output_entrypoint: if preserve_entrypoint { + layout.entrypoint + } else { + 0 + }, + output_section_count: updated.len(), + retained_sections: names[..layout.private_section_index].to_vec(), + section_header_offset, + }) +} + +fn section_by_name<'a>( + layout: &'a ElfLayout, + names: &[String], + wanted: &str, +) -> Result<&'a SectionHeader> { + let indices = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == wanted).then_some(index)) + .collect::>(); + match indices.as_slice() { + [index] => Ok(&layout.section_headers[*index]), + [] => invalid(format!("restored ELF lacks {wanted}")), + _ => invalid(format!("restored ELF contains duplicate {wanted}")), + } +} + +fn validate_restored_binary( + data: &[u8], + preserve_entrypoint: bool, + materialization: Option<&ElfMaterializationReport>, +) -> Result { + let layout = ElfLayout::parse(data, false)?; + let has_louser = layout + .section_headers + .iter() + .any(|section| section.section_type == SHT_LOUSER); + if has_louser { + return invalid("restored output still contains SHT_LOUSER"); + } + if !preserve_entrypoint && layout.entrypoint != 0 { + return invalid("restored output retains the protector entrypoint"); + } + let names = layout.section_names(data)?; + let dynsym = section_by_name(&layout, &names, ".dynsym")?; + let rela_dyn = section_by_name(&layout, &names, ".rela.dyn")?; + let rela_plt = section_by_name(&layout, &names, ".rela.plt")?; + let dynamic_symbols = usize_from_u64( + dynsym.size / ELF64_SYMBOL_SIZE as u64, + "restored dynamic symbol count", + )?; + let dynamic_relocations = usize_from_u64( + rela_dyn.size / ELF64_RELA_SIZE as u64, + "restored dynamic relocation count", + )?; + let pltgot_relocations = usize_from_u64( + rela_plt.size / ELF64_RELA_SIZE as u64, + "restored PLT relocation count", + )?; + if let Some(expected) = materialization { + if dynamic_symbols != expected.new_symbol_count + || dynamic_relocations != expected.rela_dyn_count + || pltgot_relocations != expected.rela_plt_count + { + return invalid("restored ELF table counts do not match materialization report"); + } + } + Ok(ValidationReport { + format: "ELF64".to_owned(), + machine: "AARCH64".to_owned(), + sections: layout.section_headers.len(), + segments: layout.program_headers.len(), + dynamic_symbols, + dynamic_relocations, + pltgot_relocations, + has_louser, + }) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|current| current.join(path)) + .map_err(|error| Error::io("query current directory", path, error)) + } +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .map_err(|error| Error::io("create output directory", parent, error))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|error| Error::io("create temporary file", parent, error))?; + temporary + .write_all(data) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|error| Error::io("write temporary file", temporary.path(), error))?; + temporary + .persist(path) + .map_err(|error| Error::io("replace output", path, error.error))?; + Ok(()) +} + +/// Restore the current protected `libil2cpp.so` without executing protector code. +pub fn restore_libil2cpp(options: &RestoreOptions) -> Result { + let started = Instant::now(); + let input_path = absolute(&options.input)?; + let output_path = absolute(&options.output)?; + let index_path = absolute(&options.index)?; + if input_path == output_path + || (output_path.exists() + && std::fs::canonicalize(&input_path).ok() == std::fs::canonicalize(&output_path).ok()) + { + return invalid("refusing to overwrite the protected input in place"); + } + let artifacts = load_artifacts(&index_path)?; + let module = read_file(&artifacts[&0x9b].path)?; + let symbol_patch_data = read_file(&artifacts[&0x9e].path)?; + let config = Module9bConfig::parse(&module)?; + + let input_file = File::open(&input_path) + .map_err(|error| Error::io("open protected input", &input_path, error))?; + let source = map_read_only(&input_file, &input_path)?; + let payload_path = &artifacts[&0x9d].path; + let payload_file = File::open(payload_path) + .map_err(|error| Error::io("open 0x9D artifact", payload_path, error))?; + let payload = map_read_only(&payload_file, payload_path)?; + let layout = ElfLayout::parse(&source, true)?; + let private = layout.private_section()?; + if private.offset != layout.file_load_end()? { + return invalid("SHT_LOUSER does not begin at the file-backed PT_LOAD end"); + } + let descriptor = ProtectedDescriptor::decrypt(&payload, config.header_seed)?; + let load_end = layout.load_end()?; + if u64::from(descriptor.outer_expected_size) != load_end { + return invalid(format!( + "0x9D target size 0x{:x} != ELF load size 0x{load_end:x}", + descriptor.outer_expected_size + )); + } + let outer = ContainerHeader::parse( + &payload, + descriptor.outer_offset as usize, + config.container_seed, + )?; + if u64::from(outer.output_size) != load_end { + return invalid(format!( + "primary container output 0x{:x} != ELF load size 0x{load_end:x}", + outer.output_size + )); + } + let auxiliary_header = ContainerHeader::parse( + &payload, + descriptor.auxiliary_offset as usize, + config.container_seed, + )?; + if auxiliary_header.output_size != descriptor.auxiliary_expected_size { + return invalid("auxiliary container output size does not match the 0x9D descriptor"); + } + if outer.encoded_end()? != descriptor.auxiliary_offset as usize { + return invalid("primary and auxiliary 0x9D containers are not contiguous"); + } + + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .map_err(|error| Error::io("create output directory", parent, error))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|error| Error::io("create temporary output", parent, error))?; + let temporary_path = temporary.path().to_path_buf(); + let private_size = usize_from_u64(private.offset, "private section offset")?; + copy_range( + &source, + temporary.as_file_mut(), + private_size, + &temporary_path, + )?; + temporary + .as_file_mut() + .flush() + .map_err(|error| Error::io("flush initial output", &temporary_path, error))?; + temporary + .as_file() + .set_len(private.offset) + .map_err(|error| Error::io("size temporary output", &temporary_path, error))?; + + let mut restored_layout = layout.clone(); + let mut auxiliary_data = None; + let mut auxiliary_stats = None; + let mut materialization = None; + let primary_stats; + { + let mut output = map_mut(temporary.as_file(), private_size, &temporary_path)?; + eprintln!("Decoding primary 0x9D target-image container..."); + let mut writer = FileLayoutWriter { + output: &mut output, + layout: &layout, + load_end, + }; + primary_stats = decode_container(&payload, &outer, &config, |address, data| { + writer.write(address, data) + })?; + if !options.outer_only { + eprintln!("Decoding auxiliary 0x9D ELF materialization container..."); + let mut decoded = vec![0_u8; auxiliary_header.output_size as usize]; + let stats = decode_container(&payload, &auxiliary_header, &config, |offset, data| { + let start = usize_from_u64(offset, "auxiliary write offset")?; + let end = start + .checked_add(data.len()) + .ok_or_else(|| Error::Invalid("auxiliary decoded write overflow".to_owned()))?; + let destination = decoded.get_mut(start..end).ok_or_else(|| { + Error::Invalid("auxiliary decoded write is out of range".to_owned()) + })?; + destination.copy_from_slice(data); + Ok(data.len()) + })?; + if let Some(path) = &options.dump_auxiliary { + write_atomic(&absolute(path)?, &decoded)?; + } + eprintln!("Rebuilding static ELF dynamic-linker tables..."); + let (new_layout, report) = materialize_static_elf_tables( + &mut output, + &source, + &layout, + &symbol_patch_data, + &decoded, + )?; + restored_layout = new_layout; + materialization = Some(report); + auxiliary_stats = Some(stats); + auxiliary_data = Some(decoded); + } + output + .flush() + .map_err(|error| Error::io("flush restored image", &temporary_path, error))?; + } + drop(auxiliary_data); + + let cleaning = finalize_clean_elf( + temporary.as_file_mut(), + &temporary_path, + &source, + &restored_layout, + options.preserve_entrypoint, + )?; + let validation = { + let restored = map_read_only(temporary.as_file(), &temporary_path)?; + validate_restored_binary( + &restored, + options.preserve_entrypoint, + materialization.as_ref(), + )? + }; + temporary + .persist(&output_path) + .map_err(|error| Error::io("replace restored output", &output_path, error.error))?; + + Ok(RestoreReport { + input: input_path.display().to_string(), + input_sha256: sha256_file(&input_path)?, + output: output_path.display().to_string(), + output_sha256: sha256_file(&output_path)?, + output_size: std::fs::metadata(&output_path) + .map_err(|error| Error::io("inspect restored output", &output_path, error))? + .len(), + module_index: index_path.display().to_string(), + static_config: StaticConfigReport { + header_seed: format!("0x{:08X}", config.header_seed), + container_seed: format!("0x{:08X}", config.container_seed), + aes_key_sha256: sha256_bytes(&config.aes_key), + schedule_offset: format!("0x{:X}", config.schedule_offset), + }, + descriptor: DescriptorReport { + command_id: format!("0x{:X}", descriptor.command_id), + flags: format!("0x{:X}", descriptor.flags), + outer_offset: format!("0x{:X}", descriptor.outer_offset), + outer_expected_size: format!("0x{:X}", descriptor.outer_expected_size), + auxiliary_offset: format!("0x{:X}", descriptor.auxiliary_offset), + auxiliary_expected_size: format!("0x{:X}", descriptor.auxiliary_expected_size), + }, + primary: primary_stats, + auxiliary: auxiliary_stats, + elf_materialization: materialization, + cleaning, + validation, + elapsed_seconds: started.elapsed().as_secs_f64(), + }) +} diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml new file mode 100644 index 0000000..35aad71 --- /dev/null +++ b/senbei-android-io/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "senbei-android-io" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Filesystem and CLI orchestration for Senbei Android" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +senbei-android-elf.workspace = true +senbei-android-metadata.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs new file mode 100644 index 0000000..48fab34 --- /dev/null +++ b/senbei-android-io/src/lib.rs @@ -0,0 +1,154 @@ +//! Filesystem orchestration for the Android restoration commands. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; +use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; +use serde::Serialize; +use tempfile::NamedTempFile; + +/// Filesystem arguments for restoring one protected `libil2cpp.so`. +#[derive(Debug, Clone)] +pub struct RestoreSoJob { + pub input: PathBuf, + pub output: PathBuf, + pub index: Option, + pub report: Option, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Filesystem arguments for restoring one `global-metadata.dat`. +#[derive(Debug, Clone)] +pub struct RestoreMetadataJob { + pub input: PathBuf, + pub output: PathBuf, + pub seed: u32, + pub report: Option, +} + +impl RestoreMetadataJob { + #[must_use] + pub fn new(input: PathBuf, output: PathBuf) -> Self { + Self { + input, + output, + seed: DEFAULT_METHOD_TOKEN_SEED, + report: None, + } + } +} + +/// Infer the Stage 2 module index produced for `libil2cpp.so`. +#[must_use] +pub fn default_module_index(input: &Path) -> PathBuf { + input + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("libil2cpp_stage2_modules") + .join("index.json") +} + +/// Run static SO restoration and optionally emit its JSON report. +pub fn run_restore_so(job: &RestoreSoJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let options = RestoreOptions { + input: job.input.clone(), + output: job.output.clone(), + index: job + .index + .clone() + .unwrap_or_else(|| default_module_index(&job.input)), + dump_auxiliary: job.dump_auxiliary.clone(), + outer_only: job.outer_only, + preserve_entrypoint: job.preserve_entrypoint, + }; + let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Restore MethodDef tokens and atomically write the cleaned metadata. +pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let input = + std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; + let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) + .with_context(|| format!("restore `{}`", job.input.display()))?; + write_atomic(&job.output, &output)?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { + let input_absolute = absolute(input)?; + let output_absolute = absolute(output)?; + let same_existing_file = + output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); + if input_absolute == output_absolute || same_existing_file { + bail!( + "refusing to overwrite input in place: `{}`", + input.display() + ); + } + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("query current directory")? + .join(path)) + } +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; + data.push(b'\n'); + write_atomic(path, &data) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("create output directory `{}`", parent.display()))?; + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary file in `{}`", parent.display()))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("write temporary output for `{}`", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace output `{}`", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_index_next_to_input() { + assert_eq!( + default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), + PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") + ); + } + + #[test] + fn metadata_job_uses_current_seed() { + let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); + assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); + } +} diff --git a/senbei-android-metadata/Cargo.toml b/senbei-android-metadata/Cargo.toml new file mode 100644 index 0000000..2dbaeb7 --- /dev/null +++ b/senbei-android-metadata/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "senbei-android-metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "IL2CPP metadata restoration for Senbei Android" + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs new file mode 100644 index 0000000..3abe1ff --- /dev/null +++ b/senbei-android-metadata/src/lib.rs @@ -0,0 +1,421 @@ +//! Static restoration of protected IL2CPP v31 method tokens. + +use serde::Serialize; + +/// Seed embedded in the current `libil2cpp` module `0x0C`. +pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; + +const MAGIC: u32 = 0xfab1_1baf; +const SUPPORTED_VERSION: u32 = 31; +const HDR_METHODS: usize = 0x30; +const HDR_TYPES: usize = 0xa0; +const HDR_IMAGES: usize = 0xa8; +const METHOD_STRIDE: usize = 0x24; +const METHOD_TOKEN_OFFSET: usize = 0x18; +const TYPE_STRIDE: usize = 0x58; +const TYPE_METHOD_START_OFFSET: usize = 0x24; +const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; +const IMAGE_STRIDE: usize = 0x28; +const IMAGE_TYPE_START_OFFSET: usize = 0x08; +const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; +const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; + +/// Summary of one metadata restoration pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Report { + pub version: u32, + pub seed: String, + pub images: usize, + pub images_with_methods: usize, + pub types: usize, + pub methods: usize, + pub visited_methods: usize, + pub already_correct_before: usize, + pub correct_after: usize, + pub changed_tokens: usize, + pub transformed_images: usize, +} + +/// Metadata parsing or validation failure. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not an IL2CPP global-metadata.dat")] + NotMetadata, + #[error("unsupported metadata version {0}")] + UnsupportedVersion(u32), + #[error("malformed metadata: {0}")] + Malformed(String), + #[error("method-token restoration failed: {0}")] + Validation(String), +} + +type Result = std::result::Result; + +fn malformed(message: impl Into) -> Result { + Err(Error::Malformed(message.into())) +} + +fn validation(message: impl Into) -> Result { + Err(Error::Validation(message.into())) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Malformed(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let value: [u8; 2] = bytes(data, offset, 2)? + .try_into() + .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(value)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(value)) +} + +fn read_i32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; + Ok(i32::from_le_bytes(value)) +} + +fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { + let offset = read_u32(data, header_offset)? as usize; + let size = read_u32(data, header_offset + 4)? as usize; + bytes(data, offset, size)?; + Ok((offset, size)) +} + +#[inline] +fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { + let mirror = count.wrapping_mul(2).wrapping_sub(1); + if value & 1 != 0 { + value = mirror.wrapping_sub(value); + } + value >>= 1; + if value >= count { + value = mirror.wrapping_sub(value); + } + let value = value.wrapping_sub(key); + if value > count { + value.wrapping_add(count) + } else { + value + } +} + +fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { + let count = high + .checked_add(1) + .and_then(|value| value.checked_sub(low)) + .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; + if count < 2 { + return validation("RID inverse permutation requires at least two entries"); + } + let half = count / 2; + if half == 0 { + return validation("RID inverse permutation has a zero divisor"); + } + let key = seed % half + count / 4; + let mut value = rid + .checked_sub(low) + .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + .checked_add(low) + .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) +} + +/// Restore MethodDef RID values exactly as module `0x0C` does. +/// +/// The operation is idempotent for tooling purposes: an image whose tokens are +/// already canonical is detected and left untouched instead of applying the +/// native inverse permutation a second time. +pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Err(Error::UnsupportedVersion(version)); + } + + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut owners = vec![u32::MAX; method_count]; + let mut output = data.to_vec(); + let mut images_with_methods = 0_usize; + let mut visited_methods = 0_usize; + let mut already_correct_before = 0_usize; + let mut correct_after = 0_usize; + let mut changed_tokens = 0_usize; + let mut transformed_images = 0_usize; + + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; + let type_start = usize::try_from(type_start) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; + let method_start = usize::try_from(method_start).map_err(|_| { + Error::Malformed(format!( + "type {type_index} has methods but negative methodStart" + )) + })?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + for (method_index, owner) in owners + .iter_mut() + .enumerate() + .take(method_end) + .skip(method_start) + { + if *owner != u32::MAX { + return malformed(format!("method {method_index} belongs to multiple images")); + } + *owner = u32::try_from(image_index) + .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; + methods.push(method_index); + } + } + if methods.is_empty() { + continue; + } + images_with_methods += 1; + visited_methods += methods.len(); + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return malformed(format!( + "image {image_index} method block is not contiguous" + )); + } + + let mut tokens = Vec::with_capacity(methods.len()); + let mut image_already_clean = true; + for &method_index in &methods { + let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; + let token = read_u32(data, token_offset)?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return malformed(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + if rid == expected { + already_correct_before += 1; + } else { + image_already_clean = false; + } + tokens.push((method_index, token_offset, token, expected)); + } + + if image_already_clean { + correct_after += tokens.len(); + continue; + } + transformed_images += 1; + let low = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .min() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + let high = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .max() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + if high <= 1 { + return validation(format!( + "image {image_index} is noncanonical but native R > 1 gate would skip it" + )); + } + let interval = high - low + 1; + if interval as usize != tokens.len() { + return validation(format!( + "image {image_index} RID interval {low}..={high} is not a permutation" + )); + } + for (method_index, token_offset, token, expected) in tokens { + let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; + if restored_rid != expected { + return validation(format!( + "method {method_index} restored RID {restored_rid} != expected {expected}" + )); + } + let restored_token = METHOD_TOKEN_TABLE | restored_rid; + if restored_token != token { + output[token_offset..token_offset + 4] + .copy_from_slice(&restored_token.to_le_bytes()); + changed_tokens += 1; + } + correct_after += 1; + } + } + + if owners.contains(&u32::MAX) { + return malformed("one or more method definitions are not owned by an image"); + } + if visited_methods != method_count || correct_after != method_count { + return validation(format!( + "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" + )); + } + + Ok(( + output, + Report { + version, + seed: format!("0x{seed:08X}"), + images: image_count, + images_with_methods, + types: type_count, + methods: method_count, + visited_methods, + already_correct_before, + correct_after, + changed_tokens, + transformed_images, + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn put_u16(data: &mut [u8], offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(data: &mut [u8], offset: usize, value: u32) { + data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { + (1..=count) + .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) + .expect("inverse permutation must be bijective") + } + + fn build(tokens: &[u32]) -> (Vec, usize) { + let header_size = 0x100; + let images = header_size; + let types = images + IMAGE_STRIDE; + let methods = types + 2 * TYPE_STRIDE; + let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; + put_u32(&mut data, 0, MAGIC); + put_u32(&mut data, 4, SUPPORTED_VERSION); + put_u32(&mut data, HDR_METHODS, methods as u32); + put_u32( + &mut data, + HDR_METHODS + 4, + (tokens.len() * METHOD_STRIDE) as u32, + ); + put_u32(&mut data, HDR_TYPES, types as u32); + put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); + put_u32(&mut data, HDR_IMAGES, images as u32); + put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); + put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); + put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); + // Deliberately traverse the high method indices first. + put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); + put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); + put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); + put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); + for (index, &token) in tokens.iter().enumerate() { + put_u32( + &mut data, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + token, + ); + } + (data, methods) + } + + #[test] + fn restores_five_round_permutation_by_physical_method_index() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, methods) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.correct_after, 7); + for index in 0..7 { + assert_eq!( + read_u32( + &restored, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET + ) + .expect("token"), + METHOD_TOKEN_TABLE | (index as u32 + 1) + ); + } + } + + #[test] + fn clean_metadata_is_idempotent() { + let tokens = (1..=7) + .map(|rid| METHOD_TOKEN_TABLE | rid) + .collect::>(); + let (data, _) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.changed_tokens, 0); + assert_eq!(restored, data); + } +} From 131ced6db5917d08b06a65bf52c3929d133b16c9 Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 01:47:25 +0800 Subject: [PATCH 3/8] feat: add static stage extraction and metadata detection --- Cargo.lock | 75 +++- Cargo.toml | 3 + README.md | 27 +- senbei-android-cli/Cargo.toml | 2 + senbei-android-cli/src/main.rs | 110 +++++- senbei-android-crypto/src/lib.rs | 135 ++++++- senbei-android-elf/src/restore.rs | 122 ++++-- senbei-android-io/Cargo.toml | 1 + senbei-android-io/src/lib.rs | 38 ++ senbei-android-metadata/src/lib.rs | 238 ++++++++++++ senbei-android-stage2/Cargo.toml | 20 + senbei-android-stage2/src/error.rs | 63 ++++ senbei-android-stage2/src/extract.rs | 529 +++++++++++++++++++++++++++ senbei-android-stage2/src/lib.rs | 12 + senbei-android-stage2/src/report.rs | 115 ++++++ senbei-android-stage2/src/stage1.rs | 231 ++++++++++++ senbei-android-stage2/src/stream.rs | 168 +++++++++ 17 files changed, 1840 insertions(+), 49 deletions(-) create mode 100644 senbei-android-stage2/Cargo.toml create mode 100644 senbei-android-stage2/src/error.rs create mode 100644 senbei-android-stage2/src/extract.rs create mode 100644 senbei-android-stage2/src/lib.rs create mode 100644 senbei-android-stage2/src/report.rs create mode 100644 senbei-android-stage2/src/stage1.rs create mode 100644 senbei-android-stage2/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 5da0292..9df140a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ dependencies = [ "r-efi", ] +[[package]] +name = "goblin" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "inout" version = "0.1.4" @@ -143,6 +154,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -164,6 +181,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -201,6 +224,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "senbei-android-cli" version = "0.1.0" @@ -208,6 +251,8 @@ dependencies = [ "anyhow", "senbei-android-io", "senbei-android-metadata", + "senbei-android-stage2", + "serde_json", ] [[package]] @@ -238,6 +283,7 @@ dependencies = [ "anyhow", "senbei-android-elf", "senbei-android-metadata", + "senbei-android-stage2", "serde", "serde_json", "sha2", @@ -252,6 +298,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "senbei-android-stage2" +version = "0.1.0" +dependencies = [ + "goblin", + "memmap2", + "senbei-android-crypto", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + [[package]] name = "serde" version = "1.0.229" @@ -279,7 +339,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -306,6 +366,17 @@ dependencies = [ "digest", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -347,7 +418,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e544dd7..caf0c2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "senbei-android-elf", "senbei-android-io", "senbei-android-metadata", + "senbei-android-stage2", ] default-members = ["senbei-android-cli"] resolver = "2" @@ -18,6 +19,7 @@ license = "AGPL-3.0-only" [workspace.dependencies] aes = "0.8" anyhow = "1" +goblin = "0.10" memmap2 = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -29,6 +31,7 @@ senbei-android-crypto = { path = "senbei-android-crypto" } senbei-android-elf = { path = "senbei-android-elf" } senbei-android-io = { path = "senbei-android-io" } senbei-android-metadata = { path = "senbei-android-metadata" } +senbei-android-stage2 = { path = "senbei-android-stage2" } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/README.md b/README.md index 87ed4f8..3e3e5f8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ target\release\senbei-android.exe ## 还原 libil2cpp.so +先直接从受保护 SO 静态提取 Stage 1/Stage 2 和模块索引: + +```powershell +senbei-android extract-stage2 INPUT OUTPUT_DIR +``` + +默认在 `OUTPUT_DIR` 写入紧凑的 `index.json` 及后续还原实际需要的模块产物; +需要保留完整 Stage 2 镜像用于分析时,额外传入 `--stage2-out FILE`。 + ```powershell senbei-android restore-so INPUT OUTPUT --index INDEX_JSON --report REPORT_JSON ``` @@ -74,7 +83,23 @@ senbei-android restore-metadata ` ``` 可用 `--seed 0xA6FAE968` 显式指定十六进制 seed,也支持十进制。还原操作是 -幂等的:已规范化的 image 会保持不变。 +幂等的,并严格先检测状态、再决定是否解密: + +- 先验证 metadata magic、版本、表边界、MethodDef token 类型与完整归属关系。 +- 所有 image 的 RID 已规范时报告 `encryption_status: "clean"`,不执行逆置换, + 输出与输入逐字节一致。 +- 存在非规范 RID 时,必须先确认它们构成合法置换,并让指定 seed 对所有 image + 完整通过五轮逆置换校验;只有此时才报告 `encryption_status: "encrypted"` 并写出结果。 +- seed 错误、算法变化或数据损坏会直接报错,不生成输出文件和报告。 + +不确定样本 seed 时可先执行只读诊断: + +```powershell +senbei-android discover-metadata INPUT +``` + +该命令不会修改文件,会列出每个 image 的状态、seed residue 以及满足当前 v31 +算法的 32 位 seed 候选。 ## Workspace diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml index 3d6c2a2..b64f9a2 100644 --- a/senbei-android-cli/Cargo.toml +++ b/senbei-android-cli/Cargo.toml @@ -12,8 +12,10 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +serde_json.workspace = true senbei-android-io.workspace = true senbei-android-metadata.workspace = true +senbei-android-stage2.workspace = true [lints] workspace = true diff --git a/senbei-android-cli/src/main.rs b/senbei-android-cli/src/main.rs index c4203fc..93a4248 100644 --- a/senbei-android-cli/src/main.rs +++ b/senbei-android-cli/src/main.rs @@ -2,7 +2,10 @@ use std::ffi::OsString; use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use senbei_android_io::{RestoreMetadataJob, RestoreSoJob, run_restore_metadata, run_restore_so}; +use senbei_android_io::{ + ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, run_extract_stage2, run_restore_metadata, + run_restore_so, +}; fn main() -> std::process::ExitCode { match run(std::env::args_os().skip(1)) { @@ -24,6 +27,8 @@ fn run(args: impl Iterator) -> Result<()> { match command.as_ref() { "restore-so" => restore_so(args.collect()), "restore-metadata" => restore_metadata(args.collect()), + "discover-metadata" => discover_metadata(args.collect()), + "extract-stage2" => extract_stage2(args.collect()), "-h" | "--help" => { print_help(); Ok(()) @@ -36,6 +41,87 @@ fn run(args: impl Iterator) -> Result<()> { } } +fn discover_metadata(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + for value in args { + if value == "-h" || value == "--help" { + println!("senbei-android discover-metadata INPUT"); + return Ok(()); + } + if value.to_string_lossy().starts_with('-') { + bail!( + "unknown discover-metadata option `{}`", + value.to_string_lossy() + ); + } + positional.push(PathBuf::from(value)); + } + let [input] = positional.as_slice() else { + bail!("discover-metadata requires INPUT; use --help for usage"); + }; + let data = + std::fs::read(input).with_context(|| format!("read metadata `{}`", input.display()))?; + let report = senbei_android_metadata::discover_method_token_seeds(&data) + .with_context(|| format!("discover metadata seed `{}`", input.display()))?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn extract_stage2(args: Vec) -> Result<()> { + let mut positional = Vec::new(); + let mut stage2_output = None; + let mut outer_size = senbei_android_stage2::DEFAULT_OUTER_SIZE; + let mut cipher_constant = senbei_android_stage2::DEFAULT_CIPHER_CONSTANT; + let mut cursor = 0; + while cursor < args.len() { + match args[cursor].to_string_lossy().as_ref() { + "--stage2-out" => { + stage2_output = Some(option_path(&args, &mut cursor, "--stage2-out")?); + } + "--outer-size" => { + let value = option_string(&args, &mut cursor, "--outer-size")?; + outer_size = usize::try_from(parse_u64(&value)?) + .with_context(|| format!("invalid --outer-size `{value}`"))?; + } + "--cipher-constant" => { + let value = option_string(&args, &mut cursor, "--cipher-constant")?; + cipher_constant = parse_u32(&value) + .with_context(|| format!("invalid --cipher-constant `{value}`"))?; + } + "-h" | "--help" => { + print_extract_help(); + return Ok(()); + } + option if option.starts_with('-') => { + bail!("unknown extract-stage2 option `{option}"); + } + _ => positional.push(PathBuf::from(&args[cursor])), + } + cursor += 1; + } + let [input, output_dir] = positional.as_slice() else { + bail!("extract-stage2 requires INPUT and OUTPUT_DIR; use --help for usage"); + }; + let mut job = ExtractStage2Job::new(input.clone(), output_dir.clone()); + job.stage2_output = stage2_output; + job.outer_size = outer_size; + job.cipher_constant = cipher_constant; + let result = run_extract_stage2(&job)?; + let module_images = result + .module_registry + .iter() + .filter(|module| module.classification == "module_image") + .count(); + println!( + "Extracted {} streams, {} modules and {} compact artifacts", + result.streams.len(), + module_images, + result.artifacts.len() + ); + println!("Index {}", output_dir.join("index.json").display()); + Ok(()) +} + fn restore_so(args: Vec) -> Result<()> { let mut positional = Vec::new(); let mut index = None; @@ -112,8 +198,11 @@ fn restore_metadata(args: Vec) -> Result<()> { report, })?; println!( - "Restored {}/{} MethodDef tokens ({} already canonical)", - result.changed_tokens, result.methods, result.already_correct_before + "Metadata status={} restored {}/{} MethodDef tokens ({} already canonical)", + result.encryption_status, + result.changed_tokens, + result.methods, + result.already_correct_before ); Ok(()) } @@ -133,11 +222,15 @@ fn option_string(args: &[OsString], cursor: &mut usize, name: &str) -> Result Result { + Ok(u32::try_from(parse_u64(value)?)?) +} + +fn parse_u64(value: &str) -> Result { if let Some(hex) = value .strip_prefix("0x") .or_else(|| value.strip_prefix("0X")) { - Ok(u32::from_str_radix(hex, 16)?) + Ok(u64::from_str_radix(hex, 16)?) } else { Ok(value.parse()?) } @@ -148,9 +241,18 @@ fn print_help() { println!("Usage:"); println!(" senbei-android restore-so INPUT OUTPUT [OPTIONS]"); println!(" senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); + println!(" senbei-android discover-metadata INPUT"); + println!(" senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); println!(" senbei-android --version"); } +fn print_extract_help() { + println!("senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); + println!(" --stage2-out FILE Write the raw decrypted Stage 2 image"); + println!(" --outer-size VALUE Stage 1 outer wrapper size (default 0x23C)"); + println!(" --cipher-constant VALUE Stage 1 cipher constant (default 0xBF20165D)"); +} + fn print_so_help() { println!("senbei-android restore-so INPUT OUTPUT [OPTIONS]"); println!(" --index FILE Stage 2 module index.json"); diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs index e752863..49fe6c9 100644 --- a/senbei-android-crypto/src/lib.rs +++ b/senbei-android-crypto/src/lib.rs @@ -103,6 +103,18 @@ pub struct Module9bConfig { impl Module9bConfig { /// Parse the unique AES-256 decryption schedule and adjacent configuration. pub fn parse(image: &[u8]) -> Result { + Self::parse_inner(image, true) + } + + /// Parse the decoder configuration embedded in the raw Stage 2 image. + /// + /// The embedded decoder ends before the interpreter-only `skip_aes` + /// field, so that flag is definitionally false for this layout. + pub fn parse_embedded(image: &[u8]) -> Result { + Self::parse_inner(image, false) + } + + fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; let mut matches = image .windows(MARKER.len()) @@ -117,7 +129,7 @@ impl Module9bConfig { let header_seed = read_u32(image, schedule_offset - 8)?; let schedule_size = read_u32(image, schedule_offset - 4)?; - if schedule_size != 0xf4 { + if !matches!(schedule_size, 0 | 0xf4) { return invalid(format!( "unexpected 0x9B AES schedule size 0x{schedule_size:x}" )); @@ -148,16 +160,24 @@ impl Module9bConfig { let container_seed_offset = schedule_offset .checked_add(0x100) .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; - let skip_aes_offset = schedule_offset - .checked_add(0x240) - .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; - let skip_aes = *image.get(skip_aes_offset).ok_or_else(|| { - Error::Invalid("0x9B static configuration exceeds its image".to_owned()) - })? != 0; + let skip_aes = if has_skip_aes { + let skip_aes_offset = schedule_offset + .checked_add(0x240) + .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; + *image.get(skip_aes_offset).ok_or_else(|| { + Error::Invalid("module static configuration exceeds its image".to_owned()) + })? != 0 + } else { + false + }; Ok(Self { header_seed, - container_seed: read_u32(image, container_seed_offset)?, + container_seed: if has_skip_aes { + read_u32(image, container_seed_offset)? + } else { + header_seed + }, aes_key, skip_aes, schedule_offset, @@ -572,6 +592,105 @@ pub fn transform_segment( Ok(transformed) } +/// Decode one complete protector container into its flat output buffer. +/// +/// This is the static equivalent of the decoder entrypoint embedded in Stage +/// 2 and in each nested interpreter module. +pub fn decode_container( + data: &[u8], + config: &Module9bConfig, + expected_size: usize, +) -> Result> { + let header = ContainerHeader::parse(data, 0, config.container_seed)?; + let header_size = usize::try_from(header.output_size) + .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; + if header_size != expected_size { + return invalid(format!( + "container output size 0x{header_size:x} != expected 0x{expected_size:x}" + )); + } + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + let mut output = vec![0_u8; expected_size]; + + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = header + .start + .checked_add(encoded.offset as usize) + .ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?; + let encoded_data = range(data, start, encoded.size as usize)?; + let transformed = transform_segment( + encoded_data, + config.container_seed, + &config.aes_key, + decrypt_aes, + )?; + if transformed.len() < 16 { + return invalid(format!( + "decoded segment {segment_index} is shorter than its header" + )); + } + let base_offset = read_u32(&transformed, 0)? as usize; + let writer_count = read_u32(&transformed, 4)? as usize; + let table_offset = read_u32(&transformed, 8)? as usize; + let data_offset = read_u32(&transformed, 12)? as usize; + let table_size = writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; + let table_end = table_offset + .checked_add(table_size) + .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; + if table_end > transformed.len() || data_offset > transformed.len() { + return invalid(format!( + "decoded segment {segment_index} has invalid writer offsets" + )); + } + + let mut data_cursor = data_offset; + for writer_index in 0..writer_count { + let record = + table_offset + .checked_add(writer_index.checked_mul(16).ok_or_else(|| { + Error::Invalid("writer record offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; + let output_offset = read_u32(&transformed, record)? as usize; + let output_size = read_u32(&transformed, record + 4)? as usize; + let encoded_size = read_u32(&transformed, record + 8)? as usize; + let reserved = read_u32(&transformed, record + 12)?; + let encoded_end = data_cursor + .checked_add(encoded_size) + .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; + if reserved != 0 || encoded_end > transformed.len() { + return invalid(format!( + "segment {segment_index} writer {writer_index} has invalid bounds" + )); + } + let source = &transformed[data_cursor..encoded_end]; + let decoded = if encoded_size == output_size { + None + } else { + Some(decoder.decode(source, output_size)?) + }; + let decoded = decoded.as_deref().unwrap_or(source); + let target = base_offset + .checked_add(output_offset) + .ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?; + let target_end = target + .checked_add(decoded.len()) + .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; + let destination = output.get_mut(target..target_end).ok_or_else(|| { + Error::Invalid(format!( + "segment {segment_index} writer {writer_index} target is out of range" + )) + })?; + destination.copy_from_slice(decoded); + data_cursor = encoded_end; + } + } + Ok(output) +} + #[cfg(test)] mod tests { use super::*; diff --git a/senbei-android-elf/src/restore.rs b/senbei-android-elf/src/restore.rs index 8aae28c..c88dbe9 100644 --- a/senbei-android-elf/src/restore.rs +++ b/senbei-android-elf/src/restore.rs @@ -437,12 +437,33 @@ impl AuxiliaryElfImage { let dynsym_end = u64::from(result.dynsym_offset) + u64::from(result.dynsym_count) * ELF64_SYMBOL_SIZE as u64; let dynstr_end = u64::from(result.dynstr_offset) + u64::from(result.dynstr_size); - if relocation1_end != u64::from(result.relocation2_offset) - || relocation2_end != u64::from(result.dynsym_offset) + let expected_relocation2 = align_up(relocation1_end, 0x10)?; + let expected_dynsym = align_up(relocation2_end, 0x10)?; + if expected_relocation2 != u64::from(result.relocation2_offset) + || expected_dynsym != u64::from(result.dynsym_offset) || dynsym_end != u64::from(result.dynstr_offset) || dynstr_end != data.len() as u64 { - return invalid("auxiliary ELF tables are not contiguous"); + return invalid(format!( + "auxiliary ELF layout mismatch: rela1_end=0x{relocation1_end:x}/rela2=0x{:x}, rela2_end=0x{relocation2_end:x}/dynsym=0x{:x}, dynsym_end=0x{dynsym_end:x}/dynstr=0x{:x}, dynstr_end=0x{dynstr_end:x}/size=0x{:x}", + result.relocation2_offset, + result.dynsym_offset, + result.dynstr_offset, + data.len() + )); + } + for (start, end) in [ + (relocation1_end, expected_relocation2), + (relocation2_end, expected_dynsym), + ] { + if slice_u64(data, start, end - start)? + .iter() + .any(|&byte| byte != 0) + { + return invalid(format!( + "auxiliary ELF alignment padding 0x{start:x}..0x{end:x} is nonzero" + )); + } } if result.dynsym_count < 2 { return invalid("auxiliary dynamic symbol table is empty"); @@ -537,9 +558,12 @@ fn restore_hidden_symbols( let target_offset = target_index as usize * ELF64_SYMBOL_SIZE; symbols[target_offset..target_offset + ELF64_SYMBOL_SIZE].copy_from_slice(source_symbol); } - if cursor != table_end { + let string_padding = patch_data.get(cursor..table_end).ok_or_else(|| { + Error::Invalid("0x9E symbol strings exceed the primary patch blob".to_owned()) + })?; + if string_padding.len() > 3 || string_padding.iter().any(|&byte| byte != 0) { return invalid(format!( - "0x9E symbol strings end at 0x{cursor:x}, expected 0x{table_end:x}" + "0x9E symbol strings have invalid padding at 0x{cursor:x}..0x{table_end:x}" )); } let first_target_index = patched_indices @@ -715,12 +739,11 @@ fn patch_dynamic_tags( } fn required_section_indices(names: &[String]) -> Result> { - const REQUIRED: [&str; 10] = [ + const REQUIRED: [&str; 9] = [ ".dynsym", ".gnu.version", ".gnu.version_r", ".gnu.hash", - ".hash", ".dynstr", ".rela.dyn", ".rela.plt", @@ -742,6 +765,18 @@ fn required_section_indices(names: &[String]) -> Result return invalid(format!("ELF contains duplicate section {required}")), } } + let sysv_hash = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == ".hash").then_some(index)) + .collect::>(); + match sysv_hash.as_slice() { + [index] => { + result.insert(".hash", *index); + } + [] => {} + _ => return invalid("ELF contains duplicate section .hash"), + } Ok(result) } @@ -811,9 +846,13 @@ fn materialize_static_elf_tables( merged_versions.extend_from_slice(&VER_NDX_GLOBAL.to_le_bytes()); } let merged_names = dynamic_symbol_names(&merged_symbols, &merged_strings)?; - let sysv_hash = build_sysv_hash(&merged_names)?; + let sysv_hash = indices + .contains_key(".hash") + .then(|| build_sysv_hash(&merged_names)) + .transpose()?; let gnu_hash_table = build_gnu_hash(&merged_names)?; let new_symbol_count = merged_names.len(); + let new_dynstr_size = merged_strings.len(); if rela_dyn.entry_size != ELF64_RELA_SIZE as u64 || rela_plt.entry_size != ELF64_RELA_SIZE as u64 @@ -887,7 +926,7 @@ fn materialize_static_elf_tables( alignment: u64, data: Vec, } - let tables = vec![ + let mut tables = vec![ TablePayload { name: ".dynsym", alignment: 8, @@ -908,11 +947,15 @@ fn materialize_static_elf_tables( alignment: 8, data: gnu_hash_table, }, - TablePayload { + ]; + if let Some(data) = sysv_hash { + tables.push(TablePayload { name: ".hash", alignment: 4, - data: sysv_hash, - }, + data, + }); + } + tables.extend([ TablePayload { name: ".dynstr", alignment: 1, @@ -928,7 +971,7 @@ fn materialize_static_elf_tables( alignment: 8, data: merged_rela_plt, }, - ]; + ]); let metadata_start = dynsym.offset; let mut cursor = metadata_start; let mut placements = BTreeMap::new(); @@ -981,24 +1024,23 @@ fn materialize_static_elf_tables( } let section_address = |name: &'static str| -> u64 { updated_sections[indices[name]].address }; - patch_dynamic_tags( - output, - dynamic, - &BTreeMap::from([ - (DT_PLTRELSZ, (rela_plt_count * ELF64_RELA_SIZE) as u64), - (DT_HASH, section_address(".hash")), - (DT_STRTAB, section_address(".dynstr")), - (DT_SYMTAB, section_address(".dynsym")), - (DT_RELA, section_address(".rela.dyn")), - (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), - (DT_STRSZ, tables[5].data.len() as u64), - (DT_JMPREL, section_address(".rela.plt")), - (DT_GNU_HASH, section_address(".gnu.hash")), - (DT_VERSYM, section_address(".gnu.version")), - (DT_RELACOUNT, relative_count as u64), - (DT_VERNEED, section_address(".gnu.version_r")), - ]), - )?; + let mut dynamic_values = BTreeMap::from([ + (DT_PLTRELSZ, (rela_plt_count * ELF64_RELA_SIZE) as u64), + (DT_STRTAB, section_address(".dynstr")), + (DT_SYMTAB, section_address(".dynsym")), + (DT_RELA, section_address(".rela.dyn")), + (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), + (DT_STRSZ, new_dynstr_size as u64), + (DT_JMPREL, section_address(".rela.plt")), + (DT_GNU_HASH, section_address(".gnu.hash")), + (DT_VERSYM, section_address(".gnu.version")), + (DT_RELACOUNT, relative_count as u64), + (DT_VERNEED, section_address(".gnu.version_r")), + ]); + if indices.contains_key(".hash") { + dynamic_values.insert(DT_HASH, section_address(".hash")); + } + patch_dynamic_tags(output, dynamic, &dynamic_values)?; let mut restored_layout = layout.clone(); restored_layout.section_headers = updated_sections; @@ -1012,7 +1054,7 @@ fn materialize_static_elf_tables( new_symbol_count, old_dynstr_size: old_strings.len(), auxiliary_dynstr_size: auxiliary.dynstr_size, - new_dynstr_size: tables[5].data.len(), + new_dynstr_size, rela_dyn_count, rela_plt_count, relative_prefix_count: relative_count, @@ -1247,8 +1289,20 @@ pub fn restore_libil2cpp(options: &RestoreOptions) -> Result { let payload = map_read_only(&payload_file, payload_path)?; let layout = ElfLayout::parse(&source, true)?; let private = layout.private_section()?; - if private.offset != layout.file_load_end()? { - return invalid("SHT_LOUSER does not begin at the file-backed PT_LOAD end"); + let file_load_end = layout.file_load_end()?; + let aligned_load_end = align_up(file_load_end, 0x10)?; + if private.offset != aligned_load_end { + return invalid(format!( + "SHT_LOUSER offset 0x{:x} != aligned file-backed PT_LOAD end 0x{aligned_load_end:x} (raw 0x{file_load_end:x})", + private.offset + )); + } + let load_padding = slice_u64(&source, file_load_end, private.offset - file_load_end)?; + if load_padding.iter().any(|&byte| byte != 0) { + return invalid(format!( + "nonzero padding between PT_LOAD end 0x{file_load_end:x} and SHT_LOUSER 0x{:x}", + private.offset + )); } let descriptor = ProtectedDescriptor::decrypt(&payload, config.header_seed)?; let load_end = layout.load_end()?; diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml index 35aad71..a744e46 100644 --- a/senbei-android-io/Cargo.toml +++ b/senbei-android-io/Cargo.toml @@ -14,6 +14,7 @@ sha2.workspace = true tempfile.workspace = true senbei-android-elf.workspace = true senbei-android-metadata.workspace = true +senbei-android-stage2.workspace = true [lints] workspace = true diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index 48fab34..f0997fb 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -6,6 +6,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; +use senbei_android_stage2::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +}; use serde::Serialize; use tempfile::NamedTempFile; @@ -30,6 +33,29 @@ pub struct RestoreMetadataJob { pub report: Option, } +/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractStage2Job { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractStage2Job { + #[must_use] + pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + impl RestoreMetadataJob { #[must_use] pub fn new(input: PathBuf, output: PathBuf) -> Self { @@ -87,6 +113,18 @@ pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result Ok(result) } +/// Extract Stage 2 modules directly from one protected ELF. +pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { + extract_stage2(&ExtractOptions { + input: job.input.clone(), + output_dir: job.output_dir.clone(), + stage2_output: job.stage2_output.clone(), + outer_size: job.outer_size, + cipher_constant: job.cipher_constant, + }) + .context("extract protected Stage 1/Stage 2 payload") +} + fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { let input_absolute = absolute(input)?; let output_absolute = absolute(output)?; diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs index 3abe1ff..1a13817 100644 --- a/senbei-android-metadata/src/lib.rs +++ b/senbei-android-metadata/src/lib.rs @@ -25,6 +25,7 @@ const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; pub struct Report { pub version: u32, pub seed: String, + pub encryption_status: String, pub images: usize, pub images_with_methods: usize, pub types: usize, @@ -36,6 +37,26 @@ pub struct Report { pub transformed_images: usize, } +/// Per-image constraints recovered from the encrypted MethodDef RID +/// permutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImageKeyDiscovery { + pub image: usize, + pub method_count: u32, + pub modulus: u32, + pub clean: bool, + pub seed_residues: Vec, +} + +/// Result of statically testing the known five-round permutation against a +/// metadata file without assuming a seed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SeedDiscoveryReport { + pub version: u32, + pub images: Vec, + pub seed_candidates: Vec, +} + /// Metadata parsing or validation failure. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum Error { @@ -318,6 +339,11 @@ pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report) Report { version, seed: format!("0x{seed:08X}"), + encryption_status: if changed_tokens == 0 { + "clean".to_owned() + } else { + "encrypted".to_owned() + }, images: image_count, images_with_methods, types: type_count, @@ -331,6 +357,199 @@ pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report) )) } +/// Discover seeds compatible with the known v31 five-round RID permutation. +/// +/// This is diagnostic and does not modify metadata. It enumerates the only +/// possible per-image key residues and intersects them over the 32-bit seed +/// domain. An empty candidate list means that the sample changed the +/// permutation itself rather than merely embedding a different seed. +pub fn discover_method_token_seeds(data: &[u8]) -> Result { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Ok(SeedDiscoveryReport { + version, + images: Vec::new(), + seed_candidates: Vec::new(), + }); + } + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut reports = Vec::with_capacity(image_count); + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = + usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( + |_| Error::Malformed(format!("type {type_index} has negative methodStart")), + )?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + methods.extend(method_start..method_end); + } + if methods.is_empty() { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: 0, + modulus: 0, + clean: true, + seed_residues: Vec::new(), + }); + continue; + } + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return validation(format!( + "image {image_index} method block is not contiguous" + )); + } + let mut values = Vec::with_capacity(methods.len()); + let mut clean = true; + for method_index in methods { + let token = read_u32( + data, + method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + )?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return validation(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + clean &= rid == expected; + values.push((rid, expected)); + } + let count = u32::try_from(values.len()) + .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; + if clean { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: count / 2, + clean, + seed_residues: Vec::new(), + }); + continue; + } + let low = values + .iter() + .map(|(rid, _)| *rid) + .min() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + let high = values + .iter() + .map(|(rid, _)| *rid) + .max() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + if high - low + 1 != count || count < 2 { + return validation(format!( + "image {image_index} RID interval is not a permutation" + )); + } + let half = count / 2; + let quarter = count / 4; + let mut residues = Vec::new(); + for key_delta in 0..half { + let key = quarter + key_delta; + let valid = values + .iter() + .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); + if valid { + residues.push(key_delta); + } + } + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: half, + clean, + seed_residues: residues, + }); + } + + let constraints = reports + .iter() + .filter(|report| !report.clean) + .collect::>(); + let mut seeds = Vec::new(); + if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { + for &residue in &anchor.seed_residues { + let mut candidate = u64::from(residue); + let modulus = u64::from(anchor.modulus); + while candidate <= u64::from(u32::MAX) { + let valid = constraints.iter().all(|report| { + report.modulus != 0 + && !report.seed_residues.is_empty() + && report + .seed_residues + .iter() + .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) + }); + if valid { + seeds.push(candidate as u32); + } + candidate = candidate.saturating_add(modulus); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + Ok(SeedDiscoveryReport { + version, + images: reports, + seed_candidates: seeds, + }) +} + +fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { + let count = high - low + 1; + let mut value = rid - low; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + low +} + #[cfg(test)] mod tests { use super::*; @@ -394,6 +613,8 @@ mod tests { let (data, methods) = build(&tokens); let (restored, report) = restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "encrypted"); + assert!(report.changed_tokens > 0); assert_eq!(report.correct_after, 7); for index in 0..7 { assert_eq!( @@ -415,7 +636,24 @@ mod tests { let (data, _) = build(&tokens); let (restored, report) = restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "clean"); assert_eq!(report.changed_tokens, 0); assert_eq!(restored, data); } + + #[test] + fn encrypted_metadata_rejects_the_wrong_seed() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, _) = build(&tokens); + let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); + + assert!(matches!( + restore_method_tokens(&data, wrong_seed), + Err(Error::Validation(_)) + )); + } } diff --git a/senbei-android-stage2/Cargo.toml b/senbei-android-stage2/Cargo.toml new file mode 100644 index 0000000..489b620 --- /dev/null +++ b/senbei-android-stage2/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "senbei-android-stage2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Static Stage 1 and Stage 2 extraction for Senbei Android" + +[dependencies] +goblin.workspace = true +memmap2.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +senbei-android-crypto.workspace = true + +[lints] +workspace = true diff --git a/senbei-android-stage2/src/error.rs b/senbei-android-stage2/src/error.rs new file mode 100644 index 0000000..2711d68 --- /dev/null +++ b/senbei-android-stage2/src/error.rs @@ -0,0 +1,63 @@ +use std::path::{Path, PathBuf}; + +/// Stage 1 or Stage 2 extraction failure. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{action} `{path}`: {source}")] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("parse ELF `{path}`: {source}")] + Elf { + path: PathBuf, + #[source] + source: goblin::error::Error, + }, + #[error("serialize extraction index: {0}")] + Json(#[from] serde_json::Error), + #[error("embedded Stage 2 decoder configuration: {0}")] + EmbeddedConfig(#[source] senbei_android_crypto::Error), + #[error( + "depth {depth} stream 0x{stream_id:02X} interpreter 0x{interpreter_id:02X} configuration: {source}" + )] + InterpreterConfig { + depth: usize, + stream_id: u32, + interpreter_id: u32, + #[source] + source: senbei_android_crypto::Error, + }, + #[error( + "depth {depth} stream 0x{stream_id:02X} record {record_index} command 0x{command_id:02X} {part}: {source}" + )] + RecordDecode { + depth: usize, + stream_id: u32, + record_index: usize, + command_id: u32, + part: &'static str, + #[source] + source: senbei_android_crypto::Error, + }, + #[error("{0}")] + Invalid(String), +} + +impl Error { + pub(crate) fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self { + Self::Io { + action, + path: path.to_path_buf(), + source, + } + } +} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} diff --git a/senbei-android-stage2/src/extract.rs b/senbei-android-stage2/src/extract.rs new file mode 100644 index 0000000..c79c598 --- /dev/null +++ b/senbei-android-stage2/src/extract.rs @@ -0,0 +1,529 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fs::{File, create_dir_all}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use memmap2::MmapOptions; +use senbei_android_crypto::{Module9bConfig, decode_container}; +use serde_json::to_vec_pretty; +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; + +use crate::error::{Error, Result, invalid}; +use crate::report::{ + ArtifactReport, DecoderReport, ExtractionReport, ModuleRegistryEntry, RecordReport, + Stage1Report, StreamParent, StreamReport, +}; +use crate::stage1::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, SHT_LOUSER, Stage1Result, inspect, +}; +use crate::stream::{DIRECT_FLAG, Record, parse_record_stream}; + +/// Inputs and output locations for one complete static Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractOptions { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractOptions { + #[must_use] + pub fn with_defaults(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + +#[derive(Debug)] +struct LoadedModule { + image: Vec, + metadata: Option>, + image_path: String, + metadata_path: Option, + sha256: String, + depth: usize, + record_index: usize, + command_id: u32, + init_offset: u32, + entry_offset: u32, +} + +#[derive(Debug, Clone, Copy)] +struct ArtifactSpec<'a> { + suffix: &'a str, + kind: &'a str, + classification: &'a str, +} + +struct Extractor { + output_dir: PathBuf, + streams: Vec, + artifacts: Vec, + registry: BTreeMap, + seen_streams: HashSet<(u32, String)>, +} + +pub fn extract_stage2(options: &ExtractOptions) -> Result { + let input_path = absolute(&options.input)?; + let output_dir = absolute(&options.output_dir)?; + if !input_path.is_file() { + return invalid(format!( + "protected ELF does not exist: {}", + input_path.display() + )); + } + if let Some(stage2_output) = &options.stage2_output { + let stage2_output = absolute(stage2_output)?; + if stage2_output == input_path { + return invalid("refusing to overwrite the protected ELF with Stage 2 output"); + } + } + create_dir_all(&output_dir) + .map_err(|source| Error::io("create Stage 2 output directory", &output_dir, source))?; + + let file = File::open(&input_path) + .map_err(|source| Error::io("open protected ELF", &input_path, source))?; + // SAFETY: the mapping is read-only, the file remains open for the mapping + // lifetime, and extraction never mutates or truncates the source. + let source = unsafe { MmapOptions::new().map(&file) } + .map_err(|source| Error::io("map protected ELF", &input_path, source))?; + let stage1 = inspect( + &source, + &input_path, + options.outer_size, + options.cipher_constant, + )?; + if let Some(stage2_output) = &options.stage2_output { + write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?; + } + + let core_config = + Module9bConfig::parse_embedded(&stage1.plaintext).map_err(Error::EmbeddedConfig)?; + let bootstrap_end = stage1 + .remaining_file_offset + .checked_add(stage1.remaining_size) + .ok_or_else(|| Error::Invalid("Stage 2 bootstrap range overflow".to_owned()))?; + let bootstrap = source + .get(stage1.remaining_file_offset..bootstrap_end) + .ok_or_else(|| Error::Invalid("Stage 2 bootstrap range is outside the ELF".to_owned()))?; + let mut extractor = Extractor { + output_dir: output_dir.clone(), + streams: Vec::new(), + artifacts: Vec::new(), + registry: BTreeMap::new(), + seen_streams: HashSet::new(), + }; + extractor.extract_stream( + bootstrap, + 0xe2, + 0, + None, + Some(stage1.remaining_file_offset), + core_config, + )?; + + let module_registry = extractor + .registry + .values() + .map(|module| ModuleRegistryEntry { + command_id: module.command_id, + size: module.image.len(), + sha256: module.sha256.clone(), + depth: module.depth, + record_index: module.record_index, + image_path: module.image_path.clone(), + metadata_path: module.metadata_path.clone(), + init_offset: module.init_offset, + entry_offset: module.entry_offset, + classification: if module.metadata.is_some() { + "module_image".to_owned() + } else { + "decoded_data".to_owned() + }, + }) + .collect::>(); + let report = ExtractionReport { + format_version: 4, + protected_elf: input_path.display().to_string(), + output_dir: output_dir.display().to_string(), + stage1: stage1_report(&stage1, options.outer_size), + streams: extractor.streams, + artifacts: extractor.artifacts, + errors: Vec::new(), + module_registry, + }; + write_json_atomic(&output_dir.join("index.json"), &report)?; + Ok(report) +} + +impl Extractor { + fn extract_stream( + &mut self, + stream: &[u8], + stream_id: u32, + depth: usize, + parent: Option, + source_file_offset: Option, + config: Module9bConfig, + ) -> Result<()> { + let digest = sha256(stream); + if !self.seen_streams.insert((stream_id, digest.clone())) { + return Ok(()); + } + let (header, records, table_size) = + parse_record_stream(stream, stream_id).map_err(|source| { + Error::Invalid(format!( + "depth {depth} stream 0x{stream_id:02X} record table: {source}" + )) + })?; + let mut stream_report = StreamReport { + depth, + stream_id, + parent, + source_file_offset, + available_size: stream.len(), + descriptor_table_size: table_size, + encrypted_header_words: header.encrypted_words, + decrypted_header_words: header.decrypted_words, + record_state: header.record_state, + sha256: digest, + decoder: decoder_report( + if depth == 0 { + "embedded_stage2" + } else { + "decoded_interpreter" + }, + (depth != 0).then_some(stream_id), + &config, + ), + records: Vec::with_capacity(records.len()), + }; + let mut direct_records = Vec::new(); + let mut modules_at_level = BTreeSet::new(); + + for record in records { + let mut result = record_report(record); + let mut image_data = None; + let mut metadata_data = None; + + if !record.direct() && record.image_size != 0 { + let image_source = record_tail(stream, record.image_offset)?; + let image = decode_container(image_source, &config, record.image_size as usize) + .map_err(|source| Error::RecordDecode { + depth, + stream_id, + record_index: record.index, + command_id: record.command_id, + part: "image decode", + source, + })?; + let classification = if record.metadata_size != 0 { + "module_image" + } else { + "decoded_data" + }; + let artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "module.bin", + kind: "decoded_container", + classification, + }, + &image, + )?; + result.image = Some(artifact.clone()); + image_data = Some((image, artifact)); + } + if record.metadata_size != 0 { + let metadata_source = record_tail(stream, record.metadata_offset)?; + let metadata = + decode_container(metadata_source, &config, record.metadata_size as usize) + .map_err(|source| Error::RecordDecode { + depth, + stream_id, + record_index: record.index, + command_id: record.command_id, + part: "metadata decode", + source, + })?; + let artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "metadata.bin", + kind: "decoded_metadata", + classification: "decoded_metadata", + }, + &metadata, + )?; + result.metadata = Some(artifact.clone()); + metadata_data = Some((metadata, artifact)); + } + if let Some((image, image_artifact)) = image_data { + let (metadata, metadata_path) = if let Some((data, artifact)) = metadata_data { + (Some(data), Some(artifact.path)) + } else { + (None, None) + }; + self.register_module(LoadedModule { + sha256: image_artifact.sha256.clone(), + image_path: image_artifact.path.clone(), + metadata_path, + image, + metadata, + depth, + record_index: record.index, + command_id: record.command_id, + init_offset: record.init_offset, + entry_offset: record.entry_offset, + })?; + modules_at_level.insert(record.command_id); + } + if record.direct() && record.image_size != 0 { + direct_records.push((record, stream_report.records.len())); + } + stream_report.records.push(result); + } + + let mut children = Vec::new(); + for (record, report_index) in direct_records { + let next_stream_id = record.command_id.wrapping_sub(0x10); + if modules_at_level.contains(&next_stream_id) { + stream_report.records[report_index].nested_stream_id = Some(next_stream_id); + children.push((record, next_stream_id)); + continue; + } + let direct_data = record_slice(stream, record.image_offset, record.image_size)?; + let artifact = self.write_artifact( + &record, + depth, + stream_id, + ArtifactSpec { + suffix: "direct.bin", + kind: "direct", + classification: "direct_data", + }, + direct_data, + )?; + stream_report.records[report_index].image = Some(artifact); + } + + self.streams.push(stream_report); + for (record, next_stream_id) in children { + let child_data = record_slice(stream, record.image_offset, record.image_size)?; + let parent = StreamParent { + stream_id, + record_index: record.index, + command_id: record.command_id, + }; + let interpreter = self.registry.get(&next_stream_id).ok_or_else(|| { + Error::Invalid(format!( + "depth {depth} stream 0x{stream_id:02X} child 0x{next_stream_id:02X} has no interpreter module" + )) + })?; + let interpreter_config = + Module9bConfig::parse(&interpreter.image).map_err(|source| { + Error::InterpreterConfig { + depth: depth + 1, + stream_id: next_stream_id, + interpreter_id: next_stream_id, + source, + } + })?; + self.extract_stream( + child_data, + next_stream_id, + depth + 1, + Some(parent), + None, + interpreter_config, + )?; + } + Ok(()) + } + + fn register_module(&mut self, module: LoadedModule) -> Result<()> { + if let Some(previous) = self.registry.get(&module.command_id) { + if previous.sha256 != module.sha256 { + return invalid(format!( + "module 0x{:02X} produced conflicting images: {} and {}", + module.command_id, previous.sha256, module.sha256 + )); + } + return Ok(()); + } + self.registry.insert(module.command_id, module); + Ok(()) + } + + fn write_artifact( + &mut self, + record: &Record, + depth: usize, + stream_id: u32, + spec: ArtifactSpec<'_>, + data: &[u8], + ) -> Result { + let digest = sha256(data); + let filename = format!( + "d{depth:02}_s{stream_id:02X}_r{:03}_id{:08X}_{}.{}", + record.index, + record.command_id, + &digest[..12], + spec.suffix + ); + let path = self.output_dir.join(filename); + write_atomic(&path, data)?; + let artifact = ArtifactReport { + kind: spec.kind.to_owned(), + path: path + .file_name() + .ok_or_else(|| Error::Invalid("artifact path has no file name".to_owned()))? + .to_string_lossy() + .into_owned(), + size: data.len(), + sha256: digest, + depth, + stream_id, + record_index: Some(record.index), + command_id: Some(record.command_id), + classification: spec.classification.to_owned(), + }; + self.artifacts.push(artifact.clone()); + Ok(artifact) + } +} + +fn record_report(record: Record) -> RecordReport { + RecordReport { + index: record.index, + command_id: record.command_id, + flags: record.flags, + image_offset: record.image_offset, + image_size: record.image_size, + metadata_offset: record.metadata_offset, + metadata_size: record.metadata_size, + id_copy: record.id_copy, + entry_offset: record.entry_offset, + init_offset: record.init_offset, + direct: record.flags & DIRECT_FLAG != 0, + extraction_status: "complete".to_owned(), + image: None, + metadata: None, + nested_stream_id: None, + } +} + +fn decoder_report( + kind: &str, + interpreter_id: Option, + config: &Module9bConfig, +) -> DecoderReport { + DecoderReport { + kind: kind.to_owned(), + interpreter_id, + header_seed: config.header_seed, + container_seed: config.container_seed, + schedule_offset: config.schedule_offset, + aes_key_sha256: sha256(&config.aes_key), + skip_aes: config.skip_aes, + } +} + +fn record_slice(stream: &[u8], offset: u32, size: u32) -> Result<&[u8]> { + let offset = usize::try_from(offset) + .map_err(|_| Error::Invalid("record payload offset exceeds usize".to_owned()))?; + let size = usize::try_from(size) + .map_err(|_| Error::Invalid("record payload size exceeds usize".to_owned()))?; + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("record payload range overflows usize".to_owned()))?; + stream.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "record payload range 0x{offset:x}..0x{end:x} exceeds stream 0x{:x}", + stream.len() + )) + }) +} + +fn record_tail(stream: &[u8], offset: u32) -> Result<&[u8]> { + let offset = usize::try_from(offset) + .map_err(|_| Error::Invalid("record container offset exceeds usize".to_owned()))?; + stream.get(offset..).ok_or_else(|| { + Error::Invalid(format!( + "record container offset 0x{offset:x} exceeds stream 0x{:x}", + stream.len() + )) + }) +} + +fn stage1_report(stage1: &Stage1Result, outer_size: usize) -> Stage1Report { + Stage1Report { + section_index: stage1.section_index, + section_type: SHT_LOUSER, + section_offset: stage1.section_offset, + section_size: stage1.section_size, + outer_size, + header_offset: stage1.header_offset, + header_key: stage1.header.key, + payload_offset: stage1.header.payload_offset, + payload_size: stage1.header.payload_size, + payload_key: stage1.header.payload_key, + entry_offset: stage1.header.entry_offset, + protect_size: stage1.header.protect_size, + stage2_file_offset: stage1.payload_file_offset, + stage2_size: stage1.plaintext.len(), + stage2_sha256: sha256(&stage1.plaintext), + remaining_file_offset: stage1.remaining_file_offset, + remaining_size: stage1.remaining_size, + } +} + +fn write_json_atomic(path: &Path, value: &impl serde::Serialize) -> Result<()> { + let mut bytes = to_vec_pretty(value)?; + bytes.push(b'\n'); + write_atomic(path, &bytes) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + create_dir_all(parent) + .map_err(|source| Error::io("create output directory", parent, source))?; + let mut temporary = NamedTempFile::new_in(parent) + .map_err(|source| Error::io("create temporary output", parent, source))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .map_err(|source| Error::io("write temporary output", temporary.path(), source))?; + temporary + .persist(path) + .map_err(|error| Error::io("replace output", path, error.error))?; + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|current| current.join(path)) + .map_err(|source| Error::io("query current directory", path, source)) + } +} + +fn sha256(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} diff --git a/senbei-android-stage2/src/lib.rs b/senbei-android-stage2/src/lib.rs new file mode 100644 index 0000000..b8736cc --- /dev/null +++ b/senbei-android-stage2/src/lib.rs @@ -0,0 +1,12 @@ +//! Pure-static Stage 1 decryption and recursive Stage 2 module extraction. + +mod error; +mod extract; +mod report; +mod stage1; +mod stream; + +pub use error::Error; +pub use extract::{ExtractOptions, extract_stage2}; +pub use report::ExtractionReport; +pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; diff --git a/senbei-android-stage2/src/report.rs b/senbei-android-stage2/src/report.rs new file mode 100644 index 0000000..9435067 --- /dev/null +++ b/senbei-android-stage2/src/report.rs @@ -0,0 +1,115 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Stage1Report { + pub section_index: usize, + pub section_type: u32, + pub section_offset: usize, + pub section_size: usize, + pub outer_size: usize, + pub header_offset: usize, + pub header_key: u32, + pub payload_offset: u32, + pub payload_size: u32, + pub payload_key: u32, + pub entry_offset: u32, + pub protect_size: u32, + pub stage2_file_offset: usize, + pub stage2_size: usize, + pub stage2_sha256: String, + pub remaining_file_offset: usize, + pub remaining_size: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DecoderReport { + pub kind: String, + pub interpreter_id: Option, + pub header_seed: u32, + pub container_seed: u32, + pub schedule_offset: usize, + pub aes_key_sha256: String, + pub skip_aes: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArtifactReport { + pub kind: String, + pub path: String, + pub size: usize, + pub sha256: String, + pub depth: usize, + pub stream_id: u32, + pub record_index: Option, + pub command_id: Option, + pub classification: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordReport { + pub index: usize, + pub command_id: u32, + pub flags: u32, + pub image_offset: u32, + pub image_size: u32, + pub metadata_offset: u32, + pub metadata_size: u32, + pub id_copy: u32, + pub entry_offset: u32, + pub init_offset: u32, + pub direct: bool, + pub extraction_status: String, + pub image: Option, + pub metadata: Option, + pub nested_stream_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StreamParent { + pub stream_id: u32, + pub record_index: usize, + pub command_id: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StreamReport { + pub depth: usize, + pub stream_id: u32, + pub parent: Option, + pub source_file_offset: Option, + pub available_size: usize, + pub descriptor_table_size: usize, + pub encrypted_header_words: [u32; 2], + pub decrypted_header_words: [u32; 2], + pub record_state: u32, + pub sha256: String, + pub decoder: DecoderReport, + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ModuleRegistryEntry { + pub command_id: u32, + pub size: usize, + pub sha256: String, + pub depth: usize, + pub record_index: usize, + pub image_path: String, + pub metadata_path: Option, + pub init_offset: u32, + pub entry_offset: u32, + pub classification: String, +} + +/// Machine-readable output of one complete static Stage 2 extraction. +#[derive(Debug, Clone, Serialize)] +pub struct ExtractionReport { + pub format_version: u32, + pub protected_elf: String, + pub output_dir: String, + pub stage1: Stage1Report, + pub streams: Vec, + pub artifacts: Vec, + pub errors: Vec, + pub module_registry: Vec, +} diff --git a/senbei-android-stage2/src/stage1.rs b/senbei-android-stage2/src/stage1.rs new file mode 100644 index 0000000..fe629e5 --- /dev/null +++ b/senbei-android-stage2/src/stage1.rs @@ -0,0 +1,231 @@ +use std::path::Path; + +use goblin::elf::{Elf, header::EM_AARCH64}; + +use crate::error::{Error, Result, invalid}; + +pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; +pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d; +pub const DEFAULT_OUTER_SIZE: usize = 0x23c; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Stage1Header { + pub key: u32, + pub reserved: u32, + pub payload_offset: u32, + pub payload_size: u32, + pub payload_key: u32, + pub entry_offset: u32, + pub protect_size: u32, + pub size_copy: u32, +} + +#[derive(Debug)] +pub(crate) struct Stage1Result { + pub section_index: usize, + pub section_offset: usize, + pub section_size: usize, + pub header_offset: usize, + pub payload_file_offset: usize, + pub remaining_file_offset: usize, + pub remaining_size: usize, + pub header: Stage1Header, + pub plaintext: Vec, +} + +pub(crate) fn inspect( + data: &[u8], + path: &Path, + outer_size: usize, + cipher_constant: u32, +) -> Result { + let elf = Elf::parse(data).map_err(|source| Error::Elf { + path: path.to_path_buf(), + source, + })?; + if elf.header.e_machine != EM_AARCH64 { + return invalid(format!( + "expected AArch64 ELF (machine 0x{EM_AARCH64:X}), got 0x{:X}", + elf.header.e_machine + )); + } + let matches = elf + .section_headers + .iter() + .enumerate() + .filter(|(_, section)| section.sh_type == SHT_LOUSER) + .collect::>(); + if matches.len() != 1 { + return invalid(format!( + "expected exactly one SHT_LOUSER section, found {}", + matches.len() + )); + } + let (section_index, section) = matches[0]; + let section_offset = usize::try_from(section.sh_offset) + .map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?; + let section_size = usize::try_from(section.sh_size) + .map_err(|_| Error::Invalid("SHT_LOUSER size exceeds usize".to_owned()))?; + let section_end = section_offset + .checked_add(section_size) + .ok_or_else(|| Error::Invalid("SHT_LOUSER range overflows usize".to_owned()))?; + if section_end > data.len() { + return invalid("SHT_LOUSER range extends beyond the input file"); + } + let header_relative = outer_size; + if outer_size + .checked_add(0x1000) + .is_none_or(|end| end > section_size) + { + return invalid("Stage 1 outer header leaves no complete parameter area"); + } + let header_offset = section_offset + .checked_add(header_relative) + .ok_or_else(|| Error::Invalid("Stage 1 header offset overflow".to_owned()))?; + let header_raw = bytes(data, header_offset, 0x1000)?; + let header = decrypt_header(header_raw, cipher_constant)?; + if header.reserved != 0 { + return invalid(format!( + "Stage 1 header reserved word is nonzero: 0x{:x}", + header.reserved + )); + } + if header.size_copy != header.payload_size { + return invalid(format!( + "Stage 1 payload size copy 0x{:x} != size 0x{:x}", + header.size_copy, header.payload_size + )); + } + let private_size = section_size - outer_size; + let payload_offset = usize::try_from(header.payload_offset) + .map_err(|_| Error::Invalid("Stage 1 payload offset exceeds usize".to_owned()))?; + let payload_size = usize::try_from(header.payload_size) + .map_err(|_| Error::Invalid("Stage 1 payload size exceeds usize".to_owned()))?; + let payload_end = payload_offset + .checked_add(payload_size) + .ok_or_else(|| Error::Invalid("Stage 1 payload range overflow".to_owned()))?; + if payload_offset < 0x20 || payload_end > private_size { + return invalid(format!( + "Stage 1 payload range 0x{payload_offset:x}..0x{payload_end:x} exceeds private size 0x{private_size:x}" + )); + } + if payload_size == 0 || payload_size % 4 != 0 { + return invalid(format!( + "Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}" + )); + } + let entry_offset = usize::try_from(header.entry_offset) + .map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?; + if entry_offset >= payload_size { + return invalid("Stage 1 entry offset is outside the payload"); + } + let protect_size = usize::try_from(header.protect_size) + .map_err(|_| Error::Invalid("Stage 1 protect size exceeds usize".to_owned()))?; + if protect_size > payload_size { + return invalid("Stage 1 mprotect length exceeds the payload"); + } + let payload_file_offset = header_offset + .checked_add(payload_offset) + .ok_or_else(|| Error::Invalid("Stage 1 payload file offset overflow".to_owned()))?; + let encrypted = bytes(data, payload_file_offset, payload_size)?; + let plaintext = decrypt_words(encrypted, header.payload_key, cipher_constant)?; + let aligned_payload_end = (payload_end + 3) & !3; + let remaining_relative = aligned_payload_end; + if remaining_relative > private_size { + return invalid("aligned Stage 2 cursor exceeds SHT_LOUSER"); + } + let remaining_file_offset = section_offset + .checked_add(outer_size) + .and_then(|value| value.checked_add(remaining_relative)) + .ok_or_else(|| Error::Invalid("Stage 2 stream offset overflow".to_owned()))?; + Ok(Stage1Result { + section_index, + section_offset, + section_size, + header_offset, + payload_file_offset, + remaining_file_offset, + remaining_size: private_size - remaining_relative, + header, + plaintext, + }) +} + +fn decrypt_header(raw: &[u8], constant: u32) -> Result { + let key = read_u32(raw, 0)?; + let mut decoded = decrypt_words(&raw[..0x20], key, constant)?; + decoded[..4].copy_from_slice(&key.to_le_bytes()); + Ok(Stage1Header { + key, + reserved: read_u32(&decoded, 4)?, + payload_offset: read_u32(&decoded, 8)?, + payload_size: read_u32(&decoded, 12)?, + payload_key: read_u32(&decoded, 16)?, + entry_offset: read_u32(&decoded, 20)?, + protect_size: read_u32(&decoded, 24)?, + size_copy: read_u32(&decoded, 28)?, + }) +} + +fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result> { + if ciphertext.len() % 4 != 0 { + return invalid("Stage 1 word cipher input is not 4-byte aligned"); + } + let mut plaintext = ciphertext.to_vec(); + for (index, chunk) in plaintext.chunks_exact_mut(4).enumerate() { + let index = u32::try_from(index) + .map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?; + let mut word = u32::from_le_bytes( + chunk + .try_into() + .map_err(|_| Error::Invalid("Stage 1 word has an invalid size".to_owned()))?, + ); + word = word.wrapping_add(index.wrapping_add(3).wrapping_mul(key)); + word ^= constant.wrapping_mul(index.wrapping_add(1)); + chunk.copy_from_slice(&word.to_le_bytes()); + } + Ok(plaintext) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is outside the input" + )) + }) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = bytes(data, offset, 4)?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::Invalid("invalid u32 byte range".to_owned()) + })?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage1_word_transform_round_trips() { + let key = 0x1234_5678; + let constant = DEFAULT_CIPHER_CONSTANT; + let plain = [0x1122_3344_u32, 0xaabb_ccdd, 0x0102_0304]; + let mut cipher = Vec::new(); + for (index, value) in plain.into_iter().enumerate() { + let index = index as u32; + let word = (value ^ constant.wrapping_mul(index + 1)) + .wrapping_sub((index + 3).wrapping_mul(key)); + cipher.extend_from_slice(&word.to_le_bytes()); + } + let decoded = decrypt_words(&cipher, key, constant).unwrap(); + let expected = plain + .into_iter() + .flat_map(u32::to_le_bytes) + .collect::>(); + assert_eq!(decoded, expected); + } +} diff --git a/senbei-android-stage2/src/stream.rs b/senbei-android-stage2/src/stream.rs new file mode 100644 index 0000000..b1d4e19 --- /dev/null +++ b/senbei-android-stage2/src/stream.rs @@ -0,0 +1,168 @@ +use senbei_android_crypto::gf32_mul_fixed; + +use crate::error::{Error, Result, invalid}; + +pub(crate) const RECORD_SIZE: usize = 0x5c; +pub(crate) const DIRECT_FLAG: u32 = 2; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Record { + pub index: usize, + pub command_id: u32, + pub flags: u32, + pub image_offset: u32, + pub image_size: u32, + pub metadata_offset: u32, + pub metadata_size: u32, + pub id_copy: u32, + pub entry_offset: u32, + pub init_offset: u32, +} + +impl Record { + pub(crate) fn direct(self) -> bool { + self.flags & DIRECT_FLAG != 0 + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StreamHeader { + pub encrypted_words: [u32; 2], + pub decrypted_words: [u32; 2], + pub record_state: u32, +} + +pub(crate) fn parse_record_stream( + stream: &[u8], + stream_id: u32, +) -> Result<(StreamHeader, Vec, usize)> { + if stream.len() < 8 { + return invalid(format!( + "stream 0x{stream_id:02X} is shorter than its 8-byte header" + )); + } + let cipher0 = read_u32(stream, 0)?; + let cipher1 = read_u32(stream, 4)?; + let key = stream_id.wrapping_mul(0x9d32_3cd7); + let shift = stream_id & 7; + let base = (key >> shift) + .wrapping_add(0x5e72_7d74) + .wrapping_add(key.wrapping_shl(stream_id & 0xb)) + .wrapping_add(0xf71e_3005); + let plain0 = + gf32_mul_fixed(cipher0.wrapping_add(0xcbf0_c1d8)) ^ 0xeb_e81dba_u32.wrapping_add(base); + let plain1 = gf32_mul_fixed(cipher1.wrapping_add(cipher0)) + ^ 0xeb_e81dba_u32.wrapping_mul(5).wrapping_add(base); + let header = StreamHeader { + encrypted_words: [cipher0, cipher1], + decrypted_words: [plain0, plain1], + record_state: plain1.wrapping_add(base), + }; + + let mut records = Vec::new(); + let mut first_payload = stream.len(); + for index in 0..256_usize { + let start = + 8_usize + .checked_add(index.checked_mul(RECORD_SIZE).ok_or_else(|| { + Error::Invalid("record descriptor offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("record descriptor offset overflow".to_owned()))?; + let end = start + .checked_add(RECORD_SIZE) + .ok_or_else(|| Error::Invalid("record descriptor end overflow".to_owned()))?; + if end > stream.len() { + return invalid(format!( + "stream 0x{stream_id:02X} descriptor table is truncated at record {index}" + )); + } + let record = decrypt_record(&stream[start..end], index, header.record_state)?; + if record.id_copy != 0 && record.command_id != record.id_copy { + return invalid(format!( + "stream 0x{stream_id:02X} record {index} command/id mismatch: 0x{:X} != 0x{:X}", + record.command_id, record.id_copy + )); + } + for (offset, size) in [ + (record.image_offset, record.image_size), + (record.metadata_offset, record.metadata_size), + ] { + if offset != 0 && size != 0 { + let offset = usize::try_from(offset).map_err(|_| { + Error::Invalid(format!( + "stream 0x{stream_id:02X} record {index} payload offset exceeds usize" + )) + })?; + if offset >= stream.len() { + return invalid(format!( + "stream 0x{stream_id:02X} record {index} payload offset 0x{offset:x} exceeds stream 0x{:x}", + stream.len() + )); + } + first_payload = first_payload.min(offset); + } + } + records.push(record); + if end == first_payload { + return Ok((header, records, first_payload)); + } + if end > first_payload { + return invalid(format!( + "stream 0x{stream_id:02X} descriptor table crosses first payload at 0x{first_payload:x}" + )); + } + } + invalid(format!( + "stream 0x{stream_id:02X} has no descriptor boundary in 256 records" + )) +} + +fn decrypt_record(raw: &[u8], index: usize, state: u32) -> Result { + if raw.len() != RECORD_SIZE { + return invalid(format!( + "record {index} has size 0x{:x}, expected 0x{RECORD_SIZE:x}", + raw.len() + )); + } + let product = state.wrapping_add(0x96f6_0b71).wrapping_mul(state); + let index_mask = product.wrapping_shl(((index + 1) & 3) as u32); + let mix = state.wrapping_mul(0x06a5_5bcc).wrapping_add(product); + let mut accumulator = 0x7993_4cf6_u32; + let mut feedback = 0xf02f_7685_u32; + let mut words = [0_u32; RECORD_SIZE / 4]; + for (word_index, chunk) in raw.chunks_exact(4).enumerate() { + feedback = feedback.wrapping_mul(feedback); + let cipher = u32::from_le_bytes( + chunk + .try_into() + .map_err(|_| Error::Invalid("record word has an invalid size".to_owned()))?, + ); + let mut value = gf32_mul_fixed(cipher ^ (feedback >> 3)) ^ index_mask; + value = value.wrapping_add(accumulator).wrapping_add(state); + value = value.wrapping_sub(mix >> ((word_index * 4 + 3) & 5)); + words[word_index] = value; + accumulator = accumulator.wrapping_add(0xe64d_33d8); + feedback = cipher; + } + Ok(Record { + index, + command_id: words[0], + flags: words[1], + image_offset: words[2], + image_size: words[3], + metadata_offset: words[4], + metadata_size: words[5], + id_copy: words[6], + entry_offset: words[7], + init_offset: words[8], + }) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 4).ok_or_else(|| { + Error::Invalid(format!("record header range 0x{offset:x} is out of bounds")) + })?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::Invalid("invalid record u32 range".to_owned()) + })?)) +} From 7f827d6400ecf3084d13fc9adcbf6e6182a2df5f Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 02:38:07 +0800 Subject: [PATCH 4/8] feat: add folder-based Android unpack workflow --- Cargo.lock | 114 +++- Cargo.toml | 6 +- README.md | 111 +--- senbei-android-cli/Cargo.toml | 1 - senbei-android-cli/src/main.rs | 278 +--------- .../Cargo.toml | 2 +- .../src/error.rs | 0 .../src/extract.rs | 0 senbei-android-engine/src/lib.rs | 32 ++ .../src/report.rs | 0 .../src/stage1.rs | 31 ++ .../src/stream.rs | 0 senbei-android-io/Cargo.toml | 4 +- senbei-android-io/src/folder.rs | 505 ++++++++++++++++++ senbei-android-io/src/lib.rs | 8 +- senbei-android-stage2/src/lib.rs | 12 - 16 files changed, 702 insertions(+), 402 deletions(-) rename {senbei-android-stage2 => senbei-android-engine}/Cargo.toml (93%) rename {senbei-android-stage2 => senbei-android-engine}/src/error.rs (100%) rename {senbei-android-stage2 => senbei-android-engine}/src/extract.rs (100%) create mode 100644 senbei-android-engine/src/lib.rs rename {senbei-android-stage2 => senbei-android-engine}/src/report.rs (100%) rename {senbei-android-stage2 => senbei-android-engine}/src/stage1.rs (92%) rename {senbei-android-stage2 => senbei-android-engine}/src/stream.rs (100%) create mode 100644 senbei-android-io/src/folder.rs delete mode 100644 senbei-android-stage2/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9df140a..ec734a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aes" version = "0.8.4" @@ -34,6 +40,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cfg-if" version = "1.0.4" @@ -59,6 +71,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -95,6 +122,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -175,6 +212,16 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -251,7 +298,6 @@ dependencies = [ "anyhow", "senbei-android-io", "senbei-android-metadata", - "senbei-android-stage2", "serde_json", ] @@ -277,29 +323,7 @@ dependencies = [ ] [[package]] -name = "senbei-android-io" -version = "0.1.0" -dependencies = [ - "anyhow", - "senbei-android-elf", - "senbei-android-metadata", - "senbei-android-stage2", - "serde", - "serde_json", - "sha2", - "tempfile", -] - -[[package]] -name = "senbei-android-metadata" -version = "0.1.0" -dependencies = [ - "serde", - "thiserror", -] - -[[package]] -name = "senbei-android-stage2" +name = "senbei-android-engine" version = "0.1.0" dependencies = [ "goblin", @@ -312,6 +336,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "senbei-android-io" +version = "0.1.0" +dependencies = [ + "anyhow", + "flate2", + "senbei-android-elf", + "senbei-android-engine", + "senbei-android-metadata", + "serde", + "serde_json", + "sha2", + "tempfile", + "zip", +] + +[[package]] +name = "senbei-android-metadata" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", +] + [[package]] name = "serde" version = "1.0.229" @@ -366,6 +414,12 @@ dependencies = [ "digest", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "syn" version = "2.0.119" @@ -454,6 +508,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index caf0c2f..388d306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ "senbei-android-elf", "senbei-android-io", "senbei-android-metadata", - "senbei-android-stage2", + "senbei-android-engine", ] default-members = ["senbei-android-cli"] resolver = "2" @@ -19,6 +19,7 @@ license = "AGPL-3.0-only" [workspace.dependencies] aes = "0.8" anyhow = "1" +flate2 = "1" goblin = "0.10" memmap2 = "0.9" serde = { version = "1", features = ["derive"] } @@ -26,12 +27,13 @@ serde_json = "1" sha2 = "0.10" tempfile = "3" thiserror = "2" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } senbei-android-crypto = { path = "senbei-android-crypto" } senbei-android-elf = { path = "senbei-android-elf" } senbei-android-io = { path = "senbei-android-io" } senbei-android-metadata = { path = "senbei-android-metadata" } -senbei-android-stage2 = { path = "senbei-android-stage2" } +senbei-android-engine = { path = "senbei-android-engine" } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/README.md b/README.md index 3e3e5f8..771e5af 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,9 @@ # Senbei Android -用于静态还原 Android 版受保护的 `libil2cpp.so` 和 IL2CPP -`global-metadata.dat`。生产路径已经完全 Rust 化,不执行保护器代码,也不依赖 -Unicorn、IDA 或 Python。 - ## 兼容性 -| 游戏 | 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | -|------|------|------|------|--------------|---------------------| -| リバースブルー×リバースエンド | Android | 1.28.2 | AArch64 | 支持 | v31 MethodDef token | +### リバースブルー×リバースエンド -当前 SO 实现针对该版本的 Stage 2 模块格式,运行时会从模块产物中发现 -`0x9B` 的种子、AES-256 key schedule 和相关配置,不硬编码样本 offset。 -metadata 默认使用模块 `0x0C` 中确认的 seed `0xA6FAE968`。 - -## 构建 - -```powershell -cargo build --release -``` - -生成的程序为: - -```text -target\release\senbei-android.exe -``` - -## 还原 libil2cpp.so - -先直接从受保护 SO 静态提取 Stage 1/Stage 2 和模块索引: - -```powershell -senbei-android extract-stage2 INPUT OUTPUT_DIR -``` - -默认在 `OUTPUT_DIR` 写入紧凑的 `index.json` 及后续还原实际需要的模块产物; -需要保留完整 Stage 2 镜像用于分析时,额外传入 `--stage2-out FILE`。 - -```powershell -senbei-android restore-so INPUT OUTPUT --index INDEX_JSON --report REPORT_JSON -``` - -示例: - -```powershell -senbei-android restore-so ` - Native\libil2cpp.so ` - Native\libil2cpp_restored.so ` - --index Native\libil2cpp_stage2_modules\index.json ` - --report Native\libil2cpp_restore_report.json -``` - -省略 `--index` 时,默认读取输入文件同目录下的: - -```text -libil2cpp_stage2_modules\index.json -``` - -可选参数: - -- `--dump-aux FILE`:保存解码后的辅助 ELF 数据。 -- `--outer-only`:只还原主容器,不物化辅助动态链接表。 -- `--preserve-entrypoint`:保留保护器入口点;正常干净输出不应使用此项。 - -完整还原会静态处理 `0x9B/0x9D/0x9E` 数据,恢复 ELF load image、隐藏动态 -符号、字符串、SysV/GNU hash、version、`.rela.dyn` 和 `.rela.plt`,移除 -`SHT_LOUSER` 私有区并将入口点归零。 - -## 还原 metadata - -```powershell -senbei-android restore-metadata INPUT OUTPUT --report REPORT_JSON -``` - -示例: - -```powershell -senbei-android restore-metadata ` - Package\base\assets\bin\Data\Managed\Metadata\global-metadata.dat ` - Package\base\assets\bin\Data\Managed\Metadata\global-metadata_restored.dat ` - --report metadata_restore_report.json -``` - -可用 `--seed 0xA6FAE968` 显式指定十六进制 seed,也支持十进制。还原操作是 -幂等的,并严格先检测状态、再决定是否解密: - -- 先验证 metadata magic、版本、表边界、MethodDef token 类型与完整归属关系。 -- 所有 image 的 RID 已规范时报告 `encryption_status: "clean"`,不执行逆置换, - 输出与输入逐字节一致。 -- 存在非规范 RID 时,必须先确认它们构成合法置换,并让指定 seed 对所有 image - 完整通过五轮逆置换校验;只有此时才报告 `encryption_status: "encrypted"` 并写出结果。 -- seed 错误、算法变化或数据损坏会直接报错,不生成输出文件和报告。 - -不确定样本 seed 时可先执行只读诊断: - -```powershell -senbei-android discover-metadata INPUT -``` - -该命令不会修改文件,会列出每个 image 的状态、seed residue 以及满足当前 v31 -算法的 32 位 seed 候选。 - -## Workspace - -| Crate | 职责 | -|-------|------| -| `senbei-android-cli` | 命令行参数解析与结果输出 | -| `senbei-android-io` | 路径推导、原地覆盖保护、原子写入与 JSON 报告 | -| `senbei-android-elf` | AArch64 ELF 还原与结构验证 | -| `senbei-android-crypto` | `0x9B/0x9D` 容器、AES、Huffman/LZ 和字变换 | -| `senbei-android-metadata` | v31 MethodDef token 静态逆变换与覆盖验证 | +| 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | +|------|------|------|--------------|---------------------| +| Android | 1.28.2 | AArch64 | ✅ | v31 | diff --git a/senbei-android-cli/Cargo.toml b/senbei-android-cli/Cargo.toml index b64f9a2..9d274b9 100644 --- a/senbei-android-cli/Cargo.toml +++ b/senbei-android-cli/Cargo.toml @@ -15,7 +15,6 @@ anyhow.workspace = true serde_json.workspace = true senbei-android-io.workspace = true senbei-android-metadata.workspace = true -senbei-android-stage2.workspace = true [lints] workspace = true diff --git a/senbei-android-cli/src/main.rs b/senbei-android-cli/src/main.rs index 93a4248..3da1b59 100644 --- a/senbei-android-cli/src/main.rs +++ b/senbei-android-cli/src/main.rs @@ -1,14 +1,10 @@ -use std::ffi::OsString; use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use senbei_android_io::{ - ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, run_extract_stage2, run_restore_metadata, - run_restore_so, -}; +use senbei_android_io::run_folder; fn main() -> std::process::ExitCode { - match run(std::env::args_os().skip(1)) { + match run(std::env::args_os().skip(1).collect()) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error:#}"); @@ -17,264 +13,42 @@ fn main() -> std::process::ExitCode { } } -fn run(args: impl Iterator) -> Result<()> { - let mut args = args.peekable(); - let Some(command) = args.next() else { - print_help(); - bail!("missing command"); - }; - let command = command.to_string_lossy(); - match command.as_ref() { - "restore-so" => restore_so(args.collect()), - "restore-metadata" => restore_metadata(args.collect()), - "discover-metadata" => discover_metadata(args.collect()), - "extract-stage2" => extract_stage2(args.collect()), - "-h" | "--help" => { - print_help(); - Ok(()) - } - "-V" | "--version" => { - println!("senbei-android {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - _ => bail!("unknown command `{command}`; use --help for usage"), - } -} - -fn discover_metadata(args: Vec) -> Result<()> { - let mut positional = Vec::new(); +fn run(args: Vec) -> Result<()> { + let mut input = None; for value in args { if value == "-h" || value == "--help" { - println!("senbei-android discover-metadata INPUT"); + print_help(); + return Ok(()); + } + if value == "-V" || value == "--version" { + println!("senbei-android {}", env!("CARGO_PKG_VERSION")); return Ok(()); } if value.to_string_lossy().starts_with('-') { - bail!( - "unknown discover-metadata option `{}`", - value.to_string_lossy() - ); + bail!("unknown option `{}`; use --help", value.to_string_lossy()); } - positional.push(PathBuf::from(value)); - } - let [input] = positional.as_slice() else { - bail!("discover-metadata requires INPUT; use --help for usage"); - }; - let data = - std::fs::read(input).with_context(|| format!("read metadata `{}`", input.display()))?; - let report = senbei_android_metadata::discover_method_token_seeds(&data) - .with_context(|| format!("discover metadata seed `{}`", input.display()))?; - println!("{}", serde_json::to_string_pretty(&report)?); - Ok(()) -} - -fn extract_stage2(args: Vec) -> Result<()> { - let mut positional = Vec::new(); - let mut stage2_output = None; - let mut outer_size = senbei_android_stage2::DEFAULT_OUTER_SIZE; - let mut cipher_constant = senbei_android_stage2::DEFAULT_CIPHER_CONSTANT; - let mut cursor = 0; - while cursor < args.len() { - match args[cursor].to_string_lossy().as_ref() { - "--stage2-out" => { - stage2_output = Some(option_path(&args, &mut cursor, "--stage2-out")?); - } - "--outer-size" => { - let value = option_string(&args, &mut cursor, "--outer-size")?; - outer_size = usize::try_from(parse_u64(&value)?) - .with_context(|| format!("invalid --outer-size `{value}`"))?; - } - "--cipher-constant" => { - let value = option_string(&args, &mut cursor, "--cipher-constant")?; - cipher_constant = parse_u32(&value) - .with_context(|| format!("invalid --cipher-constant `{value}`"))?; - } - "-h" | "--help" => { - print_extract_help(); - return Ok(()); - } - option if option.starts_with('-') => { - bail!("unknown extract-stage2 option `{option}"); - } - _ => positional.push(PathBuf::from(&args[cursor])), + if input.is_some() { + bail!("only one input folder is accepted"); } - cursor += 1; + input = Some(PathBuf::from(value)); } - let [input, output_dir] = positional.as_slice() else { - bail!("extract-stage2 requires INPUT and OUTPUT_DIR; use --help for usage"); - }; - let mut job = ExtractStage2Job::new(input.clone(), output_dir.clone()); - job.stage2_output = stage2_output; - job.outer_size = outer_size; - job.cipher_constant = cipher_constant; - let result = run_extract_stage2(&job)?; - let module_images = result - .module_registry - .iter() - .filter(|module| module.classification == "module_image") - .count(); + let input = input.context("missing input folder; use --help for usage")?; + if !input.is_dir() { + bail!("input must be a folder: `{}`", input.display()); + } + let summary = run_folder(&input)?; println!( - "Extracted {} streams, {} modules and {} compact artifacts", - result.streams.len(), - module_images, - result.artifacts.len() + "restored {} SO(s), skipped {} SO(s), restored {} metadata file(s), skipped {} metadata file(s), {} archive(s)", + summary.so_restored, + summary.so_skipped, + summary.metadata_restored, + summary.metadata_skipped, + summary.archives ); - println!("Index {}", output_dir.join("index.json").display()); + println!("output {}", input.join("unpack").display()); Ok(()) } -fn restore_so(args: Vec) -> Result<()> { - let mut positional = Vec::new(); - let mut index = None; - let mut report = None; - let mut dump_auxiliary = None; - let mut outer_only = false; - let mut preserve_entrypoint = false; - let mut cursor = 0; - while cursor < args.len() { - match args[cursor].to_string_lossy().as_ref() { - "--index" => index = Some(option_path(&args, &mut cursor, "--index")?), - "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), - "--dump-aux" => { - dump_auxiliary = Some(option_path(&args, &mut cursor, "--dump-aux")?); - } - "--outer-only" => outer_only = true, - "--preserve-entrypoint" => preserve_entrypoint = true, - "-h" | "--help" => { - print_so_help(); - return Ok(()); - } - option if option.starts_with('-') => bail!("unknown restore-so option `{option}`"), - _ => positional.push(PathBuf::from(&args[cursor])), - } - cursor += 1; - } - let [input, output] = positional.as_slice() else { - bail!("restore-so requires INPUT and OUTPUT; use --help for usage"); - }; - let result = run_restore_so(&RestoreSoJob { - input: input.clone(), - output: output.clone(), - index, - report, - dump_auxiliary, - outer_only, - preserve_entrypoint, - })?; - println!("Restored {} bytes to {}", result.output_size, result.output); - println!("SHA-256 {}", result.output_sha256); - Ok(()) -} - -fn restore_metadata(args: Vec) -> Result<()> { - let mut positional = Vec::new(); - let mut report = None; - let mut seed = senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED; - let mut cursor = 0; - while cursor < args.len() { - match args[cursor].to_string_lossy().as_ref() { - "--seed" => { - let value = option_string(&args, &mut cursor, "--seed")?; - seed = parse_u32(&value).with_context(|| format!("invalid --seed `{value}`"))?; - } - "--report" => report = Some(option_path(&args, &mut cursor, "--report")?), - "-h" | "--help" => { - print_metadata_help(); - return Ok(()); - } - option if option.starts_with('-') => { - bail!("unknown restore-metadata option `{option}`"); - } - _ => positional.push(PathBuf::from(&args[cursor])), - } - cursor += 1; - } - let [input, output] = positional.as_slice() else { - bail!("restore-metadata requires INPUT and OUTPUT; use --help for usage"); - }; - let result = run_restore_metadata(&RestoreMetadataJob { - input: input.clone(), - output: output.clone(), - seed, - report, - })?; - println!( - "Metadata status={} restored {}/{} MethodDef tokens ({} already canonical)", - result.encryption_status, - result.changed_tokens, - result.methods, - result.already_correct_before - ); - Ok(()) -} - -fn option_path(args: &[OsString], cursor: &mut usize, name: &str) -> Result { - *cursor += 1; - args.get(*cursor) - .map(PathBuf::from) - .with_context(|| format!("{name} requires a path")) -} - -fn option_string(args: &[OsString], cursor: &mut usize, name: &str) -> Result { - *cursor += 1; - args.get(*cursor) - .map(|value| value.to_string_lossy().into_owned()) - .with_context(|| format!("{name} requires a value")) -} - -fn parse_u32(value: &str) -> Result { - Ok(u32::try_from(parse_u64(value)?)?) -} - -fn parse_u64(value: &str) -> Result { - if let Some(hex) = value - .strip_prefix("0x") - .or_else(|| value.strip_prefix("0X")) - { - Ok(u64::from_str_radix(hex, 16)?) - } else { - Ok(value.parse()?) - } -} - fn print_help() { - println!("senbei-android {}", env!("CARGO_PKG_VERSION")); - println!("Usage:"); - println!(" senbei-android restore-so INPUT OUTPUT [OPTIONS]"); - println!(" senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); - println!(" senbei-android discover-metadata INPUT"); - println!(" senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); - println!(" senbei-android --version"); -} - -fn print_extract_help() { - println!("senbei-android extract-stage2 INPUT OUTPUT_DIR [OPTIONS]"); - println!(" --stage2-out FILE Write the raw decrypted Stage 2 image"); - println!(" --outer-size VALUE Stage 1 outer wrapper size (default 0x23C)"); - println!(" --cipher-constant VALUE Stage 1 cipher constant (default 0xBF20165D)"); -} - -fn print_so_help() { - println!("senbei-android restore-so INPUT OUTPUT [OPTIONS]"); - println!(" --index FILE Stage 2 module index.json"); - println!(" --report FILE Write a JSON restoration report"); - println!(" --dump-aux FILE Dump decoded auxiliary ELF data"); - println!(" --outer-only Skip auxiliary ELF table materialization"); - println!(" --preserve-entrypoint Keep the protector entrypoint"); -} - -fn print_metadata_help() { - println!("senbei-android restore-metadata INPUT OUTPUT [OPTIONS]"); - println!(" --seed VALUE Module 0x0C seed (decimal or 0x-prefixed hex)"); - println!(" --report FILE Write a JSON restoration report"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_decimal_and_hex_seeds() { - assert_eq!(parse_u32("42").unwrap(), 42); - assert_eq!(parse_u32("0xA6FAE968").unwrap(), 0xa6fa_e968); - } + println!("senbei-android INPUT_FOLDER"); } diff --git a/senbei-android-stage2/Cargo.toml b/senbei-android-engine/Cargo.toml similarity index 93% rename from senbei-android-stage2/Cargo.toml rename to senbei-android-engine/Cargo.toml index 489b620..c164822 100644 --- a/senbei-android-stage2/Cargo.toml +++ b/senbei-android-engine/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "senbei-android-stage2" +name = "senbei-android-engine" version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/senbei-android-stage2/src/error.rs b/senbei-android-engine/src/error.rs similarity index 100% rename from senbei-android-stage2/src/error.rs rename to senbei-android-engine/src/error.rs diff --git a/senbei-android-stage2/src/extract.rs b/senbei-android-engine/src/extract.rs similarity index 100% rename from senbei-android-stage2/src/extract.rs rename to senbei-android-engine/src/extract.rs diff --git a/senbei-android-engine/src/lib.rs b/senbei-android-engine/src/lib.rs new file mode 100644 index 0000000..780215d --- /dev/null +++ b/senbei-android-engine/src/lib.rs @@ -0,0 +1,32 @@ +//! Pure-static Stage 1 decryption and recursive Stage 2 module extraction. + +mod error; +mod extract; +mod report; +mod stage1; +mod stream; + +pub use error::Error; +pub use extract::{ExtractOptions, extract_stage2}; +pub use report::ExtractionReport; +pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; + +/// Return whether `data` has the protected AArch64 Stage 1 section layout. +/// +/// This is a cheap, read-only probe used by folder mode to distinguish the +/// protected target from ordinary Unity libraries before invoking extraction. +#[must_use] +pub fn is_protected_libil2cpp(data: &[u8]) -> bool { + if !stage1::looks_protected(data) { + return false; + } + let Ok(stage1) = stage1::inspect( + data, + std::path::Path::new(""), + DEFAULT_OUTER_SIZE, + DEFAULT_CIPHER_CONSTANT, + ) else { + return false; + }; + senbei_android_crypto::Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() +} diff --git a/senbei-android-stage2/src/report.rs b/senbei-android-engine/src/report.rs similarity index 100% rename from senbei-android-stage2/src/report.rs rename to senbei-android-engine/src/report.rs diff --git a/senbei-android-stage2/src/stage1.rs b/senbei-android-engine/src/stage1.rs similarity index 92% rename from senbei-android-stage2/src/stage1.rs rename to senbei-android-engine/src/stage1.rs index fe629e5..8b52c68 100644 --- a/senbei-android-stage2/src/stage1.rs +++ b/senbei-android-engine/src/stage1.rs @@ -8,6 +8,37 @@ pub(crate) const SHT_LOUSER: u32 = 0x8000_0000; pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d; pub const DEFAULT_OUTER_SIZE: usize = 0x23c; +pub(crate) fn looks_protected(data: &[u8]) -> bool { + let Ok(elf) = Elf::parse(data) else { + return false; + }; + if elf.header.e_machine != EM_AARCH64 + || elf + .section_headers + .iter() + .filter(|section| section.sh_type == SHT_LOUSER) + .count() + != 1 + { + return false; + } + [ + ".dynsym", + ".dynstr", + ".gnu.hash", + ".gnu.version", + ".gnu.version_r", + ] + .into_iter() + .all(|wanted| { + elf.section_headers.iter().any(|section| { + elf.shdr_strtab + .get_at(section.sh_name) + .is_some_and(|name| name == wanted) + }) + }) +} + #[derive(Debug, Clone, Copy)] pub(crate) struct Stage1Header { pub key: u32, diff --git a/senbei-android-stage2/src/stream.rs b/senbei-android-engine/src/stream.rs similarity index 100% rename from senbei-android-stage2/src/stream.rs rename to senbei-android-engine/src/stream.rs diff --git a/senbei-android-io/Cargo.toml b/senbei-android-io/Cargo.toml index a744e46..500883e 100644 --- a/senbei-android-io/Cargo.toml +++ b/senbei-android-io/Cargo.toml @@ -8,13 +8,15 @@ description = "Filesystem and CLI orchestration for Senbei Android" [dependencies] anyhow.workspace = true +flate2.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +zip.workspace = true senbei-android-elf.workspace = true senbei-android-metadata.workspace = true -senbei-android-stage2.workspace = true +senbei-android-engine.workspace = true [lints] workspace = true diff --git a/senbei-android-io/src/folder.rs b/senbei-android-io/src/folder.rs new file mode 100644 index 0000000..f8bd0ea --- /dev/null +++ b/senbei-android-io/src/folder.rs @@ -0,0 +1,505 @@ +use std::collections::BTreeSet; +use std::fs::{File, create_dir_all, read_dir}; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use flate2::read::DeflateDecoder; +use senbei_android_engine::{ExtractOptions, extract_stage2, is_protected_libil2cpp}; +use sha2::{Digest, Sha256}; +use tempfile::{TempDir, tempdir}; +use zip::ZipArchive; + +use crate::{RestoreMetadataJob, RestoreSoJob, run_restore_metadata, run_restore_so}; + +const METADATA_NAME: &str = "global-metadata.dat"; +const METADATA_SUFFIX: [&str; 6] = [ + "assets", + "bin", + "Data", + "Managed", + "Metadata", + METADATA_NAME, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum TargetKind { + So, + Metadata, +} + +#[derive(Debug, Clone)] +struct Target { + kind: TargetKind, + source: PathBuf, + destination: PathBuf, + label: String, + identity: String, + source_priority: u8, +} + +/// Summary of one folder-mode restoration run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FolderSummary { + pub so_restored: usize, + pub so_skipped: usize, + pub metadata_restored: usize, + pub metadata_skipped: usize, + pub archives: usize, +} + +/// Find protected Android targets below `root`, restore them, and write only +/// the clean files below `/unpack`. +pub fn run_folder(root: &Path) -> Result { + if !root.is_dir() { + bail!("input must be a folder: `{}`", root.display()); + } + let root = std::fs::canonicalize(root) + .with_context(|| format!("canonicalize input folder `{}`", root.display()))?; + let output_root = root.join("unpack"); + create_dir_all(&output_root) + .with_context(|| format!("create output folder `{}`", output_root.display()))?; + + let temporary = tempdir().context("create temporary archive workspace")?; + let mut targets = Vec::new(); + let mut archives = BTreeSet::new(); + collect_targets( + &root, + &root, + &output_root, + &temporary, + &mut targets, + &mut archives, + )?; + targets = deduplicate_targets(targets); + targets.sort_by(|left, right| left.destination.cmp(&right.destination)); + + let mut summary = FolderSummary { + so_restored: 0, + so_skipped: 0, + metadata_restored: 0, + metadata_skipped: 0, + archives: archives.len(), + }; + let mut seen_destinations = BTreeSet::new(); + + for target in targets { + if !seen_destinations.insert(target.destination.clone()) { + bail!( + "duplicate target destination `{}`", + target.destination.display() + ); + } + match target.kind { + TargetKind::So => { + if restore_so_target(&target, &temporary)? { + summary.so_restored += 1; + } else { + summary.so_skipped += 1; + } + } + TargetKind::Metadata => { + if restore_metadata_target(&target)? { + summary.metadata_restored += 1; + } else { + summary.metadata_skipped += 1; + } + } + } + } + Ok(summary) +} + +fn restore_so_target(target: &Target, temporary: &TempDir) -> Result { + let data = std::fs::read(&target.source) + .with_context(|| format!("read protected `{}`", target.label))?; + if !is_protected_libil2cpp(&data) { + bail!( + "target is not a protected AArch64 libil2cpp ELF: `{}`", + target.label + ); + } + let key = target.label.bytes().fold(0_u64, |hash, byte| { + hash.wrapping_mul(131).wrapping_add(u64::from(byte)) + }); + let stage2_dir = temporary.path().join(format!("stage2-{key:016x}")); + let index = stage2_dir.join("index.json"); + if let Err(error) = extract_stage2(&ExtractOptions::with_defaults( + target.source.clone(), + stage2_dir, + )) + .with_context(|| format!("extract Stage 1/Stage 2 for `{}`", target.label)) + { + eprintln!("skip SO `{}`: {error:#}", target.label); + return Ok(false); + } + if let Err(error) = run_restore_so(&RestoreSoJob { + input: target.source.clone(), + output: target.destination.clone(), + index: Some(index), + report: None, + dump_auxiliary: None, + outer_only: false, + preserve_entrypoint: false, + }) + .with_context(|| format!("restore `{}`", target.label)) + { + eprintln!("skip SO `{}`: {error:#}", target.label); + return Ok(false); + } + Ok(true) +} + +fn restore_metadata_target(target: &Target) -> Result { + let input = std::fs::read(&target.source) + .with_context(|| format!("read metadata `{}`", target.label))?; + let discovery = senbei_android_metadata::discover_method_token_seeds(&input) + .with_context(|| format!("inspect metadata `{}`", target.label))?; + if discovery.version != 31 { + return Ok(false); + } + if discovery.images.iter().all(|image| image.clean) { + return Ok(false); + } + let seed = match discovery.seed_candidates.as_slice() { + [] => senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED, + [seed] => *seed, + candidates => bail!( + "metadata `{}` has ambiguous MethodDef seeds: {} candidates", + target.label, + candidates.len() + ), + }; + run_restore_metadata(&RestoreMetadataJob { + input: target.source.clone(), + output: target.destination.clone(), + seed, + report: None, + })?; + Ok(true) +} + +fn collect_targets( + root: &Path, + current: &Path, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, + archives: &mut BTreeSet, +) -> Result<()> { + let mut entries = read_dir(current) + .with_context(|| format!("scan folder `{}`", current.display()))? + .collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + if entry.file_name().eq_ignore_ascii_case("unpack") { + continue; + } + collect_targets(root, &path, output_root, temporary, targets, archives)?; + continue; + } + if !file_type.is_file() { + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("input path escaped root: `{}`", path.display()))? + .to_path_buf(); + if is_so_path(&relative) { + let bytes = + std::fs::read(&path).with_context(|| format!("probe `{}`", path.display()))?; + if is_protected_libil2cpp(&bytes) { + targets.push(Target { + kind: TargetKind::So, + source: path, + destination: output_root.join(&relative), + label: relative.display().to_string(), + identity: content_identity(&bytes), + source_priority: 0, + }); + } + } else if is_metadata_path(&relative) { + let bytes = std::fs::read(&path) + .with_context(|| format!("read metadata `{}`", path.display()))?; + targets.push(Target { + kind: TargetKind::Metadata, + source: path, + destination: output_root.join(&relative), + label: relative.display().to_string(), + identity: content_identity(&bytes), + source_priority: 0, + }); + } else if is_archive(&path) { + extract_archive_targets(&path, &relative, output_root, temporary, targets, archives)?; + } + } + Ok(()) +} + +fn is_archive(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("apk") || value.eq_ignore_ascii_case("apks") + }) +} + +fn extract_archive_targets( + archive_path: &Path, + archive_relative: &Path, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, + archives: &mut BTreeSet, +) -> Result<()> { + archives.insert(archive_relative.to_path_buf()); + let is_apks = archive_path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("apks")); + let mut archive = ZipArchive::new( + File::open(archive_path) + .with_context(|| format!("open archive `{}`", archive_path.display()))?, + ) + .with_context(|| format!("read archive `{}`", archive_path.display()))?; + let mut nested = Vec::new(); + let mut direct = Vec::new(); + for index in 0..archive.len() { + let (path, is_directory) = { + let entry = archive.by_index(index)?; + (entry.enclosed_name().map(PathBuf::from), entry.is_dir()) + }; + if is_directory { + continue; + } + let Some(path) = path else { + bail!( + "archive entry has unsafe path in `{}`", + archive_path.display() + ); + }; + if is_apks + && path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("apk")) + { + nested.push((index, path)); + } else if !is_apks && (is_so_path(&path) || is_metadata_path(&path)) { + direct.push((index, path)); + } + } + drop(archive); + + // Keep the archive suffix in the output directory name. An input tree may + // already contain an extracted `base/` beside `base.apk`; dropping the + // suffix would make those two independent targets collide. + let archive_base = archive_relative.to_path_buf(); + for (index, path) in direct { + let source = + extract_entry_from_archive_path(archive_path, index, temporary, archive_relative)?; + push_archive_target( + output_root, + targets, + archive_base.clone(), + path, + source, + archive_relative, + if is_apks { 2 } else { 1 }, + )?; + } + for (index, nested_path) in nested { + let nested_source = + extract_entry_from_archive_path(archive_path, index, temporary, archive_relative)?; + let nested_base = archive_base.join(nested_path.with_extension("")); + extract_nested_apk( + &nested_source, + nested_base, + archive_relative.join(&nested_path), + output_root, + temporary, + targets, + )?; + } + Ok(()) +} + +fn extract_nested_apk( + apk_path: &Path, + output_base: PathBuf, + nested_label: PathBuf, + output_root: &Path, + temporary: &TempDir, + targets: &mut Vec, +) -> Result<()> { + let mut archive = ZipArchive::new( + File::open(apk_path) + .with_context(|| format!("open nested APK `{}`", apk_path.display()))?, + ) + .with_context(|| format!("read nested APK `{}`", apk_path.display()))?; + let mut entries = Vec::new(); + for index in 0..archive.len() { + let (path, is_directory) = { + let entry = archive.by_index(index)?; + (entry.enclosed_name().map(PathBuf::from), entry.is_dir()) + }; + if is_directory { + continue; + } + let Some(path) = path else { + bail!( + "nested APK entry has unsafe path in `{}`", + apk_path.display() + ); + }; + if is_so_path(&path) || is_metadata_path(&path) { + entries.push((index, path)); + } + } + drop(archive); + for (index, path) in entries { + let source = extract_entry_from_archive_path(apk_path, index, temporary, &nested_label)?; + push_archive_target( + output_root, + targets, + output_base.clone(), + path, + source, + &nested_label, + 2, + )?; + } + Ok(()) +} + +fn push_archive_target( + output_root: &Path, + targets: &mut Vec, + base: PathBuf, + entry_path: PathBuf, + source: PathBuf, + archive_label: &Path, + source_priority: u8, +) -> Result<()> { + let kind = if is_so_path(&entry_path) { + TargetKind::So + } else { + TargetKind::Metadata + }; + let bytes = std::fs::read(&source) + .with_context(|| format!("probe archive target `{}`", archive_label.display()))?; + if kind == TargetKind::So && !is_protected_libil2cpp(&bytes) { + return Ok(()); + } + targets.push(Target { + kind, + source, + destination: output_root.join(&base).join(&entry_path), + label: format!("{}::{}", archive_label.display(), entry_path.display()), + identity: content_identity(&bytes), + source_priority, + }); + Ok(()) +} + +fn is_so_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("so")) +} + +fn is_metadata_path(path: &Path) -> bool { + let components = path + .components() + .filter_map(|component| component.as_os_str().to_str()) + .collect::>(); + components.len() >= METADATA_SUFFIX.len() + && components[components.len() - METADATA_SUFFIX.len()..] + .iter() + .zip(METADATA_SUFFIX) + .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected)) +} + +fn content_identity(data: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(data); + format!("{:x}", digest.finalize()) +} + +fn deduplicate_targets(mut targets: Vec) -> Vec { + targets.sort_by(|left, right| { + (left.kind, &left.identity) + .cmp(&(right.kind, &right.identity)) + .then_with(|| right.source_priority.cmp(&left.source_priority)) + .then_with(|| left.label.cmp(&right.label)) + }); + let mut seen = BTreeSet::new(); + targets + .into_iter() + .filter(|target| seen.insert((target.kind, target.identity.clone()))) + .collect() +} + +fn extract_entry_from_archive_path( + archive_path: &Path, + index: usize, + temporary: &TempDir, + archive_label: &Path, +) -> Result { + let mut archive = ZipArchive::new(File::open(archive_path)?)?; + extract_entry(&mut archive, index, temporary, archive_label) +} + +fn extract_entry( + archive: &mut ZipArchive, + index: usize, + temporary: &TempDir, + archive_label: &Path, +) -> Result { + let mut entry = archive.by_index_raw(index)?; + let key = format!("{}-{index:08x}", archive_label.display()); + let destination = temporary.path().join(key.replace(['\\', '/'], "_")); + let compressed_size = usize::try_from(entry.compressed_size()) + .map_err(|_| anyhow::anyhow!("archive entry compressed size exceeds usize"))?; + let output_size = usize::try_from(entry.size()) + .map_err(|_| anyhow::anyhow!("archive entry size exceeds usize"))?; + let mut compressed = vec![0_u8; compressed_size]; + entry.read_exact(&mut compressed).with_context(|| { + format!( + "read raw archive `{}` entry index {index}", + archive_label.display() + ) + })?; + let mut output_data = Vec::with_capacity(output_size); + match entry.compression() { + zip::CompressionMethod::Stored => output_data.extend_from_slice(&compressed), + zip::CompressionMethod::Deflated => { + DeflateDecoder::new(compressed.as_slice()) + .read_to_end(&mut output_data) + .with_context(|| { + format!( + "deflate archive `{}` entry index {index}", + archive_label.display() + ) + })?; + } + method => bail!( + "unsupported compression method {method:?} in archive `{}` entry index {index}", + archive_label.display() + ), + } + if output_data.len() != output_size { + bail!( + "archive `{}` entry index {index} decompressed to 0x{:x}, expected 0x{:x}", + archive_label.display(), + output_data.len(), + output_size + ); + } + let mut output = File::create(&destination)?; + output.write_all(&output_data)?; + Ok(destination) +} diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index f0997fb..34168fe 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -1,14 +1,18 @@ //! Filesystem orchestration for the Android restoration commands. +mod folder; + +pub use folder::{FolderSummary, run_folder}; + use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; -use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; -use senbei_android_stage2::{ +use senbei_android_engine::{ DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, }; +use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; use serde::Serialize; use tempfile::NamedTempFile; diff --git a/senbei-android-stage2/src/lib.rs b/senbei-android-stage2/src/lib.rs deleted file mode 100644 index b8736cc..0000000 --- a/senbei-android-stage2/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Pure-static Stage 1 decryption and recursive Stage 2 module extraction. - -mod error; -mod extract; -mod report; -mod stage1; -mod stream; - -pub use error::Error; -pub use extract::{ExtractOptions, extract_stage2}; -pub use report::ExtractionReport; -pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; From a9aaf95e01efb0d698d6d2d9301a36e0d57fd45c Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 03:31:25 +0800 Subject: [PATCH 5/8] docs: add Android compatibility matrix --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 771e5af..21dde6e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,50 @@ ## 兼容性 +### 地獄楽 パラダイスバトル + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 1.6.10 | ✅ | ✅ | + +### プリンセスコネクト!Re:Dive + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 12.6.0 | ✅ | ✅ | + +### あんスタ!! + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 2.0.54 | ✅ | ✅ | + +### アンリベ + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 1.1.0 | ✅ | ✅ | + +### エリオスR + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 8.1.0 | ✅ | ✅ | + +### ネココネ + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 1.8.1 | ✅ | ✅ | + +### まどドラ + +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 3.14.2 | ✅ | ✅ | + ### リバースブルー×リバースエンド -| 平台 | 版本 | 架构 | libil2cpp.so | global-metadata.dat | -|------|------|------|--------------|---------------------| -| Android | 1.28.2 | AArch64 | ✅ | v31 | +| 平台 | 版本 | libil2cpp.so | global-metadata.dat | +|------|------|---------------|---------------------| +| Android | 1.28.2 | ✅ | ✅ | From b534de872d657c32fbe6c185f19a9242bc3a95fe Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 03:38:42 +0800 Subject: [PATCH 6/8] refactor: move library implementations into modules --- senbei-android-crypto/src/lib.rs | 721 +------------------ senbei-android-crypto/src/protector.rs | 717 ++++++++++++++++++ senbei-android-engine/src/lib.rs | 22 +- senbei-android-engine/src/probe.rs | 22 + senbei-android-io/src/jobs.rs | 192 +++++ senbei-android-io/src/lib.rs | 196 +---- senbei-android-metadata/src/lib.rs | 663 +---------------- senbei-android-metadata/src/method_tokens.rs | 659 +++++++++++++++++ 8 files changed, 1609 insertions(+), 1583 deletions(-) create mode 100644 senbei-android-crypto/src/protector.rs create mode 100644 senbei-android-engine/src/probe.rs create mode 100644 senbei-android-io/src/jobs.rs create mode 100644 senbei-android-metadata/src/method_tokens.rs diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs index 49fe6c9..36c6876 100644 --- a/senbei-android-crypto/src/lib.rs +++ b/senbei-android-crypto/src/lib.rs @@ -1,717 +1,8 @@ -//! Cryptographic and compression primitives used by the Android protector. +//! Cryptographic and container primitives used by Senbei Android. -use aes::Aes256; -use aes::cipher::{Block, BlockDecrypt, KeyInit}; +mod protector; -const RECORD_SIZE: usize = 0x5c; - -/// Errors raised while parsing or decoding protector containers. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("{0}")] - Invalid(String), -} - -type Result = std::result::Result; - -fn invalid(message: impl Into) -> Result { - Err(Error::Invalid(message.into())) -} - -fn range(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { - let end = offset - .checked_add(size) - .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; - data.get(offset..end).ok_or_else(|| { - Error::Invalid(format!( - "byte range 0x{offset:x}..0x{end:x} is out of bounds" - )) - }) -} - -fn read_u16(data: &[u8], offset: usize) -> Result { - let bytes: [u8; 2] = range(data, offset, 2)? - .try_into() - .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; - Ok(u16::from_le_bytes(bytes)) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let bytes: [u8; 4] = range(data, offset, 4)? - .try_into() - .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; - Ok(u32::from_le_bytes(bytes)) -} - -fn align_up(value: usize, alignment: usize) -> Result { - let mask = alignment - .checked_sub(1) - .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; - value - .checked_add(mask) - .map(|v| v & !mask) - .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) -} - -/// Multiply by the fixed element used by the native GF(2^32) transform. -#[must_use] -pub fn gf32_mul_fixed(mut value: u32) -> u32 { - let mut multiplier = 0x9451_1dd2_u32; - let mut result = 0_u32; - while multiplier != 0 { - if multiplier & 1 != 0 { - result ^= value; - } - let carry = value >> 31; - value = value.wrapping_shl(1); - if carry != 0 { - value ^= 0x5793_57eb; - } - multiplier >>= 1; - } - result -} - -fn mix_columns(block: [u8; 16]) -> [u8; 16] { - const fn xtime(value: u8) -> u8 { - (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } - } - - let mut output = [0_u8; 16]; - for offset in (0..16).step_by(4) { - let [a, b, c, d] = block[offset..offset + 4] else { - unreachable!("fixed four-byte AES column") - }; - output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; - output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; - output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); - output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); - } - output -} - -/// Static configuration recovered from module `0x9B`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Module9bConfig { - pub header_seed: u32, - pub container_seed: u32, - pub aes_key: [u8; 32], - pub skip_aes: bool, - pub schedule_offset: usize, -} - -impl Module9bConfig { - /// Parse the unique AES-256 decryption schedule and adjacent configuration. - pub fn parse(image: &[u8]) -> Result { - Self::parse_inner(image, true) - } - - /// Parse the decoder configuration embedded in the raw Stage 2 image. - /// - /// The embedded decoder ends before the interpreter-only `skip_aes` - /// field, so that flag is definitionally false for this layout. - pub fn parse_embedded(image: &[u8]) -> Result { - Self::parse_inner(image, false) - } - - fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { - const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; - let mut matches = image - .windows(MARKER.len()) - .enumerate() - .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); - let schedule_offset = matches - .next() - .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; - if schedule_offset < 8 || matches.next().is_some() { - return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); - } - - let header_seed = read_u32(image, schedule_offset - 8)?; - let schedule_size = read_u32(image, schedule_offset - 4)?; - if !matches!(schedule_size, 0 | 0xf4) { - return invalid(format!( - "unexpected 0x9B AES schedule size 0x{schedule_size:x}" - )); - } - let bits = read_u16(image, schedule_offset)?; - let rounds = read_u16(image, schedule_offset + 2)?; - if (bits, rounds) != (0x100, 14) { - return invalid(format!( - "unexpected AES schedule header 0x{bits:x}/{rounds}" - )); - } - - let schedule = range(image, schedule_offset + 4, 15 * 16)?; - let mut round_keys = [[0_u8; 16]; 15]; - for (round, output) in round_keys.iter_mut().enumerate() { - let source = &schedule[round * 16..round * 16 + 16]; - for word in 0..4 { - let start = word * 4; - for byte in 0..4 { - output[start + byte] = source[start + 3 - byte]; - } - } - } - let mut aes_key = [0_u8; 32]; - aes_key[..16].copy_from_slice(&round_keys[14]); - aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); - - let container_seed_offset = schedule_offset - .checked_add(0x100) - .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; - let skip_aes = if has_skip_aes { - let skip_aes_offset = schedule_offset - .checked_add(0x240) - .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; - *image.get(skip_aes_offset).ok_or_else(|| { - Error::Invalid("module static configuration exceeds its image".to_owned()) - })? != 0 - } else { - false - }; - - Ok(Self { - header_seed, - container_seed: if has_skip_aes { - read_u32(image, container_seed_offset)? - } else { - header_seed - }, - aes_key, - skip_aes, - schedule_offset, - }) - } -} - -/// Decrypted header at the start of direct-data object `0x9D`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProtectedDescriptor { - pub command_id: u32, - pub flags: u32, - pub outer_offset: u32, - pub outer_expected_size: u32, - pub auxiliary_offset: u32, - pub auxiliary_expected_size: u32, -} - -impl ProtectedDescriptor { - /// Decrypt the `0x5c`-byte descriptor with the module header seed. - pub fn decrypt(data: &[u8], seed: u32) -> Result { - if data.len() < RECORD_SIZE { - return invalid("0x9D descriptor is truncated"); - } - let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); - let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); - let mut words = [0_u32; RECORD_SIZE / 4]; - for (index, word) in words.iter_mut().enumerate() { - let cipher = read_u32(data, index * 4)?; - let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); - *word = cipher.wrapping_sub(subtractor) - ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); - } - if words[6..].iter().any(|&word| word != 0) { - return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); - } - let descriptor = Self { - command_id: words[0], - flags: words[1], - outer_offset: words[2], - outer_expected_size: words[3], - auxiliary_offset: words[4], - auxiliary_expected_size: words[5], - }; - if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { - return invalid("unexpected decrypted 0x9D descriptor"); - } - Ok(descriptor) - } -} - -/// One encrypted segment in a decoded `0x9D` container header. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncodedSegment { - pub offset: u32, - pub size: u32, -} - -/// Parsed primary or auxiliary `0x9D` container. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContainerHeader { - pub start: usize, - pub output_size: u32, - pub skip_aes: bool, - pub tree: Vec, - pub segments: Vec, -} - -impl ContainerHeader { - /// Parse and decrypt a container header, Huffman tree, and segment table. - pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { - range(data, start, 12)?; - let seed_square = seed.wrapping_mul(seed); - let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); - let raw0 = read_u32(data, start)?; - let raw1 = read_u32(data, start + 4)?; - let raw2 = read_u32(data, start + 8)?; - let output_size = 0xa21d_fb3a_u32 - .wrapping_shl(state & 7) - .wrapping_add(state.wrapping_mul(0xf87b_337c)) - .wrapping_add(gf32_mul_fixed(raw0)); - let flag_word = gf32_mul_fixed(raw1) - ^ state - .wrapping_add(0xbd19_c63c) - .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); - let segment_count = (flag_word & 0xff) as usize; - let skip_aes = (flag_word >> 8) & 0xff == 1; - let tree_size = 0x643a_3a3b_u32 - .wrapping_shl(state & 0x0b) - .wrapping_sub(state ^ 0x3b2b_f538) - .wrapping_add(gf32_mul_fixed(raw2)) as usize; - if segment_count == 0 || tree_size > 0x1b00 { - return invalid(format!( - "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" - )); - } - - let tree_start = start - .checked_add(12) - .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; - let mut tree = range(data, tree_start, tree_size)?.to_vec(); - for offset in (0..tree_size & !3).step_by(4) { - let value = read_u32(&tree, offset)?; - tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); - } - let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); - let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); - for (index, byte) in tree.iter_mut().enumerate() { - let shift = u32::try_from(index & 0x1b) - .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; - let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); - let right = tree_delta.wrapping_shr((index & 0x17) as u32); - let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); - *byte = byte.wrapping_add(adjustment as u8); - } - - let table_start = start - .checked_add(align_up(12 + tree_size, 4)?) - .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; - let table_size = segment_count - .checked_mul(8) - .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; - let mut table = range(data, table_start, table_size)?.to_vec(); - let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); - let table_xor = table_state.wrapping_shl(3); - let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); - for offset in (0..table_size).step_by(4) { - let value = read_u32(&table, offset)?; - let decoded = gf32_mul_fixed(value ^ table_xor) - .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); - table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); - } - let mut segments = Vec::with_capacity(segment_count); - for index in 0..segment_count { - let offset = read_u32(&table, index * 8)?; - let size = read_u32(&table, index * 8 + 4)?; - let absolute = start - .checked_add(offset as usize) - .and_then(|value| value.checked_add(size as usize)); - if size == 0 || absolute.is_none_or(|end| end > data.len()) { - return invalid(format!("container segment {index} lies outside 0x9D")); - } - segments.push(EncodedSegment { offset, size }); - } - Ok(Self { - start, - output_size, - skip_aes, - tree, - segments, - }) - } - - /// End offset of the furthest encrypted segment. - pub fn encoded_end(&self) -> Result { - self.segments - .iter() - .map(|segment| { - self.start - .checked_add(segment.offset as usize) - .and_then(|value| value.checked_add(segment.size as usize)) - .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) - }) - .collect::>>()? - .into_iter() - .max() - .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) - } -} - -/// Decoder for the protector's Huffman/LZ writer streams. -#[derive(Debug, Clone)] -pub struct HuffmanLzDecoder { - tree: Vec, - lookup_symbols: Vec, - lookup_bits: Vec, -} - -impl HuffmanLzDecoder { - /// Build the full 16-bit prefix lookup used by the static decoder. - pub fn new(tree: &[u8]) -> Result { - if tree.len() < 256 * 3 || tree.len() % 3 != 0 { - return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); - } - let mut result = Self { - tree: tree.to_vec(), - lookup_symbols: vec![0; 0x1_0000], - lookup_bits: vec![0; 0x1_0000], - }; - for word in 0..0x1_0000_u32 { - let (symbol, bits) = result.decode_symbol(word)?; - if bits <= 16 { - result.lookup_symbols[word as usize] = symbol; - result.lookup_bits[word as usize] = bits; - } - } - Ok(result) - } - - fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { - let offset = index - .checked_mul(3) - .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; - let bytes = range(&self.tree, offset, 3)?; - let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); - Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) - } - - fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { - let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; - if leaf { - if extra == 0 { - return invalid("zero-width Huffman leaf"); - } - return Ok((value, extra)); - } - let mut bits = extra - .checked_add(1) - .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; - let mut mask = 1_u32.wrapping_shl(u32::from(extra)); - loop { - let branch = usize::from(word & mask != 0); - let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; - value = next; - if is_leaf { - return Ok((value, bits)); - } - mask = mask.wrapping_shl(1); - bits = bits - .checked_add(1) - .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; - if bits > 31 { - return invalid("Huffman code exceeds the native 32-bit window"); - } - } - } - - /// Decode one compressed writer payload to its exact expected size. - pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { - let mut output = vec![0_u8; output_size]; - let mut source_pos = 0_usize; - let mut bit_buffer = 0_u64; - let mut available = 0_u8; - let mut consumed_bits = 0_usize; - let mut output_pos = 0_usize; - let mut prefix = 0_usize; - - while output_pos < output_size { - while available < 24 && source_pos < source.len() { - bit_buffer |= u64::from(source[source_pos]) << available; - source_pos += 1; - available += 8; - } - let key = (bit_buffer & 0xffff) as usize; - let mut bits = self.lookup_bits[key]; - let symbol = if bits != 0 { - self.lookup_symbols[key] - } else { - let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; - let mut node = range(&self.tree, value_offset, 3)?; - let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); - if raw & 0x8000 != 0 { - bits = node[2]; - raw & 0x7fff - } else { - let extra = node[2]; - bits = extra + 1; - let mut mask = 1_u64 << extra; - loop { - let branch = usize::from(bit_buffer & mask != 0); - let index = usize::from(raw & 0x7fff) + branch; - value_offset = index - .checked_mul(3) - .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; - node = range(&self.tree, value_offset, 3)?; - raw = u16::from(node[0]) | (u16::from(node[1]) << 8); - if raw & 0x8000 != 0 { - break raw & 0x7fff; - } - mask <<= 1; - bits += 1; - } - } - }; - if bits == 0 || bits > available { - return invalid("compressed stream ends inside a Huffman code"); - } - bit_buffer >>= bits; - available -= bits; - consumed_bits = consumed_bits - .checked_add(usize::from(bits)) - .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; - - let kind = symbol & 0x300; - let value = usize::from(symbol & 0xff); - match kind { - 0 => { - output[output_pos] = value as u8; - output_pos += 1; - } - 0x100 => { - if prefix > 0xff { - return invalid("compressed prefix exceeds 16 bits"); - } - prefix = if prefix == 0 { - value - } else { - value | (prefix << 8) - }; - } - 0x200 => { - if prefix == 0 { - prefix = 1; - } - let count = value - .checked_mul(prefix) - .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; - if !matches!(value, 1 | 2 | 4) - || value > output_pos - || output_pos - .checked_add(count) - .is_none_or(|end| end > output_size) - { - return invalid("invalid compressed repeated-pattern command"); - } - let pattern = output[output_pos - value..output_pos].to_vec(); - for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { - chunk.copy_from_slice(&pattern); - } - output_pos += count; - prefix = 0; - } - 0x300 => { - let length = value; - let distance = prefix.checked_add(length).ok_or_else(|| { - Error::Invalid("back-reference distance overflow".to_owned()) - })?; - if distance > output_pos - || output_pos - .checked_add(length) - .is_none_or(|end| end > output_size) - { - return invalid("invalid compressed back-reference"); - } - let source_start = output_pos - distance; - output.copy_within(source_start..source_start + length, output_pos); - output_pos += length; - prefix = 0; - } - _ => unreachable!("masked Huffman symbol kind"), - } - } - if consumed_bits.div_ceil(8) != source.len() { - return invalid(format!( - "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", - consumed_bits.div_ceil(8), - source.len() - )); - } - Ok(output) - } -} - -/// Apply the native word transform and optional AES-256-CBC decryption. -pub fn transform_segment( - data: &[u8], - seed: u32, - aes_key: &[u8; 32], - decrypt_aes: bool, -) -> Result> { - let mut transformed = data.to_vec(); - let mut state = seed; - let mut left = 0xe34e_ac63_u32; - let mut right = 0x07b4_8238_u32; - for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { - let index32 = u32::try_from(index) - .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; - left = state - .wrapping_add(0x72f6_fcbe) - .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) - .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); - right = state - .wrapping_sub(0x71b6_a98d) - .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) - .wrapping_shl(index32 & 7); - state = left ^ right; - let bytes: [u8; 4] = chunk - .try_into() - .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; - let mut value = u32::from_le_bytes(bytes); - value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); - value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); - value = value.wrapping_sub(state) ^ state; - chunk.copy_from_slice(&value.to_le_bytes()); - } - - if decrypt_aes { - let cipher = Aes256::new_from_slice(aes_key) - .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; - let aligned_size = transformed.len() & !0x0f; - let mut previous = [0_u8; 16]; - for chunk in transformed[..aligned_size].chunks_exact_mut(16) { - let mut ciphertext = [0_u8; 16]; - ciphertext.copy_from_slice(chunk); - cipher.decrypt_block(Block::::from_mut_slice(chunk)); - for (byte, prior) in chunk.iter_mut().zip(previous) { - *byte ^= prior; - } - previous = ciphertext; - } - } - Ok(transformed) -} - -/// Decode one complete protector container into its flat output buffer. -/// -/// This is the static equivalent of the decoder entrypoint embedded in Stage -/// 2 and in each nested interpreter module. -pub fn decode_container( - data: &[u8], - config: &Module9bConfig, - expected_size: usize, -) -> Result> { - let header = ContainerHeader::parse(data, 0, config.container_seed)?; - let header_size = usize::try_from(header.output_size) - .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; - if header_size != expected_size { - return invalid(format!( - "container output size 0x{header_size:x} != expected 0x{expected_size:x}" - )); - } - let decoder = HuffmanLzDecoder::new(&header.tree)?; - let decrypt_aes = !(config.skip_aes || header.skip_aes); - let mut output = vec![0_u8; expected_size]; - - for (segment_index, encoded) in header.segments.iter().enumerate() { - let start = header - .start - .checked_add(encoded.offset as usize) - .ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?; - let encoded_data = range(data, start, encoded.size as usize)?; - let transformed = transform_segment( - encoded_data, - config.container_seed, - &config.aes_key, - decrypt_aes, - )?; - if transformed.len() < 16 { - return invalid(format!( - "decoded segment {segment_index} is shorter than its header" - )); - } - let base_offset = read_u32(&transformed, 0)? as usize; - let writer_count = read_u32(&transformed, 4)? as usize; - let table_offset = read_u32(&transformed, 8)? as usize; - let data_offset = read_u32(&transformed, 12)? as usize; - let table_size = writer_count - .checked_mul(16) - .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; - let table_end = table_offset - .checked_add(table_size) - .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; - if table_end > transformed.len() || data_offset > transformed.len() { - return invalid(format!( - "decoded segment {segment_index} has invalid writer offsets" - )); - } - - let mut data_cursor = data_offset; - for writer_index in 0..writer_count { - let record = - table_offset - .checked_add(writer_index.checked_mul(16).ok_or_else(|| { - Error::Invalid("writer record offset overflow".to_owned()) - })?) - .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; - let output_offset = read_u32(&transformed, record)? as usize; - let output_size = read_u32(&transformed, record + 4)? as usize; - let encoded_size = read_u32(&transformed, record + 8)? as usize; - let reserved = read_u32(&transformed, record + 12)?; - let encoded_end = data_cursor - .checked_add(encoded_size) - .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; - if reserved != 0 || encoded_end > transformed.len() { - return invalid(format!( - "segment {segment_index} writer {writer_index} has invalid bounds" - )); - } - let source = &transformed[data_cursor..encoded_end]; - let decoded = if encoded_size == output_size { - None - } else { - Some(decoder.decode(source, output_size)?) - }; - let decoded = decoded.as_deref().unwrap_or(source); - let target = base_offset - .checked_add(output_offset) - .ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?; - let target_end = target - .checked_add(decoded.len()) - .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; - let destination = output.get_mut(target..target_end).ok_or_else(|| { - Error::Invalid(format!( - "segment {segment_index} writer {writer_index} target is out of range" - )) - })?; - destination.copy_from_slice(decoded); - data_cursor = encoded_end; - } - } - Ok(output) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aes_mix_columns_matches_fips_example() { - let input = [ - 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, - 0xc6, 0xc6, - ]; - assert_eq!( - mix_columns(input), - [ - 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, - 0xc6, 0xc6, - ] - ); - } - - #[test] - fn descriptor_rejects_truncated_input() { - assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); - } -} +pub use protector::{ + ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, + decode_container, gf32_mul_fixed, transform_segment, +}; diff --git a/senbei-android-crypto/src/protector.rs b/senbei-android-crypto/src/protector.rs new file mode 100644 index 0000000..49fe6c9 --- /dev/null +++ b/senbei-android-crypto/src/protector.rs @@ -0,0 +1,717 @@ +//! Cryptographic and compression primitives used by the Android protector. + +use aes::Aes256; +use aes::cipher::{Block, BlockDecrypt, KeyInit}; + +const RECORD_SIZE: usize = 0x5c; + +/// Errors raised while parsing or decoding protector containers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Invalid(String), +} + +type Result = std::result::Result; + +fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} + +fn range(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = range(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = range(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn align_up(value: usize, alignment: usize) -> Result { + let mask = alignment + .checked_sub(1) + .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; + value + .checked_add(mask) + .map(|v| v & !mask) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} + +/// Multiply by the fixed element used by the native GF(2^32) transform. +#[must_use] +pub fn gf32_mul_fixed(mut value: u32) -> u32 { + let mut multiplier = 0x9451_1dd2_u32; + let mut result = 0_u32; + while multiplier != 0 { + if multiplier & 1 != 0 { + result ^= value; + } + let carry = value >> 31; + value = value.wrapping_shl(1); + if carry != 0 { + value ^= 0x5793_57eb; + } + multiplier >>= 1; + } + result +} + +fn mix_columns(block: [u8; 16]) -> [u8; 16] { + const fn xtime(value: u8) -> u8 { + (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } + } + + let mut output = [0_u8; 16]; + for offset in (0..16).step_by(4) { + let [a, b, c, d] = block[offset..offset + 4] else { + unreachable!("fixed four-byte AES column") + }; + output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; + output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; + output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); + output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); + } + output +} + +/// Static configuration recovered from module `0x9B`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Module9bConfig { + pub header_seed: u32, + pub container_seed: u32, + pub aes_key: [u8; 32], + pub skip_aes: bool, + pub schedule_offset: usize, +} + +impl Module9bConfig { + /// Parse the unique AES-256 decryption schedule and adjacent configuration. + pub fn parse(image: &[u8]) -> Result { + Self::parse_inner(image, true) + } + + /// Parse the decoder configuration embedded in the raw Stage 2 image. + /// + /// The embedded decoder ends before the interpreter-only `skip_aes` + /// field, so that flag is definitionally false for this layout. + pub fn parse_embedded(image: &[u8]) -> Result { + Self::parse_inner(image, false) + } + + fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { + const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; + let mut matches = image + .windows(MARKER.len()) + .enumerate() + .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); + let schedule_offset = matches + .next() + .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; + if schedule_offset < 8 || matches.next().is_some() { + return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); + } + + let header_seed = read_u32(image, schedule_offset - 8)?; + let schedule_size = read_u32(image, schedule_offset - 4)?; + if !matches!(schedule_size, 0 | 0xf4) { + return invalid(format!( + "unexpected 0x9B AES schedule size 0x{schedule_size:x}" + )); + } + let bits = read_u16(image, schedule_offset)?; + let rounds = read_u16(image, schedule_offset + 2)?; + if (bits, rounds) != (0x100, 14) { + return invalid(format!( + "unexpected AES schedule header 0x{bits:x}/{rounds}" + )); + } + + let schedule = range(image, schedule_offset + 4, 15 * 16)?; + let mut round_keys = [[0_u8; 16]; 15]; + for (round, output) in round_keys.iter_mut().enumerate() { + let source = &schedule[round * 16..round * 16 + 16]; + for word in 0..4 { + let start = word * 4; + for byte in 0..4 { + output[start + byte] = source[start + 3 - byte]; + } + } + } + let mut aes_key = [0_u8; 32]; + aes_key[..16].copy_from_slice(&round_keys[14]); + aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); + + let container_seed_offset = schedule_offset + .checked_add(0x100) + .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; + let skip_aes = if has_skip_aes { + let skip_aes_offset = schedule_offset + .checked_add(0x240) + .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; + *image.get(skip_aes_offset).ok_or_else(|| { + Error::Invalid("module static configuration exceeds its image".to_owned()) + })? != 0 + } else { + false + }; + + Ok(Self { + header_seed, + container_seed: if has_skip_aes { + read_u32(image, container_seed_offset)? + } else { + header_seed + }, + aes_key, + skip_aes, + schedule_offset, + }) + } +} + +/// Decrypted header at the start of direct-data object `0x9D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProtectedDescriptor { + pub command_id: u32, + pub flags: u32, + pub outer_offset: u32, + pub outer_expected_size: u32, + pub auxiliary_offset: u32, + pub auxiliary_expected_size: u32, +} + +impl ProtectedDescriptor { + /// Decrypt the `0x5c`-byte descriptor with the module header seed. + pub fn decrypt(data: &[u8], seed: u32) -> Result { + if data.len() < RECORD_SIZE { + return invalid("0x9D descriptor is truncated"); + } + let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); + let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); + let mut words = [0_u32; RECORD_SIZE / 4]; + for (index, word) in words.iter_mut().enumerate() { + let cipher = read_u32(data, index * 4)?; + let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); + *word = cipher.wrapping_sub(subtractor) + ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); + } + if words[6..].iter().any(|&word| word != 0) { + return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); + } + let descriptor = Self { + command_id: words[0], + flags: words[1], + outer_offset: words[2], + outer_expected_size: words[3], + auxiliary_offset: words[4], + auxiliary_expected_size: words[5], + }; + if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { + return invalid("unexpected decrypted 0x9D descriptor"); + } + Ok(descriptor) + } +} + +/// One encrypted segment in a decoded `0x9D` container header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodedSegment { + pub offset: u32, + pub size: u32, +} + +/// Parsed primary or auxiliary `0x9D` container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerHeader { + pub start: usize, + pub output_size: u32, + pub skip_aes: bool, + pub tree: Vec, + pub segments: Vec, +} + +impl ContainerHeader { + /// Parse and decrypt a container header, Huffman tree, and segment table. + pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { + range(data, start, 12)?; + let seed_square = seed.wrapping_mul(seed); + let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); + let raw0 = read_u32(data, start)?; + let raw1 = read_u32(data, start + 4)?; + let raw2 = read_u32(data, start + 8)?; + let output_size = 0xa21d_fb3a_u32 + .wrapping_shl(state & 7) + .wrapping_add(state.wrapping_mul(0xf87b_337c)) + .wrapping_add(gf32_mul_fixed(raw0)); + let flag_word = gf32_mul_fixed(raw1) + ^ state + .wrapping_add(0xbd19_c63c) + .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); + let segment_count = (flag_word & 0xff) as usize; + let skip_aes = (flag_word >> 8) & 0xff == 1; + let tree_size = 0x643a_3a3b_u32 + .wrapping_shl(state & 0x0b) + .wrapping_sub(state ^ 0x3b2b_f538) + .wrapping_add(gf32_mul_fixed(raw2)) as usize; + if segment_count == 0 || tree_size > 0x1b00 { + return invalid(format!( + "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" + )); + } + + let tree_start = start + .checked_add(12) + .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; + let mut tree = range(data, tree_start, tree_size)?.to_vec(); + for offset in (0..tree_size & !3).step_by(4) { + let value = read_u32(&tree, offset)?; + tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); + } + let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); + let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); + for (index, byte) in tree.iter_mut().enumerate() { + let shift = u32::try_from(index & 0x1b) + .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; + let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); + let right = tree_delta.wrapping_shr((index & 0x17) as u32); + let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); + *byte = byte.wrapping_add(adjustment as u8); + } + + let table_start = start + .checked_add(align_up(12 + tree_size, 4)?) + .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; + let table_size = segment_count + .checked_mul(8) + .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; + let mut table = range(data, table_start, table_size)?.to_vec(); + let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); + let table_xor = table_state.wrapping_shl(3); + let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); + for offset in (0..table_size).step_by(4) { + let value = read_u32(&table, offset)?; + let decoded = gf32_mul_fixed(value ^ table_xor) + .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); + table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); + } + let mut segments = Vec::with_capacity(segment_count); + for index in 0..segment_count { + let offset = read_u32(&table, index * 8)?; + let size = read_u32(&table, index * 8 + 4)?; + let absolute = start + .checked_add(offset as usize) + .and_then(|value| value.checked_add(size as usize)); + if size == 0 || absolute.is_none_or(|end| end > data.len()) { + return invalid(format!("container segment {index} lies outside 0x9D")); + } + segments.push(EncodedSegment { offset, size }); + } + Ok(Self { + start, + output_size, + skip_aes, + tree, + segments, + }) + } + + /// End offset of the furthest encrypted segment. + pub fn encoded_end(&self) -> Result { + self.segments + .iter() + .map(|segment| { + self.start + .checked_add(segment.offset as usize) + .and_then(|value| value.checked_add(segment.size as usize)) + .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) + } +} + +/// Decoder for the protector's Huffman/LZ writer streams. +#[derive(Debug, Clone)] +pub struct HuffmanLzDecoder { + tree: Vec, + lookup_symbols: Vec, + lookup_bits: Vec, +} + +impl HuffmanLzDecoder { + /// Build the full 16-bit prefix lookup used by the static decoder. + pub fn new(tree: &[u8]) -> Result { + if tree.len() < 256 * 3 || tree.len() % 3 != 0 { + return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); + } + let mut result = Self { + tree: tree.to_vec(), + lookup_symbols: vec![0; 0x1_0000], + lookup_bits: vec![0; 0x1_0000], + }; + for word in 0..0x1_0000_u32 { + let (symbol, bits) = result.decode_symbol(word)?; + if bits <= 16 { + result.lookup_symbols[word as usize] = symbol; + result.lookup_bits[word as usize] = bits; + } + } + Ok(result) + } + + fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { + let offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; + let bytes = range(&self.tree, offset, 3)?; + let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); + Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) + } + + fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { + let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; + if leaf { + if extra == 0 { + return invalid("zero-width Huffman leaf"); + } + return Ok((value, extra)); + } + let mut bits = extra + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + let mut mask = 1_u32.wrapping_shl(u32::from(extra)); + loop { + let branch = usize::from(word & mask != 0); + let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; + value = next; + if is_leaf { + return Ok((value, bits)); + } + mask = mask.wrapping_shl(1); + bits = bits + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + if bits > 31 { + return invalid("Huffman code exceeds the native 32-bit window"); + } + } + } + + /// Decode one compressed writer payload to its exact expected size. + pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { + let mut output = vec![0_u8; output_size]; + let mut source_pos = 0_usize; + let mut bit_buffer = 0_u64; + let mut available = 0_u8; + let mut consumed_bits = 0_usize; + let mut output_pos = 0_usize; + let mut prefix = 0_usize; + + while output_pos < output_size { + while available < 24 && source_pos < source.len() { + bit_buffer |= u64::from(source[source_pos]) << available; + source_pos += 1; + available += 8; + } + let key = (bit_buffer & 0xffff) as usize; + let mut bits = self.lookup_bits[key]; + let symbol = if bits != 0 { + self.lookup_symbols[key] + } else { + let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; + let mut node = range(&self.tree, value_offset, 3)?; + let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + bits = node[2]; + raw & 0x7fff + } else { + let extra = node[2]; + bits = extra + 1; + let mut mask = 1_u64 << extra; + loop { + let branch = usize::from(bit_buffer & mask != 0); + let index = usize::from(raw & 0x7fff) + branch; + value_offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; + node = range(&self.tree, value_offset, 3)?; + raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + break raw & 0x7fff; + } + mask <<= 1; + bits += 1; + } + } + }; + if bits == 0 || bits > available { + return invalid("compressed stream ends inside a Huffman code"); + } + bit_buffer >>= bits; + available -= bits; + consumed_bits = consumed_bits + .checked_add(usize::from(bits)) + .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; + + let kind = symbol & 0x300; + let value = usize::from(symbol & 0xff); + match kind { + 0 => { + output[output_pos] = value as u8; + output_pos += 1; + } + 0x100 => { + if prefix > 0xff { + return invalid("compressed prefix exceeds 16 bits"); + } + prefix = if prefix == 0 { + value + } else { + value | (prefix << 8) + }; + } + 0x200 => { + if prefix == 0 { + prefix = 1; + } + let count = value + .checked_mul(prefix) + .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; + if !matches!(value, 1 | 2 | 4) + || value > output_pos + || output_pos + .checked_add(count) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed repeated-pattern command"); + } + let pattern = output[output_pos - value..output_pos].to_vec(); + for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { + chunk.copy_from_slice(&pattern); + } + output_pos += count; + prefix = 0; + } + 0x300 => { + let length = value; + let distance = prefix.checked_add(length).ok_or_else(|| { + Error::Invalid("back-reference distance overflow".to_owned()) + })?; + if distance > output_pos + || output_pos + .checked_add(length) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed back-reference"); + } + let source_start = output_pos - distance; + output.copy_within(source_start..source_start + length, output_pos); + output_pos += length; + prefix = 0; + } + _ => unreachable!("masked Huffman symbol kind"), + } + } + if consumed_bits.div_ceil(8) != source.len() { + return invalid(format!( + "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", + consumed_bits.div_ceil(8), + source.len() + )); + } + Ok(output) + } +} + +/// Apply the native word transform and optional AES-256-CBC decryption. +pub fn transform_segment( + data: &[u8], + seed: u32, + aes_key: &[u8; 32], + decrypt_aes: bool, +) -> Result> { + let mut transformed = data.to_vec(); + let mut state = seed; + let mut left = 0xe34e_ac63_u32; + let mut right = 0x07b4_8238_u32; + for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { + let index32 = u32::try_from(index) + .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; + left = state + .wrapping_add(0x72f6_fcbe) + .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) + .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); + right = state + .wrapping_sub(0x71b6_a98d) + .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) + .wrapping_shl(index32 & 7); + state = left ^ right; + let bytes: [u8; 4] = chunk + .try_into() + .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; + let mut value = u32::from_le_bytes(bytes); + value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); + value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); + value = value.wrapping_sub(state) ^ state; + chunk.copy_from_slice(&value.to_le_bytes()); + } + + if decrypt_aes { + let cipher = Aes256::new_from_slice(aes_key) + .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; + let aligned_size = transformed.len() & !0x0f; + let mut previous = [0_u8; 16]; + for chunk in transformed[..aligned_size].chunks_exact_mut(16) { + let mut ciphertext = [0_u8; 16]; + ciphertext.copy_from_slice(chunk); + cipher.decrypt_block(Block::::from_mut_slice(chunk)); + for (byte, prior) in chunk.iter_mut().zip(previous) { + *byte ^= prior; + } + previous = ciphertext; + } + } + Ok(transformed) +} + +/// Decode one complete protector container into its flat output buffer. +/// +/// This is the static equivalent of the decoder entrypoint embedded in Stage +/// 2 and in each nested interpreter module. +pub fn decode_container( + data: &[u8], + config: &Module9bConfig, + expected_size: usize, +) -> Result> { + let header = ContainerHeader::parse(data, 0, config.container_seed)?; + let header_size = usize::try_from(header.output_size) + .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; + if header_size != expected_size { + return invalid(format!( + "container output size 0x{header_size:x} != expected 0x{expected_size:x}" + )); + } + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + let mut output = vec![0_u8; expected_size]; + + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = header + .start + .checked_add(encoded.offset as usize) + .ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?; + let encoded_data = range(data, start, encoded.size as usize)?; + let transformed = transform_segment( + encoded_data, + config.container_seed, + &config.aes_key, + decrypt_aes, + )?; + if transformed.len() < 16 { + return invalid(format!( + "decoded segment {segment_index} is shorter than its header" + )); + } + let base_offset = read_u32(&transformed, 0)? as usize; + let writer_count = read_u32(&transformed, 4)? as usize; + let table_offset = read_u32(&transformed, 8)? as usize; + let data_offset = read_u32(&transformed, 12)? as usize; + let table_size = writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; + let table_end = table_offset + .checked_add(table_size) + .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; + if table_end > transformed.len() || data_offset > transformed.len() { + return invalid(format!( + "decoded segment {segment_index} has invalid writer offsets" + )); + } + + let mut data_cursor = data_offset; + for writer_index in 0..writer_count { + let record = + table_offset + .checked_add(writer_index.checked_mul(16).ok_or_else(|| { + Error::Invalid("writer record offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; + let output_offset = read_u32(&transformed, record)? as usize; + let output_size = read_u32(&transformed, record + 4)? as usize; + let encoded_size = read_u32(&transformed, record + 8)? as usize; + let reserved = read_u32(&transformed, record + 12)?; + let encoded_end = data_cursor + .checked_add(encoded_size) + .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; + if reserved != 0 || encoded_end > transformed.len() { + return invalid(format!( + "segment {segment_index} writer {writer_index} has invalid bounds" + )); + } + let source = &transformed[data_cursor..encoded_end]; + let decoded = if encoded_size == output_size { + None + } else { + Some(decoder.decode(source, output_size)?) + }; + let decoded = decoded.as_deref().unwrap_or(source); + let target = base_offset + .checked_add(output_offset) + .ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?; + let target_end = target + .checked_add(decoded.len()) + .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; + let destination = output.get_mut(target..target_end).ok_or_else(|| { + Error::Invalid(format!( + "segment {segment_index} writer {writer_index} target is out of range" + )) + })?; + destination.copy_from_slice(decoded); + data_cursor = encoded_end; + } + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_mix_columns_matches_fips_example() { + let input = [ + 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ]; + assert_eq!( + mix_columns(input), + [ + 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ] + ); + } + + #[test] + fn descriptor_rejects_truncated_input() { + assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); + } +} diff --git a/senbei-android-engine/src/lib.rs b/senbei-android-engine/src/lib.rs index 780215d..386eb55 100644 --- a/senbei-android-engine/src/lib.rs +++ b/senbei-android-engine/src/lib.rs @@ -2,31 +2,13 @@ mod error; mod extract; +mod probe; mod report; mod stage1; mod stream; pub use error::Error; pub use extract::{ExtractOptions, extract_stage2}; +pub use probe::is_protected_libil2cpp; pub use report::ExtractionReport; pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; - -/// Return whether `data` has the protected AArch64 Stage 1 section layout. -/// -/// This is a cheap, read-only probe used by folder mode to distinguish the -/// protected target from ordinary Unity libraries before invoking extraction. -#[must_use] -pub fn is_protected_libil2cpp(data: &[u8]) -> bool { - if !stage1::looks_protected(data) { - return false; - } - let Ok(stage1) = stage1::inspect( - data, - std::path::Path::new(""), - DEFAULT_OUTER_SIZE, - DEFAULT_CIPHER_CONSTANT, - ) else { - return false; - }; - senbei_android_crypto::Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() -} diff --git a/senbei-android-engine/src/probe.rs b/senbei-android-engine/src/probe.rs new file mode 100644 index 0000000..73c250d --- /dev/null +++ b/senbei-android-engine/src/probe.rs @@ -0,0 +1,22 @@ +use std::path::Path; + +use senbei_android_crypto::Module9bConfig; + +use crate::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; + +/// Return whether `data` has a supported protected AArch64 IL2CPP layout. +#[must_use] +pub fn is_protected_libil2cpp(data: &[u8]) -> bool { + if !stage1::looks_protected(data) { + return false; + } + let Ok(stage1) = stage1::inspect( + data, + Path::new(""), + DEFAULT_OUTER_SIZE, + DEFAULT_CIPHER_CONSTANT, + ) else { + return false; + }; + Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() +} diff --git a/senbei-android-io/src/jobs.rs b/senbei-android-io/src/jobs.rs new file mode 100644 index 0000000..f67859d --- /dev/null +++ b/senbei-android-io/src/jobs.rs @@ -0,0 +1,192 @@ +//! File-oriented restoration jobs and atomic output helpers. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; +use senbei_android_engine::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +}; +use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; +use serde::Serialize; +use tempfile::NamedTempFile; + +/// Filesystem arguments for restoring one protected `libil2cpp.so`. +#[derive(Debug, Clone)] +pub struct RestoreSoJob { + pub input: PathBuf, + pub output: PathBuf, + pub index: Option, + pub report: Option, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Filesystem arguments for restoring one `global-metadata.dat`. +#[derive(Debug, Clone)] +pub struct RestoreMetadataJob { + pub input: PathBuf, + pub output: PathBuf, + pub seed: u32, + pub report: Option, +} + +/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractStage2Job { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractStage2Job { + #[must_use] + pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + +impl RestoreMetadataJob { + #[must_use] + pub fn new(input: PathBuf, output: PathBuf) -> Self { + Self { + input, + output, + seed: DEFAULT_METHOD_TOKEN_SEED, + report: None, + } + } +} + +/// Infer the Stage 2 module index produced for `libil2cpp.so`. +#[must_use] +pub fn default_module_index(input: &Path) -> PathBuf { + input + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("libil2cpp_stage2_modules") + .join("index.json") +} + +/// Run static SO restoration and optionally emit its JSON report. +pub fn run_restore_so(job: &RestoreSoJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let options = RestoreOptions { + input: job.input.clone(), + output: job.output.clone(), + index: job + .index + .clone() + .unwrap_or_else(|| default_module_index(&job.input)), + dump_auxiliary: job.dump_auxiliary.clone(), + outer_only: job.outer_only, + preserve_entrypoint: job.preserve_entrypoint, + }; + let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Restore MethodDef tokens and atomically write the cleaned metadata. +pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let input = + std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; + let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) + .with_context(|| format!("restore `{}`", job.input.display()))?; + write_atomic(&job.output, &output)?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Extract Stage 2 modules directly from one protected ELF. +pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { + extract_stage2(&ExtractOptions { + input: job.input.clone(), + output_dir: job.output_dir.clone(), + stage2_output: job.stage2_output.clone(), + outer_size: job.outer_size, + cipher_constant: job.cipher_constant, + }) + .context("extract protected Stage 1/Stage 2 payload") +} + +fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { + let input_absolute = absolute(input)?; + let output_absolute = absolute(output)?; + let same_existing_file = + output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); + if input_absolute == output_absolute || same_existing_file { + bail!( + "refusing to overwrite input in place: `{}`", + input.display() + ); + } + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("query current directory")? + .join(path)) + } +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; + data.push(b'\n'); + write_atomic(path, &data) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("create output directory `{}`", parent.display()))?; + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary file in `{}`", parent.display()))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("write temporary output for `{}`", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace output `{}`", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_index_next_to_input() { + assert_eq!( + default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), + PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") + ); + } + + #[test] + fn metadata_job_uses_current_seed() { + let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); + assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); + } +} diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index 34168fe..2128d55 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -1,196 +1,10 @@ -//! Filesystem orchestration for the Android restoration commands. +//! Filesystem orchestration interfaces for Senbei Android. mod folder; +mod jobs; pub use folder::{FolderSummary, run_folder}; - -use std::io::Write; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; -use senbei_android_engine::{ - DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +pub use jobs::{ + ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, default_module_index, run_extract_stage2, + run_restore_metadata, run_restore_so, }; -use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; -use serde::Serialize; -use tempfile::NamedTempFile; - -/// Filesystem arguments for restoring one protected `libil2cpp.so`. -#[derive(Debug, Clone)] -pub struct RestoreSoJob { - pub input: PathBuf, - pub output: PathBuf, - pub index: Option, - pub report: Option, - pub dump_auxiliary: Option, - pub outer_only: bool, - pub preserve_entrypoint: bool, -} - -/// Filesystem arguments for restoring one `global-metadata.dat`. -#[derive(Debug, Clone)] -pub struct RestoreMetadataJob { - pub input: PathBuf, - pub output: PathBuf, - pub seed: u32, - pub report: Option, -} - -/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. -#[derive(Debug, Clone)] -pub struct ExtractStage2Job { - pub input: PathBuf, - pub output_dir: PathBuf, - pub stage2_output: Option, - pub outer_size: usize, - pub cipher_constant: u32, -} - -impl ExtractStage2Job { - #[must_use] - pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { - Self { - input, - output_dir, - stage2_output: None, - outer_size: DEFAULT_OUTER_SIZE, - cipher_constant: DEFAULT_CIPHER_CONSTANT, - } - } -} - -impl RestoreMetadataJob { - #[must_use] - pub fn new(input: PathBuf, output: PathBuf) -> Self { - Self { - input, - output, - seed: DEFAULT_METHOD_TOKEN_SEED, - report: None, - } - } -} - -/// Infer the Stage 2 module index produced for `libil2cpp.so`. -#[must_use] -pub fn default_module_index(input: &Path) -> PathBuf { - input - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("libil2cpp_stage2_modules") - .join("index.json") -} - -/// Run static SO restoration and optionally emit its JSON report. -pub fn run_restore_so(job: &RestoreSoJob) -> Result { - refuse_in_place(&job.input, &job.output)?; - let options = RestoreOptions { - input: job.input.clone(), - output: job.output.clone(), - index: job - .index - .clone() - .unwrap_or_else(|| default_module_index(&job.input)), - dump_auxiliary: job.dump_auxiliary.clone(), - outer_only: job.outer_only, - preserve_entrypoint: job.preserve_entrypoint, - }; - let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; - if let Some(path) = &job.report { - write_json_atomic(path, &result)?; - } - Ok(result) -} - -/// Restore MethodDef tokens and atomically write the cleaned metadata. -pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { - refuse_in_place(&job.input, &job.output)?; - let input = - std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; - let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) - .with_context(|| format!("restore `{}`", job.input.display()))?; - write_atomic(&job.output, &output)?; - if let Some(path) = &job.report { - write_json_atomic(path, &result)?; - } - Ok(result) -} - -/// Extract Stage 2 modules directly from one protected ELF. -pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { - extract_stage2(&ExtractOptions { - input: job.input.clone(), - output_dir: job.output_dir.clone(), - stage2_output: job.stage2_output.clone(), - outer_size: job.outer_size, - cipher_constant: job.cipher_constant, - }) - .context("extract protected Stage 1/Stage 2 payload") -} - -fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { - let input_absolute = absolute(input)?; - let output_absolute = absolute(output)?; - let same_existing_file = - output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); - if input_absolute == output_absolute || same_existing_file { - bail!( - "refusing to overwrite input in place: `{}`", - input.display() - ); - } - Ok(()) -} - -fn absolute(path: &Path) -> Result { - if path.is_absolute() { - Ok(path.to_path_buf()) - } else { - Ok(std::env::current_dir() - .context("query current directory")? - .join(path)) - } -} - -fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { - let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; - data.push(b'\n'); - write_atomic(path, &data) -} - -fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent) - .with_context(|| format!("create output directory `{}`", parent.display()))?; - let mut temporary = NamedTempFile::new_in(parent) - .with_context(|| format!("create temporary file in `{}`", parent.display()))?; - temporary - .write_all(data) - .and_then(|()| temporary.as_file().sync_all()) - .with_context(|| format!("write temporary output for `{}`", path.display()))?; - temporary - .persist(path) - .map_err(|error| error.error) - .with_context(|| format!("replace output `{}`", path.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn derives_index_next_to_input() { - assert_eq!( - default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), - PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") - ); - } - - #[test] - fn metadata_job_uses_current_seed() { - let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); - assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); - } -} diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs index 1a13817..e6741d8 100644 --- a/senbei-android-metadata/src/lib.rs +++ b/senbei-android-metadata/src/lib.rs @@ -1,659 +1,8 @@ -//! Static restoration of protected IL2CPP v31 method tokens. +//! Static IL2CPP metadata restoration interfaces. -use serde::Serialize; +mod method_tokens; -/// Seed embedded in the current `libil2cpp` module `0x0C`. -pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; - -const MAGIC: u32 = 0xfab1_1baf; -const SUPPORTED_VERSION: u32 = 31; -const HDR_METHODS: usize = 0x30; -const HDR_TYPES: usize = 0xa0; -const HDR_IMAGES: usize = 0xa8; -const METHOD_STRIDE: usize = 0x24; -const METHOD_TOKEN_OFFSET: usize = 0x18; -const TYPE_STRIDE: usize = 0x58; -const TYPE_METHOD_START_OFFSET: usize = 0x24; -const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; -const IMAGE_STRIDE: usize = 0x28; -const IMAGE_TYPE_START_OFFSET: usize = 0x08; -const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; -const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; - -/// Summary of one metadata restoration pass. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct Report { - pub version: u32, - pub seed: String, - pub encryption_status: String, - pub images: usize, - pub images_with_methods: usize, - pub types: usize, - pub methods: usize, - pub visited_methods: usize, - pub already_correct_before: usize, - pub correct_after: usize, - pub changed_tokens: usize, - pub transformed_images: usize, -} - -/// Per-image constraints recovered from the encrypted MethodDef RID -/// permutation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ImageKeyDiscovery { - pub image: usize, - pub method_count: u32, - pub modulus: u32, - pub clean: bool, - pub seed_residues: Vec, -} - -/// Result of statically testing the known five-round permutation against a -/// metadata file without assuming a seed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct SeedDiscoveryReport { - pub version: u32, - pub images: Vec, - pub seed_candidates: Vec, -} - -/// Metadata parsing or validation failure. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum Error { - #[error("not an IL2CPP global-metadata.dat")] - NotMetadata, - #[error("unsupported metadata version {0}")] - UnsupportedVersion(u32), - #[error("malformed metadata: {0}")] - Malformed(String), - #[error("method-token restoration failed: {0}")] - Validation(String), -} - -type Result = std::result::Result; - -fn malformed(message: impl Into) -> Result { - Err(Error::Malformed(message.into())) -} - -fn validation(message: impl Into) -> Result { - Err(Error::Validation(message.into())) -} - -fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { - let end = offset - .checked_add(size) - .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; - data.get(offset..end).ok_or_else(|| { - Error::Malformed(format!( - "byte range 0x{offset:x}..0x{end:x} is out of bounds" - )) - }) -} - -fn read_u16(data: &[u8], offset: usize) -> Result { - let value: [u8; 2] = bytes(data, offset, 2)? - .try_into() - .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; - Ok(u16::from_le_bytes(value)) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let value: [u8; 4] = bytes(data, offset, 4)? - .try_into() - .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; - Ok(u32::from_le_bytes(value)) -} - -fn read_i32(data: &[u8], offset: usize) -> Result { - let value: [u8; 4] = bytes(data, offset, 4)? - .try_into() - .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; - Ok(i32::from_le_bytes(value)) -} - -fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { - let offset = read_u32(data, header_offset)? as usize; - let size = read_u32(data, header_offset + 4)? as usize; - bytes(data, offset, size)?; - Ok((offset, size)) -} - -#[inline] -fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { - let mirror = count.wrapping_mul(2).wrapping_sub(1); - if value & 1 != 0 { - value = mirror.wrapping_sub(value); - } - value >>= 1; - if value >= count { - value = mirror.wrapping_sub(value); - } - let value = value.wrapping_sub(key); - if value > count { - value.wrapping_add(count) - } else { - value - } -} - -fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { - let count = high - .checked_add(1) - .and_then(|value| value.checked_sub(low)) - .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; - if count < 2 { - return validation("RID inverse permutation requires at least two entries"); - } - let half = count / 2; - if half == 0 { - return validation("RID inverse permutation has a zero divisor"); - } - let key = seed % half + count / 4; - let mut value = rid - .checked_sub(low) - .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; - for _ in 0..5 { - value = inverse_round(value, count, key); - } - value - .checked_add(low) - .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) -} - -/// Restore MethodDef RID values exactly as module `0x0C` does. -/// -/// The operation is idempotent for tooling purposes: an image whose tokens are -/// already canonical is detected and left untouched instead of applying the -/// native inverse permutation a second time. -pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { - if read_u32(data, 0).ok() != Some(MAGIC) { - return Err(Error::NotMetadata); - } - let version = read_u32(data, 4)?; - if version != SUPPORTED_VERSION { - return Err(Error::UnsupportedVersion(version)); - } - - let (method_offset, method_size) = table(data, HDR_METHODS)?; - let (type_offset, type_size) = table(data, HDR_TYPES)?; - let (image_offset, image_size) = table(data, HDR_IMAGES)?; - if method_size % METHOD_STRIDE != 0 - || type_size % TYPE_STRIDE != 0 - || image_size % IMAGE_STRIDE != 0 - { - return malformed("v31 table size is not divisible by its entry stride"); - } - let method_count = method_size / METHOD_STRIDE; - let type_count = type_size / TYPE_STRIDE; - let image_count = image_size / IMAGE_STRIDE; - let mut owners = vec![u32::MAX; method_count]; - let mut output = data.to_vec(); - let mut images_with_methods = 0_usize; - let mut visited_methods = 0_usize; - let mut already_correct_before = 0_usize; - let mut correct_after = 0_usize; - let mut changed_tokens = 0_usize; - let mut transformed_images = 0_usize; - - for image_index in 0..image_count { - let image_base = image_offset + image_index * IMAGE_STRIDE; - let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; - let type_start = usize::try_from(type_start) - .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; - let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; - let type_end = type_start - .checked_add(type_entries) - .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; - if type_end > type_count { - return malformed(format!("image {image_index} type range exceeds the table")); - } - - let mut methods = Vec::new(); - for type_index in type_start..type_end { - let type_base = type_offset + type_index * TYPE_STRIDE; - let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; - if method_entries == 0 { - continue; - } - let method_start = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; - let method_start = usize::try_from(method_start).map_err(|_| { - Error::Malformed(format!( - "type {type_index} has methods but negative methodStart" - )) - })?; - let method_end = method_start - .checked_add(method_entries) - .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; - if method_end > method_count { - return malformed(format!("type {type_index} method range exceeds the table")); - } - for (method_index, owner) in owners - .iter_mut() - .enumerate() - .take(method_end) - .skip(method_start) - { - if *owner != u32::MAX { - return malformed(format!("method {method_index} belongs to multiple images")); - } - *owner = u32::try_from(image_index) - .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; - methods.push(method_index); - } - } - if methods.is_empty() { - continue; - } - images_with_methods += 1; - visited_methods += methods.len(); - let method_base = *methods - .iter() - .min() - .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; - let method_last = *methods - .iter() - .max() - .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; - if method_last - method_base + 1 != methods.len() { - return malformed(format!( - "image {image_index} method block is not contiguous" - )); - } - - let mut tokens = Vec::with_capacity(methods.len()); - let mut image_already_clean = true; - for &method_index in &methods { - let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; - let token = read_u32(data, token_offset)?; - if token & 0xff00_0000 != METHOD_TOKEN_TABLE { - return malformed(format!( - "method {method_index} has non-MethodDef token 0x{token:08x}" - )); - } - let expected = u32::try_from(method_index - method_base + 1) - .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; - let rid = token & 0x00ff_ffff; - if rid == expected { - already_correct_before += 1; - } else { - image_already_clean = false; - } - tokens.push((method_index, token_offset, token, expected)); - } - - if image_already_clean { - correct_after += tokens.len(); - continue; - } - transformed_images += 1; - let low = tokens - .iter() - .map(|(_, _, token, _)| token & 0x00ff_ffff) - .min() - .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; - let high = tokens - .iter() - .map(|(_, _, token, _)| token & 0x00ff_ffff) - .max() - .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; - if high <= 1 { - return validation(format!( - "image {image_index} is noncanonical but native R > 1 gate would skip it" - )); - } - let interval = high - low + 1; - if interval as usize != tokens.len() { - return validation(format!( - "image {image_index} RID interval {low}..={high} is not a permutation" - )); - } - for (method_index, token_offset, token, expected) in tokens { - let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; - if restored_rid != expected { - return validation(format!( - "method {method_index} restored RID {restored_rid} != expected {expected}" - )); - } - let restored_token = METHOD_TOKEN_TABLE | restored_rid; - if restored_token != token { - output[token_offset..token_offset + 4] - .copy_from_slice(&restored_token.to_le_bytes()); - changed_tokens += 1; - } - correct_after += 1; - } - } - - if owners.contains(&u32::MAX) { - return malformed("one or more method definitions are not owned by an image"); - } - if visited_methods != method_count || correct_after != method_count { - return validation(format!( - "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" - )); - } - - Ok(( - output, - Report { - version, - seed: format!("0x{seed:08X}"), - encryption_status: if changed_tokens == 0 { - "clean".to_owned() - } else { - "encrypted".to_owned() - }, - images: image_count, - images_with_methods, - types: type_count, - methods: method_count, - visited_methods, - already_correct_before, - correct_after, - changed_tokens, - transformed_images, - }, - )) -} - -/// Discover seeds compatible with the known v31 five-round RID permutation. -/// -/// This is diagnostic and does not modify metadata. It enumerates the only -/// possible per-image key residues and intersects them over the 32-bit seed -/// domain. An empty candidate list means that the sample changed the -/// permutation itself rather than merely embedding a different seed. -pub fn discover_method_token_seeds(data: &[u8]) -> Result { - if read_u32(data, 0).ok() != Some(MAGIC) { - return Err(Error::NotMetadata); - } - let version = read_u32(data, 4)?; - if version != SUPPORTED_VERSION { - return Ok(SeedDiscoveryReport { - version, - images: Vec::new(), - seed_candidates: Vec::new(), - }); - } - let (method_offset, method_size) = table(data, HDR_METHODS)?; - let (type_offset, type_size) = table(data, HDR_TYPES)?; - let (image_offset, image_size) = table(data, HDR_IMAGES)?; - if method_size % METHOD_STRIDE != 0 - || type_size % TYPE_STRIDE != 0 - || image_size % IMAGE_STRIDE != 0 - { - return malformed("v31 table size is not divisible by its entry stride"); - } - let method_count = method_size / METHOD_STRIDE; - let type_count = type_size / TYPE_STRIDE; - let image_count = image_size / IMAGE_STRIDE; - let mut reports = Vec::with_capacity(image_count); - for image_index in 0..image_count { - let image_base = image_offset + image_index * IMAGE_STRIDE; - let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) - .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; - let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; - let type_end = type_start - .checked_add(type_entries) - .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; - if type_end > type_count { - return malformed(format!("image {image_index} type range exceeds the table")); - } - let mut methods = Vec::new(); - for type_index in type_start..type_end { - let type_base = type_offset + type_index * TYPE_STRIDE; - let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; - if method_entries == 0 { - continue; - } - let method_start = - usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( - |_| Error::Malformed(format!("type {type_index} has negative methodStart")), - )?; - let method_end = method_start - .checked_add(method_entries) - .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; - if method_end > method_count { - return malformed(format!("type {type_index} method range exceeds the table")); - } - methods.extend(method_start..method_end); - } - if methods.is_empty() { - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: 0, - modulus: 0, - clean: true, - seed_residues: Vec::new(), - }); - continue; - } - let method_base = *methods - .iter() - .min() - .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; - let method_last = *methods - .iter() - .max() - .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; - if method_last - method_base + 1 != methods.len() { - return validation(format!( - "image {image_index} method block is not contiguous" - )); - } - let mut values = Vec::with_capacity(methods.len()); - let mut clean = true; - for method_index in methods { - let token = read_u32( - data, - method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, - )?; - if token & 0xff00_0000 != METHOD_TOKEN_TABLE { - return validation(format!( - "method {method_index} has non-MethodDef token 0x{token:08x}" - )); - } - let expected = u32::try_from(method_index - method_base + 1) - .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; - let rid = token & 0x00ff_ffff; - clean &= rid == expected; - values.push((rid, expected)); - } - let count = u32::try_from(values.len()) - .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; - if clean { - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: count, - modulus: count / 2, - clean, - seed_residues: Vec::new(), - }); - continue; - } - let low = values - .iter() - .map(|(rid, _)| *rid) - .min() - .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; - let high = values - .iter() - .map(|(rid, _)| *rid) - .max() - .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; - if high - low + 1 != count || count < 2 { - return validation(format!( - "image {image_index} RID interval is not a permutation" - )); - } - let half = count / 2; - let quarter = count / 4; - let mut residues = Vec::new(); - for key_delta in 0..half { - let key = quarter + key_delta; - let valid = values - .iter() - .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); - if valid { - residues.push(key_delta); - } - } - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: count, - modulus: half, - clean, - seed_residues: residues, - }); - } - - let constraints = reports - .iter() - .filter(|report| !report.clean) - .collect::>(); - let mut seeds = Vec::new(); - if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { - for &residue in &anchor.seed_residues { - let mut candidate = u64::from(residue); - let modulus = u64::from(anchor.modulus); - while candidate <= u64::from(u32::MAX) { - let valid = constraints.iter().all(|report| { - report.modulus != 0 - && !report.seed_residues.is_empty() - && report - .seed_residues - .iter() - .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) - }); - if valid { - seeds.push(candidate as u32); - } - candidate = candidate.saturating_add(modulus); - } - } - } - seeds.sort_unstable(); - seeds.dedup(); - Ok(SeedDiscoveryReport { - version, - images: reports, - seed_candidates: seeds, - }) -} - -fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { - let count = high - low + 1; - let mut value = rid - low; - for _ in 0..5 { - value = inverse_round(value, count, key); - } - value + low -} - -#[cfg(test)] -mod tests { - use super::*; - - fn put_u16(data: &mut [u8], offset: usize, value: u16) { - data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); - } - - fn put_u32(data: &mut [u8], offset: usize, value: u32) { - data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - } - - fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { - (1..=count) - .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) - .expect("inverse permutation must be bijective") - } - - fn build(tokens: &[u32]) -> (Vec, usize) { - let header_size = 0x100; - let images = header_size; - let types = images + IMAGE_STRIDE; - let methods = types + 2 * TYPE_STRIDE; - let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; - put_u32(&mut data, 0, MAGIC); - put_u32(&mut data, 4, SUPPORTED_VERSION); - put_u32(&mut data, HDR_METHODS, methods as u32); - put_u32( - &mut data, - HDR_METHODS + 4, - (tokens.len() * METHOD_STRIDE) as u32, - ); - put_u32(&mut data, HDR_TYPES, types as u32); - put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); - put_u32(&mut data, HDR_IMAGES, images as u32); - put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); - put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); - put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); - // Deliberately traverse the high method indices first. - put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); - put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); - put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); - put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); - for (index, &token) in tokens.iter().enumerate() { - put_u32( - &mut data, - methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, - token, - ); - } - (data, methods) - } - - #[test] - fn restores_five_round_permutation_by_physical_method_index() { - let tokens = (1..=7) - .map(|expected| { - METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) - }) - .collect::>(); - let (data, methods) = build(&tokens); - let (restored, report) = - restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); - assert_eq!(report.encryption_status, "encrypted"); - assert!(report.changed_tokens > 0); - assert_eq!(report.correct_after, 7); - for index in 0..7 { - assert_eq!( - read_u32( - &restored, - methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET - ) - .expect("token"), - METHOD_TOKEN_TABLE | (index as u32 + 1) - ); - } - } - - #[test] - fn clean_metadata_is_idempotent() { - let tokens = (1..=7) - .map(|rid| METHOD_TOKEN_TABLE | rid) - .collect::>(); - let (data, _) = build(&tokens); - let (restored, report) = - restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); - assert_eq!(report.encryption_status, "clean"); - assert_eq!(report.changed_tokens, 0); - assert_eq!(restored, data); - } - - #[test] - fn encrypted_metadata_rejects_the_wrong_seed() { - let tokens = (1..=7) - .map(|expected| { - METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) - }) - .collect::>(); - let (data, _) = build(&tokens); - let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); - - assert!(matches!( - restore_method_tokens(&data, wrong_seed), - Err(Error::Validation(_)) - )); - } -} +pub use method_tokens::{ + DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport, + discover_method_token_seeds, restore_method_tokens, +}; diff --git a/senbei-android-metadata/src/method_tokens.rs b/senbei-android-metadata/src/method_tokens.rs new file mode 100644 index 0000000..1a13817 --- /dev/null +++ b/senbei-android-metadata/src/method_tokens.rs @@ -0,0 +1,659 @@ +//! Static restoration of protected IL2CPP v31 method tokens. + +use serde::Serialize; + +/// Seed embedded in the current `libil2cpp` module `0x0C`. +pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; + +const MAGIC: u32 = 0xfab1_1baf; +const SUPPORTED_VERSION: u32 = 31; +const HDR_METHODS: usize = 0x30; +const HDR_TYPES: usize = 0xa0; +const HDR_IMAGES: usize = 0xa8; +const METHOD_STRIDE: usize = 0x24; +const METHOD_TOKEN_OFFSET: usize = 0x18; +const TYPE_STRIDE: usize = 0x58; +const TYPE_METHOD_START_OFFSET: usize = 0x24; +const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; +const IMAGE_STRIDE: usize = 0x28; +const IMAGE_TYPE_START_OFFSET: usize = 0x08; +const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; +const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; + +/// Summary of one metadata restoration pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Report { + pub version: u32, + pub seed: String, + pub encryption_status: String, + pub images: usize, + pub images_with_methods: usize, + pub types: usize, + pub methods: usize, + pub visited_methods: usize, + pub already_correct_before: usize, + pub correct_after: usize, + pub changed_tokens: usize, + pub transformed_images: usize, +} + +/// Per-image constraints recovered from the encrypted MethodDef RID +/// permutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImageKeyDiscovery { + pub image: usize, + pub method_count: u32, + pub modulus: u32, + pub clean: bool, + pub seed_residues: Vec, +} + +/// Result of statically testing the known five-round permutation against a +/// metadata file without assuming a seed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SeedDiscoveryReport { + pub version: u32, + pub images: Vec, + pub seed_candidates: Vec, +} + +/// Metadata parsing or validation failure. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not an IL2CPP global-metadata.dat")] + NotMetadata, + #[error("unsupported metadata version {0}")] + UnsupportedVersion(u32), + #[error("malformed metadata: {0}")] + Malformed(String), + #[error("method-token restoration failed: {0}")] + Validation(String), +} + +type Result = std::result::Result; + +fn malformed(message: impl Into) -> Result { + Err(Error::Malformed(message.into())) +} + +fn validation(message: impl Into) -> Result { + Err(Error::Validation(message.into())) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Malformed(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let value: [u8; 2] = bytes(data, offset, 2)? + .try_into() + .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(value)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(value)) +} + +fn read_i32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; + Ok(i32::from_le_bytes(value)) +} + +fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { + let offset = read_u32(data, header_offset)? as usize; + let size = read_u32(data, header_offset + 4)? as usize; + bytes(data, offset, size)?; + Ok((offset, size)) +} + +#[inline] +fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { + let mirror = count.wrapping_mul(2).wrapping_sub(1); + if value & 1 != 0 { + value = mirror.wrapping_sub(value); + } + value >>= 1; + if value >= count { + value = mirror.wrapping_sub(value); + } + let value = value.wrapping_sub(key); + if value > count { + value.wrapping_add(count) + } else { + value + } +} + +fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { + let count = high + .checked_add(1) + .and_then(|value| value.checked_sub(low)) + .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; + if count < 2 { + return validation("RID inverse permutation requires at least two entries"); + } + let half = count / 2; + if half == 0 { + return validation("RID inverse permutation has a zero divisor"); + } + let key = seed % half + count / 4; + let mut value = rid + .checked_sub(low) + .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + .checked_add(low) + .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) +} + +/// Restore MethodDef RID values exactly as module `0x0C` does. +/// +/// The operation is idempotent for tooling purposes: an image whose tokens are +/// already canonical is detected and left untouched instead of applying the +/// native inverse permutation a second time. +pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Err(Error::UnsupportedVersion(version)); + } + + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut owners = vec![u32::MAX; method_count]; + let mut output = data.to_vec(); + let mut images_with_methods = 0_usize; + let mut visited_methods = 0_usize; + let mut already_correct_before = 0_usize; + let mut correct_after = 0_usize; + let mut changed_tokens = 0_usize; + let mut transformed_images = 0_usize; + + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; + let type_start = usize::try_from(type_start) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; + let method_start = usize::try_from(method_start).map_err(|_| { + Error::Malformed(format!( + "type {type_index} has methods but negative methodStart" + )) + })?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + for (method_index, owner) in owners + .iter_mut() + .enumerate() + .take(method_end) + .skip(method_start) + { + if *owner != u32::MAX { + return malformed(format!("method {method_index} belongs to multiple images")); + } + *owner = u32::try_from(image_index) + .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; + methods.push(method_index); + } + } + if methods.is_empty() { + continue; + } + images_with_methods += 1; + visited_methods += methods.len(); + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return malformed(format!( + "image {image_index} method block is not contiguous" + )); + } + + let mut tokens = Vec::with_capacity(methods.len()); + let mut image_already_clean = true; + for &method_index in &methods { + let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; + let token = read_u32(data, token_offset)?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return malformed(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + if rid == expected { + already_correct_before += 1; + } else { + image_already_clean = false; + } + tokens.push((method_index, token_offset, token, expected)); + } + + if image_already_clean { + correct_after += tokens.len(); + continue; + } + transformed_images += 1; + let low = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .min() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + let high = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .max() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + if high <= 1 { + return validation(format!( + "image {image_index} is noncanonical but native R > 1 gate would skip it" + )); + } + let interval = high - low + 1; + if interval as usize != tokens.len() { + return validation(format!( + "image {image_index} RID interval {low}..={high} is not a permutation" + )); + } + for (method_index, token_offset, token, expected) in tokens { + let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; + if restored_rid != expected { + return validation(format!( + "method {method_index} restored RID {restored_rid} != expected {expected}" + )); + } + let restored_token = METHOD_TOKEN_TABLE | restored_rid; + if restored_token != token { + output[token_offset..token_offset + 4] + .copy_from_slice(&restored_token.to_le_bytes()); + changed_tokens += 1; + } + correct_after += 1; + } + } + + if owners.contains(&u32::MAX) { + return malformed("one or more method definitions are not owned by an image"); + } + if visited_methods != method_count || correct_after != method_count { + return validation(format!( + "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" + )); + } + + Ok(( + output, + Report { + version, + seed: format!("0x{seed:08X}"), + encryption_status: if changed_tokens == 0 { + "clean".to_owned() + } else { + "encrypted".to_owned() + }, + images: image_count, + images_with_methods, + types: type_count, + methods: method_count, + visited_methods, + already_correct_before, + correct_after, + changed_tokens, + transformed_images, + }, + )) +} + +/// Discover seeds compatible with the known v31 five-round RID permutation. +/// +/// This is diagnostic and does not modify metadata. It enumerates the only +/// possible per-image key residues and intersects them over the 32-bit seed +/// domain. An empty candidate list means that the sample changed the +/// permutation itself rather than merely embedding a different seed. +pub fn discover_method_token_seeds(data: &[u8]) -> Result { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Ok(SeedDiscoveryReport { + version, + images: Vec::new(), + seed_candidates: Vec::new(), + }); + } + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut reports = Vec::with_capacity(image_count); + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = + usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( + |_| Error::Malformed(format!("type {type_index} has negative methodStart")), + )?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + methods.extend(method_start..method_end); + } + if methods.is_empty() { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: 0, + modulus: 0, + clean: true, + seed_residues: Vec::new(), + }); + continue; + } + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return validation(format!( + "image {image_index} method block is not contiguous" + )); + } + let mut values = Vec::with_capacity(methods.len()); + let mut clean = true; + for method_index in methods { + let token = read_u32( + data, + method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + )?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return validation(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + clean &= rid == expected; + values.push((rid, expected)); + } + let count = u32::try_from(values.len()) + .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; + if clean { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: count / 2, + clean, + seed_residues: Vec::new(), + }); + continue; + } + let low = values + .iter() + .map(|(rid, _)| *rid) + .min() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + let high = values + .iter() + .map(|(rid, _)| *rid) + .max() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + if high - low + 1 != count || count < 2 { + return validation(format!( + "image {image_index} RID interval is not a permutation" + )); + } + let half = count / 2; + let quarter = count / 4; + let mut residues = Vec::new(); + for key_delta in 0..half { + let key = quarter + key_delta; + let valid = values + .iter() + .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); + if valid { + residues.push(key_delta); + } + } + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: half, + clean, + seed_residues: residues, + }); + } + + let constraints = reports + .iter() + .filter(|report| !report.clean) + .collect::>(); + let mut seeds = Vec::new(); + if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { + for &residue in &anchor.seed_residues { + let mut candidate = u64::from(residue); + let modulus = u64::from(anchor.modulus); + while candidate <= u64::from(u32::MAX) { + let valid = constraints.iter().all(|report| { + report.modulus != 0 + && !report.seed_residues.is_empty() + && report + .seed_residues + .iter() + .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) + }); + if valid { + seeds.push(candidate as u32); + } + candidate = candidate.saturating_add(modulus); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + Ok(SeedDiscoveryReport { + version, + images: reports, + seed_candidates: seeds, + }) +} + +fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { + let count = high - low + 1; + let mut value = rid - low; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + low +} + +#[cfg(test)] +mod tests { + use super::*; + + fn put_u16(data: &mut [u8], offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(data: &mut [u8], offset: usize, value: u32) { + data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { + (1..=count) + .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) + .expect("inverse permutation must be bijective") + } + + fn build(tokens: &[u32]) -> (Vec, usize) { + let header_size = 0x100; + let images = header_size; + let types = images + IMAGE_STRIDE; + let methods = types + 2 * TYPE_STRIDE; + let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; + put_u32(&mut data, 0, MAGIC); + put_u32(&mut data, 4, SUPPORTED_VERSION); + put_u32(&mut data, HDR_METHODS, methods as u32); + put_u32( + &mut data, + HDR_METHODS + 4, + (tokens.len() * METHOD_STRIDE) as u32, + ); + put_u32(&mut data, HDR_TYPES, types as u32); + put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); + put_u32(&mut data, HDR_IMAGES, images as u32); + put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); + put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); + put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); + // Deliberately traverse the high method indices first. + put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); + put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); + put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); + put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); + for (index, &token) in tokens.iter().enumerate() { + put_u32( + &mut data, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + token, + ); + } + (data, methods) + } + + #[test] + fn restores_five_round_permutation_by_physical_method_index() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, methods) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "encrypted"); + assert!(report.changed_tokens > 0); + assert_eq!(report.correct_after, 7); + for index in 0..7 { + assert_eq!( + read_u32( + &restored, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET + ) + .expect("token"), + METHOD_TOKEN_TABLE | (index as u32 + 1) + ); + } + } + + #[test] + fn clean_metadata_is_idempotent() { + let tokens = (1..=7) + .map(|rid| METHOD_TOKEN_TABLE | rid) + .collect::>(); + let (data, _) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "clean"); + assert_eq!(report.changed_tokens, 0); + assert_eq!(restored, data); + } + + #[test] + fn encrypted_metadata_rejects_the_wrong_seed() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, _) = build(&tokens); + let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); + + assert!(matches!( + restore_method_tokens(&data, wrong_seed), + Err(Error::Validation(_)) + )); + } +} From 18886272e52e54bddc7b224f6469c66c654fe0dd Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 03:43:12 +0800 Subject: [PATCH 7/8] docs: note embedded metadata compatibility --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 21dde6e..5c85e5a 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ ### プリンセスコネクト!Re:Dive -| 平台 | 版本 | libil2cpp.so | global-metadata.dat | -|------|------|---------------|---------------------| +| 平台 | 版本 | libil2cpp.so | global-metadata.dat(内嵌) | +|------|------|---------------|---------------------------| | Android | 12.6.0 | ✅ | ✅ | ### あんスタ!! From 35076848b636a8d16ab71bccc9dd85a44d1a103c Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Sun, 16 Aug 2026 03:49:11 +0800 Subject: [PATCH 8/8] chore: ignore local test corpus --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..66632e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/test/