将零距离Shell流传输到您的智能音箱 | RET2系统博客
工程博客
流式传输零距离Shell到您的智能音箱 利用恶意HLS播放列表攻破Sonos Era 300
2025年6月11日 / Jack Dates & Markus Gaasedelen
2024年10月,RET2参加了Pwn2Own的“小型办公室/家庭办公室”(SOHO)类别竞赛,这项竞赛挑战顶级安全研究人员去攻破面向消费者的网络设备。这包括流行的智能音箱、路由器、IP摄像头、打印机和网络附加存储(NAS)设备。
我们今年设定的两个目标设备之一就是Sonos Era 300,这是一款零售价约500美元的高端智能音箱。Sonos部署了多种安全技术来建立植根于硬件的信任链,保护设备特定的秘密(ARM Trustzone, eFuses),并加固其主运行时服务以防止被主动利用。对于这个历史上被嘲笑为“垃圾破解”的设备类别来说,情况无疑已经发生了变化。
在本文中,我们将探讨如何借鉴过去的研究成果在设备上获得立足点,这为我们提供了必要的内省能力,从而发现并利用一个位于可远程访问、无需认证的攻击面上的强大内存破坏漏洞(CVE-2025-1050),并最终为我们的漏洞利用赢得了比赛提供的60,000美元奖金。
Jack Dates(最右)在Pwn2Own 2024上用零日漏洞攻击Sonos Era 300
设备侦察
在参加任何Pwn2Own比赛前,我们都会权衡几个因素来决定要攻击哪些设备。这可能包括设备对我们个人的吸引力、分配给它的奖金、我们预计会有多少其他竞争者研究它,以及我们预计研究所需的时间。
我们选择Sonos智能音箱,主要是因为该产品系列近期有一些公开研究,似乎可以作为起步的有用背景资料。虽然还没有针对Sonos Era 300的明确研究,但我们购买了它,并假设可能有一些值得探测漏洞的新功能。
之前Pwn2Own中针对Sonos的参赛项目也都是内存破坏漏洞,这意味着远程攻击面涉及原生代码,这与我们的技能和经验相符。
ZDI列出的Pwn2Own 2024智能音箱设备及其相应奖金
在等待设备到达期间,我们仔细记录并回顾了以下研究成果:
- 《Shooting Yourself in the .flags – Jailbreaking the Sonos Era 100》
- 《A 3-Year Tale of Hacking a Pwn2Own Target》
- 《A Journey To Pwn And Own The Sonos One Speaker》
- 《Dumping the Amlogic A113X Bootrom》
- 《Smart Speaker Shenanigans: Making The SONOS One Sing》
- 《Exploiting the Sonos One Speaker Three Different Ways: A Pwn2Own Toronto Highlight》
- …
虽然很明显Sonos产品过去确实得到了高质量研究人员的关注,但能够基于先前研究开展工作并不意味着它就是一个容易攻克的目标。
例如,Synacktiv描述了Sonos如何在他们2021年的参赛后确保启用所有主要的基于编译器的缓解措施(Cookies, PIE, RELRO),而Orange Tsai推测Sonos在2022年正在积极地监控和修补研究人员产生的崩溃。NCC在2023年还消耗掉了几个重要的引导加载程序漏洞,这些漏洞本应对在Sonos Era 300上建立立足点非常有用。
设备拆解
拆解Sonos Era 300有点棘手,但我们关心的所有东西都集中在一块主逻辑板上。“PSU”(电源单元)直接集成在主板上,而不是一个独立或模块化的单元,这有点不寻常,并且使得在实验台上处理逻辑侧时更加危险,因为高压元件暴露在外。
设备的装配和精密度非常高,以至于我们基本上放弃了重新组装设备的可能。
Sonos Era 300的主逻辑板+PSU组合
取下屏蔽罩后,板的顶部有两个值得关注的芯片:一个Mediatek MT7921(Wi-Fi/蓝牙)和一个8GB的金士顿EMMC/闪存芯片。我们最终没有为Pwn2Own对Mediatek芯片组进行任何硬件或软件研究,因此不再进一步讨论它。
Sonos Era 300主逻辑板顶部的Mediatek MT7921射频芯片和金士顿EMMC
在板的底部,我们找到了标记为‘S767e’的主CPU和更多屏蔽罩下的RAM芯片。
Sonos Era 300主逻辑板底部的CPU+RAM芯片
根据一些谷歌搜索和之前研究者的工作,CPU似乎是一颗Amlogic芯片,推测是S905X3或与之非常接近的型号。即使我们找不到确切的芯片,拥有同一芯片系列/供应商的数据手册也能提供相当多的见解。
读写EMMC闪存
我们的首要任务是从工厂全新的设备中获取8GB EMMC的完整转储。这很有用,原因有很多:它可能是一个较旧的固件,在网上或其他地方可能很难或无法找到;它也提供了一个干净的已知良好备份,以防我们意外更新设备或使其变砖。
为此,我们需要定位板上的一些信号引脚。
我们拍摄了几张PCB的照片,并开始根据数据手册(经过拉伸、扭曲和缩放)在芯片封装应处的位置叠加焊球图。这为我们提供了一个粗略的地图,显示信号可能从板的另一面引出的位置。
使用Photoshop标记板子顶部/底部,以映射EMMC信号到CPU的路径
这种方法通常效果很好,结合对EMMC信号从何处引出以及它们如何根据CPU焊球布局进行走线的推理,我们几乎可以在探测板子之前预测出所有信号。
虽然EMMC芯片将所有八条数据线都连接到CPU,但在“硬件破解”时,通常只连接一条数据线加上CMD和CLK信号。这足以以1位/SD模式与EMMC芯片通信,读取或写入其全部内容(尽管速度较慢)。
连接到CLK + CMD + DAT0的30AWG漆包铜线,用于读写Sonos Era 300的EMMC
要读写EMMC(转储闪存、重写它),我们必须给板子供电。但为了确保我们不与CPU试图访问闪存或从它启动的行为冲突,通常需要尝试将CPU保持在复位状态。这可以通过将RESET#引脚接地(低电平有效)来实现,如果你能找到它的话!
类似于我们处理EMMC芯片的方法,我们根据过孔大致在板的顶层叠加了一个CPU的倒置焊球图。这帮助我们推理了从板子背面BGA的扇出(走线布局,过孔位置),而无需移除芯片。
接下来的图像有运气、经验和上下文线索的混合,但我们能够识别出RESET#引脚如下,而无需移除CPU:
30AWG漆包铜线连接到一个我们认为可能是CPU RESET#信号的刮开过孔
我们注意到CPU焊球图上的RESET#信号大部分被未使用的GPIO引脚包围。在PCB底部,紧邻该引脚簇下方的区域,有一个单独的旁路电容,通过一根细走线连接到一个专用的过孔(上图中电线连接的位置)。事实上,如果你仔细观察,这基本上是CPU下方唯一这样设置的电容器。
在正常操作中,RESET#线上的轻微干扰可能导致系统随机复位,因此在RESET#线上放置一个小旁路电容来帮助保持信号免受小毛刺干扰是很常见的。如果RESET#被拉低到地,它将使CPU保持在复位状态。
Sonos Era 300,其UART、EMMC、RESET#信号被引出,作为我们的主要研究设备
沿着板子边缘固定一个临时“调试”接头,我们可以将探测到的EMMC线路和RESET#信号路由到更坚固的东西上。这样我们就不必太担心在处理板子或在实验台上移动时给漆包线或我们连接的小触点施加应力。
从接头处,我们可以轻松地将RESET#接地,并将三条EMMC信号线(DAT0, CLK, CMD)连接到一个低压(1.8v)SD卡读卡器。这允许我们通过正常方式为Sonos主板供电,并像在Windows或Linux中插入标准SD卡一样自由地读取或写入EMMC。
每次读取/写入EMMC后,我们拔掉连接到SD读卡器的电线,并将RESET#线从地释放,以允许Sonos主板再次正常启动。
建立立足点
不出所料,我们从EMMC获得的固件转储几乎完全加密。但我们在2024年8月购买的全新Sonos Era 300的制造日期是2024年1月,大约在NCC的Sonos Era 100引导加载程序漏洞被称已修复(2023年11月)的两个月后。
在制造设备时,向消费者交付旧的、经过充分测试且已知良好的映像,并强制用户作为初始家庭设置的一部分更新设备,要安全得多。正如所料,我们不仅能够确认NCC的研究适用于我们的Sonos Era 300引导加载程序,而且还在几天内将其移植了过来。
|
|
NCC的研究为我们节省了数周的研究时间,因为这个漏洞允许我们半盲目地打破设备强大的信任链、加密的引导加载程序、内核和文件系统,通过结合几个巧妙的漏洞来快速获得直接进入Linux用户空间的立足点。
NCC利用的问题有效地允许对U-Boot环境进行细微但未经授权的操作,从而使我们可以指定自定义的U-Boot环境变量,这些变量直接传递给Linux内核启动参数。
我们可以滥用这种经典的启动参数注入形式,加载一个我们偷偷放入设备闪存中与加密内核一起的小型initrd ramfs(基于RAM的Linux文件系统),引导内核直接进入我们控制在ramfs内的启动可执行文件,而不是正常的文件系统。
通过NCC的安全启动绕过漏洞获得root shell(CVE-2023-50810)
这使我们在系统完全启动后,可以通过串口在Linux内核环境中以root身份执行命令。拥有系统上的root shell后,我们现在可以自由地探查他们的Linux环境、挂载真正的加密文件系统、提取感兴趣的可执行文件,并进行初步的设备内研究。
再次跟随NCC的领导(根据他们文章的结语),我们改编了blasty的a113x-el3 Trustzone漏洞利用程序,以从我们的设备中转储EL3二进制文件和OTP(eFuses)。
从EL3 Trustzone运行时中抓取内存和设备特定的eFuse秘密
除此之外,这还使我们能够访问本应由Trustzone持有的设备特定密钥。这些密钥用于对固件、更新进行加密/解密,或用于媒体DRM目的。Trustzone及其eFuses还指定了诸如启动撤销、设备特定功能等。
虽然不是绝对必要,但拥有这些密钥允许我们使用主机上的简单Python脚本解密所有未来的Sonos固件更新,而无需Sonos硬件或Trustzone作为预言机。
Anacapad概述
立足点建立后,我们可以用netstat验证主要的用户空间进程是anacapad,这与我们之前对针对Sonos产品的漏洞的“侦察”中发现的先前研究相符。
|
|
服务anacapad(推测为“模拟功能守护进程”)是一个单体二进制文件,在端口1400上运行HTTP服务器,并处理Sonos智能音箱产品几乎所有功能方面的事务。
这包括:
- 多音箱配置
- 闹钟通知
- 媒体处理
- 管理音轨队列
- 与云音乐服务交互
- 解析/播放实际音乐
- …
该服务专为家庭内部使用设计,因此认证不是问题,并且实现这些功能的所有HTTP端点都可以在局域网内自由交互,无需任何形式的认证。
值得注意的是,作为远程攻击风险最高的进程,根据checksec,它也相当加固,这将使远程利用媒体编解码器变得相当有趣。
(Checksec截图)
媒体解析
作为一个更侧重于音乐/音频的智能音箱,最主要的功能自然是播放媒体,这为它旨在支持的各种媒体编解码器和流媒体协议打开了大量的攻击面。
anacapad基础设施将媒体消费分解为两个基本操作:
- 封装 - 解析初始媒体容器以获取单独的音频帧(编码的音频数据)
- 例如,进行GET请求下载mp4文件然后解析它
- 由继承自
AudioFramer的对象执行
- 播放 - 将音频帧解码为原始样本数据(PCM)以供扬声器播放
- 例如,在编码帧上调用FLAC解码器
- 由继承自
AudioPlayer的对象执行
这些操作发生在不同的线程中:chsrc(通道源)用于封装,chsnk(通道汇)用于播放。
音乐队列是待播放音乐URI的列表。当chsrc从队列中弹出一个URI时,会发生以下整体过程:
- 它决定使用哪个具体的
AudioFramer- 这可能涉及URI方案、文件扩展名和/或MIME类型
- 帧化器子类从
FramerFactory对象获取
- 进行虚函数调用
framer->download(...)- 此操作请求文件(或实现流媒体协议),确定编码,并解析出帧
- 帧被转发到
notifyFrame,该函数接收原始缓冲区/大小和编码类型枚举 chsnk线程接收帧,并使用编码类型枚举来选择相应的AudioPlayer- 播放器子类从
AudioPlayerFactory对象获取
- 播放器子类从
- 进行虚函数调用
player->play(...),传入编码的帧缓冲区/大小 - 调用特定的解码器将帧转换为原始音频样本
- 播放声音!!🎸 🤘
由于二进制文件中保留了C++ RTTI,所有类名对我们都是可用的。可用的帧化器和播放器的完整列表如下:
帧化器
- AIFFAudioFramer
- BuzzerAudioFramer
- FLACAudioFramer
- M4AAudioFramer
- M4ARadioFramer
- SonosADTSAudioFramer
- HLSAudioFramer
- MP3AudioFramer
- MP3RadioFramer
- OggVorbisAudioFramer
- WAVAudioFramer
- WMAAudioFramer
- WMARadioFramer
- SpotifyAudioFramer
- SpotifyOggVorbisAudioFramer
- RadioTimeRecentShowAudioFramer
- RDASHAudioFramer
播放器
- MP3AudioPlayer
- WAVAudioPlayer
- WMAAudioPlayer
- M4AAudioPlayer
- FLACAudioPlayer
- OggVorbisAudioPlayer
- ALACAudioPlayer
- SBCAudioPlayer
- HTAudioPlayer
- EC3AudioPlayer
- OPUSAudioPlayer
- NullAudioPlayer
- EncryptedAudioPlayer
每个帧化器和播放器都代表我们可以开始研究的攻击面。
HLS流媒体
我们最终利用的特定漏洞位于HLS流媒体协议中。
HLS(HTTP Live Streaming)是苹果开发的一种协议,在RFC 8216中描述。
其基本概念是将媒体分解为更小的片段,客户端根据需要逐个下载每个片段。例如,每个片段可能包含音轨的10秒内容。
HLS的另一个主要功能是自适应比特率流媒体,即媒体流可以以各种不同的比特率/质量进行编码,较低比特率的流版本需要较少的带宽。然后,客户端可以根据连接速度动态地在比特率之间切换。
客户端通过最初下载一个顶层的“扩展M3U”播放列表文件来获取这些片段/流/元数据的列表,该文件的扩展名为.m3u或.m3u8。
这些是换行符分隔的纯文本文件,其中元数据“标签”位于以#开头的行上。
为了澄清我们所处的上下文,我们的攻击将包括向Sonos提供一个M3U播放列表的URL进行流式传输,该列表将从我们控制的HTTP服务器下载。
HLS变体流
具有各种比特率的变体流在播放列表文件中用#EXT-X-STREAM-INF标签表示,如下所示:
|
|
对我们来说最相关的属性是BANDWIDTH=<整数>,表示此变体流的比特率/带宽。通常,第二行上的URI将指向专门用于该单个流的另一个M3U播放列表。
在anacapad中,顶层播放列表被解析成如下所示的HLSPlayList结构(名称是我们自己命名的)。它包含一个最多16个唯一带宽的数组,每个带宽最多有4个流。请注意,这个数组在播放列表解析开始时被零初始化。
|
|
当播放列表解析器遇到一个#EXT-X-STREAM-INF标签时,它会调用storeStream,该函数执行以下操作:
- 将属性列表解析到栈上的一个临时
Stream中 - 调用辅助函数
reserve_bandwidth_entry(playlist, bandwidth) - 如果返回值非空,则该返回值为
Stream保留了一个槽位 - 如果返回值非空,则将栈上的临时变量复制到全局数组的保留槽位中
漏洞出现在使用异常输入保留带宽条目时。该操作的伪代码如下:
|
|
考虑如果我们指定BANDWIDTH=0,这段代码执行时会发生什么……
首先,它会检查数组中是否有匹配的带宽,由于带宽值初始化为零,确实存在这样一个条目。将在该零带宽条目中保留一个槽位,将nstreams递增到1。这使数组条目处于一种“奇怪状态”:其中一个流已被填充,但带宽为零表明该条目未使用。
我们可以再添加3个带宽为零的流来进一步推进这种状态,将nstreams递增到其最大值4。
现在,想象我们添加一个非零带宽的流。数组中尚不存在这样的带宽,所以我们将进入情况2来填充一个全新的条目。这将搜索数组中的可用槽位,而可用性定义为带宽为零……这将找到我们那个看似“未使用”但nstreams却设为4的“奇怪”条目。
接下来发生的是,保留的槽位将是流索引4,这越界了。注意在情况2中没有对nstreams进行边界检查,因为假设未使用的条目处于其零初始化的状态,未被触及。
在reserve_bandwidth_entry返回越界的流槽位后,storeStream继续将其本地流实例memcpy到越界的内存中。
下图说明了在解析变体流时,结构字段发生的变化:
(显示结构状态变化的示意图)
控制溢出 - 空字节
我们获得的原语是破坏BREntry结构相邻的内存。当前条目的nstreams将始终被破坏,接着是BREntry之后的所有内容。
回想一下,这是在16个BREntry结构的数组内部,因此从索引0到14的条目触发溢出只会破坏下一个BREntry,这相对无用(因为我们只会破坏带宽字段和第一个Stream的字符串字段)。
然而,用15个填充带宽条目填满数组,然后触发溢出,我们将能够破坏更大的HLSPlayList容器结构中后面的字段(注意该结构足够大,溢出不会触及相邻的内存分配)。
(显示溢出范围及目标的示意图)
在我们研究如何利用这些字段中的某些字段之前,我们需要了解我们的溢出是否有任何限制(即,我们是否可以嵌入空字节)。
回顾Stream结构,其主要部分是第一个字段,即用于流的URI的char[8193]。这来自变体流定义的第二行(#EXT-X-STREAM-INF行之后),意味着我们控制它。然而,作为一个字符串,空字节自然不是一个选项(严格来说,由于播放列表文件是换行符分隔的,换行符/回车符也不是选项)。
这时我们可以利用代码的编写方式。回想一下,Stream结构被解析到栈上的一个临时局部变量中,然后被复制到堆结构brentries数组的一个槽位中。这个临时局部变量的初始化只会将每个字符数组字段的第一个字节置空(而不是用memset清零整个结构)。换句话说,复制到堆上的内容可以包含未初始化的栈内存。
这有两个重要的后果:
- 我们可以通过在前面的迭代中使用依次缩短的字符串来放置空字节,从而嵌入空字节
- 对于已经有4个流的带宽条目,流会被忽略,所以我们可以根据需要利用任意多次“设置”迭代
- 复制到堆上的未初始化栈内容可能包含有用的值(例如代码/栈指针、栈cookie),我们稍后需要泄露这些值
使用依次缩短字符串的技术,我们可以为溢出设置包含空字节的任意载荷。
诱导栈缓冲区溢出
在获得精确控制的溢出后,我们现在希望寻找HLSPlayList中我们可以破坏的字段,以产生有用的后果。
随着我们继续逆向播放列表解析逻辑,我们遇到了一个有趣的后处理步骤,用于修剪某些流。
变体流可以指定一个包含编解码器列表的属性,即CODEC="<codec1>,<codec2>,..."。如果播放列表内的变体流同时使用了ALAC和EC-3编解码器,则会调用此后处理步骤来移除此列表中第一个编解码器是ALAC的任何流。
我们将这个后处理函数称为remove_alac_streams。它通过遍历brentries数组内的流,并将任何非ALAC流复制到临时的栈数组中来执行过滤。之后,它将整个栈临时数组复制回堆上的brentries数组。
考虑到漏洞,问题在于循环迭代是基于nstreams等被破坏的值。
如果nstreams大于预期的最大值4,这个复制循环可能会复制到栈上越界的流槽位,从而导致栈缓冲区溢出。
假设我们已有泄露,这将是通过覆盖保存的链接寄存器来实现简单胜利的途径。
为完整起见,该函数的伪代码如下:
|
|
不幸的是,我们首先需要处理栈帧/结构布局上的一个小细节……
控制栈溢出
我们来回复制的结构是相同的。除非由于移除ALAC流而导致“错位”,否则从源堆数组复制的偏移量将始终与目标栈数组的偏移量相同。
移除ALAC流将导致偏移量不同。然而,我们的漏洞只允许写入一个越界的流。为了演示,在没有任何移除的情况下,写入栈数组+1越界的位置将从堆源+1越界的位置复制,这正好复制了我们写入越界的那个流。如果我们执行一次移除,在栈上+1越界的位置写入将从堆上+2越界的位置复制,这将来自HLSPlayList中更远的相邻字段,这些字段的内容我们目前无法控制……
考虑到这一点,让我们考虑如果我们不执行移除,并将我们唯一的越界流复制到栈上会发生什么。下图展示了栈帧(复制目标)和HLSPlayList(复制源)的叠加情况:
(显示栈帧与HLSPlayList结构布局叠加的示意图)
关键的是,nbrentries(即前面brentries数组中已填充的条目数)与栈cookie位于相同的偏移量。
nbrentries决定了remove_alac_streams中外部循环的迭代次数,因此必须是一个小整数,以避免“永远”复制并触及未映射的内存。
同时,栈cookie几乎肯定不是一个小整数……
显然,如果我们想成功地在remove_alac_streams中触发栈溢出,这些值不能相同。另外需要注意的是,栈cookie是通过编译器提供的libssp实现的,它使用GOT中的__stack_chk_guard_ptr指向libssp数据段中的cookie,而不是TLS(线程本地存储)。这排除了任何CTF式的技巧,即通过溢出到足够远的线程栈来破坏TLS从而覆盖cookie本身。
我们唯一的选择是通过移除ALAC流来“错位”复制偏移量。如前所述,这使得复制的源在HLSPlayList结构中进一步越界,因此我们需要了解如何控制这些HLSPlayList字段。
控制音轨列表
幸运的是,HLSPlayList中这些更远的字段是某种音轨列表,一个音轨数组,每个音轨包含一些元数据和一个URI。
这些音轨实际上是HLS流的分段媒体块(例如,前N秒音频,下一个N秒音频,等等……),因此在播放列表文件中指定(即我们可以控制它们)。
具体来说,越界复制的源将部分来自第一个音轨的URI缓冲区内部。
为了控制此URI的内容,我们将利用相同的空字节嵌入技术,即使用依次缩短的字符串。
音轨列表有足够的空间容纳16个音轨,这些音轨以类似环形缓冲区的方式填充,其中第17个音轨将被写入索引0(覆盖第1个音轨的原始内容)。将音轨列表循环回索引0使我们能够重复发送字符串以实现空嵌入技术。
信息泄露
既然我们已经理解了如何在remove_alac_streams中触发具有精确控制载荷的栈溢出,我们需要的就只是泄露信息来有意义地劫持控制流。
回顾一下HLSPlayList结构中我们可以破坏的以下字段:
|
|
br_idx决定了brentries中的索引,而stream_idx决定了该条目流数组中的索引。组合起来,它们定义了当前选定的流。
在HLS播放列表解析器消费了顶层播放列表并想要开始流式传输后,它会从当前选定流的URI字段获取内部播放列表。
如果它是非绝对URL,则会在前面加上顶层播放列表的主机/端口以发出实际的HTTP请求。如果我们破坏br_idx / stream_idx使其指向一个越界的URI,我们就可以通过观察我们的HTTP服务器接收到的请求URI来获取泄露信息。
回想一下,brentries数组中流的字符串字段是从包含未初始化栈内存的栈局部变量复制而来的。
这意味着所有感兴趣的值都在复制到堆上的那些未初始化内容中的某个地方,问题只是找到正确的br_idx / stream_idx值,以产生从br_idx * sizeof(BREntry) + stream_idx * sizeof(Stream)开始的所需偏移量。
(显示通过br_idx和stream_idx计算泄露位置的示意图)
每次触发漏洞都会产生一次泄露,因此我们需要连续发送几个播放列表来获取所有泄露信息:
- 一个代码指针(给我们提供用于gadget等的代码段泄露)
- 一个栈指针(我们在栈上有用作函数参数的控制数据)
- 栈cookie(与glibc不同,libssp的实现似乎没有在最低字节放置空值,这意味着当被解释为字符串时,cookie可以被完全泄露)
代码执行
获取泄露后,我们可以发送最终精心制作的播放列表文件,以在remove_alac_streams中诱导栈溢出。
栈帧的布局在cookie之后直接有一堆被调用者保存的寄存器(包括链接寄存器)。
我们用COP(面向调用编程)gadget破坏LR,将其转换为具有三个受控参数的任意函数调用:
|
|
我们将其指向PLT中的execve存根,并设置参数指向栈上的字符串/argv数组,以执行/bin/sh -c <cmd>。
这给了我们任意的shell命令执行。要将其转换为交互式shell,我们需要生成一个反向连接,这需要在基于busybox的系统上创建一个FIFO:
|
|
唯一的细微差别是我们必须在一个我们有权限创建文件的目录中。顺便说一句,这在实际比赛中让我们惊出了一