This is a bug in the gifclrmp command line tool, not in libgif. ModifyColorMap is static at gifclrmp.c:319 and has no references outside gifclrmp.c, and the patch below touches no other file. dgif_lib.c, egif_lib.c and gifalloc.c are unchanged. Library clients are not affected by this and I am not asking for a CVE.
The trigger also needs the operator. Max is derived from the -t translation file the operator supplies, not from the GIF, so the attacker controls how far the overflow runs and what bytes go into it but not whether the operator invokes the vulnerable path at all. The condition is a translation table whose largest `after' value is less than half the colour count of the input GIF, which is ordinary palette reduction and the documented use of the flag, but it is not a fully attacker-controlled overflow and I am not going to argue it as one.
I am filing it anyway for a reason that has nothing to do with memory safety. The same single-line error makes -t produce the wrong colours today, silently, on every translation table that is not its own inverse. gifclrmp is one of the five tools in INSTALLABLE (Makefile lines 57 to 62, alongside gifbuild, giffix, giftext and giftool), it has a maintained man page in doc/gifclrmp.xml, and Debian's giflib-tools package ships /usr/bin/gifclrmp. It is not in the obsolete bin. OBSOLETE_UTILS is referenced at Makefile lines 145 and 158 but never assigned, so it expands empty.
doc/gifclrmp.xml lines 65 to 69, verbatim.
Change color index values. The change is made to both the
selected color table and the raster bits of the selected image. A
translation file is a list of pairs of `before' and `after' index
values. At present, the `before' index values must be in ascending
order starting from 0.
The raster half of that is done at gifclrmp.c:245.
*cp = Translation[*cp];
So a pixel that held index k now holds Translation[k]. For rendering to survive the change, the new colour map must satisfy NewMap->Colors[Translation[k]] == ColorMap->Colors[k].
The colour map half is done at gifclrmp.c:392.
NewMap->Colors[i] = ColorMap->Colors[Translation[i]];
That is the inverse of the required mapping. The two agree only when the table is its own inverse, which includes the identity, and disagree everywhere else.
Demonstrated on a rotation table, Translation[i] = (i + 1) % 256, against a 256 colour GIF. Max is 255 there so the allocation is a full 256 entries and nothing overflows in either build. This isolates the colour question from the memory question.
unpatched 256 of 256 pixels render a different colour than the input
patched 0 of 256 pixels differ
The patched code renders identically to the input, which is what the man page specifies, and the current code does not.
A rotation is the right table to show this with, and it is also why the bug has stayed hidden. Any table that is its own inverse, a straight reversal included, renders correctly under the current code, so the defect only appears once you use a table that is not an involution. It is worth avoiding a palette-reducing table for this comparison too, since collapsing 256 indices into fewer necessarily changes some pixels in both builds and the before and after images then look equally wrong.
gifclrmp.c:381 sizes the new map from the operator's table.
if ((NewMap = GifMakeMapObject(1 << GifBitSize(Max + 1), NULL)) == NULL) {
GifBitSize (gifalloc.c:23) returns the smallest i in 1 to 8 with (1 << i) >= n, so the allocation is the smallest power of two at least Max + 1, minimum 2 entries, capped at 256.
gifclrmp.c:387 bounds the loop from the GIF instead.
for (i = 0; i < ColorMap->ColorCount; i++) {
if (Translation[i] >= ColorMap->ColorCount) {
GIF_EXIT("Color map translation index out of range.");
}
NewMap->Colors[i] = ColorMap->Colors[Translation[i]];
}
The guard bounds Translation[i], which is the read subscript on ColorMap->Colors. The write subscript i is never checked against NewMap->ColorCount. When Max + 1 rounds up to fewer entries than ColorCount, the loop writes past the end of the calloc at gifalloc.c:58.
Built at HEAD a8e3114 with -fsanitize=address, so the 080d059 guard is present and compiled in.
==56788==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000f6 at pc 0x000102d0abb4
WRITE of size 3 at 0x6020000000f6 thread T0
#0 0x000102d0abb0 in __asan_memcpy+0x330
#1 0x0001024863d8 in ModifyColorMap gifclrmp.c:392
#2 0x000102484bfc in main gifclrmp.c:130
0x6020000000f6 is located 0 bytes after 6-byte region [0x6020000000f0,0x6020000000f6)
allocated by thread T0 here:
#0 0x000102d0d450 in calloc+0x80
#1 0x0001024937a4 in GifMakeMapObject gifalloc.c:58
#2 0x000102486340 in ModifyColorMap gifclrmp.c:381
#3 0x000102484bfc in main gifclrmp.c:130
SUMMARY: AddressSanitizer: heap-buffer-overflow gifclrmp.c:392 in ModifyColorMap
I do not want to overstate this, so here is the plain -O2 -g behaviour with no sanitizer, on the same two inputs.
$ ./gifclrmp -t trans.txt evil.gif > out.gif dies, 0 bytes of stderr, 0-byte output
$ ./gifclrmp -t max127.txt evil.gif > out.gif exit 0, 0 bytes of stderr, 614-byte output
The Max=0 case dies every time in 50 consecutive runs, but not with a stable signal. It gave SIGSEGV, exit 139, on 44 of those runs and SIGBUS, exit 138, on the other 6. An earlier build of mine, against a differently shaped input, aborted with SIGTRAP, exit 133, instead. So the death is deterministic and the signal is not, which is what you would expect from a 762-byte run past the end of a 6-byte allocation landing in whatever the allocator put there. Do not read a specific exit code out of this.
The Max=127 case is the one that worries me more. It exits 0 ten times out of ten, prints nothing at all, and writes a 614-byte GIF assembled from corrupted heap memory. Under a sanitizer that same input reports a 384-byte overflow past a 384-byte region, so it is overflowing just as surely, only not fatally on this allocator. Max=127 is the largest value that still overflows, not the first safe one. The first safe value is Max=128.
So the honest description of this bug is heap corruption, not a crash. The abort is a secondary effect that depends on the allocator noticing, and on this evidence it often does not. A triager who happens to run a mid-range table first will see a clean exit 0 and a plausible-looking output file and conclude there is nothing here.
The largest overflow is at Max=0. I measured the extent rather than deriving it, using an -O0 build that prints the loop index and byte offset with the write itself elided so the loop runs to completion.
WRITE i=0 byteoff=0 allocbytes=6
WRITE i=255 byteoff=765 allocbytes=6
The last write covers bytes 765 to 767 of a 6-byte allocation, so 762 bytes past the end.
One thing to expect when you run this yourself. AddressSanitizer's default output does not say 762, it says 0 bytes after 6-byte region, because it halts on the first out-of-bounds write. The 762 figure only appears with ASAN_OPTIONS=halt_on_error=0:suppress_equal_pcs=0, where the last of 244 reports reads 759 bytes after 6-byte region for a 3-byte write. REPRO.sh step 5b shows both so the number in this report is not mistaken for the headline.
That number is exact but the content control is weaker than the number suggests. Max=0 means every Translation[i] is 0, so all 256 iterations copy the same 3-byte Colors[0] entry. It is up to 762 bytes of attacker-supplied palette data, and at maximum extent it is one attacker-chosen 3-byte pattern repeated 256 times. Byte-granular control needs a larger Max, and a larger Max shrinks the overflow proportionally. At Max=127 the overflow is 384 bytes past a 384-byte region with 128 distinct source entries available.
I generated 256 tables, t{M}.txt with Translation[i] = i % (M + 1), giving a maximum of exactly M and keeping all 256 before-indices present, and ran each against the 256-colour GIF on both ASan builds.
unpatched : 128 of 256 produce a heap-buffer-overflow. Failing Max range 0..127. Clean 128..255.
patched : 0 of 256 produce any ASan error.
Max=128 is clean because 1 << GifBitSize(129) is 256, equal to ColorCount. The condition is exactly Max < ColorCount / 2, and it holds at every value of Max rather than at the two points I first tested.
--- a/gifclrmp.c
+++ b/gifclrmp.c
@@ -389,7 +389,7 @@
GIF_EXIT(
"Color map translation index out of range.");
}
- NewMap->Colors[i] = ColorMap->Colors[Translation[i]];
+ NewMap->Colors[Translation[i]] = ColorMap->Colors[i];
}
return (NewMap);
git apply --check against a8e3114 reports no offset. The git format-patch version is attached.
I want to be explicit about why this is the shape of the fix rather than a new bounds check on i. Adding a guard on the write index would make the tool call GIF_EXIT on exactly the inputs it currently miscolours, which fixes the memory safety and leaves palette reduction broken, and palette reduction is the one thing -t exists to do. Writing to NewMap->Colors[Translation[i]] is what the man page describes, and the overflow disappears as a consequence rather than as a separate patch.
No new check is needed to make that safe. Translation[i] is at most Max by construction at gifclrmp.c:375 to 376, and the allocation at gifclrmp.c:381 is at least Max + 1 entries, so the write subscript is in bounds for every possible translation file. The read subscript is now i, bounded by the loop condition against ColorMap->ColorCount.
Why it is behaviour preserving.
The 080d059 guard is left exactly where it is and still rejects the same inputs with the same message. I did not touch it.
On a table that is its own inverse the output is byte identical. On the identity table the patched output, the unpatched output and the original input are all the same 1087 bytes, md5 f19933e4e7e3e2c0dc804d6de755a87c, and cmp reports no difference between any pair.
Entries of NewMap that have no preimage under Translation keep the zeros that GifMakeMapObject already put there. gifalloc.c:58 uses calloc, not malloc, so those slots are initialised zeros and the patch does not introduce a disclosure of uninitialised heap. The current code writes those slots with whatever Colors[Translation[i]] happens to be, so the patch changes their contents but not their initialisation state.
make -C tests gives 51 tests and 1 failure on both trees, the same pre-existing giffix failure. Nothing regresses. Worth knowing that the gifclrmp-regress target invokes gifclrmp with no -t flag, so the suite has never covered this path.
Ticket #183, "out-of-bounds access in ModifyColorMap", closed 2026-02-19, is the same function, so I expect the dupe reflex. Commit 080d059, "Prevent buffer overruns (fix by ChatGPT-5.2)", closed it by adding
if (Translation[i] >= ColorMap->ColorCount) {
GIF_EXIT("Color map translation index out of range.");
}
immediately above the assignment. That bounds the read subscript into ColorMap->Colors. The write subscript into NewMap->Colors was not part of that change and is unguarded at HEAD. The ASan trace above is from HEAD with that guard compiled in.
I looked for other prior art and found none. All 17 CVEs listed for source package giflib in the Debian security tracker are in gif2rgb and DumpScreen2RGB, dgif_lib and DGifDecompressLine, giffix, gifcolor, EGifGCBToSavedExtension and GifUnionColorMap. None mentions gifclrmp or a colour map translation table. The six open tracker tickets, 205, 204, 202, 201, 198 and 189, are all elsewhere. A tracker search for gifclrmp returns only #183, #73 and #50, all closed. NEWS at HEAD lists only #203 and #199.
The defect is also in the shipped releases, not only at HEAD. It is gifclrmp.c:392 in tag 6.1.3 and gifclrmp.c:390 in tag 5.2.2, which is the version most distributions still carry. gifclrmp.c has not been modified since 080d059 on 2026-02-18.
I did not build an exploit. I did not attempt to place a controlled object after the NewMap->Colors allocation and I have shown no consequence beyond corrupted heap memory and a corrupted output file.
Every crash and exit code here is macOS libmalloc on arm64. I did not test glibc or musl, and given that the signal already varies between SIGSEGV, SIGBUS and SIGTRAP across builds on one allocator, I would not predict what another one does. On an allocator that does not notice, expect the Max=127 outcome, silent corruption and exit 0.
The patch fixes the write bounds and the index inversion and nothing else. It does not make the -t path correct in general. Dummy, the before' value in each pair, is read by thefscanfat gifclrmp.c:372 and then never used, and the man page's requirement that before-indices be ascending from 0 is not validated anywhere. A line that fails to parse is silently skipped and leavesTranslation[i]` at the file-scope zero from gifclrmp.c:32. Those are real and I left them out on purpose to keep the diff to one line.
Upstream git://git.code.sf.net/p/giflib/code
HEAD a8e3114a81f0987a61d06a41c99fd7cc2d58232c, "NEWS update.", 2026-06-11
Version git describe --tags = 6.1.3-4-ga8e3114, ./getversion = 6.1.3
Host macOS 26.6.2 (25G83), Darwin 25.6.0, arm64
Compiler Apple clang 21.0.0 (clang-2100.1.1.101), arm64-apple-darwin25.6.0
Builds used.
make gifclrmp CFLAGS="-g -O1 -fsanitize=address -fno-omit-frame-pointer" LDFLAGS="-fsanitize=address"
make gifclrmp CFLAGS="-O2 -g"
make all fails on this host at libutil.dylib with an undefined _GifErrorString. That is a pre-existing macOS shared-library build problem unrelated to this report. The static-linked utility targets and make -C tests both work.
Inputs, all attached.
evil.gif is 16x16 GIF87a with a 256 entry global colour table, written by giflib's own encoder so that an identity translation round-trips byte for byte by construction. It is a well-formed file rather than a fuzzer artefact. giftext parses it with exit 0 and no stderr and reports Image #1: 16x16, Non Interlaced, file calls it GIF image data, version 87a, 16 x 16, and Pillow opens it as mode P, size (16, 16), 256 palette entries.
trans.txt is 256 lines whose second column is 0, so Max is 0 and the allocation is 1 << GifBitSize(1), two entries, six bytes. The first column is the before' index, whichgifclrmp.c:372parses intoDummy` and never uses.
./gifclrmp -t trans.txt evil.gif > /dev/null
ident.txt, 256 lines of i i, is the control. It exits 0 with no ASan report on the unpatched build and produces output byte identical to the input, so the harness is not simply broken.
max127.txt in the transcript above is not attached separately. REPRO.sh generates it, along with the rotation table used for the colour comparison, so that every number in this report can be regenerated from a fresh clone with no manual steps.