├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── apk_util.py ├── dex_util.py ├── docs ├── apicloud的文件解密过程说明.txt └── 关于资源的加密算法.txt ├── entropy.py ├── file_util.py ├── main.py ├── optional-requirements.txt ├── requirements.txt ├── tools.py └── uzm_util.py /.gitignore: -------------------------------------------------------------------------------- 1 | /__pycache__/ 2 | /*.pyc 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uzmap-resource-extractor 2 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 3 | [![Python 3.x](https://img.shields.io/badge/python-3.x-blue.svg)](https://github.com/python/cpython/tree/master) 4 | 5 |
用于解密和提取apicloud apk下的资源文件(html, js ,css) 6 | ### 背景&说明 ### 7 | 本人平时分析这类h5 app的时候,经常需要提取html, css, js等资源文件。 然而目前没有便捷的方法(有些通过xpose hook的方式提取,但比较麻烦) 8 |
所以我针对同类app分析, 同时也对其中的libsec.so文件进行逆向,发现是使用rc4方式加密,而且密钥可以静态提取,所以写了这个工具方便快速提取资源文件 9 |
项目的 [resources](https://github.com/newdive/resources) 文件夹中附带了apk和libsec.so的文件样本,供参考分析。 10 |
如果后续的加密方式有修改而导致不适用,可以提issue,也特别欢迎各位有志之士添砖加瓦 11 |
这个工具仅供个人研究学习使用。 其它非法用途所造成的法律责任,一律与本项目无关。 12 | ### Note ### 13 | > **接入 [AndroidNativeEmu](https://github.com/AeonLucid/AndroidNativeEmu/) ,探索并尝试新的解密思路**
14 | 15 | - ```master``` 分支支持 ```python3.x```
16 | - [emu_support](https://github.com/newdive/uzmap-resource-extractor/tree/emu_support) 分支支持 **AndroidNativeEmu 解密方式**
17 | - ```python2``` 分支支持 ```python2.7```
18 | 请根据具体需要选择相应分支 19 | 20 | ### Setup ### 21 | 先安装项目的依赖 22 | ``` 23 | pip install -r requirements.txt 24 | ``` 25 | 26 | - 支持pycryptodome, 让解密更高效 27 | 28 | ``` 29 | pip install -r optional-requirements.txt 30 | ``` 31 | 32 | ### Usage ### 33 | ``` 34 | python main.py xxx.apk 35 | ``` 36 | 支持参数列表通过 -h查看 37 | ``` 38 | python main.py -h 39 | ``` 40 | 41 | 具体用例 42 | 43 | - 查看apk中的rc4密钥 44 | 45 | ```python main.py -v xxx.apk ``` 46 | 47 | 输出信息说明 48 | ``` 49 | package : xxx.ooo.xxx ==> 应用包名 50 | uz_version : 1.3.13 ==> apicloud engine的版本号 51 | encrypted : False ==> 资源是否加密 52 | rc4Key : xxxxxxxxxxxxxxxxxxxx ==> 资源加密用到的RC4密钥 53 | ``` 54 | 55 | - 解密并提取所有的资源文件(如果不指明输出路径 默认输出到apk所在的文件夹下) 56 | 57 | ```python main.py -o 输出路径 xxx.apk ``` 58 | 59 | - 支持批量识别和解密 可以指定文件夹,会自动扫描文件夹下的所有 apicloud apk 并执行识别或解密 60 | 61 | ```python main.py -v targetFolder``` 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newdive/uzmap-resource-extractor/0df9773ed5e63f2e65ece1d8614d76595f109647/__init__.py -------------------------------------------------------------------------------- /apk_util.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | #created by SamLee 2020/6/3 3 | 4 | # This file is part of Androguard. 5 | # 6 | # Copyright (C) 2012, Anthony Desnos 7 | # All rights reserved. 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # http://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS-IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | 21 | # The main part of AndroidManifest.xml parsing was inspired by apk_parse 22 | # For your interest, please refer to the original implementation => https://github.com/tdoly/apk_parse/blob/master/apk.py 23 | 24 | import os 25 | import sys 26 | import struct 27 | import zipfile 28 | import traceback 29 | import codecs 30 | from asn1crypto import cms 31 | 32 | APK_MANIFEST = 'AndroidManifest.xml' 33 | 34 | APICLOUD_MANIFEST_APPNAME = 'com.uzmap.pkg.uzapp.UZApplication' 35 | APICLOUD_MANIFEST_APPVERSION = 'uz_version' 36 | APICLOUD_JNI_INTERFACE = 'com/uzmap/pkg/uzcore/external/Enslecb' 37 | 38 | APK_MANIFEST_STARTTAG_BYTES = b'\x02\x01\x10\x00' 39 | DEXHEAD_MAGICS = [b'\x64\x65\x78\x0A\x30\x33',\ 40 | b'\x64\x65\x79\x0A\x30\x33'] 41 | 42 | CHUNK_XML,CHUNK_STRING,CHUNK_TABLE,CHUNK_TABLEPACKAGE = 0x0003,0x0001,0x0002,0x0200 43 | 44 | def getFileSize(f): 45 | org_pos = f.tell() 46 | try: 47 | f.seek(0, os.SEEK_END) 48 | return f.tell() 49 | finally: 50 | f.seek(org_pos, os.SEEK_SET) 51 | 52 | 53 | def isPossibleDexFile(dexFile,dexInfo=None): 54 | global DEXHEAD_MAGICS 55 | apkHead = dexFile.read(8) 56 | if len(apkHead)<8 or not (apkHead[0:6] in DEXHEAD_MAGICS and apkHead[-1:]==b'\x00'): 57 | return False 58 | dexFile.read(24) 59 | dexFileSize = struct.unpack('> 4) & 3] 158 | 159 | def getAttributeValue(vStr,vType,vData,stringList): 160 | 161 | if vType == ATTR_TYPE_STRING: 162 | return stringList[vStr] 163 | 164 | elif vType == ATTR_TYPE_ATTRIBUTE: 165 | return "?%s%08X" % ('android:' if (vData>>24)==1 else '', vData) 166 | 167 | elif vType == ATTR_TYPE_REFERENCE: 168 | return "@%s%08X" % ('android:' if (vData>>24)==1 else '', vData) 169 | 170 | elif vType == ATTR_TYPE_FLOAT: 171 | return struct.unpack("=f", struct.pack("=L", vData))[0] 172 | 173 | elif vType == ATTR_TYPE_INT_HEX: 174 | return "0x%08X" % vData 175 | 176 | elif vType == ATTR_TYPE_INT_BOOLEAN: 177 | if vData == 0: 178 | return False 179 | return True 180 | 181 | elif vType == ATTR_TYPE_DIMENSION: 182 | return "%f%s" % (complexToFloat(vData), DIMENSION_UNITS[vData & COMPLEX_UNIT_MASK]) 183 | 184 | elif vType == ATTR_TYPE_FRACTION: 185 | return "%f%s" % (complexToFloat(vData) * 100, FRACTION_UNITS[vData & COMPLEX_UNIT_MASK]) 186 | 187 | elif vType >= ATTR_TYPE_FIRST_COLOR_INT and vType <= ATTR_TYPE_LAST_COLOR_INT: 188 | return "#%08X" % vData 189 | 190 | elif vType >= ATTR_TYPE_FIRST_INT and vType <= ATTR_TYPE_LAST_INT: 191 | return (0x7fffffff & vData) - 0x80000000 if vData>0x7fffffff else vData 192 | 193 | return "<0x%X, type 0x%02X>" % (vData, vType) 194 | 195 | def extractStringList(fileBytes,fOffset): 196 | ''' 197 | fileHeader(8) 198 | header(2) + headerSize(2) + chunkSize(4) + stringCount(4) + styleOffsetCount(4) + flags(4) + stringsOffset(4) + stylesOffset(4) 199 | 0:stringCount stringOffset(4) 200 | 0:styleOffsetCount styleOffset(4) 201 | ''' 202 | stringList = [] 203 | chunkSize = struct.unpack('>24) 274 | if onlySimpleAttr and attrType in complexTypes: 275 | continue 276 | nameIdx,valueIdx = struct.unpack('b', '>B', '>h', '>H', '>i', '>I', '>q', '>Q', '>f', '>d'] 6 | littleEndianFormatTypes = ['-1: 72 | self.seek(seekOff) 73 | for i in range(length): 74 | result.append(self.readShort()) 75 | return result 76 | 77 | def readUnsignedLeb128(self): 78 | result, cur, count = 0, 0, 0 79 | while True: 80 | cur = self.readByte() 81 | result |= (cur & 0x7f) << (count * 7) 82 | count += 1 83 | if cur&0x80 != 0x80 or count >= 5: 84 | break 85 | if cur&0x80 == 0x80: 86 | raise Exception("invalid LEB128 sequence") 87 | return result 88 | 89 | def decodeUTF8Str(self): 90 | result = [] 91 | while True: 92 | a = self.readByte()&0xFF 93 | if a == 0: 94 | return bytes(result).decode('utf-8') 95 | result.append(a) 96 | if a < 0x80: 97 | pass 98 | elif (a & 0xe0) == 0xc0: 99 | b = self.readByte()&0xFF 100 | if (b & 0xC0) != 0x80: 101 | raise Exception("UTFDataFormatException: bad second byte") 102 | result.append(((a & 0x1F) << 6) | (b & 0x3F)) 103 | elif (a & 0xf0) == 0xe0: 104 | b, c = self.readByte() & 0xff, self.readByte() & 0xff 105 | if ((b & 0xC0) != 0x80) or ((c & 0xC0) != 0x80): 106 | raise Exception("UTFDataFormatException: bad second or third byte") 107 | result.append(((a & 0x0F) << 12) | ((b & 0x3F) << 6) | (c & 0x3F)) 108 | else: 109 | raise Exception("UTFDataFormatException: bad byte") 110 | 111 | def readString(self): 112 | offset = self.readInt() 113 | curOffset = self.offset 114 | self.seek(offset) 115 | expectedLength = self.readUnsignedLeb128() 116 | try: 117 | resultStr = self.decodeUTF8Str() 118 | if len(resultStr) != expectedLength: 119 | raise Exception("Declared length {} doesn't match decoded length of {}".format(expectedLength, 120 | len(resultStr))) 121 | return resultStr 122 | finally: 123 | self.seek(curOffset) 124 | 125 | 126 | 127 | ''' 128 | reference com/android/dex/TableOfContents.java 129 | ''' 130 | class TableOfContents: 131 | __slots__ = ('magic', 'checksum', 'signiture', 'fileSize', 'linkSize', 'linkOff', 'dataSize', 'dataOff', 132 | 'header', 'stringIds', 'typeIds', 'protoIds', 'fieldIds', 'methodIds', 'classDefs', 'mapList', 133 | 'typeLists', 'annotationSetRefLists', 'annotationSets', 'classDatas', 'codes', 'stringDatas', 'debugInfos', 'annotations', 134 | 'encodedArrays', 'annotationsDirectories') 135 | sectionTypeMap = { 136 | 0x0000: 'header', 137 | 0x0001: 'stringIds', 138 | 0x0002: 'typeIds', 139 | 0x0003: 'protoIds', 140 | 0x0004: 'fieldIds', 141 | 0x0005: 'methodIds', 142 | 0x0006: 'classDefs', 143 | 0x1000: 'mapList', 144 | 0x1001: 'typeLists', 145 | 0x1002: 'annotationSetRefLists', 146 | 0x1003: 'annotationSets', 147 | 0x2000: 'classDatas', 148 | 0x2001: 'codes', 149 | 0x2002: 'stringDatas', 150 | 0x2003: 'debugInfos', 151 | 0x2004: 'annotations', 152 | 0x2005: 'encodedArrays', 153 | 0x2006: 'annotationsDirectories', 154 | } 155 | 156 | def __init__(self, dataBytes): 157 | # initialize sections 158 | for k, v in TableOfContents.sectionTypeMap.items(): 159 | setattr(self, v, (0, -1)) 160 | bytesReader = BytesReader(dataBytes, littleEndian=True) 161 | self._readHeader(bytesReader) 162 | self._readMap(bytesReader) 163 | 164 | def _readHeader(self, dataBytes): 165 | self.magic = dataBytes.readBytes(8) 166 | self.checksum = dataBytes.readInt() 167 | self.signiture = dataBytes.readBytes(20) 168 | self.fileSize = dataBytes.readInt() 169 | headerSize = dataBytes.readInt() 170 | endianTag = dataBytes.readInt() 171 | self.linkSize = dataBytes.readInt() 172 | self.linkOff = dataBytes.readInt() 173 | self.mapList = (0, dataBytes.readInt()) 174 | for k in ['stringIds','typeIds', 'protoIds', 'fieldIds', 'methodIds', 'classDefs']: 175 | setattr(self, k, (dataBytes.readInt(), dataBytes.readInt())) 176 | self.dataSize, self.dataOff = dataBytes.readInt(), dataBytes.readInt() 177 | 178 | # size, offset 179 | def _getSection(self, type): 180 | return getattr(self, TableOfContents.sectionTypeMap[type], None) 181 | 182 | def _updateSection(self, type, size, offset): 183 | setattr(self, TableOfContents.sectionTypeMap[type], (size, offset)) 184 | 185 | def _readMap(self, dataBytes): 186 | dataBytes.seek(self.mapList[1]) 187 | mapSize = dataBytes.readInt() 188 | previous = None 189 | for i in range(mapSize): 190 | type = dataBytes.readShort() 191 | dataBytes.skip(2) 192 | size, offset = dataBytes.readInt(), dataBytes.readInt() 193 | section = self._getSection(type) 194 | if section is None: 195 | continue 196 | if (section[0] != 0 and section[0] != size) or\ 197 | (section[1] != -1 and section[1] != offset): 198 | raise Exception("DexException: Unexpected map value for 0x{:04x}".format(type)) 199 | #throw new DexException("Map is unsorted at " + previous + ", " + section) 200 | if section[0] == 0 or section[1] == -1: 201 | self._updateSection(type, size, offset) 202 | section = self._getSection(type) 203 | if previous is not None and previous[1] > section[1]: 204 | raise Exception("DexException: Map is unsorted at {}, {}".format(previous, section)) 205 | previous = section 206 | 207 | 208 | class SizeOf: 209 | UBYTE = 1 210 | USHORT = 2 211 | UINT = 4 212 | SIGNATURE = UBYTE * 20 213 | HEADER_ITEM = (8 * UBYTE) + UINT + SIGNATURE + (20 * UINT) 214 | STRING_ID_ITEM = UINT 215 | TYPE_ID_ITEM = UINT 216 | TYPE_ITEM = USHORT 217 | PROTO_ID_ITEM = UINT + UINT + UINT 218 | MEMBER_ID_ITEM = USHORT + USHORT + UINT 219 | CLASS_DEF_ITEM = 8 * UINT 220 | MAP_ITEM = USHORT + USHORT + UINT + UINT 221 | TRY_ITEM = UINT + USHORT + USHORT 222 | 223 | 224 | class ValueType: 225 | ENCODED_BYTE = 0x00 226 | ENCODED_SHORT = 0x02 227 | ENCODED_CHAR = 0x03 228 | ENCODED_INT = 0x04 229 | ENCODED_LONG = 0x06 230 | ENCODED_FLOAT = 0x10 231 | ENCODED_DOUBLE = 0x11 232 | ENCODED_STRING = 0x17 233 | ENCODED_TYPE = 0x18 234 | ENCODED_FIELD = 0x19 235 | ENCODED_ENUM = 0x1b 236 | ENCODED_METHOD = 0x1a 237 | ENCODED_ARRAY = 0x1c 238 | ENCODED_ANNOTATION = 0x1d 239 | ENCODED_NULL = 0x1e 240 | ENCODED_BOOLEAN = 0x1f 241 | 242 | 243 | class ClassDef: 244 | __slots__ = ('typeIndex', 'accessFlags', 'supertypeIndex', 'interfacesOffset', 245 | 'sourceFileIndex', 'annotationsOffset', 'classDataOffset', 'staticValuesOffset') 246 | 247 | def __init__(self,typeIndex, accessFlags, 248 | supertypeIndex, interfacesOffset, sourceFileIndex, 249 | annotationsOffset, classDataOffset, staticValuesOffset): 250 | self.typeIndex = typeIndex 251 | self.accessFlags = accessFlags 252 | self.supertypeIndex = supertypeIndex 253 | self.interfacesOffset = interfacesOffset 254 | self.sourceFileIndex = sourceFileIndex 255 | self.annotationsOffset = annotationsOffset 256 | self.classDataOffset = classDataOffset 257 | self.staticValuesOffset = staticValuesOffset 258 | 259 | 260 | class FieldId: 261 | __slots__ = ('declaringClassIndex', 'typeIndex', 'nameIndex') 262 | 263 | def __init__(self,declaringClassIndex, typeIndex, nameIndex): 264 | self.declaringClassIndex = declaringClassIndex 265 | self.typeIndex = typeIndex 266 | self.nameIndex = nameIndex 267 | 268 | 269 | class Field: 270 | __slots__ = ('fieldIndex', 'accessFlags') 271 | 272 | def __init__(self, fieldIndex, accessFlags): 273 | self.fieldIndex = fieldIndex 274 | self.accessFlags = accessFlags 275 | 276 | 277 | class FieldInfo: 278 | def __init__(self): 279 | self.accessFlags = 0 280 | self.name, self.type = None, None 281 | 282 | 283 | 284 | class Annotation: 285 | VISIBILITY_BUILD = 0 286 | VISIBILITY_RUNTIME = 1 287 | VISIBILITY_SYSTEM = 2 288 | 289 | def __init__(self, visibility, annoType, values): 290 | self.visibility = visibility 291 | self.atype = annoType 292 | self.values = values 293 | 294 | 295 | class ClassValueParser: 296 | 297 | def __init__(self, dex, valueOffset): 298 | self.dex = dex 299 | self.valueOffset = valueOffset 300 | 301 | def processValues(self, stopIdx=-1): 302 | self.dex.data.seek(self.valueOffset) 303 | valueCount = self.dex.data.readUnsignedLeb128() 304 | fieldValues, readValueCount = [], stopIdx+1 if stopIdx>-1 else valueCount 305 | for i in range(readValueCount): 306 | fieldValues.append(self.parseValue()) 307 | return fieldValues 308 | 309 | def parseValue(self): 310 | argAndType = self.dex.data.readByte() & 0xFF 311 | type = argAndType & 0x1F 312 | arg = (argAndType & 0xE0) >> 5 313 | size = arg + 1 314 | if type == ValueType.ENCODED_NULL: 315 | return type, None 316 | if type == ValueType.ENCODED_BOOLEAN: 317 | return type, arg == 1 318 | if type == ValueType.ENCODED_BYTE: 319 | return type, self.dex.data.readByte() & 0xFF 320 | if type == ValueType.ENCODED_CHAR: 321 | return type, self.parseNumber0(size, True) 322 | if type == ValueType.ENCODED_SHORT: 323 | return type, self.parseUnsignedInt(size) 324 | if type == ValueType.ENCODED_INT: 325 | return type, self.parseNumber0(size, True) 326 | if type == ValueType.ENCODED_LONG: 327 | return type, self.parseNumber0(size, True) 328 | if type == ValueType.ENCODED_FLOAT: 329 | return type, struct.unpack('>f', struct.pack('>I', self.parseNumber(size, False, 4)))[0] 330 | if type == ValueType.ENCODED_DOUBLE: 331 | return type, struct.unpack('>d', struct.pack('>Q', self.parseNumber(size, False, 8)))[0] 332 | if type == ValueType.ENCODED_STRING: 333 | strIdx = self.parseUnsignedInt(size) 334 | curOff = self.dex.data.offset 335 | strVal = self.dex.stringFromDescriptorIndex(strIdx) 336 | self.dex.data.seek(curOff) 337 | return type, strVal 338 | if type == ValueType.ENCODED_ARRAY: 339 | arrValCount = self.dex.data.readUnsignedLeb128() 340 | arrVals = [] 341 | for i in range(arrValCount): 342 | arrVals.append(self.parseValue()) 343 | return type, arrVals 344 | if type == ValueType.ENCODED_TYPE: 345 | typeIdx = self.parseUnsignedInt(size) 346 | curOff = self.dex.data.offset 347 | typeName = self.dex.stringFromTypeIndex(typeIdx) 348 | self.dex.data.seek(curOff) 349 | return type, typeName 350 | if type == ValueType.ENCODED_METHOD: 351 | # methodInfo 352 | return type, self.parseUnsignedInt(size) # methodId index 353 | if type == ValueType.ENCODED_FIELD or type == ValueType.ENCODED_ENUM: 354 | # fieldInfo 355 | return type, self.parseUnsignedInt(size) # fieldId index 356 | if type == ValueType.ENCODED_ANNOTATION: 357 | return type, self._readAnnotation() 358 | raise Exception("DecodeException: Unknown encoded value type: {:x}" .format(type)) 359 | 360 | def _readAnnotation(self): 361 | typeIndex = self.dex.data.readUnsignedLeb128() 362 | size = self.dex.data.readUnsignedLeb128() 363 | valueMap = {} 364 | for i in range(size): 365 | nIdx = self.dex.data.readUnsignedLeb128() 366 | curOff = self.dex.data.offset 367 | name = self.dex.get_string(nIdx) 368 | self.dex.data.seek(curOff) 369 | valueMap[name] = self.parseValue() 370 | curOff = self.dex.data.offset 371 | annoType = self.dex.stringFromTypeIndex(typeIndex) 372 | self.dex.data.seek(curOff) 373 | return Annotation(None, annoType, valueMap) 374 | 375 | def parseUnsignedInt(self, byteCount): 376 | return self.parseNumber(byteCount, False, 0) 377 | 378 | def parseNumber0(self, byteCount, isSignExtended): 379 | return self.parseNumber(byteCount, isSignExtended, 0) 380 | 381 | def parseNumber(self, byteCount, isSignExtended, fillOnRight): 382 | result, last = 0, 0 383 | for i in range(byteCount): 384 | last = self.dex.data.readByte() & 0xFF 385 | result |= last << i * 8 386 | if fillOnRight != 0: 387 | for i in range(byteCount, fillOnRight): 388 | result <<= 8 389 | else: 390 | # abs(a) + abs(negative of a) = (1 << byteCount of a) 391 | if isSignExtended and (last & 0x80) != 0: 392 | result -= (1 << byteCount * 8) 393 | return result 394 | 395 | 396 | class Dex: 397 | 398 | def __init__(self, dataBytes): 399 | self.data = BytesReader(dataBytes) 400 | self.tableOfContents = TableOfContents(dataBytes) 401 | 402 | def stringFromTypeIndex(self, idx): 403 | return self.stringFromDescriptorIndex(self.descriptorIndexFromTypeIndex(idx)) 404 | 405 | def stringFromDescriptorIndex(self, idx): 406 | self._checkBounds(idx, self.tableOfContents.stringIds[0]) 407 | stringOff = self.tableOfContents.stringIds[1] + (idx * SizeOf.STRING_ID_ITEM) 408 | self.data.seek(stringOff) 409 | return self.data.readString() 410 | 411 | def descriptorIndexFromTypeIndex(self, typeIndex): 412 | self._checkBounds(typeIndex, self.tableOfContents.typeIds[0]) 413 | position = self.tableOfContents.typeIds[1] + (typeIndex * SizeOf.TYPE_ID_ITEM ) 414 | return self.data.readInt(position) 415 | 416 | def readClassDef(self, classTypeName): 417 | offSet = self.tableOfContents.classDefs[1] 418 | for i in range(self.tableOfContents.classDefs[0]): 419 | self.data.seek(offSet) 420 | clsDef = self._readClassDef() 421 | if self.stringFromTypeIndex(clsDef.typeIndex)==classTypeName: 422 | return clsDef 423 | offSet += SizeOf.CLASS_DEF_ITEM 424 | return None 425 | 426 | def findFieldInfoFromClassDef(self, classDef, fieldName, isStatic=False): 427 | if not classDef or classDef.classDataOffset == 0: 428 | return -1, None 429 | offset = classDef.classDataOffset 430 | self.data.seek(offset) 431 | staticFieldsSize = self.data.readUnsignedLeb128() 432 | instanceFieldsSize = self.data.readUnsignedLeb128() 433 | directMethodsSize = self.data.readUnsignedLeb128() 434 | virtualMethodsSize = self.data.readUnsignedLeb128() 435 | staticFields = self._readFields(staticFieldsSize) 436 | instanceFields = self._readFields(instanceFieldsSize) 437 | # ignore methods 438 | targetFields = staticFields if isStatic else instanceFields 439 | for tfIdx, field in enumerate(targetFields): 440 | fieldId = self.getFieldIdByIndex(field.fieldIndex) 441 | fName = self.stringFromDescriptorIndex(fieldId.nameIndex) 442 | if fName == fieldName: 443 | fieldInfo = FieldInfo() 444 | fieldInfo.accessFlags = field.accessFlags 445 | fieldInfo.name = fName 446 | fieldInfo.type = self.stringFromTypeIndex(fieldId.typeIndex) 447 | return tfIdx, fieldInfo 448 | return -1, None 449 | 450 | def findFieldInfo(self, className, fieldName, isStatic=False): 451 | return self.findFieldInfoFromClassDef(self.readClassDef(className), fieldName, isStatic=isStatic) 452 | 453 | def getClassStaticFieldAndValue(self, className, fieldName): 454 | classDef = self.readClassDef(className) 455 | fIdx, fieldInfo = self.findFieldInfoFromClassDef(classDef, fieldName, isStatic=True) 456 | if not fieldInfo: 457 | return None, None 458 | if classDef.staticValuesOffset == 0: # static value is not defined 459 | return fieldInfo, None 460 | cvParser = ClassValueParser(self, classDef.staticValuesOffset) 461 | staticValues = cvParser.processValues(fIdx) 462 | return fieldInfo, staticValues[fIdx] 463 | 464 | def getFieldIdByIndex(self, fieldIndex): 465 | offSet = self.tableOfContents.fieldIds[1] + fieldIndex*SizeOf.MEMBER_ID_ITEM 466 | self.data.seek(offSet) 467 | return self._readFieldId() 468 | 469 | def _checkBounds(self, index, length): 470 | if index < 0 or index >= length: 471 | raise Exception('IndexOutOfBounds => index={}, length={}'.format(index, length)) 472 | 473 | def _readFields(self, count): 474 | result = [] 475 | fieldIndex = 0 476 | for i in range(count): 477 | fieldIndex += self.data.readUnsignedLeb128() 478 | accessFlags = self.data.readUnsignedLeb128() 479 | result.append(Field(fieldIndex, accessFlags)) 480 | return result 481 | 482 | def _readFieldId(self): 483 | declaringClassIndex = self.data.readUnsignedShort() 484 | typeIndex = self.data.readUnsignedShort() 485 | nameIndex = self.data.readInt() 486 | return FieldId(declaringClassIndex, typeIndex, nameIndex) 487 | 488 | def _readClassDef(self): 489 | typeIndex = self.data.readInt() 490 | accessFlags = self.data.readInt() 491 | supertype = self.data.readInt() 492 | interfacesOffset = self.data.readInt() 493 | sourceFileIndex = self.data.readInt() 494 | annotationsOffset = self.data.readInt() 495 | classDataOffset = self.data.readInt() 496 | staticValuesOffset = self.data.readInt() 497 | return ClassDef(typeIndex, accessFlags, supertype, 498 | interfacesOffset, sourceFileIndex, annotationsOffset, classDataOffset, 499 | staticValuesOffset) 500 | 501 | 502 | -------------------------------------------------------------------------------- /docs/apicloud的文件解密过程说明.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newdive/uzmap-resource-extractor/0df9773ed5e63f2e65ece1d8614d76595f109647/docs/apicloud的文件解密过程说明.txt -------------------------------------------------------------------------------- /docs/关于资源的加密算法.txt: -------------------------------------------------------------------------------- 1 | 从已知的分析来看, apicloud使用的加密算法是RC4, 而且密钥的长度为 20 2 | 只不过在不同版本中使用的RC4算法略有不同 3 | descriptor属性为 "sdk" 或者 uz_version在 1.2.0以后的(包括1.2.0) 使用 com.uzmap.pkg.uzcore.external.Enslecb.ohs方法进行解密 4 | 这个方法在目前的分析来看使用的都是通用的RC4算法 5 | 没有uz_version属性 或者 uz_version 在 1.2.0 以前的 则使用变种的RC4算法 这个算法定义在java层 而不是在jni层 6 | 这个RC4的state大小只有20字节(通用的RC4的state大小由256字节) 7 | 8 | 关于这个的判断逻辑可以参考下边的代码逻辑(属性k为true 则调用ohs方法解密, 否则使用变种的rc4算法解密) 9 | if ("sdk".equals(b.q())) { // 这里对应的是 compile.Properties.descriptor 方法的返回值 10 | k = true; 11 | return; 12 | } 13 | String v = this.v.metaData.getString("uz_version"); 14 | if (!TextUtils.isEmpty(v)) { 15 | String[] vs = v.split("\\."); 16 | if (vs != null && vs.length >= 3) { 17 | String v1 = vs[0]; 18 | String v2 = vs[1]; 19 | int ver1 = Integer.valueOf(v1).intValue(); 20 | int ver2 = Integer.valueOf(v2).intValue(); 21 | if ((ver1 == 1 && ver2 >= 2) || ver1 > 1) { // uz_version>=1.2.x 则 i.k = True 22 | k = true; 23 | } 24 | } 25 | } 26 | 27 | 当然这里 ohs 的实现逻辑不一定是rc4算法 28 | 保险的话可以考虑通过 unicorn/AndroidNativeEmu/Unidbg 之类的工具来直接调用得到解密结果 29 | 30 | 对于变种的rc4算法,密钥则来源于 Enslecb.oc 方法 和 compile.Properties.cloudKey 31 | 密钥具体构造如下 32 | 1、提取compile.Properties.cloudKey 中的10个字符 33 | 如果 cloudKey 长度为10 则直接返回 ; 否则 每4个字符取前两个字符拼接成长度为10的字符串 34 | 2、Enslecb.oc() + 第1步中的字符串 35 | 36 | 对于上述的两种解密方法都涉及到对 jni的调用 而且jni里边有对apk签名的校验 37 | 签名的校验过程是: 38 | 先对apk的签名字节进行rc4加密 39 | 接着对加密的apk签名字节进行base64编码, 40 | 然后对 base64字符串进行 md5 得到长度为 32 的 hex字符串 41 | 最后将这个字符串与jni中的字符串常量进行比对, 相等则通过校验, 否则校验失败 42 | apk签名串的初始化过程在 Enslecb.sm 中调用 这个方法会在application的onCreate方法中先调用 43 | 所以如果使用 AndroidNativeEmu之类的工具的话需要先手动调用 Enslecb.sm 方法, 传入apk对应的签名字节 44 | 保证后续的调用能通过校验 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /entropy.py: -------------------------------------------------------------------------------- 1 | import math 2 | import zlib 3 | import collections 4 | import os 5 | 6 | ''' 7 | for 256 values in a byte, the maximum entropy is log2(256)/256*256 = 8 8 | this method is a bit slow though 9 | when the length of dataBytes is less than 256 must consider maximum entropy log2(len(dataBytes)) 10 | ''' 11 | def shannonEntropy(dataBytes): 12 | entropy,byteValNum = 0,1 13 | if dataBytes: 14 | dataFreq = collections.Counter(dataBytes) 15 | length = len(dataBytes) 16 | 17 | for k,freq in dataFreq.items(): 18 | p_x = float(freq)/length 19 | entropy -= p_x * math.log(p_x, 2) 20 | byteValNum = min(length,256) 21 | 22 | return entropy/max(1,math.log(byteValNum,2)) 23 | 24 | ''' 25 | this method is fast, in the cost of some accuracy(not as good as shanno formula) 26 | ''' 27 | def gzipEntropy(dataBytes): 28 | if isinstance(dataBytes,type('')): 29 | dataBytes = bytes(dataBytes,'latin1') 30 | 31 | e = float(float(len(zlib.compress(dataBytes, 9))) / float(len(dataBytes))) 32 | 33 | return min(e,1.0) 34 | 35 | 36 | def calculateEntropy(dataBytes): 37 | return gzipEntropy(dataBytes) if len(dataBytes)>512 else shannonEntropy(dataBytes) 38 | 39 | def calculateFileEntropy(fileDir): 40 | entropyMap = {} 41 | if os.path.exists(fileDir): 42 | targetFiles = [] 43 | if os.path.isdir(fileDir): 44 | for root, dirs, files in os.walk(fileDir): 45 | if files: 46 | targetFiles.extend([os.path.join(root,f) for f in files]) 47 | else: 48 | targetFiles.append(fileDir) 49 | 50 | for f in targetFiles: 51 | with open(f,'rb') as rf: 52 | fBytes = rf.read() 53 | entropyMap[f] = gzipEntropy(fBytes) if len(fBytes)>512 else shannonEntropy(fBytes) 54 | 55 | return entropyMap -------------------------------------------------------------------------------- /file_util.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | #created by SamLee 2020/6/12 3 | 4 | import os 5 | import sys 6 | 7 | Windows_ForbiddenFileChars = set(['?',':','*','<','>','|','"','\\']) 8 | Windows_ReservedFilenames = set(['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']) 9 | 10 | ''' 11 | windows上有些预留的字符,单词不能作为文件名 需要避免 12 | 处理方式是将这些不合法字符转化成url-escape形式的字符串 13 | ''' 14 | def legimateFileName(originalFileName): 15 | global Windows_ReservedFilenames,Windows_ForbiddenFileChars 16 | transformedFileName = originalFileName 17 | 18 | if 'win' in sys.platform.lower(): 19 | dirName,baseFileName = os.path.dirname(originalFileName), os.path.basename(originalFileName) 20 | extIdx = baseFileName.find('.') 21 | if extIdx!=-1 and baseFileName[0:extIdx].upper() in Windows_ReservedFilenames: 22 | baseFileName = '{}{}'.format(''.join(['%{:X}'.format(ord(c)) for c in baseFileName[0:extIdx]]),baseFileName[extIdx:]) 23 | baseFileName = ''.join(['%{:X}'.format(ord(c)) if c in Windows_ForbiddenFileChars else c for c in baseFileName]) 24 | transformedFileName = '{}/{}'.format(dirName,baseFileName) 25 | 26 | return transformedFileName 27 | 28 | 29 | def createDirectoryIfNotExist(targetFile): 30 | fParent = os.path.dirname(targetFile) 31 | if fParent and not os.path.exists(fParent): 32 | os.makedirs(fParent) -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | #created by SamLee 2020/3/6 3 | import sys 4 | import os 5 | import zipfile 6 | import optparse 7 | import tools 8 | import time 9 | 10 | if sys.version_info[0] < 3: 11 | print(u"当前的Python版本: {}。该程序只能在Python3.x下运行。".format(sys.version.split(' ')[0])) 12 | sys.exit(1) 13 | 14 | if __name__ == '__main__': 15 | parser = optparse.OptionParser() 16 | parser.add_option('-o','--output', 17 | action='store',dest='output', 18 | help='提取文件存放的目录(默认存放到apk所在的目录下)',default='') 19 | parser.add_option('-v','--viewInfo', 20 | action='store_true',dest='viewInfo', 21 | help='查看rc4的key等信息',default=False) 22 | 23 | options,args = parser.parse_args() 24 | #print(options) 25 | #print(args) 26 | if not args : 27 | print('没有指定apk文件/文件夹') 28 | sys.exit() 29 | if args[0] and not os.path.exists(args[0]) : 30 | print('没有指定apk文件/文件夹') 31 | sys.exit() 32 | if args[0] and not os.path.isdir(args[0]) and not zipfile.is_zipfile(args[0]): 33 | print('{} 不是apk文件'.format(args[0])) 34 | sys.exit() 35 | 36 | if options.viewInfo: 37 | apkInfos = tools.extractAPICloudApkInfos(args[0],True) 38 | for apk,apkInfo in apkInfos.items(): 39 | print(apk) 40 | print('\tpackage : {}\n\tuz_version : {}\n\tencrypted : {}\n\trc4Key : {}\n'.format(apkInfo['package'], \ 41 | apkInfo['uz_version'], \ 42 | apkInfo['encrypted'], \ 43 | apkInfo['resKey'])) 44 | print('共找到 {} 个 apicloud apk'.format(len(apkInfos))) 45 | else: 46 | outputFolder = options.output 47 | startTime = time.time() 48 | extractMap = tools.decryptAndExtractAPICloudApkResources(args[0],outputFolder,printLog=True) 49 | print('耗时 : {} 秒'.format(time.time()-startTime)) -------------------------------------------------------------------------------- /optional-requirements.txt: -------------------------------------------------------------------------------- 1 | #efficient crypto operations for python 2 | #if performance matters, choose this 3 | pycryptodome>=3.8.1 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyelftools>=0.25 2 | asn1crypto>=0.24.0 -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | #created by SamLee 2020/6/5 3 | 4 | import os 5 | import sys 6 | import multiprocessing 7 | import threading 8 | from concurrent.futures import ThreadPoolExecutor 9 | import time 10 | import datetime 11 | import apk_util 12 | import uzm_util 13 | import traceback 14 | 15 | def determineSavePath(apkPath,saveTo): 16 | saveTo = saveTo.strip() 17 | apkPath = os.path.abspath(apkPath) 18 | apkName = os.path.basename(apkPath) 19 | if not saveTo: 20 | saveTo = os.path.dirname(apkPath) 21 | if '.' in apkName: 22 | apkName = apkName[0:apkName.rfind('.')] 23 | saveApkPath = '{}/{}'.format(saveTo,apkName) 24 | #in order to avoid conflict , add a timestamp to saveApkPath 25 | if os.path.exists(saveApkPath): 26 | saveApkPath = '{}_{}'.format(saveApkPath,datetime.datetime.now().strftime('%Y%m%d%H%M%S')) 27 | return saveApkPath 28 | 29 | ''' 30 | 旧api 31 | 对apicloud的apk进行资源的解密提取 32 | ''' 33 | def decryptAllResourcesInApk(apkFilePath,saveTo=None,printLog=False): 34 | return uzm_util.decryptAllResourcesInApk(apkFilePath,determineSavePath(apkFilePath,saveTo),printLog) 35 | 36 | ''' 37 | 旧api 38 | 查看apicloud的apk的资源密钥 39 | ''' 40 | def extractRC4KeyFromApk(apkFilePath): 41 | return uzm_util.extractRC4KeyFromApk(apkFilePath) 42 | 43 | 44 | 45 | def extractAPICloudApkInfo(resourcePath,extractRC4Key=False,msgQueue=None,isDefaultApk=False): 46 | apicloudInfo = None 47 | try: 48 | apicloudInfo = apk_util.extractAPICloudInfo(resourcePath,isDefaultApk) 49 | except: 50 | print('error while extracting apk info from {}'.format(resourcePath)) 51 | traceback.print_exc() 52 | 53 | if apicloudInfo and extractRC4Key: 54 | apicloudInfo['resKey'] = uzm_util.extractRC4KeyFromApk(resourcePath) 55 | apicloudInfo['encrypted'] = uzm_util.isResourceEncrypted(resourcePath) 56 | if msgQueue: 57 | msgQueue.put_nowait((resourcePath,apicloudInfo)) 58 | return resourcePath,apicloudInfo 59 | 60 | def _decryptAPICloudApkResources(apkFilePath,saveTo,msgQueue=None,printLog=False): 61 | decMap = uzm_util.decryptAllResourcesInApk(apkFilePath,saveTo,printLog) 62 | if msgQueue: 63 | msgQueue.put_nowait((apkFilePath,saveTo,decMap)) 64 | return apkFilePath,saveTo,decMap 65 | 66 | def _decryptAPICloudApkResourcesParallel(apkFilePath,saveTo,procPool=None,msgQueue=None,printLog=False): 67 | decMap = uzm_util.decryptAllResourcesInApkParallel(apkFilePath,saveTo,printLog,procPool=procPool) 68 | if msgQueue: 69 | msgQueue.put_nowait((apkFilePath,saveTo,decMap)) 70 | return apkFilePath,saveTo,decMap 71 | 72 | def _scanAPICloudApks(procPool,msgQueue,resourcePath,extractRC4Key=False,printLog=False): 73 | 74 | def scanHandle(procPool,msgQueue,resourcePath,extractRC4Key,globalStates): 75 | for root, dirs, files in os.walk(resourcePath): 76 | for f in files: 77 | procPool.apply_async(extractAPICloudApkInfo,args=('{}/{}'.format(root,f),extractRC4Key,msgQueue)) 78 | globalStates['submittedFiles'] += 1 79 | globalStates['scanComplete'] = True 80 | 81 | globalStates = {'submittedFiles':0,'scanComplete':False,'processedFiles':0} 82 | 83 | scanTh = threading.Thread(target=scanHandle,args=(procPool,msgQueue,resourcePath,extractRC4Key,globalStates)) 84 | scanTh.start() 85 | 86 | apkInfoMap = {} 87 | while True: 88 | if globalStates['scanComplete'] and globalStates['submittedFiles']<=globalStates['processedFiles']: 89 | break 90 | if msgQueue.empty(): 91 | time.sleep(0.01) 92 | continue 93 | apkPath,apkInfo = msgQueue.get_nowait() 94 | globalStates['processedFiles'] += 1 95 | if apkInfo: 96 | apkInfoMap[apkPath] = apkInfo 97 | msgQueue.task_done() 98 | if printLog: 99 | sys.stdout.write('{}/{} => {}\r'.format(globalStates['processedFiles'],globalStates['submittedFiles'],apkPath)) 100 | sys.stdout.flush() 101 | if printLog: 102 | print('\n') 103 | return apkInfoMap 104 | 105 | def _decryptAPICloudApks(procPool,msgQueue,apkInfoMap,saveTo,printLog=False): 106 | 107 | totalApks = len(apkInfoMap) 108 | decApkMap = {} 109 | for apkPath,apkInfo in apkInfoMap.items(): 110 | if printLog: 111 | print(apkPath) 112 | saveApkPath = determineSavePath(apkPath,saveTo) 113 | decMap = uzm_util.decryptAllResourcesInApkParallel(apkPath,saveApkPath,printLog,procPool=procPool,msgQueue=msgQueue) 114 | decApkMap[apkPath] = (saveApkPath,decMap) 115 | if printLog: 116 | print('\t=>{}'.format(saveApkPath)) 117 | print('\t{} files decrypted.'.format(len(decMap))) 118 | print('\n') 119 | return decApkMap 120 | 121 | ''' 122 | resourcePath 可以是apk的路径, 也可以apk所在的目录 123 | 如果是目录,则会扫描所有可能的apicloud apk,并进行信息的提取 124 | ''' 125 | def extractAPICloudApkInfos(resourcePath,printLog=False): 126 | if not os.path.isdir(resourcePath): 127 | _,apicloudInfo = extractAPICloudApkInfo(resourcePath,True) 128 | return {resourcePath:apicloudInfo} if apicloudInfo else {} 129 | 130 | msgQueue = multiprocessing.Manager().Queue(0) 131 | procPool = multiprocessing.Pool(processes=max(2, multiprocessing.cpu_count() ) ) 132 | 133 | apkInfoMap = _scanAPICloudApks(procPool,msgQueue,resourcePath,True,printLog=printLog) 134 | try: 135 | procPool.close() 136 | procPool.join() 137 | except:pass 138 | 139 | return apkInfoMap 140 | ''' 141 | resourcePath 可以是apk的路径, 也可以apk所在的目录 142 | 如果是目录,则会自动扫描并解密所有的apk, 解密后存放到 saveTo/apkName/ 143 | ''' 144 | def decryptAndExtractAPICloudApkResources(resourcePath,saveTo,printLog=False): 145 | if not os.path.isdir(resourcePath): 146 | print(determineSavePath(resourcePath,saveTo)) 147 | return {resourcePath:uzm_util.decryptAllResourcesInApkParallel(resourcePath,determineSavePath(resourcePath,saveTo),printLog)} 148 | 149 | msgQueue = multiprocessing.Manager().Queue(0) 150 | procPool = multiprocessing.Pool(processes=max(2, multiprocessing.cpu_count() ) ) 151 | 152 | startTime = time.time() 153 | apkInfoMap = _scanAPICloudApks(procPool,msgQueue,resourcePath,False,printLog=printLog) 154 | scanCost = time.time()-startTime 155 | if not apkInfoMap: 156 | if printLog: 157 | print('no apicloud apk found') 158 | return {} 159 | 160 | if printLog: 161 | print('{} seconds elapsed. {} apks found'.format(scanCost,len(apkInfoMap))) 162 | 163 | if len(apkInfoMap)<2: 164 | apkFile = list(apkInfoMap.keys())[0] 165 | decryptMap = {apkFile:(determineSavePath(apkFile,saveTo),uzm_util.decryptAllResourcesInApkParallel(apkFile,saveTo,printLog,procPool,msgQueue))} 166 | else: 167 | decryptMap = _decryptAPICloudApks(procPool,msgQueue,apkInfoMap,saveTo,printLog) 168 | 169 | try: 170 | procPool.close() 171 | procPool.join() 172 | except:pass 173 | 174 | return decryptMap -------------------------------------------------------------------------------- /uzm_util.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | #created by SamLee 2020/3/6 3 | 4 | import os 5 | import sys 6 | import math 7 | from elftools.elf.elffile import ELFFile 8 | import zipfile 9 | import tempfile 10 | import shutil 11 | import struct 12 | import entropy 13 | from concurrent.futures import ThreadPoolExecutor 14 | import threading 15 | from queue import Queue 16 | import time 17 | import multiprocessing 18 | import importlib 19 | import file_util 20 | 21 | import apk_util 22 | import dex_util 23 | ''' 24 | 文件使用rc4算法进行加密 rc4的key数据定义在rodata中 25 | 0:20*4 byte 数据映射的取值 26 | 27 | 208:208+33 apk的签名串 用于校验 28 | 208+33: 208+33+9*4 为key数据 分4段存储 需要合并处理 29 | 2020/4/26 30 | jni注册使用的类名字符串常量 "com/uzmap/pkg/uzcore/external/Enslecb" 31 | 这个字符串常量之前的 9byte 1段的4段数据 还有33 byte的apk签名串 32 | 之前固定位置的方式对有些不适用 33 | 34 | 得到key数据在利用 [0:20]byte的索引数组取出20byte的key值 35 | 2020/6/5 36 | 从原先的tools.py 迁移到 uzm_util.py 37 | ''' 38 | JNI_PACKAGE_BYTES = 'com/uzmap/pkg/uzcore/external/Enslecb'.encode('utf-8') 39 | 40 | # pycryptodome rc4 implementation 41 | CRYPTODOME_ARC4 = None 42 | try: 43 | CRYPTODOME_ARC4 = importlib.import_module('Crypto.Cipher.ARC4') 44 | except:pass 45 | 46 | # rc4 initial state for uz_version < 1.2.0 47 | mrc4_initial_states = [239, 157, 102, 150, 29, 86, 207, 230, 165, 46, 102, 181, 75, 90, 17, 62, 153, 44, 78, 204] 48 | 49 | hexBytesSet = set([ord(a) for a in '0123456789abcdefABCDEF']) 50 | def isHexStrBytes(targetBytes): 51 | global hexBytesSet 52 | for a in targetBytes: 53 | if a not in hexBytesSet: 54 | return False 55 | return True 56 | 57 | ''' 58 | hex区块由连续4个长度为9的字节构成 59 | 每块字节都是 8个 0-f 的字符 加上一个 0x00 字节结尾 60 | ''' 61 | def findLegalHexStrBlock(byteSource,endIdx): 62 | startIdx = endIdx - 9*4 63 | while startIdx>=0: 64 | foundMatch,unMatchSkip = True, 1 65 | for i in range(4): 66 | byteBlock = byteSource[startIdx+i*9:startIdx+i*9+9] 67 | if byteBlock[-1]!=0 or not isHexStrBytes(byteBlock[0:8]): 68 | foundMatch = False 69 | if i>0: 70 | unMatchSkip = (4-i)*9 71 | break 72 | if foundMatch: 73 | return startIdx, startIdx+9*4 74 | startIdx = startIdx - unMatchSkip 75 | return -1,-1 76 | 77 | # keyIdx的长度为0x14*4 每个idx对应的值的范围是 [0,0x20) 78 | #旧版本中有的keyIdx的位置略有变化 可以通过遍历尝试的方法来检测 79 | def findBestMatchKeyIdx(dataContent, keyStr, keyStartIdx, rawEncryptedContent, keyLen=0x14, littleEndian=True): 80 | if not rawEncryptedContent: 81 | return None 82 | dFmt = 'I' 83 | tKeyIdx,dIdx = [], 0 84 | rawEntropyValue = entropy.calculateEntropy(rawEncryptedContent) 85 | while dIdx0: 87 | tKeyIdx.pop(0) 88 | while dIdx0x20 or idxVal<0: 91 | tKeyIdx.clear() 92 | dIdx += 4 93 | break 94 | tKeyIdx.append(idxVal) 95 | dIdx += 4 96 | if len(tKeyIdx)==keyLen: 97 | encKey = ''.join([keyStr[idx] for idx in tKeyIdx]) 98 | decBytes = decrypt(rawEncryptedContent, encKey) 99 | entropyValue = entropy.calculateEntropy(decBytes) 100 | #print(tKeyIdx,len(decBytes), rawEntropyValue, '=>', entropyValue ) 101 | if entropyValue<0.7: 102 | return tKeyIdx 103 | return None 104 | 105 | digitLetters = set([ord(a) for a in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ']) 106 | def extractAsciiStrings(dataContent): 107 | global digitLetters 108 | lStrings,strStart = [], 0 109 | for i in range(len(dataContent)): 110 | a = dataContent[i] 111 | if a == 0: 112 | if strStart1: 136 | print('found {} possible candidates'.format(len(candidates))) 137 | return candidates[0] if len(candidates)==1 else None 138 | 139 | # encContentSample for later decrypt to check legality of keyStr 140 | def extractRC4Key(soFile, encContentSample=None): 141 | global JNI_PACKAGE_BYTES 142 | keyStr,keyIdx = None,None 143 | if isinstance(soFile,str): 144 | soFile = open(soFile,'rb') if os.path.exists(soFile) else None 145 | 146 | with soFile as f: 147 | elffile = ELFFile(f) 148 | littleEndian = elffile.little_endian 149 | dataSection,dataContent = elffile.get_section_by_name('.rodata'),None 150 | if dataSection: 151 | dataContent = dataSection.data() 152 | if dataContent and dataContent.find(JNI_PACKAGE_BYTES)>=80+9*4: 153 | pkgIdx = dataContent.find(JNI_PACKAGE_BYTES) 154 | #little endian bytes 155 | blockStart,blockEnd = findLegalHexStrBlock(dataContent,pkgIdx) 156 | if blockStart>-1 and blockEnd>-1: 157 | keyStr = dataContent[blockStart:blockEnd].replace(b'\x00',b'').decode('utf-8') 158 | if blockEnd == pkgIdx: 159 | dFmt = 'I' 160 | keyIdx = [struct.unpack(dFmt, dataContent[i:i+4])[0] for i in range(0,20*4,4)] 161 | else: #旧版本的没有libsec中位置略有变化 162 | keyIdx = findBestMatchKeyIdx(dataContent,keyStr, blockStart, encContentSample ,littleEndian=littleEndian) 163 | 164 | return ''.join([keyStr[idx] for idx in keyIdx]) if keyStr else None 165 | 166 | ''' 167 | #sample data for rc4 key data source (not the rc4 key itself) 168 | preKeyIdx = [0x13,0x6,0x1f,0xa,0x8,0x12,0x3,0x16,0xb,0x0,0x12,0xc,0x19,0x6,0x12,0x9,0xe,0x2,0x17,0x1a] 169 | rawKeyData = '988f520873542ac4a8df3cbfa8937024' 170 | ''' 171 | 172 | def getPreKey(rawKey,keyIdxArr): 173 | return ''.join([rawKey[idx] for idx in keyIdxArr]) 174 | 175 | def computeRC4KeyState(rc4Key,initialState=None): 176 | preKey = rc4Key 177 | if rc4Key is None or isinstance(rc4Key,tuple): 178 | preKey = getPreKey(rc4Key[0] if rc4Key else None,rc4Key[1] if rc4Key else None) 179 | stateSize = len(initialState) if initialState else 256 180 | keyLen = len(preKey) 181 | blockA = [ord(preKey[i%keyLen]) for i in range(stateSize)] 182 | blockB = [a for a in initialState] if initialState else [i for i in range(stateSize)] 183 | #blockA = [ord(a) for a in ( preKey*(math.ceil(256/len(preKey))) )[0:256]] 184 | #blockB = [i for i in range(256)] 185 | si = 0 186 | for i in range(stateSize): 187 | si = (si + blockA[i] + blockB[i]) % stateSize 188 | blockB[i], blockB[si] = blockB[si], blockB[i] 189 | return blockB 190 | 191 | def decrypt(dataBytes,rc4Key, initialState=None): 192 | global CRYPTODOME_ARC4 193 | if not initialState and CRYPTODOME_ARC4: 194 | rc4 = CRYPTODOME_ARC4.new(rc4Key.encode('utf-8') if isinstance(rc4Key,type(' ')) else rc4Key) 195 | return rc4.decrypt(dataBytes) 196 | isBytes,isByteArray = isinstance(dataBytes,bytes),isinstance(dataBytes,bytearray) 197 | decDataBytes = [] 198 | keyState = computeRC4KeyState(rc4Key, initialState=initialState) 199 | stateSize = len(keyState) 200 | R3,R4 = 0, 0 201 | decDataBytes = [0]*len(dataBytes) 202 | for i in range(len(dataBytes)): 203 | R3 = (R3 + 1) % stateSize 204 | R4 = (R4 + keyState[R3]) % stateSize 205 | keyState[R3], keyState[R4] = keyState[R4], keyState[R3] 206 | sIdx = (keyState[R3] + (keyState[R4] % stateSize)) % stateSize 207 | decDataBytes[i] = (dataBytes[i] ^ keyState[sIdx]) & 0xFF 208 | 209 | return bytes(bytearray(decDataBytes)) if isBytes else bytearray(decDataBytes) if isByteArray else decDataBytes 210 | 211 | 212 | ''' 213 | 只有 js html css config.xml key.xml 进行了加密 其他文件没有 不需要解密 214 | ''' 215 | enc_exts = ['js','html','css'] 216 | def needDecryptFile(fileName): 217 | global enc_exts 218 | extIdx = fileName.rfind('.') 219 | ext = fileName[extIdx+1:] if extIdx>-1 else None 220 | return ext in enc_exts or 'config.xml' in fileName or 'key.xml' in fileName 221 | 222 | def decryptSingleFile(targetFile,rc4Key,saveTo=None): 223 | if not os.path.exists(targetFile): 224 | return None 225 | if not needDecryptFile(targetFile): 226 | return None 227 | decContent = None 228 | with open(targetFile,'rb') as f: 229 | decContent = decrypt(f.read(),rc4Key) 230 | 231 | if saveTo: 232 | with open(saveTo,'wb') as f: 233 | f.write(decContent) 234 | return decContent 235 | 236 | def decryptResourceFiles(folder): 237 | if not os.path.exists(folder): 238 | return 239 | 240 | targetFiles = [] 241 | if os.path.isdir(folder): 242 | for root, dirs, files in os.walk(folder): 243 | targetFiles.extend(['{}/{}'.format(root,f) for f in files]) 244 | else: 245 | targetFiles.append(folder) 246 | 247 | if targetFiles: 248 | for tFile in targetFiles: 249 | extIdx = tFile.rfind('.') 250 | saveTo = '{}_decrypted.{}'.format(tFile[0:extIdx],tFile[extIdx+1:]) if extIdx>-1 else '{}_decrypted'.format(tFile) 251 | if os.path.exists(saveTo): 252 | continue 253 | decryptResult = decryptSingleFile(tFile,saveTo) 254 | if not decryptResult: 255 | continue 256 | print('decrypt:{} => {}'.format(tFile,saveTo)) 257 | 258 | #python3.7.0 zipfile '_SharedFile'.seek calls 'writing' method instead of '_writing' 259 | def isBuggyZipfile(): 260 | return sys.version_info.major==3 and sys.version_info.minor==7 and sys.version_info.micro<1 261 | 262 | 263 | class SeekableZipContent: 264 | def __init__(self, zipContent, zipPath=None): 265 | self.contentTmp = None 266 | self.zipContent = zipContent 267 | self.zipPath = zipPath 268 | 269 | def __enter__(self): 270 | if not self.zipContent.seekable() or isBuggyZipfile(): 271 | tmpDir = os.path.dirname(os.path.abspath(self.zipPath)) if self.zipPath else os.getcwd() 272 | self.contentTmp = tempfile.mkstemp('.tmp', 'tmp', tmpDir) 273 | with open(self.contentTmp[1],'wb') as contentTmpC: 274 | shutil.copyfileobj(self.zipContent, contentTmpC) 275 | self.zipContent.close() 276 | self.zipContent = open(self.contentTmp[1],'rb') 277 | return self.zipContent 278 | 279 | def __exit__(self, exc_type, exc_val, exc_tb): 280 | try: 281 | self.zipContent.close() 282 | except: 283 | traceback.print_exc() 284 | if self.contentTmp: 285 | os.close(self.contentTmp[0]) 286 | os.remove(self.contentTmp[1]) 287 | 288 | 289 | def isOlderVersion(apkFilePath): 290 | apicloudInfo = None 291 | try: 292 | apicloudInfo = apk_util.extractAPICloudInfo(apkFilePath) 293 | except: 294 | print('error while extracting apk info from {}'.format(apkFilePath)) 295 | traceback.print_exc() 296 | # for older version 1.2.0 297 | if not apicloudInfo or apk_util.APICLOUD_MANIFEST_APPVERSION not in apicloudInfo: 298 | return True 299 | versionStr = apicloudInfo[apk_util.APICLOUD_MANIFEST_APPVERSION] 300 | vParts = [int(a) for a in versionStr.split('.')] 301 | return not ((vParts[0]==1 and vParts[1]>=2) or vParts[0]>1) 302 | 303 | def extractRC4KeyForOlderVersionFromApk(apkFilePath): 304 | with zipfile.ZipFile(apkFilePath) as apkFile: 305 | apkResList = apkFile.namelist() 306 | soFiles, dexFiles = [], [] 307 | for fname in apkResList: 308 | if fname.startswith('lib/') and fname.endswith('libsec.so'): 309 | with apkFile.open(fname) as soContent: 310 | elfHeader = soContent.read(6) 311 | #check elffile format(https://en.wikipedia.org/wiki/Executable_and_Linkable_Format) 312 | if elfHeader[1]==ord('E') and elfHeader[2]==ord('L') and elfHeader[3]==ord('F'): 313 | soFiles.append(fname) 314 | if fname.endswith('.dex'): 315 | dexFiles.append(fname) 316 | if not soFiles or not dexFiles: 317 | return None 318 | ocKey, cloudKey = None, None 319 | for dexName in dexFiles: 320 | with apkFile.open(dexName,'r') as dexContent: 321 | dex = dex_util.Dex(dexContent.read()) 322 | fInfo, fValue = dex.getClassStaticFieldAndValue('Lcompile/Properties;', 'CLOUD_KEY') 323 | if fValue: 324 | cloudKey = fValue[1] 325 | break 326 | 327 | for soFile in soFiles: 328 | with apkFile.open(soFile,'r') as soContent: 329 | with SeekableZipContent(soContent, apkFilePath) as seekableSo: 330 | elffile = ELFFile(seekableSo) 331 | dataSection,dataContent = elffile.get_section_by_name('.rodata'),None 332 | if dataSection: 333 | dataContent = dataSection.data() 334 | if dataContent: 335 | ocKey = findEnslecbocKey(dataContent) 336 | if ocKey: 337 | break 338 | 339 | if ocKey and cloudKey: 340 | tCloudKey = cloudKey if len(cloudKey)==10 else ''.join([cloudKey[i:i+2] for i in range(0, len(cloudKey), 4)]) 341 | return ocKey + tCloudKey 342 | 343 | return None 344 | 345 | def extractRC4KeyFromApk(apkFilePath): 346 | if not os.path.exists(apkFilePath): 347 | print('{} does not exists'.format(apkFilePath)) 348 | return None 349 | if isOlderVersion(apkFilePath): 350 | return extractRC4KeyForOlderVersionFromApk(apkFilePath) 351 | 352 | with zipfile.ZipFile(apkFilePath) as apkFile: 353 | apkResList = apkFile.namelist() 354 | soFiles = [] 355 | for fname in apkResList: 356 | if fname.startswith('lib/') and fname.endswith('libsec.so'): 357 | with apkFile.open(fname) as soContent: 358 | elfHeader = soContent.read(6) 359 | #check elffile format(https://en.wikipedia.org/wiki/Executable_and_Linkable_Format) 360 | if elfHeader[1]==ord('E') and elfHeader[2]==ord('L') and elfHeader[3]==ord('F'): 361 | soFiles.append(fname) 362 | if not soFiles: 363 | print('libsec.so file not exists in apk file') 364 | return None 365 | for soFile in soFiles: 366 | with apkFile.open(soFile,'r') as soContent: 367 | with SeekableZipContent(soContent, apkFilePath) as seekableSo: 368 | encSampleBytes = None 369 | if isResourceEncrypted(apkFilePath): 370 | minAssetName, maxAssetName = findSmallestAndBiggestEncryptedAsset(apkFilePath) 371 | if minAssetName: 372 | with apkFile.open(minAssetName,'r') as encAsset: 373 | encSampleBytes = encAsset.read() 374 | rc4Key = extractRC4Key(seekableSo, encContentSample=encSampleBytes) 375 | if rc4Key: 376 | return rc4Key 377 | return None 378 | 379 | def iterateAllNeedDecryptAssets(apkFilePath): 380 | if not os.path.exists(apkFilePath): 381 | print('{} does not exists'.format(apkFilePath)) 382 | return 383 | with zipfile.ZipFile(apkFilePath) as apkFile: 384 | apkResList = apkFile.namelist() 385 | for resName in apkResList: 386 | if not (resName.startswith('assets/widget/') and needDecryptFile(resName)): 387 | continue 388 | yield resName,apkFile.open(resName) 389 | 390 | def findSmallestAndBiggestEncryptedAsset(apkFilePath): 391 | if not os.path.exists(apkFilePath): 392 | print('{} does not exists'.format(apkFilePath)) 393 | return None, None 394 | minSize, maxSize = 1<<32, -1 395 | minInfoName, maxInfoName = None, None 396 | with zipfile.ZipFile(apkFilePath) as apkFile: 397 | for zInfo in apkFile.infolist(): 398 | # must ignore empty content 399 | if zInfo.file_size<1: 400 | continue 401 | if not (zInfo.filename.startswith('assets/widget/') and needDecryptFile(zInfo.filename)): 402 | continue 403 | if zInfo.file_sizemaxSize: 407 | maxSize = zInfo.file_size 408 | maxInfoName = zInfo.filename 409 | return minInfoName, maxInfoName 410 | 411 | 412 | def isResourceEncrypted(apkFilePath): 413 | ''' 414 | 可以通过判断 apk 中的类 compile.Properties.smode 的值 : true表示有加密 false表示未加密 415 | 但目前没办法直接通过解析 apk的字节码来判断对应类方法的返回值,所以先简单的从 assets/widget/config.xml 文件进行判断 416 | app第一个需要解密的文件是config.xml,如果这个文件没有加密 则说明其它文件也一样没有加密 反之亦然 417 | ''' 418 | if not os.path.exists(apkFilePath): 419 | print('{} does not exists'.format(apkFilePath)) 420 | return False 421 | confFile = 'assets/widget/config.xml' 422 | rawXmlFileHead = '=0.9 441 | 442 | def decryptAllResourcesInApk(apkFilePath,saveTo=None,printLog=False): 443 | global mrc4_initial_states 444 | resEncrypted, rc4Key, olderVersion = isResourceEncrypted(apkFilePath), None, False 445 | if resEncrypted: 446 | olderVersion = isOlderVersion(apkFilePath) 447 | rc4Key = extractRC4KeyFromApk(apkFilePath) 448 | if not rc4Key: 449 | if printLog: 450 | print('fail to extract rc4 key') 451 | return None 452 | allAssets = iterateAllNeedDecryptAssets(apkFilePath) 453 | decryptMap = {} 454 | if allAssets: 455 | initialState = mrc4_initial_states if olderVersion else None 456 | storeFolder = os.path.dirname(os.path.abspath(apkFilePath)) 457 | saveTo = saveTo.strip() 458 | if saveTo : 459 | if not os.path.exists(saveTo): 460 | os.makedirs(saveTo) 461 | storeFolder = saveTo 462 | 463 | if storeFolder.endswith('/') or storeFolder.endswith('\\'): 464 | storeFolder = storeFolder[0:-1] 465 | 466 | while True: 467 | assetFile = next(allAssets,None) 468 | if not assetFile: 469 | break 470 | fName,fileContent = assetFile 471 | rawContent = fileContent.read() 472 | decContent = decrypt(rawContent,rc4Key=rc4Key, initialState=initialState) if resEncrypted and isVeryLikelyEncrypted(rawContent) else rawContent # 473 | fileContent.close() 474 | resDecrypted = file_util.legimateFileName('{}/{}'.format(storeFolder,fName)) 475 | decryptMap[fName] = resDecrypted 476 | file_util.createDirectoryIfNotExist(resDecrypted) 477 | with open(resDecrypted,'wb') as f: 478 | f.write(decContent) 479 | if printLog: 480 | sys.stdout.write('decrypt {}\r'.format(fName)) 481 | sys.stdout.flush() 482 | if printLog: 483 | print() 484 | 485 | return decryptMap 486 | 487 | def _decryptHandle(fName,rawContent,rc4Key, olderVersion, resEncrypted,msgQueue): 488 | global mrc4_initial_states 489 | initialState = mrc4_initial_states if olderVersion else None 490 | decContent = decrypt(rawContent,rc4Key, initialState=initialState) if resEncrypted and isVeryLikelyEncrypted(rawContent) else rawContent 491 | msgQueue.put_nowait((fName,decContent)) 492 | 493 | def decryptAllResourcesInApkParallel(apkFilePath,saveTo=None,printLog=False,procPool=None,msgQueue=None): 494 | resEncrypted, rc4Key, olderVersion = isResourceEncrypted(apkFilePath), None, False 495 | if resEncrypted: 496 | rc4Key = extractRC4KeyFromApk(apkFilePath) 497 | olderVersion = isOlderVersion(apkFilePath) 498 | if not rc4Key: 499 | if printLog: 500 | print('fail to extract rc4 key') 501 | return None 502 | #print('decryptAllResourcesInApkParallel',apkFilePath,resEncrypted,rc4Key) 503 | allAssets = iterateAllNeedDecryptAssets(apkFilePath) 504 | decryptMap = {} 505 | if allAssets: 506 | storeFolder = os.path.dirname(os.path.abspath(apkFilePath)) 507 | saveTo = saveTo.strip() 508 | if saveTo : 509 | if not os.path.exists(saveTo): 510 | os.makedirs(saveTo) 511 | storeFolder = saveTo 512 | 513 | if storeFolder.endswith('/') or storeFolder.endswith('\\'): 514 | storeFolder = storeFolder[0:-1] 515 | if not procPool: 516 | procPool = multiprocessing.Pool(processes=max(2, multiprocessing.cpu_count() ) ) 517 | if not msgQueue: 518 | msgQueue = multiprocessing.Manager().Queue(0) 519 | def subHandle(allAssets,rc4Key, olderVersion, resEncrypted, procPool,msgQueue,globalStates): 520 | while True: 521 | assetFile = next(allAssets,None) 522 | if not assetFile: 523 | break 524 | fName,fileContent = assetFile 525 | rawContent = fileContent.read() 526 | fileContent.close() 527 | if resEncrypted: 528 | procPool.apply_async(_decryptHandle,args=(fName,rawContent,rc4Key, olderVersion, resEncrypted,msgQueue)) 529 | else: 530 | msgQueue.put_nowait((fName,rawContent)) 531 | globalStates['submittedFiles'] += 1 532 | globalStates['submitCompleted'] = True 533 | 534 | globalStates = {'submittedFiles':0,'processedFiles':0,'submitCompleted':False} 535 | subTh = threading.Thread(target=subHandle,args=(allAssets, rc4Key, olderVersion, resEncrypted,procPool,msgQueue,globalStates)) 536 | subTh.start() 537 | 538 | while True: 539 | if globalStates['submitCompleted'] and globalStates['processedFiles']>=globalStates['submittedFiles']: 540 | break 541 | if msgQueue.empty(): 542 | time.sleep(0.01) 543 | continue 544 | fName,decContent = msgQueue.get_nowait() 545 | globalStates['processedFiles'] += 1 546 | msgQueue.task_done() 547 | resDecrypted = file_util.legimateFileName('{}/{}'.format(storeFolder,fName)) 548 | decryptMap[fName] = resDecrypted 549 | file_util.createDirectoryIfNotExist(resDecrypted) 550 | with open(resDecrypted,'wb') as f: 551 | f.write(decContent) 552 | if printLog: 553 | #sys.stdout.write('\r{}'.format(' '*96)) 554 | #sys.stdout.flush() 555 | sys.stdout.write('{}/{} decrypt {}\r'.format(globalStates['processedFiles'],globalStates['submittedFiles'],fName)) 556 | sys.stdout.flush() 557 | if printLog: 558 | print('completed') 559 | 560 | return decryptMap 561 | --------------------------------------------------------------------------------