Skip to content

Commit afcf375

Browse files
authored
Merge pull request #131 from ausimian/fix/issue-125-build-cache-hardening
build: harden dev/CI source-build cache trust
2 parents 5d7630f + cb968a0 commit afcf375

5 files changed

Lines changed: 97 additions & 11 deletions

File tree

MAINTAINING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ The switch is driven by a `File.dir?("c_src")` check in mix.exs's
3535
`compilers/0` — the hex `package[:files]` list ships only `lib/` and
3636
the docs, so consumers land on the download path automatically.
3737

38+
`EMILY_CACHE` must point at a **private, user-owned** directory: the
39+
source build statically links `$EMILY_CACHE/mlx-<v>-<variant>/lib/libmlx.a`
40+
into the NIF, so the build refuses to reuse a cache dir owned by another
41+
user (and keeps its own dirs `0700`) to stop a shared cache from planting
42+
native code. The per-user macOS/XDG defaults already satisfy this.
43+
3844
Variant selection is unified via the `:variant` app-config key:
3945
in-repo builds read `EMILY_MLX_VARIANT` env var (`aot`|`jit`,
4046
default `aot`) through `config/config.exs` and stash the atom as

RELEASE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
### Security
22

3+
- The dev/CI source-build path now refuses to trust an MLX install
4+
directory it doesn't own and keeps the build cache `0700`, so a shared
5+
or attacker-controlled `EMILY_CACHE` can't plant a `libmlx.a` that is
6+
then statically linked into the NIF. Fixed system tools (`getconf`,
7+
`id`, `sw_vers`, plus `xcrun`/`sysctl`/`ps` in `build-mlx.sh`) resolve
8+
from absolute/system paths rather than `$PATH`, and the MLX-build lock
9+
records the holder's process start time so a recycled PID can't be
10+
mistaken for the original holder. Build-time only; no runtime change.
11+
312
- Precompiled NIF downloads are now verified against checksums pinned
413
inside the hex package (`native_checksums.txt`) rather than a `.sha256`
514
sidecar fetched from the same GitHub release as the tarball. Because

lib/mix/tasks/emily.doctor.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ defmodule Mix.Tasks.Emily.Doctor do
333333
defp macos_version do
334334
case :os.type() do
335335
{:unix, :darwin} ->
336-
case System.cmd("sw_vers", ["-productVersion"], stderr_to_stdout: true) do
336+
case System.cmd("/usr/bin/sw_vers", ["-productVersion"], stderr_to_stdout: true) do
337337
{version, 0} -> String.trim(version)
338338
_ -> :unknown
339339
end

mix.exs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ defmodule Emily.MixProject do
251251
defp default_cache_dir do
252252
case :os.type() do
253253
{:unix, :darwin} ->
254-
{out, 0} = System.cmd("getconf", ["DARWIN_USER_CACHE_DIR"])
254+
{out, 0} = System.cmd("/usr/bin/getconf", ["DARWIN_USER_CACHE_DIR"])
255255
Path.join(String.trim(out), "emily")
256256

257257
_ ->
@@ -260,6 +260,45 @@ defmodule Emily.MixProject do
260260
end
261261
end
262262

263+
# ---------- Cache-dir trust (dev/CI source build) ----------
264+
265+
# The MLX install dir is statically linked into libemily, so a planted
266+
# libmlx.a is arbitrary native code in the BEAM. Refuse to trust (or
267+
# reuse) a cache/install dir owned by another user — the exposure is a
268+
# shared EMILY_CACHE on a multi-user host — and keep our own dirs 0700.
269+
defp current_uid do
270+
{out, 0} = System.cmd("/usr/bin/id", ["-u"])
271+
out |> String.trim() |> String.to_integer()
272+
end
273+
274+
defp assert_owned!(dir) do
275+
case File.stat(dir) do
276+
{:ok, %File.Stat{uid: uid}} ->
277+
unless uid == current_uid() do
278+
Mix.raise("""
279+
Refusing to trust #{dir}: it is owned by uid #{uid}, not you
280+
(uid #{current_uid()}). A shared or attacker-controlled cache
281+
could plant a malicious libmlx.a that is statically linked into
282+
the NIF. Point EMILY_CACHE at a private, user-owned directory.
283+
""")
284+
end
285+
286+
{:error, :enoent} ->
287+
:ok
288+
289+
{:error, reason} ->
290+
Mix.raise("Cannot stat #{dir}: #{inspect(reason)}")
291+
end
292+
end
293+
294+
defp prepare_cache_dir! do
295+
cache = cache_dir()
296+
File.mkdir_p!(cache)
297+
assert_owned!(cache)
298+
File.chmod!(cache, 0o700)
299+
cache
300+
end
301+
263302
defp mlx_variant do
264303
case Application.get_env(:emily, :variant, :aot) do
265304
:aot -> "aot"
@@ -292,7 +331,9 @@ defmodule Emily.MixProject do
292331

293332
defp build_mlx(args) do
294333
_ = arch_tag()
334+
assert_owned!(cache_dir())
295335
dir = mlx_install_dir()
336+
assert_owned!(dir)
296337

297338
if "--force" in args do
298339
File.rm_rf!(dir)
@@ -328,7 +369,7 @@ defmodule Emily.MixProject do
328369
script = Path.expand("scripts/build-mlx.sh", File.cwd!())
329370
jit_flag = if mlx_variant() == "jit", do: "1", else: "0"
330371

331-
File.mkdir_p!(cache_dir())
372+
prepare_cache_dir!()
332373

333374
Mix.shell().info("Building MLX #{@mlx_version} (#{mlx_variant()}) from source")
334375

scripts/build-mlx.sh

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111

1212
set -euo pipefail
1313

14+
# Resolve the fixed macOS system tools (uname, xcrun, sysctl, ps, id, …)
15+
# from the real system bin dirs regardless of a poisoned inbound $PATH.
16+
# The original PATH is appended so user-installed build tools (cmake,
17+
# ninja) still resolve.
18+
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:${PATH}"
19+
1420
if [[ $# -ne 4 ]]; then
1521
echo "usage: $0 <mlx-src-dir> <mlx-version> <jit 0|1> <install-prefix>" >&2
1622
exit 2
@@ -70,8 +76,10 @@ fi
7076
#
7177
# `flock(1)` isn't shipped on macOS, so we use atomic `mkdir` as the
7278
# lock primitive. The lock dir is keyed on PREFIX, which both
73-
# contexts share. A PID file inside lets us reclaim a stale lock if
74-
# the previous holder died without cleanup.
79+
# contexts share. A token file (PID + process start time) inside lets
80+
# us reclaim a stale lock if the previous holder died without cleanup —
81+
# the start time guards against PID reuse, so a recycled PID belonging
82+
# to an unrelated live process is still treated as stale.
7583
BUILD_DIR="${PREFIX}.build"
7684
STAGING="${PREFIX}.staging"
7785
LOCK_DIR="${PREFIX}.lock"
@@ -101,24 +109,46 @@ cleanup() {
101109
}
102110
trap cleanup EXIT
103111

112+
# Record the lock holder as "PID\n<process start time>". A recycled PID
113+
# (same number, different process) has a different start time, so the
114+
# stale-lock reclaim below can't mistake an unrelated live process for
115+
# the original holder — `kill -0` alone can't tell them apart.
116+
write_lock_token() {
117+
{ echo "$$"; ps -o lstart= -p "$$" 2>/dev/null; } > "$LOCK_PID_FILE"
118+
}
119+
120+
# Is the recorded holder ($1=pid, $2=start time) still the live process
121+
# that took the lock?
122+
holder_is_live() {
123+
local pid="$1" started="$2"
124+
[[ -n "$pid" ]] || return 1
125+
kill -0 "$pid" 2>/dev/null || return 1
126+
local now
127+
now=$(ps -o lstart= -p "$pid" 2>/dev/null || true)
128+
[[ -n "$now" && "$now" == "$started" ]]
129+
}
130+
104131
while :; do
105132
if mkdir "$LOCK_DIR" 2>/dev/null; then
106-
echo "$$" > "$LOCK_PID_FILE"
133+
write_lock_token
107134
acquired_lock=1
108135
break
109136
fi
110137

111-
holder=""
112-
[[ -r "$LOCK_PID_FILE" ]] && holder=$(cat "$LOCK_PID_FILE" 2>/dev/null || true)
138+
holder_pid=""
139+
holder_started=""
140+
if [[ -r "$LOCK_PID_FILE" ]]; then
141+
{ IFS= read -r holder_pid; IFS= read -r holder_started; } < "$LOCK_PID_FILE" 2>/dev/null || true
142+
fi
113143

114-
if [[ -n "$holder" ]] && ! kill -0 "$holder" 2>/dev/null; then
115-
echo "==> Reclaiming stale MLX-build lock (dead PID $holder)" >&2
144+
if [[ -n "$holder_pid" ]] && ! holder_is_live "$holder_pid" "$holder_started"; then
145+
echo "==> Reclaiming stale MLX-build lock (holder PID ${holder_pid} is gone)" >&2
116146
rm -rf "$LOCK_DIR"
117147
continue
118148
fi
119149

120150
if (( printed_wait == 0 )); then
121-
echo "==> Waiting for concurrent MLX build${holder:+ (PID $holder)} on ${PREFIX}" >&2
151+
echo "==> Waiting for concurrent MLX build${holder_pid:+ (PID $holder_pid)} on ${PREFIX}" >&2
122152
printed_wait=1
123153
fi
124154
sleep 1

0 commit comments

Comments
 (0)