Skip to content

Commit 65b325d

Browse files
partoufclaude
andcommitted
Add --prepare-nul to grant AppContainer access to the NUL device
NUL is \Device\Null, a device object, so its DACL cannot be reached through the SE_FILE_OBJECT named-object provider; it has to be read and written through an open handle with GetKernelObjectSecurity/SetKernelObjectSecurity instead. The kernel recreates \Device\Null with a default security descriptor on every boot, and that default does not grant the AppContainer SIDs, so a sandboxed child that opens NUL (directly, or by redirecting its stdio there) fails with ERROR_ACCESS_DENIED. Grant it to ALL APPLICATION PACKAGES and ALL RESTRICTED APPLICATION PACKAGES rather than to the per-run cesandbox<pid> SID: the device is machine wide, so a per-run grant would race with concurrently running sandboxes and would leak an ACE for every crashed run. This is a standalone maintenance mode with no target executable, meant to run elevated once per boot, so also guard the progid assignment - it previously read argv[argc] when no executable followed the flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 75b0f5e commit 65b325d

8 files changed

Lines changed: 189 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this is
6+
7+
`cewrapper.exe` is a Windows-only jailing wrapper used by Compiler Explorer to run untrusted executables (compilers, user programs) under an AppContainer sandbox plus a Job object with resource limits. It is a single-binary MSVC C++ project — no CMake, no package manager, no test suite.
8+
9+
```
10+
cewrapper.exe [-v|-vv] [--summary] [--config=C:\path\config.json] [--home=C:\cwd]
11+
[--time_limit=<seconds>] [--suspend] [--wait] C:\full\path\to\exe.exe [args...]
12+
cewrapper.exe --prepare-nul
13+
```
14+
15+
`--prepare-nul` is a standalone maintenance mode (no target exe): it grants the app package SIDs access
16+
to `\Device\Null` and exits. It must run elevated, once per boot — the kernel resets that DACL at boot
17+
and the default locks AppContainers out of `NUL`. `infra/init/start.ps1` calls it via `PrepareNulDevice`.
18+
19+
Note the `--time_limit=` value is in **seconds** and is multiplied by 1000 into `time_limit_ms`.
20+
21+
## Build
22+
23+
MSVC only, x64 only, `Debug|x64` and `Release|x64` configurations, toolset v142, `/std:c++latest`, warning level 4.
24+
25+
```
26+
msbuild cewrapper.vcxproj -t:rebuild -property:Configuration=Release -property:Platform=x64
27+
```
28+
29+
Output lands in `x64\Release\cewrapper.exe`. CI (`.github/workflows/build-test.yml`) runs exactly this on `windows-2022` and publishes the exe as a release artifact on tags. Despite the workflow name there are **no tests** — verification is building plus running the exe manually against a real target program on Windows.
30+
31+
The repo may be checked out on Linux (e.g. inside the Compiler Explorer tree), where nothing compiles: every source file pulls in `windows.h`, `userenv.h`, `aclapi.h`, `winsafer.h`, `ntsecapi.h`. Code changes made there can only be reviewed by reading, not built.
32+
33+
Formatting: clang-format with the checked-in `.clang-format` (Allman braces, 4 spaces, 120 cols, `AccessModifierOffset: 0` so `private:`/`public:` sit at member indent).
34+
35+
Adding a source file means editing `cewrapper.vcxproj` (`ClCompile`/`ClInclude` item groups) and ideally `cewrapper.vcxproj.filters`.
36+
37+
## Architecture
38+
39+
`wmain` (`src/main.cpp`) parses config, constructs one `Job`, then dispatches to one of two sandboxing strategies:
40+
41+
- `execute_using_appcontainer` — the default (`use_appcontainer: true`). Creates an `AppContainer`, builds a `STARTUPINFOEX` carrying `PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`, grants the container SID ACLs on the home dir and each configured path/registry key, spawns, then revokes the path ACLs afterwards.
42+
- `execute_using_lower_rights` — fallback when `use_appcontainer` is false. Uses the Safer API (`SaferCreateLevel` / `SaferComputeTokenFromLevel` at `SAFER_LEVELID_NORMALUSER`) and `CreateProcessAsUserW`.
43+
44+
Both funnel into `SpawnProcess`, which is the only place a process is created. Invariant: the child is **always** created `CREATE_SUSPENDED`, assigned to the job with `job.AddProcess` while still suspended, and only then resumed (unless `--suspend`). Don't reorder that — a process that starts before it is in the job escapes the limits. `SpawnProcess` also sets `lpDesktop` to `winsta0\default`; without it AppContainer children can die with `0xc0000142` (STATUS_DLL_INIT_FAILED).
45+
46+
Module responsibilities:
47+
48+
- `config.{hpp,cpp}``Config` is a **process-global singleton** (`Config::get()`, backed by a file-scope `_main_config`). CLI flags are parsed in order until the first non-flag argument, which becomes `progid`; everything after is `args`. `--config=` loads the JSON via the vendored `3rdparty/nlohmann/json.hpp`. `Job` and `AppContainer` each take `const Config` **by value**, so config mutated after their construction won't be seen by them.
49+
- `appcontainer.{hpp,cpp}` — profile is named `cesandbox<pid>`, created in the constructor and deleted in the destructor. `InitializeCapabilities()` builds the `SID_AND_ATTRIBUTES` vector; `sec_cap.CapabilityCount` is derived from `capabilities.size()`, so capabilities can be added/removed by editing only that function. The `capabilities` vector is the backing store for `sec_cap.Capabilities` and must outlive any use of `sec_cap`. `DeriveCapabilitySidsFromName` is resolved dynamically from `KernelBase.dll` (not in an import lib).
50+
- `job.{hpp,cpp}` — job named `cejob<pid>`, always sets `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`; process/time/memory limits are only applied when the corresponding config value is > 0. Destructor terminates the job, prints the `--summary`/`-vv` stats, then closes the handle.
51+
- `access.{hpp,cpp}` — read-modify-write of DACLs via `GetNamedSecurityInfoW` / `SetEntriesInAclW` / `SetNamedSecurityInfoW`, for `SE_FILE_OBJECT` and the three registry views. `grant_access_to_nul_device` is the exception: `NUL` is a device object, not a file object, so it goes through `CreateFileW(L"\\\\.\\NUL", READ_CONTROL | WRITE_DAC, …)` plus `GetKernelObjectSecurity` / `SetKernelObjectSecurity`. Don't try to reach it with `SE_FILE_OBJECT` — that was tried in `28751a1`..`5786c0a` and does not work.
52+
- `checks.{hpp,cpp}` — error funnel. `CheckWin32`/`CheckStatus` print via `FormatMessageW` and `throw std::exception`, caught in `wmain`; `CheckStatusAllowFail` only warns. AppContainer creation failures call `abort()` instead of throwing.
53+
- `exitcodes.hpp``SpecialExitCode` values collide with real child exit codes by design-so-far (see the `todo` in that file); the wrapper returns the child's exit code on the normal path.
54+
55+
## Config JSON
56+
57+
```json
58+
{
59+
"use_appcontainer": true,
60+
"mem_max": 0,
61+
"pids_max": 0,
62+
"allowed_paths": [{ "path": "C:\\some\\dir", "rw": false, "noexec": false }],
63+
"allowed_registry": [{ "path": "MACHINE\\Software\\...", "rw": false, "type": "normal" }]
64+
}
65+
```
66+
67+
Paths get `GENERIC_READ`, plus `GENERIC_WRITE` if `rw`, plus `GENERIC_EXECUTE` unless `noexec`. Registry keys get `GENERIC_READ`, or `GENERIC_ALL` if `rw`; `type` is `normal` | `wow6464` | `wow6432`. `allowed_paths` and `allowed_registry` are indexed unconditionally (`data["allowed_paths"]`), so both keys must be present.
68+
69+
## Known landmines
70+
71+
- `Config::loadFromFile` selects the registry type with `jsregtype.compare("wow6464")` used as a boolean. `std::string::compare` returns 0 on equality, so those branches are inverted — every type other than `wow6464` currently resolves to `wow6464`. Fix carefully if you touch it; existing configs may depend on the current behaviour.
72+
- Path ACLs granted to the container SID are revoked at the end of `execute_using_appcontainer`, but registry grants are never revoked.
73+
- If the process crashes and a later run reuses the same PID, `CreateAppContainerProfile` returns `ERROR_ALREADY_EXISTS`; the code deletes and recreates the profile in that case.

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ jailing wrapper for execution of windows applications
88

99
for some verboseness, use `cewrapper.exe -v C:\full\path\to\executable.exe arguments`
1010

11+
## host preparation
12+
13+
`cewrapper.exe --prepare-nul`
14+
15+
Grants ALL APPLICATION PACKAGES and ALL RESTRICTED APPLICATION PACKAGES read/write/execute on the NUL
16+
device (`\Device\Null`). Without this, a sandboxed process that opens `NUL`, or redirects its stdio
17+
there, fails with access denied.
18+
19+
The kernel recreates `\Device\Null` with a default security descriptor on every boot, so this has to be
20+
run again after each boot, elevated, before any sandboxed process starts.
21+
1122
## dev
1223

1324
To format your sourcefiles, use clang-format. Can be downloaded along with llvm [here](https://github.com/llvm/llvm-project/releases/download/llvmorg-15.0.6/LLVM-15.0.6-win64.exe)

include/access.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
namespace cewrapper
77
{
88

9+
void grant_access_to_nul_device();
10+
911
void grant_access_to_path(wchar_t *container_sid, wchar_t *dir, uint32_t permissions);
1012
void grant_access_to_registry(wchar_t *container_sid, wchar_t *key, uint32_t permissions, registry_type_t regtype);
1113

include/config.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct Config
2121
bool home_set{};
2222
bool use_appcontainer{ true };
2323
bool wait_before_spawn{};
24+
bool prepare_nul{};
2425

2526
int pids_max{};
2627
int64_t mem_max{};

include/exitcodes.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ enum class SpecialExitCode : DWORD
66
NotEnoughArgs = 1,
77
InvalidArgs = 2,
88
ProcessTookTooLong = 3,
9+
ErrorPreparingNulDevice = 4,
910
ProcessTookTooLongMethod2 = 9,
1011
ErrorWhenExecutingProcess = 254,
1112
UnknownErrorWhileWaitingOnProcess = 255,

src/access.cpp

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,72 @@
11
#include "../include/access.hpp"
22
#include "../include/checks.hpp"
33
#include <aclapi.h>
4+
#include <sddl.h>
5+
#include <vector>
6+
7+
// NUL resolves to the \Device\Null device object, so the SE_FILE_OBJECT named-object provider does not
8+
// apply to it and its DACL has to be read and written through an open handle instead.
9+
//
10+
// The kernel recreates \Device\Null with a default security descriptor on every boot, and that default
11+
// does not grant the AppContainer SIDs, so a sandboxed child that opens NUL (directly, or by redirecting
12+
// its stdio there) fails with ERROR_ACCESS_DENIED. \Device\Null is machine wide and shared by every
13+
// process, so this grants access to all app packages rather than to a single container SID: a per-run
14+
// grant would race with concurrently running sandboxes and would leak an ACE for every crashed run.
15+
//
16+
// Must be run elevated (WRITE_DAC on \Device\Null), once per boot, before any sandboxed process starts.
17+
void cewrapper::grant_access_to_nul_device()
18+
{
19+
// S-1-15-2-1 is ALL APPLICATION PACKAGES, S-1-15-2-2 is ALL RESTRICTED APPLICATION PACKAGES (LPAC)
20+
PSID all_packages = nullptr;
21+
PSID all_restricted_packages = nullptr;
22+
cewrapper::CheckWin32(ConvertStringSidToSidW(L"S-1-15-2-1", &all_packages), L"ConvertStringSidToSidW(S-1-15-2-1)");
23+
cewrapper::CheckWin32(ConvertStringSidToSidW(L"S-1-15-2-2", &all_restricted_packages),
24+
L"ConvertStringSidToSidW(S-1-15-2-2)");
25+
26+
EXPLICIT_ACCESSW access[2] = {};
27+
for (auto &entry : access)
28+
{
29+
entry.grfAccessPermissions = GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE;
30+
entry.grfAccessMode = GRANT_ACCESS;
31+
entry.grfInheritance = NO_INHERITANCE;
32+
entry.Trustee.TrusteeForm = TRUSTEE_IS_SID;
33+
entry.Trustee.TrusteeType = TRUSTEE_IS_GROUP;
34+
}
35+
access[0].Trustee.ptstrName = static_cast<wchar_t *>(all_packages);
36+
access[1].Trustee.ptstrName = static_cast<wchar_t *>(all_restricted_packages);
37+
38+
HANDLE hnul = CreateFileW(L"\\\\.\\NUL", READ_CONTROL | WRITE_DAC,
39+
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, 0,
40+
nullptr);
41+
cewrapper::CheckWin32(hnul != INVALID_HANDLE_VALUE, L"CreateFileW(\\\\.\\NUL)");
42+
43+
DWORD sd_size = 0;
44+
GetKernelObjectSecurity(hnul, DACL_SECURITY_INFORMATION, nullptr, 0, &sd_size);
45+
std::vector<BYTE> sd_buffer(sd_size);
46+
cewrapper::CheckWin32(GetKernelObjectSecurity(hnul, DACL_SECURITY_INFORMATION, sd_buffer.data(), sd_size, &sd_size),
47+
L"GetKernelObjectSecurity");
48+
49+
BOOL dacl_present = FALSE;
50+
BOOL dacl_defaulted = FALSE;
51+
ACL *prevAcl = nullptr;
52+
cewrapper::CheckWin32(GetSecurityDescriptorDacl(sd_buffer.data(), &dacl_present, &prevAcl, &dacl_defaulted),
53+
L"GetSecurityDescriptorDacl");
54+
55+
// SetEntriesInAclW replaces any existing ACE for the same trustee, so re-running this is harmless
56+
ACL *newAcl = nullptr;
57+
cewrapper::CheckStatus(SetEntriesInAclW(2, access, dacl_present ? prevAcl : nullptr, &newAcl), L"SetEntriesInAclW");
58+
59+
SECURITY_DESCRIPTOR newSd = {};
60+
cewrapper::CheckWin32(InitializeSecurityDescriptor(&newSd, SECURITY_DESCRIPTOR_REVISION),
61+
L"InitializeSecurityDescriptor");
62+
cewrapper::CheckWin32(SetSecurityDescriptorDacl(&newSd, TRUE, newAcl, FALSE), L"SetSecurityDescriptorDacl");
63+
cewrapper::CheckWin32(SetKernelObjectSecurity(hnul, DACL_SECURITY_INFORMATION, &newSd), L"SetKernelObjectSecurity");
64+
65+
LocalFree(newAcl);
66+
LocalFree(all_packages);
67+
LocalFree(all_restricted_packages);
68+
CloseHandle(hnul);
69+
}
470

571
void cewrapper::grant_access_to_path(wchar_t *container_sid, wchar_t *dir, uint32_t permissions)
672
{

src/config.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,29 @@ void cewrapper::Config::initFromArguments(int argc, wchar_t *argv[])
6868
this->wait_before_spawn = true;
6969
arg_idx++;
7070
}
71+
else if (arg.compare(L"--prepare-nul") == 0)
72+
{
73+
this->prepare_nul = true;
74+
arg_idx++;
75+
}
7176
else
7277
{
7378
break;
7479
}
7580
}
7681

77-
this->progid = argv[arg_idx];
7882
this->args.clear();
83+
84+
// --prepare-nul is a standalone maintenance mode, everything else needs an executable to run
85+
if (arg_idx >= argc)
86+
{
87+
if (this->prepare_nul)
88+
return;
89+
90+
throw std::exception("No executable given");
91+
}
92+
93+
this->progid = argv[arg_idx];
7994
arg_idx += 1;
8095
for (; arg_idx < argc; ++arg_idx)
8196
this->args.push_back(argv[arg_idx]);

src/main.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ int wmain(int argc, wchar_t *argv[])
216216
std::wcerr << L"Too few arguments\n";
217217
std::wcerr << L"Usage: cewrapper.exe [-v] [--config=/full/path/to/config.json] [--home=/preferred/cwdpath] "
218218
L"[--time_limit=1] ExePath [args]\n";
219+
std::wcerr << L" cewrapper.exe --prepare-nul (run elevated, once per boot)\n";
219220
return (DWORD)SpecialExitCode::NotEnoughArgs;
220221
}
221222

@@ -231,6 +232,24 @@ int wmain(int argc, wchar_t *argv[])
231232
return (DWORD)SpecialExitCode::InvalidArgs;
232233
}
233234

235+
if (cewrapper::Config::get().prepare_nul)
236+
{
237+
try
238+
{
239+
cewrapper::grant_access_to_nul_device();
240+
}
241+
catch (std::exception &)
242+
{
243+
std::wcerr << L"Could not grant AppContainer access to the NUL device, is this running elevated?\n";
244+
return (DWORD)SpecialExitCode::ErrorPreparingNulDevice;
245+
}
246+
247+
if (cewrapper::Config::get().debugging)
248+
std::wcerr << L"Granted app package access to the NUL device\n";
249+
250+
return 0;
251+
}
252+
234253
cewrapper::Job job(cewrapper::Config::get());
235254

236255
DWORD app_exit_code = (DWORD)SpecialExitCode::ErrorWhenExecutingProcess;

0 commit comments

Comments
 (0)