28 04 2026
research: mapping whatsapp phishing farm from one tls cert
One OpenPhish URL, 205 domains, 253 live TLS IPs, and a staging block I did not expect to find.
I started this from a single OpenPhish entry on May 10:
https://cn-hht-web-whatsapp[.]com[.]cn/shop/configs/index.php
It looked like the usual WhatsApp-themed phishing domain at first.
.com.cn, city-ish naming, fake web flow. Certstream
connected, but I did not get anything useful in the short window I
watched it.
So I kept it simple: pull the live TLS cert, read the SANs, probe what was still alive, then pivot on IPs and keys.
That was enough.
SANs, Quickly
When a site has a TLS certificate, that certificate can cover more than one hostname. Those hostnames are in the Subject Alternative Name field, usually just called SAN.
A phishing cert might look like this:
Common Name: www[.]cn-hgh-web-whatsapp[.]com[.]cn
SANs:
www[.]cn-fzh-web-whatsapp[.]com[.]cn
www[.]cn-hfi-web-whatsapp[.]com[.]cn
www[.]cn-hgh-web-whatsapp[.]com[.]cn
www[.]cn-wuh-web-whatsapp[.]com[.]cn
...
That SAN list is useful because operators often issue certificates in batches. One cert can expose the rest of the batch, even if the feed only gave you one URL.
I treat the SAN list as the first graph:
- domain to certificate
- certificate to other domains
- domains to IPs
- IPs back to other certificates
- certificates grouped by public key
One domain is an IOC. The graph tells you whether it is alone or part of a bigger setup.
Pulling The First Cert
The first thing I did was connect to the seed host on 443 and parse the cert.
This is the small helper I used. It is not a framework, just enough to grab the SANs and the SPKI hash.
import hashlib
import socket
import ssl
from cryptography import x509
from cryptography.hazmat.primitives import serialization
def grab_cert(host, port=443):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=8) as s:
with ctx.wrap_socket(s, server_hostname=host) as tls:
raw = tls.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(raw)
pub = cert.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
try:
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
sans = san_ext.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
sans = []
return {
"subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(),
"cert_sha256": hashlib.sha256(raw).hexdigest(),
"spki_sha256": hashlib.sha256(pub).hexdigest(),
"sans": sans,
"days": (cert.not_valid_after_utc - cert.not_valid_before_utc).days,
}
The seed domain returned a TrustAsia LiteSSL certificate with 26 SAN entries.
Every hostname followed the same pattern:
www[.]cn-{code}-web-whatsapp[.]com[.]cn
The {code} values looked like city codes:
hgh, wuh, xmi, nbp,
and so on.
One TLS connection gave me 26 domains.
Probing The SANs
I do not like stopping at the SAN list. A cert can be stale. DNS can move. Some names can stop serving the same certificate.
So I probed all 26 SAN domains live.
san domains: 26
live tls: 21
ips seen: 3
certs seen: 2
Most still sat on:
43.226.17.54
and still served the original 26-SAN TrustAsia cert.
Five names had already moved:
www[.]cn-xmi-web-whatsapp[.]com[.]cn 43.226.17.135
www[.]cn-aqi-web-whatsapp[.]com[.]cn 43.226.17.135
www[.]cn-qzh-web-whatsapp[.]com[.]cn 38.181.10.193
www[.]cn-mas-web-whatsapp[.]com[.]cn 43.226.17.135
www[.]cn-nto-web-whatsapp[.]com[.]cn 43.226.17.135
Those five were no longer showing the original cert. They were serving a single-name Let's Encrypt certificate for:
2026[.]webfqprxvmt-whatsapp[.]com[.]cn
That was the first useful clue. The operator was not just sitting on one static batch. Some names had already migrated while the original certificate was still valid elsewhere.
IP Pivot
The seed domain resolved to 43.226.17.54. I checked it
with Shodan InternetDB because that endpoint is free and does not need
an API key.
curl -s https://internetdb.shodan.io/43.226.17.54 | jq '.hostnames | length'
It returned:
90
That mattered because those 90 names were not in the 26-SAN city certificate.
They used a different pattern:
faq[.]webfzlwpakd-whatsapp[.]com[.]cn
faq[.]webwxqra-whatsapp[.]com[.]cn
faq[.]webmqlen-whatsapp[.]com[.]cn
faq[.]webbqvra-whatsapp[.]com[.]cn
...
When I connected to one of them, it served a 90-SAN Let's Encrypt cert listing the whole FAQ cluster.
So the seed certificate gave me 26 names. The IP pivot gave me 90 more. Same IP, different certificate, different naming template.
That is why I usually pivot both ways. Cert to domain is useful. IP to other hostnames is often where the next batch appears.
Subnet Scan
At that point the obvious block was 43.226.17.0/24,
under AS152194, HONG KONG WAN SHOU NETWORK TECHNOLOGY LIMITED.
I scanned the /24 for TLS on 443 and pulled the cert from anything that answered.
range: 43.226.17.0/24
live tls: 43
spki groups: 10
The 90-SAN FAQ cert was repeated across seven IPs. The scan also found other cert groups I had not seen from the original seed.
| Cluster | SANs | Issuer | Pattern |
|---|---|---|---|
| FAQ random | 90 | Let's Encrypt R12 | faq[.]web{random}-whatsapp[.]com[.]cn |
| 3-letter random | 49 | Let's Encrypt R13 | {abc}-whatsapp[.]com[.]cn |
| API/web | 30 | TrustAsia LiteSSL | web[.]api-*-whatsapp[.]com[.]cn |
| Online/dev | 30 | TrustAsia LiteSSL | online[.]*-whatsapp[.]com[.]cn |
| City-coded | 26 | TrustAsia LiteSSL | www[.]cn-{city}-web-whatsapp[.]com[.]cn |
| Migration | 1 | Let's Encrypt R13 | 2026[.]webfqprxvmt-whatsapp[.]com[.]cn |
| Numeric | 2 | Let's Encrypt R13 | 128-whatsapp[.]com[.]cn |
Some API/web examples:
web[.]api-app-whatsapp[.]com[.]cn
web[.]graph-api-whatsapp[.]com[.]cn
web[.]cloud-sync-whatsapp[.]com[.]cn
web[.]auto-backup-whatsapp[.]com[.]cn
Some 3-letter examples:
acy-whatsapp[.]com[.]cn
adp-whatsapp[.]com[.]cn
agk-whatsapp[.]com[.]cn
ahm-whatsapp[.]com[.]cn
ain-whatsapp[.]com[.]cn
At this point I had several naming templates, but they all pointed in the same direction: same suffix, same hosting, similar cert timing, repeated key material.
I would not call a match proof by itself. But this was not one weak link. It was a pile of matching weak links.
The Staging Block
The second range was less noisy in terms of names and more interesting in terms of infrastructure.
I scanned 38.181.8.0 through
38.181.10.255.
live tls: 244
san count: 0 on every host I checked
cert: self-signed Managed CA
spki: same key across all 244 boxes
That does not look like a live phishing cluster yet. It looks like staging.
Two hundred and forty-four boxes had TLS ready, but no domain names attached to the certs. Same empty self-signed certificate. Same key. Waiting for the next wave, or at least prepared for one.
Combined with the active block, my count at the end looked like this:
domains mapped: 205
live tls ips: 253
active cert clusters: 9
staging boxes: 244
main asn: AS152194
web server: nginx
faq http title: Search - Microsoft Bing
The Graph
This is the rough shape I ended up with:
OpenPhish URL
-> cn-hht-web-whatsapp[.]com[.]cn
-> TLS cert
-> 26 city-coded SANs
-> 15 still on 43.226.17.54
-> 5 moved to 43.226.17.135 / 38.181.x.x
-> IP pivot on 43.226.17.54
-> 90 faq[.]web* names from InternetDB
-> 90-SAN Let's Encrypt FAQ cert
-> scan 43.226.17.0/24
-> 43 live TLS IPs
-> API/web cluster
-> online/dev cluster
-> 3-letter random cluster
-> scan 38.181.8.0-38.181.10.255
-> 244 boxes with empty self-signed certs
One feed URL. Four pivots. No paid API.
Naming Templates
After pulling the certs, five templates stood out.
City-coded names:
www[.]cn-hgh-web-whatsapp[.]com[.]cn
www[.]cn-wuh-web-whatsapp[.]com[.]cn
www[.]cn-xmi-web-whatsapp[.]com[.]cn
FAQ random names:
faq[.]webfzlwpakd-whatsapp[.]com[.]cn
faq[.]webwxqra-whatsapp[.]com[.]cn
API/web names:
web[.]graph-api-whatsapp[.]com[.]cn
web[.]cloud-sync-whatsapp[.]com[.]cn
Online/dev names:
online[.]auth-s-whatsapp[.]com[.]cn
online[.]api-c-whatsapp[.]com[.]cn
Short 3-letter names:
acy-whatsapp[.]com[.]cn
adp-whatsapp[.]com[.]cn
bjs-whatsapp[.]com[.]cn
The names are not subtle, but the useful part is the batching. Different naming waves, different certificates, same broad operation.
SPKI Pivots
If I had Censys or a working CT search handy, these SPKI hashes are where I would pivot next:
# 26-SAN city cluster
3ca513f64927808bc0a97e977e944e013a90012ed9dbf55bca45459236358495
# 90-SAN faq cluster
0c10bb2779bbb2750fec8f0539302b7150d544d378937458ee7671a4546c0cca
# 49-SAN 3-letter cluster
7c3db7bf6e1edbca437c41be6735bf4345a7155989d75b4da086246447a30c7d
# 30-SAN API/web cluster
2b6d1f3f64e8f786748aa80e9f3fe9feed51dec8d87fb7be191786d91c7d887c
# migration cert
77222f1ad1b1ff34879f43f1605070438a03ebb499dbdea3b652534116c57891
On Censys, the query would be:
services.tls.certificates.leaf_data.subject_key_info.fingerprint_sha256: "<hash>"
That finds hosts that reused the same public key. It can also surface bare IPs that will never show up from a domain-only CT search.
Workflow I use
For a single suspicious domain, this is the flow I would reuse:
- Pull the live TLS cert.
- Extract SANs and SPKI.
- Probe every SAN domain live.
- Record current IP, cert hash, and SPKI.
- Query free IP enrichment like Shodan InternetDB.
- Pull certs from any new hostnames.
- Scan the nearby hosting range if the block looks dedicated.
- Group by SPKI instead of domain alone.
Steps 1 and 2 give a cluster. The IP and subnet pivots are what turn that cluster into infrastructure.
Closing Notes
The gap between one phishing URL and the shape of the operation was mostly graph work.
The SAN list gave me the first batch. The IP pivot gave me the second. The subnet scan showed the staging capacity behind it.
I would still treat every edge as a lead, not proof. Shared hosting and stale DNS can lie. But when the naming, certs, IP space, issuer timing, and SPKI reuse all point the same way, the graph is usually telling you something worth following.