├── .gitignore ├── README.md ├── TODO.md ├── WinPcap_installer ├── WinPcap │ ├── Packet_Vista_x64.dll │ ├── Packet_Vista_x86.dll │ ├── Packet_nt4_x86.dll │ ├── Packet_nt5_x64.dll │ ├── Packet_nt5_x86.dll │ ├── drivers │ │ ├── npf_nt4_x86.sys │ │ ├── npf_nt5_nt6_x64.sys │ │ └── npf_nt5_nt6_x86.sys │ ├── pthreadVC.dll │ ├── wpcap_x64.dll │ └── wpcap_x86.dll ├── winpcap_installer.py └── winpcap_installer_test.py ├── changelog ├── dist ├── WinPcap │ ├── Packet_Vista_x64.dll │ ├── Packet_Vista_x86.dll │ ├── Packet_nt4_x86.dll │ ├── Packet_nt5_x64.dll │ ├── Packet_nt5_x86.dll │ ├── drivers │ │ ├── npf_nt4_x86.sys │ │ ├── npf_nt5_nt6_x64.sys │ │ └── npf_nt5_nt6_x86.sys │ ├── pthreadVC.dll │ ├── wpcap_x64.dll │ └── wpcap_x86.dll ├── s7scan.exe └── winpcap_installer_test.exe ├── protocols ├── __init__.py ├── clnp.py ├── cotp.py ├── ether.py ├── s01fd.py ├── s7.py └── tpkt.py ├── s7scan.py ├── tests ├── plcclient.py ├── plcserver.py └── s7scan_functions.py └── third_parties └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore unnecessary files 2 | *.pyc 3 | *.rar 4 | *.zip 5 | *.gpg 6 | *.pcapng 7 | 8 | # Ignore unnecessary folders 9 | build 10 | docs 11 | backups 12 | WinPcap_installer/dist 13 | # spec files for pyinstaller 14 | *.spec -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # s7scan 2 | 3 | ## General description 4 | **s7scan** is a tool that scans networks, enumerates Siemens PLCs and gathers basic information about them, such as PLC firmware and hardwaare version, network configuration and security parameters. 5 | It is completely written on Python. 6 | The tool uses S7 protocol to connect to talk toPLCs. More specifically, it performs "Read SZL" to get information about controllers. Formats of these requests are documented in "Siemens SIMATIC System Software for S7-300/400 System and 7 | Standard Functions. Reference manual", which can be found at the following link: https://cache.industry.siemens.com/dl/files/574/1214574/att_44504/v1/SFC_e.pdf 8 | Main features of the utility: 9 | 1. Identifying all active PLCs in a particular network; 10 | 2. Obtaining basic information about each PLC: 11 | a. PLC type; 12 | b. Software version; 13 | c. Hardware version; 14 | d. Protection settings applied to the PLC (key position, r/w/rw access rights); 15 | e. Network configuration of the PLC. 16 | 3. Supporting both TCP/IP and LLC transport protocols. 17 | 4. Ability to be built as a stand-alone binary with pyinstaller 18 | 19 | **s7scan** is based on the utility called "plcscan" from Dmitry Efanov (Positive Research). Comparing this old version, here are main differences: 20 | - Support of low-level LLC protocol; 21 | - Showing protection configuration of PLCs; 22 | - Improvements fo default COTP TSAP checking procedure in order to find all PLCs within racks; 23 | - Improved stability. 24 | 25 | The tool is designed to use scapy for crafting and sending low-level LLC packets. Still, for TCP/IP communications it uses standard OS socket interface for simplicity and stability. 26 | 27 | ## What is this tool actually for? 28 | The main purpose of the tool providing technical specialists/security auditors the ability to enumerate PLCs for that additional security configuration and/or firmware updates are needed. 29 | 30 | ## Installation 31 | Actual installation is not required. Just download **s7scan** and run python with s7scan.py 32 | The tool currently depends on scapy, so scapy installation is required. 33 | The tool currently works with Python 2 only 34 | 35 | ## Use cases 36 | You can use s7scan in the following form: 37 | 1. Usage with python and scapy installed on the machine. In this case you only need to download **s7scan**, go to its directory and run "python s7scan.py" in the console. 38 | 2. Usage on computers without python. In this case the option is to use pyinstaller. Install it, go to s7scan folder and run 39 | 40 | ``` 41 | "pyinstaller --onefile s7scan.py" 42 | ``` 43 | to build a stand-alone binary. Then distribute this binary to the target computer and use it. 44 | Both use-cases are acceptable on Linux/Windows/Mac. 45 | Alternatively, you can use pre-built executables built by pyinstaller in **dist** directory. 46 | 47 | **Note:** on Windows you will need WinPcap (or Npcap) if you want to scan LLC networks. If installing it is not an option, you have 2 alternatives: 48 | 1. Download and run portable version of Wireshark; 49 | 2. Use the script winpcap_installer_test.py that is included in s7scan. Run 50 | ``` 51 | winpcap_installer_test.py install 52 | ``` 53 | command in your console, and it will perform silent install of WinPcap. After scanning you can simply run 54 | ``` 55 | winpcap_installer.py uninstall 56 | ``` 57 | to get rid of all WinPcap files. You can also run 58 | ``` 59 | winpcap_installer_test.py check 60 | ``` 61 | in order to check whether WinPcap is installed on the machine. 62 | 63 | ## Kudos 64 | `@_moradek_` at twitter for help with development 65 | 66 | ## Disclaimer of warranty 67 | 68 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 69 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 70 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, 71 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 72 | FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF 73 | THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST 74 | OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 75 | IF ANYONE BELIEVES THAT THIS TOOL HAVE BEEN VIOLATED SOME COPYRIGHTS, PLEASE EMAIL US, 76 | AND ALL THE NECESSARY CHANGES WILL BE MADE. 77 | 78 | ## Less formal disclaimer (or why we had to write the disclaimer at all) 79 | 80 | This open-source tool was developed for internal purposes. It was tested on 81 | several different PLC families: S7-300, S7-400 and S7-1500. Nevertheless, it's 82 | still just a result of a research project, and as always, it may be vulnerable to 83 | mistakes and lack of knowledge under some hypothetical circumstances. Neither the 84 | author of the tool nor Kaspersky Lab are responsible for any possible 85 | damage caused by the tool to the industrial equipment or any technological and 86 | business processes. Use the tool only after considering the consequences, and at 87 | your own risk. 88 | 89 | ## Contacts 90 | Please feel free to contact us if you have any questions/suggestions/feedback related 91 | to the tool. Use the following coordinates: 92 | **Twitter:** @zero_wf from @kl_secservices 93 | **Github:** @klsecservices 94 | Any contribution to the project is always welcome! 95 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | [ ] 1. Integrate winpcap_installer with s7scan 2 | [ ] 2. Too large executable on Linux 3 | [ ] 3. Add saving scan state (for large networks) 4 | [ ] 4. Match IP scan and LLC scan (IP to MAC correlation) -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/Packet_Vista_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/Packet_Vista_x64.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/Packet_Vista_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/Packet_Vista_x86.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/Packet_nt4_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/Packet_nt4_x86.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/Packet_nt5_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/Packet_nt5_x64.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/Packet_nt5_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/Packet_nt5_x86.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/drivers/npf_nt4_x86.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/drivers/npf_nt4_x86.sys -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/drivers/npf_nt5_nt6_x64.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/drivers/npf_nt5_nt6_x64.sys -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/drivers/npf_nt5_nt6_x86.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/drivers/npf_nt5_nt6_x86.sys -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/pthreadVC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/pthreadVC.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/wpcap_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/wpcap_x64.dll -------------------------------------------------------------------------------- /WinPcap_installer/WinPcap/wpcap_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/WinPcap_installer/WinPcap/wpcap_x86.dll -------------------------------------------------------------------------------- /WinPcap_installer/winpcap_installer.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import os 3 | import subprocess 4 | from shutil import copy2 5 | 6 | DLL_PACKET_NT4 = 'Packet_nt4_x86.dll' 7 | DLL_PACKET_NT5_x86 = 'Packet_nt5_x86.dll' 8 | DLL_PACKET_NT5_x64 = 'Packet_nt5_x64.dll' 9 | DLL_PACKET_Vista_x86 = 'Packet_Vista_x86.dll' 10 | DLL_PACKET_Vista_x64 = 'Packet_Vista_x64.dll' 11 | DLL_PACKET = 'Packet.dll' 12 | DLL_WPCAP_x86 = 'wpcap_x86.dll' 13 | DLL_WPCAP_x64 = 'wpcap_x64.dll' 14 | DLL_WPCAP = 'wpcap.dll' 15 | DLL_PTHREAD_VC = 'pthreadVC.dll' 16 | DRIVER_NPF_NT4 = 'drivers\\npf_nt4_x86.sys' 17 | DRIVER_NPF_NT5_NT6_x86 = 'drivers\\npf_nt5_nt6_x86.sys' 18 | DRIVER_NPF_NT5_NT6_x64 = 'drivers\\npf_nt5_nt6_x64.sys' 19 | DRIVER_NPF = 'npf.sys' 20 | DLL_DST_PATH = 'C:\\windows\\System32' 21 | DLL_DST_PATH_x86_64 = 'C:\\Windows\\sysWOW64' 22 | DRIVER_DST_PATH = 'C:\\Windows\\System32\\Drivers' 23 | 24 | def vprint(msg, verbose): 25 | if verbose: 26 | print('winpcap_installer: ' + msg) 27 | 28 | def parse_win_version(version_str): 29 | tmp = version_str.split('.') 30 | if len(tmp) == 1 or len(tmp) == 2: 31 | return float(version_str) 32 | elif len(tmp) > 2: 33 | return float(tmp[0] + '.' + tmp[1]) 34 | else: 35 | raise ValueError('incorrect win_version string') 36 | 37 | def assert_windows(): 38 | system = platform.system() 39 | if system != 'Windows': 40 | raise OSError('winpcap_installer is designed for Windows only') 41 | 42 | def sys_cmd(cmd): 43 | fcmd = filter(len, cmd.split(' ')) 44 | p = subprocess.Popen(fcmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) 45 | out, err = p.communicate() 46 | return out 47 | 48 | def is_installed(verbose=False): 49 | assert_windows() 50 | version = parse_win_version(platform.win32_ver()[1]) 51 | is_x64 = platform.machine().endswith('64') 52 | result = os.path.isfile(os.path.join(DLL_DST_PATH, DLL_PTHREAD_VC)) 53 | result = result and os.path.isfile(os.path.join(DLL_DST_PATH, DLL_WPCAP)) 54 | result = result and os.path.isfile(os.path.join(DLL_DST_PATH, DLL_PACKET)) 55 | result = result and os.path.isfile(os.path.join(DRIVER_DST_PATH, DRIVER_NPF)) 56 | if is_x64: 57 | result = result and os.path.isfile(os.path.join(DLL_DST_PATH_x86_64, DLL_WPCAP)) 58 | result = result and os.path.isfile(os.path.join(DLL_DST_PATH_x86_64, DLL_PACKET)) 59 | service_info = filter(len, sys_cmd('sc query npf').split('\r\n')) 60 | if len(service_info) < 3: 61 | result = False 62 | else: 63 | service_state = service_info[2].strip() 64 | if service_state.startswith('STATE') and service_state.endswith('RUNNING'): 65 | result = True 66 | else: 67 | result = False 68 | return result 69 | 70 | def install(winpcap_path='WinPcap', verbose=False): 71 | assert_windows() 72 | vprint('Preparing for installation', verbose) 73 | # 1. Choose what files to distribute 74 | version = parse_win_version(platform.win32_ver()[1]) 75 | is_x64 = platform.machine().endswith('64') 76 | if is_x64: 77 | vprint('Detected platform: Windows NT {0} (x64)'.format(version), verbose) 78 | else: 79 | vprint('Detected platform: Windows NT {0} (x86)'.format(version), verbose) 80 | pthread_dll = os.path.join(winpcap_path, DLL_PTHREAD_VC) 81 | wpcap_dll_x86 = os.path.join(winpcap_path, DLL_WPCAP_x86) 82 | if is_x64: 83 | wpcap_dll_x64 = os.path.join(winpcap_path, DLL_WPCAP_x64) 84 | else: 85 | wpcap_dll_x64 = '' 86 | if version < 5.0: 87 | # NT 4.0 Windows or older 88 | packet_dll_x86 = os.path.join(winpcap_path, DLL_PACKET_NT4) 89 | packet_dll_x64 = '' 90 | driver_sys = os.path.join(winpcap_path, DRIVER_NPF_NT4) 91 | elif version < 6.0: 92 | # NT 5.0 Windows or older 93 | packet_dll_x86 = os.path.join(winpcap_path, DLL_PACKET_NT5_x86) 94 | if is_x64: 95 | packet_dll_x64 = os.path.join(winpcap_path, DLL_PACKET_NT5_x64) 96 | driver_sys = os.path.join(winpcap_path, DRIVER_NPF_NT5_NT6_x64) 97 | else: 98 | packet_dll_x64 = '' 99 | driver_sys = os.path.join(winpcap_path, DRIVER_NPF_NT5_NT6_x86) 100 | else: 101 | # NT 6.0 Windows or newer 102 | packet_dll_x86 = os.path.join(winpcap_path, DLL_PACKET_Vista_x86) 103 | if is_x64: 104 | packet_dll_x64 = os.path.join(winpcap_path, DLL_PACKET_Vista_x64) 105 | driver_sys = os.path.join(winpcap_path, DRIVER_NPF_NT5_NT6_x64) 106 | else: 107 | packet_dll_x64 = '' 108 | driver_sys = os.path.join(winpcap_path, DRIVER_NPF_NT5_NT6_x86) 109 | vprint('Files to copy:', verbose) 110 | if verbose: 111 | for file in (pthread_dll, wpcap_dll_x86, wpcap_dll_x64, packet_dll_x86, packet_dll_x64, driver_sys): 112 | if file != '': 113 | print(' {}'.format(file)) 114 | # 2. Copy files 115 | copy2(pthread_dll, os.path.join(DLL_DST_PATH, DLL_PTHREAD_VC)) 116 | if wpcap_dll_x64 == '': 117 | copy2(wpcap_dll_x86, os.path.join(DLL_DST_PATH, DLL_WPCAP)) 118 | else: 119 | copy2(wpcap_dll_x86, os.path.join(DLL_DST_PATH_x86_64, DLL_WPCAP)) 120 | copy2(wpcap_dll_x64, os.path.join(DLL_DST_PATH, DLL_WPCAP)) 121 | if packet_dll_x64 == '': 122 | copy2(packet_dll_x86, os.path.join(DLL_DST_PATH, DLL_PACKET)) 123 | else: 124 | copy2(packet_dll_x86, os.path.join(DLL_DST_PATH_x86_64, DLL_PACKET)) 125 | copy2(packet_dll_x64, os.path.join(DLL_DST_PATH, DLL_PACKET)) 126 | copy2(driver_sys, os.path.join(DRIVER_DST_PATH, DRIVER_NPF)) 127 | # 3. Start npf driver as a service 128 | vprint('Registering npf as a service', verbose) 129 | os.system(""" sc create npf binPath= system32\\drivers\\npf.sys type= kernel 130 | start= auto error= normal tag= no DisplayName= \"NetGroup Packet Filter Driver\" """) 131 | os.system('sc start npf') 132 | vprint('Installation complete', verbose) 133 | 134 | def uninstall(verbose=False): 135 | vprint('Preparing for cleanup', verbose) 136 | assert_windows() 137 | version = parse_win_version(platform.win32_ver()[1]) 138 | is_x64 = platform.machine().endswith('64') 139 | # 1. Stop service 140 | vprint('Stopping and deleting npf service', verbose) 141 | os.system('sc stop npf') 142 | os.system('sc delete npf') 143 | # 2. Delete files 144 | vprint('Deleting files', verbose) 145 | if os.path.isfile(os.path.join(DLL_DST_PATH, DLL_PTHREAD_VC)): 146 | os.remove(os.path.join(DLL_DST_PATH, DLL_PTHREAD_VC)) 147 | if os.path.isfile(os.path.join(DLL_DST_PATH, DLL_WPCAP)): 148 | os.remove(os.path.join(DLL_DST_PATH, DLL_WPCAP)) 149 | if os.path.isfile(os.path.join(DLL_DST_PATH, DLL_PACKET)): 150 | os.remove(os.path.join(DLL_DST_PATH, DLL_PACKET)) 151 | if os.path.isfile(os.path.join(DRIVER_DST_PATH, DRIVER_NPF)): 152 | os.remove(os.path.join(DRIVER_DST_PATH, DRIVER_NPF)) 153 | if is_x64: 154 | if os.path.isfile(os.path.join(DLL_DST_PATH_x86_64, DLL_WPCAP)): 155 | os.remove(os.path.join(DLL_DST_PATH_x86_64, DLL_WPCAP)) 156 | if os.path.isfile(os.path.join(DLL_DST_PATH_x86_64, DLL_PACKET)): 157 | os.remove(os.path.join(DLL_DST_PATH_x86_64, DLL_PACKET)) 158 | vprint('Cleanup complete', verbose) 159 | -------------------------------------------------------------------------------- /WinPcap_installer/winpcap_installer_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import winpcap_installer as wpinst 3 | 4 | def test_install(): 5 | wpinst.install(verbose=True) 6 | 7 | def test_uninstall(): 8 | wpinst.uninstall(verbose=True) 9 | 10 | def test_is_installed(): 11 | if wpinst.is_installed(verbose=True): 12 | print("WinPcap is installed on this machine") 13 | else: 14 | print("WinPcap is not installed on this machine") 15 | 16 | if len(sys.argv) > 1: 17 | if sys.argv[1] == 'install': 18 | test_install() 19 | elif sys.argv[1] == 'uninstall': 20 | test_uninstall() 21 | elif sys.argv[1] == 'check': 22 | test_is_installed() 23 | else: 24 | print('please specify what function to test [install/uninstall/check]') 25 | sys.exit() 26 | else: 27 | print('please specify what function to test [install/uninstall/check]') -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | s7scan v1.03 [Python 2] [Scapy-based] 2 | 1. Improved PLC versions displaying. There is still some confusion with versions of modules 3 | 2. Fixed issue with slashes in paths on different platforms 4 | 5 | 6 | s7scan v1.02 [Python 2] [Scapy-based] 7 | Initial release version -------------------------------------------------------------------------------- /dist/WinPcap/Packet_Vista_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/Packet_Vista_x64.dll -------------------------------------------------------------------------------- /dist/WinPcap/Packet_Vista_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/Packet_Vista_x86.dll -------------------------------------------------------------------------------- /dist/WinPcap/Packet_nt4_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/Packet_nt4_x86.dll -------------------------------------------------------------------------------- /dist/WinPcap/Packet_nt5_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/Packet_nt5_x64.dll -------------------------------------------------------------------------------- /dist/WinPcap/Packet_nt5_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/Packet_nt5_x86.dll -------------------------------------------------------------------------------- /dist/WinPcap/drivers/npf_nt4_x86.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/drivers/npf_nt4_x86.sys -------------------------------------------------------------------------------- /dist/WinPcap/drivers/npf_nt5_nt6_x64.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/drivers/npf_nt5_nt6_x64.sys -------------------------------------------------------------------------------- /dist/WinPcap/drivers/npf_nt5_nt6_x86.sys: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/drivers/npf_nt5_nt6_x86.sys -------------------------------------------------------------------------------- /dist/WinPcap/pthreadVC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/pthreadVC.dll -------------------------------------------------------------------------------- /dist/WinPcap/wpcap_x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/wpcap_x64.dll -------------------------------------------------------------------------------- /dist/WinPcap/wpcap_x86.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/WinPcap/wpcap_x86.dll -------------------------------------------------------------------------------- /dist/s7scan.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/s7scan.exe -------------------------------------------------------------------------------- /dist/winpcap_installer_test.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/dist/winpcap_installer_test.exe -------------------------------------------------------------------------------- /protocols/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/protocols/__init__.py -------------------------------------------------------------------------------- /protocols/clnp.py: -------------------------------------------------------------------------------- 1 | from scapy.fields import ByteField 2 | from scapy.layers.l2 import LLC 3 | from scapy.packet import Packet, bind_layers 4 | 5 | class CLNP(Packet): 6 | name = "CLNP" 7 | fields_desc = [ByteField("subnet", 0) ] 8 | bind_layers(LLC, CLNP, dsap=0xfe, ssap=0xfe, ctrl=3) -------------------------------------------------------------------------------- /protocols/cotp.py: -------------------------------------------------------------------------------- 1 | import random 2 | import socket 3 | from scapy.fields import ShortField, BitField, ByteField, IntField 4 | from scapy.packet import Packet, bind_layers 5 | from scapy.layers.l2 import LLC 6 | from scapy.compat import raw 7 | from scapy.sendrecv import sendp 8 | from clnp import CLNP 9 | from tpkt import TPKT 10 | from ether import EtherRaw 11 | 12 | # Timeout walue 13 | TRUSTED_TIMEOUT_TCP_IP = 1 14 | 15 | class COTP_Exception(Exception): 16 | def __init__(self, message, packet=None): 17 | self._message_ = message 18 | self._packet_ = packet 19 | def __str__(self): 20 | if self._packet_ == None: 21 | return "[ERROR][COTP]{}".format(self._message_) 22 | else: 23 | return "[ERROR][COTP]{}\nPacket:{}".format(self._message_, str(self._packet_).encode('hex')) 24 | class UnreachableHostException(Exception): 25 | """Thrown when socket.timeout occured while connecting to host""" 26 | def __init__(self): 27 | self.message = "[ERROR][COTP]Host TCP-connect request timeout exceeded" 28 | class ConnectionRefusedException(Exception): 29 | """Thrown when socket.timeout occured while connecting to host""" 30 | def __init__(self): 31 | self.message = "[ERROR][COTP]Host TCP-connect on port refused" 32 | class COTP(Packet): 33 | name = "COTP" 34 | fields_desc = [ 35 | ByteField("length", 0), 36 | ByteField("pdu_type", 0) 37 | ] 38 | class COTP_TCP_ConnectRequest(Packet): 39 | name = "COTP_TCP_ConnectRequest" 40 | fields_desc = [ 41 | ShortField("dst_ref", 0), 42 | ShortField("src_ref", 0), 43 | BitField('class_', 0, 4), 44 | BitField('reserved', 0, 2), 45 | BitField('ext_format', 0, 1), 46 | BitField('explicit', 0, 1), 47 | ShortField("tpdu_size_param", 0xc001), 48 | ByteField("tpdu_size_value", 0x0a), 49 | ShortField("src_tsap_param", 0xc102), 50 | ShortField("src_tsap_value", 0x0), 51 | ShortField("dst_tsap_param", 0xc202), 52 | ShortField("dst_tsap_value", 0x0) 53 | ] 54 | class COTP_LLC_ConnectRequest(Packet): 55 | name = "COTP_LLC_ConnectRequest" 56 | fields_desc = [ 57 | ShortField("dst_ref", 0), 58 | ShortField("src_ref", 0), 59 | BitField('class_', 0, 4), 60 | BitField('reserved', 0, 2), 61 | BitField('ext_format', 0, 1), 62 | BitField('explicit', 0, 1), 63 | ShortField("dst_tsap_param", 0xc202), 64 | ShortField("dst_tsap_value", 0x0), 65 | ShortField("src_tsap_param", 0xc102), 66 | ShortField("src_tsap_value", 0x0), 67 | ShortField("tpdu_size_param", 0xc001), 68 | ByteField("tpdu_size_value", 0x0), 69 | ShortField("version_param", 0xc401), 70 | ByteField("version_value", 0x1), 71 | ShortField("options_param", 0xc601), 72 | ByteField("options_value", 0x2), 73 | ShortField("priority_param", 0x8702), 74 | ShortField("priority_value", 0x0), 75 | ShortField("checksum_param", 0xc302), 76 | ShortField("checksum_value", 0x0000), 77 | ] 78 | class COTP_TCP_ConnectConfirm(Packet): 79 | name = "COTP_TCP_ConnectConfirm" 80 | fields_desc = [ 81 | ShortField("dst_ref", 0), 82 | ShortField("src_ref", 0), 83 | BitField('class_', 0, 4), 84 | BitField('reserved', 0, 2), 85 | BitField('ext_format', 0, 1), 86 | BitField('explicit', 0, 1), 87 | ShortField("tpdu_size_param", 0xc001), 88 | ByteField("tpdu_size_value", 0x0a), 89 | ShortField("src_tsap_param", 0xc102), 90 | ShortField("src_tsap_value", 0x0), 91 | ShortField("dst_tsap_param", 0xc202), 92 | ShortField("dst_tsap_value", 0x0) 93 | ] 94 | class COTP_LLC_ConnectConfirm(Packet): 95 | name = "COTP_LLC_ConnectConfirm" 96 | fields_desc = [ 97 | ShortField("dst_ref", 0), 98 | ShortField("src_ref", 0), 99 | BitField('class_', 0, 4), 100 | BitField('reserved', 0, 2), 101 | BitField('ext_format', 0, 1), 102 | BitField('explicit', 0, 1), 103 | ShortField("tpdu_size_param", 0xc001), 104 | ByteField("tpdu_size_value", 0x0), 105 | ShortField("options_param", 0xc601), 106 | ByteField("options_value", 0x0), 107 | ] 108 | class COTP_DataAcknowledgement(Packet): 109 | name = "COTP_DataAcknowledgement" 110 | fields_desc = [ 111 | ShortField("dst_ref", 0), 112 | IntField("tpdu_num", 0), 113 | ShortField("credit", 1) 114 | ] 115 | class COTP_LLC_Data(Packet): 116 | name = "COTP_LLC_Data" 117 | fields_desc = [ 118 | ShortField("dst_ref", 0), 119 | BitField('last_data_unit', 1, 1), 120 | BitField('tpdu_num', 0, 31) 121 | ] 122 | class COTP_TCP_Data(Packet): 123 | name = "COTP_TCP_Data" 124 | fields_desc = [ 125 | BitField('last_data_unit', 1, 1), 126 | BitField('tpdu_num', 0, 7) 127 | ] 128 | class COTP_DisconnectRequest(Packet): 129 | name = "COTP_DisconnectRequest" 130 | fields_desc = [ 131 | ShortField("dst_ref", 0), 132 | ShortField("src_ref", 0), 133 | ByteField("cause", 0x0), 134 | ] 135 | class COTP_DisconnectConfirm(Packet): 136 | name = "COTP_DisconnectConfirm" 137 | fields_desc = [ 138 | ShortField("dst_ref", 0), 139 | ShortField("src_ref", 0) 140 | ] 141 | 142 | bind_layers(CLNP, COTP) 143 | bind_layers(TPKT, COTP) 144 | bind_layers(COTP, COTP_TCP_ConnectRequest, pdu_type=0xe0, length=17) 145 | bind_layers(COTP, COTP_TCP_ConnectConfirm, pdu_type=0xd0) 146 | bind_layers(COTP, COTP_LLC_ConnectRequest, pdu_type=0xe1, length=0x1F) 147 | bind_layers(COTP, COTP_LLC_ConnectConfirm, pdu_type=0xd1) 148 | bind_layers(COTP, COTP_DataAcknowledgement, pdu_type=0x60, length=0x09) 149 | bind_layers(COTP, COTP_LLC_Data, pdu_type=0xf0, length=0x07) 150 | bind_layers(COTP, COTP_TCP_Data, pdu_type=0xf0, length=0x02) 151 | bind_layers(COTP, COTP_DisconnectRequest, pdu_type=0x80, length=0x06) 152 | bind_layers(COTP, COTP_DisconnectConfirm, pdu_type=0xC0) 153 | 154 | def _stop_filter(x): 155 | """ This filter stops capture when it meets certain amount 156 | of COTP packets with appropriate dst_ref. Without capture stop 157 | we will loose a lot of time waiting for timeout at each packet sendrecv. 158 | """ 159 | if x.haslayer(COTP_LLC_ConnectConfirm): 160 | i = COTP_LLC_ConnectConfirm 161 | elif x.haslayer(COTP_LLC_Data): 162 | i = COTP_LLC_Data 163 | elif x.haslayer(COTP_DataAcknowledgement): 164 | i = COTP_DataAcknowledgement 165 | elif x.haslayer(COTP_DisconnectConfirm): 166 | i = COTP_DisconnectConfirm 167 | elif x.haslayer(COTP_DisconnectRequest): 168 | i = COTP_DisconnectRequest 169 | else: 170 | return False 171 | if x[i].dst_ref == _stop_filter.dst_ref: 172 | # In case of disconnect request to ud we need to stop capture immediately 173 | if i == COTP_DisconnectRequest: 174 | return True 175 | else: 176 | _stop_filter.counter += 1 177 | if _stop_filter.counter == _stop_filter.count: 178 | return True 179 | else: 180 | return False 181 | 182 | class COTP_Layer(): 183 | """ This class implements COTP network layer. Supports LLC- and TCP/IP-based networks 184 | """ 185 | def __init__(self, is_llc=False, ifname=None, mac_addr=None, timeout=10): 186 | """ is_llc - choose whether COTP layer must be based on LLC or TCP/IP network 187 | ifname - name of the network interface to use (only for LLC-based network) 188 | mac_addr - source mac_addr to use (only for LLC-based network) 189 | timeout - receive timeout in seconds 190 | """ 191 | self._timeout_ = timeout 192 | self._socket_ = None 193 | if is_llc == True: 194 | self._is_llc_ = True 195 | if ifname == None or mac_addr == None: 196 | raise COTP_Exception("[INIT]For LLC-based COTP layer source mac_addr and ifname must be specified") 197 | self._ether_ = EtherRaw(ifname, mac_addr, timeout) 198 | self._socket_ = None 199 | self._dst_mac_ = None 200 | else: 201 | self._is_llc_ = False 202 | self._ether_ = None 203 | self._src_tpdu_num_ = 0 204 | self._srv_tpdu_num = 0 205 | self._src_ref_ = 0 206 | self._dst_ref_ = 0 207 | self._connected_ = False 208 | def _filter(self, packets): 209 | """ Filters only COTP packets. 210 | This protects us from the theoretical case when we send COTP packet to the server and expect COTP answer, 211 | but receive non-COTP packet(s) for some reason 212 | """ 213 | filtered = [] 214 | if len(packets) == 0: 215 | return filtered 216 | for packet in packets: 217 | if packet.haslayer(COTP): 218 | filtered.append(packet) 219 | return filtered 220 | def _send(self, packet): 221 | if self._is_llc_: 222 | self._ether_.send(self._dst_mac_, LLC(dsap=0xfe, ssap=0xfe, ctrl=3) / CLNP() / COTP() / packet) 223 | else: 224 | l = len(str(TPKT() / COTP() / packet)) 225 | self._socket_.send(str(TPKT(length=l) / COTP() / packet)) 226 | def _sendrcv(self, packet, recv_count=1): 227 | """ Sends COTP packet and receives up to recv_count COTP packets from the server 228 | """ 229 | if self._is_llc_: 230 | # Init _stop_filter static vars 231 | _stop_filter.count = recv_count 232 | _stop_filter.counter = 0 233 | _stop_filter.dst_ref = self._src_ref_ 234 | full_packet = LLC(dsap=0xfe, ssap=0xfe, ctrl=3) / CLNP() / COTP() / packet 235 | # For LLC connect requests we need to calculate checksum 236 | if full_packet.haslayer(COTP_LLC_ConnectRequest): 237 | full_packet[COTP_LLC_ConnectRequest].checksum_value = self.checksum(raw(full_packet[COTP])[:-2]) 238 | answers = self._ether_.sendrcv(self._dst_mac_, full_packet, _stop_filter) 239 | # Filter COTP answers only 240 | return self._filter(answers) 241 | else: 242 | self._send(packet) 243 | answer = self._socket_.recv(4096) 244 | if answer == None: 245 | return None 246 | else: 247 | return [TPKT(answer)] 248 | def settimeout(self, timeout): 249 | self._timeout_ = timeout 250 | def connect(self, dst_addr, src_tsap, dst_tsap): 251 | """ Performs COTP connection. 252 | dst_addr - destination address. MAC-address for LLC or IP-address for TCP/IP 253 | Throws: COTP_Exception 254 | """ 255 | timeout_counter = 0 256 | self._dst_addr_ = dst_addr 257 | self._src_ref_ = random.randint(0x0000, 0xFFFF) 258 | if not self._is_llc_: 259 | self._socket_ = socket.socket() 260 | self._socket_.settimeout(self._timeout_) 261 | while timeout_counter < 2: 262 | try: 263 | self._socket_.connect(dst_addr) 264 | break 265 | except (socket.timeout, socket.error) as e: 266 | if type(e) == socket.timeout: 267 | if self._timeout_ < TRUSTED_TIMEOUT_TCP_IP: 268 | self._timeout_ = TRUSTED_TIMEOUT_TCP_IP 269 | self._socket_ = socket.socket() 270 | self._socket_.settimeout(self._timeout_) 271 | timeout_counter += 1 272 | continue 273 | else: 274 | raise UnreachableHostException 275 | elif type(e) == socket.error and e.errno == 10061: 276 | raise ConnectionRefusedException 277 | else: 278 | raise e 279 | answer = self._sendrcv(COTP_TCP_ConnectRequest( 280 | dst_ref=self._dst_ref_, src_ref=self._src_ref_, dst_tsap_value=dst_tsap, src_tsap_value=src_tsap) 281 | ) 282 | else: 283 | self._dst_mac_ = dst_addr 284 | answer = self._sendrcv(COTP_LLC_ConnectRequest( 285 | dst_ref=self._dst_ref_, src_ref=self._src_ref_, class_=0x4, ext_format=1, 286 | dst_tsap_value=dst_tsap, src_tsap_value=src_tsap, tpdu_size_value=0xa) 287 | ) 288 | if len(answer) == 0: 289 | raise COTP_Exception("[CONNECT]No response from the server") 290 | elif len(answer) > 1: 291 | raise COTP_Exception("[CONNECT]Received more than one answer") 292 | answer = answer[0] 293 | if self._is_llc_: 294 | cc = COTP_LLC_ConnectConfirm 295 | else: 296 | cc = COTP_TCP_ConnectConfirm 297 | if answer.haslayer(cc): 298 | self._dst_ref_ = answer[cc].src_ref 299 | self._src_tpdu_num_ = 0 300 | self._srv_tpdu_num = 0 301 | if self._is_llc_: 302 | # For LLC we need to send acknowledge for each COTP packet 303 | # For TCP acknowledges are controlled by TCP itself (no need to send COTP Acks) 304 | self._send(COTP_DataAcknowledgement(dst_ref=self._dst_ref_)) 305 | self._connected_ = True 306 | elif answer.haslayer(COTP_DisconnectRequest): 307 | self._src_ref_ = 0 308 | self._dst_addr_ = None 309 | raise COTP_Exception("[CONNECT]Received DR", packet=answer) 310 | else: 311 | raise COTP_Exception("[CONNECT]Received Unexpected answer", packet=answer) 312 | def disconnect(self): 313 | if not self._connected_: 314 | raise COTP_Exception("[DISCONNECT]Not connected") 315 | if self._is_llc_: 316 | answer = self._sendrcv(COTP_DisconnectRequest(dst_ref=self._dst_ref_, src_ref=self._src_ref_)) 317 | else: 318 | self._socket_.close() 319 | self._socket_ = None 320 | answer = None 321 | self._dst_mac_ = None 322 | self._dst_ref_ = 0 323 | self._src_ref_ = 0 324 | self._src_tpdu_num_ = 0 325 | self._srv_tpdu_num = 0 326 | self._connected_ = False 327 | _stop_filter.dst_ref = 0 328 | if self._is_llc_: 329 | if len(answer) == 0: 330 | raise COTP_Exception("[CONNECT]No response from the server") 331 | elif len(answer) > 1: 332 | raise COTP_Exception("[DISCONNECT]Received more than one answer") 333 | answer = answer[0] 334 | if not answer.haslayer(COTP_DisconnectConfirm): 335 | raise COTP_Exception("[DISCONNECT]Received Unexpected answer", packet=answer) 336 | def sendrecv(self, packet): 337 | if not self._connected_: 338 | raise COTP_Exception("[DATA]Not connected") 339 | if self._is_llc_: 340 | answer = self._sendrcv(COTP_LLC_Data(dst_ref=self._dst_ref_, tpdu_num=self._src_tpdu_num_) / packet, recv_count=2) 341 | if len(answer) < 2: 342 | raise COTP_Exception("[DATA]No response from server") 343 | ack = answer[0] 344 | data = answer[1] 345 | if not ack.haslayer(COTP_DataAcknowledgement) or not data.haslayer(COTP_LLC_Data): 346 | raise COTP_Exception("[DATA]Incorrect response from server") 347 | old_src_tpdu_num = self._src_tpdu_num_ 348 | self._src_tpdu_num_ = ack[COTP_DataAcknowledgement].tpdu_num # Next tpdu_num we will use 349 | if data[COTP_LLC_Data].tpdu_num != old_src_tpdu_num: 350 | raise COTP_Exception("[DATA]Packet contains incorrect tpdu_number") 351 | self._srv_tpdu_num += 1 352 | # Send ACK for data packet 353 | self._send(COTP_DataAcknowledgement(dst_ref=self._dst_ref_, tpdu_num=self._srv_tpdu_num)) 354 | else: 355 | answer = self._sendrcv(COTP_TCP_Data(tpdu_num=self._src_tpdu_num_) / packet) 356 | if len(answer) < 1: 357 | raise COTP_Exception("[DATA]No response from server") 358 | data = answer[0] 359 | if not data.haslayer(COTP_TCP_Data): 360 | raise COTP_Exception("[DATA]Incorrect response from server") 361 | return data 362 | def checksum(self, data): 363 | c0 = c1 = 0 364 | for byte in data: 365 | c0 += ord(byte) 366 | c1 += c0 367 | x = (-c1 - c0) % 255 368 | y = c1 % 255 369 | return ((x << 8) & 0xFF00) | (y & 0x00FF) -------------------------------------------------------------------------------- /protocols/ether.py: -------------------------------------------------------------------------------- 1 | from scapy.sendrecv import sendp, sniff 2 | from scapy.layers.l2 import Dot3 3 | import threading 4 | 5 | 6 | class receiverThread (threading.Thread): 7 | def __init__(self, ifname, timeout, handler): 8 | threading.Thread.__init__(self) 9 | self.ifname = ifname 10 | self.handler = handler 11 | self.packets = None 12 | self.timeout = timeout 13 | 14 | def run(self): 15 | self.packets = sniff(stop_filter=self.handler, iface=self.ifname, 16 | timeout=self.timeout) 17 | 18 | 19 | class receiverTimeoutThread (threading.Thread): 20 | def __init__(self, ifname, timeout): 21 | threading.Thread.__init__(self) 22 | self.ifname = ifname 23 | self.timeout = timeout 24 | self.packets = None 25 | 26 | def run(self): 27 | self.packets = sniff(timeout=self.timeout, iface=self.ifname) 28 | 29 | 30 | class EtherRaw(): 31 | BROADCAST_MAC_ADDR = "FF:FF:FF:FF:FF:FF" 32 | 33 | def __init__(self, ifname, mac_addr, timeout=10): 34 | self._ifname_ = ifname 35 | self._addr = mac_addr 36 | self._timeout_ = timeout 37 | 38 | def _addr_filter(self, packets): 39 | result = [] 40 | if packets is None: 41 | return result 42 | for packet in packets: 43 | if packet.haslayer(Dot3): 44 | if (packet[Dot3].dst == self._addr) or (packet[Dot3].dst == self.BROADCAST_MAC_ADDR): 45 | result.append(packet) 46 | return result 47 | 48 | def send(self, dst_mac, packet): 49 | sendp(Dot3(src=self._addr, dst=dst_mac) / packet, 50 | iface=self._ifname_, verbose=False) 51 | 52 | def recv(self, stop_filter): 53 | return filter(stop_filter, 54 | sniff(stop_filter=stop_filter, iface=self._ifname_)) 55 | 56 | def sendrcv(self, dst_mac, packet, stop_filter=None): 57 | receiver = receiverThread(self._ifname_, self._timeout_, stop_filter) 58 | receiver.start() 59 | self.send(dst_mac, packet) 60 | receiver.join() 61 | return self._addr_filter(receiver.packets) 62 | 63 | def sendrcv_timeout(self, dst_mac, packet, timeout): 64 | receiver = receiverTimeoutThread(self._ifname_, timeout) 65 | receiver.start() 66 | self.send(dst_mac, packet) 67 | receiver.join() 68 | return receiver.packets 69 | -------------------------------------------------------------------------------- /protocols/s01fd.py: -------------------------------------------------------------------------------- 1 | from scapy.fields import ShortField 2 | from scapy.packet import Packet, bind_layers 3 | from scapy.layers.l2 import SNAP 4 | 5 | 6 | class S01FD(Packet): 7 | name = "S01FD" 8 | fields_desc = [ShortField("type", 0)] 9 | 10 | bind_layers(SNAP, S01FD, OUI=0x080006, code=0x01fd) 11 | -------------------------------------------------------------------------------- /protocols/s7.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from socket import inet_ntoa 3 | from scapy.packet import Packet, bind_layers 4 | from scapy.fields import LEShortField, ThreeBytesField, ByteField, ShortField, BitField 5 | from cotp import COTP_Layer, COTP_LLC_Data, COTP_TCP_Data 6 | 7 | 8 | class S7COMM_Exception(Exception): 9 | def __init__(self, message, packet=None, code=0): 10 | self._message_ = message 11 | self._packet_ = packet 12 | self._code_ = code 13 | 14 | def __str__(self): 15 | if self._packet_ is None: 16 | s = "[ERROR][S7]{}".format(self._message_) 17 | else: 18 | s = "[ERROR][S7]{}\r\n Packet:{}".format( 19 | self._message_, str(self._packet_).encode('hex')) 20 | if self._code_ != 0: 21 | s += "\r\n Code: {}".format(self._code_) 22 | if self._code_ == 0xD402: 23 | s += " (Information function unavailable)" 24 | return s 25 | 26 | 27 | class S7COMM(Packet): 28 | name = "S7COMM" 29 | fields_desc = [ 30 | ByteField("protocol_id", 0x32), 31 | ByteField("rosctr", 1), # Remote operating service control 32 | ShortField("reserved", 0x0000), 33 | LEShortField("pdu_ref", 0x0000), 34 | ShortField("param_length", 0x0000), 35 | ShortField("data_length", 0x0000) 36 | ] 37 | 38 | 39 | class S7COMM_Ack(Packet): 40 | name = "S7COMM_Ack" 41 | fields_desc = [ 42 | ByteField("error_class", 0), 43 | ByteField("error_code", 0) 44 | ] 45 | 46 | 47 | class S7COMM_Job(Packet): 48 | name = "S7COMM_Job_Func" 49 | fields_desc = [ByteField("function", 0)] 50 | 51 | 52 | class S7COMM_Job_Connect(Packet): 53 | name = "S7COMM_ConnectRequest" 54 | fields_desc = [ 55 | ByteField("reserved", 0x00), 56 | ShortField("max_amq_calling", 1), 57 | ShortField("max_amq_called", 1), 58 | ShortField("pdu_length", 0x01E0) 59 | ] 60 | 61 | 62 | class S7COMM_Data(Packet): 63 | name = "S7COMM_Data" 64 | fields_desc = [ 65 | ThreeBytesField("param_head", 0x000112), 66 | ByteField("param_length", 4), 67 | ByteField("request_response", 0x11), 68 | BitField('type', 4, 4), 69 | BitField('func_group', 0, 4), 70 | ByteField("subfunc", 1), 71 | ByteField("seq_num", 0) 72 | ] 73 | 74 | 75 | class S7COMM_Data_ReadSZL(Packet): 76 | name = "S7COMM_Read_SZL" 77 | fields_desc = [ 78 | ByteField("return_code", 0xFF), 79 | ByteField("transport_size_os", 0x09), 80 | ShortField("transport_size_value", 0x0004), 81 | ShortField("szl_id", 0x0000), 82 | ShortField("szl_ind", 0x0000) 83 | ] 84 | 85 | 86 | class S7COMM_Data_SZL(Packet): 87 | name = "S7COMM_Data_SZL" 88 | fields_desc = [ 89 | ByteField("du_ref_num", 0), 90 | ByteField("last_du", 0), 91 | ShortField("error_code", 0x0000), 92 | ByteField("return_code", 0xFF), 93 | ByteField("transport_size_os", 0x09), 94 | ShortField("length", 0x0000) 95 | ] 96 | 97 | bind_layers(COTP_LLC_Data, S7COMM) 98 | bind_layers(COTP_TCP_Data, S7COMM) 99 | bind_layers(S7COMM, S7COMM_Ack, rosctr=3) 100 | bind_layers(S7COMM, S7COMM_Job, rosctr=1) 101 | bind_layers(S7COMM, S7COMM_Data, rosctr=7) 102 | bind_layers(S7COMM_Ack, S7COMM_Job) 103 | bind_layers(S7COMM_Job, S7COMM_Job_Connect, function=0xF0) 104 | bind_layers(S7COMM_Data, S7COMM_Data_ReadSZL, request_response=0x11, 105 | type=4, func_group=4, subfunc=1) 106 | bind_layers(S7COMM_Data, S7COMM_Data_SZL, 107 | request_response=0x12, func_group=4, subfunc=1) 108 | 109 | 110 | class ModuleID_Record(): 111 | def __init__(self, data): 112 | self.index = struct.unpack("!H", data[:2])[0] 113 | self.order_number = data[2:22].strip(" ").strip("\x00") 114 | self.reserved = struct.unpack("!H", data[22:24])[0] 115 | self.version = struct.unpack("!H", data[24:26])[0] 116 | self.version2 = struct.unpack("!H", data[26:28])[0] 117 | # Skip letter 'V/v' in version 118 | if chr((self.version >> 8) & 0xFF) in ['V', 'v']: 119 | self.version = self.version & 0xFF 120 | 121 | def __str__(self): 122 | if self.index == 1: 123 | s = "Module\r\n" 124 | elif self.index == 6: 125 | s = "Basic hardware\r\n" 126 | elif self.index == 7: 127 | s = "Basic firmware\r\n" 128 | else: 129 | s = "Unknown index {}\r\n".format(self.index) 130 | if self.order_number != "": 131 | if self.index in [1, 6]: 132 | s += " Order number: {}\r\n".format(self.order_number) 133 | else: 134 | s += " {}\r\n".format(self.order_number) 135 | s += " Version: {}.{}.{}".format(self.version, self.version2 >> 8, self.version2 & 0xFF) 136 | return s 137 | 138 | 139 | class ProtectionRecord(): 140 | def __init__(self, data): 141 | index = struct.unpack("!H", data[0:2])[0] 142 | index_b0 = (index >> 8) & 0xFF 143 | if index_b0 == 0: 144 | self.cpu_type = "Standard CPU" 145 | self.cpu_mode = None 146 | self.rack_num = 0 147 | else: 148 | self.cpu_type = "H CPU (High availability)" 149 | self.rack_num = index_b0 & 0x07 150 | if index_b0 & 0x08 == 0: 151 | self.cpu_mode = "Standby CPU" 152 | else: 153 | self.cpu_mode = "Master CPU" 154 | self.protection_mode_selector = struct.unpack("!H", data[2:4])[0] 155 | self.protection_parameters = struct.unpack("!H", data[4:6])[0] 156 | self.protection_cpu = struct.unpack("!H", data[6:8])[0] 157 | self.mode_selector = struct.unpack("!H", data[8:10])[0] 158 | self.startup_switch = struct.unpack("!H", data[10:12])[0] 159 | self.id = struct.unpack("!H", data[12:14])[0] 160 | self.hw_chksum_1 = struct.unpack("!H", data[14:16])[0] 161 | self.hw_chksum_2 = struct.unpack("!H", data[16:18])[0] 162 | self.user_prog_chksum_1 = struct.unpack("!H", data[18:20])[0] 163 | self.user_prog_chksum_2 = struct.unpack("!H", data[20:22])[0] 164 | 165 | def __str__(self): 166 | s = " CPU type: {}\r\n".format(self.cpu_type) 167 | if self.cpu_type == "H CPU (High availability)": 168 | s += " CPU rack number: {}. Mode: {}\r\n".format(self.rack_num, 169 | self.cpu_mode) 170 | if self.protection_mode_selector in [1, 2, 3]: 171 | s += " Protection level set with the mode selector: {}\r\n".format(self.protection_mode_selector) 172 | else: 173 | s += " Protection level set with the mode selector: not applicable ({})\r\n".format(self.protection_mode_selector) 174 | if self.protection_parameters in [0, 1, 2, 3]: 175 | s += " Protection level set in parameters: {}".format(self.protection_parameters) 176 | if self.protection_parameters == 0: 177 | s += " (no password)" 178 | s += "\r\n" 179 | else: 180 | s += " Protection level set in parameters: unknown ({})\r\n".format(self.protection_parameters) 181 | if self.protection_cpu in [0, 1, 2, 3]: 182 | s += " Valid protection level of the cpu: {}\r\n".format(self.protection_cpu) 183 | else: 184 | s += " Valid protection level of the cpu: unknown ({})\r\n".format(self.protection_cpu) 185 | s += " Mode selector: {} (".format(self.mode_selector) 186 | if self.mode_selector == 1: 187 | s += "RUN)\r\n" 188 | elif self.mode_selector == 2: 189 | s += "RUN-P)\r\n" 190 | elif self.mode_selector == 3: 191 | s += "STOP)\r\n" 192 | elif self.mode_selector == 4: 193 | s += "MRES)\r\n" 194 | else: 195 | s += "undefined / cannont be determined)\r\n" 196 | s += " Startup switch setting: {} (".format(self.startup_switch) 197 | if self.startup_switch == 1: 198 | s += "CRST)\r\n" 199 | elif self.startup_switch == 2: 200 | s += "WRST)\r\n" 201 | else: 202 | s += "undefined)\r\n" 203 | return s 204 | 205 | 206 | class ComponentID_Record(): 207 | def __init__(self, data): 208 | self.index = struct.unpack("!H", data[:2])[0] 209 | if self.index in [1, 2, 5]: 210 | self.name = data[2:26].strip("\x00") 211 | elif self.index in [3, 7, 8, 11]: 212 | self.name = data[2:34].strip("\x00") 213 | elif self.index == 4: 214 | self.name = data[2:28].strip("\x00") 215 | elif self.index == 9: 216 | self.name = "" 217 | self.manufacturer_id = struct.unpack("!H", data[2:4])[0] 218 | self.profile_id = struct.unpack("!H", data[4:6])[0] 219 | self.profile_type = struct.unpack("!H", data[6:8])[0] 220 | elif self.index == 10: 221 | self.name = data[2:28].strip("\x00") 222 | self.oem_id = struct.unpack("!H", data[28:30])[0] 223 | self.oem_add_id = struct.unpack("!H", data[30:32])[0] 224 | else: 225 | self.name = data[2:] 226 | 227 | def __str__(self): 228 | if self.name == "" and self.index not in [9, 10]: 229 | return "" 230 | if self.index == 1: 231 | return " PLC name: {}".format(self.name) 232 | elif self.index == 2: 233 | return " Module name: {}".format(self.name) 234 | elif self.index == 3: 235 | return " Plant identification of the module: {}".format(self.name) 236 | elif self.index == 4: 237 | return " Stamp: {}".format(self.name) 238 | elif self.index == 5: 239 | return " Serial number: {}".format(self.name) 240 | elif self.index == 7: 241 | return " Module type name: {}".format(self.name) 242 | elif self.index == 8: 243 | if self.name in ["MC", "MMC"]: 244 | return " No memory card installed" 245 | else: 246 | return " Memory card serial number: {}".format(self.name) 247 | elif self.index == 9: 248 | return " Manufacturer ID: {}; ptofile ID: {}; profile specific type: {}".format(self.manufacturer_id, self.profile_id, self.profile_type) 249 | elif self.index == 10: 250 | return " OEM copyright ID: {}; OEM ID: {}; additional OEM ID: {}".format(self.name, self.oem_id, self.oem_add_id) 251 | elif self.index == 11: 252 | return " Location designation: {}".format(self.name) 253 | else: 254 | return " Unknown component identification index ({}) {}\r\n {}".format(self.index, self.name, self.name.encode("hex")) 255 | 256 | 257 | class EthDetailsRecord(): 258 | def __init__(self, data): 259 | self.logaddr = struct.unpack("!H", data[:2])[0] 260 | self.ip_addr = inet_ntoa(data[2:6]) 261 | self.subnetmask = inet_ntoa(data[6:10]) 262 | self.defaultrouter = inet_ntoa(data[10:14]) 263 | self.mac_addr = "{}:{}:{}:{}:{}:{}".format(data[14].encode('hex'), 264 | data[15].encode('hex'), 265 | data[16].encode('hex'), 266 | data[17].encode('hex'), 267 | data[18].encode('hex'), 268 | data[19].encode('hex')) 269 | self.source = ord(data[20]) 270 | if self.source == 0: 271 | self.source_str = "IP address not initialized" 272 | elif self.source == 1: 273 | self.source_str = "IP address was configured in STEP 7" 274 | elif self.source == 2: 275 | self.source_str = "IP address was set via DCP" 276 | elif self.source == 3: 277 | self.source_str = " IP address was obtained from a DHCP server" 278 | else: 279 | self.source_str = "" 280 | self.dcp_mod_timestamp = data[21:29] 281 | self.phys_modes = data[30:46] 282 | 283 | def __str__(self): 284 | s = " Logical base address: {:X}\r\n".format(self.logaddr) 285 | if self.source_str: 286 | s += " {}\r\n".format(self.source_str) 287 | if self.source in [1, 2, 3]: 288 | s += " IP address: {}/{}\r\n".format(self.ip_addr, self.subnetmask) 289 | s += " Default gateway: {}\r\n".format(self.defaultrouter) 290 | s += " MAC address: {}\r\n".format(self.mac_addr) 291 | if self.source == 2: 292 | s += " IP address last changed through DCP: {:X}".format(self.dcp_mod_timestamp.encode("hex")) 293 | s += " Physical status of ports: {}".format(self.phys_modes.encode("hex")) 294 | return s 295 | 296 | 297 | class S7Layer(): 298 | def __init__(self, is_llc=False, ifname=None, mac_addr=None, recv_timeout=10): 299 | # S7 layer relies on COTP layer 300 | self._cotp_ = COTP_Layer(is_llc, ifname, mac_addr, recv_timeout) 301 | self._connected_ = False 302 | self._pdu_ref_ = 0 303 | 304 | def _parse_szl(self, szl_data, elen=0, ecount=0): 305 | entries = [] 306 | if len(szl_data) < 8: 307 | raise S7COMM_Exception("[PARSE SZL]Unknown szl format") 308 | szl_data = szl_data[4:] # Skip SZL id and index from szl_data 309 | entry_len, entries_count = struct.unpack("!HH", szl_data[:4]) 310 | if elen != 0 and elen != entry_len: 311 | raise S7COMM_Exception("[PARSE SZL]Incorrect entry length") 312 | if ecount != 0 and ecount != entries_count: 313 | raise S7COMM_Exception("[PARSE SZL]Incorrect entries count") 314 | if len(szl_data) - 4 < entry_len * entries_count: 315 | raise S7COMM_Exception("[PARSE SZL]Incorrect data length") 316 | for i in range(0, entries_count): 317 | offset = 4 + entry_len * i 318 | entries.append(szl_data[offset:offset+entry_len]) 319 | return entries 320 | 321 | def settimeout(self, timeout): 322 | self._cotp_.settimeout(timeout) 323 | 324 | def sendrecv(self, packet): 325 | packet[S7COMM].pdu_ref = self._pdu_ref_ 326 | answer = self._cotp_.sendrecv(packet) 327 | self._pdu_ref_ = (self._pdu_ref_ + 1) & 0xFFFF 328 | return answer 329 | 330 | def connect(self, dst_addr, dst_tsap): 331 | self._cotp_.connect(dst_addr, 0x0100, dst_tsap) 332 | answer = self.sendrecv(S7COMM(param_length=8, data_length=0) / S7COMM_Job() / S7COMM_Job_Connect()) 333 | if not answer.haslayer(S7COMM_Ack) or not answer.haslayer(S7COMM_Job_Connect): 334 | raise S7COMM_Exception("[CONNECT]Incorrect reply from server", answer) 335 | if answer[S7COMM_Ack].error_class != 0 or answer[S7COMM_Ack].error_code != 0: 336 | raise S7COMM_Exception("[CONNECT]Server replied with connection error", answer) 337 | self._connected_ = True 338 | self._pdu_length_ = answer[S7COMM_Job_Connect].pdu_length 339 | 340 | def disconnect(self): 341 | self._cotp_.disconnect() 342 | self._connected_ = False 343 | 344 | def read_szl(self, szl_id, szl_index): 345 | if not self._connected_: 346 | raise S7COMM_Exception("[READ_SZL]Not connected") 347 | answer = self.sendrecv( 348 | S7COMM(param_length=8, data_length=8) / 349 | S7COMM_Data(param_length=4) / 350 | S7COMM_Data_ReadSZL(szl_id=szl_id, szl_ind=szl_index) 351 | ) 352 | if not answer.haslayer(S7COMM_Data_SZL): 353 | raise S7COMM_Exception("[READ_SZL]Incorrect reply from server") 354 | # Save SZL data 355 | szl_data = str(answer[S7COMM_Data_SZL].payload)[:answer[S7COMM_Data_SZL].length] 356 | # If data unit is not last, we need to get the rest part 357 | while answer[S7COMM_Data_SZL].last_du: 358 | answer = self.sendrecv( 359 | S7COMM(param_length=12, data_length=4) / 360 | S7COMM_Data(param_length=4, seq_num=answer[S7COMM_Data].seq_num) / 361 | S7COMM_Data_SZL(return_code=0x0A, transport_size_os=0) 362 | ) 363 | szl_data = szl_data + str(answer[S7COMM_Data_SZL].payload)[:answer[S7COMM_Data_SZL].length] 364 | return szl_data 365 | 366 | def read_szl_list(self): 367 | szl_list = [] 368 | szl_data = self.read_szl(0x0000, 0x0000) 369 | try: 370 | entries = self._parse_szl(szl_data, elen=2) 371 | except S7COMM_Exception: 372 | return szl_data 373 | for entry in entries: 374 | szl_list.append(struct.unpack("!H", entry)[0]) 375 | return szl_list 376 | 377 | def read_module_id(self): 378 | records = [] 379 | szl_data = self.read_szl(0x0011, 0x0000) 380 | try: 381 | entries = self._parse_szl(szl_data, elen=28) 382 | except S7COMM_Exception: 383 | return szl_data 384 | for entry in entries: 385 | records.append(ModuleID_Record(entry)) 386 | return records 387 | 388 | def read_protection(self): 389 | records = [] 390 | szl_data = self.read_szl(0x0232, 0x0004) 391 | try: 392 | entries = self._parse_szl(szl_data, elen=40) 393 | except S7COMM_Exception: 394 | return szl_data 395 | for entry in entries: 396 | records.append(ProtectionRecord(entry)) 397 | return records 398 | 399 | def read_component_id(self): 400 | records = [] 401 | szl_data = self.read_szl(0x001C, 0x0000) 402 | try: 403 | entries = self._parse_szl(szl_data, elen=34) 404 | except S7COMM_Exception: 405 | return szl_data 406 | for entry in entries: 407 | records.append(ComponentID_Record(entry)) 408 | return records 409 | 410 | def read_eth_details(self): 411 | records = [] 412 | szl_data = self.read_szl(0x0037, 0x0000) 413 | try: 414 | entries = self._parse_szl(szl_data, elen=48) 415 | except S7COMM_Exception: 416 | return szl_data 417 | for entry in entries: 418 | records.append(EthDetailsRecord(entry)) 419 | return records 420 | -------------------------------------------------------------------------------- /protocols/tpkt.py: -------------------------------------------------------------------------------- 1 | from scapy.fields import ByteField, ShortField 2 | from scapy.packet import Packet 3 | 4 | 5 | class TPKT(Packet): 6 | name = "TPKT" 7 | fields_desc = [ByteField("version", 3), 8 | ByteField("reserved", 0), 9 | ShortField("length", 0x0000)] 10 | -------------------------------------------------------------------------------- /s7scan.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import platform 4 | import socket 5 | import struct 6 | import pickle 7 | import datetime 8 | from collections import OrderedDict 9 | from argparse import ArgumentParser 10 | # sys.path.append('./third_parties') 11 | from scapy.arch import get_if_hwaddr 12 | from scapy.layers.l2 import LLC, SNAP 13 | from protocols import s7, cotp, s01fd, ether 14 | 15 | S7SCAN_LOG_FILE = "scan_log.txt" 16 | S7SCAN_PLC_FILE = "plc_data.dat" 17 | 18 | 19 | def ask_yes_no(): 20 | valid = {"yes": True, "y": True, "no": False, "n": False} 21 | while True: 22 | choice = raw_input().lower() 23 | if choice in valid: 24 | return valid[choice] 25 | else: 26 | sys.stdout.write("Please respond with 'yes' or 'no'\n") 27 | 28 | 29 | def get_ip_list(mask): 30 | try: 31 | net_addr, mask = mask.split('/') 32 | mask = int(mask) 33 | start, = struct.unpack('!L', socket.inet_aton(net_addr)) 34 | start &= 0xFFFFFFFF << (32-mask) 35 | end = start | (0xFFFFFFFF >> mask) 36 | return [socket.inet_ntoa(struct.pack('!L', addr)) for addr in range(start + 1, end)] 37 | except (struct.error, socket.error, ValueError): 38 | return [] 39 | 40 | 41 | def validate_ip(ip): 42 | try: 43 | socket.inet_aton(ip) 44 | except socket.error: 45 | return False 46 | return ip.count(".") == 3 47 | 48 | 49 | def validate_mac(mac): 50 | if mac.count(":") != 5: 51 | return False 52 | for i in mac.split(":"): 53 | if len(i) != 2: 54 | return False 55 | for j in i: 56 | if j.upper() > "F" or (j.upper() < "A" and not j.isdigit()) or len(i) != 2: 57 | return False 58 | return True 59 | 60 | 61 | def get_user_args(argv): 62 | # Setup option parser 63 | parser = ArgumentParser( 64 | usage="s7scan [options] [addresses]...", 65 | description="""Scan network for Siemens PLC devices. \ 66 | Supports LLC- and TCP/IP based networks. \ 67 | Uses S7 to communicate to PLCs""" 68 | ) 69 | parser.add_argument("--llc", action='store_const', 70 | const=True, default=False, dest='is_llc', 71 | help="Perform LLC networ scan") 72 | parser.add_argument("--tcp", action='store_const', 73 | const=True, default=False, dest='is_tcp', 74 | help="Perform TCP network scan") 75 | parser.add_argument("--iface", default="", dest='iface', 76 | help="Network interface to use (required for LLC scan only)") 77 | parser.add_argument("--tcp-hosts", dest="tcp_hosts", help="""Scan TCP hosts from FILE. \ 78 | TCP host list is a list of IP-addresses. Each address must be placed \ 79 | on a separate line""", 80 | metavar="FILE") 81 | parser.add_argument("--llc-hosts", dest="llc_hosts", help="""Scan LLC hosts from FILE. \ 82 | LLC host list is a list of MAC-addresses. Each address must be placed \ 83 | on a separate line""", 84 | metavar="FILE") 85 | parser.add_argument("--ports", dest="ports", 86 | help="Scan ports from PORTS (for TCP/IP only)", 87 | metavar="PORTS", default="102") 88 | parser.add_argument("--timeout", dest="timeout", default=0, 89 | help="Receive timeout (seconds). How long to wait for server responses") 90 | parser.add_argument("--log-dir", dest="log_dir", 91 | help="Path to the directory where scan results will be stored", 92 | metavar="LOG_DIR", 93 | default=os.path.join(".", "s7scan_{}".format(datetime.datetime.now().strftime("%Y%m%d_%H%M")))) 94 | parser.add_argument("--no-log", action='store_const', 95 | const=True, default=False, dest='no_log', 96 | help="Disable saving scan results in files") 97 | parser.add_argument("addresses", nargs="*") 98 | # Parse arguments and retrun them to caller 99 | args = parser.parse_args(argv) 100 | return parser, args 101 | 102 | 103 | def validate_user_args(args): 104 | # Check if at least one scanning protocol is selected 105 | if not (args.is_llc or args.is_tcp): 106 | print("Select at least one protocol to scan (LLC/TCP)") 107 | return False 108 | # Check user input for args.iface 109 | if args.is_llc: 110 | if args.iface == "": 111 | print("Please specify network interface to use for LLC scan (For example, 'eth0' or 'Intel(R) Ethernet Connection')") 112 | return False 113 | try: 114 | get_if_hwaddr(args.iface) 115 | except (IOError, ValueError): # For Linux it's IOError in ioctl, for Windows it's ValueError 116 | print("Error: invalid interface '{}'. Please check that network interface specified exists. A valid interface might be 'eth0' or 'Intel(R) Ethernet Connection'").format(args.iface) 117 | return False 118 | # Prepare arrays for TCP and LLC scan hosts 119 | tcp_scan_hosts = [] 120 | llc_scan_hosts = [] 121 | # Read args.tcp_hosts file contents if --tcp was specified 122 | if args.is_tcp: 123 | if args.tcp_hosts: 124 | try: 125 | tcp_scan_hosts = [line.strip() for line in open(args.tcp_hosts, 'r').readlines()] 126 | except IOError: 127 | print("Can't open file {}".format(args.tcp_hosts)) 128 | return False 129 | # Read args.llc_hosts file contents if --llc was specified 130 | if args.is_llc: 131 | if args.llc_hosts: 132 | try: 133 | llc_scan_hosts = [line.strip() for line in open(args.llc_hosts, 'r').readlines()] 134 | except IOError: 135 | print("Can't open file {}".format(args.llc_hosts)) 136 | return False 137 | # Add addresses from args.addresses 138 | if args.is_tcp: 139 | for addr in args.addresses: 140 | tcp_scan_hosts.extend(get_ip_list(addr) if '/' in addr else [addr]) 141 | elif args.is_llc: 142 | llc_scan_hosts.extend(args.addresses) 143 | # Delete empty and repeated hosts 144 | llc_scan_hosts = filter(None, llc_scan_hosts) 145 | llc_scan_hosts = list(OrderedDict.fromkeys(llc_scan_hosts)) 146 | tcp_scan_hosts = filter(None, tcp_scan_hosts) 147 | tcp_scan_hosts = list(OrderedDict.fromkeys(tcp_scan_hosts)) 148 | # Validate all target IP addresses 149 | for host in tcp_scan_hosts: 150 | if not validate_ip(host): 151 | print("Error: incorrect target IP address found: {}".format(host)) 152 | return False 153 | for host in llc_scan_hosts: 154 | if not validate_mac(host): 155 | print("Error: incorrect target MAC address found: {}".format(host)) 156 | return False 157 | if args.is_tcp and not tcp_scan_hosts: 158 | print("No targets for TCP/IP scan") 159 | return False 160 | args.tcp_hosts = tcp_scan_hosts 161 | args.llc_hosts = llc_scan_hosts 162 | # Validate scan ports (TCP only) 163 | if args.is_tcp: 164 | try: 165 | scan_ports = [int(port) for port in args.ports.split(',')] 166 | except ValueError: 167 | print("Incorrect port value specified") 168 | return False 169 | args.ports = scan_ports 170 | # Check whether the directory for log files exists if args.no_log is not specified 171 | if not args.no_log: 172 | logfile = os.path.join(args.log_dir, S7SCAN_LOG_FILE) 173 | plcfile = os.path.join(args.log_dir, S7SCAN_PLC_FILE) 174 | if not os.path.isdir(args.log_dir): 175 | # The directory does not exist. Create it now 176 | try: 177 | os.makedirs(args.log_dir) 178 | open(logfile, "wb") 179 | open(plcfile, "wb") 180 | except: 181 | print("Error: unable to create directory for log files {}. Please check access rights".format(args.log_dir)) 182 | return False 183 | else: 184 | # The directory already exists. Check whether log files exist in it 185 | if os.path.isfile(logfile) or os.path.isfile(plcfile): 186 | print("The log files already exist in specified log directory. Do you want to override them?") 187 | if not ask_yes_no(): 188 | print("Cancelled") 189 | return False 190 | else: 191 | try: 192 | open(logfile, "wb") 193 | open(plcfile, "wb") 194 | except IOError: 195 | print("Error: unable to create access log files. Please check access rights") 196 | return False 197 | else: 198 | args.log_dir = None 199 | # Validate timeout value 200 | try: 201 | args.timeout = int(args.timeout) 202 | except ValueError: 203 | print("Incorrect timeout value specified") 204 | return False 205 | if args.timeout == 0: 206 | if args.is_llc: 207 | args.timeout = 10 208 | else: 209 | args.timeout = 1 210 | # All arguments seem to be OK, returning True 211 | return True 212 | 213 | 214 | class S7_PLC_Module: 215 | def __init__(self, tsap, port=None, records=None): 216 | self.tsap = tsap 217 | self.port = port 218 | self.module_ids_records = [] 219 | self.protection_records = [] 220 | self.component_id_records = [] 221 | self.eth_records = [] 222 | self.szl_list = [] 223 | if records: 224 | self.add_records(records) 225 | 226 | def __str__(self): 227 | s = "Tsap {:04X}".format(self.tsap) 228 | if self.port: 229 | s += " (found on TCP port {})\r\n".format(self.port) 230 | else: 231 | s += "\r\n" 232 | s += "Module identification:\r\n" 233 | for record in self.module_ids_records: 234 | s += str(record) + "\r\n" 235 | s += "Module protection:\r\n" 236 | for record in self.protection_records: 237 | s += str(record) + "\r\n" 238 | s += "Component identification:\r\n" 239 | for record in self.component_id_records: 240 | s += str(record) + "\r\n" 241 | s += "Module ethernet details:\r\n" 242 | for record in self.eth_records: 243 | s += str(record) + "\r\n" 244 | return s 245 | 246 | def add_szl_list(self, szls): 247 | self.szl_list = szls 248 | 249 | def add_records(self, records): 250 | if not records: 251 | return 252 | for record in records: 253 | if isinstance(record, s7.ModuleID_Record): 254 | self.module_ids_records.append(record) 255 | elif isinstance(record, s7.ProtectionRecord): 256 | self.protection_records.append(record) 257 | elif isinstance(record, s7.ComponentID_Record): 258 | self.component_id_records.append(record) 259 | elif isinstance(record, s7.EthDetailsRecord): 260 | self.eth_records.append(record) 261 | else: 262 | continue 263 | 264 | 265 | class S7_PLC: 266 | def __init__(self, sup_llc, sup_tcp, ip_addr, mac_addr): 267 | self.ip_addr = ip_addr 268 | self.mac_addr = mac_addr 269 | self.ports = [] 270 | self.supports_tcp = sup_tcp 271 | self.supports_llc = sup_llc 272 | self.modules = OrderedDict() 273 | 274 | def add_module(self, tsap, port=None): 275 | if tsap not in self.modules.keys(): 276 | self.modules[tsap] = S7_PLC_Module(tsap) 277 | if port and port not in self.ports: 278 | self.ports.append(port) 279 | return self.modules[tsap] 280 | 281 | 282 | class PLC_Scanner(): 283 | def __init__(self, is_llc=False, ifname=None, timeout=3, log_dir=None): 284 | if is_llc: 285 | self._mac_addr_ = get_if_hwaddr(ifname) 286 | self._ifname_ = ifname 287 | self._ether_ = ether.EtherRaw(ifname, self._mac_addr_) 288 | else: 289 | self._mac_addr_ = None 290 | self._ifname_ = None 291 | self._ether_ = None 292 | self._conn_ = s7.S7Layer(is_llc, ifname, self._mac_addr_, timeout) 293 | self._szl_list_ = [] 294 | self._timeout_ = timeout 295 | self._is_llc = is_llc 296 | self.results = OrderedDict() 297 | self.results["Scan start time"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 298 | self.results["Command line arguments"] = [] 299 | for arg in sys.argv: 300 | self.results["Command line arguments"].append(arg.decode(sys.stdin.encoding).encode("utf-8")) 301 | self.plcs = OrderedDict() 302 | if log_dir: 303 | self.logfile = open(os.path.join(log_dir, S7SCAN_LOG_FILE), "wb") 304 | self.plcfile = open(os.path.join(log_dir, S7SCAN_PLC_FILE), "wb") 305 | else: 306 | self.logfile = None 307 | self.plcfile = None 308 | self.silent = False 309 | """ 310 | Reset scanner and configure it to use another protocol without using 311 | previous scan results 312 | """ 313 | def reset(self, is_llc=False, ifname=None, timeout=3): 314 | if is_llc: 315 | self._mac_addr_ = get_if_hwaddr(ifname) 316 | self._ifname_ = ifname 317 | self._ether_ = ether.EtherRaw(ifname, self._mac_addr_) 318 | else: 319 | self._mac_addr_ = None 320 | self._ifname_ = None 321 | self._ether_ = None 322 | self._conn_ = s7.S7Layer(is_llc, ifname, self._mac_addr_, timeout) 323 | self._timeout_ = timeout 324 | self._is_llc = is_llc 325 | 326 | def log_write(self, log_str): 327 | if not self.silent: 328 | print(log_str) 329 | if self.logfile: 330 | self.logfile.write(log_str + "\r\n") 331 | 332 | def log_flush(self): 333 | if self.logfile: 334 | self.logfile.flush() 335 | 336 | def serialize_plc(self, addr): 337 | if self.plcfile and addr in self.plcs.keys(): 338 | pickle.dump(self.plcs[addr], self.plcfile) 339 | self.plcfile.flush() 340 | 341 | def add_plc(self, addr): 342 | if addr not in self.plcs.keys(): 343 | if self._is_llc: 344 | self.plcs[addr] = S7_PLC(True, False, None, addr) 345 | else: 346 | self.plcs[addr] = S7_PLC(False, True, addr, None) 347 | 348 | def add_plc_module(self, addr, tsap): 349 | if not self._is_llc: 350 | (addr, port) = addr 351 | else: 352 | (addr, port) = (addr, None) 353 | if addr not in self.plcs.keys(): 354 | self.add_plc(addr) 355 | return self.plcs[addr].add_module(tsap, port) 356 | 357 | def llc_enumerate(self, timeout): 358 | bcast = "ff:ff:ff:ff:ff:ff" 359 | bcast_packet = LLC() / SNAP() / s01fd.S01FD(type=0x0500) 360 | answers = self._ether_.sendrcv_timeout(bcast, bcast_packet, timeout) 361 | macs = [] 362 | for x in answers: 363 | if x.haslayer(s01fd.S01FD): 364 | if x[s01fd.S01FD].type == 0x0501: 365 | macs.append(x.src) 366 | return macs 367 | 368 | def read_device_info(self, szl_list, szls): 369 | result = [] 370 | for szl in szls: 371 | if (len(szl_list) == 0) or (len(szl_list) > 0 and szl in szl_list): 372 | if szl == 0x0011: 373 | ids = self._conn_.read_module_id() 374 | log_str = "module identification" 375 | elif szl == 0x001C: 376 | ids = self._conn_.read_component_id() 377 | log_str = "component identification" 378 | elif szl == 0x0232: 379 | ids = self._conn_.read_protection() 380 | log_str = "module protection info" 381 | elif szl == 0x0037: 382 | ids = self._conn_.read_eth_details() 383 | log_str = "module ethernet details" 384 | else: 385 | ids = [] 386 | if type(ids) is str: 387 | if len(ids) > 0: 388 | self.log_write(" [-] Error occured while reading {} (unknown response format). Raw resposne:".format(log_str)) 389 | self.log_write(ids.encode('hex')) 390 | else: 391 | self.log_write(" [-] Error occured while reading {} (no answer from the device)".format(log_str)) 392 | ids = [] 393 | else: 394 | if szl == 0x0011: 395 | log_str = "module identification" 396 | elif szl == 0x001C: 397 | log_str = "component identification" 398 | elif szl == 0x0232: 399 | log_str = "module protection info" 400 | elif szl == 0x0037: 401 | log_str = "module ethernet details" 402 | else: 403 | log_str = "unknown" 404 | self.log_write("[-] {} SZL ({:04X}) is not supported by this module".format(log_str, szl)) 405 | ids = [] 406 | self.log_flush() 407 | result.extend(ids) 408 | return result 409 | 410 | def scan(self, addr, tsap): 411 | # 1. Connect to the device 412 | try: 413 | self._conn_.connect(addr, tsap) 414 | except (cotp.COTP_Exception, s7.S7COMM_Exception, socket.error): 415 | return 416 | self.log_write("\r\n\r\nConnected to {} with tsap {:04x}".format(addr, tsap)) 417 | self.log_flush() 418 | # 1. Create new PLC module (and PLC, if it doesn't exist) 419 | module = self.add_plc_module(addr, tsap) 420 | # 2. Get SZL list from the device 421 | szl_list = self._conn_.read_szl_list() 422 | if len(szl_list) == 0: 423 | self.log_write("[-] Error occured while reading SZL list (no answer from the device)") 424 | elif type(szl_list) is str: 425 | self.log_write("[-] Error occured while reading SZL list (unknown response format). Raw resposne:") 426 | self.log_write(szl_list.encode('hex')) 427 | szl_list = [] 428 | module.add_szl_list(szl_list) 429 | self.log_flush() 430 | # 3. Read all module parameters, save them in the PLC array and log 431 | module.add_records(self.read_device_info(szl_list, [0x0011, 0x001C, 0x0232, 0x0037])) 432 | self.log_write(str(module)) 433 | self.log_flush() 434 | 435 | def scan_llc(self, scan_hosts): 436 | self.log_write("LLC network scan started") 437 | self.log_write("Using network interface {} / {}".format(self._ifname_, self._mac_addr_)) 438 | if scan_hosts in [None, []]: 439 | self.log_write("Hosts to scan were not specified. Sending broadcast enumeration request...") 440 | scan_hosts = self.llc_enumerate(5) 441 | if len(scan_hosts) == 0: 442 | self.log_write("No PLCs detected in the network") 443 | return 444 | self.log_write("Detected hosts: {}".format(scan_hosts)) 445 | self.log_flush() 446 | for host in scan_hosts: 447 | self.log_write("\rScanning {}...".format(host)) 448 | # Restore timeout value after possible fine tune from previous host scan 449 | self._conn_.settimeout(self._timeout_) 450 | for tsap in range(0x0100, 0x0200): 451 | self.scan(host, tsap) 452 | # Serialize data collected for curent PLC (host) 453 | self.serialize_plc(host) 454 | self.log_write("\r\n\r\nScan ended") 455 | return 456 | 457 | def scan_tcp(self, scan_hosts, scan_ports): 458 | self.log_write("TCP/IP network scan started") 459 | for host in scan_hosts: 460 | sys.stdout.write("\rScanning {}...".format(host)) 461 | # Restore timeout value after possible fine tune from previous host scan 462 | self._conn_.settimeout(self._timeout_) 463 | for port in scan_ports: 464 | for tsap in range(0x0100, 0x0200): 465 | try: 466 | self.scan((host, port), tsap) 467 | except (cotp.UnreachableHostException, cotp.ConnectionRefusedException, cotp.COTP_Exception, s7.S7COMM_Exception, socket.timeout) as e: 468 | if type(e) == socket.timeout: 469 | self.log_write("socket timeout happened") 470 | continue 471 | elif type(e) == s7.S7COMM_Exception or type(e) == cotp.COTP_Exception: 472 | self.log_write(str(e)) 473 | continue 474 | else: 475 | break 476 | # Serialize data collected for curent PLC (host) 477 | self.serialize_plc(host) 478 | self.log_write("\r\n\r\nScan ended") 479 | 480 | 481 | def main(): 482 | print("s7scan v1.03 [Python 2] [Scapy-based]") 483 | # Get user arguments 484 | parser, args = get_user_args(sys.argv[1:]) 485 | # Validate user arguments 486 | if not validate_user_args(args): 487 | parser.print_help() 488 | return 489 | # Run scan 490 | scanner = None 491 | if args.is_llc: 492 | # For LLC we need to check whether WinPcap is installed first (in case we are running on Windows) 493 | #system = platform.system() 494 | #if system == 'Windows': 495 | # if not winpcap_installer.is_installed(): 496 | # # WinPcap is not installed. We need to install it to continue 497 | # print "[Warning] WinPcap is not installed on the current Windows system. Do you want to install it (y/n)?" 498 | # print "It will be uninstalled automatically after scan" 499 | # if not ask_yes_no(): 500 | # print "LLC scan without WinPcap is not supported. Terminating..." 501 | # return 502 | # else: 503 | # winpcap_installer.install() 504 | # reload(scapy) 505 | # Setup scanner 506 | scanner = PLC_Scanner(is_llc=True, ifname=args.iface, timeout=args.timeout, log_dir=args.log_dir) 507 | scanner.scan_llc(args.llc_hosts) 508 | # winpcap_installer.uninstall() 509 | if args.is_tcp: 510 | # Setup scanner 511 | if scanner: 512 | scanner.reset() 513 | else: 514 | scanner = PLC_Scanner(is_llc=False, ifname=None, timeout=args.timeout, log_dir=args.log_dir) 515 | scanner.scan_tcp(args.tcp_hosts, args.ports) 516 | # Serialize collected data to the separate file using pickle 517 | #if not args.no_log: 518 | # json.dump(scanner.results, open(args.log_file, "wb"), indent=4) 519 | # print("Scan results saved to {}".format(args.log_file)) 520 | 521 | if __name__ == "__main__": 522 | try: 523 | main() 524 | except KeyboardInterrupt: 525 | print("\r\nScan terminated") 526 | -------------------------------------------------------------------------------- /tests/plcclient.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import random 3 | sys.path.append('../') 4 | from protocols.ether import EtherRaw 5 | from protocols.s01fd import * 6 | from protocols.s7 import * 7 | 8 | class PLCClient(): 9 | def __init__(self, is_llc, iface, src_mac, recv_timeout=10): 10 | self._is_llc = is_llc 11 | self._ether_ = EtherRaw(iface, src_mac) 12 | self._tpdu_num_ = 0 13 | self._s7comm_ = S7Layer(is_llc=is_llc, ifname=iface, mac_addr=src_mac, recv_timeout=recv_timeout) 14 | 15 | @staticmethod 16 | def recv_s7_filter(self): 17 | def recv_cc_filter(x): 18 | if x.haslayer(COTP_Data): 19 | # x.show() 20 | return True 21 | 22 | def sendrecv_s7(self, dst, src_ref, dst_ref, data): 23 | self._ether_.send(dst, 24 | LLC(dsap=0xfe, ssap=0xfe, ctrl=3) / 25 | CLNP(subnet=0) / 26 | COTP(length=0x9, dst_ref=dst_ref) / 27 | COTP_DataAcknowledgement(tpdu_num=0x0, credit=0x1) 28 | ) 29 | answer = self._ether_.sendrcv(dst, 30 | LLC(dsap=0xfe, ssap=0xfe, ctrl=3) / 31 | CLNP(subnet=0) / 32 | COTP(length=0x7, dst_ref=dst_ref) / 33 | COTP_Data(tpdu_num=self._tpdu_num_) / 34 | Raw(data), 35 | PLCClient.recv_s7_filter 36 | ) 37 | self._tpdu_num_ += 1 38 | answer = filter(PLCClient.recv_s7_filter, answer) 39 | if len(answer) != 1: 40 | raise Exception() 41 | return answer[0] 42 | 43 | def scan(self, dst, szls): 44 | self._cotp_.connect(dst, 0x0100, 0x0100) 45 | s7_setup = '32010000020000080000f0000002000201e0'.decode('hex') 46 | self.sendrecv_s7(dst, src_ref, target_ref, s7_setup) 47 | for i, szl in enumerate(szls): 48 | id, index = szl 49 | s7_request = ('320700000a00000800080001120411440100ff090004'.decode('hex') + struct.pack('>H', id) + struct.pack('>H', index)).decode('hex') 50 | self.sendrecv_s7(dst, src_ref, target_ref, s7_request) 51 | 52 | def plc_enumerate(self, timeout): 53 | bcast = "ff:ff:ff:ff:ff:ff" 54 | answers = self._ether_.sendrcv_timeout(bcast, LLC() / SNAP() / S01FD(type=0x0500), timeout) 55 | macs = [] 56 | for x in answers: 57 | if x.haslayer(S01FD): 58 | if x[S01FD].type == 0x0501: 59 | macs.append(x.src) 60 | return macs 61 | 62 | def main(): 63 | is_llc = True 64 | plc_ip = '192.168.129.129' 65 | plc_port = 102 66 | iface="VMware Virtual Ethernet Adapter for VMnet1" 67 | 68 | if is_llc: 69 | client = PLCClient(is_llc, iface, "00:00:00:00:00:01", 10) 70 | print "Enumerating PLC devices in the network..." 71 | plc_macs = client.plc_enumerate(3) 72 | for plc_mac in plc_macs: 73 | print "Found PLC with MAC-address {}".format(plc_mac) 74 | print "Connecting to PLC using COTP..." 75 | try: 76 | client._s7comm_.connect(plc_mac) 77 | except (COTP_Exception, S7COMM_Exception) as e: 78 | print str(e) 79 | continue 80 | print "Connected successfully" 81 | client._s7comm_.read_szl(0x0132, 0x0004) 82 | try: 83 | client._s7comm_.disconnect() 84 | except COTP_Exception as e: 85 | print str(e) 86 | continue 87 | else: 88 | client = PLCClient(is_llc, None, None, 10) 89 | client._s7comm_.connect((plc_ip, plc_port)) 90 | 91 | 92 | 93 | 94 | szls = [(0x132, 0x4), (0x0, 0x0), (0x111, 0x1), (0x424, 0x0), (0xf19, 0x0), (0x19, 0x0), (0xf11, 0x0), (0x11, 0x0)] 95 | #client.scan("00:00:00:00:00:02", szls) 96 | """ 97 | macs = client.enum(10) 98 | for target in macs: 99 | client.scan(target, szls) 100 | """ 101 | 102 | if __name__ == '__main__': 103 | main() 104 | -------------------------------------------------------------------------------- /tests/plcserver.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import random 3 | sys.path.append('../') 4 | from protocols.ether import EtherRaw 5 | from protocols.s01fd import * 6 | from protocols.cotp import * 7 | from protocols.s7 import * 8 | 9 | szl_0000 = "\x32\x07\x00\x00\x06\x00\x00\x0c\x01\x82\x00\x01\x12\x08\x12\x84" \ 10 | "\x01\x01\x00\x00\x00\x00\xff\x09\x01\x7e\x00\x00\x00\x00\x00\x02" \ 11 | "\x00\xbb\x00\x00\x01\x00\x02\x00\x03\x00\x0f\x00\x00\x11\x01\x11" \ 12 | "\x0f\x11\x00\x12\x01\x12\x0f\x12\x00\x13\x01\x13\x0f\x13\x00\x14" \ 13 | "\x01\x14\x0f\x14\x00\x15\x01\x15\x0f\x15\x00\x16\x01\x16\x0f\x16" \ 14 | "\x00\x17\x01\x17\x0f\x17\x00\x19\x01\x19\x0f\x19\x00\x1a\x0f\x1a" \ 15 | "\x00\x1b\x0f\x1b\x00\x1c\x01\x1c\x02\x1c\x03\x1c\x0f\x1c\x00\x21" \ 16 | "\x01\x21\x02\x21\x09\x21\x0a\x21\x0f\x21\x00\x22\x01\x22\x08\x22" \ 17 | "\x09\x22\x0f\x22\x02\x22\x00\x23\x01\x23\x02\x23\x0f\x23\x00\x24" \ 18 | "\x01\x24\x02\x24\x0f\x24\x04\x24\x05\x24\x00\x25\x01\x25\x02\x25" \ 19 | "\x0f\x25\x01\x31\x0f\x31\x01\x32\x02\x32\x0f\x32\x00\x33\x0f\x33" \ 20 | "\x00\x36\x01\x36\x0f\x36\x00\x37\x0f\x37\x00\x38\x01\x38\x02\x38" \ 21 | "\x0f\x38\x01\x39\x0f\x39\x00\x3a\x01\x3a\x0f\x3a\x00\x3c\x01\x3c" \ 22 | "\x0f\x3c\x00\x71\x0f\x71\x00\x74\x01\x74\x0f\x74\x00\x75\x0c\x75" \ 23 | "\x0f\x75\x00\x81\x01\x81\x02\x81\x03\x81\x05\x81\x06\x81\x07\x81" \ 24 | "\x08\x81\x09\x81\x0a\x81\x0b\x81\x0c\x81\x0f\x81\x00\x82\x01\x82" \ 25 | "\x02\x82\x03\x82\x05\x82\x06\x82\x07\x82\x08\x82\x09\x82\x0a\x82" \ 26 | "\x0b\x82\x0c\x82\x0f\x82\x00\x90\x01\x90\x0f\x90\x05\x91\x0a\x91" \ 27 | "\x0c\x91\x0d\x91\x00\x92\x02\x92\x03\x92\x04\x92\x05\x92\x06\x92" \ 28 | "\x0f\x92\x00\x94\x02\x94\x06\x94\x07\x94\x0f\x94\x00\x95\x01\x95" \ 29 | "\x0f\x95\x06\x96\x0c\x96\x0c\x97\x0d\x97\x01\x9a\x02\x9a\x0f\x9a" \ 30 | "\x0c\x9b\x00\x9c\x01\x9c\x02\x9c\x03\x9c\x0f\x9c\x00\xa0\x01\xa0" \ 31 | "\x04\xa0\x05\xa0\x06\xa0\x07\xa0\x08\xa0\x09\xa0\x0a\xa0\x0b\xa0" \ 32 | "\x0c\xa0\x0d\xa0\x0e\xa0\x0f\xa0\x00\xb1\x00\xb2\x00\xb3\x00\xb4" \ 33 | "\x01\xb5\x02\xb5\x03\xb5\x04\xb5\x05\xb5\x06\xb5\x07\xb5\x08\xb5" \ 34 | "\x01\xb6\x02\xb6\x03\xb6\x04\xb6" 35 | 36 | szl_0011 = "\x32\x07\x00\x00\x35\x00\x00\x0c\x00\x7c\x00\x01\x12\x08\x12\x84" \ 37 | "\x01\x01\x00\x00\x00\x00\xff\x09\x00\x78\x00\x11\x00\x00\x00\x1c" \ 38 | "\x00\x04\x00\x01\x36\x45\x53\x37\x20\x34\x31\x32\x2d\x35\x48\x4b" \ 39 | "\x30\x36\x2d\x30\x41\x42\x30\x20\x00\x82\x00\x42\x00\x00\x00\x06" \ 40 | "\x36\x45\x53\x37\x20\x34\x31\x32\x2d\x35\x48\x4b\x30\x36\x2d\x30" \ 41 | "\x41\x42\x30\x20\x00\x82\x00\x01\x00\x00\x00\x07\x20\x20\x20\x20" \ 42 | "\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20" \ 43 | "\x00\x00\x56\x06\x00\x04\x00\x81\x42\x6f\x6f\x74\x20\x4c\x6f\x61" \ 44 | "\x64\x65\x72\x20\x20\x20\x20\x20\x20\x20\x20\x20\x00\x00\x56\x06" \ 45 | "\x00\x00" 46 | 47 | szl_001C = "\x32\x07\x00\x00\x34\x00\x00\x0c\x01\x82\x00\x01\x12\x08\x12\x84" \ 48 | "\x01\x01\x00\x00\x00\x00\xff\x09\x01\x7e\x00\x1c\x00\x00\x00\x22" \ 49 | "\x00\x0b\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 50 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 51 | "\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 52 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 53 | "\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00" \ 54 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 55 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x4f\x72\x69\x67\x69\x6e" \ 56 | "\x61\x6c\x20\x53\x69\x65\x6d\x65\x6e\x73\x20\x45\x71\x75\x69\x70" \ 57 | "\x6d\x65\x6e\x74\x00\x00\x00\x00\x00\x00\x00\x05\x53\x56\x50\x46" \ 58 | "\x31\x33\x31\x33\x38\x34\x37\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 59 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x43\x50" \ 60 | "\x55\x20\x34\x31\x32\x2d\x35\x48\x00\x00\x00\x00\x00\x00\x00\x00" \ 61 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08" \ 62 | "\x4d\x43\x20\x53\x56\x50\x46\x35\x30\x30\x33\x30\x37\x36\x20\x00" \ 63 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 64 | "\x00\x09\x00\x2a\xf6\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" \ 65 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 66 | "\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 67 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 68 | "\x00\x00\x00\x00\x00\x0c\x31\x41\x41\x30\x36\x2d\x30\x58\x41\x30" \ 69 | "\x20\x20\x30\x33\x20\x42\x49\x46\x30\x31\x37\x39\x4f\x31\x31\x30" \ 70 | "\x31\x32\x34\x39\x20\x20\x00\x0d\x31\x41\x41\x30\x36\x2d\x30\x58" \ 71 | "\x41\x30\x20\x20\x30\x33\x20\x42\x49\x46\x30\x31\x37\x39\x4f\x38" \ 72 | "\x31\x30\x33\x30\x34\x34\x20\x20" 73 | 74 | szl_0037 = "\x32\x07\x00\x00\x35\x00\x00\x0c\x00\x3c\x00\x01\x12\x08\x12\x84" \ 75 | "\x01\x01\x00\x00\x00\x00\xff\x09\x00\x38\x00\x37\x00\x00\x00\x30" \ 76 | "\x00\x01\xff\xff\xc0\xa8\x00\x28\xff\xff\xff\x00\xc0\xa8\x00\x28" \ 77 | "\x00\x1b\x1b\xbc\xfb\xc4\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 78 | "\x8f\x88\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 79 | "\x00\x00" 80 | 81 | szl_0232 = "\x32\x07\x00\x00\x32\x00\x00\x0c\x00\x34\x00\x01\x12\x08\x12\x84" \ 82 | "\x01\x01\x00\x00\x00\x00\xff\x09\x00\x30\x02\x32\x00\x04\x00\x28" \ 83 | "\x00\x01\xf8\x04\x00\x01\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00" \ 84 | "\x56\x56\x00\x00\x00\x00\x20\x00\xb3\x5f\x02\x56\x00\x00\x00\x00" \ 85 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" 86 | szl_error = "\x32\x07\x00\x00\x07\x00\x00\x0c\x00\x04\x00\x01\x12\x08\x12\x84" \ 87 | "\x01\x01\x00\x00\xd4\x02\x0a\x00\x00\x00" 88 | 89 | class PLCServer(): 90 | def __init__(self, is_llc=False, ether=None): 91 | self._is_llc_ = is_llc 92 | if not is_llc: 93 | self._ether_ = None 94 | self._socket_ = socket.socket() 95 | self._socket_.bind(("", 102)) 96 | else: 97 | self._socket_ = None 98 | self._ether_ = ether 99 | def recv_enum(self): 100 | def recv_enum_filter(x): 101 | if x.haslayer(SNAP): 102 | if x[S01FD].type == 0x0500: 103 | return True 104 | return False 105 | answer = self._ether_.recv(recv_enum_filter) 106 | answer = filter(recv_enum_filter, answer) 107 | if len(answer) != 1: 108 | raise Exception() 109 | packet = answer[0] 110 | return packet.src 111 | def reply_enum(self, dst): 112 | self._ether_.send(dst, LLC() / SNAP() / S01FD(type=0x0501) / 113 | "\x0c\x01\x53\x37\x2d\x33\x30\x30\x20\x43\x50\x00\x1e\x02\x4d\xfc" \ 114 | "\x68\x6c\x65\x28\x32\x29\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ 115 | "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x03\x07\x00" 116 | ) 117 | def recv_cr(self): 118 | if self._is_llc_: 119 | def recv_cr_filter(x): 120 | if x.haslayer(COTP_LLC_ConnectRequest): 121 | return True 122 | answer = self._ether_.recv(recv_cr_filter) 123 | answer = filter(recv_cr_filter, answer) 124 | if len(answer) != 1: 125 | raise Exception() 126 | packet = answer[0] 127 | self._dst_mac_ = packet.src 128 | self._dst_ref_ = packet[COTP_LLC_ConnectRequest].src_ref 129 | else: 130 | packet = TPKT(self._tcp_conn_.recv(4096)) 131 | self._dst_ref_ = packet[COTP_TCP_ConnectRequest].src_ref 132 | def send_cc(self, src_ref): 133 | if self._is_llc_: 134 | self._ether_.send(self._dst_mac_, 135 | LLC(dsap=0xfe, ssap=0xfe, ctrl=3) / 136 | CLNP(subnet=0) / 137 | COTP(length=0x0c) / 138 | COTP_LLC_ConnectConfirm( 139 | dst_ref=self._dst_ref_, src_ref=src_ref, class_=0x4, ext_format=1, tpdu_size_value=0x9, options_value=0x02 140 | ) 141 | ) 142 | else: 143 | self._tcp_conn_.send(raw(TPKT(length = 22) / COTP(length=0x0c) / 144 | COTP_TCP_ConnectConfirm( 145 | dst_ref=self._dst_ref_, src_ref=src_ref, class_=0x4, ext_format=1, tpdu_size_value=0x9 146 | ) 147 | )) 148 | 149 | def recv_s7(self): 150 | def recv_ak_filter(x): 151 | if x.haslayer(COTP_DataAcknowledgement): 152 | return True 153 | def recv_dt_filter(x): 154 | if x.haslayer(S7COMM_SetupComm): 155 | return True 156 | if self._is_llc_: 157 | #self._ether_.recv(recv_ak_filter) 158 | answer = self._ether_.recv(recv_dt_filter) 159 | answer = filter(recv_dt_filter, answer) 160 | if len(answer) != 1: 161 | raise Exception() 162 | packet = answer[0] 163 | return packet 164 | else: 165 | data = self._tcp_conn_.recv(4096) 166 | if not data: 167 | return None 168 | else: 169 | return TPKT(data) 170 | def send_s7(self, data): 171 | packet = TPKT(length = len(data) + 7) / COTP() / COTP_TCP_Data() / raw(data) 172 | self._tcp_conn_.send(raw(packet)) 173 | def send_s7_cc(self): 174 | if self._is_llc_: 175 | self._ether_.send(self._dst_mac_, 176 | LLC() / 177 | CLNP() / 178 | COTP(dst_ref = self._dst_ref_) / 179 | COTP_DataAcknowledgement() 180 | ) 181 | self._ether_.send(self._dst_mac_, 182 | LLC() / 183 | CLNP() / 184 | COTP(dst_ref = self._dst_ref_) / 185 | COTP_LLC_Data() / 186 | S7COMM() / 187 | S7COMM_SetupCommAck() 188 | ) 189 | else: 190 | self._tcp_conn_.send(raw(TPKT(length=27) / 191 | COTP() / 192 | COTP_TCP_Data() / S7COMM(param_length=8) / 193 | S7COMM_Ack() / S7COMM_Job() / 194 | S7COMM_Job_Connect() 195 | )) 196 | 197 | def run(self): 198 | if self._is_llc_: 199 | print "Server is starting in LLC mode..." 200 | self.reply_enum(self.recv_enum()) 201 | else: 202 | print "Server is starting in TCP/IP mode..." 203 | self._socket_.listen(10) 204 | while 1: 205 | if not self._is_llc_: 206 | conn, addr = self._socket_.accept() 207 | self._tcp_conn_ = conn 208 | self._tcp_addr_ = addr 209 | self.recv_cr() 210 | src_ref = self._dst_ref_ 211 | while src_ref == self._dst_ref_: 212 | src_ref = random.randint(0, 0xffff) 213 | self.send_cc(src_ref) 214 | s7_cr = self.recv_s7() 215 | self.send_s7_cc() 216 | while 1: 217 | s7_request = self.recv_s7() 218 | if s7_request == None: 219 | break 220 | if S7COMM_Data_ReadSZL in s7_request: 221 | szl_request = s7_request[S7COMM_Data_ReadSZL] 222 | if szl_request.szl_id == 0x0000: 223 | szl_answer = szl_0000 224 | elif szl_request.szl_id == 0x0011: 225 | szl_answer = szl_0011 226 | elif szl_request.szl_id == 0x001C: 227 | szl_answer = szl_001C 228 | elif szl_request.szl_id == 0x0037: 229 | szl_answer = szl_0037 230 | elif szl_request.szl_id == 0x0232: 231 | szl_answer = szl_0232 232 | else: 233 | szl_answer = szl_error 234 | self.send_s7(szl_answer) 235 | 236 | 237 | 238 | def main(iface="VMware Virtual Ethernet Adapter for VMnet1", is_llc=True): 239 | if is_llc: 240 | ether = EtherRaw(iface, "00:00:00:00:00:02") 241 | else: 242 | ether = None 243 | server = PLCServer(is_llc, ether=ether) 244 | server.run() 245 | 246 | if __name__ == '__main__': 247 | is_llc = False 248 | adapter = "VMware Virtual Ethernet Adapter for VMnet1" 249 | if len(sys.argv) > 1 and sys.argv[1] == "--llc": 250 | is_llc = True 251 | if len(sys.argv) > 2: 252 | adapter = sys.argv[2] 253 | main(adapter, is_llc) 254 | -------------------------------------------------------------------------------- /tests/s7scan_functions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.append('../') 3 | from s7scan import ask_yes_no, get_ip_list, validate_ip, validate_mac, get_user_args, validate_user_args 4 | 5 | def test_ask_yes_no(): 6 | print("Testing ask_yes_no()") 7 | result = ask_yes_no() 8 | print("Result was {}".format(result)) 9 | def test_get_ip_list(ip_list): 10 | print("Testing get_ip_list()") 11 | result = get_ip_list(ip_list) 12 | print("Result IP list:") 13 | print(result) 14 | def test_validate_ip(ip): 15 | print("Testing validate_ip()") 16 | result = validate_ip(ip) 17 | print("Validation result: {}".format(result)) 18 | def test_validate_mac(mac): 19 | print("Testing validate_mac()") 20 | result = validate_mac(mac) 21 | print("Validation result: {}".format(result)) 22 | def test_user_args(argv): 23 | parser, args = get_user_args(argv) 24 | result = validate_user_args(args) 25 | print("Argument validation result: {}".format(result)) 26 | if result: 27 | print("Arguments:") 28 | print(" is_llc: {}".format(args.is_llc)) 29 | print(" is_tcp: {}".format(args.is_tcp)) 30 | print(" iface: {}".format(args.iface)) 31 | print(" tcp_hosts: {}".format(args.tcp_hosts)) 32 | print(" llc_hosts: {}".format(args.llc_hosts)) 33 | print(" ports: {}".format(args.ports)) 34 | print(" timeout: {}".format(args.timeout)) 35 | print(" log_dir: {}".format(args.log_dir)) 36 | print(" no_log: {}".format(args.no_log)) 37 | print(" addresses: {}".format(args.addresses)) 38 | -------------------------------------------------------------------------------- /third_parties/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klsecservices/s7scan/87a7aeeb3c932491745dfded2577d221083f87df/third_parties/__init__.py --------------------------------------------------------------------------------