Another Maya malware in the wild

  1. MAYA_SKIP_USER_SETUP=1 will disable usersetup.py
  2. To disable script node execution, you want cmds.optionVar(iv= ("fileExecuteSN", 0))
  3. I should have said “mayapy” – i was thinking you’d make a tool which fired up mayapy (in a process with MAYA_SKIP_USER_SETUP=1), used the optionVar to disable script node execution, and then looped through all the files you care about cataloging and/or deleting their script node contents.

Here’s the shell of a script you could use, though it needs to be beefed up – it just audits for fiiles with any script nodes other than the two standard ones (sceneConfigurationScriptNode and uiConfigurationScriptNode). A clever attacker could hijack those, so this is NOT production quality security – but it could be turned into such with some attention.

# usage :
# mayapy.exe  quick_scan.py   path/to/directory/to/scan
# recursively scanes all maya files in path/to/directory/to/scan 

import sys
import logging
logger = logging.getLogger("mayascan")
logger.setLevel(logging.INFO)

import os
os.environ['MAYA_SKIP_USER_SETUP'] = "1"
logger.info("usersetup disabled")

import maya.standalone
maya.standalone.initialize()
logger.info("maya initialized")

import maya.cmds as cmds
cmds.optionVar(iv= ("fileExecuteSN", 0))
logger.info("scriptnodes disabled")

file_list = []
counter = 0

for root, _, files in os.walk(sys.argv[-1]):
    for mayafile in files:
        lower = mayafile.lower()
        if lower.endswith(".ma") or lower.endswith(".mb"):
            counter += 1
            abspath = os.path.join(root, mayafile)
            logger.info("scanning {}".format(abspath))
            cmds.file(abspath, open=True)
            scriptnodes = cmds.ls(type='script')
            # almost all Maya files will contain two nodes named
            # 'sceneConfigurationScriptNode' and 'uiConfigurationScriptNode'
            # a proper job wouldd make sure that they contained only trivial MEL 
            # but youd have to really inspect the contents to make sure
            # a smart attacker hadn't hidden inside those nodes.  For demo purposes
            # I'm just ignoring them but that is a clear vulnerability

            if len(scriptnodes) > 2:
                # here's where you'd want to nuke and resave the file if you were really cleaning house,
                # or you could loop through them applying your own safety test
                logger.warning("file {} contains {} scriptnodes".format(abspath, len(scriptnodes) - 2 ))
                file_list.append(abspath)


logger.info("scanned {} files".format(counter))
if file_list:
    logger.warning ("=" * 72)
    logger.warning ("filenodes found in:")
    for f in file_list:
        logger.warning(f)


4 Likes

Thanks so much!

Maybe what I’m thinking is overkill but for long term, I was thinking about registering the script nodes with a custom attr stored in an asset db*, or external file, and then scanning for un-registered nodes and flagging files / automatically cleaning out the nodes. Would have to find a way to keep our “tag” of the asset hidden and not go out in any files to external developers.

At least, I have a way to let the producers scan the incoming files for suspicious nodes now.

We do have python distributed environments, and we only work in ascii so I wasn’t limited to mayapy, and just did a fast ascii script search.

For others, simple ascii scanner for maya script nodes, puts all nodes in a dictionary to do with as you please: (os walk file method not included)

# go get script node code blocks
for ma in maya_ascii_files:
    script_nodes = dict()
    script_node_block = None
    f = open(ma, 'r')
    for line in f:
        if line.startswith('createNode script'):
            script_node_block = line.rsplit('"')[-2]
            script_nodes[script_node_block] = [line]
        elif script_node_block is not None and line.startswith('\t'):
            script_nodes[script_node_block].append(line)
        else:
            script_node_block = None
    f.close()

As a note in another train of thought, one machine got a full vaccine.py file, many didn’t, and only got the userSetup. I’m not sure where/when it gets created yet. I didn’t look closely at the opening script node code posted in the original post, but I wonder about not keeping that code public. ¯\(ツ)

I think what I’d do for internal security is to have a “secret” value like you would for a web app:

1 generate a checksum value for the script contents of the node .
2. combine it with the secret value to generate a final checksum
3. Store the result in an attribute
4. When scanning, first check for the presence of your attribute – if it’s not present the node is not one of your in-house ones
5. finally, check the crc against a crc of the current script to guarantee it has not been tampered with.

Here’s an example module that uses a sha512 hash:

import maya.cmds as cmds
import hashlib

# YOUR SECRET NOT BE IN CODE THAT IS
# AVAILABLE TO POTENTIAL ATTACKERS!
SECRET = '1234567890987654321'  


def generate_hash(node, secret):
    """
    Generates a sha512 hash for <node> as a string, keyed with <secret>
    """
    hasher = hashlib.sha512()
    hasher.update(str(cmds.getAttr(node +  ".scriptType")).encode('utf-8'))
    hasher.update(str(cmds.getAttr(node + ".sourceType")).encode('utf-8'))
    before = cmds.getAttr(node +  ".before") or 'empty before'
    hasher.update(before.encode('utf-8'))
    after = cmds.getAttr(node + ".after") or 'empty_after'
    hasher.update(after.encode('utf-8'))
    hasher.update(str(secret).encode('utf-8'))
    return hasher.hexdigest()
    
def update_hash(node, secret):
    """
    Updates scriptNode <node> with a correct hash attribute using <secret>
    """
    
    if not cmds.ls(node + ".hash"):
        cmds.addAttr(node, ln = "hash", dt='string', hidden=True)
    new_hash = generate_hash(node, secret)
    cmds.setAttr(node + ".hash", new_hash, type='string')

def validate_hash(node, secret):
    """
    Ensures that scriptNode <node> has a valid hash for the supplied secret
    """
    if not cmds.ls(node + ".hash"):
         return False
   
    return cmds.getAttr(node + ".hash") == generate_hash(node, secret)

def scan_script_nodes(secret):
    """
    Yields a tuple of <node>, <valid> for all scriptNodes in the scene,
    where "valid" is true if the node has a valid hash attribute.

    This is code you'd want to include in a scanner or in a file-open callback.
    """
    for item in cmds.ls(type='script', recursive=True):
        yield item, validate_hash(item, secret)
        
def update_all_script_nodes(secret):
    """
    Updates all the script nodes in the scene so their hash values represent
    their current contents and the supplied secret.

    You'd probably want to run this on file save, or ask the user on save.
    """
    for item in cmds.ls(type='script', recursive=True):
        update_hash(item, secret)

This is still not bank level security but it’s better than nothing. Whatever you do, let the secret value show up in code an attacker could see – you probably want to install it an an env var or a text file you load at startup. It’s essentially the “password” for scripts you want to allows so it’s important not to allow it to float free. An internal-only file share or an environment variable on internal machines is a good choice since they are harder to leak; so would an internal webserver which only gave out the value to approved IP addresses.

1 Like

Is the same risk not posed by any program that can execute arbitrary code contained within it’s scene files (i.e. any DCC app I can think of has some mechanism for this). Install a security check on maya scriptjobs, sure, but theres a bigger probem which to me feels is most effectively solved by quarantining 3rd-party files that may contain malicious code. However you do it, this seems like a wakeup call make sure you’re thoroughly checking your maya/houdini/3ds-max/etc files before loading them in an app which has full network access.

You’re correct - any program that executes code on load is vulnerable , this goes all the way back to activeX in MS Word docs. I’d always recommend quarantining and then scanning the incoming files.

IMPORTANT

As we’ve been looking at security issues in the Maya Security Project it’s become clear that scriptNodes are NOT the only easy attack vector — while the code above is useful it is also nothing like a guarantee. Please be careful!

TLDR :DO NOT ACCEPT .MA FILES FROM UNTRUSTED SOURCES.

Hello,
For anyone still finding this thread from a google search, and wondering for a quick fix solution, made a script with a quick interface to fix this issue.

tool window screenshot

Cheers.
Liam.

1 Like

Had the same “malware” spread among the many files we had, so had to cobble up the following python code. Contains project specific parts, also can check all .ma files in the current path (and lower) recursively and cleans them (non-destructive, keeps the original file with a timestamp).

Maya Scanner by Autodesk does detect the malware, but is unable to clean it, especially if exists in one of the numerous external references. This one does, since it checks every file individually. Run it from your favorite terminal.

usage: cleaner.py 41

(“41” refers to a non-existing episode number, which tells the script to check current dir and below)

import os
import sys
import re
import glob
import ntpath
import datetime
from datetime import datetime
import time
import argparse

version = "1.81"

print("\n\nRecursive .ma cleaner - Tankut, version " + version + "\n\n")

#signature = r'createNode script -n "vaccine_gene";.*createNode script -n "breed_gene";.*setAttr ".stp" 1;'
signature = r'createNode script -n "vaccine_gene.";.*\]"\);\ncreateNode script -n "breed_gene?";.*setAttr ".stp" 1;'
signature_a = r'createNode script -n "vaccine_gene.{7129}'
signature_b = r'createNode script -n "breed_gene.{405}'
innocent = r'createNode script -n ".*sceneConfigurationScriptNode.*"'
suspicious = r'createNode script -n'
remnant =  r'connectAttr "(breed|vaccine)_gene.msg" .*dn"'


number = 0
checked = 0
suspected = 0
red_flag = 0
folderindex = 0
episodeflag = 0
lastcheck = 0
logexists = 0

currentpath = os.getcwd()

def truncate(n, decimals=0):
    multiplier = 10 ** decimals
    return int(n * multiplier) / multiplier


def quitapp(errorcode):
    if errorcode == 1:
        print ("\nenter an episode number  ( 01 - 40 )   \n")
    if errorcode == 2:
        print ("\nchecking current dir and below for malware \n")
    exit()
    return

def noepisode():
    print("no episode number, checking current dir : ")
    return





episodelist = [".", "e01_oyuncakMuhendisi", "e02_dagDustu", "e03_CokSoguk", "e04_calisirsanOlur", "e05_bulutTamircisi", "e06_bitmeyenKale", "e07_BeniUnutmaNiloya", "e08_aksamOldu", "e09_tospikAraKurtar", "e10_NiloyaHaritasi", "e11_kucukBilimInsanlari", "e12_hayalOyunu", "e13_elimYuzumSobe", "e14_uzaydanSinyal", "e15_yuruyenKutuphane", "e16_temizlikSagliktir", "e17_magaradakiAyi", "e18_goool", "e19_denizciMete", "e20_masaCadiri", "e21_salyangozGibiSakin", "e22_kucukSaglikcilar", "e23_cekirgeOgretmeni", "e24_oyuncakBrosuru", "e25_canimSikiliyor", "e26_koydenHaberler", "e27_sporHerkesIcin", "e28_kurbagaSarkisi", "e29_neseliParkur", "e30_ataTohumlari", "e31_akilliCobanKopegi", "e32_sekerimYok", "e33_haylazSular", "e34_benimGuzelDenizim", "e35_dahaOzel", "e36_disariCikalim", "e37_elifinTeleskobu", "e38_evcilKarinca", "e39_miniklerinParki", "e40_baharTelasi"]

try:
    argument = sys.argv[1]
except:
    noepisode()
    argument =""

try:
    force = sys.argv[2]
except:
    force = ""

if argument != "":
    try:
        folderindex = int(argument)
    except ValueError:
        noepisode()


if folderindex >=0 and folderindex < 41:
    episode = episodelist [folderindex]
    projectpath = "D:\\Dropbox"
    if currentpath.find ("D:\\Dropbox") == -1:
        projectpath = "M:\\PROJECTS"
    currentpath = projectpath + "\\Niloya_2019\\scenes\\s07\\" + episode
    episodeflag = 1
    if episode == ".":
        episode = "ALL"
    print("Checking episode: " + episode + "\n" + currentpath + "\n")



else:
    noepisode()
    print(currentpath + "\n")



try:
    log = open(currentpath + "\\vircheck.ini",'r')
    logexists = 1

except FileNotFoundError:
    print ("no checkpoint data. skipping..")

if logexists == 1 :
    lastcheck = int (log.read())
    print ("last checkpoint: " + str(lastcheck))
    log.close()

if logexists == 1 and force == "f":
    print ("ignoring checkpoint")
    lastcheck = 0


## mainloop

filelist = glob.glob(currentpath + '\**\*.ma', recursive=True)

for filename in filelist:
    if int(os.path.getmtime(filename)) > lastcheck:

        suspectfile = open(filename, "r")
        try: 
            infile = suspectfile.read()
            checked = checked + 1
            print('\r', str(checked).zfill(6), end = ' ')
        except UnicodeDecodeError: 
            print ("Unicode read error, skipping: "+filename)
            infile = ""
        except FileNotFoundError:
            print ("File not found (moved?) : "+ filename)
            infile = ""
        except MemoryError:
            print ("Mem error (size = "+ str(truncate(os.path.getsize(filename)/1024**2,3)) + " MB) : "+ filename)
            infile = ""


        suspectfile.close()

        red_flag = 0

        if re.search (suspicious, infile, flags=re.DOTALL):
            red_flag = red_flag + 1
            if re.search (innocent, infile, flags=re.DOTALL):
                red_flag = red_flag - 1
            if red_flag >> 0:
                print (str(red_flag) + " unknown scriptnode(s) in " + filename)

        

        if re.search ('vaccine_gene', infile, flags=re.DOTALL):
            number = number + 1
            currenttime = datetime.now()
            datestamp = str(currenttime.year).zfill(4)+"-"+str(currenttime.month).zfill(2)+"-"+str(currenttime.day).zfill(2)+"_"+str(currenttime.hour)+str(currenttime.minute)+str(currenttime.second)

            try:
                os.rename (filename, filename+"_old_"+datestamp )
            except FileExistsError:
                os.rename (filename, filename+"_old_"+ datestamp + "_2")

            clean = infile

            item = 0
    
            while item<4:          
                clean = re.sub(signature_a, ' ', clean, flags=re.DOTALL)
                clean = re.sub(signature_b, ' ', clean, flags=re.DOTALL)
                item += 1

            oldsize = sys.getsizeof(infile)
            newsize = sys.getsizeof(clean)

            clean = re.sub(remnant, ' ', clean, flags=re.DOTALL)
            print (filename + " " + f'{oldsize:,}' + " " + f'{newsize:,}')

            outfile = open(filename, "w+")
            outfile.write(clean)
            outfile.close()
        

print ("\nTotal checked: "+ str(checked) + " of " + str(len(filelist)) + " - cleaned : " + str(number) + "\n")

log = open(currentpath + "\\vircheck.ini",'w')
log.write(str(int(time.time())))
log.close()

# requires pip3 install psutil, not standard library
#
#running_from = psutil.Process(os.getpid()).parent().name()
#if running_from == 'explorer.exe':
#    input('Press Enter to Exit..')

if episodeflag == 0:
    input('Press Enter to Exit..\n')


2 Likes

Could you upload a zipped copy of an infected file? It’d be good to ensure that the Manik MA scanner finds it correctly.

Sure, here are a few random infected files from the archive:

https://www.dropbox.com/s/arrhezisfdppy41/infected_vacc_examples.zip?dl=1

1 Like

Thanks, helped me catch a bug in the scanner. Otherwise it correctly identified the infected files, if that’s useful ping me and I can get your the code

1 Like

Glad it was useful. And yes, I’d be interested in your MA scanner.

This link goes to a Maya plugin (tested on Maya 2022, should be OK at least as far back as 2020 or 2019). If you unzip into a folder on the your MAYA_MODULE_PATH (usually Documents /Maya/2022/Modules) it will scan MA files as you open them and warn you if the files are suspicious.

Note that the “Maya security plugin” does NOT catch all of the (many!) zero-day exploits that are trivially easy to do in MA files. It’s only really able to catch scriptnode hacks.

If you catch any holes in this please ping me. If you want to join the Github group that created the plugin, also reach out via DM

https://drive.google.com/file/d/1omxpztUiM6by2_olbhMTTp5Y71vIBYJA/view?usp=sharing

3 Likes

Thank you very much.

It would be nice if the plugin could also (without duplication of code) scan existing files on the server, without loading them up in Maya.

The plugin also exposes a command you can call from the Maya listener which will scan files w/o opening them

manik "file-or-folder-path-here" will scan a file or folder and print issues to the listener

2 Likes

Possible Amateur suggestion:
(I used this to track down some code at a different job)

For finding code in files including returning surrounding lines:

For finding code in pdfs or office documents (or just multi-pdf search):

For scanning for file names:

In my experience nothing has given me quicker results

1 Like

Updated instructions and latest version of the scanner here:

1 Like

This is nuts! I can’t believe that someone wrote a virus for maya.

When I worked at one of the AAA studios we had an issue where we got ma scene bloat due to replicating light linker nodes. I wrote a python script called “SaveAsOptimized” that had logic for clearing out dangling nodes and the light linker nodes and save off a clean file. Perhaps I can write another version of the script and take “viruses” into account? But then again I don’t have a Maya license these days so…

The scanner only finds the files that are suspicious, fixing them is definitely a trickier problem. Often you can render them inert by just removing one one – but almost any fix could possibly be hacked by a motivated attacker too, so we opted to just warn people and let them sort it out for this release.

2 Likes