技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

WriteUp 3周前 admin
16 0 0

技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

听我说

技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


这是一道2024年Real world CTF 0解题,人称“薛定谔”。如果你对CTF比赛、漏洞分析与挖掘、Windows操作系统特性这些领域感兴趣,这篇文章绝对不容错过!


在本文中,作者深入剖析了2024年Real world CTF 6th比赛中挑战难度很高的本地提权(Local Privilege Escalation)题目:PyGhost,同时涵盖了对 CVE-2023-49797 漏洞的详细分析。


总体而言,本题的难度在于:

1、题目蕴藏某组件历史漏洞,如何在不了解该漏洞的情况下做出分析,进而利用?

2、Windows操作系统特性的考察也是本题的一道关卡。解题过程中,需要利用一些windows的文件操作相关特性来完成,不熟悉或不了解该领域的相关知识,都将造成难以通关的局面。

话不多说,开始挑战它!

正文揭晓





01
背景

本题目来自2024 年RealworldCTF 6th。题目类型为Pwn, Misc。题目难度为薛定谔。题目在比赛结束时解出人数为0。此文章是对此题目的赛后复盘。

本题的题目描述是:


This is an LPE(Local Privilege Escalation) challenge. Your task is to pop a highly-privileged(nt authoritysystem) cmd.exe as a low-privileged user. Follow these steps to deploy the challenge locally:


  1. download and install the virtual machine from: https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/

  2. execute the installer (installer.exe in the attachment) as Administrator

  3. the installer will set up the vulnerable component. You can then attempt to find the vulnerability and exploit it

Notes about the demo:

  1. Send your exploit archive file to email and DM on Discord when you’re ready. Meanwhile, the email should also contains your team name and team token

  2. You can choose to demo your exploit publicly or privately, according to your preference. If you choose to demo publicly, the entire process will be visible to everyone, so remember to remove sensitive information. If you choose to demo privately, we will set up a private discord channel that only includes the admin and your team members

  3. Our demo VM is slightly configured, including:

    a. Windows Defender is disabled. You don’t have to contend with it.

    b. A standard user(not in the Administrator group, with the username being ctf) is created for demo purposes. We will run your exploit in the context of the standard user.

  4. If your exploit needs multiple steps, please batch them in a single file. We will only execute one of your files and then wait for the result without more user interaction

I will not accept more than 3 emails per team. If you really need more, you will need to explain to me in detail why you messed up your first 3 tries and convince me that you deserve a 4th chance.


The running time for each try cannot exceed 3 minutes.

I will reward you with the flag if the highly-privileged cmd.exe pops up.



此题目需要选手在Windows平台上以BUILTINUsers组用户权限执行一系列操作,获得一个以NT AUTHORITYSYSTEM权限运行的cmd.exe。从题目描述中我们不难分析得,installer.exe以Administrator权限运行后会注册成一个漏洞组件,可以初步猜测选手需要写程序与这个组件进行交互,在此过程中劫持某些资源,来获得高执行权限。


02
题目分析

用虚拟机运行此安装程序,在运行前打开procmon监控程序的行为。可以看到程序访问了sc.exe,说明此漏洞组件是与Windows服务相关的。


技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


使用service.msc寻找蛛丝马迹,找到了一个叫try to hack me的服务。此服务的服务名称是RWCTF,可执行路径是C:WindowsTempserver.exe。


技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


定位到server.exe,提取,发现是一个pyinstaller打包的.exe程序。

技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

Pyinstaller版本尚不明确,程序功能尚不明确。

此时使用procexp可以看到server.exe的运行权限是NT AUTHORITYSYSTEM的,证明高权限的任意写入/重命名/删除是可能存在的。

之后的任务就是python逆向,这一部分省略。直接给出逆向代码:


```pythonimport socketimport win32serviceutilimport servicemanagerimport win32eventimport win32serviceimport win32pipeimport win32securityimport win32fileimport pywintypesimport randomimport matplotlib.pyplot as pltimport numpy as npimport osimport sysimport shutil

class SMWinservice(win32serviceutil.ServiceFramework): _svc_name_ = "RWCTF" _svc_display_name_ = 'try to hack me' _svc_description_ = ''
@classmethod def parse_command_line(cls): if len(sys.argv) == 1: servicemanager.Initialize() servicemanager.PrepareToHostSingle() servicemanager.StartServiceCtrlDispatcher() win32serviceutil.HandleCommandLine(cls) else: win32serviceutil.HandleCommandLine(cls) def __init__(self, args): win32serviceutil.ServiceFramework.__init__(self, args) self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) socket.setdefaulttimeout(60) def SvcStop(self): self.stop() self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) win32event.SetEvent(self.hWaitStop) def SvcDoRun(self): self.start() servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, servicemanager.PYS_SERVICE_STARTED, self._svc_name_) self.main() def start(self): pass def stop(self): pass def main(self): pass

class RWCTFService(SMWinservice): __qualname__ = 'RWCTFService' _svc_name_ = 'RWCTF' _svc_display_name_ = 'try to hack me' _svc_description_ = ''
def start(self): self.LICENSE = 'n The Star And Thank Author License (SATA)n Version 2.0, April 2021nnCopyright <2024> <RWCTF>([email protected])nnProject Url: https://realworldctf.com/nnPermission is hereby granted, free of charge, to any person obtaining a copynof this software and associated documentation files (the "Software"), to dealnin the Software without restriction, including without limitation the rightsnto use, copy, modify, merge, publish, distribute, sublicense, and/or sellncopies of the Software, and to permit persons to whom the Software isnfurnished to do so, subject to the following conditions:nnThe above copyright notice and this permission notice shall be included innall copies or substantial portions of the Software.nnAnd wait, the most important, you should star/+1/like the project(s) in project urlnsection above first, and then thank the author(s) in Copyright section.nnHere are some suggested ways:nn - Email the authors a thank-you letter, and make friends with him/her/them.n - Report bugs or issues.n - Tell friends what a wonderful project this is.n - And, sure, you can just express thanks in your mind without telling the world.nnContributors of this project by forking have the option to add his/her name andnforked project url at copyright and project url sections, but shall not deletenor modify anything else in these two sections.nnTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORnIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEnAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERnLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS INnTHE SOFTWARE.n' self.output_directory = 'C:\Windows\Temp' self.output_prefix = 'C:\Windows\Temp\res.' self.isrunning = True def stop(self): try: self.isrunning = False except FileNotFoundError: self.isrunning = False except: raise def challenge(self, option, key): RAND_32_64 = lambda: random.randint(32, 64) nonce = '' if option == b'png': nonce = random.getrandbits(RAND_32_64()) + 1 elif option == b'jpg': nonce = random.getrandbits(RAND_32_64()) * 2 elif option == b'eps': nonce = random.getrandbits(RAND_32_64()) + 4660 elif option == b'svg': nonce = random.getrandbits(RAND_32_64()) - 61769 elif option == b'pgf': nonce = random.getrandbits(RAND_32_64()) % random.getrandbits(RAND_32_64()) elif option == b'pdf': nonce = random.getrandbits(RAND_32_64()) // random.getrantbits(random.randint(2, 10)) else: return False if key == nonce: return True else: return False def loop(self, pipeHandle, data): RAND_0_100 = lambda: random.randint(0, 100) pieces = data.split(b'|') option = pieces[1] key = int(pieces[2]) if option == b'png': if self.challenge(option, key): x = np.linspace(0, 10, 200) plt.plot(x, np.sin(x)) plt.plot(x, np.cos(x)) plt.savefig(self.output_prefix + str(RAND_0_100()) + '.png') return None elif option == b'jpg': if self.challenge(option, key): l = [1, 2, 3, 4] plt.plot(l) plt.ylabel('some number') plt.savefig(self.output_prefix + str(RAND_0_100()) + '.jpg') return None elif option == b'svg': if self.challenge(option, key): x = ['A', 'B', 'C', 'D'] y = [3, 8, 1, 10] plt.bar(x, y) plt.savefig(self.output_prefix + str(RAND_0_100()) + '.svg') return None elif option == b'pdf': if self.challenge(option, key): x = [1, 2, 3, 4, 5] y = [1, 4, 9, 16, 25] plt.figure() plt.plot(x, y) plt.title('My first matplotlib plot') plt.xlabel('X') plt.ylabel('Y') plt.savefig(self.output_prefix + str(RAND_0_100()) + '.pdf') return None elif option == b'LICENSE': win32file.WriteFile(pipeHandle, 'Please read the LICENSE carefully: n' + self.LICENSE) return None elif option == b'I give up': pipeHandle.Close() self.stop() return None else: return 0 """ 通过反汇编能看出有这段,但是 try catch 的位置还没确认怎么放,不过这个影响不大 try: except Exception as e: servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, servicemanager.PYS_SERVICE_STARTED, (self._svc_name_, str(e))) e = None del e else: e = None del e """ def main(self): while self.isrunning: pipeName = '\\.\pipe\rwctf' openMode = win32pipe.PIPE_ACCESS_DUPLEX | win32file.FILE_FLAG_OVERLAPPED pipeMode = win32pipe.PIPE_TYPE_MESSAGE ACL = 'D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;AU)' sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor(ACL, win32security.SDDL_REVISION_1) sa = pywintypes.SECURITY_ATTRIBUTES() sa.SECURITY_DESCRIPTOR = sd pipeHandle = win32pipe.CreateNamedPipe( pipeName, openMode, pipeMode, win32pipe.PIPE_UNLIMITED_INSTANCES, 0, 0, 6000, sa) # !!! 待检查 while self.isrunning: hr = win32pipe.ConnectNamedPipe(pipeHandle, None) hr, data = win32file.ReadFile(pipeHandle, 256) self.loop(pipeHandle, data) win32pipe.DisconnectNamedPipe(pipeHandle) """ 整个结构应该是包含在 while 里面的 同样也有一个 try-catch 虽然不确定位置但是影响不大 while self.isrunning: # ... while self.isrunning: # ... return None try: except Exception as details: NULL + print('Error connecting pipe!', details) pipeHandle.NULL | self + Close() details = None del details continue else: details = None del details """
if __name__ == "__main__": RWCTFService.parse_command_line()```


代码实现了一个根据传入的信息进行matplotlib画图的功能,代码本身的价值不大。由于过于专注于matplotlib的写入部分,导致本人做题时没有去关注服务本身的问题。通过分析代码,基本可以得出通过challenge基本是不可能的。因此不可能通过文件写入的方式进行提权。用到的部分仅仅是“I give up”结束服务的过程中文件夹的删除,以下是相关的分析过程:


03
Pyinstaller 漏洞分析

此时去考虑1day。通过赛后复盘找到了pyinstaller导致的Windows提权CVE:https://github.com/advisories/GHSA-9w2p-rh8c-v9g5


漏洞中的描述如下:

  1. 用户在代码中使用了matplotlib或者win32com

  2. 程序以Administrator运行(或者至少比攻击者权限高)

  3. 用户的临时文件夹不属于特定用户(TMP/TEMP指向一个Public的,不受保护的文件夹)


达成这些条件,攻击者就可以在cpython的is_symlink和rmtree之间将一个文件夹变成symlink。具体分析如下:

(https://github.com/python/cpython/blob/0fb18b02c8ad56299d6a2910be0bab8ad601ef24/Lib/shutil.py#L623)


def _rmtree_unsafe(path, onexc): try: with os.scandir(path) as scandir_it: entries = list(scandir_it) except OSError as err: onexc(os.scandir, path, err) entries = [] for entry in entries: fullname = entry.path try: is_dir = entry.is_dir(follow_symlinks=False) except OSError: is_dir = False
if is_dir and not entry.is_junction(): try: if entry.is_symlink(): # This can only happen if someone replaces # a directory with a symlink after the call to # os.scandir or entry.is_dir above. raise OSError("Cannot call rmtree on a symbolic link") except OSError as err: onexc(os.path.islink, fullname, err) continue _rmtree_unsafe(fullname, onexc) else: try: os.unlink(fullname) except OSError as err: onexc(os.unlink, fullname, err) try: os.rmdir(path) except OSError as err: onexc(os.rmdir, path, err)

此函数使用os.scandir来获取文件夹内容,存入一个list中。然后开始检查文件夹/文件。当检查到文件夹,并且不是junction时,为了防止symlink攻击,又检查了一次symlink。之后递归调用此函数来删除临时文件夹。


那么我们可以构造一个文件夹test:

  1. 代码检查test为文件夹,不是junction,并且不是symlink,通过检查。

  2. 代码开始删除test下的文件。

  3. 在删除的过程中,test被替换成symlink。

  4. 由于test下的文件已经被list记录,那么在程序删除这个文件之前主动删除掉这个文件,程序还会再尝试删除这个文件。


这样就可以轻松地绕过symlink检查从而达成一次任意文件删除。


此漏洞描述中的2已经证实。程序在C:WindowsTemp进行临时文件读写,因此3也满足。条件1也是满足的,但是在本题中的作用没有体现出来。需要回到题目中分析这个点。


注意到Temp中除了server.exe,pyinstaller解压文件夹。还有一个以tmp开头的文件夹被最近创建,进入查看可以看到fontlist-v330.json文件,这正是matplotlib创建的临时文件夹。结合pyinstaller漏洞,我们大胆猜测这个服务在结束时会删除这个文件夹。开启procmon验证想法,得证:

技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

三个条件都得到验证,因此可以进行利用。


但是当以普通权限用户访问此文件夹时会出现权限问题:


技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


那么漏洞利用就到此为止了吗?其实不然。


打开acl列表查看相关权限,可以得出Users组的权限继承自C:WindowsTemp,而这个目录是一个典型的可写不可读的目录。因为此目录中有许多随机文件名,微软为了避免有恶意程序预测文件名导致攻击,将C:WindowsTemp设置成了可写不可读。因此尝试在server.exe的tmp目录中建立一个工作目录test,在其中布置自己的文件,得证。


技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


那么根据我们之前的分析,按照时间顺序列出我们的劫持计划,利用过程就非常清晰了:

  1. 建立 tmptest。

  2. 布置大量文件”%d.txt”%(x : 1-998),999(DIR)为竞争做准备

  3. 在1.txt上设置oplock。

  4. 通过pipe与服务交互,使其停止服务,等待临时文件夹删除。

  5. 当oplock触发时,回调函数中直接删除其他所有文件。

  6. 释放oplock,此时tmptest为空,可以将test变为reparse point。

  7. 将tmptest指向RPC Control,将RPC Control999指向待删除文件夹。

  8. 等待目标文件夹被删除。

  9. 当oplock触发时,server.exe试图删除test下的所有文件,即使不存在。那么我们之前建立的大量垃圾文件可以趁此获得竞争窗口期。直接删除其他文件后,oplock一旦释放,test就会变成空文件夹,从而使得此文件夹可以替换成symlink。结合前面的删除操作,目标文件夹会被以高权限删除,导致漏洞。


本地测试时可以使用googleprojectzero的symbolic-testing-tools来测试。参考命令:

  1. SetOplock.exe C:WindowsTemptmpapa32hjotest1.txt d

  2. CreateSymlink.exe C:WindowsTemptmpapa32hjotest999 C:Config.Msi

功能分别是:

  1. 给1.txt上oplock,触发时会阻塞相关的进程。

  2. CreateSymlink.exe可以完成整个第7步,将C:WindowsTemptmpapa32hjotest指向RPC Control,将RPC Control999指向C:Config.Msi


Windows Symlink Attack的基本知识可以搜索James Forshaw的slides进行学习。


这样布置之后,在删除到999时,返回结果REPARSE,导向了C:Config.Msi(symlink指向的文件夹),那么C:Config.Msi就会被高权限删除。


那么如何弹一个cmd.exe呢?既然我们只有一个任意文件删除,这里直接用zdi-team的FilesystemEoPs即可。这个程序是通过高权限删除Config.Msi之后开启msi安装服务进行竞争得到提权的,具体细节见zdi-team的文章。由于我们可以控制tmp中的文件类型,因此任意文件/任意文件删除都是可以的。正好契合此提权程序。


04
总结

本题在Windows 11 Pro 23H2 26040.1000 上复现成功,获得了一个SYSTEM权限的cmd.exe。


技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!


Windows的NTFS权限控制不像POSIX一样。在考虑类型、权限、操作三者之前,一个文件会被如何写入/移动/删除,是不确定的。本题中考虑到了很多点,需要选手对Windows系统非常熟悉,并且需要精心布置文件夹结构。笔者在本题中学到了比较多,也希望大家能够有所收获。





END



点击卡片👇  get本期征稿详情
技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

原文始发于微信公众号(长亭安全观察):技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权!

版权声明:admin 发表于 2024年3月21日 上午11:01。
转载请注明:技术说|挑战0解薛定谔!RWCTF6th PyGhost——symlink提权! | CTF导航

相关文章