chmod -R and the setgid Bit: Why a Four-Digit Mode Is Not Enough
A recursive chmod that looks correct can silently strip the bit that made group ownership propagate.
The problem
Two containers write into the same bind-mounted directory as different UIDs, sharing a GID so that either one can read what the other wrote:
sudo chown -R :shared_data /srv/data
sudo chmod -R 2775 /srv/data
2775 sets read/write/execute for owner and group, read/execute for others, and the
leading 2 sets setgid on every directory it touches — meaning new files and
subdirectories created inside inherit the directory’s group rather than the creating
process’s primary group. This is exactly the mechanism that makes two containers with
different UIDs but the same GID able to share a directory at all: without it, a file a
second container writes gets that container’s own primary group, and the first container
can no longer read it.
It works. Both containers write, both containers read what the other wrote, the
permissions look right in ls -l. Then, weeks later, a maintenance script runs:
sudo chmod -R 775 /srv/data
Nothing in this command looks dangerous. It is fixing an unrelated permission complaint —
maybe a file ended up 770 and something needed to read it — and 775 is a subset of
2775 in every digit that is actually shown. But chmod -R 775 is a four-character mode
with an implicit leading 0. It does not merely fail to set setgid; it actively clears
it, on every directory the recursion touches, because a chmod’s special-bits digit is
absolute, not additive. The directory tree still shows 775 in ls -l, still looks
correct, and from that point on every new file created inside inherits the writer’s own
group instead of the directory’s — which is invisible until the other container tries to
read a file the first one just wrote and gets Permission denied.
The bug is hard to notice specifically because the visible part of the mode — the three
familiar rwx triplets — never changed. Nothing in a superficial ls -l /srv/data comparison
before and after tells you setgid is gone; you have to know to check for the s in the
group execute position, and most permission audits do not.
Working through it
What each chmod digit actually means
A four-digit octal chmod is two things concatenated: a special-bits digit, then the usual
owner/group/other triplet. The special digit is a bitmask of its own: 4 is setuid, 2
is setgid, 1 is sticky, and they combine by addition — 6 is setuid plus setgid, 2775
is setgid plus the familiar 775.
The part that catches people is that a three-digit mode is not “the special bits,
unspecified” — chmod 775 file is exactly equivalent to chmod 0775 file. The 0 is
implicit, and it is absolute: it does not mean “leave special bits as they are,” it means
“set them to zero.” There is no chmod numeric mode that means “whatever the special bits
currently are, keep them” — that is only expressible with the symbolic form, chmod
g+s, which is additive by design.
Setgid on a directory versus setgid on a file
Setgid means something different depending on what it is set on, and this is where a
blanket chmod -R 2775 earns a second look rather than blind trust. On a directory, it
does exactly what this article needs: new entries created inside inherit the directory’s
group. On a regular file, setgid means something else entirely — historically, mandatory
file locking on some Unix variants, and on Linux specifically, if the file is also group
executable, the kernel runs it with the privileges of the file’s group rather than the
invoking user’s, which is a real, if usually inert, privilege escalation vector for any
file that later becomes executable by accident.
Recursing chmod -R 2775 sets setgid on every file it touches too, not only directories.
For non-executable data files this bit is inert — nothing consults it — but it is not
nothing: a later chmod +x on one of those files, done for an unrelated reason, now
produces a group-executable file with setgid already set, and that combination is worth
never creating by accident. The correct recursive fix separates directories from files
explicitly:
sudo find /srv/data -type d -exec chmod 2775 {} +
sudo find /srv/data -type f -exec chmod 664 {} +
Directories get 2775 — setgid, so the inheritance mechanism keeps working. Files get
664 — no execute bit at all for a pure data directory, which makes the setgid-on-an-
executable-file scenario impossible by construction, because nothing in the tree is
executable.
Why ls -l alone is not a sufficient check
ls -ld /srv/data shows drwxrwsr-x when setgid is present — the s replacing the x in
the group triplet is the only visible sign, and it is easy to skim past when scanning a
long ls -l for the parts of the mode you were actually thinking about. The reliable
check is stat, which reports the mode numerically and is what an automated test should
assert against rather than parsing ls output:
stat -c '%a %n' /srv/data
# 2775 /srv/data
If a later maintenance action drops that to 775, this is the line that changes, and it
is worth asserting on directly rather than trusting a human glance at directory listings
to catch it.
The solution
A small script that fixes permissions correctly — setting setgid on directories only, leaving files without an execute bit — plus a bats test that proves both the fix and the regression it guards against.
#!/usr/bin/env bash
# fix-shared-permissions.sh
set -euo pipefail
target_dir="${1:?usage: fix-shared-permissions.sh <dir> <group>}"
group="${2:?usage: fix-shared-permissions.sh <dir> <group>}"
chown -R ":${group}" "${target_dir}"
find "${target_dir}" -type d -exec chmod 2775 {} +
find "${target_dir}" -type f -exec chmod 664 {} +
#!/usr/bin/env bats
# test/fix-shared-permissions.bats
setup() {
export TEST_DIR
TEST_DIR="$(mktemp -d)"
mkdir -p "${TEST_DIR}/nested"
touch "${TEST_DIR}/file.txt" "${TEST_DIR}/nested/other.txt"
}
teardown() {
rm -rf "${TEST_DIR}"
}
@test "directories get setgid after the fix" {
bash fix-shared-permissions.sh "${TEST_DIR}" "$(id -gn)"
mode="$(stat -c '%a' "${TEST_DIR}")"
[ "${mode}" = "2775" ]
}
@test "nested directories also get setgid" {
bash fix-shared-permissions.sh "${TEST_DIR}" "$(id -gn)"
mode="$(stat -c '%a' "${TEST_DIR}/nested")"
[ "${mode}" = "2775" ]
}
@test "files do not carry setgid or an execute bit" {
bash fix-shared-permissions.sh "${TEST_DIR}" "$(id -gn)"
mode="$(stat -c '%a' "${TEST_DIR}/file.txt")"
[ "${mode}" = "664" ]
}
@test "a subsequent chmod -R 775 would strip setgid -- this is the regression to avoid" {
bash fix-shared-permissions.sh "${TEST_DIR}" "$(id -gn)"
chmod -R 775 "${TEST_DIR}"
mode="$(stat -c '%a' "${TEST_DIR}")"
# This assertion documents the failure mode: 775, not 2775.
[ "${mode}" = "775" ]
}
The last test does not assert desired behaviour — it pins down the exact regression, so
that anyone reading the test suite sees, in executable form, precisely what chmod -R 775
does to a tree the earlier tests just fixed. It is a guard against re-introducing the bug
via a “harmless” maintenance chmod, documented as a failing expectation on purpose.
Verifying it
chmod +x fix-shared-permissions.sh
bats test/fix-shared-permissions.bats
Correct output is all four tests passing, including the one documenting the regression:
✓ directories get setgid after the fix
✓ nested directories also get setgid
✓ files do not carry setgid or an execute bit
✓ a subsequent chmod -R 775 would strip setgid -- this is the regression to avoid
4 tests, 0 failures
Conclusion
A numeric chmod mode is absolute, not a diff. Every digit you do not intend to change still has to be specified as the value you want it to end up at — there is no numeric “leave alone,” and a shorter mode string does not mean “the rest is untouched,” it means the rest becomes zero.
Special bits and the ordinary permission triplet are orthogonal, and blanket recursion conflates them. Directories and files play different roles under setgid, and a fix that treats a whole tree with one chmod mode is choosing a bit’s meaning for two different kinds of entities at once, whether or not that was the intent.
ls -l is for humans skimming, not for asserting. A permission fix that matters is
worth a stat-based check in a test file, checked into the same place as the fix, because
the failure mode here is specifically that the visible parts of the output stay
reassuring while the part that mattered silently reverts.