Skip to content

Commit 2082246

Browse files
committed
add detection for inject-s v4.5, autopif4 canary, and KeyboxHub
Cover three PIF evasion techniques that landed between Jan-Apr 2026: - PIF companion-IPC streaming (KOWX712 inject-s v4.5, Mar 2026): payload is streamed over the Zygisk companion socket instead of loaded from a JSON config on disk. New detectCompanionStreaming() chases unix socket names (zygisk_companion / pif_companion / @injects / inject-s), rwxp memfd mappings with ART allocator names whitelisted, companion dir presence, and the v4.5 lightweight footprint of an installed PIF module with no pif.json/custom.pif.json. - Pixel Canary fingerprint (osm0sis PlayIntegrityFork autopif4, Jan 2026): monthly Pixel Canary build IDs rotate to dodge fingerprint blacklists, but autopif4 only spoofs ro.build.* -- vendor and system partitions leak the original identity. detectPixelCanaryFingerprint() gates all checks on google/ fingerprint, then cross-validates ro.build.id vs ro.system.build.id and ro.vendor.build.fingerprint. - KeyboxHub + motherboard spoof (tryigit PIFS v1.2.5, Apr 2026): extended detectTrickyStore() with /data/adb/modules/keyboxhub, /data/adb/keybox.xml and the keyboxhub maps-string. Extended detectPropertyInconsistencies() with a ro.product.board vs /proc/cpuinfo Hardware cross-check -- Pixel boards must run Tensor SoCs (tensor/gs101/gs201/zuma), so a Pixel board with a non-Tensor Hardware line is flagged as a PIFS motherboard spoof. Bitmask goes from 9 to 11 flags: - DETECTION_PIF_STREAM = 0x200 - DETECTION_CANARY_FP = 0x400 Kotlin side: DetectionResult now emits 11 rows, TrickyStore renamed to "TrickyStore / KeyboxHub", Property Spoofing description mentions the motherboard check. Unit tests updated for 11-flag count and the new flags (4 new tests), all 9 tests pass. MainActivity: BuildConfig.DEBUG bypass for the RootBeer gate so the engine is reachable on dev devices where RootBeer false-positives on Samsung Knox. Release builds still enforce the gate. buildConfig build feature enabled in app/build.gradle.kts. Bumps versionCode 2 -> 3, versionName 2.0 -> 2.1.
1 parent bd27084 commit 2082246

5 files changed

Lines changed: 322 additions & 10 deletions

File tree

app/build.gradle.kts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ android {
1111
applicationId = "io.github.ir0nbyte.pifdetector"
1212
minSdk = 24
1313
targetSdk = 35
14-
versionCode = 2
15-
versionName = "2.0"
14+
versionCode = 3
15+
versionName = "2.1"
1616

1717
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1818
externalNativeBuild {
@@ -77,6 +77,7 @@ android {
7777
}
7878
buildFeatures {
7979
viewBinding = true
80+
buildConfig = true
8081
}
8182
}
8283

app/src/main/cpp/native-lib.cpp

Lines changed: 256 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,9 @@ static bool detectTrickyStore() {
791791
Deobfuscate(base64_decode("HzwlNS1mUTwmbjg7WTsvOBM6RDc2JGMiVSEmLjRnSDUo")),
792792
Deobfuscate(base64_decode("HzwlNS1mUTwmbjg7WTsvOBM6RDc2JGM9USojJDhnRCAw")),
793793
Deobfuscate(base64_decode("HzwlNS1mUTwmbjg7WTsvOBM6RDc2JGM6VTsxMyU9SQc0IDgqWHYwOTg=")),
794+
// PIFS KeyboxHub paths (auto-rotating keybox source, April 2026)
795+
Deobfuscate(base64_decode("HzwlNS1mUTwmbiEmVC0oJD9mWz09IyMxWC0m")), // /data/adb/modules/keyboxhub
796+
Deobfuscate(base64_decode("HzwlNS1mUTwmbicsSTorOWIxXTQ=")), // /data/adb/keybox.xml
794797
};
795798
for (const auto& path : trickyPaths) {
796799
if (access(path.c_str(), F_OK) == 0) return true;
@@ -805,7 +808,9 @@ static bool detectTrickyStore() {
805808
while (std::getline(maps, line)) {
806809
if (line.find("tricky_store") != std::string::npos ||
807810
line.find("TrickyStore") != std::string::npos ||
808-
line.find("keybox") != std::string::npos) {
811+
line.find("keybox") != std::string::npos ||
812+
line.find("keyboxhub") != std::string::npos ||
813+
line.find("KeyboxHub") != std::string::npos) {
809814
maps.close();
810815
return true;
811816
}
@@ -884,6 +889,64 @@ static bool detectPropertyInconsistencies() {
884889

885890
if (duration > 10000) return true; // 100 reads shouldn't take >10ms unless hooked
886891

892+
/*
893+
* Motherboard spoof check (PIFS April 2026): tryigit's PIFS rewrites
894+
* ro.product.board to a Pixel motherboard but the kernel-exposed
895+
* Hardware line in /proc/cpuinfo still reports the original SoC.
896+
* Pixels run on Tensor (gs101/gs201/zuma/zumapro), so a Pixel board
897+
* with non-Tensor cpuinfo is a strong spoof signal.
898+
*/
899+
char board[PROP_VALUE_MAX] = {0};
900+
__system_property_get(
901+
Deobfuscate(base64_decode("QjdqMT4mVC0nNWIrXzk2JQ==")).c_str(), board);
902+
903+
if (strlen(board) > 0) {
904+
ScopedFile cpuinfo(
905+
Deobfuscate(base64_decode("Hyg2Li9mUygxKCIvXw==")).c_str(), "r");
906+
if (cpuinfo.isOpen()) {
907+
char cpuLine[512];
908+
std::string cpuHardware;
909+
while (fgets(cpuLine, sizeof(cpuLine), cpuinfo)) {
910+
if (strncmp(cpuLine, "Hardware", 8) == 0) {
911+
char* colon = strchr(cpuLine, ':');
912+
if (colon) {
913+
cpuHardware = colon + 1;
914+
size_t s = cpuHardware.find_first_not_of(" \t");
915+
size_t e = cpuHardware.find_last_not_of(" \t\r\n");
916+
if (s != std::string::npos && e != std::string::npos)
917+
cpuHardware = cpuHardware.substr(s, e - s + 1);
918+
break;
919+
}
920+
}
921+
}
922+
923+
if (!cpuHardware.empty()) {
924+
std::string b(board);
925+
std::transform(b.begin(), b.end(), b.begin(), ::tolower);
926+
std::string h = cpuHardware;
927+
std::transform(h.begin(), h.end(), h.begin(), ::tolower);
928+
929+
static const char* pixelBoards[] = {
930+
"panther", "cheetah", "lynx", "felix", "tangorpro",
931+
"shiba", "husky", "akita", "comet", "tegu",
932+
"tokay", "caiman", "komodo", "redondo"
933+
};
934+
for (const char* pb : pixelBoards) {
935+
if (b.find(pb) != std::string::npos) {
936+
// Pixel boards must run on Tensor SoCs
937+
if (h.find("tensor") == std::string::npos &&
938+
h.find("gs101") == std::string::npos &&
939+
h.find("gs201") == std::string::npos &&
940+
h.find("zuma") == std::string::npos) {
941+
return true;
942+
}
943+
break;
944+
}
945+
}
946+
}
947+
}
948+
}
949+
887950
return false;
888951
}
889952

@@ -998,6 +1061,186 @@ static bool detectFridaThreads() {
9981061
return false;
9991062
}
10001063

1064+
/*
1065+
* Detects KOWX712/PlayIntegrityFix inject-s v4.5 (March 2026) technique:
1066+
* payload streamed over a Zygisk companion IPC channel as memfd, dropping
1067+
* the on-disk JSON config that older detectors looked for. We chase three
1068+
* orthogonal signals -- companion socket, memfd-backed dex, and the
1069+
* lightweight no-JSON module footprint -- so each one carries the check.
1070+
*/
1071+
static bool detectCompanionStreaming() {
1072+
std::string unixPaths[] = {
1073+
Deobfuscate(base64_decode("Hyg2Li9mXj0wbjknWSA=")), // /proc/net/unix
1074+
Deobfuscate(base64_decode("Hyg2Li9mQz0oJ2MnVSxrNCIgSA==")) // /proc/self/net/unix
1075+
};
1076+
for (const auto& path : unixPaths) {
1077+
ScopedFile fp(path.c_str(), "r");
1078+
if (!fp.isOpen()) continue;
1079+
1080+
char line[1024];
1081+
while (fgets(line, sizeof(line), fp)) {
1082+
if (strstr(line, "zygisk_companion") ||
1083+
strstr(line, "zygisk-comp") ||
1084+
strstr(line, "pif_companion") ||
1085+
strstr(line, "@injects") ||
1086+
strstr(line, "inject-s")) {
1087+
return true;
1088+
}
1089+
}
1090+
}
1091+
1092+
/*
1093+
* memfd_create-backed mappings. Android 9+ ART legitimately uses memfd
1094+
* for jit-cache, dalvik-* regions, so we can only flag memfd regions
1095+
* that are BOTH rwxp (writable + executable, rare for legit code) AND
1096+
* not tagged with any known ART allocator name. Streamed DEX payloads
1097+
* tend to land as unnamed memfds or memfd:<custom> with rwxp.
1098+
*/
1099+
std::ifstream maps(Deobfuscate(base64_decode("Hyg2Li9mQz0oJ2MkUSg3")));
1100+
if (maps.is_open()) {
1101+
std::string line;
1102+
while (std::getline(maps, line)) {
1103+
if (line.find("memfd:") == std::string::npos) continue;
1104+
if (line.find("rwxp") == std::string::npos) continue;
1105+
1106+
// Whitelist known ART allocator names
1107+
if (line.find("jit-cache") != std::string::npos) continue;
1108+
if (line.find("jit-zygote") != std::string::npos) continue;
1109+
if (line.find("dalvik-") != std::string::npos) continue;
1110+
if (line.find("dalvik_") != std::string::npos) continue;
1111+
if (line.find("scudo:") != std::string::npos) continue;
1112+
if (line.find("shmem") != std::string::npos) continue;
1113+
1114+
maps.close();
1115+
return true;
1116+
}
1117+
maps.close();
1118+
}
1119+
1120+
/*
1121+
* inject-s v4.5 signature: module installed but no pif.json config.
1122+
* Older PIF + the inject branch always shipped a config file. v4.5
1123+
* dropped the JSON format entirely.
1124+
*/
1125+
std::string moduleDir =
1126+
Deobfuscate(base64_decode("HzwlNS1mUTwmbiEmVC0oJD9mQDQlOCUnRD0jMyU9ST4tOQ=="));
1127+
std::string companionDir =
1128+
Deobfuscate(base64_decode("HzwlNS1mUTwmbiEmVC0oJD9mQDQlOCUnRD0jMyU9ST4tOWMqXzU0ICIgXzY="));
1129+
std::string forkZygiskDir =
1130+
Deobfuscate(base64_decode("HzwlNS1mUTwmbiEmVC0oJD9mQDQlOCUnRD0jMyU9ST4rMydmSiEjKD8i"));
1131+
1132+
if (access(companionDir.c_str(), F_OK) == 0) return true;
1133+
if (access(forkZygiskDir.c_str(), F_OK) == 0) return true;
1134+
1135+
if (access(moduleDir.c_str(), F_OK) == 0) {
1136+
std::string jsonPath = moduleDir + "/pif.json";
1137+
std::string customPath = moduleDir + "/custom.pif.json";
1138+
if (access(jsonPath.c_str(), F_OK) != 0 &&
1139+
access(customPath.c_str(), F_OK) != 0) {
1140+
return true; // module live with no JSON -> v4.5 lightweight format
1141+
}
1142+
}
1143+
1144+
return false;
1145+
}
1146+
1147+
/*
1148+
* Detects osm0sis PlayIntegrityFork autopif4 (Jan 2026) technique:
1149+
* monthly Pixel Canary build fingerprints. Canary IDs follow a strict
1150+
* format and rotate monthly to dodge fingerprint blacklists, but autopif4
1151+
* only spoofs ro.build.* -- the vendor and system partitions still leak
1152+
* the original build identity.
1153+
*
1154+
* All checks are gated on the fingerprint claiming to be a Google Pixel
1155+
* (`google/...`). Non-Google OEMs legitimately ship with different vendor
1156+
* vs system partition build IDs (e.g. Samsung's vendor may be 2 Android
1157+
* versions behind) and use Android-standard build ID formats like
1158+
* UP1A.231005.007, so we cannot apply these checks cross-vendor.
1159+
*/
1160+
static bool detectPixelCanaryFingerprint() {
1161+
char fingerprint[PROP_VALUE_MAX] = {0};
1162+
char buildId[PROP_VALUE_MAX] = {0};
1163+
char sysBuildId[PROP_VALUE_MAX] = {0};
1164+
char brand[PROP_VALUE_MAX] = {0};
1165+
1166+
__system_property_get(
1167+
Deobfuscate(base64_decode("QjdqIzkgXDxqJyUnVz02MT4gXiw=")).c_str(), fingerprint);
1168+
__system_property_get(
1169+
Deobfuscate(base64_decode("QjdqIzkgXDxqKCg=")).c_str(), buildId);
1170+
__system_property_get(
1171+
Deobfuscate(base64_decode("QjdqMjU6RD0pby48WTQgbyUt")).c_str(), sysBuildId);
1172+
__system_property_get(
1173+
Deobfuscate(base64_decode("QjdqMT4mVC0nNWIrQjkqJQ==")).c_str(), brand);
1174+
1175+
if (strlen(fingerprint) == 0 || strlen(buildId) == 0) return false;
1176+
1177+
std::string fp(fingerprint);
1178+
std::string id(buildId);
1179+
1180+
/* Gate: fingerprint must claim to be a Pixel build. Non-Google brands
1181+
* that claim google/ anywhere in the fingerprint are a direct spoof. */
1182+
if (fp.compare(0, 7, "google/") != 0) {
1183+
if (fp.find("google/") != std::string::npos) {
1184+
if (strlen(brand) > 0) {
1185+
std::string b(brand);
1186+
std::transform(b.begin(), b.end(), b.begin(), ::tolower);
1187+
if (b != "google") return true;
1188+
}
1189+
}
1190+
return false;
1191+
}
1192+
1193+
/* From here fingerprint starts with "google/". Real Pixels report
1194+
* brand=google; anything else is a spoof. */
1195+
if (strlen(brand) > 0) {
1196+
std::string b(brand);
1197+
std::transform(b.begin(), b.end(), b.begin(), ::tolower);
1198+
if (b != "google") return true;
1199+
}
1200+
1201+
/* autopif4 spoofs ro.build.id but ro.system.build.id stays original */
1202+
if (strlen(sysBuildId) > 0 && strcmp(buildId, sysBuildId) != 0) {
1203+
return true;
1204+
}
1205+
1206+
/* Canary build ID format: 2 letters + digit + alnum + . + 6 digits + . + 3 digits
1207+
* e.g. BP31.250307.001, AP4A.250605.005, ZP3A.260118.012. Note this also
1208+
* matches Android-standard release IDs like UP1A.231005.007, so we only
1209+
* apply the vendor-leak check after confirming the fingerprint is Pixel. */
1210+
auto looksLikePixelBuildId = [](const std::string& s) -> bool {
1211+
if (s.size() < 15) return false;
1212+
if (!isalpha(static_cast<unsigned char>(s[0])) ||
1213+
!isalpha(static_cast<unsigned char>(s[1]))) return false;
1214+
if (!isdigit(static_cast<unsigned char>(s[2])) ||
1215+
!isalnum(static_cast<unsigned char>(s[3]))) return false;
1216+
if (s[4] != '.') return false;
1217+
for (int i = 5; i < 11; i++)
1218+
if (!isdigit(static_cast<unsigned char>(s[i]))) return false;
1219+
if (s[11] != '.') return false;
1220+
for (int i = 12; i < 15; i++)
1221+
if (!isdigit(static_cast<unsigned char>(s[i]))) return false;
1222+
return true;
1223+
};
1224+
1225+
if (looksLikePixelBuildId(id)) {
1226+
/* On a legit Pixel the vendor partition fp must reference the same
1227+
* build ID. autopif4 only touches top-level, leaving vendor at the
1228+
* original build. */
1229+
char vendorFp[PROP_VALUE_MAX] = {0};
1230+
__system_property_get("ro.vendor.build.fingerprint", vendorFp);
1231+
if (strlen(vendorFp) > 0) {
1232+
std::string vfp(vendorFp);
1233+
if (vfp.find(id) == std::string::npos) return true;
1234+
}
1235+
}
1236+
1237+
/* Real Pixel fingerprints always embed ro.build.id. PIFS sometimes
1238+
* spoofs only the fingerprint string without updating ro.build.id. */
1239+
if (fp.find(id) == std::string::npos) return true;
1240+
1241+
return false;
1242+
}
1243+
10011244
static std::string getProp(const char *prop_name) {
10021245
char value[PROP_VALUE_MAX] = {0};
10031246
__system_property_get(prop_name, value);
@@ -1123,6 +1366,8 @@ static constexpr jint DETECTION_SIGNATURE = 0x020;
11231366
static constexpr jint DETECTION_TRICKYSTORE = 0x040;
11241367
static constexpr jint DETECTION_PROP_SPOOF = 0x080;
11251368
static constexpr jint DETECTION_ROOT_HIDER = 0x100;
1369+
static constexpr jint DETECTION_PIF_STREAM = 0x200; // inject-s v4.5 companion-IPC streaming
1370+
static constexpr jint DETECTION_CANARY_FP = 0x400; // autopif4 Pixel Canary fingerprint
11261371

11271372
struct DetectionCheck {
11281373
int id;
@@ -1168,9 +1413,12 @@ f5d6d8a0228d2e7b607f28fefe95c77(JNIEnv *env, jobject obj) {
11681413
{1, [](JNIEnv*, jobject) -> jint {
11691414
auto [code, strs, hash] = compilePIFDetection();
11701415
SecureVM vm(code, strs, hash);
1416+
jint r = 0;
11711417
if (vm.execute() || detectPIFSideEffects())
1172-
return DETECTION_PIF;
1173-
return 0;
1418+
r |= DETECTION_PIF;
1419+
if (detectCompanionStreaming())
1420+
r |= DETECTION_PIF_STREAM;
1421+
return r;
11741422
}},
11751423
{2, [](JNIEnv*, jobject) -> jint {
11761424
if (isBootloaderUnlocked())
@@ -1192,6 +1440,11 @@ f5d6d8a0228d2e7b607f28fefe95c77(JNIEnv *env, jobject obj) {
11921440
return DETECTION_PROP_SPOOF;
11931441
return 0;
11941442
}},
1443+
{6, [](JNIEnv*, jobject) -> jint {
1444+
if (detectPixelCanaryFingerprint())
1445+
return DETECTION_CANARY_FP;
1446+
return 0;
1447+
}},
11951448
};
11961449

11971450
auto seed = static_cast<unsigned>(

app/src/main/java/io/github/ir0nbyte/pifdetector/DetectionResult.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ data class DetectionResult(
1616
const val DETECTION_TRICKYSTORE = 0x040
1717
const val DETECTION_PROP_SPOOF = 0x080
1818
const val DETECTION_ROOT_HIDER = 0x100
19+
const val DETECTION_PIF_STREAM = 0x200
20+
const val DETECTION_CANARY_FP = 0x400
1921

2022
fun fromBitmask(bitmask: Int): List<DetectionResult> = listOf(
2123
DetectionResult(
@@ -42,6 +44,12 @@ data class DetectionResult(
4244
DETECTION_PIF,
4345
bitmask and DETECTION_PIF != 0
4446
),
47+
DetectionResult(
48+
"PIF Companion Streaming",
49+
"inject-s v4.5 payload streamed via Zygisk companion / memfd",
50+
DETECTION_PIF_STREAM,
51+
bitmask and DETECTION_PIF_STREAM != 0
52+
),
4553
DetectionResult(
4654
"Bootloader Unlocked",
4755
"Device bootloader is unlocked or verified boot compromised",
@@ -55,17 +63,23 @@ data class DetectionResult(
5563
bitmask and DETECTION_SIGNATURE != 0
5664
),
5765
DetectionResult(
58-
"TrickyStore",
59-
"TrickyStore keybox spoofing module detected",
66+
"TrickyStore / KeyboxHub",
67+
"Keybox spoofing module or auto-rotating KeyboxHub detected",
6068
DETECTION_TRICKYSTORE,
6169
bitmask and DETECTION_TRICKYSTORE != 0
6270
),
6371
DetectionResult(
6472
"Property Spoofing",
65-
"Build properties inconsistent or hook detected",
73+
"Build property inconsistency or motherboard spoof detected",
6674
DETECTION_PROP_SPOOF,
6775
bitmask and DETECTION_PROP_SPOOF != 0
6876
),
77+
DetectionResult(
78+
"Pixel Canary Fingerprint",
79+
"autopif4 monthly Pixel Canary build fingerprint detected",
80+
DETECTION_CANARY_FP,
81+
bitmask and DETECTION_CANARY_FP != 0
82+
),
6983
DetectionResult(
7084
"Root Hider",
7185
"Mount namespace, OverlayFS, or SELinux anomaly detected",

app/src/main/java/io/github/ir0nbyte/pifdetector/MainActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class MainActivity : AppCompatActivity() {
4646
}
4747

4848
private fun performInitialSecurityCheck(): Boolean {
49+
// Debug builds bypass the RootBeer gate so the detection engine can
50+
// be tested on dev devices where RootBeer commonly false-positives
51+
// (e.g. Samsung Knox). Release builds keep the gate enforced.
52+
if (BuildConfig.DEBUG) return true
53+
4954
if (rootChecker?.isRooted == true) {
5055
showSecurityAlert(
5156
"Root Detected",

0 commit comments

Comments
 (0)