Skip to content

Commit 91c763d

Browse files
committed
Initial attempt at internalizing self-approval into the plugin
1 parent ba6b594 commit 91c763d

5 files changed

Lines changed: 159 additions & 80 deletions

File tree

sample/bin/sudo_approve

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ declare -r SUDO_SOCKET_PATH="/var/run/sudo_pair"
2424

2525
pair() {
2626
declare -r socket="${1}"
27+
declare -r token="${2}"
2728

2829
# restore TTY settings on exit
2930
# shellcheck disable=SC2064
@@ -40,26 +41,19 @@ pair() {
4041
clear
4142

4243
# prompt the user to approve
43-
socat STDIO unix-connect:"${socket}"
44+
cat <(echo "${token}") - | socat STDIO unix-connect:"${socket}"
4445
}
4546

4647
usage() {
47-
echo "Usage: $(basename -- "$0") uid pid"
48+
echo "Usage: $(basename -- "$0") pid"
4849
exit 1
4950
}
5051

5152
main() {
5253
declare -r socket_path="${1}"
53-
declare -ri uid="${2}"
54-
declare -ri pid="${3}"
54+
declare -ri pid="${2}"
5555

56-
# if we're running this under `sudo`, we want to know the original
57-
# user's `uid` from `SUDO_UID`; if not, it's jsut their normal `uid`
58-
declare -i ruid
59-
ruid="${SUDO_UID:-$(id -u)}"
60-
declare -r ruid
61-
62-
declare -r socket="${socket_path}/${uid}.${pid}.sock"
56+
declare -r socket="${socket_path}/${pid}.sock"
6357

6458
declare -i socket_uid socket_gid
6559
socket_uid="$(stat -c '%u' "${socket}")"
@@ -72,32 +66,22 @@ main() {
7266
socket_mode="$(stat -c '%a' "${socket}")"
7367
declare -r socket_user socket_group socket_mode
7468

75-
# if the user approving the command is the same as the user who
76-
# invoked `sudo` in the first place, abort
77-
#
78-
# another option would be to allow the session, but log it in a way
79-
# that it immediately pages oncall security engineers; such an
80-
# approach is useful in production systems in that it allows for a
81-
# in-case-of-fire-break-glass workaround so engineers can respond to
82-
# a outage in the middle of the night
83-
#
84-
# this responsibility will be moved into the plugin itself when time
85-
# allots
86-
if [[ "${uid}" -eq "${ruid}" ]]; then
87-
echo "Users may not approve their own sudo session"
88-
exit 1
69+
if [[ ! ${SUDO_PAIR_TOKEN:-} ]]; then
70+
declare -x SUDO_PAIR_TOKEN
71+
token=$(socat STDOUT unix-connect:"${socket}")
72+
declare -r SUDO_PAIR_TOKEN
8973
fi
9074

9175
# if we can write: pair
9276
# if user-owner can write: sudo to them and try again
9377
# if group-owner can write: sudo to them and try again
9478
# if none, die
9579
if [ -w "${socket}" ]; then
96-
pair "${socket}"
80+
pair "${socket}" "${SUDO_PAIR_TOKEN}"
9781
elif [[ $(( 8#${socket_mode} & 8#200 )) -ne 0 ]]; then
98-
sudo -u "${socket_user}" "${0}" "${uid}" "${pid}"
82+
sudo -u "${socket_user}" "${0}" "${pid}"
9983
elif [[ $(( 8#${socket_mode} & 8#020 )) -ne 0 ]]; then
100-
sudo -g "${socket_group}" "${0}" "${uid}" "${pid}"
84+
sudo -g "${socket_group}" "${0}" "${pid}"
10185
else
10286
echo "The socket for this sudo session is neither user- nor group-writable."
10387
exit 2

sudo_pair/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ crate-type = ["cdylib"]
1919

2020
[dependencies]
2121
libc = '0'
22+
rand = '0'
2223
error-chain = '0'
2324
sudo_plugin = { version = "1.1", path = "../sudo_plugin" }
2425

sudo_pair/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ The full list of options are as follows:
140140

141141
Note that root is *always* exempt.
142142

143+
* `self_approval` (default: false)
144+
This is a boolean ("true" or "false") that controls whether or not users are allowed to approve their own commands. When a user approves their own command this way, a message is sent to syslog.
145+
146+
This capability is provided so that engineers can act unilaterally in the event of an emergency when no-one else is available. Since it is effectively a complete bypass of this plugin, the intent is that using this capability should invoke something drastic (e.g., immediately page an oncall security engineer).
147+
143148
## Prompts
144149

145150
This plugin allows you to configure the prompts that are displayed to
@@ -258,6 +263,7 @@ Given the security-sensitive nature of this project, it is an explicit
258263
goal to have a minimal set of dependencies. Currently, those are:
259264

260265
* [rust-lang/libc][libc]
266+
* [rust-lang-nursery/rand][rand]
261267
* [rust-lang-nursery/rust-bindgen][bindgen]
262268
* [rust-lang-nursery/error-chain][error-chain]
263269

@@ -284,6 +290,7 @@ See [LICENSE-APACHE](LICENSE-APACHE) for details.
284290

285291
[sudo_plugin_man]: https://www.sudo.ws/man/1.8.22/sudo_plugin.man.html
286292
[libc]: https://github.com/rust-lang/libc
293+
[rand]: https://github.com/rust-lang-nursery/rand
287294
[bindgen]: https://github.com/rust-lang-nursery/rust-bindgen
288295
[error-chain]: https://github.com/rust-lang-nursery/error-chain
289296
[airtight-hatchway]: https://blogs.msdn.microsoft.com/oldnewthing/20060508-22/?p=31283

sudo_pair/src/lib.rs

Lines changed: 85 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
// TODO: various badges
2929
// TODO: fill out all fields of https://doc.rust-lang.org/cargo/reference/manifest.html
3030
// TODO: implement change_winsize
31+
// TODO: convert all outgoing errors to be unauthorized errors
3132

3233
#![warn(bad_style)]
3334
#![warn(future_incompatible)]
@@ -67,6 +68,8 @@
6768
// #![cfg_attr(feature="cargo-clippy", warn(clippy_cargo))]
6869

6970
extern crate libc;
71+
extern crate rand;
72+
7073
#[macro_use] extern crate error_chain;
7174
#[macro_use] extern crate sudo_plugin;
7275

@@ -117,54 +120,49 @@ impl SudoPair {
117120
// TODO: convert all outgoing errors to be unauthorized errors
118121
let options = PluginOptions::from(&plugin.plugin_options);
119122

120-
let mut pair = Self {
123+
let mut session = Self {
121124
plugin,
122125
options,
123126
socket: None,
124127
};
125128

126-
if pair.is_sudoing_to_user_and_group() {
129+
// sessions without a socket are bypassed entirely, so if the
130+
// session is exempt we can go ahead and return what we already
131+
// have
132+
if session.is_exempt() {
133+
return Ok(session)
134+
}
135+
136+
// based on the current authorization model, allowing `-u` and
137+
// `-g` simultaneously would let a user who can
138+
// `sudo -g ${group}` approve a `sudo -u ${user} -g ${group}`
139+
// even if they can't `sudo -u ${user}`, so we disable the
140+
// capability entirely
141+
if session.is_sudoing_to_user_and_group() {
127142
bail!(ErrorKind::Unauthorized(
128143
"sudo_pair doesn't support sudoing to both a user and a group".into()
129144
));
130145
}
131146

132-
if pair.is_exempt() {
133-
return Ok(pair)
134-
}
147+
let template_spec = session.template_spec();
135148

136-
let template_spec = pair.template_spec();
137-
138-
pair.local_pair_prompt(&template_spec);
139-
pair.remote_pair_connect()?;
140-
pair.remote_pair_prompt(&template_spec)?;
141-
142-
// TODO(security): provide a configurable option to deny or log
143-
// if the remote euid is the same as the local euid. For some
144-
// reason I convinced myself that this is necessary to implement
145-
// in the client and not the pair plugin, but I can't remember
146-
// what the reasoning was at the moment.
147-
//
148-
// Oh, now I remember. It *has* to be done on the client,
149-
// because the approval script is run under `sudo` itself so
150-
// that we can verify the pairer is also capable of doing the
151-
// task the user invoking `sudo` is trying to do. Unfortunately,
152-
// the OS APIs we have to determine the other side of the
153-
// connection only tell us the *euid*, not the *uid*. So we end
154-
// up with the euid of `root` which isn't helpful. So this kind
155-
// of check *must* be done on the client.
149+
// We want to know the actual user on the other end of the
150+
// socket in order to enforce restrictions around self-approval.
156151
//
157-
// Except I have an idea for how to solve this plugin-side. Open
158-
// a socket writable by all. When someone connects, get the
159-
// credentials of the peer and send them a cryptographically-
160-
// random token. Close the socket and reopen a new one as we
161-
// currently do. Instead of expecting a `y`, expect the token.
162-
// This binds their ability to approve the session (able to
163-
// write to the socket) with their original identity (proven
164-
// through providing the token from their original user). This
165-
// shouldn't be too hard, but I haven't gotten around to it yet.
166-
167-
Ok(pair)
152+
// To do this, we initially allow any user to connect to the
153+
// socket. We then record their euid and then we hand them a
154+
// cryptographically-random token. The socket is closed and a
155+
// new one is opened with restricted permissions. When a client
156+
// connects to this socket, we expect them to echo the token
157+
// back to us before we ask for their approval.
158+
let peer_token : [u8; 16] = rand::random();
159+
160+
session.local_pair_prompt(&template_spec);
161+
session.remote_pair_connect_unprivileged(&peer_token)?;
162+
session.remote_pair_connect_privileged(&peer_token)?;
163+
session.remote_pair_prompt(&template_spec)?;
164+
165+
Ok(session)
168166
}
169167

170168
fn close(&mut self, _: i64, _: i64) {
@@ -260,19 +258,47 @@ impl SudoPair {
260258
.ok_or_else(||self.plugin.stderr().write_all(&prompt));
261259
}
262260

263-
fn remote_pair_connect(&mut self) -> Result<()> {
264-
if self.socket.is_some() {
265-
return Ok(());
261+
fn remote_pair_connect_unprivileged(&mut self, token: &[u8; 16]) -> Result<()> {
262+
let mut socket = Socket::open(
263+
self.socket_path(),
264+
self.socket_uid(),
265+
self.socket_gid(),
266+
libc::S_IWUSR | libc::S_IWGRP | libc::S_IWOTH,
267+
).chain_err(|| ErrorKind::Unauthorized("unable to connect to a pair".into()))?;
268+
269+
let peer_euid = socket.peer_euid()
270+
.chain_err(|| ErrorKind::Unauthorized("unable determine uid of pair".into()))?;
271+
272+
if peer_euid == self.plugin.user_info.uid {
273+
// TODO: log or abort
266274
}
267275

276+
socket
277+
.write_all(token)
278+
.chain_err(|| ErrorKind::Unauthorized("unable to ask pair for approval".into()))?;
279+
280+
Ok(())
281+
}
282+
283+
fn remote_pair_connect_privileged(&mut self, token: &[u8; 16]) -> Result<()> {
268284
// TODO: clearly indicate when the socket path is missing
269-
let socket = Socket::open(
285+
let mut socket = Socket::open(
270286
self.socket_path(),
271287
self.socket_uid(),
272288
self.socket_gid(),
273289
self.socket_mode(),
274290
).chain_err(|| ErrorKind::Unauthorized("unable to connect to a pair".into()))?;
275291

292+
let mut response : [u8; 16] = [0; 16];
293+
let _ = socket.read_exact(&mut response)
294+
.chain_err(|| ErrorKind::Unauthorized("unable to ask pair for approval".into()))?;
295+
296+
// non-constant comparison is fine here since a comparison
297+
// failure results in an immediate exit
298+
if response != *token {
299+
bail!("denied by pair");
300+
}
301+
276302
self.socket = Some(socket);
277303

278304
Ok(())
@@ -452,19 +478,8 @@ impl SudoPair {
452478
}
453479

454480
fn socket_path(&self) -> PathBuf {
455-
// we encode the originating `uid` into the pathname since
456-
// there's no other (easy) way for the approval command to probe
457-
// for this information
458-
//
459-
// note that we want the *`uid`* and not the `euid` here since
460-
// we want to know who the real user is and not the `uid` of the
461-
// owner of `sudo`
462481
self.options.socket_dir.join(
463-
format!(
464-
"{}.{}.sock",
465-
self.plugin.user_info.uid,
466-
self.plugin.user_info.pid,
467-
)
482+
format!("{}.sock", self.plugin.user_info.pid)
468483
)
469484
}
470485

@@ -614,6 +629,21 @@ struct PluginOptions {
614629
///
615630
/// Default: `[]` (however, root is *always* exempt)
616631
gids_exempted: HashSet<gid_t>,
632+
633+
/// `self_approval` is a boolean ("true" or "false") that controls
634+
/// whether or not users are allowed to approve their own commands.
635+
/// When a user approves their own command this way, a message is
636+
/// sent to syslog.
637+
///
638+
/// This capability is provided so that engineers can act
639+
/// unilaterally in the event of an emergency when no-one else is
640+
/// available. Since it is effectively a complete bypass of this
641+
/// plugin, the intent is that using this capability should invoke
642+
/// something drastic (e.g., immediately page an oncall security
643+
/// engineer).
644+
///
645+
/// Default: `false`
646+
self_approval: bool,
617647
}
618648

619649
impl PluginOptions {
@@ -647,6 +677,9 @@ impl<'a> From<&'a OptionMap> for PluginOptions {
647677

648678
gids_exempted: map.get("gids_exempted")
649679
.unwrap_or_default(),
680+
681+
self_approval: map.get("self_approval")
682+
.unwrap_or(false),
650683
}
651684
}
652685
}

sudo_pair/src/socket.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,60 @@ impl Socket {
121121
self.socket.shutdown(Shutdown::Both)
122122
}
123123

124+
#[cfg(any(
125+
target_os = "linux",
126+
target_os = "android",
127+
target_os = "fuchsia",
128+
))]
129+
pub(crate) fn peer_euid(&self) -> Result<uid_t> {
130+
unsafe {
131+
let cred : libc::ucred = mem::uninitialized();
132+
let size : libc::socklen_t = mem::size_of::<libc::ucred>() as _;
133+
134+
if libc::getsockopt(
135+
self.socket.as_raw_fd(),
136+
libc::SOL_SOCKET,
137+
libc::SO_PEERCRED,
138+
&mut cred as _,
139+
&mut size,
140+
) == -1 {
141+
return Err(Error::last_os_error());
142+
}
143+
144+
// `getsockopt` should fill out the `ucred` pointer
145+
// completely, no more and no less
146+
debug_assert!(size == mem::size_of::<libc::ucred>() as _);
147+
148+
Ok(cred.uid)
149+
}
150+
}
151+
152+
#[cfg(any(
153+
target_os = "macos",
154+
target_os = "ios",
155+
target_os = "freebsd",
156+
target_os = "dragonfly",
157+
target_os = "openbsd",
158+
target_os = "netbsd",
159+
target_os = "bitrig",
160+
))]
161+
pub(crate) fn peer_euid(&self) -> Result<uid_t> {
162+
unsafe {
163+
let mut uid : uid_t = mem::uninitialized();
164+
let mut gid : gid_t = mem::uninitialized();
165+
166+
if libc::getpeereid(
167+
self.socket.as_raw_fd(),
168+
&mut uid,
169+
&mut gid
170+
) == -1 {
171+
return Err(Error::last_os_error());
172+
}
173+
174+
Ok(uid)
175+
}
176+
}
177+
124178
fn unlink(path: &Path) -> Result<()> {
125179
match fs::metadata(&path).map(|md| md.file_type().is_socket()) {
126180
// file exists, is a socket; delete it

0 commit comments

Comments
 (0)