Hacker News new | ask | show | jobs
by comex 1974 days ago
Incidentally, after inspecting doas for a few minutes, I found two near-vulnerability bugs in it.

The first bug lets any user cause doas to read out of bounds of an array, though not in a way that's exploitable.

Well, it's arguably a bug in libc. If you run doas with a completely empty argv (argc = 0, so not even an executable name; the two systems I tried, Linux and macOS, both let you do this), getopt will exit with optind = 1. Then when doas does;

    argv += optind;
    argc -= optind;
`argc` will become negative, and `argv` will advance past the null terminator. On most OSes, the `argv` array is immediately followed in memory by `environ`, so argv will now point to the list of environment variables.

doas will then dereference argv, and generally act as if you tried to execute a command consisting of the environment variables. However, the environment variables are not secret, and doas doesn't behave any differently than if you just passed the environment variables as normal command-line arguments, so this is not exploitable.

On an OS where argv is not followed by environ or a similar array of character pointers, doas might crash instead, although since it only reads from those pointers rather than writing to them, this still probably wouldn't be exploitable.

The second bug would compromise memory safety if things were slightly different. The bug is in configuration file parsing. Even if it did compromise memory safety, it would not actually be exploitable, because doas normally only parses the trusted systemwide configuration file. It can be asked to parse a configuration file passed on the command line, but it drops privileges before doing so. This is a good example of layered defense, so kudos to doas for that! Still, I thought the bug was worth mentioning.

The bug is a traditional sort of integer overflow. parse.y grows the array of rules with

    maxrules *= 2;
but maxrules is an int, so this will eventually overflow if the configuration file is large enough.

However, because maxrules happens to be signed, before doubling produces a smaller-than-expected positive value, it will first produce a negative value. This will then get sign-extended when converting to size_t (assuming 32-bit int and 64-bit size_t), and reallocarray's overflow check will trigger, causing reallocarray to return NULL. doas interprets that as out-of-memory and handles it cleanly.

(On a system where sizeof(int) == sizeof(size_t), things are a bit different, but it will just run out of memory before maxrules gets that high.)

Moral of the story? Well, as I see it:

Simplicity and layered defense, both featured in doas, are both effective ways to avoid vulnerabilities. But guaranteed memory safety, which would require a different implementation language, is also an effective way to avoid vulnerabilities. You aren't forced to pick and choose. Why not demand all three?

2 comments

The argv += optind; is a standard pattern. I have never considered argc=0 case to be possible. I need to read some more on this.

As for your second find. It already got fixed: https://marc.info/?l=openbsd-cvs&m=161176698927944&w=2

Nice finds. I would agree that that's more arguably a bug in libc than in doas, but also note that the startup code for any language has to consider this case. As far as theoretical operating systems are concerned, this is a consequence of the System-V ABI, so any OS compatible with it would have the same issue.

As for the integer overflow case, it's also highly unlikely to be exploitable, even if it were unsigned - the system would have to, as I'm sure you can infer, have tens of millions of rules before this was an issue. It's quite within the realm of reason, in my opinion, to declare this an acceptable trade-off. The rest of your explanation shows that even if this weren't the case, the bug wouldn't be exploitable.

Anyway, I like your comment, but I'd recommend a different moral to this story: in the space of 47 minutes you were able to conduct a reasonably thorough audit on the doas codebase. Wanna give that a shot for sudo now?

> I would agree that that's more arguably a bug in libc than in doas, but also note that the startup code for any language has to consider this case.

This is true, but for a language where dynamically sized arrays are a standard data type, the most natural thing to do is to start by collecting the arguments into an array (maybe copying the strings at this point, maybe not). All further argument parsing is done with the array and is thus bounds-checked. I checked Rust's standard library and sure enough, it follows this pattern. Though, I could imagine some hypothetical startup code messing up the argc=0 case if it tried to separate argv[0] from the rest of the arguments while constructing the array.

> Anyway, I like your comment, but I'd recommend a different moral to this story: in the space of 47 minutes you were able to conduct a reasonably thorough audit on the doas codebase. Wanna give that a shot for sudo now?

Fair point. (And I didn't downvote you.) But in my opinion, that just confirms my view: ideally you want both simplicity and memory safety.

Aye, I agree. But if we consider that case, a similar mistake could be made: hard-coding argv[0]. The result is different, in that the program just aborts, but it's still the Wrong Thing To Do, and in both cases it never leads to anything exploitable. Bugs are bugs, no matter what language. We could come up with examples all day. Just head to your nearest Rust program's bug tracker :)
Aborting when argv[0] doesn't exist... is a perfectly reasonable thing to do? Someone called the program with arguments severely out of spec, crashing is fine.
It's actually within spec, in this case. Still reasonable?
It's within the C and systemv abi specs, but it's not within the implicit contract of how you call command line programs. I'm fine with it.