Continuing the legacy of Vanced
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.
An example fingerprint is shown below:
package app.revanced.patches.ads.fingerprints
fingerprint {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returns("Z")
parameters("Z")
opcodes(Opcode.RETURN)
strings("pro")
custom { (method, classDef) -> classDef == "Lcom/some/app/ads/AdsLoader;" }
}
The following code is reconstructed from the fingerprint to understand how a fingerprint is created.
The fingerprint contains the following information:
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returns("Z")
parameters("Z")
opcodes(Opcode.RETURN)
strings("pro")
custom { (method, classDef) -> classDef == "Lcom/some/app/ads/AdsLoader;" }
With this information, the original code can be reconstructed:
package com.some.app.ads;
<accessFlags> class AdsLoader {
public final boolean <methodName>(boolean <parameter>) {
// ...
var userStatus = "pro";
// ...
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. In contrast, the return type, access flags, parameters, patterns of opcodes, and strings are likely to remain the same.
A fingerprint is matched to a method,
once the match
property of the fingerprint is accessed in a patch's execute
scope:
val fingerprint = fingerprint {
// ...
}
val patch = bytecodePatch {
execute {
val match = fingerprint.match!!
}
}
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:
// Either of these two patches will match the fingerprint first and the other patch can reuse the match:
val mainActivityPatch1 = bytecodePatch {
execute {
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
:
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.
}
}
}
[!WARNING] 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.[!TIP] If a fingerprint has an opcode pattern, you can use the
fuzzyPatternScanThreshhold
parameter of theopcode
function to fuzzy match the pattern.
null
can be used as a wildcard to match any opcode:> 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 originalMethod: Method, val originalClassDef: ClassDef, val patternMatch: Match.PatternMatch?, val stringMatches: List<Match.StringMatch>?, // ... ) { val classDef by lazy { /* ... */ } val method by lazy { /* ... */ } // ... }
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.
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:
If you have a known list of classes you know the fingerprint can match in, you can match the fingerprint on the list of classes:
execute {
val match = showAdsFingerprint.match(classes) ?: throw PatchException("No match found")
}
If you know the fingerprint can match a method in a specific class, you can match the fingerprint in the class:
execute {
val adsLoaderClass = classes.single { it.name == "Lcom/some/app/ads/Loader;" }
val match = showAdsFingerprint.match(context, adsLoaderClass) ?: throw PatchException("No match found")
}
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:
execute {
val currentPlanFingerprint = fingerprint {
strings("free", "trial")
}
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")
}
[!TIP] To see real-world examples of fingerprints, check out the repository for ReVanced Patches.
The next page discusses the structure and conventions of patches.
Continue: 📜 Project structure and conventions