I built a CLI in Go that reads HTTPS traffic as plaintext — no CA certificate installation, no proxy, and no changes to the target app.
The original motivation was simple: spinning up mitmproxy every time I needed to debug something added one too many steps. I found an approach using eBPF, wrapped it into a CLI, and called it tinytap. This article is about how I built it.
Here's what it looks like in action.
Two terminals side by side. The left sends one HTTPS request; the right shows tinytap printing it in plaintext — request line, headers, Authorization token, JSON body, all of it.
What it captures
tinytap targets apps like this — ordinary code that hits an API, in this case the OpenAI API from Python:
from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="https://127.0.0.1:8443/v1")
client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "what is a uprobe"}],
)
And here's what tinytap captures and outputs:
POST /v1/chat/completions HTTP/1.1
Host: 127.0.0.1:8443
Authorization: Bearer sk-demo-xxxx
Content-Type: application/json
User-Agent: OpenAI/Python 2.53.0
...
Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}
Request line, all headers, the Authorization: Bearer token, and the JSON body. The token in the gif is sk-demo-xxxx — a dummy — but when you point tinytap at a real app, the real token shows up exactly like that. No reformatting.
Nothing on the app side was touched. One command: tinytap.
How eBPF makes this work
A uprobe is a kernel mechanism for dynamically hooking into user-space functions. tinytap attaches a uprobe to SSL_write in libssl. The moment that function is called, tinytap reads the argument buffer — which is still plaintext at that point. The data travels through a ring buffer to user space and gets displayed in the TUI. No modifications to the app or to libssl.
This is not breaking TLS
One thing worth clearing up.
tinytap only looks at the buffer your own process hands to the TLS library — the moment before it gets encrypted. It is not a vulnerability that lets you intercept traffic on the network. What flows over the wire is properly encrypted. Other processes and other people's traffic are not accessible.
App
│ plaintext
▼
TLS library ◀── tinytap looks here
│ ciphertext
▼
Network
│ ciphertext
▼
API server
It's the same idea as curl printing its own request to your terminal.
Why Go
A few reasons I chose Go.
First, cilium/ebpf. It's a mature library for managing eBPF programs from Go — it absorbs the boilerplate of talking to the kernel, so I could focus on the actual hook logic.
Second, binary distribution. go build produces a single binary you can hand to someone and it just runs. No runtime or external dependencies to install. That matters a lot when you're shipping an open-source CLI.
Third, the TUI. I used Bubble Tea to build a live event display. Even when a burst of eBPF events comes in from the kernel, Go's goroutines and channels handle concurrent ingestion and rendering without trouble.
Try it
tinytap uses eBPF and is Linux-only. On Mac or Windows, set up a Linux container with Docker.
curl -fsSL https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh
sudo setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin=eip $(command -v tinytap)
sudo chmod +x $(ldconfig -p | grep libssl.so | awk '{print $NF}' | head -1)
tinytap
setcap grants the binary the capabilities it needs for eBPF. The chmod +x step is needed on Debian and Ubuntu because libssl.so.3 ships without the execute bit set there. With both done, tinytap runs without sudo.
If something isn't working, run tinytap doctor. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.
$tinytap doctor
tinytap doctor — v0.6.1
[OK ] kernel version 6.1.0 (>= 5.8 required)
[OK ] kernel BTF present
[OK ] cap_dac_read_search present
[OK ] cap_perfmon present
[OK ] cap_bpf present
[OK ] cap_sys_admin present
[OK ] cap_syslog not needed on arm64
[OK ] perf_event_paranoid 4
[OK ] unprivileged_bpf_disabled 2
[OK ] RLIMIT_MEMLOCK soft=unlimited hard=unlimited
[OK ] syscall tracepoints available
[OK ] BPF dry-run load ok
[DEGRADED] libssl execute bit /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
Affects: TLS capture only. Plaintext HTTP capture is unaffected.
Fix: sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3
12 ok, 1 degraded, 0 blocking, 0 info
Python, Ruby, PHP, and Node.js all work. Go apps are out of scope — the standard library's crypto/tls doesn't go through OpenSSL, so there's no SSL_write to hook. tinytap currently understands HTTP/1.1 only.
Targeting implementations that bypass OpenSSL — like Go's crypto/tls or Rust's rustls — would require attaching the uprobe at a different layer. Where exactly that should be is still an open question.
Verified compatibility details are in the docs:
- Server compatibility — how much of the response body tinytap can surface for nginx, Caddy, Uvicorn, and other servers
- TLS compatibility — the list of supported TLS implementations; OpenSSL-based stacks (Python, nginx, curl, Node.js, etc.)
- Platform support — tested Linux distributions and kernel versions; kernel 5.8 or later is required (Ubuntu 20.04's default kernel 5.4 is too old)
Wrapping up
I built a CLI in Go that reads HTTPS plaintext using eBPF uprobes — no CA certificate, no proxy needed. By hooking into SSL_write in libssl, it works with any OpenSSL-based stack: Python, Ruby, Node.js, and others, regardless of language.
Code is on GitHub. Environment requirements and compatibility details are in the docs.