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()
|
---|