I’m so glad to hear that. The first and second time I heard about pledge/unveil I didn’t “get it”, so if I’m finally able to explain it clearly I think that’s good! :)
What is the mechanism that keeps a compromised process from making additional pledges and calls to unveil? ie. if there was an RCE in some program, could I not include in my payload to first call pledge/unveil before I go off and do evil things?
pledge calls are one way, meaning they can only ratchet further down (become more restrictive). If I call pledge("stdio") (pseudo call), any subsequent calls to pledge will fail and the kernel will kill the app.
Similar with unveil, I set the access and call pledge("unveil"), now any attempts to unveil will cause a pledge violation. Also calling unveil with null args will prevent further manipulation.
Pledge and unveil work together or separate depending on the app.
First pledge promises can only be removed not extended once the first call to pledge was made.
Some apps only have pledge promises like stdio, dns, net. When there is no rpath, wpath, cpath, fattr the app can not access the file system anyhow, no need for unveil. Exception: You can read and write to filedescriptors opened before the pledge call.
Unveil works the other way the first call to unveil hides the entire fs except for the unveiled path.
Further calls to unveil make more files in the filesystem visible until you call unveil(NULL, NULL) which locks it down or you call pledge without the unveil promise which will kill the program when it tries to call unveil in the future.
Why did you choose to pass in pledges as a null-terminated string? Did you consider adding a length field? What about encoding options as flags in a variable or two, eliminating the parsing step?
A stringly typed system can ignore values it doesn’t understand, while if you have bitfields and change what one means or need to expand the number of bits, you have changed your API and need to recompile.
Ok, so lets look at a couple changes one might want to make, and how different apis handle them. The primary ones are adding, merging, and splitting pledge categories.
First, to add a new pledge category in both cases no code needs to be recompiled. This type of change could occur when adding new syscalls to the kernel. In each case, old pledge calls will still be valid. Old kernels can also ignore new pledge categories for both strings and bitfields.
When merging two (or more) pledge categories no code needs to be recompiled. However, the bitfield case is more elegant in its changes. Consider merging the pledge categories foo and bar into foobar. With strings, new kernels will need to recognize both older categories, in addition to the new (merged) category. Old kernels encountering foobar will kill the process when syscalls from either foo or bar are made. This effectively breaks the api, meaning that merges into a new pledge category in this manner difficult. There is no way to have one set of code work on both new and old kernels just using the merged category. Therefore, the only way to do merges is to have foo imply bar and vice versa. However, this may result in new code which breaks on old kernels since it uses syscalls from bar and only has foo in its pledge string.
In the bitfield case, merging new categories is much easier. If the two older categories were PLEDGE_FOO = 1 and PLEDGE_BAR = 2, then a new definition PLEDGE_FOOBAR = 3 can be added. New code can use this symbol. Bugs where new code only sets PLEDGE_BAR or PLEDGE_FOO can occur, but many compilers have the ability to deprecate enums. Now, using the old values for FOO and BAR gives a warning at compile-time. Alternatively, the header writer could just define PLEDGE_BAR and PLEDGE_FOO to both be 3, in addition to defining PLEDGE_FOOBAR. (though this would not affect behaviour, since the kernel would still imply bar from foo and vice versa).
Last, lets look at the case of splitting a pledge category into two new categories (for more fine-grained control). As before, no code needs to be recompiled. To illustrate, consider splitting the pledge category foobar into foo and bar. In order to not break new code running on old kernels, the old category must be included with pledge calls in addition to the new ones. E.g. to maintain compatibility, the new api for pledging just bar would be to pledge both foobar and bar, and for new kernels to then disable foo since it was not also pledged. With strings, each caller must do this manually, and there is a chance for breakage on old kernels if just bar is passed. However, with bitfields, if PLEDGE_FOOBAR = 1 and bits 1 and 2 are unused, one could define PLEDGE_BAR = 3 and PLEDGE_FOO = 5. This prevents incompatibility, while allowing transparent use of the new api. If desired, the old PLEDGE_FOOBAR could be marked as deprecated.
Of course, all these changes rely on having spare bits left. Since there are only 18 categories in use, 64 bits provides more than enough for expansion. The ability to transparently add merges in software is helpful as well. For example, if foo, bar, and baz are commonly pledged together, a constant for foobarbaz could easily be defined in software, with no change to the kernel. With a string api, such a change would be breaking. For these reasons, in addition to not having to parse (potentially unterminated) user-generated strings, I find the design of this api puzzling.
You are ignoring the case when you might want to add more than bits, maybe instead of just ‘stdio bar’ you want to add extensions other than bits, Say for example you want “~stdio” to mean prevent children from inheriting new permissions other than stdio.
Perhaps, but either way, null-terminated strings in a syscall are bad design imo. The composability of a bitfield representation is a real advantage, especially when combined with the C preprocessor. I really can’t think of a case where strings would be more extensible, except if you wanted to add more than 64ish pledges.
This looks really cool and reminds me of some of the WebAssembly security talk we had here maybe a month or two ago. I presume this works in a similar way: for a simple program as maybe cat is, there’s not much reason to suspect cat of doing naughty things - if you’ve compromised cat, you can change the pledge() call as well. But on a larger, more complicated program which may be linking to shared libraries which may be compromised, it can detect and prevent naughty behaviour in those shared libraries, requiring attackers to compromise both the library and the programs which use it.
At least, that’s how I kind of read what this is for. It’s a very slick interface nonetheless!
The concern is taking over a process, not replacing its binary, and with that, pledge’s append-only semantics in terms of limitations ensure that a process can’t do things it’s not supposed to do.
Then when the “real” call to pledge is made, the process will crash because it’s the second invocation. (Just pointing that out, not that it contradicts what you wrote.)
It would still be “easy” to patch out the “real” call to pledge.
But as others pointed it out, this is not the threat prevented by pledge, it’s about limiting process compromission (eg. A remote code-exec in some utilities cannot do more than what the utility has already pledge)
This post gets an upvote for its clear explanation on how pledge and unveil works.
I’m so glad to hear that. The first and second time I heard about pledge/unveil I didn’t “get it”, so if I’m finally able to explain it clearly I think that’s good! :)
What is the mechanism that keeps a compromised process from making additional pledges and calls to unveil? ie. if there was an RCE in some program, could I not include in my payload to first call pledge/unveil before I go off and do evil things?
pledge
calls are one way, meaning they can only ratchet further down (become more restrictive). If I callpledge("stdio")
(pseudo call), any subsequent calls to pledge will fail and the kernel will kill the app.Similar with
unveil
, I set the access and callpledge("unveil")
, now any attempts to unveil will cause a pledge violation. Also callingunveil
with null args will prevent further manipulation.unveil(2) pledge(2)
Super clever, thanks for taking the time to explain!
I made a mistake, that pledge call can be reduced:
pledge("")
would be the most ratcheted down.Also
pledge("unveil")
allows for making calls to unveil, one would remove “unveil” from the list to further prohibit modifications.Pledge and unveil work together or separate depending on the app.
First pledge promises can only be removed not extended once the first call to pledge was made.
Some apps only have pledge promises like stdio, dns, net. When there is no rpath, wpath, cpath, fattr the app can not access the file system anyhow, no need for unveil. Exception: You can read and write to filedescriptors opened before the pledge call.
Unveil works the other way the first call to unveil hides the entire fs except for the unveiled path.
Further calls to unveil make more files in the filesystem visible until you call unveil(NULL, NULL) which locks it down or you call pledge without the unveil promise which will kill the program when it tries to call unveil in the future.
Why did you choose to pass in pledges as a null-terminated string? Did you consider adding a length field? What about encoding options as flags in a variable or two, eliminating the parsing step?
It is using the openbsd api. tame(2) the pledge predecessor used flags. I can’t find a reference on why it was changed to strings.
strings are easier to change without breaking code or recompiling.
Can you elaborate on this?
A stringly typed system can ignore values it doesn’t understand, while if you have bitfields and change what one means or need to expand the number of bits, you have changed your API and need to recompile.
This is as I understand it.
Ok, so lets look at a couple changes one might want to make, and how different apis handle them. The primary ones are adding, merging, and splitting pledge categories.
First, to add a new pledge category in both cases no code needs to be recompiled. This type of change could occur when adding new syscalls to the kernel. In each case, old pledge calls will still be valid. Old kernels can also ignore new pledge categories for both strings and bitfields.
When merging two (or more) pledge categories no code needs to be recompiled. However, the bitfield case is more elegant in its changes. Consider merging the pledge categories
foo
andbar
intofoobar
. With strings, new kernels will need to recognize both older categories, in addition to the new (merged) category. Old kernels encounteringfoobar
will kill the process when syscalls from either foo or bar are made. This effectively breaks the api, meaning that merges into a new pledge category in this manner difficult. There is no way to have one set of code work on both new and old kernels just using the merged category. Therefore, the only way to do merges is to havefoo
implybar
and vice versa. However, this may result in new code which breaks on old kernels since it uses syscalls frombar
and only hasfoo
in its pledge string.In the bitfield case, merging new categories is much easier. If the two older categories were
PLEDGE_FOO = 1
andPLEDGE_BAR = 2
, then a new definitionPLEDGE_FOOBAR = 3
can be added. New code can use this symbol. Bugs where new code only setsPLEDGE_BAR
orPLEDGE_FOO
can occur, but many compilers have the ability to deprecate enums. Now, using the old values for FOO and BAR gives a warning at compile-time. Alternatively, the header writer could just definePLEDGE_BAR
andPLEDGE_FOO
to both be 3, in addition to definingPLEDGE_FOOBAR
. (though this would not affect behaviour, since the kernel would still implybar
fromfoo
and vice versa).Last, lets look at the case of splitting a pledge category into two new categories (for more fine-grained control). As before, no code needs to be recompiled. To illustrate, consider splitting the pledge category
foobar
intofoo
andbar
. In order to not break new code running on old kernels, the old category must be included with pledge calls in addition to the new ones. E.g. to maintain compatibility, the new api for pledging justbar
would be to pledge bothfoobar
andbar
, and for new kernels to then disablefoo
since it was not also pledged. With strings, each caller must do this manually, and there is a chance for breakage on old kernels if justbar
is passed. However, with bitfields, ifPLEDGE_FOOBAR = 1
and bits 1 and 2 are unused, one could definePLEDGE_BAR = 3
andPLEDGE_FOO = 5
. This prevents incompatibility, while allowing transparent use of the new api. If desired, the oldPLEDGE_FOOBAR
could be marked as deprecated.Of course, all these changes rely on having spare bits left. Since there are only 18 categories in use, 64 bits provides more than enough for expansion. The ability to transparently add merges in software is helpful as well. For example, if
foo
,bar
, andbaz
are commonly pledged together, a constant forfoobarbaz
could easily be defined in software, with no change to the kernel. With a string api, such a change would be breaking. For these reasons, in addition to not having to parse (potentially unterminated) user-generated strings, I find the design of this api puzzling.You are ignoring the case when you might want to add more than bits, maybe instead of just ‘stdio bar’ you want to add extensions other than bits, Say for example you want “~stdio” to mean prevent children from inheriting new permissions other than stdio.
then do
pledge(PLEDGE_ALL, PLEDGE_STDIO)
.I think you missed my point because my example was bad.
Perhaps, but either way, null-terminated strings in a syscall are bad design imo. The composability of a bitfield representation is a real advantage, especially when combined with the C preprocessor. I really can’t think of a case where strings would be more extensible, except if you wanted to add more than 64ish pledges.
This looks really cool and reminds me of some of the WebAssembly security talk we had here maybe a month or two ago. I presume this works in a similar way: for a simple program as maybe
cat
is, there’s not much reason to suspectcat
of doing naughty things - if you’ve compromisedcat
, you can change thepledge()
call as well. But on a larger, more complicated program which may be linking to shared libraries which may be compromised, it can detect and prevent naughty behaviour in those shared libraries, requiring attackers to compromise both the library and the programs which use it.At least, that’s how I kind of read what this is for. It’s a very slick interface nonetheless!
The concern is taking over a process, not replacing its binary, and with that, pledge’s append-only semantics in terms of limitations ensure that a process can’t do things it’s not supposed to do.
Wouldn’t shared library constructors execute before main (and thus, the pledge) would begin?
If shared library constructors call pledge.
Then when the “real” call to
pledge
is made, the process will crash because it’s the second invocation. (Just pointing that out, not that it contradicts what you wrote.)It would still be “easy” to patch out the “real” call to
pledge
.But as others pointed it out, this is not the threat prevented by
pledge
, it’s about limiting process compromission (eg. A remote code-exec in some utilities cannot do more than what the utility has already pledge)