Safety And C

Posted on Jun 3, 2021

Yesterday I found a very, very, very absurd bug on my project cras. It only affected the development master branch, not any of the stable releases, but… it was a very absurd one… In a nutshell, cras tried to overwrite itself. Yep, my lovely program tried to open itself, write some data on the binary itself, and call it a day… Being in C you might think that’s super dangerous and tell me that I should know better and consider using higher level languages, blah blah blah… Have you noticed though that I keep saying it tried doing that, not that it really succeeded in doing it? Bingo.

This is the bugfix commit. The bug was caused do to me mistakenly setting the default file path (fileptr) to be argv[0]. fileptr is later used in the code as the file path argument for all fopen() calls. The expected behavior, though, is that the file provided by the user as the last argument in the CLI… So, poor, ol' buggy cras passed its own file path to all file-related operations… and when we tried creating a new task list (cras -n file), you wouldn’t believe what happened next </clickbait>:

$ ./cras -n ohno
Let's crash the system!
Could not write to ./cras: Text file busy

Sorry to disappoint you. The binary doesn’t overwrite itself. Opening for writing fails because the program is already open and cras stops gracefully. No binary was harmed or injured in this demonstration.

Honestly, this suprised me at first… and made me wonder (I’m such a curious girl…): What happens if we try this on other languages, like Python? So, I slapped this together…

#!/usr/bin/env python

import sys

target = open(sys.argv[0], "w")
target.write("Overwrite me!\n")
target.close()

Shockingly, this happened when I ran it:

$ chmod ug+x argv0.py
$ ./argv0.py
$ cat argv0.py
Overwrite me!

The same happens in POSIX shell scripts, and in less lines than Python, thus showing their superiority even in dangerous things (just kidding!):

#!/bin/sh

echo 'Overwritten!' > $0
$ chmod ug+x argv0-shell
$ ./argv0-shell
$ cat argv0-shell
Overwritten!

So… Aren’t we always telling people and ourselves that C is way more dangerous than higher level programming languages? How is it possible that C is able to protect itself but Python or shell scripts are not? You (and I) would expect the opposite result, considering how forgiving scripting languages are compared to C… Is this for real? What is going on? HALP!

The Great Reveal

OK, I actually did know the answer from the first time I saw cras crash stating that the text file was busy. In low-level parlance, text means code (as opposed to data)…

I’ve got news for you: C has nothing to do with this. It’s actually all about the kernel. If you try this in Go you get not only the same safe behavior as in C, but the exact same error message…1 because your program is compiled as a native binary and therefore the kernel manages and flags them as read-only executable memory.

pmap shows you the current memory layout of a process… If we run cras and look at its memory layout, you’ll see that the executable memory page (the second one) is read-only. That page is where our code (the so-called .text section) resides.

$ pmap $(pidof cras)
452153:   cras -n
00005590b5542000      4K r---- cras
00005590b5543000      8K r-x-- cras
00005590b5545000      4K r---- cras
00005590b5546000      4K r---- cras
00005590b5547000      4K rw--- cras
00005590b5bcb000    132K rw---   [ anon ]
00007f427be93000      8K rw---   [ anon ]
00007f427be95000    152K r---- libc-2.33.so
00007f427bebb000   1324K r-x-- libc-2.33.so
00007f427c006000    304K r---- libc-2.33.so
00007f427c052000     12K r---- libc-2.33.so
00007f427c055000     12K rw--- libc-2.33.so
00007f427c058000     44K rw---   [ anon ]
00007f427c079000      4K r---- ld-2.33.so
00007f427c07a000    144K r-x-- ld-2.33.so
00007f427c09e000     36K r---- ld-2.33.so
00007f427c0a7000      8K r---- ld-2.33.so
00007f427c0a9000      8K rw--- ld-2.33.so
00007ffcb46bc000    132K rw---   [ stack ]
00007ffcb4740000     16K r----   [ anon ]
00007ffcb4744000      8K r-x--   [ anon ]
ffffffffff600000      4K --x--   [ anon ]
 total             2372K

Scripts on the other hand are loaded as regular files, regardless of the executable bit stored on the filesystem. In the eyes of the operating system, a script, even if run directly, is a file loaded by an executable… For the kernel, your Python code looks exactly like the text file you’re editing on your text editor or like an image rendered by your browser: it’s data, not text… so no r-x permissions for it, but rw-. It’s the interpreters job to make that “data” behave as code.

If you don’t believe me, and you should believe me, because I’m the most amazing Pop-CompSci female writer in the whole FOSS world (FIGHT ME!)… have a look at this program written in C, which does the dumbest thing ever, but serves our point:

#include <stdio.h>

int
main(void)
{
	FILE *orig, *cpy;

	orig = fopen("test", "r");
	cpy = fopen("test", "w");
	
	if (orig == NULL || cpy == NULL) {
		perror("error");
		return -1;
	}

	fprintf(cpy, "Overwritten!\n");

	fclose(cpy);
	fclose(orig);

	return 0;
}

That code above opens the same file (test) twice: both for read access and for write access, in two separate calls. I know it’s the dumbest code ever and that if you really wanted to have read+write access in production code, r+, w+, a+ are the flags you need to pass to fopen(). In our example, though, we want to make sure we overwrite after having gained read-only access. Does the first call to fopen(), which forces read-only access, block the second call?

Nope, see it happily destroying our test file without mercy down below… Ain’t it a cutie data-destroying monster?

$ echo 'Oh my lord' > test
$ cat test
Oh my lord
$ gcc -o overwr -std=c99 -Wall -Wextra -Wpedantic -D_POSIX_C_SOURCE=200809L overwr.c
$ ./overwr
$ cat test
Overwritten!

Basically, our earlier Python example is doing this very same thing the C code above. Python first loads your code into memory, presumably under read-only access mode. Then, our own Python code opens the file again, now under write access mode… effectively truncating it and overwriting its contents. Exactly the same scenario.

The key difference here is who gets to run the code. Although everything ends up being translated as some CPU opcode, the path things take to get to the CPU is relevant. OK, so this is a dense topic I definitely want to tackle in the (near?) future, maybe even as some kind of series… But in a nutshell, native binaries are directly executed by the CPU and just supervised by the kernel (apart from accessing syscalls and resources exposed by the OS). Code running in an interpreter is more about you instructing how the interpreter is run (by the CPU.) For 90% of developers out there, I know, this is irrelevant… From a hardware-software interface POV, though, there are implementation differences which are interesting… well, at least to me… I like to navigate the dark waters that almost touch the silicon…

For our small bug here, the take-home message is that cras being a binary grants it all the protection measures put in place by the kernel at the CPU level. True, my very same code on a i286 would’ve very, very probably overwritten the binary because CPUs of older times didn’t have the protections modern ones implement…

One of the reasons why I adore retrocomputing YouTube channels and people doing crazy stuff on the OG GameBoy is because those old machines were so unprotected that you could abuse them in creative ways so creative that… wow… you know, that’s how they were able to fit all those demos back in the 80s and 90s squeezing every single byte out of the 8 bit era computers!

You can’t do that on a Linux system… You can on a QEMU VM though! 2

A Word Of Caution, Though

There is something to be said about C and safety, though. C… or actually… binary code is as safe as your platform is. Modern x64 computers and the OSs that run them enforce lots of protection measures that ensure you don’t mess around the memory and resources of other processes or the OS itself.

That doesn’t mean C is safe.

You can still introduce very nasty bugs in your code in C which leak critical information or which allow for taking control of your own process and memory. That’s what all the fuzz about stack smashing, overflows, etc., is. The OS can protect you from other processes, but except for stuff like the one I demonstrated here… in which the kernel has some degree of intervention… it can do very little to make your own code not screwing itself up. Some of the safety protections which can be put in place to avoid some common vulnerabilities are rather actually introduced by the compiler into your program… (e.g. stack pointer protection!)

C inherits most of its safety levels from the platform… so if you’re into C, know your target platform! Knowledge is power… and more so in systems programming!

Which is the sexiest kind of programming! See ya!


  1. I did try a version in Go and it worked as expected, i.e. as in C. I’m not showing it because I don’t really know any Go. I’m so sure my code was not idiomatic Go at all, so let’s just forget it. ↩︎

  2. I’ve been fooling around some 16 bits x86 real mode ASM lately. It’s hard, it’s pointless, but it’s so much fun! ↩︎