hxp CTF 2022: Secure Flag Dispenser

We’re given a dispense binary and some captured traffic.

Examining Executable File

Decompiling the binary immediately reveals strings BN_CTX_new and BN_new, hinting towards OpenSSL being statically linked into the executable image. OpenSSL lets us comfortably recover symbols by extracting function names, file names, and line numbers from run-time assertions and comparing with the structure of the code structure in source:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  unsigned __int64 v3; // r14
  FILE *flag_fd; // rax
  FILE *v5; // rbx
  FILE *psk_fd; // rax
  FILE *v7; // rbx
  int sock; // eax MAPDST
  int v10; // eax
  int v11; // r15d
  const char *enc_msg_bs; // r12
  __int64 j; // rbp
  __int64 i; // rbp
  __int64 result; // rax
  const char *v16; // rdi
  unsigned __int64 v17; // [rsp+8h] [rbp-40h]
  const unsigned __int8 *randbytes; // [rsp+10h] [rbp-38h]

  v3 = __readfsqword(0xFFFFFFE0);
  __writefsqword(0xFFFFFFE0, v3 - 1680);
  *(_QWORD *)(v3 - 1632) = 3097821186LL;
  *(_QWORD *)(v3 - 1624) = 0LL;
  memset((void *)(v3 - 1280), 0, 0x401uLL);
  *(_OWORD *)(v3 - 1600) = 0LL;
  *(_OWORD *)(v3 - 1584) = 0LL;
  *(_OWORD *)(v3 - 1568) = 0LL;
  *(_OWORD *)(v3 - 1552) = 0LL;
  *(_OWORD *)(v3 - 1648) = 0LL;
  *(_OWORD *)(v3 - 1664) = 0LL;
  *(_DWORD *)(v3 - 4) = 16;
  *(_OWORD *)(v3 - 1680) = 0LL;
  flag_fd = fopen("flag.txt", "rb");
  if ( !flag_fd )
    goto LABEL_26;
  v5 = flag_fd;
  randbytes = (const unsigned __int8 *)(v3 - 1680);
  if ( !fread((void *)(v3 - 1600), 1uLL, 0x40uLL, flag_fd) )
  {
LABEL_27:
    v16 = "fread\n";
    goto LABEL_37;
  }
  fclose(v5);
  psk_fd = fopen("psk", "rb");
  if ( !psk_fd )
  {
LABEL_26:
    v16 = "fopen\n";
LABEL_37:
    perror(v16);
    result = 0xFFFFFFFFLL;
    goto LABEL_38;
  }
  v7 = psk_fd;
  if ( !fread((void *)(v3 - 1648), 1uLL, 0x10uLL, psk_fd) )
    goto LABEL_27;
  v17 = v3 - 1600;
  fclose(v7);
  sock = socket(2, 1, 0);
  if ( sock == -1 )
  {
    v16 = "socket";
    goto LABEL_37;
  }
  *(_DWORD *)(v3 - 252) = 1;
  if ( setsockopt(sock, 1, 2, (const void *)(v3 - 252), 4u) == -1 )
  {
    v16 = "setsockopt";
    goto LABEL_37;
  }
  if ( bind(sock, (const struct sockaddr *)(v3 - 1632), 0x10u) == -1 )
  {
    v16 = "bind";
    goto LABEL_37;
  }
  if ( listen(sock, 1337) == -1 )
  {
    v16 = "listen";
    goto LABEL_37;
  }
  v10 = accept(sock, (struct sockaddr *)(v3 - 1664), (socklen_t *)(v3 - 4));
  if ( v10 == -1 )
  {
    v16 = "accept";
    goto LABEL_37;
  }
  v11 = v10;
  if ( (unsigned int)getentropy(randbytes, 16LL) == -1 )
  {
    v16 = "getentropy";
    goto LABEL_37;
  }
  getnameinfo((const struct sockaddr *)(v3 - 1664), *(_DWORD *)(v3 - 4), (char *)(v3 - 1280), 0x401u, 0LL, 0, 8);
  if ( !(unsigned int)ossl_a2ucompare((void *)(v3 - 1280), "🔒.hxp.io") )
  {
    *(_OWORD *)(v3 - 1304) = 0LL;
    *(_OWORD *)(v3 - 1320) = 0LL;
    *(_OWORD *)(v3 - 1336) = 0LL;
    *(_OWORD *)(v3 - 1352) = 0LL;
    *(_OWORD *)(v3 - 1368) = 0LL;
    *(_OWORD *)(v3 - 1384) = 0LL;
    *(_OWORD *)(v3 - 1400) = 0LL;
    *(_OWORD *)(v3 - 1416) = 0LL;
    *(_OWORD *)(v3 - 1432) = 0LL;
    *(_OWORD *)(v3 - 1448) = 0LL;
    *(_OWORD *)(v3 - 1464) = 0LL;
    *(_OWORD *)(v3 - 1480) = 0LL;
    *(_OWORD *)(v3 - 1496) = 0LL;
    *(_OWORD *)(v3 - 1512) = 0LL;
    *(_OWORD *)(v3 - 1528) = 0LL;
    *(_DWORD *)(v3 - 1288) = 0;
    strcpy((char *)(v3 - 1616), "hxp{n0t_4_fl4g}");
    if ( !AES_set_encrypt_key((const unsigned __int8 *)(v3 - 1648), 128, (AES_KEY *)(v3 - 1528)) )
    {
      ((void (__fastcall *)(unsigned __int64, unsigned __int64, __int64, unsigned __int64, unsigned __int64, __int64))AES_cbc_encrypt)(
        v17,
        v17,
        64LL,
        v3 - 1528,
        v3 - 1616,
        1LL);
      for ( i = 0LL; i != 64; ++i )
        dprintf(v11, "%02hhx", *(unsigned __int8 *)(v3 + i - 1600));
      dprintf(v11, "\n");
      goto LABEL_25;
    }
LABEL_34:
    v16 = "enc";
    goto LABEL_37;
  }
  ctx = BN_CTX_new();
  if ( !ctx )
  {
    v16 = "BN_CTX_new";
    goto LABEL_37;
  }
  rsa_n = BN_new();
  rsa_e = BN_new();
  msg = BN_new();
  enc_msg = BN_new();
  if ( !enc_msg || !rsa_n || !rsa_e || !msg )
  {
    v16 = "BN_new";
    goto LABEL_37;
  }
  BN_hex2bn(
    &rsa_n,
    "6878703c33796f753c336878700000000000000000000000009debf6dfc0d75203bde49a1f3ba3d949f08fa84b6d0a770f730306c32d17956654"
    "4333df001dcf71d1030707aabf254b8777e1c9c0d857133d5ccabfa0232858588e17ba23dc2a34880ced41edb9f0814695e6ab4ba2d5fc758f43"
    "1de65e8e34b508a1facda541");
  BN_hex2bn(&rsa_e, "309");
  msg = BN_bin2bn(randbytes, 16LL, msg);
  BN_mod_exp(enc_msg, msg, rsa_e, rsa_n, ctx);
  enc_msg_bs = (const char *)BN_bn2hex(enc_msg);
  dprintf(v11, "%s\n", enc_msg_bs);
  *(_OWORD *)(v3 - 248) = 0LL;
  *(_OWORD *)(v3 - 232) = 0LL;
  *(_OWORD *)(v3 - 216) = 0LL;
  *(_OWORD *)(v3 - 200) = 0LL;
  *(_OWORD *)(v3 - 184) = 0LL;
  *(_OWORD *)(v3 - 168) = 0LL;
  *(_OWORD *)(v3 - 152) = 0LL;
  *(_OWORD *)(v3 - 136) = 0LL;
  *(_OWORD *)(v3 - 120) = 0LL;
  *(_OWORD *)(v3 - 104) = 0LL;
  *(_OWORD *)(v3 - 88) = 0LL;
  *(_OWORD *)(v3 - 72) = 0LL;
  *(_OWORD *)(v3 - 56) = 0LL;
  *(_OWORD *)(v3 - 40) = 0LL;
  *(_OWORD *)(v3 - 24) = 0LL;
  *(_DWORD *)(v3 - 8) = 0;
  strcpy((char *)(v3 - 1616), "hxp{n0t_4_fl4g}");
  if ( AES_set_encrypt_key(randbytes, 128, (AES_KEY *)(v3 - 248)) )
    goto LABEL_34;
  ((void (__fastcall *)(unsigned __int64, unsigned __int64, __int64, unsigned __int64, unsigned __int64, __int64))AES_cbc_encrypt)(
    v17,
    v17,
    64LL,
    v3 - 248,
    v3 - 1616,
    1LL);
  for ( j = 0LL; j != 64; ++j )
    dprintf(v11, "%02hhx", *(unsigned __int8 *)(v3 + j - 1600));
  dprintf(v11, "\n");
  OPENSSL_free(enc_msg_bs, "dispense.c", 499LL);
  BN_free((__int64 *)enc_msg);
  BN_free((__int64 *)msg);
  BN_free((__int64 *)rsa_e);
  BN_free((__int64 *)rsa_n);
  BN_CTX_free(ctx);
LABEL_25:
  close(v11);
  result = 0LL;
LABEL_38:
  __writefsqword(0xFFFFFFE0, v3);
  return result;
}

The logic of the function is roughly summarized as follows:

  1. Read flag and pre-shared secret psk into memory
  2. Set up listening socket on port 42424 and accept exactly one connection
  3. Check if client connects from 🔒.hxp.io
  4. If yes, use psk as an AES key to encrypt flag and send it to the client
  5. If no, wrap randomly generated secret key with textbook RSA, send wrapped key and AES encrypted flag to the client

The RSA parameters are (N, e) = (0x6878703c33796f753c336878700000000000000000000000009debf6dfc0d75203bde49a1f3ba3d949f08fa84b6d0a770f730306c32d179566544333df001dcf71d1030707aabf254b8777e1c9c0d857133d5ccabfa0232858588e17ba23dc2a34880ced41edb9f0814695e6ab4ba2d5fc758f431de65e8e34b508a1facda541, 0x309), which look suspiciously backdoored.

Examining the Captured Traffic File

The t.pcap file contains 777 tcp connections from either 42.226.106.30 or 92.243.26.60 to 42.226.106.54. The astute reader will notice two things:

  1. The number of TCP connections in the PCAP is equal to the public exponent. A fact that is completely irrelevant to the solution of this challenge.
  2. The IP addresses are internet-routable addresses. A fact that is completely relevant to the solution of this challenge.

An easy solution (no, really!)

So lets think about what would happen on any connection attempt from either 42.226.106.30 or 92.243.26.60. Remember, the behaviour of the binary depends on whether the client connects from 🔒.hxp.io or from somewhere else. According to getnameinfo(3) the function is the inverse of getaddrinfo(3): it converts a socket address to a corresponding host and service, in a protocol-independent manner. It does so by performing reverse DNS lookups. So what do the two addresses resolve to?

➜  flag_dispenser dig -x 42.226.106.30

; <<>> DiG 9.16.37-Debian <<>> -x 42.226.106.30
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 17325
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;30.106.226.42.in-addr.arpa.	IN	PTR

;; ANSWER SECTION:
30.106.226.42.in-addr.arpa. 21600 IN	PTR	hn.kd.ny.adsl.

;; Query time: 300 msec
;; SERVER: 192.168.178.1#53(192.168.178.1)
;; WHEN: Sun Mar 12 16:40:16 CET 2023
;; MSG SIZE  rcvd: 82

➜  flag_dispenser dig -x 92.243.26.60

; <<>> DiG 9.16.37-Debian <<>> -x 92.243.26.60
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 27019
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
dig: 'xn--ls8haaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xn--ls8haaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xn--ls8haaaaaaaaaaaaaa.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.kirschju.re.' is not a legal IDNA2008 name (string contains a disallowed character), use +noidnout

Hm … 42.226.106.30 doesn’t have a reverse DNS record set, but 92.243.26.60 resolves to 💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩.💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩.💩💩💩💩💩💩💩💩💩💩💩💩💩💩💩[...].kirschju.re . That doesn’t look right. Googling for punycode and OpenSSL immediately points to the Halloween 2022 OpenSSL CVEs CVE-2022-3602 and CVE-2022-3786. One of them allows to overflow a stack-allocated buffer inside OpenSSL with . (dot) characters. When inserting the values from the reverse DNS request into the vulnerable program, one can see that the randbytes variable at the very top (1680 bytes) of the tack stack frame gets overwritten by .s almost entirely. That means that we know the AES key used to encrypt the flag that is sent to the client!

Taking for example the encryptd flag from tcpstream #1 from wireshark (e86ff18a103d528ac01d0fbba5d55491f678ee3a7c6dd53135243ddf2e7852b7daa32347eaad1c6c869d6d569e366578c0a442da2e091a24eed12b1e7772a9fb) we write a quick bruteforcer as follows:

#!/usr/bin/env python3

from Crypto.Cipher import AES

enc = bytes.fromhex("e86ff18a103d528ac01d0fbba5d55491f678ee3a7c6dd53135243ddf2e7852b7daa32347eaad1c6c869d6d569e366578c0a442da2e091a24eed12b1e7772a9fb")


# We need to recover three bytes from the AES key
for i in range(2**24):
    k = b"." * 13 + int.to_bytes(i, 3, "little")
    aes = AES.new(k, iv = b"hxp{n0t_4_fl4g}\x00", mode = AES.MODE_CBC)
    dec = aes.decrypt(enc)
    if b"hxp" in dec:
        print(dec)

Which prints (among other garbage) the flag hxp{th3y_pr0m153d_cr1t1c4l_but_0nly_g4v3_h1gh}