javadockercvecontainer-security

1 Finding to Zero: The One-Line Dockerfile Fix for CVE-2026-46675

Share X in
1 Finding to Zero: The One-Line Dockerfile Fix for CVE-2026-46675

Container Security · CVE-2026-46675 · Alpine Remediation

In yesterday’s comparison, one candidate was a single finding away from clean and carried a severity of UNKNOWN. I wrote it off as a CVE nobody had patched yet. That was wrong. The fix had been published for weeks — the image was simply built on an Alpine package snapshot old enough to predate it. One line in the Dockerfile cleared it, and cost 3.1 MB.

1. Where we started

The comparison produced seven candidates. Three of them came back with fewer than five findings, which made them the only images worth spending remediation effort on — you do not spend an afternoon closing a 894-finding gap when a zero-finding image is available.

ImageTagOS findingsApp findingsTotalRegistry
Red Hat Hummingbirdcvefix2000145.5 MB
Amazon Corretto Alpinecvefix2000220.5 MB
Eclipse Temurin Alpinecvefix2101132.8 MB

Two of the three were already at zero and there was nothing left to do. The third was not, and it is the subject of this post.

2. The one finding

Pulled straight out of the Trivy JSON rather than read off a summary table:

trivy image --format json → Results[].Vulnerabilities[] VulnerabilityIDCVE-2026-46675 PkgName / InstalledVersionlibpng 1.6.58-r1 FixedVersion1.6.59-r0 SeverityUNKNOWN Statusfixed Classos-pkgs (Alpine) CVSS vectornot yet assigned TitleUse-after-free of zlib input in png_read_end

The finding carries no severity score because none has been published yet — but it carries a fix version, which is the field that matters.

The title is a use-after-free in png_read_end triggered after incomplete zTXt, iTXt or iCCP decompression. UNKNOWN is not a severity rating of “harmless” — it means nobody has published a CVSS vector yet. On yesterday’s write-up I treated it as a reason to leave the image alone.

The field that settles it. FixedVersion was populated: 1.6.59-r0. A finding with an empty FixedVersion and Status: "fixed" is genuinely unpatchable today. A finding with a FixedVersion is a to-do list, not a dead end. Reading the severity first and stopping there is how you ship a fixable CVE for a year.

3. The wrong conclusion

Yesterday’s post ranked this image third, labelled it “acceptable, not clean”, and wrote that the single unrated finding was the price of admission. That framing was wrong in a way worth being precise about, because it is the most common way a security review goes stale.

I had reasoned: the base image is eclipse-temurin:21.0.12.1_1-jre-alpine, an upstream tag I do not control, so its package versions are whatever Eclipse chose on their build date. That is a fact about the tag, not about the CVE. It never followed that the tag could not be remediated on top of.

The test I should have run before writing a single word about severity was the cheapest one available: ask the package manager what it thinks is available.

4. The real cause

Alpine publishes to a rolling repository. The image was built when Alpine 3.24.2’s main had libpng 1.6.58-r1 as current. By the time of the scan, the live repository at dl-cdn.alpinelinux.org/alpine/v3.24/main carried libpng 1.6.59-r0 — the fixed version. Nothing had been missed. The image was simply frozen at build time while the distribution moved on.

The gap is between what is installed and what is available Inside the image libpng 1.6.58-r1 Frozen at the moment the base image was built In the Alpine v3.24/main repository libpng 1.6.59-r0 The fixed version, already published apk upgrade An immutable tag is a snapshot, not a subscription eclipse-temurin:21.0.12.1_1-jre-alpine will never move again. Whatever the distribution patches tomorrow, that tag keeps shipping 1.6.58-r1 forever.

A published tag is frozen at build time. The fix existed in the repository the whole time — the image simply never asked for it.

5. The fix

One line, placed before anything else touches the filesystem:

The complete change FROM docker.io/library/eclipse-temurin:21.0.12.1_1-jre-alpine RUN apk upgrade --no-cache RUN addgroup -S -g 65532 app && adduser -S -u 65532 …

One added line. The `FROM` and the user creation are untouched.

Two details in that placement are load-bearing rather than stylistic. It runs before the user is created, so every subsequent operation links against the patched libraries rather than the ones being replaced. And it is a full upgrade rather than a targeted add libpng, which is why it quietly brought four other packages forward with it.

Why not apk upgrade libpng? It would also have worked here, and it is the right instinct when you want a minimal diff. It is the wrong instinct as a default: a package you did not think to name is a package that stays vulnerable after the next rebuild. Upgrading everything costs a few megabytes and removes the entire class of bug.

6. What it upgraded

Five packages moved. I named one; four came along.

PackageBeforeAfterWhy it mattered
libpng1.6.58-r11.6.59-r0The finding itself. Clears CVE-2026-46675.
openssl3.5.8-r03.5.9-r0Not flagged by this scan. Would have been flagged by the next one.
libssl33.5.8-r03.5.9-r0Same patch train as openssl.
libcrypto33.5.8-r03.5.9-r0Same patch train as openssl.
libldap2.6.14-r02.6.15-r0Carried into the image by the JRE’s base, not requested by anything.

Only the first row was visible in the scan. The other four were latent — no CVE was published against them at scan time, but they were one disclosure away from being four new findings on the same image. Four of the five are OpenSSL components moving together on the same patch release, which is exactly the shape of a coordinated security release you do not want to be behind on.

7. Verification

A clean scan proves nothing if the image does not run, so the order was: rebuild, prove the libraries changed inside the image, boot it, then scan, then push.

CheckResult
Patched versions present in the built imagelibpng-1.6.59-r0, openssl-3.5.9-r0, libssl3-3.5.9-r0, libcrypto3-3.5.9-r0, libldap-2.6.15-r0
Still runs as non-rootUser=app, UID 65532
Application startsHTTP 200 on /petclinic/actuator/health after 10s
Health body{“groups”:[“liveness”,“readiness”],“status”:“UP”}
RescanOS 0, application 0, total 0
Pushed and read backquay.io/hellodk/base-image:temurin-4.0.2-java21-cvefix3
Digest confirmed from the registrysha256:b924ecd3c5a8fc87ee436b813e132ce536bda46cb60028cbc9b60a3500d5fa0f

The digest step matters more than it looks. A tag is a mutable pointer; a digest is the content. Reading the digest back out of the registry after the push is the only way to assert that what you scanned is what anyone else will pull — and it is the assertion most teams skip, which is how a scan result quietly stops describing the running image.

8. What it cost

MetricBefore (cvefix2)After (cvefix3)Change
Findings10−1
Registry transfer132.8 MB135.9 MB+3.1 MB (+2.3%)
Local uncompressed280 MB288 MB+8 MB
Compressed layers89+1

Three point one megabytes, one extra layer, one fewer finding. The fix also changed the image’s rank: it moved from third-and-not-clean to the smallest scan-clean Java 21 image in the whole comparison set, at 135.9 MB against Hummingbird’s 145.5 MB. It still ships a BusyBox shell, which is why it did not become the recommendation — but “cannot be made clean” is no longer a true statement about it.

The honest summary of the trade. 2.3% more transfer in exchange for a use-after-free that a remote party can reach if your application parses attacker-supplied PNG data. If it does not, you have bought cheap insurance against a future that includes a content-type confusion bug. Either way the price was 3.1 MB.

9. The app layer too

Worth recording because it was the other half of the gap, and it was not fixed by a Dockerfile line at all.

Two layers, two different tools OS layer — fixed in the image libpng 1.6.58-r1 → 1.6.59-r0 Tool: apk upgrade CVE-2026-46675 cleared Application layer — fixed in the POM spring-retry 1.0.3.RELEASE → 2.0.13 Tool: dependencyManagement pin CVE-2026-41710 cleared

A base image can only fix the OS layer. The application layer belongs to the POM, and no Dockerfile can compensate for it.

CVE-2026-41710 was a MEDIUM denial of service via cache exhaustion in org.springframework.retry:spring-retry 1.0.3.RELEASE, fixed in 2.0.13. It arrived transitively through spring-data-jdbc 1.2.1.RELEASE, which is why it was invisible until something forced full dependency resolution. Pinning it in dependencyManagement took Hummingbird and Corretto from one finding to zero and produced the cvefix2 tags.

The uncomfortable part is that this image was never vulnerable because of anything in the base image. It was vulnerable because of a transitive dependency resolved by a build tool, and no amount of base image remediation would ever have touched it. Scans report layers; fixes live in different places depending on which layer is dirty.

10. Lessons

  • Read FixedVersion before Severity. Severity tells you how much to care. FixedVersion tells you whether you can do anything today. An empty FixedVersion is the only real dead end.
  • UNKNOWN means unrated, not unfixable. The finding that caused this entire post had no CVSS vector and was patched the same week it was disclosed. Treating unscored as unfixable is how a one-line fix becomes a permanent footnote.
  • A base image tag is a build-time snapshot. Nobody is going to rebuild someone else’s tag when a CVE lands. If your runtime has a package manager, apk upgrade / apt-get upgrade / dnf upgrade in your own Dockerfile is the only remediation you control.
  • Upgrade everything, not the thing the scanner named. The four packages I did not ask for were latent findings. A targeted pin leaves the class of bug in place for the next rebuild.
  • Immutable tags drift silently. Nothing failed, nothing alerted, no scan result changed. The image was quietly a version behind its own distribution for as long as it existed. Re-scan on a schedule, not only at build time.
  • Assert the digest, not the tag. A tag can point at something other than what you scanned. sha256:b924ecd3… cannot.
  • Measure the fix in the currency of the decision. 3.1 MB is a number. “Zero findings in a 135.9 MB image” is a decision. Convert before you argue.

11. Reproduce

Check what your own image is carrying before you check what it is scanning as:

docker run --rm <image> sh -c 'apk update; apk upgrade --simulate' docker run --rm <image> sh -c 'apk info -v | grep libpng' trivy image --scanners vuln --format json --output scan.json <image> docker run -d -p 9966:9966 <image> # then poll the health endpoint

The simulation is the important one: it tells you what is available without changing anything, which is the check that would have prevented yesterday’s conclusion.

12. Sources

Share this post X in

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.