Skip to content

Instantly share code, notes, and snippets.

@singe
Last active June 26, 2024 10:51
Show Gist options
  • Save singe/88aa76814b89a2fc3d6e7a48607706c9 to your computer and use it in GitHub Desktop.
Save singe/88aa76814b89a2fc3d6e7a48607706c9 to your computer and use it in GitHub Desktop.
Quick 'n Dirty seatbelt/sandbox

macOS Seatbelt/Sandbox Trace Script

macOS sandbox profiles used to be able to include a trace command that would write all the denied operations to a sandbox profile, allowing a profile to be iterativley built up. Apple removed that functionality for reasons explained below.

trace.sh examines the kernel log for the denied operations and creates the relevant allow rules in a sandbox profile, just like the sandbox profile trace command used to.

shrink.sh tries to reduce a sandbox profile to the minimum lines necessary.

It's very rough and ready at the moment (check the sed regex'es in the script to see what I mean) and needs more testing with a wider set of use cases.

Usage

trace.sh <executable name> <sandbox profile>

Will execute executable name with a deny all rule, record all the denied operations that occur during execution, then write the corresponding allow rule to the sandbox profile.

It will keep executing until there is a return code of 0, and build up the rule base. It will also stop if no new rules are added. This logic can be flawed. See the discussion below.

shrink.sh <executable name> <sandbox profile>

Will execute executable name with the sandbox profile provided, then attempt to remove each line and discard them if the command still executes.

As with trace, this will use a return code of 0 as indication of successful execution.

Background

macOS contains a nuanced and detailed sandbox tool that used to be called seatbelt. macOS itself uses these profiles to constrain the execution of many internal services. You can see these at /System/Library/Sandbox/Profiles.

However, Apple don't want users writing these anymore. So they deprecated the utility for running executables with these sandbox scripts sandbox-exec. But, this hasn't impacted it's functioning yet, and given that Apple themselves still use these, and it's part of the underpinning of the App sandbox, the functionality is unlikely to go away.

Apple would instead like us to use the App sandbox. The problem with the App sandbox is two fold:

  1. It requires a .app which is a real hassle for simple executables and scripts.
  2. It moves everything into it's own filesystem container requiring an irritating copying/linking process.
  3. Entitlements aren't able to constrain things as granularly as the sandbox profiles are.

Also, Apple states in various places that this is an internal API and likely to change. So even if you have a working sandbox profile, it could change from one version of macOS to the next.

Finally, the profile language isn't documented anywhere, so the only way to discover what's needed is by doing this.

Usage Considerations

Stop condition

By default the stop condition is if no new rules are added or a return code of 0 is achieved. This might not be appropriate for your case and you'll need to edit the script (i.e. if the valid return code is 1).

Temporary or Random files

If your executable writes to a temporary file or a file with a random filename, then every execution will generate a new literal path for that file. You'll need to stop the execution and change the literal to a subpath for the directory it's writing to instead.

Shrinking

The kernel log will show denied operations that aren't mandatory for the program to execute. shrink.sh can be used to reduce the final profile to the minimum necessary.

Commands that don't exit

Some commands, once running, don't exit. You can use trace.sh to get to the point of basic execution, but after that might need to manually kill the executable after testing additional functionality to get coverage for all the code paths. Tests or fuzzer's can help here.

Sub-processes

This doesn't currently follow the execution of subprocesses (e.g. the exec*() series of functions from the stdc unistd.h). Rather run a new trace for those and include them in the master profile with the (import "subprocess.sb") sandbox profile command.

#!/bin/bash
# Rough and ready macOS seatbelt/sandbox profile shrinker - removes unnecessary lines
# by @singe
# Path to the sandbox profile
SANDBOX_PROFILE="$2"
# Temporary sandbox profile used for testing
TEMP_SANDBOX_PROFILE=$(mktemp)
# The command to run in the sandbox
COMMAND="$1"
# Check the full sandbox first
sandbox-exec -f $SANDBOX_PROFILE $COMMAND
if [ $? -ne 0 ]; then
echo "[+] The command could not execute successfully with the initial sandbox profile provided."
exit 1
else
echo "[*] Successful execution of the command with initial sandbox."
fi
# Read each line from the sandbox file, excluding the first two lines
LINE_COUNT=$(wc -l < "$SANDBOX_PROFILE")
cp $SANDBOX_PROFILE $TEMP_SANDBOX_PROFILE
# Loop through each line starting from the bottom (so as not to mess up line numbers when we modify the file)
# This will remove the (version 1) line too, because we can't be sure it's the
# first line if there are comments above it
for (( i=$LINE_COUNT; i>0; i-- ))
do
TMP=$(mktemp)
# Create a new sandbox profile without the current line, but include the first two lines
sed "${i}d" "$TEMP_SANDBOX_PROFILE" > $TMP
LINE="$(sed "${i}q;d" $SANDBOX_PROFILE)"
echo "[-] Attempting to remove line $i: $LINE"
# Test the command with the modified sandbox profile
echo "[-] Executing ..."
sandbox-exec -f "$TMP" $COMMAND
if [ $? -eq 0 ]; then
echo $LINE | grep "([ ]*deny " > /dev/null
if [[ $? -eq 0 ]]; then
# Command successful but we removed a deny rule so put it back
echo "[*] Not removing a deny rule"
else
# Command successful without the rule, so we remove it permanently
echo "[+] Removed line $i: unnecessary rule."
cp $TMP $TEMP_SANDBOX_PROFILE
fi
else
# Command failed without the rule, keep the rule
echo "[*] Kept line $i: necessary rule."
fi
done
# Output the minimized sandbox profile
echo "[-] Minimised sandbox profile:"
cat $TEMP_SANDBOX_PROFILE
mv $TEMP_SANDBOX_PROFILE $SANDBOX_PROFILE.shrunk
#!/bin/bash
# Rough and ready macOS seatbelt/sandbox profile creator
# by @singe
# Enable job control
set -m
# Define your program name and sandbox profile path
PROGRAM_NAME="$1"
SANDBOX_PROFILE="$2"
LOG_FILE="sandbox_log.txt"
PROGRAM_PID=0
RETURN_CODE=9999
RULE_LENGTH=0
done=false
# Create the initial sandbox profile
if [[ ! -f "$SANDBOX_PROFILE" ]]; then
echo "(version 1)
(deny default)" > $SANDBOX_PROFILE
fi
# Function to run the program in the sandbox and log events
run_program() {
# Monitor sandbox events specifically for this program
echo [-] Starting logging ...
log stream --style compact --info --debug --predicate "((processID == 0) AND (senderImagePath CONTAINS '/Sandbox'))" > $LOG_FILE &
sleep 2
LOG_PID=$!
# Run the program in sandbox mode
echo [-] Executing program
sandbox-exec -f $SANDBOX_PROFILE $PROGRAM_NAME &
PROGRAM_PID=$!
# Wait for the program to finish
wait $PROGRAM_PID
RETURN_CODE=$?
echo [-] Finished executing. Stopping logging.
sleep 5
kill $LOG_PID
}
# Function to update the sandbox profile from log events
update_sandbox_profile() {
OLD_LENGTH=$RULE_LENGTH
grep -a -e "deny" $LOG_FILE | grep -a -e "($PROGRAM_PID)" | while read -r line; do
# Extract service and operation from the log line
# 1 - transform log line into allow rule with literal
# 2 - convert sysctl lines to use sysctl-name not literal
# 3 - convert ioctil path: to just the path
# 4 - convert literal in network rules to local ip - this is a bad
# assumption and needs to be tested with remote ips
# 5 - convert network local:* to localhost
# 6 - convert rules that take no parameters
# 7 - convert mach-lookup rules to use global-name instead of literal
# 8 - why would a network line have a file path in it? It happens for
# some reason. Binding it to a random port on localhost.
rule=$(echo $line \
| sed "s/.* deny([0-9]*) \([^ ]*\) \([^ ]*\).*$/(allow \1 (literal \"\2\"))/" \
| sed "s/sysctl-\(.*\) (literal /sysctl-\1 (sysctl-name /" \
| sed "s/\"path:/\"/" \
| sed "s/network-\(.*\) (literal /network-\1 (local ip /" \
| sed "s/\"local:\*/\"localhost/" \
| sed "s/.* deny([0-9]*) \([^ ]*\)/(allow \1)/" \
| sed "s/mach-lookup (literal /mach-lookup (global-name /" \
| sed "s/network-\([^ ]*\) (local ip \"\/.*\"/network-\1 (local ip \"localhost:2000\"/" \
)
# Check if rule exists already
grep "$rule" $SANDBOX_PROFILE > /dev/null
if [[ $? -ne 0 ]]; then
# Append the corresponding allow rule to the sandbox profile
echo [+] New rule, adding to profile.
echo "$rule" >> $SANDBOX_PROFILE
else
echo [*] Duplicate rule, not adding.
fi
done
# Check if we've added any rules
RULE_LENGTH=$(wc -l $SANDBOX_PROFILE)
if [[ $RULE_LENGTH == $OLD_LENGTH ]]; then
done=true
echo [*] No new rules added, exiting.
fi
}
# Run the functions and loop until exit code is 0 or no new rules added
while [[ "$done" == "false" ]]; do
run_program
update_sandbox_profile
if [[ $RETURN_CODE -eq 0 ]]; then
echo [+] Return code is 0, stopping execution loop.
done=true
fi
done
killall $PROGRAM_NAME
killall log
echo "Updated sandbox profile:"
cat $SANDBOX_PROFILE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment