0%

greatwall2021

长城杯2021决赛

10月11号比的赛,9号才知道自己进了靠着顺延进了线下,战队因为没人想去最后被拉去打awdp了,这是第一次打awdp,题目质量挺高的,比赛过程中踩了很多坑,但是收获很大的~感谢我的师父elegant-crazy和队友triplewings,算是一次难忘的线下经历!

fancyapi

源码里有两个文件夹,一个是python,一个是go,经过阅读代码发现内网环境有两个服务,内网端口8000是python开的,映射到对外端口;内网端口5000是一个go服务。这个python代码就相当于一个中转站,接受外网的请求,经过自己的一些处理后转发给go,让go进行最底层的处理。

关键代码为go中的backend.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package controller

import (
db "ctf/database"
"encoding/json"
"fmt"
"github.com/buger/jsonparser"
"io/ioutil"
"net/http"
)

type Language struct {
Id int32 `json:"id"`
Name string `json:"name"`
Votes int64 `json:"votes"`
}


func Index(w http.ResponseWriter, _ *http.Request) {
ok(w, "Hello World!")
}

func List(w http.ResponseWriter, _ *http.Request) {

rows, err := db.Sqlite.Query("SELECT * FROM languages;")
if err != nil {
fail(w, "Something wrong")
fmt.Println(err.Error())
return
}
defer rows.Close()

res := make([]Language, 0)
for rows.Next() {
var pl Language
_ = rows.Scan(&pl.Id, &pl.Name, &pl.Votes)
res = append(res, pl)
}
err = json.NewEncoder(w).Encode(res)
}

func Search(w http.ResponseWriter, r *http.Request) {
reqBody, _ := ioutil.ReadAll(r.Body)

votes, err := jsonparser.GetInt(reqBody, "votes")
if err != nil {
fail(w, "Error reading votes")
return
}
name, err := jsonparser.GetString(reqBody, "name")
if err != nil {
fail(w, "Error reading name")
return
}

query := fmt.Sprintf("SELECT * FROM languages WHERE votes >= %d OR name LIKE '%s';", votes, name)
rows, err := db.Sqlite.Query(query)
if err != nil {
fail(w, "Something wrong")
fmt.Println(err.Error())
return
}
res := make([]Language, 0)
for rows.Next() {
var pl Language
_ = rows.Scan(&pl.Id, &pl.Name, &pl.Votes)
res = append(res, pl)
}
err = json.NewEncoder(w).Encode(res)
}


func Flag(w http.ResponseWriter, r *http.Request ) {
action:= r.URL.Query().Get("action")
if action == "" {
fail(w, "Error getting action")
return
}

token:= r.URL.Query().Get("token")
if token == "" {
fail(w, "Error getting token")
return
}

var secret string
row := db.Sqlite.QueryRow("SELECT secret FROM token;")
if err := row.Scan(&secret); err != nil {
fail(w, "Error querying secret token")
return
}

if action == "readFlag" && secret == token {
data, err := ioutil.ReadFile("flag")
if err != nil {
fail(w, "Error reading flag")
return
}
ok(w, fmt.Sprintf("Congrats this is your flag: %s", string(data)))
return
}
ok(w, "Wrong token")
}

路由中的/flag里面get两个参数,?action=readFlag&token=xxxxx如果token对了的话就会拿到flag,怎么拿token也是很容易发现的,query := fmt.Sprintf("SELECT * FROM languages WHERE votes >= %d OR name LIKE '%s';", votes, name)存在sql注入漏洞,所以这题思路很明确,sql注入拿到token,然后直接readflag。

然后是python服务中的app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
from flask import Flask, request, render_template, jsonify
from urllib.parse import unquote
import requests

app = Flask(__name__)

server = '127.0.0.1:8000'


@app.route("/", methods=["GET"])
def index():
return render_template("index.html")


@app.route("/list", methods=["POST"])
def listAll():
r = requests.post(f"http://{server}/api/list")
return jsonify(r.json())


@app.route("/search", methods=["GET", "POST"])
def search():
if request.method == "GET":
return render_template("search.html")
else:
data = request.json
if data['name']:
if not isinstance(data['name'], str) or not data['name'].isalnum():
if not ('\'' in data['name']):
return jsonify({"error": "Bad word detected"})
if data['votes']:
if not isinstance(data['votes'], int):
return jsonify({"error": "Bad word detected"})
r = requests.post(f"http://{server}/api/search", data=request.data)
return jsonify(r.json())


@app.route("/healthcheck", methods=["GET"])
def healthCheck():
getPath = ["", "flag"]
postPath = ["api/list", "api/search"]
try:
for path in getPath:
requests.get(f"http://{server}/{path}")
for path in postPath:
requests.post(f"http://{server}/{path}")
except:
return "Down"
return "OK"


@app.route("/<path:path>", methods=["GET"])
def handle(path):
if 'flag' in unquote(path):
action = request.args.get('action')
token = request.args.get('token')
print(action)
if action == "readFlag":
return jsonify({"error": "Sorry, readFlag is not permitted"})
r = requests.get(f"http://{server}/{path}", params={
"action": action,
"token": token
})
else:
r = requests.get(f"http://{server}/{path}")
return jsonify(r.text)


if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

很不巧,我们刚才的思路全被这个python打断了,原因有二:

  • 直接检测如果url里get了action=readFlag就给打断了
  • /search里对json进行了严格过滤,name必须是isalnum()(只能是字母和数字),votes必须是数字,这个也把sql注入的问题给修好了

@app.route("/<path:path>")这种路由定义的是一种叫path的类型的path,查阅官方文档,path like the default but also accepts slashes,是一种允许斜杠的路径,也就是说任何路由都会匹配进这个handle函数里,这不禁让我们想到了rctf2021的那个easyphp,利用nginx转发给php-fpm这个中间过程能够进行一些编码绕过,于是在这里也尝试对?进行url编码,payload为/flag%3faction=readFlag&token=,这样在python层面,%3f会经过一层编码,会理解成url而不是get标志,整个path的内容也是/flag?action=readFlag&tokenaction = request.args.get('action')就无法获取到readFlag的内容,这个请求被转发到了GO,GO的处理器就会认识?了,就会把action=readFlag解析到了,于是我们绕过了第一层,开始看第二层的sql注入。

由于python层面已经把name和vote的类型限制死了,我们尝试构造含有两个name参数的json,看看python和go对这个json的理解是否相同。{“vote”:0,”name”:”java”,”name”:”‘’”}发现,在python的理解里,会认识json的第一个name,而在go会认为是json的第二个name,于是我们构造在第一个name输入正常的字符串,在第二个name里构造sql语句进行注入即可,比赛的时候用的是联合注入。

最终的payload:(我也记得不太清了){"vote":0,"name":"java","name":"java' union select 1,secret,1 from token where '1'='1"},就能拿到token了,之后带着token访问/flag%3faction=readFlag&token=就好了。

fix

比赛的时候起了个简单的flask项目做实验,发现只要检查path就好,我传进来/flag%3faction=readFlag&token=会被python解析为path=/flag?action=readFlag&token=

所以直接把readFlag在path里禁了就好。

1
2
if action == "readFlag" or 'readFlag' in path:
return jsonify({"error": "Sorry, readFlag is not permitted"})

work

一道java题,用的是springboot,幸好假期有一丢丢springboot的开发经验,看起来就快了很多。直接看关键代码,有几个比较关键的代码login,register和adminmanager,adminmanager要求session是admin,所以第一步是用admin登进去。然后仔细看login和register都是通过一种很屑的方式进行注册和登录,直接?user={"username":"hacker","password":"guess"},但这样会400bad request,url编码一下?user=%7b%22username%22%3a%22hacker%22%2c%22password%22%3a%22guess%22%7d就能注册一个叫hacker的用户了。如何注册admin呢?

1
Pattern pattern = Pattern.compile("\"username\"\:\"(.*?)\"");

register代码会进行一个正则过滤,把抓到的部分替换成username:hacker,这样不让你注册admin,绕过也很简单,直接在username:admin前加个空格就能绕了,之后直接?user=%7b%22username%22%3a%22%20admin%22%2c%22password%22%3a%22guess%22%7d就能注册管理员了。

进到adminmanager里,我们可控的参数是url,name和pwd,url要填jdbc的url,name和pwd都是你连的数据库的用户名和密码,我们在register里看到了这样的代码:

1
JdbcUtils jdbcUtils = new JdbcUtils("jdbc:mysql://127.0.0.1:3306/www?serverTimezone=UTC", "root", "root");

大概就是链接本地的mysql然后读取所有的用户名密码,还会读取一下/tmp/admin的文件内容。

能任意控制jdbc连接了,我们就用rogue mysql server在本地搭建一个恶意mysql服务器,控制靶机连进来,然后就能任意文件读了。

fix

修改正则,在冒号前允许空白字符的出现,就能禁止用户注册了。

1
Pattern pattern = Pattern.compile("\"username\"\b*\:\b*\"(.*?)\"");

quotes

nodejs题,上来在router/index.js里写了就看到了触动dna的代码:

1
2
3
4
5
6
7
8
9
10
if (Array.isArray(infos) && infos.length) {
infos.forEach((info, id) => {
let quote = { id: id + 1 };
Object.keys(info).forEach((key) => {
utils.set(quote, key, info[key]);
});
quotes.push(quote);
});
}

看到set就大概率原型链污染了,但是utils.js又看到了更脑溢血的一幕

1
2
3
4
5
6
7
8
9
10
11
12
const banWords = [
/constructor/i,
/prototype/i,
/__proto__/i,
/flag/i,
];

const set = (object, path, val, obj) => {
return !/^(__proto__|constructor|prototype).*$/.test(path) && ((path = path.split ? path.split('.') : path.slice(0)).slice(0, -1).reduce( (obj, p) => {
return obj[p] = obj[p] || {};
}, obj = object)[path.pop()] = val), object;
}

比完赛后问天枢,天枢告诉我就是原型链污染,但是要绕过,就利用下面的

1
2
3
4
5
6
7
8
9
10
11
12
13
const check = (input) => {
const percentEncoded = /%[a-fA-F0-9]{2}/i.test(input);
if (percentEncoded) {
return check(decodeURIComponent(input));
}

for (const bad of banWords) {
if (bad.test(input)) {
return true;
}
}
return false;
}

用%ff让decodeURIComponent失败,就能绕了(又是一个没听说过的姿势)

原型链污染后估计还是得用redis去RCE(可惜没环境没法复现了。

整体复盘

三道web三道pwn,队友神速patch好了两道pwn,俩web狗先是找到了fancyapi的洞,我负责修,elegant-crazy负责继续往下打,但是发现死活修不对,我十分肯定的是我源码修的绝对没有问题,就是那个update.sh,由于之前从来没打过awdp,以为就是国赛的break&fix一样替换一下代码然后运行一下某个shell script就好了,结果发现不是,我需要先pkill掉正在运行的web进程,然后替换代码,最后再重新起web服务,但是我又无法接触靶机,又不知道我这种执行方式能不能让web服务起来,只能通过平台告诉我的“exp利用成功”告诉我写的有问题,就这样荒废了每道题仅有的十次修补机会。

比完赛后问天枢,告诉我们首先要python3而不是python(这是他们问主办方的),然后还要nohup后台运行,否则那个check脚本就会一直卡住,听了之后人都傻了,居然还要这样。

1
2
3
pkill -u user
cat app.py > /home/user/app.py
nohup python3 /home/user/app.py > res.file 2>&1 &

这个仅仅是python的,碰到那个java题更是难受,需要自己传jar包上去,虽然都代码怎么改是对的,但就是修不动。

总而言之第一次awdp因为不太懂Update.sh的问题错失了一道或者两道题的修补机会,少上了好多分,要是之前打过的话有经验的话就应该能进前四了。

另外awdp不让上网是真的难受,打web不上网,全靠一个脑子积累,碰到新的知识点只能干瞪眼。。。以后电脑里还是要多备这种类似rogue mysql server的神仙脚本吧。