In an era of digital revolution and an ever-increasing number of Internet-connected devices, knowledge of network protocols is key for cybersecurity professionals. Our hacking site presents a detailed tutorial on network protocol analysis that will allow you to decipher, analyze and understand important aspects of network communication. By looking at popular protocols such as TCP, UDP, ICMP and others, we examine how data is transmitted over the network and what potential threats and vulnerabilities may be hidden in those transmissions. For a hacker, the ability to read and analyze network traffic is a vital skill that can reveal system weaknesses or potential targets for attack. Each protocol has its own unique structure and transmission mechanisms. Understanding these mechanisms is key to effective analysis.
For example, knowing how the TCP three-way handshake works will help you determine if a SYN flood attack is being attempted on your network. This part of Network Protocol Analysis is designed to provide you with specific tools, techniques, and knowledge to deeply investigate network traffic. From the basics to advanced techniques, you’ll learn about tools like Wireshark, tcpdump, and more to help you with your analysis. Don’t let network protocols be a mystery to you. With our help, you can become a real expert in this field, expanding your capabilities and skills in the field of cyber security. We invite you to explore the network world in depth with us!
Protocol analysis is important for tasks such as fingerprinting, information gathering, and even surgery. But in the IoT world, you often have to work with proprietary, custom, or new network protocols. These protocols can cause difficulties in
Even if you manage to capture network traffic, packet sniffers like Wireshark usually can’t make out what you’ve found. Sometimes you need to use new tools to communicate with your IoT device.
In this chapter, we will explain the process of analyzing network communications, focusing on the problems you will encounter when working with unusual protocols. Let’s start by learning how to assess the security of unfamiliar network protocols and implement special tools for their analysis. Next, we’ll extend the most popular traffic analyzer, Wireshark, by writing our own protocol analyzer. After that, we’ll write custom modules for Nmap that will scan fingerprints and even attack any new network protocol that dares to get in your way.
The examples in this section are not aimed at anything out of the ordinary, but DICOM is one of the most common protocols in medical devices and clinical systems. However, almost no security tools support DICOM, so this section will help you deal with any unusual network protocol you may encounter in the future.
When you work with unusual protocols, it is better to analyze them according to the methodology. Follow the procedure described in this section when evaluating the security of a network protocol. We try to cover the most important tasks, including information gathering, analysis, prototyping and security testing.
Collection of information
At the stage of gathering information, try to find all the relevant resources available to you. But first, find out if the protocol is well documented: look for its official and unofficial documentation.
Listing and installation of customers
After accessing the documentation, locate and install any client programs that may interact with the protocol. You can use them to replicate and generate traffic as you wish. Different clients may implement the protocol with slight differences – pay attention to these differences! Also, check if the programmers have written implementations of the protocol in different programming languages. The more customers and implementations you find, the more chances you have to gather complete information and reproduce network messages.
Detection of dependent protocols
Find out if the protocol really depends on other protocols. For example, the Server Message Block (SMB) protocol typically works with NetBios over TCP/IP (NBT). If you’re writing new tools, you need to know all the protocol dependencies in order to read and understand messages and create and send new ones. Be sure to find out what transport protocol your protocol uses. Is it TCP or UDP? Or maybe something else – SKTP?
Defining the protocol port
Determine the default port number for the protocol and determine whether the protocol can run on alternate ports. Defining a default port and being able to change that port is useful information that you will use when writing scanners or information gathering tools. For example, Nmap network exploration scripts may not work if we write the wrong rule, and Wireshark may use the wrong low-level parser/decoder (dissector). While there are workarounds for these issues, it’s best to have robust scanning rules in place from the start.
Find additional documentation
For more documentation or capture examples, visit the Wireshark website. The Wireshark project often includes packet capture and is generally a great source of information. The project uses a wiki (https://gitlab.com/wireshark/wireshark/-/ wikis/home/) that allows users to edit each page.
Also note which areas lack documentation. Can you identify features that are not described well enough? Lack of documentation can lead you to interesting discoveries.
Check that all Wireshark dissectors work correctly with the protocol you are using. Can Wireshark correctly interpret and read all fields in protocol messages?
To do this, first check if Wireshark has an analyzer for the protocol and if it is enabled: click Analyze > Enabled Protocols.
If the protocol specifications are publicly available, ensure that all fields are correct. Dissectors are often prone to errors, especially when dealing with complex protocols. If you notice any errors, pay close attention to them. For more ideas, see the list of common vulnerabilities and vulnerabilities (CVEs) for which Wireshark dissectors exist.
During the analysis phase, generate and reproduce traffic to understand how the protocol works. The goal is to gain a clear understanding of the overall structure of the protocol, including its transport layer, messages, and available operations.
Obtaining a copy of network traffic
Depending on the type of device, there are different ways of receiving network traffic that need to be analyzed. Some of them can support proxy configurations right out of the box! Determine whether you need to perform active or passive network traffic analysis. (You can find some examples of how to do this in James Forshaw’s book Attacking Protocol-Level Networks.) Try to generate traffic for every available use case, and generate as much as possible. Having different customers will help you understand the differences and nuances in existing implementations.
One of the first steps in the analysis phase should be to monitor the traffic and inspect the packets sent and received. There may be some obvious problems, so it is useful to do this before moving on to active analysis. The website https://gitlab.com/wireshark/wireshark/-/ wikis/SampleCaptures/ is a great resource for finding public captures.
If Wireshark has a dissector that can analyze the traffic you generate, enable it by checking its name in the Enabled Protocols window. 5.2.
Now try to find the following:
The first bytes in the message. Sometimes the first few bytes in the initial connection or message are magic: they allow you to quickly identify a service.
Initial connection. This is an important feature of any protocol. Usually at this stage you will learn about the protocol version and supported features, including security features such as encryption. Repeating this step will also help you design scanners to easily find these devices and services on networks.
Any TCP/UDP streams and common data structures used in the protocol. Sometimes you will identify strings in the plaintext or common data structures, such as packets, whose length is appended to the beginning of the message.
Byte order in the protocol. Some protocols use mixed byte-passing order, which can cause problems if not detected at an early stage. The byte order varies greatly from protocol to protocol, but it is necessary to create correct packets.
Structure of messages. Define different message headers and structures, and how to initialize and close a connection.
After you’ve analyzed the protocol, you can start prototyping or turning the notes you’ve gathered from the analysis into real software that you can use to communicate with the service over the protocol. The prototype will allow you to make sure you understand the packet structure of each message type correctly. At this stage, it is important to choose a programming language that will allow you to work very quickly. For this reason, we prefer dynamically typed scripting languages such as Lua or Python. Check if there are libraries and frameworks available that can be used to speed up development.
If Wireshark does not support the protocol, develop a parser to help with the analysis. We’ll discuss this process below in the section Developing a Wireshark Dissector for the Lua DICOM Protocol. We will also use Lua to prototype the Nmap Scripting Engine to communicate with the service.
After you have completed your analysis, confirmed your assumptions about the protocol, and created a working prototype for communication using the DICOM service, you need to evaluate the security of the protocol.
In addition to the general security assessment process described in Chapter 3, consider the following key points.
Check for an attack involving server and client authentication. Ideally, the client and server should authenticate each other, a process known as mutual authentication. If not, you can impersonate the client or server. Such behavior can have serious consequences; For example, we once conducted a customer impersonation attack to spoof a drug library component and inject fraudulent drug libraries into an infusion pump. Even though the two endpoints exchanged data over Transport Layer Security (TLS), This could not prevent the attack because there was no mutual authentication.
Deliberately miscoding the protocol and checking for flood attacks. Also, try to reproduce crashes and detect errors. Fuzzing is the process of automatically feeding malformed input data into the system with the ultimate goal of finding implementation errors. In most cases, this leads to a system crash. The more complex the protocol, the higher the chances of detecting memory corruption defects. DICOM (discussed below) is a great example. Given its complexity, buffer overflows and other security issues can be found in various implementations. In flood attacks, attackers send a large number of requests to the system to exhaust the system’s resources, causing the system to become unresponsive. A typical example is TCP SYN, a flood attack that can be mitigated with SYN cookies.
Check encryption and digital signature. Is the data confidential? Can we guarantee data integrity? How reliable are cryptographic algorithms? We’ve seen vendors implement their own encryption algorithms, always with disastrous results. In addition, many network protocols do not require a digital signature, which ensures message authentication, data integrity, and non-repudiation. For example, DICOM does not use a digital signature unless it uses a secure protocol such as Transport Layer Security (TLS), which is vulnerable to man-in-the-middle attacks.
Check the possibility of an attack on an outdated version. These are cryptographic attacks on the protocol that force the system to use a lower quality and less secure mode of operation (for example, one that sends data in the clear). Examples include Padding Oracle on Downgraded Legacy Encryption (POODLE) attacks at the TLS/SSL layer. In this attack, a man-in-the-middle attacker forces clients to use SSL 3.0 and exploits an inherent vulnerability in the protocol to steal cookies or passwords.
Check for a reservation attack. These attacks are triggered when the protocol has features that far exceed the request, as attackers can abuse these features to cause a denial of service. An example is a DDoS attack on mDNS mapping where some mDNS implementations responded to unicast requests from sources outside the local network. We’ll cover mDNS in Chapter 6.
This section shows how to write a dissector that can be used with Wireshark. When testing network protocols used by IoT devices, it is critical to understand how data is transmitted, how messages are generated, and whether features, operations, and security mechanisms are involved. We can then start modifying the data streams to find vulnerabilities. We will use Lua to write our dissector; This will allow for quick analysis of intercepted network communications with a small amount of code. We’ll go from presenting an array of information to readable messages with just a few lines of code.
In this exercise, we will focus on only a subset of the functions required to process DICOM Type A messages (discussed in the next section). Another thing to keep in mind when writing Wireshark dissectors for TCP in Lua is that packets can be fragmented. Also, depending on factors such as packet retransmission, Wireshark failure, or configuration errors, by limiting the size of captured packets (the default capture packet size limit is 262,144 bytes), we may receive less or more than one message per TCP segment. Let’s ignore this for now and focus on the A-ASSOCIATE queries, which will be sufficient to identify the DICOM services when writing the scanner. If you want to learn more about how to combat TCP fragmentation, see the sample orthanc.lua file in this book or go to https:// nostarch.com/practical-iot-hacking/.
Lua is a scripting language for creating extensible or scriptable modules in many important security projects such as Nmap, Wireshark, and even commercial security products such as LogRhythm’s NetMon. Some of the products you use every day are likely powered by Lua. Many IoT devices also use Lua because of its small binary size and well-documented API, making it easy to use to extend projects in other languages such as C, C++, Erlang, and even Java. This makes Lua ideal for embedding into applications. You’ll learn how to represent and work with data in Lua, and how popular applications such as Wireshark and Nmap use Lua to enhance traffic analysis, network exploration, and exploit capabilities.
DICOM is a non-proprietary protocol developed by the American College of Radiology and the National Electrical Manufacturers Association. It has become the international standard for the transmission, storage and processing of information about medical images. Although not proprietary, DICOM is a good example of a network protocol implemented in many medical devices; Traditional network security tools do not support it very well. DICOM communication over TCP/IP is two-way: the client requests an action, and the server performs it, but if necessary, they can be swapped. In DICOM terminology, the client is a Service Call User (SCU), and the server is a Service Call Provider (SCP). Before we start writing the code, let’s review some important DICOM messages and protocol structure.
DICOM C-ECHO messages serve, among other things, to exchange information about calling and calling programs, objects, versions, unique identifiers (UIDs), names, and roles. We commonly refer to these messages as DICOM Queries because they allow us to determine if a DICOM service provider is on the network. The C-ECHO message uses several type A messages, so we will cover them in this section. The first packet sent by the C-ECHO operation is an A-ASSOCIATE request, which is sufficient to identify the DICOM service provider. Information about the service can be obtained from the A-ASSOCIATE response.
There are seven types of Type A messages used in C-ECHO messages:
A-ASSOCIATE request (A-ASSOCIATE-RQ): requests sent by the client to establish a DICOM connection;
A-ASSOCIATE accept (A-ASSOCIATE-AC): responses sent by the server to accept the DICOM A-ASSOCIATE request;
A-ASSOCIATE rejection (A-ASSOCIATE-RJ): responses sent by the server to reject a DICOM A-ASSOCIATE request;
(P-DATA-TF): data packets sent by the server and the client;
A-RELEASE request (A-RELEASE-RQ): a request sent by a client to close a DICOM connection;
A-RELEASE response (PDU A-RELEASE-RP): response sent by the server to confirm the A-RELEASE request;
A-ASSOCIATE Abort (A-ABORT PDU): Responses sent by the server to abort the A-ASSOCIATE operation.
All of these PDUs begin with a similar packet structure. The first part is a single-byte unsigned integer in Big Endian format that indicates the PDU type. The second part is a one-byte reserved section set at 0x0. The third part is the PDU length information, a four-byte unsigned integer in Little Endian format. The fourth part is a variable-length data field. This structure is shown in fig. 5.3.
Once we know the structure of the message, we can start reading and analyzing the DICOM message. Using the size of each field, we can calculate the offset when defining the fields in our prototypes for analysis and interaction with DICOM services.
To complete this exercise, you need to set up a DICOM server and client. Orthanc is a robust open source DICOM server that runs on Windows, Linux, and macOS. Install it on your system, make sure the DicomServerEnabled flag is enabled in the configuration file, and run the Orthanc binary. If everything works correctly, you should have a DICOM server running on TCP port 4242 (the default port). Enter the orthanc command to view the following logs that describe the configuration options:
$ ./Orthanc <timestamp> main.cpp:1305] Orthanc version: 1.4.2 <timestamp> OrthancInitialization.cpp:216] Using the default Orthanc configuration <timestamp> OrthancInitialization.cpp:1050] SQLite index directory: "XXX" <timestamp> OrthancInitialization.cpp:1120] Storage directory: "XXX" <timestamp> HttpClient.cpp:739] HTTPS will use the CA certificates from this file: ./orthancAndPluginsOSX.stable <timestamp> LuaContext.cpp:103] Lua says: Lua toolbox installed <timestamp> LuaContext.cpp:103] Lua says: Lua toolbox installed <timestamp> ServerContext.cpp:299] Disk compression is disabled <timestamp> ServerIndex.cpp:1449] No limit on the number of stored patients <timestamp> ServerIndex.cpp:1466] No limit on the size of the storage area <timestamp> ServerContext.cpp:164] Reloading the jobs from the last execution of Orthanc <timestamp> JobsEngine.cpp:281] The jobs engine has started with 2 threads <timestamp> main.cpp:848] DICOM server listening with AET ORTHANC on port: 4242 <timestamp> MongooseServer.cpp:1088] HTTP compression is enabled <timestamp> MongooseServer.cpp:1002] HTTP server listening on port: 8042 (HTTPS encryption is disabled, remote access is not allowed) <timestamp> main.cpp:667] Orthanc has started
If you do not want to install an Orthanc server, you can find sample captured packets in the online resources for this book or on the Wireshark page for sample packets for DICOM.
Before jumping into the code, make sure you have Lua installed and enabled in Wireshark’s settings. You can check if it is available in the About Wireshark window – see 5.4.
The Lua engine is disabled by default. To enable it, set the disable_lua boolean variable to false in the init.lua file from the Wireshark installation directory:
disable_lua = false
After you have verified that Lua is available and enabled, verify that Lua support is working correctly by writing a test script and running it as follows:
$ tshark -X lua_script: <ваш тестовый сценарий Lua>
If we include a simple print statement (for example, the line print “Hello from Lua”) in the test file, we should see the output before the capture starts.
$ tshark -X lua_script:test.lua Hello from Lua Capturing on 'ens33'
On Windows, the output may not be displayed if a normal print operator is used. But the report_failure() function will open a window with your message, so you can do without the print statement.
Let’s define our new protocol dissector using the Proto(name, description) function. As mentioned earlier, it specifically identifies a DICOM Type A message (one of the seven messages listed earlier):
dicom_protocol = Proto("dicom-a", "DICOM A-Type message")
Next, we define the header fields in Wireshark to match the DICOM PDU structure discussed earlier using the ProtoField class:
pdu_type = ProtoField.uint8("dicom-a.pdu_type","pduType", base.DEC, {[1]="ASSOC Request", [2]="ASSOC Accept", [3]="ASSOC Reject", [4]="Data", [5]="RELEASE Request", [6]="RELEASE Response", [7]="ABORT"}) -- unsigned 8-bit integer message_length = ProtoField.uint16("dicom-a.message_length", "messageLength", base.DEC) -- unsigned 16-bit integer dicom_protocol.fields = {pdu_type, message_length}
We use these ProtoFields to add items to the parse tree. For our parser, let’s call ProtoField twice: once to create a one-byte unsigned integer to store the PDU type, and a second time for two bytes to store the message length. Notice how we have assigned a table of values to the PDU types. Wireshark will automatically display this information. We then represent the fields of our protocol parser as a Lua table containing our ProtoFields.
Next, we declare our main dissector function, Dissector(), which takes three arguments: a buffer for Wireshark parsing, packet information, and a tree that displays protocol information.
In this Dissector() function, we will parse our protocol and add the ProtoFields we defined earlier to the tree containing our protocol information.
function dicom_protocol.dissector(buffer, pinfo, tree) pinfo.cols.protocol = dicom_protocol.name local subtree = tree:add(dicom_protocol, buffer(), "DICOM PDU") subtree:add_le(pdu_type, buffer(0,1)) -- big endian subtree:add(message_length, buffer(2,4)) -- skip 1 byte end
We set the protocol field to the protocol name we defined in dicom_protocol.name. For each element to be added, we use either add_le() for Big Endian format or add() for Little Endian format, as well as a ProtoField and a buffer range to parse.
Completion of the dissector
DissectorTable contains the sub-dissector table for the protocol, which is displayed through the Wireshark decoding dialog.
local tcp_port = DissectorTable.get("tcp.port") tcp_port:add(4242, dicom_protocol)
To complete the dissector, simply add it to the DissectorTable for TCP ports on port 4242.
Code listing 5.1. DICOM type A dissector completed
dicom_protocol = Proto("dicom-a", "DICOM A-Type message") pdu_type = ProtoField.uint8("dicom-a.pdu_type", "pduType", base.DEC, {[1]="ASSOC Request", [2]="ASSOC Accept", [3]="ASSOC Reject", [4]="Data", [5]="RELEASE Request", [6]="RELEASE Response", [7]="ABORT"}) message_length = ProtoField.uint16("dicom-a.message_length", "messageLength", base.DEC) dicom_protocol.fields = {message_length, pdu_type} function dicom_protocol.dissector(buffer, pinfo, tree) pinfo.cols.protocol = dicom_protocol.name local subtree = tree:add(dicom_protocol, buffer(), "DICOM PDU") subtree:add_le(pdu_type, buffer(0,1)) subtree:add(message_length, buffer(2,4)) end local tcp_port = DissectorTable.get("tcp.port") tcp_port:add(4242, dicom_protocol)
We enable this dissector by placing the Lua file in the Wireshark plugin directory and then restarting Wireshark. Then, when parsing the DICOM capture, we should see the pduType byte and message length displayed in the DICOM PDU column we defined in our tree:add() call. In fig. Figure 5.5 shows how this looks in Wireshark. Dicom-a filters can also be used. message_length and dicom-a.pdu_type which we defined for traffic filtering.
You can now clearly define the PDU type and message length in DICOM packets.
Analyzing the C-ECHO request with our new parser, we should see that it consists of various type A messages, as shown in Fig. 5.5. The next step is to analyze the data contained in these DICOM packages.
To show how we can process strings in our Lua dissector, let’s add code to the dissector to parse the A-ASSOCIATE message. Fig. Figure 5.6 shows the structure of the A-ASSOCIATE query.
Note the 16-byte headers of calling and calling programs. The application object header is a label that identifies the service provider. The message also contains a 32-byte reserved section that must be zero-padded, as well as variable-length elements, including Application Context, Presentation Context, and User Information elements.
Let’s start by extracting the fixed-length message fields, including the subscriber string values and the entity name program called. This is useful information; often the services do not have authentication, so if you have the correct application object header you can connect and start issuing DICOM commands. We can define new ProtoField objects for our A-ASSOCIATE request message with the following code:
protocol_version = ProtoField.uint8("dicom-a.protocol_version", "protocolVersion", base.DEC) calling_application = ProtoField.string( "dicom-a.calling_app", "callingApplication") called_application = ProtoField.string("dicom-a.called_app", "calledApplication")
We use the ProtoField ProtoField function to extract the string values of the names of called and calling applications. line. We pass it a name that will be used in filters, an optional name that will be displayed in the v tree, a display format (base. ASCII or base. UNICODE) and an optional description field. Initial loading of dissector function data
After adding the new ProtoFields as fields to the protocol dissector, we need to add code to load them into the dissector function, dicom_protocol.dissector(), so that they are included in the protocol mapping tree:
local pdu_id = buffer(0, 1):uint() -- Convert to unsigned int if pdu_id == 1 or pdu_id == 2 then -- ASSOC-REQ (1) / ASSOC-RESP (2) local assoc_tree = subtree:add(dicom_protocol, buffer(), "ASSOCIATE REQ/ RSP") assoc_tree:add(protocol_version, buffer(6, 2)) assoc_tree:add(calling_application, buffer(10, 16)) assoc_tree:add(called_application, buffer(26, 16)) end
The dissector must add the extracted fields to a subtree in the protocol tree. To create a subtree, call the add() function from our existing protocol tree. Our simple parser can now determine PDU types, message length, ASSOCIATE message type, protocol, call, and called applications.
Now that we have defined and analyzed fixed-length sections, let’s analyze variable-length message fields. In DICOM, we use identifiers called contexts to store, represent, and share various characteristics. We’ll show you how to find the three different types of contexts available: application context, presentation context, and user information context, which have varying numbers of field elements. But we We will not write the code to parse the content of the element.
For each of the contexts, add a subtree that displays the length of the context and a variable number of context elements. Change the main protocol dissector to look like this:
function dicom_protocol.dissector(buffer, pinfo, tree) pinfo.cols.protocol = dicom_protocol.name local subtree = tree:add(dicom_protocol, buffer(), "DICOM PDU") local pkt_len = buffer(2, 4):uint() local pdu_id = buffer(0, 1):uint() subtree:add_le(pdu_type, buffer(0,1)) subtree:add(message_length, buffer(2,4)) if pdu_id == 1 or pdu_id == 2 then -- ASSOC-REQ (1) / ASSOC-RESP (2) local assoc_tree = subtree:add(dicom_protocol, buffer(), "ASSOCIATE REQ/RSP") assoc_tree:add(protocol_version, buffer(6, 2)) assoc_tree:add(calling_application, buffer(10, 16)) assoc_tree:add(called_application, buffer(26, 16)) --Extract Application Context local context_variables_length = buffer(76,2):uint() local app_context_tree = assoc_tree:add(dicom_protocol, buffer(74, context_variables_ length + 4), "Application Context") app_context_tree:add(app_context_type, buffer(74, 1)) app_context_tree:add(app_context_length, buffer(76, 2)) app_context_tree:add(app_context_name, buffer(78, context_variables_length)) --Extract Presentation Context(s) local presentation_items_length = buffer(78 + context_variables_length + 2, 2):uint() local presentation_context_tree = assoc_tree:add(dicom_protocol, buffer(78 + context_ variables_length, presentation_items_length + 4), "Presentation Context") presentation_context_tree:add(presentation_context_type, buffer(78 + context_variables_ length, 1)) presentation_context_tree:add(presentation_context_length, buffer(78 + context_ variables_length + 2, 2)) -- TODO: Extract Presentation Context Items --Extract User Info Context local user_info_length = buffer(78 + context_variables_length + 2 + presentation_items_ length + 2 + 2, 2):uint() local userinfo_context_tree = assoc_tree:add(dicom_protocol, buffer(78 + context_ variables_length + presentation_items_length + 4, user_info_length + 4), "User Info Context") userinfo_context_tree:add(userinfo_length, buffer(78 + context_variables_length + 2 + presentation_items_length + 2 + 2, 2)) -- TODO: Extract User Info Context Items end end
When working with network protocols, there are often fields of variable length that require the calculation of offsets. It is very important that you get the length values correct because all offset calculations depend on them.
After making the changes mentioned in the previous section, verify that your DICOM packets are analyzed correctly by checking the resulting length. You should now see a subtree for each context (Figure 5.8). Note that since we provide a range of buffers in the new subtrees, you can select them to select the appropriate partition. Take the time to make sure that the context of each DICOM protocol is recognized as you expect.
If you want to practice, we recommend adding fields from different contexts to the dissector. You can download the DICOM package from the sample Wireshark package page, where we have posted the package containing the DICOM echo request. You will also find a complete example, including TCP fragmentation, in the online resources of this book. Remember that you can reload your Lua scripts at any time to test your latest dissector without restarting Wireshark by clicking Analyze > Restart Lua>s Plugin.
Earlier in this chapter, you learned that DICOM has a ping-like utility called C-Echo-query, which consists of several Type A messages. Next, you created a Lua dissector to analyze these messages with Wireshark. Next, you’ll use Lua to solve another problem: writing a DICOM Services scanner. The scanner remotely identifies DICOM service providers (DSPs) on networks to proactively test their configurations and even launch attacks. Since Nmap is well known for its scanning capabilities, and its scripting engine also runs on Lua, it is the perfect tool for writing such a scanner.
In this exercise, we will focus on a subset of the functions involved in sending a partial C-ECHO request.
Let’s start by building the Nmap Scripting Engine library for our DICOM-related code. We will use the library to store any functions used when creating and deleting sockets, sending and receiving DICOM packets, and operations such as binding and requesting services.
Nmap already includes libraries that help with standard I/O, sockets, and other tasks. Take a moment to browse the library’s collection to see what’s already available. See the documentation for these scripts and libraries at https://nmap.org/nsedoc/.
Typically, the Nmap Scripting Engine libraries can be found in the <installation>/nselib/ directory. Find this directory and create a file called dicom.lua. In this file, start by declaring other Lua and Nmap Scripting Engine standard libraries to use. Also give the environment a name for the new library:
local nmap = require "nmap" local stdnse = require "stdnse" local string = require "string" local table = require "table" local nsedebug = require "nsedebug" _ENV = stdnse.module("dicom", stdnse.seeall)
In this case, we will use four different libraries: two Nmap Scripting Engine libraries (nmap and stdnse) and two standard Lua libraries (string and table). Lua’s string and table libraries are, surprisingly, designed for string and table operations. We will mainly use the nmap library’s socket handling, as well as stdnse to read user arguments and print debug statements when needed. In addition, we will use the useful nsedebug library, which displays various types of data in an easy-to-read form.
Now let’s define some constants for storing PDU codes, UUID values, as well as the minimum and maximum allowable packet sizes. This will allow you to write code that is more readable and easier to maintain. In Lua, we usually define constants with capital letters:
local MIN_SIZE_ASSOC_REQ = 68 -- Минимальный размер запроса ASSOCIATE local MAX_SIZE_PDU = 128000 -- Максимальный размер любого PDU local MIN_HEADER_LEN = 6 -- Минимальная длина заголовка DICOM local PDU_NAMES = {} local PDU_CODES = {} local UID_VALUES = {} -- Таблица имен PDU для кодов PDU_CODES = { ASSOCIATE_REQUEST = 0x01, ASSOCIATE_ACCEPT = 0x02, ASSOCIATE_REJECT = 0x03, DATA = 0x04, RELEASE_REQUEST = 0x05, RELEASE_RESPONSE = 0x06, ABORT = 0x07 } -- Таблица имен UID для значений UID_VALUES = { VERIFICATION_SOP = "1.2.840.10008.1.1", -- Проверочный класс SOP APPLICATION_CONTEXT = "1.2.840.10008.3.1.1.1", -- Имя контекста приложения DICOM IMPLICIT_VR = "1.2.840.10008.1.2", -- Подразумевает VR Little Endian: Синтаксис по умолчанию для DICOM FIND_QUERY = "1.2.840.10008.5.1.4.1.2.2.1" -- Корень иноформационной модели запрос/ответ - FIND } -- Мы сохраняем имена, используя их коды и ключи для вывода на печать имен типов PDU for i, v in pairs(PDU_CODES) do PDU_NAMES[v] = i end
Constant values for common DICOM operation codes are defined here. We also defined tables to represent different classes of data using DICOM specific UIDs and packet lengths. Now we are ready to start communicating with the service.
We will use the nmap scripting engine library to send and receive data. Since creating and destroying sockets is a routine operation, it is recommended to write functions for them in our new library. Let’s write our first function dicom.start_connection(), which creates a socket for the DICOM service:
--- -- start_connection(host, port) открывает сокет для службы DICOM -- -- @param host Host object -- @param port Port table -- @return (status, socket) Если status равен true, сокет возвращает объект DICOM -- Если status равен false, сокет возвращает сообщение об ошибке. --- function start_connection(host, port) local dcm = {} local status, err Анализ сетевых протоколов 141 dcm['socket'] = nmap.new_socket() status, err = dcm['socket']:connect(host, port, "tcp") if(status == false) then return false, "DICOM: невозможно подключиться к службе: " .. err end return true, dcm end
In the same way, we create functions for sending and receiving DICOM packets:
-- send(dcm, data) Отправляет пакет DICOM через открытый сокет -- -- @param dcm объект DICOM -- @param data данные для отправки -- @return status равен True если данные отправлены корректно, и False в случае ошибки function send(dcm, data) local status, err stdnse.debug2("DICOM: Sending DICOM packet (%d bytes)", #data) if dcm["socket"] ~= nil then status, err = dcm["socket"]:send(data) if status == false then return false, err end else return false, "No socket available" end return true end -- receive(dcm) Чтение пакетов DICOM через открытый сокет -- -- @param dcm DICOM object -- @return (status, data) возвращает данные, если status равен true, иначе возвращает сообщение об ошибке. function receive(dcm) local status, data = dcm["socket"]:receive() if status == false then return false, data end stdnse.debug2("DICOM: receive() read %d bytes", #data) return true, data end
The send(dcm, data) and receive(dcm) functions use the Nmap socket functions send() and receive() respectively. They refer to the connection handle stored in the dcm[“socket”] variable to read and write DICOM u packets over the socket. Note stdnse.debug[1-9], which is used to print diagnostic statements when Nmap runs the debug (-d) flag. In this case, using stdnse.debug2() will print when the debug level is set to 2 or higher.
Now that we’ve set up the basic network I/O, let’s create the functions that are responsible for generating DICOM messages. As mentioned earlier, a DICOM PDU uses a header to define its type and length. In the Nmap scripting engine, we use strings to store byte streams and the string.pack() and string.unpack() functions to encode and retrieve information in different formats and byte orders. The way string.pack() and string.unpack() are used, you will need to familiarize yourself with Lua format strings, as you will need to represent data in different formats. You can read about them at https://www.lua. org/manual/5.3/manual.html#6.4.2. Take the time to learn how to record endiante order and common conversions.
--- -- pdu_header_encode(pdu_type, length) кодирует заголовок PDU DICOM -- -- @param pdu_type тип PDU – беззнаковое целое -- @param length длина сообщения DICOM -- @return (status, dcm) если status равен true, возвращает заголовок. -- если status равен false, dcm содержит сообщение об ошибке --- function pdu_header_encode(pdu_type, length) -- несколько простых проверок; мы не проверяем диапазоны, чтобы позволить пользователю создавать некорректные пакеты. if not(type(pdu_type)) == "number" then return false, "PDU должен быть беззнаковым целым. Диапазон:0-7" Анализ сетевых протоколов 143 end if not(type(length)) == "number" then return false, "Длина должна быть беззнаковым целым." end local header = string.pack("<B >B I4", pdu_type, -- тип PDU ( 1 байт – беззнаковое целое в формате Big Endian ) 0, -- раздел зарезервирован ( 1 байт, должен быть равен 0x0 ) length) -- длина PDU ( 4 байта – беззнаковое целое в формате Little Endian) if #header < MIN_HEADER_LEN then return false, "Заголовок не должен быть короче 6 байтов. Произошла ошибка." end return true, header end
The pdu_header_encode() function encodes information about the PDU type and length. After performing some simple checks, we define the header variable. To encode a stream of bytes according to byte order and format, we use string. pack() and a string of the format <B> B I4 , where <B is a single byte in Big Endian , and > B I4 is a byte followed by a four-byte unsigned integer price in Little Endian | The function returns a boolean value, the state of the representing operation, and a result.
Additionally, we need to write a function that sends and parses A-ASSOCIATE requests and responses. As you saw earlier in this chapter, the A-ASSOCIATE request message contains different types of contexts: application, presentation, and user information. Since this is a longer feature, let’s break it down into parts.
The application context explicitly defines the elements and parameters of the service. In DICOM, you often see Information Object Definitions (IODs) that represent data objects that are managed through a central registry. A complete list of IODs can be found at http://dicom.nema.org/dicom/2013/output/chtml/part06/chapter_A.html. We will read these IODs from the constant definitions we placed at the beginning of our library. Let’s start the DICOM connection and create an application context.
--- -- associate(host, port) Пытается связаться с провайдером службы DICOM путем отправки запроса A-ASSOCIATE. -- -- @param host объект хоста -- @param port объект порта -- @return (status, dcm) если status равен true, возвращает объект DICOM -- если status равен false, dcm содержит сообщение об ошибке --- function associate(host, port, calling_aet_arg, called_aet_arg) local application_context = "" local presentation_context = "" local userinfo_context = "" local status, dcm = start_connection(host, port) if status == false then return false, dcm end application_context = string.pack(">B B I2 c" .. #UID_VALUES["APPLICATION_CONTEXT"], 0x10, -- тип элемента (1 байт) 0x0, -- зарезервировано ( 1 байт) #UID_VALUES["APPLICATION_CONTEXT"], -- длина (2 байта) UID_VALUES["APPLICATION_CONTEXT"]) -- контекст приложения
An application context consists of a type (one byte), a reserved field (one byte), a context length (two bytes), and a value represented by OIDs. To represent this structure in Lua, we use a string of the format B B I2 C[#length]. We can drop the line size value by one byte. We create view and user information contexts in a similar way. Here is the view context that defines Abstract Syntax and Transfer Syntax. These are sets of rules for formatting and exchanging objects, and we represent them using IODs.
presentation_context = string.pack(">B B I2 B B B B B B I2 c" .. #UID_VALUES["VERIFICATION_ SOP"] .. "B B I2 c".. #UID_VALUES["IMPLICIT_VR"], 0x20, -- тип контекста представления ( 1 байт ) 0x0, -- зарезервировано ( 1 байт ) 0x2e, -- длина элемента ( 2 байта ) 0x1, -- идентификатор контекста представления ( 1 байт ) 0x0,0x0,0x0, -- зарезервировано ( 3 байта ) 0x30, -- дерево абстрактного синтаксиса ( 1 байт ) 0x0, -- зарезервировано ( 1 байт ) 0x11, -- длина элемента ( 2 байта ) UID_VALUES["VERIFICATION_SOP"], 0x40, -- синтаксис передачи ( 1 байт ) 0x0, -- зарезервировано ( 1 байт ) 0x11, -- длина элемента ( 2 байта ) UID_VALUES["IMPLICIT_VR"])
Note that there can be multiple view contexts. Next, we define the context of user information:
local implementation_id = "1.2.276.0.7230010.3.0.3.6.2" local implementation_version = "OFFIS_DCMTK_362" userinfo_context = string.pack(">B B I2 B B I2 I4 B B I2 c" .. #implementation_id .. " B B I2 c".. #implementation_version, 0x50, -- тип 0x50 (1 байт) 0x0, -- зарезервировано ( 1 байт ) 0x3a, -- длина ( 2 байта ) 0x51, -- тип 0x51 ( 1 байт) 0x0, -- зарезервировано ( 1 байт ) 0x04, -- длина ( 2 байта ) 0x4000, -- данные ( 4 байта ) 0x52, -- тип 0x52 (1 байт ) 0x0, -- зарезервировано (1 байт ) 0x1b, -- длина (2 байта) implementation_id, -- идентификатор реализации ( байты #implementation_id) 0x55, -- тип 0x55 (1 байт) 0x0, -- зарезервировано (1 байт) #implementation_version, -- длина (2 байта) implementation_version)
Now we have three variables that contain the contexts: application_context, presentation_context, and userinfo_context.
Let’s add the contexts we just created to the A-ASSOCIATE header and query. To allow other scripts to pass arguments to our function and use different values for caller names and application callers, we’ll offer two options: an optional argument or user input. In the Nmap script engine, you can read the script arguments provided by –script-args using the Nmap stdnse.get_script_args() function as follows:
local called_ae_title = called_aet_arg or stdnse.get_script_args("dicom.called_aet") or "ANYSCP" local calling_ae_title = calling_aet_arg or stdnse.get_script_args("dicom.calling_aet") or "NMAP-DICOM" if #calling_ae_title > 16 or #called_ae_title > 16 then return false, "Calling/Called AET field can't be longer than 16 bytes." еnd
The structure containing the application component headers must be 16 bytes long, so we use string.rep() to pad the rest of the buffer with spaces:
--Fill the rest of buffer with %20 called_ae_title = called_ae_title .. string.rep(" ", 16 – #called_ae_title) calling_ae_title = calling_ae_title .. string.rep(" ", 16 – #calling_ae_title)
Now we can define our own calls and called application object names using script arguments. We can also use script arguments to write a tool that tries to guess the correct application object, as if we were inputting the password using the iterative method. Defining the A-ASSOCIATE query structure
Let’s build our A-ASSOCIATE query. We define its structure in the same way as in the context:
-- ASSOCIATE request local assoc_request = string.pack(">I2 I2 c16 c16 c32 c" .. application_ context:len() .. " c" .. presentation_context:len() .. " c".. userinfo_context:len(), 0x1, -- версия протокола ( 2 байта ) 0x0, -- зарезервировано ( 2 байта должны быть равны 0x0 ) called_ae_title, -- заголовок вызываемого AE ( 16 байтов) calling_ae_title, -- заголовок вызывающего AE ( 16 байтов) 0x0, -- зарезервировано ( 32 должны быть равны 0x0 ) application_context, presentation_context, userinfo_context)
We start by specifying the protocol version (two bytes), a reserved section (two bytes), the calling program object header (16 bytes), the calling program object header (16 bytes), another reserved section (32 bytes ) and newly created contexts (application, presentation, and user information). Now our A-ASSOCIATE query is missing only the header. Now it’s time to use the dicom.pdu_header_encode() function we defined earlier to generate it:
local status, header = pdu_header_encode(PDU_CODES["ASSOCIATE_REQUEST"], #assoc_request) -- с заголовком может что-то пойти не так if status == false then return false, header end assoc_request = header .. assoc_request stdnse.debug2("PDU len minus header:%d", #assoc_request-#header) if #assoc_request < MIN_SIZE_ASSOC_REQ then return false, string.format("запрос ASSOCIATE должен содержать не менее %d байтов и мы пробуем послать %d.", MIN_SIZE_ASSOC_REQ, #assoc_request) end
We create a header with a PDU type corresponding to the A-ASSOCIATE request value, and then add the message body. We also add error checking logic here. It is now possible to send a complete A-ASSOCIATE request and read the response using the previously defined functions for sending and reading DICOM packets:
status, err = send(dcm, assoc_request) if status == false then return false, string.format("Невозможно отправить запрос ASSOCIATE:%s", err) end status, err = receive(dcm) if status == false then return false, string.format("Невозможно прочитать запрос ASSOCIATE:%s", err) end if #err < MIN_SIZE_ASSOC_RESP then return false, "Ответ ASSOCIATE слишком короткий." end
That’s cool! Next, you need to define the type of BRZ that is used to accept or reject the connection.
At this point, it’s just a matter of unpacking the response with string.unpack(). This is similar to string.pack() and we use format strings to specify the structure to read. In this case, we read the response type (one byte), the reserved field (one byte), the length (four bytes), and the protocol version (two bytes), according to the format string > B B I4 I2:
local resp_type, _, resp_length, resp_version = string.unpack(">B B I4 I2", err) stdnse.debug1("PDU тип:%d длина:%d Protocol:%d", resp_type, resp_length, resp_version)
Next, check the response code to see if it matches the PDU to accept or reject the ASSOCIATE:
if resp_type == PDU_CODES["ASSOCIATE_ACCEPT"] then stdnse.debug1("Обнаружено сообщение ASSOCIATE ACCEPT!") return true, dcm elseif resp_type == PDU_CODES["ASSOCIATE_REJECT"] then stdnse.debug1("Обнаружено сообщение ASSOCIATE REJECT!") return false, "получено ASSOCIATE REJECT " else return false, "Неопеределенный ответ:" .. resp_type end end -- end of function
If we receive an ASSOCIATE acceptance message, we return true; otherwise, we will return false.
Now that we’ve implemented the service association function, we create a script that loads the library and calls the dicom.associate() function:
description = [[ Пытается обнаружить серверы DICOM (провайдеры служб DICOM) при помощи частичного запроса C-ECHO. Запросы C-ECHO также известны как проверочные пакеты DICOM для проверки соединения. Обычно проверочный пакет DICOM формируется так: * Client -> A-ASSOCIATE request -> Server * Server -> A-ASSOCIATE ACCEPT/REJECT -> Client * Client -> C-ECHO request -> Server * Server -> C-ECHO response -> Client * Client -> A-RELEASE request -> Server * Server -> A-RELEASE response -> Client В данном сценарии мы отправляем только запрос A-ASSOCIATE и ищем код успеха в ответе, поскольку это очевидный способ обнаружения провайдеров служб DICOM. ]] --- -- @usage nmap -p4242 --script dicom-ping <target> -- @usage nmap -sV --script dicom-ping <target> -- -- @output -- PORT STATE SERVICE REASON -- 4242/tcp open dicom syn-ack -- |_dicom-ping: провайдер служб DICOM обнаружен --- author = "Paulino Calderon <calderon()calderonpale.com>" license = "Same as Nmap--See http://nmap.org/book/man-legal.html" categories = {"discovery", "default"} local shortport = require "shortport" local dicom = require "dicom" local stdnse = require "stdnse" local nmap = require "nmap" portrule = shortport.port_or_service({104, 2761, 2762, 4242, 11112}, "dicom", "tcp", "open") action = function(host, port) local dcm_conn_status, err = dicom.associate(host, port) if dcm_conn_status == false then stdnse.debug1("Association failed:%s", err) if nmap.verbosity() > 1 then return string.format("Association failed:%s", err) else return nil end end -- Мы убедились, что это DICOM, обновляем имя службы port.version.name = "dicom" nmap.set_port_version(host, port) return "DICOM Service Provider discovered" end
First, we fill in some mandatory fields, such as description, author, license, categories, and execution rule. We declare the main action script function as a Lua function. You can learn more about script formats by reading the official documentation (https://nmap.org/book/nse-script-format.html) or browsing the official script collection. If the script finds the DICOM service, it returns the following data:
Nmap scan report for 127.0.0.1 PORT STATE SERVICE REASON 4242/tcp open dicom syn-ack |_dicom-ping: DICOM Service Provider discovered Final times for host: srtt: 214 rttvar: 5000 to: 100000
Otherwise, the script does not return any results because, by default, Nmap only shows information when it accurately detects a service.
In this chapter, you learned how to work with new network protocols and created tools for the most popular network scanning (Nmap) and traffic analysis (Wireshark) frameworks. You also learned how to perform a number of common operations (creating shared data structures, manipulating strings, and performing network I/O operations) to quickly prototype new network security tools in Lua. Armed with this knowledge, you will be able to solve the problems covered in this chapter and others and sharpen your Lua skills. In the ever-evolving world of the Internet of Things, being able to quickly write new network operations tools is very convenient.
Also, remember to follow the methodology when conducting a security assessment. The topic of this chapter is only a starting point for understanding and detecting anomalies in network protocols. Because the topic is so large, we couldn’t cover all the common tasks involved in protocol analysis, but we strongly recommend that you read James Forshaw’s book mentioned above, Attacking Networks at the Protocol Level.
We used materials from the book “The Definitive Guide to Attacking the Internet of Things” written by Photios Chantsis, Ioannis Stais, Paulino Calderon, Evangelos Deirmentsoglu and Beau Woods.