guest - flak

experiments with prepledge

MP3 is officially dead, so I figure I should listen to my collection one last time before it vanishes entirely. The provenance of some of these files is a little suspect however, and since I know one shouldn’t open files from strangers, I’d like to take some precautions against malicious malarkey. This would be a good use for pledge, perhaps, if we can get it working.

At the same time, an occasional feature request for pledge is the ability to specify restrictions before running a program. Given some untrusted program, wrap its execution in a pledge like environment. There are other system call sandbox mechanisms that can do this (systrace was one), but pledge is quite deliberately designed not to support this. But maybe we can bend it to our will.

Our pledge wrapper can’t be an external program. This leaves us with the option of injecting the wrapper into the target program via LD_PRELOAD. Before main even runs, we’ll initialize what needs initializing, then lock things down with a tight pledge set. Our eventual target will be ffplay, but hopefully the design will permit some flexibility and reuse.

preopen

First, a little bit of code to open all the preset paths. We’re going to be overriding open so we need to get at the next version of this function via dlsym. The user must specify the list of files to open via environment.

static void __attribute__((constructor))
init(void)
{
        char *paths, *p, *nextp;

        nextopen = dlsym(RTLD_NEXT, "open");
        LIST_INIT(&handles);

        paths = getenv("PREOPEN");
        if (!paths)
                paths = "";
        paths = strdup(paths);
        for (nextp = paths; p = strsep(&nextp, ":"); p = nextp) {
                int fd = nextopen(p, O_RDWR, 0);
                if (fd != -1) {
                        struct filehandle *fh = malloc(sizeof(*fh));
                        fh->path = p;
                        fh->fd = fd;
                        LIST_INSERT_HEAD(&handles, fh, next);
                }
        }

        pledge("stdio", NULL);
}

int
open(const char *path, int flags, ...)
{
        struct filehandle *fh;

        LIST_FOREACH(fh, &handles, next) {
                if (strcmp(fh->path, path) == 0)
                        return fh->fd;
        }
        fprintf(stderr, "no dice for %s\n", path);
        return -1;
}

In effect, we’ve implemented a tiny linked list filesystem, prepopulated with a selected set of files. Once pledge is called, that’s all the program will have access to.

A small test program. It doesn’t do much, just open a few files and report success.

int
main(int argc, char **argv)
{
        int fd;

        fd = open("testopen.c", O_RDONLY);
        printf("fd is %d\n", fd);
        fd = open("foo.c", O_RDONLY);
        printf("fd is %d\n", fd);
        fd = open("nextopen.c", O_RDONLY);
        printf("fd is %d\n", fd);
        fd = openat(-1, "foo.c", O_RDONLY);
        printf("fd is %d\n", fd);
}

Let’s try running it.

env LD_PRELOAD=./open.so PREOPEN=testopen.c:nextopen.c ./testopen 

fd is 3
no dice for foo.c
fd is -1
fd is 4
Abort trap 

Exactly what we want. The files we wanted to open, as specified by PREOPEN, worked just fine. Attempting to open foo.c returns an error. And attempting to subvert our safeguards by using another system call, openat, results in a hard failure. This fits the basic mold of a pledge wrapper, and it’s pretty simple, if unwieldy.

If you like your programming adventures to have happy endings, you should stop reading right about here. Otherwise, onwards and downwards.

ffplay

We’ve got the basic pieces in place to try running ffplay.

env LD_PRELOAD=/tmp/open.so ffplay taylor.mp3

Abort trap

That didn’t take long. What failed? ktrace to the rescue.

 44328 ffplay   CALL  open(0x105fa37cabc0,0x30000<O_RDONLY|O_CLOEXEC|O_DIRECTORY>)
 44328 ffplay   NAMI  "/usr/lib"
 44328 ffplay   PSIG  SIGABRT SIG_DFL

Hrmph. ffplay called open. But we’re open! This shouldn’t be happening. Somebody is calling straight to the system call without going via libc. On the bright side, this is exactly what we’re trying to prevent, but it’s unfortunate that it’s happening so soon, with no exploit in sight. To find out more, we turn to ktrace’s little cousin, ltrace. It works almost exactly like ktrace (the output is even viewed with kdump), but it traces ld.so, the dynamic linker, instead of system calls.

 78693 ffplay   USER  .plt object: 18 bytes
       "/usr/libexec/ld.so"
 78693 ffplay   USER  .plt symbol: 6 bytes
       "dlopen"

And here’s the trouble spot. dlopen, as implemented by ld.so, contains a direct call to the open syscall. (To avoid strange recursive dependencies, ld.so doesn’t use any libc functions. Everything it needs is reimplemented internally.) This is trouble, but maybe we can blunder our way past. We can’t get ld.so to call our open function, but we can get ffplay to call our dlopen function.

void *
dlopen(const char *path, int mode)
{
        fprintf(stderr, "no handle for %s\n", path);
        return NULL;
}

We’re not sure what library ffplay is trying to open, but let’s hope it’s not important.

no handle for libx265_main10.so
no handle for libx265.so
ffplay version git-N-75412-g523da8eac1 Copyright (c) 2003-2017 the FFmpeg developers
  built with clang version 4.0.0 (tags/RELEASE_400/final)
  configuration: --enable-shared --arch=amd64 --cc=cc --disable-altivec --disable-armv5te --disable-armv6 --disable-armv6t2 --disable-debug --disable-iconv --disable-indev=jack --disable-indev=oss --disable-lzma --disable-mips32r5 --disable-mips64r6 --disable-mipsdspr1 --disable-mipsdspr2 --disable-mipsfpu --disable-mmi --disable-msa --disable-neon --disable-outdev=oss --disable-outdev=sdl --disable-vfp --enable-avresample --enable-fontconfig --enable-gpl --enable-libass --enable-libfreetype --enable-libfribidi --enable-libgsm --enable-libmp3lame --enable-libopus --enable-libspeex --enable-libv4l2 --enable-libvorbis --enable-libvpx --enable-libx264 --enable-libx265 --enable-libxvid --enable-nonfree --enable-openssl --extra-cflags='-I/usr/local/include -I/usr/X11R6/include' --extra-libs='-L/usr/local/lib -L/usr/X11R6/lib' --mandir=/usr/local/man --optflags='-O2 -pipe -Wno-redundant-decls'
  libavutil      54. 31.100 / 54. 31.100
  libavcodec     56. 60.100 / 56. 60.100
  libavformat    56. 40.101 / 56. 40.101
  libavdevice    56.  4.100 / 56.  4.100
  libavfilter     5. 40.101 /  5. 40.101
  libavresample   2.  1.  0 /  2.  1.  0
  libswscale      3.  1.101 /  3.  1.101
  libswresample   1.  2.101 /  1.  2.101
  libpostproc    53.  3.100 / 53.  3.100
no handle for libX11.so
no handle for libXext.so
no handle for libXrender.so
no handle for libXrandr.so
no handle for libX11.so
no handle for libXext.so
no handle for libXrender.so
no handle for libXrandr.so
Abort trap 

And hey, look at that. There’s the familiar console spray we know and love. Mixed in are some hints about the libraries that couldn’t be loaded. We’ll have to survive without X support apparently, but for an MP3 I think that’s ok. (For the record, among the 50 libraries ffplay links with (ldd ffplay) is libX11, but it wants to reopen it again. Ours is not to wonder why.)

More troubling is the abort trap. Another pledge violation.

 66963 ffplay   CALL  ioctl(0,_IOR('v',7,0x4),0x7f7ffffd70b4)
 66963 ffplay   PSIG  SIGABRT SIG_DFL

I’ll spoil the surprise and tell you that’s the VT_GETACTIVE ioctl. If you’re unfamiliar with VT_GETACTIVE, that’s not surprising. It’s not mentioned in any man page. The important thing is that we’re not allowed to call ioctl. We promised not to. Let’s stub it out.

int
ioctl(int fd, unsigned long req, int *ptr)
{
        return -1;
}

Here’s what happens next.

no handle for libXrender.so
no handle for libXrandr.so
WSCONS error: Unable to determine active terminal: Invalid argument

But do you really need to know? Setting aside the question of why, we can fake this.

int
ioctl(int fd, unsigned long req, int *ptr)
{
        switch (req) { 
        case 1074034183:
                *ptr = 1;
                return 0;
        }
        fprintf(stderr, "unknown ioctl %ld\n", req);
        return 0;
}

We’ll worry about other ioctls when we get to them. For the most part, pledge is pretty restrictive about what ioctls one can use because they represent a large kernel side attack surface.

IOR decoder ring:

local function _IOR(group, num, len)
        return bit.bor(0x40000000, bit.lshift(len, 16), 
                bit.lshift(string.byte(group), 8), num)
end

print(_IOR('v',7,0x4))

Now we run again and get a new error message.

no dice for /dev/ttyC0
WSCONS error: open /dev/ttyC0: Invalid argument

Looks like we’re making progress. We see that we’ve gotten to the point where we need to open files, although ttyC0 is an unusual choice of file to open. Just add the console to PREOPEN (don’t ask why) and run again. More errors.

env LD_PRELOAD=/tmp/open.so PREOPEN=/dev/ttyC0 ffplay taylor.mp3

unknown ioctl 1074812737
unknown ioctl 1074026304
unknown ioctl 1074026335
WSCONS error: Displays with 8 bpp or less are not supported
unknown ioctl 2147768140
unknown ioctl 536890119
Could not initialize SDL - Displays with 8 bpp or less are not supported

So a bunch more stuff, which corresponds to WSDISPLAYIO_GINFO and whatever. But again, all I’m trying to do is listen to an MP3. None of this should matter. We haven’t even gotten to opening the input file yet; it seems a little premature to freak out about display depth.

Fortunately, ffplay has a nodisp option to kill the display. And that leads to another abort trap because sio_open tries to create a socket. We can fake up sio_open as well, but then we’re back to faking more ioctls. This is a long, muddy road.

ffmpeg

New strategy. Let’s just use ffmpeg to convert the MP3 to a WAV file. Maybe we can shortcut some of the media system calls.

env LD_PRELOAD=/tmp/open.so PREOPEN=taylor.mp3 ffmpeg -i taylor.mp3 taylor.wav

Abort trap 

What now?

 82214 ffmpeg   CALL  ioctl(0,TIOCGETA,0x7f7ffffe1700)
 82214 ffmpeg   PSIG  SIGABRT SIG_DFL

Again?? Now it’s coming from inside libc, from inside isatty(). We’re going to need some more permissions.

        pledge("stdio tty", NULL);

 47514 ffmpeg   CALL  access(0xc0e06bc1548,0<F_OK>)
 47514 ffmpeg   NAMI  "taylor.wav"
 47514 ffmpeg   PSIG  SIGABRT SIG_DFL

Alright, fine.

int
access(const char *path, int mode)
{
        return -1;
}

Please work, please work, please work.

env LD_PRELOAD=/tmp/open.so PREOPEN=taylor.mp3:taylor.wav ffmpeg -i taylor.mp3 taylor.wav

Stream mapping:
  Stream #0:0 -> #0:0 (mp3 (native) -> pcm_s16le (native))
Press [q] to stop, [?] for help
size=   37121kB time=00:03:35.48 bitrate=1411.2kbits/s    
video:0kB audio:37121kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.000205%

Success! Just one last command cat taylor.wav > /dev/audio and I’m done, relaxing to my tunes.

thoughts

This point has been made from the early days, but I think this exercise reinforces it, that pledge works best with programs where you understand what the program is doing. A generic pledge wrapper isn’t of much use because the program is going to do something unexpected and you’re going to have a hard time wrangling it into submission.

For the most part, pledge fails very hard, killing the process. There’s a few whitelisted exceptions, but if a process steps out of line, it doesn’t get back permission failed errors. The reason for this is to avoid creating subsets of POSIX with unpredictable behavior. It’s easier to reason about the behavior of a program if you know the syscalls it makes behave normally. As evidenced by various attempts to stub out library functions like ioctl, the calling program doesn’t expect them to fail and it’s a very deep and very dark rabbit hole trying to backfill in this functionality.

Software is too complex. What in the world is ffplay doing? Even if I were working with the source, how long would it take to rearrange the program into something that could be pledged? One can try using another program, but I would wager that as far as multiformat media players go, ffplay is actually on the lower end of the complexity spectrum. Most of the trouble comes from using SDL as an abstraction layer, which performs a bunch of console operations.

On the flip side, all of this early init code is probably the right design. Once SDL finally gets its screen handle setup, we could apply pledge and sandbox the actual media decoder. That would be the right way to things.

Is pledge too limiting? Perhaps, but that’s what I want. I could have just kept adding permissions until ffplay had full access to my X socket, but what kind of sandbox is that? I don’t want naughty MP3s scraping my screen and spying on my keystrokes. The sandbox I created had all the capabilities one needs to convert an MP3 to audible sound, but the tool I wanted to use wasn’t designed to work in that environment. And in its defense, these were new post hoc requirements. Other programs, even sed, suffer from less than ideal pledge sets as well. The best summary might be to say that pledge is designed for tomorrow’s programs, not yesterday’s (and vice versa).

There were a few things I could have done better. In particular, I gave up getting audio to work, even though there’s a nice description of how to work with pledge in the sio_open manual. Alas, even going back and with a bit more effort I still haven’t succeeded. The requirements to use libsndio are more permissive than I might prefer.

In hindsight, ffplay may not have been the best guinea pig, but it looked appealing. I thought it was simple. I definitely use it with inputs of unknown origin, so adding another layer of security here is worth a lot to me. Besides the (one may hope) well tested MP3 code, it supports a lot of other formats of less reliable development. Sounds a lot like a program I’d want to sandbox.

Posted 2017-05-20 16:28:36 by tedu Updated: 2017-05-20 16:28:36
Tagged: c openbsd programming