This commit is contained in:
chiteroman 2023-12-28 01:18:58 +01:00
parent 325fbdc2f5
commit aa340e742e
19 changed files with 236 additions and 263 deletions

3
.idea/.gitignore vendored
View File

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/Dobby" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -1,6 +1,3 @@
-ignorewarnings
-dontobfuscate
-keep class es.chiteroman.playintegrityfix.EntryPoint {public <methods>;} -keep class es.chiteroman.playintegrityfix.EntryPoint {public <methods>;}
-keep class es.chiteroman.playintegrityfix.CustomProvider -keep class es.chiteroman.playintegrityfix.CustomProvider
-keep class es.chiteroman.playintegrityfix.CustomKeyStoreSpi -keep class es.chiteroman.playintegrityfix.CustomKeyStoreSpi
-keep class es.chiteroman.playintegrityfix.CustomCertificates

View File

@ -1,7 +1,6 @@
#include <android/log.h> #include <android/log.h>
#include <sys/system_properties.h> #include <sys/system_properties.h>
#include <unistd.h> #include <unistd.h>
#include <filesystem>
#include "zygisk.hpp" #include "zygisk.hpp"
#include "dobby.h" #include "dobby.h"
@ -48,6 +47,10 @@ static void modify_callback(void *cookie, const char *name, const char *value, u
value = BUILD_ID.c_str(); value = BUILD_ID.c_str();
LOGD("Set '%s' to '%s'", name, value); LOGD("Set '%s' to '%s'", name, value);
} }
} else if (prop == "sys.usb.state") {
value = "none";
} }
return o_callback(cookie, name, value, serial); return o_callback(cookie, name, value, serial);
@ -87,6 +90,11 @@ public:
void preAppSpecialize(zygisk::AppSpecializeArgs *args) override { void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
if (args->is_child_zygote && *args->is_child_zygote) {
api->setOption(zygisk::DLCLOSE_MODULE_LIBRARY);
return;
}
auto name = env->GetStringUTFChars(args->nice_name, nullptr); auto name = env->GetStringUTFChars(args->nice_name, nullptr);
if (name && strncmp(name, "com.google.android.gms", 22) == 0) { if (name && strncmp(name, "com.google.android.gms", 22) == 0) {
@ -95,24 +103,39 @@ public:
if (strcmp(name, "com.google.android.gms.unstable") == 0) { if (strcmp(name, "com.google.android.gms.unstable") == 0) {
auto rawDir = env->GetStringUTFChars(args->app_data_dir, nullptr);
dir = rawDir;
env->ReleaseStringUTFChars(args->app_data_dir, rawDir);
long size = dir.size();
bool done = false;
int fd = api->connectCompanion(); int fd = api->connectCompanion();
write(fd, &size, sizeof(long)); read(fd, &dexSize, sizeof(long));
write(fd, dir.data(), size);
read(fd, &done, sizeof(bool)); if (dexSize > 0) {
dexBuffer = new unsigned char[dexSize];
read(fd, dexBuffer, dexSize);
} else {
LOGD("Couldn't load classes.dex file in memory!");
api->setOption(zygisk::DLCLOSE_MODULE_LIBRARY);
goto end;
}
read(fd, &jsonSize, sizeof(long));
if (jsonSize > 0) {
jsonBuffer = new char[jsonSize];
read(fd, jsonBuffer, jsonSize);
} else {
LOGD("Couldn't load pif.json file in memory!");
delete[] dexBuffer;
api->setOption(zygisk::DLCLOSE_MODULE_LIBRARY);
goto end;
}
end:
close(fd); close(fd);
LOGD("Files copied: %d", done);
goto clear; goto clear;
} }
} }
@ -124,31 +147,10 @@ public:
} }
void postAppSpecialize(const zygisk::AppSpecializeArgs *args) override { void postAppSpecialize(const zygisk::AppSpecializeArgs *args) override {
if (dir.empty()) return; if (dexSize < 1 || jsonSize < 1 || dexBuffer == nullptr || jsonBuffer == nullptr) return;
std::string classesDex(dir + "/classes.dex"); std::string_view jsonStr(jsonBuffer, jsonSize);
nlohmann::json json = nlohmann::json::parse(jsonStr, nullptr, false, true);
FILE *dexFile = fopen(classesDex.c_str(), "rb");
if (dexFile == nullptr) {
LOGD("classes.dex doesn't exist... This is weird.");
dir.clear();
api->setOption(zygisk::DLCLOSE_MODULE_LIBRARY);
return;
}
fclose(dexFile);
doHook();
std::string pifJson(dir + "/pif.json");
FILE *jsonFile = fopen(pifJson.c_str(), "r");
nlohmann::json json = nlohmann::json::parse(jsonFile, nullptr, false, true);
fclose(jsonFile);
if (json.contains("FIRST_API_LEVEL")) { if (json.contains("FIRST_API_LEVEL")) {
@ -180,20 +182,20 @@ public:
LOGD("JSON file doesn't contain SECURITY_PATCH key :("); LOGD("JSON file doesn't contain SECURITY_PATCH key :(");
} }
if (json.contains("BUILD_ID")) { if (json.contains("ID")) {
if (json["BUILD_ID"].is_string()) { if (json["ID"].is_string()) {
BUILD_ID = json["BUILD_ID"].get<std::string>(); BUILD_ID = json["ID"].get<std::string>();
} }
json.erase("BUILD_ID");
} else { } else {
LOGD("JSON file doesn't contain BUILD_ID key :("); LOGD("JSON file doesn't contain BUILD_ID key :(");
} }
doHook();
LOGD("get system classloader"); LOGD("get system classloader");
auto clClass = env->FindClass("java/lang/ClassLoader"); auto clClass = env->FindClass("java/lang/ClassLoader");
auto getSystemClassLoader = env->GetStaticMethodID(clClass, "getSystemClassLoader", auto getSystemClassLoader = env->GetStaticMethodID(clClass, "getSystemClassLoader",
@ -201,11 +203,11 @@ public:
auto systemClassLoader = env->CallStaticObjectMethod(clClass, getSystemClassLoader); auto systemClassLoader = env->CallStaticObjectMethod(clClass, getSystemClassLoader);
LOGD("create class loader"); LOGD("create class loader");
auto dexClClass = env->FindClass("dalvik/system/PathClassLoader"); auto dexClClass = env->FindClass("dalvik/system/InMemoryDexClassLoader");
auto dexClInit = env->GetMethodID(dexClClass, "<init>", auto dexClInit = env->GetMethodID(dexClClass, "<init>",
"(Ljava/lang/String;Ljava/lang/ClassLoader;)V"); "(Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V");
auto dexStr = env->NewStringUTF(classesDex.c_str()); auto buffer = env->NewDirectByteBuffer(dexBuffer, dexSize);
auto dexCl = env->NewObject(dexClClass, dexClInit, dexStr, systemClassLoader); auto dexCl = env->NewObject(dexClClass, dexClInit, buffer, systemClassLoader);
LOGD("load class"); LOGD("load class");
auto loadClass = env->GetMethodID(clClass, "loadClass", auto loadClass = env->GetMethodID(clClass, "loadClass",
@ -220,7 +222,16 @@ public:
auto str = env->NewStringUTF(json.dump().c_str()); auto str = env->NewStringUTF(json.dump().c_str());
env->CallStaticVoidMethod(entryClass, entryInit, str); env->CallStaticVoidMethod(entryClass, entryInit, str);
dir.clear(); env->DeleteLocalRef(clClass);
env->DeleteLocalRef(dexClClass);
env->DeleteLocalRef(buffer);
env->DeleteLocalRef(dexCl);
env->DeleteLocalRef(entryClassName);
env->DeleteLocalRef(entryClassObj);
env->DeleteLocalRef(str);
delete[] dexBuffer;
delete[] jsonBuffer;
} }
void preServerSpecialize(zygisk::ServerSpecializeArgs *args) override { void preServerSpecialize(zygisk::ServerSpecializeArgs *args) override {
@ -230,42 +241,53 @@ public:
private: private:
zygisk::Api *api = nullptr; zygisk::Api *api = nullptr;
JNIEnv *env = nullptr; JNIEnv *env = nullptr;
std::string dir; long dexSize = 0, jsonSize = 0;
unsigned char *dexBuffer = nullptr;
char *jsonBuffer = nullptr;
}; };
static void companion(int fd) { static void companion(int fd) {
long dexSize = 0, jsonSize = 0;
unsigned char *dexBuffer = nullptr;
char *jsonBuffer = nullptr;
long size = 0; FILE *dexFile = fopen(CLASSES_DEX, "rb");
std::string dir;
read(fd, &size, sizeof(long)); if (dexFile) {
dir.resize(size); fseek(dexFile, 0, SEEK_END);
dexSize = ftell(dexFile);
fseek(dexFile, 0, SEEK_SET);
read(fd, dir.data(), size); dexBuffer = new unsigned char[dexSize];
fread(dexBuffer, 1, dexSize, dexFile);
LOGD("[ROOT] GMS dir: %s", dir.c_str()); fclose(dexFile);
}
std::string classesDex(dir + "/classes.dex"); write(fd, &dexSize, sizeof(long));
std::string pifJson(dir + "/pif.json"); write(fd, dexBuffer, dexSize);
bool a = std::filesystem::copy_file(CLASSES_DEX, classesDex, delete[] dexBuffer;
std::filesystem::copy_options::overwrite_existing);
std::filesystem::permissions(classesDex, std::filesystem::perms::owner_read | FILE *jsonFile = fopen(PIF_JSON, "r");
std::filesystem::perms::group_read |
std::filesystem::perms::others_read);
bool b = std::filesystem::copy_file(PIF_JSON, pifJson, if (jsonFile) {
std::filesystem::copy_options::overwrite_existing);
std::filesystem::permissions(pifJson, std::filesystem::perms::owner_read | fseek(jsonFile, 0, SEEK_END);
std::filesystem::perms::group_read | jsonSize = ftell(jsonFile);
std::filesystem::perms::others_read); fseek(jsonFile, 0, SEEK_SET);
bool done = a && b; jsonBuffer = new char[jsonSize];
fread(jsonBuffer, 1, jsonSize, jsonFile);
write(fd, &done, sizeof(bool)); fclose(jsonFile);
}
write(fd, &jsonSize, sizeof(long));
write(fd, jsonBuffer, jsonSize);
delete[] jsonBuffer;
} }
REGISTER_ZYGISK_MODULE(PlayIntegrityFix) REGISTER_ZYGISK_MODULE(PlayIntegrityFix)

View File

@ -14,7 +14,8 @@ import java.util.Date;
import java.util.Enumeration; import java.util.Enumeration;
public class CustomKeyStoreSpi extends KeyStoreSpi { public class CustomKeyStoreSpi extends KeyStoreSpi {
public static volatile KeyStoreSpi keyStoreSpi; public static KeyStoreSpi keyStoreSpi;
@Override @Override
public Key engineGetKey(String alias, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException { public Key engineGetKey(String alias, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException {
@ -23,16 +24,10 @@ public class CustomKeyStoreSpi extends KeyStoreSpi {
@Override @Override
public Certificate[] engineGetCertificateChain(String alias) { public Certificate[] engineGetCertificateChain(String alias) {
EntryPoint.LOG("Tried to get certificate chain, throwing exception!");
if (EntryPoint.isDroidGuard()) {
EntryPoint.LOG("engineGetCertificateChain call! Throw exception");
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
return keyStoreSpi.engineGetCertificateChain(alias);
}
@Override @Override
public Certificate engineGetCertificate(String alias) { public Certificate engineGetCertificate(String alias) {
return keyStoreSpi.engineGetCertificate(alias); return keyStoreSpi.engineGetCertificate(alias);

View File

@ -2,7 +2,7 @@ package es.chiteroman.playintegrityfix;
import java.security.Provider; import java.security.Provider;
public class CustomProvider extends Provider { public final class CustomProvider extends Provider {
public CustomProvider(Provider provider) { public CustomProvider(Provider provider) {
super(provider.getName(), provider.getVersion(), provider.getInfo()); super(provider.getName(), provider.getVersion(), provider.getInfo());
@ -14,7 +14,6 @@ public class CustomProvider extends Provider {
@Override @Override
public synchronized Service getService(String type, String algorithm) { public synchronized Service getService(String type, String algorithm) {
EntryPoint.LOG("[SERVICE] Type: " + type + " | Algorithm: " + algorithm);
EntryPoint.spoofDevice(); EntryPoint.spoofDevice();

View File

@ -1,66 +1,58 @@
package es.chiteroman.playintegrityfix; package es.chiteroman.playintegrityfix;
import android.os.Build; import android.os.Build;
import android.util.JsonReader;
import android.util.Log; import android.util.Log;
import java.io.StringReader; import org.json.JSONException;
import org.json.JSONObject;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.security.KeyStore;
import java.security.KeyStoreSpi;
import java.security.Provider; import java.security.Provider;
import java.security.Security; import java.security.Security;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public class EntryPoint { public final class EntryPoint {
private static final Map<String, String> map = new HashMap<>(); private static JSONObject jsonObject = new JSONObject();
public static void init(String json) { public static void init(String json) {
try (JsonReader reader = new JsonReader(new StringReader(json))) { try {
reader.beginObject(); jsonObject = new JSONObject(json);
while (reader.hasNext()) { } catch (JSONException e) {
String key = reader.nextName(); LOG("Couldn't parse JSON from Zygisk");
String value = reader.nextString();
map.put(key, value);
}
reader.endObject();
} catch (Exception e) {
LOG("Error parsing JSON: " + e);
} }
LOG("Map info (keys and values):"); boolean FORCE_BASIC_ATTESTATION = true;
map.forEach((s, s2) -> LOG(String.format("[%s] -> %s", s, s2)));
if (jsonObject.has("FORCE_BASIC_ATTESTATION")) {
try {
FORCE_BASIC_ATTESTATION = jsonObject.getBoolean("FORCE_BASIC_ATTESTATION");
} catch (JSONException e) {
LOG("Couldn't parse FORCE_BASIC_ATTESTATION from JSON");
}
jsonObject.remove("FORCE_BASIC_ATTESTATION");
}
spoofDevice(); spoofDevice();
spoofProvider();
if (FORCE_BASIC_ATTESTATION) spoofProvider();
} }
protected static void LOG(String msg) { static void LOG(String msg) {
Log.d("PIF/Java", msg); Log.d("PIF/Java", msg);
} }
protected static void spoofDevice() { static void spoofDevice() {
map.forEach(EntryPoint::setFieldValue); jsonObject.keys().forEachRemaining(s -> {
try {
Object value = jsonObject.get(s);
setFieldValue(s, value);
} catch (JSONException ignored) {
} }
});
protected static boolean isDroidGuard() {
return Arrays.stream(Thread.currentThread().getStackTrace()).anyMatch(e -> e.getClassName().toLowerCase(Locale.US).contains("droidguard"));
} }
private static void spoofProvider() { private static void spoofProvider() {
try { try {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
Field field = keyStore.getClass().getDeclaredField("keyStoreSpi");
field.setAccessible(true);
CustomKeyStoreSpi.keyStoreSpi = (KeyStoreSpi) field.get(keyStore);
field.setAccessible(false);
Provider provider = Security.getProvider("AndroidKeyStore"); Provider provider = Security.getProvider("AndroidKeyStore");
Provider customProvider = new CustomProvider(provider); Provider customProvider = new CustomProvider(provider);
@ -70,14 +62,18 @@ public class EntryPoint {
LOG("Spoof KeyStoreSpi and Provider done!"); LOG("Spoof KeyStoreSpi and Provider done!");
} catch (Exception e) { } catch (Throwable t) {
LOG("spoofProvider exception: " + e); LOG("spoofProvider exception: " + t);
} }
} }
private static void setFieldValue(String name, String value) { private static void setFieldValue(String name, Object value) {
if (name == null || value == null || name.isEmpty() || value.isEmpty()) return; if (name == null || value == null || name.isEmpty()) return;
if (value instanceof String str) if (str.isEmpty() || str.isBlank()) return;
Field field = null; Field field = null;
try { try {
field = Build.class.getDeclaredField(name); field = Build.class.getDeclaredField(name);
} catch (NoSuchFieldException e) { } catch (NoSuchFieldException e) {
@ -87,16 +83,23 @@ public class EntryPoint {
LOG("Couldn't find field: " + e); LOG("Couldn't find field: " + e);
} }
} }
if (field == null) return; if (field == null) return;
field.setAccessible(true); field.setAccessible(true);
String oldValue = null;
try { try {
oldValue = (String) field.get(null);
if (value.equals(oldValue)) return; Object oldValue = field.get(null);
if (!value.equals(oldValue)) {
field.set(null, value); field.set(null, value);
} catch (IllegalAccessException e) { LOG("Set [" + name + "] field value to [" + value + "]");
LOG("Couldn't get or set field: " + e);
} }
LOG(String.format("Field '%s' with value '%s' is now set to '%s'", name, oldValue, value));
} catch (IllegalAccessException e) {
LOG("Couldn't modify field :(");
}
field.setAccessible(false);
} }
} }

View File

@ -2,6 +2,6 @@ We have a Telegram channel!
If you want to share your knowledge join: If you want to share your knowledge join:
https://t.me/playintegrityfix https://t.me/playintegrityfix
# v14.9 # v15.0
- Fix DEVICE verdict not passing with some fingerprints. - Fix issues.

View File

@ -1,32 +0,0 @@
resetprop_if_diff() {
local NAME=$1
local EXPECTED=$2
local CURRENT=$(resetprop $NAME)
[ -z "$CURRENT" ] || [ "$CURRENT" == "$EXPECTED" ] || resetprop $NAME $EXPECTED
}
resetprop_if_match() {
local NAME=$1
local CONTAINS=$2
local VALUE=$3
[[ "$(resetprop $NAME)" == *"$CONTAINS"* ]] && resetprop $NAME $VALUE
}
# Avoid breaking Realme fingerprint scanners
resetprop_if_diff ro.boot.flash.locked 1
# Avoid breaking Oppo fingerprint scanners
resetprop_if_diff ro.boot.vbmeta.device_state locked
# Avoid breaking OnePlus display modes/fingerprint scanners
resetprop_if_diff vendor.boot.verifiedbootstate green
# Avoid breaking OnePlus/Oppo display fingerprint scanners on OOS/ColorOS 12+
resetprop_if_diff ro.boot.verifiedbootstate green
resetprop_if_diff ro.boot.veritymode enforcing
resetprop_if_diff vendor.boot.vbmeta.device_state locked
# Restrict permissions to socket file
chmod 440 /proc/net/unix

View File

@ -52,7 +52,7 @@ if [ -d "/system/app/EliteDevelopmentModule" ]; then
ui_print "- EliteDevelopmentModule app removed." ui_print "- EliteDevelopmentModule app removed."
fi fi
# Move pif.json file # Move empty pif.json file
if [ ! -f "/data/adb/pif.json" ]; then if [ ! -f "/data/adb/pif.json" ]; then
mv -f $MODPATH/pif.json /data/adb/ mv -f $MODPATH/pif.json /data/adb/
fi fi

View File

@ -1,7 +1,7 @@
id=playintegrityfix id=playintegrityfix
name=Play Integrity Fix name=Play Integrity Fix
version=v14.9 version=v15.0
versionCode=14900 versionCode=15000
author=chiteroman author=chiteroman
description=Universal modular fix for Play Integrity (and SafetyNet) on devices running Android 8+. description=Universal modular fix for Play Integrity (and SafetyNet) on devices running Android 8+.
updateJson=https://raw.githubusercontent.com/chiteroman/PlayIntegrityFix/main/update.json updateJson=https://raw.githubusercontent.com/chiteroman/PlayIntegrityFix/main/update.json

View File

@ -5,7 +5,8 @@
"BRAND": "", "BRAND": "",
"MODEL": "", "MODEL": "",
"FINGERPRINT": "", "FINGERPRINT": "",
"FIRST_API_LEVEL": 21,
"SECURITY_PATCH": "", "SECURITY_PATCH": "",
"BUILD_ID": "" "ID": "",
"FIRST_API_LEVEL": 24,
"FORCE_BASIC_ATTESTATION": true
} }

View File

@ -32,29 +32,21 @@ if [ "$(toybox cat /sys/fs/selinux/enforce)" == "0" ]; then
chmod 440 /sys/fs/selinux/policy chmod 440 /sys/fs/selinux/policy
fi fi
# KernelSU handles boot completed state in different file. # late props which must be set after boot_completed for various OEMs
if [ -z "$KSU" ] || [ "$KSU" = false ]; then until [ "$(resetprop sys.boot_completed)" == "1" ]; do
{
# late props which must be set after boot_completed for various OEMs
until [ "$(resetprop sys.boot_completed)" == "1" ]; do
sleep 1 sleep 1
done done
# Avoid breaking Realme fingerprint scanners # Avoid breaking Realme fingerprint scanners
resetprop_if_diff ro.boot.flash.locked 1 resetprop_if_diff ro.boot.flash.locked 1
# Avoid breaking Oppo fingerprint scanners
resetprop_if_diff ro.boot.vbmeta.device_state locked
# Avoid breaking OnePlus display modes/fingerprint scanners
resetprop_if_diff vendor.boot.verifiedbootstate green
# Avoid breaking OnePlus/Oppo display fingerprint scanners on OOS/ColorOS 12+
resetprop_if_diff ro.boot.verifiedbootstate green
resetprop_if_diff ro.boot.veritymode enforcing
resetprop_if_diff vendor.boot.vbmeta.device_state locked
# Avoid breaking Oppo fingerprint scanners # Restrict permissions to socket file
resetprop_if_diff ro.boot.vbmeta.device_state locked chmod 440 /proc/net/unix
# Avoid breaking OnePlus display modes/fingerprint scanners
resetprop_if_diff vendor.boot.verifiedbootstate green
# Avoid breaking OnePlus/Oppo display fingerprint scanners on OOS/ColorOS 12+
resetprop_if_diff ro.boot.verifiedbootstate green
resetprop_if_diff ro.boot.veritymode enforcing
resetprop_if_diff vendor.boot.vbmeta.device_state locked
# Restrict permissions to socket file
chmod 440 /proc/net/unix
}&
fi

View File

@ -1,6 +1,6 @@
{ {
"version": "v14.9", "version": "v15.0",
"versionCode": 14900, "versionCode": 15000,
"zipUrl": "https://github.com/chiteroman/PlayIntegrityFix/releases/download/v14.9/PlayIntegrityFix_v14.9.zip", "zipUrl": "https://github.com/chiteroman/PlayIntegrityFix/releases/download/v15.0/PlayIntegrityFix_v15.0.zip",
"changelog": "https://raw.githubusercontent.com/chiteroman/PlayIntegrityFix/main/changelog.md" "changelog": "https://raw.githubusercontent.com/chiteroman/PlayIntegrityFix/main/changelog.md"
} }