==Phrack Inc.== Volume 0x10, Issue 0x47, Phile #0x0F of 0x11 |=-----------------------------------------------------------------------=| |=------------------=[ Evasion by De-optimization ]=---------------------=| |=-----------------------------------------------------------------------=| |=---------------------------=[ Ege BALCI ]=-----------------------------=| |=-----------------------------------------------------------------------=| --[ Table of Contents 1 - Intro 2 - Current A.V. Evasion Challenges 3 - Prior Work 4 - Transforming Machine Code 5 - Transform Gadgets 5.1 - Arithmetic Partitioning 5.2 - Logical Inverse 5.3 - Logical Partitioning 5.4 - Offset Mutation 5.5 - Register Swapping 6 - Address Realignments 7 - Known Limitations 8 - Conclusion 9 - References --[ 1 - Intro Bypassing security products is a very important part of many offensive security engagements. The majority of the current AV evasion techniques used in various evasion tools, such as packers, encoders, and obfuscators, are heavily dependent on the use of self-modifying code running on RWE memory regions. Considering the current state of security products, such evasion attempts are easily detected by memory analysis tools such as Moneta[1] and Pe-sieve[2]. This study introduces a new approach to code obfuscation with the use of machine code de-optimization. In this study, we will delve into how we can use certain mathematical approaches, such as arithmetic partitioning, logical inverse, polynomial transformation, and logical partitioning, to design a generic formula that can be applied to many machine code instructions for transforming / mutating or de-optimizing the bytes of the target program without creating any recognizable patterns. Another objective of this study is to give a step-by-step guide for implementing a machine code de-optimizer tool that uses these methods to bypass pattern-based detection. Such tools have already been implemented, and will be shared[3] as an open-source project with references to this article. --[ 2 - Current A.V. Evasion Challenges The term "security product" represents a wide variety of software nowadays. The inner workings of such software may differ, but the main purpose is always to detect some type of malicious asset by recognizing certain patterns. Such indicators can originate from a piece of code, data, a behavior log, or even from the entropy of the program. The main goal of this study is to introduce a new way of obfuscating malicious code. So, we will only focus on evading malicious CODE patterns. Arguably, the most popular way of hiding malicious code is by using encoders. Encoding a piece of machine code can be considered the most primitive way of eliminating malicious code patterns. Because of the current security standards, most encoders are not very effective at bypassing security products on their own. Instead, they're being used in more complex evasion software such as packers, crypters, obfuscators, command-and-control, and exploitation frameworks. Most encoders work by encoding the malicious code by applying some arithmetic or logical operations to the bytes. After such transformation, encoder software needs to add a decoding routine at the beginning of the encoded payload to fix the bytes before executing the actual malicious code. +-----------+ | decoder | <--- Decodes the original payload. +-----------+ +-----------+ | | | | | malicious | | | | code | ===> | enc-code | | | | | | | | | +-----------+ +-----------+ There are three critical issues with this approach. The first issue is the effectiveness of the encoding algorithm. If the malicious code is being encoded by just a single byte of logical/arithmetic operation, the security products can also detect the encoded form of the malicious code.[4] In order to make the malicious code unrecognizable, the encoder needs to use more complex encoding algorithms, such as primitive cryptographic ciphers or at least multi-byte encoding schemes. Naturally, such complex encoding algorithms require larger decoder routines. This brings us to the second critical issue, which is the visibility of the decoder routine. Most security products can easily detect the existence of such code at the beginning of an encoded blob by using static detection rules[5]. There are some new-generation encoder software that tries to obfuscate and hide the decoder code by also encoding the decoder routine, reducing its size, and obfuscating it with garbage instructions.[6] Such designs produce promising results, but it does not solve the final and most crucial issue. In the end, all of the encoder software produces a piece of self-modifying code. This means the resulting payload must be running inside a Read-Write-Execute (RWE) memory region. Such a scenario can easily be considered a suspicious indicator. --[ 3 - Prior Work To solve the aforementioned issues, security researchers created several machine code obfuscators.[7][8] The purpose of these obfuscators is to transform certain machine code instructions with certain rules for bypassing security products without the need for self-modifying code. These obfuscators analyze the instructions of the given binary and mutate them to some other instruction(s). The following lines contain a simple example. The original instruction loads the 0xDEAD value into the RCX register; lea rcx, [0xDEAD] ------+-> lea rcx, [1CE54] +-> sub rcx, EFA7 The obfuscator program creates the above instruction sequence. After executing two of the newly generated instructions, the resulting value inside RCX will be the same. The assembled bytes of the newly generated sequence are different enough to bypass static detection rules. But there is one problem here. The final SUB instruction of the generated sequence modifies the condition flags. Depending on the code, this could affect the control flow of the program. To avoid such conditions, these obfuscator programs also add save/restore instructions to the generated sequence to preserve all the register values including the condition flags. So, the final output of the program will be; pushf ; --------------> Save condition flags to stack. lea rcx, [1CE54] sub rcx, EFA7 popf ; --------------> Restore condition flags from the stack. If you analyze the final instruction sequence, you will realize that such an order of instructions is very uncommon, and wouldn't be generated from a compiler under normal circumstances. It means that this method of obfuscating instructions can be detected by security products with static detection rules for these techniques. The following simple Yara rule can be used for detecting all the sequences generated by the LEA transform gadget of the Alcatraz binary obfuscator. rule Alcatraz_LEA_Transform { strings: // pushf > 66 9c // lea ?, [?] > 48 8d ?? ?? ?? ?? ?? // sub ?, ? > 48 81 ?? ?? ?? ?? ?? // popf > 66 9d $lea_transform = { 66 9c 48 8d ?? ?? ?? ?? ?? 48 81 ?? ?? ?? ?? ?? 66 9d } condition: $lea_transform } This rule can easily be used by security products because it has a very low false-positive rate due to the unusual order of the generated instruction sequence. Another shortcoming of currently available machine code obfuscators is using specific transform logic for specific instructions. There are around 1503 instructions available for the current Intel x86 instruction set. Designing specific transform gadgets for each of these instructions can be considered challenging and time-consuming. One of the main goals of this study is to create math-based generic algorithms that can be applied to many instructions at once. --[ 4 - Transforming Machine Code To effectively transform x86 machine code, we need a deep understanding of x86 architecture and the instruction set. In the x86 architecture, instructions can take up to five operands. We can divide the types of operands into three main categories. An operand can either be a register, immediate, or memory. These operand types have their subcategories, but for the sake of simplicity, we can skip this for now. Depending on the mnemonic, an x86 instruction can have all the combinations of the mentioned operand types. As such, there are many ways to transform x86 instructions. All the compiler toolchains do this all the time during the optimization phase[9]. These optimizations can be as complex as reducing a long loop condition to a brief branch operation or re-encoding a single instruction to a much shorter sequence of bytes, effectively making the program smaller and faster. The following example shows that there are multiple ways to encode certain instructions in x86 architecture. add al,10h --------------------> \x04\x10 add al,10h --------------------> \x80\xC0\x10 adc al,0DCh -------------------> \x14\xDC adc al,0DCh -------------------> \x80\xD0\xDC sub al,0A0h -------------------> \x2C\xA0 sub al,0A0h -------------------> \x80\xE8\xA0 sub eax,19930520h -------------> \x2D\x20\x05\x93\x19 sub eax,19930520h -------------> \x81\xE8\x20\x05\x93\x19 sbb al,0Ch --------------------> \x1C\x0C sbb al,0Ch --------------------> \x80\xD8\x0C sbb rax,221133h ---------------> \x48\x1D\x33\x11\x22\x00 sbb rax,221133h ---------------> \x48\x81\xD8\x33\x11\x22\x00 Each of the instruction pairs does the exact same thing, with different corresponding bytes. This is usually done automatically by the compiler toolchains. Unfortunately, this type of shortening can only be applied to certain instructions. Also, once you analyze the produced bytes, you'll see that only the beginning part (mnemonic code) of the bytes are changing, the rest of the bytes representing the operand values are exactly the same. This is not good in terms of detection, because most detection rules focus on the operand values. These are the reasons why we need to focus on more complex ways of de-optimization. Most modern compiler toolchains such as LLVM, convert the code into intermediate representations (IR) for applying complex optimizations. IR languages make it very easy to manipulate the code and apply certain simplifications independent from the target platform. At first glance, using LLVM sounds very logical for achieving our objective in this study; it already has various engines, libraries, and tooling built into the framework for code manipulation. Unfortunately, this is not the case. After getting into the endless rabbit hole of LLVM's inner workings, you realize that IR-based optimizations are leaving behind certain patterns in the code[10]. When the code is transformed into IR, whether from source code or binary lifting[11], you lose control of individual instructions. Because IR-based optimizations mainly focus on simplifying and shortening well-structured functions instead of raw pieces of code, it makes it hard to eradicate certain patterns. Maybe highly skilled LLVM wizards can hack their way around these limitations, but we will go with manual disassembly using the iced_x86[12] rust disassembly library in this study. It will help us thoroughly analyze the binary code and give us enough control over the individual instructions. Since our primary objective is to evade security products, while de-optimizing the instructions, we also need to be sure that the generated instruction sequence is also commonly generated by regular compiler toolchains. This way, our obfuscated code can blend in with the benign code, and rule-based detection will not be possible against our transform gadgets. In order to determine how common the generated instructions are, we can write specific Yara rules for our transform gadgets, and run the rules on a large dataset. For this study, ~300 GB dataset consisting of executable sections of various well-known benign EXE, ELF, SO, and DLL files has been curated. We will simply run our Yara rules on this dataset and check the false positive rate. --[ 5 - Transform Gadgets Now, we need a way of transforming individual instructions, while maintaining the overall functionality of the program. In order to achieve this, we will take advantage of basic math and numbers theory. Most instructions in the x86 instruction set can be mapped to equivalent mathematical operands. For example, the "ADD" instruction can be directly translated to the addition operand "+". The following table shows various translation examples: MOV, PUSH, POP, LEA ---> = CMP, SUB, SBB ---> - ADD, ADC ---> + IMUL, MUL ---> * IDIV, DIV ---> / TEST, AND ---> & OR ---> | XOR ---> ^ SHL ---> << SHR ---> >> NOT ---> ' With this approach, we can easily represent basic x86 instructions as mathematical equations. For example, "MOV EAX, 0x01" can be represented as "x = 1". A bit more complex example could be; MOV ECX,8 ) -------------> z = 8 ) SHL EAX,2 ) -------------> 4x ) SHL EBX,1 ) -------------> 2y ) ---> ((4x+2y+8)**2) ADD EAX,EBX ) -------------> 4x+2y ) ADD EAX,ECX ) -------------> 4x+2y+8 ) IMUL EAX,EAX ) -------------> (4x+2y+8)^2 ) When dealing with code sequences that only contain operations of addition, subtraction, multiplication, and positive-integer powers of variables, formed expressions can be transformed using polynomial transformation tricks. Similar "Data-flow optimization" tricks are being used by compiler toolchains during code optimizations[9], but we can also leverage the same principles for infinitely expanding the expressions. In the case of this example, the above expression can be extended to: (16x^2 + 16xy + 64x + 4y^2 + 32y + 64) When this expression is transformed back into assembly code, you'll see that multiple instructions are changed, new ones are added, and some disappear. The only problem for us is that some instructions stay exactly the same, which may still trigger detection. In order to prevent this, we need to use other mathematical methods on a more individual level. In the following sections, we'll analyze five different transform gadgets that will be targeting specific instruction groups. --[ 5.1 - Arithmetic Partitioning Our first transform gadget will target all the arithmetic instructions with an immediate type operand, such as MOV, ADD, SUB, PUSH, POP, etc. Consider the following example; "ADD EAX, 0x10" This simple ADD instruction can be considered the addition (+) operator in the expression "X + 16". This expression can be infinitely extended using the arithmetic partitioning method, such as: (X + 16) = (X + 5 - 4 + 2 + 13) When we encounter such instructions, we can simply randomize the immediate value and add an extra instruction for fixing it. Based on the randomly generated immediate value, we need to choose between the original mnemonic, or the arithmetic inverse of it. In order to keep the generated code under the radar, only one level of partitioning (additional fix instruction) will suffice. Applying many arithmetic operations to a single destination operand might create a recognizable pattern. Here are some other examples: mov edi,0C000008Eh ---+-> mov edi,0C738EE04h +-> sub edi,738ED76h add al,10h ------------+-> add al,0D8h +-> sub al,0C8h sub esi,0A0h ----------+-> sub esi,5062F20Ch +-> add esi,5062F16Ch push 0AABBh -----------+-> push 7F08C11Dh +-> sub dword ptr [esp],7F081662h Upon testing how frequent the generated code sequences are on our sample dataset, we see that ~38% of the compiler-generated sections contain such instruction sequences. This means that almost one of every three compiled binary files contains these instructions, which makes it very hard to distinguish. --[ 5.2 - Logical Inverse This transform gadget will target half of the logical operation instructions with an immediate operand such as AND, OR, or XOR. Consider the following example; "XOR R10, 0x10" This simple XOR instruction can be written as "X ^ 16". This expression can be transformed using the properties of the logical inverse, such as; (X ^ 16) = (X' ^ '16) = (X' ^ -17) Once we encounter such instructions, we can simply transform the instructions by taking the inverse of the immediate value and adding an additional NOT instruction for taking the inverse of the destination operand. The same logic can also be applied to other logical operands. "AND AL, 0x10" instructions can be expressed as "X & 16". Using the same logical inverse trick, we can transform this expression into; (X & 16) = (X' | 16') = (X' | -17) For the case of AND and OR mnemonics, the destination operand needs to be restored with an additional NOT instruction at the end. Here are some other examples: xor r10d,49656E69h ---+-> not r10d +-> xor r10d,0B69A9196h +-> not al and al,1 --------------+-> or al,0FEh +-> not al +-> not edx or edx,300h -----------+-> and edx,0FFFFFCFFh +-> not edx As mentioned earlier in this article, pattern-based detection rules written for detecting malicious "code" mostly target the immediate values on the instructions. So, using this simple logical inverse trick will sufficiently mutate the immediate value without creating any recognizable patterns. After testing the frequency of the generated code sequence, we see that ~%10 of the compiler-generated sections contain such instruction sequences. This is high enough that any detection rule for this specific transform won't be used by AV vendors due to the potential for high false positives. --[ 5.3 - Logical Partitioning This transform gadget will target the remaining half of the logical operation instructions with an immediate operand such as ROL, ROR, SHL, or SHR. In the case of shift instructions, we can split the shift operation into two parts. Consider the following example; "SHL AL, 0x05". This instruction can be split into "SHL AL, 0x2" and "SHL AL, 0x3". The resulting AL value and the condition flags will always be the same. In the case of roll instructions, there is a simpler way to mutate the immediate value. The destination operand of these logical operations is either a register, or a memory with a defined size. Based on the destination operand size, the roll immediate value can be changed accordingly. Consider the following example: "ROL AL, 0x01" This instruction will roll the bits of the AL register once to the left. Since AL is an 8-bit register, the "ROL AL, 0x09" instruction will have the exact same effect. Roll transforms are very effective for keeping the mutated code size low since we don't need extra instructions. Here are some other examples: shr rbx,10h -------------+-> shr rbx,8 +-> shr rbx,8 shl qword ptr [ecx],20h -+-> shl qword ptr [ecx],10h +-> shl qword ptr [ecx],10h ror eax,0Ah ---------------> ror eax,4Ah rol rcx,31h ---------------> rol rcx,0B1h These transforms modify the condition flags the exact same way as the original instruction, and thus can be used safely without any additional save/restore instructions. Since the transformed code is very small, writing an effective Yara rule becomes quite hard. After testing the frequency of the generated code sequences, we see that ~%59 of the compiler-generated sections contain such instruction sequences. --[ 5.4 - Offset Mutation This transform gadget will target all the instructions with a memory-type operand. For a better understanding of the memory operand type, let's deconstruct the memory addressing logic of the x86 instruction set. Any instruction with a memory operand needs to define a memory location represented inside square brackets. This form of representation may contain base registers, segment prefix registers, positive and negative offsets, and positive scale vectors. Consider the following instruction: MOV CS:[EAX+0x100*8] | | | | +----+----+---+---> Segment Register +----+---+---> Base Register +---+---> Displacement Offset +---> Scale Vector A valid memory operand can contain any combination of these fields. If it only contains a large (the same size as the bitness) displacement offset, then it can be called an absolute address. Our Offset Mutation Transform gadget will specifically target memory operands with a base register. We will be using basic arithmetic partitioning tricks on the memory displacement value of the operand. The "MOV RAX, [RAX+0x10]" instruction moves 16 bytes from the [RAX+0x10] memory location onto itself. Such move operations are very common because of operations like referencing a pointer. For mutating the memory operand values, we can simply manipulate the contents of the RAX register. Adding a simple ADD/SUB instruction with RAX before the original instruction will enable us to mutate the displacement offset. Here are some examples: mov rax,[rax] -------+-> add rax,705EBC8Dh +-> mov rax,[rax-705EBC8Dh] mov rax,[rax+10h] ---+-> sub rax,20DA86AAh +-> mov rax,[rax+20DA86BAh] lea rcx,[rcx] -------+-> add rcx,0D5F14ECh +-> lea rcx,[rcx-0D5F14ECh] In each of these example cases, the destination operand is the base register inside the memory operand (pointer referencing). For the other cases, we need additional instructions at the end for preserving the base register contents. Here are some other examples: +-> add rax,4F037035h mov [rax],edi --------------+-> mov [rax-4F037035h],edi +-> sub rax,4F037035h +-> add rbx,34A92BDh mov rcx,[rbx+28h] ----------+-> mov rcx,[rbx-34A9295h] +-> sub rbx,34A92BDh +-> sub rbp,2841821Ch mov dword ptr [rbp+40h],1 --+-> mov dword ptr [rbp+2841825Ch],1 +-> add rbp,2841821Ch The offset mutation transform can be applied to any instruction with a memory operand. Unfortunately, this transform may affect the condition flags. In such a scenario, instead of adding extra save/restore instructions, we can check if the manipulated condition flags are actually affecting the control flow of the application by tracing the next instructions. If the manipulated condition flags are being overwritten by another instruction, we can safely use this transform. Due to the massive scope of this transform gadget, it becomes quite hard to write an effective Yara rule. We can easily consider the instruction mutated by this transform to be common, and undetectable. --[ 5.5 - Register Swapping This transform gadget will target all the instructions with a register- type operand, which can be considered a very large scope. This may be the most basic but still effective transformation in our arsenal. After the immediate and memory operand types, the register is the third most common operand type that is being targeted by detection rules. Our goal is to replace the register being used on an instruction with another, same-sized register using the XCHG instruction. Consider the "XOR RAX,0x10" instruction. We can change the RAX register with any other 64-bit register by exchanging the value before and after the original instruction. Here are some examples: +-> xchg rax,rcx xor rax,10h --------+-> xor rcx,10h +-> xchg rax,rcx +-> xchg rbx,rsi and rbx,31h --------+-> and rsi,31h +-> xchg rbx,rsi +-> xchg rdx,rdi mov rdx,rax --------+-> mov rdi,rax +-> xchg rdx,rdi This transform does not modify any of the condition flags, and can be used safely without any additional save/restore instructions. The generated sequence of instructions may seem uncommon, but due to the scope of this transform and the small size of the exchange instructions, the generated sequence of bytes is found to be very frequent in our sample data set. After testing the frequency of the generated code sequences, we see that ~92% of the compiler-generated sections contain such instruction sequences. --[ 6 - Address Realignments After using any of these transform gadgets, an obvious outcome will be the increased code size due to the additional number of instructions. This will cause misalignments in the branch operations and relative memory addresses. While de-optimizing each instruction, we need to be aware of how much the original instruction size is increased so that we can calculate a delta value for aligning each of the branch operations. This may sound complex, simply because it is :) Handling such address calculations is easy when you have the source code of the program. But if you only have an already-compiled binary, address alignment becomes a bit tricky. We will not dive into the line-by-line implementation of post-de-optimization address realignment; the only thing to keep in mind is double-checking branch instructions after the alignment. There is a case where modified branch instructions (conditional jumps) increase in size if they are modified to branch into much further addresses. This specific issue causes a recursive misalignment and requires a realignment after each fix on branch targets. --[ 7 - Known Limitations There are some known limitations while using these transform gadgets. The first and most obvious one is the limited scope of supported instruction types. There are some instruction types that cannot be transformed with the mentioned gadgets. Instructions with no operands are one of them. Such instructions are very hard to transform since they do not have any operands to mutate. The only thing we can do is relocate them somewhere else in the code. This is not a very big problem because the frequency of unsupported instructions is very low. In order to find out how frequently an instruction is being generated by compilers, we can calculate frequency statistics on our previously mentioned sample data set. The following list contains the frequency statistics of each x86 instruction. 1. %33.5 MOV 2. %9.2 JCC (All conditional jumps) 3. %6.4 CALL 4. %5.5 LEA 5. %4.9 CMP 6. %3.9 ADD 7. %3.7 TEST 8. %3.5 JMP 9. %3.3 PUSH 10. %3.0 POP 11. %2.7 NOP 12. %2.2 XOR 13. %1.7 SUB 14. %1.5 INT3 15. %1.1 MOVZX 16. %1.0 AND 17. %1.O RET 18. %0.6 SHL 19. %0.5 OR 20. %0.5 SHR - %11.3 Similar instruction frequency studies[13] on x86 instruction set have been made on different samples, and it can be seen that the results are very much parallel with the list above. The instruction frequency list shows that only around 5% of the instructions are not supported by our transform gadgets. As can be seen on the list, the most commonly used instructions are simple load/store, arithmetic, logic, and branch instructions. This means that, if implemented properly, previously explained transform gadgets are able to transform the ~%95 of the instructions of compiler- generated programs. This can be considered more than enough to bypass rule-based detection mechanisms. Another known limitation is self-modifying code. If the code is overwriting itself, our transform gadgets will probably break the code. Some code may also be using branch instructions with dynamically calculated branch targets, in such cases the address realignment becomes impossible without using code emulation. Lucky for us, such code is not very commonly produced by compiler toolchains. Another rare condition is overlapping instructions. Under certain circumstances, compiler toolchains generate instructions that can be executed differently when branched into the middle of the instruction. Consider the following example: 0000: B8 00 03 C1 BB mov eax, 0xBBC10300 0005: B9 00 00 00 05 mov ecx, 0x05000000 000A: 03 C1 add eax, ecx 000C: EB F4 jmp $-10 000E: 03 C3 add eax, ebx 0010: C3 ret The JMP instruction will land on the third byte of the five-byte MOV instruction at address 0000. It will create a completely new instruction stream with a new alignment. This situation is very hard to detect without some code emulation. Another thing to consider is code with data inside. This is also a very rare condition, but in certain circumstances, code can contain strings of data. The most common scenario for such a condition is string operations in shellcodes. It is very hard to differentiate between code and data when there are no structured file formats or code sections. Under such circumstances, our de-optimizer tool may treat data as code and corrupt it by trying to apply transforms; but this can be avoided to some extent during disassembly. Instead of using linear sweep[14] disassembly, control flow tracing with a depth-first search[14] approach can be used to skip data bytes inside the code. --[ 8 - Conclusion In this article, we have underlined real-life evasion challenges commonly encountered by security professionals, and introduced several alternative ways for solving these challenges via de-optimizing individual X86 instructions. The known limitations of these methods are proven not to be a critical obstacle to the objective of this study. These de-optimization methods have been found to be highly effective for eliminating any pattern in machine code. A POC de-optimizer tool[3] has been developed during this study to test the effectiveness of these de-optimization methods. The tests are conducted by de-optimizing all the available Metasploit[15] shellcodes and checking the detection rates via multiple memory-based scanners and online analysis platforms. The test results show that using these de-optimization methods is proven to be highly effective against pattern-based detection while avoiding the use of self-modifying code (RWE memory use). Of course, as in every study on evasion, the real results will emerge over time after the release of this open-source POC tool. --[ 9 - References - [1] https://github.com/forrest-orr/moneta - [2] https://github.com/hasherezade/pe-sieve - [3] https://github.com/EgeBalci/deoptimizer - [4] https://github.com/hasherezade/pe-sieve/blob/603ea39612d7eb81545734c63dd1b4e7a36fd729/params_info/pe_sieve_params_info.cpp#L179 - [5] https://www.mandiant.com/resources/blog/shikata-ga-nai-encoder-still-going-strong - [6] https://github.com/EgeBalci/sgn - [7] https://github.com/zeroSteiner/crimson-forge - [8] https://github.com/weak1337/Alcatraz - [9] https://en.wikipedia.org/wiki/Optimizing_compiler - [10] https://monkbai.github.io/files/sp-22.pdf - [11] https://github.com/lifting-bits/mcsema - [12] https://docs.rs/iced-x86/latest/iced_x86/ - [13] https://www.strchr.com/x86_machine_code_statistics - [14] http://infoscience.epfl.ch/record/167546/files/thesis.pdf - [15] https://github.com/rapid7/metasploit-framework |=[ EOF ]=---------------------------------------------------------------=|