Advanced Malware Detection with Suricata Lua Scripting

Normal IDPS signatures using either Snort or Suricata have quite a few options and, if regex is added in, can be very effective and flexible for matching network traffic. However, there are some instances where those options just don't quite get the job done and more complex detection is needed.

Take for instance when some malware is using encoding to talk to its "Command and Control" server or exfiltrating credit card numbers. It can be difficult to create a signature to not only detect this encoding but also do it in a way that reduces false positives. Suricata has the ability to invoke Lua scripts which, in turn, gives us the ability to decode this type of malware traffic and peer into what is being sent.

The example I'll be using in this post is traffic from Alina Point of Sale (PoS) malware. Alina PoS has been well documented, but if you aren't familiar with it then I suggest you read some older Trustwave SpiderLabs research on the family:

Alina--Casting-a-Shadow-on-POS
Alina--Following-The-Shadow-Part-1
Alina--Following-The-Shadow-Part-2
Alina-POS-malware--sparks--off-a-new-variant

Below a snippet of a HTTP POST command to one of the CnC addresses. As you can see the header is completely visible and we could even use a few elements from it to create a normal IDPS signature. For instance we can develop a signature that keys off of data from the HTTP method, URI, and the User-Agent. Depending on what version of Alina you are inspecting, the URI and User-Agent will likely be different and isn't 100% dependable. That's why we'll need to look into the payload to see what's really going on.

POST /adobe/version_check.php HTTP/1.1
Accept: application/octet-stream
Content-Type: application/octet-stream
Connection: Close
User-Agent: Alina v5.3
Host: 172.20.30.40
Content-Length: 2980
Cache-Control: no-cache

....................................................................y.....h..QP_...S...UWVQV2%W...Z.....DUW.QPd%W...[..S..DUWPQV6%W .
]..U...UQ.QW1%V
.. ......US.QRb%PZ.

We know from the previous Alina analysis that there are two keys we need to use to decode the payload. For the variants we are focusing on, we will need to extract bytes 18-35, as this is the running key that will be used to decode bytes 76 on. We then need to XOR the entire payload with 0xAA. To do this with Suricata, we will use Lua.

The beginning of the Lua script will have to initiate what buffer we'll be using. In this example, we need the "http.request_body". We also need a function called match(). This is where most the code will be and where we'll be decoding the payload. Both init() and match() are required but other functions can be added.

function init (args)
    local needs = {}
    needs["http.request_body"] = tostring(true)
    return needs
end

The init() function is one of the required functions in the script. It load the buffers that are needed into a table. This buffer will be used in the match() function. There are multiple buffers that can be called upon in a script depending on what is needed. For instance, if we needed to inspect the URI, we can change http.request_body to http.uri or http.uri_raw.

function decodeSTR(s)
        if(s) then
                s = string.gsub(s, '%%(%x%x)',
                function (hex) return string.char(tonumber(hex,16)) end )
        end
return s
end

The decodeSTR() function removes % symbols and returns the ASCII equivalent of the hex string.

function match(args)
    a = tostring(args["http.request_body"])
    local bit = require("bit")
    local bxor, tohex = bit.bxor, bit.tohex
    local decoded1 = {}        
    local key1 = 0xAA           
    local key2 = {}             
    local decoded2 = {}         
    local decoded_str = ""     
    local counter = 1

    if #a > 0 then
        for index=19,36,1 do
            key2[counter] = string.byte(a, index)
            counter = counter + 1
        end

        for i=1, #a, 1 do
            decoded1[i] = bxor(string.byte(a, i), key1)
        end

        counter = 1
        for index=77,#decoded1,1 do
            if ((counter % 18) ~= 0) then
                decoded2[counter] = bxor(decoded1[index], key2[counter % 18])
                decoded_str = decoded_str .. string.char(decoded2[counter])
                counter = counter + 1
            else
                decoded2[counter] = bxor(decoded1[index], key2[18])
                decoded_str = decoded_str .. string.char(decoded2[counter])
                counter = counter + 1
            end
        end

        if string.find(decodeSTR(decoded_str), ".exe") then
            return 1
        end
    end
    return 0
end
return 0

If we break down the match() function above, the first step is to extract the second key from bytes 18-35 then decode the entire payload using XOR and 0xAA.

for index=19,36,1 do
    key2[counter] = string.byte(a, index)
    counter = counter + 1
end

for i=1, #a, 1 do
    decoded1[i] = bxor(string.byte(a, i), key1)
end
Key2 = cf 92 9b 92 93 9c 98 9b 14 2e df da ce cb de cf aa aa

Payload XOR 0xAA = 03 05 41 6c 69 6e 61 20 76 35 2e 33 00 00
00 00 00 00 65 38 31 38 39 36 32 31 29 23 75 70 64 61 74 65
00 00 44 45 4c 4c 58 54 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 d3 0b 00 00 6c ab
c2 26 ab fb fa f5 ae b9 ad f9 a6 ba be ff fd fc fb fc 98 8f
fd a2 be a1 f0 b9 ae ae a6 bf ee ff fd ae fb fa ce 8f fd a2
be a5 f1 b9 ad f9 a6 bb ee ff fd fa fb...

After we have the second key extracted and XOR'd the payload with the initial key of 0xAA, we need to extract the main part of the payload that's been XOR'd with that second key. The second key is a running key, which means that when you reach the end of the key you wrap around back to the beginning of the key and continue XOR'ing the payload. The below code will iterate over bytes 77 -> end while also iterating the second key so the XOR will be in sync. If the below script looks a bit odd it's because Lua starts its table count at 1 instead of 0. Normally, this script wouldn't need the else statement. But when counter reaches 18 it throws an error since 18 % 18 = 0 and there is nothing available for key2[0].

The for loop takes the number retrieved from the XOR data and converts it to a string character.

counter = 1
for index=77,#decoded1,1 do
    if ((counter % 18) ~= 0) then
        decoded2[counter] = bxor(decoded1[index], key2[counter % 18])
        decoded_str = decoded_str .. string.char(decoded2[counter])
        counter = counter + 1
    else
        decoded2[counter] = bxor(decoded1[index], key2[18])
        decoded_str = decoded_str .. string.char(decoded2[counter])
        counter = counter + 1
    end
end

After the second XOR and the string conversion is complete we can see that some addition obfuscation was used but at this point we can see that the XOR'ing worked. This is where decodeSTR() function is going to be used. What this function will do is it will remove the "%" symbols, take the string, convert it to hex, and finally back to its ASCII string equivalent.

diag=%5b%3a%37%32%20%3c%65%61%3e%5d%20%7b%5b%21%31%36%21%5d%7d%7b%5b
%21%32%30%21%5d%7d%7b%5b%21%32%36%21%5d%7d%43%3a%5c%43%72%61%73%68
%70%6c%61%6e%30%30%31%5c%34%43%37%35%34%31%35%30%36%33%39%41%41%33
%41%38%36%43%41%34%44%36%42%36%33%34%32%38%32%30%42%45%2e%65%78%65
%0a%5b%3a%31%31%32%20%3c%32%3e%5d%20%7b%5b%21%31%36%21%5d%7d%7b%5b
%21%34%36%21%5d%7d%76%6d%61%63%74%68%6c%70%2e%65%78%65...
function decodeSTR(s)
        if(s) then
                s = string.gsub(s, '%%(%x%x)',
                function (hex) return string.char(tonumber(hex,16)) end )
        end
return s
end

Below is the final output after the decodeSTR() function. This function may not be necessary if you know to what you are looking for. For instance, if you know you want to find "exe" you could look for "%65%78%65", otherwise using this statement would identify the ASCII characters "exe". From this point you could also print the keys, and decoded payload to a file for later use.

if string.find(decodeSTR(decoded_str), ".exe") then
            return 1
end
diag=[:72 ] {[!16!]}{[!20!]}{[!26!]}C:\Crashplan001\4C754150639AA3A86CA4D6B6342820BE.exe
[:112 ] {[!16!]}{[!46!]}vmacthlp.exe (904)
[:112 ] {[!16!]}{[!46!]}AcrylicService.exe (1632)
[:112 ] {[!16!]}{[!46!]}ekrn.exe (1724)
[:112 ] {[!16!]}{[!46!]}jqs.exe (1752)
[:112 ] {[!16!]}{[!46!]}mdm.exe (1772)
[:112 ] {[!16!]}{[!46!]}vmtoolsd.exe (1988)
[:112 ] {[!16!]}{[!46!]}TPAutoConnSvc.exe (548)
[:112 ] {[!16!]}{[!46!]}TPAutoConnect.exe (1936)
[:112 ] {[!16!]}{[!46!]}rundll32.exe (596)
[:112 ] {[!16!]}{[!46!]}vmtoolsd.exe (656)
[:112 ] {[!16!]}{[!46!]}ctfmon.exe (764)
[:112 ] {[!16!]}{[!46!]}langpack.exe (460)
[:112 ] {[!16!]}{[!46!]}mspaint.exe (812)
[:112 ] {[!16!]}{[!46!]}cmd.exe (1316)
[:112 ] {[!16!]}{[!46!]}cmd.exe (3320)
[:112 ] {[!16!]}{[!46!]}translator.exe (1808)
[:112 ] {[!16!]}{[!46!]}notepad.exe (3136)
[:112 ] {[!16!]}{[!46!]}wireshark.exe (1184)
[:112 ] {[!16!]}{[!46!]}wireshark.exe (3184)
&

Here is an example of what the signature could look like. In order for the script to be called, it would still need to match the criteria of a signature. This keeps the resource demand low and multiple signatures can be created to adjust for changes in the traffic for variants of the malware.

alert http $HOME_NET any -> $EXTERNAL_NET $HTTP_PORTS (msg:"SLR Alert - Alina PoS"; 
content:"POST"; http_method; content:"version_check.php"; http_uri; content:"User-Agent: Alina v5.3"; 
http_header; luajit:scripts/alina_pos.lua; sid:11223344; rev:1;)

Adding Lua scripts as an extension of an IDPS detection system can greatly increase its ability to detect data coming in and going out of your network. This is just one example of what these scripts can do. Of course they will take more resources, especially for the inline devices. However, if they are combined with proper signature creation, both the effectiveness and the confidence of the signature goes up. With inline devices, a higher confidence in the signatures will result in more of them being set to "DROP" traffic and be an active protection rather than just an "ALERT".

Trustwave reserves the right to review all comments in the discussion below. Please note that for security and other reasons, we may not approve comments containing links.