Ticket #33461: te500xss.py

File te500xss.py, 8.4 KB (added by Keryn Knight, 2 years ago)

Attachment 1, showing how to XSS the 500 page via {% extends %}

Line 
1"""
2Proof of Concept for an XSS on the Technical 500 debug page.
3
4I'm only considering this a *potential* security issue due to the fact that sensitive
5variables output on the technical 500 are scrubbed. This relies on so much
6user footgunning that it shouldn't actually be something that can happen, but
7hey ho.
8
9Requirements:
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
16The 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
27Taken altogether, it's a wildly unlikely scenario, and realistically if DEBUG
28is on there are probably other problems.
29
30
31Demo
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
43How it works:
44------------
45Given the string {% extends "anything" %} the "anything" token part is
46turned into a Variable (or a FilterExpression wrapping over one). The Variable
47is detected as a 'constant' within Variable.__init__ and as such is stored
48as self.literal, and optimistically considered safe via mark_safe().
49
50That it's marked safe is the root of the problem, but unfortunately, that
51appears to be a partially undocumented function of DTL - that {{ '<html>' }}
52isn't escaped - and tests fail if it's removed. It is documented that the RHS
53of filters work that way, and if you extrapolate enough it's readily apparent,
54but it's never expressly said or demonstrated.
55
56Anyway, from there onwards it's a SafeString containing our XSS. The SafeString
57is given directly to find_template, which goes to the Engine's find_template.
58The Engine.find_template will raise TemplateDoesNotExist and provide the SafeString
59as the `name` argument, which is also `args[0]` - the latter is important shortly.
60
61Because a TemplateDoesNotExist is thrown and render_annotated detects
62that the engine is in debug mode, it ultimately calls Template.get_exception_info
63which ends up doing str(exception.args[0]) ... but str() on a SafeString returns
64the same SafeString instance; that is id(str(my_safe_str)) and id(my_safe_str)
65are the same, presumably due to a str() optimisation to avoid copying down in
66the C.
67
68So if the template name given to the extends tag contains HTML (... how?!) it'll
69throw, 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
71written directly, rather than escaped.
72
73
74Mitigation ideas
75----------------
76This 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"""
86import django
87from django.conf import settings
88from django.http import HttpResponse
89from django.template import Template, Context
90from django.urls import path
91
92if 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
114def 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
153def 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
169def 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
182def 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
191urlpatterns = [
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
199if __name__ == "__main__":
200 from django.core import management
201
202 management.execute_from_command_line()
203else:
204 from django.core.wsgi import get_wsgi_application
205
206 application = get_wsgi_application()
Back to Top