Building a Hardened Virtual Browser to Bypass IRCTC's Bot Detection
Anyone who’s tried to book a train ticket on IRCTC from a VPS or cloud machine has hit this wall:
Access Denied
You don't have permission to access "http://www.irctc.co.in/" on this server.
Reference #18.3e4c6168.1778672540.518b2
That’s Akamai’s WAF talking. And it’s not a simple IP ban — it’s a multi-layer fingerprinting system that correlates signals across TLS, HTTP/2, JavaScript runtime, and IP reputation. This post is a full technical breakdown of how I built neko-secure-browser, a Dockerized virtual browser that defeats each of those layers systematically.
The Problem: What IRCTC Actually Checks
Most people assume it’s just an IP block. It isn’t. I ran a series of curl diagnostics from GCP before writing a single line of code:
# Test 1: HTTP/2 default
curl -I -v --max-time 10 "https://www.irctc.co.in/nget/train-search"
# Test 2: Force HTTP/1.1
curl --http1.1 -I -v --max-time 10 "https://www.irctc.co.in"
# Test 3: HTTP/2 + full Chrome headers
curl --http2 -I \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)..." \
-H "sec-ch-ua: \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\"" \
-H "Sec-Fetch-Dest: document" \
...
The key diagnostic output:
Test 1 fails + Test 2 fails → IP reputation block
Test 1 fails + Test 3 passes → Header fingerprint matters
All fail → Hard IP block
From GCP, the TCP connection succeeds. The TLS handshake completes. The rejection happens at the HTTP/2 application layer, after Akamai evaluates the full fingerprint bundle. That’s important: it means we can fix this.
Here’s what Akamai actually correlates:
| Layer | Signal | What trips automation |
|---|---|---|
| Network | TLS JA3/JA3S fingerprint | Non-Chrome ClientHello |
| HTTP/2 | SETTINGS frames, stream behavior | Automation-default h2 params |
| HTTP | Header ordering, sec-ch-ua, Sec-Fetch-* |
Missing/wrong client hints |
| JS runtime | navigator.webdriver, plugins, languages |
Headless Chrome defaults |
| IP | Datacenter ASN reputation | GCP/AWS/DO get extra scrutiny |
Fix each layer. That’s the project.
The Architecture
User's Browser (anywhere)
│
│ WebRTC / WebSocket (port 8080)
▼
┌─────────────────────────────────────────┐
│ Docker Container │
│ │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Neko v3 │────▶│ Real Chrome │ │
│ │ Server │ │ (not headless) │ │
│ └──────────┘ └────────┬────────┘ │
│ │ │
│ HTTP proxy │
│ 127.0.0.1:8118 │
│ │ │
│ ┌─────────▼────────┐ │
│ │ ja3proxy │ │
│ │ TLS spoof: │ │
│ │ Chrome 120 │ │
│ └─────────┬────────┘ │
│ │ │
└─────────────────────────────┼───────────┘
│
(optional) UPSTREAM_PROXY
residential SOCKS5
│
▼
IRCTC / Internet
Three key components:
- n.eko — a self-hosted virtual browser that streams a real desktop Chrome session over WebRTC to any browser. Not headless. Real pixels, real input events.
- LyleMi/ja3proxy — a MITM proxy that intercepts Chrome’s outgoing TLS connections and rewrites the ClientHello using uTLS to match a specified real browser fingerprint.
- Stealth Extension — a custom Chrome extension that patches the JavaScript runtime at
document_starton every page.
Layer 1: TLS Fingerprint Spoofing with ja3proxy
The JA3 fingerprint is a hash of the TLS ClientHello — specifically the cipher suites, extensions, elliptic curves, and elliptic curve point formats your client offers. Every browser has a distinct signature. Automation tools like Selenium or Puppeteer emit a fingerprint that doesn’t match any real Chrome version.
ja3proxy solves this by acting as a transparent HTTP proxy. Chrome connects to 127.0.0.1:8118, ja3proxy accepts the connection, then opens a new TLS connection to the real server — but this time using uTLS to impersonate a legitimate Chrome 120 ClientHello byte-for-byte.
The Entrypoint
The container starts ja3proxy via CLI flags (not a config file — LyleMi’s version is pure CLI):
/usr/local/bin/ja3proxy \
-addr "127.0.0.1" \
-port "8118" \
-cert "/opt/ja3proxy/certs/cert.pem" \
-key "/opt/ja3proxy/certs/key.pem" \
-client "Chrome" \
-version "120"
The -client and -version values map directly to uTLS ClientHelloID presets. Available fingerprints include Chrome, Firefox, Safari, Edge, iOS — all at specific version numbers.
MITM Certificate Trust
Since ja3proxy intercepts HTTPS traffic, Chrome needs to trust its self-signed CA. The Dockerfile pre-generates a cert and installs it system-wide:
RUN mkdir -p /opt/ja3proxy/certs \
&& openssl req -x509 -newkey rsa:4096 \
-keyout /opt/ja3proxy/certs/key.pem \
-out /opt/ja3proxy/certs/cert.pem \
-days 3650 -nodes \
-subj "/C=US/ST=Security/L=Lab/O=SecureBrowser/CN=SecureBrowserCA" \
&& cp /opt/ja3proxy/certs/cert.pem \
/usr/local/share/ca-certificates/secure-browser-ca.crt \
&& update-ca-certificates
Then at runtime, the entrypoint computes the SPKI hash and passes it to Chrome via --ignore-certificate-errors-spki-list:
SPKI_HASH=$(openssl x509 -in "$CERT" -noout -pubkey \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64)
CHROME_FLAGS="--proxy-server=http://127.0.0.1:8118
--ignore-certificate-errors-spki-list=${SPKI_HASH}
..."
This pins trust to exactly this cert without disabling all certificate validation (which would itself be a fingerprinting signal).
Layer 2: Real Chrome, Not Headless
The base image is ghcr.io/m1k1o/neko/google-chrome:latest — actual Google Chrome with a full X11 display server, not Chromium, not headless mode. This matters because:
- Real Chrome has a different binary fingerprint than Chromium
- The WebGL renderer, audio stack, and font rendering differ
- Headless mode strips dozens of browser features that fingerprinters probe
neko runs Chrome inside a container with a virtual framebuffer (Xvfb), then streams the screen over WebRTC to your browser. The result: you interact with a real desktop Chrome session as if it’s running on your machine.
Chrome Launch Flags
The flags passed to Chrome are carefully curated. Notably absent are common automation flags that scream “bot”:
# REMOVED — all are automation red flags:
# --disable-web-security
# --allow-running-insecure-content
# --disable-features=IsolateOrigins
# --enable-automation (excluded via --exclude-switches)
# PRESENT — legitimate stealth flags:
--disable-blink-features=AutomationControlled
--exclude-switches=enable-automation
--no-first-run
--no-default-browser-check
--disable-infobars
--force-color-profile=srgb
--metrics-recording-only
--use-mock-keychain
Layer 3: JavaScript Runtime Patching
Even with a perfect TLS fingerprint and real Chrome, Akamai’s JS probes can detect automation. The stealth extension runs at document_start in the MAIN world (not isolated) on every frame of every page, patching before any page script executes.
navigator.webdriver
The most obvious automation tell:
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
configurable: true
});
In a normal browser this property is undefined. Selenium/WebDriver sets it to true. Returning undefined is correct — not false, which some naive patches use (and which can itself be a signal).
Plugin List Spoofing
Headless Chrome has zero plugins. Real Chrome has three built-in ones. The patch constructs proper Plugin and MimeType prototype objects:
Object.defineProperty(navigator, 'plugins', {
get: () => {
const makePlugin = (name, filename, desc, mimeTypes) => {
const plugin = Object.create(Plugin.prototype);
// ... construct with correct prototype chain
};
return [
makePlugin('Chrome PDF Plugin', 'internal-pdf-viewer', ...),
makePlugin('Chrome PDF Viewer', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', ...),
makePlugin('Native Client', 'internal-nacl-plugin', ...)
];
}
});
The key detail: using Object.create(Plugin.prototype) rather than a plain object. Fingerprinters check navigator.plugins[0] instanceof Plugin — a plain object fails this check.
Language Spoofing for IRCTC
IRCTC specifically looks for Indian locale signals:
Object.defineProperty(navigator, 'languages', {
get: () => ['en-IN', 'en', 'hi'],
configurable: true
});
Permissions API Normalization
Headless Chrome returns 'denied' for notification permission queries; real Chrome returns the actual permission state:
window.navigator.permissions.query = (parameters) => {
if (parameters.name === 'notifications') {
return Promise.resolve({ state: Notification.permission });
}
return originalQuery(parameters);
};
Removing Neko’s Default Chrome Policies
Out of the box, neko enforces managed Chrome policies that prevent installing extensions and restrict browser behavior. These are applied via Chrome’s policy directory. To start with a completely clean browser:
Create policy.json:
{}
Mount it in docker-compose.yml:
volumes:
- ./policy.json:/etc/opt/chrome/policies/managed/policies.json
An empty JSON object disables all managed policies — no blocked extensions, no forced settings, no download restrictions. The browser behaves exactly as a freshly installed Chrome.
Policy paths by browser:
| Browser | Policy Path |
|---|---|
| Chrome | /etc/opt/chrome/policies/managed/policies.json |
| Chromium | /etc/chromium/policies/managed/policies.json |
| Brave | /etc/brave/policies/managed/policies.json |
| Edge | /etc/opt/edge/policies/managed/policies.json |
Layer 4: IP Reputation — The Hard Part
This is where TLS spoofing hits its ceiling. Even with a perfect Chrome fingerprint, GCP/AWS/DO IPs carry a lower Akamai trust score. IRCTC allows the connection but applies stricter WAF rules to datacenter ASNs.
The solution is routing traffic through a residential IP. The project supports this via UPSTREAM_PROXY:
# .env
PROXY_ENABLED=true
UPSTREAM_PROXY=socks5://user:pass@residential-proxy:1080
Note: ja3proxy only supports SOCKS5 upstream (not HTTP), because it needs to establish its own TLS connection to control the fingerprint. HTTP tunnel proxies take ownership of the TLS layer.
Tailscale as a Free Residential Proxy
If you have a home machine with a residential ISP (Jio, Airtel, etc.), Tailscale can turn it into your exit node for free:
VPS (neko container)
│
│ Tailscale tunnel (100.x.x.x mesh)
▼
Home PC (Jio/Airtel residential IP)
│
▼
IRCTC — sees your home ISP IP
Setup on home PC:
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
# Advertise as exit node
sudo tailscale up --advertise-exit-node
On VPS:
sudo tailscale up --exit-node=<home-tailscale-ip> --exit-node-allow-lan-access
Then run a SOCKS5 proxy on the home machine (e.g. microsocks) and point UPSTREAM_PROXY at your home Tailscale IP. Your neko container’s traffic now exits from your home Jio/Airtel connection — highest possible IRCTC trust score.
The key insight about WiFi: The VPS keeps running 24/7 regardless of your WiFi connection. Your home WiFi (or a 5-minute library visit) is only needed to SSH in and issue docker-compose up -d. After that, the container runs autonomously. You connect to the neko UI from anywhere — phone, library, home — because the VPS has its own persistent connection to the internet.
Docker Compose: Full Configuration
services:
neko-secure:
build:
context: .
dockerfile: Dockerfile
container_name: neko-secure-browser
environment:
NEKO_SCREEN: "1920x1080@30"
NEKO_PASSWORD: "${NEKO_PASSWORD:-changeme123}"
NEKO_ADMIN_PASSWORD: "${NEKO_ADMIN_PASSWORD:-adminchangeme123}"
NEKO_BIND: ":8080"
JA3_LISTEN: "127.0.0.1:8118"
JA3_CLIENT: "${JA3_CLIENT:-Chrome}"
JA3_VERSION: "${JA3_VERSION:-120}"
PROXY_ENABLED: "${PROXY_ENABLED:-false}"
UPSTREAM_PROXY: "${UPSTREAM_PROXY:-}"
ports:
- "${NEKO_PORT:-8080}:8080"
- "52000-52100:52000-52100/udp"
shm_size: '2gb' # Chrome needs this for shared memory renderer
cap_add:
- SYS_ADMIN # Required for Chromium sandbox in Docker
security_opt:
- seccomp:unconfined
volumes:
- neko-profile:/root/.config/google-chrome
- neko-certs:/opt/ja3proxy/certs
- ./policy.json:/etc/opt/chrome/policies/managed/policies.json
restart: unless-stopped
volumes:
neko-profile:
neko-certs:
Startup Sequence
The entrypoint orchestrates a precise startup order:
1. Start ja3proxy (background, CLI flags from env)
│
▼
2. Wait for ja3proxy port readiness (bash /dev/tcp poll, 10s timeout)
│
▼
3. Install MITM CA into Chrome's NSS cert store
│
▼
4. Compute SPKI hash → build Chrome flag set with proxy + SPKI pin
│
▼
5. Export NEKO_CHROMIUM_FLAGS
│
▼
6. exec supervisord → launches Xorg + PulseAudio + openbox + neko server
The bash /dev/tcp readiness check is worth noting — netcat isn’t installed in the neko base image, so the script uses bash’s built-in TCP pseudo-device:
until (echo > /dev/tcp/"$ADDR"/"$PORT") 2>/dev/null; do
sleep 0.5
RETRY=$((RETRY + 1))
done
Quick Start
# 1. Run diagnostics from your server (before building)
bash scripts/diagnose-irctc.sh
# 2. Download ja3proxy binary
bash setup.sh
# 3. Configure
cp .env .env.local
nano .env.local # Set passwords, JA3 fingerprint, proxy settings
# 4. Build and run
docker-compose --env-file .env.local up --build -d
# 5. Access neko UI
open http://your-server-ip:8080
Login with NEKO_PASSWORD. You’ll see a full Chrome desktop streaming in your browser. Navigate to IRCTC normally.
Debugging
If IRCTC still blocks after all the above, the diagnostic script identifies exactly which layer is failing:
Test 1 fails + Test 2 passes → HTTP/2 WAF rejection
Test 1 fails + Test 2 fails → IP reputation block
Test 1 fails + Test 3 passes → Header fingerprint matters
All fail → Hard IP block
For HTTP/2 specific failures, add --disable-http2 to force HTTP/1.1 which sidesteps certain SETTINGS frame fingerprinting. For IP reputation, there’s no substitute for a residential exit — Tailscale or a commercial residential proxy service.
Indian ISP IPs (Jio, Airtel, BSNL) get the highest trust scores with IRCTC. An Indian VPS from Hostinger India or BigRock is a good middle ground if running at home isn’t feasible.
Fingerprint Profiles Available
Via uTLS (configurable in .env):
JA3_CLIENT |
JA3_VERSION |
Use Case |
|---|---|---|
Chrome |
120 |
Default, highest IRCTC compatibility |
Chrome |
106 |
Older profile for legacy WAF rules |
Firefox |
105 |
Alternative fingerprint |
Safari |
16.0 |
iOS-like fingerprint |
Edge |
106 |
Microsoft Edge profile |
iOS |
14 |
Mobile Safari fingerprint |
What This Isn’t
This setup makes automation look like a real browser, but it doesn’t make automation faster or automate ticket booking. It’s a virtual desktop browser — you still click buttons yourself, just through a browser-streamed interface rather than directly on a physical machine.
The neko project was built for remote browser isolation and self-hosted browsing. The IRCTC use case is just accessing a website that incorrectly flags cloud VPS IPs, not automating the booking flow.
Resources
- n.eko documentation
- LyleMi/ja3proxy
- refraction-networking/utls — uTLS ClientHelloID list
- Tailscale exit nodes
- Neko browser customization