Skip to content

Commit ad68d0e

Browse files
ausimianclaude
andcommitted
fix: verify the working directory rather than trust the window
The last commit moved the race from a file inode to a directory inode. mkdir creates 0777 against the umask, so under 0002 - ordinary - or 0000 the new directory is briefly writable by others, and nothing checked that it was still empty when the chmod landed. The child names are predictable, and create_private/1 used File.write/2, which follows a symlink: an interloper plants sys.config pointing at a file it can read, keeps a descriptor on that file, and receives the configuration written through it. Measured, against the previous commit: the target is truncated, chmodded to 0600 and filled. The justification was the flaw, not the mechanism. It said the window was harmless because it covered an empty directory, and offered that traversal is checked on every lookup rather than captured in a descriptor. Both halves are true and both are about reading. An empty directory is safe to read. It is not safe to write into, and the attack wants a name inside it, not a handle on it. So work_dir/1 now creates, narrows, and then verifies: File.ls must come back empty after the chmod, or the directory is removed and the install refused with the entry named and the umask pointed at. Verification is the point rather than the belt to a braces - it does not depend on any judgement about whether a window is harmless, which is the judgement that has now been wrong five times running in this module. AGENTS.md says that as the rule, and preserves the argument it replaced so nobody reconstructs it from first principles. create_private/1 creates exclusively. This is a different use of :exclusive from the one measured two commits ago and the comment keeps them apart: there it was asked to settle the mode and does not, here it is asked to refuse a name that is already there and does - eexist for a regular file, a symlink, and a symlink to nothing, the last without bringing its target into existence. Exclusivity says nothing about the permissions an inode arrives with, and a private directory says nothing about a name already inside it, so neither substitutes for the other. Which makes write_private/2 single-use per path. Both call sites already were; the one test that wrote a path twice was testing the mode, and now uses two. Audited the rest of the module for predictable names it creates. The working directory's own name is refused if taken, and File.mkdir/1 is what refuses it - measured against a directory, a regular file, a symlink to a directory and a dangling symlink, all eexist, since {:mode, _} being silently ignored on open was the same category of surprise. sys.config is replaced by File.rename/2, which replaces a symlink at that name rather than writing through it, target untouched. The base is published by File.ln/2, which refuses eexist against all three shapes and creates nothing from a dangling one. What follows an eexist there is a read of whatever holds the name, so a symlink planted at sys.config.pristine would be read through - but planting it needs write permission on the version directory, and an account with that can write its own configuration into the file directly. That is the boundary already documented, not a new hole. All recorded in the module comment. The test that discriminates stands in the mkdir-to-chmod window, which is why secure_dir/1 is public: a directory at 0777 with a sys.config symlink planted in it is refused, removed, and what the symlink pointed at is neither truncated nor written through - File.rm_rf/1 unlinks a symlink rather than following it, also measured. Its companion plants a name inside an already-private directory and asserts write_private/2 refuses rather than writing through it, which is the half the directory does not cover; against 1f726fc that one returns :ok, follows the link and hands over the file. Both fail against 1f726fc, the second with the disclosure visible. Refs: #13 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent 1f726fc commit ad68d0e

3 files changed

Lines changed: 271 additions & 64 deletions

File tree

AGENTS.md

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,42 @@ Castle's job is configuration and release management on a running node.
9393
closes it. Do not go looking for an atomic create-with-mode; there isn't one.
9494

9595
What works is `Castle.Peer.work_dir/1`: `mkdir` a directory in the version
96-
directory and chmod it 0700 **while it is still empty**. `mkdir` takes no mode
97-
either, so the directory is narrowed after the fact exactly as a file would
98-
be — but an empty directory has nothing behind the window, and permission to
99-
traverse a directory is checked on every lookup rather than captured at open
100-
the way permission to read a file is, so a descriptor taken on it during that
101-
window grants nothing once the chmod has happened. That asymmetry is the whole
102-
reason this works on a directory and could not be made to work on the file.
103-
`plan/2` is the one place the two paths are decided, and it puts both inside
104-
the working directory under the names they will have when they leave.
96+
directory, chmod it 0700, and then **check that it is still empty**, refusing
97+
and removing it if it is not. `plan/2` is the one place the two file paths are
98+
decided, and it puts both inside that directory under the names they will have
99+
when they leave. Each is created **exclusively**, so a name already at the
100+
path is refused rather than followed or truncated.
101+
102+
**The rule is create, narrow, then verify.** The verification is not
103+
decoration and it is not defence in depth: it is there because five successive
104+
attempts to reason about whether a window was harmless were all wrong, and a
105+
sixth judgement of the same kind is worth nothing. Do not remove it on the
106+
grounds that you can see why the window is safe. That is precisely the
107+
sentence that preceded each of the previous five findings.
108+
109+
The particular argument it replaced, so nobody reconstructs it: an empty
110+
directory has nothing behind the window, and permission to traverse a
111+
directory is checked on every lookup rather than captured at open the way
112+
permission to read a file is, so a stale directory descriptor grants nothing
113+
once the chmod has happened. Both halves are true, and both are about
114+
*reading*. A directory the umask left group-writable — 0002 is an ordinary
115+
umask and 0000 exists — can be written *into* during that window, and the child
116+
names are predictable, so an interloper needs no descriptor at all: it plants
117+
`sys.config` as a symlink to a file it can read and waits for the
118+
configuration to arrive through it. Empty is safe to read. It is not safe to
119+
write into.
120+
121+
Exclusivity and privacy are separate properties and neither substitutes for
122+
the other. `:exclusive` on the open says nothing about the permissions the
123+
inode arrives with, which is why it is no answer to the paragraph above — that
124+
was measured, and `{:mode, _}` alongside it is silently ignored. A private
125+
directory says nothing about what a name already inside it would do, which is
126+
why it is no answer to a planted symlink: `File.write/2` follows one, truncates
127+
what it points at, chmods *that* to 0600, fills it with the configuration, and
128+
creates the target outright if the link dangles. All measured, all refused by
129+
`:exclusive`, which returns `:eexist` for a regular file, a symlink and a
130+
dangling symlink alike. `File.mkdir/1` refuses all three too, which is what
131+
stops the working directory's own name being taken first.
105132

106133
It does not defend a version directory other accounts can write to: whoever
107134
can create a name there can replace `sys.config` itself, so that release is
@@ -112,11 +139,11 @@ Castle's job is configuration and release management on a running node.
112139
anything to group or other, rather than trusting the caller to have picked a
113140
path inside the working directory — the invariant is in the primitive because
114141
remembering it at the call sites is what failed, four times over. It is a
115-
guard against the next call site and not against an attacker (a directory can
116-
be chmodded between the check and the create), and it also catches a
117-
filesystem that took the `mkdir` and ignored the `chmod`, where none of this
118-
can be honoured and the operator's own mode on `sys.config` would not be
119-
either.
142+
guard against the next call site rather than against an attacker a directory
143+
can be chmodded between the check and the create, which is the gap the
144+
exclusive create covers — and it also catches a filesystem that took the
145+
`mkdir` and ignored the `chmod`, where none of this can be honoured and the
146+
operator's own mode on `sys.config` would not be either.
120147

121148
Inside the directory the file is still created owner-only at 0600, filled
122149
through that, and given the model's mode **last**`write_like/3` is the two
@@ -352,16 +379,24 @@ commit — and the other once with the environment as it ended up. The two
352379
`sys.config` terms have to be equal. That is why `Castle.SyntheticRelease` makes
353380
its symlinks idempotently: a root has to be able to hold two versions.
354381

355-
`Castle.Peer.work_dir/1`, `write_like/3`, `write_private/2`, `create_private/1`
356-
and `publish/2` are public for the same kind of reason: what they guarantee is
357-
about *intermediate* states, and a window nothing can stand in is a window
358-
nothing can test. One test takes `work_dir/1`, `write_like/3` and `publish/2` one
359-
at a time and looks at the destination in between — where it finds no file, rather
360-
than a partial one — then checks that publishing again is refused rather than
361-
allowed to replace. Another calls `work_dir/1` and finds the directory already at
362-
0700 **and still empty**, which is the pair of facts that makes its own window
363-
harmless. A third calls `write_private/2` with a path in a directory the host can
364-
traverse and gets a refusal.
382+
`Castle.Peer.work_dir/1`, `secure_dir/1`, `write_like/3`, `write_private/2`,
383+
`create_private/1` and `publish/2` are public for the same kind of reason: what
384+
they guarantee is about *intermediate* states, and a window nothing can stand in
385+
is a window nothing can test. One test takes `work_dir/1`, `write_like/3` and
386+
`publish/2` one at a time and looks at the destination in between — where it finds
387+
no file, rather than a partial one — then checks that publishing again is refused
388+
rather than allowed to replace. Another calls `work_dir/1` and finds the
389+
directory at 0700 and still empty.
390+
391+
`secure_dir/1` is public so that the `mkdir`-to-`chmod` window can be stood in:
392+
a test creates a directory at 0777, plants a `sys.config` symlink inside it, and
393+
asserts that securing it is refused, the directory removed and what the symlink
394+
pointed at untouched. That is the only way to observe it — nothing about the end
395+
state distinguishes a directory that was empty when it was narrowed from one that
396+
was not, which is what made the same mistake possible a sixth time at the
397+
directory after five at the file. A companion test plants a name inside an
398+
already-private directory and asserts `write_private/2` refuses it rather than
399+
writing through it, which is the half a private directory does not cover.
365400

366401
Those have to be written that way. The mode a file *ends up* with is the same
367402
whether it was set before or after the content, so a test of the end state

lib/castle/peer.ex

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -599,23 +599,55 @@ defmodule Castle.Peer do
599599
# momentary blink. Narrowing after creation is not a window that can be made
600600
# small enough. It has to be a path no one else can reach.
601601
#
602-
# So `work_dir/1` makes a working directory in the version directory and
603-
# chmods it 0700 *while it is still empty*, and both files are created inside
604-
# it and moved out. `mkdir` takes no mode either, so the directory is created
605-
# against the umask and narrowed just as a file would be - but an empty
606-
# directory exposes nothing to see, and permission to traverse a directory is
607-
# checked on every lookup rather than captured in a descriptor, so a handle
608-
# taken on it during that window grants nothing once the chmod has happened.
609-
# That is the whole difference between doing this to a directory and doing it
610-
# to a file, and it is why the file-level version of this could not be made
611-
# airtight however carefully it was written.
602+
# So `work_dir/1` makes a working directory in the version directory, chmods it
603+
# 0700, and then checks that it is *still empty*. Both files are created inside
604+
# it, exclusively, and moved out.
605+
#
606+
# The check is the part that matters, and it is here because the reasoning it
607+
# replaces was wrong. `mkdir` takes no mode either, so the directory is created
608+
# against the umask and narrowed afterwards exactly as a file would be, and the
609+
# argument for why that was harmless went: an empty directory has nothing in it
610+
# to read, and permission to traverse a directory is checked on every lookup
611+
# rather than captured in a descriptor, so a handle taken on it during the
612+
# window grants nothing once the chmod has happened. Both halves are true. Both
613+
# are about *reading*. A directory the umask left group-writable - 0002 is an
614+
# ordinary umask and 0000 exists - can be written *into* during that window,
615+
# and the names inside it are predictable, so an interloper needs no descriptor
616+
# at all: it plants `sys.config` as a symlink to a file it can read, and waits
617+
# for the configuration to be written through it.
618+
#
619+
# Hence create, narrow, then *verify*: a directory that gained an entry before
620+
# it was narrowed is removed and the install refused. Verifying does not depend
621+
# on the window being harmless, which is the judgement that has now been wrong
622+
# five times running in this module.
623+
#
624+
# Each child is then created exclusively, which refuses rather than follows or
625+
# truncates a name that is already there - a regular file, a symlink, or a
626+
# symlink to nothing, all `:eexist`, and the last of those without creating
627+
# what it pointed at. That is a different property from the mode, and the two
628+
# do not substitute for each other: exclusivity says nothing about the
629+
# permissions an inode arrives with, so it is no answer to the paragraph above,
630+
# and a private directory says nothing about what a name already inside it
631+
# would do, so it is no answer to this one.
612632
#
613633
# What it does not defend is a version directory other accounts can write to:
614634
# whoever can create a name there can replace `sys.config` itself, so such a
615635
# release is compromised before Castle is asked to configure it. The case being
616636
# defended is the ordinary one, a version directory anyone may traverse and
617637
# read.
618638
#
639+
# The other two names this module brings into existence are in the version
640+
# directory, and neither can be captured by a name planted at it. `sys.config`
641+
# is replaced by `File.rename/2`, which replaces a symlink sitting at that name
642+
# with the file rather than writing through it - measured, and the link's target
643+
# is left untouched. The base is published by `File.ln/2`, which refuses
644+
# `:eexist` against a file, a symlink, or a symlink to nothing, and creates
645+
# nothing in the last case. What follows an `:eexist` there is a *read* of
646+
# whatever holds the name, so a planted symlink at `sys.config.pristine` would
647+
# be read through - but planting it needs write permission on the version
648+
# directory, and an account with that could put its own configuration in that
649+
# file directly. It is the case above, not a separate one.
650+
#
619651
# `write_private/2` is the one way to bring one of these files into existence,
620652
# and it refuses to create one in a directory that grants anything to group or
621653
# other rather than trusting its caller to have picked a path inside the
@@ -663,32 +695,63 @@ defmodule Castle.Peer do
663695
# after it was filled looks exactly like one secured before, and a window
664696
# nothing can stand in is a window nothing can test.
665697

666-
# Created rather than ensured: a name that is already there is not adopted,
667-
# whether it is a directory, a file or a symlink to somewhere else - `mkdir`
668-
# refuses all three, and the name carries this process's pid and a number no
669-
# other call in it will use again. Chmodded straight afterwards, while the only
670-
# thing an interloper could reach through it is the fact that it is empty.
671-
#
672-
# A directory that could not be secured is removed again rather than used: it
673-
# is this call's own, made an instant ago and with nothing in it, so removing it
674-
# cannot take anybody else's work with it.
698+
# Created rather than ensured: a name that is already there is not adopted, and
699+
# `File.mkdir/1` is what refuses it - measured against a directory, a regular
700+
# file, a symlink to a directory and a symlink to nothing, all `:eexist`. The
701+
# name carries this process's pid and a number no other call in it will use
702+
# again, so there is nothing to guess in time either way.
675703
@doc false
676704
@spec work_dir(Path.t()) :: {:ok, Path.t()} | {:error, String.t()}
677705
def work_dir(dir) do
678706
path = Path.join(dir, "castle-#{System.pid()}-#{unique()}.work")
679707

680-
with :ok <- mkdir(path) do
681-
case chmod(path, 0o700) do
682-
:ok ->
683-
{:ok, path}
708+
with :ok <- mkdir(path), :ok <- secure_dir(path), do: {:ok, path}
709+
end
684710

685-
{:error, _reason} = error ->
686-
discard(path)
687-
error
688-
end
711+
# Narrowed and then checked, and removed rather than used if either fails. What
712+
# the check catches is an entry that appeared between the `mkdir` and the
713+
# `chmod`, which is possible for exactly as long as the umask left the new
714+
# directory writable by anyone else - and is worth catching whatever put it
715+
# there, since nothing of Castle's is written until afterwards.
716+
#
717+
# Removing it cannot take anyone else's work with it. It is this call's own,
718+
# made an instant ago, and `File.rm_rf/1` unlinks a symlink rather than
719+
# following it, so a planted name goes and what it pointed at stays.
720+
@doc false
721+
@spec secure_dir(Path.t()) :: :ok | {:error, String.t()}
722+
def secure_dir(path) do
723+
case narrowed_and_empty(path) do
724+
:ok ->
725+
:ok
726+
727+
{:error, _reason} = error ->
728+
discard(path)
729+
error
689730
end
690731
end
691732

733+
defp narrowed_and_empty(path) do
734+
with :ok <- chmod(path, 0o700), do: empty(path)
735+
end
736+
737+
defp empty(path) do
738+
case File.ls(path) do
739+
{:ok, []} -> :ok
740+
{:ok, entries} -> {:error, occupied(path, entries)}
741+
{:error, reason} -> {:error, "Cannot list #{path}. #{format_error(reason)}"}
742+
end
743+
end
744+
745+
defp occupied(path, entries) do
746+
"Cannot assemble configuration in #{path}. Castle had just created that " <>
747+
"directory and written nothing to it, and it already holds " <>
748+
"#{Enum.join(entries, ", ")} - so something else can write where this " <>
749+
"release's configuration is about to be, and a name planted there is a name " <>
750+
"the configuration could be written through. Nothing has been written and " <>
751+
"the directory has been removed. Check the umask the release runs under: a " <>
752+
"new directory has to be private to the account doing the install."
753+
end
754+
692755
@doc false
693756
@spec write_like(Path.t(), iodata(), Path.t()) :: :ok | {:error, String.t()}
694757
def write_like(path, bytes, model) do
@@ -704,10 +767,35 @@ defmodule Castle.Peer do
704767
end
705768
end
706769

770+
# Exclusively, so that a name already at this path is refused rather than
771+
# followed or truncated. `File.write/2` here would do neither safely: pointed at
772+
# a symlink it truncates what the link points at, chmods *that* to 0600, and
773+
# fills it with the configuration - and creates the target outright if the link
774+
# dangles. All three measured. The mode still goes on afterwards, because there
775+
# is still no way to ask for it at creation.
707776
@doc false
708777
@spec create_private(Path.t()) :: :ok | {:error, String.t()}
709778
def create_private(path) do
710-
with :ok <- write(path, ""), do: chmod(path, 0o600)
779+
with :ok <- create_exclusive(path), do: chmod(path, 0o600)
780+
end
781+
782+
defp create_exclusive(path) do
783+
case File.open(path, [:write, :exclusive, :raw]) do
784+
{:ok, handle} -> close(path, handle)
785+
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
786+
end
787+
end
788+
789+
# Closed rather than written through, because what is written to these files is
790+
# written more than once and by more than this module - the peer's own pipeline
791+
# writes the scratch too - so no handle could serve all of it. Nothing can reach
792+
# the path between the close and the write: the directory holding it has been
793+
# verified private.
794+
defp close(path, handle) do
795+
case File.close(handle) do
796+
:ok -> :ok
797+
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
798+
end
711799
end
712800

713801
@doc false

0 commit comments

Comments
 (0)