Gartner’s How to Achieve the Minimum Viable AI Governance
Trust Me, I’m the System
This is the story of how a seemingly minor parsing flaw exposed a deeper lesson in secure system architecture, trust boundaries, and the danger of components disagreeing about what a request actually means.
The request that returned ArangoDB's root password hash did not contain any credentials. No password, token, or session cookie - just a PUT request with a single URL-encoded character.
A small change to the second request produced a different result. By flipping one JSON field, the same HTTP interface could be used to execute root code on the host.
These turned out to be two separate vulnerabilities with an underlying problem in common: the server trusted the information supplied by the request to determine what the caller was allowed to do.
We found both issues while reviewing ArangoDB's HTTP attack surface. This is how we went from an unauthenticated request to root-level access.
Door One: The Underscore That Opened Everything
ArangoDB decides what is protected by prefix. Paths under /_api and /_admin need a login. Everything else is treated as a public app route. The gate is one line:
// arangod/Actions/RestActionHandler.cpp:142 — hasAllowedUnauthenticatedPath()auto const& path = request()->requestPath(); // raw, straight off the wirereturn auth->authenticationSystemOnly() && // on by default!path.empty() && !path.starts_with("/_"); // "public" if it isn't /_
Pass that check and the request runs unauthenticated. In the default system-only mode it also gets escalated to superuser, because anything that isn't /_ is assumed to be your own Foxx microservice.
The catch: this check reads the raw URL. The code that decides which handler to run reads the decoded URL.
// arangod/Actions/actions.cpp:96 — TRI_LookupActionVocBase()auto suffixes = request->decodedSuffixes(); // %5f becomes _auto name = join(suffixes, '/'); // "_api/simple/..."
%5f is an underscore. So /%5fapi/... looks public to the gate and decodes to the restricted _api/... for the router. One reads it as public, the other runs the privileged API.
First try we sent %5f_api and got a 404 - it decodes to __api, double underscore. You replace the underscore, you don't prepend one.
Here it is in Burp - three requests, none of them carrying a credential. The first is refused; changing one character in the path gets the other two served.
1 - The real path is blocked. Plain underscore, _api, no login → 401 Unauthorized, "not authorized to execute this request":
2 - Swap the underscore for %5f and the gate is gone. It's the same request, byte for byte, and still no credentials, only _api → %5fapi. It's no longer 401 - the auth check waved it through as "public" and the router handed it to the real action, which only objects that GET is the wrong method (405 Unsupported method). That 405 is the finding: we're past the lock, inside a privileged handler, unauthenticated.
3 - Modify the action and the method, and it will just run. use HTTP Method PUT with a HTTP request body → then HTTP response is 200 OK, and the secret document comes straight back - still nobody logged in:
Then, we can simply read all the user collections, or any other data we want:
Root's hash. Reads, writes, and deletes all work the same way, against any collection in any database, with nobody logged in.
From a secure system architecture perspective, the vulnerability was not simply unsafe input handling. It was a mismatch between the application's authorization assumptions and the behavior of the underlying data layer.
So where's the shell?
The catch: door one only reaches ArangoDB's JavaScript actions, not its C++ handlers. You own all the data. You don't have code execution. Yet.
But you're holding root's password hash, stored as single-round SHA-256 with a 32-bit salt. Plenty of deployments ship a weak or default root password, and that hash won't survive one. Crack it, log in for real, and you're an authenticated user. That's all door two needs.
Door two: I'm the system
ArangoDB schedules background jobs over HTTP. A job is JavaScript. That JavaScript runs in one of two contexts: a sandbox, or the server's own god-mode. This is decided only by one field that the client can control it, inside the HTTP request body.
// arangod/RestHandler/RestTasksHandler.cpp:204 — registerTask()bool isSystem = VelocyPackHelper::getBooleanValue(body, "isSystem", false); // your body...Task::createTask(id, name, exec, &_vocbase, command, isSystem, res);
isSystem:true selects the Internal context, which reads and writes any file on disk. To reach this endpoint you need write on one database - and door one just handed you root's hash to get exactly that.
The kicker: the other way to create a task, the internal JS API, guards this. The HTTP handler didn't.
// arangod/V8Server/v8-dispatcher.cpp:161 — the check that lived on the other doorif (isSystem && !securityContext.isInternal())throw FORBIDDEN("Only internal context may create system tasks");
arangod runs as root in the official image. So:
The task fires a tick later and stores the file contents in a collection. Read it right back over the API.
/etc/shadow, over the API. And it's not just one file read. isSystem:true drops the task out of the sandbox and into ArangoDB's own Internal context - the server's god-mode - which unlocks three capabilities the sandbox flatly denies:
- Read any file on the host.
/etc/shadow, TLS private keys, the cluster JWT secret keyfile - and/proc/1/environ, which on the official image hands backARANGO_ROOT_PASSWORDin cleartext. - Write any file on the host. Drop
~/.ssh/authorized_keys, a cron entry, or a systemd unit; overwrite any script or binary the host runs on a timer or at boot. - Make outbound HTTP requests.
internal.download(url)- SSRF into internal-only services and cloud metadata endpoints.
Flip isSystem to false and all three come back denied - not allowed to read files in this path, not allowed to modify files in this path, not allowed to connect to this URL. Same account, same command; the one boolean is the entire privilege boundary.
arangod runs as root in the official image, so every one of those is as root. Direct process spawning (internal.executeExternal, a straight /bin/sh) is off by default - and it doesn't need to be on. Arbitrary file write as root already is code execution: write the cron file or the SSH key, wait one tick, and the shell is yours. Door two isn't "leak a file" - it's full host compromise, remote code execution from a single JSON boolean.
That is the architectural failure worth carrying forward. Secure system architecture cannot be reduced to individually secure components. The security properties of the system emerge from how those components interpret identity, authorization, input, and trust across their boundaries.
Disclosure
23 Aug 2026→ Reported both issues to ArangoDB.
31 Aug 2026→ Both fixes shipped in ArangoDB 3.12.11.
6 Sep 2026→ Advisories published: GHSA-rrgq-978q-36mq (door one) and GHSA-rvhw-4hpw-9vrx (door two).
Both issues are rated Critical. Scored separately:
- Door one - unauthenticated superuser, full data compromise: CVSS 3.1 9.8 (
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). - Door two - authenticated DB-write user to root RCE: CVSS 3.1 9.9 (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).
A CVE has been requested for each and is pending assignment; until then they're tracked under the GitHub advisories above.
Affected Version: ArangoDB ≤ 3.12.10.1. Fixed: 3.12.11. If you're on anything older, upgrade.
A genuine thank-you to the ArangoDB team. They clearly care about their security for taking both reports seriously from the first message, staying responsive throughout, and shipping fixes fast. Exactly how vendor disclosure should go.