Files
InstanceBrick--nightcap/src/update/apk_bundle.cc
T
珍惜andHttpAnimations 40237fae72 arm 64 (#152)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-21 20:09:37 +03:00

598 lines
21 KiB
C++

#include "update/apk_bundle.h"
#define JSON_NOEXCEPTION 1
#include <elf.h>
#include <fcntl.h>
#include <gelf.h>
#include <libelf.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <memory>
#include <nlohmann/json.hpp>
#include <set>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
#include "update/android_manifest.h"
#include "update/apk_signature.h"
#include "update/payload_integrity.h"
#include "update/zip_archive.h"
namespace mocktail::update {
namespace {
constexpr std::size_t kMaximumApkBytes = 1024ULL * 1024ULL * 1024ULL;
constexpr std::size_t kMaximumManifestBytes = 16U * 1024U * 1024U;
constexpr std::size_t kMaximumAssetBytes = 2ULL * 1024ULL * 1024ULL * 1024ULL;
constexpr std::size_t kMaximumCandidates = 64;
constexpr std::array<std::string_view, 7> kRequiredExports = {
"JNI_OnLoad",
"Java_com_roblox_engine_jni_NativeGLInterface_nativeGameGlobalInit",
"Java_com_roblox_engine_jni_NativeGLInterface_nativeUpdateAdapterInit",
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2InitWithParams",
"Java_com_roblox_engine_jni_NativeGLInterface_nativeAppBridgeStartLuaAppDM",
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2StartAppWithParams",
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2UpdateSurfaceAppWithPlatformParams",
};
struct ElfCloser {
void operator()(Elf* value) const {
if (value != nullptr) elf_end(value);
}
};
std::string ReadRegular(const std::filesystem::path& path, std::size_t maximum,
std::string* error) {
const int descriptor = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (descriptor < 0) {
*error = "cannot open regular file: " + path.string();
return {};
}
struct stat metadata = {};
if (fstat(descriptor, &metadata) != 0 || !S_ISREG(metadata.st_mode) ||
metadata.st_size < 0 ||
static_cast<std::uintmax_t>(metadata.st_size) > maximum) {
close(descriptor);
*error = "file exceeds its size limit: " + path.string();
return {};
}
std::string contents;
contents.reserve(static_cast<std::size_t>(metadata.st_size));
std::array<char, 64U * 1024U> buffer{};
while (true) {
const ssize_t bytes = read(descriptor, buffer.data(), buffer.size());
if (bytes == 0) break;
if (bytes < 0) {
if (errno == EINTR) continue;
close(descriptor);
*error = "cannot read regular file: " + path.string();
return {};
}
contents.append(buffer.data(), static_cast<std::size_t>(bytes));
}
close(descriptor);
return contents;
}
bool WritePrivateFile(const std::filesystem::path& path,
std::string_view contents, std::string* error) {
const int descriptor = open(
path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
if (descriptor < 0) {
*error = "cannot create payload metadata";
return false;
}
std::size_t offset = 0;
while (offset < contents.size()) {
const ssize_t bytes =
write(descriptor, contents.data() + offset, contents.size() - offset);
if (bytes < 0) {
if (errno == EINTR) continue;
close(descriptor);
*error = "cannot write payload metadata";
return false;
}
offset += static_cast<std::size_t>(bytes);
}
const bool synced = fsync(descriptor) == 0;
close(descriptor);
if (!synced) *error = "cannot persist payload metadata";
return synced;
}
std::string UtcTimestamp() {
const std::time_t now = std::time(nullptr);
std::tm utc{};
if (gmtime_r(&now, &utc) == nullptr) return "1970-01-01T00:00:00Z";
std::array<char, 32> value{};
std::strftime(value.data(), value.size(), "%Y-%m-%dT%H:%M:%SZ", &utc);
return value.data();
}
bool CopyRegular(const std::filesystem::path& source,
const std::filesystem::path& destination, std::string* error) {
std::error_code filesystem_error;
const std::filesystem::file_status status =
std::filesystem::symlink_status(source, filesystem_error);
if (filesystem_error || !std::filesystem::is_regular_file(status) ||
std::filesystem::is_symlink(status)) {
*error = "APK candidate is not a regular file";
return false;
}
const std::uintmax_t size =
std::filesystem::file_size(source, filesystem_error);
if (filesystem_error || size == 0 || size > kMaximumApkBytes) {
*error = "APK candidate exceeds its size limit";
return false;
}
// Provider downloads, candidate inspection, and prepared output all live in
// one private workspace. Reuse the immutable bytes when the filesystem
// supports hard links; this avoids several hundred MiB of transient copies.
std::filesystem::create_hard_link(source, destination, filesystem_error);
if (!filesystem_error) return true;
filesystem_error.clear();
std::filesystem::copy_file(source, destination,
std::filesystem::copy_options::none,
filesystem_error);
if (filesystem_error) {
*error = "cannot snapshot APK candidate";
return false;
}
return true;
}
bool HasEntry(const ZipListResult& list, std::string_view name) {
return std::any_of(list.entries.begin(), list.entries.end(),
[&](const ZipEntry& entry) { return entry.name == name; });
}
bool EndsWith(std::string_view value, std::string_view suffix) {
return value.size() >= suffix.size() &&
value.substr(value.size() - suffix.size()) == suffix;
}
struct ApkCandidate {
std::filesystem::path path;
AndroidManifestIdentity identity;
bool has_guest_library = false;
bool has_assets = false;
};
std::string GuestLibEntryName() {
return std::string(compat::kGuestLibApkEntryPrefix) +
std::string(compat::kGuestAbi) + "/libroblox.so";
}
bool CollectCandidates(const std::vector<std::filesystem::path>& archives,
const std::filesystem::path& directory,
std::vector<std::filesystem::path>* candidates,
std::string* error) {
std::error_code filesystem_error;
std::filesystem::create_directories(directory, filesystem_error);
if (filesystem_error) {
*error = "cannot create APK candidate directory";
return false;
}
std::size_t candidate_number = 0;
for (const std::filesystem::path& archive : archives) {
const ZipListResult list = ListZipEntries(archive);
if (!list) {
*error = list.error;
return false;
}
if (HasEntry(list, "AndroidManifest.xml")) {
if (++candidate_number > kMaximumCandidates) {
*error = "provider produced too many APK candidates";
return false;
}
const std::filesystem::path destination =
directory /
("candidate-" + std::to_string(candidate_number) + ".apk");
if (!CopyRegular(archive, destination, error)) return false;
candidates->push_back(destination);
continue;
}
for (const ZipEntry& entry : list.entries) {
if (entry.directory || !EndsWith(entry.name, ".apk")) continue;
if (++candidate_number > kMaximumCandidates ||
entry.uncompressed_size > kMaximumApkBytes) {
*error = "provider bundle has excessive APK candidates";
return false;
}
const std::filesystem::path destination =
directory /
("candidate-" + std::to_string(candidate_number) + ".apk");
if (!ExtractZipEntry(archive, entry.name, destination, kMaximumApkBytes,
error)) {
return false;
}
candidates->push_back(destination);
}
}
if (candidates->empty()) {
*error = "provider output contains no APK candidates";
return false;
}
return true;
}
bool InspectCandidates(const std::vector<std::filesystem::path>& paths,
const ExpectedPayloadIdentity& expected,
std::vector<ApkCandidate>* candidates,
std::string* error) {
for (const std::filesystem::path& path : paths) {
const ZipListResult list = ListZipEntries(path);
if (!list) continue;
const ZipReadResult manifest =
ReadZipEntry(path, "AndroidManifest.xml", kMaximumManifestBytes);
if (!manifest) continue;
AndroidManifestIdentity identity = ParseAndroidManifest(manifest.bytes);
if (!identity || identity.package_name != "com.roblox.client" ||
identity.version_code != expected.version_code ||
(identity.version_name != expected.version_name &&
!(identity.version_name.empty() && !identity.split_name.empty()))) {
continue;
}
ApkCandidate candidate;
candidate.path = path;
candidate.identity = std::move(identity);
candidate.has_guest_library = HasEntry(list, GuestLibEntryName());
candidate.has_assets = std::any_of(
list.entries.begin(), list.entries.end(), [](const ZipEntry& entry) {
return entry.name.size() > 7 && entry.name.substr(0, 7) == "assets/";
});
candidates->push_back(std::move(candidate));
}
if (candidates->empty()) {
*error = "no APK matches the requested Roblox version";
return false;
}
return true;
}
std::set<std::string> TrustedCertificates(const std::filesystem::path& path,
std::string* error) {
const std::string contents = ReadRegular(path, 1024U * 1024U, error);
if (!error->empty()) return {};
const nlohmann::json document =
nlohmann::json::parse(contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
document.value("package", "") != "com.roblox.client" ||
!document.contains("trusted_sha256") ||
!document["trusted_sha256"].is_array()) {
*error = "Roblox signing trust manifest is invalid";
return {};
}
std::set<std::string> trusted;
for (const auto& value : document["trusted_sha256"]) {
if (!value.is_string()) {
*error = "Roblox signing trust manifest contains a non-string digest";
return {};
}
std::string digest = value.get<std::string>();
std::transform(digest.begin(), digest.end(), digest.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
if (digest.size() != 64 ||
!std::all_of(digest.begin(), digest.end(), [](unsigned char character) {
return std::isdigit(character) ||
(character >= 'a' && character <= 'f');
})) {
*error = "Roblox signing trust manifest contains an invalid digest";
return {};
}
trusted.insert(std::move(digest));
}
if (trusted.empty()) *error = "Roblox signing trust manifest is empty";
return trusted;
}
std::vector<std::string> CommonTrustedCertificates(
const std::filesystem::path& base, const std::filesystem::path& split,
const std::set<std::string>& trusted, std::string* error) {
const ApkSignatureResult base_signature = VerifyApkSignature(base);
if (!base_signature) {
*error = "base APK signature: " + base_signature.error;
return {};
}
const ApkSignatureResult split_signature =
base == split ? base_signature : VerifyApkSignature(split);
if (!split_signature) {
*error = std::string(compat::kGuestAbi) +
" APK signature: " + split_signature.error;
return {};
}
std::vector<std::string> accepted;
for (const std::string& certificate : base_signature.certificate_sha256) {
if (trusted.find(certificate) != trusted.end() &&
std::find(split_signature.certificate_sha256.begin(),
split_signature.certificate_sha256.end(),
certificate) != split_signature.certificate_sha256.end()) {
accepted.push_back(certificate);
}
}
if (accepted.empty()) {
*error = "APK pair has no common trusted Roblox signing certificate";
}
return accepted;
}
bool ValidateElf(const std::filesystem::path& path, std::string* build_id,
std::string* error) {
if (elf_version(EV_CURRENT) == EV_NONE) {
*error = "libelf cannot initialise";
return false;
}
const int descriptor = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (descriptor < 0) {
*error = "cannot open extracted libroblox.so";
return false;
}
std::unique_ptr<Elf, ElfCloser> elf(
elf_begin(descriptor, ELF_C_READ, nullptr));
const auto close_elf = [&]() {
elf.reset();
close(descriptor);
};
if (!elf || elf_kind(elf.get()) != ELF_K_ELF) {
close_elf();
*error = "extracted libroblox.so is not ELF";
return false;
}
GElf_Ehdr header{};
if (gelf_getehdr(elf.get(), &header) == nullptr ||
header.e_machine != compat::kGuestElfMachine ||
gelf_getclass(elf.get()) != ELFCLASS64) {
close_elf();
*error = "libroblox.so is not a " + std::string(compat::kGuestAbi) +
" ELF";
return false;
}
std::size_t section_count = 0;
std::size_t string_index = 0;
if (elf_getshdrnum(elf.get(), &section_count) != 0 ||
elf_getshdrstrndx(elf.get(), &string_index) != 0) {
close_elf();
*error = "cannot inspect libroblox.so sections";
return false;
}
bool android_note = false;
std::set<std::string> exports;
for (std::size_t index = 0; index < section_count; ++index) {
Elf_Scn* section = elf_getscn(elf.get(), index);
GElf_Shdr section_header{};
if (section == nullptr ||
gelf_getshdr(section, &section_header) == nullptr) {
close_elf();
*error = "cannot inspect libroblox.so section";
return false;
}
const char* name =
elf_strptr(elf.get(), string_index, section_header.sh_name);
android_note =
android_note ||
(name != nullptr && std::string_view(name) == ".note.android.ident");
if (section_header.sh_type != SHT_DYNSYM ||
section_header.sh_entsize == 0) {
continue;
}
Elf_Data* data = elf_getdata(section, nullptr);
if (data == nullptr) continue;
const std::size_t symbol_count =
section_header.sh_size / section_header.sh_entsize;
for (std::size_t symbol_index = 0; symbol_index < symbol_count;
++symbol_index) {
GElf_Sym symbol{};
if (gelf_getsym(data, symbol_index, &symbol) == nullptr ||
symbol.st_name == 0 || symbol.st_shndx == SHN_UNDEF) {
continue;
}
const char* symbol_name =
elf_strptr(elf.get(), section_header.sh_link, symbol.st_name);
if (symbol_name != nullptr) exports.insert(symbol_name);
}
}
close_elf();
if (!android_note) {
*error = "libroblox.so has no Android identity note";
return false;
}
for (const std::string_view required : kRequiredExports) {
if (exports.find(std::string(required)) == exports.end()) {
*error =
"libroblox.so is missing required export: " + std::string(required);
return false;
}
}
const compat::BuildIdResult identity = compat::ReadElfBuildId(path.string());
if (!identity || identity.build_id.size() != 40) {
*error =
identity ? "libroblox.so Build ID is not 20 bytes" : identity.error;
return false;
}
*build_id = identity.build_id;
return true;
}
} // namespace
PreparedPayload PreparePayloadFromArchives(
const std::vector<std::filesystem::path>& archives,
const ExpectedPayloadIdentity& expected,
const std::filesystem::path& signing_trust_manifest,
const std::filesystem::path& workspace, std::string_view source) {
PreparedPayload result;
if (archives.empty()) {
result.error = "provider returned no archives";
return result;
}
std::error_code filesystem_error;
std::filesystem::create_directories(workspace, filesystem_error);
if (filesystem_error ||
!std::filesystem::is_empty(workspace, filesystem_error)) {
result.error = "payload preparation workspace must be empty";
return result;
}
std::vector<std::filesystem::path> candidate_paths;
if (!CollectCandidates(archives, workspace / "candidates", &candidate_paths,
&result.error)) {
return result;
}
std::vector<ApkCandidate> candidates;
if (!InspectCandidates(candidate_paths, expected, &candidates,
&result.error)) {
return result;
}
const ApkCandidate* base = nullptr;
const ApkCandidate* split = nullptr;
for (const ApkCandidate& candidate : candidates) {
if (candidate.identity.split_name.empty() && candidate.has_assets &&
base == nullptr) {
base = &candidate;
}
}
const std::string guest_split_name(compat::kGuestApkSplitName);
for (const ApkCandidate& candidate : candidates) {
if (candidate.has_guest_library &&
(candidate.identity.split_name == guest_split_name ||
candidate.identity.split_name.empty()) &&
split == nullptr) {
split = &candidate;
}
}
if (base == nullptr || split == nullptr) {
result.error = "no matching Roblox base and " +
std::string(compat::kGuestAbi) + " APK pair was found";
return result;
}
const std::set<std::string> trusted =
TrustedCertificates(signing_trust_manifest, &result.error);
if (!result.error.empty()) return result;
const std::vector<std::string> accepted = CommonTrustedCertificates(
base->path, split->path, trusted, &result.error);
if (!result.error.empty()) return result;
const std::filesystem::path prepared = workspace / "prepared";
std::filesystem::create_directories(prepared / "sober_apk", filesystem_error);
if (filesystem_error ||
!CopyRegular(base->path, prepared / "sober_apk/base.apk",
&result.error) ||
!CopyRegular(split->path,
prepared / "sober_apk" / compat::kGuestSplitApkFile,
&result.error)) {
if (result.error.empty()) result.error = "cannot create prepared payload";
return result;
}
if (!ExtractZipEntry(split->path, GuestLibEntryName(),
prepared / "libroblox.so", kMaximumApkBytes,
&result.error)) {
return result;
}
std::size_t asset_count = 0;
if (!ExtractZipPrefix(base->path, "assets/", prepared / "assets",
kMaximumAssetBytes, &asset_count, &result.error) ||
asset_count == 0) {
if (result.error.empty()) result.error = "base APK contains no assets";
return result;
}
std::string build_id;
if (!ValidateElf(prepared / "libroblox.so", &build_id, &result.error)) {
return result;
}
if (expected.exact_build_id.has_value() &&
build_id != *expected.exact_build_id) {
result.error = "downloaded Roblox Build ID is not exact-supported";
return result;
}
std::string hash_error;
std::size_t asset_count_verified = 0;
const std::string asset_tree_hash =
HashAssetTree(prepared / "assets", &asset_count_verified, &hash_error);
if (!hash_error.empty()) {
result.error = hash_error;
return result;
}
const std::string library_hash =
HashRegularFile(prepared / "libroblox.so", &hash_error);
const std::string base_hash =
HashRegularFile(prepared / "sober_apk/base.apk", &hash_error);
const std::string split_hash = HashRegularFile(
prepared / "sober_apk" / compat::kGuestSplitApkFile, &hash_error);
if (!hash_error.empty()) {
result.error = hash_error;
return result;
}
nlohmann::json metadata = {
{"schema_version", 1},
{"package", "com.roblox.client"},
{"version_name", expected.version_name},
{"version_code", expected.version_code},
{"abi", std::string(compat::kGuestAbi)},
{"elf_build_id", build_id},
{"sha256",
{{"libroblox", library_hash},
{"base_apk", base_hash},
{std::string(compat::kGuestSplitApkHashKey), split_hash}}},
{"signing_certificates_sha256", accepted},
{"assets",
{{"file_count", asset_count_verified},
{"sha256_tree", asset_tree_hash}}},
{"source", std::string(source)},
{"imported_at", UtcTimestamp()},
{"compatibility_status", expected.exact_build_id.has_value()
? "exact-supported"
: "verified-candidate"},
{"apk_layout", base->path == split->path ? "monolithic" : "split"},
};
if (!WritePrivateFile(prepared / "roblox_payload.json",
metadata.dump(2) + "\n", &result.error)) {
return result;
}
// The hashes above were computed from these exact bytes and written into
// roblox_payload.json, so re-hashing the tree here would only re-derive them.
// The shape, schema, and identity guard still runs; the bytes that reach the
// immutable store are hashed in full by PayloadStore::Stage after the copy.
const PayloadIntegrityResult verified = InspectPreparedPayload(prepared);
if (!verified || verified.payload_id !=
std::to_string(expected.version_code) + "-" + build_id) {
result.error =
verified ? "prepared payload identity changed" : verified.error;
return result;
}
result.directory = prepared;
result.payload_id = std::to_string(expected.version_code) + "-" + build_id;
result.version_name = expected.version_name;
result.version_code = expected.version_code;
result.build_id = build_id;
return result;
}
ExpectedPayloadIdentity ExactPayloadIdentity(
const SupportedPayloadProfile& profile) {
return {profile.version_name, profile.version_code, profile.elf_build_id};
}
} // namespace mocktail::update