| 1 | """
|
|---|
| 2 | Proof of Concept for an XSS on the Technical 500 debug page.
|
|---|
| 3 |
|
|---|
| 4 | I'm only considering this a *potential* security issue due to the fact that sensitive
|
|---|
| 5 | variables output on the technical 500 are scrubbed. This relies on so much
|
|---|
| 6 | user footgunning that it shouldn't actually be something that can happen, but
|
|---|
| 7 | hey ho.
|
|---|
| 8 |
|
|---|
| 9 | Requirements:
|
|---|
| 10 | ------------
|
|---|
| 11 | - DEBUG=True to get the technical 500
|
|---|
| 12 | - Templates much be constructed from at least partial user data.
|
|---|
| 13 | - TemplateDoesNotExist (or possibly TemplateSyntaxError ... somehow) must be
|
|---|
| 14 | raised, and the message needs to somehow contain user data.
|
|---|
| 15 |
|
|---|
| 16 | The only way I've managed to establish it could even happen is:
|
|---|
| 17 | - Developer's template system allows construction of templates (e.g Database loader,
|
|---|
| 18 | or fragments joined together from form output in the admin to dynamically
|
|---|
| 19 | create CMS templates or whatever)
|
|---|
| 20 | - Developer's template system must allow for dynamic extends, and for reasons
|
|---|
| 21 | unknown isn't using variable resolution for it, but is instead doing something
|
|---|
| 22 | akin to: data = '{{% extends "{}" %}}'.format(template_name), and is
|
|---|
| 23 | allowing the user to select the base template (e.g. from a dropdown) but isn't
|
|---|
| 24 | sanitising it (e.g. with a form's ChoicesField)
|
|---|
| 25 | - Developer has left DEBUG turned on
|
|---|
| 26 |
|
|---|
| 27 | Taken altogether, it's a wildly unlikely scenario, and realistically if DEBUG
|
|---|
| 28 | is on there are probably other problems.
|
|---|
| 29 |
|
|---|
| 30 |
|
|---|
| 31 | Demo
|
|---|
| 32 | ----
|
|---|
| 33 |
|
|---|
| 34 | - Run this file by doing `python te500xss.py runserver`
|
|---|
| 35 | - Navigate to the /syntax URL to see the apparently intentional
|
|---|
| 36 | self-inflicted XSS. That's not the XSS we care about...
|
|---|
| 37 | - Visit /block and /include to see normal expected behaviour; the former doesn't
|
|---|
| 38 | error, the latter does error but does so safely (despite being the same
|
|---|
| 39 | error message).
|
|---|
| 40 | - Visit /extends and see the actual XSS on the 500 error page.
|
|---|
| 41 |
|
|---|
| 42 |
|
|---|
| 43 | How it works:
|
|---|
| 44 | ------------
|
|---|
| 45 | Given the string {% extends "anything" %} the "anything" token part is
|
|---|
| 46 | turned into a Variable (or a FilterExpression wrapping over one). The Variable
|
|---|
| 47 | is detected as a 'constant' within Variable.__init__ and as such is stored
|
|---|
| 48 | as self.literal, and optimistically considered safe via mark_safe().
|
|---|
| 49 |
|
|---|
| 50 | That it's marked safe is the root of the problem, but unfortunately, that
|
|---|
| 51 | appears to be a partially undocumented function of DTL - that {{ '<html>' }}
|
|---|
| 52 | isn't escaped - and tests fail if it's removed. It is documented that the RHS
|
|---|
| 53 | of filters work that way, and if you extrapolate enough it's readily apparent,
|
|---|
| 54 | but it's never expressly said or demonstrated.
|
|---|
| 55 |
|
|---|
| 56 | Anyway, from there onwards it's a SafeString containing our XSS. The SafeString
|
|---|
| 57 | is given directly to find_template, which goes to the Engine's find_template.
|
|---|
| 58 | The Engine.find_template will raise TemplateDoesNotExist and provide the SafeString
|
|---|
| 59 | as the `name` argument, which is also `args[0]` - the latter is important shortly.
|
|---|
| 60 |
|
|---|
| 61 | Because a TemplateDoesNotExist is thrown and render_annotated detects
|
|---|
| 62 | that the engine is in debug mode, it ultimately calls Template.get_exception_info
|
|---|
| 63 | which ends up doing str(exception.args[0]) ... but str() on a SafeString returns
|
|---|
| 64 | the same SafeString instance; that is id(str(my_safe_str)) and id(my_safe_str)
|
|---|
| 65 | are the same, presumably due to a str() optimisation to avoid copying down in
|
|---|
| 66 | the C.
|
|---|
| 67 |
|
|---|
| 68 | So if the template name given to the extends tag contains HTML (... how?!) it'll
|
|---|
| 69 | throw, and then the SafeString gets output in the technical 500 as
|
|---|
| 70 | {{ template_info.message }} but it's already been marked safe, so the HTML is
|
|---|
| 71 | written directly, rather than escaped.
|
|---|
| 72 |
|
|---|
| 73 |
|
|---|
| 74 | Mitigation ideas
|
|---|
| 75 | ----------------
|
|---|
| 76 | This can be fixed in a couple of ways:
|
|---|
| 77 | - Forcibly coerce the value to an actual string inside of get_exception_info, by
|
|---|
| 78 | doing something like str(exception.args[0]).strip(). That'd work for all
|
|---|
| 79 | string subclasses which don't have a custom strip method which does something
|
|---|
| 80 | clever.
|
|---|
| 81 | - add escape() to the str(exception.args[0]) call as has been done with
|
|---|
| 82 | self.source on the lines previous.
|
|---|
| 83 | - Use |force_escape filter in the technical 500; the |escape filter won't work
|
|---|
| 84 | because it's actually conditional_escape.
|
|---|
| 85 | """
|
|---|
| 86 | import django
|
|---|
| 87 | from django.conf import settings
|
|---|
| 88 | from django.http import HttpResponse
|
|---|
| 89 | from django.template import Template, Context
|
|---|
| 90 | from django.urls import path
|
|---|
| 91 |
|
|---|
| 92 | if not settings.configured:
|
|---|
| 93 | settings.configure(
|
|---|
| 94 | SECRET_KEY="??????????????????????????????????????????????????????????",
|
|---|
| 95 | DEBUG=True,
|
|---|
| 96 | INSTALLED_APPS=(),
|
|---|
| 97 | ALLOWED_HOSTS=("*"),
|
|---|
| 98 | ROOT_URLCONF=__name__,
|
|---|
| 99 | MIDDLEWARE=[],
|
|---|
| 100 | TEMPLATES=[
|
|---|
| 101 | {
|
|---|
| 102 | "BACKEND": "django.template.backends.django.DjangoTemplates",
|
|---|
| 103 | "DIRS": [],
|
|---|
| 104 | "APP_DIRS": False,
|
|---|
| 105 | "OPTIONS": {
|
|---|
| 106 | "context_processors": [],
|
|---|
| 107 | },
|
|---|
| 108 | },
|
|---|
| 109 | ],
|
|---|
| 110 | )
|
|---|
| 111 | django.setup()
|
|---|
| 112 |
|
|---|
| 113 |
|
|---|
| 114 | def valid_syntax(request) -> HttpResponse:
|
|---|
| 115 | """
|
|---|
| 116 | This is mostly undocumented in the DTL language docs AFAIK, but apparently
|
|---|
| 117 | is intentionally supported. It's referenced as
|
|---|
| 118 | https://docs.djangoproject.com/en/4.0/ref/templates/language/#string-literals-and-automatic-escaping
|
|---|
| 119 | but only shows it being used on the RHS of a filter, unlike the original
|
|---|
| 120 | ticket ( https://code.djangoproject.com/ticket/5945 ) which showed using
|
|---|
| 121 | it as the whole, unescaped value.
|
|---|
| 122 |
|
|---|
| 123 | The following tests fail if Variable.__init__ is changed so that:
|
|---|
| 124 | self.literal = mark_safe(unescape_string_literal(var))
|
|---|
| 125 | becomes:
|
|---|
| 126 | self.literal = unescape_string_literal(var)
|
|---|
| 127 |
|
|---|
| 128 | FAIL: test_i18n16 (template_tests.syntax_tests.i18n.test_underscore_syntax.I18nStringLiteralTests)
|
|---|
| 129 | FAIL: test_autoescape_literals01 (template_tests.syntax_tests.test_autoescape.AutoescapeTagTests)
|
|---|
| 130 | FAIL: test_basic_syntax26 (template_tests.syntax_tests.test_basic.BasicSyntaxTests)
|
|---|
| 131 | FAIL: test_basic_syntax27 (template_tests.syntax_tests.test_basic.BasicSyntaxTests)
|
|---|
| 132 |
|
|---|
| 133 | So that's a non-starter.
|
|---|
| 134 |
|
|---|
| 135 | If I ever knew about this functionality, I've long since forgotten it, and
|
|---|
| 136 | grepping the source for \{\{(\s+?)["'] shows nothing much outside of tests...
|
|---|
| 137 |
|
|---|
| 138 | Having {{ '<raw html>' }} work when
|
|---|
| 139 | {% templatetag %} AND {% verbatim %} AND {% filter %} seems like a weird
|
|---|
| 140 | historic quirk, though of course I may just be missing some use-case or other.
|
|---|
| 141 |
|
|---|
| 142 | Anyway, by having a literal become a SafeString, I can later XSS the
|
|---|
| 143 | technical 500 page.
|
|---|
| 144 | """
|
|---|
| 145 | template = Template("""
|
|---|
| 146 | This is intentionally supported syntax, but relatively well hidden IMHO.
|
|---|
| 147 | <hr>
|
|---|
| 148 | {{ '<script>alert(1);</script>' }}
|
|---|
| 149 | """)
|
|---|
| 150 | return HttpResponse(template.render(Context({})))
|
|---|
| 151 |
|
|---|
| 152 |
|
|---|
| 153 | def extends(request) -> HttpResponse:
|
|---|
| 154 | """
|
|---|
| 155 | So in the previous view we saw that we can apparently intentionally have
|
|---|
| 156 | raw HTML within variables and it'll parse OK, possibly so you could
|
|---|
| 157 | feed it to a filter like {{ '<div>...</div>'|widont }} or something?
|
|---|
| 158 |
|
|---|
| 159 | Because the template tag passes the Variable's resolved SafeString to
|
|---|
| 160 | find_template, and the technical 500 template outputs template_info.message
|
|---|
| 161 | which is actually TemplateDoesNotExist.args[0], which is the name
|
|---|
| 162 | argument ... it gets output having been treated as safe ... which it should
|
|---|
| 163 | be, ordinarily.
|
|---|
| 164 | """
|
|---|
| 165 | template = Template("{% extends '<script>alert(1);</script>' %}")
|
|---|
| 166 | return HttpResponse(template.render(Context({})))
|
|---|
| 167 |
|
|---|
| 168 |
|
|---|
| 169 | def include(request) -> HttpResponse:
|
|---|
| 170 | """
|
|---|
| 171 | Note that the include node doesn't express the same problem.
|
|---|
| 172 | This is because the TemplateDoesNotExist uses select_template which
|
|---|
| 173 | internally discards the SafeString instance by converting the tuple (which
|
|---|
| 174 | DOES contain a SafeString) into a string like so:
|
|---|
| 175 | ', '.join(not_found)
|
|---|
| 176 | which happens to erase the SafeString somewhere down in C.
|
|---|
| 177 | """
|
|---|
| 178 | template = Template("{% include '<script>alert(1);</script>' %}")
|
|---|
| 179 | return HttpResponse(template.render(Context({})))
|
|---|
| 180 |
|
|---|
| 181 |
|
|---|
| 182 | def block(request) -> HttpResponse:
|
|---|
| 183 | """
|
|---|
| 184 | And the block tag doesn't even throw an exception in the first place, because
|
|---|
| 185 | you can name a block basically anything and never resolves the name.
|
|---|
| 186 | """
|
|---|
| 187 | template = Template("{% block '<script>alert(1);</script>' %}{% endblock %}")
|
|---|
| 188 | return HttpResponse(template.render(Context({})))
|
|---|
| 189 |
|
|---|
| 190 |
|
|---|
| 191 | urlpatterns = [
|
|---|
| 192 | path("syntax", valid_syntax, name="syntax"),
|
|---|
| 193 | path("block", block, name="block"),
|
|---|
| 194 | path("include", include, name="include"),
|
|---|
| 195 | path("extends", extends, name="extends"),
|
|---|
| 196 | ]
|
|---|
| 197 |
|
|---|
| 198 |
|
|---|
| 199 | if __name__ == "__main__":
|
|---|
| 200 | from django.core import management
|
|---|
| 201 |
|
|---|
| 202 | management.execute_from_command_line()
|
|---|
| 203 | else:
|
|---|
| 204 | from django.core.wsgi import get_wsgi_application
|
|---|
| 205 |
|
|---|
| 206 | application = get_wsgi_application()
|
|---|