The Asus DSL-AC87VG: Reverse Engineering a CGI API
In the previous post I reverse engineered the Speedport Plus 2 — an encrypted JSON-RPC API, SJCL AES-CCM, a five-step authentication flow. The Asus DSL-AC87VG is the other router in my setup. It is the main gateway: handles the DSL line, manages the primary network, sits in the ground floor.
No encryption here. But old firmware has its own kind of weirdness.
The CSRF token is hidden inside a GIF image. The password is double-hashed with an undocumented MD5+SHA-512 combination. The API responses are raw JavaScript, not JSON. And buried in a diagnostics endpoint is the full DSL line telemetry — SNR, attenuation, CRC errors — data your ISP dashboard will never show you.
Table of Contents
- The Asus DSL-AC87VG and Arcadyan Firmware
- What DevTools Shows You
- The CSRF Token Hidden in a GIF
- The Auth Flow
- JavaScript All the Way Down
- The Data Endpoints
- The Quirks That Cost Me Time
- What You Get
- Lessons Learned
The Asus DSL-AC87VG and Arcadyan Firmware
The DSL-AC87VG is an Asus router with Arcadyan firmware — a combination common in ISP-distributed home gateways across Europe. Arcadyan makes the firmware, Asus puts their name on it, the ISP configures it. The result is a device with a modern-looking web interface that runs on infrastructure that feels like early 2010s embedded web tech.
The web UI lives at http://192.168.1.1/. Everything works over plain HTTP on the local network. No encryption. The session is a cookie. The data is JavaScript.
There is no API documentation.
What DevTools Shows You
Open the router’s web interface. Open DevTools. Navigate to the device list.
The browser makes a GET request to /cgi/cgi_clients.js. The response is not JSON. It is JavaScript source code:
var dhcp_client=['MyPhone','192.168.1.42','AA:BB:CC:DD:EE:FF','255.255.255.0','1'];
Navigate to the WAN status page. The browser fetches /cgi/cgi_internet.js. Again, JavaScript:
addCfg("wan_ipv4_proto0", "", "pppoe");
addCfg("wan_ip_pppoe0", "", "93.184.216.34");
addCfg("wan_gw_pppoe0", "", "93.184.216.1");
The DSL statistics page fetches /cgi/cgi_Main_AdslStatus_Content.js. What comes back is a JavaScript constructor call with 35 arguments:
new obj_dsl_inf('3.24.23.0','5.0.0.4','Showtime','227:26:3',
'VDSL2','B','17a','0','0','6.5','5.8','12.0','12.5','Fast','Fast',
'0','0','19939','99947','22540','102350','14.5','-1.5',
'0','0','0','0','0','0','0','0','0','0','0','79079','25987');
The router is not serving an API. It is serving the data that its own JavaScript frontend reads. The frontend parses these responses with its own client-side functions. We are doing the same thing — just in Python, with regex.
The CSRF Token Hidden in a GIF
Every page on the router includes a CSRF token. The token is required for the login POST and for all CGI requests. Without it, every request fails.
Finding it is the first puzzle. The token is not in a form field. It is not in a JavaScript variable. It is embedded in an <img> tag as a data URI — and the token is appended to the GIF image data.
The HTML contains something like:
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICRAEAOw==dG9rZW52YWx1ZWhlcmU=">
The prefix data:image/gif;base64, is 22 characters. The 1×1 GIF that follows is always the same fixed image — its base64 encoding is always 56 characters. So the token starts at position 78 in the full data URI string.
@staticmethod
def _extract_token(html: str) -> str:
for img in re.findall(r'<img[^>]*src=["\']?(data:[^"\'>\s]+)', html):
if img.startswith('data:') and len(img) > 78:
try:
return base64.b64decode(img[78:]).decode()
except (ValueError, UnicodeDecodeError):
return ''
return ''
The offset 78 is a magic constant derived from the known structure: 22 (prefix) + 56 (GIF base64). It works as long as the firmware uses the same 1×1 GIF. Whether this was an intentional obfuscation or just an accidental quirk of the firmware’s template system is unclear. Either way, it is the only way to get the token.
The Auth Flow
With the token extracted, the login sequence is four steps.
Step 1 — Fetch /login.htm and extract the CSRF token as described above.
Step 2 — Hash the credentials. The router does not accept the password directly. It expects:
md5 = hashlib.md5(value.encode()).hexdigest()
hashed = hashlib.sha512(md5.encode()).hexdigest()
SHA-512 of the MD5 hex digest. Not SHA-512 of the password. Not MD5 of the password. The MD5 hex string goes into SHA-512 as the input. Why this combination exists is not documented anywhere. It is simply what the router’s own login JavaScript does.
Step 3 — POST to /login.cgi with the hashed credentials, the token, and a Referer: http://router/login.htm header. Without the Referer header, the router rejects the login silently.
r = self._session.post(f'{self._base}/login.cgi', data={
'name': '',
'usr': self._hash(self._username),
'pws': self._hash(self._password),
'set_lang': 'en',
'httoken': token,
}, allow_redirects=False)
A successful login returns HTTP 302 redirecting to /index.htm. Any other response means the login failed.
Step 4 — Follow the redirect and fetch /index.htm to extract a fresh page token. The page token is used as a _tn query parameter on all subsequent CGI requests, alongside a _t millisecond timestamp.
JavaScript All the Way Down
Two parsing patterns cover every endpoint.
Pattern 1: addCfg key-value pairs
addCfg("wan_ipv4_proto0", "", "pppoe");
addCfg("LAN_IP4_Addr", "", "192.168.1.1");
These are straightforward to extract:
{m.group(1): m.group(2)
for m in re.finditer(r'addCfg\("(\w+)",\s*"[^"]*",\s*"([^"]*)"\)', text)}
Pattern 2: Positional constructor arguments
new obj_dsl_inf('fw', 'driver', 'status', 'uptime', 'modulation', ...)
There is no key-value structure. The arguments are positional. You must know that index 9 is downstream SNR, index 17 is upstream sync rate, index 23 is downstream CRC errors. This knowledge comes from reading the router’s own frontend JavaScript — the functions that read these arguments by index.
The extraction regex captures everything in the parentheses:
m = re.search(r"new obj_dsl_inf\(([^)]+)\)", text)
args = [a.strip().strip("'") for a in m.group(1).split(',')]
Then each value is pulled by index: args[9] for downstream SNR, args[18] for downstream sync rate, and so on.
The Data Endpoints
| Endpoint | Data |
|---|---|
/login.htm | CSRF token (embedded in GIF data URI) |
/js/state.js | Model, firmware, uptime (var name="value") |
/cgi/cgi_internet.js | WAN config (addCfg() calls) |
/cgi/cgi_clients.js | Devices + WiFi signal (var dhcp_client=[...]) |
/cgi/cgi_Main_AdslStatus_Content.js | DSL line status (new obj_dsl_inf(35 args)) |
/cgi/cgi_network_router.js | LAN config (addCfg() + var statements) |
/update.htm?output=netdev | Interface traffic (hex-encoded byte counters) |
Each endpoint requires the _tn and _t query parameters. The _tn is the page token. The _t is the current Unix timestamp in milliseconds — it functions as a cache-buster.
The Quirks That Cost Me Time
Traffic Counters Are Hex
The network interface statistics come from /update.htm?output=netdev. The response looks like this:
'eth0':{rx:0x1a2b3c4d,tx:0x5e6f7a8b},'eth1':{rx:0x00000000,tx:0x00000000}
The byte counts are hexadecimal. Not decimal. int('0x1a2b3c4d', 16) is the extraction. Using int() directly on the string fails. This is the kind of thing that produces incorrect zero values with no error message.
for m in re.finditer(r"'(\w+)':\{rx:(0x[0-9a-f]+),tx:(0x[0-9a-f]+)\}", text):
rx = int(m.group(2), 16)
tx = int(m.group(3), 16)
The DSL Argument Indices Are Fragile
The obj_dsl_inf constructor has 35 arguments. The mapping from index to meaning comes entirely from reading the router’s frontend JavaScript. That mapping is not stable across firmware versions.
The only way to verify is to cross-reference the parsed values against what the router’s web UI displays for the same fields. If args[9] parsed as downstream SNR does not match what the UI shows for downstream SNR, the index is wrong. This is tedious but not difficult.
The Traffic Endpoint Needs a Different Referer
Most CGI endpoints work with Referer: http://router/index.htm. The traffic endpoint /update.htm?output=netdev requires Referer: http://router/Main_TrafficMonitor_realtime.htm — the URL of the traffic monitoring page in the web UI. Arcadyan likely added Referer checks as a lightweight anti-CSRF measure, a common pattern in early 2010s embedded firmware. It is trivially spoofable, but a plain requests.get() without the header returns a session redirect instead of data. The client swaps the Referer header temporarily and restores it after the request:
self._session.headers['Referer'] = f'{self._base}/Main_TrafficMonitor_realtime.htm'
try:
r = self._session.get(f'{self._base}/update.htm?output=netdev', ...)
finally:
self._session.headers['Referer'] = referer # restore
Session Expiry Is a Redirect, Not a Status Code
The router does not return 401 or 403 when a session expires. It returns HTTP 200 with a short HTML page that redirects to /login.htm. The client detects this by checking two conditions together:
if 'login.htm' in r.text and len(r.text) < 300:
# session expired — re-login and retry
Checking only for login.htm in the response body would produce false positives on pages that legitimately link to the login page. The length check (< 300) narrows it to the redirect case.
What You Get
System
- Model, firmware version, uptime
WAN
- Protocol (PPPoE, DHCP, etc.), public IP, gateway, DNS server
Connected devices
- DHCP hostname, IP, MAC address
- Connection type: 2.4 GHz, 5 GHz, or Wired
- WiFi signal quality (0–100) for wireless clients
Interface traffic
- Per-interface RX/TX byte counters (cumulative since last reboot)
- Total across all interfaces
DSL line status — the data most tools do not expose:
- Line status (Showtime / Idle / Training)
- Modulation (VDSL2, ADSL2+) and profile (17a, 8b, etc.)
- Downstream and upstream SNR in dB
- Downstream and upstream attenuation in dB
- Sync rates and attainable rates in kbps
- CRC errors and FEC corrections (downstream and upstream)
- Line uptime
The DSL data is the most valuable part. It tells you how stable the line is, how close the sync rate is to the attainable maximum, and whether CRC errors are accumulating. That combination is not available from any ISP dashboard.
Lessons Learned
Old firmware documents itself through its own JavaScript. There is no API spec for the Asus DSL-AC87VG. But the router ships its own frontend JavaScript, and that JavaScript reads the data by known indices and key names. The spec is the frontend — you just have to read it.
Positional argument arrays are a maintenance burden. The obj_dsl_inf constructor with 35 positional arguments works, but it is fragile. A firmware update that adds or reorders arguments silently breaks the parser. Structured JSON would have made this trivially robust. The lesson is not specific to this router — it is a general property of old embedded firmware: data formats were designed for the firmware’s own JavaScript, not for external consumers.
Referer headers as access control. Requiring a specific Referer header is a weak security measure — it is trivially spoofable — but it does prevent naive automated scraping. It is also why a naive requests.get() without headers returns the wrong response. Setting User-Agent and Referer to match what a real browser sends is not optional.
The value is in the DSL data. WAN IP and connected devices are visible in any router UI. The DSL line statistics — SNR, attenuation, CRC errors, attainable rates — are usually buried in a diagnostics page that no monitoring tool reads. Polling them every 60 seconds gives a time series that makes line degradation visible long before it causes noticeable connectivity issues.
Two routers, two completely different approaches. The Speedport uses standard encryption, a formal parameter model, and a well-structured auth flow that took a day to reverse engineer. The Asus uses no encryption, returns raw JavaScript, and hides its CSRF token in a GIF — it took less time overall, but produced more fragile parsing code.
Neither approach is obviously better for the end goal. Both now feed the same dashboard, every 60 seconds, reliably.
Frequently Asked Questions
- Q: What is the Asus DSL-AC87VG and who uses it?
- A: The DSL-AC87VG is an ADSL/VDSL2 gateway made by Asus, running Arcadyan firmware. It is distributed by ISPs in several countries as a branded home gateway. It has a web UI but no publicly documented API.
- Q: Is it safe to read your own router's data programmatically?
- A: Yes. You are authenticating with your own credentials over your own local network and reading data the router already exposes in its web interface. The only risk is a buggy logout implementation, which is why the client uses a session lock and graceful fallbacks.
- Q: Can I use this approach on other Arcadyan firmware routers?
- A: The CGI endpoint names, the addCfg() format, and the obj_dsl_inf constructor are Arcadyan patterns. Other Arcadyan-based routers — used by ISPs in Germany, Greece, and elsewhere — often share the same structure. The CSRF token extraction logic (offset 78 in the data URI) may vary by firmware version.
- Q: How do you handle DSL line statistics that other tools do not expose?
- A: The DSL status comes from /cgi/cgi_Main_AdslStatus_Content.js, which returns a JavaScript constructor call with 35 positional arguments. Most home monitoring tools ignore this endpoint entirely. Parsing it gives you SNR, attenuation, sync rates, CRC errors, and FEC counts — the full picture of your line quality.
- Q: What happens when the router session expires?
- A: Arcadyan routers signal session expiry by redirecting CGI responses to login.htm with a very short body — under 300 characters. The client detects this pattern, re-logs in automatically, and retries the request once.