Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Current version: 3.3.6 | [Changelog](debian/changelog)
[CLI reference and configuration guide](https://shadowsocks.github.io/shadowsocks-c/)
are generated from the source with Doxygen and published after updates to `master`.

An opt-in [printable TCP salt experiment](docs/printable-salt.md) is available
through `ss-local --printable-salt` or `"printable_salt": true` in the config
file. It is disabled by default.

Use `--help` for grouped CLI options and `--version` for version information.
See [CLI conventions](https://shadowsocks.github.io/shadowsocks-c/index.html#cli_conventions)
for the long flags, short aliases, and configuration precedence.
Expand Down
2 changes: 1 addition & 1 deletion completions/bash/ss-local
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
_ss_local()
{
local cur prev opts ciphers
opts='-s -p -l -k -m -a -f -t -c -n -i -b -u -U -v -h --reuse-port --fast-open --acl --mtu --mptcp --no-delay --key --plugin --plugin-opts --help'
opts='-s -p -l -k -m -a -f -t -c -n -i -b -u -U -v -h --reuse-port --fast-open --acl --mtu --mptcp --no-delay --printable-salt --key --plugin --plugin-opts --help'
ciphers='rc4-md5 aes-128-gcm aes-192-gcm aes-256-gcm aes-128-cfb aes-192-cfb aes-256-cfb aes-128-ctr aes-192-ctr aes-256-ctr camellia-128-cfb camellia-192-cfb camellia-256-cfb bf-cfb chacha20-ietf-poly1305 xchacha20-ietf-poly1305 salsa20 chacha20 chacha20-ietf'
cur=${COMP_WORDS[COMP_CWORD]}
prev="${COMP_WORDS[COMP_CWORD-1]}"
Expand Down
1 change: 1 addition & 0 deletions completions/zsh/_ss-local
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ _arguments "-h::" \
"--acl:acl file:_files" \
"--mtu::" \
"--mptcp::" \
"--printable-salt[Experimental printable TCP salt]" \
"--no-delay::" \
"--key:key in base64:" \
"--plugin:plugin name:" \
Expand Down
1 change: 1 addition & 0 deletions doc/shadowsocks-c.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ The config file equivalent of command line options is listed as examples below.
- `--fast-open`: `"fast_open": true`
- `--reuse-port`: `"reuse_port": true`
- `--no-delay`: `"no_delay": true`
- `--printable-salt`: `"printable_salt": true` (ss-local only)
- `--plugin "obfs-server"`: `"plugin": "obfs-server"`
- `--plugin-opts "obfs=http"`: `"plugin_opts": "obfs=http"`
- `-6`: `"ipv6_first": true`
Expand Down
110 changes: 110 additions & 0 deletions docs/encoding-experiments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Outer-transport experiments

These Python prototypes extend the printable-salt investigation. They wrap the
TCP ciphertext of unchanged Shadowsocks peers. They are research fixtures, not
production plugins. Both endpoints need a matching relay; UDP is not wrapped.
The original `--printable-salt` flag remains a separate, disabled-by-default,
wire-compatible client option.

Base64 has been dropped from the active experiments. Earlier measurements are
retained unchanged in the historical report linked below.

## Candidates and tradeoffs

| Mode | Transformation | Bulk expansion | Main observable property |
|---|---|---:|---|
| raw | Transparent relay control | 0% | Ordinary Shadowsocks ciphertext |
| printable salt | Random 6–12-byte printable prefix, tested separately | 0% | First six bytes always printable |
| Base85 | Base85 records with newline separators | About 25.01% | Wider printable alphabet |
| lowpop85 | Base85 symbols mapped to 85 low-popcount byte values | About 25.01% | Restricted alphabet, mostly zero bits |
| TLS | Verified TLS 1.3 with two-byte inner record lengths | Variable; handshake plus TLS records | Actual OpenSSL TLS handshake and record behavior |

The bulk expansion figures include delimiters/padding for 16 KiB input records.
Small writes have proportionally more framing overhead. EOF is represented by
an empty encoded record, allowing TCP half-close through the relays. Base85 records preserve every ciphertext byte. The low-popcount mapping is public and
reversible; its symbols have at most three set bits, with `0xff` as a separator.
It is a simple baseline, **not** the keyed bit-shuffling construction from the
[modified-Shadowsocks implementation](https://gfw.report/blog/modified_shadowsocks/en/).

Base85 and lowpop85 have about `log2(85) = 6.41` bits per symbol. None destroys information or
makes the underlying encryption weaker merely by encoding it. Conversely,
lower byte entropy does not establish lower detectability: these encodings have
recognizable alphabets. A capable classifier can decode them or learn their
patterns. The low-popcount variant is particularly easy to distinguish from
uniform bytes.

TLS uses a fresh, short-lived experiment certificate, verified by the client,
with hostname `experiment.invalid`. It is genuine TLS, not a fabricated TLS
prefix. It does not imitate a browser or carry HTTP, and the self-signed
certificate and OpenSSL handshake can themselves be fingerprinted. No existing
server certificate, key, or service is reused or modified.

## Local screening

```sh
python3 tests/experiments/test_transport.py
python3 tests/experiments/benchmark.py \
--output build-artifacts/printable-salt/encoding-screen.json
python3 tests/experiments/local_interop.py --bin build-printable-stock/bin \
--output build-artifacts/printable-salt/encoding-local-interop.json
```

The tests cover fragmented and coalesced records, malformed/truncated input,
record size limits, half-close (including TLS), certificate verification,
concurrency, and empty readiness connections.
Local interoperability exercises all four relay modes around an independently
built stock server/client pair, with both supported ciphers, concurrent payloads
from one byte to one MiB, and a verified ten MiB bidirectional transfer.

The synthetic benchmark uses uniform random bytes as a ciphertext model. It
measures byte histograms, popcount, expansion, and encode/decode speed, and checks
2,000 independent 100-byte samples per mode against the four published
[historical byte-statistic exemptions](https://gfw.report/publications/usenixsecurity23/en/).
Those classifier results are simulated, not observed firewall decisions.
These timings measure the Python prototypes, not the inherent speed of each format.

## Route screening

After verifying SSH trust, route, unused ports, available disk, and server health:

```sh
python3 tests/experiments/route_screen.py \
--host root@SERVER --host-key-alias VERIFIED_KNOWN_HOST \
--address SERVER_IPV4 --stock-bin build-printable-stock/bin/ss-local \
--ports 32220 32221 32222 32223 \
--output build-artifacts/printable-salt/encoding-route-v2
python3 tests/experiments/analyze.py \
build-artifacts/printable-salt/encoding-route-v2
```

The protocol is saved before outcomes are observed. Four two-minute phases rotate
each mode over every port. Each mode attempts a verified one KiB transfer every
five seconds, plus one ten MiB transfer per phase: 100 transfers per mode, 400 in
total. Raw mode traverses the same relay architecture, providing a control for
Python relay overhead. The backend is one unchanged stock server using
`chacha20-ietf-poly1305`; all phases use fresh test credentials from the same run.

Server-local clients verify each entire relay/crypto path at phase start and
every 30 seconds. Management or local-control failure aborts the run. Ports and
listeners are isolated; no firewall rule or production service is changed.
A remote watchdog bounds the supervisor lifetime; a 60-second control-command
timeout also stops it if a broken SSH path never delivers EOF. Cleanup stops owned processes,
removes private configs and certificate keys, and retrieves captures.

Captures include full experiment packets and the SSH control flow. They contain
address metadata and are kept in a private output directory. The analyzer reports
first-arriving payloads separately from payloads at the TCP stream start: packet
reordering can make these differ. It also separates other source addresses from
the test client and looks for exact replayed opening payloads. A matching replay
from another address is evidence of replay traffic, not sufficient attribution
to a particular actor. A lack of observed probes is not proof of resistance.

Performance figures are end-to-end echo timings with concurrent traffic sharing
the same route and a small server. TLS includes a fresh connection handshake.
Small-transfer completion time is the latency metric; local SOCKS handshake time
is not remote RTT. Bulk throughput counts payload bytes once, though the echo
moves them in both directions. An eight-minute all-success result establishes only
short-run compatibility and measured prototype performance. It cannot establish
long-term censorship resistance or a speed advantage from a small sample.

Recorded results: [2026-09-23 measurements and limitations](measurements/printable-salt-2026-09-23/README.md).
166 changes: 166 additions & 0 deletions docs/measurements/printable-salt-2026-09-23/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Printable salt and outer-transport results — 2026-09-23

## Conclusion

The opt-in printable-salt implementation is interoperable and adds no framing
bytes. Base64, Base85, low-popcount encoding, and verified TLS prototypes also
transferred data successfully around unchanged Shadowsocks peers.

**Neither planned field trial completed.** Both stopped on loss of the direct
SSH management path. All recorded transfer outcomes succeeded, including the
raw controls, so there is **no demonstrated availability or censorship-resistance
advantage**. Some in-flight outcomes could be omitted by the logger at abort;
these are not counted as successes. The logger was subsequently fixed.

Three unsolicited exact replays were captured against raw Shadowsocks openings,
including raw traffic on two different ports in the rotated experiment. None
were observed against printable-salt or outer-transport openings in these
captures. This is a limited observation of replay traffic, not attribution to
the GFW or proof of protection. The original acceptance criterion—modified
traffic repeatedly succeeding while stock fails, including after crossover—was
not met.

## Environment and reproducibility

- Direct Mac `en0` route; the proxy-free address lookup reported China Mobile,
Anhui. The Singapore server saw the same mainland source address.
- All measurement child processes had proxy environment variables removed.
- Base source: `d52ddf2bcee97d799c74f54bf63533351a05324c`.
An independent `git archive` checkout was built before edits.
- Local stock and modified C builds: Release, system dependencies, macOS arm64.
Remote peer: unchanged shadowsocks-rust 1.24.0, Linux x86-64.
- Remote stock binary SHA-256:
`bbb26b41ad6ef40fd9a9ab399009ddff14ec22b1a04b77288671d9fa50dd9b06`.
- Fresh credentials, isolated temporary listeners, loopback-only echo origins,
no firewall changes, and no production service changes.
- [Salt reproduction](../../printable-salt.md) and
[outer-transport reproduction](../../encoding-experiments.md).

## Local validation

Regular and sanitizer CMake/CTest builds passed **25/25** tests each. The suite
includes first-write/empty-write behavior, exact framing overhead, salt-tail
preservation, byte-fragmented decryption, tampering, replay rejection, CLI cipher
validation, enabled interoperability, and experiment supervisor cleanup.

Independent stock-C and Rust interoperability passed for both supported ciphers.
Default TCP/UDP interoperability passed for all six AEAD/AEAD-2022 methods.
The repository's 10 MiB stress tests passed for all three standard AEAD ciphers.
Python lint, CLI documentation checks, and documentation unit tests passed.

Loopback packet captures checked 32 enabled and 32 disabled connections per
cipher. All 64 enabled first transmitted payloads began with six printable
bytes; none of the 64 disabled samples happened to do so. All payloads followed
the observed SYN sequence correctly. See [packet results](local-packets.json).

All five outer-relay modes passed local testing with both ciphers: four
concurrent transfers per case and a verified 10 MiB echo transfer. Codec tests
cover fragmentation, coalescing, malformed records, size limits, concurrency,
empty readiness connections, half-close, and TLS certificate verification.
Supervisor tests verify real backend/listener cleanup after EOF, silence, and
termination. See [local results](local-transports.json).

## Salt field trial: aborted before crossover

Planned: two 30-minute phases, swapping stock/modified clients between ports
32181 and 32182; a one KiB transfer every five seconds plus ten 10 MiB transfers
per mode per phase.

Observed: approximately 16 minutes, from 10:20:21 UTC until cleanup at 10:36:30.
Each mode recorded **197 successful outcomes**: 191 small and six large transfers.
All 32 completed health checks passed, including verified server-local transfers.
The next control exchange failed when SSH stopped responding. No port crossover
was reached. Median small-transfer completion times were 173.4 ms stock and
172.6 ms modified. Large-transfer medians were 23.89 and 27.25 Mbps respectively;
these six samples under shared load do not establish a speed difference.

Both endpoint captures confirm 197 legitimate transmitted openings per mode.
All 197 modified openings had the required first-packet prefix. A different
source replayed a stock opening's entire 79 bytes **3.109 seconds** later. The
server sent no application bytes in that replay connection; this alone does not
prove replay rejection, because the opening contained only a destination header.
Replay rejection is separately tested in the crypto unit tests.

[Sanitized summary](salt-aborted.json) · [recorded events](salt-events.jsonl).

## Outer-transport field trial: interrupted fourth rotation

Planned: five two-minute phases across five ports; every mode visits every port,
with 24 small transfers and one 10 MiB transfer per phase. The raw control uses
the same Python relay architecture. Server-local controls exercise each complete
relay/crypto path at every phase start and every 30 seconds.

Observed: three complete phases and part of the fourth, starting 10:43 UTC and
aborting at 10:50:53 UTC. All 19 completed health checkpoints passed. Values below
are **recorded outcomes**, not a claim that every scheduled or in-flight attempt
completed. Four large transfers per mode were recorded. Timings include shared
route/server load and concurrent bulk traffic; tail latency is substantial.

| Mode | Recorded success | Small median ms | Small p95 ms | Large median Mbps |
|---|---:|---:|---:|---:|
| raw | 92/92 | 182.7 | 2646.3 | 12.73 |
| base64 | 93/93 | 181.2 | 2382.5 | 5.42 |
| base85 | 92/92 | 186.9 | 2382.6 | 4.11 |
| lowpop85 | 92/92 | 197.5 | 2383.1 | 3.79 |
| tls | 92/92 | 280.9 | 3078.0 | 4.80 |

The full endpoint captures match these recorded connection counts. All encoded
client openings meet a published historical byte-statistic exemption; only one
of 92 raw openings does. This is evaluation of captured bytes against historical
rules, not an observation that a firewall applied those rules.

At the server, three TLS connections had later TCP data arrive before the stream
start. Their first-arriving payload did not match the historical exemptions;
the eventual stream-start payload did. This is why packet arrival and application
write boundaries must not be conflated.

Two other source addresses replayed exact 79-byte raw openings, one on port
32220 in phase one after **3.930 seconds**, and one on port 32223 in phase four
after **0.936 seconds**. No equivalent replay was observed for the other modes.
The low counts and incomplete rotation prevent a strong comparative conclusion.

[Sanitized summary](encoding-aborted.json) · [recorded events](encoding-events.jsonl).
Short calibration runs and the superseded run containing empty readiness
connections remain in the private artifact directory and are excluded above.

## SSH failure and cleanup

The client capture's last SSH request found in the server capture was sent at
10:50:38.585912 UTC. Starting at **10:50:43.584421 UTC**, ten captured client SSH
payload packets, including retransmissions, were absent from the server capture.
Both captures reported zero kernel capture drops. This supports forward-path
loss for the management connection; it does not identify the cause or actor.
See [packet comparison](ssh-path.json).

Fresh direct SSH connections also temporarily timed out. Recovery through the
existing local HTTP proxy reached the healthy server from a different egress IP.
That alternate route was used only for recovery and cleanup, not measurements.
Direct SSH subsequently recovered. The retrieved server capture's SHA-256
matched the remote file:
`4c86092fe8b7ca6dcdd77c5e4017c937d28a8d2ded9fba07d75d5786c6db2477`.

All experimental processes/listeners, temporary remote files and certificate
keys were removed. Original TCP/UDP listeners remained unchanged. No temporary
firewall rules were created. Raw captures, build hashes, test logs, recovery
verification, and reproduction helpers are retained locally under
`build-artifacts/printable-salt/` (not committed).

The failed control connection exposed two harness limitations, now fixed:
remote cleanup now has a 60-second control-message deadline in addition to its
lifetime watchdog, and transfer workers persist outcomes directly rather than
waiting for the control loop to collect them. These fixes were validated locally;
no further live load was started after the second management failure.

## Encoding tradeoff

The synthetic benchmark found bulk expansion of 33.36% for Base64 and 25.01% for
Base85/lowpop85. Their empirical symbol entropies were about 6.00 and 6.41 bits per
byte; lowpop85 averaged 2.44 one-bits per byte. Every transformed 100-byte sample
passed at least one historical exemption, versus five of 2,000 raw samples.
See [synthetic measurements](synthetic-encodings.json).

These encodings retain all ciphertext information while introducing recognizable
alphabets. Printable salt is cheapest but imposes a fixed printable property on
the first six bytes. TLS uses a real protocol but adds connection setup and has
its own handshake/certificate fingerprint. The measurements do not justify
selecting any candidate as a proven censorship-resistant transport.
Loading
Loading