
In-depth analysis and proof-of-concept for CVE-2026-27280, an out-of-bounds write in Adobe DNG SDK's dng_render_task::ProcessArea, reachable via Android's image decoding API, with root cause, exploitability assessment, and RCE gap analysis.
dng_render_task::ProcessArea (Adobe DNG SDK / Android)Reached from an ordinary DNG file through Android's public image-decoding API, and
reproduced on a Galaxy S25 using the phone's own /system/lib64/libdng_sdk.so.
DNG_REQUIRE(srcArea.Size() == dstArea.Size()) + srcArea indexing)#12 AImageDecoder_decodeImage /system/lib64/libjnigraphics.so
#11 android::ImageDecoder::decode /system/lib64/libhwui.so
#09 SkCodec::getPixels /system/lib64/libhwui.so
#08 SkRawCodec::onGetPixels /system/lib64/libhwui.so
#07 dng_render::Render() /system/lib64/libdng_sdk.so
#06 SkDngHost::PerformAreaTask /system/lib64/libhwui.so
#00 dng_render_task::ProcessArea /system/lib64/libdng_sdk.so <-- vulnerable
ProcessArea maps a tile-sized temporary buffer over srcArea but indexes it with dstArea
coordinates. Since srcArea = dstArea + fSrcOffset and fSrcOffset is the file's
DefaultCropOrigin — non-zero in every real camera DNG — the destination pointer is
displaced out of the allocation by fSrcOffset * rowStep * pixelSize, and the mask read-back
writes there. It is a displacement, not an integer overflow, so it needs no huge allocation:
that is why it is reachable on a phone.
01-vulnerability/ the vulnerable and fixed versions of dng_render.cpp, and the 2471->2502 diff
02-poc/ generators, loaders, probes (sources) + prebuilt arm64 binaries
03-samples/ the DNG files
04-evidence/ ASAN logs, device tombstones, probe output
05-analysis/ root cause, generator, neighbour measurements, RCE gap analysis
The work that goes beyond the earlier verdict: it disproves two of its claims by
measurement, closes the "is a corrupted pointer used" gap, and pins exactly why the
chain stops. Read 06-rce-lab/README.md first; the evidence/*.md files are the
per-experiment write-ups, and src/ + apk/ are the reproducible harnesses.
adb push 02-poc/bin-arm64/poc_aimg 03-samples/trigger_natural_croporigin.dng /data/local/tmp/
adb shell "cd /data/local/tmp && chmod 755 poc_aimg && ./poc_aimg trigger_natural_croporigin.dng"
# -> Segmentation fault, tombstone frames inside libdng_sdk.so
Rebuilding the arm64 loaders requires Android's own dng_sdk flags (from Android.bp), or the
struct layouts will not match the shipped library:
-DUNIX_ENV=1 -DqDNGBigEndian=0 -DqDNGThreadSafe=1 -DqDNGUseLibJPEG=1 \
-DqDNGUseXMP=0 -DqDNGValidateTarget=1 -DqDNGUsingSanitizer=1 -DqLinux=1 -DqAndroid=1
This corresponds to CVE-2026-27280 (DNG SDK 1.7.1 2471 and earlier, CWE-787, CVSS 3.1 7.8 HIGH), published 2026-03-10 — matched by version range and class, not confirmed by Adobe naming the function.
Proven, on the real Android decode path, on a device patched to 2026-01-01:
out-of-bounds write with attacker-chosen destination (4-byte granularity, both
directions, ASLR-independent); reachable from a plain DNG through AImageDecoder,
demonstrated killing the Samsung Gallery in its thumbnail loader; silent deterministic
corruption of neighbours; a corrupted pointer reaching free() (N1 closed); a
heap-spray expressed by the DNG format (ImageStats × SubIFD); the write aimed onto
those sprayed objects, 6/10 vs 0/10.
Not RCE, on the Android path, and for one root reason: the code can write a single
value, 0x3F800000. Everything follows from that — the write is contiguous (no stride
over Scudo headers), there is no zero to write (no length/refcount corruption), and the
tag-preserving partial overwrite has exactly one fixed target offset. One value is
one shot against a randomised 256 MB reservation, and no amount of spray volume or
concurrency adds shots. On the desktop SDK the writable set is 256 values → 256 shots
across a ~67 MB band, which is why the 7.8 is correct there.
Retracted from the earlier verdict (05-analysis/05-exploitability-verdict.md),
falsified by measurement: "a real decoder offers no spray primitive" and "the
overlap is not steerable from inside a single decode". The block is not PAC, BTI or
Scudo's checksums — it is the single writable value, a property of the code that Adobe's
2502 fix removes at the root by indexing with srcArea.
No working exploit was built, and none is published here.
| file | what it does |
|---|
trigger_natural_croporigin.dng | natural geometry (DefaultCropOrigin = 8,8); crashes AImageDecoder |
benign_reference.dng | same file with origin 0 — decodes fine, used to probe the heap |
silent_corruption.dng | small size class: writes out of bounds without crashing (5/5) |
far_write_crash.dng | large displacement: faults visibly |
| file | contents |
|---|
01-root-cause.md | the bug, the fix, the shape of the write |
02-dng-generator.md | how the trigger file is built, the two tuning knobs, pitfalls |
03-neighbours.md | controllability numbers, what sits next to the buffer, mitigations |
04-what-is-missing-for-rce.md | what is proven (P1-P8), what is not (N1-N3), the remaining steps |
05-exploitability-verdict.md | the earlier verdict — superseded by 06-rce-lab, kept for the record |
| file | contents |
|---|
EXP1-stride-vs-scudo.md | stride vs Scudo headers: possible in the abstract, impossible on the Android path (planes==1) |
EXP2-N1-corrupted-pointer-used.md | a corrupted pointer reaches free() — N1 closed |
EXP2-real-process.md | the write in the real Samsung Gallery process, nothing planted |
EXP-imagestats-spray.md | the heap-spray the earlier verdict called impossible, expressed by the DNG format |
EXP-aimed-spray.md | aiming the write onto sprayed objects: 6/10 vs 0/10 |
EXP-small-class-in-app.md | code pointers in reach inside a real app process |
EXP-band-commit.md / EXP-concurrency.md | why committing the target fails: one writable value = one shot |
EXP-write-read-mismatch.md | a second defect + the full source audit of ProcessArea |
EXP-value-1.0f.md | the 1.0f-as-datum route, closed structurally |