Autor: Aldair Maihuiri
Fecha: 2 de agosto de 2026
Binario: cm1_strcmp (ELF 64-bit, sin strippear)
Herramientas: GDB, file, chmod
Tags: crackme reverse-engineering gdb x86_64 strcmp elf
© 2026 Aldair Maihuiri. Todos los derechos reservados. Puedes compartir este writeup con atribución. Prohibida la reproducción sin permiso.
Lo primero que hago antes de abrir un binario en el debugger es correr file sobre él.
Sirve para saber con qué tipo de ejecutable estoy tratando: arquitectura, si está
strippado o no, si es dinámico o estático.
$ file ./crackme
./crackme: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=b4784a7ce15cbbec0e4411e0179fb8eb4959548c,
for GNU/Linux 4.4.0, with debug_info, not stripped
ELF 64-bit, enlazado dinámicamente y sin strippear. Que no esté strippado es
conveniente porque significa que los símbolos de depuración están presentes, así que
los nombres de funciones como main o strcmp van a aparecer en el debugger
directamente.
En los siguientes crackmes quizá sea necesario verificar si están strippados o no, pero en este primer ejercicio no hace falta. De todas formas queda en el lector ir investigando ese tema para que cuando llegue el momento no le tome por sorpresa.
$ gdb ./crackme
(gdb) break main
Breakpoint 1 at 0x1721: file crackme.c, line 5.
(gdb) run
Y me dio un error bastante común cuando se trabaja con binarios descargados o clonados:
/bin/bash: line 1:
/home/house/Cracmes-main/nivel1/cm1_strcmp/crackme: Permission denied
❌ During startup program exited with code 126.
El archivo no tenía permisos de ejecución. La solución:
$ chmod +x ./crackme
Algo que hay que tener en mente cada vez que se clona un repositorio con binarios.
Con los permisos corregidos:
$ gdb ./crackme
(gdb) run
╔══════════════════════════════════════════════
║ Nivel 1 · CM01 [* ]
║ strcmp hardcodeado
╠══════════════════════════════════════════════
║ Un guardian compara tu clave directamente con strcmp.
╚══════════════════════════════════════════════
Contrasena : asdasd
El propio enunciado del crackme nos da la pista: strcmp hardcodeado. Eso ya nos dice exactamente por dónde hay que ir.
strcmp significa STRing CoMPare, o sea, comparar cadenas de texto. Es una
función estándar de C que compara dos cadenas y devuelve un entero que indica la
diferencia:
int strcmp(const char *cadena1, const char *cadena2);
| Valor devuelto | Significado | En assembly (condicional) |
|---|---|---|
| 0 | ¡Son IGUALES! | test eax, eax → je (salta si cero) |
| < 0 (negativo) | cadena1 < cadena2 | js/jl — raro verlo en la práctica |
| > 0 (positivo) | cadena1 > cadena2 | jg — raro; los compiladores usan je/jne |
Regla de oro: si strcmp devuelve 0, las cadenas son IDÉNTICAS. Todo lo que hagamos a continuación gira alrededor de ese cero.
int strcmp(const char *s1, const char *s2) {
while (*s1 && (*s1 == *s2)) {
s1++;
s2++;
}
return *(unsigned char *)s1 - *(unsigned char *)s2;
}
Esta es la lógica base. La implementación real de glibc está fuertemente optimizada — la veremos en ensamblador más adelante.
Esta vez el breakpoint va directamente en strcmp, no en main. El crackme ya nos
dijo que usa strcmp, así que saltamos directo a la comparación en lugar de caminar
todo el programa desde el inicio:
| Breakpoint | Cuándo se dispara | Qué ves |
|---|---|---|
break main |
Al inicio del programa, antes de pedir la contraseña | El programa recién arranca |
break strcmp |
En el momento exacto de la comparación | Ambas cadenas (tu input y la correcta) |
¿Por qué no usar siempre break strcmp en todos los crackmes? Porque en los más
avanzados strcmp está ofuscado, se llama indirectamente, o simplemente no se usa. Ese
es el caso normal — exponer una comparación sensible a través de una llamada de
librería con nombre visible es un punto débil obvio.
(gdb) break strcmp
(gdb) run
Metemos Hola como contraseña para avanzar la ejecución hasta el punto de comparación:
╔══════════════════════════════════════════════
║ Nivel 1 · CM01 [* ]
║ strcmp hardcodeado
╠══════════════════════════════════════════════
║ Un guardian compara tu clave directamente con strcmp.
╚══════════════════════════════════════════════
Contrasena : Hola
Breakpoint 1, 0x00007ffff7d73210 in ?? () from /usr/lib/libc.so.6
El debugger se detuvo justo cuando strcmp estaba a punto de ejecutarse.
En arquitectura de 64 bits, los argumentos de una función se pasan en registros en lugar de en la pila. Esta es una decisión de rendimiento tomada cuando los procesadores pasaron de 32 a 64 bits:
| Sistema | 1er argumento | 2do argumento | Comando en GDB |
|---|---|---|---|
| 32 bits (x86) | $esp+4 |
$esp+8 |
x/s $esp+4 |
| 64 bits (x86_64) | rdi |
rsi |
x/s $rsi |
RDI (Destination Index) y RSI (Source Index) fueron diseñados originalmente para
las instrucciones de manipulación de cadenas del 8086 (1978): movsb, stosb,
lodsb, rep movs, scasb. En esas instrucciones RSI apunta al origen y RDI al
destino.
Su uso para pasar los dos primeros argumentos de función es una convención moderna
del System V AMD64 ABI, decidida cuando se diseñó el ISA de 64 bits (~2003). No
es universal: en Windows x64 la convención es distinta — se usan RCX/RDX para los
dos primeros argumentos. Linux eligió RDI/RSI precisamente porque las
instrucciones de cadena habían caído en desuso, dejando esos registros efectivamente
libres.
Para strcmp: RDI contiene tu input, RSI contiene la contraseña correcta.
Tabla completa de registros de argumentos en x86_64:
| Argumento | Registro | Uso típico |
|---|---|---|
| 1ro | rdi | Primer parámetro |
| 2do | rsi | Segundo parámetro |
| 3ro | rdx | Tercer parámetro |
| 4to | rcx | Cuarto parámetro |
| 5to | r8 | Quinto parámetro |
| 6to | r9 | Sexto parámetro |
| 7mo+ | Pila ($rsp) |
Cuando hay más de 6 argumentos |
(gdb) x/s $rdi
0x7fffffffe490: "Hola"
(gdb) x/s $rsi
0x5555555562cf: "s3cr3t0"
La contraseña es s3cr3t0. La encontramos sin leer una sola línea de
ensamblador — deteniendo la ejecución en el momento exacto de la comparación y
leyendo los registros directamente.
Uno esperaría que ASLR aleatorice la dirección de strcmp en cada ejecución, y sin
embargo 0x7ffff7d73210 aparece idéntica entre corridas. La razón no es que ASLR
esté desactivado en el sistema — GDB desactiva ASLR por defecto para el proceso que
está depurando. Es una comodidad del debugger, no una propiedad del sistema
operativo.
La dirección 0x7ffff7... es la firma clásica de libc cargada con ASLR desactivado
bajo GDB. Con ASLR activo verías una base completamente distinta en cada ejecución.
Dentro de una misma ejecución, todas las funciones de libc tienen offsets relativos fijos respecto a esa base. Esta es la primitiva que hace posibles los ataques ret2libc: si logras filtrar un solo puntero de libc, puedes calcular la dirección de cualquier otra función restando su offset conocido.
(gdb) x/20i $rip
=> 0x7ffff7d73210: endbr64
0x7ffff7d73214: vpxor %xmm15,%xmm15,%xmm15
0x7ffff7d73219: mov %edi,%eax
0x7ffff7d7321b: or %esi,%eax
0x7ffff7d7321d: shl $0x14,%eax
0x7ffff7d73220: cmp $0xf8000000,%eax
0x7ffff7d73225: ja 0x7ffff7d73550
0x7ffff7d7322b: vmovdqu (%rdi),%ymm0
0x7ffff7d7322f: vpcmpeqb (%rsi),%ymm0,%ymm1
0x7ffff7d73233: vpcmpeqb %ymm0,%ymm15,%ymm2
0x7ffff7d73237: vpandn %ymm1,%ymm2,%ymm1
0x7ffff7d7323b: vpmovmskb %ymm1,%ecx
0x7ffff7d7323f: inc %ecx
0x7ffff7d73241: je 0x7ffff7d732a0
0x7ffff7d73243: tzcnt %ecx,%ecx
0x7ffff7d73247: movzbl (%rdi,%rcx,1),%eax
0x7ffff7d7324b: movzbl (%rsi,%rcx,1),%ecx
0x7ffff7d7324f: sub %ecx,%eax
0x7ffff7d73251: vzeroupper
0x7ffff7d73254: ret
Esto no es el loop byte a byte de la implementación simplificada. Esta es la implementación AVX2 de strcmp en glibc, que compara 32 bytes a la vez usando instrucciones SIMD:
| Dirección | Instrucción | Significado |
|---|---|---|
0x...3210 |
endbr64 |
Seguridad CET — marca destino de salto válido |
0x...3214 |
vpxor %xmm15,%xmm15,%xmm15 |
Pone ymm15 a cero (todos los bytes = 0) |
0x...3219 |
mov %edi,%eax |
Copia dirección de s1 a eax para chequeo de límite de página |
0x...321b |
or %esi,%eax |
OR con dirección de s2 (combina bits altos de ambos punteros) |
0x...321d |
shl $0x14,%eax |
Desplaza para aislar bits que indican cercanía al límite de página |
0x...3220 |
cmp $0xf8000000,%eax |
Comprueba si algún puntero está a menos de 32 bytes de un límite de página de 4KB |
0x...3225 |
ja 0x...3550 |
Si hay riesgo de cruzar página, salta a la ruta segura |
0x...322b |
vmovdqu (%rdi),%ymm0 |
Carga 32 bytes de s1 en ymm0 |
0x...322f |
vpcmpeqb (%rsi),%ymm0,%ymm1 |
Compara 32 bytes de s2 contra s1, byte por byte |
0x...3233 |
vpcmpeqb %ymm0,%ymm15,%ymm2 |
Busca bytes nulos en s1 |
0x...3237 |
vpandn %ymm1,%ymm2,%ymm1 |
ymm1 = (NOT ymm2) AND ymm1 → marca bytes iguales no nulos |
0x...323b |
vpmovmskb %ymm1,%ecx |
Extrae un bit por byte → máscara de 32 bits en ecx |
0x...323f |
inc %ecx |
Si los 32 bytes coincidieron y no hay nulo, ecx era 0xFFFFFFFF → inc lo hace 0 |
0x...3241 |
je 0x...32a0 |
ZF=1 → los 32 bytes iguales y sin nulo: salta a procesar el siguiente bloque |
0x...3243 |
tzcnt %ecx,%ecx |
Cuenta ceros al final → posición del primer byte que difiere |
0x...3247 |
movzbl (%rdi,%rcx,1),%eax |
Carga byte de s1 en la posición de divergencia |
0x...324b |
movzbl (%rsi,%rcx,1),%ecx |
Carga byte de s2 en la posición de divergencia |
0x...324f |
sub %ecx,%eax |
Resta → valor de retorno de strcmp |
0x...3251 |
vzeroupper |
Limpia registros YMM (evita penalización de transición) |
0x...3254 |
ret |
Retorna |
finish(gdb) finish
Run till exit from #0 0x00007ffff7d73210 in ?? () from /usr/lib/libc.so.6
0x000055555555578a in main () at crackme.c:12
12 if (strcmp(input, "s3cr3t0") == 0) {
Ya estamos en el punto exacto de la comparación en main. Diez instrucciones lo
deciden todo:
=> 0x55555555578a <main+116>: test %eax,%eax
0x55555555578c <main+118>: jne 0x5555555557a4 <main+142>
0x55555555578e <main+120>: lea 0xb42(%rip),%rax # "Acceso concedido"
0x555555555795 <main+127>: mov %rax,%rdi
0x555555555798 <main+130>: call 0x55555555560c <print_ok>
0x55555555579d <main+135>: mov $0x0,%eax
0x5555555557a2 <main+140>: jmp 0x5555555557b8 <main+162>
0x5555555557a4 <main+142>: lea 0xb42(%rip),%rax # "Acceso denegado"
0x5555555557ab <main+149>: mov %rax,%rdi
0x5555555557ae <main+152>: call 0x555555555691 <print_err>
1. test %eax, %eax → ¿eax == 0?
│
├── SÍ (eax == 0, las cadenas coinciden)
│ 2. jne NO salta
│ 3. lea "Acceso concedido" → rax
│ 4. mov rax → rdi
│ 5. call print_ok
│ 6. mov $0x0, %eax (return 0)
│ 7. jmp al final
│
└── NO (eax != 0, las cadenas difieren)
2. jne SALTA a 0x...57a4
8. lea "Acceso denegado" → rax
9. mov rax → rdi
10. call print_err
1 — test %eax, %eax (2 bytes: 85 c0)
AND lógico de eax consigo mismo. El resultado se descarta, pero las flags se
actualizan. Si eax == 0 → ZF = 1. Equivale a if (strcmp(...) == 0) en C.
2 — jne 0x...57a4 (2 bytes: 75 16)
Jump if Not Equal — se dispara cuando ZF = 0 (las cadenas difieren). Cálculo del
salto: 0x...578c + 2 + 0x16 = 0x...57a4. Confirmado.
3 — lea 0xb42(%rip), %rax (7 bytes: 48 8d 05 42 0b 00 00)
Carga la dirección de la cadena “Acceso concedido”. Direccionamiento relativo a RIP:
RIP apunta a la siguiente instrucción al momento del cálculo.
0x...578e + 7 = 0x...5795, luego 0x...5795 + 0xb42 = 0x...62d7.
Verificación:
(gdb) x/s 0x5555555562d7
0x5555555562d7: "Acceso concedido"
4 — mov %rax, %rdi (3 bytes: 48 89 c7)
Mueve la dirección del mensaje a rdi — registro del primer argumento para la llamada
que viene.
5 — call 0x...560c <print_ok> (5 bytes: e8 6f fe ff ff)
Llama a print_ok. Empuja la dirección de retorno (0x...579d) a la pila y salta.
Offset relativo: 0x...5798 + 5 + 0xfffffe6f = 0x...560c. Confirmado.
6 — mov $0x0, %eax (5 bytes: b8 00 00 00 00)
Establece el valor de retorno de main en 0 (éxito).
7 — jmp 0x...57b8 (5 bytes: e9 11 00 00 00)
Salto incondicional al final de main, saltándose la rama de error.
0x...57a2 + 5 + 0x11 = 0x...57b8. Confirmado.
8 — lea 0xb42(%rip), %rax (7 bytes: 48 8d 05 42 0b 00 00) — rama de error
Mismo principio, destino distinto. 0x...57a4 + 7 = 0x...57ab,
luego 0x...57ab + 0xb42 = 0x...62ed.
Verificación:
(gdb) x/s 0x5555555562ed
0x5555555562ed: "Acceso denegado"
9 — mov %rax, %rdi (3 bytes: 48 89 c7)
Mueve la dirección del mensaje de error a rdi para la llamada a print_err.
10 — call 0x...5691 <print_err> (5 bytes: e8 de fe ff ff)
Llama a print_err. 0x...57ae + 5 + 0xfffffede = 0x...5691. Confirmado.
| Dirección | Bytes (hex) | Instrucción | Registros | Tamaño | Qué hace |
|---|---|---|---|---|---|
0x...578a |
85 c0 |
test %eax,%eax |
eax, flags | 2 | Comprueba eax == 0 |
0x...578c |
75 16 |
jne 0x...57a4 |
flags | 2 | Salta si eax != 0 |
0x...578e |
48 8d 05 42 0b 00 00 |
lea 0xb42(%rip),%rax |
rip, rax | 7 | Dirección de “Acceso concedido” |
0x...5795 |
48 89 c7 |
mov %rax,%rdi |
rax, rdi | 3 | Prepara mensaje de éxito |
0x...5798 |
e8 6f fe ff ff |
call print_ok |
rip, rsp | 5 | Llama función de éxito |
0x...579d |
b8 00 00 00 00 |
mov $0x0,%eax |
rax | 5 | return 0 |
0x...57a2 |
e9 11 00 00 00 |
jmp 0x...57b8 |
rip | 5 | Salta al final |
0x...57a4 |
48 8d 05 42 0b 00 00 |
lea 0xb42(%rip),%rax |
rip, rax | 7 | Dirección de “Acceso denegado” |
0x...57ab |
48 89 c7 |
mov %rax,%rdi |
rax, rdi | 3 | Prepara mensaje de error |
0x...57ae |
e8 de fe ff ff |
call print_err |
rip, rsp | 5 | Llama función de error |
test vs cmp
test %eax, %eax y cmp $0, %eax producen las mismas flags, pero:
test es un AND lógico — cmp es una resta. test es marginalmente más rápido y es
el idiom estándar para comprobar si un valor es cero.
lea vs mov
lea calcula una dirección y la guarda sin acceder a memoria — sin riesgo de page
fault. mov lee de o escribe en memoria. lea se usa frecuentemente para aritmética
de punteros que no requiere desreferenciar.
call y la pila
call empuja la dirección de retorno a la pila antes de saltar. El ret
correspondiente la saca y salta de vuelta.
Matamos el proceso actual, ejecutamos de nuevo con la contraseña correcta, y
confirmamos que el jne no se dispara:
(gdb) kill
(gdb) run
Contrasena : s3cr3t0
Breakpoint 1, 0x00007ffff7d73210 in ?? () from /usr/lib/libc.so.6
(gdb) finish
0x000055555555578a in main () at crackme.c:12
12 if (strcmp(input, "s3cr3t0") == 0) {
(gdb) stepi
0x000055555555578c 12 if (strcmp(input, "s3cr3t0") == 0) {
(gdb) stepi
13 print_ok("Correcto! Bien hecho.");
jne no saltó. La ejecución fue directo a la línea 13 — print_ok.
Crackme 1 resuelto. La contraseña estaba hardcodeada en el binario, se pasaba
directamente como segundo argumento a strcmp, y era visible inspeccionando $rsi
en el momento exacto de la comparación.
Nota sobre verificación: los bytes de las instrucciones lea de este writeup fueron
verificados directamente en GDB con x/7xb. La aritmética de todos los saltos y
llamadas fue confirmada de forma independiente.
Parte de una serie de writeups de crackmes cubriendo binarios progresivamente más difíciles — desde comparaciones hardcodeadas hasta chequeos ofuscados, funciones hash propias y técnicas anti-debug.
Los binarios de los crackmes están disponibles en github.com/GinoMaihuiri/Crackmes
Todos los writeups: ginomaihuiri.github.io
© 2026 Aldair Maihuiri. Todos los derechos reservados. Compartir con atribución es bienvenido. Prohibida la reproducción no autorizada.