#116 - Hot Fridges, sshole and New Container Defenses
Bash entrypoints and curl health checks can keep unnecessary tools inside otherwise minimal containers.
17 min read
A compromised fridge can mean spoiled stock, while exposed SSH can give attackers a route into your infrastructure. Andrey Devyatkin and Mattias Hemmingsson connect those risks to practical defenses: private access, fewer container tools and automated rebuilds.
A freezer at 50 degrees and an SSH login that needs no password
Over the night into 2 November, refrigeration units in supermarkets across several European countries were driven up towards 50°C. Swedish stores were on the list this time. ICA says a limited number of its stores lost their cold aisles. Coop confirmed seven affected stores in Gävle and Sundsvall. ICA’s press office said the stores that were hit look like the ones connected to Danfoss systems. The same thing had run through French supermarkets three weeks earlier, with the same equipment vendor named in the alert. The stock still went in the bin.
Three days later, on 5 November, OpenSSH shipped an emergency release. On Alpine Linux and other musl systems built without PAM, you can log in as an account that has no password at all by sending a password long enough to break the hash comparison. One connection. No guessing, no brute force, no lockout to dodge.
Different industries, same shape: a management interface that answers from outside, with a password in front of it. With OpenSSH on musl we know exactly how that password check fails. With the refrigeration controllers nobody has published how the stores were reached, and CERT-FR’s alert names no entry point, so what the two cases share is the exposure rather than a technique. Neither one needed a new idea to prevent, which is the uncomfortable part. The defenses are the ones we keep repeating: keep passwords out of the login path, keep the login path off the public internet, and leave nothing useful inside the container for whoever eventually gets in.
A 257-byte password is a valid login on Alpine
Start with the one you can fix this afternoon, because the fix is small and the exposure is not.
What does the Alpine SSH bug actually let someone do?
It lets anyone who can reach your SSH port log in as an account that was never given a password.
The mechanism sits below sshd. On musl, crypt() returns * when it is handed a password longer than it will process. * is also what /etc/shadow holds for an account with no password set, which is the normal state for key-only accounts and for root in the Alpine base image since around 3.16. The returned value and the stored value match, so the comparison succeeds. Alpine’s advisory says the attacker sends 257 to 1024 bytes and needs the machine to carry at least one other account with a $5$ or $6$ hash. One user with a real password is enough to make every passwordless account reachable.
OpenSSH 10.6p2 is the workaround, released 5 November and credited to Özgün Kültekin of Wakefield. The release notes are careful to say the root cause is outside sshd, and sshd now refuses a match when the stored field is too short to be a real hash. Two corrections worth making before you panic in the wrong direction. An Alpine-based container is not affected unless it runs sshd, which most don’t. And OpenWrt routers ship Dropbear by default, so only the ones where someone installed openssh-server are in scope.
On an affected host: apk upgrade -U -a, then restart the daemon, because upgrading the package leaves the running sshd in place. The advisory carries fixed packages for every branch from 3.16 through edge, in point releases 3.21.10, 3.22.8, 3.23.8 and 3.24.4. If you can’t update yet, set PasswordAuthentication no and restart. Alpine’s default PermitRootLogin prohibit-password keeps root out of reach, which helps right up until the account the attacker lands on can use doas.
Keep the login path off the public internet
Patching is the easy half. A Thursday evening release only turns into a scramble because so many of these ports answer to anyone at all.
How do you stay off the list when the next SSH bug lands?
Take the port off the public internet, so the scan that enumerates everyone else doesn’t enumerate you.
“Nobody knows this IP address” is not a control. Shodan and Censys sweep the routable address space continuously and index what answered, including the software and version in the banner. When a CVE lands, the work is a search query and a list. You are not hidden, you are catalogued.
So, in order. Password authentication off, keys or certificates only, which removes the entire class of bug this month belonged to. Then no SSH on a public address: put it behind a VPN, or an overlay network like Tailscale, where the host registers with a coordination server instead of listening on a public port. On AWS there’s a version you don’t operate yourself: with Session Manager, the agent on the instance and your client each open an outbound connection to an AWS-run endpoint that joins them, so inbound 22 stays closed. Fail2ban still earns a place on anything that must stay open.
The same ordering is what’s left for equipment whose login you don’t control. Claroty’s Team82 documented flaws in the Danfoss AK-SM 800A system manager, including an authentication password the controller derives from its own date, and Danfoss shipped firmware R4.3.1 for them. CERT-FR’s alert stops at the work to do regardless. Take the units off public IP addresses and restore access over a VPN. Check the whole configuration, then apply the vendor’s firmware and hardening. For a unit that may already have been reached, restart the Danfoss equipment. Failing that, restore factory settings. Either way, reconfigure and harden it before it goes back into service. Firmware only helps on a model still in support. Past end of life, restricting who can reach the box carries most of the weight. That does not make it the only control: the configuration check and the recovery steps still apply. CERT-FR is blunt about the alternative: an exposed device with no security fixes coming has a very high probability of compromise.
These boxes are networked for a reason nobody can argue with: a freezer drifting warm at 2am has to page someone. That argues for monitoring, not for a management interface on a routable address.
Take the tools out of the container
Keeping people out is the first layer. The second is what they find to work with once they are in.
What do you actually strip out of a container image?
The shell, the HTTP clients, the interpreters, and write access to the image’s filesystem.
A scratch image holds nothing but what you copy into it: no shell, no package manager, no libraries. Distroless is a family, so name the variant. base carries glibc and no shell, for a binary that needs libc. static leaves libc out too and sits next to scratch. Both also publish :debug tags that put a busybox shell back, so keep those off production.
A shell-less image changes the arithmetic after a break-in. No sh means the payloads that expect to spawn one stop there. Without curl or wget, the convenient way to pull a second stage is gone, but a compromised application can still open a socket of its own, so egress control stays a separate job. Drop python and node and there’s no interpreter sitting there with a standard library that opens sockets, and interpreters are genuinely hard to lock down. “Living off the land” needs some land.
A read-only root filesystem is a runtime restriction worth setting, but read what it covers. It stops writes to the container’s own filesystem, which is where a dropped script usually lands. It leaves anything you mount in unchanged, so each mount needs restricting on its own. A volume the application only reads takes ro or readonly. Scratch space it has to write stays writable, scoped to the one path that needs it. Set noexec on that writable --tmpfs mount to block direct execution; this doesn’t stop an interpreter already in the image from running scripts stored there.
Health checks and entrypoint scripts are two reasons a shell stays in an image. The health check depends on your orchestrator. Kubernetes performs HTTP probes from the kubelet, outside the container, so an image with nothing in it is fine. ECS runs its container health check as a command inside the container, which is how curl ends up in an image whose application never makes an outbound request. That’s why we built lprobe, a small Go binary that does the localhost check without a general-purpose HTTP client in reach. Our example images are public. Take the patterns, and check their base image tags are still current before you reuse them.
Rewrite the entrypoint script in Go
That leaves the entrypoint, the other reason the shell is still in there.
Why is there still a shell in an image that doesn’t need one?
Because the entrypoint is a Bash script, and a Bash script needs Bash.
Take a concrete one. In our Envoy setup, a Bash entrypoint rendered the configuration from a template at startup, filled in from environment variables. Envoy itself takes static or dynamic configuration, so templating at boot was our choice rather than a requirement, and the shell sat in the image to run that script. Doing it in Go meant writing the program, writing the tests and compiling it, more work than the shell script was worth. So the shell script stayed.
That calculation has changed. Hand the script to a coding agent, ask for a statically linked Go binary that does the same thing, and the shell leaves with the script.
How far the base image can then shrink depends on the application, not on the entrypoint. Envoy’s own image variants include a distroless build, and that is where the Envoy example stops. scratch is for what you ship as a static binary with no runtime of its own. Either way you get two things besides a smaller image: logic you can cover with tests, and easier troubleshooting than the shell version ever gave you.
The same argument retires prototyping in Python because it’s quick. Quick is real, and the cost is an interpreter in the image for the lifetime of the service. Compiling does not get you out of it: py_compile turns source into .pyc bytecode, and bytecode still needs the interpreter to run it. Packagers that build one executable do the opposite, and the interpreter goes inside the file. A statically linked Go binary carries no separately installed shared libraries and no general-purpose interpreter for an attacker to feed new code to. It still carries the code it was built from, so it needs the same dependency updates and scanning as anything else. Static linking changes what sits in the image, not whether your application has bugs.
Make yourself the expensive target
Stack those layers and the question becomes what they are worth, because none of them is a guarantee.
Does any of this stop a determined attacker?
It can, but it comes with no guarantee. Someone specifically after you has more time and more reason to keep working than the layers were priced for.
The pattern after a disclosure is mechanical. Pull the list of hosts running the affected version, work down it, and stop wherever the going gets slow, because the next address is free. Break in, then escape the container: that is the chain, and every step you remove is a step that has to be developed instead of downloaded. Against the spray, cost is most of what you have, and it is usually enough. Against someone who picked you on purpose, the layers still hold or fail on their own merits, but you lose the help of being the harder target, because they are not comparing you with anyone.
One version of making it expensive has aged badly. The old assumption behind obscurity was that reverse engineering is tedious enough that a human eventually gives up. Patience used to be the binding constraint, and what patience produced over years is impressive: VCMI rebuilt the Heroes of Might and Magic III engine well enough to run the original game data on an ARM Mac. ja2-stracciatella gets Jagged Alliance 2 onto the same hardware, though that one is a source port built on the code Strategy First released in 2004, so it says nothing either way about how long obscurity holds.
Rebuild every week so patching is routine
Layers buy time. What you do with that time is the last piece, and it’s a scheduling decision more than a security one.
How do you make emergency patching routine instead of an event?
By having shipped the same rebuild last week for no reason at all, and by knowing how long it took.
The setup is two jobs. One rebuilds your base image on a weekly schedule with docker build --pull --no-cache. The two flags do different work, and Docker spells out the difference: --pull is what fetches a newer version of the base image you build FROM, and --no-cache is what stops the package-install layer being served from an old cached layer. Neither one moves a version you pinned yourself, so a lockfile or an apt-get install foo=1.2.3 still needs its own update job. The other rebuilds the application images on top, weekly or daily. When something lands that you are genuinely exposed to, the emergency procedure is the routine one with the schedule removed: rebuild the base, trigger the application builds, let the deployment pipeline do what it does every week.
Time that run while nothing is on fire. Base rebuild to application rebuild to running in production, measured end to end, is the number to put against any claim about how fast you can patch, and it is usually slower than anyone guesses. Measuring it on an ordinary Tuesday also tells you which step is the one worth shortening.
This forces a choice about tags, and it is a real choice rather than a best practice with one right answer. The application images you deploy should be pinned and never latest. For the base image you own, one option is a moving major tag: v2 means this week’s v2, the weekly rebuild moves it, and the applications pick it up on their next build. The other is to keep the digest pinned in the Dockerfile and run a scheduled job that updates the pin, which is what Docker suggests when you want the audit trail of exactly which image each build used. The moving tag costs you a build that is reproducible from the Dockerfile alone, so record the digest each build resolved to and keep it where incident response can read it. The pinned digest costs you an update pipeline you have to keep alive. Pick the one you will actually maintain.
The bug reports are arriving faster than they used to
It’s worth knowing why this autumn feels relentless, because it sets how long your patch window actually is.
OpenSSH put it in the 10.6p2 release notes themselves. The team has received a large number of security bug reports recently, “many of which are findings from AI models or made with AI assistance”, and their conclusion is the part to read twice:
Their response was to stop batching fixes and release more often. Yours has to match that cadence. The capability itself is only loosely gated: OpenAI put its Astra model at the Critical cybersecurity threshold of its own Preparedness Framework, the first model it has designated there, and announced an initial rollout of the advanced cyber capabilities to a small group of testers, with broader defensive access planned afterward. Open-weight models that will happily work through an exploit with you are a download away for anyone with the hardware to run them. As far as we could tell from our own use, the frontier assistants have gotten noticeably more careful this year, asking whether you own the domain before helping with anything that looks like a penetration test. That is a reasonable thing for them to do, and it changes nothing for the people who were never going to ask.
So the window is set by whoever automates first, and they already have. If their side is automated and yours is a ticket queue, the gap is the exposure.
What this means for teams
The common thread across a warm freezer and a free SSH login is that both were reachable from outside and both sat behind a password. The warnings were public in both cases: Claroty’s controller research and CERT-FR’s alert on one side, Alpine’s advisory and an emergency OpenSSH release on the other. Whether any of that reached the person who owns the box is a different question, and not one worth depending on. Three pieces of work follow, and none of them covers for the others. Reduce what is reachable from outside. Restrict what a compromised workload can do once it is inside. Rehearse the patch, so the deployment path is known-good before you need it. All three are scheduled, boring work rather than a product you can buy.
This week:
- Inventory what answers SSH on a public address, patch any musl host running
sshdto the fixed OpenSSH package, restart the daemon, and setPasswordAuthentication nowherever a key already works. - Pick one service and move its administrative access behind a VPN, an overlay network or Session Manager, then close the inbound port and confirm it stays closed.
- Open the Dockerfile of the service you’d least like breached, and find out why it still has a shell. If the answer is the health check or the entrypoint, you know what to replace.
- Schedule the weekly
--pull --no-cacherebuild of your base image and the rebuild of what sits on top, then run it once by hand and time it from base rebuild to deployed, so you know the number before you need it.
Common questions, answered
How do I fix the Alpine SSH password bypass on my servers?
Run apk upgrade -U -a to pick up the fixed openssh package, then restart the daemon with rc-service sshd restart, because upgrading the package does not replace the running sshd.
If you can’t update right now, set PasswordAuthentication no and restart sshd, after confirming your key login works and while keeping your current session open. Either action closes it. Hosts using openssh-server-pam with UsePAM yes were never affected.
Do I need to patch my Alpine containers for this?
Only if the container runs an affected sshd, which most don’t. The bug is exploitable through the SSH daemon itself, whatever port it listens on, so an Alpine-based image that starts your application and runs no SSH daemon isn’t exposed to it.
Patch on the next scheduled rebuild anyway, along with everything else that moved. Alpine is the small base image a lot of us reach for by default, so the whole fleet tends to move together.
How do I run a container health check without curl in the image?
Use a purpose-built probe binary instead of a general-purpose HTTP client, or let the orchestrator do the check from outside the container. Kubernetes performs HTTP probes from the kubelet, so a scratch image with no client in it works as is.
ECS runs its health check command inside the container, which is what usually puts curl or wget in an image that has no other reason to make outbound requests. We built lprobe for that case: a small Go binary you copy into the image to check a local endpoint. It’s a smaller tool, not a network boundary, so enforce egress separately.
Related episodes
- Episode 114, Fridge Hacks and Security by Obscurity covers the French refrigeration wave and the date-derived controller password in detail.
- Episode 115, Julien Bisconti on Node2Shell, PHP and Patch Panic is the same shell-less container argument applied to an interpreter bug, plus the distance between a merged fix and a patched server.
- Episode 108, Assume You’re Vulnerable: Security Beyond Patching is what you architect when patching alone can’t keep up.
- Episode 102, The 90-Day Patch Window Is Dead covers how fast exploitation follows disclosure, with Ian Amit and Matias Madou.
Resources
- OpenSSH 10.6p2 release notes: what was fixed, who reported it, and the team’s own statement on AI-assisted bug reports and faster releases.
- Alpine security advisory, openssh-server password authentication bypass: the exact conditions for exposure, fixed package versions per branch, and the drop-in config workaround.
- Claroty Team82, Freeze the Controller, Defrost the Food: the documented Danfoss AK-SM 800A flaws, the date-derived password, and the firmware that fixes them.
- CERT-FR alert CERTFR-2026-ALE-012: what the French authority does and does not say about the refrigeration incidents, and its firmware, configuration and access recommendations.
- Kubernetes liveness, readiness and startup probes: which probe types the kubelet runs from outside the container, and which one needs a command in the image.
- Amazon ECS container health checks: why the ECS check is a command executed inside the container, and what the task definition expects it to return.
- Docker building best practices: what
--pulland--no-cacheeach refresh, and the trade-off between a pinned digest and a tag. - fivexl/lprobe: the local health-check binary that keeps
curlout of an image.
