Ingeniería Inversa del Solitario (Windows XP): Forzando victorias y manipulando la visibilidad del tablero con Ghidra
root@SynAckLabs:~# ./show_index.sh
1. Introducción
En esta entrada voy a explicar el proceso de análisis de dos aspectos del Solitario de Windows mediante ingeniería inversa con Ghidra. El objetivo es documentar cómo identificar y alterar comportamientos específicos del juego directamente desde el binario.
El write-up se divide en dos objetivos principales:
- Localización de funciones ocultas: Identificar y activar el comando
"Force a win", una funcionalidad no documentada que permite forzar la victoria. Veremos cómo rastrear este comando desde los strings del programa hasta su implementación en el código. - Modificación de la lógica de renderizado: Analizar cómo el programa gestiona la visibilidad de las cartas en el tablero. El reto aquí es modificar la lógica que controla las columnas para que todas las cartas se muestren boca arriba, eliminando la restricción que solo permite ver la última carta de cada pila.
2. Parte I: Activando el comando oculto “Force a win”
2.1. Análisis de strings: Primer contacto con la lista de cadenas definidas.
Lo primero que hice al abrir el binario en Ghidra fue ir a lo seguro. Como no sabía por dónde empezar a buscar funciones ocultas, decidí echar un vistazo a los strings del programa. Me fui al menú Window y seleccioné Defined Strings. Mi idea era ver si los desarrolladores habían dejado algún mensaje de debug o algún nombre interesante que me diera una pista de qué hace el juego por detrás.

Al abrir la lista de strings, me puse a investigar un poco. La mayoría parecían nombres de funciones estándar de Windows, pero de repente vi algo que me llamó la atención: “Force a win”.

2.2. Identificación del recurso: ID 1010.
La presencia de esta cadena sugiere la existencia de una funcionalidad no documentada que fuerza la victoria. Al hacer doble clic sobre ella, Ghidra me llevó a su ubicación en la sección de recursos. Allí se observa que pertenece a una tabla de strings con identificadores numéricos. Concretamente, “Force a win” tiene asignado el ID 1010 (Rsrc String ID 1010).

2.3. Conversión de bases: Localizando el valor hexadecimal 0x03F2.
Antes de seguir buscando en el código, me detuve un momento. Como Ghidra y el lenguaje ensamblador suelen trabajar con valores hexadecimales, necesitaba saber cómo se escribe 1010 en hex. Convertimos el ID a hexadecimal (0x3F2). Ya tengo el valor que debo buscar en las instrucciones del programa: 0x3f2.

2.4. Rastreo en el desensamblado: Localización del manejador de comandos
Con el valor 0x3F2 como objetivo, realicé una búsqueda en el código desensamblado. En la dirección 010019d2 aparecía la instrucción CMP EAX, 0x3f2, justo antes de un salto condicional JZ hacia la etiqueta LAB_01001a35. Esta comparación es típica de un manejador de comandos: el programa evalúa si el parámetro recibido coincide con el ID 1010 y, en caso afirmativo, desvía el flujo hacia la rutina correspondiente.



2.5. Análisis del flujo de victoria y el “gatillo” del Deal
Seguí el salto hasta la etiqueta LAB_01001a35 para ver qué pasaba allí. Me encontré con un bloque de código que hace un XOR ESI, ESI (una forma rápida de poner a cero un registro) y luego hace varios PUSH, incluyendo el valor 0x12. Aunque todavía me cuesta entender exactamente qué hace cada instrucción de la pila, este parece ser el camino que toma el juego para saltar directamente a la pantalla de victoria.

Investigando el código cercano para entender qué más podíamos controlar, identifiqué el valor 1000 (en hex 0x3E8). Este ID es el “gatillo” que se activa cuando el usuario pulsa “Deal” (Repartir). En condiciones normales, este evento llama a una función que yo mismo renombré como RESET_TIME, encargada de limpiar el tablero para una partida nueva. También vi menciones a 0x3e9 y 0x3ea. Estos IDs corresponden probablemente a otras funciones internas, como reiniciar el tiempo o activar modos de depuración.

2.6. Secuestro de flujo: Convirtiendo el reinicio en victoria
Con el conocimiento de que el ID 1000 dispara la función de repartir (RESET_TIME), decidí realizar un secuestro de flujo (hijacking) utilizando la herramienta Patch Instruction de Ghidra. El objetivo era simple: redirigir una acción legítima del usuario hacia la función de victoria que acababa de descubrir.
El proceso técnico consistió en lo siguiente:
- Identificación del salto: Localicé la instrucción de salto condicional que se ejecuta cuando el programa detecta el ID 1000.
- Redirección del destino: Modifiqué la dirección de memoria de ese salto para que, en lugar de apuntar a la rutina de reinicio, saltara directamente a la etiqueta
LAB_01001a35(el bloque que activa la animación de victoria).
Gracias a este parche, el comportamiento del binario cambia por completo: ahora, cada vez que se pulsa “Nueva partida”, el programa omite la lógica de inicio y ejecuta la rutina de victoria. Esto demuestra cómo, alterando un solo salto en el desensamblado, podemos forzar al software a ejecutar funciones fuera de su contexto original.





3. Parte II: Ingeniería de la visibilidad de las cartas
3.1. Descartando el motor gráfico: Limitaciones de Cards.dll y CDT_Draw.
Para entender cómo el juego muestra las cartas, empecé por lo más lógico: buscar qué librerías externas utiliza para dibujar. En la tabla de importaciones encontré Cards.dll y, dentro de ella, la función CDT_Draw. Mi razonamiento fue que, si encontraba dónde se llama a esta función, podría interceptar el momento en que se decide si una carta se dibuja boca arriba o boca abajo. Sin embargo, al profundizar en las llamadas, me di cuenta de que cdtDraw solo recibe parámetros y dibuja; la lógica que decide si una carta va boca arriba o boca abajo está en otro lado. Necesitaba encontrar el código que toma esas decisiones.



3.2. La técnica de la constante conocida: Buscando el “7” en la lógica de columnas.
Decidí cambiar de enfoque: en lugar de seguir el rastro de las funciones gráficas, me centré en la lógica del tablero. El solitario clásico tiene 7 columnas en la zona de juego, así que el programa seguramente itera sobre esas columnas. Si hay un bucle que recorre las columnas, debe haber una comparación con el número 7. Busqué en todo el código desensamblado la instrucción CMP con el valor 0x7 y encontré varios candidatos.

¡Bingo! Entre los resultados, encontré una sección de código donde se comparaba el registro EAX con 0x7. Esta comparación (CMP EAX, 7) no parecía ser un valor aleatorio; estaba justo antes de un salto condicional que controlaba un bucle importante. Sabía que estaba en el lugar correcto: esta es la función que gestiona el barrido de las 7 columnas del tablero.

3.3. Anatomía del bucle de renderizado: Cómo el juego barre el tablero.
Al analizar la función que barre las 7 columnas, identifiqué un bucle interno encargado de procesar las cartas de cada pila. La lógica es interesante: el programa recupera el número total de cartas en la columna y utiliza un contador para iterar sobre ellas.

La clave de la visibilidad reside en una comparación crítica: el juego contrasta el índice de la carta actual con el índice de la última carta de la pila.
- La condición: Si el índice es igual al total (la carta de la cima), el flujo continúa hacia la rutina de visibilidad.
- El salto condicional: En ensamblador, esto es un
CMPseguido de unJNZ(Jump if Not Zero). Si la carta no es la última, elJNZhace que el programa “salte” por encima delCALLque marca la carta como visible.
Al neutralizar este JNZ, obligo al programa a ejecutar el CALL que da visibilidad para todas las cartas, sin importar su posición.


4. El Parche Final: Neutralización del salto: Uso de NOPs para revelar todo el tablero.
El parche consiste en sustituir los bytes del JNZ por dos instrucciones NOP (No Operation). Al hacer esto, el flujo de ejecución se vuelve lineal: el “portero” que impedía mostrar las cartas inferiores desaparece, y el motor gráfico recibe la instrucción de renderizar la cara de cada carta de la columna. El resultado es un tablero completamente destapado desde el inicio de la partida.



Conclusión
Hemos recorrido dos caminos diferentes dentro del ejecutable del Solitario: primero localizamos y activamos un comando oculto que fuerza la victoria, y después modificamos la lógica de dibujado para hacer visibles todas las cartas. A lo largo del proceso hemos puesto en práctica varias técnicas fundamentales de ingeniería inversa.
Estas técnicas nos han permitido descubrir mecanismos internos del juego que no están visibles en la interfaz normal, demostrando cómo el análisis directo del binario puede revelar funcionalidades ocultas y ofrecer un control completo sobre el comportamiento de la aplicación.