Taking apart Infatica
Infatica is a residential proxy service. Their supply comes from an SDK embedded in free apps, so every phone that installs one becomes an exit node.
Carriers
| package | sample |
|---|---|
com.chucklefish.stardewvalley |
Stardew_Valley_v1.6.15.3-v8_UnlimitedMoney.apk |
com.kiloo.subwaysurf |
Subway Surfers Mod v3.66.0 - androforever.apk |
com.nekki.shadowfight |
shadow-fight-2-mod-2.46.0-v7v8_RBMODS.apk |
com.fgol.HungrySharkEvolution |
hungry-shark-evolution-mod-14.3.0-an1.com.apk |
com.ubisoft.hungrysharkworld |
Hungry-Shark-World-New_v8.0.2.apk |
com.Neurononfire.SupremeDuelist |
supreme-duelist-stickman-mod_4.0.5-566-an1.com.apk |
com.dogbytegames.offtheroad |
off-the-road-otr-open-world-driving-1.18-mod.apk |
com.roman.protectvpn |
com.roman.protectvpn_1.4.4.xapk |
com.lunara.audiobooks, com.swoon.novels, com.fmproject.lovella |
audiobook/novel cluster |
com.test.testsdk |
app-release.apk |
Cracked games off an1.com, 5play, FarsRoid, repackaged with the SDK injected. Chucklefish, Kiloo,
Nekki and Ubisoft integrated nothing. Whoever rebuilt the APK holds the partnerID and collects.
This breaks the consent chain in two places. The publisher never agreed to sell its users’ bandwidth, and the user thinks they installed a game with the ads stripped out. Infatica’s own marketing frames the SDK as a revenue share with app developers, but a large part of the supply here is warez traffic monetized by whoever runs the repack. The rest of the set is a cluster of near-identical romance and audiobook apps that look like one operator’s fleet, plus one VPN.
I pulled ProtectVPN apart. It is an XAPK, and the SDK ships in config.arm64_v8a.apk:
| layer | what |
|---|---|
com.infatica.agent.service.Service |
Kotlin foreground service |
libservice.so |
JNI stub |
libagent.so |
Go, the proxy engine |
The Kotlin is unobfuscated. The Go is stripped of DWARF, but .go.buildinfo and pclntab are intact, so 284 symbols survive by name:
$ go version -m libagent.so
libagent.so: go1.24.4
path android_sdk
mod android_sdk v0.0.0-20251006114713-ad1765d67be8+dirty # 2025-10-06
dep github.com/flynn/noise v1.1.0
dep github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
build -buildmode=c-shared GOOS=android GOARCH=arm64
op/go-logging is still linked, so every path carries its format string.
On the phone
// com.infatica.agent.service.Service
private external fun create(rwParamPath: String, apiVersion: Int, partnerID: String)
private external fun destroy()
public external fun getId(): String
public external fun networksThread()
private external fun start()
private external fun stop()
// init { System.loadLibrary("service") }
$ nm -D libservice.so | grep Java_
Java_com_infatica_agent_service_Service_create -> InfaticaAgentCreate
Java_com_infatica_agent_service_Service_start -> InfaticaAgentStart
Java_com_infatica_agent_service_Service_stop -> InfaticaAgentStop
Java_com_infatica_agent_service_Service_destroy -> InfaticaAgentDestroy
Java_com_infatica_agent_service_Service_getId -> InfaticaAgentId
Java_com_infatica_agent_service_Service_networksThread -> InfaticaAgentNetworksThread
libagent.so exports eight. InfaticaAgentRunning and InfaticaAgentStatus have no thunk.
Integration is one call: start the service with a partnerID. EXTRA_PARTNER_ID ->
create(paramPath, apiVersion, partnerID) -> attached to every traffic report. Residency is
askIgnoreBatteryOptimizations, a PARTIAL_WAKE_LOCK, and FOREGROUND_SERVICE_TYPE_SPECIAL_USE:
// onStartCommand
if (Build.VERSION.SDK_INT >= 34) i = 1073741824; // 0x40000000 SPECIAL_USE
else if (Build.VERSION.SDK_INT >= 29) i = -1; // TYPE_MANIFEST
ServiceCompat.startForeground(this, intExtra, notification, i);
A bound Messenger answers one message, MSG_WHAT_GET_ID.
networksThread() profiles every interface first. getAllNetworks() filtered on
NET_CAPABILITY_INTERNET, each one reduced to {type, dns, active}:
int i = networkCapabilities.hasTransport(0) ? 1 : 0; // 0x1 cellular
if (networkCapabilities.hasTransport(1)) i |= 2; // 0x2 wifi
if (networkCapabilities.hasTransport(4)) i |= 4; // 0x4 vpn
networks(): [{"type":1,"dns":["10.13.37.1"],"active":true},
{"type":2,"dns":["192.168.1.1"],"active":false}]
Cleartext Log.d, with the interface’s DNS servers included. It amounts to an inventory sheet: the
operator learns whether an exit is cellular or wifi, whether a VPN is up, which interface is active,
and which resolvers the device trusts. Mobile IPs sell at a premium over residential wifi, so the
transport bitmask carries real commercial value.
NodeId is minted once by param_rw.NewRandomNodeId, then pinned:
server=%v: RX: CONNECT_RESPONSE_V4: trying to change NodeId, this is not supported
Stored via DataStoreBridge (cryptowrap_datastore), sealed by KeystoreBridge:
AES/GCM/NoPadding, 256-bit, AndroidKeyStore alias app_wrap_key, 128-bit tag. Failure is
non-fatal:
Keystore wrapping failed, using unwrapped key: %v
The node id is the billing identity, so rather than lose a paying node when the keystore is unreadable, they fall back to storing the key in plaintext.
Engine
| package | role |
|---|---|
internal/agent |
Agent.Init/Start/Stop/Status/run behind the exports |
internal/servers |
RouterServer, Server, ServerConnection, Servers |
internal/serverlist |
HandleExtraServersList |
internal/serveraddr |
ServerAddr[ConfAddr=%v, Ip=%s, Port=%v, IpVer=%v] |
internal/encryption |
Noise transport, key cache, MiniCert |
internal/param_rw |
NodeId, encrypted params on disk |
internal/param_ro |
embedded read-only config |
internal/dns_cache |
resolver and dialer for customer targets |
internal/routine |
goroutine lifecycle |
internal/logger |
op/go-logging wrapper |
Bootstrap gateways out of param_ro, three domains, two ports each:
node.flowmediaworks.com:8885 node.flowmediaworks.com:8887
node.playflowtech.com:8885 node.playflowtech.com:8887
node.castflowzone.com:8885 node.castflowzone.com:8887
Framed, single-byte command, payload inside Noise. Handlers:
servers.(*ServerConnection).handleProxyRxConnResp
servers.(*ServerConnection).handleProxyRxConnRespV4
servers.(*ServerConnection).handleProxyRxCommReq
servers.(*ServerConnection).handleProxyRxCommClosed
servers.(*ServerConnection).handleProxyRxPing
servers.(*ServerConnection).handleProxyRxSetLogEnabled
servers.(*ServerConnection).handleBroadcastStatusRequest
servers.(*ServerConnection).handleStartNode
servers.(*ServerConnection).handleStopNode
| command | direction | meaning |
|---|---|---|
CONNECT_V4 |
node to router | announce, carries partner, apiVersion |
CONNECT_RESPONSE_V4 |
router to node | { id, extra-servers, dns-list, capabilities } |
START_NODE / STOP_NODE |
router to node | turn this device on or off as an exit |
TCP_COMMUTATE_REQUEST |
router to node | { reqId, host, port }, dial this target |
TCP_COMMUTATE_RESPONSE_V3 / TCP_COMMUTATION_CLOSED |
both | result / teardown |
BROADCAST_STATUS_REQUEST / PING / SET_LOG_ENABLED |
router to node | status / keepalive / log toggle |
Connect is V4, the commutate response is still V3, CONNECT_RESPONSE_V3 is handled alongside _V4.
server=%v: RX: CONNECT_RESPONSE_V4: id=%v, extra-servers=%v, dns-list=%v, capabilities=%v
server=%v: RX: START_NODE command received
server=%v: RX: handling packed PING message after STOP_NODE (without protocol header)
START_NODE and STOP_NODE are the whole relationship. The device announces itself, then waits to be
switched on as live inventory, and nothing in the client decides when that happens. CONNECT_RESPONSE
programs the rest of it in one message: the pinned id, the gateway list, the resolvers, the
capability set.
dns-list is whatever the router sends, so every customer lookup resolves at the exit through
resolvers the operator picks. extra-servers is merged by serverlist.HandleExtraServersList,
persisted by param_rw.Storage.SetExtraServers, live connections kept:
Node state: restarting extra server connections
Closing unnecessary extra server connection to %s
Keeping existing extra server connection to %s
RouterServer holds one active gateway, the rest ranked by priority, two goroutines between them:
servers.(*RouterServer).runFailoverMonitor -> isServerHealthy -> switchToNextServer
servers.(*RouterServer).runPriorityHealthChecker -> checkHigherPriorityServers -> switchToServer
servers.(*RouterServer).healthCheckPort
servers.(*RouterServer).healthCheckLegacy
Two health-check implementations, mid-migration. This failover layer is the most carefully engineered part of the client.
Relay is two connections: openTarget to the customer’s destination, openSlave back to the gateway,
proxyBetween copying through copyWithTrafficCount. TCP CONNECT only, the only UDP in the binary is
the resolver. isTargetBlackListed runs first, on IsPrivate, IsLoopback, IsLinkLocalUnicast,
IsLinkLocalMulticast, so RFC1918 and 169.254/16 (cloud metadata included) are unreachable:
reqId=%v: Target denied (type %v): '%s' (%s:%v)
reqId=%v: target to slave copy completed
reqId=%v: both copy operations completed
Billing is metered on the device, keyed to partnerID:
Traffic report: sending TRAFFIC_REPORT - period=%ds, bytes_in=%d, bytes_out=%d, connections=%d
Traffic stats: traffic_counter_not_sent=%d bytes, total_traffic_counter=%d bytes, active_connections=%d
Traffic report: failed to send, keeping counters
Counters live on the device, survive a failed send, and are what the publisher gets paid on. The gateway sees the same bytes pass through it, so the numbers are verifiable, but the authoritative count still sits on the untrusted end of the link.
Crypto, 2025
encryption.PerformXXHandshake encryption.NewNoiseKeyCacheManager
encryption.PerformIKHandshake encryption.(*NoiseKeyCacheManager).Load/Save/Clear
encryption.VerifyMiniCertificate encryption.(*NoiseKeyCache).IsValid
encryption.VerifyMiniCertificateWithEmbeddedKey
encryption.GetEmbeddedRootPublicKey encryption.MaskKey
encryption.(*MiniCert).ToNoiseKeyCache encryption.getOSSpecificCacheDir
XX on first contact, gateway static key cached in noise_key_cache_%s_%s.dat under a KID, IK on
reconnect. Curve25519, AES-GCM, SHA-256, 2-byte length-prefixed frames:
XX handshake: using embedded root public key: %s
XX handshake successful, cached new key (KID=%d)
Attempting IK handshake with cached key (KID=%d)
Invalid cached key detected, clearing cache and retrying with KID=0 (XX handshake)
Handshake PeerStatic: %s
MiniCert is their own pinning scheme: embedded ed25519 root validates a per-gateway cert bound into
the handshake. Fields Gateway, Region, RegionAlloc, Serial, NotBefore, NotAfter,
PublicKey, so the node cryptographically learns each gateway’s region.
encryption.GetEmbeddedRootPublicKey at 0x309df4 is 320 bytes of Keccak-style bit-mixing under
MaskKey, derived at runtime, not dumpable statically.
The region binding is the part worth noting. A gateway key on its own proves nothing about where that gateway is, so pinning the region into the cert means a key lifted from one region cannot be reused to serve another. That is a meaningful control when the product being sold is geographic location.
The plaintext path never went away, though. Both format strings are still in the 2025 build:
Connecting to %s:%d with encryption
Connecting to %s:%d
Windows agent, 2023
MediaGet bundle, file dated 2023-10-27, unstripped:
$ go version -m infatica_agent.exe
infatica_agent.exe: go1.21.1
path infatica_agent
dep github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
dep gopkg.in/natefinch/lumberjack.v2 v2.0.0
build -ldflags=-H=windowsgui CGO_ENABLED=0 GOOS=windows GOARCH=386
C:/am/proxy_infatica/agent/agent/{dns_cache, logger, logstream, param_ro, param_rw, serverlist, servers}
Same engine, one generation back: ServerConnection with runRx/runTx, TargetConnection with
openTarget/openSlave/proxyBetween, isTargetBlackListed, Servers.applyServerList.
No Noise, no MiniCert, no TLS, no handshake. Only crypto symbols are Go’s map hashing and one
param_rw.encrypt for config at rest. infatica_agent.dat is XOR-obfuscated. Wire protocol is
cleartext, and the client logs its own frames:
102.apiserv.org:8886
server=%v: sending CONNECT_V2
server=%v: RX: CONNECT_RESPONSE_V2: id=%v, extra-servers=%v
reqId=%v: slave connection: Connected, OPEN_SLAVE sent.
reqId=%v: Target denied: '%s' (%s)
server=%v: TX: %x
server=%v: RX: %x
V2, one hardcoded gateway, no TRAFFIC_REPORT, no partner id anywhere, which fits a first-party
bundle with nobody to pay. Plus a logstream package the Android build dropped, shipping the client’s
own log out, remote-toggled by SET_LOG_ENABLED:
infatica_agent/logstream.(*LogStream).connect
infatica_agent/logstream.(*LogBackend).Log
LogStream: can't connect (reconnect) to proxy: %v
LogStream: send to proxy failed (after reconnect): %v
LogStream: tx buffer overflow
Same packages, same relay, same blacklist, two years apart. The engine has been stable for years and the transport security is the new part. In 2023 the desktop client announced itself in the clear over raw TCP, logged its own frames as hex, and hid its state with XOR. By 2025 the Android client wrapped everything in Noise, pinned every gateway to an embedded root, and kept a key cache keyed by KID. What changed over those two years is not the proxy engine, it is how much harder the client is to observe.
Domains
chtsite.com and apiserv.org for desktop, flowmediaworks.com, playflowtech.com,
castflowzone.com for mobile. The two sets are built on opposite designs.
The desktop side is permanent. Registered 2020-11-24, on King Servers space since 2020-12-01, and still there. The reverse record gives it away:
$ nmap -sV 185.162.130.54
Nmap scan report for customer.clientshostname.com (185.162.130.54)
88/tcp open kerberos-sec?
89/tcp open ssl/su-mit-tg?
$ nmap -sV 1.chtsite.com 2.chtsite.com 103.chtsite.com
185.159.80.149 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 222/tcp open ssh
185.159.80.150 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 222/tcp open ssh
185.223.94.75 8080/tcp open http Golang net/http server 8085/tcp open http
1.chtsite.com 185.159.80.149 101.chtsite.com 185.159.80.149
2.chtsite.com 185.159.80.150 102.chtsite.com 185.159.80.150
3.chtsite.com 185.159.80.151 103.chtsite.com 185.223.94.75
N and 100+N mostly alias one host, 3/103 don’t. No HTTP on the frontline gateways, Go
net/http on the router side.
The mobile side is disposable. The three domains were registered three seconds apart on Porkbun:
FLOWMEDIAWORKS.COM registration=2025-09-24T14:23:11Z Porkbun LLC
PLAYFLOWTECH.COM registration=2025-09-24T14:23:10Z Porkbun LLC
CASTFLOWZONE.COM registration=2025-09-24T14:23:12Z Porkbun LLC
2025-09-24 44.230.85.241, 52.33.207.7 AWS us-west-2, PTR *.gr7.us-west-2.eks.amazonaws.com
2026-07-28 207.207.210.36 RDAP registrant Porkbun, LLC (parked)
2026-09-15 207.207.210.23, .50 parked
EKS while live, parked after. The node never needs a stable gateway name, because the router pushes a fresh list at runtime. That is what makes the mobile side disposable: burn the domains, push new ones to the fleet, nothing on the phone has to change.
Everything durable sits on one ASN. RDAP netname NL-KING-SERVERS, org Hosting Solution Ltd, registered to
Meppel NL, prefixes geolocating EE and SG:
185.162.130.83 AS14576 185.162.130.0/24 EE HOSTING-SOLUTIONS - Hosting Solution Ltd.
185.209.162.36 AS14576 185.209.162.0/24 EE HOSTING-SOLUTIONS - Hosting Solution Ltd.
103.152.136.37 AS14576 103.152.136.0/24 SG HOSTING-SOLUTIONS - Hosting Solution Ltd.
45.159.191.35 AS14576 45.159.188.0/24 EE HOSTING-SOLUTIONS - Hosting Solution Ltd.
Infrastructure
$ nuclei -u https://dashboard.infatica.io
[ioncube-loader-wizard] [http] [medium] https://dashboard.infatica.io/loader-wizard.php
[htpasswd-detection] [http] [high] https://dashboard.infatica.io/.htpasswd
It serves its own .htaccess too. That file is the reseller API, endpoint by endpoint:
#Reseller API
RewriteRule ^(.*)includes/api/reseller/package/(.*)/prolongate/?$ .../prolongate.php?key=$2 [R=307,L]
RewriteRule ^(.*)includes/api/reseller/package/(.*)/suspend/?$ .../suspend.php?key=$2 [R=307,L]
RewriteRule ^(.*)includes/api/reseller/package/(.*)/generate/?$ .../generate.php?key=$2 [R=307,L]
RewriteRule ^(.*)includes/api/reseller/package/(.*)$ .../package-info.php?key=$2 [R=301,L]
RewriteRule ^/?includes/api/reseller/isps/(.*)/(.*)/(.*)$ .../isps.php?country=$2®ion=$3&city=$4
RewriteRule ^/?includes/api/reseller/mobile-nodes/?$ .../mobile-nodes.php [R=307,L]
RewriteRule ^/?includes/api/reseller/nodes-info/?$ .../nodes-info.php [R=301,L]
#End Reseller API
Package lifecycle, proxy-list generation, targeting down to country/region/city/ISP, package key in a
query parameter through a 307, and a 301 on package-info. The exempt paths name themselves:
RewriteCond %{REQUEST_URI} !^(.*)(admin|network-types|pythonparser|ghost|includes/api)(.*)
And the auth blocks:
<Files "_adm.php"> AuthType Basic AuthUserFile .../.htpasswd Require valid-user </Files>
<Files "docs.php"> AuthType Basic AuthUserFile .../.htpasswd Require valid-user </Files>
<Files "/var/www/www-root/data/www/dashboard.infatica.io/file-download/infatica-agent-mediaget.zip">
AuthType Basic
AuthUserFile /var/www/www-root/data/www/dashboard.infatica.io/file-download/.htpasswd
Require valid-user
</Files>
First two work. Third doesn’t: <Files> matches a basename, never a path, so the directive matches
nothing and the agent download it is meant to gate is unauthenticated. The .htpasswd it points at is
served off the same web root.
Rest of the estate:
wp.infatica.io 185.223.95.47 401 nginx/1.10.3 (Ubuntu 16.04 vintage)
tmp.infatica.io 185.223.95.47 401 nginx/1.10.3
scrape.infatica.io 185.162.128.84 401 nginx
wpmcp.infatica.io 103.152.136.21 401 nginx/1.31.4
proxychecker.infatica.io 185.209.162.36 200 "Proxy checker"
benchmark.internal.infatica.io 103.152.136.57 - leaked via certificate transparency
What it opens up
The choices above are not just sloppy. Each one creates a specific attack.
The node owner carries the abuse. Relaying is router-directed: TCP_COMMUTATE_REQUEST names
a host and port, the node dials it. The only client-side guard is isTargetBlackListed, and it blocks
private space (IsPrivate, IsLoopback, IsLinkLocalUnicast, IsLinkLocalMulticast) so the pool
can’t be aimed back at Infatica’s own networks. It does nothing for the node owner. Every public host
on v4 and v6 is reachable, and whatever the customer does, scraping, credential stuffing, fraud, lands
on the phone owner’s residential IP. The guard protects the operator, not the person carrying it.
The operator sees and can steer customer DNS. dns-list is pushed in CONNECT_RESPONSE, and the
node resolves every customer target through those resolvers before isTargetBlackListed sees an
address. Whoever runs the router, or anyone who takes a gateway, logs every lookup a customer makes and
can answer it with an IP of their choosing. That is resolution steering at the exit, on traffic the
customer believes is only being tunnelled.
Billing is forgeable, because it is metered on the device. TRAFFIC_REPORT carries the byte
counters the publisher is paid on, and it is generated on the untrusted end. The SDK is already being
injected into repacks by third parties who hold the partnerID; a carrier that inflates bytes_out
before it reports is the same kind of edit, one field further along. The gateway forwards the same bytes and could
reconcile, but nothing in the protocol forces it to.
One misconfig chain reaches the dashboard. .htpasswd is served straight off the web root
(nuclei, high). That same file gates _adm.php and docs.php, so the exposed hash is an offline crack
away from the admin and docs pages it is supposed to protect. The broken <Files> rule throws in the
agent binary unauthenticated on top.
Reseller keys travel in the query string. Every reseller route redirects with ?key=$2 through a
307, and package-info through a cacheable 301. The key lands in the nginx access log, the Referer,
any intermediary, and a cache. A single leaked key drives the whole reseller API, create, suspend,
prolong, generate, so key theft is package fraud and account takeover.
The .htaccess is the recon map. Serving it hands over the entire internal endpoint list and the
paths they tried to hide in the same breath (admin, ghost, pythonparser, network-types). There
is nothing left to guess.