| 1 | """
|
|---|
| 2 | Proof of Concept for an XSS on the Technical 500 debug page.
|
|---|
| 3 |
|
|---|
| 4 | Alternate take on it. Still requires a misunderstanding by a user, marking
|
|---|
| 5 | something as safe when in fact it's _not_.
|
|---|
| 6 | """
|
|---|
| 7 | import django
|
|---|
| 8 | from django import template
|
|---|
| 9 | from django.conf import settings
|
|---|
| 10 | from django.http import HttpResponse
|
|---|
| 11 | from django.template import Template, Context
|
|---|
| 12 | from django.urls import path
|
|---|
| 13 | from django.utils.safestring import mark_safe
|
|---|
| 14 |
|
|---|
| 15 | if not settings.configured:
|
|---|
| 16 | settings.configure(
|
|---|
| 17 | SECRET_KEY="??????????????????????????????????????????????????????????",
|
|---|
| 18 | DEBUG=True,
|
|---|
| 19 | INSTALLED_APPS=(),
|
|---|
| 20 | ALLOWED_HOSTS=("*"),
|
|---|
| 21 | ROOT_URLCONF=__name__,
|
|---|
| 22 | MIDDLEWARE=[],
|
|---|
| 23 | TEMPLATES=[
|
|---|
| 24 | {
|
|---|
| 25 | "BACKEND": "django.template.backends.django.DjangoTemplates",
|
|---|
| 26 | "DIRS": [],
|
|---|
| 27 | "APP_DIRS": False,
|
|---|
| 28 | "OPTIONS": {
|
|---|
| 29 | "context_processors": [],
|
|---|
| 30 | "libraries": {
|
|---|
| 31 | "my_custom_filters": __name__,
|
|---|
| 32 | },
|
|---|
| 33 | },
|
|---|
| 34 | },
|
|---|
| 35 | ],
|
|---|
| 36 | )
|
|---|
| 37 | django.setup()
|
|---|
| 38 |
|
|---|
| 39 |
|
|---|
| 40 | class MyModel:
|
|---|
| 41 | def get_user_value1(self):
|
|---|
| 42 | # User controlled data, but for some reason has been marked as safe.
|
|---|
| 43 | # Always risky! But I need a way to provide a SafeString to the
|
|---|
| 44 | # filter.
|
|---|
| 45 | # Perhaps I've copied it from production to investigate what's going on.
|
|---|
| 46 | return mark_safe("<script>alert('good')</script>")
|
|---|
| 47 |
|
|---|
| 48 | def get_user_value2(self):
|
|---|
| 49 | return mark_safe("<script>alert('bad')</script>")
|
|---|
| 50 |
|
|---|
| 51 |
|
|---|
| 52 | register = template.Library()
|
|---|
| 53 |
|
|---|
| 54 |
|
|---|
| 55 | @register.filter("test_filter")
|
|---|
| 56 | def test_filter(val, arg):
|
|---|
| 57 | """
|
|---|
| 58 | Another contrived thing...
|
|---|
| 59 | If I can get user controlled data to become a SafeString, and force
|
|---|
| 60 | an exception to occur with the value as args[0]
|
|---|
| 61 | """
|
|---|
| 62 |
|
|---|
| 63 | # This is where 'the magic' happens; if .partition (or 'rpartition') don't
|
|---|
| 64 | # find the separator, they return the whole original(!) value as [0] (or [2])
|
|---|
| 65 | #
|
|---|
| 66 | # The only other method I can find to not actually coerce to a string is
|
|---|
| 67 | # SafeString('...').format(x=x) where format does no replacements, and
|
|---|
| 68 | # f'{mysafestring}' where the f-string has literally no other characters...
|
|---|
| 69 | parts = list(arg.partition("'good'")) # I could be splitting by anything.
|
|---|
| 70 |
|
|---|
| 71 | # I'm a person who doesn't want things to silently fail in dev, because
|
|---|
| 72 | # then they'll silently fail in production.
|
|---|
| 73 | # (I actually _am_ that person it turns out, given I wrote
|
|---|
| 74 | # https://github.com/kezabelle/django-shouty-templates to avoid that
|
|---|
| 75 | # happening. I would be raising an exception, but probably not quite like
|
|---|
| 76 | # this.)
|
|---|
| 77 | if settings.DEBUG and not parts[2]:
|
|---|
| 78 | # I'm for some reason choosing to rely on exceptions accepting varargs
|
|---|
| 79 | # and binding those into .args - this gets me args[0] as a SafeString
|
|---|
| 80 | # Note that the asterisk expansion is required, even though the str()
|
|---|
| 81 | # output looks the same!
|
|---|
| 82 | raise ValueError(*parts)
|
|---|
| 83 | raise ValueError(arg) # This would also work, I guess.
|
|---|
| 84 | raise ValueError(f'{arg}') # As would this ...
|
|---|
| 85 | # Here I might be mutating the value, which is fine because the act of
|
|---|
| 86 | # combining a str() + SafeString() leads to a str(). So it needs marking
|
|---|
| 87 | # as safe again in the template...
|
|---|
| 88 | parts.insert(0, val)
|
|---|
| 89 | return "".join(parts)
|
|---|
| 90 |
|
|---|
| 91 |
|
|---|
| 92 | def via_filter_looks_safe(request) -> HttpResponse:
|
|---|
| 93 | template = Template(
|
|---|
| 94 | """
|
|---|
| 95 | {% load my_custom_filters %}
|
|---|
| 96 | {{ 'safe text or variable'|test_filter:object.get_user_value1 }}
|
|---|
| 97 | """
|
|---|
| 98 | )
|
|---|
| 99 | return HttpResponse(template.render(Context({
|
|---|
| 100 | "object": MyModel(),
|
|---|
| 101 | })))
|
|---|
| 102 |
|
|---|
| 103 |
|
|---|
| 104 | def via_filter(request) -> HttpResponse:
|
|---|
| 105 | template = Template(
|
|---|
| 106 | """
|
|---|
| 107 | {% load my_custom_filters %}
|
|---|
| 108 | {{ 'safe text or variable'|test_filter:object.get_user_value2 }}
|
|---|
| 109 | """
|
|---|
| 110 | )
|
|---|
| 111 |
|
|---|
| 112 | return HttpResponse(template.render(Context({
|
|---|
| 113 | "object": MyModel(),
|
|---|
| 114 | })))
|
|---|
| 115 |
|
|---|
| 116 |
|
|---|
| 117 | urlpatterns = [
|
|---|
| 118 | path("good", via_filter_looks_safe),
|
|---|
| 119 | path("bad", via_filter),
|
|---|
| 120 | ]
|
|---|
| 121 |
|
|---|
| 122 |
|
|---|
| 123 | if __name__ == "__main__":
|
|---|
| 124 | from django.core import management
|
|---|
| 125 |
|
|---|
| 126 | management.execute_from_command_line()
|
|---|
| 127 | else:
|
|---|
| 128 | from django.core.wsgi import get_wsgi_application
|
|---|
| 129 |
|
|---|
| 130 | application = get_wsgi_application()
|
|---|