Opening a tool for DDoS attacks on Russian IT infrastructure

 

Opening a tool for DDoS attacks on Russian IT infrastructure

Hornbeam

Introduction

In early April 2023, a suspicious file mhddos_proxy_linux_arm64 (MD5: 9e39f69350ad6599420bbd66e2715fcb) was found on one of the corporate hosts, loaded with a certain Docker container. According to open sources, it became clear that this file is a freely distributed tool for carrying out a distributed denial of service (DDoS) attack against the Russian IT infrastructure.

Once launched, the program receives all the necessary settings and automatically initiates massive network connections to target hosts at various TCP / IP levels for denial of service.

Since this program is not malicious in the usual sense for anti-virus products - it does not pin and self-propagate, does not try to hide its presence on the device, and is not currently used to control the device or steal information from it - no antivirus reads this file malicious and does not attempt to prevent its execution. But unlike ordinary malware, the execution of such a program leads to unintentional participation in actions punishable under the laws of the Russian Federation, which can be more critical than compromising a personal device or corporate network.

Therefore, it was decided to analyze this tool in order to identify the exact list of its purposes, as well as possible indicators of presence on the device.

This material will be useful for information security / IT specialists, as well as for everyone interested in the internal structure of the Python language and software obfuscation. In addition to exploration, a list of targets is provided, extracted from the tool's internal configuration.

The first part of the article will require the reader to know Python. For the second part, it would be nice to have basic reverse engineering skills. And in the third part of the article, deep knowledge of Python and C is required, or strong reverse engineering skills. If you are only interested in the results obtained, and not in the technical details, you can immediately proceed to the conclusion .


Level 1: Easy. Decrypting the L7 configuration

After a couple of seconds, on Google, the query “mhddos” easily finds information about the mhddos tool . This is an open source project that provides a wide range of network stress testing functionality at various OSI levels (Layer 4 - transport and Layer 7 - applications) and many supported protocols, with the ability to bypass some captchas to protect sites from DDoS attacks, and using numerous proxy servers. That is, the functionality of the tool is known, and anyone with knowledge of Python can study it. However, MHDDoS is distributed with source codes and not as a binary file… 

But for the request "mhddos_proxy" you can already find the repository of the customized mhddos_proxy project and its description in Telegraph from the authors who complain that the original mhddos has already ceased to give good performance, and provide a new, more convenient version of the script, in which the list of targets is chosen by yourself developers and comes with a configuration. Well, it's impossible to effectively protect Python sources, right? Then just find the configuration with the list of targets in the source code, business for a couple of minutes!

neural network python
neural network python

We open the repository, the config.json file immediately catches our eye:

Configuration tool
Configuration tool

Lists of proxies on these links are no longer available - now in the specified repositories, instead of the files “1(2,3,4).txt”, there are files “11.txt”, however, they are encrypted and are not intended for this version of mhddos_proxy.

Target URLs (file “11.txt”) can still be downloaded and these files are constantly updated. However, after downloading the 11.txt file, it becomes clear that this is not text at all:

Contents of file 11.txt

It turns out that the program somehow decodes this file. So you need to find procedures for this decoding or decryption. Searching the code for the string "config.json" leads to the desired _possibly_decrypt method in the src/targets.py file :

A fragment of the file src/targets.py
A fragment of the file src/targets.py

This method compares the first 4 bytes of the file with the list of versions in the ENC_KEYS dictionary , and if there is a match, decrypts the remaining file data with the corresponding key from the dictionary using the ChaCha20Poly1305 encryption algorithm . The dictionary itself contains only one version with a key:

 ENC_KEYS = {b'\xe4\xdc\xf7\x1f': b'fZPK2OTLiNdqVDBxJTSMuph/rfLzpFWHDmHC1/+rR1s='}

And it exactly matches the first 4 bytes of the configuration file from the 11.txt file. Well, we are lucky, because this means that we can also repeat the same locally: just copy this piece of code and run it on our machine (you may need to download the cryptography package for python). As a result, we get something interesting:

Fragment of a decrypted file with targets for a DDoS attack
Fragment of a decrypted file with targets for a DDoS attack

Namely - a list of about four hundred URLs of sites of Russian federal and municipal institutions, educational organizations, Internet service providers. Complementing this list with other files encoded with base64 or encrypted with this algorithm, we get about 500 URLs, here are just a few of them:

URLs

You can get acquainted with the full list and check the presence of the resource of interest in it in the .

But there are only 500 links. Excluding the numerous domains of the Ministry of Foreign Affairs of the Russian Federation and the Beeline server, even less remains - something not a lot. It should be noted that the links from the config can also be used to find other files, also encrypted, but with a different key, which could not be decrypted. Perhaps they contain even more domains.

The developer made an attempt to exclude the use of the tool against certain targets: the file src/exclude.pycontains the corresponding IPs (for example, internal network addresses, Cloudflare, Google DNS servers), and the obfuscated src/vendor/rotate.py file excludes an attack on domains in the .ua zone. We can deobfuscate it manually by simply applying base64 sequentially (e.g. via https://www.base64decode.org/), decoding text in escaped hex strings (e.g. via https://codepen.io/kamakalolii/pen/RKNoMr ), and shifting the text with rot13 ( https://rot13.com/ ). Or you can use any online Python interpreter and copy the obfuscated code there. The output will be the following:

from yarl import URL
suffix = '.ua'
params = [
(URL('https://profile.sber.ru'), '84.252.144.102'),
(URL('https://3dsec.sberbank.ru'), '62.76.205.110'),
(URL('https://cdek.ru'), '178.248.238.208'),
(URL('https://lk.platon.ru'), '83.169.194.22'),
(URL('https://auth.kontur.ru'), '46.17.206.15'),
]

The src/vendor/useragents.py file also contains packaged Useragents for connecting to sites, but this is standard information for mimicking legitimate devices and is of no interest.

In the src/utils.py file, you can also find code for bypassing protection against bots on State Services (the code for creating the correct Cookie):

mhddos_proxy/src/utils.py
class GOSSolver:
DEFAULT_A = 1800
MAX_RPC = 100
OWN_IP_KEY = "OWN"
_path = 'https://www.gosuslugi.ru/__jsch/schema.json'
_verifier = b'__jsch/static/script.js'
#...
def solve(self, ua, resp, *, cache_key: str) -> Tuple[int, Dict[str, str]]:
    a, ip, cn = resp["a"], resp["ip"], resp["cn"]
    bucket = self.time_bucket(a)
    value = f"{ua}:{ip}:{bucket}"

    hasher = md5
    for pos in range(10_000_000):
        response = hasher(f'{value}{pos}'.encode()).hexdigest()
        if response[6:10] == '3fe3':
            cookies = {
                cn: response.upper(),
                f"{cn}_2": pos,
                f"{cn}_3": crc32(value.encode())
            }
            self._cache[cache_key] = (bucket + a, ua, cookies)
            return bucket + a, cookies
    raise ValueError("invalid input")

Okay, we have received and decrypted the configuration. But the mhddos_proxy_arm64 file that was originally discovered is not a Python script, so where did it come from? The answer is in the same repository: the developer points out that the python open source project is already out of date, and encourages everyone to move to a new version in a different mhddos_proxy_releases repository . Unfortunately, there are no source codes in this repository, and the tool is distributed only as executable programs. Therefore, you will have to apply reverse engineering methods.

Download the assembly for linux under x86 (mhddos_proxy_linux v81, MD5: a004b948f72c6eb14f348cc698bda16e) - it will be easier to explore than the binary for ARM. We open it in the disassembler, look at the lines and see the characteristic lines starting with _PYI:

Fragment of program lines
Fragment of program lines

These lines indicate that the source code was packaged with PyInstaller . This is an open source project designed to compile Python projects into executable files for easy distribution, and to protect the source code from copying and modification.


Level 2: Medium. Unpack the modified PyInstaller

The functionality of the PyInstaller packager is to compile all source code (including dependencies) into .pyc bytecode files, and package it, along with the Python interpreter library, into a self-extracting archive as an executable. When the file is run, PyInstaller includes the interpreter executable, unpacks the bytecode archive into a temporary folder (except for the main script), and runs the main script without unpacking, setting up its environment so that dependencies are correctly connected from the temporary directory.

Neural network packaged python
Neural network packaged python

Therefore, we can do the opposite and extract the compiled bytecode (how useful it will be for analysis is another question).

Unpacking the executable file

Fortunately, there is already an open source unpacker for PyInstaller -  https://github.com/extremecoders-re/pyinstxtractor . We run and get the following error:

$python3.9 pyinstxtractor/pyinstxtractor.py mhddos_proxy_linux
[+] Processing mhddos_proxy_linux
[!] Error : Missing cookie, unsupported pyinstaller version or not a pyinstaller archive

We climb into the source code of the unpacker, and we see:

A fragment of pyintxtractor.py

The MAGIC constant denotes the beginning of the archive header of packed Python files, “ MEI\014\013\012\013\016 ”. Well, it turned out that not everything is so simple, apparently the developer modified PyInstaller to package mhddos_proxy, which means you have to go into the disassembler.

Studying the main procedure, we find the procedure at 0x4024C0 ​​, which parses the archive header, which contains a new, non-standard magic number 0x742F271B6DD36293 :

loc_4024E5:             ; n
mov     edx, 8
mov     rsi, rsp        ; s2
mov     [rsp+28h+cookie], 74h ; 't' ; char
mov     [rsp+28h+var_27], 2Fh ; '/'
mov     [rsp+28h+var_26], 27h ; '''
mov     [rsp+28h+var_24], 1Bh
mov     [rsp+28h+var_23], 6Dh ; 'm'
mov     [rsp+28h+var_22], 0D3h
mov     [rsp+28h+var_21], 62h ; 'b'
mov     [rsp+28h+var_25], 93h
call    find_cookie
test    rax, rax
mov     rbx, rax
jz      loc_4026B0

We correct pyinstxtractor.py right there in the source code:

Adding the correct archive header signature
Adding the correct archive header signature

If we take a closer look at the pyinstxtractor source code and the decompiled header parsing procedure, we can see that the values ​​important for unpacking are XORed with different constant values:

Fragment of header parsing procedure
Fragment of header parsing procedure

Correcting pyinstxtractor again, now in the parseTOC and getCArchiveInfo methods :

Fragment of the augmented procedure parseTOC
Fragment of the augmented procedure parseTOC

Run the patched pyinstxtractor again:

$python3.9 pyinstxtractor.py mhddos_proxy_linux
[+] Processing mhddos_proxy_linux__
[+] Pyinstaller version: 2.1+
[+] Python version: 3.9
[+] Length of package: 25802384 bytes
[+] Found 102 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_subprocess.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: runner.pyc
[+] Found 695 files in PYZ archive
[!] Error: Failed to decompress PYZ-00.pyz_extracted/OpenSSL/__init__.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted/OpenSSL/SSL.pyc, probably encrypted. Extracting as is.
...
$ls
faker/ libssl.so.1.0.0.i64 pyimod00_crypto_key.pyc PYZ-00.pyz frozenlist/ \
libtinfo.so.5 pyimod01_os_path.pyc _cffi_backend.cpython-39-x86_64-linux-gnu.so \
libz.so.1 pyimod02_archive.pyc aiohttp/ lib-dynload/ pyimod03_importers.pyc \
libbz2.so.1.0 markupsafe/ pyimod04_ctypes.pyc base_library.zip libcrypto.so.1.0.0 \
pytransform.so bin libcrypto.so.1.0.0.i64 certifi libffi.so.6 multidict/ \
cryptography/ libgcc_s.so.1 cryptography-37.0.2.dist-info/ liblzma.so.5 psutil/ \
libncursesw.so.5 pyi_rth_inspect.pyc struct.pyc libpython3.9.so.1.0 \
pyi_rth_multiprocessing.pyc libpython3.9.so.1.0_copy pyi_rth_pkgutil.pyc \
tinyaes.cpython-39-x86_64-linux-gnu.so libpython3.9.so.1.0_copy.idc \
pyi_rth_subprocess.pyc uvloop libssl.so.1.0.0 pyiboot01_bootstrap.pyc yarl

Already better. The main application libraries and PYZ unpacking scripts (another format of self-extracting Python archives in our matryoshka) were extracted. We can immediately note some interesting dependencies. For example: faker is a framework for generating fictitious personal data, including Russian ones. Obviously, such a framework is used in this case to increase the effectiveness of a DDoS attack.

However, the PYZ archive itself is not unpacked. Apparently, we have not taken into account all modifications of the PyInstaller code.

PYZ unpacking

Fortunately, Google suggests that we are not the first to encounter such a problem. It turns out that from a certain version PyInstaller allows you to embed the encryption key for PYZ, it is located in the file pyimod00_crypto_key.pyc . We decompile it using the Python decompiler - Decompyle++ , use the version for Python3.9, because it was it that was used by the authors to develop mhddos_proxy.

$pycdc pyimod00_crypto_key.pyc
# Source Generated with Decompyle++
# File: pyimod00_crypto_key.pyc (Python 3.9)
key = '7848c0e62fdae63e'

Bingo! However, taking this key and simply pasting it into the appropriate unpacking function in pyinstxtractor will not work for you. And all because the schemes and modes of using AES encryption of the PYZ archive in PyInstaller vary from version to version, and in this case could also be modified by the developer. After several futile attempts to select the appropriate AES library and the desired encryption mode, we move on to another method: we analyze the PyInstaller and unpacker sources, and come to the conclusion that unpacking is implemented in the ZlibArchiveReader class, which is located in the pyimod02_archive.pyc file we have already extracted :

$pycdc pyimod02_archive.pyc
# Source Generated with Decompyle++
# File: pyimod02_archive.pyc (Python 3.9)
... 
class Cipher:
    '''
    This class is used only to decrypt Python modules.
    '''
    def __create_cipher(self, iv):
        return self._aesmod.AES(self.key.encode(), iv)
    def decrypt(self, data):
        cipher = self.__create_cipher(data[:CRYPT_BLOCK_SIZE])
        return cipher.CTR_xcrypt_buffer(data[CRYPT_BLOCK_SIZE:])
...
class ZlibArchiveReader(ArchiveReader):
    '''
    ZlibArchive - an archive with compressed entries. Archive is read from the executable created by PyInstaller.
    This archive is used for bundling python modules inside the executable.
    NOTE: The whole ZlibArchive (PYZ) is compressed, so it is not necessary to compress individual modules.
    '''
    MAGIC = b'PYZ\x00'
    TOCPOS = 8
    HDRLEN = ArchiveReader.HDRLEN + 5
def extract(self, name):

        ...

Why not just reuse it right then and there by including this compiled file from a Python script? It turns out very short and neat:

from pyimod02_archive import ZlibArchiveReader
import sys, os
arch = ZlibArchiveReader("PYZ-00.pyz")
os.makedirs("PYZ-00.pyz_extracted")
for toc_name in arch.contents():
typ, obj = arch.extract(toc_name)
filename = "./PYZ-00.pyz_extracted/" + toc_name.replace(".", "/")
if typ == 1:
os.makedirs(filename, exist_ok=True)
filename += "/init"
filename += ".pyc"
with open(filename, 'wb') as f:
f.write(obj)

We run the script and unpack PYZ, getting all the compiled sources and numerous mhddos_proxy dependencies.

Unpacked contents of PYZ
Unpacked contents of PYZ

Pay attention to the src folder, remember the mhddos_proxy code of previous versions, it should contain the bytecode of the project itself:

/src/ directory structure
/src/ directory structure

As you can see, the project structure has become a little more complicated, and the bypass folder now contains a lot of scripts for bypassing various DDoS protection services, including DDOS-Guard, Variti, Qrator, Stormwall .

That's all, our efforts have paid off, we use a decompiler, or, in extreme cases, a Python bytecode disassembler, and we get sources in which we can find the configuration, right? We try:

$pycdc runner.pyc
# Source Generated with Decompyle++
# File: runner.pyc (Python 3.9)
from pytransform import pyarmor
pyarmor(name,file,b'PYARMOR\x00\x00\x03\t\x00a\r\r\n\x08\xa0\x01\x01\x00'
'\x00\x00\x01\x00\x00\x00@\x00\x00\x00aP\x00\x00\x0b\x00\x00z\xe9\xb4G\x1e'
'\xd1\x1b\xe9\x1b\x9d\xf4\x86\xf5\x19V\x18<\x00\x00\x00\x00\x00\x00\x00\x00'
'\x97\xf1\xaa!h\x0fu\xaeIO\t\x98\xcf\xd6\xd5\xb8O\xb7\xdd\xe8\x00\x15\xc4'
'\xe3v\x98\xca\xdd\xf5xO0V\x1e\x0b\x12?\xba_i\x7fX\x84X\x0bmW\x9dA}1\xfd\xa1'
'\x10\x08.\x98\x87\x83\xe1\[\n\x90K\x19:\xb2\xbex\x99\xbe\xbd\xf6\x84\xa2'E'
'\x05\rB\xe8\x8e\xc0\xc33Y\x7f\xea\xcf]f\xccb\xbb\xa7\x8c\xfa\xba\xf0\xa5\xb2'
'@1~\xa8\xbc\x97|<оставшиеся ~17т. неразборчивых байт...>'

Not very similar to the usual python source. As a result, runner.pyc and all files of the src directory from the PYZ archive cannot be decompiled. Only a call to a certain pyarmor function from the pytransform library is visible.

After a couple of minutes on Google, on the request “pyarmor”, we come across a commercially popular Python obfuscation project - http://pyarmor.dashingsoft.com/ , https://github.com/dashingsoft/pyarmor .


Level 3: Hard. We bypass Pyarmor and study the internals of the Python implementation to get the L4 configuration

Previous obfuscation tools have been open source, but the commercial PyArmor project only has the client side open. Of course, this in itself does not say anything about the quality of protection, but in fact - today there are no effective tools for recovering code protected with PyArmor in the public domain.

Neural network armored python
Neural network armored python

To understand how PyArmor works, first let's remember what the Python language is, or rather its reference open implementation in C language - CPython . It is with it that most people work when they say that they “write in python”. There are other implementations: Jython, PyPy, IronPython.

How CPython works

In the CPython implementation, source code is first translated into bytecode, a low-level intermediate language. You can see this for yourself with the help of the dis standard library , which allows you to disassemble modules of this bytecode:

$python3.9
Python 3.9.16 (main, Dec  7 2022, 01:12:08)
[GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import dis
>>> def main(): print("Hello, world!")
...
>>> main.__code__
<code object main at 0x7fd40cd5e5b0, file "<stdin>", line 1>
>>> main.__code__.co_code
b't\x00d\x01\x83\x01\x01\x00d\x00S\x00'
>>> dis.dis(main.__code__)
  1           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('Hello, world!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

LOAD_GLOBAL , LOAD_CONST , etc. are the instruction names of the given bytecode. Like machine code, CPython bytecode has binary and human-readable forms. Most instructions are double-byte in this case - the first byte encodes the command itself, and the second byte encodes its argument. For example “ LOAD_CONST 1 ” means to load onto the stack the first constant from the list of constants (in our case 'Hello, world!'). Developers encounter the binary form of bytecode all the time - it is this form that is contained in .pyc files created after the program is launched.

The bytecode obtained after translation is interpreted (executed) on the CPython interpreter, therefore Python is called interpreted, implying its CPython reference implementation. An interpreter is also called a virtual machine for a given set of instructions, so we will use these terms interchangeably in what follows. In fact, this is a software analogue of a processor with its own set of instructions and binary code format.

PyArmor functionality

The creators of the PyArmor obfuscator provide documentation on how to use their product (it varies from version to version, as do obfuscation modes). It can be distinguished from it that PyArmor performs a number of reversible and irreversible transformations on the code:

  • rftmode - rename functions, classes and arguments. Indeed, only people need names to understand the source code, you can get rid of them and rename everything to X1, X2, X3 or something else. 

  • bccmode - translation of most functions in C and subsequent compilation to machine code. How will the interpreter call them? It's just that control from the interpreter will be transferred to machine code and vice versa. Just like it constantly calls functions from various system libraries.

  • Modular Obfuscation - Each module (.py source) is encrypted and distributed in an encrypted form (as can be seen from the illegible bytes we've already seen). At startup, of course, the decryption and execution of the code is carried out.

  • Obfuscation at the object level - obfuscation of the bytecode itself of each function and class. The method of obfuscation, for obvious reasons, was not disclosed.

  • Object wrapper - functions and classes are stored in encrypted form, decrypted on the fly and encrypted back after execution.

  • Protection of the pytransform library - code integrity checks, JIT generation of executable code, anti-debugging mechanisms optional use of code virtualization (using another, additional virtual machine) Themida to protect the PyArmor runtime on Windows.

  • Packaging with PyInstaller , which we covered in the previous part of the article.

To summarize, all of the above looks extremely regrettable. Code protected by all of these mechanisms will be quite difficult to analyze and almost impossible to recover. There is one hope - obfuscation is almost always a compromise between performance and security, so it's not a fact that absolutely all of the listed mechanisms are applied in our case.

Finding a way to bypass PyArmor

The very first link on Google for “pyarmor unpacker” will take you to the PyArmor-Unpacker repository . This is a useful place to start our exploration, as it lists the features of PyArmor and there is also a link to a topic on the tuts4you forum , where people share ways to open this evil spirit.

Several PyArmor unpacking methods can be distinguished from these sources:

  1. Inject a specially designed library into the running process in order to dump the main executable module decrypted in the interpreter's memory (bypassing external, modular obfuscation). Then deobfuscate it if possible.

  2. The same as in the first method, but deobfuscate on the fly and dump the already finished code.

  3. statically submit the obfuscated module to the Python interpreter, run it, and using https://docs.python.org/3/library/sys.html#sys.addaudithook intercept the module execution to deserialize the decrypted executable modules, immediately deobfuscate them and complete the execution programs.

The last method does not bypass PyArmor's binding to the interpreter (we could see the libpython library in the unpacked archive - that's why it is distributed along with the obfuscated code). Other methods can have a lot of shortcomings, for example, the need to run code. For our case, this is not critical, since the program under study is not malware, but in the general case it is impractical. Also, the need to connect to a running process with a third-party program to implement the library is not very convenient - maybe our program will work in a second, and we won’t even have time to do anything. And we note right away that for our case, none of the presented tools work (due to the settings or the version of PyArmor). This is logical, the PyArmor developers also monitor similar repositories and make life difficult for their opponents from version to version.

Despite the shortcomings, we note an important detail - PyArmor does not protect against code injection through loading a third-party library. We will not use third-party programs to inject it, because Linux has a more convenient mechanism for injecting the library through the LD_PRELOAD environment variable . Simply specify your library in this variable before starting the program, and your library will be loaded together at startup. In the future, when the program requests some functionality from other libraries (for example, the memcpy function from libc), the dynamic loader will also check your library, and if it contains the corresponding function, it will call it, and not the function from the real library.

This way you can intercept calls to libc or, for example, the CPython interpreter contained in libpython. After all, the code, after all, was originally written in Python, so it must somehow access the standard interpreter? Then we will intercept these calls, and perhaps their analysis will help to bypass PyArmor, or forget about it altogether.

CPython API interception implementation

Developing call interception and parsing the structures of an unknown library is also not a trivial task, but CPython is one of the most popular and successful projects, it has open source code and the best documentation .

Armed with the code and documentation, let's try to answer a simple question - is there a function that takes a code object as input for execution? Surely it will already be at least decrypted, then we will dump it!

Searches lead to the PyEval_EvalCode function . Here is its signature:

PyObject* PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);

What is PyObject? This is the default CPython structure from which all other types inherit, here is its definition:

typedef ssize_t Py_ssize_t;
typedef struct _object
{
  Py_ssize_t ob_refcnt;
  struct _object ob_type;
} PyObject;

 We define the same in our library, plus do not forget to include the header files of the libraries that will be needed in the future:

#include <ucontext.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <link.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void _libhook_init() attribute((constructor));
static void _libhook_init() { printf("[] Hook actviated.\n"); }

The first two lines contain the definition of a function that will signal that our library is loaded by the program. We immediately get acquainted with the basic interception technology by defining the target function in our code:

static long long (*PyEval_EvalCode_real)(PyCodeObject *, void *, void *) = NULL;
long long PyEval_EvalCode(PyCodeObject co, void globals, void locals) {
  if (!PyEval_EvalCode_real) {
    PyEval_EvalCode_real = dlsym(-1, "PyEval_EvalCode");
  }
  printf("[] hooked PyEval_EvalCode(%p, %p, %p)", co, globals, locals);
  PyObject retval = PyEval_EvalCode_real(co, globals, locals);
  return retval;
}

We first define a local symbol, PyEval_EvalCode_real, which will contain the address of the real function. Then we define a PyEval_EvalCode function with the same name as the hooked one. In the body of the function, we initialize the real symbol if it has not yet been initialized (the function is called for the first time), print the addresses of the arguments via printf, return the value obtained by calling the real function, and that's it, our hook is ready! It remains only to compile:

LD_PRELOAD=../../src/ldpreloadhook/pyarmor_hook.so ./mhddos_proxy_linux
[] Hook actviated.
[] Hook actviated.
[] Hook actviated.
[] hooked PyEval_EvalCode(0x7f4cd56bfa80, 0x7f4cd56bef80, 0x7f4cd56bef80)
[] hooked PyEval_EvalCode(0x7f4cd56693a0, 0x7f4cd56ce440, 0x7f4cd56ce440)
[*] hooked PyEval_EvalCode(0x7f4cd56902f0, 0x7f4cd5684e80, 0x7f4cd5684e80)
...<множество других перехваченных обращений>...

Great! The first step has been taken. Now let's figure out what this function really receives as input. It is defined in the Python/ceval.c file of the CPython repository, and as you can see from the source code, calling it results in a call to the _PyEval_EvalCode procedure ( code ), in which the _co argument is cast to a PyCodeObject type. This is the same basic structure of the compiled code (we disassembled this one with dis), which also contains a link to the Python bytecode:

typedef struct attribute((aligned(4))) code_obj
{
  PyObject ob_base;
  int co_argcount;
  // <...>
  PyObject co_code;
  // <...>
} PyCodeObject;

Okay, so we can dump with   PyMarshal_WriteObjectToFile , which we will also load with dlsym. To do this, add the following lines to our function:

  FILE * fp = fopen(((PyBytesObject)co->co_name)->ob_sval, "wb");
  PyMarshal_WriteObjectToFile(co, fp, 0);
  fclose(fp);

To do this, let's not forget to define the PyBytesObject type, in which Python stores all python strings as follows:

typedef struct _varobj
{
  PyObject ob_base;
  Py_ssize_t ob_size;
} PyVarObject;
typedef struct {
    PyVarObject ob_base;
    Py_ssize_t ob_shash[3];
    char ob_sval[1];
} PyBytesObject;

Unfortunately, even by dumping these objects at the PyEval_EvalCode input, we bypassed only the “external” encryption of the module and get a lot of encrypted objects:

>>> import marshal, dis
>>> f = open("./<frozen src.crypto>", "rb")
>>> co = marshal.load(f)
>>> dis.dis(co)
  1           0 LOAD_GLOBAL             35 (armor_wrap)
              2 CALL_FUNCTION            0
              4 NOP
              6 RETURN_VALUE
  2           8 NOP
             10 NOP
             12 <0>
             14 <0>
  3          16 <149>                   24
             ...<мусорный байткод>...

In the hex editor we see the same thing: a bunch of encrypted code and names, among which is a certain “armor_wrap” function.

Hex dump of code file
Hex dump of code file

That is, even the CPython interpreter receives encrypted code as input? Surely it is somehow decoded in the armor_wrap function . But where did she come from? We'll have to study it with PyArmor even deeper, and this small maneuver will cost us a couple of minutes.

PyArmor internals

You will not find the __armor_wrap__ function in this file, however, there is a corresponding line, if you look at the links to it, you can see that at address 002B5D00h there is a link to this line, and then at 002B5D08h this line is a link to a function that we ourselves will call __armor_wrap__func :

; фрагмент секции данных pytransform.so
.data:002B5D00 new_python_method dq offset __armor_wrap__
.data:002B5D00                                         ; DATA XREF: sub_19180+2B↑o
.data:002B5D00                                         ; sub_19180+49↑r
.data:002B5D00                                         ; "__armor_wrap__"
.data:002B5D08                 dq offset __armor_wrap__func
.data:002B5D10                 dd 4
.data:002B5D14                 dd 0
.data:002B5D18                 dd 0
.data:002B5D1C                 dd 0

This function is added to the interpreter environment when the pytransform.so library is imported. Let's disassemble it:

; .text:0000000000018F70 фрагмент __armor_wrap__func
__armor_wrap__func proc near            ; DATA XREF: .data:00000000002B5D08↓o
buffer          = qword ptr -38h
len             = qword ptr -30h
; __unwind {
                push    r13
                push    r12
                push    rbp
                push    rbx
                sub     rsp, 18h
                call    _PyEval_GetFrame
                mov     rbp, [rax+20h]
                lea     rdx, [rsp+38h+len]
                mov     rsi, rsp
                mov     rbx, rax
                mov     r12, [rax+40h]
                mov     r13d, [rax+68h]
                mov     rdi, [rbp+30h]
                call    _PyBytes_AsStringAndSize

The code gets a certain frame by calling the PyEval_GetFrame function. But what are these frames?  

PyCodeObjects are inherently static, like native code in an executable. The execution of such code depends on the context - the state of the registers and memory in which the objects that the function accesses (for example, working with arguments) are located. And in the CPython interpreter, the bytecode memory is defined by the stack (the CPython interpreter is a stack virtual machine). And the stack memory of each individual executable bytecode object at runtime is determined by the frame - PyFrameObject , which specifies what part of the stack the object uses. Here is its definition:

typedef struct _frame
{
  PyVarObject ob_base;
  struct _frame *f_back;
  PyCodeObject *f_code;
  PyObject *f_builtins;
  PyObject *f_globals;
  PyObject *f_locals;
  PyObject **f_valuestack;
  PyObject **f_stacktop;
  PyObject *f_trace;
  char f_trace_lines;
  char f_trace_opcodes;
  PyObject *f_gen;
  int f_lasti;
  int f_lineno;
  int f_iblock;
  char f_executing;
  PyTryBlock f_blockstack[20];
  PyObject *f_localsplus[1];
} PyFrameObject;

As you can see from the definition, PyFrameObject is a dynamic object that also contains a pointer to a bytecode object. It is the frames that the CPython interpreter operates on when executing the program. By the way, to simplify the analysis, it is recommended to add these structures to your disassembler / decompiler. In IDA Pro, this is done very simply, in Ghidra it is much more inconvenient. And you can take these types from the libpython.so library, which we also unpacked earlier from the mhddos_proxy executable archive, because as it turned out, there are debug symbols and types there! So just export them from one IDB and add them to another (and to your code, of course).

But why does PyArmor access it in __armor_wrap__ ? The answer lies ahead in the function at 18AC0h , which is called from __armor_wrap__:

Function fragment at 18AC0h
Function fragment at 18AC0h

If you decompile it, you will find that some transformations are performed on the frame bytecode, very similar to cryptography, then a certain function at 9190h is called, which I called pyarm , and then, oddly enough, the cryptographic operations on the bytecode are repeated again . Assuming that the bytecode is first decrypted and then encrypted again, what can potentially happen between these two procedures? That is, why is it first decrypted and then encrypted back? Already guessed?

Personally, I did not guess until I saw that the pyarm function called between these two actions weighs as much as 50 (!) KB. For you to understand, 1 machine instruction on x86-x64 takes an average of 4-5 bytes, that is, our function performs more than 10 thousand operations, while its decompiled code takes ~ 146 thousand lines. Most of these lines are occupied by switch-case statements paired with goto. Unfortunately, the graphical representation of the CFG of this function simply cannot be made informative on the scale of ordinary monitors:

CFG functions pyarm
CFG functions pyarm

Without experience and immersion in CPython, it would be very difficult for us to understand what this function does. But after reading the same eval.c from CPython, you can understand (I will not bore you) that the largest function in it takes several thousand lines of source code, and this is _PyEval_EvalFrameDefault(PyThreadState *, PyFrameObject *, int) ( code), that is, the implementation of the bytecode interpreter itself. Why did 3 thousand lines turn into 146 thousand? This is function inlining. Instead of leaving the “call funcA(x)” call in native code, funcA is simply inlined in the body of the calling function, thus increasing its size to an unimaginable 50 KB and reducing program execution time. Of course, this function is also present in libpython.so, but its decompiled code takes 3 times less, only ~ 50 thousand lines.

As a result of our research, we can already conclude that PyArmor does not give the decrypted code to the CPython interpreter. It executes this code on its own, in its own interpreter implementation. And this means that instead of Python bytecode, it can contain anything , and developers could change and obfuscate Python bytecode in any way. But if we compare pyarm and _PyEval_EvalFrameDefault from libpython.so, we can find similar blocks of code:

Comparison of similar code blocks in pytransform.so and libpython.so interpreters
Comparison of similar code blocks in pytransform.so and libpython.so interpreters

All names and locations in pytransform are set manually, but you can immediately notice that if in libpython.so the specified block of code is case 0x14 in some switch-case table, then in pytransform.so it is case 5 . This switch-case table is the choice of opcode and operand code, that is, opcodes are confused in the implementation of the pytransform interpreter, and, for example, the BINARY_MULTIPLY operation has opcode 5 , not 0x14h . Therefore, even if we dump the decrypted bytecode, it will not work normally to decompile it without a new opcode table.

The situation is complicated by the size of the functions - IDA Pro runs in single-threaded mode, and if you try to rename any variables in the pyarm function to indicate where they match with the real _PyEval_EvalFrameDefault , then each such small maneuver will cost you several years (IDA Pro interface will freeze at 3 -10 minutes each time you change the decompiled code). Nevertheless, it is possible, but we now have an easier task - to gain access to at least the decrypted code and data. By the way, Ghidra will not be able to decompile this function normally at all: in this case, its decompiler cannot determine the boundaries of the jump-table set.

Implementing bytecode and data interception in PyArmor

So the goal is clear. There is a non-exportable, internal library function, and we need to intercept its arguments (we get access to PyFrameObject = we get access to bytecode, stack, bytecode arguments, etc.). How to implement this from our library, which is implemented in LD_PRELOAD? The most obvious and correct option is soft breakpoints (breakpoints). However, it requires the implementation of handlers in code. Let's say we find some simple implementation of the debugger library. But soft BPs are easy to deal with, and PyArmor can easily counteract them, so a more “dirty” trick was chosen.  

Obviously, the interpreter in pytransform.so will access libpython.so via the CPython API, which we can intercept. Can we from the called function (callee) access the internal data of the calling function (caller)? Easily!

First, let's choose a target: at the very beginning of its execution, the interpreter calls PyThreadState_Get, gaining access to another PyThreadState runtime structure:

The start of the pyarm function
The start of the pyarm function

The PyThreadState structure itself is not of interest to us yet, but we will intercept this function by defining it in our library:

void *PyThreadState_Get(void) {
if (!PyThreadState_Get_real) {
    PyThreadState_Get_real = dlsym(-1, "PyThreadState_Get");
  }
 return PyThreadState_Get_real();
}

How to implement that very cherished magic - to get access to the interpreter frame from our interceptor? Let's pay attention to the pyarm signature:

Signature pyarm
Signature pyarm

Note that the PyFrameObject argument *a1 is passed in the rdi register. At the same time, in the machine code, we see that in the function prologue, rdi is immediately saved to the r13 register before calling PyThreadState_Get:

; __unwind { пролог pyarmor
; .text:0000000000009190 
push    r15
push    r14
push    r13
mov     r13, rdi
push    r12
push    rbp
push    rbx
sub     rsp, 118h
mov     [rsp+148h+var_B0], esi
call    _PyThreadState_Get

Great, we can just pull this value from the rdi or r13 registers in our interceptor! How? With the help of assembler inserts!

PyFrameObject *dst = 0;
  __asm__ __volatile__("mov %%rdi, %0" : "=r"(dst));

That's all, the frame with the decrypted bytecode is in our hands, and we can do whatever we want with it. However, a number of new problems immediately arise: it is obvious that the real interpreter also calls this PyThreadState_Get, and, perhaps, from many other functions. How to filter these calls? After all, we only need a call from pytransform.so. You can find out the return address of the current function (the address from which execution will continue after the function ends) using the magic of the gcc compiler - the __builtin_return_address(0 ) intrusion, but we do not know the base address of the pytransform.so dynamic library. You can parse /proc/self/maps, or sprinkle some more interception magic: redefine dlopen:

void *dlopen(const char *fname, int flag) {
  if (!real_dlopen) {
    real_dlopen = dlsym(REAL_LIBC, "dlopen");
  }
  void *result = real_dlopen(fname, flag);
  if (fname) {
    printf("%.*s\n", 256, fname);
    struct link_map *lm = (struct link_map *)result;
    if (ends_with(fname, "pytransform.so")) {
        printf("PYTRANSFORM LOADED at %p\n", lm->l_addr);
        PYTRANSFORM_ADDRESS = lm->l_addr;
  //...

Now that the program loads pytransform.so, we store its address in the PYTRANSFORM_ADDRESS global variable, and we can finally define our hook:

void *PyThreadState_Get(void) {
  PyFrameObject *frame = 0;
  __asm__ __volatile__("mov %%rdi, %0" : "=r"(frame));
  void *result = PyThreadState_Get_real();
  if (PYTRANSFORM_ADDRESS) {
    void *retaddr = __builtin_return_address(0);
    if (retaddr == PYTRANSFORM_ADDRESS + PYTRANSFORM_INTERP_HOOK /*адрес возврата в pyarm*/) {
      printf("\n[*][%d]Hooked obfuscated interpreter. Frame %d”, NUM++);
      // делаем с frame всё что нужно
    }
  }
  return result;
}

This will already give us amazing results, but in fact, it will be even more useful to intercept the state of the frame when calling the _Py_CheckFunctionResult function . It is called when the pyarm interpreter finishes executing, so it contains the result (!) of executing the obfuscated bytecode:

PyObject * _Py_CheckFunctionResult(PyObject *tstate, PyObject *callable, PyObject *result, const char *where) {
  if (__builtin_return_address(0)==PYTRANSFORM_ADDRESS + 0x9B3F) { //0x9B3F - адрес инструкции, следующей после 
    if (where)                                                     //вызова _Py_CheckFunctionResult  
      printf("%s\n", where);
    if (tstate) {
        PyFrameObject * frame = (*(PyFrameObject**)((void*)tstate + 0x18));
        // дампим frame и result

When extracting information from PyFrameObject, you will also have to face some problems: frame->f_code->co_consts(array of code constants) for any decoded frame will be an array of one element, like (2,), (1,)The answer to this riddle can also be found in the pyarm code, here is how it refers to the constants:

Access to constants in pyarm

That is, the real address of the array of constants is calculated by the expression:

 (frame->f_code->f_consts->ob_refcnt – 0x7f38) ^ a2

Where the a2 argument in pyarm is a pointer to the value at 314FE8h. We repeat this in our code:

PyCodeObject * co = frame->f_code;  
PyObject* old_consts = co->co_consts;
PyObject* consts = co->co_consts;
unsigned long key = *(unsigned long *)(PYTRANSFORM_ADDRESS + 0x314FE8);
consts = (consts->ob_refcnt - 0x7F38)^key;
co->consts = consts; // перед возвращением в интерпретатор не забыть вернуть сюда old_consts

That's all, now we can safely dump frame and bytecode objects using functions using CPython:

print_repr(frame->f_globals);
print_repr(frame->f_locals);
print_repr(co->co_names);
print_repr(co->co_varnames);
print_repr(co->co_freevars);
print_repr(co->co_cellvars);
dump_stack(frame);

The implementation of print_repr turned out to be a little tricky, but this is all because in bare C it is impossible to find out from under the box whether the value is a valid pointer on the heap / stack, and for this we had to implement a crutch to check the pointer. And CPython, in turn, does not provide a means of checking the validity of the fact that the data at the pointer is a valid PyObject, as this would complicate the implementation and add extra data to the PyObject structures. The program itself must keep a record of the objects that it has allocated on the heap. Therefore, we are trying to validate all this heuristically (with a crutch):

void print_repr(PyObject *obj) {
  if (!check_ptr(obj) || !obj->ob_refcnt) {
    return;
  }
  PyObjectType * type =  obj->ob_type;
  if (!check_ptr(type)) {
    return;
  }
  PyObject * repr = PyObject_Repr_real(obj);
  if (repr) {
    const char * bytes = ((PyBytesObject*)repr)->ob_sval;
    printf("%s", bytes);
  }
  else {
    printf("<unreprable>");
  }
}

We iterate over the stack with even stricter heuristic checks, since we do not yet know its boundaries and contents:

static void dump_stack(PyFrameObject *frame) {
  PyObject **sp = frame->f_valuestack;
  int size = frame->f_code->co_stacksize + frame->f_code->co_nlocals;
  int i = 0;
  printf("\nstack(%p-%p, %d)=[\n", frame->f_stacktop, frame->f_valuestack ,size);
  for (PyObject **ptr = sp; i < size; ptr--, i++) {
    printf(", <");
    PyObject * obj = *ptr;
    if (check_ptr(obj)){
      PyObjectType * type = obj->ob_type;
      if (check_ptr(type)) {
        char * tp_name = type->tp_name;
        if (check_ptr(tp_name) && strlen(tp_name)>2&&(strcmp(tp_name, "13'}"))){
          if (strcmp(tp_name, "code")) 
            print_repr(obj);
        }
        printf(">\n");
      }
   }
   //...

Now that's all for sure! We start and we can skim the cream. To do this, you have to wander in the logs for a long time and extract the necessary information, but something immediately catches your eye:

HOOKED ./co_marshaled/ffffffff_src.bypasses.stormwall.solutions___modu
co_attrs: argcnt=0, posonlyacnt:0, kwonlyacnt:0, nlocals:0, stacksize:7, flags:1644167232,fl:1
consts:
(0, None, b'BZh91AY&SY\xb6\x83&o\x00\x03\xd6\xcf\x80@\x10\x7f\xf0+\xfd]p?d\x01\x00`{\xb8\x06\x80\x0fo^\xa5U\xdd\x9dU"t\xca\xec\xc2\xd7v\x9e\xb5\xdbN\xcd\xd5\x1d\xd9)... 

By the signature of the binary files, it is immediately clear that this is BZ2, copy and unpack, we get:

$cat bz2_decomp.py 
data = b'BZh91AY&SY\xb6\x83&o\x00\x03\xd6\xcf\x80@\x10\x7f\xf0+\xfd]...'
import bz2;
print(bz2.decompress(data))
$python3 bz2_decomp.py
066a08c18735422080a9cf82dfed4589bf98114c:JYCX4FL5
518faf987ab05ebed19ee83ef658efa5bbd0bf38:5JCF4YLX
e47110d17629b2a03998b5667a7c833040e8d5ad:J4X5LFCY
3316b11b92c392744b06c6395021985963570b22:JC5FY4XL
...

The compressed data included 50,000 stormwall captcha solutions. Actually, this is how the tool bypasses the DDOS protection of many providers - the developers simply collect the base of the necessary solutions. By the way, the most pleasant thing about the interceptor we developed is that sometimes it allows you to get data immediately in decoded / decrypted form, since we can analyze the stack with arguments and return values.

If you look through the logs a little more, you can see the lines:

'33ebd69a',
'o14q3151nq6p45o795o03654656p4ro58o5n4o6961q1nq51o14q0p71569377o977n14r6o6s6
44982744n5365696549o36sn44q7n72or46oq9q9o5n55776q45o691o76o708o8o79o367o74r8
r6p9054n76soo538s775po1o8869o6o2n82oq5491onnr6972p792764p734n4570748q44538o4
o4s69795o58o592o47432535r544s7po667896992o88s715374916o90568noo4n898opn6qoq4
1q44q3151nqq145nr8n5n66595n7249oonro3595qnqq1nq51o1',
'qpr97n44p3s46so811r20299nrrq71q293r0r5r8q741pr32r5n69r9s17q67714',

They correspond to the ENC_KEYS and SIGN_KEYS variables. 33ebd69a - These are the first bytes of the configuration of new versions, but something is clearly wrong with the keys (the characters q, p, r, o, etc. are not included in the hexadecimal alphabet). Recall the rot13 encoding:

Encryption key decoding
Encryption key decoding

That's better, we got the encryption keys. But it was not possible to decrypt the configuration, since Chacha20Poly1305 implies the possibility of using additional authentication information during encryption, and we cannot find out the exact scheme without decompiling the corresponding code (and for this we will have to deobfuscate the modified VM to the end). Well, I didn't really want to.

Captcha solutions and encryption keys are interesting, but the most interesting thing awaits us when we find the “ranges” line in the logs:

HOOKED ./co_marshaled/ffffffff_src.misc.exclude___load_ru_ranges
co_attrs: argcnt=0, posonlyacnt:0, kwonlyacnt:0, nlocals:3, stacksize:7, flags:1644167235,fl:142
consts:
(None, <code object <listcomp> at 0x7fa190d8a500, file "<frozen src.misc.exclude>", line 143>, '_load_ru_ranges.<locals>.<listcomp>', 0, 5,
<code object <listcomp> at 0x7fa190dadea0, file "<frozen src.misc.exclude>", line 148>, <code object <listcomp> at 0x7fa190dadf50,
file "<frozen src.misc.exclude>", line 152>)
('range', 'len', '_RANGES', 'ONLY_BYPASS', 'DDOS_GUARD', '_collapse_ranges', 'armor_wrap')('networks', 'ranges', 'range_starts')()()
stack((nil)-0x7fa191093990, 7)=[
<([(34608128, 34608639), (34616576, 34616831), (34629376, 34629631),
(34642432, 34642687), (34643712, 34644479), (34646016, 34646271),

What are the numbers on the stack when exiting the function with the unpretentious name load_ru_ranges? You'll know the answer when you scroll through the logs below and see:

Indeed, 34608128 10 = 02101400 16 = 2.16.20.0, and 34608639 is the broadcast address 2.16.21.255 , that is, these two numbers set the subnet 2.16.20.0 / 23. So, the question for experts is why these 18 are used in the DDOS attack tool thousands of predominantly Russian IP addresses (both subnets and individual hosts)? The question is rhetorical, it is obvious that these are the possible targets of a DDOS attack.

This large list of ranges includes the addresses and subnets of many providers, here are just a few, randomly selected from them:

domains

The full list of IP addresses can be found in the text file . mhddos_proxy tries to scan them, and if available services are found on any IP, it launches a DDoS attack.

For further analysis, you can continue to restore the functionality of the tool, analyze the opcode table in PyArmor, try to let the tool download the configuration and intercept its processing in our library, but since we did not set ourselves the task of a complete analysis of the tool, this time we will stop at this point, and then for one article on Habré, it’s already overkill. If suddenly someone wants to continue the analysis of PyArmor, then the source code of the interceptor is available in the repository (careful, a lot of unsightly C code!).


Conclusion

With the help of some reverse engineering and programming techniques, we were able to extract the list of attacked hosts of this tool (or most of them). This required:

  1. Decrypt the L7 configuration with keys embedded in the source code.

  2. Unpack a file packaged with a modified PyInstaller.

  3. Bypass PyArmor protection by intercepting bytecode interpreter functions.

As a result, we have significantly deepened our knowledge of Python and its interpreter, PyArmor code protection methods and methods of dealing with them, as well as some useful features of Linux. We were not able to completely remove PyArmor obfuscation, since it requires more expenses for analyzing the obfuscated interpreter, but this turned out to be not necessary to obtain worthwhile results. However, as you can see from the study itself, it is indeed possible to secure Python code with PyArmor to a good level, without putting much effort into it.

Users are once again advised to check files received from any sources on the Internet before using them. Yes, source codes and containers too. As you can see from this case, even Virustotal does not guarantee you that the resulting file is not malicious.

It is not difficult to detect the use of the tool on the host - since the developer is faced with the task of distributing this software to ordinary users, it does not currently detect hiding and pinning functionality. And since it is freely distributed, it is possible to detect it by available hashes and filename mhddos_proxy_.*Detection of potential ITW modifications that may not obey these rules can be implemented heuristically by searching the binary file for PyInstaller signatures along with project filenames (in the directory src/), since PyInstaller must unpack the entire internal archive into OS temporary directories before executing the code.

In the tool code, attempts were found to bypass captchas and protection against distributed attacks in DDOS Guard, Stormwall, iHead, QRator, Variti, as well as captchas implemented on the State Services website. This once again emphasizes the importance of constant updating of these protection mechanisms by developers and the need to invalidate obsolete captchas.

Thanks to the analysis in the tool, about 500 URLs and 18 thousand IPv4 addresses of both entire subnets and individual hosts were found. They belong to a variety of organizations in the Russian Federation: from large banks and other IT giants to small urban ISPs; from universities, to sites of various federal services. We will leave the assessment of the effectiveness and expediency of such attacks for attackers to users. It would be especially interesting to see the comments of those who have experienced such attacks or their consequences. You can check the presence of interesting target resources of this attack in the list of URLs and in the list of IP addresses .

Did you like the material? Write your comments and questions in the comments!

Просмотры:

Коментарі

Популярні публікації