writing
repo

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&region=$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.