I've recently been looking at linux security modules. My first two experiments helped me learn:
- My First module -
whitelist_lsm.c
This looked for the presence of an xattr, and if present allowed execution of binaries.
I learned about the Kernel build-system, and how to write a simple LSM.
- My second module -
hashcheck_lsm.c
This looked for the presence of a "known-good" SHA1 hash xattr, and if it matched the actual hash of the file on-disk allowed execution.
I learned how to hash the contents of a file, from kernel-space.
Both allowed me to learn things, but both were a little pointless. They were not fine-grained enough to allow different things to be done by different users. (i.e. If you allowed "alice
" to run "wget
" you'd also allow www-data
to do the same.)
So, assuming you wanted to do your security job more neatly what would you want? You'd want to allow/deny execution of commands based upon:
- The user who was invoking them.
- The path of the binary itself.
So your local users could run "bad" commands, but "www-data
" (post-compromise) couldn't.
Obviously you don't want to have to recompile your kernel to change the rules of who can execute what. So you think to yourself "I'll write those rules down in a file". But of course reading a file from kernel-space is tricky. And parsing any list of rules, in a file, from kernel-space would prone to buffer-related problems.
So I had a crazy idea:
- When a user attempts to execute a program.
- Call back to user-space to see if that should be permitted.
- Give the user-space binary the UID of the invoker, and the path to the command they're trying to execute.
Calling userspace? Every time a command is to be executed? Crazy. But it just might work.
One problem I had with this approach is that userspace might not even be available, when you're booting. So I setup a flag to enable this stuff:
# echo 1 >/proc/sys/kernel/can-exec/enabled
Now the kernel will invoke the following on every command:
/sbin/can-exec $UID $PATH
Because the kernel waits for this command to complete - as it reads the exit-code - you cannot execute any child-processes from it as you'd end up in recursive hell, but you can certainly read files, write to syslog, etc. My initial implementionation was as basic as this:
int main( int argc, char *argv[] ) { ... // Get the UID + Program int uid = atoi( argv[1] ); char *prg = argv[2]; // syslog openlog ("can-exec", LOG_CONS | LOG_PID | LOG_NDELAY, LOG_LOCAL1); syslog (LOG_NOTICE, "UID:%d CMD:%s", uid, prg ); // root can do all. if ( uid == 0 ) return 0; // nobody if ( uid == 65534 ) { if ( ( strcmp( prg , "/bin/sh" ) == 0 ) || ( strcmp( prg , "/usr/bin/id" ) == 0 ) ) { fprintf(stderr, "Allowing 'nobody' access to shell/id\n" ); return 0; } } fprintf(stderr, "Denied\n" ); return -1; }
Although the UIDs are hard-code it actually worked! Yay!
I updated the code to convert the UID to a username, then check executables via the file /etc/can-exec/$USERNAME.conf
, and this also worked.
I don't expect anybody to actually use this code, but I do think I've reached a point where I can pretend I've written a useful (or non-pointless) LSM at last. That means I can stop.
https://steve.fi/
Of course adding an easter-egg is left as an exercise - deny all binaries with
s
in their filenames, on 1st April? Trivial.