Follow @Openwall on Twitter for new release announcements and other news
[<prev] [next>] [<thread-prev] [thread-next>] [day] [month] [year] [list]
Message-ID: <20230213120214.GB19824@localhost.localdomain>
Date: Mon, 13 Feb 2023 12:02:13 +0000
From: Qualys Security Advisory <qsa@...lys.com>
To: "oss-security@...ts.openwall.com" <oss-security@...ts.openwall.com>
Subject: Re: double-free vulnerability in OpenSSH server 9.1 (CVE-2023-25136)

Hi all,

On Thu, Feb 02, 2023 at 01:02:04PM +0000, Qualys Security Advisory wrote:
> Exploiting this vulnerability will not be easy: modern memory allocators
> provide protections against double frees, and the impacted sshd process
> is unprivileged and heavily sandboxed.

Quick update: we were able to gain arbitrary control of the "rip"
register through this bug (i.e., we can jump wherever we want in sshd's
address space) on an unpatched installation of OpenBSD 7.2 (which runs
OpenSSH 9.1 by default). This is by no means the end of the story: this
was only step 1, bypass the malloc and double-free protections. The next
steps, which may or may not be feasible at all, are:

- step 2, execute arbitrary code despite the ASLR, NX, and ROP
  protections (this will probably require an information leak, either
  through the same bug or through a secondary bug);

- step 3, escape from sshd's sandbox (through a secondary bug, either in
  the privileged parent process or in the kernel's reduced attack
  surface).

Quick recap of this double-free bug: if an old ssh client connects to an
sshd 9.1, then options.kex_algorithms (which is a string of 266 bytes in
the default configuration) is mistakenly free()d at the beginning of the
key-exchange phase (in compat_kex_proposal()); options.kex_algorithms is
then later free()d again at the beginning of the authentication phase
(in mm_getpwnamallow()).

The trick to bypass malloc's double-free and use-after-free protections
is to re-allocate the memory that was occupied by options.kex_algorithms
as soon as it is free: from malloc's point of view, no attempt is made
to free, read, or write memory that is already free; from sshd's point
of view, however, an aliasing attack occurs: two different pointers to
two different objects refer to the same chunk of memory, and a write to
one object overwrites the other object. This opens up a world of
possibilities.

We started our investigation on Debian bookworm (which uses glibc's
malloc), but we eventually switched to OpenBSD 7.2, because OpenBSD's
malloc (despite its very defensive programming) has two features that
make it especially interesting for this particular double-free bug:

- Free chunks of memory are sorted (according to their size) into
  buckets that are spaced at power-of-two intervals; i.e., any object
  whose size is between 256 and 512 bytes can re-allocate the memory
  that was occupied by options.kex_algorithms. This gives us
  considerable freedom.

  (On the other hand, glibc's malloc sorts free chunks of memory into
  buckets that are spaced at 16-byte intervals; i.e., only an object
  whose size is almost exactly 266 bytes can re-allocate the memory that
  was occupied by options.kex_algorithms. This leaves us with little
  freedom of choice.)

- OpenBSD's malloc() function picks a free chunk at random from several
  (4) pages of memory; i.e., most of the time it will not pick the chunk
  where options.kex_algorithms was allocated, but at least sometimes it
  will (with a probability of ~1/(4*4096/512)=1/32).

  (On the other hand, glibc's malloc() function behaves like a strict
  LIFO (last in, first out); i.e., it will never pick the chunk where
  options.kex_algorithms was allocated unless we precisely control the
  sequence of malloc() and free() calls in sshd, which is something we
  have been unable to do so far.)

Our current proof-of-concept works as follows:

- First, we free options.kex_algorithms in compat_kex_proposal(), by
  pretending that our ssh client is an old "FuTTY" client.

- Second, we re-allocate the chunk that was occupied by
  options.kex_algorithms, with a struct EVP_AES_KEY whose size is 264
  bytes, by selecting the "aes128-ctr" cipher during the key-exchange
  phase. This re-allocation happens with a probability of ~1/32.

- Third, we free (again) the chunk that was occupied by
  options.kex_algorithms (and is occupied by the struct EVP_AES_KEY now)
  in kex_assemble_names() (via mm_getpwnamallow()). This free happens if
  and only if the first byte of the chunk is '+', '-', or '^' (otherwise
  kex_assemble_names() returns an error and fatal_fr() is called), but
  luckily for us the first byte of the struct EVP_AES_KEY is the first
  byte of the AES key itself, which is a random byte; so this free
  happens with a probability of ~3/256.

- Fourth, we re-allocate the chunk that was occupied by
  options.kex_algorithms (and is still referenced as a struct
  EVP_AES_KEY now), with a string of 300 'A' bytes, through either
  "authctxt->user" or "authctxt->style" during the authentication phase.
  This re-allocation, which effectively overwrites the entire struct
  EVP_AES_KEY with 'A' bytes, happens with a probability of ~2/32.

- Last, we jump to 0x4141414141414141 when sshd calls EVP_Cipher(),
  because the struct EVP_AES_KEY contains a function pointer that was
  overwritten by our 'A' bytes and that is called by
  CRYPTO_ctr128_encrypt_ctr32() (via EVP_Cipher()).

This proof-of-concept can be simply reproduced with the ssh client from
OpenBSD 7.2; we change its banner from "OpenSSH" to "FuTTY", and run it
in a loop with the "aes128-ctr" cipher:

------------------------------------------------------------------------
$ cp -i /usr/bin/ssh ./ssh

$ sed -i s/OpenSSH_9.1/FuTTYSH_9.1/g ./ssh

$ user=`perl -e 'print "A" x 300'` && while true ;do ./ssh -o NumberOfPasswordPrompts=0 -o Ciphers=aes128-ctr -l "$user:$user" 192.168.56.123 ;done
------------------------------------------------------------------------

sshd has a good chance of jumping to 0x4141414141414141 after
32*256/3*16=43690 runs (this number is probably not entirely correct,
but not entirely wrong either):

------------------------------------------------------------------------
# gdb /usr/sbin/sshd 53370
...
Attaching to program: /usr/sbin/sshd, process 53370
...

(gdb) continue
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x000009bb0ea6f324 in __llvm_retpoline_r11 () from /usr/lib/libcrypto.so.50.0

(gdb) bt
#0  0x000009bb0ea6f324 in __llvm_retpoline_r11 () from /usr/lib/libcrypto.so.50.0
#1  0x4141414141414141 in ?? ()
#2  0x000009bb0ead2fe5 in CRYPTO_ctr128_encrypt_ctr32 (... func=0x4141414141414141)
...

(gdb) disassemble 0x000009bb0ea6f324
Dump of assembler code for function __llvm_retpoline_r11:
...
0x000009bb0ea6f320 <__llvm_retpoline_r11+16>:   mov    %r11,(%rsp)
0x000009bb0ea6f324 <__llvm_retpoline_r11+20>:   retq
...

(gdb) i r
...
r10            0x4141414141414141   4702111234474983745
r11            0x4141414141414141   4702111234474983745
------------------------------------------------------------------------

(Note: this is a very crude proof-of-concept, which can certainly be
improved in many different ways.)

In conclusion, this double-free bug in sshd is an excellent opportunity
to test how strong the modern memory protections really are. We are at
your disposal for questions, comments, and further discussions. Thank
you very much!

With best regards,

-- 
the Qualys Security Advisory team

Powered by blists - more mailing lists

Please check out the Open Source Software Security Wiki, which is counterpart to this mailing list.

Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.