1 简介
《使用和开发桌面多功能Apk工具》这篇文章你还有印象吗,文章地址:这是作者阅读次数最多的文章。 现在这个工具已经升级了很多次,移植给我们了,但可惜的是它和公司APP端的业务绑定紧密,所以最终不适合开源。
但这一次,我使用并开发了一个桌面APK逆向工具。 里面使用的很多工具都是从以前的桌面工具复制过来的。 原理是一样的,代码已经开源:(目前基础部分已经搭建完成,其余功能会逐步完善,如果有什么想法请告诉我)
2、小情绪
距离上次写文章已经过去不到半年的时间了。 年初之后,我一直在应用程序删除和重新启动之间来回切换,在隐私合规和侵犯之间来回切换。 实在是太累了。 好在我已经基本搞清楚了国内各大应用商店的相关规则,以及马甲袋判断的相关原则。
马甲包,情商高的应该叫“APP矩阵”,像我这样情商低的就叫马甲包。 为什么要做背心袋? 说实话,这类社交直播应用一不小心就有可能被屏蔽。 虽然各种色情、风控等基础设施都有,但路高一尺,魔高一丈。 一只脚,总会有漏网之鱼。 加之政策收紧,多应用布局已成必然。
那么这种情况下有两种选择:
第一种方法费时、费力、成本高。 除非是新项目,大多数公司绝不会选择这么做。 第二种方法简单粗暴,快速高效,是大多数人的首选,但其缺点也很明显。 代码、资源等文件重复度高,很容易被判断为类似应用,从而被限制(OPPO)或者拒绝put(),所以我们需要一个策略来应对。
上面说了这么多,你一定能猜到我后续的新文章应该往哪个方向发展,不过也不用担心一口气吃胖。 让我们保持简单。 我们先从APK文件的反面开始理解。 逆向工程所需的工具,然后我们将其集成到我们自己的桌面应用程序中。 这个桌面应用程序的目标是能够“解码APK”文件,修改解码后的文件,比如修改应用程序名称、应用程序图标等简单的东西,最后“重新打包”成一个新的APK文件,“对齐” ,重新签名”。
3. 逆向工程工具介绍 3.1.
地址:
网站地址:
这是我目前最常用的工具。 它可以将APK文件解码为资源文件和代码等。我们可以编辑解码后的布局文件、图片、字符串等资源小程序开发横向布局,甚至可以修改代码。 修改后,这些文件也可以重新打包成新的APK文件。
常用命令如下:
# 解码APK文件,可以使用decode命令
apktool decode old.apk -o outputDir
# 将解码后的文件构建为一个新的APK文件,可以使用build命令
apktool build outputDir -o new.apk
**注意:** 重建的APK文件已经丢失了签名信息,如果使用需要对齐签名。 目前我们应用后期的一些定制功能基本都会用到他。
3.2. 贾德克斯
地址:
相比之下,Jadx可以直接将APK、Dex文件等反编译成Java源代码。 它还提供了图形界面、命令行功能以及相关的依赖库。
本例中我们直接集成了仓库中Jadx提供的依赖。 具体方法也有说明。 如有需要,请参考:
// jadx依赖相关
commonMainImplementation("io.github.skylot:jadx-core:1.4.7")
commonMainImplementation("io.github.skylot:jadx-dex-input:1.4.7")
反编译Dex文件相关代码如下:
/**
* 使用Jadx将dex文件反编译为java源文件
*/
private fun dexFile2java(
dexFile: File,
outDirPath: String
) {
val jadxArgs = JadxArgs()
jadxArgs.setInputFile(dexFile)
jadxArgs.outDir = File(outDirPath)
try {
JadxDecompiler(jadxArgs).use { jadx ->
jadx.load()
jadx.save()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
3.3. 其他工具
实际应用中我用的最多的就是以上两个,还有其他一些小工具也不错,推荐给大家。
3.3.1. 将Dex文件反编译成Jar文件
地址:(已2年未更新)
顾名思义,一个将Dex文件转换为Jar文件的工具。 有一个缺点。 当dex文件比较大时,反编译经常会卡住。
下载后是一个压缩包。 解压后,win系统上有.bat可执行文件,unix系统上有.sh可执行文件。 以mac上的使用为例:
# 将classes.dex文件反编译为output.jar
./d2j-dex2jar.sh -o output.jar classes.dex
3.3.2. 将Jar文件反编译为Java文件
地址:(4年未更新)
网站首页:
将Jar文件反编译为Java文件的工具包括命令行工具JD-Core、图形化页面JD-GUI以及依赖库。
地址:
根据大多数逆向工程师的比较,这个工具比较好用,常用命令如下(换成jadx之前我也用过这个工具):
# 反编译input.jar文件到outputDir文件夹中
java -jar procyon-decompiler.jar -o outputDir input.jar
地址:
IDEA自带的反编译工具也相当不错。 但需要自己使用编译好的可执行jar文件。 常用命令如下:
# 反编译input.jar文件到outputDir文件夹中
java -jar fernflower.jar input.jar outputDir
病死率
地址:
网站首页:
我自己没有使用过这个工具,所以这里就不过多介绍了。 有兴趣的朋友可以自己去看看。
4.桌面逆向APK应用开发
接下来我们需要用它来开发桌面APK逆向工具,需要集成上面提供的可执行jar文件以及jadx的仓库依赖。
4.1. 文件拖放
首先我们需要支持将APK文件拖拽到我们的工具面板中,然后自动执行后续流程。 之前的文章也写过,但是文章发表后发现了一个bug。 添加该功能后,调整应用程序窗口大小的功能失效。 但随后@读者反馈并给出了解决方案:
@Composable
fun DropHerePanel(
modifier: Modifier,
composeWindow: ComposeWindow,
onFileDrop: (List<File>) -> Unit
) {
val component = remember {
ComposePanel().apply {
val target = object : DropTarget() {
override fun drop(event: DropTargetDropEvent) {
event.acceptDrop(DnDConstants.ACTION_REFERENCE)
val dataFlavors = event.transferable.transferDataFlavors
dataFlavors.forEach {
if (it == DataFlavor.javaFileListFlavor) {
val list = event.transferable.getTransferData(it) as List<*>
list.map { filePath ->
File(filePath.toString())
}.also(onFileDrop)
}
}
event.dropComplete(true)
}
}
dropTarget = target
isOpaque = false
}
}
val pane = remember {
composeWindow.rootPane
}
Box(
modifier = modifier
.onPlaced {
val x = it.positionInWindow().x.roundToInt()
val y = it.positionInWindow().y.roundToInt()
val width = it.size.width
val height = it.size.height
component.setBounds(x, y, width, height)
},
contentAlignment = Alignment.Center
) {
Text(text = "请拖拽文件到这里哦", fontSize = 36.sp, color = textColor)
DisposableEffect(true) {
pane.add(component)
onDispose {
pane.remove(component)
}
}
}
}
4.2. 结构工程目录
上一节我们实现了文件拖拽功能。 当APK文件拖到当前项目面板时,我们首先使用工具解析APK文件,获取“包名_版本号”等基本信息后构造项目根目录。 然后将解码后的APK文件复制到该文件夹中。 解码后,我们使用jadx将解码后的文件反编译为java源文件并存放在文件夹中。 如果我们需要查看dex文件,我们可以将apk文件解压到一个文件夹中,这样我们就创建了一个基本的项目目录,然后构建目录树来显示。 我们可以使用 file.walk 来构建目录树。 ()来遍历然后存储对应的信息。
为了更专注的查看我们需要的文件,我们还添加了项目目录类型切换功能,与IDEA类似,切换到模式时,java目录下会显示java源文件,资源文件会在res目录下显示出来,文件目录也会显示出来,方便大家比较java和文件:
当然,当显示项目结构树,或者选择源代码文件时,右侧代码编辑区域的长度或宽度会超出应用程序大小,如下图所示,所以需要支持水平滚动和垂直滚动功能:
这根本不是问题,我们可以直接封装一个简单的函数,如下:
/**
* 带有滚动条的面板
* 支持横向滚动条,竖向滚动条
*/
@Composable
fun ScrollPanel(
modifier: Modifier,
verticalScrollStateAdapter: ScrollbarAdapter,
horizontalScrollStateAdapter: ScrollbarAdapter,
content: @Composable BoxScope.() -> Unit
) {
Row(modifier = modifier) {
Column(modifier = Modifier.fillMaxHeight().weight(1f)) {
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
content()
}
HorizontalScrollbar(
adapter = horizontalScrollStateAdapter,
style = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.White.copy(alpha = 0.20f),
hoverColor = Color.White.copy(alpha = 0.50f)
),
modifier = Modifier.fillMaxWidth().background(color = Color.Transparent)
)
}
VerticalScrollbar(
adapter = verticalScrollStateAdapter,
style = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.White.copy(alpha = 0.20f),
hoverColor = Color.White.copy(alpha = 0.50f)
),
modifier = Modifier.fillMaxHeight().background(color = Color.Transparent)
)
}
}
4.3. 文件选项卡页
在做这个标签页功能的时候,涉及到一个知识点【固有特性测量】。 请参阅内容介绍。 如下所示,当标题栏的长度不同时,我们需要根据标题的具体长度来确定下面的指示器的长度。
此时,我们可以利用固有的特征测量来实现这种效果,在最外层使用.( = .Max)修饰符来指定根据子项的最大宽度进行测量,子项的宽度为自适应,并且子项指示器(Box)的宽度是最大固有宽度。 这样,当文本的内容长度发生变化时,指示器的宽度也会随之变化。 示例代码如下:
val textValue = remember {
mutableStateOf("hello, this is intrinsic measurements sample.")
}
Column(
modifier = Modifier.width(intrinsicSize = IntrinsicSize.Max)
.padding(16.dp)
) {
TextField(
value = textValue.value,
onValueChange = {
textValue.value = it
},
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.clip(shape = RoundedCornerShape(50))
.background(color = Color(0xFF3674f0))
)
}
效果如下:

4.4. 关键词高亮
本来,在显示java和文件字符串之后,我打算做下一个功能。 然而代码显示出来后,总感觉少了点什么。 和真实的IDE对比后发现缺少关键字高亮功能。 目前仅针对Java关键字实现高亮效果,如下:
java方面,有50多个关键字。 不可能匹配超过50次再找出关键字的索引位置,这样效率太低了。 那么这里介绍一种高效的多模式匹配算法——[Aho-],只需一次遍历即可匹配所有关键字(字符串)。 至于算法原理请自行学习。 这里我们直接依赖java中的Aho-算法库来满足我们的需求。 目前使用的依赖库及版本如下:
commonMainImplementation("com.hankcs:aho-corasick-double-array-trie:1.2.2")
当所有关键字都匹配时,为了实现丰富的文本样式、字体、颜色、下划线等效果,可以通过“文本”中的“”进行处理,可以通过设置“文本”中的参数“”来实现”。 用于控制输入文本的视觉风格变换,可用于实现各种文本变换效果,如隐藏密码、格式化文本等。它只影响输入文本的视觉风格效果,并不影响输入文本的视觉风格效果。影响实际的数据模型。 代码如下所示:
TextField(
value = textContent,
// ...省略了其他参数设置
onValueChange = {},
visualTransformation = if (textType == "java") {
JavaKeywordVisualTransformation
} else {
VisualTransformation.None
}
)
实现Java关键字高亮的类:
/**
* java关键字高亮显示
*/
object JavaKeywordVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val textString = text.text
return TransformedText(toAnnotatedString(textString), OffsetMapping.Identity)
}
}
/**
* String转换为AnnotatedString
*/
private fun toAnnotatedString(string: String): AnnotatedString {
val keywordColor = Color(0xFFCF8E6D)
val treeMap = TreeMap()
keywordList.forEach {
treeMap[it] = it
}
// 构建 Aho-Corasick 自动机
val acdat = AhoCorasickDoubleArrayTrie()
acdat.build(treeMap)
val stringLength = string.length
return buildAnnotatedString {
append(string)
val hitList = acdat.parseText(string)
hitList.forEach { hit: AhoCorasickDoubleArrayTrie.Hit ->
val start = hit.begin
val end = hit.end
// 如果后一个字符不是空格,证明不是一个单独的单词,则跳过
if (end < stringLength) {
val char = string[end]
if (!char.isWhitespace()) {
return@forEach
}
}
addStyle(
style = SpanStyle(color = keywordColor),
start = start,
end = end
)
}
}
}
4.5. 本地图像显示
为了显示APK解码后的图片资源,我们需要从本机加载图片资源,所以下面的方法不适用:
Image(
painter = painterResource(项目resources文件夹中的图片文件,支持png、svg等格式),
contentDescription = "",
)
可以使用()代替,示例如下:
// 本地图片文件
val imageFile = File(filePath)
// 加载本地图片
Image(
bitmap = loadImageBitmap(imageFile.inputStream()),
contentDescription = ""
)
4.6. APK解码后修改资源
以这样的场景为例,我们需要对一个APP进行抓包,但是抓包.0及以上的数据包就会出现问题,并且无法轻易抓到请求的明文数据。 这是因为7.0中有一个名为“ ”的安全功能,这里不再赘述。 我们的目的是在解码后的res/xml目录下添加一个.xml文件,内容大致如下:
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" overridePins="true" />
<certificates src="user" overridePins="true" />
trust-anchors>
base-config>
network-security-config>
那么就需要在文件中将“”节点下的“:g”属性添加或者修改为“@xml/”,这样才能让我们使用用户目录下的证书来达到抓包的目的。
我们使用创建和修改XML文件的功能来处理,首先添加依赖:
// xml文件解析
commonMainImplementation("org.dom4j:dom4j:2.1.3")
生成.xml文件的代码如下:
fun createNetWorkSecurityConfigXml(
outputFilePath: String
) {
val document = DocumentHelper.createDocument()
val networkSecurityConfigElement = document.addElement("network-security-config")
val baseConfigElement = networkSecurityConfigElement.addElement("base-config")
baseConfigElement.addAttribute("cleartextTrafficPermitted", "true")
val trustAnchorsElement = baseConfigElement.addElement("trust-anchors")
trustAnchorsElement.addElement("certificates")
.addAttribute("src", "system")
.addAttribute("overridePins", "true")
trustAnchorsElement.addElement("certificates")
.addAttribute("src", "user")
.addAttribute("overridePins", "true")
var xmlWriter: XMLWriter? = null
var outputStream: FileOutputStream? = null
try {
val litterFile = File(outputFilePath)
outputStream = FileOutputStream(litterFile)
val outputFormat = OutputFormat.createPrettyPrint()
outputFormat.encoding = "UTF-8"
xmlWriter = XMLWriter(outputStream, outputFormat)
xmlWriter.write(document)
} catch (e: Throwable) {
e.printStackTrace()
} finally {
xmlWriter?.close()
outputStream?.close()
}
}
修改列表文件的功能就留给大家想象了。 无非就是解析xml文件,找到节点,判断是否有:g属性。
4.7. 重新打包 APK 并对齐和签名
我再解释一下:首先,大部分功能都是基于jar文件或者exe文件(上图)或者其他可执行文件(Mac上),那么我们可以在Java/中通过如下方式调用这些外部程序,exec就是其实最终的调用,总体原理是这样的:
//方式1
Runtime.getRuntime().exec(cmd)
//方式2
ProcessBuilder(cmd)
这里涉及到的是打包的相关知识,需要用到sdk/-下的相关工具,比如.jar等工具。 有一点需要注意的是,官方的解释如下:
❝
:您必须在 中使用 at a 。 在您使用的应用程序工具上:
如果使用,必须是已经使用过的APK文件。 如果您签署您的 APK 并制作该 APK,则其为 。如果您使用(不),则必须使用已使用的 APK 文件。
❞
因为我们使用.jar来对APK文件进行签名,所以在签名之前我们需要用它来进行对齐。 基本命令如下(这里以Mac为例):
zipalign -p -f -v 4 对齐前的APK路径 对齐后的APK路径
使用()对齐APK文件的方法如下。 ()方法将在下一节中介绍:
fun alignApk(
srcApkPath: String,
outputApkPath: String
): Boolean {
var isAlignSuccess = false
val result = runCMD(
localZipAlignPath(), // 拷贝到本地的zipalign文件,区分系统、架构
"-p",
"-f",
"-v",
"4",
srcApkPath,
outputApkPath,
onLine = {
logger("Align APK : $it")
if (it.contains("Verification succesful")) {
isAlignSuccess = true
}
}
)
return result == 0 && isAlignSuccess
}
对齐后,用.jar签名的过程是一样的。 签名命令如下:
apksigner sign --verbose --ks 签名文件路径 --ks-pass pass:${签名文件密码} --ks-key-alias 签名别名 --key-pass pass:${签名密码} --out 签名后的APK路径 签名前的APK路径
使用()方法执行代码如下:
fun signApk(
alignedApkPath: String,
outputApkPath: String,
keyStorePath:String,
keyStorePassword:String,
keyAlias:String,
keyPassword:String
): Boolean {
var isSignSuccess = false
val result = runCMD(
"java",
"-jar",
localApkSignerJarPath(), // 拷贝到本地的apksigner.jar文件
"sign",
"--verbose",
"--ks",
keyStorePath,
"--ks-pass",
"pass:${keyStorePassword}",
"--ks-key-alias",
keyAlias,
"--key-pass",
"pass:${keyPassword}",
"--out",
outputApkPath,
alignedApkPath,
onLine = {
logger("Sign APK : $it")
if (it.contains("Signed")) {
isSignSuccess = true
}
}
)
return result == 0 && isSignSuccess
}
注意:为什么需要复制本地的相关文件,上一篇文章提到,打包后这些资源会被打包成jar文件,不能直接执行,所以需要先复制出来当地的。
4.8. 系统和架构适配
一般情况下,我们只需将大多数命令或脚本封装起来即可执行,如下所示:
/**
* 运行CMD指令(不区分系统环境)
*/
fun runCMD(
vararg elements: String,
directory: File? = null,
onLine: (String) -> Unit
): Int {
val cmdStringBuilder = StringBuilder()
elements.forEach {
cmdStringBuilder.append(it).append(" ")
}
val process = ProcessBuilder(*elements)
.directory(directory)
.redirectErrorStream(true)
.start()
val reader = BufferedReader(
InputStreamReader(
process.inputStream,
Charset.forName("UTF-8")
)
)
var line: String?
while (reader.readLine().also { line = it } != null) {
line?.let {
onLine(it)
}
}
return process.waitFor()
}
但对于上一节介绍的工具,我们会发现有些工具是区分系统和架构的。 以对齐工具为例:网上应该使用.exe,Mac上应该使用可执行文件,但Mac近几年推出的芯片都是ARM架构,所以不同架构要选择不同的文件例如 ARM 和 .
这就需要我们先准备好这些不同架构和系统的资源,以便根据不同的系统和架构进行加载和适配(以下方法并不完全适合所有系统和架构,需要改进):
/**
* 获取系统名,根据名称判断系统类型
*/
fun getSystem(): String {
return System.getProperties().getProperty("os.name")
}
/**
* 判断是否是mac
*/
fun isMac(): Boolean {
return getSystem().lowercase().contains("mac")
}
/**
* 判断是否是windows
*/
fun isWindows(): Boolean {
return getSystem().lowercase().contains("windows")
}
/**
* 获取cpu架构
*/
fun geChip(): String {
return System.getProperties().getProperty("os.arch")
}
/**
* 判断是否是arm架构
*/
fun isArm(): Boolean {
return geChip().lowercase().contains("aarch64")
}
/**
* 判断是否是amd64架构
*/
fun isAmd64(): Boolean {
return geChip().lowercase().contains("amd64")
}
在整理这些文件时,我们将文件放入如下目录中,如下:
当需要执行这些文件时,我们根据系统类型和架构将相应的工具复制到本地存储(仅适配常用的Mac和Win,没有其他)。
在执行这些可执行文件时,Mac和都可以直接执行(前提是需要可执行权限),但是在Win上执行相关命令之前,可能需要加上cmd /c的前缀,所以对于不同的系统,执行命令还需要一个层次区分如下:
/**
* 运行CMD指令
* 区分win和unix(Mac OS和Linux)环境,一般执行exe或者unix上的可执行文件
*
* @param winCMD win命令前缀
* @param unixCMD unix命令前缀
* @param cmdSuffix 统一的后缀,会拼接到前面的数组上
*/
fun runCMD(
winCMD: Array<String>,
unixCMD: Array<String>,
cmdSuffix: Array<String> = emptyArray(),
directory: File? = null,
onLine: (String) -> Unit
): Int {
val cmd = if (isWindows()) {
winCMD
} else {
unixCMD
} + cmdSuffix
return runCMD(elements = cmd, directory = directory, onLine = onLine)
}
然后针对不同的系统,用命令打包的例子(这里切换起来很唐突,因为我们的打包服务就有这种情况),用法如下:
runCMD(
winCMD = arrayOf("cmd", "/c", "gradlew"),
unixCMD = arrayOf("./gradlew"),
cmdSuffix = arrayOf("assembleXxxRelease"),
directory = targetDir,
onLine = {
}
5. 其他问题清单 5.1. APK文件解压
下面是一个常用的解压文件的代码示例。 当APK文件较小时,解压正常,但当APK文件比较大时,解压时会出现异常情况。 你可以在评论中看到它。 name的结果始终为空,导致直接退出解压:
fun decompressByZip(
zipFilePath: String,
outputDirPath: String
) {
val buffer = ByteArray(1024)
try {
val outputDir = File(outputDirPath)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
val zipInputStream = ZipInputStream(File(zipFilePath).inputStream())
var zipEntry: ZipEntry? = zipInputStream.nextEntry
while (zipEntry != null) {
// 有获取到结果为空字符串的情况
if (zipEntry.name.isNullOrBlank()) {
continue
}
val newFile = File(outputDirPath, zipEntry.name)
if (zipEntry.isDirectory) {
newFile.mkdirs()
} else {
val parentDir = newFile.parentFile
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs()
}
val bufferedOutputStream = BufferedOutputStream(FileOutputStream(newFile))
var bytesRead: Int
while (zipInputStream.read(buffer).also { bytesRead = it } != -1) {
bufferedOutputStream.write(buffer, 0, bytesRead)
}
bufferedOutputStream.close()
}
zipEntry = zipInputStream.nextEntry
}
zipInputStream.closeEntry()
zipInputStream.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
最后可能是因为APK文件使用了非标准的压缩方式,所以被替换了。 依赖库及版本如下:
commonMainImplementation("org.apache.commons:commons-compress:1.22")
解压代码示例如下:
fun decompressByZip(
zipFilePath: String,
outputDirPath: String
) {
try {
val outputDir = File(outputDirPath)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
ZipFile(zipFilePath).use { zip ->
zip.entries.asSequence().forEach { entry ->
val entryFile = File(outputDir, entry.name)
logger("decompress zip: ${entryFile.name}")
// 确保父目录存在
entryFile.parentFile?.mkdirs()
if (entry.isDirectory) {
// 如果是目录,创建对应的目录
entryFile.mkdirs()
} else {
// 如果是文件,将文件解压到目标目录
zip.getInputStream(entry).use { input ->
FileOutputStream(entryFile).use { output ->
input.copyTo(output)
}
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
六、总结
文章到这里就结束了。 总体来说,我感觉这篇文章比较散乱。 文章整体虽然没有太多深度,但还是涉及到很多知识点。 由于我也是刚接触逆向工程的菜鸟,所以文中使用的工具都是最基本的,无法获取加固后的APK源代码,但是处理基础资源还是比较容易的文件。 后续可能还需要接触拆包等相关内容。 如果有进展的话我会及时把文章分享给大家,就到这里吧。
正值端午节,祝大家端午节健康快乐! ! !