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