Recognizing Insecure Drupal Code
Within the Drupal community, it seems like many developers are interested in ensuring their modules and themes are secure, but don’t really know what insecure code looks like. I’ve personally found a lot of resources that tell you about security best practices, but don’t dive deeper into common missteps and their consequences.
Drupal 8 is the most modern and secure release of Drupal yet, which leads developers to expect that all Drupal 8 APIs are perfectly safe to use. While it’s great that Drupal has earned that reputation, there are still plenty of ways to leave your site vulnerable.
In this blog I’ll go through examples of insecure code that I’ve seen doing security research and review into Drupal 8, which will hopefully make it easier for you to know what to look for when reviewing your own code.
So you want to render HTML…
Outputting HTML is Drupal’s bread and butter, but if you’re rendering user input you may be vulnerable to cross site scripting, otherwise known as XSS.
XSS occurs when a malicious user identifies an exploit that allows user input to be executed as Javascript. Then, typically, an attacker leads someone without higher privileges (an administrator) to trigger the exploit. At that point, an attacker can do anything the administrator can do - add more administrator accounts, delete content, download sensitive data, and potentially use a chained exploit to execute server-side code.
Twig has your back
With Drupal 8’s implementation of Twig, all variables rendered normally (within curly braces) are automatically filtered. The attributes object, which is often used in Twig, is also generally safe. For example, trying to add a malicious attribute with code like:
<span class="nt"><b</span> <span class="cp">{{</span> <span class="nv">attributes.addClass</span><span class="p">(</span><span class="s1">'"onmouseover="alert(1)"'</span><span class="p">)</span> <span class="cp">}}</span><span class="nt">></span>Hello<span class="nt"></b></span>
Will render safely as:
<span class="nt"><b</span> <span class="na">class=</span><span class="s">"&quot;onload=&quot;alert&quot;"</span><span class="nt">></span>Hello<span class="nt"></b></span>
Unquoted attributes
Twig isn’t inherently immune to XSS. If you don’t wrap attributes in double quotes, for instance, user input could render a malicious attribute. For example, if you have a template like:
<span class="nt"><b</span> <span class="na">class=</span><span class="cp">{{</span> <span class="nv">configurable_class</span> <span class="cp">}}</span><span class="err">></span><span class="s">Hello</b</span><span class="nt">></span>
And pass in a class configured by a user:
<span class="nv">$variables</span><span class="p">[</span><span class="s1">'configurable_class'</span><span class="p">]</span> <span class="o">=</span> <span class="s1">'foo onclick=alert(bar)'</span><span class="p">;</span>
The final, unsafe HTML will be:
<span class="nt"><b</span> <span class="na">class=</span><span class="s">foo</span> <span class="na">onclick=</span><span class="s">alert(bar)</span><span class="nt">></span>Hello<span class="nt"></b></span>
This is because variables have HTML special characters escaped, but aren’t aware of the context they’re rendered in. onclick=alert(bar)
on its own is completely safe, but when inside an opening HTML tag can trigger XSS.
The raw filter
One of the filters that comes with Twig, raw
, marks a value as being safe and does not escape it. That means that if you ever see something like:
<span class="cp">{{</span> <span class="nv">variable</span> <span class="err">|</span> <span class="nv">raw</span> <span class="cp">}}</span>
In your templates, that could lead to an XSS vulnerability. There are very few use cases for raw
, so if you can avoid using it completely you should.
Misusing render arrays
Render and form arrays in Drupal can also be misused to allow XSS. For example, you may know that HTML like this executes arbitrary Javascript on click:
<span class="nt"><a</span> <span class="na">href=</span><span class="s">"javascript:alert()"</span><span class="nt">></span>Click me!<span class="nt"></a></span>
And if you’re using url or link objects or render elements, this will be rendered as:
<span class="nt"><a</span> <span class="na">href=</span><span class="s">"alert()"</span><span class="nt">></span>Click me!<span class="nt"></a></span>
Which is safe. However, if you’re not using the url or link APIs, Drupal doesn’t have the context to know that the “href” attribute could be unsafe, and will render it without escaping. For example, this code:
<span class="nv">$build</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'#type'</span> <span class="o">=></span> <span class="s1">'html_tag'</span><span class="p">,</span> <span class="s1">'#tag'</span> <span class="o">=></span> <span class="s1">'a'</span><span class="p">,</span> <span class="s1">'Hello'</span><span class="p">];</span><span class="nv">$build</span><span class="p">[</span><span class="s1">'#attributes'</span><span class="p">][</span><span class="s1">'href'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$user_input</span><span class="p">;</span>
When provided this user input:
<span class="nv">$user_input</span> <span class="o">=</span> <span class="s1">'javascript:alert("foo")'</span><span class="p">;</span>
Will render:
<span class="nt"><a</span> <span class="na">href=</span><span class="s">"javascript:alert("</span><span class="na">foo</span><span class="err">")"</span><span class="nt">></span>Hello<span class="nt"></a></span>
Like the Twig attribute issue, this is a result of Drupal not being aware that untrusted data is being passed to potentially dangerous APIs. Here are some more examples of render arrays that allow XSS:
<span class="nv">$build</span><span class="p">[</span><span class="s1">'#markup'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$user_input</span><span class="p">;</span><span class="nv">$build</span><span class="p">[</span><span class="s1">'#allowed_tags'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'script'</span><span class="p">];</span>
<span class="nv">$build</span><span class="p">[</span><span class="s1">'#children'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$user_input</span><span class="p">;</span>
<span class="nv">$build</span><span class="p">[</span><span class="s1">'#markup'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">t</span><span class="p">(</span><span class="nv">$user_input</span><span class="p">);</span>
<span class="nv">$build</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'#type'</span> <span class="o">=></span> <span class="s1">'inline_template'</span><span class="p">,</span> <span class="s1">'#template'</span> <span class="o">=></span> <span class="nv">$user_input</span><span class="p">];</span>
Not filtering in Javascript
While the examples so far have been about backend code, XSS is commonly triggered from Javascript. Take this example, where the value of an input is passed to jQuery’s html
function to display an error:
<span class="kd">var</span> <span class="nx">value</span> <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s1">'input.title'</span><span class="p">).</span><span class="nx">val</span><span class="p">();</span><span class="nx">$</span><span class="p">(</span><span class="s1">'.error'</span><span class="p">).</span><span class="nx">html</span><span class="p">(</span><span class="s1">'<p>Invalid title "'</span> <span class="o">+</span> <span class="nx">value</span> <span class="o">+</span> <span class="s1">'"</p>'</span><span class="p">);</span>
Since the html
function assumes the data you pass is safe, this could trigger XSS. A better way of approaching this is to use the text
function, which escapes special characters:
<span class="kd">var</span> <span class="nx">value</span> <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s1">'input.title'</span><span class="p">).</span><span class="nx">val</span><span class="p">();</span><span class="nx">$</span><span class="p">(</span><span class="s1">'.error'</span><span class="p">).</span><span class="nx">text</span><span class="p">(</span><span class="s1">'Invalid title "'</span> <span class="o">+</span> <span class="nx">value</span> <span class="o">+</span> <span class="s1">'"'</span><span class="p">);</span>
The most Drupal-y way to accomplish this would be to use the Drupal.t
function, which accepts placeholders that are automatically escaped, and translates text:
<span class="kd">var</span> <span class="nx">value</span> <span class="o">=</span> <span class="nx">$</span><span class="p">(</span><span class="s1">'input.title'</span><span class="p">).</span><span class="nx">val</span><span class="p">();</span><span class="nx">$</span><span class="p">(</span><span class="s1">'.error'</span><span class="p">).</span><span class="nx">html</span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">t</span><span class="p">(</span><span class="s1">'<p>Invalid title "@title"</p>'</span><span class="p">,</span> <span class="p">{</span><span class="s1">'@title'</span><span class="p">:</span> <span class="nx">value</span><span class="p">});</span>
Sniffing out XSS problems
In general, a good way to spot XSS is to question complexity wherever you see it. Look into your biggest forms and controllers and see if there’s anything odd using user input, and if so make an effort to exploit it. Also, if there’s any opportunity to use Twig instead of concatenating HTML in the backend, use Twig!
So you want to query the database…
Drupal comes with a database abstraction layer that saves you from writing SQL by hand, which has done a lot to prevent a type of vulnerability called SQL injection, otherwise known as SQLi.
SQLi occurs when a malicious user identifies an SQL query that can be unsafely modified by user input, allowing them to add arbitrary statements or additional queries onto an existing query. SQLi can allow attackers to read arbitrary sensitive data, insert arbitrary data, or even wipe existing data if they are able to.
Use the abstractions
The best advice when querying the database is to use Drupal’s database API wherever possible. Drupal has great documentation on how to properly use this API here: https://www.drupal.org/docs/8/api/database-api
The API is normally safe to use, but can be used unsafely in ways that aren’t clear to all Drupal developers.
Not using placeholders
There are cases where you need to write a query by-hand, which is fine unless that query uses user input, in which case you need to use placeholders. For example, this code has user input ($name
) in the query string:
<span class="err">\</span><span class="n">Drupal</span><span class="p">::</span><span class="k">database</span><span class="p">()</span> <span class="o">-></span><span class="n">query</span><span class="p">(</span><span class="nv">"DELETE FROM people WHERE name = </span><span class="se">"</span><span class="nv">$name</span><span class="se">"</span><span class="nv">"</span><span class="p">)</span> <span class="o">-></span><span class="k">execute</span><span class="p">();</span>
If $name
is set to a malicious value, like:
<span class="nv">$name</span> <span class="o">=</span> <span class="s1">'myname" OR "" = "'</span><span class="p">;</span>
The final query ends up being:
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">people</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="nv">"myname"</span> <span class="k">OR</span> <span class="nv">""</span> <span class="o">=</span> <span class="nv">""</span>
Which in this example would delete everyone from the people
table. The proper way to do this would be to use placeholders in your query string, and pass the user input as an argument:
<span class="nx">\Drupal</span><span class="o">::</span><span class="na">database</span><span class="p">()</span> <span class="o">-></span><span class="na">query</span><span class="p">(</span><span class="s1">'DELETE FROM people WHERE name = :name'</span><span class="p">,</span> <span class="p">[</span> <span class="s1">':name'</span> <span class="o">=></span> <span class="nv">$name</span><span class="p">,</span> <span class="p">])</span> <span class="o">-></span><span class="na">execute</span><span class="p">();</span>
Not escaping LIKE
Typically when using the database API, using the condition
method and passing user input as the value is safe. However, if you are using the LIKE
condition, you need to escape user input that may contain the wildcard character (%
). For example, this code has user input ($name
) in a LIKE
condition:
<span class="nv">$result</span> <span class="o">=</span> <span class="nx">\Drupal</span><span class="o">::</span><span class="na">database</span><span class="p">()</span> <span class="o">-></span><span class="na">delete</span><span class="p">(</span><span class="s1">'people'</span><span class="p">)</span> <span class="o">-></span><span class="na">condition</span><span class="p">(</span><span class="s1">'name'</span><span class="p">,</span> <span class="s1">'%_'</span> <span class="o">.</span> <span class="nv">$name</span><span class="p">,</span> <span class="s1">'LIKE'</span><span class="p">)</span> <span class="o">-></span><span class="na">execute</span><span class="p">();</span>
If $name
is set to a malicious value, like:
<span class="nv">$name</span> <span class="o">=</span> <span class="s1">'%'</span><span class="p">;</span>
The final query ends up being:
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">people</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="k">LIKE</span> <span class="nv">"%_%"</span>
Which would delete every row in the people
table where the name included an underscore. The proper way to do this is to escape the user input using the escapeLike
method, like so:
<span class="nv">$database</span> <span class="o">=</span> <span class="nx">\Drupal</span><span class="o">::</span><span class="na">database</span><span class="p">();</span><span class="nv">$result</span> <span class="o">=</span> <span class="nv">$database</span> <span class="o">-></span><span class="na">delete</span><span class="p">(</span><span class="s1">'people'</span><span class="p">)</span> <span class="o">-></span><span class="na">condition</span><span class="p">(</span><span class="s1">'name'</span><span class="p">,</span> <span class="s1">'%_'</span> <span class="o">.</span> <span class="nv">$database</span><span class="o">-></span><span class="na">escapeLike</span><span class="p">(</span><span class="nv">$name</span><span class="p">),</span> <span class="s1">'LIKE'</span><span class="p">)</span> <span class="o">-></span><span class="na">execute</span><span class="p">();</span>
Trusting user operators
Passing user input as a condition value is generally safe, but passing it to other parts of the API like table names, column names, or condition operators is dangerous. For example, this code has user input ($operator
) as a condition operator:
<span class="nv">$result</span> <span class="o">=</span> <span class="nx">\Drupal</span><span class="o">::</span><span class="na">database</span><span class="p">()</span> <span class="o">-></span><span class="na">select</span><span class="p">(</span><span class="s1">'people'</span><span class="p">)</span> <span class="o">-></span><span class="na">condition</span><span class="p">(</span><span class="s1">'name'</span><span class="p">,</span> <span class="nv">$name</span><span class="p">,</span> <span class="nv">$operator</span><span class="p">)</span> <span class="o">-></span><span class="na">execute</span><span class="p">();</span>
If $operator
is set to a malicious value, like:
<span class="nv">$operator</span> <span class="o">=</span> <span class="s1">'IS NOT NULL)UNION ALL SELECT SID,SSID FROM SESSIONSJOIN USERS WHERE ("foo" <>'</span><span class="p">;</span>
The final query ends up being:
<span class="k">SELECT</span> <span class="k">FROM</span> <span class="n">people</span> <span class="k">WHERE</span> <span class="p">(</span><span class="n">name</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">)</span><span class="k">UNION</span> <span class="k">ALL</span> <span class="k">SELECT</span> <span class="n">SID</span><span class="p">,</span><span class="n">SSID</span> <span class="k">FROM</span> <span class="n">SESSIONS</span><span class="k">JOIN</span> <span class="n">USERS</span> <span class="k">WHERE</span> <span class="p">(</span><span class="nv">"foo"</span> <span class="o"><></span> <span class="p">:</span><span class="n">name</span><span class="p">)</span>
Which would query all session IDs from the sessions
table, allowing user sessions to be hijacked.
To address this, compare the user input to a list of known valid SQL operators before using it in the query.
General SQL tips
If you use the database API in a typical, non-complex way, you’ll probably be fine. Just remember to use placeholders, escape user input when used in a LIKE
statement or as an operator, and try to never write queries by hand.
So you want to write some code…
Beyond Drupal specific APIs, a lot of your code is just plain PHP, which comes with its own set of security issues. One last kind of exploit I’ll briefly cover is remote code execute, otherwise known as RCE.
RCE occurs when a malicious user identifies an exploit that allows user input to be executed as server-side code, most commonly by your runtime language (PHP) or the shell. RCE allows an attacker to do anything your web user can do, which could be everything from reading sensitive data, setting up a persistent backdoor, or using the compromised server to reach more servers on your network.
PHP, historically, has allowed for RCE in a lot of different ways, so there’s no golden rule to follow. Instead, watch out for some of the RCE classics:
Using user input to execute shell commands:
<span class="sb">`</span>magick convert <span class="nv">$user_input</span> output.png<span class="sb">`</span><span class="p">;</span>shell_exec<span class="o">(</span><span class="s2">"magick convert </span><span class="nv">$user_input</span><span class="s2"> output.png"</span><span class="o">)</span><span class="p">;</span>
You could use the escapeshellarg
function here to escape user input, but that isn’t foolproof as options (--foo=bar
) are just wrapped in quotes, which in some command line applications is treated as a valid option. Validating the user input against a small set of allowed characters may be the best bet here, in addition to using escapeshellarg
.
Using eval
to execute dynamic PHP expressions:
<span class="nb">eval</span><span class="o">(</span><span class="s2">"is_null(</span><span class="nv">$user_input</span><span class="s2">)"</span><span class="o">)</span><span class="p">;</span>
This allows arbitrary PHP to be executed and should not be used.
Using unserialize
on data that could be entered by the user:
unserialize<span class="o">(</span><span class="nv">$user_input</span><span class="o">)</span><span class="p">;</span>
This allows for object injection, a vulnerability that can lead to RCE, and should be avoided if possible. Consider storing complex data as JSON instead, which is safe to use.
Without a deep experience in how RCE exploits are performed it’s hard to spot vulnerabilities, but you should review any code that has dynamic shell commands, eval, or unserialize with a high level of scrutiny.
A parting thought
Information like this can be daunting, but the best way to apply it to your work is to research common vulnerabilities, try a few exploits out, and make security a part of your company’s culture as well as code. Once you start thinking about security it’s hard to get it out of your head - does your company properly use encryption? Is two factor authentication enforced? How’s your office’s physical security? Being aware of these issues can lead to improvements that extend far beyond your custom code.