CVE-2024-27933 (Rust, published 2024-03-21T02:52Z)
Description: value: Deno is a JavaScript, TypeScript, and WebAssembly runtime. In version 1.39.0, use of raw file descriptors in `op_node_ipc_pipe()` leads to premature close of arbitrary file descriptors, allowing standard input to be re-opened as a different resource resulting in permission prompt bypass. Node child_process IPC relies on the JS side to pass the raw IPC file descriptor to `op_node_ipc_pipe()`, which returns a `IpcJsonStreamResource` ID associated with the file descriptor. On closing the resource, the raw file descriptor is closed together.

Use of raw file descriptors in `op_node_ipc_pipe()` leads to premature close of arbitrary file descriptors. This allow standard input (fd 0) to be closed and re-opened for a different resource, which allows a silent permission prompt bypass. This is exploitable by an attacker controlling the code executed inside a Deno runtime to obtain arbitrary code execution on the host machine regardless of permissions.

This bug is known to be exploitable. There is a working exploit that achieves arbitrary code execution by bypassing prompts from zero permissions, additionally abusing the fact that Cache API lacks filesystem permission checks. The attack can be conducted silently as stderr can also be closed, suppressing all prompt outputs.

Version 1.39.1 fixes the bug.

Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/denoland/deno
Files changed:
Cargo.lock
cli/args/mod.rs
cli/worker.rs
ext/node/Cargo.toml
ext/node/lib.rs
ext/node/ops/ipc.rs
ext/node/polyfills/internal/child_process.ts
runtime/Cargo.toml
runtime/ops/process.rs
runtime/worker_bootstrap.rs
Commit message: fix(node): child_process IPC on Windows (#21597)

This PR implements the child_process IPC pipe between parent and child.
The implementation uses Windows named pipes created by parent and passes
the inheritable file handle to the child.

I've also replace parts of the initial implementation which passed the
raw parent fd to JS with resource ids instead. This way no file handle
is exposed to the JS land (both parent and child).

`IpcJsonStreamResource` can stream upto 800MB/s of JSON data on Win 11
AMD Ryzen 7 16GB (without `memchr` vectorization)

Vulnerable code:
  pub fn node_ipc_fd(&self) -> Option<i32> {
      node_channel_fd.parse::<i32>().ok()
  node_ipc: Option<i32>,
    node_ipc: Option<i32>,
    ops::ipc::op_node_ipc_pipe,
#[cfg(unix)]
pub use unix::*;
#[cfg(windows)]
pub use windows::*;
pub struct ChildPipeFd(pub i32);

#[cfg(unix)]
mod unix {
  #[op2(fast)]
  #[smi]
  pub fn op_node_ipc_pipe(
    state: &mut OpState,
    #[smi] fd: i32,
  ) -> Result<ResourceId, AnyError> {
    Ok(state.resource_table.add(IpcJsonStreamResource::new(fd)?))
  }
  struct IpcJsonStreamResource {
    fn new(stream: RawFd) -> Result<Self, std::io::Error> {
      // Safety: The fd is part of a pair of connected sockets create by child process
      // implementation.
      let unix_stream = UnixStream::from_std(unsafe {
        std::os::unix::net::UnixStream::from_raw_fd(stream)
      })?;
      let (read_half, write_half) = unix_stream.into_split();
    fn from_unix_stream(stream: UnixStream) -> Self {

      let (fd1, mut fd2) = tokio::net::UnixStream::pair()?;
      let ipc = Rc::new(IpcJsonStreamResource::from_unix_stream(fd1));

      let (fd1, mut fd2) = tokio::net::UnixStream::pair()?;
        let mut buf = [0u8; 1024];
        let n = fd2.read(&mut buf).await?;
        assert_eq!(&buf[..n], b"\"hello\"\n");
      /* Similar to how ops would use the resource */
      let ipc = Rc::new(IpcJsonStreamResource::from_unix_stream(fd1));

      let (fd1, mut fd2) = tokio::net::UnixStream::pair()?;
        let mut buf = [0u8; 1024];
        let n = fd2.read(&mut buf).await?;
        assert_eq!(&buf[..n], b"\"hello\"\n\"world\"\n");
      let ipc = Rc::new(IpcJsonStreamResource::from_unix_stream(fd1));
      let (fd1, mut fd2) = tokio::net::UnixStream::pair()?;
      let ipc = Rc::new(IpcJsonStreamResource::from_unix_stream(fd1));

#[cfg(windows)]
mod windows {
  use deno_core::error::AnyError;
  use deno_core::op2;

  #[op2(fast)]
  pub fn op_node_ipc_pipe() -> Result<(), AnyError> {
    Err(deno_core::error::not_supported())
  }

  #[op2(fast)]
  #[smi]
  pub fn op_node_child_ipc_pipe() -> Result<i32, AnyError> {
    Ok(-1)
  }

  #[op2(async)]
  pub async fn op_node_ipc_write() -> Result<(), AnyError> {
    Err(deno_core::error::not_supported())
  }

  #[op2(async)]
  pub async fn op_node_ipc_read() -> Result<(), AnyError> {
    Err(deno_core::error::not_supported())
  }
}
const ops = core.ops;
export function setupChannel(target, channel) {
  const ipc = ops.op_node_ipc_pipe(channel);

  #[cfg(unix)]
type CreateCommand = (
  std::process::Command,
  // TODO(@littledivy): Ideally this would return Option<ResourceId> but we are dealing with file descriptors
  // all the way until setupChannel which makes it easier to share code between parent and child fork.
  Option<i32>,
);
      let pipe_fd = Some(fd1);
      return Ok((command, pipe_fd));
  pipe_fd: Option<i32>,
  pipe_fd: Option<i32>,
  let (command, pipe_fd) = create_command(state, args, &api_name)?;
  spawn_child(state, command, pipe_fd)
  pub node_ipc_fd: Option<i32>,

Fixed code:
 "windows-sys 0.48.0",
 "windows-sys 0.48.0",
  pub fn node_ipc_fd(&self) -> Option<i64> {
      node_channel_fd.parse::<i64>().ok()
  node_ipc: Option<i64>,
    node_ipc: Option<i64>,

[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
winapi = { workspace = true, features = ["consoleapi"] }
pub use ops::ipc::IpcJsonStreamResource;
pub use impl_::*;
pub struct ChildPipeFd(pub i64);
mod impl_ {
  #[cfg(unix)]
  #[cfg(unix)]

  #[cfg(unix)]
  #[cfg(unix)]
  #[cfg(unix)]
  #[cfg(windows)]
  type NamedPipeClient = tokio::net::windows::named_pipe::NamedPipeClient;
  pub struct IpcJsonStreamResource {
    #[cfg(unix)]
    #[cfg(windows)]
    write_half: AsyncRefCell<tokio::io::WriteHalf<NamedPipeClient>>,
  #[cfg(unix)]
  fn pipe(stream: RawFd) -> Result<(OwnedReadHalf, OwnedWriteHalf), io::Error> {
    // Safety: The fd is part of a pair of connected sockets create by child process
    // implementation.
    let unix_stream = UnixStream::from_std(unsafe {
      std::os::unix::net::UnixStream::from_raw_fd(stream)
    })?;
    Ok(unix_stream.into_split())
  }

  #[cfg(windows)]
  fn pipe(
    handle: i64,
  ) -> Result<
    (
      tokio::io::ReadHalf<NamedPipeClient>,
      tokio::io::WriteHalf<NamedPipeClient>,
    ),
    io::Error,
  > {
    // Safety: We cannot use `get_osfhandle` because Deno statically links to msvcrt. It is not guaranteed that the
    // fd handle map will be the same.
    let pipe = unsafe { NamedPipeClient::from_raw_handle(handle as _)? };
    Ok(tokio::io::split(pipe))
  }

    pub fn new(stream: i64) -> Result<Self, std::io::Error> {
      let (read_half, write_half) = pipe(stream as _)?;
    #[cfg(unix)]
    fn from_stream(stream: UnixStream) -> Self {
    #[cfg(windows)]
    #[cfg(test)]
    fn from_stream(pipe: NamedPipeClient) -> Self {
      let (read_half, write_half) = tokio::io::split(pipe);
      Self {
        read_half: AsyncRefCell::new(IpcJsonStream::new(read_half)),
        write_half: AsyncRefCell::new(write_half),
        cancel: Default::default(),
      }
    }

    #[cfg(unix)]
    #[cfg(windows)]
    pipe: BufReader<tokio::io::ReadHalf<NamedPipeClient>>,
    #[cfg(unix)]
    #[cfg(windows)]
    fn new(pipe: tokio::io::ReadHalf<NamedPipeClient>) -> Self {
      Self {
        pipe: BufReader::with_capacity(INITIAL_CAPACITY, pipe),
        buffer: Vec::with_capacity(INITIAL_CAPACITY),
      }
    }

    #[cfg(unix)]
    pub async fn pair() -> (Rc<IpcJsonStreamResource>, tokio::net::UnixStream) {
      let (a, b) = tokio::net::UnixStream::pair().unwrap();

      /* Similar to how ops would use the resource */
      let a = Rc::new(IpcJsonStreamResource::from_stream(a));
      (a, b)
    }

    #[cfg(windows)]
    pub async fn pair() -> (
      Rc<IpcJsonStreamResource>,
      tokio::net::windows::named_pipe::NamedPipeServer,
    ) {
      use tokio::net::windows::named_pipe::ClientOptions;
      use tokio::net::windows::named_pipe::ServerOptions;

      let name =
        format!(r"\\.\pipe\deno-named-pipe-test-{}", rand::random::<u32>());

      let server = ServerOptions::new().create(name.clone()).unwrap();
      let client = ClientOptions::new().open(name).unwrap();

      server.connect().await.unwrap();
      /* Similar to how ops would use the resource */
      let client = Rc::new(IpcJsonStreamResource::from_stream(client));
      (client, server)
    }

      let (ipc, mut fd2) = pair().await;
      let (ipc, mut fd2) = pair().await;
        const EXPECTED: &[u8] = b"\"hello\"\n";
        let mut buf = [0u8; EXPECTED.len()];
        let n = fd2.read_exact(&mut buf).await?;
        assert_eq!(&buf[..n], EXPECTED);

      let (ipc, mut fd2) = pair().await;
        const EXPECTED: &[u8] = b"\"hello\"\n\"world\"\n";
        let mut buf = [0u8; EXPECTED.len()];
        let n = fd2.read_exact(&mut buf).await?;
        assert_eq!(&buf[..n], EXPECTED);
      let (ipc, mut fd2) = pair().await;
export function setupChannel(target, ipc) {
windows-sys.workspace = true
type CreateCommand = (std::process::Command, Option<ResourceId>);
      let pipe_rid = Some(
        state
          .resource_table
          .add(deno_node::IpcJsonStreamResource::new(fd1 as _)?),
      );
      return Ok((command, pipe_rid));
  #[cfg(windows)]
  // Safety: We setup a windows named pipe and pass one end to the child process.
  unsafe {
    use windows_sys::Win32::Foundation::CloseHandle;
    use windows_sys::Win32::Foundation::DuplicateHandle;
    use windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS;
    use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
    use windows_sys::Win32::Foundation::ERROR_PIPE_CONNECTED;
    use windows_sys::Win32::Foundation::GENERIC_READ;
    use windows_sys::Win32::Foundation::GENERIC_WRITE;
    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
    use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
    use windows_sys::Win32::Storage::FileSystem::CreateFileW;
    use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_FIRST_PIPE_INSTANCE;
    use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED;
    use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
    use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
    use windows_sys::Win32::System::Pipes::ConnectNamedPipe;
    use windows_sys::Win32::System::Pipes::CreateNamedPipeW;
    use windows_sys::Win32::System::Pipes::PIPE_READMODE_BYTE;
    use windows_sys::Win32::System::Pipes::PIPE_TYPE_BYTE;
    use windows_sys::Win32::System::Threading::GetCurrentProcess;

    use std::io;
    use std::os::windows::ffi::OsStrExt;
    use std::path::Path;
    use std::ptr;

    if let Some(ipc) = args.ipc {
      if ipc < 0 {
        return Ok((command, None));
      }

      let (path, hd1) = loop {
        let name = format!("\\\\.\\pipe\\{}", uuid::Uuid::new_v4());
        let mut path = Path::new(&name)
          .as_os_str()
          .encode_wide()
          .collect::<Vec<_>>();
        path.push(0);

        let hd1 = CreateNamedPipeW(
          path.as_ptr(),
          PIPE_ACCESS_DUPLEX
            | FILE_FLAG_FIRST_PIPE_INSTANCE
            | FILE_FLAG_OVERLAPPED,
          PIPE_TYPE_BYTE | PIPE_READMODE_BYTE,
          1,
          65536,
          65536,
          0,
          std::ptr::null_mut(),
        );

        if hd1 == INVALID_HANDLE_VALUE {
          let err = io::Error::last_os_error();
          /* If the pipe name is already in use, try again. */
          if err.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) {
            continue;
          }

          return Err(err.into());
        }

        break (path, hd1);
      };

      /* Create child pipe handle. */
      let s = SECURITY_ATTRIBUTES {
        nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
        lpSecurityDescriptor: ptr::null_mut(),
        bInheritHandle: 1,
      };
      let mut hd2 = CreateFileW(
        path.as_ptr(),
        GENERIC_READ | GENERIC_WRITE,
        0,
        &s,
        OPEN_EXISTING,
        FILE_FLAG_OVERLAPPED,
        0,
      );
      if hd2 == INVALID_HANDLE_VALUE {
        return Err(io::Error::last_os_error().into());
      }

      // Will not block because we have create the pair.
      if ConnectNamedPipe(hd1, ptr::null_mut()) == 0 {
        let err = std::io::Error::last_os_error();
        if err.raw_os_error() != Some(ERROR_PIPE_CONNECTED as i32) {
          CloseHandle(hd2);
          return Err(err.into());
        }
      }

      // Duplicating the handle to allow the child process to use it.
      if DuplicateHandle(
        GetCurrentProcess(),
        hd2,
        GetCurrentProcess(),
        &mut hd2,
        0,
        1,
        DUPLICATE_SAME_ACCESS,
      ) == 0
      {
        return Err(std::io::Error::last_os_error().into());
      }

      /* One end returned to parent process (this) */
      let pipe_fd = Some(
        state
          .resource_table
          .add(deno_node::IpcJsonStreamResource::new(hd1 as i64)?),
      );

      /* The other end passed to child process via DENO_CHANNEL_FD */
      command.env("DENO_CHANNEL_FD", format!("{}", hd2 as i64));

      return Ok((command, pipe_fd));
    }
  }

  pipe_fd: Option<ResourceId>,
  pipe_fd: Option<ResourceId>,
  let (command, pipe_rid) = create_command(state, args, &api_name)?;
  spawn_child(state, command, pipe_rid)
  pub node_ipc_fd: Option<i64>,