Fork bomb

Una bomba se entiende normalmente por «artefacto explosivo provisto del artificio necesario para que estalle en el momento conveniente», la definición número 12 del Diccionario de la Real Academia Española para la palabra bomba.
En el contexto de un sistema operativo, to fork es la acción de duplicar un proceso y sus atributos (memoria, propietario, código ...). Una fork bomb es una bomba fork. El término bomba claramente es en sentido figurado, ya que lo que describe es una explosión o generación masiva de procesos, una creación de réplicas en un instante mínimo de tiempo, tan mínimo como el tiempo que el sistema emplee en duplicar procesos.

Otro efecto asociado a una bomba además de la explosión, es la destrucción como consecuencia de la explosión. Esto, en términos informáticos significa dejar al sistema cao, sin capacidad de hacer nada. También puede significar la pérdida de datos.
Así, una bomba fork es un programa que mediante la duplicación masiva de procesos agota los recursos del sistema dejándolo totalmente inservible. Para recuperarse de una bomba fork no queda otra opción que reiniciar el ordenador o conjunto de ordenadores sobre los que se esté ejecutando el sistema operativo.

En GNU/Linux se puede crear una bomba fork de forma muy sencilla, basta con escribir en una consola de comandos, como por ejemplo GNU Bash, lo siguiente:
:(){ :|: & }; :
Ahí lo que vemos es una función de nombre «:» en cuyo cuerpo (entre los corchetes) se vuelve a llamar a sí misma y además se entuba otra llamada a sí misma. Al crear el tubo de comunicación, las dos llamadas a «:» se ejecutan en procesos hijo independientes conectados por dicho tubo en vez de en el proceso Bash desde el que se ha llamado a «:» (después del «;»). Además el entubado se ejecuta en segundo plano por la presencia del operador «&».

Una forma más clara de verlo sería la siguiente:

#!/bin/bash
function bombaFork { #alternativa a «bombaFork() {»
    bombaFork | bombaFork &
}
bombaFork
done

En ese guión se puede ver más claro lo que está ocurriendo. Definimos una función bombaFork (function bombaFork{...}) y la llamamos (el bombaFork al final del guión Bash -Bash script-).

Dentro de la función bombaFork() ejecutamos una sola línea que consiste en la creación de un tubo (pipe) entre dos llamadas recursivas a bombaFork(). Como las llamadas están en un entubado (pipeline), en vez de ejecutarse como llamadas normales a función se crean dos procesos nuevos. La parte derecha del tubo «|» es un nuevo proceso P2 al que se conecta vía el tubo al proceso P1 correspondiente a la llamada bombaFork() de la izquierda del tubo. El entubado se ejecuta en 2º plano debido al operador «&» de forma que el proceso llamante no espera a que los hijos terminen, muriendo inmediatamente después de la creación del entubado, dejando a los hijos huérfanos. P2 tiene el mismo código que P1, así que también ejecutará llamadas recursivas (a sí mismo) y por cada llamada recursiva creará nuevos procesos. Cada nuevo proceso creado también se llamará recursivamente a sí mismo, y cada nuevo proceso creado ejecuta el mismo código que crea otros nuevos procesos. Como la función bombaFork() nunca termina, se van creando cada vez más procesos en paralelo hasta agotar los recursos del sistema. A cada llamada recursiva se duplica el número de procesos creados.
Una representación gráfica podría ser ésta:


Si vamos de arriba abajo, se van creando procesos. Cada nuevo proceso tiene el mismo código que crea otros procesos que a su vez crean nuevos procesos (aunque no está dibujado, sólo puntos suspensivos indicando que la serie sigue). Los tubos o pipes realmente no se utilizan ya que el código de la bomba fork no lee ni escribe nada, simplemente es una forma de obligar a Bash a crear un nuevo proceso por cada llamada de función y además poner todo el entubado en segundo plano. El número de procesos llamados al mismo tiempo va aumentando, incrementando el número de procesos exponencialmente (1*2*2*2*2 => 2N).
Esta característica de crear nuevos procesos al realizar una llamada a función es propia de los intérpretes de comandos.
En el caso de Bash (intérprete para cuya sintaxis se ha pensado la bomba presentada al principio), cuando se llama a una función se llama igual que se llama a cualquier otro comando simple, y los parámetros se pasan igual que si se llamase a un comando simple. Cuando el flujo de ejecución está dentro de la función, los parámetros posicionales ($1, $2 ...) son sustituidos temporalmente por los parámetros pasados a la función. Al estar la llamada en un entubado o pipeline se crean nuevos procesos que ejecutan esa función. Fuera de un entubado, esa especie de comando que es una función, se ejecuta en el proceso Bash llamante con el entorno de ejecución (variables) de dicho proceso. Pero dentro de un entubado, los comandos del entubado se ejecutan en procesos hijo independientes sin heredar variables de entorno, lo que en esta bombaFork provoca una creación infinita de procesos. Además también cabe distinguir entre el uso de corchetes y paréntesis en la definición de una función. Para más información consultar el manual de Bash[1], secciones «SHELL GRAMMAR»[2] y «FUNCTIONS»[3].

Una versión más bestia de la bombaFork podría ser la siguiente:
#!/bin/bash
function bombaFork { #alternativa a «bombaFork() {»
    bombaFork | bombaFork &
    while : #los : en este caso no son un nombre de función, sino

    do      #un comando integrado en bash que devuelve 0, (éxito).
        sleep 1   #Sería como poner «while true», o si existiera, «while success».
    done    
}
bombaFork
exit 0

Con esta variante mantenemos los padres vivos, llenando más rápidamente el cupo de procesos, pero para meterla en una sola línea no queda tan bonito como :(){ :|: & }; :
Sin embargo, el árbol de procesos aparece completo por lo que terminar con la creación de procesos es directa (matar árbol de procesos).

El porqué una bomba fork requiere de dos llamadas es debido a que con una sola llamada  se crean procesos uno tras otro, sin aumentar el número de procesos creado cada vez (lógico), por lo que el SO no se ve desbordado. Aún así, con un ordenador de cuatro núcleos el consumo de CPU alcanza el 50%.

La problemática de las bombas fork está muy vista en Internet, como también la forma de evitarla en un sistema GNU/Linux. En estos sistemas existe la posibilidad de limitar la cantidad de procesos que un usuario puede tener funcionando al mismo tiempo, así como la memoria máxima a usar, archivos abiertos y más. Esto se puede configurar con la orden ulimit y con el archivo /etc/security/limits.conf. Con ulimit los límites son temporales, duran configurados lo que dure la sesión.

El archivo limits.conf consiste en una serie de líneas divididas en una serie de campos: <domain> <type> <item> <value>.
  • <domain>:  un username, groupname (@group), * ó %.
  • <type>: hard ó soft. Hard: este es el límite máximo que no se puede superar en ningún caso. Configurado por superusuario. Un usuario normal no puede superarlo. Soft: se podría definir como límites por defecto para el <domain>. El propio usuario perteneciente al <domain> puede cambiarlo, pero el límite soft nunca podrá superar al hard. Límite «-»: para cambiar soft y hard al mismo tiempo, considerarlos iguales.
  • <item>: Cosas a limitar. Core: limita el tamaño de volcado en caso de que un programa haga un fallo de segmento (KB). Data: tamaño máximo de datos (KB). Fsize: tamaño máximo para los archivos (KB). Memlock: máximo tamaño de espacio de memoria bloqueada por el <domain> (KB). Nofile: máximo número de archivos abiertos por un <domain>, por tanto en caso de un usuario con un límite X, ese X debe repartirse entre todos los procesos que quieran abrir archivos. Recordemos que un archivo no es sólo lo que está almacenado en un disco duro, es cualquier cosa susceptible de tener información guardada en un inodo. Rss: tamaño máximo de memoria residente (KB). Stack: tamaño máximo de pila (KB). Cpu: máximo tiempo de CPU (minutos). Nproc: máximo número de procesos. As: límite de espacio de direcciones (KB). Maxlogins: máximo número de logins para el <domain> excepto el de uid=0. Maxsyslogins: máximo número de logins en el sistema. Priority: la prioridad de un proceso cualquiera para ese <domain>. Locks: número máximo de archivos bloqueados, de acceso exclusivo. Sigpending: número máximo de signals pendientes. Msqqueue: memoria máxima que las colas de mensajes POSIX pueden usar (bytes). Nice: la prioridad nice a la que como mucho se puede elevar [-20,19]. Rtprio: La máxima prioridad de tiempo real permitida para procesos no privilegiados. Chroot: El directorio al que el <domain> puede hacer chroot.
Todos los items pueden tener los valores -1, unlimited o infinity para indicar que no hay límite, excepto para priority y nice.
Ejemplo:

           <domain>        <type>  <item>          <value>
           *               soft    core            0
           root            hard    core            100000
           *               hard    rss             10000
           @student        hard    nproc           20
           @faculty        soft    nproc           20
           @faculty        hard    nproc           50
           ftp             hard    nproc           0
           @student        -       maxlogins       4

Así pues, basta con poner un nproc lo suficientemente bajo que impida que el sistema quede bloqueado. Para tener una idea, podemos mirar cuántos procesos activos tenemos en el sistema. Si miramos en el monitor de sistema o ejecutamos en un terminal  ps aux --no-headers | wc -l, sabremos el número actual de procesos activos en el sistema. A mí me salen unos 220. Ya véis que con 1000 procesos de límite sobra. Por defecto, en Ubuntu los límites están configurados de la siguiente manera:

~$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 31369
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 31369
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

Es decir, prácticamente ilimitado, salvo por el número de archivos abiertos, tamaño de pila y memoria en acceso exclusivo. Si miramos qué hay en el archivo /etc/security/limits.conf, se puede ver que está todo comentado. Así pues hace falta configurarlo al gusto.

Acerca de la sintaxis de Bash

Diseccionemos la fork bomb. Para ello nos serviremos del manual de Bash[1].
Algunas definiciones:
word
A sequence of characters considered as a single unit by the shell.  Also known as a token.

reserved words
Palabras especiales que son reconocidas por Bash cuando no están citadas y o bien so la primera palabra de un simple command o bien son la tercera palabra de un case o for.
! case  do done elif else esac fi for function if in select then until while { } time [[ ]]

name
A word consisting only of alphanumeric characters and underscores, and beginning with an alphabetic character or an underscore.  Also referred  to as an identifier.

metacharacter
A character that, when unquoted, separates words.  One of the following: |  & ; ( ) < > space tab.

control operator
A token that performs a control function.  It is one of the following symbols:  | & && ; ;; ( ) | |& <newline>.

simple commands
Opcionalmente encabezadas por asignaciones de variables,  siempre habrá un conjunto de words separadas por espacios, cadena que debe terminar en un control operator. La primera palabra es el nombre del comando y las siguientes se pasan como argumentos del comando.

pipelines
Un pipeline es una secuencia de uno o varios comandos separados por los operadores de control | ó |&.

lists
A list is a sequence of one or more pipelines separated by one of the operators ;, &, &&, or ||, and optionally terminated by one of ;, &, or <newline>. [...] A sequence of one or more newlines may appear in a list instead of a semicolon (;) to delimit commands.
[...]
If a command is terminated by the control operator &, the shell executes the command in the background in a subshell. The shell does not wait for the command to finish, and the return status is 0. Commands separated by a ; are executed sequentially; the shell waits for each command to terminate in turn. The return status is the exit status of the last command executed.

compound commands
Hay varios tipos de comandos compuestos, mencionamos dos:
  • (list) La list se ejecuta en un proceso hijo.
  • { list; } La list se ejecuta en el proceso actual. Como los corchetes no son metacarácteres ni operadores de control que dividan palabras, sino que son palabras (reservadas), deben ir separados de la list para poder ser reconocidos como palabras separadas. Además, la list debe terminar en ;.
Shell Function Definitions 
A shell function is an object that is called like a simple command and executes a compound command with a new set of positional parameters.  Shell  functions are declared as follows:
name () compound-command [redirection]
function name [()] compound-command [redirection]

Es decir, que es un nombre de función seguido de un cuerpo consistente en un comando compuesto (ver arriba) y una redirección opcional. El compound-command habitualmente es una list entre corchetes curvos, { }.
Una función actúa como un almacén de comandos que se ejecutan cuando es llamada.


Con todas estas definiciones en mano, volvamos a cómo está escrita la bomba:
:(){ :|: & }; :

Teniendo en cuenta que la bomba fork usa corchetes, significa que el comando compuesto es del tipo «{ list; }», sin embargo la lista de comandos (un pipeline) no termina en «;» sino en «&» a pesar de que el manual indica que la lista de un comando compuesto con corchetes curvos debe terminar en «;».

Además se indica que las llamadas se invocan como un simple command.
Contradictorio. ¿Error en el manual? Veamos:
:(){ :|: } :
# OK, aunque se queda a la espera de introducir más órdenes.

:(){ :|: }; :
# OK, aunque se queda a la espera de introducir más órdenes.

:(){ :|: ; } :
bash: error sintáctico cerca del elemento inesperado `:'

:(){ :|: &; } :
bash: error sintáctico cerca del elemento inesperado `;'

:(){ :|: & } :
bash: error sintáctico cerca del elemento inesperado `:'
 
:(){ :|: ; }; :
# OK. La fork bomb funciona y además lo hace en la terminal actual, no en segundo plano.

Habida cuenta los errores mostrados y la definición de función que indica como opcional la posibilidad de redirigir la salida (redirection) de la función, el «;» indica final de función (como un simple command) de forma que no tome «:» como operador de redirección, algo erróneo, sin embargo no está debidamente especificado como delimitar una función ya que hay varios operadores de control y no se menciona ninguno en concreto, y un simple command puede terminar con varios operadores de control, aunque basándonos en la intuición sólo se admitan «;» y «<newline>», aunque se podría entender que si se separase con «&» esa función siempre se ejecutaría en 2º plano.
El hecho de que en los errores mostrados arriba se espere a más comandos también significa que no está correctamente terminada la función, por ejemplo el pipeline sin carácter de control final o el «;» sin indicar final de función, por lo que aunque se añada un final adecuado la sintaxis ya es errónea porque por los errores ya introducidos tampoco se interpretaría como un operador de redirección adecuado. Además, aunque el comando compuesto { list; } indique que la lista debe terminar en «;», parece que tiene prioridad la definición de list a secas, por lo que puede terminar en «&» o en «;» o en «<newline>».
Diría que es una contradicción en el manual, ya que por un lado indican la obligación de terminar en punto y coma pero luego se puede terminar la list como una lista normal, aunque eso sí, la existencia de una terminación es obligatoria (;, &, or <newline>).
Respecto a tomar «:» como un operador, no hay problema ya que Bash primero trata de buscar coincidencias con nombres de función antes de buscarlas en los builtin commands (comandos empotrados del propio Bash), así pues se toma como llamada a función ejecutándose como comando simple (al pulsar intro estamos introduciendo un <newline> de terminación y además esto provoca en una consola interactiva la ejecución de las palabras introducidas hasta el momento). Si quisieramos añadir más cosas (como killall), habría que poner un «;» primero. Es por ello que los «:» terminan en error de sintaxis si no se delimita adecuadamente la función.

Comentarios