CVE-2026-123198: How an SSTI in LMFDB, Led to Unauthenticated Remote Code Execution
Hi, I’m Tommaso Bona, Security Researcher at SatoriCI. This article walks through CVE-2026-123198, a critical, unauthenticated Remote Code Execution vulnerability we found in LMFDB, how we confirmed it was real on their own live infrastructure, and how it was reported, fixed, and ultimately assigned a CVE ID.
How we found it
LMFDB turned up the same way almost everything we report does, through “pyspector-monitor”, the internal pipeline we built around PySpector, our Graph-based SAST tool for Python, which keeps an eye on public repositories for patterns that look like real, exploitable flaws. Nothing it flags gets sent anywhere on its own, since every raw finding first passes through an LLM, which reads the surrounding code, and discards the obvious False Positives before a human ever sees it. This particular case, is a good illustration of where that step has a hard limit, since it only has the source in front of it and no way to actually talk to a running server, so it can describe what a sink looks like, but can’t settle whether an attacker could really reach it. Here the model could tell something was off, after checking the PySpector report about this finding, but wasn’t willing to call it exploitable, and it came back closer to a False Positive, than a confirmed one. That uncertainty, is exactly why a human *stays in the loop for every finding** that reaches this stage, True Positive or not, and this time that was me, reading through the request handling by hand, and then going and testing the actual live path, rather than taking the static read on faith. Only once a finding is confirmed by a human, does the pipeline take over again, pre-filling a GHSA report that we review once again, and adjust (if necessary) before it gets automatically filed privately to the maintainers.
What is LMFDB
LMFDB, the L-functions and Modular Forms Database, is a large, free, open source online database of number theory objects and the connections between them, built around the Langlands program, the deep web of conjectures linking things like L-functions, modular forms, elliptic curves, number fields, and Galois representations. It holds **tens of millions** of computed mathematical objects, exposes them through both a web interface and an API, and is **actively used by research mathematicians worldwide**, both to look up specific objects and to search for patterns across entire families of them. The project is an **international academic collaboration**, rather than a single institution's product, funded in large part by the **US National Science Foundation**, but **MIT** is one of its lead institutions, hosting infrastructure and employing several of its core maintainers. It runs as a Flask application backed by PostgreSQL, and like most research software written by mathematicians first, and web developers second, security was never its primary design goal, which is exactly where this vulnerability came from.The vulnerability
LMFDB has a concept called a “knowl”, a small, reusable piece of explanatory content (a definition, a note, a reference, etc..) that gets rendered inline across the site. Knowls are rendered through a dedicated endpoint, and before the fix that endpoint looked like this in lmfdb/knowledge/main.py:
@knowledge_page.route("/render/<ID>", methods=["GET", "POST"])
def render(ID):
return render_knowl(ID) No login requirement, and both GET and POST were accepted. Inside render_knowl, the handler took every key and value from the request’s own query string or form data (kwargs, built straight from request.args), and used it to build a Jinja template as a plain Python string, through old-style % formatting:
kw_params = ', '.join(('%s="%s"' % (key, val) for key, val in kwargs.items()))
render_me += """ <div class="knowl-header">
<a href="{{ url_for('.show', ID='%(ID)s', %(kw_params)s ) }}">%(title)s</a>
</div>""" % {'ID': k.id, 'title': (k.title or k.id), 'kw_params': kw_params} The comment above the original kw_params line, even explained the intent plainly: it was meant to be inserted verbatim into the url_for(...) call inside the template, so that knowl links could carry their query parameters along the chain. The problem is that “verbatim” is doing a lot of work there, since kw_params is built directly out of attacker-controlled request data, with no escaping of quotes, braces, or anything else that means something to Jinja, and the whole string then gets handed straight to Jinja’s template compiler:
data = render_template_string(render_me, k=k, **kwargs) render_template_string compiles and executes whatever template source it is given, so once attacker data reaches it (as source, rather than as a value), the attacker is no longer injecting data into a template, they are writing the template. Closing the quote and the url_for(...) call early, and opening a fresh {{ ... }} expression right after it, is enough to make Jinja evaluate arbitrary Python, including reaching self.__init__.__globals__.__builtins__, and calling os.popen directly from there, which is a well known Jinja SSTI to RCE escape. Since the endpoint required no authentication, and no prior interaction with the application, any remote, unauthenticated visitor could trigger this on a live LMFDB instance.
Confirming it was real
Since the automated verification pass couldn’t be sure the sink was actually reachable the way I just described, and flagged it even as potential False Positive on that basis, I went and checked by hand, first against a local copy of the code to confirm the data flow, and then against the live site itself, since static reading alone can tell you a sink looks dangerous, but only a real request tells you whether it’s actually wired up the way you think. I started with the simplest possible SSTI probe, the classic 7*7 arithmetic payload, sent as a query parameter to LMFDB’s own live demo site at lmfdb.org. It came back evaluated as 49 rather than as the literal string, confirming that the injected expression, really was reaching Jinja’s evaluator on production.
With injection confirmed, the next step was proving code execution, carefully, since this was live infrastructure serving real users and mathematicians, not a lab environment, so the only responsible payload was a single, safe, read-only command. So I ran the following from PowerShell:
$knowlId="4"; $payload='") }}{{ self.__init__.__globals__.__builtins__.__import__(''os'').popen(''id'').read() }}'; $body=@{x=$payload}; $response=Invoke-WebRequest -Uri "https://www.lmfdb.org/knowledge/render/$knowlId" -Method Get -Body $body -UseBasicParsing; $response.Content The payload closes the quoted ID argument, and the url_for(...) call that kw_params was meant to sit inside of, closes the original {{ }} expression, and opens a brand new one that imports os and runs id, reading back its output. Against the real server, this returned the output of id running as the application’s own service account, which the advisory itself later documented as uid=1300(lmfdb) gid=1300(lmfdb) groups=1300(lmfdb), proof of genuine, unauthenticated Remote Code Execution on LMFDB’s own production host, achieved with nothing more intrusive than listing who the process was running as:
Reporting and the fix
We reported this to the LMFDB maintainers through our pipeline, and they moved fast, shipping a fix within two days. The patch, merged as commit 01829bd, restructures render_knowl so that request data is never again concatenated into template source. Query parameters, the permalink, and the knowl’s title are now passed into render_template_string as ordinary template variables (through its keyword arguments), rather than being spliced into the template string itself, and the only thing that can still influence the actual Jinja source is the knowl’s own saved content, which is authored by logged in, trusted editors, not pulled from a unauthenticated request. The fix also closed a smaller companion issue in the same function, a preview endpoint for unsaved knowl edits, by requiring login on it explicitly, instead of relying on it being reachable only through the editor UI.
Getting a CVE and the CVSS “disagreement”
Once the fix was out and the advisory was ready, we requested a CVE ID through VulnCheck, who assigned CVE-2026-123198 in less than 5 hours, so huge thanks to the VulnCheck team for that super fast turnaround (especially given how slow GitHub’s own CVE assignment queue has been running lately). The advisory itself, GHSA-6jw3-pmcx-2xww, was published with a CVSS4 score of 10.0, vector CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H, scoring the subsequent system metrics (SC/SI/SA) as High on top of a full-High vulnerable system impact, on the basis that unauthenticated RCE on a production webserver, is about as bad as a single finding gets.
During review, VulnCheck rescored it down to a 9.3, dropping the subsequent system metrics to None (vector CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N). Their stated reasoning was that CVSS4’s subsequent system impact, requires demonstrated harm reaching a separate system beyond the vulnerable one, and the advisory as written, only showed impact on LMFDB’s own host.
We pushed back on that rescore, and we still think it isn’t quite right for this specific case, because to actually demonstrate impact on a separate, subsequent system, the only path available to us would’ve been to use the unauthenticated RCE we already had, to pivot off the LMFDB server into whatever internal infrastructure it could reach, and move laterally from there. Of course, we didn’t do that, deliberately, because doing so would have been illegal and unsafe, going well beyond what responsible disclosure testing permits, and that step wasn’t necessary to prove the vulnerability was real in the first place. But that’s exactly the point: a real attacker without our restraint, holds that same capability the moment they have unauthenticated code execution on the box, so the subsequent system impact isn’t absent here, it’s simply something we correctly declined to demonstrate ourselves. Scoring strictly on what we were willing to prove, rather than on what the underlying primitive already grants an attacker, is a defensible reading of the CVSS4 methodology, but we still think it understates what this specific vulnerability actually would’ve handed to a real, unconstrained attacker.
Conclusion
If you run a self-hosted LMFDB instance, update to a version built after commit 01829bd as soon as possible, since the unpatched render endpoint required no authentication at all to reach. If you maintain a Flask application with anything resembling LMFDB’s old pattern, building Jinja template source out of request data with string formatting before handing it to render_template_string or render_template, treat it as a critical vulnerability, regardless of how old or trusted that code path looks, since the comment explaining the original design here was not wrong about what it was trying to do, it was wrong about doing it with untrusted input.
This is also a good example of why manual review still matters, even with automated triage in the loop, as an LLM reading source code in isolation couldn’t be confident this was exploitable, and it took a human actually reaching out, and touching the live application to turn “looks suspicious” into a confirmed, critical, unauthenticated RCE.