Cómo resolver un crackme paso a paso: Crackme 001 en x64dbg

Autor: Diego J D Arias · Plataforma: Windows x64 · Herramientas: x64dbg (IDA opcional) · Nivel: principiante Descargar el CrackMe En este tutorial resolvemos el crackme, sin saber de antemano cuál es la clave, probando, equivocándonos y verificando cada suposición con el depurador. La idea es que puedas seguirlo con el binario abierto y que, al terminar, te lleves un método que sirva para otros crackmes.

Qué necesitás: el crackme, x64dbg y una calculadora.

Importante: Trabajá siempre en una máquina virtual aislada (red host-only o desconectada) y con un snapshot que incluya la memoria.


Miramos el programa antes de abrir el depurador

Ejecutamos el crackme y aparece una ventana con cuatro cuadros de texto y un botón Register. Evidentemente la clave está partida en cuatro partes.

Ventana del programa con el mensaje de éxito

Probamos algo con valores fáciles de reconocer después, dentro del código, como por ejemplo 777777777 888888888 999999999 111111111

¿Por qué esos? Porque si más adelante vemos un 7777 o un 1111 en un registro, sabemos de qué cuadro viene, es conveniente evitar valores como 1234 repetidos en todos los cuadros, ya que seria dificil de distinguir.

Si al apretar Register. No pasa nada visible, o aparece un mensaje de error. Tenemos que averiguar qué se está comprobando.

Consejo: Antes de depurar, debemos probár el programa y anotár qué se ve. Te da strings, tamaños de campos y mensajes que después podemos utilizar para buscar referencias.


Ventana del programa con el mensaje de éxito

Como llegar a la función de validación

Abrimos el crackme en x64dbg. Hay dos formas rápidas de llegar a la función que valida la clave:

  • Desde los strings: clic derecho → Search for → Current Module → String references. Buscamos el texto del mensaje de éxito, seguimos la referencia y subimos hasta el código que decide el resultado.
  • Desde la API de entrada: ponemos un breakpoint en GetDlgItemTextW, que es la función de Windows que lee el texto de un cuadro y cuando se detiene, miramos quién la llamó.

En este caso, buscaremos el string Failed.

Ventana del programa con el mensaje de éxito

Aqui vemos dos string interesante la de Failed y Success, vayamos a Success y veamos que hay.

[ ASM ][Copiar]
00007FF735B6183E | FF15 EC180000         | call qword ptr ds:[<DefWindowProcW>]   |
00007FF735B61844 | E9 8A000000           | jmp crackme.001.7FF735B618D3           |
00007FF735B61849 | E8 C2F9FFFF           | call crackme.001.7FF735B61210          |
00007FF735B6184E | 45:33C9               | xor r9d,r9d                            |
00007FF735B61851 | 4C:8D05 201C0000      | lea r8,qword ptr ds:[7FF735B63478]     | 00007FF735B63478:L"Registration Result"
00007FF735B61858 | 33C9                  | xor ecx,ecx                            |
00007FF735B6185A | 84C0                  | test al,al                             |
00007FF735B6185C | 74 0F                 | je crackme.001.7FF735B6186D            |
00007FF735B6185E | 48:8D15 3B1C0000      | lea rdx,qword ptr ds:[7FF735B634A0]    | 00007FF735B634A0:L"Success"
00007FF735B61865 | FF15 3D180000         | call qword ptr ds:[<MessageBoxW>]      |
00007FF735B6186B | EB 64                 | jmp crackme.001.7FF735B618D1           |
00007FF735B6186D | 48:8D15 3C1C0000      | lea rdx,qword ptr ds:[7FF735B634B0]    | 00007FF735B634B0:L"Failed"
00007FF735B61874 | FF15 2E180000         | call qword ptr ds:[<MessageBoxW>]      |

Si miramos un poco mas arriba, vemos un je crackme.001.7FF735B6186D el cual decide si es Success o Failed, un poco mas arriba hay uncall crackme.001.7FF735B61210, que es la que vamos a analizar. Desde acá vamos a trabajar con las direcciones de ese ejecutable (las tuyas pueden cambiar si el módulo se carga en otra base).

Consejo: no leas el programa desde el principio. Empezá por los extremos, la API que lee la entrada o el mensaje que informa el resultado.


[ ASM ][Copiar]
00007FF735B61210 | 48:8BC4               | mov rax,rsp                            |
00007FF735B61213 | 48:8958 10            | mov qword ptr ds:[rax+10],rbx          |
00007FF735B61217 | 48:8970 18            | mov qword ptr ds:[rax+18],rsi          |
00007FF735B6121B | 48:8978 20            | mov qword ptr ds:[rax+20],rdi          |
00007FF735B6121F | 55                    | push rbp                               |
00007FF735B61220 | 41:54                 | push r12                               |
00007FF735B61222 | 41:55                 | push r13                               |
00007FF735B61224 | 41:56                 | push r14                               |
00007FF735B61226 | 41:57                 | push r15                               |
00007FF735B61228 | 48:8D68 A1            | lea rbp,qword ptr ds:[rax-5F]          |
00007FF735B6122C | 48:81EC A0000000      | sub rsp,A0                             |
00007FF735B61233 | 0F2970 C8             | movaps xmmword ptr ds:[rax-38],xmm6    |
00007FF735B61237 | 48:8B05 CA3D0000      | mov rax,qword ptr ds:[7FF735B65008]    |
00007FF735B6123E | 48:33C4               | xor rax,rsp                            |

Todo eso es ruido del compilador: guarda registros, arma el marco de pila y coloca el stack cookie (protección /GS). Hay una única pista útil: movaps xmmword ptr ds:[rax-38],xmm6 . Se guarda xmm6 porque la función va a usar números de punto flotante más adelante. Lo anotamos y seguimos.

También vemos un GetDlgItem y un GetWindowTextLength, pero el resultado se pisa enseguida con xor eax,eax: no se usa. Ruido también.

[ ASM ][Copiar]
00007FF735B61245 | 48:8BD9               | mov rbx,rcx                            |
00007FF735B61248 | BA 79000000           | mov edx,79                             | 79:'y'
00007FF735B6124D | 48:8B0D A43E0000      | mov rcx,qword ptr ds:[7FF735B650F8]    |
00007FF735B61254 | FF15 961E0000         | call qword ptr ds:[<GetDlgItem>]       |
00007FF735B6125A | 48:8BC8               | mov rcx,rax                            |
00007FF735B6125D | FF15 551E0000         | call qword ptr ds:[<GetWindowTextLengt |
00007FF735B61263 | 33C0                  | xor eax,eax                            |

Consejos: siempre debemos reconocé el ruido y saltalo. Prólogo, cookie y resultados que se descartan no cuentan.


Lo interensante cómo se lee la entrada

Acá empieza lo interesante. Aparece cuatro veces este patrón:

[ ASM ][Copiar]
00007FF735B61265 | 48:8945 D7            | mov qword ptr ss:[rbp-29],rax          |
00007FF735B61269 | 66:8945 DF            | mov word ptr ss:[rbp-21],ax            |
00007FF735B6126D | 44:8D48 05            | lea r9d,qword ptr ds:[rax+5]           | cchMax = 5
00007FF735B61271 | 4C:8D45 D7            | lea r8,qword ptr ss:[rbp-29]           | buffer donde se guarda el texto
00007FF735B61275 | 8D50 79               | lea edx,qword ptr ds:[rax+79]          | ID del control (0x79)
00007FF735B61278 | 48:8BCB               | mov rcx,rbx                            | handle de la ventana
00007FF735B6127B | FF15 671E0000         | call qword ptr ds:[<GetDlgItemTextW>]  |
00007FF735B61281 | 83F8 04               | cmp eax,4                              | si devolvió menos de 4 caracteres -> falla
00007FF735B61284 | 0F82 0B050000         | jb crackme.001.7FF735B61795            |

Leemos los argumentos de GetDlgItemTextW(hDlg, nIDDlgItem, lpString, cchMax), que en x64 van en rcx, rdx, r8 y r9. Y sacamos tres conclusiones:

  1. cchMax = 5: se leen como máximo 4 caracteres más el nulo. Aunque escribimos 9 dígitos, el programa ve solo los primeros 4. Nuestro 777777777 en realidad entra como 7777.
  2. cmp eax,4 / jb: si el cuadro devuelve menos de 4 caracteres, salta al camino de fallo. Cada cuadro necesita exactamente 4 caracteres útiles (más no importa).
  3. Los cuatro controles tienen IDs 0x79, 0x7C, 0x7A y 0x7B, en ese orden de lectura:
OrdenIDBuffer en la pila
1°0x79[rbp-29]
2°0x7C[rbp-19]
3°0x7A[rbp-9]
4°0x7B[rbp+7]

Ojo con algo: el orden de lectura no tiene por qué coincidir con el orden visual de los cuadros. Dejamos ese tema pendiente y lo comprobamos enseguida.

Consejo: leé los argumentos de las APIs. Ahí aparecen restricciones (largos máximos, IDs, etc).


La conversión a número, parece un laberinto!

Después de leer los textos, el código se vuelve largo y confuso. Aparecen bucles, comparaciones con 7FFFFFFFFFFFFFFE, un memcpy, llamadas a funciones desconocidas y bloques que parecen liberar memoria. Antes de asustarse, conviene hacerse una pregunta:

[ ASM ][Copiar]
00007FF735B61320 | 48:FFC3               | inc rbx                                |
00007FF735B61323 | 66:393C58             | cmp word ptr ds:[rax+rbx*2],di         |
00007FF735B61327 | 75 F7                 | jne crackme.001.7FF735B61320           |
00007FF735B61329 | 49:BE FEFFFFFFFFFFFF7 | mov r14,7FFFFFFFFFFFFFFE               |
00007FF735B61333 | 49:3BDE               | cmp rbx,r14                            |
00007FF735B61336 | 0F87 93040000         | ja crackme.001.7FF735B617CF            |
00007FF735B6133C | 41:BC 0A000000        | mov r12d,A                             | 0A:'\n'
00007FF735B61342 | 48:C745 CF 07000000   | mov qword ptr ss:[rbp-31],7            |
00007FF735B6134A | 48:83FB 08            | cmp rbx,8                              |
00007FF735B6134E | 73 1E                 | jae crackme.001.7FF735B6136E           |
00007FF735B61350 | 48:895D C7            | mov qword ptr ss:[rbp-39],rbx          |
00007FF735B61354 | 48:03DB               | add rbx,rbx                            |
00007FF735B61357 | 4C:8BC3               | mov r8,rbx                             |
00007FF735B6135A | 48:8D55 D7            | lea rdx,qword ptr ss:[rbp-29]          |
00007FF735B6135E | 48:8D4D B7            | lea rcx,qword ptr ss:[rbp-49]          |
00007FF735B61362 | E8 FB180000           | call <JMP.&memcpy>                     |
00007FF735B61367 | 66:897C1D B7          | mov word ptr ss:[rbp+rbx-49],di        |
00007FF735B6136C | EB 4E                 | jmp crackme.001.7FF735B613BC           |
00007FF735B6136E | 4C:8BFB               | mov r15,rbx                            |
00007FF735B61371 | 49:83CF 07            | or r15,7                               |
00007FF735B61375 | 4D:3BFE               | cmp r15,r14                            |
00007FF735B61378 | 76 05                 | jbe crackme.001.7FF735B6137F           |
00007FF735B6137A | 4D:8BFE               | mov r15,r14                            |
00007FF735B6137D | EB 07                 | jmp crackme.001.7FF735B61386           |
00007FF735B6137F | 4D:3BFC               | cmp r15,r12                            |
00007FF735B61382 | 4D:0F42FC             | cmovb r15,r12                          |
00007FF735B61386 | 49:8D57 01            | lea rdx,qword ptr ds:[r15+1]           |
00007FF735B6138A | 48:8D4D B7            | lea rcx,qword ptr ss:[rbp-49]          |
00007FF735B6138E | E8 9D050000           | call crackme.001.7FF735B61930          |
00007FF735B61393 | 48:8BF8               | mov rdi,rax                            |
00007FF735B61396 | 48:8945 B7            | mov qword ptr ss:[rbp-49],rax          |
00007FF735B6139A | 48:895D C7            | mov qword ptr ss:[rbp-39],rbx          |
00007FF735B6139E | 4C:897D CF            | mov qword ptr ss:[rbp-31],r15          |
00007FF735B613A2 | 48:03DB               | add rbx,rbx                            |
00007FF735B613A5 | 4C:8BC3               | mov r8,rbx                             |
00007FF735B613A8 | 48:8D55 D7            | lea rdx,qword ptr ss:[rbp-29]          |
00007FF735B613AC | 48:8BC8               | mov rcx,rax                            |
00007FF735B613AF | E8 AE180000           | call <JMP.&memcpy>                     |

¿Qué hace este bloque con el dato que importa?

Si miramos solo lo esencial, el patrón es este:

[ ASM ][Copiar]
00007FF735B613BC | 45:8BC4               | mov r8d,r12d                           | r8d = 10 (la base)
00007FF735B613BF | 33D2                  | xor edx,edx                            | edx = 0
00007FF735B613C1 | 48:8D4D B7            | lea rcx,qword ptr ss:[rbp-49]          | rcx = un string temporal
00007FF735B613C5 | E8 D6050000           | call crackme.001.7FF735B619A0          | <-- esto devuelve un número en eax
00007FF735B613CA | 44:8BE8               | mov r13d,eax                           | el resultado se guarda en r13d

Con argumentos (string, 0, 10) y un número como retorno, esto huele a std::stoi(string, nullptr, 10): convertir texto a entero en base 10. Es una inferencia, no un hecho confirmado, así que más adelante la comprobamos.

Todo lo demás del bloque es mantenimiento de un std::wstring de C++:

  • el bucle inc rbx / cmp word [...],0 / jne es un wcslen en línea,
  • 7FFFFFFFFFFFFFFE es el max_size() del string,
  • la comparación con 8 decide entre guardar el texto en el buffer interno (SSO) o reservar memoria en el heap, y con 4 caracteres siempre va por el buffer interno,
  • el bloque con cmp rdx,1000 / add rdx,27 / cmp rax,1F es el destructor del string, que con 4 caracteres se salta,
  • el call [_invalid_parameter_noinfo_noreturn] seguido de un int3 es un camino de error que nunca debería ejecutarse.

Y aparece algo que ayuda mucho: el mismo bloque se repite cuatro veces, una por cada cuadro. Entonces lo entendemos una vez y para los demás miramos solo qué cambia: el buffer de origen y el registro donde cae el resultado.

ConversiónFuenteResultado en
1ª[rbp-29] (ID 0x79)r13d
2ª[rbp-19] (ID 0x7C)r12d
3ª[rbp-9] (ID 0x7A)r15d
4ª[rbp+7] (ID 0x7B)ebx

Estos cuatro registros no se pisan después: eso es la señal de que se van a usar. Los llamamos A, B, C y D según el ID de control: A = 0x79, B = 0x7A, C = 0x7B, D = 0x7C.

Consejo: cuando un bloque se repite, entendelo una sola vez. Y los registros que sobreviven después de un call son los datos que más adelante importan.


Verificar con el depurador.

Acá es donde conviene dejar de deducir y mirar. Ponemos un breakpoint en la instrucción que sigue al segundo call 19A0, en 7FF7499E14C2, y ejecutamos. Con la entrada del paso 1, esperábamos ver en eax el valor del segundo cuadro (8888, que en hexadecimal es 0x22B8).

Vemos otra cosa: eax = 457.

Recordemos que x64dbg muestra los registros en hexadecimal: 0x457 = 1111. Es decir, el segundo número que lee el programa no viene de nuestro segundo cuadro, sino del cuarto (el que tenía 111111111).

Dos cosas quedan confirmadas con eso:

  • La función es stoi en base 10, porque el resultado de convertir "1111" fue 1111.
  • El orden de lectura no es el visual: el ID 0x7C corresponde al cuarto cuadro.

Esto nos deja un mapeo parcial. Del ID 0x7C estamos seguros; de los otros tres solo podemos suponer, por ser IDs consecutivos (0x79, 0x7A, 0x7B), que siguen el orden visual. Lo anotamos como suposición pendiente.

Consejo: una deducción no es un hecho hasta que se confirma con un breakpoint. Un solo vistazo a eax justo después de un call vale más que diez minutos de interpretar instrucciones.


Busquemo la validación, que es lo que realmente importa

Pasamos todo el destructor y llegamos a 7FF735B61700. Y acá cambia el tipo de código: ya no hay manejo de strings, hay aritmética con números de punto flotante. Esta es la parte que decide.

Aparece tres veces este patrón de nueve instrucciones:

[ ASM ][Copiar]
00007FF735B61700 | 6641:0F6EF5           | movd xmm6,r13d                         | el número del usuario, como int
00007FF735B61705 | F3:0FE6F6             | cvtdq2pd xmm6,xmm6                     | lo convierte a double
00007FF735B61709 | F2:0F100D C71D0000    | movsd xmm1,qword ptr ds:[7FF735B634D8] | xmm1 = exponente
00007FF735B61711 | F2:0F1005 C71D0000    | movsd xmm0,qword ptr ds:[7FF735B634E0] | xmm0 = base
00007FF735B61719 | E8 68150000           | call <JMP.&pow>                        | xmm0 = pow(base, exponente)
00007FF735B6171E | F2:0F5905 A21D0000    | mulsd xmm0,qword ptr ds:[7FF735B634C8] | xmm0 = xmm0 * constante
00007FF735B61726 | 66:0F2EF0             | ucomisd xmm6,xmm0                      | ¿usuario == calculado?
00007FF735B6172A | 7A 69                 | jp crackme.001.7FF735B61795            |
00007FF735B6172C | 75 67                 | jne crackme.001.7FF735B61795           | si no coincide -> falla

Cómo se lee:

  • El número del usuario va a xmm6, que sobrevive a la llamada a pow (por eso el prólogo lo había guardado).
  • En Windows x64, pow(double, double) recibe la base en xmm0 y el exponente en xmm1.
  • ucomisd compara dos doubles y jp/jne saltan a fail si no son exactamente iguales.

Para resolver necesitamos los valores de esas constantes.

Leamos las constantes, y ver que nos dicen.

En x64dbg: Ctrl+G → 7FF735B634C0. En el dump, clic derecho → Float → Double. El dump muestra dos valores por fila:

Ventana del programa con el mensaje de éxito

DirecciónValor
7FF735B634C00.125
7FF735B634C80.25
7FF735B634D00.5
7FF735B634D84
7FF735B634E08

Bueno debemos hacer las cuentas

Resolvemos cada condición con la calculadora, cuidando bien qué es base y qué es exponente:

| Campo          | Cálculo                           | Resultado |
|----------------|-----------------------------------|-----------|
| A (0x79, r13d) | pow(8, 4) × 0.25  = 4096 × 0.25   |    1024   |
| B (0x7A, r15d) | pow(8, 4) × 0.5   = 4096 × 0.5    |    2048   |
| D (0x7C, r12d) | pow(4, 8) × 0.125 = 65536 × 0.125 |    8192   |
| C (0x7B, ebx)  | cmp ebx,200 → 0x200               |    512    |

Hay un detalle que no hay que pasar por alto: en la condición D, base y exponente están invertidos respecto a A y B (se cargan 34D8 y 34E0 en el orden contrario). Por eso el resultado es 65536 y no 4096.

La última condición es la más simple: cmp ebx,200 seguido de sete al. Si todo coincide, al = 1 (clave correcta); si algo falló, el salto a 7FF735B61795 hace xor al,al y devuelve 0.

Consejo: la lógica real es la que toca los datos del usuario: comparaciones, aritmética y constantes. Cuando el código solo verifica contra una expresión constante, la clave se obtiene resolviendo la expresión: no hay que “revertir” nada.


Ahora demos armar la clave y probarla

Tenemos los cuatro valores. Falta recordar la restricción del paso 4: cada cuadro necesita al menos 4 caracteres, y 512 tiene solo 3. Lo escribimos con un cero adelante: 0512. stoi("0512", 10) devuelve 512, así que sirve.

Con la suposición de que los IDs siguen el orden visual:

CuadroValor
1° (0x79)1024
2° (0x7A)2048
3° (0x7B)0512
4° (0x7C)8192

Los ingresamos y apretamos Register:

Ventana del programa con el mensaje de éxito

Aparece “Registration Result: Success”. La suposición del orden era correcta.

Si hubiera fallado, el plan de respaldo era simple: dejar 8192 en el cuarto cuadro (ya confirmado) y probar las 6 permutaciones de 1024, 2048 y 0512 en los tres primeros. Son muy pocas combinaciones.


Resumen: el recorrido en una línea

Probar el programa → llegar a la función por la API de entrada o el mensaje de éxito → saltar el ruido → leer las restricciones de la entrada → entender el molde repetido de conversión → verificar con un breakpoint → resolver la comparación final con las constantes → probar.

Método para el próximo crackme

  1. Probá el programa con valores reconocibles.
  2. Empezá por los extremos (API de entrada o string de éxito), no por el principio.
  3. Reconocé y salteá el ruido del compilador.
  4. Leé los argumentos de las APIs: ahí están las restricciones.
  5. Los bloques repetidos se entienden una vez; después solo mirás qué cambia.
  6. Seguí los registros que sobreviven: son los datos importantes.
  7. Verificá con breakpoints en lugar de confiar en una deducción.
  8. Cuando solo hay que cumplir una comparación, resolvé la expresión.

Ideas para practicar

  • Escribir un keygen en Python (acá es trivial porque la clave es fija, pero te crea el hábito).
  • Parchear sete al (0F 94 C0) por mov al,1 para que siempre devuelva éxito.
  • Probar el atajo del string de éxito en el siguiente crackme, sin ayuda, y comparar cuánto camino te ahorra.