A minimal VNC server that serves static images with advanced rotation capabilities.
Shodan shadowbanned VNC services from their image feed (https://images.shodan.io/) and added official product recognition for FictusVNC: https://www.shodan.io/search?query=product:"FictusVNC"
UPDATE: Disable branding and don't mention FictusVNC in server names if you want to avoid being flagged.
Note: This affected ALL VNC services, not just FictusVNC. Interestingly, it's now being classified as a honeypot - took them long enough to notice.
- 🖼 Serve static JPG & PNG as framebuffer
- 🖥 Supports RealVNC / UltraVNC / TightVNC clients
- 🗜 ZRLE encoding (with Raw fallback) — far less bandwidth per connection
- 🛠 Unified configuration via
config.toml - 📶 Multi-instance support (multiple ports/images)
- 🎲 Image rotation with weighted random selection
- 📋 Sequential rotation mode
- 🏷️ Named server configurations
- 💾 Cross-platform: Linux, Windows, macOS, ARM64
- 📉 Lightweight: ~3MB binary
Create config.toml:
[global]
name = "FictusVNC"
branding = true
show_client_ip = false
# Simple single-image setup (no rotation)
[server.desktop]
listen = ":5900"
image = "default.png"
name = "My Desktop"
# Example with rotation
[server.office]
listen = "127.0.0.1:5901"
name = "Office Computer"
rotation_mode = "random"
images = [
{path = "desktop_work.png", weight = 70},
{path = "desktop_idle.png", weight = 25},
{path = "desktop_error.png", weight = 5}
]Then run:
./fictusvnc-linux-amd64 --config config.toml| Flag | Description | Default Value |
|---|---|---|
--config |
Path to TOML configuration file | ./config.toml |
--check |
Validate the config, print a summary and exit | false |
--version, -v |
Show version and exit | false |
Note: Starting with v2.0.0, all other options (--name, --no-brand, --show-ip) have been moved to the configuration file under the [global] section.
go run . --config config.toml# Minimal setup - single image, no global options
[server.my_server]
listen = "0.0.0.0:5900"
name = "Test Server"
image = "desktop.png"[global]
name = "My VNC Server"
show_client_ip = true
[server.my_server]
listen = "0.0.0.0:5900"
name = "Test Server"
image = "desktop.png"[global]
name = "FictusVNC"
branding = true
show_client_ip = true
# Server with rotation enabled
[server.random_rotation]
listen = "0.0.0.0:5900"
name = "Random Server"
rotation_mode = "random"
images = [
{path = "normal_desktop.png", weight = 60},
{path = "busy_desktop.png", weight = 30},
{path = "error_screen.png", weight = 10}
]
# Server with sequential rotation
[server.sequential_boot]
listen = "0.0.0.0:5901"
name = "Boot Sequence"
rotation_mode = "sequential"
images = [
{path = "boot_bios.png"},
{path = "boot_loading.png"},
{path = "login_screen.png"},
{path = "desktop_ready.png"}
]
# Simple server without rotation (default mode)
[server.static]
listen = "0.0.0.0:5902"
name = "Static Server"
image = "desktop.png"[server.multi_port]
listen = "0.0.0.0"
start_port = 5900
end_port = 5905
name = "Multi-Port Server"
image = "desktop.png"Image rotation is DISABLED by default. FictusVNC uses simple single-image mode unless you explicitly configure rotation.
- Default behavior: Use
image = "path.png"for single static image - Optional rotation: Use
images = [...]array to enable rotation withrotation_mode
| Mode | Description | Example Use Case |
|---|---|---|
random |
Weighted random selection based on image weights | Honeypot with realistic error rates |
sequential |
Images shown in order, cycling through on each connection | Boot sequence simulation |
- Only active when using
imagesarray - Each image can have a
weightparameter (default: 10 if not specified) - Higher weights make images more likely to be selected
- Example: weights 50, 30, 20 = 50%, 30%, 20% probability
- Example: no weights specified = equal distribution (every image gets the same default weight)
- Perfect for simulating realistic desktop scenarios with varying frequencies
- Only active when using
imagesarray - Images cycle in the order defined in configuration
- Each new VNC connection gets the next image in sequence
- Ideal for simulating boot processes or step-by-step scenarios
FictusVNC negotiates the client's preferred encoding automatically — no configuration required.
- ZRLE (
encoding 16) is used when the client advertises it. The framebuffer is split into 64×64 tiles and each tile is sent with the cheapest ZRLE subencoding (solid, packed palette, palette/plain RLE or raw), then the whole stream is zlib-compressed over a single per-connection stream. This cuts per-connection bandwidth dramatically — a typical static desktop drops from hundreds of KB (Raw) to a few KB. - Raw (
encoding 0) is the automatic fallback for clients that don't support ZRLE or that negotiate an unusual pixel format.
Standard KeyEvent / PointerEvent / ClientCutText messages are accepted
and discarded (the server is view-only — it never changes the displayed
image based on client input).
The server speaks RFB 3.7 and 3.8 and offers exactly one security type,
None (1) — there is no password.
Both halves of the handshake are validated rather than assumed. A greeting that
is not a well-formed RFB xxx.yyy string ends the connection instead of being
parsed as if the rest of the stream were protocol. RFB 3.3 and older are
refused, because those revisions put the server in charge of choosing the
security type over a different message flow. A client that selects a security
type that was never offered gets a proper RFB 3.8 failure result — status 1
plus a reason string — instead of a silent success.
Each case lands in the connection log with its own outcome
(malformed_version, unsupported_version, bad_security_type), which makes
misbehaving scanners easy to separate from real clients.
--check parses the config, loads every image and prints what the server
would do — without binding a single port, so it is safe to run against a live
host where an instance is already listening:
$ fictusvnc --config config.toml --check
config: config.toml
warning: unknown key "global.show_clientip" — check the spelling, it is being ignored
warning: server "a" has rotation_mode = "randam", expected "random" or "sequential"; using random
servers:
[a] Reception
listen: 0.0.0.0:5900
images: 2 (random)
desktop_work.png 1920x1080 weight 70
desktop_idle.png 1920x1080 weight 30
banner: client ip, time
listeners: 1
max_connections: 512
logging: json/info -> stdout
config is usable, with 2 warning(s)
It exits non-zero when something would actually stop a server from starting —
a missing or corrupt image, a server with no listen address, two servers
claiming the same address (which otherwise only shows up as a bind failure at
startup), or no [server.*] sections at all. Warnings alone do not fail it.
Unknown keys are reported. A mistyped key used to be dropped in silence, so
the option simply appeared not to work; the same went for a mistyped section
name. Typos are now flagged at every level — [global], [logging], a section
name, a key inside a server, even a key inside an inline image table — both by
--check and as WARN records on a normal start. Settings that quietly
override one another are flagged too: image together with images, or a port
in listen together with start_port/end_port.
| Parameter | Type | Description | Default |
|---|---|---|---|
name |
string | Brand prefix used when branding is enabled |
"FictusVNC" |
branding |
boolean | Prefix server names with the global name |
true |
show_client_ip |
boolean | Draw the client IP on the image | false |
show_rdns |
boolean | Add the client's reverse-DNS name to the banner | false |
show_time |
boolean | Add the connection timestamp to the banner | false |
max_connections |
int | Concurrent clients across all listeners, 0 = unlimited |
512 |
max_connections bounds how many clients are served at once, counted across
every listener rather than per port — the resource it protects is process-wide
memory, since each connection can hold its own framebuffer copy while the info
banner is enabled. Clients arriving over the cap are closed immediately, before
any greeting, and recorded with outcome: "connection_limit" so a flood shows
up in the same aggregation as every other connection.
Any of the three flags above turns on a translucent banner in the top-left corner. It is sized to its contents — the box grows for an IPv6 address or an extra line and shrinks back for a short IPv4 — and the font scales with the image, so it stays readable on a 4K wallpaper without swallowing a thumbnail. On a narrow image the text shrinks rather than running off the edge.
IP: 198.51.100.42
Host: bot.internet-census.example.org
Time: 2026-08-03 21:53:12 UTC
show_rdns is worth thinking about before enabling. It performs a PTR lookup,
bounded at 700 ms, that queries the client's own DNS authority — telling that
operator you looked them up. The banner (and the lookup) is only built when a
client actually requests its first frame, so the RFB greeting is never delayed
and the scanners that merely open a socket and vanish trigger no DNS traffic
and no framebuffer copy. With the flag off no DNS traffic is generated at all.
Clients with no PTR record show (no PTR record).
show_time uses the server's local timezone.
All three flags can also be set on an individual server, where they override
the [global] default. An unset key inherits; a key set to false switches a
globally enabled line back off. That makes mixed setups straightforward — the
banner on the servers you are watching, nothing on the ones meant to look
untouched:
[global]
show_client_ip = true # default for every server
[server.watched]
listen = "0.0.0.0:5900"
image = "desktop.png"
show_rdns = true # this one also resolves the client name
[server.clean]
listen = "0.0.0.0:5901"
image = "desktop.png"
show_client_ip = false # no banner here at all| Parameter | Type | Description | Default |
|---|---|---|---|
level |
string | debug, info, warn or error |
"info" |
format |
string | json or text |
"json" |
output |
string | stdout, stderr or a file path |
"stdout" |
FictusVNC emits structured logs through the standard library's log/slog — no
extra dependency, and nothing to configure on the shipping side beyond pointing
a collector at the stream.
One record per connection. Instead of scattering six lines per client, the server accumulates the whole session and emits it as a single event when the connection ends:
{"time":"2026-08-03T22:11:41.986Z","level":"INFO","msg":"connection",
"server":"Reception","listen":"127.0.0.1:5900",
"peer_ip":"198.51.100.42","peer_port":48280,
"handshake":true,"outcome":"client_eof","duration_ms":1049,"bytes_sent":6407,
"client_version":"RFB 003.008","security_type":1,
"image":"default.png","updates":1,"pixel_bpp":32,"pixel_depth":24,
"encodings":[16,0,-239],"encoding_used":"zrle"}That record is designed to be aggregated. encodings keeps the client's own
ordering, which is the single best fingerprint of which VNC software is on the
other end — RealVNC, TightVNC, noVNC and mass scanners each advertise a
distinctive list. outcome is a small stable set (client_eof,
idle_timeout, unknown_message, version_read_failed, read_error,
update_write_failed, cut_text_too_large, panic, …) so it groups cleanly,
and handshake separates real clients from probes that open a socket and
vanish. Set level = "debug" to also get per-message protocol detail.
Shipping to Elasticsearch, Loki or Splunk needs no code in FictusVNC and no credentials in this config. Write JSON to stdout and let a collector — Vector, Filebeat, Fluent Bit, Promtail — pick it up. An in-process exporter would have to reimplement buffering, retries and backpressure, and would still drop events on restart; collectors already solved that.
Under systemd or Docker, leave output = "stdout": journald and the Docker
log driver already capture and rotate the stream. A file path is for running
without a supervisor. After rotating the file, send SIGHUP and the server
reopens it — the usual logrotate arrangement:
/var/log/fictusvnc/*.log {
daily
rotate 14
compress
missingok
postrotate
systemctl kill -s HUP fictusvnc.service
endscript
}
Without that signal the server would keep writing to the rotated-away inode and the live file would stay empty.
| Parameter | Type | Description | Default |
|---|---|---|---|
listen |
string | Listen address and port | Required |
name |
string | Display name for the server | Server key name |
image |
string | Single image path (default mode) | - |
images |
array | Array of images for rotation (optional) | - |
rotation_mode |
string | "random" or "sequential" (ignored if using image) |
"random" |
start_port |
int | Start of port range | - |
end_port |
int | End of port range | - |
show_client_ip |
bool | Override the global banner setting for this server | inherits |
show_rdns |
bool | Override the global banner setting for this server | inherits |
show_time |
bool | Override the global banner setting for this server | inherits |
Image paths are resolved relative to the directory holding the config file
(inside its images/ subdirectory), so the server behaves identically whether
it is started from a shell or by systemd. Absolute paths are used as-is.
The old spellings still work and log a deprecation warning, so existing configs keep running unchanged.
| Old key | New key | Note |
|---|---|---|
show_ip |
show_client_ip |
Same meaning |
no_brand |
branding |
Inverted: no_brand = true becomes branding = false |
server_name |
name |
Matches the global name key |
# DEFAULT MODE: Single static image (no rotation)
image = "desktop.png"
# OPTIONAL ROTATION MODE: Use images array
images = [
# For weighted random rotation - specify weights
{path = "normal_desktop.png", weight = 50},
{path = "busy_desktop.png", weight = 30},
{path = "error_screen.png", weight = 20}
]
# OR for equal weight distribution (weight = 1 by default)
images = [
{path = "desktop1.png"},
{path = "desktop2.png"},
{path = "desktop3.png"}
]
# OR for sequential rotation (weights ignored anyway)
images = [
{path = "boot_bios.png"},
{path = "boot_loading.png"},
{path = "desktop_ready.png"}
]Old format (v1.x):
[[server]]
listen = ":5900"
image = "desktop.png"New format (v2.0.0+):
[server.desktop]
listen = ":5900"
image = "desktop.png"Migration Guide:
- Replace
[[server]]with[server.any_name]whereany_nameis your chosen server identifier - Rename configuration file from
servers.tomltoconfig.toml - Move CLI flags to config file: Add
[global]section and move--name,--no-brand,--show-ipoptions there - Update command line: only
--configand--versionflags are supported now
Example migration:
Old v1.x command:
./fictusvnc --servers servers.toml --show-ip --no-brandNew v2.0.0:
./fictusvnc --config config.tomlWith config.toml:
[global]
show_client_ip = true
branding = false
[server.main]
listen = ":5900"
image = "desktop.png"Single binary for the current platform:
go build -o fictusvnc .All release targets (Linux / Windows / macOS, amd64 / arm64 / 386) into build/:
./build.shTwo release streams run off main:
| Release | Trigger | Contents |
|---|---|---|
Dev build (dev, pre-release) |
Every push to main |
The latest commit, version <appVersion>-dev.g<sha>. The tag moves, so the assets are always the newest build. |
Stable (v<appVersion>) |
A push to main whose appVersion has no tag yet |
Tagged, versioned archives for every platform. |
Cutting a stable release is just bumping appVersion in config.go and merging
to main: the workflow tests the merged tree, builds every target, writes the
release notes, tags the commit and publishes. The tag is the gate, so pushing
again without a bump changes nothing — a version is never released twice.
The release notes are the commit log since the previous tag, one bullet per
commit (changelog.sh). Run it by hand to preview what the next release would
say:
./changelog.sh v2.1.0..HEADHand-pushed v* tags still work as an escape hatch — for releasing a commit
that is not the head of main — and produce the same notes.
This project is licensed under the terms specified in the LICENSE file.

