Building a Hardened Virtual Browser to Bypass IRCTC's Bot Detection

2026, May 26    

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:

  1. 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.
  2. 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.
  3. Stealth Extension — a custom Chrome extension that patches the JavaScript runtime at document_start on 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.

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