Skip to content

Commit a9dff55

Browse files
committed
fix(security): harden symlink target validation during extraction
Resolve symlink escape checks against real filesystem paths by validating the first existing path component with canonical resolution. This closes a bypass where lexical path checks could be fooled by pre-existing symlinks while keeping extraction behavior and performance stable. log: fix cnnvd Bug: https://pms.uniontech.com/bug-view-353989.html https://pms.uniontech.com/bug-view-353985.html
1 parent 0cea7c5 commit a9dff55

File tree

5 files changed

+113
-1
lines changed

5 files changed

+113
-1
lines changed

3rdparty/interface/common.cpp

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,74 @@ bool Common::findLnfsPath(const QString &target, Compare func)
518518
}
519519

520520

521+
bool extractPathIsWithinTarget(const QString &extractRoot, const QString &absoluteDestPath)
522+
{
523+
const QFileInfo rootFi(extractRoot);
524+
const QString rootCanon = rootFi.canonicalFilePath();
525+
if (rootCanon.isEmpty()) {
526+
return false;
527+
}
528+
529+
const QString destAbs = QFileInfo(absoluteDestPath).absoluteFilePath();
530+
QString path = destAbs;
531+
532+
while (true) {
533+
QFileInfo fi(path);
534+
if (fi.exists()) {
535+
const QString canon = fi.canonicalFilePath();
536+
if (canon.isEmpty()) {
537+
return false;
538+
}
539+
if (!canon.startsWith(rootCanon + QDir::separator()) && canon != rootCanon) {
540+
return false;
541+
}
542+
return true;
543+
}
544+
const QString parent = fi.path();
545+
if (parent == path || parent.isEmpty()) {
546+
break;
547+
}
548+
path = parent;
549+
}
550+
return rootFi.exists();
551+
}
552+
553+
bool symlinkTargetIsWithinTarget(const QString &extractRoot, const QString &symlinkFilePath, const QString &symlinkTarget)
554+
{
555+
const QFileInfo rootFi(extractRoot);
556+
const QString rootCanon = rootFi.canonicalFilePath();
557+
if (rootCanon.isEmpty()) {
558+
return false;
559+
}
560+
561+
if (symlinkTarget.isEmpty()) {
562+
return false;
563+
}
564+
565+
const QString linkParent = QFileInfo(symlinkFilePath).path();
566+
const QString resolved = QDir::cleanPath(QDir(linkParent).absoluteFilePath(symlinkTarget));
567+
QString path = resolved;
568+
569+
while (true) {
570+
QFileInfo fi(path);
571+
if (fi.exists()) {
572+
const QString canon = fi.canonicalFilePath();
573+
if (canon.isEmpty()) {
574+
return false;
575+
}
576+
return canon.startsWith(rootCanon + QDir::separator()) || canon == rootCanon;
577+
}
578+
579+
const QString parent = fi.path();
580+
if (parent == path || parent.isEmpty()) {
581+
break;
582+
}
583+
path = parent;
584+
}
585+
586+
return resolved.startsWith(rootCanon + QDir::separator()) || resolved == rootCanon;
587+
}
588+
521589
bool IsMtpFileOrDirectory(QString path) noexcept {
522590
const static QRegExp regexp("((/run/user/[0-9]+/gvfs/mtp:)|(/root/.gvfs/mtp:)).+");
523591
return regexp.exactMatch(path);

3rdparty/interface/common.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-FileCopyrightText: 2022 UnionTech Software Technology Co., Ltd.
1+
// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd.
22
//
33
// SPDX-License-Identifier: GPL-3.0-or-later
44

@@ -58,4 +58,14 @@ class Common: public QObject
5858
*/
5959
bool IsMtpFileOrDirectory(QString path) noexcept;
6060

61+
/**
62+
* 解压安全:校验目标绝对路径在真实文件系统解析后仍位于解压根目录内(防止符号链接路径逃逸)。
63+
*/
64+
bool extractPathIsWithinTarget(const QString &extractRoot, const QString &absoluteDestPath);
65+
66+
/**
67+
* 解压安全:ZIP 内符号链接目标解析后须位于解压根目录内。
68+
*/
69+
bool symlinkTargetIsWithinTarget(const QString &extractRoot, const QString &symlinkFilePath, const QString &symlinkTarget);
70+
6171
#endif

3rdparty/libarchive/libarchive/libarchiveplugin.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,8 @@ int LibarchivePlugin::extractionFlags() const
826826
{
827827
int result = ARCHIVE_EXTRACT_TIME;
828828
result |= ARCHIVE_EXTRACT_SECURE_NODOTDOT;
829+
result |= ARCHIVE_EXTRACT_SECURE_SYMLINKS;
830+
result |= ARCHIVE_EXTRACT_SECURE_NOABSOLUTEPATHS;
829831

830832
// TODO: Don't use arksettings here
831833
/*if ( ArkSettings::preservePerms() )

3rdparty/libminizipplugin/libminizipplugin.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,14 @@ ErrorType LibminizipPlugin::extractEntry(unzFile zipfile, unz_file_info file_inf
283283
strFileName = strFileName.remove(0, options.strDestination.size());
284284
}
285285

286+
while (strFileName.contains(QStringLiteral("../"))) {
287+
qInfo() << "skipped ../ path component(s) in " << strFileName;
288+
strFileName = strFileName.replace(QStringLiteral("../"), QString());
289+
}
290+
if (strFileName.contains(QLatin1Char('\\'))) {
291+
strFileName = strFileName.replace(QLatin1Char('\\'), QDir::separator());
292+
}
293+
286294
emit signalCurFileName(strFileName); // 发送当前正在解压的文件名
287295

288296
bool bIsDirectory = strFileName.endsWith(QDir::separator()); // 是否为文件夹
@@ -293,6 +301,19 @@ ErrorType LibminizipPlugin::extractEntry(unzFile zipfile, unz_file_info file_inf
293301

294302
// 解压完整文件名(含路径)
295303
QString strDestFileName = options.strTargetPath + QDir::separator() + strFileName;
304+
305+
const QString cleanTargetPath = QDir::cleanPath(QDir(options.strTargetPath).absolutePath());
306+
const QString cleanDestPath = QDir::cleanPath(QDir(strDestFileName).absolutePath());
307+
if (!cleanDestPath.startsWith(cleanTargetPath + QDir::separator()) &&
308+
cleanDestPath != cleanTargetPath) {
309+
qInfo() << "Path traversal detected! Rejected path: " << strFileName;
310+
return ET_FileWriteError;
311+
}
312+
if (!extractPathIsWithinTarget(options.strTargetPath, strDestFileName)) {
313+
qInfo() << "Rejected path (symlink escape or out of root):" << strDestFileName;
314+
return ET_FileWriteError;
315+
}
316+
296317
QFile file(strDestFileName);
297318

298319
if (bIsDirectory) { // 文件夹

3rdparty/libzipplugin/libzipplugin.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,11 @@ ErrorType LibzipPlugin::extractEntry(zip_t *archive, zip_int64_t index, const Ex
895895
return ET_FileWriteError;
896896
}
897897

898+
if (!extractPathIsWithinTarget(options.strTargetPath, strDestFileName)) {
899+
qInfo() << "Rejected path (symlink escape or out of root):" << strDestFileName;
900+
return ET_FileWriteError;
901+
}
902+
898903
QFile file(strDestFileName);
899904

900905
// Store parent mtime.
@@ -951,6 +956,12 @@ ErrorType LibzipPlugin::extractEntry(zip_t *archive, zip_int64_t index, const Ex
951956
const auto readBytes = zip_fread(zipFile, buf, zip_uint64_t(READBYTES));
952957
if (readBytes > 0) {
953958
QString strBuf = QString(buf).toLocal8Bit();
959+
if (!symlinkTargetIsWithinTarget(options.strTargetPath, strDestFileName, strBuf)) {
960+
qInfo() << "Symlink target escapes extract root, rejected:" << strBuf;
961+
zip_fclose(zipFile);
962+
emit signalFileWriteErrorName(strBuf);
963+
return ET_FileWriteError;
964+
}
954965
if (QFile::link(strBuf, strDestFileName)) {
955966
qInfo() << "Symlink's created:" << buf << strFileName;
956967
} else {

0 commit comments

Comments
 (0)