symbolicator-server 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- symbolicator_server-0.0.1/.github/workflows/python-app.yml +35 -0
- symbolicator_server-0.0.1/.github/workflows/python-publish.yml +33 -0
- symbolicator_server-0.0.1/.gitignore +50 -0
- symbolicator_server-0.0.1/.pre-commit-config.yaml +15 -0
- symbolicator_server-0.0.1/PKG-INFO +85 -0
- symbolicator_server-0.0.1/README.md +67 -0
- symbolicator_server-0.0.1/pyproject.toml +40 -0
- symbolicator_server-0.0.1/requirements.txt +2 -0
- symbolicator_server-0.0.1/setup.cfg +4 -0
- symbolicator_server-0.0.1/symbolicator_server/__init__.py +0 -0
- symbolicator_server-0.0.1/symbolicator_server/__main__.py +4 -0
- symbolicator_server-0.0.1/symbolicator_server/_version.py +24 -0
- symbolicator_server-0.0.1/symbolicator_server/scanner.py +99 -0
- symbolicator_server-0.0.1/symbolicator_server/server.py +170 -0
- symbolicator_server-0.0.1/symbolicator_server/store.py +385 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/PKG-INFO +85 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/SOURCES.txt +21 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/dependency_links.txt +1 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/entry_points.txt +2 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/requires.txt +2 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/scm_file_list.json +16 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/scm_version.json +8 -0
- symbolicator_server-0.0.1/symbolicator_server.egg-info/top_level.txt +2 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Python App
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ '**' ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ 'main' ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: [ '3.14' ]
|
|
16
|
+
os: [ macos-latest, ubuntu-latest ]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v3
|
|
20
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
21
|
+
uses: actions/setup-python@v4
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python3 -m pip install --upgrade pip
|
|
27
|
+
python3 -m pip install pre-commit
|
|
28
|
+
- name: Run pre-commit hooks
|
|
29
|
+
run: |
|
|
30
|
+
pre-commit run --all-files
|
|
31
|
+
- name: Test install
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install -U .
|
|
34
|
+
- name: Test show usage
|
|
35
|
+
run: symbolicator-server --help
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# These workflows will upload a Python Package using Twine when a release is created
|
|
2
|
+
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
|
|
3
|
+
|
|
4
|
+
name: Upload Python Package
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [ created ]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
deploy:
|
|
12
|
+
|
|
13
|
+
runs-on: macos-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v4
|
|
21
|
+
with:
|
|
22
|
+
python-version: '3.14'
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
pip install -U build setuptools wheel twine
|
|
27
|
+
- name: Build and publish
|
|
28
|
+
env:
|
|
29
|
+
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
|
30
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
|
31
|
+
run: |
|
|
32
|
+
python -m build
|
|
33
|
+
twine upload dist/*
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Object files
|
|
2
|
+
|
|
3
|
+
*.ko
|
|
4
|
+
*.obj
|
|
5
|
+
*.elf
|
|
6
|
+
|
|
7
|
+
# Precompiled Headers
|
|
8
|
+
*.gch
|
|
9
|
+
*.pch
|
|
10
|
+
|
|
11
|
+
# Libraries
|
|
12
|
+
*.lib
|
|
13
|
+
*.a
|
|
14
|
+
*.la
|
|
15
|
+
*.lo
|
|
16
|
+
*.o
|
|
17
|
+
|
|
18
|
+
# Shared objects (inc. Windows DLLs)
|
|
19
|
+
*.dll
|
|
20
|
+
*.so
|
|
21
|
+
*.so.*
|
|
22
|
+
*.dylib
|
|
23
|
+
|
|
24
|
+
# Executables
|
|
25
|
+
*.exe
|
|
26
|
+
*.out
|
|
27
|
+
*.app
|
|
28
|
+
*.i*86
|
|
29
|
+
*.x86_64
|
|
30
|
+
*.hex
|
|
31
|
+
|
|
32
|
+
# Caches
|
|
33
|
+
__pycache__
|
|
34
|
+
_CodeSignature
|
|
35
|
+
*.egg-info
|
|
36
|
+
|
|
37
|
+
# Debug files
|
|
38
|
+
*.dSYM/
|
|
39
|
+
|
|
40
|
+
# macOS shit
|
|
41
|
+
.DS_Store
|
|
42
|
+
|
|
43
|
+
# ide files
|
|
44
|
+
.vscode
|
|
45
|
+
.idea
|
|
46
|
+
|
|
47
|
+
# Build
|
|
48
|
+
**/_version.py
|
|
49
|
+
/tests/.pytest_cache/
|
|
50
|
+
dist/
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# .pre-commit-config.yaml
|
|
2
|
+
repos:
|
|
3
|
+
- repo: https://github.com/pre-commit/mirrors-isort
|
|
4
|
+
rev: v5.9.3
|
|
5
|
+
hooks:
|
|
6
|
+
- id: isort
|
|
7
|
+
args: [ '-m', 'HANGING_INDENT', '-l', '120','--check-only' ]
|
|
8
|
+
files: .
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/pycqa/flake8
|
|
11
|
+
rev: "7.0.0"
|
|
12
|
+
hooks:
|
|
13
|
+
- id: flake8
|
|
14
|
+
args: [ '--max-line-length=127' ]
|
|
15
|
+
files: \.py$
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symbolicator-server
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A Python stand-in for ipswd's symbol server
|
|
5
|
+
Author-email: netanelc305 <netanelc305@protonmail.com>
|
|
6
|
+
Maintainer-email: netanelc305 <netanelc305@protonmail.com>
|
|
7
|
+
Project-URL: Homepage, https://github.com/netanelc305/symbolicator-server
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/netanelc305/symbolicator-server/issues
|
|
9
|
+
Keywords: ipsw,ipswd,symbolication,symbols,dyld_shared_cache,server
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: fastapi>=0.162.2
|
|
17
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
18
|
+
|
|
19
|
+
# symbolicator-server
|
|
20
|
+
|
|
21
|
+
A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
|
|
22
|
+
database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
|
|
23
|
+
works against it.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## How it differs from ipswd
|
|
27
|
+
|
|
28
|
+
The API and the answers are the same: a lookup returns the same symbol ipswd
|
|
29
|
+
would, including which one wins when several cover an address (the first one
|
|
30
|
+
inserted). What changes is how symbols are stored and how scans are fed in.
|
|
31
|
+
|
|
32
|
+
**Symbol storage**
|
|
33
|
+
|
|
34
|
+
| | ipswd | symbolicator-server |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
|
|
37
|
+
| Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
|
|
38
|
+
| Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
|
|
39
|
+
| Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
|
|
40
|
+
| Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.14
|
|
45
|
+
- The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
python3 -m pip install -U .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Run the server
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
symbolicator-server --db syms.db
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
or `python3 -m symbolicator_server --db syms.db`.
|
|
60
|
+
|
|
61
|
+
| Option | Default | |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `--db` | (required) | SQLite database file, created if missing |
|
|
64
|
+
| `--host` / `--port` | `localhost` / `3993` | Where to listen |
|
|
65
|
+
| `--sigs-dir` | | Default kernel signatures dir for scans |
|
|
66
|
+
| `--pem-db` | | Default AEA pem DB for scans |
|
|
67
|
+
| `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
|
|
68
|
+
| `-v`, `--verbose` | | Log every image of a scan |
|
|
69
|
+
|
|
70
|
+
## Scan an IPSW
|
|
71
|
+
|
|
72
|
+
```shell
|
|
73
|
+
curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The scan runs as a single transaction, and lookups keep working while it runs.
|
|
77
|
+
Scanning an IPSW that is already in the database returns `409`. To scan it
|
|
78
|
+
again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
|
|
79
|
+
accept optional `sig_dir` and `pem_db` query parameters.
|
|
80
|
+
|
|
81
|
+
## Symbolicate a crash
|
|
82
|
+
|
|
83
|
+
```shell
|
|
84
|
+
ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
|
|
85
|
+
```
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# symbolicator-server
|
|
2
|
+
|
|
3
|
+
A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
|
|
4
|
+
database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
|
|
5
|
+
works against it.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## How it differs from ipswd
|
|
9
|
+
|
|
10
|
+
The API and the answers are the same: a lookup returns the same symbol ipswd
|
|
11
|
+
would, including which one wins when several cover an address (the first one
|
|
12
|
+
inserted). What changes is how symbols are stored and how scans are fed in.
|
|
13
|
+
|
|
14
|
+
**Symbol storage**
|
|
15
|
+
|
|
16
|
+
| | ipswd | symbolicator-server |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
|
|
19
|
+
| Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
|
|
20
|
+
| Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
|
|
21
|
+
| Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
|
|
22
|
+
| Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Python 3.14
|
|
27
|
+
- The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```shell
|
|
32
|
+
python3 -m pip install -U .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Run the server
|
|
36
|
+
|
|
37
|
+
```shell
|
|
38
|
+
symbolicator-server --db syms.db
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
or `python3 -m symbolicator_server --db syms.db`.
|
|
42
|
+
|
|
43
|
+
| Option | Default | |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `--db` | (required) | SQLite database file, created if missing |
|
|
46
|
+
| `--host` / `--port` | `localhost` / `3993` | Where to listen |
|
|
47
|
+
| `--sigs-dir` | | Default kernel signatures dir for scans |
|
|
48
|
+
| `--pem-db` | | Default AEA pem DB for scans |
|
|
49
|
+
| `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
|
|
50
|
+
| `-v`, `--verbose` | | Log every image of a scan |
|
|
51
|
+
|
|
52
|
+
## Scan an IPSW
|
|
53
|
+
|
|
54
|
+
```shell
|
|
55
|
+
curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The scan runs as a single transaction, and lookups keep working while it runs.
|
|
59
|
+
Scanning an IPSW that is already in the database returns `409`. To scan it
|
|
60
|
+
again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
|
|
61
|
+
accept optional `sig_dir` and `pem_db` query parameters.
|
|
62
|
+
|
|
63
|
+
## Symbolicate a crash
|
|
64
|
+
|
|
65
|
+
```shell
|
|
66
|
+
ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
|
|
67
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "symbolicator-server"
|
|
3
|
+
description = "A Python stand-in for ipswd's symbol server"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
keywords = ["ipsw", "ipswd", "symbolication", "symbols", "dyld_shared_cache", "server"]
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "netanelc305", email = "netanelc305@protonmail.com" }
|
|
9
|
+
]
|
|
10
|
+
maintainers = [
|
|
11
|
+
{ name = "netanelc305", email = "netanelc305@protonmail.com" }
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
]
|
|
19
|
+
dynamic = ["dependencies", "version"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
"Homepage" = "https://github.com/netanelc305/symbolicator-server"
|
|
23
|
+
"Bug Reports" = "https://github.com/netanelc305/symbolicator-server/issues"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
symbolicator-server = "symbolicator_server.__main__:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
exclude = ["docs*", "tests*"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.dynamic]
|
|
32
|
+
dependencies = { file = ["requirements.txt"] }
|
|
33
|
+
version = { attr = "symbolicator_server._version.__version__" }
|
|
34
|
+
|
|
35
|
+
[tool.setuptools_scm]
|
|
36
|
+
version_file = "symbolicator_server/_version.py"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["setuptools>=43.0.0", "setuptools_scm>=8", "wheel"]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g026bd4807'
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scanning: run the same scanner ipswd uses and feed its output into a store.
|
|
3
|
+
|
|
4
|
+
Taking an IPSW apart (decrypting the AEA cryptexes, mounting the APFS volumes,
|
|
5
|
+
parsing the dyld_shared_cache and kernelcache, finding every function) is done
|
|
6
|
+
by `ipsw symbols --json`, the scan code inside ipswd, exposed as a command.
|
|
7
|
+
It streams one JSON record per line, grouped per Mach-O:
|
|
8
|
+
|
|
9
|
+
{"type":"ipsw", "id":<sha1>, "name", "version", "build", "platform", "devices":[...]}
|
|
10
|
+
{"type":"dsc", "uuid", "shared_region_start"} once per shared cache
|
|
11
|
+
{"type":"image", "uuid", "kind", "path", "text_start", "text_end", "dsc_uuid"?}
|
|
12
|
+
{"type":"symbol", "image_uuid", "name", "start", "end"} ...that image's symbols
|
|
13
|
+
|
|
14
|
+
Each image is handed to the store as soon as its symbols have arrived (memory
|
|
15
|
+
stays bounded), and the whole scan is one transaction, like ipswd's.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("scanner")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AlreadyScanned(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScanFailed(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scan(store, ipsw_path, *, ipsw_bin="ipsw", sigs_dir=None, pem_db=None, rescan=False):
|
|
36
|
+
cmd = [ipsw_bin, "symbols", "--json", ipsw_path]
|
|
37
|
+
if sigs_dir:
|
|
38
|
+
cmd += ["--signatures", sigs_dir]
|
|
39
|
+
if pem_db:
|
|
40
|
+
cmd += ["--pem-db", pem_db]
|
|
41
|
+
log.info("running %s", " ".join(cmd))
|
|
42
|
+
|
|
43
|
+
started = time.time()
|
|
44
|
+
counts = {"images": 0, "symbols": 0, "skipped": 0}
|
|
45
|
+
conn = store.connect()
|
|
46
|
+
with tempfile.TemporaryFile("w+") as stderr:
|
|
47
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr, text=True, bufsize=1 << 20)
|
|
48
|
+
try:
|
|
49
|
+
ipsw_id = None
|
|
50
|
+
image, symbols = None, []
|
|
51
|
+
|
|
52
|
+
def flush():
|
|
53
|
+
# Hand the finished image and its symbols to the store.
|
|
54
|
+
if image is None:
|
|
55
|
+
return
|
|
56
|
+
note = store.save_image(conn, ipsw_id, image, symbols)
|
|
57
|
+
counts["images"] += 1
|
|
58
|
+
counts["symbols"] += len(symbols)
|
|
59
|
+
counts["skipped"] += note.startswith("already stored")
|
|
60
|
+
log.debug("%s %s: %s", image["kind"], image["path"], note)
|
|
61
|
+
|
|
62
|
+
for line in proc.stdout:
|
|
63
|
+
rec = json.loads(line)
|
|
64
|
+
kind = rec["type"]
|
|
65
|
+
if kind == "ipsw":
|
|
66
|
+
ipsw_id = rec["id"]
|
|
67
|
+
if not rescan and store.has_ipsw(conn, ipsw_id):
|
|
68
|
+
raise AlreadyScanned(f"IPSW {rec['name']} ({ipsw_id}) is already in the database")
|
|
69
|
+
store.save_ipsw(conn, rec)
|
|
70
|
+
log.info("scanning %s %s (%s) for %s",
|
|
71
|
+
rec["name"], rec["version"], rec["build"], ", ".join(rec.get("devices", [])))
|
|
72
|
+
elif kind == "dsc":
|
|
73
|
+
store.save_dsc(conn, ipsw_id, rec)
|
|
74
|
+
elif kind == "image":
|
|
75
|
+
flush()
|
|
76
|
+
image, symbols = rec, []
|
|
77
|
+
elif kind == "symbol":
|
|
78
|
+
symbols.append((rec["start"], rec["end"], rec["name"]))
|
|
79
|
+
# other record types (comparison facts) are not part of the symbol database
|
|
80
|
+
flush()
|
|
81
|
+
|
|
82
|
+
if proc.wait() != 0:
|
|
83
|
+
stderr.seek(0)
|
|
84
|
+
raise ScanFailed(stderr.read().strip()[-2000:] or f"ipsw exited with {proc.returncode}")
|
|
85
|
+
conn.commit()
|
|
86
|
+
except BaseException:
|
|
87
|
+
conn.rollback()
|
|
88
|
+
proc.kill()
|
|
89
|
+
proc.wait()
|
|
90
|
+
raise
|
|
91
|
+
finally:
|
|
92
|
+
conn.close()
|
|
93
|
+
|
|
94
|
+
counts["seconds"] = round(time.time() - started, 1)
|
|
95
|
+
log.info(
|
|
96
|
+
"scan done in %ss: %d images, %d symbols, %d images already stored",
|
|
97
|
+
counts["seconds"], counts["images"], counts["symbols"], counts["skipped"],
|
|
98
|
+
)
|
|
99
|
+
return counts
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Python stand-in for ipswd's symbol server, storing one all-integer row per
|
|
3
|
+
symbol (see store.py).
|
|
4
|
+
|
|
5
|
+
symbolicator-server --db syms.db
|
|
6
|
+
|
|
7
|
+
Same endpoints as ipswd (/v1/...), so `ipsw symbolicate --server` works against it:
|
|
8
|
+
|
|
9
|
+
POST /v1/syms/scan?path=<ipsw>[&sig_dir=][&pem_db=] scan an IPSW into the database
|
|
10
|
+
PUT /v1/syms/rescan?path=<ipsw> scan again, replacing what changed
|
|
11
|
+
GET /v1/_ping
|
|
12
|
+
GET /v1/syms/ipsw?version=&build=&device= is this IPSW scanned?
|
|
13
|
+
GET /v1/syms/macho/{uuid} a Mach-O (path, __TEXT range)
|
|
14
|
+
GET /v1/syms/dsc/{uuid} a dyld_shared_cache
|
|
15
|
+
GET /v1/syms/dsc/{uuid}/{addr} the dylib of a shared cache containing addr
|
|
16
|
+
GET /v1/syms/{uuid}/{addr} the symbol at addr
|
|
17
|
+
GET /v1/syms/{uuid} all symbols of a Mach-O
|
|
18
|
+
|
|
19
|
+
Plus one that ipswd does not have:
|
|
20
|
+
|
|
21
|
+
GET /v1/stats database size per table
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
|
|
29
|
+
import uvicorn
|
|
30
|
+
from fastapi import FastAPI, Request
|
|
31
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
32
|
+
|
|
33
|
+
from symbolicator_server import scanner
|
|
34
|
+
from symbolicator_server.store import Store, symbol_json
|
|
35
|
+
|
|
36
|
+
app = FastAPI(title="symbolicator-server")
|
|
37
|
+
STORE = None # set in main()
|
|
38
|
+
CONFIG = {} # ipsw binary, default signatures dir and pem DB
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error(status, message):
|
|
42
|
+
return JSONResponse(status_code=status, content={"error": message})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_addr(addr):
|
|
46
|
+
"""ipswd parses the address with cast.ToUint64: decimal or 0x-hex, anything else is 0.
|
|
47
|
+
|
|
48
|
+
SQLite integers are signed 64-bit and stored addresses are below 2**63, so a
|
|
49
|
+
larger address can match nothing: it becomes -1 rather than failing to bind."""
|
|
50
|
+
try:
|
|
51
|
+
n = int(addr, 0)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return 0
|
|
54
|
+
return n if 0 <= n < 1 << 63 else -1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/v1/_ping", response_class=PlainTextResponse)
|
|
58
|
+
def ping():
|
|
59
|
+
return "OK"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------- scan
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_scan(request: Request, rescan: bool):
|
|
66
|
+
q = request.query_params
|
|
67
|
+
path = q.get("path")
|
|
68
|
+
if not path:
|
|
69
|
+
return error(400, "missing path query parameter")
|
|
70
|
+
if q.get("device"):
|
|
71
|
+
return error(400, "device-scoped database ingestion is not supported; use device-scoped JSONL export instead")
|
|
72
|
+
try:
|
|
73
|
+
scanner.scan(
|
|
74
|
+
STORE,
|
|
75
|
+
os.path.normpath(path),
|
|
76
|
+
ipsw_bin=CONFIG["ipsw_bin"],
|
|
77
|
+
sigs_dir=q.get("sig_dir") or CONFIG["sigs_dir"],
|
|
78
|
+
pem_db=q.get("pem_db") or CONFIG["pem_db"],
|
|
79
|
+
rescan=rescan,
|
|
80
|
+
)
|
|
81
|
+
except scanner.AlreadyScanned as e:
|
|
82
|
+
return error(409, str(e))
|
|
83
|
+
except Exception as e: # noqa: BLE001 - ipswd answers 500 with the error text
|
|
84
|
+
logging.exception("scan failed")
|
|
85
|
+
return error(500, str(e))
|
|
86
|
+
return JSONResponse(status_code=201, content={"created": True}) if rescan else {"success": True}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Plain `def` routes run in FastAPI's thread pool, so lookups keep working during a scan.
|
|
90
|
+
@app.post("/v1/syms/scan")
|
|
91
|
+
def scan(request: Request):
|
|
92
|
+
return run_scan(request, rescan=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.put("/v1/syms/rescan")
|
|
96
|
+
def rescan(request: Request):
|
|
97
|
+
return run_scan(request, rescan=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------- lookups
|
|
101
|
+
# Order matters: the fixed paths (ipsw, macho, dsc) must come before /syms/{uuid}.
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.get("/v1/syms/ipsw")
|
|
105
|
+
def get_ipsw(version: str = "", build: str = "", device: str = ""):
|
|
106
|
+
if not (version and build and device):
|
|
107
|
+
return error(400, "version, build and device are required")
|
|
108
|
+
ipsw = STORE.get_ipsw(STORE.reader(), version, build, device)
|
|
109
|
+
return ipsw if ipsw else error(404, "not found")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.get("/v1/syms/macho/{uuid}")
|
|
113
|
+
def get_macho(uuid: str):
|
|
114
|
+
macho = STORE.get_macho(STORE.reader(), uuid)
|
|
115
|
+
return macho if macho else error(404, "not found")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/v1/syms/dsc/{uuid}")
|
|
119
|
+
def get_dsc(uuid: str):
|
|
120
|
+
dsc = STORE.get_dsc(STORE.reader(), uuid)
|
|
121
|
+
return dsc if dsc else error(404, "not found")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.get("/v1/syms/dsc/{uuid}/{addr}")
|
|
125
|
+
def get_dsc_image(uuid: str, addr: str):
|
|
126
|
+
dylib = STORE.get_dsc_image(STORE.reader(), uuid, parse_addr(addr))
|
|
127
|
+
return dylib if dylib else error(404, "not found")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.get("/v1/stats")
|
|
131
|
+
def stats():
|
|
132
|
+
return STORE.stats(STORE.reader())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.get("/v1/syms/{uuid}/{addr}")
|
|
136
|
+
def get_symbol(uuid: str, addr: str):
|
|
137
|
+
sym = STORE.get_symbol(STORE.reader(), uuid, parse_addr(addr))
|
|
138
|
+
return symbol_json(*sym) if sym else error(404, "not found")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.get("/v1/syms/{uuid}")
|
|
142
|
+
def get_symbols(uuid: str):
|
|
143
|
+
syms = STORE.get_symbols(STORE.reader(), uuid)
|
|
144
|
+
return [symbol_json(*s) for s in syms]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------- main
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main():
|
|
151
|
+
global STORE
|
|
152
|
+
p = argparse.ArgumentParser(description="ipswd-compatible symbol server")
|
|
153
|
+
p.add_argument("--db", required=True, help="sqlite database file")
|
|
154
|
+
p.add_argument("--host", default="localhost")
|
|
155
|
+
p.add_argument("--port", type=int, default=3994)
|
|
156
|
+
p.add_argument("--sigs-dir", help="default kernel signatures dir (ipswd: daemon.sigs-dir)")
|
|
157
|
+
p.add_argument("--pem-db", help="default AEA pem DB (ipswd: daemon.pem-db)")
|
|
158
|
+
p.add_argument("--ipsw-bin", default=shutil.which("ipsw") or "ipsw", help="the ipsw CLI used to scan")
|
|
159
|
+
p.add_argument("-v", "--verbose", action="store_true", help="log every image of a scan")
|
|
160
|
+
args = p.parse_args()
|
|
161
|
+
|
|
162
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(name)s: %(message)s")
|
|
163
|
+
STORE = Store(args.db)
|
|
164
|
+
CONFIG.update(ipsw_bin=args.ipsw_bin, sigs_dir=args.sigs_dir, pem_db=args.pem_db)
|
|
165
|
+
logging.info("database %s, scanning with %s", args.db, args.ipsw_bin)
|
|
166
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Symbol storage: normalized tables, one all-integer row per symbol.
|
|
3
|
+
|
|
4
|
+
ipsws, ipsw_devices, dyld_shared_caches, ipsw_dscs,
|
|
5
|
+
machos, ipsw_machos, dsc_images what was scanned
|
|
6
|
+
names (id, name) name -> id
|
|
7
|
+
macho_ids (id, uuid, base, max_size, next_seq) uuid -> small integer id
|
|
8
|
+
symbols (macho_id, off, seq, size, name_code) WITHOUT ROWID one row per symbol
|
|
9
|
+
symbol_lists (macho_id, list_hash) fingerprints of merged lists
|
|
10
|
+
|
|
11
|
+
Why it is small:
|
|
12
|
+
* The Mach-O is referenced by a small integer instead of its 36-character UUID.
|
|
13
|
+
* The address is stored as an offset from the Mach-O's __TEXT start and the
|
|
14
|
+
end as a size, so most values fit in 2-4 bytes (SQLite stores integers in
|
|
15
|
+
1-8 bytes as needed).
|
|
16
|
+
* WITHOUT ROWID: the rows live directly in the primary-key B-tree, so there
|
|
17
|
+
is no second index holding the same columns again.
|
|
18
|
+
* A list a scan delivers is fingerprinted first; a list already merged
|
|
19
|
+
(another device's IPSW, a rescan) writes nothing.
|
|
20
|
+
* Placeholder names (func_<address>) are not stored (name_code 0 / 1).
|
|
21
|
+
|
|
22
|
+
Same answers as ipswd: a Mach-O's symbols only grow (new lists are merged in),
|
|
23
|
+
and when several symbols cover an address the first inserted (lowest seq)
|
|
24
|
+
wins, like ipswd's ORDER BY id.
|
|
25
|
+
|
|
26
|
+
The schema is a simplified version of ipswd's. The kernelcache -> kext and
|
|
27
|
+
ipsw -> file-system links are folded into one ipsw_machos table, because they
|
|
28
|
+
do not affect how symbols are stored.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sqlite3
|
|
35
|
+
import threading
|
|
36
|
+
|
|
37
|
+
SCHEMA = """
|
|
38
|
+
CREATE TABLE IF NOT EXISTS ipsws (
|
|
39
|
+
id TEXT PRIMARY KEY, -- sha1 of the .ipsw file
|
|
40
|
+
name TEXT,
|
|
41
|
+
version TEXT,
|
|
42
|
+
build TEXT,
|
|
43
|
+
platform TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS ipsw_devices (
|
|
46
|
+
ipsw_id TEXT,
|
|
47
|
+
device TEXT,
|
|
48
|
+
PRIMARY KEY (ipsw_id, device)
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS dyld_shared_caches (
|
|
51
|
+
uuid TEXT PRIMARY KEY,
|
|
52
|
+
shared_region_start INTEGER
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS ipsw_dscs (
|
|
55
|
+
ipsw_id TEXT,
|
|
56
|
+
dsc_uuid TEXT,
|
|
57
|
+
PRIMARY KEY (ipsw_id, dsc_uuid)
|
|
58
|
+
);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS machos (
|
|
60
|
+
uuid TEXT PRIMARY KEY,
|
|
61
|
+
kind TEXT, -- kernel / kext / dylib / macho
|
|
62
|
+
path TEXT,
|
|
63
|
+
text_start INTEGER,
|
|
64
|
+
text_end INTEGER
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS ipsw_machos (
|
|
67
|
+
ipsw_id TEXT,
|
|
68
|
+
macho_uuid TEXT,
|
|
69
|
+
PRIMARY KEY (ipsw_id, macho_uuid)
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS dsc_images (
|
|
72
|
+
dsc_uuid TEXT,
|
|
73
|
+
macho_uuid TEXT,
|
|
74
|
+
PRIMARY KEY (dsc_uuid, macho_uuid)
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS names (
|
|
77
|
+
id INTEGER PRIMARY KEY,
|
|
78
|
+
name TEXT UNIQUE
|
|
79
|
+
);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS macho_ids (
|
|
81
|
+
id INTEGER PRIMARY KEY,
|
|
82
|
+
uuid TEXT UNIQUE,
|
|
83
|
+
base INTEGER, -- the Mach-O's __TEXT start; symbol addresses are stored relative to it
|
|
84
|
+
max_size INTEGER, -- its largest symbol; bounds how far back a lookup has to look
|
|
85
|
+
next_seq INTEGER -- insertion counter: keeps "first inserted wins" like the old ids
|
|
86
|
+
);
|
|
87
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
88
|
+
macho_id INTEGER,
|
|
89
|
+
off INTEGER, -- start - base
|
|
90
|
+
seq INTEGER, -- insertion order within the Mach-O
|
|
91
|
+
size INTEGER, -- end - start
|
|
92
|
+
name_code INTEGER, -- 0/1 = func_ placeholder, otherwise names.id + 2
|
|
93
|
+
PRIMARY KEY (macho_id, off, seq)
|
|
94
|
+
) WITHOUT ROWID;
|
|
95
|
+
CREATE TABLE IF NOT EXISTS symbol_lists (
|
|
96
|
+
macho_id INTEGER,
|
|
97
|
+
list_hash BLOB,
|
|
98
|
+
PRIMARY KEY (macho_id, list_hash)
|
|
99
|
+
) WITHOUT ROWID;
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
HIGH_BIT = 1 << 63 # cleared from kernel addresses, but still part of their func_ names
|
|
103
|
+
|
|
104
|
+
# name_code: 0 and 1 are placeholders, real names are names.id + 2.
|
|
105
|
+
NAME_FUNC = 0 # func_<start> (dylibs, file-system Mach-Os)
|
|
106
|
+
NAME_FUNC_KERNEL = 1 # func_<start | bit 63> (kernel and kexts)
|
|
107
|
+
FIRST_REAL_NAME = 2
|
|
108
|
+
|
|
109
|
+
LIST_HASH_VERSION = b"\x02" # part of every stored fingerprint; changing it re-merges every list
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def placeholder_code(name, start):
|
|
113
|
+
"""NAME_FUNC / NAME_FUNC_KERNEL if name is the func_ placeholder for start, else None."""
|
|
114
|
+
if name.startswith("func_"):
|
|
115
|
+
if name == f"func_{start:x}":
|
|
116
|
+
return NAME_FUNC
|
|
117
|
+
if name == f"func_{start | HIGH_BIT:x}":
|
|
118
|
+
return NAME_FUNC_KERNEL
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def decode_name(code, start, name):
|
|
123
|
+
"""(name_id, name) from a stored name_code and the name joined from the names table."""
|
|
124
|
+
if code == NAME_FUNC:
|
|
125
|
+
return None, f"func_{start:x}"
|
|
126
|
+
if code == NAME_FUNC_KERNEL:
|
|
127
|
+
return None, f"func_{start | HIGH_BIT:x}"
|
|
128
|
+
return code - FIRST_REAL_NAME, name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def list_hash(symbols):
|
|
132
|
+
"""Fingerprint of one symbol list as a scan delivered it (addresses + name text, in order)."""
|
|
133
|
+
text = "".join(f"{start}:{end}:{name}\n" for start, end, name in symbols)
|
|
134
|
+
return hashlib.sha256(LIST_HASH_VERSION + text.encode()).digest()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Symbols of one Mach-O as (name_id, name, start, end), by address then insertion order.
|
|
138
|
+
SYMBOLS_SQL = """
|
|
139
|
+
SELECT m.base + s.off, s.size, s.name_code, n.name
|
|
140
|
+
FROM macho_ids m
|
|
141
|
+
JOIN symbols s ON s.macho_id = m.id
|
|
142
|
+
LEFT JOIN names n ON n.id = s.name_code - 2
|
|
143
|
+
WHERE m.uuid = ?
|
|
144
|
+
ORDER BY s.off, s.seq
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
# Only symbols starting within max_size before addr can cover it: a short range
|
|
148
|
+
# scan of the primary key, then the first inserted among the matches.
|
|
149
|
+
SYMBOL_AT_SQL = """
|
|
150
|
+
SELECT m.base + s.off, s.size, s.name_code, n.name
|
|
151
|
+
FROM macho_ids m
|
|
152
|
+
JOIN symbols s ON s.macho_id = m.id
|
|
153
|
+
AND s.off BETWEEN :addr - m.base - m.max_size AND :addr - m.base
|
|
154
|
+
LEFT JOIN names n ON n.id = s.name_code - 2
|
|
155
|
+
WHERE m.uuid = :uuid AND :addr - m.base < s.off + s.size
|
|
156
|
+
ORDER BY s.seq LIMIT 1
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Store:
|
|
161
|
+
def __init__(self, path):
|
|
162
|
+
self.path = path
|
|
163
|
+
self._local = threading.local()
|
|
164
|
+
with self.connect() as conn:
|
|
165
|
+
conn.execute("PRAGMA journal_mode = WAL") # persistent: lookups keep working while a scan writes
|
|
166
|
+
conn.executescript(SCHEMA)
|
|
167
|
+
|
|
168
|
+
def connect(self):
|
|
169
|
+
conn = sqlite3.connect(self.path, timeout=60)
|
|
170
|
+
conn.execute("PRAGMA synchronous = NORMAL") # durable enough under WAL, much faster commits
|
|
171
|
+
conn.execute("PRAGMA cache_size = -65536") # 64 MiB page cache
|
|
172
|
+
conn.execute("PRAGMA mmap_size = 1073741824") # read pages straight from the mapped file
|
|
173
|
+
return conn
|
|
174
|
+
|
|
175
|
+
def reader(self):
|
|
176
|
+
"""A connection for lookups, one per thread and kept open (FastAPI reuses its pool threads)."""
|
|
177
|
+
conn = getattr(self._local, "conn", None)
|
|
178
|
+
if conn is None:
|
|
179
|
+
conn = self._local.conn = self.connect()
|
|
180
|
+
return conn
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------ saving
|
|
183
|
+
|
|
184
|
+
def has_ipsw(self, conn, ipsw_id):
|
|
185
|
+
return conn.execute("SELECT 1 FROM ipsws WHERE id = ?", (ipsw_id,)).fetchone() is not None
|
|
186
|
+
|
|
187
|
+
def save_ipsw(self, conn, rec):
|
|
188
|
+
conn.execute(
|
|
189
|
+
"INSERT OR REPLACE INTO ipsws (id, name, version, build, platform) VALUES (?, ?, ?, ?, ?)",
|
|
190
|
+
(rec["id"], rec["name"], rec["version"], rec["build"], rec.get("platform")),
|
|
191
|
+
)
|
|
192
|
+
conn.executemany(
|
|
193
|
+
"INSERT OR IGNORE INTO ipsw_devices (ipsw_id, device) VALUES (?, ?)",
|
|
194
|
+
[(rec["id"], dev) for dev in rec.get("devices", [])],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def save_dsc(self, conn, ipsw_id, rec):
|
|
198
|
+
conn.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO dyld_shared_caches (uuid, shared_region_start) VALUES (?, ?)",
|
|
200
|
+
(rec["uuid"], rec["shared_region_start"]),
|
|
201
|
+
)
|
|
202
|
+
conn.execute("INSERT OR IGNORE INTO ipsw_dscs VALUES (?, ?)", (ipsw_id, rec["uuid"]))
|
|
203
|
+
|
|
204
|
+
def save_image(self, conn, ipsw_id, image, symbols):
|
|
205
|
+
"""Save one Mach-O and its symbols. symbols: [(start, end, name), ...]. Returns a note for the scan log."""
|
|
206
|
+
uuid = image["uuid"]
|
|
207
|
+
conn.execute(
|
|
208
|
+
"INSERT OR IGNORE INTO machos (uuid, kind, path, text_start, text_end) VALUES (?, ?, ?, ?, ?)",
|
|
209
|
+
(uuid, image["kind"], image["path"], image["text_start"], image["text_end"]),
|
|
210
|
+
)
|
|
211
|
+
conn.execute("INSERT OR IGNORE INTO ipsw_machos VALUES (?, ?)", (ipsw_id, uuid))
|
|
212
|
+
if image.get("dsc_uuid"):
|
|
213
|
+
conn.execute("INSERT OR IGNORE INTO dsc_images VALUES (?, ?)", (image["dsc_uuid"], uuid))
|
|
214
|
+
return self.save_symbols(conn, uuid, symbols)
|
|
215
|
+
|
|
216
|
+
def save_symbols(self, conn, uuid, symbols):
|
|
217
|
+
macho_id, base, max_size, next_seq = self._macho_id(conn, uuid)
|
|
218
|
+
fingerprint = list_hash(symbols)
|
|
219
|
+
seen = conn.execute(
|
|
220
|
+
"SELECT 1 FROM symbol_lists WHERE macho_id = ? AND list_hash = ?", (macho_id, fingerprint)
|
|
221
|
+
).fetchone()
|
|
222
|
+
if seen:
|
|
223
|
+
return "already stored, skipped" # this exact list was merged before: nothing to write
|
|
224
|
+
conn.execute("INSERT INTO symbol_lists (macho_id, list_hash) VALUES (?, ?)", (macho_id, fingerprint))
|
|
225
|
+
|
|
226
|
+
# Encode first, then merge in encoded form: stored rows stay, rows not stored yet are
|
|
227
|
+
# appended after them. Comparing (off, size, name_code) needs no join with names.
|
|
228
|
+
codes = [placeholder_code(name, start) for start, _, name in symbols]
|
|
229
|
+
ids = self.name_ids(conn, {sym[2] for sym, code in zip(symbols, codes) if code is None})
|
|
230
|
+
known = (
|
|
231
|
+
set(conn.execute("SELECT off, size, name_code FROM symbols WHERE macho_id = ?", (macho_id,)))
|
|
232
|
+
if next_seq
|
|
233
|
+
else set()
|
|
234
|
+
)
|
|
235
|
+
rows = []
|
|
236
|
+
for (start, end, name), code in zip(symbols, codes):
|
|
237
|
+
# an inverted range is stored with size 0 and never matches, as in ipswd
|
|
238
|
+
key = (start - base, max(end - start, 0), ids[name] + FIRST_REAL_NAME if code is None else code)
|
|
239
|
+
if key not in known:
|
|
240
|
+
known.add(key)
|
|
241
|
+
rows.append((macho_id, key[0], next_seq + len(rows), key[1], key[2]))
|
|
242
|
+
max_size = max(max_size, key[1])
|
|
243
|
+
if not rows:
|
|
244
|
+
return "already stored, skipped" if next_seq else "no symbols"
|
|
245
|
+
|
|
246
|
+
conn.executemany("INSERT INTO symbols (macho_id, off, seq, size, name_code) VALUES (?, ?, ?, ?, ?)", rows)
|
|
247
|
+
conn.execute(
|
|
248
|
+
"UPDATE macho_ids SET max_size = ?, next_seq = ? WHERE id = ?", (max_size, next_seq + len(rows), macho_id)
|
|
249
|
+
)
|
|
250
|
+
if next_seq:
|
|
251
|
+
return f"merged {len(rows)} new symbols into the {next_seq} stored"
|
|
252
|
+
return f"inserted {len(rows)} symbol rows"
|
|
253
|
+
|
|
254
|
+
def _macho_id(self, conn, uuid):
|
|
255
|
+
"""(id, base, max_size, next_seq) for uuid, creating the id on first sight."""
|
|
256
|
+
row = conn.execute("SELECT id, base, max_size, next_seq FROM macho_ids WHERE uuid = ?", (uuid,)).fetchone()
|
|
257
|
+
if row:
|
|
258
|
+
return row
|
|
259
|
+
cur = conn.execute(
|
|
260
|
+
"""INSERT INTO macho_ids (uuid, base, max_size, next_seq)
|
|
261
|
+
SELECT uuid, COALESCE(text_start, 0), 0, 0 FROM machos WHERE uuid = ?
|
|
262
|
+
RETURNING id, base, max_size, next_seq""",
|
|
263
|
+
(uuid,),
|
|
264
|
+
)
|
|
265
|
+
return cur.fetchone()
|
|
266
|
+
|
|
267
|
+
def name_ids(self, conn, names):
|
|
268
|
+
"""Insert the names that are missing and return {name: id}."""
|
|
269
|
+
if not names:
|
|
270
|
+
return {}
|
|
271
|
+
names = json.dumps(list(names)) # one bound parameter, however many names
|
|
272
|
+
conn.execute("INSERT OR IGNORE INTO names (name) SELECT value FROM json_each(?)", (names,))
|
|
273
|
+
return {
|
|
274
|
+
name: nid
|
|
275
|
+
for nid, name in conn.execute(
|
|
276
|
+
"SELECT id, name FROM names WHERE name IN (SELECT value FROM json_each(?))", (names,)
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# ----------------------------------------------------------------- lookups
|
|
281
|
+
|
|
282
|
+
def get_symbol(self, conn, uuid, addr):
|
|
283
|
+
"""(name_id, name, start, end) of the symbol covering addr, or None."""
|
|
284
|
+
row = conn.execute(SYMBOL_AT_SQL, {"uuid": uuid, "addr": addr}).fetchone()
|
|
285
|
+
if row is None:
|
|
286
|
+
return None
|
|
287
|
+
start, size, code, name = row
|
|
288
|
+
return *decode_name(code, start, name), start, start + size
|
|
289
|
+
|
|
290
|
+
def get_symbols(self, conn, uuid):
|
|
291
|
+
"""[(name_id, name, start, end), ...] sorted by start."""
|
|
292
|
+
return [
|
|
293
|
+
(*decode_name(code, start, name), start, start + size)
|
|
294
|
+
for start, size, code, name in conn.execute(SYMBOLS_SQL, (uuid,))
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
def get_ipsw(self, conn, version, build, device):
|
|
298
|
+
row = conn.execute(
|
|
299
|
+
"""SELECT i.id, i.name, i.version, i.build, i.platform FROM ipsws i
|
|
300
|
+
JOIN ipsw_devices d ON d.ipsw_id = i.id
|
|
301
|
+
WHERE i.version = ? AND i.build = ? AND d.device = ?""",
|
|
302
|
+
(version, build, device),
|
|
303
|
+
).fetchone()
|
|
304
|
+
if row is None:
|
|
305
|
+
return None
|
|
306
|
+
devices = [d for (d,) in conn.execute("SELECT device FROM ipsw_devices WHERE ipsw_id = ?", (row[0],))]
|
|
307
|
+
return ipsw_json(row, devices)
|
|
308
|
+
|
|
309
|
+
def get_macho(self, conn, uuid):
|
|
310
|
+
row = conn.execute("SELECT uuid, path, text_start, text_end FROM machos WHERE uuid = ?", (uuid,)).fetchone()
|
|
311
|
+
return None if row is None else macho_json(row)
|
|
312
|
+
|
|
313
|
+
def get_dsc(self, conn, uuid):
|
|
314
|
+
row = conn.execute("SELECT uuid, shared_region_start FROM dyld_shared_caches WHERE uuid = ?", (uuid,)).fetchone()
|
|
315
|
+
return None if row is None else {"uuid": row[0], "shared_region_start": row[1]}
|
|
316
|
+
|
|
317
|
+
def get_dsc_image(self, conn, dsc_uuid, addr):
|
|
318
|
+
"""The dylib of a shared cache whose __TEXT contains addr."""
|
|
319
|
+
row = conn.execute(
|
|
320
|
+
"""SELECT m.uuid, m.path, m.text_start, m.text_end FROM machos m
|
|
321
|
+
JOIN dsc_images di ON di.macho_uuid = m.uuid
|
|
322
|
+
WHERE di.dsc_uuid = ? AND m.text_start <= ? AND ? < m.text_end LIMIT 1""",
|
|
323
|
+
(dsc_uuid, addr, addr),
|
|
324
|
+
).fetchone()
|
|
325
|
+
return None if row is None else macho_json(row)
|
|
326
|
+
|
|
327
|
+
def stats(self, conn):
|
|
328
|
+
files = [self.path, self.path + "-wal"]
|
|
329
|
+
out = {
|
|
330
|
+
"db": self.path,
|
|
331
|
+
"size_bytes": sum(os.path.getsize(f) for f in files if os.path.exists(f)),
|
|
332
|
+
"tables": {},
|
|
333
|
+
}
|
|
334
|
+
tables = [t for (t,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")]
|
|
335
|
+
try: # per-table bytes (tables + their indexes), when sqlite has dbstat compiled in
|
|
336
|
+
sizes = dict(conn.execute(
|
|
337
|
+
"""SELECT COALESCE(m.tbl_name, s.name), SUM(s.pgsize) FROM dbstat s
|
|
338
|
+
LEFT JOIN sqlite_master m ON m.name = s.name GROUP BY 1"""
|
|
339
|
+
))
|
|
340
|
+
except sqlite3.OperationalError:
|
|
341
|
+
sizes = {}
|
|
342
|
+
for t in tables:
|
|
343
|
+
(rows,) = conn.execute(f'SELECT COUNT(*) FROM "{t}"').fetchone()
|
|
344
|
+
out["tables"][t] = {"rows": rows, "bytes": sizes.get(t)}
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --------------------------------------------------------------------------
|
|
349
|
+
# JSON shapes, matching how ipswd serializes its Go models
|
|
350
|
+
# (field names are what the `ipsw symbolicate --server` client decodes).
|
|
351
|
+
# --------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def ipsw_json(row, devices):
|
|
355
|
+
ipsw_id, name, version, build, platform = row
|
|
356
|
+
return {
|
|
357
|
+
"id": ipsw_id,
|
|
358
|
+
"name": name,
|
|
359
|
+
"version": version,
|
|
360
|
+
"buildid": build,
|
|
361
|
+
"platform": platform,
|
|
362
|
+
"devices": [{"name": d} for d in devices],
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def macho_json(row):
|
|
367
|
+
uuid, path, text_start, text_end = row
|
|
368
|
+
return {
|
|
369
|
+
"uuid": uuid,
|
|
370
|
+
"PathID": 0,
|
|
371
|
+
"Path": {"id": 0, "path": path},
|
|
372
|
+
"text_start": text_start,
|
|
373
|
+
"text_end": text_end,
|
|
374
|
+
"Symbols": None,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def symbol_json(name_id, name, start, end):
|
|
379
|
+
return {
|
|
380
|
+
"ID": 0,
|
|
381
|
+
"NameID": name_id,
|
|
382
|
+
"Name": {"ID": name_id, "name": name},
|
|
383
|
+
"start": start,
|
|
384
|
+
"end": end,
|
|
385
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symbolicator-server
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A Python stand-in for ipswd's symbol server
|
|
5
|
+
Author-email: netanelc305 <netanelc305@protonmail.com>
|
|
6
|
+
Maintainer-email: netanelc305 <netanelc305@protonmail.com>
|
|
7
|
+
Project-URL: Homepage, https://github.com/netanelc305/symbolicator-server
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/netanelc305/symbolicator-server/issues
|
|
9
|
+
Keywords: ipsw,ipswd,symbolication,symbols,dyld_shared_cache,server
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: fastapi>=0.162.2
|
|
17
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
18
|
+
|
|
19
|
+
# symbolicator-server
|
|
20
|
+
|
|
21
|
+
A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
|
|
22
|
+
database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
|
|
23
|
+
works against it.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## How it differs from ipswd
|
|
27
|
+
|
|
28
|
+
The API and the answers are the same: a lookup returns the same symbol ipswd
|
|
29
|
+
would, including which one wins when several cover an address (the first one
|
|
30
|
+
inserted). What changes is how symbols are stored and how scans are fed in.
|
|
31
|
+
|
|
32
|
+
**Symbol storage**
|
|
33
|
+
|
|
34
|
+
| | ipswd | symbolicator-server |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
|
|
37
|
+
| Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
|
|
38
|
+
| Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
|
|
39
|
+
| Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
|
|
40
|
+
| Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.14
|
|
45
|
+
- The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
python3 -m pip install -U .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Run the server
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
symbolicator-server --db syms.db
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
or `python3 -m symbolicator_server --db syms.db`.
|
|
60
|
+
|
|
61
|
+
| Option | Default | |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `--db` | (required) | SQLite database file, created if missing |
|
|
64
|
+
| `--host` / `--port` | `localhost` / `3993` | Where to listen |
|
|
65
|
+
| `--sigs-dir` | | Default kernel signatures dir for scans |
|
|
66
|
+
| `--pem-db` | | Default AEA pem DB for scans |
|
|
67
|
+
| `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
|
|
68
|
+
| `-v`, `--verbose` | | Log every image of a scan |
|
|
69
|
+
|
|
70
|
+
## Scan an IPSW
|
|
71
|
+
|
|
72
|
+
```shell
|
|
73
|
+
curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The scan runs as a single transaction, and lookups keep working while it runs.
|
|
77
|
+
Scanning an IPSW that is already in the database returns `409`. To scan it
|
|
78
|
+
again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
|
|
79
|
+
accept optional `sig_dir` and `pem_db` query parameters.
|
|
80
|
+
|
|
81
|
+
## Symbolicate a crash
|
|
82
|
+
|
|
83
|
+
```shell
|
|
84
|
+
ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
|
|
85
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
.pre-commit-config.yaml
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
requirements.txt
|
|
6
|
+
.github/workflows/python-app.yml
|
|
7
|
+
.github/workflows/python-publish.yml
|
|
8
|
+
symbolicator_server/__init__.py
|
|
9
|
+
symbolicator_server/__main__.py
|
|
10
|
+
symbolicator_server/_version.py
|
|
11
|
+
symbolicator_server/scanner.py
|
|
12
|
+
symbolicator_server/server.py
|
|
13
|
+
symbolicator_server/store.py
|
|
14
|
+
symbolicator_server.egg-info/PKG-INFO
|
|
15
|
+
symbolicator_server.egg-info/SOURCES.txt
|
|
16
|
+
symbolicator_server.egg-info/dependency_links.txt
|
|
17
|
+
symbolicator_server.egg-info/entry_points.txt
|
|
18
|
+
symbolicator_server.egg-info/requires.txt
|
|
19
|
+
symbolicator_server.egg-info/scm_file_list.json
|
|
20
|
+
symbolicator_server.egg-info/scm_version.json
|
|
21
|
+
symbolicator_server.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".github/workflows/python-app.yml",
|
|
4
|
+
".github/workflows/python-publish.yml",
|
|
5
|
+
".gitignore",
|
|
6
|
+
".pre-commit-config.yaml",
|
|
7
|
+
"README.md",
|
|
8
|
+
"pyproject.toml",
|
|
9
|
+
"requirements.txt",
|
|
10
|
+
"symbolicator_server/__init__.py",
|
|
11
|
+
"symbolicator_server/__main__.py",
|
|
12
|
+
"symbolicator_server/scanner.py",
|
|
13
|
+
"symbolicator_server/server.py",
|
|
14
|
+
"symbolicator_server/store.py"
|
|
15
|
+
]
|
|
16
|
+
}
|