Taller de escalada de privilegios locales de Looney Tunables (CVE-2023-4911) (solo con fines educativos)
En informática, un vinculador dinámico es la parte de un sistema operativo que carga y vincula las bibliotecas compartidas que necesita un ejecutable cuando se ejecuta, copiando el contenido de las bibliotecas del almacenamiento persistente a la RAM, llenando tablas de salto y reubicando punteros.
Por ejemplo, tenemos un programa que utiliza la biblioteca openssl para calcular el hash md5:
$ head md5_hash.c
#include <stdio.h>
#include <string.h>
#include <openssl/md5.h>
ld.so analiza el binario e intenta encontrar una biblioteca relacionada con <openssl/md5.h>
$ ldd md5_hash
linux-vdso.so.1 (0x00007fffa530b000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f19cda00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f19cd81e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f19ce032000)
Como podemos ver, encuentra la biblioteca criptográfica necesaria en /lib/x86_64-linux-gnu/libcrypto.so.3 Durante el inicio del programa, coloca el código de esta biblioteca en la RAM del proceso y vincula todas las referencias a esta biblioteca.
Cuando se inicia un programa, este cargador primero examina el programa para determinar las bibliotecas compartidas que requiere. Luego busca estas bibliotecas, las carga en la memoria y las vincula con el ejecutable en tiempo de ejecución. En el proceso, el cargador dinámico resuelve referencias de símbolos, como referencias de funciones y variables, asegurando que todo esté configurado para la ejecución del programa. Dada su función, el cargador dinámico es muy sensible a la seguridad, ya que su código se ejecuta con privilegios elevados cuando un usuario local inicia un programa set-user-ID o set-group-ID.
Los sintonizables son una característica de la biblioteca GNU C que permite a los autores de aplicaciones y mantenedores de distribución alterar el comportamiento de la biblioteca en tiempo de ejecución para que coincida con su carga de trabajo. Estos se implementan como un conjunto de interruptores que pueden modificarse de diferentes maneras. El método predeterminado actual para hacer esto es a través de la variable de entorno GLIBC_TUNABLES configurándola en una cadena de pares nombre=valor separados por dos puntos. Por ejemplo, el siguiente ejemplo habilita la verificación de malloc y establece el umbral de recorte de malloc en 128 bytes:
GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3
export GLIBC_TUNABLES
Pasando --list-tunables al cargador dinámico para imprimir todos los ajustables con valores mínimos y máximos:
$ /lib64/ld-linux-x86-64.so.2 --list-tunables
glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
glibc.elision.skip_lock_after_retries: 3 (min: 0, max: 2147483647)
glibc.malloc.trim_threshold: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.perturb: 0 (min: 0, max: 255)
glibc.cpu.x86_shared_cache_size: 0x100000 (min: 0x0, max: 0xffffffffffffffff)
glibc.pthread.rseq: 1 (min: 0, max: 1)
glibc.cpu.prefer_map_32bit_exec: 0 (min: 0, max: 1)
glibc.mem.tagging: 0 (min: 0, max: 255)
Al comienzo de su ejecución, ld.so llama a __tunables_init() para recorrer el entorno (en la línea 279), buscando variables GLIBC_TUNABLES (en la línea 282); para cada GLIBC_TUNABLES que encuentra, hace una copia de esta variable (en la línea 284), llama a parse_tunables() para procesar y desinfectar esta copia (en la línea 286) y finalmente reemplaza el GLIBC_TUNABLES original con esta copia desinfectada (en la línea 288 ):
// (GLIBC ld.so sources in ./glibc-2.37/elf/dl-tunables.c)
269 void
270 __tunables_init ( char * * envp )
271 {
272 char * envname = NULL ;
273 char * envval = NULL ;
274 size_t len = 0 ;
275 char * * prev_envp = envp ;
...
279 while (( envp = get_next_env ( envp , & envname , & len , & envval ,
280 & prev_envp )) != NULL )
281 {
282 if ( tunable_is_name ( "GLIBC_TUNABLES" , envname )) // searching for GLIBC_TUNABLES variables
283 {
284 char * new_env = tunables_strdup ( envname );
285 if ( new_env != NULL )
286 parse_tunables ( new_env + len + 1 , envval ); //
287 /* Put in the updated envval. */
288 * prev_envp = new_env ;
289 continue ;
290 }
El primer argumento de parse_tunables() (tunestr) apunta a la copia de GLIBC_TUNABLES que pronto será desinfectada, mientras que el segundo argumento (valstring) apunta a la variable de entorno GLIBC_TUNABLES original (en la pila). Para desinfectar la copia de GLIBC_TUNABLES (que debe tener el formato "tunable1= aaa:tunable2=bbb"
), parse_tunables() elimina todos los sintonizables peligrosos (los sintonizables SXID_ERASE) de tunestr, pero mantiene los sintonizables SXID_IGNORE y NONE (en las líneas 221- 235):
// (GLIBC ld.so sources in ./glibc-2.37/elf/dl-tunables.c)
162 static void
163 parse_tunables ( char * tunestr , char * valstring )
164 {
...
168 char * p = tunestr ;
169 size_t off = 0 ;
170
171 while (true)
172 {
173 char * name = p ;
174 size_t len = 0 ;
175
176 /* First, find where the name ends. */
177 while ( p [ len ] != '=' && p [ len ] != ':' && p [ len ] != ' ' )
178 len ++ ;
179
180 /* If we reach the end of the string before getting a valid name-value
181 pair, bail out. */
182 if ( p [ len ] == ' ' )
183 {
184 if ( __libc_enable_secure )
185 tunestr [ off ] = ' ' ;
186 return ;
187 }
188
189 /* We did not find a valid name-value pair before encountering the
190 colon. */
191 if ( p [ len ] == ':' )
192 {
193 p += len + 1 ;
194 continue ;
195 }
196
197 p += len + 1 ;
198
199 /* Take the value from the valstring since we need to NULL terminate it. */
200 char * value = & valstring [ p - tunestr ];
201 len = 0 ;
202
203 while ( p [ len ] != ':' && p [ len ] != ' ' )
204 len ++ ;
205
206 /* Add the tunable if it exists. */
207 for ( size_t i = 0 ; i < sizeof ( tunable_list ) / sizeof ( tunable_t ); i ++ )
208 {
209 tunable_t * cur = & tunable_list [ i ];
210
211 if ( tunable_is_name ( cur -> name , name ))
212 {
...
219 if ( __libc_enable_secure )
220 {
221 if ( cur -> security_level != TUNABLE_SECLEVEL_SXID_ERASE )
222 {
223 if ( off > 0 )
224 tunestr [ off ++ ] = ':' ;
225
226 const char * n = cur -> name ;
227
228 while ( * n != ' ' )
229 tunestr [ off ++ ] = * n ++ ;
230
231 tunestr [ off ++ ] = '=' ;
232
233 for ( size_t j = 0 ; j < len ; j ++ )
234 tunestr [ off ++ ] = value [ j ];
235 }
236
237 if ( cur -> security_level != TUNABLE_SECLEVEL_NONE )
238 break ;
239 }
240
241 value [ len ] = ' ' ;
242 tunable_initialize ( cur , value );
243 break ;
244 }
245 }
246
247 if ( p [ len ] != ' ' )
248 p += len + 1 ;
249 }
250 }
Desafortunadamente, si una variable de entorno GLIBC_TUNABLES tiene la forma "tunable1=tunable2=AAA" (donde "tunable1" y "tunable2" son sintonizables SXID_IGNORE, por ejemplo "glibc.malloc.mxfast"), entonces:
durante la primera iteración de " while (true)" en parse_tunables(), todo el "tunable1=tunable2=AAA" se copia in situ en tunestr (en las líneas 221-235), llenando así tunestr;
en las líneas 247-248, p no se incrementa (p[len] es '