这篇文章上次修改于 318 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

这是以前的一点分析记录,现在分享出来


2023-06-21T06:09:19.png

主要是目的是为了分析metadata数据与原始类相关的映射关系,用在逆向时辅助还原被混淆的类、方法名等信息

即通过d1d2的数据能得到什么信息

查找一系列资料以及阅读部分代码后...

基础知识

源代码中d1对应的名字是data1,其他对应关系如下:

  • kind => k

    • 1 => Class

      • d1 用 Class 解析即可
    • 2 => File

      • d1 用 Package 解析即可
    • 3 => Synthetic class

      • 一般就是对应java的匿名函数,d1 用 Function 解析即可,这个有些情况没有d1,具体不清楚
    • 4 => Multi-file class facade

      • 这种好像只在kotlin自己代码有 d1 直接就是一个字符串数组 好像是表示一些合集
    • 5 => Multi-file class part

      • 就是多个2的合集 用 Package 解析即可
  • metadataVersion => mv

    • 这个值每个版本可能不同,一般需要根据该值对应到具体kotlin版本
    • 根据关键词搜索源代码后,发现一个关键提示:@see COMPATIBLE_METADATA_VERSION
    • 关键词COMPATIBLE_METADATA_VERSION,位于:
    • libraries/kotlinx-metadata/jvm/src/kotlinx/metadata/jvm/KotlinClassHeader.kt
    • 关键词JvmMetadataVersion,位于:
    • core/metadata.jvm/src/org/jetbrains/kotlin/metadata/jvm/deserialization/JvmMetadataVersion.kt
    • 这个文件中定义了具体版本信息
  • bytecodeVersion => bv

    • 这个好像有的注解有有的没有
  • data1 => d1

    • 字符串数组,protobuf数据,可以结合对应版本的metadata.proto对应解析,换行符作为分隔符,分割后与d2数量一致
    • 信息比较多的时候会被分割成多个字符串,解析的时候拼接起来即可
  • data2 => d2

    • 字符串数组,可能是空可能是其他的,具体含义与d1对应的信息一致
  • extraString => xs

    • 不关心
  • packageName => pn

    • 不关心
  • extraInt => xi

    • 不关心

对了,怎么data1就变成d1了呢?关键词ReplaceWith

不,是通过@get:JvmName("d1")注解完成的

一些详细解释可以查看源代码中的注释,位于:

  • libraries/stdlib/jvm/runtime/kotlin/Metadata.kt

那么可以根据git信息快速定位原apk使用的kotlin版本

可以知道2021/05/23 - 2021/7/30期间metadataVersion1.5.1

查看release记录,2021/05/05发布的是1.5.0版本,2021/05/24发布的是1.5.10版本

那么可以结合1.5.10版的源代码做分析

关键文件

在分析的过程中,以下文件对理解metadata数据如何被解析非常有作用

core/metadata.jvm/src/org/jetbrains/kotlin/metadata/jvm/deserialization/JvmProtoBufUtil.kt
core/descriptors.jvm/src/org/jetbrains/kotlin/load/kotlin/JavaClassDataFinder.kt
core/metadata.jvm/src/org/jetbrains/kotlin/metadata/jvm/JvmProtoBuf.java
core/metadata/src/org/jetbrains/kotlin/metadata/ProtoBuf.java
libraries/kotlinx-metadata/jvm/src/kotlinx/metadata/jvm/KotlinClassHeader.kt
    - metadataVersion
    - COMPATIBLE_METADATA_VERSION
libraries/stdlib/jvm/runtime/kotlin/Metadata.kt
    - metadataVersion
core/metadata.jvm/src/org/jetbrains/kotlin/metadata/jvm/deserialization/JvmMetadataVersion.kt
    - JvmMetadataVersion
core/reflection.jvm/src/kotlin/reflect/jvm/reflectLambda.kt
    - metadataVersion
core/deserialization.common/src/org/jetbrains/kotlin/serialization/deserialization/NameResolverUtil.kt
    - getClassId
libraries/kotlinx-metadata/src/kotlinx/metadata/impl/readUtils.kt
    - readAnnotationArgument
    - getQualifiedClassName
core/metadata/src/org/jetbrains/kotlin/metadata/deserialization/NameResolverImpl.kt
    - getQualifiedClassName
    - traverseIds
core/reflection.jvm/src/kotlin/reflect/jvm/internal/ReflectionFactoryImpl.java
    - renderLambdaToString
core/metadata.jvm/src/org/jetbrains/kotlin/metadata/jvm/deserialization/JvmNameResolver.kt
    - getQualifiedClassName

想要知道@Metadata注解中d1的数据怎么解析,主要关注这几个文件的代码:

  • JvmProtoBufUtil.kt

    • 这个可以说是入口,从readxxxDataFrom这样的函数命名就能看出来,跟着这个阅读源代码就能理解解析过程
    • 想知道d1d2是怎么映射的?看看mapTypeDefault就懂了
    • 根据实际测试,d1的数据用metadata.proto进行解析得到一些数字,就是d2中的索引,这样就知道哪个是哪个了
  • ProtoBuf.java

    • protobuf数据解析的基类
  • JvmProtoBuf.java

    • 更像是通过工具生成的这个文件,比如是metadata.proto转换得到,因为里面的字段定义是一致的

在分析代码的过程中,发现可以通过JvmProtoBuf.javaenum字段的具体值,快速定位app中对应的类

为什么要对应,因为APP里面是简单混淆,就是把包名、类名、变量名等都处理过了

但是enum中的变量名通常还在,借助enum变量名关键词可以快速知道对应是什么类,起初是根据签名、常量等进行分析,效率低下...

最开始是想定位关键位置,看看具体的metadata解析逻辑,不过现在已经知道了

99.9%的情况下,开发者都不会去自己改kotlin包、或者自己修改源代码拿来编译进APP,都是用官方的包

所以只要定位分析出版本后,就可以直接拿源代码中的proto文件进行数据解析了

数据转换

前面提到的proto文件是这些:

  • core/metadata/src/builtins.proto
  • core/metadata/src/ext_options.proto
  • core/metadata/src/metadata.proto
  • core/metadata.jvm/src/jvm_metadata.proto
  • core/metadata.jvm/src/jvm_module.proto

还有一些proto文件,不过它们对于还原工作并不重要:

  • build-common/src/java_descriptors.proto
  • core/metadata/src/google/protobuf/descriptor.proto
  • compiler/ir/serialization.common/src/KotlinIr.proto
  • compiler/util-klib-metadata/src/KlibMetadataProtoBuf.proto
  • plugins/kotlin-serialization/kotlin-serialization-compiler/src/class_extensions.proto
  • js/js.serializer/src/js.proto
  • js/js.serializer/src/js-ast.proto

严格来说,kotlin中的代码与java并不是完全对应的,kt转到java后,一个kt的类可能被拆为多个java类

虽然可以通过metadata知道java的类原始kt类名,但由于阅读的是java代码,不能简单修改/还原java的类名

依次创建文件夹core/metadata/src,将proto文件放到src下,是的,将metadata.jvm那个路径下的proto文件也放到这个路径下

然后将proto文件转换到py文件,再core上级目录执行下面的命令即可,每个proto文件都执行一次

python -m grpc_tools.protoc -I . --python_out=. core/metadata/src/metadata.proto

会对应生成py文件,比如:

  • core/metadata/src/metadata_pb2.py

记得先安装工具和protobuf包

pip install grpcio
pip install grpcio-tools
pip install protobuf

然后通过下面的代码就可以实现数据解析,经过测试发现必须同时导入jvm_metadata_pb2,解析出来的结果最全面

import json
from google.protobuf.json_format import MessageToDict

from core.metadata.src.metadata_pb2 import Class
from core.metadata.src.metadata_pb2 import Function
from core.metadata.src.metadata_pb2 import Package
from core.metadata.src.jvm_metadata_pb2 import *
from core.metadata.src.jvm_module_pb2 import *

data1 = bytes.fromhex(
    "1C0A0218020A0210000A0208020A0218020A0208050A0210080A020805180032"
    "0230014205A206021002521C10031A041801300458860EA2060E0A001A040805"
    "1006220408071008521A10091A02300A58860EA2060E0A001A04080B100C2204"
    "080D100EA8060F"
)

msg = Class()
msg.ParseFromString(b'\x08' + data1)
print(json.dumps(MessageToDict(msg), ensure_ascii=False, indent=4))

关于解析实际上使用哪个类,具体测试结论请查看开头的基础知识部分

简单来说根据k决定,0x1用Class、0x2和0x5用Package、0x3用Function

对了,代码中给数据头部加的0x08是怎么回事呢?

在dex中注解的d1原始数据如下(hex):

C0 80 1C 0A 02 18 02 0A 02 10 C0 80 0A 02 08 02
0A 02 18 02 0A 02 08 05 0A 02 10 08 0A 02 08 05
18 C0 80 32 02 30 01 42 05 C2 A2 06 02 10 02 52
1C 10 03 1A 04 18 01 30 04 58 C2 86 0E C2 A2 06
0E 0A C0 80 1A 04 08 05 10 06 22 04 08 07 10 08
52 1A 10 09 1A 02 30 0A 58 C2 86 0E C2 A2 06 0E
0A C0 80 1A 04 08 0B 10 0C 22 04 08 0D 10 0E C2
A8 06 0F

其中C0 80以utf-8的角度来说,应该被当作00,那么数据应该是这样的

00 1C 0A 02 18 02 0A 02 10 00 0A 02 08 02 0A 02
18 02 0A 02 08 05 0A 02 10 08 0A 02 08 05 18 00
32 02 30 01 42 05 C2 A2 06 02 10 02 52 1C 10 03
1A 04 18 01 30 04 58 C2 86 0E C2 A2 06 0E 0A 00
1A 04 08 05 10 06 22 04 08 07 10 08 52 1A 10 09
1A 02 30 0A 58 C2 86 0E C2 A2 06 0E 0A 00 1A 04
08 0B 10 0C 22 04 08 0D 10 0E C2 A8 06 0F

在前面提到的那些处理类所在文件,比如JvmProtoBufUtil,其处理过程都会调用BitEncoding.decodeBytes

BitEncoding.decodeBytes这个方法进行分析,可以知道一般情况下,它就是把第一个00给去掉了...

然后传入ProtoBuf.java中的xxx.parseFrom解析

不过使用python的官方protobuf库解析,最开始发现解析失败

后来把第一位补00,总是提示有异常,但偶尔能解析出来一点东西

RuntimeWarning: Unexpected end-group tag: Not all data was converted

后来想到之前涉及的一些protobuf数据都是08开头,果然加上就好了

为什么是08呢

解析protobuf的时候,第一个字节含有tag信息

根据网上的资料,可以知道tag = (field_number << 3) | wire_type

field_number就是proto文件中字段的那些数字编号,wire_type则对应是下面这些

  • 0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
  • 1 64-bit fixed64, sfixed64, double
  • 2 Length-delimited string, bytes, embedded messages, packed repeated fields
  • 3 Start group groups (deprecated)
  • 4 End group groups (deprecated)
  • 5 32-bit fixed32, sfixed32, float

所以常见的情况下,第一个字节就是0x08 = 1 << 3 | 0

至此可以解析d1

比如,Lcom/tencent/mm/plugin/scanner/e/a/c;

data1 = bytes.fromhex(
    "1C0A0218020A0210000A0208020A0218020A0208050A0210080A020805180032"
    "0230014205A206021002521C10031A041801300458860EA2060E0A001A040805"
    "1006220408071008521A10091A02300A58860EA2060E0A001A04080B100C2204"
    "080D100EA8060F"
)

data2 = [
    "Lcom/tencent/mm/plugin/scanner/image/uploader/AiImageUploader$AiImageUploadRequestWrapper;",
    "",
    "()V",
    "request",
    "Lcom/tencent/mm/plugin/scanner/api/ScanOpImageRequest;",
    "getRequest",
    "()Lcom/tencent/mm/plugin/scanner/api/ScanOpImageRequest;",
    "setRequest",
    "(Lcom/tencent/mm/plugin/scanner/api/ScanOpImageRequest;)V",
    "sceneHashCode",
    "",
    "getSceneHashCode",
    "()I",
    "setSceneHashCode",
    "(I)V",
    "plugin-scan_release"
]
{
    "flags": 28,
    "fqName": 0,
    "supertype": [
        {
            "className": 1
        }
    ],
    "constructor": [
        {
            "[org.jetbrains.kotlin.metadata.jvm.constructor_signature]": {
                "desc": 2
            }
        }
    ],
    "property": [
        {
            "name": 3,
            "returnType": {
                "nullable": true,
                "className": 4
            },
            "flags": 1798,
            "[org.jetbrains.kotlin.metadata.jvm.property_signature]": {
                "field": {},
                "getter": {
                    "name": 5,
                    "desc": 6
                },
                "setter": {
                    "name": 7,
                    "desc": 8
                }
            }
        },
        {
            "name": 9,
            "returnType": {
                "className": 10
            },
            "flags": 1798,
            "[org.jetbrains.kotlin.metadata.jvm.property_signature]": {
                "field": {},
                "getter": {
                    "name": 11,
                    "desc": 12
                },
                "setter": {
                    "name": 13,
                    "desc": 14
                }
            }
        }
    ],
    "[org.jetbrains.kotlin.metadata.jvm.class_module_name]": 15
}
data1 = bytes.fromhex(
    "260A0218020A0210000A000A0210020A000A0210090A0208030A0218020A000A"
    "0218020A02080208661800320230013A010C4A1010021A023003320610041A02"
    "300548264A0810061A02300348264A1810071A023003320610081A0230093206"
    "100A1A02300B4826A8060D"
)

data2 = [
    "Lcom/tencent/mm/plugin/scanner/image/uploader/AiImageUploader;",
    "",
    "cancelUploadImage",
    "",
    "session",
    "",
    "release",
    "uploadImage",
    "requestWrapper",
    "Lcom/tencent/mm/plugin/scanner/image/uploader/AiImageUploader$AiImageUploadRequestWrapper;",
    "resultCallback",
    "Lcom/tencent/mm/plugin/scanner/api/ScanOpImageResultCallback;",
    "AiImageUploadRequestWrapper",
    "plugin-scan_release"
]
{
    "flags": 102,
    "fqName": 0,
    "supertype": [
        {
            "className": 1
        }
    ],
    "nestedClassName": [
        12
    ],
    "function": [
        {
            "name": 2,
            "returnType": {
                "className": 3
            },
            "valueParameter": [
                {
                    "name": 4,
                    "type": {
                        "className": 5
                    }
                }
            ],
            "flags": 38
        },
        {
            "name": 6,
            "returnType": {
                "className": 3
            },
            "flags": 38
        },
        {
            "name": 7,
            "returnType": {
                "className": 3
            },
            "valueParameter": [
                {
                    "name": 8,
                    "type": {
                        "className": 9
                    }
                },
                {
                    "name": 10,
                    "type": {
                        "className": 11
                    }
                }
            ],
            "flags": 38
        }
    ],
    "[org.jetbrains.kotlin.metadata.jvm.class_module_name]": 13
}

结合d2的数据便可以进行对应,可用于反混淆

其他

fqName是什么?解释如下,一定程度上可以认为就是类名

Fully qualified name of the package this class is located in, from Kotlin's point of view, or empty string if this name
does not differ from the JVM's package FQ name. These names can be different in case the [JvmPackageName] annotation is used.
Note that this information is also stored in the corresponding module's `.kotlin_module` file.

d2里面是<anonymous>怎么办?这个一般k0x3,就是匿名函数...所以没有名字也是正常的

如果对应到java中,一般是xx$1或者AnonymousClass1这样的名字

这种不处理也问题不大,好像这种JEB并不可以改名字

data1 = bytes.fromhex(
    "0e0a000a0210020a0208020a02100910001a023001220408001002320610031a"
    "023004480a"
)
data2 = [
    "<anonymous>",
    "",
    "R",
    "frameTimeNanos",
    ""
]
{
    "oldFlags": 14,
    "name": 0,
    "returnType": {
        "className": 1
    },
    "typeParameter": [
        {
            "id": 0,
            "name": 2
        }
    ],
    "valueParameter": [
        {
            "name": 3,
            "type": {
                "className": 4
            }
        }
    ],
    "flags": 10
}

直接复制JEB的d1内容处理的注意事项:

chr(0xa8).encode('utf-8') => b'\xc2\xa8'
chr(0xa8).encode('latin-1') => b'\xa8'

也就是说,除了第一位改成0x08,还有这种编码问题,应该用latin-1而不是utf-8

参考