Recientemente, hablando con un colega del lugar donde trabajo, acerca de las mejores maneras de proteger un producto contra las técnicas de cracking, me vino el recuerdo de aquellos días en los que me interesé por primera vez en el tema, no como programador que protege su producto, sino como usuario que lo desprotege para uso propio sin costo alguno (por Dios, ¿quién ha pagado aquí por un software?). Claro que en aquel entonces mi enfoque era muy distinto al de ahora.
Resulta que una vez fui atacante, y ahora puedo ser atacado, lo cual no es un gran problema, si consideramos que conozco las armas de mi enemigo, sé cómo las usan, y más aún, las he usado. La verdad, hace ya tiempo que no practico el cracking; antes lo hacía por diversión -nunca por necesidad-, ya que todo lo que he crackeado ya ha sido trabajado. Lamentablemente, lo que no se ejercita se olvida y con el tiempo vamos perdiendo las facultades que menos usamos, así que si escribo este artículo es para no olvidar y ya de paso les brindo a ustedes la posibilidad de conocer esta arma tan fina, precisa y poderosa, que es la ingeniería inversa.
Lo que vamos a hacer es una práctica muy sencilla y bastante elemental. Probablemente muchos hayan oído hablar del método "74/75", una técnica muy básica, pero increíblemente útil y, particularmente yo, no sólo la he usado para quebrantar sistemas de verificación de números de serie, sino para muchas cosas más.
Primero lo primero, ¿a qué nos enfrentamos? Bueno, a partir de ahora nuestro enemigo número uno será aquel cuadro con dos cajas de texto y dos botones al cual le insertas algo como un nombre de usuario y un serial y mágicamente, por una razón que parece contradecir las leyes naturales que rigen nuestro mundo, casi siempre nos dice lo mismo: "Los datos suministrados no son los correctos, verifique y vuelva a intentarlo". Este es un escenario clásico, lo del nombre de usuario y el serial pude cambiar, pues algunos sólo piden una sola cosa: el serial; el mensaje de error puede ser diferente, en fin, la variedad se manifiesta entre estos odiosos cuadros de diálogo.
Segundo paso: conoce a tu enemigo, y aquí surge la segunda pregunta, ¿cómo funciona? Como ya he dicho, la variedad se manifiesta en todo su esplendor, pero limitándonos al escenario clásico, el funcionamiento es el siguiente: el programa recibe un nombre de usuario; luego, partiendo del mismo, genera un código que comparará con el número de serie; de ser iguales, ¡felicidades!, puedes usar el producto, si no, tendrás que sentarte a leer uno de esos tutoriales de cracking, lo que me recuerda por qué estás leyendo este artículo.
También está el otro caso que te pide sólo el serial, difícil de encontrar, demasiado pobre e ineficiente, muy fácil de crackear, así que ya casi nadie lo usa. Más tarde hablo de él.
Hasta ahora sabemos que al proporcionarle mi nombre al programa, éste genera el serial a partir del mismo y luego compara el que obtuvo con el que yo le dí. Llevemos eso a los códigos.
El esquema básico puede verse así:
// si el serial es bueno
// gracias por usar un buen keygen
if ( Serial_Entrado == GetSerial( Nombre_Entrado ) ) {
// quita restricciones
MessageBox( NULL, "Gracias por usar nuestro programa, bla bla", "Registro oK", NULL );
// el serial no sirve
...
} else { //si no
MessageBox( NULL, "Los datos entrados no son correctos.", "Error", NULL );
}
Los códigos pueden cambiar, pero en esencia, la mayoría lucirán como éste, en especial si el programador no quiere pasar trabajo con algoritmos complicados.
Aquí vemos cómo el nombre de usuario ( Nombre_Entrado )
es pasado como parámetro a la función imaginaria GetSerial
. Esta función se encarga de generar a partir de cada caracter de Nombre_Entrado
un string, que es nuestro número de serie. Obviamente, cada nombre tendrá su propio serial.
Pasamos ahora al plan de ataque. Ya sabemos más, sabemos que nuestra felicidad depende en estos momentos de un if
, o sea, una condicional. Por muchos intentos que tengamos disponibles, las posibilidades de acertar con el verdadero código son irrisorias, por lo tanto, ¿qué hacemos? Pues invertimos la condicional de la siguiente manera:
//si el serial NO es bueno
//gracias por registrarte
if ( Serial_Entrado != GetSerial( Nombre_Entrado ) ) {
//quita restricciones
MessageBox( NULL, "Gracias por usar nuestro programa bla bla", "Registro oK", NULL );
//mensaje de error
...
} else { //si es bueno
MessageBox( NULL, "Los datos entrados no son correctos.", "Error", NULL );
}
¿Dónde esta el cambio? El operador de la condicional cambió de ==
a !=
-para aquellos que no conozcan el lenguaje C, !=
equivale a <>
en otros lenguajes-. ¡Listo!, ahora cualquier serial que escribamos será válido, cualquiera excepto el verdadero :0. Bueno, qué más da, si las posibilidades de acertar son despreciables.
Es aquí donde todos me acusan de loco y me recuerdan que no tengo el código fuente del programa. Lo cierto es que quizás no tengamos los códigos en C, ni en lenguajes de alto nivel, pero sin dunda alguna tenemos los códigos en ensamblador, y es allí donde vamos a hacer los cambios, directamente en el hexadecimal del programa.
Aquellos que conocen de ensamblador, sabrán que las condicionales se definen usando las instrucciones JE
, JZ
("Jump If Equal", "Jump If Zero") y JNE
, JNZ
("Jump If Not Equal", "Jump If Not Zero") seguido de la dirección de saltos y antecedido de un TEST
o un CMP
, que determina lo que se está comparando.
El caso es que JE
, codificado en hexadecimal, es 74 y JNE
, 75; de ahí el nombre "74/75" para esta técnica. Cambiaremos un JE
por un JNE
, o un JNE
por un JE
, 74 a 75 ó 75 a 74, la idea es invertir la condicional a nuestro favor.
Ya concluyendo la introducción del artículo y entrando en el área práctica, haré un comentario. Para tener éxito en el mundo de la ingeniería inversa, poseer conocimientos de ensamblador es fundamental; mientras mejor domines el ASM, mejor podrás rastrear el código de un programa, hasta el punto de sentirte tan cómodo leyendo el código en ASM como si lo estuvieras leyendo en C o en otro lenguaje de alto nivel. No obstante, si no sabes nada de ASM, sigue leyendo igual. Cuando yo empecé en el tema del cracking tampoco sabía nada de ensamblador, de hecho, mis inicios en ASM fueron gracias al cracking. Como principiante no necesitas saber escribir programas en ASM para tener éxito, pero sí es muy importante que sepas cómo funciona lo básico, que conozcas las principales instrucciones, los registros del microprocesador, etc. Luego, con el tiempo, puedes profundizar y, si te interesa, empezar a usarlo para programar.
Vamos a la práctica. Para empezar, permítanme presentarles a nuestro enemigo, un pequeño CrackMe que acabo de preparar para este fin; servirá bien de conejillo de indias. Este es el código en C:
/*
**
**Nombre: CrackMe.c
**Autor: h0aX
**Fecha: 17-julio-2007
**
**Descripción: Nuestro conejillo de indias
**
**
*/
#include
#pragma hdrstop
#pragma argsused
HWND hwndMain, hwndButton1,
hwndButton2, hwndButton3,
hwndEditUser, hwndEditSerial;
HINSTANCE hInst;
void GetSerial( char *szName, char *szSerial )
{
int i, cont;
memset( szSerial, 0, 255 );
cont = 0;
/* Aquí se genera el serial a partir del
** nombre de usuario. Este es un ejemplo
** sencillo, así que simplemente invertiremos
** el nombre de usuario y le ponemos un punto
** al principio de la cadena, ej:
** usuario: test serial: .tset
*/
for ( i = strlen(szName) ; i >= 0; i-- ) {
strcat(szSerial,".");
// botón registrar
szSerial[cont] = szName[i] ;
cont++;
}
}
LRESULT CALLBACK WndProc( HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam )
{
switch (uMsg) {
case WM_COMMAND: {
// usuario entrado
if (hwndButton1 == (HWND) lParam) {
char szUserName[255];
// serial verdadero
char szSerial[255];
// serial entrado
char szSerial_Entrado[255];
// obtiene los datos
// chequea si no hay nada en blanco
GetWindowTextA( hwndEditUser, szUserName, 255 );
GetWindowTextA( hwndEditSerial, szSerial_Entrado, 255 );
if ( ( !strcmp( szSerial_Entrado, "" ) ) || ( !strcmp( szUserName, "" ) ) ) {
MessageBoxA( hwndMain, "Datos en blanco.", "Error", MB_ICONERROR );
return 0;
}
// genera el serial a partir del usuario
// y he aquí nuestra preciosa condicional
GetSerial( szUserName, szSerial );
// quita restricciones, bla bla ...
if ( !strcmp( szSerial_Entrado, szSerial ) ) {
MessageBoxA( hwndMain, "Serial correcto, felicidades.", "Registro oK", MB_ICONINFORMATION );
// botón cancelar
} else {
MessageBoxA( hwndMain, "Los datos no son correctos.", "Error", MB_ICONERROR );
}
} else
// botón acerca de
if (hwndButton2 == (HWND) lParam) {
PostQuitMessage(0);
} else
if (hwndButton3 == (HWND) lParam) {
MessageBoxA( hwndMain, "CrackMe por h0aX para \r\nla revista "
"BlackHat,\r\npruebas de ingenieria inversa."
"\r\n\r\n[17-julio-2007]",
"CrackMe por h0aX", MB_ICONINFORMATION );
}
return 0;
}
case WM_DESTROY: {
PostQuitMessage(0);
return 0;
}
}
return DefWindowProcA(hwnd,uMsg,wParam,lParam);
}
WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
MSG msg;
WNDCLASS wndclass;
char szClassName[] = "hXClass_CrackMe";
hInst = hInstance;
wndclass.style = CS_VREDRAW | CS_HREDRAW;
wndclass.lpfnWndProc = WndProc;
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = hInstance;
wndclass.hIcon = LoadIconA(NULL, IDI_APPLICATION);
wndclass.hCursor = LoadCursorA(NULL, IDC_ARROW);
wndclass.hbrBackground = GetSysColorBrush(COLOR_BTNFACE);
wndclass.lpszMenuName = NULL;
wndclass.lpszClassName = szClassName;
RegisterClassA(&wndclass);
hwndMain = CreateWindow(szClassName,"CrackMe por h0aX", WS_SYSMENU | WS_MINIMIZEBOX | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT, 290, 180, NULL, NULL, hInstance, 0);
CreateWindow("Static","Nombre de usuario:", WS_VISIBLE | WS_CHILD, 15,10, 250,15, hwndMain, 0, hInst, NULL);
hwndEditUser = CreateWindowEx(WS_EX_CLIENTEDGE,"Edit","", WS_VISIBLE | WS_CHILD | ES_AUTOHSCROLL, 15,30, 250,22, hwndMain, 0, hInst, NULL);
CreateWindow("Static","Serial:", WS_VISIBLE | WS_CHILD , 15,60, 250,15, hwndMain, 0, hInst, NULL);
hwndEditSerial = CreateWindowEx(WS_EX_CLIENTEDGE,"Edit","", WS_VISIBLE | WS_CHILD | ES_AUTOHSCROLL , 15,80, 250,22, hwndMain, 0, hInst, NULL);
hwndButton1 = CreateWindow("Button","Registrar", WS_VISIBLE | WS_CHILD, 15,110, 75,25, hwndMain, 0, hInst, NULL);
hwndButton2 = CreateWindow("Button","Cancelar", WS_VISIBLE | WS_CHILD, 90, 110, 75,25, hwndMain, 0, hInst, NULL);
hwndButton3 = CreateWindow("Button","Acerca de", WS_VISIBLE | WS_CHILD, 190, 110, 75,25, hwndMain, 0, hInst, NULL);
ShowWindow(hwndMain, nCmdShow);
while (GetMessageA(&msg, NULL,0,0)) {
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
return msg.wParam;
}
Seguimos con las herramientas. Nuestro arsenal personal debe estar compuesto básicamente de:
- un desensamblador
- un depurador
- un editor hexadecimal
El desensamblador es una herramienta que, como su nombre indica, desensambla un programa, o sea, nos muestra su código ensamblador de modo que nosotros podamos estudiarlo. Particularmente yo uso el W32Dasm.
El depurador (o debugger) es una herramienta que nos permite no sólo desensamblar un programa, sino también correrlo, ponerle breakpoints en determinadas posiciones del código, efectuar cambios en el código cargado en memoria, observar los valores de los registros, la pila; todo al mismo tiempo que lo corremos. Como debugger uso y recomiendo el OlyDbg.
El editor hexadecimal nos permite ver el código en sus valores hexadecimales, además de hacer cambios en ellos para luego guardarlos en disco. Yo uso WinHex.
Y ahora que nuestras armas han sido introducidas procedemos a utilizarlas.
Conociendo al enemigo::
Si ejecutamos nuestro conejillo de indias nos encontraremos con dos cajas de texto y tres botones; los textos para el nombre y el serial, un botón para registrar el programa, otro para cancelar y el último para los créditos del programa.
Como si fuéramos dueños de la situación, enviamos el foco del teclado a las cajas de texto, tecleamos nombre de usuario "yo", serial "1234567890", y le damos al botón Registrar. Tal como sospechamos, el programa no se intimidó ante la confianza que mostramos, pues nos ha dado un mensaje de error con el texto "Los datos no son correctos". Muchos pensarán que este texto no les sirve de mucho.
Entonces procedemos a desensamblar el programa con el desensamblador, a ver qué encontramos de interés. Lo que aparece es una cantidad de código impresionante, mucho más de lo que hemos escrito en C. Entre tantas líneas en ASM sería una locura encontrar la instrucción específica de la condicional que buscamos. Pues que no cunda el pánico, en el menú superior del W32Dasm hay un botón llamado StrnRef.
Si pulsamos en él nos mostrará una ventana con una lista de mensajes, que no son más que todas las referencias de strings que usa el programa; es decir, todas las cadenas de caracteres que aparezcan en el programa se encontrarán allí. Si damos doble clic sobre una de ellas, nos lleva exactamente a la instrucción donde se le hizo referencia. Si se hace referencia al mismo texto en más de una instrucción, por cada vez que se la haga doble clic irá saltando a la siguiente referencia. Ahora vemos que aquel texto de error no era tan inservible. Busquemos el texto; no es tan difícil encontrarlo ya que la lista está ordenada alfabéticamente.
Una vez localizado, hacemos doble clic para que nos lleve directamente a la instrucción que la referenció y vamos a parar directamente a la dirección 0040135Ch
dentro del código, la cual corresponde a una instrucción PUSH
, que estaba metiendo en la pila la dirección de memoria que contenía el texto. En el desensamblador que usen verán que cada instrucción corresponde con una dirección en el código; ésta se encuentra al extremo izquierdo con formato 00000000h
(la h
del final es para saber que el número está en hexadecimal). Normalmente, el código de las aplicaciones Win32 comienza en 00400000h
. La segunda columna nos muestra los valores hexadecimales del código, y la tercera el código como tal en ASM.
Importante saber que cada instrucción en ASM tiene un equivalente en hexadecimal, por ejemplo, la instrucción PUSH EBX
siempre será 51h
, es decir, 51 en hexadecimal.
Si somos lo suficientemente curiosos para seguir explorando las referencias de strings encontraremos otra muy interesante, "Serial correcto, felicidades". Si le damos doble clic, iremos a parar a 00401343h
, sólo a unas pocas instrucciones más arriba que la referencia al texto de error. ¿Qué me dice todo esto?: que estamos en la zona caliente. Comencemos a rastrear hacia atrás a partir de las referencias y casi inmediatamente en la posición 0040133Ah
encontramos una instrucción JNE
, ¡bingo!, sin analizarla podría apostar por ella, no obstante, mejor estar seguro.
La instrucción dice JNE 00401355
, es decir, que si una determinada condición no se cumple salte a la dirección 00401335h
; luego, si buscamos esta nueva posición veremos que queda exactamente atrás del mensaje de registro satisfactorio. En pocas palabras, si la condición no se cumple salta el mensaje bueno y ve al mensaje malo, de lo contrario muestra el mensaje bueno. Ahora, para ver esta situación mucho más de cerca, copiemos la dirección de la condicional, 0040133A
, abrimos el depurador OlyDbg y cargamos el programa allí. Una vez ejecutado veremos la pantalla dividida en varias áreas, donde arriba a la izquierda se encuentra el código ASM. Si allí hacemos click derecho, menú "Go To", submenú "Expression" y nos aparecerá un cuadro de diálogo. Allí ponemos la dirección de la instrucción JNE, 0040133A, damos OK y nos envía directamente a la instrucción deseada.
Ahora vamos a analizar el código con detenimiento.
00401338 |. 85C0 TEST EAX,EAX
; /Style = MB_OK|MB_ICONASTERISK|MB_APPLMODAL
0040133A |. 75 19 JNZ SHORT 00401355
0040133C |. 6A 40 PUSH 40
; |Title = "Registro oK"
0040133E |. 68 EDA04000 PUSH 0040A0ED
; |Text = "Serial correcto, felicidades."
00401343 |. 68 CFA04000 PUSH 0040A0CF
; |hOwner = NULL
00401348 |. FF35 C8C64000 PUSH DWORD PTR DS:[40C6C8]
; \MessageBoxA
0040134E |. E8 BB800000 CALL JMP.&USER32.MessageBoxA
; /Style = MB_OK|MB_ICONHAND|MB_APPLMODAL
00401353 |. EB 4E JMP SHORT 004013A3
00401355 |> 6A 10 PUSH 10
; |Title = "Error"
00401357 |. 68 15A14000 PUSH 0040A115
; |Text = "Los datos no son correctos."
0040135C |. 68 F9A04000 PUSH 0040A0F9
; |hOwner = NULL
00401361 |. FF35 C8C64000 PUSH DWORD PTR DS:[40C6C8]
; \MessageBoxA
00401367 |. E8 A2800000 CALL JMP.&USER32.MessageBoxA
Para que no se pierda nadie, a los que no sepan ASM aún les recuerdo que JNZ
es exactamente lo mismo que JNE
, de hecho ambos se codifican como 75h
, al igual que JE
y JZ
se codifican ambos como 74h
.
Aquí vemos que al llegar a JNZ
, en caso que la condicional resulte 1 salta a 00401355h
y muestra el mensaje de error; de lo contrario, si resulta 0, continúa la ejecución sin saltar, hace lo que nos interesa y llega a un JMP
en 00401353h
que se asegura de no ejecutar el mensaje de error después de mostrar el de serial correcto (JMP
: jump, salta a una dirección del código).
Ahora posicionémonos sobre la instrucción TEST EAX,EAX
en OlyDbg y presionemos F2; con ello pondremos un breakpoint en ese lugar. Luego presionemos F9, el programa se abrirá y nos mostrará el diálogo para introducir los datos. Una vez más llenamos las cajas de texto con cualquier cosa, démosle a Registrar y el programa se detiene justo en el breakpoint, debido a que la ejecución ha llegado a esa instrucción del código. Ahora, para ir corriendo el programa de instrucción en instrucción vamos presionando F8. Aquí veremos detalladamente cómo al llegar al JNZ
salta a 401355h
, justo lo que no queremos que haga.
Carguemos nuevamente el programa; en OlyDbg tienes un botón en la esquina superior izquierda con dos flechas "<<" que nos permite resetear la ejecución. Una vez más nos dirigimos a la instrucción en cuestión, pero ahora vamos a hacer unos cambios en los códigos antes de ejecutar el programa. Haciendo doble clic en una instrucción te permitirá editarla; es importante saber que estos cambios se realizan en memoria, no en disco. Cambiemos JNZ SHORT 00401355
por JZ SHORT 00401355
y presionemos el botón Assemble, tecleemos F9 e introduzcamos cualquier texto en ambos campos, ¡voilá!, ya está: el programa ha aceptado el serial.
Para finalizar, lo único que nos queda es llevar los cambios al disco. Esto lo haremos con el editor hexadecimal. Primeramente retornemos al W32Dasm y posicionémonos sobre la instrucción que nos interesa cambiar (el JNZ
). Si miramos abajo, en la barra de estado, podremos ver el offset de la instrucción (en este caso 93Ah
, los ceros a la izquierda no cuentan).
Abrimos el WinHex. En él cargamos el programa, en el menú principal vamos a Position » Go To Offset y escribimos 93A, presionamos OK, y nos lleva directo a un número 75 entre todo ese código.
¿Pues qué estamos esperando? Cambia el 75
por un 74
y guarda los cambios en File » Save. Luego abre el programa, introdúcele cualquier cosa y dale Registrar. Ya lo tenemos, el programa está parcheado.
Con todo esto lo que hemos logrado es cambiar el código del programa para que haga lo que nosotros queramos, no lo que su programador le ordenó hacer. Me gustaría decir que aquí teníamos otras opciones además del método "74/75"; podríamos haber sustituido la instrucción JNZ SHORT 00401355
por NOP NOP
. Esto vendría a ser otra técnica: "nopear". Con los NOP
le decimos al microprocesador que no haga nada, que pase a la siguiente instrucción; de este modo lograríamos que el programa se registrara incluso aunque se escribiera el serial correcto. En este caso habría que cambiar dos números del hexadecimal y sustituirlos por 90 90
, que corresponde a decir NOP NOP
en ASM.
Tal como mencioné al inicio, esta es una técnica elemental; un buen cracker nunca tratará de cambiar el código del programa que está trabajando. En vez de eso, el trabajo más fino consiste en buscar la parte del código que genera el serial, o sea, la función GetSerial()
, analizarla, estudiarla y entenderla para luego hacer un programa que haga todo lo que ésta hace, es decir, un keygen o keymaker. Aun así, parchear a veces se vuelve necesario cuando la función GetSerial()
está demasiado protegida o complicada.
Este es el escenario más sencillo que podemos encontrarnos. Aun así, es bastante común. La mayoría de los programadores no le prestan atención a la seguridad de sus productos a la hora de protegerlos. De cualquier forma, cuando te detienes a parchear un programa puedes encontrar cualquier cosa: algunos están protegidos con algoritmos de compactación (yo siempre uso UPX para hacerlos pequeños, más que por protegerlos), lo cual podría ser un problema si eres principiante; otros programas hacen cálculos al iniciarse para verificar que su código no ha sido cambiado (esto creo que me lo encontré en el mIRC), en fin, los modos de protegerse son muchos. Sin embargo, nunca faltarán esos programadores que de ingeniería inversa no han oído hablar absolutamente nada.
Por otra parte, en ocasiones aunque el programa no esté protegido, te podrá ser muy difícil rastrear el código hasta la condicional correcta. A veces hay que tener mucha paciencia y sentarse algunas horas a leer el ensamblador para comprender dónde está la condicional que buscamos. La complicación puede tener dos causas: o el programador tuvo siempre muy clara la idea de la ingeniería inversa o simplemente fue muy desordenado al escribir su código. Pues sí, las chapucerías en lenguajes de alto nivel resultan en grandes cantidades inservibles de códigos ASM.
Respecto a los programas que sólo te piden el serial, ¿no te imaginas la condicional?
if ( Serial_Entrado == "MI-SE-RI-AL" ) {
/* ... */
}
Busca en las referencias de strings del W32Dasm; cuando encuentres un texto que parezca un serial se lo pones al programa y ¡listo!. Las aplicaciones que "hardcodean" el serial son difíciles de encontrar; esto podría darte una idea de cómo quebrantar la seguridad del programa que hizo tu vecino y que te pide un password.
Para los que piensen que estos conocimientos sólo son útiles para quebrantar sistemas de verificación de números de serie, hace un año, mientras cursaba el tercero de informática, decidieron que las prácticas se harían en la escuela. La idea era poner a todos los estudiantes a llenar una base de datos de un censo. El trabajo se efectuaba desde un programa cliente que se encontraba en todas las computadoras y en él se llenaban los formularios de datos. La tarea era realmente desagradable y tediosa y, para mayor fastidio, había que entrar al sistema con una cuenta propia de la cual quedaba un log de quién trabajó y quién no en el servidor. Los alumnos obviamente tenían cuentas limitadas, mientras los profesores tenían cuentas más abiertas que permitían hacer cambios en los logs y otras cosas. Para no aburrir, usuario/serial, usuario/contraseña, ¿cuál es la diferencia?, si tu contraseña es correcta, ¿no depende de una condicional?. Mientras todos en mi escuela sufrieron meses de trabajo inhumano con este censo, yo solo trabajé un día: el primero; al final, para mi sorpresa, salí destacado, pues mis logs mostraban un trabajo infatigable, digno de reconocimientos.
Como verán, el empleo de esta técnica no está limitado a seriales. En cuanto tenga otra tarde libre escribo más sobre técnicas de ingeniería inversa.
No hay comentarios:
Publicar un comentario