Normal view

There are new articles available, click to refresh the page.
Before yesterdayLayle

Instrumenting binaries using revng and LLVM

By: Layle
23 August 2021 at 08:59
Instrumenting binaries using revng and LLVM

One of the first things I ever wanted to implement was an import hooking library that placed the hooks by rewriting the calls statically instead of hooking the functions in-memory. To implement this I ended up using revng. We’ll be exploring the implementation of a similar example to show how you can instrument your own ELF binaries using revng and LLVM. You’ll need a working LLVM development environment and workspace. If you want to set it up using CMake check out this guide.

What is revng?

I think the revng repository explains it very well:

revng is a static binary translator. Given a input ELF binary for one of the supported architectures (currently i386, x86-64, MIPS, ARM, AArch64 and s390x) it will analyze it and emit an equivalent LLVM IR. To do so, revng employs the QEMU intermediate representation (a series of TCG instructions) and then translates them to LLVM IR.

What are we gonna do?

To keep it simple, we’ll be developing a tool that finds a call to dlsym, injects another call to printf printing out the string pointer passed to dlsym. I had this idea during a CTF where the binary wouldn’t allow me to debug the process (debugging it would break a crucial race condition). The task was to figure out what the arguments were and what functions were being called. I ended up using the LD_PRELOAD trick but I figured why not solve it differently :)
You’ll need a dummy binary. Compile the following source code to follow along:

#include <iostream>
#include <dlfcn.h>

typedef uint32_t random_function_t(const char*);

const char* g_encrypted_fn = "qtur";
const char* g_encrypted_str = "udru";

// xor string with key 0x1
char* decrypt(const char* encrypted, size_t encrypted_size) {
    char* ptr = (char*)malloc(encrypted_size + 1);
    
    for (int i = 0; i < encrypted_size; ++i) {
        ptr[i] = encrypted[i] ^ 1;
    }
    ptr[encrypted_size] = '\0';
    
    return ptr;
}

int main() {
    puts("-- test dlsym --");

    auto fn_name = decrypt(g_encrypted_fn, 4);
    auto fn_ptr = (random_function_t*)dlsym((void*)-1, fn_name);

    auto str = decrypt(g_encrypted_str, 4);
    fn_ptr(str);

    return 0;
}

Compile it using g++ dummy.cpp -ldl -O0 -o dummy.

Setting up revng

First things first, we need the dependencies, or the setup is gonna kneecap us later on:

sudo apt install python3-pip git git-lfs graphviz graphviz-dev

We’ll have to work with orchestra, which is essentially a build system for various revng tools and dependencies. We’ll be using orchestra in all future posts, so make sure you use this installation method.

# install orchestra
pip3 install --user --force-reinstall https://github.com/revng/revng-orchestra/archive/master.zip
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or use ~/.zshrc, whichever you use
source ~/.bashrc
cd ~
git clone https://github.com/revng/orchestra

# update orchestra components
cd orchestra
orc update
orc components

# install orchestra dependencies
sudo ./.orchestra/ci/install-dependencies.sh

# configure revng cmake
orchestra clone revng
orc configure -b revng

# drop into orchestra shell to build revng
orc shell -c revng

# build revng
ninja

# make revng available as command
orc install -b revng

revng should now be built and available as a command (revng) as long as you’re in the orchestra shell. You can drop back into it by executing orc shell -c revng in orchestra’s root folder.

Lifting weights binaries 2.0

revng actually executes a series of different binaries. The steps are:

  1. Lift the program to LLVM
  2. Link QEMU helpers to emulate some complex instructions such as floating points
  3. Compile LLVM IR to object code
  4. Link the object code. If you add functions from other libraries while processing the IR, make sure to add them to the command (we won’t be doing this)
  5. Merge linked code with a dynamic binary to fix up various things, resulting in a functional executable

To get a list of (almost) all steps executed, you can run revng --verbose translate ./dummy. We can make two shell scripts out of the output. Note that $1 would originally be dummy.translated.ll:

/home/layle/orchestra/root/bin/revng-lift \
  -g \
  ll \
  dummy \
  dummy.translated.ll
lift.sh
/home/layle/orchestra/root/bin/llvm-link \
  -S \
  $1 \
  ../../../../../../home/layle/orchestra/root/share/revng/support-x86_64-normal.ll \
  -o \
  $1.linked.ll

/home/layle/orchestra/root/bin/llc \
  -O0 \
  $1.linked.ll \
  -o \
  $1.linked.ll.o \
  -disable-machine-licm \
  -filetype=obj

/home/layle/orchestra/root/link-only/bin/c++ \
  ./$1.linked.ll.o \
  -lz \
  -lm \
  -lrt \
  -lpthread \
  -L \
  ./ \
  -o \
  ./dummy.translated \
  -fno-pie \
  -no-pie \
  -Wl,-z,max-page-size=4096 \
  -Wl,--section-start=.o_r_0x400000=0x400000 \
  -Wl,--section-start=.o_rx_0x401000=0x401000 \
  -Wl,--section-start=.o_r_0x402000=0x402000 \
  -Wl,--section-start=.o_rw_0x403d68=0x403d68 \
  -fuse-ld=bfd \
  -Wl,--section-start=.elfheaderhelper=0x3fffff \
  -Wl,-Ttext-segment=0x405000 \
  -Wl,--no-as-needed \
  -ldl \
  -lstdc++ \
  -lc \
  -Wl,--as-needed

# this step is actually not shown but it's needed
cp ./dummy.translated ./dummy.translated.tmp

/home/layle/orchestra/root/bin/revng \
  merge-dynamic \
  ./dummy.translated.tmp \
  ./dummy \
  ./dummy.translated
recompile.sh

lift.sh will give us dummy.translated.ll which is our lifted LLVM IR. We’ll be operating on this file.

Examining the LLVM IR

Let’s look at the code where puts is used:

bb.main:                                          ; preds = %dispatcher.entry_epoch_0_address_space_0_type_Code_x86_64
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198977 }, i64 4, i32 1, i8* null)
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198981 }, i64 1, i32 0, i8* null)
  %225 = load i64, i64* @rbp
  %226 = load i64, i64* @rsp
  %227 = add i64 %226, -8
  %228 = inttoptr i64 %227 to i64*
  store i64 %225, i64* %228
  store i64 %227, i64* @rsp
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198982 }, i64 3, i32 0, i8* null)
  %229 = load i64, i64* @rsp
  store i64 %229, i64* @rbp
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198985 }, i64 4, i32 0, i8* null)
  %230 = load i64, i64* @rsp
  %231 = add i64 %230, -32
  store i64 %231, i64* @rsp
  store i64 32, i64* @cc_src
  store i64 %231, i64* @cc_dst
  store i32 17, i32* @cc_op
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198989 }, i64 7, i32 0, i8* null)
  store i64 4202511, i64* @rdi
  call void (%struct.PlainMetaAddress, i64, i32, i8*, ...) @newpc(%struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4198996 }, i64 5, i32 0, i8* null)
  %232 = load i64, i64* @rsp
  %233 = add i64 %232, -8
  %234 = inttoptr i64 %233 to i64*
  store i64 4199001, i64* %234
  store i64 %233, i64* @rsp
  store i64 4198592, i64* @pc
  call void @function_call(i8* blockaddress(@root, %bb.0x4010c0), i8* blockaddress(@root, %bb.main.0x18), %struct.PlainMetaAddress { i32 0, i16 0, i16 4, i64 4199001 }, i64* null, i8* null)
  br label %bb.0x4010c0

revng has a concept of denoting the presence of an original function through a marker (function_call). If we look closer, we can also notice that there are symbols called as the x64 register names: @rdi, @rsp, etc. These are global variables which revng uses to emulate the original CPU state. In revng jargon we call these “CSV” (CPU State Variables).

store i64 4202511, i64* @rdi

If we look even closer, we notice that 4202511 is being stored into @rdi. That decimal is the same as 0x40200f in hexadecimal which is the address our -- test dlsym -- is located in. As a first step, we need to look up libc’s dlsym function prototype: void *dlsym(void *handle, const char *symbol);. We now know that symbol is represented by the x64 register rsi as it’s the 2nd argument. We’ll need this to define the function using LLVM. We are now able to form an idea of what we can do:

  1. Find all references to function_call
  2. Walk all instructions upwards for each reference finding the store instruction pointing to @rsi
  3. Inject a printf call that prints @rsi (second argument in calling convention) before it executes function_call but after executing the store to @rsi

This way, it will eventually print out the string passed into dlsym.

Automation using LLVM and C++

With an idea in mind we can start implementing it. We’ll have to be able to parse and dump LLVM IR files:

#include <iostream>
#include <fstream>
#include <utility>

#include <llvm/IR/Module.h>
#include <llvm/IR/PassManager.h>
#include <llvm/IR/Verifier.h>
#include <llvm/IR/LLVMContext.h>
#include <llvm/IR/Instructions.h>
#include <llvm/IR/IRBuilder.h>
#include <llvm/IRReader/IRReader.h>
#include <llvm/Support/SourceMgr.h>

using llvm::LLVMContext;
using llvm::SMDiagnostic;
using llvm::Module;
using llvm::IRBuilder;
using llvm::CallInst;
using llvm::StoreInst;
using llvm::BranchInst;
using llvm::Instruction;
using llvm::FunctionType;
using llvm::ConstantInt;
using llvm::Type;
using llvm::ArrayRef;

void parse(const char* path, std::unique_ptr<Module>& program, LLVMContext& ctx)
{
    SMDiagnostic error;
    
    program = llvm::parseIRFile(path, error, ctx);
    if (!program)
    {
        printf("Failed to parse IR file\n");
        error.print(path, llvm::errs());

        exit(-1);
    }
}

void dump(const char* path, std::unique_ptr<Module>& program)
{
    std::string ir;
    llvm::raw_string_ostream stream(ir);
    program->print(stream, nullptr);

    std::ofstream output(path);
    output << ir;
    output.close();
}

void process(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    // TODO: Process IR
}

int main(int argc, char* argv[])
{
    LLVMContext context;
    std::unique_ptr<Module> program = nullptr;
    parse(argv[1], program, context);

    printf("Loaded IR: %s\n", program->getModuleIdentifier().data());

    IRBuilder builder(context);
    process(program, builder);

    // 0 => generated IR is valid
    printf("Verification: %d\n", llvm::verifyModule(*program, &llvm::dbgs()));
    dump(argv[2], program);
 
    return 0;
}

It’s time to get to the fun part. The very first thing we are going to do is getting a reference to function_call. Once we have that, we are going to fetch all references (function_call->users() in LLVM).

const auto function_call = program->getFunction("function_call");

for (const auto& user : function_call->users())
{
    // make sure the reference is actually a call instruction
    if (!llvm::isa<CallInst>(user))
        continue;

    auto call_instruction = llvm::cast<CallInst>(user);
}

We are now looping through all references dismissing all non-call instructions. Our next step is getting a reference to the variable @rsi. In the same basic block, there must be at least one store instruction that writes to @rsi. This may not always be the case for all references but it must be the case where function_call actually ends up calling dlsym. Our next task is to find that instruction for each call to function_call. For this, we implement another function that returns the most recent instruction that stores into @rsi.

Instruction* find_store(Instruction* start, const char* target)
{
    auto previous_instruction = start->getPrevNode();

    while (previous_instruction != nullptr)
    {
        // we only want to check store instructions
        if (llvm::isa<StoreInst>(previous_instruction))
        {
            const auto store_instruction = llvm::cast<StoreInst>(previous_instruction);
            const auto target_operand = store_instruction->getOperand(1);
            const auto operand_name = target_operand->getName().data();

            // make sure the operand (register) to be written matches our target
            if (strcmp(operand_name, target) == 0)
                return previous_instruction;
        }

        previous_instruction = previous_instruction->getPrevNode();
    }

    return nullptr;
}

Now that we are able to find the store instructions, it’s time to create our printf declaration. We’ll be using the format dlsym => %p\n to print our findings at runtime.

void create_printf(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    // uint64_t printf(char*, uint64_t);
    std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo(), builder.getInt64Ty() };
    auto function_type = FunctionType::get(builder.getInt64Ty(), args, false);

    program->getOrInsertFunction("printf", function_type);
}

It’s finally time to inject calls to printf into our IR. To do this, we’re going to iterate through all function_call references, get the most recent @rsi store instruction and then emit the printf call immediately after the store instruction. To successfully emit our printf call we also have to generate our format string. To do this we have to keep in mind that @rsi points to a global storage of type i64. This means that we have to load the literal value from the pointer first. To do this we can use builder.CreateLoad(rsi).

void process(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    const auto function_call = program->getFunction("function_call");
    const auto fmt_str = builder.CreateGlobalStringPtr("dlsym => %p\n", "dlsym_fmt", 0, program.get());
    const auto print = program->getFunction("printf");

    for (const auto& user : function_call->users())
    {
        // make sure the reference is actually a call instruction
        if (!llvm::isa<CallInst>(user))
            continue;

        const auto call_instruction = llvm::cast<CallInst>(user);
        const auto store_instruction = find_store(call_instruction, "rsi");
        if (store_instruction == nullptr)
            continue;

        const auto rsi = store_instruction->getOperand(1);

        // we want to emit instructions after the store instruction
        builder.SetInsertPoint(store_instruction->getNextNode());
        const auto loaded = builder.CreateLoad(rsi);
        builder.CreateCall(print, { fmt_str, loaded });
    }
}

Now that we have written an instrumentation utility that prints out the pointer address passed into dlsym through rsi (2nd argument), we can finally run it on the translated IR and then recompile it back to a functioning ELF executable. To do this, execute the following commands:

# in orchestra shell
bash lift.sh
dlsym_hook dummy.translated.ll dummy.translated.processed.ll
bash recompile.sh dummy.translated.processed.ll

Originally the binary would print the following text:

$ ./dummy
-- test dlsym --
test

Let’s see what the instrumented version outputs:

$ ./dummy.translated
dlsym => 0x1
dlsym => 0x4165d4c8
dlsym => 0x4165d4c8
dlsym => 0xffff
dlsym => 0x404021
-- test dlsym --
dlsym => 0x4
dlsym => 0x1d94dc0
dlsym => 0x4
test

This looks great! Note that not all of those are arguments passed into dlsym. We are intercepting all function_calls. Fun fact: The 0x4 is actually the size passed into decrypt! Either way, we can confidently assume that 0x1d94dc0 points to the decrypted string which is the name of a libc function to be loaded at runtime. To verify this, let’s check out what gdb has to say about it. Execute b printf to set a breakpoint on printf and continue using the command c until you see something along the lines of:

gef➤  c
Continuing.
dlsym => 0x4a2dc0

Make sure that this string was printed after -- test dlsym --. Also, note that the exact pointer may vary. You should be able to see the following string behind the pointer:

Instrumenting binaries using revng and LLVM

At this point, we figured out how to instrument the LLVM IR to dump “decrypted” contents from memory without having to debug the binary at all.

Our code so far looks like this:

#include <iostream>
#include <fstream>
#include <utility>

#include <llvm/IR/Module.h>
#include <llvm/IR/PassManager.h>
#include <llvm/IR/Verifier.h>
#include <llvm/IR/LLVMContext.h>
#include <llvm/IR/Instructions.h>
#include <llvm/IR/IRBuilder.h>
#include <llvm/IRReader/IRReader.h>
#include <llvm/Support/SourceMgr.h>

using llvm::LLVMContext;
using llvm::SMDiagnostic;
using llvm::Module;
using llvm::IRBuilder;
using llvm::CallInst;
using llvm::StoreInst;
using llvm::BranchInst;
using llvm::Instruction;
using llvm::FunctionType;
using llvm::ConstantInt;
using llvm::Type;
using llvm::ArrayRef;
using llvm::ConstantDataArray;

void parse(const char* path, std::unique_ptr<Module>& program, LLVMContext& ctx)
{
    SMDiagnostic error;
    
    program = llvm::parseIRFile(path, error, ctx);
    if (!program)
    {
        printf("Failed to parse IR file\n");
        error.print(path, llvm::errs());

        exit(-1);
    }
}

void dump(const char* path, std::unique_ptr<Module>& program)
{
    std::string ir;
    llvm::raw_string_ostream stream(ir);
    program->print(stream, nullptr);

    std::ofstream output(path);
    output << ir;
    output.close();
}

Instruction* find_store(Instruction* start, const char* target)
{
    auto previous_instruction = start->getPrevNode();

    while (previous_instruction != nullptr)
    {
        // we only want to check store instructions
        if (llvm::isa<StoreInst>(previous_instruction))
        {
            const auto store_instruction = llvm::cast<StoreInst>(previous_instruction);
            const auto target_operand = store_instruction->getOperand(1);
            const auto operand_name = target_operand->getName().data();

            // make sure the operand (register) to be written matches our target
            if (strcmp(operand_name, target) == 0)
                return previous_instruction;
        }

        previous_instruction = previous_instruction->getPrevNode();
    }

    return nullptr;
}

void process(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    const auto function_call = program->getFunction("function_call");
    const auto fmt_str = builder.CreateGlobalStringPtr("dlsym => %p\n", "dlsym_fmt", 0, program.get());
    const auto print = program->getFunction("printf");

	for (const auto& user : function_call->users())
    {
        // make sure the reference is actually a call instruction
        if (!llvm::isa<CallInst>(user))
            continue;

        const auto call_instruction = llvm::cast<CallInst>(user);
        const auto store_instruction = find_store(call_instruction, "rsi");
        if (store_instruction == nullptr)
            continue;

        const auto rsi = store_instruction->getOperand(1);

        // we want to emit instructions after the store instruction
        builder.SetInsertPoint(store_instruction->getNextNode());
        const auto loaded = builder.CreateLoad(rsi);
        builder.CreateCall(print, { fmt_str, loaded });
    }
}

void create_printf(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo(), builder.getInt64Ty() };
    auto function_type = FunctionType::get(builder.getInt64Ty(), args, false);

    program->getOrInsertFunction("printf", function_type);
}

int main(int argc, char* argv[])
{
    LLVMContext context;
    std::unique_ptr<Module> program = nullptr;
    parse(argv[1], program, context);

    printf("Loaded IR: %s\n", program->getModuleIdentifier().data());

    IRBuilder builder(context);

    create_printf(program, builder);
    process(program, builder);

    printf("Verification: %d\n", llvm::verifyModule(*program, &llvm::dbgs()));
    dump(argv[2], program);
 
    return 0;
}

Going the extra mile

Let’s go the extra mile and instrument the binary so that we can output the string at runtime, not just the pointer!
To do this, we have to implement a function that checks the provided pointer and makes sure it’s actually pointing to valid memory. Then, if the check passes, we can print the content of the pointer. To do this, we are going to introduce a new function called print_checked which we are going to implement in C.
To get started let’s create a new function declaration for LLVM first:

void create_print_checked(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    // void print_checked(char*);
    std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo() };
    auto function_type = FunctionType::get(builder.getVoidTy(), args, false);

    program->getOrInsertFunction("print_checked", function_type);
}

The next step is to create a call to print_checked. As revng emulates x64 registers through global LLVM variables the storage type of rsi is i64. In order to pass LLVMs type checks for print_checked we have to cast our literal value stored in @rsi to a i8*. To do this we insert the following code into our process function:

// emit a call to an external checked printf
const auto ptr_type = Type::getIntNPtrTy(program->getContext(), 8);
const auto ptr = builder.CreateCast(Instruction::CastOps::IntToPtr, loaded, ptr_type);
builder.CreateCall(print_checked, { ptr });

Our code should now look like this:

#include <iostream>
#include <fstream>
#include <utility>

#include <llvm/IR/Module.h>
#include <llvm/IR/PassManager.h>
#include <llvm/IR/Verifier.h>
#include <llvm/IR/LLVMContext.h>
#include <llvm/IR/Instructions.h>
#include <llvm/IR/IRBuilder.h>
#include <llvm/IRReader/IRReader.h>
#include <llvm/Support/SourceMgr.h>

using llvm::LLVMContext;
using llvm::SMDiagnostic;
using llvm::Module;
using llvm::IRBuilder;
using llvm::CallInst;
using llvm::StoreInst;
using llvm::BranchInst;
using llvm::Instruction;
using llvm::FunctionType;
using llvm::ConstantInt;
using llvm::Type;
using llvm::ArrayRef;
using llvm::ConstantDataArray;

void parse(const char* path, std::unique_ptr<Module>& program, LLVMContext& ctx)
{
    SMDiagnostic error;
    
    program = llvm::parseIRFile(path, error, ctx);
    if (!program)
    {
        printf("Failed to parse IR file\n");
        error.print(path, llvm::errs());

        exit(-1);
    }
}

void dump(const char* path, std::unique_ptr<Module>& program)
{
    std::string ir;
    llvm::raw_string_ostream stream(ir);
    program->print(stream, nullptr);

    std::ofstream output(path);
    output << ir;
    output.close();
}

Instruction* find_store(Instruction* start, const char* target)
{
    auto previous_instruction = start->getPrevNode();

    while (previous_instruction != nullptr)
    {
        // we only want to check store instructions
        if (llvm::isa<StoreInst>(previous_instruction))
        {
            const auto store_instruction = llvm::cast<StoreInst>(previous_instruction);
            const auto target_operand = store_instruction->getOperand(1);
            const auto operand_name = target_operand->getName().data();

            // make sure the operand (register) to be written matches our target
            if (strcmp(operand_name, target) == 0)
                return previous_instruction;
        }

        previous_instruction = previous_instruction->getPrevNode();
    }

    return nullptr;
}

void process(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    const auto function_call = program->getFunction("function_call");
    const auto fmt_str = builder.CreateGlobalStringPtr("dlsym => %p\n", "dlsym_fmt", 0, program.get());
    const auto print = program->getFunction("printf");
    const auto print_checked = program->getFunction("print_checked");

	for (const auto& user : function_call->users())
    {
        // make sure the reference is actually a call instruction
        if (!llvm::isa<CallInst>(user))
            continue;

        const auto call_instruction = llvm::cast<CallInst>(user);
        const auto store_instruction = find_store(call_instruction, "rsi");
        if (store_instruction == nullptr)
            continue;

        const auto rsi = store_instruction->getOperand(1);

        // we want to emit instructions after the store instruction
        builder.SetInsertPoint(store_instruction->getNextNode());
        const auto loaded = builder.CreateLoad(rsi);
        builder.CreateCall(print, { fmt_str, loaded });

        // emit a call to an external checked printf
        const auto ptr_type = Type::getIntNPtrTy(program->getContext(), 8);
        const auto ptr = builder.CreateCast(Instruction::CastOps::IntToPtr, loaded, ptr_type);
        builder.CreateCall(print_checked, { ptr });
    }
}

void create_printf(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo(), builder.getInt64Ty() };
    auto function_type = FunctionType::get(builder.getInt64Ty(), args, false);

    program->getOrInsertFunction("printf", function_type);
}

void create_print_checked(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
    std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo() };
    auto function_type = FunctionType::get(builder.getVoidTy(), args, false);

    program->getOrInsertFunction("print_checked", function_type);
}

int main(int argc, char* argv[])
{
    LLVMContext context;
    std::unique_ptr<Module> program = nullptr;
    parse(argv[1], program, context);

    printf("Loaded IR: %s\n", program->getModuleIdentifier().data());

    IRBuilder builder(context);

    create_printf(program, builder);
    create_print_checked(program, builder);
    process(program, builder);

    printf("Verification: %d\n", llvm::verifyModule(*program, &llvm::dbgs()));
    dump(argv[2], program);
 
    return 0;
}

As we are compiling with an undefined function we have to seperately generate LLVM IR for it. First things first, let’s create the C code in a file called utils.cpp:

#include <cstdio>
#include <cstdint>

extern "C" void print_checked(char* ptr) {
    if ((uint64_t)ptr > 0xffff) {
        printf("dlsym(???, \"%s\");\n", ptr);
    }
}

It’s a naive check but it will do the job. Now we have to generate LLVM IR from utils.cpp. clang can actually do this. In recompile.sh add a call to clang and then insert the newly created LLVM IR file (utils.ll) into the llvm-link command. Your recompile.sh file should now look like this (unchanged parts ommited):

clang -S -emit-llvm utils.cpp

/home/layle/orchestra/root/bin/llvm-link \
  -S \
  $1 \
  utils.ll \
  ../../../../../../home/layle/orchestra/root/share/revng/support-x86_64-normal.ll \
  -o \
  $1.linked.ll

# ...

Let’s process and then recompile our LLVM IR files again:

# in orchestra shell
bash lift.sh
dlsym_hook dummy.translated.ll dummy.translated.processed.ll
bash recompile.sh dummy.translated.processed.ll

Let’s examine the output:

$ ./dummy.translated
dlsym => 0x1
dlsym => 0x42f8c4c8
dlsym(???, ���B);
dlsym => 0x42f8c4c8
dlsym(???, ���B);
dlsym => 0xffff
dlsym => 0x404021
dlsym(???, );
-- test dlsym --
dlsym => 0x4
dlsym => 0x1c78dc0
dlsym(???, "puts");
dlsym => 0x4
test

In case you can’t spot it in all the noise: dlsym(???, "puts");!

We finally did it! We instrumented our executable in a way that it now dumps strings passed to dlsym at runtime without having to place any in-memory hooks. This also means that most anti-debugging tricks are rendered useless.
You can find the entire project including the example code on my GitHub.

Grazie

I’d like to thank @antoniofrighez, @fcremo and @alerevng for helping me solve all the roadblocks I encountered while using LLVM and revng. Also huge thank you to the entire @_revng team for creating such a wonderful tool.

LLVM with CMake: It's easier than you'd think!

By: Layle
23 August 2021 at 00:00
LLVM with CMake: It's easier than you'd think!

Have you ever wondered how you can set up LLVM using CMake? It’s actually easier than you might think. All thanks to an amazing fork of a project called hunter. You may be wondering: “What’s hunter?”. It’s a very easy to use C++ package manager that you can integrate directly into your CMake projects. We’ll be using a fork that is maintained by my friend @mrexodia. The fork contains definitions for the LLVM project sources.

A few words

We’ll be using Windows in this example, however, in theory this should also work on any other platform.

Setting up your project

We’ll be using a very basic “Hello World!” example that uses LLVM. In essence, we’ll be outputting LLVM IR that hosts a main function which will call puts("Hello World!\n"). I’m using a mixture between WSL2 and Git Bash for some commands. That means, I’ll be using some Linux commands to create files, etc. You are free to use the same set up as I use or use Windows' equivalents.

Let’s create a new project:

mkdir llvm_hello_world
cd llvm_hello_world
touch CMakeLists.txt
mkdir src
touch .\src\main.cpp
mkdir CMake
touch .\CMake\LLVM.cmake
touch .\CMake\HunterPackages.cmake

Your structure should now look like this:

layle@ubuntu:~/Projects/llvm_hello_world$ tree
.
├── CMake
│   ├── HunterPackages.cmake
│   └── LLVM.cmake
├── CMakeLists.txt
└── src
    └── main.cpp

First things first: HunterPackages.cmake. This file will contain the hunter information needed to pull the LLVM package and make it available to us. We’ll be using version 12.0.1 of LLVM as we’ll need this version anyways in future posts ;) Paste the following code into CMake/HunterPackages.cmake:

# HUNTER_URL is the URL to the latest source code archive on GitHub
# HUNTER_SHA1 is the hash of the downloaded archive

set(HUNTER_URL "https://github.com/LLVMParty/hunter/archive/e71f40b70219c81b955e8112dfbec66d4dba2d75.zip")
set(HUNTER_SHA1 "43D382102BE6A8CF218B79E0C33360EDA58FC4BA")

set(HUNTER_LLVM_VERSION 12.0.1)
set(HUNTER_LLVM_CMAKE_ARGS
        LLVM_ENABLE_CRASH_OVERRIDES=OFF
        LLVM_ENABLE_ASSERTIONS=ON
        LLVM_ENABLE_PROJECTS=clang;lld
        )
set(HUNTER_PACKAGES LLVM)

include(FetchContent)
message(STATUS "Fetching hunter...")
FetchContent_Declare(SetupHunter GIT_REPOSITORY https://github.com/cpp-pm/gate)
FetchContent_MakeAvailable(SetupHunter)

Now that we have those set up we can implement CMake/LLVM.cmake. Paste the following code:

# This is an INTERFACE target for LLVM, usage:
#   target_link_libraries(${PROJECT_NAME} <PRIVATE|PUBLIC|INTERFACE> LLVM)
# The include directories and compile definitions will be properly handled.

set(CMAKE_FOLDER_LLVM "${CMAKE_FOLDER}")
if(CMAKE_FOLDER)
    set(CMAKE_FOLDER "${CMAKE_FOLDER}/LLVM")
else()
    set(CMAKE_FOLDER "LLVM")
endif()

# Find LLVM
find_package(LLVM REQUIRED CONFIG)

message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}")
message(STATUS "Using LLVMConfig.cmake in: ${LLVM_DIR}")

# Split the definitions properly (https://weliveindetail.github.io/blog/post/2017/07/17/notes-setup.html)
separate_arguments(LLVM_DEFINITIONS)

# Some diagnostics (https://stackoverflow.com/a/17666004/1806760)
message(STATUS "LLVM libraries: ${LLVM_LIBRARIES}")
message(STATUS "LLVM includes: ${LLVM_INCLUDE_DIRS}")
message(STATUS "LLVM definitions: ${LLVM_DEFINITIONS}")
message(STATUS "LLVM tools: ${LLVM_TOOLS_BINARY_DIR}")

add_library(LLVM INTERFACE)
target_include_directories(LLVM SYSTEM INTERFACE ${LLVM_INCLUDE_DIRS})
target_link_libraries(LLVM INTERFACE ${LLVM_AVAILABLE_LIBS})
target_compile_definitions(LLVM INTERFACE ${LLVM_DEFINITIONS} -DNOMINMAX)

set(CMAKE_FOLDER "${CMAKE_FOLDER_LLVM}")
unset(CMAKE_FOLDER_LLVM)

Alright, now we should have LLVM fully exposed to our project. All we have to do now is build against it. To do this we have to hop over to our CMakeLists.txt and insert a fairly standard version of a CMakeLists file.

cmake_minimum_required(VERSION 3.19)

include(CMake/HunterPackages.cmake)

project(llvm_hello_world)

# Enable solution folder support
set_property(GLOBAL PROPERTY USE_FOLDERS ON)

# Append the CMake module search path so we can use our own modules
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/CMake)

# Require C++20
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# LLVM wrapper
include(CMake/LLVM.cmake)

# MSVC-specific options
if(MSVC)
    # This assumes the installed LLVM was built in Release mode
    set(CMAKE_C_FLAGS_RELWITHDEBINFO "/ZI /Od /Ob0 /DNDEBUG" CACHE STRING "" FORCE)
    set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "/ZI /Od /Ob0 /DNDEBUG" CACHE STRING "" FORCE)

    if(${LLVM_USE_CRT_RELEASE} STREQUAL "MD")
        set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreadedDLL)
    elseif(${LLVM_USE_CRT_RELEASE} STREQUAL "MT")
        set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded)
    else()
        message(FATAL_ERROR "Unsupported LLVM_USE_CRT_RELEASE=${LLVM_USE_CRT_RELEASE}")
    endif()
endif()

add_executable(${PROJECT_NAME} src/main.cpp)

# Link against LLVM
target_link_libraries(${PROJECT_NAME} PRIVATE LLVM)

# Set the plugin as the startup project
set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT ${PROJECT_NAME})

We should be all set now! To test our build system with an IDE execute the commands below. If you want to use VSCode: open the project root folder and configure the project using whatever kit you want. I personally like to use the “Visual Studio Community 2019 Release - amd64” kit most of the time. In this case, skip the commands once you’ve configured your project.

mkdir build
cd build
cmake ..

Keep in mind that this might take a while if it’s your first time configuring LLVM using hunter. Now that we have everything set up we can go ahead and create a simple example.

Hello world LLVM!

Let’s jump into our C++ source file main.cpp. We should first define a raw skeleton. That is, the includes, types we need and our main function:

#include <llvm/IR/Module.h>
#include <llvm/IR/PassManager.h>
#include <llvm/IR/IRBuilder.h>

#include <vector>
#include <memory>

using llvm::LLVMContext;
using llvm::IRBuilder;
using llvm::Module;
using llvm::FunctionType;
using llvm::Function;
using llvm::BasicBlock;
using llvm::Type;
using llvm::ArrayRef;

int main(int argc, char* argv[])
{
    return 0;
}

The first thing we have to do is create a Module which is the host of all of our definitions such as imports, exports, functions, basic blocks, etc. We also need a IRBuilder which is the object allowing us to interact with the instructions, basic blocks and types. Note that I’m providing a rough TLDR, for more information always consult the LLVM documentation.

LLVMContext context;
IRBuilder builder(context);
const auto module = std::make_unique<Module>("hello_llvm", context); // hello_llvm => module name

Before we can create basic blocks we have to create a function and it’s type. In LLVM we can use the classes FunctionType and Function. We have to define our main function first (the entrypoint to our program). A simple void main(); should suffice for that:

// builder.getVoidTy() => void main() {
const auto func_type = FunctionType::get(builder.getVoidTy(), false);
const auto main_func = Function::Create(func_type, Function::ExternalLinkage, "main", module.get());

Now that we have our main function set up we can finally create our first basic block.

// main_func being the parent of the basic block
const auto entry = BasicBlock::Create(context, "entrypoint", main_func);
builder.SetInsertPoint(entry); // set instruction insertion point to this basic block

You may be wondering how we are going to use an external function such as puts from within LLVM. It’s actually quite simple. All we have to do is create another function definition and a definition for the arguments. puts is defined as int puts(const char *s);, let’s implement this in LLVM:

// builder.getInt8Ty()->getPointerTo() => char*, a pointer to the null terminated string
const std::vector<Type*> puts_args = { builder.getInt8Ty()->getPointerTo() };
const ArrayRef puts_args_ref(puts_args);

// builder.getInt32Ty() => uint32_t, the return type
const auto puts_type = FunctionType::get(builder.getInt32Ty(), puts_args_ref, false);
const auto puts_func = module->getOrInsertFunction("puts", puts_type);

There’s only two more things to implement to get a working example: Creating a global string containing “Hello LLVM!” and inserting instructions into the basic block.

auto str = builder.CreateGlobalStringPtr("Hello LLVM!\n");

// equivalent to: puts("Hello LLVM!"); return;
builder.CreateCall(puts_func, { str });
builder.CreateRetVoid(); // in LLVM, blocks need a terminator

That’s it! Let’s have a look at the generated IR. To do this call module->dump(). Here you can find the full source code:

#include <llvm/IR/Module.h>
#include <llvm/IR/PassManager.h>
#include <llvm/IR/IRBuilder.h>

#include <vector>
#include <memory>

using llvm::LLVMContext;
using llvm::IRBuilder;
using llvm::Module;
using llvm::FunctionType;
using llvm::Function;
using llvm::BasicBlock;
using llvm::Type;
using llvm::ArrayRef;

int main(int argc, char* argv[])
{
    LLVMContext context;
    IRBuilder builder(context);
    const auto module = std::make_unique<Module>("hello_llvm", context);

    const auto func_type = FunctionType::get(builder.getVoidTy(), false);
    const auto main_func = Function::Create(func_type, Function::ExternalLinkage, "main", module.get());

    const auto entry = BasicBlock::Create(context, "entrypoint", main_func);
    builder.SetInsertPoint(entry);

    const std::vector<Type*> puts_args = { builder.getInt8Ty()->getPointerTo() };
    const ArrayRef puts_args_ref(puts_args);

    const auto puts_type = FunctionType::get(builder.getInt32Ty(), puts_args_ref, false);
    const auto puts_func = module->getOrInsertFunction("puts", puts_type);

    auto str = builder.CreateGlobalStringPtr("Hello LLVM!\n");

    builder.CreateCall(puts_func, { str });
    builder.CreateRetVoid();

    module->dump();

    return 0;
}

You should see something along the lines of:

; ModuleID = 'hello_llvm'
source_filename = "hello_llvm"

@0 = private unnamed_addr constant [13 x i8] c"Hello LLVM!\0A\00", align 1

define void @main() {
entrypoint:
  %0 = call i32 @puts(i8* getelementptr inbounds ([13 x i8], [13 x i8]* @0, i32 0, i32 0))
  ret void
}

declare i32 @puts(i8* %0)

If you are interested in generating an executable from the IR, save it to a file called hello_llvm.ll and execute the following commands:

$LLVM12_PATH = "C:\.hunter\_Base\Cellar\204034a1dbe9cf2995b07fc1f3542b939059d116\204034a\raw\bin"
& "$($LLVM12_PATH)\llvm-link.exe" .\hello_llvm.ll -o hello_llvm.bc
& "$($LLVM12_PATH)\llc.exe" -filetype=obj .\hello_llvm.bc
& "$($LLVM12_PATH)\clang.exe" .\hello_llvm.obj -o hello_llvm.exe
.\hello_llvm.exe
Hello LLVM!

Congrats! You’ve just set up LLVM using CMake for the first time and created your first functional “Hello World!” equivalent using LLVM IR :)

Lifting binaries to LLVM with McSema

By: Layle
25 July 2021 at 00:00
Lifting binaries to LLVM with McSema

Before embarking on my journey of lifting x64 binaries to LLVM by using revng and eventually my own tooling I worked with McSema which looked very promising. Unfortunately, using McSema wasn’t as straight forward as I had hoped and working with the lifted LLVM IR never really yielded sufficient results. This post will guide you through my set up and we’ll explore what worked and what didn’t (maybe it works for you!). We’ll be using Windows as host system as most of you have IDA Pro for Windows anyways ;) Along with Windows we’ll also be making use of WSL 2, so make sure you have that already set up! Alternatively, a Ubuntu 20.04 LTS VM works too.

Getting ready

Boot into your Ubuntu instance (may that be WSL 2 or another VM). Make sure you’re able to share files between host and guest.

We’ll need a few dependencies first:

sudo apt-get update
sudo apt-get upgrade

sudo apt-get install \
     git \
     curl \
     cmake \
     python3 python3-pip python3-virtualenv \
     wget \
     xz-utils pixz \
     clang \
     rpm \
     build-essential \
     gcc-multilib g++-multilib \
     libtinfo-dev \
     lsb-release \
     zip \
     zlib1g-dev \
     ccache \
     llvm

Now that we have the dependencies set up, we can execute the following commands (taken from the README) to pull McSema and build it:

# I used my home directory but feel free to place it wherever you want
cd ~

git clone --depth 1 --single-branch --branch master https://github.com/lifting-bits/remill.git
git clone --depth 1 --single-branch --branch master https://github.com/lifting-bits/mcsema.git

# Get a compatible anvill version
git clone --branch master https://github.com/lifting-bits/anvill.git
( cd anvill && git checkout -b release_bc3183b bc3183b )

export CC="$(which clang)"
export CXX="$(which clang++)"

# Download cxx-common, build Remill. 
./remill/scripts/build.sh --llvm-version 9 --download-dir ./
pushd remill-build
sudo cmake --build . --target install
popd

# Build and install Anvill
mkdir anvill-build
pushd anvill-build
# Set VCPKG_ROOT to whatever directory the remill script downloaded
cmake -DVCPKG_ROOT=$(pwd)/../vcpkg_ubuntu-20.04_llvm-9_amd64 ../anvill
sudo cmake --build . --target install
popd

# Build and install McSema
mkdir mcsema-build
pushd mcsema-build
cmake -DVCPKG_ROOT=$(pwd)/../vcpkg_ubuntu-20.04_llvm-9_amd64 ../mcsema
sudo cmake --build . --target install

pip install ../mcsema/tools

popd

Now that McSema is set up we can finally get to lifting binaries! I’ll be using /bin/cat with the MD5 7e9d213e404ad3bb82e4ebb2e1f2c1b3. Let’s hop over to our Windows host.

Lifting weights binaries

One of the first things we have to do is recovering a control flow graph. To do this, McSema actually comes with IDAPython scripts. To recover the control flow graph execute the following command in Powershell:

# Path to your totally legit IDA Pro installation
$IDA_ROOT = "D:\Reversing\Tools\IDA Pro 7.6\IDA Pro 7.6"
# Path to your cloned McSema repository
$MCSEMA_ROOT = "C:\Users\luca\Documents\Git\mcsema"
# Path to your executable 
$EXECUTABLE_TO_LIFT = "C:\Users\luca\Downloads\cat"
# Path to outputted control flow graph
$CFG_PATH = "C:\Users\luca\Downloads\cat.cfg"

& "$($IDA_ROOT)\ida64.exe" -S"$($MCSEMA_ROOT)\tools\mcsema_disass\ida7\get_cfg.py --output $($CFG_PATH) --log_file \\.\nul --arch amd64 --os linux --entrypoint main --pie-mode --rebase 535822336" $EXECUTABLE_TO_LIFT

The arguments should all be self explanatory. However, the argument --rebase may not. We need to specify the address to rebase to when we use --pie-mode (PIE binaries). This number can be any address, in this example I used 0x1ff00000 in decimal. More information here.

IDA Pro should pop up. Confirm the architecture and hit “OK”. Once IDA Pro finished recovering the control flow graph verify that you have it in the specified path.

We are now ready to lift the control flow graph to LLVM. To do that execute the following command in your console of choice (make sure you’re in either WSL 2 or in your VM):

# cd into the folder that contains cat.cfg
mcsema-lift-9.0 --cfg cat.cfg --output cat.bc --os linux --arch amd64 --explicit_args --merge_segments --name_lifted_sections

Alright, we now have the LLVM bitcode file. This is essentially the LLVM IR bitcode of the cat binary. Ideally we’d want to look at the LLVM IR in human readable format. To do that execute the following command:

llvm-dis cat.bc -o cat.ll

Congrats, you finally have lifted your binary to LLVM! Now let’s examine what happens if we try to recompile it back:

llvm-link cat.ll -o cat.recompiled.bc
# to figure out the libraries to link against use "ldd /bin/cat"
remill-clang-9 -o cat.recompiled cat.recompiled.bc -Wl,--section-start=.section_1ff00000=0x1ff00000

Alright, let’s give it a shot:

./cat.recompiled helloworld.txt
Segmentation fault (core dumped)

Well, that’s a bummer. I figured it’s a hit or miss situation. I tried McSema on some other binaries (mostly CTF challenges) and it seemed to work. However, as soon as I tried instrumenting the IR (by adding simple calls or primitive instructions) every binary started segfaulting again. This may be a mistake on my side, however, at this point I started using revng and ditched McSema entirely. We’ll cover more about that in my next article (with a hands on example!).

That being said: I hope you’ll find more luck with McSema!

RACEAC: Breaking Dead by Daylight's integrity checks

By: Layle
4 June 2021 at 00:00
RACEAC: Breaking Dead by Daylight's integrity checks

In an attempt to stop people from cheating by modifying game files, Dead by Daylight received an update that introduced integrity checks for the pak files/assets. In other words, things such as disabling models to get a better view and/or disabling certificate pinning for network interception were no longer possible. Unless…?

The bug

The bug is quite simple, I stumbled upon this behavior when I was analyzing how DbD loads their assets using Procmon and I noticed that EAC performs checks on the files, but the game itself reopens the file to read the actual content. I am not too familiar how the EAC SDK looks like but I’d assume that the SDK gives you the capability to get a handle to a file (and have it verify it’s integrity automatically) and you are supposed to reuse that handle as you read from the file.
In other words, we found a TOCTOU (Time Of Check, Time Of Use vulnerability)! The game verifies the assets' integrity, but what happens if we modify the assets right before the game reopens the file again, just seconds after finishing the integrity checks? Let’s find out…

The PoC

There’s no fancy tricks needed here, all we need is a bit of force when opening files. The PoC disables certificate pinning which allows us to sniff traffic using tools like Fiddler. The SSL settings are stored in the file pakchunk0-WindowsNoEditor.pak. Now, let’s peek at the code:

void detect_eac(std::wstring path)
{
	while (true)
	{
		auto pak = open_pak(path);
		if (pak == INVALID_HANDLE_VALUE) break;
		winapi::handle::close_handle(pak);
		winapi::process::sleep(10);
	}
}

void* race_eac(std::wstring path)
{
	while (true)
	{
		auto pak = open_pak(path);
		if (pak != INVALID_HANDLE_VALUE) return pak;
		winapi::process::sleep(10);
	}

	return nullptr;
}

Copy

We call detect_eac to make sure that the pak file is currently opened and therefore locked by EAC. This leads us to the next stage, race_eac. We try to open the pak file until it just works™. Once we get a valid handle the following pattern is replaced with NULL bytes:

const std::vector<char> pattern =
{
    0x2B, 0x50, 0x69, 0x6E, 0x6E, 0x65, 0x64, 0x50, 0x75, 0x62, 0x6C, 0x69,
    0x63, 0x4B, 0x65, 0x79, 0x73, 0x3D, 0x22, 0x73, 0x74, 0x65, 0x61, 0x6D,
    0x2E, 0x6C, 0x69, 0x76, 0x65, 0x2E, 0x62, 0x68, 0x76, 0x72, 0x64, 0x62,
    0x64, 0x2E, 0x63, 0x6F, 0x6D, 0x3A, 0x2B, 0x2B, 0x4D, 0x42, 0x67, 0x44,
    0x48, 0x35, 0x57, 0x47, 0x76, 0x4C, 0x39, 0x42, 0x63, 0x6E, 0x35, 0x42,
    0x65, 0x33, 0x30, 0x63, 0x52, 0x63, 0x4C, 0x30, 0x66, 0x35, 0x4F, 0x2B,
    0x4E, 0x79, 0x6F, 0x58, 0x75, 0x57, 0x74, 0x51, 0x64, 0x58, 0x31, 0x61,
    0x49, 0x3D, 0x3B, 0x45, 0x58, 0x72, 0x45, 0x65, 0x2F, 0x58, 0x58, 0x70,
    0x31, 0x6F, 0x34, 0x2F, 0x6E, 0x56, 0x6D, 0x63, 0x71, 0x43, 0x61, 0x47,
    0x2F, 0x42, 0x53, 0x67, 0x56, 0x52, 0x33, 0x4F, 0x7A, 0x68, 0x56, 0x55,
    0x47, 0x38, 0x2F, 0x58, 0x34, 0x6B, 0x52, 0x43, 0x43, 0x55, 0x3D, 0x22
};

Copy

This pattern translates to the following sequence of chars:

+PinnedPublicKeys="steam.live.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;EXrEe/XXp1o4/nVmcqCaG/BSgVR3OzhVUG8/X4kRCCU="

This is one of the public keys used for certificate pinning. Simply removing it from the pak file will disable certificate pinning.

Demo

The following demo shows a successful bypass, allowing us to dump network traffic. Tools for network analysis can be found on my other repo.

Last words

The PoC does not work with the current version of DbD but it seems like they disabled integrity checks altogether. That is, the code never breaks out of race_eac but restarting DbD without terminating the PoC actually makes it work again. I didn’t look at this in the past few months but if you know more, please let me know!

Breaking Dead by Daylight without process interaction

By: Layle
15 April 2020 at 00:00
Breaking Dead by Daylight without process interaction

This article is a mirror of the previous release posted on the secret club blog.

For the past few months I’ve been looking into a game called Dead by Daylight which is protected by EasyAntiCheat. This game is unique in a special way and we’ll be exploring why. All these methods are already to be found on various types of forums and nothing is per se ground breaking. This is a guide about how I approached these things while shedding light into the inner workings of the actual cheats. Do not use this to get an advantage in the game.

Introduction

What makes Dead by Daylight special? Dead by Daylight has a quite long record in terms of bugs and lazy coding. The game communicates with the game server using a REST API which is quite easy to reverse. As mentioned, the game is protected by EasyAntiCheat but in this case I tried to develop as many bypasses and cheats as possible without interacting with the game process at all.

Version 3.0.0

Version 3.0.0 is quite famous in the particular cheating scene. It was one of the last versions that had no SSL pinning embedded, therefore making it easy to reverse the API. However, currently version 3.6.2 is out meaning that version 3.0.0 is no longer available to download through the store. Luckily, this isn’t stopping us thanks to Steam’s depot system which allows us to download any version of the game as long as we know the game specific IDs which can be looked up on SteamDB. To do this we have to run Steam with the “-console” flag and then enter download_depot 381210 381211 9043651681125706667. Unfortunately, this isn’t working in the latest versions of Steam, but no worries, I’ll explain why. It turns out that the manifest file for this version is not available anymore. Luckily, Steam doesn’t really care about it as long as we just invert the check :)

Breaking Dead by Daylight without process interaction

You can find an automated patcher on GitHub which you can run while Steam is running. Once the files are downloaded we can copy them over to Dead by Daylights installation path and fire up a sniffer like mitmdump or Fiddler.

Sniffing

There’s 2 request of particular interest. The first one is a POST request which sends the current game version to the server to check whether there’s any:

POST https://latest.live.dbd.bhvronline.com/api/v1/auth/provider/steam/login?token=<token> HTTP/1.1
Host: latest.live.dbd.bhvronline.com
Accept: */*
Accept-Encoding: deflate, gzip
Content-Type: application/json
User-Agent: DeadByDaylight/++DeadByDaylight+Live-CL-134729 Windows/10.0.19587.1.256.64bit
Content-Length: 78

{"clientData":{"catalogId":"<ID>","consentId":"<ID>"}}

This is easily bypassed by intercepting the traffic and changing the JSON body with the latest version IDs:

{"clientData":{"catalogId":"3.6.0_281460live","consentId":"3.6.0_281460live"}}

The response of this request contains a cookie called bhvrSession which we will need to further authenticate to the server.

One of the other important requests is the following GET request:

GET https://latest.live.dbd.bhvronline.com/api/v1/utils/contentVersion/version HTTP/1.1
Host: latest.live.dbd.bhvronline.com
Accept: */*
Accept-Encoding: deflate, gzip
Content-Type: application/json
User-Agent: DeadByDaylight/++DeadByDaylight+Live-CL-134729 Windows/10.0.19587.1.256.64bit
Content-Length: 0

...

{"availableVersions":
{"3.3.0_240899live":"3.3.0_240899live-1572383573","3.3.0_241792live":"3.3.0_241792live-1572620532"
...

The request was stripped down to the most important part. The server returns all available versions that are allowed to log into the game server. This is easily bypassed by adding the identifiers for version 3.0.0 to the response body. Here’s a snippet from my script:

response["availableVersions"]["3.0.0.13"] = "3.0.0.13-1561474922"
response["availableVersions"]["3.0.0.16"] = "3.0.0.16-1562079672"
response["availableVersions"]["3.0.0.4"] = "3.0.0.4-1560778720"

Unfortunately, Dead by Daylight introduced further checks based on locally generated tokens which are not present in this version of the game. This didn’t stop other cheaters to just embed the keys from the latest version into their own script and sending the values manually.

Of course I won’t let you sit around without knowing how some of the cheats handle this which is why I reversed one of them:

public static string DecryptSettings(string cryptedString)
{
    string result;
    using (DESCryptoServiceProvider descryptoServiceProvider = new DESCryptoServiceProvider())
    {
        using (MemoryStream memoryStream = new MemoryStream(Convert.FromBase64String(cryptedString)))
        {
            using (CryptoStream cryptoStream = new CryptoStream(memoryStream, descryptoServiceProvider.CreateDecryptor(Encoding.ASCII.GetBytes("Kowalski"), Encoding.ASCII.GetBytes("XSkipper")), CryptoStreamMode.Read))
            {
                using (StreamReader streamReader = new StreamReader(cryptoStream))
                {
                    result = streamReader.ReadToEnd();
                }
            }
        }
    }
    return result;
}

Yeah, that’s it. The keys are stored in settings.cfg. The last keys I had dumped from the cheat are:

{
    "shortVersion": "gABAC7ps70O7AIQpTyDat2oXiesXt9MfXTwF4N+JuJ2bPtVLgUTNoScQqOCPG6t5YuQKkwfPeT6wT38PgeSZneZYeWtbfLVugRnnL/TbeS69utpJ6LXRYfX++4/AYQZ/6vurmRBop30Ss+QhizqLsZ0p7t3p7C6mAbULpI8MWkLu/NwmwgBKCg6Q6RzvjL+yUJWZNwZCOobiFwCLtla7yLjfXs93NJqny8UkFeIHbM4HOiUw19+aERrCvkYRgiOAfl7UVT3hISabVjj9I3XXOki+Ax45FT/mIygwrOwj3RpKPCx8a7/N4rEULj17etYFZWxAK6gi02gtgctP01G0Kg==",
    "longVersion": "fdNyatrP2l9g4EtW6S/FPsymfTGs6AjtgJi0vdLc3eCFLBBVd20gaTSC2JgKVsmx+r8sphoAraxWYT5hkMylKAlmC+7o2ZXILhLWSdOrFWqhs7gYlSXc/+6gaLOZ4fYC4m42hRLekInZL1ikIdzab6cvdbVdvmCNSWeaR9fXSRM+KKNFl9RagD5ZKOh2vFCDV1xquol2Wq+y5Q7LCBpqtvppQ59YimbtjZoaFHPVVIbaxFyhueelqe02IOOC4OWD9Kmtj7WmbGpekcMJRhdjz/NDmnJc1tmy4U5VvgnVwmC8o+plQtcLIFvpFKpKm6bkAnyiXCdy9puQe/X8S2kV6Q=="
}

Feel free to decrypt the newer versions and use his keys :) Anyways, we wont do that. Lets upgrade to 3.6.2 and do it like the cool kids.

Version 3.6.2

Upgrade as usual, you can do that by invalidating the files in Steam. Reinstalling works fine too. As mentioned before, Dead by Daylight has introduced SSL pinning, so how do we do it? Let’s take a deep dive into reverse engineering the game itself.

The true hero: Constants

First things first, we need as much information of the target binary as possible. One of the first things I like to do is checking for strings:

layle@pwn$ rabin2 -zzz DeadByDaylight-Win64-Shipping.exe | grep curl
1245065 0x04a4e368 0x144a4f568  37  38 (.rdata) ascii CLIENT libcurl 7.55.1-DEV\r\n%s\r\nQUIT\r\n

Now we know they use libcurl version 7.55.1, probably a debug build. Even better! Let’s check the official documentation on how SSL certificates are handled. You can find the specific page here. Two constants are important: CURLOPT_SSL_VERIFYHOST and CURLOPT_SSL_VERIFYPEER. Let’s download the specific source code and open it up. Searching for those constants reveal an enum in curl.h:

CINIT(SSL_VERIFYPEER, LONG, 64),
CINIT(SSL_VERIFYHOST, LONG, 81),

Now let’s navigate to url.c where Curl_setopt is defined. Here’s a short excerpt of the function:

CURLcode Curl_setopt(struct Curl_easy *data, CURLoption option,
                     va_list param)
{
  char *argptr;
  CURLcode result = CURLE_OK;
  long arg;
#ifndef CURL_DISABLE_HTTP
  curl_off_t bigsize;
#endif

  switch(option) {
  case CURLOPT_DNS_CACHE_TIMEOUT:
    data->set.dns_cache_timeout = va_arg(param, long);
    break;
  case CURLOPT_DNS_USE_GLOBAL_CACHE:
    /* remember we want this enabled */
    arg = va_arg(param, long);
    data->set.global_dns_cache = (0 != arg) ? TRUE : FALSE;
    break;
  case CURLOPT_SSL_CIPHER_LIST:
// ...
   if(strcasecompare(argptr, "ALL")) {
      /* clear all cookies */
      Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE);
      Curl_cookie_clearall(data->cookies);
      Curl_share_unlock(data, CURL_LOCK_DATA_COOKIE);
    }
    else if(strcasecompare(argptr, "SESS")) {
      /* clear session cookies */
      Curl_share_lock(data, CURL_LOCK_DATA_COOKIE, CURL_LOCK_ACCESS_SINGLE);
      Curl_cookie_clearsess(data->cookies);
      Curl_share_unlock(data, CURL_LOCK_DATA_COOKIE);
    }
    else if(strcasecompare(argptr, "FLUSH")) {
      /* flush cookies to file, takes care of the locking */
      Curl_flush_cookies(data, 0);
    }
    else if(strcasecompare(argptr, "RELOAD")) {
      /* reload cookies from file */
      Curl_cookie_loadfiles(data);
      break;
    }

The possibilities are endless. We have a lot of inlined constants and a lot of string references at our disposal to find the function in memory. The function starts with the following assembly:

mov qword ptr ss:[rsp+8],rbx
mov qword ptr ss:[rsp+10],rbp
mov qword ptr ss:[rsp+18],rsi
push rdi
sub rsp,30
xor ebp,ebp
mov rsi,r8
mov rbx,rcx
cmp edx,D2

As we can see the register edx will contain the constant. Depending on what value is set it will branch to different code to apply the options passed to the function. We are primarily looking for the constants 0x40 and 0x51. As we know from the original source code, the third argument contains the value, in this case it will be either true or false. The third register is r8 and contains a pointer to the memory holding the flag. We can use x64dbg’s conditional breakpoints to automatically patch memory on trigger:

Breaking Dead by Daylight without process interaction

In the following image you can see that edx is set to 0x40 and that register r8 points to our patched value in memory.

Breaking Dead by Daylight without process interaction

You may be wondering: “But we are touching the process?”. That’s right, we are and that’s why I’m gonna stop here :) I’m currently working on an usermode emulator for EasyAntiCheat (which I’ve been using in the above screenshot), we will cover more about the debugging features in that article.

Experiments

Looking back, I wish I had tried doing this before. Looking at Dead by Daylight’s track record it’s quite obvious that they must have messed up something trivial even in the latest version. The game uses Unreal Engine 4. All game assets are stored in so called “pak” files. Checking the strings of these files already reveals a whole lot of other information. Would you believe me if I told you Dead by Daylight doesn’t do any integrity checks on the “pak” files? Right. There’s 2 ways to approach this, either we give QuickBMS a shot or we use a hex editor. In this article we will be using the hex editor approach, doing it with QuickBMS is left as an exercise to the reader. The advantage would be that you can patch all configuration files and assets in the game, allowing you to get really close to a full wallhack. The file we will be editing is called pakchunk0-WindowsNoEditor.pak and hosts the main configuration files that are relevant for cheating purposes. Look for SSL and just erase the content to null bytes.

Breaking Dead by Daylight without process interaction

Here’s a dump of all the pinned keys that are being used:

+PinnedPublicKeys=".dev.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;PiEjPYP2N0QUoKvrwZZjmvSLIl0bBGJgKUevOeNowEM="
+PinnedPublicKeys=".qa.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;tBLmAw0lCqG3/5sn6ooVk9JNdIcptJb0iXoi4qkAqUo="
+PinnedPublicKeys=".stage.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;OBU5+MqEy/LV95MgQf23LGpAaaYElBvALjPW7AgmMNo="
+PinnedPublicKeys=".ptb.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;ICaQHYr/VHjpTY6UKcm8FUtWnUMe9q6WNrzr+WDuUls="
+PinnedPublicKeys=".cert.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;pvf7WXymw2xK8n6YTqblRrt3vwe2mSuGmAk8buiF2C4="
+PinnedPublicKeys=".management.live.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;EXrEe/XXp1o4/nVmcqCaG/BSgVR3OzhVUG8/X4kRCCU="
+PinnedPublicKeys="steam.live.bhvrdbd.com:++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=;EXrEe/XXp1o4/nVmcqCaG/BSgVR3OzhVUG8/X4kRCCU="

Anyways, save the file, open up your proxy (in my case mitmweb) and run the game!

Sniff’n’Decrypt

At this stage you should be able to see the requests. As you can see there’s also a new request to the endpoint clientVersion/check. Remember the keys I mentioned earlier? Those keys are sent to this endpoint, verifying our bhvrSession to be allowed to send further requests to the game server. However, we don’t have to worry about them anymore since the game does that for us without an issue.

Breaking Dead by Daylight without process interaction

Eventually we will end up with a request to FullProfile/binary at this stage it’s a GET request which fetches the profile data of the current player.

GET https://latest.live.dbd.bhvronline.com/api/v1/players/me/states/FullProfile/binary HTTP/1.1
...

DbdDAgAC0Yh1kjiaxVnUM1aTDgjahR1BTCe3iiAWGiwQk+4OCPnSOU6mzbfid9Ag6883sQKbf6G9jRiYUD9DQUmA4TmT6yPBznJEcxhzvp+W/QhcgXhPLsD6o8CWt1iMcV8uStjBH3W6r+Bk0COJ5SSSOdKNU8

This data contains the inventory, which is particularly interesting to cheat items. A very similar endpoint is used using a POST request which uploads the inventory. It is possible to decrypt the inventory, edit it, encrypt it and then send this to the server. This allows us to get whatever items we want, with whatever perks we want. To make things crystal clear, here’s how they decrypt the profile:

cipher  = AES.new(b"5BCC2D6A95D4DF04A005504E59A9B36E", AES.MODE_ECB)
profile = flow.response.content.decode()[8:]
profile = base64.b64decode(profile)
profile = cipher.decrypt(profile)
profile = "".join([chr(c + 1) for c in profile])
profile = base64.b64decode(profile[8:])
profile = profile[4:len(profile)]
profile = zlib.decompress(profile).decode("utf16")
profile = json.loads(profile)

Encryption works the same way, just in reverse. This is also left as an exercise for the reader.

RTM

There’s also another request sent to and endpoint named /getUrl which provides you with a URL to a websocket endpoint. There’s a lot of information flowing through, some of it is in the game state :) Unfortunately, only one client can be connected at a time, doesn’t stop you from proxying the websocket connection though. This is out of scope and is only mentioned as information, we will be doing this in an even funnier way. Nonetheless, sample code is provided here.

Predicting killers

Recently some posts emerged of people polling the Dead by Daylight logfile to figure out the killer while being in the lobby. That’s all fun and stuff, but code like this just shouldn’t be written at all. Especially not if the author claims it to be for “learning purpose”:

yourId2 = (str(x2[len(x2) - 2].split('\n[')[0]))[46:]
yourId3 = (str(x2[len(x2) - 3].split('\n[')[0]))[46:]
yourId4 = (str(x2[len(x2) - 4].split('\n[')[0]))[46:]
yourId5 = (str(x2[len(x2) - 5].split('\n[')[0]))[46:]
verifyKiller2 = (((((str(x2[len(x2) - 2].split('GameFlow: Verbose:')[0]))[509:]).split('\n'))[0]).split('_'))[0]
verifyKiller3 = (((((str(x2[len(x2) - 3].split('GameFlow: Verbose:')[0]))[509:]).split('\n'))[0]).split('_'))[0]
verifyKiller4 = (((((str(x2[len(x2) - 4].split('GameFlow: Verbose:')[0]))[509:]).split('\n'))[0]).split('_'))[0]
verifyKiller5 = (((((str(x2[len(x2) - 5].split('GameFlow: Verbose:')[0]))[509:]).split('\n'))[0]).split('_'))[0]

Let’s do it the right way. From our debugging session earlier you might have noticed that the game prints strings to the “Log” tab in x64dbg. This is because Windows redirects all debug strings that end up in DbgPrint to the debugger if one is attached. However, it’s actually possible to access these strings without a debugger and I’ve just recently reversed how to do so from DebugView. You can find the reversed project on my personal GitHub. The basic idea is to access Windows' message buffer (DBWIN_BUFFER) and wait for certain events (DBWIN_BUFFER_READY and DBWIN_DATA_READY). This allows us to interact with the games logs in realtime without having to poll any text files and accidentally filter out old information which isn’t relevant. Utilizing very basic Regular Expressions we are able to determine the killer (even if he changes the character while being in the lobby which the original script can’t do!) and also his Steam profile. The options are endless! Here’s a broken down version of what is actually going on:

const auto process_id = find_process("DeadByDaylight-Win64-Shipping.exe");

const std::regex character_id_pattern("Spawn new pawn characterId (\\d+)\\.");
const std::regex steam_id_pattern("Session:GameSession PlayerId:([0-9\\-a-z]+)\\|(\\d+)");
const std::regex killer_pattern("MatchMembersA=\\[\\\"([a-z0-9\\-]+)\\\"\\]");
auto buffer_ready = open_event(
    EVENT_ALL_ACCESS,
    L"DBWIN_BUFFER_READY");
auto data_ready = open_event(
    SYNCHRONIZE,
    L"DBWIN_DATA_READY");
auto file = open_mapping(
    L"DBWIN_BUFFER");
auto buffer = reinterpret_cast<message*>(
    wrapper::map_view_of_file(
        file,
        SECTION_MAP_READ,
        0, 0, 0));

std::string killer_id;

while (wrapper::wait_for_single_object(
    data_ready,
    INFINITE) == WAIT_OBJECT_0)
{
    if (buffer->process_id == process_id)
    {
        auto message = std::string(buffer->data);
        std::smatch matches;

        if (std::regex_search(message, matches, character_id_pattern))
        {
            auto character_id = std::stoi(matches[1].str());
            auto killer = KILLERS.find(character_id);
            if (killer != KILLERS.end())
            {
                std::cout << "Killer: " << killer->second << std::endl;
            }
        }
        else if (std::regex_search(message, matches, steam_id_pattern))
        {
            auto player_id = matches[1].str();
            auto steam_id = matches[2].str();
            if (player_id == killer_id)
            {
                std::cout << "Killer Steam Profile: https://steamcommunity.com/profiles/" << steam_id << std::endl;
            }
        }
        else if (std::regex_search(message, matches, killer_pattern))
        {
            killer_id = matches[1].str();
            std::cout << "Found Killer PlayerID: " << killer_id << std::endl;
        }
    }

    wrapper::set_event(buffer_ready);
}

wrapper::unmap_view_of_file(buffer);
wrapper::close_handle(file);
wrapper::close_handle(buffer_ready);
wrapper::close_handle(data_ready);

This code is just for demonstration purposes, you can find the project here.

Breaking Dead by Daylight without process interaction

Ranking up

During my research I created a script (authenticator.py) that automates information dumping (such as bhvrSession) and also decrypts the inventory. The data is being written in separate JSON files. Once the files are created you are free to run the scripts levelup.py and rankup.py. The scripts send basic POST requests to the endpoints /api/v1/ranks/pips and /api/v1/extensions/playerLevels/earnPlayerXp respectively. The needed JSON bodies are as follows (in the same order as above):

{
    "data": {
        "consecutiveMatch": 1,
        "emblemQualities": [
            "Iridescent",
            "Iridescent",
            "Iridescent",
            "Iridescent"
        ],
        "isFirstMatch": true,
        "levelVersion": 1337,
        "matchTime": 1000,
        "platformVersion": "steam",
        "playerType": "survivor"
    }
}
/api/v1/ranks/pips request body
{
    "forceReset": true,
    "killerPips": 2,
    "survivorPips": 2
}
/api/v1/extensions/playerLevels/earnPlayerXp request body

Final words

To wrap things up I would like to say that there’s way more to explore and the code published can be improved in a lot of ways. I believe the most important take away is that debug events/strings can be really dangerous if used excessively. I believe that the general approaches and efforts from Behaviour are fine. However, some details still have room for improvement such as fixing their anticheat to check all game files. One more important note that I haven’t mentioned is the fact the pak files do not use an encryption key. Unreal Engine 4 has a feature to encrypt all game files with a specific key. Of course this can be reverse engineered but it makes creating cheats harder nonetheless. If there’s any open questions or feedback feel free to reach out to me on Twitter.

❌
❌