├── .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)
3 | [](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 |
--------------------------------------------------------------------------------