How to write an interceptor for fcntl?

Hi,

I am trying to write an interceptor for fcntl. What differentiates it from a “normal” intercepted function, is that it requires a variadic parameter in it’s third argument:

int fcntl(int fildes, int cmd, ...);

The problem is that based on the value of cmd, the variadic parameter can be empty, or have one argument (which often has different types).

A naive approach may do something like:

case F_GETFL:
case F_GETFD:
case F_GETOWN:
    // no arguments
    return REAL(fcntl)(filedes, cmd);
case F_SETFL:
   // one argument that is an int
   // unpack va_args and pass it in to the real function
   va_list args;
   va_start(args, cmd);
   int flags = va_arg ...
   va_end(args);
   return REAL(fcntl)(filedes, cmd, flags);

This seems like unsustainable insanity. It seems like the cmds differ system by system, and would be a general maintenance nightmare.

Is there any way to safely and maintainably intercept fcntl? Or am I better off ditching this interceptor altogether?

Is it possible to do in a way that’s actually portable? No.

But is it possible in practice? Yes. Just fetch the argument unconditionally via arg = va_arg(args, unsigned long) and make the call with REAL(fcntl)(filedes, cmd, arg); – regardless of what cmd is.

This works on common platforms (all the platforms that I know of), because the calling convention for vararg functions either specifies that the argument is passed in a GPR (where int is passed the same way as long), or that values will be passed in a stack slot the size of a GPR (so, again, int is passed the same way as long).

1 Like

Thanks for the response, that makes sense.

Is there any potential UB with unpacking a va_arg in this manner when the user doesn’t pass one in?

Let’s say, for instance you have the interceptor:

INTERCEPTOR(int, open, const char *path, int oflag, ...) {
  va_list args;
  va_start(args, oflag);
  const mode_t mode = va_arg(args, int);
  va_end(args);

  const int result = REAL(open)(path, oflag, mode);
  return result;
}

And the user calls this function without specifying any variadic parameters:

open("myfile.txt", O_RDONLY);

Does the call to va_args incorrectly touch memory on the stack that may cause UB?

I’m wondering if I need to change to something like this, where the va_args unpacking is protected and only done if we KNOW they are expected

INTERCEPTOR(int, open, const char *path, int oflag, ...) {
  if (oflag & O_CREAT) {
    va_list args;
    va_start(args, oflag);
    const mode_t mode = va_arg(args, int);
    va_end(args);
    return REAL(open)(path, oflag, mode);
  }

  return REAL(open)(path, oflag);
}

You might find the discussion on this FreeBSD review informative.

If you’re going to make a blind va_arg() call, I’d suggest intptr_t vs long as being closest to correct. (It still won’t work on CHERI systems due to va_list being a bounded array which has size=0 when no argument is passed. We generally solve this in libc by enumerating all the commands which we can do for fcntl and open, but with ioctl there’s no avoiding UB except by peeking into the implementation of va_list (cheribsd/lib/libsys/ioctl.c at dev · CTSRD-CHERI/cheribsd · GitHub))

1 Like

All of this is certainly UB. Calling va_arg with the wrong type, or with an argument where none exists, is wrong and bad.

I kinda hate saying this, because the “I know what the underlying machine implementation I expect the compiler to generate will be, so my UB is totally fine” thinking causes so many problems in C and C++ ecosystem…but: Can you get away with this? I do believe you can. (except on CHERI, as noted above.)

FWIW, see also the implementation in glibc and musl, which do this, too. That they also find the need to makes sense, as on Linux, libc itself is effectively intercepting arbitrary fcntl calls, from user code to the independently-maintained Linux kernel.

Thank you both for these insights, this is exactly the info I was looking for.