an offensive look at docker desktop extensions
For our annual internal hacker conference dubbed SenseCon in 2023, I decided to take a quick look at Docker Desktop Extensions. Almost exactly a year after being announced, I wondered what the risks of a malicious docker extension could be. This is a writeup of what I learned, a few tricks I used to get some answers and how I found a “non-issue” command injection in the extensions SDK. Everything in this post was tested on macOS and Docker Desktop 4.19.0 (106363).
For our annual internal hacker conference dubbed SenseCon in 2023, I decided to take a quick look at Docker Desktop Extensions. Almost exactly a year after being announced, I wondered what the risks of a malicious docker extension could be. This is a writeup of what I learned, a few tricks I used to get some answers and how I found a “non-issue” command injection in the extensions SDK. Everything in this post was tested on macOS and Docker Desktop 4.19.0 (106363).
tl;dr – Be extra careful with Docker Desktop Extensions given that it is significantly easier for a malicious extension to run commands, access files and more when compared to a traditional container.
A summary of interesting things I discovered include:
- Extensions can execute arbitrary operating system commands, even if there isn’t a specific binary shipped with the extension. This is a confirmed bug and will be fixed later.
- Running extension service “VM’s” (aka: service containers) don’t show up in
docker ps. You have to rundocker extension lsto see those. This could be a fun persistence technique where malicious code could hide in the extensions service VM, away from endpoint security products’ prying eyes.
- Service “VM’s” being the long running components of an extension which run as a container can have more privileges than you would be comfortable to give by adding extra port/volume/privilege labels to the extensions
docker-compose.ymlfile. Bonus points for the fact that most enterprise endpoint security solutions probably wont be able to inspect the docker virtual machine either… ;)
- Unless an extension author makes their extension open source, the only way to see what it is really doing is to manually inspect / reverse engineer the extension itself. There is no UI to give you an idea of what could be happening, which binaries were included in the extension or otherwise give you an overview of what the extension could do. The most interesting warning is the docs, and a warning when you install extensions via the CLI.
- Extensions don’t have to live on the extension Marketplace to be installable. Any well-formed container can be installed as an extension with the
docker extension installcommand.
As this is a large post, here is a table of contents to help navigate it.
- introduction
- extension architecture
- extension -> backend communication
- extension security contexts
- on service “VM’s”
- the extension-api-client sdk
- (host|vm).cli.exec
- debugging docker desktop extensions
- running host binaries
- arbitrary command execution in docker.cli.exec()
- getting the extension-api-client sdk source code
- analysing the sdk and finding the “bug”
- command execution risks in context
- docker extensions for persistence
- docker desktop extensions and the extension market place
- investigating docker desktop extensions
- investigating extensions before installing
- investigating extensions after installing
- conclusion
introduction
Like most things, I need to do a bit of an introduction on Docker Desktop Extensions to set the groundwork for the rest of the post. There is a surprising amount of moving parts to extensions. I am going to assume you have a basic familiarity with container runtimes like docker and have an idea how Docker Desktop works with the Virtual Machine driving the container runtime and the Electron-based GUI itself.
As one does, I skipped reading all of the documentation and dove straight into the quick start guide. My first shock came when I ran the docker extension init command as suggested.
Yeah, you are reading that right. A Go “backend” and a React UI resulting in 165MB worth of $stuff…
$ du -sh
165M .
I pushed on through the guide in disbelief to see what the result would look like by building the extension and installing it as per the document. The result? A new entry in the “Extensions” section creatively called “My Extension” that when clicked, showed a user interface with a button and a text box for output.
This button-pushing-inspecting-the-output flow was the general workflow I used throughout the testing of the Docker Desktop Extensions feature. For development there is a hot reload capability to reduce the time of the feedback loop using the docker extension dev ui-source docker-test-extension http://localhost:3000/ command which sets an extensions frontend to point to a local web server (which will make more sense in the next section). Using this you don’t have to go through the long docker build part before you can see an incremental change you made, but rather, they reflect almost immediately.
extension architecture
Based on what just happened when running the init command, I figured I needed help to know what could possibly warrant so much complexity. So on to the next document we go. More specifically, the architecture document that has an image with the high level extension architecture.
While the documentation is definitely useful (who knew), after diving under the hood a little I came up with an expanded image that I think paints a clearer picture on all of the moving parts as well as how they interact with each other (according to my understanding anyways).
Frontends are written using your typical web technologies (HTML, JavaScript and CSS). Docker generates a React UI skeleton as a suggestion when you init a new extension. Logically, frontends can reach optional extension specific backends which are really just containers (can be multiple container services too) and do this via the @docker/extension-api-client SDK (embedded within Docker Desktop; more on this later). docker init also generates a Golang backend skeleton as an example (more on this later too).
Extension frontends don’t persist anything by design; this is what backends can be used for. In fact, extension UI’s die/reload when you navigate away from them in the UI and forcefully cleanup / kill any long running tasks when needed. Extension backends (also confusingly called “VM’s”) on the other hand live for as long as the extension is installed. Backends are typically web services that can be easily invoked from the frontend via a socket-like interface that the SDK exposes.
extension -> backend communication
It’s probably obvious by now, but frontends need to talk to backends. This capability is exposed via the frontend SDK that the extension must use. Under the hood things are a little more complicated though.
As you may know, Docker Desktop interacts with the Docker Desktop Virtual Machine via a local docker socket. The logic that handles this connection to the docker socket is part of Docker Desktop and is implemented in JavaScript as you’d expect from an Electron application. Extensions also communicate via sockets to backends, however they don’t use the docker socket. Instead, extension backends need to explicitly expose their own socket such that the frontend (via the SDK) can communicate with it. Technically you could expose a TCP socket, but the documentation suggests a unix socket / named pipe to prevent port clashes with the host operating system. Regardless of the target, Docker Desktop handles all socket communications using the same underlying library and will connect as appropriate depending on where it needs to go. For extensions, a socket hint is needed as part of the extension metadata so that it knows which backend a specific extension connects to.
[...]