Cómo funcionan los permisos SUID

Todo administrador de sistemas conoce el gran poder y la gran responsabilidad de los binarios con los bit de suid o sgid activos. La ejecución de un binario de este tipo confiere "superpoderes" al usuario que tiene permisos para ejecutarlo, permitiéndole realizar operaciones (siempre dentro del contexto del proceso) como si fuese el usuario al que pertenece el binario en el caso del suid o perteneciese al grupo en el caso del sgid. Estos binarios suelen ser el principal objetivo de los hackers ya que si se consigue encontrar una vulnerabilidad en un programa con este bit activo, sería posible realizar una escalada de privilegios.

Existen muchos artículos en la red sobre qué hace este bit, cómo encontrar los archivos con este bit activo, cómo manipular los permisos, etc, por ello no voy a hablar de todo eso y me voy a centrar en algo que poco se ha contado, ¿cómo funcionan realmente? ¿por qué no es posible utilizar estos bits con los scripts?.

Para ello nos sumergiremos en el código del kernel, comprobando en primer lugar dónde se almacenan estos bits y el resto de información relevante del fichero. A continuación veremos dónde almacena el kernel la estructura que contiene la información de seguridad de ese proceso. En base a esa información, el kernel deberá realizar las oportunas comprobaciones de seguridad, veremos cómo hace estas comprobaciones de seguridad con un caso sencillo. Finalmente, veremos cómo esta información se propaga en la creación de un proceso y cómo en la ejecución de un binario con estos bits se modifica. En este punto también veremos la explicación de por qué un fichero de tipo script no se ve afectado por estos bits.

Empecemos por el principio…​

1. ¿Dónde se almacena la información de seguridad de un fichero?

Como ya sabremos, los ficheros "setuidados" (bonito palabro) son aquellos que tienen alguno de los bits de suid o sgid activos en la información de los permisos de un archivo. Esta información se encuentra en la metainformación vinculada a cada fichero, concretamente en lo que se conoce como el inodo.

Tradicionalmente los sistemas UNIX han utilizado sistemas de archivos que utilizaban la abstracción del inodo. En un inodo se almacena la información relevante a un archivo como el número de enlaces, permisos, propietario, bytes que ocupa, etc y lo más importante: en qué bloques se encuentra (no, el nombre de archivo no se almacena en el inodo). Para dar soporte a múltiples sistemas de archivos, Linux creó el Virtual File System o VFS, que consiste en una abstracción que define una serie de estructuras y de interfaces que los desarrolladores de sistemas de archivos deberán implementar para poder integrarlos en Linux. Esta abstracción mantiene el inodo como elemento fundamental.

Todo sistema de archivos implementado debe cumplir con la interfaz VFS y debe ser capaz de mapear a los tipos de datos definidos. Esto quiere decir que aunque un sistema de archivos no contemple una estructura de inodo como pudieran ser VFAT o NTFS, los drivers del sistema de archivos deberán ser capaces de "mapear" los datos a esta estructura (incluso si no existiesen en el sistema de archivos que se pretende dar soporte).

Podemos encontrar la definición de dicha estructura en fs.h:

Parte de la estructura inode
struct inode {
	....
	umode_t			i_mode;
	....
	kuid_t			i_uid;
	kgid_t			i_gid;
	....

Nótese que sólo he incluido la información más relevante, entre la que tenemos un campo i_mode y los campos i_uid y i_gid. Los dos últimos campos se corresponden obviamente con los identificadores del propietario y grupo del fichero. El campo i_mode contiene la información que andamos buscando.

Si nos fijamos, viene definido por el tipo oscuro umode_t que en realidad es un unsigned short que son 2 bytes o lo que es lo mismo, 16 bits. En estos bits se codifica multitud de información sobre el fichero, observando las macros del fichero stat.h podemos conocer qué información contiene. Nuevamente muestro a continuación únicamente la información relevante:

Macros contenidas en el fichero stat.h
#define S_ISUID  0004000
#define S_ISGID  0002000
#define S_ISVTX  0001000
....

#define S_IRWXU 00700
#define S_IRUSR 00400
#define S_IWUSR 00200
#define S_IXUSR 00100

#define S_IRWXG 00070
#define S_IRGRP 00040
#define S_IWGRP 00020
#define S_IXGRP 00010

#define S_IRWXO 00007
#define S_IROTH 00004
#define S_IWOTH 00002
#define S_IXOTH 00001

Si los números empezasen por 0x en C indicarían que se encuentran en hexadecimal, pero en este caso empiezan por 0, lo que significa que están en formato octal.

La definición de estas macros permite crear de manera muy sencilla operaciones en la que comprobemos si un determinado bit está activo, por ejemplo con if (mode & (S_ISUID|S_ISGID)) comprobaríamos si en la variable mode se encuentra alguno de los bit de suid o sgid activos.

Vamos a distribuir los dos bytes agrupando de 3 en 3 y esto ya tiene pinta de algo a lo que estamos bastante acostumbrados:

TIPO     ESPECIALES   USUARIO   GRUPO   OTROS
0000        000         000      000     000
            ugt         rwx      rwx     rwx

Lo que nos interesa para nuestro caso son los bits especiales, concretamente los que hemos indicado como u o g que se corresponderán a los bit suid y sgid.

Nótese que estamos trabajando con una estructura de datos en memoria, y que se corresponde con la abstracción del inodo que realiza el VFS. Cada implementación concreta de un sistema de archivos almacenará esta información en disco de la manera que le convenga y por lo tanto dicha información existirá en disco de una forma u otra. Independientemente de la implementación, todas las llamadas al sistema emplearán la información que hemos visto.

Llegados a este punto, ya hemos visto dónde tenemos esta información, pero antes de ver cómo el sistema operativo la interpreta cuando ejecuta un binario, deberemos conocer cómo es la información de seguridad de un proceso.

2. ¿Dónde se almacena la información de seguridad de los procesos?

Los procesos y no los usuarios, son los que realmente utilizan los servicios del sistema operativo y acceden a sus recursos. Será responsabilidad del sistema operativo determinar si un proceso puede o no realizar una determinada operación. Para tomar esta decisión, entre otras cosas, utilizará la información de seguridad del proceso.

Esto realmente es un asunto bastante complejo, Linux define un framework llamado Linux Security Modules o LSM que permite la implementación y el "stackado" (bonito palabro) de diferentes modelos de seguridad como AppArmor o SELinux. En el futuro dedicaré algún artículo a este framework. De momento, simplemente describiré la implementación "básica".

Los procesos (y los hilos) se implementan mediante la estructura de datos task_struct que se encuentra en sched.h. En dicha estructura de datos se encuentra un campo cred que contiene un puntero a una estructura de datos tipo cred que *alberga la información de contexto de seguridad. Podemos encontrar su definición en cred.h.

Si analizamos un extracto de la estructura tenemos la siguiente información:

Contenido parcial de la estructura cred
struct cred {
	....
	kuid_t		uid;		/* real UID of the task */
	kgid_t		gid;		/* real GID of the task */
	kuid_t		suid;		/* saved UID of the task */
	kgid_t		sgid;		/* saved GID of the task */
	kuid_t		euid;		/* effective UID of the task */
	kgid_t		egid;		/* effective GID of the task */
	kuid_t		fsuid;		/* UID for VFS ops */
	kgid_t		fsgid;		/* GID for VFS ops */
	....
	struct group_info *group_info;	/* supplementary groups for euid/fsgid */
	....
};

Aunque es evidente su traducción, por enumerarlos tenemos:

  • uid, gid: usuario y grupo principal real.

  • euid, egid: usuario y grupo principal efectivo.

  • suid, sgid: usuario y grupo principal grabado (saved)

  • fsuid, fsgid: usuario y grupo principal de ficheros.

  • group_info: información de grupos suplementarios.

Posteriormente veremos qué función tienen cada uno de estos campos, pero antes…​ ¿cómo podemos ver esta información para un proceso existente?, pues simplemente bastará con consultar el fichero status del proceso en /proc. Por ejemplo si quisiera ver para el proceso 22631 sería:

$ cat /proc/22631/status
Name:	bash
State:	S (sleeping)
Tgid:	22631
Ngid:	0
Pid:	22631
PPid:	6260
TracerPid:	0
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
FDSize:	256
Groups:	4 24 27 29 30 46 113 128 130 133 134 1000
    ....

Vemos que las filas Uid: y Gid contiene varias columnas. El orden en que deben interpretarse es el siguiente: rxid, exid, sxid, fsxid (de ahora en adelante pondré una x para representar u o g). Además obsérvese la columna Groups: que contiene multitud de identificadores, estos son todos los grupos a los que "pertenecerá" el proceso para las comprobaciones de seguridad de grupo (nótese que estos grupos coinciden con los que el usuario pertenece).

En nuestros programas en espacio de usuario podremos obtener también la información en tiempo de ejecución del proceso. Valga de ejemplo el siguiente programa.

Contenido de getids.c
#include "printinfo.h"

void main() {

	print_sep();
	print_pids();
	print_puids();
	print_pgids();
	print_sep();

}
Contenido de print_info.h
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/fsuid.h>

void print_puids() {
	uid_t ruid, euid, suid, fsuid;

	getresuid(&ruid, &euid, &suid); /* no portable */
	fsuid = setfsuid(-1); /* no portable */
	printf("\truid: %d\teuid: %d\tsuid: %d\tfsuid: %d\n",
		ruid, euid, suid, fsuid);

}

void print_pgids() {
	gid_t rgid, egid, sgid, fsgid;

	getresgid(&rgid, &egid, &sgid); /* no portable */
	fsgid = setfsgid(-1); /* no portable */
	printf("\trgid: %d\tegid: %d\tsgid: %d\tfsgid: %d\n",
		rgid, egid, sgid, fsgid);

}

void print_pids() {
	pid_t pid, ppid;

	pid = getpid();
	ppid = getppid();
	printf("pid: %d\nppid: %d\n", pid, ppid);
}

void print_sep() {
	printf("-----------------------------------------------------");
	printf("----------------\n");
}

Todo esto está bien, ya conocemos que para un proceso la información de seguridad no se limita al uid y gid, sino que existen además otros identificadores como el euid o el seuid…​

3. ¿Cómo realiza el kernel las comprobaciones de seguridad?

Para realizar las comprobaciones de seguridad el sistema operativo utilizará los campos euid y egid. Para el caso concreto de acceso a ficheros se comprobarán los campos fsuid y fsgid. Además, si cualquier comprobación necesitara la pertenencia a un grupo, también se comprobará si el grupo requerido se encontrase en la lista alojada en group_info.

Pero veamos un ejemplo de comprobación de seguridad en el kernel. Para ello veremos la más sencilla de las llamadas al sistema que fija el tiempo, la llamada stime y que encontramos definida en time.c

Llamada al sistema stime
SYSCALL_DEFINE1(stime, time_t __user *, tptr)
{
	struct timespec tv;
	int err;

	if (get_user(tv.tv_sec, tptr))
		return -EFAULT;

	tv.tv_nsec = 0;

	err = security_settime(&tv, NULL);
	if (err)
		return err;

	do_settimeofday(&tv);
	return 0;
}

Podemos ver que en la función existe una llamada a security_settime. En dicha función será donde se realizará la comprobación de seguridad, que acabará llamando (mediante los mecanismos definidos en LSM) a cap_settime, que se encuentra en commoncap.c

Función cap_settime
int cap_settime(const struct timespec64 *ts, const struct timezone *tz)
{
	if (!capable(CAP_SYS_TIME))
		return -EPERM;
	return 0;
}

Para entender este artículo nos bastará asumir que la función capable devolverá cierto si el euid == 0, es decir, si el identificador efectivo es el uid del usuario root (que como sabrás es 0). Volveremos a esto en otro artículo en el que hablaré sobre las capabilities.

Para el caso de archivos, se realizará algo similar: en las llamadas al sistema de manejo de archivos, se comprobará que tiene los permisos adecuados pero comprobando contra los identificadores fsuid, fsgid y la lista de group_info.

Los identificadores fsuid y fsgid no están definidos en POSIX, están por motivos históricos en el kernel de Linux y se mantienen desde entonces con la misma función. Sin embargo, podemos ignorarlos y hacer como que si no existieran ya que en la práctica todas las llamadas al sistema que cambian el euid y el egid modifican al mismo tiempo el fsuid y fsgid.

Ya hemos visto para qué sirven los exid y fsxid, pero ¿para que sirven entonces los identificadores "grabados" o saved xid?, pues bien, nos servirán para poder cambiar el identificador efectivo al real dentro de un proceso sin perderlo, pudiendo volver a fijar el identificador efectivo posteriormente. ¿Y para qué querríamos cambiar el identificador efectivo?. Pues, por ejemplo, para realizar una operación privilegiada y después devolver al proceso al mínimo privilegio.

Pero esto lo dejo para otro artículo en el que veamos las llamadas al sistema que permiten los cambiar la información de seguridad en el contexto del proceso.

Bien, ya hemos visto la información de seguridad que tiene un proceso, pero todavía no os he respondido cómo lo hace un binario suid y por qué no funcionan con los scripts. Tranquilidad, que ya casi estamos, pero antes…​

4. ¿Cómo se crean los procesos?

Como administradores de sistemas, sabemos que Linux siempre crea los procesos mediante un mecanismo de clonación del proceso que realiza la llamada al sistema fork a un nuevo proceso (realmente es la llamada al sistema clone pero eso ya es otra historia ;) ). En dicha clonación se realizará también la copia de los valores que vimos de la estructura cred en el nuevo proceso.

Podemos comprobarlo fácilmente con el siguiente programa:

Contenido de testfork.c
#include "printinfo.h"
#include <unistd.h>
#include <sys/wait.h>

void main() {
	pid_t newp;

	print_sep();
	print_pids();
	print_puids();
	print_pgids();
	print_sep();

	if( (newp = fork()) == 0) {
		/* si hijo */
		print_sep();
		print_pids();
		print_puids();
		print_pgids();
		print_sep();
	} else {
		wait(NULL);
	}
}

Tras la ejecución podemos ver que los identificadores son idénticos en ambos procesos.

Salida de ejecución
$ ./testfork
 --------------------------------------------------------------------
pid: 4300
ppid: 3754
	ruid: 1000	euid: 1000	suid: 1000	fsuid: 1000
	rgid: 1000	egid: 1000	sgid: 1000	fsgid: 1000
 --------------------------------------------------------------------
pid: 4301
ppid: 4300
	ruid: 1000	euid: 1000	suid: 1000	fsuid: 1000
	rgid: 1000	egid: 1000	sgid: 1000	fsgid: 1000
 --------------------------------------------------------------------

Cuando lo que queremos es ejecutar un nuevo programa, podemos hacerlo utilizando alguna de las llamadas al sistema de la familia exec que reemplazará el mapa de memoria del proceso, sustituyendo también el segmento de programa por el del binario que se quiere ejecutar.

De este modo cuando una shell ejecuta un programa lo que hará será crear un nuevo proceso con la llamada el sistema fork y posteriormente una llamada al sistema exec con el que reemplazará el programa del proceso. Pero en el caso de la llamada al sistema exec, ésta podrá modificar la información de la estructura cred que vimos con anterioridad a partir de los bits de la estructura inode del fichero.

Antes de ver cómo lo realiza, veamos qué ocurre ejecutando un binario getids "setuidado" llamdado desde el siguiente programa:

Contenido de testexec.c
#include "printinfo.h"
#include <unistd.h>
#include <sys/wait.h>

void main(int argc, char *argv[]) {
	pid_t newp;

	if(argc <1) {
		printf("Falta argumento\n");
		return;
	}

	print_sep();
	print_pids();
	print_puids();
	print_pgids();
	print_sep();

	if( (newp = fork()) == 0) {
		/* si hijo */
		execl(argv[1], argv[1], (char *) NULL);
	} else {
		wait(NULL);
	}
}
Salida del comando tras setuidar el binario a www-data
$ ./testexec ./getids
 --------------------------------------------------------------------
pid: 4455
ppid: 3754
	ruid: 1000	euid: 1000	suid: 1000	fsuid: 1000
	rgid: 1000	egid: 1000	sgid: 1000	fsgid: 1000
 --------------------------------------------------------------------
 --------------------------------------------------------------------
pid: 4456
ppid: 4455
	ruid: 1000	euid: 33	suid: 33	fsuid: 33
	rgid: 1000	egid: 1000	sgid: 1000	fsgid: 1000
 --------------------------------------------------------------------

Con esto hemos visto cómo se crean los procesos y se copia la información de seguridad. Vayamos a la madre del cordero…​

5. ¿Cómo funciona la llamada exec?

Si has llegado hasta aquí falta un último esfuerzo, vayamos a ver la llamada al sistema execve en exec.c.

Llamada al sistema execve
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

En esta llamada al sistema se llama a la función do_execve con el path al binario a ejecutar y los argumentos. En dicha función se llama posteriormente a la función do_execveat_common. Esta función contiene la lógica principal de ejecución de la familia de funciones exec. En dicha función se emplea una estructura de datos llamada linux_binprm en la que se almacenará la información relevante para la ejecución del binario ejecutado. Podemos encontrar su definición el el fichero binfmts.h.

En la función do_execveat_common vemos que allí se llama a prepare_binprm y en dicha función se llama a bprm_fill_uid y security_bprm_set_creds. En la primera de las funciones es donde se realiza la carga de la información de usuario del inodo al nuevo proceso, como podemos ver a continuación en el fichero exec.c.

Parte contenido de función bprm_fill_uid
static void bprm_fill_uid(struct linux_binprm *bprm)
{
	....
	if (!mnt_may_suid(bprm->file->f_path.mnt))
		return;

	if (task_no_new_privs(current))
		return;

	inode = bprm->file->f_path.dentry->d_inode;
	mode = READ_ONCE(inode->i_mode);
	if (!(mode & (S_ISUID|S_ISGID)))
		return;
	....
	/* Be careful if suid/sgid is set */
	inode_lock(inode);

	mode = inode->i_mode;
	uid = inode->i_uid;
	gid = inode->i_gid;

	....
	if (mode & S_ISUID) {
		bprm->per_clear |= PER_CLEAR_ON_SETID;
		bprm->cred->euid = uid;
	}

	if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) {
		bprm->per_clear |= PER_CLEAR_ON_SETID;
		bprm->cred->egid = gid;
	}
}

He suprimido algunas partes de la función para que se vea más claramente lo que nos importa. En primer lugar, fijémonos que se hace una llamada a la función mnt_may_suid. Esto comprobará si el punto de montaje en el que se encuentra el fichero permite el uso de ficheros con este bit y en caso negativo no continuará con la ejecución.

La segunda llamada task_no_new_privs chequeará si el bit PFA_NO_NEW_PRIVS está activo dentro del campo atomic_flags del proceso.

Finalmente se leerá los datos de uid, gid y mode que vimos anteriormente del inodo y los asignará a los campos euid y egid si el modo incluye los bits de S_ISUID y S_ISGID. En el caso del egid chequeará también que el grupo tenga permisos de ejecución con la macro S_IXGRP.

Vemos que se copian al efectivo, pero ¿se copian al suid, sgid, fsuid y fsgid?. Bueno esto dependerá del módulo LSM. Concretamente se hace en la llamada a security_bprm_set_creds, que llamará al hook establecido en security_ops→bprm_set_creds.

En la implementación "base" tenemos la función cap_bprm_set_creds en common.cap en la que se realiza la copia como vemos a continuación:

int cap_bprm_set_creds(struct linux_binprm *bprm)
{
	....
	struct cred *new = bprm->cred;
	....
	new->suid = new->fsuid = new->euid;
	new->sgid = new->fsgid = new->egid;
	....
}
Esta función es muy interesante, ya que en ella se aloja también el código en el que se copian las capabilities y se ve también porqué un proceso ejecutado con gdb no permite ser "setuidado".

Llegados a este punto, hemos visto cómo se han copiado los datos de estos bits a la estructura de tipo linux_binprm. Pero…​ ¡todavía no se han copiado al proceso!.

6. ¿Por qué no funciona el SUID con los scripts?

Por fin llegamos a la respuesta a esta pregunta. Pues bien, la copia de la información de la estructura linux_binprm al proceso se realiza en la implementación concreta del tipo de binario soportado por el kernel. Como podíamos suponer, linux_binprm es una abstracción que tiene por objetivo dar soporte a distintos tipos de binario en el kernel. Podemos ver los tipos soportados en el directorio /source/fs, aquellos que empiezan por binfmt_. Allí encontramos:

En cada uno de estos archivos se registra el formato de binario que soporta y al menos define una función que realiza la tarea "dura" de adecuar el mapa de memoria a la estructura definida por el binario. Además, incluye antes de realizar cualquier tipo de operación la lógica que determina si el fichero pasado es un binario del tipo soportado. En el caso de los scripts es muy fácil porque contiene la secuencia conocida como Shebang.

Trozo de código que realiza la carga del formato script
static int load_script(struct linux_binprm *bprm)
{
	const char *i_arg, *i_name;
	char *cp;
	struct file *file;
	int retval;

	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
		return -ENOEXEC;

Como vemos, si no contiene la secuencia #! al principio no continuará con la carga del binario pasado.

La copia de las credenciales de la estructura linux_binprm a cred del task_struct se realizan en la función install_exec_creds dentro del fichero exec.c que llamará a commit_creds.

Contenido de función install_exec_creds
/*
 * install the new credentials for this executable
 */
void install_exec_creds(struct linux_binprm *bprm)
{
	security_bprm_committing_creds(bprm);

	commit_creds(bprm->cred);
	bprm->cred = NULL;

	/*
	 * Disable monitoring for regular users
	 * when executing setuid binaries. Must
	 * wait until new credentials are committed
	 * by commit_creds() above
	 */
	if (get_dumpable(current->mm) != SUID_DUMP_USER)
		perf_event_exit_task(current);
	/*
	 * cred_guard_mutex must be held at least to this point to prevent
	 * ptrace_attach() from altering our determination of the task's
	 * credentials; any time after this it may be unlocked.
	 */
	security_bprm_committed_creds(bprm);
	mutex_unlock(&current->signal->cred_guard_mutex);
}
EXPORT_SYMBOL(install_exec_creds);

Pues bien, por motivos de seguridad la función install_exec_creds se llama únicamente desde los módulos que soportan los formatos flat, aout y elf como puede verse aquí. Por lo tanto, este es el motivo por el cual cuando el kernel ejecuta un script no funcionan los bits de suid y sgid.

Espero que te haya gustado y que esta explicación te haya hecho más feliz, ;) Happy hacking!