chmod 755でディレクトリのsetgidが消えない理由

chmod 755 は「所有者は読み書き実行、グループとその他は読み取りと実行」という意味で、多くの人が暗記している。ところが、共有ディレクトリに付けた setgid(2000)を外すつもりで chmod 755 dir を打っても、GNU coreutils の chmod では setgid が残る。0755 と4桁で書いても同じで、消えるのは 00755 と5桁で書いたときである。

この記事では ハシトシステムの chmod 権限計算 のコードを入口に、4桁の数値表記と rwx 表記の対応、ls -l に出る大文字の S と T、ディレクトリの setuid・setgid が数値指定で消えない仕様を、Ubuntu 上で実際に確かめる。

4桁表記の先頭は特殊ビット

パーミッションは12ビットで、下位9ビットが所有者・グループ・その他の rwx(各3ビット)、その上の3ビットが特殊ビットである。8進数で書くと4桁になり、先頭の桁が特殊ビットを表す。

値 名前 rwx 表記で置き換わる位置
4 setuid 所有者の x の位置
2 setgid グループの x の位置
1 sticky その他の x の位置

755 のような3桁は先頭の特殊ビットを省略した形で、ツールも3桁の入力は先頭に 0 を補って4桁として扱っている(index.html から抜粋)。

1
2
3
4
5
6
function parseNumeric(s){
s=String(s==null?"":s).trim();
if(!/^[0-7]{3,4}$/.test(s)) return null;
if(s.length===3) s="0"+s;
return { special:Number(s[0]), user:Number(s[1]), group:Number(s[2]), other:Number(s[3]) };
}

大文字の S と T は「実行ビットがない」印

特殊ビットは rwx 表記に専用の文字を持たず、各組の3文字目(x の位置)を置き換えて表す。ツールの変換関数は次のようになっている。

1
2
var third = b.x ? "x" : "-";
if(sp){ third = i===2 ? (b.x?"t":"T") : (b.x?"s":"S"); }

GNU coreutils のマニュアル(ls の「What information is listed」)の説明もこの対応と一致する。

  • s: setuid または setgid が立っていて、対応する実行ビットも立っている
  • S: setuid または setgid が立っているが、対応する実行ビットは立っていない
  • t: sticky が立っていて、その他の実行ビットも立っている
  • T: sticky が立っているが、その他の実行ビットは立っていない

たとえば 4644 は rwSr--r-- になる。所有者に実行権がないので setuid は働く場面がなく、大文字の S は「設定の取り違えかもしれない」というサインとして読める。1754 の rwxr-xr-T も同様で、sticky はディレクトリで意味を持つビットなので、ファイルに付いた T はまず意図を確認したほうがよい。

ディレクトリの setuid・setgid は数値指定で消えない

GNU coreutils のマニュアル「Directories and the Set-User-ID and Set-Group-ID Bits」は、chmod は(モードで明示しない限り)ディレクトリの setuid・setgid ビットに影響しない、と説明している。これは GNU の拡張で、POSIX はディレクトリのこれらのビットの設定・解除要求を無視する実装も認めている、とも書かれている。

同じページによると、ディレクトリの setuid・setgid を消せるのは次の書き方である。

  • 5桁以上の8進数(例: chmod 00755 D)
  • = 付きの数値(例: chmod =755 D)
  • 記号でビットを名指しする(例: chmod a-s D、chmod -6000 D)

Linux の man ページ chmod(1)(GNU coreutils 9.11 版)にも、ディレクトリについては明示しない限り setuid・setgid を保持する、と書かれている。一方で通常のファイルにはこの保持は働かず、4755 のファイルに chmod 755 を打つと setuid は外れる(下の実験で確認した)。

つまり「chmod 755 は 0755 にする」と覚えていると、ディレクトリに限って結果が 2755 のまま残る。setgid ディレクトリは中に作ったファイルが親のグループを継ぐ設定なので、外したつもりで外れていないと、グループの継承が続くことになる。

ツールが出力するコマンドは、特殊ビットが0のとき先頭の 0 を落として chmod 755 file の形にする。ファイルにはこれで正しく効くが、setgid 付きのディレクトリを 755 に戻す目的なら、chmod 00755 dir か chmod g-s dir を使う必要がある。

Node.js の fs.chmodSync は数値をそのまま渡す

この「保持」は chmod コマンド側の振る舞いで、プログラムからモードを直接設定する場合は違う結果になる。下の実験では、Node.js の fs.chmodSync(dir, 0o755) は setgid 付きディレクトリを 0755 にした。同じディレクトリに chmod 755 コマンドを打つと 2755 のまま残る。シェルスクリプトと Node.js のデプロイスクリプトで同じ「755」を指定しても、ディレクトリでは結果が食い違う。

実際に試す

Ubuntu 24.04.4 LTS(Linux 6.8)、GNU coreutils 9.4、Node.js v18.19.1 で実行した。依存パッケージは要らない。chmod の特殊ビットは Linux のファイルシステム上で試す必要があり、Windows の NTFS では同じ結果にならない。次の内容を chmod-bits.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// chmod-bits.mjs : 4桁の数値表記と rwx 表記の変換、ディレクトリの setgid が chmod 755 で残るかを確かめる
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';

// 1. 4桁の数値 → rwx(特殊ビットは3文字目を s/S/t/T に置き換える)
function toSymbolic(mode) {
const sp = (mode >> 9) & 7;
let out = '';
for (let i = 0; i < 3; i++) {
const d = (mode >> (6 - 3 * i)) & 7;
const x = (d & 1) !== 0;
const special = sp & (4 >> i); // i=0:setuid(4) i=1:setgid(2) i=2:sticky(1)
let third = x ? 'x' : '-';
if (special) third = i === 2 ? (x ? 't' : 'T') : (x ? 's' : 'S');
out += (d & 4 ? 'r' : '-') + (d & 2 ? 'w' : '-') + third;
}
return out;
}
for (const m of [0o755, 0o4755, 0o4644, 0o2775, 0o1777, 0o1754]) {
console.log(m.toString(8).padStart(4, '0'), toSymbolic(m));
}

// 2. 実ファイルで確かめる(Linux)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chmod-'));
const sub = path.join(dir, 'shared');
fs.mkdirSync(sub);
const show = (label) => {
const m = fs.statSync(sub).mode & 0o7777;
console.log(label.padEnd(22), m.toString(8).padStart(4, '0'), toSymbolic(m));
};
fs.chmodSync(sub, 0o2775); show('fs.chmodSync 2775');
execFileSync('chmod', ['755', sub]); show('chmod 755');
execFileSync('chmod', ['0755', sub]); show('chmod 0755');
execFileSync('chmod', ['00755', sub]); show('chmod 00755');
fs.chmodSync(sub, 0o2775);
fs.chmodSync(sub, 0o755); show('fs.chmodSync 755');
fs.rmSync(dir, { recursive: true });
console.log('Node', process.version, '/', execFileSync('chmod', ['--version']).toString().split('\n')[0]);
1
node chmod-bits.mjs

出力:

1
2
3
4
5
6
7
8
9
10
11
12
0755 rwxr-xr-x
4755 rwsr-xr-x
4644 rwSr--r--
2775 rwxrwsr-x
1777 rwxrwxrwt
1754 rwxr-xr-T
fs.chmodSync 2775 2775 rwxrwsr-x
chmod 755 2755 rwxr-sr-x
chmod 0755 2755 rwxr-sr-x
chmod 00755 0755 rwxr-xr-x
fs.chmodSync 755 0755 rwxr-xr-x
Node v18.19.1 / chmod (GNU coreutils) 9.4

読み方:

  • 1〜6行目: 特殊ビットは x の位置に出る。実行ビットがあれば小文字(s t)、なければ大文字(S T)。1777 は /tmp と同じ設定である。
  • 8〜9行目: setgid 付きディレクトリに chmod 755 と chmod 0755 を打っても、グループ側の権限だけが変わり(775 → 755)、setgid は残る。
  • 10行目: 5桁の 00755 で初めて setgid が消える。
  • 11行目: Node.js の fs.chmodSync は指定した数値どおり 0755 になる。

ファイルとディレクトリで振る舞いが違うことは、シェルだけでも確かめられる(同じ環境で確認)。

1
2
3
4
5
6
d=$(mktemp -d) && cd "$d"
touch f && mkdir dir
chmod 4755 f && chmod 755 f && stat -c '%a %A %n' f
chmod 2755 dir && chmod 755 dir && stat -c '%a %A %n' dir
chmod g-s dir && stat -c '%a %A %n' dir
cd / && rm -rf "$d"
1
2
3
755 -rwxr-xr-x f
2755 drwxr-sr-x dir
755 drwxr-xr-x dir

stat -c '%a' は先頭の特殊ビットが0のとき3桁で表示する。ファイルは chmod 755 で setuid が外れ、ディレクトリは g-s を指定するまで setgid が残る。

まとめ

  • パーミッションの4桁表記の先頭は特殊ビット(setuid 4・setgid 2・sticky 1)で、rwx 表記では各組の x の位置に s / t として出る。
  • 大文字の S / T は、特殊ビットは立っているが対応する実行ビットがない状態を表す。
  • GNU chmod は、ディレクトリの setuid・setgid を 755 や 0755 では外さない。外すには 00755、=755、g-s のように明示する。通常のファイルは 755 で外れる。
  • Node.js の fs.chmodSync は指定した数値どおりに設定するので、chmod コマンドとはディレクトリで結果が食い違う。

cron の書式の落とし穴は「cronの日と曜日はOR照合」で、ビット演算でのサブネット計算は「JSのビット演算でサブネット計算」で扱った。

ツール: chmod 権限計算(ハシトシステム)。755 のような数値表記と rwxr-xr-x のような記号表記を相互に変換し、setuid・setgid・sticky を含む4桁表記と chmod コマンドを表示する。処理はブラウザ内で完結する。