Solving a CTF Challenge with S2E
2017-08-08
Introduction
Symbolic execution tools such as Angr and Manticore have become increasingly popular for analyzing binaries in Capture the Flag (CTF) challenges.
In this blog post I will show that we can do the same with S2E, using it to solve a reverse engineering challenge from the 2016 Google CTF. This post walks through the process of writing an S2E plugin “from first principles” to solve this challenge.
For comparison, solutions for the challenge using Angr and Manticore are also available. If you want to cheat and skip straight to the end, the final plugin code is available here.
Getting started
As usual, I use s2e-env to organize my S2E environment. The challenge binary is a 64-bit ELF, so I will use S2E’s Debian x86_64 image. For those playing along at home, the instructions on how to use s2e-env can be found here.
Initial analysis
The challenge binary is available here. As other writeups have discussed, an initial static analysis reveals that the binary (a 64-bit ELF executable) accepts a single command line argument (the product activation code). If this argument is not provided the program will print a usage message and exit.
If the product activation code is provided, the argument is copied into the
global buffer dest
. The strncpy
operation (at address 0x4005B8
) copies 67
characters, so we can assume that the product activation code is 67 characters
long. We are also told that the product activation code starts with “CTF{”.
Further analysis reveals that the function at 0x400850
is called when an
incorrect product activation code is given. The correct product activation call
will result in the function at 0x400830
being called and the message “Thank
you — product activated!” printed. Therefore, when exploring the program we
want to focus on states that will lead to 0x400830
and avoid those that lead
to 0x400850
. With this information, we can develop an S2E plugin to solve the
challenge.
Creating the S2E project
We can use s2e-env’s new_project
command to create a new analysis project for
the challenge. We can also use our knowledge about the product activation code
to automatically add the command line argument to the bootstrap script. Create
the project using the following command:
s2e new_project --image debian-8.7.1-x86_64 --sym-args="1" unbreakable \
`python -c "print('CTF{%s' % ('x' * (67 - 4)))"`
The Python code will generate a 67 character string starting with “CTF{”.
--sym-args="1"
makes the first argument (i.e. the product activation code)
symbolic. Note that the actual contents of the string are not important.
The astute observer might also notice that the printed instructions contain a message regarding S2E’s FunctionModels plugin. I will discuss how we can use this plugin later.
Writing the S2E plugin
We will develop a custom plugin to solve the challenge. Note that this is probably the more complex and time-consuming approach — you could also use S2E’s Lua annotations to avoid having to write C++ code. However, I think that developing a plugin from scratch is a better way to understand how S2E works.
Let’s start with the standard S2E plugin template. Create
libs2eplugins/src/Plugins/GoogleCTFUnbreakable.h
:
#ifndef S2E_PLUGINS_GOOGLE_CTF_UNBREAKABLE_H
#define S2E_PLUGINS_GOOGLE_CTF_UNBREAKABLE_H
#include <s2e/CorePlugin.h>
#include <s2e/Plugin.h>
#include <s2e/S2E.h>
namespace s2e {
namespace plugins {
class GoogleCTFUnbreakable : public Plugin {
// Declares an S2E plugin
S2E_PLUGIN
public:
// Our constructor doesn't need to do anything
GoogleCTFUnbreakable(S2E *s2e) : Plugin(s2e) { }
// This will be called by S2E when registering and configuring the different plugins
void initialize();
private:
// We will add some more methods later
};
} // namespace plugins
} // namespace s2e
#endif
And in libs2eplugins/src/Plugins/GoogleCTFUnbreakable.cpp
:
#include <s2e/S2E.h>
#include "GoogleCTFUnbreakable.h"
namespace s2e {
namespace plugins {
S2E_DEFINE_PLUGIN(
GoogleCTFUnbreakable, // Plugin class
"Solve the Google CTF unbreakable product activation code", // Plugin description
"", // Unused
); // Plugin dependencies (currently there are none)
void GoogleCTFUnbreakable::initialize() {
}
} // namespace plugins
} // namespace s2e
This is a perfectly valid S2E plugin, it just doesn’t do anything useful. We need to tell the plugin what events we are interested in and how to react to them during runtime. CorePlugin.h gives an idea of what events are available. Events can also be generated by other plugins. For example, the OSMonitor plugin generates events for process creation, module loading/unloading, etc.
So what events are we interested in? From our initial analysis we know the
function addresses that indicate success or failure. We can therefore use the
onTranslateInstructionStart
event to notify us when the code at these
addresses is translated by QEMU. Declare an event handler in
GoogleCTFUnbreakable.h
:
private:
// The method signature corresponds to the onTranslateInstructionStart signal template in CorePlugin.h
void onTranslateInstruction(ExecutionSignal *signal,
S2EExecutionState *state, TranslationBlock *tb, uint64_t pc);
We also need to register our interest in this event, which we do in the
plugin’s initialize
method.
void GoogleCTFUnbreakable::initialize() {
s2e()->getCorePlugin()->onTranslateInstructionStart.connect(
sigc::mem_fun(*this, &GoogleCTFUnbreakable::onTranslateInstruction));
}
This event occurs when code is translated by QEMU. We need to register another
event listener to notify our plugin when the code is actually executed. We do
this using the ExecutionSignal
that is generated by the
onTranslateInstructionStart
event. In GoogleCTFUnbreakable.cpp
(don’t
forget to add the method declarations to GoogleCTFUnbreakable.h
):
// We found these addresses during our initial analysis in IDA Pro.
// Note that we assume non-PIE addresses
static const uint64_t SUCCESS_ADDRESS = 0x400724;
static const uint64_t FAILURE_ADDRESS = 0x400850;
void GoogleCTFUnbreakable::onTranslateInstruction(ExecutionSignal *signal,
S2EExecutionState *state, TranslationBlock *tb, uint64_t pc) {
if (pc == SUCCESS_ADDRESS) {
// Register a handler for when the "success" code is executed
signal->connect(sigc::mem_fun(*this, &GoogleCTFUnbreakable::onSuccess));
} else if (pc == FAILURE_ADDRESS) {
// Register a handler for when the "failure" code is executed
signal->connect(sigc::mem_fun(*this, &GoogleCTFUnbreakable::onFailure));
}
}
void GoogleCTFUnbreakable::onSuccess(S2EExecutionState *state, uint64_t pc) {
// We will return to this later
}
void GoogleCTFUnbreakable::onFailure(S2EExecutionState *state, uint64_t pc) {
// There is no reason to continue execution any further. So kill the state
s2e()->getExecutor()->terminateStateEarly(*state, "Invalid path");
}
Once execution reaches either the success or failure code, there is no reason to continue. We therefore kill the state to avoid wasting resources.
Note that we use absolute addresses for our success and failure code. For position independent code (PIE), you will need to register for module load events, record the load address of the module you are interested in (in this case unbreakable) and calculate the success and failure addresses via offsets at run time.
Unfortunately, there is a problem with our plugin. Remember that S2E performs
full-system emulation, meaning other processes may be executing at the same
time that we are analyzing unbreakable. Virtual addressing also means that
there may be other processes that have code at SUCCESS_ADDRESS
and
FAILURE_ADDRESS
. If this code is executed, our plugin’s onSuccess
and
onFailure
methods will execute, potentially interfering with our analysis.
We will avoid this problem with the
ProcessExecutionDetector
plugin.
The ProcessExecutionDetector
tracks the execution of processes in the
system. It is configurable so that only processes of interest are tracked. To
add this plugin as a dependency, in GoogleCTFUnbreakable.cpp
:
#include <s2e/Plugins/OSMonitors/Support/ProcessExecutionDetector.h>
S2E_DEFINE_PLUGIN(
GoogleCTFUnbreakable,
"Solve the Google CTF unbreakable product activation code",
"",
"ProcessExecutionDetector"
); // Plugin dependency
void GoogleCTFUnbreakable::initialize() {
m_procDetector = s2e()->getPlugin<ProcessExecutionDetector>();
// ...
}
And add the ProcessExecutionDetector
as a private member of the
GoogleCTFUnbreakable
class:
// In GoogleCTFUnbreakable.h
class GoogleCTFUnbreakable : public Plugin {
// ...
private:
ProcessExecutionDetector *m_procDetector;
// ...
};
Now we can filter out all other processes except the unbreakable process.
The following code should be added to the beginning of the
onTranslateInstruction
event handler:
void GoogleCTFUnbreakable::onTranslateInstruction(ExecutionSignal *signal,
S2EExecutionState *state, TranslationBlock *tb, uint64_t pc) {
// The processes to track are declared in the S2E LUA configuration file
if (!m_procDetector->isTracked(state)) {
return;
}
// ...
}
This takes care of our success and failure paths. What about the rest of our initial analysis? Remember that we were told that the activation code begins with “CTF{”. How can we encode this knowledge in our plugin?
Because we made the product activation string symbolic, we can use the
onSymbolicVariableCreation
event to wait for the product activation code to
become symbolic, and then constrain this variable with our existing knowledge.
In GoogleCTFUnbreakable.cpp
:
#include <klee/util/ExprTemplatees.h>
// Register our onSymbolicVariableCreation event handler
void GoogleCTFUnbreakable::initialize() {
s2e()->getCorePlugin()->onSymbolicVariableCreation.connect(
sigc::mem_fun(*this, &GoogleCTFUnbreakable::onSymbolicVariableCreation));
// ...
}
void GoogleCTFUnbreakable::onSymbolicVariableCreation(S2EExecutionState *state,
const std::string &name, const std::vector<klee::ref<klee::Expr>> &expr,
const klee::MemoryObject *mo, const klee::Array *array) {
// This check is not strictly required, because we only have one symbolic
// variable in the analysis.
//
// The first program argument made symbolic with the S2E_SYM_ARGS
// environment variable will have the name "arg1".
if (name != "arg1") {
return;
}
// We know that the product activation key starts with "CTF{". We encode
// this information as KLEE constraints
state->constraints.addConstraint(E_EQ(expr[0], E_CONST('C', klee::Expr::Int8)));
state->constraints.addConstraint(E_EQ(expr[1], E_CONST('T', klee::Expr::Int8)));
state->constraints.addConstraint(E_EQ(expr[2], E_CONST('F', klee::Expr::Int8)));
state->constraints.addConstraint(E_EQ(expr[3], E_CONST('{', klee::Expr::Int8)));
}
Encoding this information in the form of additional constraints helps to speed up symbolic execution. Now the constraint solver will not waste time generating solutions which we know are not viable (e.g. activation codes beginning with “ABCD”, “1337”, etc.).
We could have also encoded additional constraints for our solution. For example, we can assume that none of the remaining characters is a NULL terminator.
for (unsigned i = 4; i < expr.size(); ++i) {
state->constraints.addConstraint(E_NEQ(expr[i], E_CONST('\0', klee::Expr::Int8)));
}
An alternate set of constraints could be that the other remaining characters are printable ASCII characters.
for (unsigned i = 4; i < expr.size(); ++i) {
state->constraints.addConstraint(E_GE(expr[i], E_CONST(' ', klee::Expr::Int8)));
state->constraints.addConstraint(E_LE(expr[i], E_CONST('~', klee::Expr::Int8)));
}
In practice I found that these additional constraints had a significant impact on performance. I will discuss this later.
The final step is to complete the onSuccess
method. This involves solving the
constraints accumulated during symbolic execution and displaying the result.
#include <cctype>
#include <sstream>
void GoogleCTFUnbreakable::onSuccess(S2EExecutionState *state, uint64_t pc) {
// `results` is a vector containing pairs of strings and a vector of bytes.
// The string corresponds to the symbolic variable's name while the vector
// of bytes is the actual solution
std::vector<std::pair<std::string, std::vector<unsigned char>>> results;
// Invoke the constraint solver
if (!s2e()->getExecutor()->getSymbolicSolution(*state, results)) {
getWarningsStream(state) << "Unable to generate a solution for the product activation code\n";
exit(1);
}
// Since we only have a single symbolic variable, we will only have a single
// result. We then iterate over the bytes in this result to print the solution
std::stringstream ss;
for (auto c : results[0].second) {
if (!std::isprint(c)) {
break;
}
ss << (char) c;
}
getInfoStream(state) << "Product activation code = " << ss.str() << "\n";
// No need to continue running S2E — terminate
exit(0);
}
With the plugin complete, we need to ensure that it is compiled with the other
S2E plugins. To do this, add the following to libs2eplugins/src/CMakeLists.txt
:
src/Plugins/GoogleCTFUnbreakable.cpp
S2E will need to be rebuilt to ensure that our plugin is compiled and available for the analysis. Finally, we need to rebuild S2E with our plugin.
s2e build --clean-target libs2e
Capturing the flag
With the plugin complete, we can use it to solve the challenge. First, the
plugin must be enabled in our project’s S2E config file. By default,
s2e new_project
will generate a s2e-config.lua
file with a standard set of
plugins enabled and configured. However, most of them are not needed for this
challenge. At a minimum the following plugins must be enabled:
GoogleCTFUnbreakable
BaseInstructions
HostFiles
LinuxMonitor
ProcessExecutionDetector
Finally, we can run S2E with the launch-s2e.sh
script. After a short while
S2E will terminate and the following message will be displayed:
Product activation code = CTF{0The1Quick2Brown3Fox4Jumped5Over6The7Lazy8Fox9}
Performance
Let’s return to the discussion on (1) the FunctionModels
plugin and (2)
the performance impact caused by enforcing additional constraints on the
product activation code.
Using the FunctionModels plugin
The FunctionModels
plugin attempts to reduce path explosion.
s2e-env
analyzes the binary when the new_project
command is executed and
determines that the binary imports the strncpy
function, for which a model
exists.
strncpy
is typically implemented as a loop over the input string until either
a NULL terminator is found or n
characters have been copied. By analyzing the
binary in a disassembler we can see that this input string is the product
activation key, which we made symbolic. Looping over a symbolic string will
result in a greater number of states forked, because any of the 63 characters
following the “CTF{” prefix could contain a NULL terminator.
By enabling the FunctionModels
plugin, the strncpy
loop will be
replaced by a single symbolic expression, reducing the total number of states
forked. However, this comes with some performance trade-offs that I will
discuss later.
To enable the FunctionModels
plugin, add the following to s2e-config.lua
:
add_plugin("FunctionModels")
Measuring performance
With the flag under our belt, we can now explore the various performance
trade-offs that must be taken into account when we (1) apply various
constraints to the product activation code and (2) use the FunctionModels
plugin. Results are given in the tables below — one for execution time and one
for the number of states forked.
For the “explore all states” results I removed the call to exit
in the
onSuccess
method, even after capturing the flag (in practice this is
unnecessary). For reference I also give the Angr results (as quoted from their
source code) and Manticore results (from running it myself).
Execution times
Description | Terminate on solution (secs) | Explore all states (secs) |
---|---|---|
No additional constraints on the input string | 12 | 240 |
No NULL terminator within the input string | 12 | 12 |
All characters must be printable ASCII | 3,180 | 3,180 |
Using FunctionModels | 13 | 13 |
Angr | 4.5 | N/A |
Manticore | 60 | N/A |
No. of states forked
Description | Terminate on solution (no. states) | Explore all states (no. states) |
---|---|---|
No additional constraints on the input string | 111 | 1720 |
No NULL terminator within the input string | 49 | 49 |
All characters must be printable ASCII | 44 | 44 |
Using FunctionModels | 49 | 49 |
Angr | N/A | N/A |
Manticore | N/A | N/A |
The first set of results are our baseline. The only constraints that we impose
are that the product activation code begins with “CTF{”; the remaining 63
characters are unconstrained. Of the 111 states forked before the solution is
found, 62 of them occurred within strncpy
. These 62 states correspond to the
NULL terminator being located within one of the remaining 63 characters
(e.g. “CTF{\0”, “CTF{x\0”, “CTF{xxxxxxxx\0”, etc.). However, the depth-first
search (DFS) strategy means that none of these 62 states is scheduled for
execution before the solution is found. The total number of states quickly
explodes otherwise.
Now look at the performance after we constrain the remaining 63 characters of the product activation code to exclude the NULL terminator. The total number of states is reduced to 49. While this has negligible impact on execution time (when we terminate after the solution is found — again due to DFS), the total number of states no longer explodes.
Performance degrades significantly when we over-constrain the product activation code to only contain printable ASCII characters. While the total number of states is reduced, the bottleneck is simply transferred from state exploration to the constraint solver.
NOTE: To view the symbolic formulae during execution the
--verbose-fork-info
can be added to the kleeArgs
table in s2e-config.lua
.
Finally, we find that the FunctionModels
plugin reduces the state space to
the same levels as when we excluded NULL terminators from the product
activation code. However, like when we over-constrain, the formula that the
constraint solver must solve grows increasingly complex after the strncpy
model is applied and the constraint solver once again becomes the bottleneck.
I personally find this trade-off between state space and constraint formulae complexity quite interesting. AFAIK there are no automatic ways to determine this trade-off; it can only be found through experimentation.
Conclusion
Compared to the Angr and Manticore solutions, solving this challenge with S2E may seem overly complex. There are a variety of reasons for this: S2E runs a full-system emulator, so the user must handle the full software stack (OS kernel, libraries, drivers, etc.); S2E is written in C++; and S2E is built on top of many different tools and frameworks (KLEE, QEMU, LLVM, etc.), each with their own APIs.
Ultimately it is a matter of selecting the correct tool for the job. For CTF-style challenges, where the program is largely self-contained and has very little interaction with the rest of the system (e.g. the OS, libraries such as libc, etc.), using full-system emulation is probably overkill (however, as I’ve hopefully demonstrated, it can be done!). However for more complex software (e.g. device drivers, software with more complex interaction with the system, etc.), S2E may be the more suitable choice.