A Little bit MIFFed - A Sorrowful Tale of Loss
“Not all things can be great.”
Vulnerability research has a habit of making you fall in love with things that are, on closer inspection, not quite what you first thought they were. Sometimes the crash becomes a clean primitive. Sometimes the suspicious code path turns into a beautiful exploit chain. And sometimes, after a few hours of digging, source-diving, and quietly muttering expletives at your screen, the “finding” politely refuses to become a vulnerability and takes a punt at your soul.
This is one of those cases.
When I’m not working on client engagements, or doing those strange things other people call weekends, I generally hunt for bugs. Some of those findings can be disclosed and used to support future work. It is also how I keep my technical skills sharp.
So, that said, this is where we begin:
The behaviour that was being investigated lives in ImageMagick’s handling of MIFF and MPC image data. At first glance, it looks a little spicy: attacker-controlled montage metadata can influence a later secondary image read when certain output paths are used. That sounds like the beginning of a decent vulnerability and set my eight-legged-bug sense tingling.
But the important phrase there is “if everything lines up”. Because here, quite a lot has to line up.
The result is not nothing. It is technically interesting and it is worth understanding. It may even become security-relevant in a very particular deployment. But as a general-purpose vulnerability claim, it is heavily caveated enough that the realistic conclusion is probably this: it is not a vulnerability worth declaring.
The Short Version
MIFF and MPC are ImageMagick-related image formats. During parsing, they can contain montage-related data. In the path reviewed here, that montage directory data can be stored on the decoded image object. Later, when the image is converted to JSON, YAML, or inspected using detailed identification output, those stored directory entries may be treated as filenames and opened again through ImageMagick’s normal image-reading path.
So the data flow looks roughly like this:
attacker-controlled MIFF or MPC bytes
|
| montage= header enables a following directory block
v
ReadMIFFImage() / ReadMPCImage()
|
| stores the NUL-terminated directory bytes in Image.directory
v
JSON / YAML writer, or verbose IdentifyImage()
|
| copies one directory entry into ImageInfo.filename
v
StrictReadImage() -> ReadImage()
|
v
selected local image is decoded as a new Image (tile)
|
v
its dimensions, format, signature and available properties are written to output
The important small detail is that the local path is not the value of the textual montage= header. That header merely makes the decoder read a separate, NUL-terminated directory payload after the MIFF/MPC header. In the reviewed path, an attacker supplies that directory payload, ImageMagick stores it on the decoded image, and a later JSON/YAML/verbose-identify operation may reopen the referenced local image and include some of its image properties in the output.
That is an interesting primitive. It feels like the start of a local file disclosure issue. The catch is that it is not a generic arbitrary file read. The target file generally needs to be something ImageMagick can decode as an image. The process must already have permission to read it. The service must accept MIFF or MPC input. The service must then return JSON, YAML, or detailed identify-style output. The policy and sandboxing must allow the secondary read. The attacker also needs to know or guess something useful to point at.
That is not impossible. But it is a fairly narrow bridge to walk.
Files and Objects Involved
|
Item |
Role in the flow |
|
coders/miff.c |
Parses the untrusted MIFF; stores the montage keyword (:880) and slurps the directory payload into image->directory |
|
coders/mpc.c |
Same behaviour for the MPC format. |
|
MagickCore/image.h |
Defines struct _Image with the montage/directory fields (:131) and struct _ImageInfo with the filename buffer |
|
MagickCore/image.c |
CloneImage() copies directory into the write-path clone so the value survives |
|
MagickWand/magick-image.c |
Wand entry points MagickReadImage / MagickWriteImage that drive the read-then-write. |
|
MagickCore/constitute.c |
Generic ReadImage (:607) / WriteImage (:1214) dispatch; also where the target file is opened via OpenBlob (:685). |
|
coders/json.c |
Primary sink — EncodeImageAttributes() splits the directory and reopens each entry (:1524, :1556). |
|
coders/yaml.c |
Same sink in the YAML writer (:1493). |
|
MagickCore/identify.c |
Same sink in verbose identify only (:1342; non-verbose returns at :632). |
Why This Looked Promising
The thing that made this worth chasing was the provenance mismatch. A value originating from an input file was later used as a filename by an output or inspection path. That should immediately make vulnerability researchers twitch slightly.
In general, when user-controlled data crosses a boundary from “data being described” into “thing the program acts upon”, there is often something useful hiding nearby. That is how you get file reads, SSRF, command injection, template injection, path traversal, deserialisation issues, and a whole family of bugs that begin with the same basic question:
“Why is untrusted input being trusted over here?”
In this case, the input-controlled montage directory is not merely printed as inert metadata. It can be interpreted as a path and passed back into the image reading machinery.
The code path also had the sort of shape that tends to produce real findings: parser accepts structured metadata, object stores it, encoder later walks it, helper function reopens filenames, secondary decoder runs, output contains derived information. That is exactly the kind of chain where you want to slow down and ask whether the software has accidentally confused a representation of a thing with permission to access the thing itself.
Step 1: Data is Provided
Everything starts with a single file the attacker fully controls: a MIFF image. MIFF is one of ImageMagick's native formats, and its header is just a list of key=value lines.
In ReadMIFFImage(), the montage header is recognised. Later, its presence causes the reader to copy bytes from the input stream into image->directory until it sees a NUL byte:
if (LocaleCompare(keyword,"montage") == 0)
{
(void) CloneString(&image->montage,options);
break;
}
...
if (image->montage != (char *) NULL)
{
image->directory=AcquireString((char *) NULL);
p=image->directory;
do
{
c=ReadBlobByte(image);
if (c == EOF)
break;
*p++=(char) c;
} while (c != (int) '\0');
}
The exact allocation-and-growth code is at coders/miff.c. The MPC reader has the same two stages at coders/mpc.c. MIFF is the simpler format for a controlled fixture; MPC also needs its matching pixel-cache sidecar and signature checks to succeed.
What the Crafted Input Actually Contains
For MIFF, the smallest useful shape is a normal textual header, followed by the MIFF header terminator and a binary directory block. In this simplified layout, the angle-bracket values are literal bytes, rather than text:
montage=1x1+0+0\n <0c><0a><3a><1a> /owned/test-image.ppm<ff><00> <00><00><00>
montage=1x1+0+0 makes the reader expect a directory. <0c><0a><3a><1a> terminates the MIFF textual header – and yes there is bounds checking. The path is then an entry in the directory payload; <ff> separates entries and <00> ends the payload. The final three zero bytes are the pixel data for a one-pixel RGB image.
Step 2: Data Taken
When ImageMagick reads the file, ReadMIFFImage() notices the montage field and, because of it, copies the bytes that follow straight into a field on the in-memory image object called image->directory. That's the whole of it: the path /tmp/somefile is now stored on the image as a plain string.
At this point no file has been opened, other than reading the MIFF itself, and nothing has been validated as a real path. It is simply treated as descriptive metadata, the same way you might store a caption or a timestamp.
A strings output of crafted.miff (a crafted MIFF file)
The screenshot shows the strings of the MIFF file that was created to test the PoC. As can be seen, the /tmp/somefilepath was present. This is the interesting part.
Step 3: The JSON Writer Turns Directory Entry into a Filename
This is the point that made the behaviour worth investigating. In EncodeImageAttributes(), the JSON coder splits image->directory on 0xff, copies an entry to image_info->filename, and then asks the normal reader to open it:
for (p=image->directory; *p != '\0'; p++)
{
q=p;
while ((*q != '\xff') && (*q != '\0') &&
((size_t) (q-p+1) < sizeof(image_info->filename)))
q++;
(void) CopyMagickString(image_info->filename,p,(size_t) (q-p+1));
p=q;
tile=StrictReadImage(image_info,exception);
if (tile == (Image *) NULL)
continue;
/* write tile dimensions, format, signature and properties */
}
The exact output logic immediately afterwards writes the target's dimensions and format, calls SignatureImage(tile,exception), then iterates its properties (coders/json.c). The equivalent YAML loop is at coders/yaml.c.
Step 4: “Strict” still means “Read”
StrictReadImage() prevents some pseudo-filename behaviour, but it is not a provenance check and it does not tie the path to the original upload. For an ordinary accessible path it does this:
if (IsPathAccessible(image_info->filename) == MagickFalse)
return((Image *) NULL);
return(ReadImage(image_info,exception));
ReadImage() then selects the decoder for the referenced target. This explains both the limited disclosure and the extra parser exposure: the target has to be something the process can read and an enabled coder can decode, but it is then handled like a normal image.
“Here’s the crux of the issue, which means that there is no arbitrary file read. It has to be structured in a format that can be interpreted by ImageMagick”
Also, it was noted that supplied secure policy denies selected patterns such as /etc/*, ../, and @*, but it does not positively confine ordinary readable image paths to a per-request directory config/policy-secure.xml.
Where the Impact Starts to Shrink
The more the behaviour was bounded, the less comfortable it became to call it a vulnerability in the broad sense.
First, this is not raw file disclosure. Pointing it at a plain text file, a private key, or some arbitrary host file does not mean the contents are magically printed into JSON. The secondary target normally needs to be readable and parseable by an enabled ImageMagick coder. If it is not an image, the read is expected to fail rather than leak the file contents.
Second, with regard to an attack vector, the right path has to be exposed. If an application never accepts MIFF or MPC input, this behaviour is irrelevant. If it accepts those formats but never produces JSON, YAML, or detailed identify output, the secondary read is unlikely to be reached. And if ImageMagick runs inside a properly isolated worker with a request-specific filesystem view, there may be nothing useful for it to read outside the current job anyway.
Third, policy matters. A deny-list style policy may not be enough to prevent every ordinary local image path from being opened, but an allow-list might. The difference between “the process can read lots of shared customer media” and “the process can only read this one temporary upload directory” is the a significant difference in impact.
Finally, even when it works, the output is bounded. The attacker might learn dimensions, format, signatures, embedded properties, or whether a target image exists and is readable. That can matter. But it is a very different claim from “arbitrary local file read”.
The Uncomfortable Middle Ground
This is the awkward bit of vulnerability research that does not always make it into polished write-ups. Not every interesting behaviour deserves a CVE. Not every weird trust boundary crossing is exploitable. Not every “this feels wrong” turns into “this is meaningfully dangerous”. But dismissing these cases too quickly is also a mistake.
This kind of behaviour teaches you where the boundaries are. It tells you which assumptions the codebase makes about metadata, provenance, filenames, helper functions, and output generation. It also gives you a reusable hunting pattern: look for places where parser-controlled values are stored as metadata and later interpreted as something active.
That pattern is valuable far beyond this specific case. Today it is MIFF montage metadata. Tomorrow it might be a document relationship, an archive manifest, an image profile, a font reference, a temporary filename, a URL embedded in project data, or a cache entry that gets revived somewhere unexpected.
The finding may not land as a clean vulnerability, but the route taken to get there is still productive. It sharpens the research process. It improves the mental model. It often leads to the next, better finding.
So, is it a Vulnerability?
My view: not really; not without a very specific deployment model.
If a service accepts untrusted MIFF or MPC, returns JSON/YAML/detailed identify output, runs with access to other users’ image files, lacks a strict path allowlist, and exposes enough of the generated output back to the attacker, then yes, this behaviour could become security relevant. In that edge case, it may provide metadata disclosure, a readability oracle, resource consumption, or additional parser exposure against local images.
Also, if any of the available coders might trigger a web request or allow the invocation of a command, this would elevate from a file read to SSRF or RCE at which point the vulnerability would be present and real. This was not possible, however.
The Real Value of this Research
The useful part of this work is not just the candidate issue itself but also the way in which review of software is approached. It’s good practice – good for the soul, one might say.
Good vulnerability research is not only about throwing fuzzers at a target until something falls over. Fuzzing is brilliant, and I am very fond of anything that turns CPU cycles into crashes, but it is only one colour in the vulnerability-discovery rainbow. The interesting work often happens when dynamic signals, static review, source-level reasoning, and a slightly unhealthy curiosity all meet in the middle.
In this case, the important question was not “can I crash it?” It was “where does this value go next?” That question is endlessly useful. Follow a filename. Follow a pointer. Follow a length. Follow a URL. Follow a chunk of metadata that looks boring until it crosses into a different subsystem. Software is full of these little hand-offs, and hand-offs are where assumptions go to die.
Even when the result is a non-vulnerability, the exercise improves the next hunt. You learn the codebase. You discover unexpected parser relationships. You identify weak boundaries. You build small reproducers. You understand which mitigations actually matter. And sometimes, while proving one thing is not exploitable, you stumble across the thing that is.
Closing Thoughts
This ImageMagick behaviour sits in that slightly unsatisfying but important category of “interesting, but probably not enough”. It is not a clean arbitrary file read. It is not shell execution. It is not something that should be dressed up beyond its actual impact. But it did still have value.
Like, ImageMagick has 143 coders. If that doesn’t’ get you excited, I don’t know what will!
It is a reminder that vulnerability research is as much about judgement as discovery. It’s also about training the discipline and accepting that sometimes hours of invested work might result in nothing – which in some cases can be soul-crushing. But that’s the fun, right?
And honestly, those “almost-findings” are worth writing about. They show the real shape of research: not a straight line from target selection to CVE, but a messy loop of suspicion, analysis, disproval, refinement, and the occasional satisfying moment when the code finally admits what it is doing.
Sometimes the conclusion is not “here is the vulnerability”. Sometimes it is “this is not quite one, but the path that led here is exactly how you find the next one”.
PoC || GTFO
As with anything, it’s prudent to provide a PoC to properly show the behaviour. In this case it’s a bit tricky, so I’ll use screenshots of the PoC code and the captured data. If I get chance, I can upload the material to AnchorSec’s GitHub too, purely for educational purposes.
Anyway, here is the ‘maliciously crafted file’
And here is the target file to read:
As can be seen, the string to be captured is flag captured.
So, as discussed, the purpose of the PoC is to use ImageMagick’s functionality to read a maliciously crafted file, point to a target file on the filesystem, and return its metadata to us.
<…>
In the JSON output in the bottom picture, we can see the montage details: 1x1+0+0, the path of the file, and the comment flag captured.