feat: Improve Fingerprint API (#316)

Fingerprints can now be matched easily without adding them to a patch first.

BREAKING CHANGE: Many APIs have been changed.
This commit is contained in:
oSumAtrIX
2024-10-27 16:04:30 +01:00
committed by GitHub
parent aa472eb985
commit 0abf1c6c02
14 changed files with 360 additions and 365 deletions

View File

@@ -89,9 +89,9 @@ val patcherResult = Patcher(PatcherConfig(apkFile = File("some.apk"))).use { pat
runBlocking {
patcher().collect { patchResult ->
if (patchResult.exception != null)
logger.info("\"${patchResult.patch}\" failed:\n${patchResult.exception}")
logger.info { "\"${patchResult.patch}\" failed:\n${patchResult.exception}" }
else
logger.info("\"${patchResult.patch}\" succeeded")
logger.info { "\"${patchResult.patch}\" succeeded" }
}
}

View File

@@ -72,6 +72,10 @@ To start developing patches with ReVanced Patcher, you must prepare a developmen
Throughout the documentation, [ReVanced Patches](https://github.com/revanced/revanced-patches) will be used as an example project.
> [!NOTE]
> To start a fresh project,
> you can use the [ReVanced Patches template](https://github.com/revanced/revanced-patches-template).
1. Clone the repository
```bash

View File

@@ -60,14 +60,16 @@
# 🔎 Fingerprinting
In the context of ReVanced, fingerprinting is primarily used to match methods with a limited amount of known information.
In the context of ReVanced, a fingerprint is a partial description of a method.
It is used to uniquely match a method by its characteristics.
Fingerprinting is used to match methods with a limited amount of known information.
Methods with obfuscated names that change with each update are primary candidates for fingerprinting.
The goal of fingerprinting is to uniquely identify a method by capturing various attributes, such as the return type,
access flags, an opcode pattern, strings, and more.
## ⛳️ Example fingerprint
Throughout the documentation, the following example will be used to demonstrate the concepts of fingerprints:
An example fingerprint is shown below:
```kt
@@ -79,11 +81,11 @@ fingerprint {
parameters("Z")
opcodes(Opcode.RETURN)
strings("pro")
custom { (method, classDef) -> method.definingClass == "Lcom/some/app/ads/AdsLoader;" }
custom { (method, classDef) -> classDef == "Lcom/some/app/ads/AdsLoader;" }
}
```
## 🔎 Reconstructing the original code from a fingerprint
## 🔎 Reconstructing the original code from the example fingerprint from above
The following code is reconstructed from the fingerprint to understand how a fingerprint is created.
@@ -107,27 +109,29 @@ The fingerprint contains the following information:
- Package and class name:
```kt
custom = { (method, classDef) -> method.definingClass == "Lcom/some/app/ads/AdsLoader;"}
custom { (method, classDef) -> classDef == "Lcom/some/app/ads/AdsLoader;" }
```
With this information, the original code can be reconstructed:
```java
package com.some.app.ads;
package com.some.app.ads;
<accessFlags> class AdsLoader {
public final boolean <methodName>(boolean <parameter>) {
// ...
<accessFlags> class AdsLoader {
public final boolean <methodName>(boolean <parameter>) {
// ...
var userStatus = "pro";
var userStatus = "pro";
// ...
// ...
return <returnValue>;
}
return <returnValue>;
}
}
```
Using that fingerprint, this method can be matched uniquely from all other methods.
> [!TIP]
> A fingerprint should contain information about a method likely to remain the same across updates.
> A method's name is not included in the fingerprint because it will likely change with each update in an obfuscated app.
@@ -135,8 +139,8 @@ With this information, the original code can be reconstructed:
## 🔨 How to use fingerprints
Fingerprints can be added to a patch by directly creating and adding them or by invoking them manually.
Fingerprints added to a patch are matched by ReVanced Patcher before the patch is executed.
A fingerprint is matched to a method,
once the `match` property of the fingerprint is accessed in a patch's `execute` scope:
```kt
val fingerprint = fingerprint {
@@ -144,48 +148,46 @@ val fingerprint = fingerprint {
}
val patch = bytecodePatch {
// Directly create and add a fingerprint.
fingerprint {
// ...
execute {
val match = fingerprint.match!!
}
// Add a fingerprint manually by invoking it.
fingerprint()
}
```
> [!TIP]
> Multiple patches can share fingerprints. If a fingerprint is matched once, it will not be matched again.
> [!TIP]
> If a fingerprint has an opcode pattern, you can use the `fuzzyPatternScanThreshhold` parameter of the `opcode`
> function to fuzzy match the pattern.
> `null` can be used as a wildcard to match any opcode:
>
> ```kt
> fingerprint(fuzzyPatternScanThreshhold = 2) {
> opcodes(
> Opcode.ICONST_0,
> null,
> Opcode.ICONST_1,
> Opcode.IRETURN,
> )
>}
> ```
Once the fingerprint is matched, the match can be used in the patch:
The fingerprint won't be matched again, if it has already been matched once.
This makes it useful, to share fingerprints between multiple patches, and let the first patch match the fingerprint:
```kt
val patch = bytecodePatch {
// Add a fingerprint and delegate its match to a variable.
val match by showAdsFingerprint()
val match2 by fingerprint {
// ...
}
// Either of these two patches will match the fingerprint first and the other patch can reuse the match:
val mainActivityPatch1 = bytecodePatch {
execute {
val method = match.method
val method2 = match2.method
val match = mainActivityOnCreateFingerprint.match!!
}
}
val mainActivityPatch2 = bytecodePatch {
execute {
val match = mainActivityOnCreateFingerprint.match!!
}
}
```
A fingerprint match can also be delegated to a variable for convenience without the need to check for `null`:
```kt
val fingerprint = fingerprint {
// ...
}
val patch = bytecodePatch {
execute {
// Alternative to fingerprint.match ?: throw PatchException("No match found")
val match by fingerprint.match
try {
match.method
} catch (e: PatchException) {
// Handle the exception for example.
}
}
}
```
@@ -194,30 +196,53 @@ val patch = bytecodePatch {
> If the fingerprint can not be matched to any method, the match of a fingerprint is `null`. If such a match is delegated
> to a variable, accessing it will raise an exception.
The match of a fingerprint contains mutable and immutable references to the method and the class it matches to.
> [!TIP]
> If a fingerprint has an opcode pattern, you can use the `fuzzyPatternScanThreshhold` parameter of the `opcode`
> function to fuzzy match the pattern.
> `null` can be used as a wildcard to match any opcode:
>
> ```kt
> fingerprint(fuzzyPatternScanThreshhold = 2) {
> opcodes(
> Opcode.ICONST_0,
> null,
> Opcode.ICONST_1,
> Opcode.IRETURN,
> )
>}
> ```
>
The match of a fingerprint contains references to the original method and class definition of the method:
```kt
class Match(
val method: Method,
val classDef: ClassDef,
val originalMethod: Method,
val originalClassDef: ClassDef,
val patternMatch: Match.PatternMatch?,
val stringMatches: List<Match.StringMatch>?,
// ...
) {
val mutableClass by lazy { /* ... */ }
val mutableMethod by lazy { /* ... */ }
val classDef by lazy { /* ... */ }
val method by lazy { /* ... */ }
// ...
}
```
## 🏹 Manual matching of fingerprints
The `classDef` and `method` properties can be used to make changes to the class or method.
They are lazy properties, so they are only computed
and will effectively replace the original method or class definition when accessed.
Unless a fingerprint is added to a patch, the fingerprint will not be matched automatically by ReVanced Patcher
before the patch is executed.
Instead, the fingerprint can be matched manually using various overloads of a fingerprint's `match` function.
> [!TIP]
> If only read-only access to the class or method is needed,
> the `originalClassDef` and `originalMethod` properties can be used,
> to avoid making a mutable copy of the class or method.
You can match a fingerprint the following ways:
## 🏹 Manually matching fingerprints
By default, a fingerprint is matched automatically against all classes when the `match` property is accessed.
Instead, the fingerprint can be matched manually using various overloads of a fingerprint's `match` function:
- In a **list of classes**, if the fingerprint can match in a known subset of classes
@@ -225,11 +250,9 @@ You can match a fingerprint the following ways:
you can match the fingerprint on the list of classes:
```kt
execute { context ->
val match = showAdsFingerprint.apply {
match(context, context.classes)
}.match ?: throw PatchException("No match found")
}
execute {
val match = showAdsFingerprint.match(classes) ?: throw PatchException("No match found")
}
```
- In a **single class**, if the fingerprint can match in a single known class
@@ -237,34 +260,39 @@ you can match the fingerprint on the list of classes:
If you know the fingerprint can match a method in a specific class, you can match the fingerprint in the class:
```kt
execute { context ->
val adsLoaderClass = context.classes.single { it.name == "Lcom/some/app/ads/Loader;" }
execute {
val adsLoaderClass = classes.single { it.name == "Lcom/some/app/ads/Loader;" }
val match = showAdsFingerprint.apply {
match(context, adsLoaderClass)
}.match ?: throw PatchException("No match found")
val match = showAdsFingerprint.match(context, adsLoaderClass) ?: throw PatchException("No match found")
}
```
Another common usecase is to use a fingerprint to reduce the search space of a method to a single class.
```kt
execute {
// Match showAdsFingerprint in the class of the ads loader found by adsLoaderClassFingerprint.
val match by showAdsFingerprint.match(adsLoaderClassFingerprint.match!!.classDef)
}
```
- Match a **single method**, to extract certain information about it
The match of a fingerprint contains useful information about the method, such as the start and end index of an opcode pattern
or the indices of the instructions with certain string references.
The match of a fingerprint contains useful information about the method,
such as the start and end index of an opcode pattern or the indices of the instructions with certain string references.
A fingerprint can be leveraged to extract such information from a method instead of manually figuring it out:
```kt
execute { context ->
val proStringsFingerprint = fingerprint {
strings("free", "trial")
}
execute {
val currentPlanFingerprint = fingerprint {
strings("free", "trial")
}
proStringsFingerprint.apply {
match(context, adsFingerprintMatch.method)
}.match?.let { match ->
match.stringMatches.forEach { match ->
println("The index of the string '${match.string}' is ${match.index}")
}
} ?: throw PatchException("No match found")
currentPlanFingerprint.match(adsFingerprintMatch.method)?.let { match ->
match.stringMatches.forEach { match ->
println("The index of the string '${match.string}' is ${match.index}")
}
} ?: throw PatchException("No match found")
}
```

View File

@@ -76,23 +76,23 @@ val disableAdsPatch = bytecodePatch(
) {
compatibleWith("com.some.app"("1.0.0"))
// Resource patch disables ads by patching resource files.
// Patches can depend on other patches, executing them first.
dependsOn(disableAdsResourcePatch)
// Precompiled DEX file to be merged into the patched app.
// Merge precompiled DEX files into the patched app, before the patch is executed.
extendWith("disable-ads.rve")
// Fingerprint to find the method to patch.
val showAdsMatch by showAdsFingerprint {
// More about fingerprints on the next page of the documentation.
}
// Business logic of the patch to disable ads in the app.
execute {
// Fingerprint to find the method to patch.
val showAdsMatch by showAdsFingerprint {
// More about fingerprints on the next page of the documentation.
}
// In the method that shows ads,
// call DisableAdsPatch.shouldDisableAds() from the extension (precompiled DEX file)
// to enable or disable ads.
showAdsMatch.mutableMethod.addInstructions(
showAdsMatch.method.addInstructions(
0,
"""
invoke-static {}, LDisableAdsPatch;->shouldDisableAds()Z
@@ -146,10 +146,10 @@ loadPatchesJar(patches).apply {
The type of an option can be obtained from the `type` property of the option:
```kt
option.type // The KType of the option.
option.type // The KType of the option. Captures the full type information of the option.
```
Options can be declared outside of a patch and added to a patch manually:
Options can be declared outside a patch and added to a patch manually:
```kt
val option = stringOption(key = "option")
@@ -183,11 +183,9 @@ and use it in a patch:
```kt
val patch = bytecodePatch(name = "Complex patch") {
extendWith("complex-patch.rve")
val match by methodFingerprint()
execute {
match.mutableMethod.addInstructions(0, "invoke-static { }, LComplexPatch;->doSomething()V")
fingerprint.match!!.mutableMethod.addInstructions(0, "invoke-static { }, LComplexPatch;->doSomething()V")
}
}
```

View File

@@ -96,21 +96,21 @@ Example of patches:
@Surpress("unused")
val bytecodePatch = bytecodePatch {
execute {
// TODO
// More about this on the next page of the documentation.
}
}
@Surpress("unused")
val rawResourcePatch = rawResourcePatch {
execute {
// TODO
execute {
// More about this on the next page of the documentation.
}
}
@Surpress("unused")
val resourcePatch = resourcePatch {
execute {
// TODO
execute {
// More about this on the next page of the documentation.
}
}
```

View File

@@ -4,13 +4,11 @@ A handful of APIs are available to make patch development easier and more effici
## 📙 Overview
1. 👹 Mutate classes with `context.proxy(ClassDef)`
2. 🔍 Find and proxy existing classes with `classBy(Predicate)` and `classByType(String)`
3. 🏃‍ Easily access referenced methods recursively by index with `MethodNavigator`
4. 🔨 Make use of extension functions from `BytecodeUtils` and `ResourceUtils` with certain applications
(Available in ReVanced Patches)
5. 💾 Read and write (decoded) resources with `ResourcePatchContext.get(Path, Boolean)`
6. 📃 Read and write DOM files using `ResourcePatchContext.document`
1. 👹 Create mutable replacements of classes with `proxy(ClassDef)`
2. 🔍 Find and create mutable replaces with `classBy(Predicate)`
3. 🏃‍ Navigate method calls recursively by index with `navigate(Method).at(index)`
4. 💾 Read and write resource files with `get(Path, Boolean)`
5. 📃 Read and write DOM files using `document`
### 🧰 APIs