user:65534:rwx #effective:r-x is not contradictory output. The named user’s ACL entry stores rwx, but the access ACL’s mask::r-x caps that entry. For this user, the kernel applies a permission intersection: rwx AND r-x = r-x. Reading and execution can succeed while writing is denied.
That distinction matters because an extended POSIX ACL has two states worth inspecting: the permissions stored on an entry and the permissions effective after the mask. It also changes how the ordinary group mode bits should be read. Once an extended ACL exists, the group-class bits reported by stat represent the ACL mask, not simply the owning group’s entry.
This guide proves those claims on one marker-owned file with a numeric identity that has no supplementary groups or capabilities. It then deliberately runs chmod g+w, observes the mask open to rwx, proves that the same identity can write, and restores both file content and ACL metadata to exact baseline hashes. Use a disposable host or test path; do not experiment on an application file whose access policy you have not backed up.
According to the authoritative acl(5) access-check algorithm, the kernel handles the file owner first. If the process is not the owner, it looks for a matching named-user entry. A matching named-user entry is allowed only when that entry and the mask entry contain the requested permission. Matching group entries are also restricted by the mask. The owner and other entries are not.
That gives the narrow rule behind this article:
named user effective permissions = named user entry ∩ mask
The mask is a ceiling, not an additional grant. user:65534:r-- paired with mask::rwx remains r--; the mask cannot invent write or execute access. Conversely, user:65534:rwx paired with mask::r-x becomes r-x. ArchWiki’s ACL permission calculation describes this as a bitwise AND for effective permissions and explains the separate OR-based calculation tools may use when rebuilding a mask.
getfacl normally prints an #effective: comment only when effective rights differ from stored rights. Its documented -e option forces those comments, which is useful during review. The getfacl manual is the reference for that display behavior. In the experiment below, -n also keeps identities numeric so a changing name-service lookup cannot disguise which UID was tested.
This mechanism is one layer of a denial investigation. If the ACL result is already sufficient but the operation still fails, move to the broader Linux permission-denied workflow beyond chmod 777 rather than repeatedly widening this file.
For isolation, the lab uses the existing nobody identity only as an independent numeric probe. It does not create a user, edit groups or change a service account. setpriv clears supplementary groups, inheritable capabilities and privilege gain so the observed result belongs to the file path and UID/GID being tested. Confirm that the host has getfacl and setfacl from the acl package plus setpriv from util-linux before starting.
Begin by refusing a pre-existing path. The first input creates one executable script, copies its original content, and captures a recursive numeric ACL baseline before any named entry exists. The marker is the cleanup boundary.
set -euo pipefail
lab=/tmp/voxfor-acl-mask-lab
shared="$lab/shared"
file="$shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
if [[ -e "$lab" ]]; then
printf 'refuse=preexisting_path path=%s\n' "$lab" >&2
exit 20
fi
install -d -m 0711 "$lab"
printf 'voxfor-acl-mask-lab-v1\n' > "$marker"
install -d -m 0750 "$shared"
printf '#!/bin/sh\nprintf "release-executed\\n"\n' > "$file"
chmod 0640 "$file"
cp --preserve=all "$file" "$lab/release.sh.content-backup"
getfacl -R -p -n "$shared" > "$lab/before.acl"
printf 'environment=debian-%s kernel=%s filesystem=%s acl=%s probe_uid=%s\n' \
"$(. /etc/os-release; printf '%s' "$VERSION_ID")" \
"$(uname -r)" \
"$(findmnt -n -o FSTYPE -T "$file")" \
"$(getfacl --version | awk 'NR==1{print $2}')" \
"$(id -u nobody)"
Our reproduced environment was Debian 13, Linux 6.12.96+deb13-amd64, tmpfs, acl 2.3.2, and probe UID 65534. The filesystem type is recorded because ACL support and mount policy are part of the environment; matching our filesystem is not a pass requirement.
Now add a named-user entry that stores rwx, while deliberately keeping the mask at r-x. setfacl -n suppresses automatic mask recalculation. The setfacl manual documents both -n and the normal recalculation behavior. Use -n only when the mask is an intentional part of the change, as it is here.
lab=/tmp/voxfor-acl-mask-lab
shared="$lab/shared"
file="$shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
[[ "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
probe_uid=$(id -u nobody)
setfacl -n -m "u:${probe_uid}:rwx,m::r-x" "$shared" "$file"
printf 'stored_named_entry=rwx configured_mask=r-x\n'
getfacl -c -e -n "$file"
UID 65534 receives the same entry on the directory so it can traverse that path. The file remains the subject of the read, write and execute decision. Do not omit parent-directory traversal and then attribute an EACCES result to the file’s mask.
With only the three traditional owner/group/other entries, mode group bits correspond to the owning group’s permissions. An extended ACL changes that interpretation. The SUSE ACL guide explains that the mode group bits represent the entire group class, and the ACL mask is the group’s maximum effective rights.
Next, read the file mode, the mask and the forced effective comment, then fail unless all three agree with the planned state. Expected mode 650 means owner rw-, group class r-x, and other ---. It does not mean the owning group entry itself became r-x; our owning group entry remains r-- and is also capped by the same mask.
lab=/tmp/voxfor-acl-mask-lab
file="$lab/shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
[[ "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
mode=$(stat -c '%A %a' "$file")
mask=$(getfacl -c -n "$file" | awk -F: '$1=="mask"{print $3}')
effective=$(getfacl -c -e -n "$file" | awk -F'[:#]' -v uid="$(id -u nobody)" '$1=="user"&&$2==uid{gsub(/[[:space:]]/,"",$5);print $5}')
printf 'mode=%s group_class_equals_mask=%s named_user_effective=%s\n' "$mode" "$mask" "$effective"
[[ "$mode" == '-rw-r-x--- 650' && "$mask" == 'r-x' && "$effective" == 'r-x' ]]
This is why ls -l can be misleading during an ACL incident. It summarizes the group class but does not show which named user or group supplied stored permissions. Keep stat and numeric getfacl -e output together when reviewing a change.
For an SSH-sensitive file, permission math is only part of acceptance: OpenSSH also evaluates ownership and its path-specific policy. Use the StrictModes ownership and permission checks before treating a matching mode string as proof that authorized_keys will be accepted.
Output is useful, but an actual operation is stronger. The independent identity should be able to read the first line and execute the script because r-x contains both rights. Its append must fail because the mask removes w even though the stored named-user entry includes it.
Read, write and execute stay in one evidence unit because they test one ACL state. Splitting them into three tiny artifacts would inflate the evidence count without improving the reasoning.
lab=/tmp/voxfor-acl-mask-lab
file="$lab/shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
[[ "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
probe_uid=$(id -u nobody)
probe_gid=$(id -g nobody)
runner=(setpriv --reuid="$probe_uid" --regid="$probe_gid" --clear-groups --inh-caps=-all --no-new-privs)
read_value=$("${runner[@]}" head -n 1 "$file")
execute_value=$("${runner[@]}" "$file")
set +e
"${runner[@]}" sh -c 'printf "unexpected-write\n" >> "$1"' sh "$file" 2> "$lab/write-denied.txt"
write_status=$?
set -e
printf 'read=%s execute=%s write_status=%s write_error=%s\n' \
"$read_value" "$execute_value" "$write_status" "$(tr '\n' ' ' < "$lab/write-denied.txt" | sed 's/[[:space:]]\+/ /g;s/ $//')"
[[ "$read_value" == '#!/bin/sh' && "$execute_value" == 'release-executed' && "$write_status" -ne 0 ]]
Here is the representative output from the continuous reproduced run:
environment=debian-13 kernel=6.12.96+deb13-amd64 filesystem=tmpfs acl=2.3.2 probe_uid=65534
stored_named_entry=rwx configured_mask=r-x
user::rw-
user:65534:rwx #effective:r-x
group::r-- #effective:r--
mask::r-x
other::---
mode=-rw-r-x--- 650 group_class_equals_mask=r-x named_user_effective=r-x
read=#!/bin/sh execute=release-executed write_status=2 write_error=sh: 1: cannot create /tmp/voxfor-acl-mask-lab/shared/release.sh: Permission denied
Accept this phase only if the numeric named entry is stored as rwx, its effective comment is r-x, mode group class and mask are both r-x, read returns the shebang, execution returns release-executed, and write returns nonzero with a denial. A successful write is a failed negative control; stop and inspect UID, parent paths, file ACL, capabilities and mount behavior.
Command authority and file access are separate decisions. A sudoers rule with an exact command scope may delegate a repair command, but it does not rewrite the kernel’s ACL calculation for the unprivileged application.
Operational surprise comes from chmod. On a file with an extended ACL, chmod g+w addresses the POSIX group-class mode bit. That group class maps to the ACL mask. It can therefore change the effective access of named users and named groups even though their stored entries do not change.
One fail-closed input deliberately opens the group class, rereads both fields, and retries the same write as UID/GID 65534. It fails unless the mask becomes rwx, the stored named-user entry remains rwx, and the append succeeds.
lab=/tmp/voxfor-acl-mask-lab
file="$lab/shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
[[ "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
probe_uid=$(id -u nobody)
probe_gid=$(id -g nobody)
chmod g+w "$file"
mask_after_chmod=$(getfacl -c -n "$file" | awk -F: '$1=="mask"{print $3}')
stored_after_chmod=$(getfacl -c -n "$file" | awk -F: -v uid="$probe_uid" '$1=="user"&&$2==uid{print $3}')
setpriv --reuid="$probe_uid" --regid="$probe_gid" --clear-groups --inh-caps=-all --no-new-privs \
sh -c 'printf "chmod-opened-write\n" >> "$1"' sh "$file"
printf 'chmod=g+w mask=%s stored_named_entry=%s write_after_chmod=allowed\n' "$mask_after_chmod" "$stored_after_chmod"
[[ "$mask_after_chmod" == 'rwx' && "$stored_after_chmod" == 'rwx' ]]
Reproduction returned chmod=g+w mask=rwx stored_named_entry=rwx write_after_chmod=allowed. The named entry did not gain a permission; it already stored rwx. The mask stopped suppressing w.
A generic chmod repair can therefore widen access silently for several ACL principals. Before changing group bits on a file with a + marker in ls -l, capture getfacl -p -n, identify every named user and group in the masked class, and review their effective permissions after the change. The current practical setfacl ACL guide from GetPageSpeed covers backup and restore patterns; the experiment below adds exact ACL and content equality gates.
Because the permitted append intentionally changed content, ACL-only rollback is incomplete. Restore the content copy first, then apply the recursive ACL baseline with setfacl --restore. The before and after ACL dumps use absolute paths and numeric IDs so the hashes can be compared exactly on the same fixture.
lab=/tmp/voxfor-acl-mask-lab
shared="$lab/shared"
file="$shared/release.sh"
marker="$lab/.voxfor-acl-mask-lab"
[[ "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
cp --preserve=all "$lab/release.sh.content-backup" "$file"
setfacl --restore="$lab/before.acl"
getfacl -R -p -n "$shared" > "$lab/after.acl"
before_acl=$(sha256sum "$lab/before.acl" | awk '{print $1}')
after_acl=$(sha256sum "$lab/after.acl" | awk '{print $1}')
before_content=$(sha256sum "$lab/release.sh.content-backup" | awk '{print $1}')
after_content=$(sha256sum "$file" | awk '{print $1}')
named_entries=$(getfacl -c -n "$file" | awk -F: '$1=="user"&&$2!=""{n++}END{print n+0}')
printf 'acl_before=%s acl_after=%s content_before=%s content_after=%s named_entries=%s verification=restored_exactly\n' \
"$before_acl" "$after_acl" "$before_content" "$after_content" "$named_entries"
[[ "$before_acl" == "$after_acl" && "$before_content" == "$after_content" && "$named_entries" -eq 0 ]]
Our ACL hashes both equaled a959898af8a134e52497d4608a331c2da2340d8a54149de5f70f9b749b6dc3a9; content hashes both equaled c12d7bca6b07d49fa5b2e535a8517cac9c44feb148f36655cf90fc91ec41514f; named_entries=0; and verification returned restored_exactly. Your hashes can differ, but each before/after pair must match.
The run passes only when the initial named-user entry stores rwx but is effective r-x; the independent identity reads and executes but cannot write; chmod g+w changes the mask to rwx without changing the stored named entry and admits the write; restore produces identical ACL and content hashes with zero named-user entries; and cleanup proves the marker-owned path is absent. Any missing predicate is a failure, not a partial demonstration.
For production change control, preserve ACL metadata in the tool that audits the target. An AIDE baseline can explicitly track ACL changes so a mode-preserving ACL edit is not invisible merely because file content stayed constant.
A correct access ACL does not guarantee an operation will succeed. The process still needs execute/search permission on every parent directory. A read-only mount can reject a write. SELinux or AppArmor can deny an operation after discretionary access control permits it. Immutable attributes, container ID mapping, NFS ACL translation and an application running as an unexpected UID can change the result.
Diagnose in that order rather than expanding the mask as a universal fix. Record the actual process identity, resolve every parent component, inspect the access ACL on the target, confirm mount flags and filesystem support, then review the active Linux Security Module and application-specific policy. Default ACLs also deserve separate treatment: they define inherited entries for new children, while the access ACL controls the current object. This lab changes access ACLs only.
When the exact restore gates pass, remove only the fixture whose marker you own:
lab=/tmp/voxfor-acl-mask-lab
marker="$lab/.voxfor-acl-mask-lab"
[[ "$lab" == /tmp/voxfor-acl-mask-lab && "$(cat "$marker")" == voxfor-acl-mask-lab-v1 ]]
find "$lab" -xdev -depth -delete
if [[ -e "$lab" ]]; then
printf 'cleanup=failed path=%s\n' "$lab" >&2
exit 21
fi
printf 'cleanup=marker_owned_fixture_absent\n'
If the run stops after the ACL change, first require /tmp/voxfor-acl-mask-lab/.voxfor-acl-mask-lab to contain exactly voxfor-acl-mask-lab-v1. Restore release.sh.content-backup, apply setfacl --restore=/tmp/voxfor-acl-mask-lab/before.acl, and require both ACL and content hashes to match before deleting the marker-owned fixture. Never recursively delete a substituted or pre-existing path. No user, group, mount or system policy was changed by this lab, so rollback must not invent those operations.
For other bounded filesystem and access experiments, the Linux Guides archive is the relevant next index. Keep the current conclusion narrow: this experiment explains the mask-controlled group class, not every possible Linux denial.
The rwx value is the stored named-user or named-group entry. The #effective:r-x comment is that entry intersected with mask::r-x. Because the mask lacks w, write is removed from the effective result while read and execute remain.
No. The kernel checks a matching file-owner entry before it enters the masked group-class branch. The mask limits named-user, owning-group and named-group entries. The other entry is also evaluated outside the mask.
Not on a file with an extended ACL. The group bits represent the ACL mask and therefore the maximum rights available to the entire group class. Use numeric getfacl output to see the owning-group and named-principal entries individually.
chmod g+w changes the group-class write bit. With an extended ACL, that class maps to the mask. Opening the mask can make an already-stored w bit effective for named users and groups even though their entries remain unchanged.
No. -n suppresses normal mask recalculation, so it is appropriate only when you intentionally calculate and own the mask value. For routine entry changes, automatic recalculation is often safer; always inspect the resulting mask and effective comments.
Yes. Parent-directory traversal, a read-only mount, SELinux, AppArmor, immutable attributes, network-filesystem translation, container identity mapping or the wrong process UID can still deny the operation. Prove the ACL calculation first, then move outward without widening unrelated permissions. The reusable rule is small: read the named entry, read the mask, intersect them, and test as the real identity. Change the mask only when every principal in the group class has been reviewed, because one group-bit edit can alter several effective permissions at once.