<?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://0xa13.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://0xa13.dev/" rel="alternate" type="text/html" /><updated>2026-06-23T10:47:20+00:00</updated><id>https://0xa13.dev/feed.xml</id><title type="html">0xA13</title><subtitle>All things offensive security &amp; malware analysis</subtitle><author><name>0xA13</name></author><entry><title type="html">How a stale metadata field became a heap buffer overflow</title><link href="https://0xa13.dev/2026/03/18/libde265-stale-metadata-heap-overflow/" rel="alternate" type="text/html" title="How a stale metadata field became a heap buffer overflow" /><published>2026-03-18T00:00:00+00:00</published><updated>2026-03-18T00:00:00+00:00</updated><id>https://0xa13.dev/2026/03/18/libde265-stale-metadata-heap-overflow</id><content type="html" xml:base="https://0xa13.dev/2026/03/18/libde265-stale-metadata-heap-overflow/"><![CDATA[<p>Some colleagues of mine do security research and some of them present the findings at conferences. Watching that happen from the sidelines was motivation enough to finally try fuzzing myself. Media libraries felt like a good starting point.</p>

<p>One of the libraries I picked was libde265.</p>

<p>libde265 is an open-source H.265/HEVC decoder maintained by Dirk Farin. The library serves as the default HEVC decoding backend for libheif, a widely used HEIF/HEIC image processing library. libde265 has also been integrated into multimedia frameworks such as GStreamer and has historically provided HEVC decoding support for projects including VLC and FFmpeg. Because HEIC is the default photo format used by modern iPhones, libde265 is frequently involved in the decoding of iPhone-generated images on Linux systems and in cross-platform applications that rely on libheif for HEIC support. That makes it a fairly interesting target.</p>

<p>While fuzzing libde265 1.0.16 with libFuzzer and AddressSanitizer I found a heap out-of-bounds write, which was later assigned CVE-2026-33165.</p>

<p>The crash itself came back quickly. Understanding why it happened took considerably longer.</p>

<hr />

<h2 id="what-even-is-a-ctb">What even is a CTB?</h2>

<p>Before getting into the crash, one HEVC concept needs to be explained: the Coding Tree Block, usually written as CTB.</p>

<p>A video decoder does not reason about individual pixels all the time. Instead, HEVC divides a picture into larger square regions. Those are CTBs. The decoder tracks per-CTB metadata: which slice a given block belongs to, its quantisation parameters, deblocking information, rather than per-pixel metadata.</p>

<p>The size of each CTB is not fixed. It is described by a parameter inside the bitstream called <code class="language-plaintext highlighter-rouge">Log2CtbSizeY</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Log2CtbSizeY = 5  -&gt;  2^5 = 32px CTBs
Log2CtbSizeY = 6  -&gt;  2^6 = 64px CTBs
</code></pre></div></div>

<p>This matters because <code class="language-plaintext highlighter-rouge">Log2CtbSizeY</code> is what the decoder uses to convert between pixel coordinates and CTB indices. If one part of the decoder thinks CTBs are 32px wide and another part thinks they are 64px wide, the same pixel coordinate maps to two different CTB indices, and one of them may be out of range.</p>

<hr />

<h2 id="what-is-an-sps">What is an SPS?</h2>

<p>The SPS, or Sequence Parameter Set, is a chunk of configuration inside an HEVC bitstream. It tells the decoder how to interpret the data that follows.</p>

<p>For this bug, only three SPS fields matter:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PicWidthInCtbsY   - how many CTBs wide the picture is
PicHeightInCtbsY  - how many CTBs tall it is
Log2CtbSizeY      - how large each CTB is
</code></pre></div></div>

<p>Two SPS entries can describe images with completely different resolutions while sharing identical <code class="language-plaintext highlighter-rouge">PicWidthInCtbsY</code> and <code class="language-plaintext highlighter-rouge">PicHeightInCtbsY</code> values, as long as the CTB size scales proportionally. For example:</p>

<table>
  <thead>
    <tr>
      <th>SPS</th>
      <th>Log2CtbSizeY</th>
      <th>PicWidthInCtbsY</th>
      <th>PicHeightInCtbsY</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SPS 0</td>
      <td>5 (32px CTBs)</td>
      <td>13</td>
      <td>8</td>
    </tr>
    <tr>
      <td>SPS 1</td>
      <td>6 (64px CTBs)</td>
      <td>13</td>
      <td>8</td>
    </tr>
  </tbody>
</table>

<p>The CTB size changes, the CTB grid stays 13x8. That is exactly the condition in the reproducer. Getting back to that in a moment.</p>

<hr />

<h2 id="the-metadata-array">The metadata array</h2>

<p>libde265 stores per-CTB metadata in <code class="language-plaintext highlighter-rouge">de265_image::ctb_info</code>, backed by a flat array allocated in <code class="language-plaintext highlighter-rouge">alloc_image</code>. The structure that wraps it looks roughly like:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">template</span><span class="o">&lt;</span><span class="k">class</span> <span class="nc">DATA</span><span class="p">&gt;</span>
<span class="k">struct</span> <span class="nc">image_data_array</span> <span class="p">{</span>
  <span class="n">DATA</span><span class="o">*</span>  <span class="n">data</span><span class="p">;</span>
  <span class="kt">int</span>    <span class="n">data_size</span><span class="p">;</span>
  <span class="kt">int</span>    <span class="n">width_in_units</span><span class="p">;</span>
  <span class="kt">int</span>    <span class="n">height_in_units</span><span class="p">;</span>
  <span class="kt">int</span>    <span class="n">log2unitSize</span><span class="p">;</span>      <span class="c1">//cached CTB size, set at allocation time</span>
<span class="p">};</span>
</code></pre></div></div>

<p>For a 13x8 CTB grid, the array holds 104 entries. Valid indices are 0-103. The <code class="language-plaintext highlighter-rouge">log2unitSize</code> field is stored alongside the array so that later code can convert pixel coordinates back into CTB indices without needing to reach into the active SPS.</p>

<p>That cached <code class="language-plaintext highlighter-rouge">log2unitSize</code> is the bug.</p>

<hr />

<h2 id="the-reallocation-condition">The reallocation condition</h2>

<p>When the active SPS changes, <code class="language-plaintext highlighter-rouge">alloc_image</code> checks whether <code class="language-plaintext highlighter-rouge">ctb_info</code> needs to be rebuilt:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">//image.cc:458-459</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ctb_info</span><span class="p">.</span><span class="n">width_in_units</span>  <span class="o">!=</span> <span class="n">sps</span><span class="o">-&gt;</span><span class="n">PicWidthInCtbsY</span> <span class="o">||</span>
    <span class="n">ctb_info</span><span class="p">.</span><span class="n">height_in_units</span> <span class="o">!=</span> <span class="n">sps</span><span class="o">-&gt;</span><span class="n">PicHeightInCtbsY</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">//reallocate</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice what is not checked: <code class="language-plaintext highlighter-rouge">Log2CtbSizeY</code>.</p>

<p>If the CTB grid dimensions stay the same but the CTB size changes, the check sees equal dimensions, decides there is nothing to rebuild, and moves on. The <code class="language-plaintext highlighter-rouge">ctb_info</code> array is reused as-is, including its cached <code class="language-plaintext highlighter-rouge">log2unitSize</code>, which now reflects the old SPS rather than the new one.</p>

<p>The reproducer contains two SPS entries that share <code class="language-plaintext highlighter-rouge">PicWidthInCtbsY=13</code> and <code class="language-plaintext highlighter-rouge">PicHeightInCtbsY=8</code> but differ in <code class="language-plaintext highlighter-rouge">Log2CtbSizeY</code>: 5 in SPS 0, 6 in SPS 1. The reallocation check sees 13==13, 8==8, and skips. The array keeps <code class="language-plaintext highlighter-rouge">log2unitSize=5</code> while the rest of the decoder now operates under <code class="language-plaintext highlighter-rouge">Log2CtbSizeY=6</code>.</p>

<hr />

<h2 id="a-note-on-the--operator">A note on the <code class="language-plaintext highlighter-rouge">&gt;&gt;</code> operator</h2>

<p>The root cause is a stale value being fed into an otherwise correct calculation, so it helps to understand what that calculation actually does.</p>

<p>The <code class="language-plaintext highlighter-rouge">&gt;&gt;</code> operator is called a right shift. Numbers in a computer are stored in binary, and shifting right moves all the bits a certain number of places to the right, dropping whatever falls off the end. Right-shifting by N is exactly the same as dividing by 2^N and throwing away the remainder.</p>

<p>So:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>256 &gt;&gt; 5  =  256 / 32  =  8
256 &gt;&gt; 6  =  256 / 64  =  4
</code></pre></div></div>

<p>The decoder uses this to convert a pixel coordinate into a CTB row number. Pixel 256, with 32px CTBs, is in row 8. Pixel 256, with 64px CTBs, is in row 4. The number stored in <code class="language-plaintext highlighter-rouge">log2unitSize</code> is the shift amount, so it controls which answer you get.</p>

<p>The bug is that <code class="language-plaintext highlighter-rouge">log2unitSize</code> still says 5 when the active SPS has already moved to 6. So the decoder asks “which row is pixel 256 in?” and gets 8 instead of 4. Row 8 does not exist in an 8-row array. Row 4 would have been perfectly fine.</p>

<hr />

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

<p>Once the second SPS is active, the decoder starts decoding a CTB at row 4. Pixel coordinates are computed using the current SPS:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">//slice.cc:2857-2866</span>
<span class="kt">int</span> <span class="n">yCtbPixels</span> <span class="o">=</span> <span class="n">yCtb</span> <span class="o">&lt;&lt;</span> <span class="n">sps</span><span class="p">.</span><span class="n">Log2CtbSizeY</span><span class="p">;</span>   <span class="c1">//4 &lt;&lt; 6 = 256</span>
<span class="n">img</span><span class="o">-&gt;</span><span class="n">set_SliceHeaderIndex</span><span class="p">(</span><span class="n">xCtbPixels</span><span class="p">,</span> <span class="n">yCtbPixels</span><span class="p">,</span> <span class="n">shdr</span><span class="o">-&gt;</span><span class="n">slice_index</span><span class="p">);</span>
</code></pre></div></div>

<p>That’s fine. Row 4 times 64 pixels per CTB equals pixel 256. But then <code class="language-plaintext highlighter-rouge">set_SliceHeaderIndex</code> passes those coordinates to <code class="language-plaintext highlighter-rouge">ctb_info.get()</code>, which converts them back to CTB indices using the cached <code class="language-plaintext highlighter-rouge">log2unitSize</code>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">//image.h:741 -&gt; image.h:124-131</span>
<span class="n">DataUnit</span><span class="o">&amp;</span> <span class="n">get</span><span class="p">(</span><span class="kt">int</span> <span class="n">x</span><span class="p">,</span> <span class="kt">int</span> <span class="n">y</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">int</span> <span class="n">unitY</span> <span class="o">=</span> <span class="n">y</span> <span class="o">&gt;&gt;</span> <span class="n">log2unitSize</span><span class="p">;</span>      <span class="c1">//256 &gt;&gt; 5 = 8  (stale log2unitSize)</span>
    <span class="c1">//assert(unitY &lt; height_in_units) removed in release build (-DNDEBUG)</span>
    <span class="k">return</span> <span class="n">data</span><span class="p">[</span> <span class="n">unitX</span> <span class="o">+</span> <span class="n">unitY</span><span class="o">*</span><span class="n">width_in_units</span> <span class="p">];</span>    <span class="c1">//OOB WRITE</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">256 &gt;&gt; 5 = 8</code>. The array has 8 rows, valid range 0-7, and row 8 is one past the end.</p>

<p>There is a per-iteration guard in <code class="language-plaintext highlighter-rouge">decode_substream</code> that checks CTB coordinates before this point:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">//slice.cc:4732-4733</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ctbx</span> <span class="o">&gt;=</span> <span class="n">sps</span><span class="p">.</span><span class="n">PicWidthInCtbsY</span> <span class="o">||</span>
    <span class="n">ctby</span> <span class="o">&gt;=</span> <span class="n">sps</span><span class="p">.</span><span class="n">PicHeightInCtbsY</span><span class="p">)</span> <span class="p">{</span>   <span class="c1">//0 &gt;= 13 -&gt; false, 4 &gt;= 8 -&gt; false</span>
</code></pre></div></div>

<p>The guard uses coordinates from the active SPS and sees a completely valid CTB at (0, 4) so it passes. The bug is not that the guard is wrong, it is that the guard and the metadata write do not share the same coordinate system.</p>

<p>ASan confirms it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>==46762==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x51e000000a42 at pc 0x5bdb7a052986 bp 0x7ffdf7d40390 sp 0x7ffdf7d40388
WRITE of size 2 at 0x51e000000a42 thread T0
    #0 0x5bdb7a052985 in de265_image::set_SliceHeaderIndex(int, int, int) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/../libde265/image.h:741:40
    #1 0x5bdb7a052985 in read_coding_tree_unit(thread_context*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/slice.cc:2866:8
    #2 0x5bdb7a05d8c0 in decode_substream(thread_context*, bool, bool) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/slice.cc:4755:5
    #3 0x5bdb7a060413 in read_slice_segment_data(thread_context*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/slice.cc:5068:14
    #4 0x5bdb7a00d576 in decoder_context::decode_slice_unit_sequential(image_unit*, slice_unit*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/decctx.cc:857:7
    #5 0x5bdb7a00bb39 in decoder_context::decode_slice_unit_parallel(image_unit*, slice_unit*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/decctx.cc:959:11
    #6 0x5bdb7a00aa66 in decoder_context::decode_some(bool*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/decctx.cc:744:13
    #7 0x5bdb7a0100a6 in decoder_context::decode(int*) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/decctx.cc:1343:11
    #8 0x5bdb79fffb92 in main /home/ana/fuzzing-lab/libde265/issue/poc.c:40:15

0x51e000000a42 is located 2 bytes after 2496-byte region [0x51e000000080,0x51e000000a40)
allocated by thread T0 here:
    #0 0x5bdb79fc13c3 in malloc
    #1 0x5bdb7a02d40a in MetaDataArray&lt;CTB_info&gt;::alloc(int, int, int) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/./image.h:94:25
    #2 0x5bdb7a02d40a in de265_image::alloc_image(...) /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/image.cc:463:39

SUMMARY: AddressSanitizer: heap-buffer-overflow /home/ana/fuzzing-lab/libde265/libde265-1.0.16/libde265/../libde265/image.h:741:40 in de265_image::set_SliceHeaderIndex(int, int, int)
Shadow bytes around the buggy address:
  0x51e000000980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=&gt;0x51e000000a00: 00 00 00 00 00 00 00 00[fa]fa fa fa fa fa fa fa
  0x51e000000a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
==46762==ABORTING
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">=&gt;</code> row is the shadow row containing the crash address <code class="language-plaintext highlighter-rouge">0x51e000000a42</code>. Each shadow byte covers 8 application bytes; <code class="language-plaintext highlighter-rouge">[fa]</code> marks the shadow byte whose range includes <code class="language-plaintext highlighter-rouge">0xa42</code>. The allocation ends at <code class="language-plaintext highlighter-rouge">0xa40</code>, so <code class="language-plaintext highlighter-rouge">0xa40-0xa47</code> is the first redzone cell, the write at <code class="language-plaintext highlighter-rouge">0xa42</code> is 2 bytes into it.</p>

<p>The write is 2 bytes past a 2496-byte region. 2496 = 104 * 24, matching the 104-entry array exactly.</p>

<hr />

<h2 id="debug-builds-confirm-it-independently">Debug builds confirm it independently</h2>

<p>What’s worth noting is that the assert guards at <code class="language-plaintext highlighter-rouge">image.h:128-129</code> fire independently in a debug build without any sanitizers involved:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">assert</span><span class="p">(</span><span class="n">unitX</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">unitX</span> <span class="o">&lt;</span> <span class="n">width_in_units</span><span class="p">);</span>   <span class="c1">//image.h:128</span>
<span class="n">assert</span><span class="p">(</span><span class="n">unitY</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">unitY</span> <span class="o">&lt;</span> <span class="n">height_in_units</span><span class="p">);</span>  <span class="c1">//image.h:129</span>
</code></pre></div></div>

<p>Two separate crash inputs trigger each assert independently, one for the <code class="language-plaintext highlighter-rouge">unitX</code> out-of-range path, one for <code class="language-plaintext highlighter-rouge">unitY</code>. That matters because it means the OOB condition is reachable on both axes, and it is the library’s own bounds check firing. In a release build these asserts are compiled out with <code class="language-plaintext highlighter-rouge">-DNDEBUG</code> and the write proceeds silently.</p>

<hr />

<h2 id="the-summary-of-what-actually-happened">The summary of what actually happened</h2>

<ol>
  <li>The program allocated an array of 104 entries to track metadata for a 13x8 grid of CTBs, and stored the CTB size (32px, log2 = 5) alongside it.</li>
  <li>The video configuration switched to a new SPS that kept the 13x8 grid but changed the CTB size to 64px (log2 = 6).</li>
  <li>The program checked whether the grid dimensions changed. They had not, so it skipped rebuilding the array. But it forgot to update the stored CTB size.</li>
  <li>Later, the program needed to record which slice a CTB at pixel row 256 belongs to. It divided 256 by the stored (stale) CTB size of 32, getting row 8.</li>
  <li>Row 8 is past the end of an 8-row array. The write landed past the end of the heap allocation.</li>
  <li>The deblocking filter read the corrupted value back in the same decode pass.</li>
  <li>ASan caught this in testing. In a production build, it would have been silent corruption.</li>
</ol>

<hr />

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

<ul>
  <li><strong>CVE:</strong> CVE-2026-33165</li>
  <li><strong>Affected version:</strong> libde265 1.0.16</li>
  <li><strong>CWE:</strong> CWE-787 (Out-of-bounds Write)</li>
  <li><strong>Reported to:</strong> Dirk Farin (farindk) via GitHub Security Advisory</li>
  <li><strong>Advisory:</strong> published, CVE assigned via GitHub CNA</li>
  <li><strong>References:</strong> https://www.sentinelone.com/vulnerability-database/cve-2026-33165/, https://github.com/strukturag/libde265/security/advisories/GHSA-653q-9f73-8hvg</li>
</ul>]]></content><author><name>0xA13</name></author><category term="fuzzing" /><category term="cve" /><summary type="html"><![CDATA[CVE-2026-33165: a heap out-of-bounds write in libde265 caused by a stale CTB size field surviving an SPS switch.]]></summary></entry></feed>