tl;dr I found several bugs in apk, the default package manager for Alpine Linux. Alpine is a really lightweight distro that is very commonly used with Docker. The worst of these bugs, the subject of this blog post, allows a network man-in-the-middle (or a malicious package mirror) to execute arbitrary code on the user’s machine. This is especially bad because packages aren’t served over TLS when using the default repositories. This bug has been fixed and the Alpine base images have been updated – you may want to rebuild your Alpine-derived images!

After gaining code execution, I figured out a cool way to make the original apk process exit with a 0 exit code (without needing the SYS_PTRACE capability) by writing to /proc/<pid>/mem. The result is that a Dockerfile that installs packages with apk can be exploited and still build successfully.

Here’s a clip of me exploiting a Docker container based on Alpine as a network man-in-the-middle:

Vulnerability

Arbitrary file creation leading to RCE

Alpine packages are distributed as .apk files, which are actually just gzipped tar files. When apk is pulling packages, it extracts them into / before checking that the hash matches what the signed manifest says it should be. Well, kind of – while extracting the archive, each file name and hardlink target is suffixed with .apk-new. Later, when apk realizes that the hash of the downloaded package is incorrect, it tries to unlink all of the extracted files and directories.

Persistent arbitrary file writes can be easily turned into code execution because of apk’s “commit hooks” feature. If we can figure out a way to extract a file into /etc/apk/commit_hooks.d/ and have it stay there after the cleanup process, it will be executed before apk exits.

With control of the tar file being downloaded, we can create a persistent “commit hook” like this:

  1. Create a folder at /etc/apk/commit_hooks.d/, which doesn’t exist by default. Extracted folders are not suffixed with .apk-new.
  2. Create a symlink to /etc/apk/commit_hooks.d/x named anything – say, link. This gets expanded to be called link.apk-new but still points to /etc/apk/commit_hooks.d/x.
  3. Create a regular file named link (which will also be expanded to link.apk-new). This will write through the symlink and create a file at /etc/apk/commit_hooks.d/x.
  4. When apk realizes that the package’s hash doesn’t match the signed index, it will first unlink link.apk-new – but /etc/apk/commit_hooks.d/x will persist! It will then fail to unlink /etc/apk/commit_hooks.d/ with ENOTEMPTY because the directory now contains our payload.

Fixing the exit code

Now that we have arbitrary code running on the client before apk has exited, it is important that we figure out a way to make the apk process exit gracefully. If using apk in a Dockerfile build step, the step will fail if apk returns a nonzero exit code.

If we do nothing, apk will return an exit code equal to the number of packages it has failed to install, which is now at least one (amusingly, this value can overflow – if the number of errors % 256 == 0, the process will return with exit code 0 and the build will succeed. This was fixed here.).

My first attempt was to use gdb to attach to the process and just call exit(0). Unfortunately, Docker containers don’t have the SYS_PTRACE capability by default and so we can’t do this. Since we’re root, however, we can read and write /proc/<pid>/mem for the apk process:

import subprocess
import re

pid = int(subprocess.check_output(["pidof", "apk"]))

print("\033[92mapk pid is {}\033[0m".format(pid))

maps_file = open("/proc/{}/maps".format(pid), 'r')
mem_file = open("/proc/{}/mem".format(pid), 'w', 0)

print("\033[92mEverything is fine! Please move along...\033[0m")

NOP = "90".decode("hex")

# xor rdi, rdi ; mov eax, 0x3c ; syscall
shellcode = "4831ffb83c0000000f05".decode("hex")

# based on https://unix.stackexchange.com/a/6302
for line in maps_file.readlines():
    m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
    start = int(m.group(1), 16)
    end = int(m.group(2), 16)

    if "apk" in line and "r-xp" in line:
        mem_file.seek(start)
        nops_len = end - start - len(shellcode)
        mem_file.write(NOP * nops_len)
        mem_file.write(shellcode)

maps_file.close()
mem_file.close()

So we:

  1. Find the pid of the apk process using pidof
  2. Find the process’s executable memory using /proc/<pid>/maps, and
  3. Write shellcode that will ultimately exit(0) directly into memory. It was really surprising to me that this worked! I was expecting the write to fail.

When apk resumes execution after our commit hook exits, it will run our shellcode.

Conclusion

If you use Alpine Linux in a production environment, you should 1. rebuild your images and 2. consider donating what you can to the developers. It seems like apk has one main developer who fixed this bug in less than a week. The lead maintainer of Alpine cut a new release shortly thereafter.

Shameless plug

There are probably hundreds of organizations using Alpine Linux in production environments that could have been affected by this bug. Some of those organizations almost certainly have bug bounty programs that would pay generously if a similar bug had been written by one of their own developers. If the goal of a bug bounty program is to help secure an organization, shouldn’t critical bugs in dependencies qualify to some extent?

This is why I launched BountyGraph last month. BountyGraph provides a mechanism to crowdfund bug bounty programs for important dependencies. I hope you’ll check it out!