unlaunch-installer_dev/arm9/src/unlaunch.cpp
Edoardo Lolletti 6edcde25c3
Add sanitization to loaded GIF images
Parse the provided loaded background images and reconstruct a new GIF file on the fly to ensure they don't contain any extension that might not be supported by unalunch.
The generated GIF file will have a global color table, and single section, being the image descriptor. It also won't have any looping block.
2025-08-19 18:18:24 +02:00

555 lines
18 KiB
C++

#include "message.h"
#include "sha1digest.h"
#include "storage.h"
#include "tonccpy.h"
#include "unlaunch.h"
#include <nds/sha1.h>
#include <algorithm>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <memory>
#include <format>
static char unlaunchInstallerBuffer[0x30000];
static char ogUnlaunchInstallerBuffer[0x30000];
static const char* hnaaTmdPath = "nand:/title/00030017/484e4141/content/title.tmd";
static const char* hnaaBackupTmdPath = "nand:/title/00030017/484e4141/content/title.tmd.bak";
UNLAUNCH_VERSION installerVersion{INVALID};
size_t unlaunchInstallerSize{};
constexpr std::array knownUnlaunchHashes{
/*"9e6a8d95062533dfc422362f99ff3e24e7de9920"_sha1, // v0.8: blacklisted, doesn't like this install method*/
/*"fb0d0ffebda67b786f608bf5cbcb2efee6ab42bb"_sha1, // v0.9: blacklisted, doesn't like this install method*/
/*"d710ff585e321082b33456dd4e0568200c9adcc7"_sha1, // v1.0: blacklisted, doesn't like this install method*/
/*"4f3e455e0a752d35a219a3ff10ba14a6c98bff13"_sha1, // v1.1: blacklisted, doesn't like this install method*/
/*"25db1a47ba84748f911d9f4357bfc417533121c7"_sha1, // v1.2: blacklisted, doesn't like this install method*/
/*"068f1d56da02bb4f93fb76d7874e14010c7e7a3d"_sha1, // v1.3: blacklisted, doesn't like this install method*/
/*"43197370de74d302ef7c4420059c3ca7d50c4f3d"_sha1, // v1.4: blacklisted, doesn't like this install method*/
/*"0525b28cc59b6f7fc00ad592aebadd7257bf7efb"_sha1, // v1.5: blacklisted, doesn't like this install method*/
/*"9470a51fde188235052b119f6bfabf6689cb2343"_sha1, // v1.6: blacklisted, doesn't like this install method*/
/*"672c11eb535b97b0d32ff580d314a2ad6411d5fe"_sha1, // v1.7: blacklisted, doesn't like this install method*/
/*"b76c2b1722e769c6c0b4b3d4bc73250e41993229"_sha1, // v1.8: blacklisted, the HNAA patch is only done for 2.0 */
/*"f3eb41cba136a3477523155f8b05df14917c55f4"_sha1, // v1.9: blacklisted, the HNAA patch is only done for 2.0 */
"15f4a36251d1408d71114019b2825fe8f5b4c8cc"_sha1, // v2.0
};
constexpr std::array gifOffsets{
/* std::make_pair(0x48d4, 0x8540),*/ /* 1.8 */
/* std::make_pair(0x48c8, 0x8534),*/ /* 1.9 */
std::make_pair(0x48f0, 0x855c), /* 2.0 */
};
constexpr std::array blockAllPatchesOffset{
/* 0xae74, */ /* 1.9 */
0xae91, /* 2.0 */
};
static bool writeUnlaunchToHNAAFolder();
bool isValidUnlaunchInstallerSize(size_t size)
{
return size == 163320 /*1.8*/ || size == 196088 /*1.9, 2.0*/;
}
static bool removeHnaaLauncher()
{
auto* errString = [] -> const char* {
if(fileExists(hnaaTmdPath)) {
if(!toggleFileReadOnly(hnaaTmdPath, false))
{
return "\x1B[31mError:\x1B[33m Failed to mark unlaunch's title.tmd as writable\nLeaving as is\n";
}
if(!removeIfExists(hnaaTmdPath))
{
return "\x1B[31mError:\x1B[33m Failed to delete ulnaunch's title.tmd\n";
}
}
if(!removeIfExists("nand:/title/00030017/484e4141/content"))
{
return "\x1B[31mError:\x1B[33m Failed to delete ulnaunch's content folder\n";
}
if(!removeIfExists("nand:/title/00030017/484e4141"))
{
return "\x1B[31mError:\x1B[33m Failed to delete ulnaunch's 484e4141 folder\n";
}
return nullptr;
}();
if(errString)
{
messageBox(errString);
return false;
}
return true;
}
static bool restoreMainTmd(const consoleInfo& info, bool removeHNAABackup)
{
std::shared_ptr<FILE> launcherTmdSptr{fopen(info.launcherTmdPath.data(), "r+b"), fclose};
if(!launcherTmdSptr)
{
messageBox("\x1B[31mError:\x1B[33m Failed to open default launcher's title.tmd\n");
return false;
}
FILE* launcherTmd = launcherTmdSptr.get();
// If the tmd is patched, assume the HNAA backup is already set in place.
if(info.tmdPatched) {
fseek(launcherTmd, 0x190, SEEK_SET);
// Set back the title.tmd's title id from GNXX to HNXX
char c = 0x48;
fwrite(&c, 1, 1, launcherTmd);
fflush(launcherTmd);
} else if(!info.tmdGood || info.tmdInvalid) {
// The tmd isn't good, it either has the wrong size, or the hash didn't match
// and it wasn't patched with the new method
// Install the hnaa backup if not found and then truncate the tmd to 520b
// before restoring it
if(!info.UnlaunchHNAAtmdFound && !removeHNAABackup)
{
auto choiceString = [&]{
if(installerVersion != INVALID)
return "Unlaunch was installed with the\n"
"legacy method.\n"
"Before uninstalling it, a\n"
"failsafe installation will be\n"
"created.\n"
"Proceed?";
return "Unlaunch was installed with the\n"
"legacy method\n"
"But a failsafe installation\n"
"cannot be created since no valid\n"
"unlaunch installer was provided.\n"
"Proceed anyways?";
}();
if(choiceBox(choiceString) == NO)
{
return false;
}
if(installerVersion != INVALID)
{
if(!writeUnlaunchToHNAAFolder())
{
if(choiceBox("Failsafe installation couldn't\n"
"be copmleted.\n"
"Proceed anyways?") == NO)
{
return false;
}
}
}
}
if (ftruncate(fileno(launcherTmd), 520) != 0) {
messageBox("\x1B[31mError:\x1B[33m Failed to remove unlaunch\n");
return false;
}
}
Sha1Digest digest;
calculateFileSha1(launcherTmd, &digest);
// the tmd still doesn't match, write a known good one
if(digest != info.recoveryTmdDataSha) {
fseek(launcherTmd, 0, SEEK_SET);
auto written = fwrite(info.recoveryTmdData.data(), info.recoveryTmdData.size(), 1, launcherTmd);
if(written != 1) {
messageBox("\x1B[31mError:\x1B[33m Failed to remove unlaunch\n");
return false;
}
}
if(removeHNAABackup && info.UnlaunchHNAAtmdFound)
{
return removeHnaaLauncher();
}
return true;
}
static bool patchMainTmd(const char* path)
{
FILE* launcherTmd = fopen(path, "r+b");
if(!launcherTmd)
{
messageBox("\x1B[31mError:\x1B[33m Failed to open default launcher's title.tmd\n");
return false;
}
// Patches the title.tmd's title id from HNXX to GNXX
fseek(launcherTmd, 0x190, SEEK_SET);
char c;
fread(&c, 1, 1, launcherTmd);
//if byte is not already set, it's clean
if(c == 0x48)
{
fseek(launcherTmd, -1, SEEK_CUR);
c = 0x47;
fwrite(&c, 1, 1, launcherTmd);
}
else if(c != 0x47)
{
messageBox("\x1B[31mError:\x1B[33m Default launcher's title.tmd was tamprered with, aborting\n");
fclose(launcherTmd);
return false;
}
fclose(launcherTmd);
return true;
}
static bool restoreProtoTmd(const char* path)
{
if (!fileExists(hnaaBackupTmdPath))
{
messageBox("\x1B[31mError:\x1B[33m No original tmd found!\nCan't uninstall unlaunch.\n");
return false;
}
removeIfExists(path);
rename(hnaaBackupTmdPath, path);
toggleFileReadOnly(path, false);
return true;
}
bool uninstallUnlaunch(const consoleInfo& info, bool removeHNAABackup)
{
// TODO: handle retailLauncherTmdPresentAndToBePatched = false on retail consoles
if (info.isRetail) {
if(!toggleFileReadOnly(info.launcherTmdPath.data(), false))
{
messageBox(std::format("\x1B[31mError:\x1B[33m Failed to make {} writable\n", info.launcherTmdPath).data());
return false;
}
if(!toggleFileReadOnly(info.launcherAppPath.data(), false))
{
messageBox(std::format("\x1B[31mError:\x1B[33m Failed to make {} writable\n", info.launcherAppPath).data());
return false;
}
if (!restoreMainTmd(info, removeHNAABackup))
{
return false;
}
} else {
if (!toggleFileReadOnly(hnaaTmdPath, false) || !restoreProtoTmd(hnaaTmdPath))
{
return false;
}
}
return true;
}
static bool writeUnlaunchTmd(const char* path)
{
static constexpr auto unlaunchShaOffset = 0x4000;
Sha1Digest expectedDigest, actualDigest;
swiSHA1Calc(expectedDigest.data(), unlaunchInstallerBuffer + unlaunchShaOffset,
(unlaunchInstallerSize + 520) - unlaunchShaOffset);
if(calculateFileSha1PathOffset(path, actualDigest.data(), unlaunchShaOffset) && expectedDigest == actualDigest)
{
// the tmd hasn't changed, no need to do anything
return true;
}
FILE* targetTmd = fopen(path, "wb");
if (!targetTmd)
{
messageBox("\x1B[31mError:\x1B[33m Failed to open target unlaunch tmd\n");
return false;
}
if(!writeToFile(targetTmd, unlaunchInstallerBuffer, unlaunchInstallerSize + 520))
{
fclose(targetTmd);
removeIfExists(path);
messageBox("\x1B[31mError:\x1B[33m Failed write unlaunch to tmd\n");
return false;
}
fclose(targetTmd);
if(!calculateFileSha1PathOffset(path, actualDigest.data(), unlaunchShaOffset) || expectedDigest != actualDigest)
{
removeIfExists(path);
messageBox("\x1B[31mError:\x1B[33m Unlaunch tmd was not properly written\n");
return false;
}
return true;
}
static bool writeUnlaunchToHNAAFolder()
{
//Create HNAA launcher folder
if (!safeCreateDir("nand:/title/00030017")
|| !safeCreateDir("nand:/title/00030017/484e4141")
|| !safeCreateDir("nand:/title/00030017/484e4141/content")) {
return false;
}
// We have to remove write protect otherwise reinstalling will fail.
if (fileExists(hnaaTmdPath) && !toggleFileReadOnly(hnaaTmdPath, false)) {
messageBox("\x1B[31mError:\x1B[33m Can't remove launcher tmd write protect\n");
return false;
}
if (!writeUnlaunchTmd(hnaaTmdPath))
{
removeHnaaLauncher();
return false;
}
//Mark the tmd as readonly
if(!toggleFileReadOnly(hnaaTmdPath, true))
{
messageBox("\x1B[31mError:\x1B[33m Failed to mark unlaunch's title.tmd as read only\n");
removeHnaaLauncher();
return false;
}
return true;
}
static bool installUnlaunchRetailConsole(const consoleInfo& info)
{
if(!writeUnlaunchToHNAAFolder())
return false;
//Finally patch the default launcher tmd to be invalid
//If there isn't a title.tmd matching the language region in the hwinfo
// nothing else has to be done, could be a language patch, or a dev system, the user will know what they have done
if (!info.tmdFound)
return true;
// Set tmd as writable in case unlaunch was already installed through the old method
if(!toggleFileReadOnly(info.launcherTmdPath.data(), false) || !patchMainTmd(info.launcherTmdPath.data()))
{
removeHnaaLauncher();
return false;
}
if (!toggleFileReadOnly(info.launcherTmdPath.data(), true) || !toggleFileReadOnly(info.launcherAppPath.data(), true))
{
messageBox("\x1B[31mError:\x1B[33m Failed to mark default launcher's title.tmd\nas read only, install might be unstable\n");
}
return true;
}
static bool installUnlaunchProtoConsole(void)
{
if(choiceBox("Your DSi has a non-standard\nregion.\n"
"\x1B[31mInstalling unlaunch may be\n"
"unsafe.\x1B[33m"
"\nCancelling is recommended!"
"\n\nContinue anyways?") == NO)
return false;
// Prototypes DSis are always HNAA. We can't use code that will nuke their launcher.
// Also some justification for adding proto support: they're really common.
// "Real" protos (X3, X4, etc) are hard to find but there are tons of release
// version DSis that are running prototype firmware.
// Likely factory rejects that never had production firmware flashed.
// We have to remove write protect otherwise reinstalling will fail.
if (fileExists(hnaaTmdPath) && !toggleFileReadOnly(hnaaTmdPath, false)) {
messageBox("\x1B[31mError:\x1B[33m Can't remove launcher tmd write protect\n");
return false;
}
bool hnaaBackupExists = fileExists(hnaaBackupTmdPath);
// Back up the TMD since we'll be writing to it directly.
if (!hnaaBackupExists)
{
rename(hnaaTmdPath, hnaaBackupTmdPath);
// Mark backup tmd as readonly, just to be sure
toggleFileReadOnly("nand:/title/00030017/484e4141/content/title.tmd.bak", true);
}
if(!writeUnlaunchTmd(hnaaTmdPath))
{
copyFile("nand:/title/00030017/484e4141/content/title.tmd.bak", hnaaTmdPath);
return false;
}
// Mark the tmd as readonly
if (!toggleFileReadOnly(hnaaTmdPath, true))
{
// There is nothing that can be done at this point.
messageBox("\x1B[31mError:\x1B[33m Failed to mark tmd as read only\n");
}
return true;
}
static bool readUnlaunchInstaller(std::string_view path)
{
FILE* unlaunchInstaller = fopen(path.data(), "rb");
if (!unlaunchInstaller)
{
messageBox("\x1B[31mError:\x1B[33m Failed to open unlaunch installer\n");
return false;
}
unlaunchInstallerSize = getFileSize(unlaunchInstaller);
if(!isValidUnlaunchInstallerSize(unlaunchInstallerSize))
{
messageBox("\x1B[31mError:\x1B[33m Unlaunch installer wrong file size\n");
return false;
}
int toRead = unlaunchInstallerSize;
auto* buff = unlaunchInstallerBuffer;
// Pad the installer with 520 bytes, those being the size of a valid tmd
buff += 520;
size_t n = 0;
while (toRead != 0 && (n = fread(buff, sizeof(uint8_t), toRead, unlaunchInstaller)) > 0)
{
toRead -= n;
buff += n;
}
if (toRead != 0 || ferror(unlaunchInstaller))
{
fclose(unlaunchInstaller);
messageBox("\x1B[31mError:\x1B[33m Failed read unlaunch installer\n");
return false;
}
fclose(unlaunchInstaller);
return true;
}
static bool verifyUnlaunchInstaller(void)
{
Sha1Digest digest;
swiSHA1Calc(digest.data(), unlaunchInstallerBuffer + 520, unlaunchInstallerSize);
auto it = std::ranges::find(knownUnlaunchHashes, digest);
if(it == knownUnlaunchHashes.end())
{
messageBox("\x1B[31mError:\x1B[33m Provided unlaunch installer has an unknown hash\n");
return false;
}
auto idx = std::distance(knownUnlaunchHashes.begin(), it);
installerVersion = static_cast<UNLAUNCH_VERSION>(idx);
return true;
}
static bool patchCustomBackground(std::span<uint8_t> customBackground)
{
auto size = customBackground.size();
if(size == 0)
{
return true;
}
if(size < 7 || size > MAX_GIF_SIZE)
{
messageBox("\x1B[31mError:\x1B[33m Gif file too big.\n");
return false;
}
const u32 gifSignatureStart = 0x38464947;
const u32 gifSignatureEnd = 0x3B000044;
auto [gifOffsetStart, gifOffsetEnd] = gifOffsets[installerVersion];
auto* gifStart = reinterpret_cast<uint32_t*>((unlaunchInstallerBuffer + 520) + gifOffsetStart);
auto* gifEnd = reinterpret_cast<uint32_t*>((unlaunchInstallerBuffer + 520) + gifOffsetEnd);
if(*gifStart != gifSignatureStart || *gifEnd != gifSignatureEnd)
{
messageBox("\x1B[31mError:\x1B[33m Gif offsets not matching.\n");
return false;
}
std::memcpy(gifStart, customBackground.data(), size);
return true;
}
static bool applyBinaryPatch(const char* path)
{
static constexpr auto lzssCompressedBinaryOffset = 0x8580;
static constexpr auto lzssCompressedBinarySize = 0x67FD;
auto* patch = fopen(path, "rb");
if(!patch)
{
messageBox("\x1B[31mError:\x1B[33m Failed to open the patch.\n");
return false;
}
auto patchSize = getFileSize(patch);
if(patchSize > lzssCompressedBinarySize)
{
messageBox("\x1B[31mError:\x1B[33m Patch is too big.\n");
fclose(patch);
return false;
}
if (fread((unlaunchInstallerBuffer + 520) + lzssCompressedBinaryOffset, 1, patchSize, patch) != patchSize)
{
messageBox("\x1B[31mError:\x1B[33m Failed to read patch.\n");
fclose(patch);
return false;
}
fclose(patch);
return true;
}
static bool patchUnlaunchInstaller(bool disableAllPatches, const char* splashSoundBinaryPatchPath, std::span<uint8_t> customBackground)
{
tonccpy(unlaunchInstallerBuffer, ogUnlaunchInstallerBuffer, sizeof(unlaunchInstallerBuffer));
if (splashSoundBinaryPatchPath)
{
iprintf("Applying splash and sound patch\n");
if(!applyBinaryPatch(splashSoundBinaryPatchPath))
{
return false;
}
} else {
if(disableAllPatches) {
// change launcher TID from ANH to SAN so that unlaunch doesn't realize it's booting the launcher
auto patchOffset = blockAllPatchesOffset[installerVersion];
const char newID[]{'S','A','N'};
memcpy((unlaunchInstallerBuffer + 520) + patchOffset, newID, 3);
}
iprintf("Applying HNAA patch\n");
if(!applyBinaryPatch("nitro:/force-hnaa-patch.bin"))
{
return false;
}
}
if(!patchCustomBackground(customBackground))
{
return false;
}
return true;
}
UNLAUNCH_VERSION loadUnlaunchInstaller(std::string_view path)
{
if(readUnlaunchInstaller(path) && verifyUnlaunchInstaller())
{
tonccpy(ogUnlaunchInstallerBuffer, unlaunchInstallerBuffer, sizeof(unlaunchInstallerBuffer));
return installerVersion;
}
return INVALID;
}
std::array unlaunchVersionStrings{
"v2.0",
"INVALID",
};
static_assert(unlaunchVersionStrings.size() == (INVALID + 1));
const char* getUnlaunchVersionString(UNLAUNCH_VERSION version)
{
return unlaunchVersionStrings[version];
}
bool installUnlaunch(const consoleInfo& info, bool disableAllPatches, const char* splashSoundBinaryPatchPath, std::span<uint8_t> customBackground)
{
if (installerVersion == INVALID || !patchUnlaunchInstaller(disableAllPatches, splashSoundBinaryPatchPath, customBackground))
return false;
// Treat protos differently
if (!info.isRetail)
{
return installUnlaunchProtoConsole();
}
// Do things normally for production units
return installUnlaunchRetailConsole(info);
}