mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-14 15:00:30 +02:00
Some shared saves are zipped without their folder, with the zip named after it instead (ULUS10019000.zip). Detect these by a root PARAM.SFO with a SAVEDATA_DIRECTORY, which holds the folder name (game ID + save name), and extract into that folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
219 lines
7.5 KiB
C++
219 lines
7.5 KiB
C++
#include "ext/libzip/zip.h"
|
|
|
|
#include "Common/File/FileUtil.h"
|
|
#include "Common/File/Path.h"
|
|
#include "Core/ELF/ParamSFO.h"
|
|
#include "Core/Loaders.h"
|
|
#include "Core/Util/GameManager.h"
|
|
#include "Core/Util/PathUtil.h"
|
|
|
|
#include "UnitTest.h"
|
|
|
|
static bool TestHasParentDirComponent() {
|
|
EXPECT_TRUE(HasParentDirComponent("../../evil.txt"));
|
|
EXPECT_TRUE(HasParentDirComponent("game/../../evil.txt"));
|
|
EXPECT_TRUE(HasParentDirComponent(".."));
|
|
EXPECT_TRUE(HasParentDirComponent("sub/.."));
|
|
EXPECT_TRUE(HasParentDirComponent("..\\evil.txt"));
|
|
EXPECT_TRUE(HasParentDirComponent("a/b/../.."));
|
|
EXPECT_FALSE(HasParentDirComponent("normal.txt"));
|
|
EXPECT_FALSE(HasParentDirComponent("game/evil.txt"));
|
|
EXPECT_FALSE(HasParentDirComponent("a.b/c.d"));
|
|
EXPECT_FALSE(HasParentDirComponent(""));
|
|
EXPECT_FALSE(HasParentDirComponent("/absolute/path.txt"));
|
|
return true;
|
|
}
|
|
|
|
// Creates a zip archive at the given path with one entry of the given name.
|
|
static bool CreateZipWithEntry(const Path &zipPath, const std::string &entryName, const std::string &contents) {
|
|
int errorp = 0;
|
|
zip_t *z = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &errorp);
|
|
if (!z)
|
|
return false;
|
|
zip_source_t *source = zip_source_buffer(z, contents.data(), contents.size(), 0);
|
|
if (!source) {
|
|
zip_close(z);
|
|
return false;
|
|
}
|
|
if (zip_file_add(z, entryName.c_str(), source, ZIP_FL_ENC_UTF_8) < 0) {
|
|
zip_source_free(source);
|
|
zip_close(z);
|
|
return false;
|
|
}
|
|
return zip_close(z) == 0;
|
|
}
|
|
|
|
// Crafts a zip with a parent-directory entry and verifies ExtractZipContents
|
|
// refuses to write outside the destination directory (Zip Slip).
|
|
static bool TestZipSlipExtraction() {
|
|
Path tempRoot = Path("unittest_zip_slip_test");
|
|
File::DeleteDirRecursively(tempRoot);
|
|
EXPECT_TRUE(File::CreateDir(tempRoot));
|
|
|
|
Path destDir = tempRoot / "dest";
|
|
EXPECT_TRUE(File::CreateDir(destDir));
|
|
|
|
Path zipPath = tempRoot / "bad.zip";
|
|
EXPECT_TRUE(CreateZipWithEntry(zipPath, "../evil.txt", "should not escape"));
|
|
|
|
int errorp = 0;
|
|
zip_t *z = zip_open(zipPath.c_str(), 0, &errorp);
|
|
EXPECT_TRUE(z != nullptr);
|
|
|
|
ZipFileInfo info;
|
|
info.numFiles = 1;
|
|
info.stripChars = 0;
|
|
info.ignoreMetaFiles = false;
|
|
|
|
GameManager manager;
|
|
EXPECT_TRUE(manager.ExtractZipContents(z, destDir, info, true));
|
|
zip_close(z);
|
|
|
|
// The malicious file must not have been written outside destDir.
|
|
// A naive "dest / ../evil.txt" would land here.
|
|
EXPECT_FALSE(File::Exists(tempRoot / "evil.txt"));
|
|
// And the actual file should not exist inside destDir either.
|
|
EXPECT_FALSE(File::Exists(destDir / "evil.txt"));
|
|
|
|
// Clean up.
|
|
File::Delete(zipPath);
|
|
File::DeleteDirRecursively(tempRoot);
|
|
return true;
|
|
}
|
|
|
|
// Save state zips are detected with stripChars past the last slash, since the whole
|
|
// directory is stripped. The files must land directly in the destination.
|
|
static bool TestSaveStateZipExtraction() {
|
|
// Absolute, since CreateFullPath does nothing with relative paths.
|
|
Path tempRoot = File::GetCurDirectory() / "unittest_zip_savestate_test";
|
|
File::DeleteDirRecursively(tempRoot);
|
|
EXPECT_TRUE(File::CreateDir(tempRoot));
|
|
|
|
Path destDir = tempRoot / "dest";
|
|
EXPECT_TRUE(File::CreateDir(destDir));
|
|
|
|
Path zipPath = tempRoot / "states.zip";
|
|
int errorp = 0;
|
|
zip_t *z = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &errorp);
|
|
EXPECT_TRUE(z != nullptr);
|
|
static const char *const contents = "data";
|
|
EXPECT_TRUE(zip_dir_add(z, "SAVE/", ZIP_FL_ENC_UTF_8) >= 0);
|
|
EXPECT_TRUE(zip_dir_add(z, "SAVE/PPSSPP_STATE/", ZIP_FL_ENC_UTF_8) >= 0);
|
|
for (const char *name : { "SAVE/PPSSPP_STATE/UCES01401_1.00_1.jpg", "SAVE/PPSSPP_STATE/UCES01401_1.00_1.ppst" }) {
|
|
zip_source_t *source = zip_source_buffer(z, contents, strlen(contents), 0);
|
|
EXPECT_TRUE(source != nullptr);
|
|
EXPECT_TRUE(zip_file_add(z, name, source, ZIP_FL_ENC_UTF_8) >= 0);
|
|
}
|
|
EXPECT_TRUE(zip_close(z) == 0);
|
|
|
|
z = zip_open(zipPath.c_str(), 0, &errorp);
|
|
EXPECT_TRUE(z != nullptr);
|
|
|
|
ZipFileInfo info;
|
|
DetectZipFileContents(z, &info);
|
|
EXPECT_EQ_INT((int)info.contents, (int)ZipFileContents::SAVE_STATES);
|
|
|
|
GameManager manager;
|
|
EXPECT_TRUE(manager.ExtractZipContents(z, destDir, info, true));
|
|
zip_close(z);
|
|
|
|
EXPECT_TRUE(File::Exists(destDir / "UCES01401_1.00_1.jpg") && !File::IsDirectory(destDir / "UCES01401_1.00_1.jpg"));
|
|
EXPECT_TRUE(File::Exists(destDir / "UCES01401_1.00_1.ppst") && !File::IsDirectory(destDir / "UCES01401_1.00_1.ppst"));
|
|
|
|
File::DeleteDirRecursively(tempRoot);
|
|
return true;
|
|
}
|
|
|
|
// A single save folder's contents zipped without the folder (named ULUS10019000.zip, say). The folder
|
|
// name comes from SAVEDATA_DIRECTORY in the PARAM.SFO.
|
|
static bool TestRootSaveDataZip() {
|
|
Path tempRoot = File::GetCurDirectory() / "unittest_zip_savedata_test";
|
|
File::DeleteDirRecursively(tempRoot);
|
|
EXPECT_TRUE(File::CreateDir(tempRoot));
|
|
|
|
Path destDir = tempRoot / "SAVEDATA";
|
|
EXPECT_TRUE(File::CreateDir(destDir));
|
|
|
|
std::string sfoContents;
|
|
for (const char *saveDir : { "ULUS10019000", "../evil" }) {
|
|
ParamSFOData sfo;
|
|
sfo.SetValue("CATEGORY", "MS", 4);
|
|
sfo.SetValue("TITLE", "CODED ARMS", 128);
|
|
sfo.SetValue("SAVEDATA_DIRECTORY", saveDir, 64);
|
|
u8 *sfoData = nullptr;
|
|
size_t sfoSize = 0;
|
|
sfo.WriteSFO(&sfoData, &sfoSize);
|
|
sfoContents.assign((const char *)sfoData, sfoSize);
|
|
delete[] sfoData;
|
|
|
|
Path zipPath = tempRoot / "save.zip";
|
|
int errorp = 0;
|
|
zip_t *z = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &errorp);
|
|
EXPECT_TRUE(z != nullptr);
|
|
static const char *const contents = "data";
|
|
zip_source_t *source = zip_source_buffer(z, sfoContents.data(), sfoContents.size(), 0);
|
|
EXPECT_TRUE(zip_file_add(z, "PARAM.SFO", source, ZIP_FL_ENC_UTF_8) >= 0);
|
|
for (const char *name : { "ICON0.PNG", "CODED.ARM" }) {
|
|
source = zip_source_buffer(z, contents, strlen(contents), 0);
|
|
EXPECT_TRUE(zip_file_add(z, name, source, ZIP_FL_ENC_UTF_8) >= 0);
|
|
}
|
|
EXPECT_TRUE(zip_close(z) == 0);
|
|
|
|
z = zip_open(zipPath.c_str(), 0, &errorp);
|
|
EXPECT_TRUE(z != nullptr);
|
|
ZipFileInfo info;
|
|
DetectZipFileContents(z, &info);
|
|
if (!strcmp(saveDir, "../evil")) {
|
|
EXPECT_EQ_INT((int)info.contents, (int)ZipFileContents::UNKNOWN);
|
|
zip_close(z);
|
|
continue;
|
|
}
|
|
EXPECT_EQ_INT((int)info.contents, (int)ZipFileContents::SAVE_DATA);
|
|
EXPECT_TRUE(info.saveDataInRoot);
|
|
EXPECT_EQ_STR(info.savedataDir, std::string("ULUS10019000"));
|
|
|
|
GameManager manager;
|
|
EXPECT_TRUE(manager.ExtractZipContents(z, destDir / info.savedataDir, info, true));
|
|
zip_close(z);
|
|
|
|
EXPECT_TRUE(File::Exists(destDir / "ULUS10019000" / "PARAM.SFO"));
|
|
EXPECT_TRUE(File::Exists(destDir / "ULUS10019000" / "CODED.ARM"));
|
|
}
|
|
|
|
File::DeleteDirRecursively(tempRoot);
|
|
return true;
|
|
}
|
|
|
|
static bool TestIsSafePathComponent() {
|
|
EXPECT_TRUE(IsSafePathComponent("ULUS10041"));
|
|
EXPECT_TRUE(IsSafePathComponent("NPJH-50465"));
|
|
EXPECT_TRUE(IsSafePathComponent("a.b"));
|
|
EXPECT_TRUE(IsSafePathComponent("My Game"));
|
|
EXPECT_FALSE(IsSafePathComponent(""));
|
|
EXPECT_FALSE(IsSafePathComponent("."));
|
|
EXPECT_FALSE(IsSafePathComponent(".."));
|
|
EXPECT_FALSE(IsSafePathComponent("..."));
|
|
EXPECT_FALSE(IsSafePathComponent(".. "));
|
|
EXPECT_FALSE(IsSafePathComponent("../ULUS10041"));
|
|
EXPECT_FALSE(IsSafePathComponent("..\\ULUS10041"));
|
|
EXPECT_FALSE(IsSafePathComponent("C:"));
|
|
EXPECT_FALSE(IsSafePathComponent("ULUS10041:stream"));
|
|
EXPECT_FALSE(IsSafePathComponent(std::string_view("ULUS\0/..", 8)));
|
|
EXPECT_FALSE(IsSafePathComponent("ULUS\n10041"));
|
|
return true;
|
|
}
|
|
|
|
bool TestZipSlip() {
|
|
if (!TestHasParentDirComponent())
|
|
return false;
|
|
if (!TestIsSafePathComponent())
|
|
return false;
|
|
if (!TestZipSlipExtraction())
|
|
return false;
|
|
if (!TestSaveStateZipExtraction())
|
|
return false;
|
|
if (!TestRootSaveDataZip())
|
|
return false;
|
|
return true;
|
|
}
|