Files
ppsspp/Core/Util/PkgUnpack.cpp
T
499cdd55ad Validate untrusted IDs before using them as directory names
Texture pack installs took the destination directory from the archive's
textures.ini, and PKG update installs from the PBOOT's DISC_ID, both
unchecked, so a crafted archive or package could write outside
PSP/TEXTURES or PSP/GAME. Add IsSafePathComponent() to PathUtil and check
both, replacing the WebServer's own upload filename check with it too.

DISC_ID from any PARAM.SFO also names configs, cheats, textures, exdata
and savestates, so ReadSFO now drops one that isn't a safe name and the
game gets a fake ID like homebrew. The same goes for a frame dump's ID.

Combines #22419 and #22420 by Acts1631.

Co-Authored-By: Acts1631 <acts1631kjv@proton.me>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:39:06 -06:00

552 lines
18 KiB
C++

// Copyright (c) 2026- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <cstring>
#include <memory>
#include "Common/File/FileUtil.h"
#include "Common/File/Path.h"
#include "Common/Log.h"
#include "Common/StringUtils.h"
#include "Common/System/Request.h"
#include "Common/System/System.h"
#include "Core/ELF/ParamSFO.h"
#include "Core/ELF/PBPReader.h"
#include "Core/Loaders.h"
#include "Core/System.h"
#include "Core/Util/PathUtil.h"
#include "Core/Util/PkgUnpack.h"
#include "ext/libkirk/AES.h"
// See docs/pkg_notes.md. Field offsets in the 0xC0-byte header:
static const u32 PKG_MAGIC = 0x7F504B47; // "\x7FPKG"
static const u32 PKG_TYPE_PSP = 2; // 1 is PS3.
// 0xC0 of header plus the 0x40-byte extended header after it, which is where the key index is.
static const size_t PKG_HEADER_SIZE = 0x100;
static const size_t PKG_ITEM_SIZE = 0x20;
// The two keys a PSP package's contents are encrypted with, picked per item. Vita packages derive
// theirs from the riv instead, which we don't handle - nothing here reads Vita packages.
static const u8 PKG_PSP_KEY[16] = {
0x07, 0xf2, 0xc6, 0x82, 0x90, 0xb5, 0x0d, 0x2c, 0x33, 0x81, 0x8d, 0x70, 0x9b, 0x60, 0xe6, 0x2b,
};
static const u8 PKG_PS3_KEY[16] = {
0x2e, 0x7b, 0x71, 0xd7, 0xc9, 0xc9, 0xa1, 0x4e, 0xa3, 0x22, 0x1f, 0x18, 0x88, 0x28, 0xb8, 0xf8,
};
// An item table with more entries than this is corrupt, not something we should try to allocate
// for. The biggest update package seen has a few hundred.
static const u32 PKG_MAX_ITEMS = 65536;
// Same idea for a single filename.
static const u32 PKG_MAX_NAME = 1024;
static const size_t PKG_COPY_BLOCK = 512 * 1024;
// Everything in a PKG header, its metadata and its item table is big-endian; the PBP inside is
// little-endian like the rest of the PSP world. None of it is guaranteed aligned, so read bytes.
static u16 Read16BE(const u8 *p) {
return (u16)(((u32)p[0] << 8) | p[1]);
}
static u32 Read32BE(const u8 *p) {
return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3];
}
static u64 Read64BE(const u8 *p) {
return ((u64)Read32BE(p) << 32) | (u64)Read32BE(p + 4);
}
static u32 Read32LE(const u8 *p) {
return ((u32)p[3] << 24) | ((u32)p[2] << 16) | ((u32)p[1] << 8) | (u32)p[0];
}
// AES-128-CTR, with the counter block starting at `iv` and incrementing once per 16 bytes. Both
// the counter and the increment are big-endian, and the counter wraps over the whole 128 bits.
static void AesCtrXor(const u8 *key, const u8 *iv, u64 blockIndex, u8 *data, size_t size) {
AES_ctx ctx;
AES_set_key(&ctx, key, 128);
u8 counter[16];
memcpy(counter, iv, 16);
// Add blockIndex to the 128-bit big-endian counter.
u32 carry = 0;
for (int i = 15; i >= 0; i--) {
const u32 sum = (u32)counter[i] + (u32)(blockIndex & 0xFF) + carry;
counter[i] = (u8)sum;
carry = sum >> 8;
blockIndex >>= 8;
}
u8 stream[16];
for (size_t pos = 0; pos < size; pos += 16) {
AES_encrypt(&ctx, counter, stream);
const size_t chunk = std::min<size_t>(16, size - pos);
for (size_t i = 0; i < chunk; i++) {
data[pos + i] ^= stream[i];
}
// Increment the counter block.
for (int i = 15; i >= 0; i--) {
if (++counter[i] != 0)
break;
}
}
}
const u8 *PkgReader::ItemKey(const PkgItem &item) const {
return item.pspType == 0x90 ? mainKey_ : PKG_PS3_KEY;
}
bool PkgReader::ReadEncrypted(u64 offset, size_t size, const u8 *key, u8 *out) {
if (size == 0) {
return true;
}
// The counter runs on 16-byte boundaries, so read from the containing block and skip the
// leading bytes afterwards.
const size_t skip = (size_t)(offset & 0xF);
const u64 base = offset - skip;
const size_t alignedSize = (skip + size + 15) & ~(size_t)0xF;
if (base > dataSize_ || alignedSize > dataSize_ - base) {
return false;
}
std::vector<u8> buf(alignedSize);
if (loader_->ReadAt(dataOffset_ + base, alignedSize, buf.data()) != alignedSize) {
return false;
}
AesCtrXor(key, riv_, base / 16, buf.data(), alignedSize);
memcpy(out, buf.data() + skip, size);
return true;
}
bool PkgReader::ReadItemData(const PkgItem &item, u64 offset, size_t size, u8 *out) {
if (offset > item.dataSize || size > item.dataSize - offset) {
return false;
}
return ReadEncrypted(item.dataOffset + offset, size, ItemKey(item), out);
}
bool PkgReader::ReadItem(const PkgItem &item, std::vector<u8> *out, size_t maxSize) {
if (item.dataSize > maxSize) {
return false;
}
out->resize((size_t)item.dataSize);
return ReadItemData(item, 0, out->size(), out->data());
}
bool PkgReader::Open(FileLoader *loader, std::string *error) {
loader_ = loader;
info_ = PkgInfo();
u8 header[PKG_HEADER_SIZE];
if (!loader || loader->ReadAt(0, sizeof(header), header) != sizeof(header)) {
*error = "Not a PKG file: too short";
return false;
}
if (Read32BE(header) != PKG_MAGIC) {
*error = "Not a PKG file";
return false;
}
const u32 type = Read16BE(header + 0x06);
if (type != PKG_TYPE_PSP) {
*error = "Not a PSP PKG file";
return false;
}
// Key index 1 is the only one a PSP package uses; 2-4 are Vita, and derive a key from the riv.
const u32 keyIndex = header[0xE7] & 7;
if (keyIndex != 1) {
*error = StringFromFormat("Unsupported PKG key index %d", keyIndex);
return false;
}
memcpy(mainKey_, PKG_PSP_KEY, sizeof(mainKey_));
const u32 metaOffset = Read32BE(header + 0x08);
const u32 metaCount = Read32BE(header + 0x0C);
const u32 itemCount = Read32BE(header + 0x14);
const u64 totalSize = Read64BE(header + 0x18);
dataOffset_ = Read64BE(header + 0x20);
dataSize_ = Read64BE(header + 0x28);
memcpy(riv_, header + 0x70, sizeof(riv_));
char contentId[0x31]{};
memcpy(contentId, header + 0x30, 0x30);
info_.contentId = contentId;
const s64 fileSize = loader->FileSize();
if (fileSize < 0 || (u64)fileSize < totalSize || dataOffset_ + dataSize_ > (u64)fileSize) {
*error = "PKG file is truncated";
return false;
}
if (itemCount == 0 || itemCount > PKG_MAX_ITEMS) {
*error = "PKG file has a broken item table";
return false;
}
// The metadata is in the clear. We only need three things out of it, and one of them (the item
// table offset) is zero in every update package seen - but read it rather than assume.
u32 itemsOffset = 0;
u64 metaPos = metaOffset;
for (u32 i = 0; i < metaCount; i++) {
u8 rec[8];
if (loader->ReadAt(metaPos, sizeof(rec), rec) != sizeof(rec)) {
*error = "PKG metadata is truncated";
return false;
}
const u32 id = Read32BE(rec);
const u32 size = Read32BE(rec + 4);
if (size > 0x1000) {
*error = "PKG metadata is corrupt";
return false;
}
std::vector<u8> value(size);
if (size && loader->ReadAt(metaPos + 8, size, value.data()) != size) {
*error = "PKG metadata is truncated";
return false;
}
switch (id) {
case 2:
if (size >= 4) {
info_.contentType = Read32BE(value.data());
}
break;
case 6:
info_.titleId = std::string((const char *)value.data(), strnlen((const char *)value.data(), size));
break;
case 13:
if (size >= 4) {
itemsOffset = Read32BE(value.data());
}
break;
default:
break;
}
metaPos += 8 + size;
}
if (info_.contentType != kPkgContentTypePSP) {
*error = StringFromFormat("PKG holds content type 0x%x, not a PSP game", info_.contentType);
return false;
}
// Item table, then the filenames it points at. Both live in the encrypted area, but a
// filename is encrypted with its own item's key rather than the table's.
std::vector<u8> table((size_t)itemCount * PKG_ITEM_SIZE);
if (!ReadEncrypted(itemsOffset, table.size(), mainKey_, table.data())) {
*error = "Failed to read the PKG item table";
return false;
}
info_.items.reserve(itemCount);
for (u32 i = 0; i < itemCount; i++) {
const u8 *rec = table.data() + (size_t)i * PKG_ITEM_SIZE;
const u32 nameOffset = Read32BE(rec);
const u32 nameSize = Read32BE(rec + 4);
PkgItem item;
item.dataOffset = Read64BE(rec + 8);
item.dataSize = Read64BE(rec + 16);
item.pspType = rec[0x18];
item.flags = rec[0x1B];
if (item.dataOffset > dataSize_ || item.dataSize > dataSize_ - item.dataOffset) {
*error = "PKG item points outside the file";
return false;
}
if (nameSize == 0 || nameSize > PKG_MAX_NAME) {
*error = "PKG item has a broken name";
return false;
}
item.name.resize(nameSize);
if (!ReadEncrypted(nameOffset, nameSize, ItemKey(item), (u8 *)item.name.data())) {
*error = "Failed to read a PKG item name";
return false;
}
// Names aren't terminated, but be forgiving if one is anyway.
item.name.resize(strnlen(item.name.c_str(), item.name.size()));
info_.items.push_back(item);
}
// The package's own PARAM.SFO. There's no reliable pointer to it in the metadata for PSP
// packages (the field is empty), so go by name.
for (const PkgItem &item : info_.items) {
if (item.name != "PARAM.SFO") {
continue;
}
std::vector<u8> sfoData;
ParamSFOData sfo;
if (ReadItem(item, &sfoData, 64 * 1024) && sfo.ReadSFO(sfoData)) {
info_.title = sfo.GetValueString("TITLE");
info_.category = sfo.GetValueString("CATEGORY");
if (info_.titleId.empty()) {
info_.titleId = sfo.GetValueString("TITLE_ID");
}
}
break;
}
for (const PkgItem &item : info_.items) {
if (item.name == "USRDIR/CONTENT/PBOOT.PBP" && !item.IsDirectory()) {
ReadPBOOTInfo(item);
break;
}
}
INFO_LOG(Log::Loader, "PKG: %s (%s), %d items, update=%d for %s v%s",
info_.contentId.c_str(), info_.category.c_str(), (int)info_.items.size(),
(int)info_.isGameUpdate, info_.discId.c_str(), info_.discVersion.c_str());
return true;
}
// The PBOOT is a normal PBP - only its DATA.PSP is encrypted, and its PARAM.SFO is the one that
// says which disc and disc version this patches. That's what makes matching an installed update
// against a game at boot time possible without decrypting anything.
bool PkgReader::ReadPBOOTInfo(const PkgItem &pboot) {
// PBP header: magic, version, then eight little-endian subfile offsets.
u8 header[0x28];
if (pboot.dataSize < sizeof(header) || !ReadItemData(pboot, 0, sizeof(header), header)) {
return false;
}
if (memcmp(header, "\0PBP", 4) != 0) {
WARN_LOG(Log::Loader, "PKG: PBOOT.PBP isn't a PBP");
return false;
}
const u32 sfoOffset = Read32LE(header + 0x08);
const u32 iconOffset = Read32LE(header + 0x0C);
if (sfoOffset > iconOffset || iconOffset > pboot.dataSize) {
return false;
}
const u32 sfoSize = iconOffset - sfoOffset;
if (sfoSize == 0 || sfoSize > 64 * 1024) {
return false;
}
std::vector<u8> sfoData(sfoSize);
if (!ReadItemData(pboot, sfoOffset, sfoSize, sfoData.data())) {
return false;
}
ParamSFOData sfo;
if (!sfo.ReadSFO(sfoData)) {
return false;
}
info_.discId = sfo.GetValueString("DISC_ID");
info_.discVersion = sfo.GetValueString("DISC_VERSION");
info_.appVer = sfo.GetValueString("APP_VER");
info_.systemVer = sfo.GetValueString("PSP_SYSTEM_VER");
info_.pbootTitle = sfo.GetValueString("PBOOT_TITLE");
if (info_.title.empty()) {
info_.title = sfo.GetValueString("TITLE");
}
if (info_.discVersion.empty()) {
info_.discVersion = "1.00";
}
// The install goes in a directory named after the disc ID, so without a usable one there's
// nowhere to put this.
info_.isGameUpdate = IsSafePathComponent(info_.discId);
return info_.isGameUpdate;
}
// Rejects anything that could escape the destination directory. Package filenames are attacker
// data as far as we're concerned.
static bool IsSafeRelativePath(std::string_view name) {
if (name.empty() || name.size() > PKG_MAX_NAME) {
return false;
}
if (name.front() == '/' || name.find('\\') != std::string_view::npos || name.find(':') != std::string_view::npos) {
return false;
}
size_t start = 0;
while (start <= name.size()) {
const size_t slash = name.find('/', start);
const std::string_view part = name.substr(start, slash == std::string_view::npos ? std::string_view::npos : slash - start);
if (part.empty() || part == "." || part == "..") {
return false;
}
for (char c : part) {
// Control characters in a filename are never legitimate here.
if ((unsigned char)c < 0x20) {
return false;
}
}
if (slash == std::string_view::npos) {
break;
}
start = slash + 1;
}
return true;
}
std::string PkgItemInstallPath(const PkgItem &item) {
// A package wraps its payload PS3-style. USRDIR/CONTENT/ is where the patch's files live, and
// USRDIR/ itself only ever holds ISO.BIN.EDAT; both map to the game folder root.
std::string_view name = item.name;
if (name == "USRDIR" || name == "USRDIR/CONTENT") {
// The wrappers themselves. Both stand for the game folder, which already exists.
return std::string();
}
if (startsWith(name, "USRDIR/CONTENT/")) {
name = name.substr(strlen("USRDIR/CONTENT/"));
} else if (startsWith(name, "USRDIR/")) {
name = name.substr(strlen("USRDIR/"));
} else {
// Everything at the root is store metadata - PARAM.SFO, PS3LOGO.DAT, ICON0.PNG and
// friends. Installing the SFO would make PPSSPP mistake the folder for save data.
return std::string();
}
if (name.empty() || !IsSafeRelativePath(name)) {
return std::string();
}
return std::string(name);
}
u64 PkgInstalledSize(const PkgInfo &info) {
u64 total = 0;
for (const PkgItem &item : info.items) {
if (!item.IsDirectory() && !PkgItemInstallPath(item).empty()) {
total += item.dataSize;
}
}
return total;
}
bool InstallPkg(PkgReader &reader, const Path &destDir, const std::function<void(float)> &progress, std::string *error) {
const PkgInfo &info = reader.Info();
const u64 totalBytes = PkgInstalledSize(info);
if (totalBytes == 0) {
*error = "Nothing to install in this PKG";
return false;
}
if (!File::CreateFullPath(destDir)) {
*error = "Failed to create the destination folder";
return false;
}
u64 writtenBytes = 0;
std::vector<u8> buffer(PKG_COPY_BLOCK);
for (const PkgItem &item : info.items) {
const std::string relative = PkgItemInstallPath(item);
if (relative.empty()) {
if (!item.IsDirectory() && !startsWith(item.name, "USRDIR")) {
// Expected - store metadata. Anything else is worth a line in the log.
VERBOSE_LOG(Log::Loader, "PKG: skipping '%s'", item.name.c_str());
}
continue;
}
const Path destPath = destDir / relative;
if (item.IsDirectory()) {
if (!File::CreateFullPath(destPath)) {
*error = "Failed to create a folder in the destination";
return false;
}
continue;
}
if (!File::CreateFullPath(destPath.NavigateUp())) {
*error = "Failed to create a folder in the destination";
return false;
}
FILE *f = File::OpenCFile(destPath, "wb");
if (!f) {
*error = "Failed to write to the destination folder";
return false;
}
u64 pos = 0;
bool failed = false;
while (pos < item.dataSize) {
const size_t chunk = (size_t)std::min<u64>(buffer.size(), item.dataSize - pos);
if (!reader.ReadItemData(item, pos, chunk, buffer.data())) {
*error = "Failed to read from the PKG file";
failed = true;
break;
}
if (fwrite(buffer.data(), 1, chunk, f) != chunk) {
*error = "Failed to write to the destination folder";
failed = true;
break;
}
pos += chunk;
writtenBytes += chunk;
if (progress) {
progress((float)((double)writtenBytes / (double)totalBytes));
}
}
fclose(f);
if (failed) {
File::Delete(destPath);
return false;
}
INFO_LOG(Log::Loader, "PKG: installed %s (%lld bytes)", relative.c_str(), (long long)item.dataSize);
}
if (progress) {
progress(1.0f);
}
return true;
}
bool FindInstalledGameUpdate(std::string_view discId, InstalledGameUpdate *update) {
if (discId.empty()) {
return false;
}
const Path folder = GetSysDirectory(DIRECTORY_GAME) / std::string(discId);
const Path pbootPath = folder / "PBOOT.PBP";
if (!File::Exists(pbootPath)) {
return false;
}
// The version we want is in the PBOOT's own PARAM.SFO, which isn't encrypted.
std::unique_ptr<FileLoader> loader(ConstructFileLoader(pbootPath));
if (!loader) {
return false;
}
PBPReader pbp(loader.get());
std::vector<u8> sfoData;
ParamSFOData sfo;
if (!pbp.IsValid() || !pbp.GetSubFile(PBP_PARAM_SFO, &sfoData) || !sfo.ReadSFO(sfoData)) {
WARN_LOG(Log::Loader, "'%s' doesn't look like a game update", pbootPath.c_str());
return false;
}
update->folder = folder;
update->pbootPath = pbootPath;
update->appVer = sfo.GetValueString("APP_VER");
update->discVersion = sfo.GetValueString("DISC_VERSION");
update->title = sfo.GetValueString("PBOOT_TITLE");
update->sharesFolderWithGame = File::Exists(folder / "EBOOT.PBP");
update->sizeOnDisk = update->sharesFolderWithGame
? (u64)std::max<s64>(0, File::GetFileSize(pbootPath))
: File::ComputeRecursiveDirectorySize(folder);
return true;
}
bool DeleteInstalledGameUpdate(const InstalledGameUpdate &update) {
const bool useTrash = System_GetPropertyBool(SYSPROP_HAS_TRASH_BIN);
// Only the PBOOT when the folder is a game in its own right - see the struct's comment.
const Path target = update.sharesFolderWithGame ? update.pbootPath : update.folder;
INFO_LOG(Log::Loader, "Removing game update '%s'", target.c_str());
if (useTrash) {
// TODO: No way to tell whether this succeeded.
System_MoveToTrash(target);
return true;
}
return update.sharesFolderWithGame ? File::Delete(target) : File::DeleteDirRecursively(target);
}