OS Lab 6 - Users. Permissions
In the last lab, we gave one process several threads. In this lab, we will look at how the kernel decides what a task is allowed to do. It compares the numbers of the task, its credentials, with the owner, group and mode of the file. We will read these numbers, turn them into names, and change the permissions of our own files. As an application, we will write our own ls -l, and a tool which finds and repairs dangerous permissions.
sudo. Some of the commands below fail with a permission error on purpose.Learning objectives
By the end of this lab you should be able to:
- read the user and group IDs of a task, and turn them into names;
- read the owner, group and mode of a file with
statx(2), and change the mode withchmod(2); - say which class of permission bits applies when a task uses a file;
- explain what read, write and execute mean for files and for directories;
- recognize the set-user-ID, set-group-ID and sticky bits.
The credentials of a task
Every task has a set of numbers, which the kernel uses for every permission check. First, it has three user IDs:
| ID | Meaning |
|---|---|
| Real UID | Who started the task |
| Effective UID | Whose rights the task uses |
| Saved set-user-ID | One more value the task can go back to |
It also has a real GID, an effective GID and a saved set-group-ID, which work in the same way, and a list of supplementary groups. All of them are copied to the child by fork(2), and kept across exec, so everything started from a shell normally has the same credentials as the shell.
getresuid(2) and getresgid(2)
// In: <unistd.h>
int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid);
int getresgid(gid_t *rgid, gid_t *egid, gid_t *sgid);
uid_t getuid(void); // The real UID
uid_t geteuid(void); // The effective UID
gid_t getgid(void); // The real GID
gid_t getegid(void); // The effective GID
getresuid(2) and getresgid(2) store the three IDs at the given addresses. The four shorter functions return a single ID, and cannot fail.
getgroups(2)
// In: <unistd.h>
int getgroups(int size, gid_t list[]);
| Parameter | Description |
|---|---|
size |
How many elements list can hold
|
list |
Where to store the supplementary groups |
It returns the number of groups, or -1 on error. If size is 0, list is not touched, and the call only returns the number of groups. This lets us first ask how many groups there are, and then declare an array of exactly that size: a variable-length array (VLA), whose size is only known at run time.
The following program prints all the credentials of the task running it:
1#include <errno.h>
2#include <stdio.h>
3#include <stdlib.h>
4#include <string.h>
5#include <unistd.h>
6
7static void print_error(const char *what)
8{
9 char msg[256];
10 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
11 write(STDERR_FILENO, msg, strlen(msg));
12}
13
14int main(void)
15{
16 uid_t ruid, euid, suid;
17 gid_t rgid, egid, sgid;
18
19 getresuid(&ruid, &euid, &suid);
20 getresgid(&rgid, &egid, &sgid);
21
22 int n = getgroups(0, nullptr);
23 if (n == -1) {
24 print_error("getgroups");
25 return EXIT_FAILURE;
26 }
27 gid_t groups[n + 1]; // + 1: a VLA cannot be empty
28 n = getgroups(n, groups);
29 if (n == -1) {
30 print_error("getgroups");
31 return EXIT_FAILURE;
32 }
33
34 char msg[256];
35 snprintf(msg, sizeof msg, "UID: real %u, effective %u, saved %u\n", ruid, euid, suid);
36 write(STDOUT_FILENO, msg, strlen(msg));
37 snprintf(msg, sizeof msg, "GID: real %u, effective %u, saved %u\n", rgid, egid, sgid);
38 write(STDOUT_FILENO, msg, strlen(msg));
39
40 write(STDOUT_FILENO, "Groups:", 7);
41 for (int i = 0; i < n; i++) {
42 snprintf(msg, sizeof msg, " %u", groups[i]);
43 write(STDOUT_FILENO, msg, strlen(msg));
44 }
45 write(STDOUT_FILENO, "\n", 1);
46 return EXIT_SUCCESS;
47}
$ ./build/debug/app
UID: real 1000, effective 1000, saved 1000
GID: real 1000, effective 1000, saved 1000
Groups: 1000
Your numbers may be different. The three IDs are the same for almost every task, and only differ while special programs run, as we will see below.
From numbers to names
The kernel does not know what users are called. Names are looked up in user space, by the C library:
// In: <pwd.h>
struct passwd *getpwuid(uid_t uid);
// In: <grp.h>
struct group *getgrgid(gid_t gid);
They return a structure describing the user or group, or nullptr if nothing is known about that number. For example, a file can still belong to a user who has been deleted. A program must be ready to print the number instead.
// In: <pwd.h> (simplified)
struct passwd {
char *pw_name; // The name of the user
uid_t pw_uid; // Its UID
gid_t pw_gid; // Its primary group
char *pw_dir; // Its home directory
char *pw_shell; // Its shell
};
// In: <grp.h> (simplified)
struct group {
char *gr_name; // The name of the group
gid_t gr_gid; // Its GID
};
The following program prints the same line as the id command:
1#include <errno.h>
2#include <grp.h>
3#include <pwd.h>
4#include <stdio.h>
5#include <stdlib.h>
6#include <string.h>
7#include <unistd.h>
8
9static void print_error(const char *what)
10{
11 char msg[256];
12 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
13 write(STDERR_FILENO, msg, strlen(msg));
14}
15
16static void print_group(gid_t gid)
17{
18 const struct group *gr = getgrgid(gid);
19 char msg[256];
20 snprintf(msg, sizeof msg, "%u(%s)", gid, gr != nullptr ? gr->gr_name : "?");
21 write(STDOUT_FILENO, msg, strlen(msg));
22}
23
24int main(void)
25{
26 int n = getgroups(0, nullptr);
27 if (n == -1) {
28 print_error("getgroups");
29 return EXIT_FAILURE;
30 }
31 gid_t groups[n + 1]; // + 1: a VLA cannot be empty
32 n = getgroups(n, groups);
33 if (n == -1) {
34 print_error("getgroups");
35 return EXIT_FAILURE;
36 }
37
38 uid_t uid = getuid();
39 const struct passwd *pw = getpwuid(uid);
40
41 char msg[256];
42 snprintf(msg, sizeof msg, "uid=%u(%s) gid=", uid, pw != nullptr ? pw->pw_name : "?");
43 write(STDOUT_FILENO, msg, strlen(msg));
44 print_group(getgid());
45
46 write(STDOUT_FILENO, " groups=", 8);
47 for (int i = 0; i < n; i++) {
48 if (i > 0) {
49 write(STDOUT_FILENO, ",", 1);
50 }
51 print_group(groups[i]);
52 }
53 write(STDOUT_FILENO, "\n", 1);
54 return EXIT_SUCCESS;
55}
$ ./build/debug/app
uid=1000(john) gid=1000(john) groups=1000(john)
$ id
uid=1000(john) gid=1000(john) groups=1000(john)
The permissions of a file
Every file has an owner (a UID), a group (a GID) and a mode of 16 bits. The mode holds the type of the file, 3 special bits, and 9 permission bits: read, write and execute, for each of the three classes.
When a task uses a file, the kernel picks exactly one class, and only checks that one:
The checks use the effective IDs. A task with effective UID 0, the superuser, skips them. In addition, every directory on the path needs the execute permission.
The mode bits have names, defined in <sys/stat.h>:
| Class | Read | Write | Execute |
|---|---|---|---|
| Owner | S_IRUSR (0400) |
S_IWUSR (0200) |
S_IXUSR (0100)
|
| Group | S_IRGRP (0040) |
S_IWGRP (0020) |
S_IXGRP (0010)
|
| Others | S_IROTH (0004) |
S_IWOTH (0002) |
S_IXOTH (0001)
|
The three special bits are:
| Bit | Value | Shown as | Meaning |
|---|---|---|---|
Set-user-ID, S_ISUID |
04000 | s instead of the owner's x |
The program runs with the effective UID of the owner of the file |
Set-group-ID, S_ISGID |
02000 | s instead of the group's x |
The program runs with the effective GID of the group of the file. On a directory: new files get the group of the directory |
Sticky, S_ISVTX |
01000 | t instead of the others' x |
On a directory: an entry can only be deleted or renamed by its owner, the owner of the directory, or root |
If the x underneath is not set, the letter is uppercase: S or T.
statx(2)
// In: <fcntl.h> and <sys/stat.h>
int statx(int dirfd, const char *pathname, int flags, unsigned int mask, struct statx *statxbuf);
| Parameter | Description |
|---|---|
dirfd |
A directory to which a relative pathname is relative, or AT_FDCWD for the current directory
|
pathname |
The file |
flags |
0 to follow a symbolic link and describe its target, or AT_SYMLINK_NOFOLLOW to describe the link itself
|
mask |
Which fields we want. STATX_BASIC_STATS asks for the usual ones
|
statxbuf |
Where to store the result |
// In: <sys/stat.h> (simplified)
struct statx {
uint16_t stx_mode; // The type, special bits and permission bits
uint32_t stx_uid; // The owner
uint32_t stx_gid; // The group
uint32_t stx_nlink; // The number of hard links
uint64_t stx_size; // The size in bytes
struct statx_timestamp stx_atime; // Last access
struct statx_timestamp stx_mtime; // Last modification
};
struct statx_timestamp {
int64_t tv_sec; /* Seconds since the Epoch (00:00 1st Jan 1970 UTC) */
uint32_t tv_nsec; /* Nanoseconds since tv_sec */
};
The type is tested with macros: S_ISREG(mode) (regular file), S_ISDIR (directory), S_ISLNK (symbolic link), S_ISCHR (character device), S_ISBLK (block device), S_ISFIFO (pipe) and S_ISSOCK (socket).
statx(2) only needs the execute permission on the directories of the path, and no permission on the file itself. The following program describes any file we can reach:
1#include <errno.h>
2#include <fcntl.h>
3#include <grp.h>
4#include <pwd.h>
5#include <stdio.h>
6#include <stdlib.h>
7#include <string.h>
8#include <sys/stat.h>
9#include <unistd.h>
10
11static void print_error(const char *what)
12{
13 char msg[256];
14 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
15 write(STDERR_FILENO, msg, strlen(msg));
16}
17
18int main(int argc, char *argv[])
19{
20 if (argc != 2) {
21 const char *usage = "usage: app <path>\n";
22 write(STDERR_FILENO, usage, strlen(usage));
23 return EXIT_FAILURE;
24 }
25
26 struct statx st;
27 if (statx(AT_FDCWD, argv[1], 0, STATX_BASIC_STATS, &st) == -1) {
28 print_error(argv[1]);
29 return EXIT_FAILURE;
30 }
31
32 const char *type = "other";
33 if (S_ISREG(st.stx_mode)) {
34 type = "regular file";
35 } else if (S_ISDIR(st.stx_mode)) {
36 type = "directory";
37 }
38
39 const struct passwd *pw = getpwuid(st.stx_uid);
40 const struct group *gr = getgrgid(st.stx_gid);
41
42 char msg[512];
43 snprintf(msg, sizeof msg, "%s, owner %u (%s), group %u (%s), mode %04o\n", type,
44 st.stx_uid, pw != nullptr ? pw->pw_name : "?",
45 st.stx_gid, gr != nullptr ? gr->gr_name : "?",
46 st.stx_mode & 07777);
47 write(STDOUT_FILENO, msg, strlen(msg));
48 return EXIT_SUCCESS;
49}
$ ./build/debug/app /etc/shadow
regular file, owner 0 (root), group 42 (shadow), mode 0640
$ ./build/debug/app /tmp
directory, owner 0 (root), group 0 (root), mode 1777
$ ./build/debug/app /usr/bin/passwd
regular file, owner 0 (root), group 0 (root), mode 4755
$ ./build/debug/app /nope
/nope: No such file or directory
stx_mode also holds the type of the file, and & 07777 keeps only the special and permission bits. In the examples above:
/etc/shadowholds the password hashes, and the others class has no permissions at all./tmpcan be written by anyone, but the sticky bit (the leading 1) stops users from deleting each other's files./usr/bin/passwdis set-user-ID (the leading 4) and belongs to root. This is how a normal user can change their own password: the program runs with effective UID 0, and it knows who started it from the real UID.
chmod(2)
// In: <sys/stat.h>
int chmod(const char *pathname, mode_t mode);
chmod(2) replaces the permission and special bits of a file. Only the owner of the file, or root, may call it. The permission bits themselves do not matter: an owner with --- can still give itself rw-. Since it replaces all the bits, changing just some of them means starting from the old mode, which statx(2) gives us. For example, to remove the write permission of the group and of the others:
mode_t new_mode = (st.stx_mode & 07777) & ~(S_IWGRP | S_IWOTH);
chmod(path, new_mode);
chmod(2) follows symbolic links: called on a link, it changes the file the link points to.Trying it out
The chmod command takes the same octal numbers. Try the following on your own files:
$ echo hello > notes.txt
$ chmod 044 notes.txt
$ cat notes.txt
cat: notes.txt: Permission denied
$ chmod 644 notes.txt
$ cat notes.txt
hello
With mode 044, everybody else can read the file, but its owner cannot: only the owner class is checked. On a directory, the three permissions mean something else:
| Permission | For a directory |
|---|---|
| Read | List the names |
| Write | Create, delete and rename entries |
| Execute | Go through it, to use the names inside |
$ mkdir box
$ echo hi > box/a.txt
$ chmod 600 box
$ ls box
a.txt
$ cat box/a.txt
cat: box/a.txt: Permission denied
$ chmod 300 box
$ ls box
ls: cannot open directory 'box': Permission denied
$ cat box/a.txt
hi
$ chmod 500 box
$ touch box/b.txt
touch: cannot touch 'box/b.txt': Permission denied
$ rm box/a.txt
rm: cannot remove 'box/a.txt': Permission denied
$ chmod 700 box
Without execute, we can see the names, but we cannot use them. Without read, we can use a name, if we already know it. Deleting a file needs write permission on its directory. The permissions of the file itself are not checked. Depending on its version, ls box may also complain about each name in the first case.
Exercises
Write each program in its own copy of the project template. As a reminder from the first lab:
mkdir -p ~/oslab/lab06 && cd ~/oslab/lab06
curl -OL https://wiki.dcae.pub.ro/images/9/93/Os_lab_skeleton.zip
unzip -d ls Os_lab_skeleton.zip
unzip -d doctor Os_lab_skeleton.zip
For this lab:
- As in the last labs, do not use the standard I/O library for output: build the text with
snprintf(3)andwrite(2)it. - Do not run other programs: make the system calls yourself.
- If a file cannot be reached, print the error on standard error, and continue with the next one.
A. Your own ls -l
Write a command which lists a directory like ls -la. It takes the directory as an optional parameter, and uses the current directory if there is none. Build it in these steps:
- Names and sizes. Read the directory with
readdir(3), as in Lab 3, and callstatx(2)for each entry. Print the size and the name. - The mode. Print the mode as 10 characters: the type (
-,d,l,c,b,pors), then the 9 permission bits. Write a function which fills in a string. - The other columns. Add the number of hard links, the owner, the group and the date. If a number has no name, print the number. For a symbolic link, also print
-> target. - The special bits. Show
sandSfor the set-user-ID and set-group-ID bits, as described above. You can ignore the sticky bit. - What can I do? Add a first column with your own rights over the file, such as
r-x, by picking the class as the kernel does: compare with your effective UID, then with your effective GID and supplementary groups, or else use the others class.
$ ./build/debug/app
rwx drwxr-xr-x 1 john john 44 2026-11-09 15:54 .
rwx drwxr-xr-x 1 john john 50 2026-11-09 15:54 ..
rw- -rw-r-S--- 1 john john 6 2026-11-09 15:54 notes.txt
rwx lrwxrwxrwx 1 john john 9 2026-11-09 15:54 link -> notes.txt
rwx drwxr-x--x 1 john john 0 2026-11-09 15:54 sub
rwx -rwsr-xr-x 1 john john 68064 2026-11-09 15:54 tool
$ ./build/debug/app /etc | grep shadow
--- -rw-r----- 1 root shadow 840 2026-11-09 15:54 shadow
The names do not have to be sorted. The widths of the columns are up to you.
- You do not need to build a path for each entry: give
statx(2)the directory throughdirfd(dir), which returns the file descriptor of an openDIR, and the name of the entry aspathname. - Use
AT_SYMLINK_NOFOLLOW, so that links are listed as links. readlinkat(dirfd(dir), name, buffer, size)reads the target of a link. It returns the length, and does not add a'\0'.- For the date:
time_t when = st.stx_mtime.tv_sec;, thenlocaltime_r(&when, &tm), andstrftime(date, sizeof date, "%Y-%m-%d %H:%M", &tm), from<time.h>. getpwuid(3)andgetgrgid(3)use different buffers, but copy the names anyway if you want to look up anything else before printing them.
After step 4, compare your output with the real ls.
B. Permission doctor
Write a command which walks a directory and everything below it, and reports dangerous permissions. With --fix, it also repairs them.
./build/debug/app [--fix] <directory>
It checks only regular files and directories, including the starting directory, against three rules:
| Applies to | Problem | Repair |
|---|---|---|
| Regular files and directories | Writable by others, except for directories with the sticky bit | Remove the write permission of the others |
Regular files whose name ends in .key |
Any permission for the group or the others | Remove all of them |
| Regular files | Set-user-ID or set-group-ID | Remove those bits |
Create a test directory inside the project:
mkdir -p demo/sub demo/locked
echo hello > demo/notes.txt
echo hello > demo/shared.txt && chmod 666 demo/shared.txt
echo secret > demo/sub/server.key && chmod 644 demo/sub/server.key
cp /bin/true demo/sub/tool && chmod 4755 demo/sub/tool
chmod 777 demo/sub
ln -s /etc/shadow demo/link
chmod 000 demo/locked
$ ./build/debug/app demo
demo/sub: writable by others (0777)
demo/sub/server.key: private key open to group or others (0644)
demo/sub/tool: runs with the rights of its owner or group (4755)
demo/locked: Permission denied
demo/shared.txt: writable by others (0666)
problems found: 4, fixed: 0
$ ./build/debug/app --fix demo
demo/sub: writable by others (0777)
fixed: 0777 -> 0775
demo/sub/server.key: private key open to group or others (0644)
fixed: 0644 -> 0600
demo/sub/tool: runs with the rights of its owner or group (4755)
fixed: 4755 -> 0755
demo/locked: Permission denied
demo/shared.txt: writable by others (0666)
fixed: 0666 -> 0664
problems found: 4, fixed: 4
$ ./build/debug/app demo
demo/locked: Permission denied
problems found: 0, fixed: 0
The lines may come in a different order. The exit status is 0 if every problem found was fixed, and 1 otherwise.
- Write a recursive function which takes the path of a directory. Build the path of each entry with
snprintf(3), into a buffer ofPATH_MAXbytes (from<limits.h>). - Skip
.and.., or the walk will never end. - Use
AT_SYMLINK_NOFOLLOW, and leave symbolic links alone. - Set
errnoto 0 before each call toreaddir(3). The recursive calls change it. - A file can break several rules. Work out the new mode from all of them, and call
chmod(2)once.
demo/sub/tool is harmless here, because that program is a copy of true. Never set it on a shell or on anything which runs commands: whoever starts the program would get your rights.Some things to look at:
- Run
./build/debug/app --fix /usr/bin. Why does it fail for every file? - Why is
/tmpnot reported, even though everybody can write to it? demo/linkhas mode 0777. What would--fixtry to do if the program treated it like a regular file? Why would that fail here, and what would happen if the link pointed to a file of yours?