Encryption Bot
7 minutos de lectura
Se nos proporciona un binario llamado chall y la salida flag.enc:
$ file chall
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=581371f680358611dc4e8d77b03858bd4c780174, for GNU/Linux 3.2.0, stripped
$ cat flag.enc
9W8TLp4k7t0vJW7n3VvMCpWq9WzT3C8pZ9Wz
$ xxd flag.enc
00000000: 3957 3854 4c70 346b 3774 3076 4a57 376e 9W8TLp4k7t0vJW7n
00000010: 3356 764d 4370 5771 3957 7a54 3343 3870 3VvMCpWq9WzT3C8p
00000020: 5a39 577a Z9Wz
Ingeniería inversa
Si lo abrimos en Ghidra, veremos esta función main (renombrada desde FUN_0010163e, ya que el binario no tiene símbolos, stripped):
int main() {
FILE *fp;
char text[32];
banner();
printf("\n\nEnter the text to encrypt : ");
__isoc99_scanf("%s", text);
fp = fopen("data.dat", "r");
if (fp != NULL) {
system("rm data.dat");
}
putchar(L'\n');
test_length(text);
dummy();
weird(text);
enc();
system("rm data.dat");
putchar(L'\n');
return 0;
}
Básicamente, pide el texto para cifrar. Luego, abre un archivo llamado data.dat. Si el archivo ya existe (el proceso de abrir no falla), el programa lo elimina.
Después de eso, la variable text se pasa a test_length (renombrada desde FUN_001015dc):
void test_length(char *string) {
size_t length;
length = strlen(string);
if ((int) length != 27) {
puts("I\'m encrypt only specific length of character.");
puts("(-_-) Find it (-_-)");
/* WARNING: Subroutine does not return */
exit(1);
}
}
Como dice el nombre de la función, verifica que la longitud del texto de entrada sea de 27 caracteres.
Pasemos a una función que renombré como weird desde FUN_0010131d:
int weird(char *string) {
size_t length;
ulong ii;
undefined local_868[2000];
int string_int[31];
int i;
i = 0;
while (true) {
ii = (ulong) i;
length = strlen(string);
if (length <= ii) break;
string_int[i] = (int) string[i];
encode(string_int[i], local_868);
i = i + 1;
}
dummy();
return 0;
}
Obsérvese que hay algunas funciones que renombré como dummy porque estaban vacías:
void dummy() {
return;
}
Centrándonos en weird, la función solo toma los caracteres de string (que era el texto de entrada) y los transforma en valores enteros. Luego, los valores enteros se pasan a encode (renombrada desde FUN_001011d9) uno por uno:
int encode(int plaintext_byte) {
int pt;
uint bits[20];
FILE *fp;
int j;
int i;
fp = fopen("data.dat", "a");
pt = plaintext_byte;
for (i = 0; i < 8; i++) {
bits[i] = pt % 2;
pt /= 2;
}
for (j = 7; j > -1; j--) {
fprintf(fp, "%d", (ulong) bits[j]);
}
fclose(fp);
return 0;
}
Aquí, la función toma el carácter como entero y obtiene su representación binaria (primer bucle for). Luego, los bits se escriben en el archivo data.dat.
Solo para verificarlo, escribamos la función anterior en Python:
$ python3 -q
>>> pt = ord('H')
>>> pt
72
>>> f'{pt:08b}'
'01001000'
>>>
>>> bits = []
>>>
>>> for _ in range(8):
... bits.append(pt % 2)
... pt //= 2
...
>>> bits
[0, 0, 0, 1, 0, 0, 1, 0]
>>>
>>> for i in range(7, -1, -1):
... print("%d" % bits[i], end='')
...
01001000
Perfecto, entonces encode solo imprime la representación binaria de un carácter en data.dat.
Después de llamar a weird, hay una función renombrada a enc (desde FUN_001014ba):
void enc() {
int c;
int bits[400];
int n;
char cc;
FILE *fp;
int j;
int res;
int ii;
int i;
fp = fopen("data.dat", "r+");
dummy();
for (i = 1; i < 217; i++) {
c = fgetc(fp);
cc = (char) c;
if (cc == '0') {
bits[i - 1] = 0;
} else if (cc == '1') {
bits[i - 1] = 1;
}
if (i != 0) {
if (i % 6 == 0) {
res = 0;
ii = i;
for (j = 0; ii = ii + -1, j < 6; j = j + 1) {
n = two_pow(j);
res = res + bits[ii] * n;
}
print_enc(res);
}
}
}
fclose(fp);
}
Esta función utiliza el archivo data.dat, que contiene el texto de entrada en representación binaria. Está leyendo el archivo carácter a carácter, y establece un vector llamado bits a 0 o 1. Cuando el índice del carácter (bit) es un múltiplo de 6, la función realiza más operaciones:
if (i % 6 == 0) {
res = 0;
ii = i;
for (j = 0; ii = ii + -1, j < 6; j = j + 1) {
n = two_pow(j);
res = res + bits[ii] * n;
}
print_enc(res);
}
Hay una función llamada two_pow (FUN_001013ab) ya que two_pow(n) devuelve
int two_pow(int n) {
int i;
int ret;
ret = 1;
for (i = n; i != 0; i--) {
ret *= 2;
}
dummy();
return ret;
}
Solamente porque:
Entonces, el bucle for de la función enc en realidad transforma los bits en un número decimal, que se guarda en res. Ese bucle for podría expresarse en términos matemáticos como:
Finalmente, print_enc (FUN_001013e9):
undefined8 print_enc(int res) {
long i;
undefined8 *puVar1;
undefined8 local_198;
undefined8 local_190;
undefined8 local_188;
undefined8 local_180;
undefined8 local_178;
undefined8 local_170;
undefined8 local_168;
undefined8 local_160;
undefined8 local_158[42];
local_198 = 0x5958575655545352;
local_190 = 0x363534333231305a;
local_188 = 0x4544434241393837;
local_180 = 0x4d4c4b4a49484746;
local_178 = 0x6463626151504f4e;
local_170 = 0x6c6b6a6968676665;
local_168 = 0x74737271706f6e6d;
local_160 = 0x7a7978777675;
puVar1 = local_158;
for (i = 42; i != 0; i--) {
*puVar1 = 0;
puVar1 = puVar1 + 1;
}
putchar((int) *(char *) ((long) &local_198 + (long) res));
return 0;
}
En esta función tenemos una cadena de texto en formato hexadecimal. Podemos imprimirlo como bytes usando pwntools (en formato little-endian):
$ python3 -q
>>> from pwn import p64
>>> string = b''.join(map(p64, [0x5958575655545352, 0x363534333231305a, 0x4544434241393837, 0x4d4c4b4a49484746, 0x6463626151504f4e, 0x6c6b6a6968676665, 0x74737271706f6e6d, 0x7a7978777675]))
>>> string
b'RSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQabcdefghijklmnopqrstuvwxyz\x00\x00'
La función print_enc toma el valor en res y lo usa como un offset para imprimir un solo carácter de la cadena anterior.
Análisis estático
Vamos a echar un vistazo a la flag cifrada:
9W8TLp4k7t0vJW7n3VvMCpWq9WzT3C8pZ9Wz
El carácter 9 aparece en el índice 18:
>>> string.index(b'9')
18
Y este número se obtuvo de 6 bits:
>>> f'{18:06b}'
'010010'
Luego, W está en la posición 5:
>>> string.index(b'W')
5
>>> f'{5:06b}'
'000101'
Hagamos otro más:
>>> string.index(b'8')
17
>>> f'{17:06b}'
'010001'
Si unimos las salidas binarias, reconstruiremos data.dat:
010010000101010001
Separando los bits en trozos de 8 bits (1 byte), obtendremos parte de la flag:
Binary: 01001000 01010100 01
Hex: 48 54
Byte: H T
Análisis dinámico
En este punto, podemos intentar ejecutar el binario con una flag de prueba (con 27 caracteres):
$ ./chall
####### ######
# # # #### ##### # # ##### ##### # #### # # # # #### #####
# ## # # # # # # # # # # # # # ## # # # # # #
##### # # # # # # # # # # # # # # # # ###### # # #
# # # # # ##### # ##### # # # # # # # # # # # #
# # ## # # # # # # # # # # # ## # # # # #
####### # # #### # # # # # # #### # # ###### #### #
Enter the text to encrypt : HTB{AAAAAAAAAAAAAAAAAAAAAA}
9W8TLqWS7BWS7BWS7BWS7BWS7BWS7BWS7BWz
En comparación con flag.enc se ve muy similar:
Test: 9W8TLqWS7BWS7BWS7BWS7BWS7BWS7BWS7BWz
flag.enc: 9W8TLp4k7t0vJW7n3VvMCpWq9WzT3C8pZ9Wz
Aquí, descubrí que el binario solo está codificando los bits en una especie de Base64. Es base 64 porque se necesitan 6 bits (
Flag
En este punto, tomé el proceso mostrado antes y encontré la flag en representación binaria. Después de eso, lo único que hay que hacer es ponerlo en bytes:
$ python3 -q
>>> from pwn import p64
>>>
>>> string = b''.join(map(p64, [0x5958575655545352, 0x363534333231305a, 0x4544434241393837, 0x4d4c4b4a49484746, 0x6463626151504f4e, 0x6c6b6a6968676665, 0x74737271706f6e6d, 0x7a7978777675]))
>>>
>>> with open('flag.enc', 'rb') as f:
... enc = f.read()
...
>>> enc
b'9W8TLp4k7t0vJW7n3VvMCpWq9WzT3C8pZ9Wz'
>>>
>>> bits = ''
>>> for c in enc:
... index = string.index(bytes([c]))
... bits += f'{index:06b}'
...
>>> bits
'010010000101010001000010011110110011001101101110010000110111001001111001011100000101010000110001001100000100111001011111010101110011000101110100010010000101111101000010001100010101010001110011001000010010000101111101'
>>> hex(int(bits, 2))
'0x4854427b336e437279705431304e5f573174485f4231547321217d'
>>> bytes.fromhex(hex(int(bits, 2))[2:])
b'HTB{3nCrypT10N_W1tH_B1Ts!!}'