How to Wrap a Tool Without Hiding the Protocol

What this post is about. Two small Python tools — a batch camera scanner and a macOS GUI for aircrack-ng — that share almost nothing except a size. Together they taught me one lesson I have applied to every security tool since: wrap, don’t reimplement — but own the protocol when you can.

What you need to know first. A protocol is the agreed sequence of messages two programs exchange. A CLI is a command-line interface: a tool you drive by typing commands with flags. A wrapper is a program that does not implement a protocol itself but calls something that does, and then presents the result in a different shape. tkinter is the widget toolkit that ships with Python, and it draws ordinary desktop windows. You do not need to have run any of these tools to follow this post.

The first tool: CameraHack

The situation

A penetration-testing team had an asset inventory. Thousands of internet-exposed IP addresses, and a question that was not “can I get in” but “which of these are worth getting into”. Three days of manual triage against thousands of hosts does not work. An asset inventory is the list of machines a team is responsible for, and triage is the work of sorting that list into “worth attacking” and “not worth attacking”.

The repository ships the answer to that question: a corpus of 6,963 candidate IPs, 3,153 hosts flagged for a Dahua CVE, and 3,381 recorded findings. A CVE is a public identifier for a published vulnerability — one number that names one flaw in one version of one piece of software. The scanner is the tool that produced them.

Why this design

One generic probe first, brand probes second. An RTSP server — RTSP is the protocol cameras use to stream video — often accepts a bare root URL with no path at all, so the tool tries the simplest possible request against every host before it knows what the host is:

1
2
3
4
5
6
7
8
def rtsp(ip: str) -> bool:
if not Check_Port(ip, 554):
logger.error(f"IP:{ip} rtsp端口未开放!"); return False
easy_res = easypwd_get_rtsp(ip, easy_rtsp)
if easy_res:
save_res(f"[+] {ip}存在{easy_res[1]}")
elif hk_get_rtsp(ip, hk_rtsp):
save_res(f"[+] {ip}存在海康威视弱密码!")

CameraHack/main.py:64. Port 554 is the default RTSP port, and Check_Port is the pre-filter described below. The order is the design: cheap and generic first, expensive and specific second. The generic probe uses ffmpeg as the client and its exit code as the oracle — an oracle is anything that tells you the answer by whether it succeeds or fails — which is a hack, but it is a hack that gives you a full RTSP client for free.

Weak passwords and auth bypass are separate code paths. Auth bypass is a flaw that lets you in without the password at all. The two are reported differently (存在海康威视弱密码, meaning a Hikvision weak password, versus 存在海康威视漏洞, meaning a Hikvision vulnerability) because they have different remediation: rotate the password, or patch the firmware. Conflating them would have produced a report that a reader could not act on.

The port pre-filter is the entire crash-avoidance strategy. There is no sniffing, no protocol negotiation, no graceful degradation per brand. One TCP connect with a one-second timeout decides whether the host is worth touching:

1
2
3
4
with ThreadPoolExecutor(max_workers=20) as pool:
for ip in load_ips():
# payload(ip)
pool.submit(payload, ip)

CameraHack/main.py:114. Twenty threads — twenty things happening at once — one syscall, one call into the operating system, of wasted time per dead host, and every payload function only ever sees a host that answered.

The proof of success is a file. A successful RTSP probe writes a 1280×720 JPEG snapshot; a successful CVE probe writes an HTTP snapshot. For a triage workflow, the artifact — the file the probe produced — is the deliverable: it is what you paste into the report, and it is what proves the finding was not a false positive. A false positive is a result that looks like a finding but is not one, and a picture of the device’s login page is the cheapest way to rule one out.

How it works

The scanner has three brands of handling: generic RTSP, Hikvision (RTSP URL variants plus the CVE-2017-7921 auth-bypass endpoints), and Dahua (CVE-2021-33044 token grab plus its own RTSP URL scheme).

The Dahua token grab is deliberately loose about the response:

1
2
3
4
5
6
7
r = self.session.post(url, json=post_json)
if r.status_code != 200:
logger.info(f"[-] 大华摄像头 token 获取失败 请求码:{r.status_code}")
return False
r = r.json()
if 'True' in str(r):
self.token = r["session"]

CameraHack/dahua/bypass.py:23. The check is a substring test on the serialized body rather than a schema check, because firmware revisions disagree about the field names. A schema is the expected shape of a message; by skipping it, the code works across revisions that name the same field differently.

The most interesting file is not the scanner at all — it is DaHua_autoAddUser.py, a Playwright script that adds a user through the camera’s web UI. Playwright drives a real browser, clicking and typing as a person would. Different Dahua firmware revisions lay out the form differently, so the script keeps a table of three XPath sets and tries them in order — an XPath is a way of naming one element on a web page, like “the second input box in the third form” — and on the final failure it screenshots the page, so the failure itself becomes an artifact for forensics.

What it got wrong

There is no rate limiting. Twenty threads against 6,963 hosts, with an ffmpeg process spawned per attempt. A rate limit caps how many requests a tool sends per second; without one, a denial-of-service vector is a way of making a service unavailable by overwhelming it. On an authorised engagement that is your own infrastructure to protect; against a third party’s, it is a denial-of-service vector. This is the first thing I would fix.

ffmpeg‘s stderr is discarded, so a failed authentication is indistinguishable from a missing codec. Every failure is re-derived by retrying with a different URL.

The path is hardcoded: C:\Users\WhaleFall\Desktop\py_photo, even though the rest of the code uses pathlib throughout.

requirements.txt lists only pycryptodome, while the code imports httpx, loguru, playwright and pyppeteer_stealth. The setup is effectively undocumented, which is why there is a committed .venv.

A function is defined twice. handle_macsan_json appears at main.py:37 and again at main.py:126; the first is dead.

No rate limit, no config, no tests, no CLI. It is a working prototype from 2022 that was abandoned, and it produced 3,381 findings before it was.

The second tool: aircrack-ng_gui_macos

The situation

Aircrack-ng is a command-line tool with a memorised invocation grammar: scan, sniff, capture, crack, four flags in an order you have to remember. The author of this tool wanted a one-click front end.

It is 143 lines of tkinter in one file, and it teaches a different half of the same lesson.

Why this design

It wraps; it does not reimplement. Packet capture, handshake cracking and driver handling are the hard parts, and they already exist. The GUI contributes only the screen furniture around the command — the UX: a channel dropdown, a password field, file pickers, a console pane.

Long-running work is handed to Terminal. This is the decision the rest of the repository is built around, and it is a lesson about tool boundaries rather than about security:

1
2
3
def open_new_terminal_and_run_command(command):
apple_script = f'tell app "Terminal" to do script "{command}"'
subprocess.run(["osascript", "-e", apple_script])

Aircrack’s progress display uses terminal control sequences that Tk cannot render. Instead of trying to reimplement or escape them, the tool opens a real Terminal window running the real command. The user gets the authentic progress bar, and the GUI does not have to understand a single byte of it.

Everything else runs in-process, streaming into a text widget:

1
2
3
4
5
6
7
8
def execute_command(text_box, password, cmd):
process = Popen(cmd, shell=True, stdout=PIPE, stderr=STDOUT, text=True)
while True:
output = process.stdout.readline()
if output:
text_box.insert(tk.END, output); text_box.see(tk.END)
elif process.poll() is not None:
break

aircrack-ng_gui_macos/main.py:10. Popen starts the command and returns a handle to it; stdout.readline reads one line at a time as it arrives, so the window fills in while the command runs instead of after it finishes.

What it got wrong

The sudo password is interpolated into a command line, which means it is visible in ps output and in shell history:

1
cmd = f"echo {password} | sudo -S /System/Library/PrivateFrameworks/Apple80211.framework/Versions/A/Resources/airport en0 sniff {selected_channel}"

shell=True with f-strings everywhere. The BSSID — the access point’s hardware address — and dictionary paths are not quoted. Passing a command as a string asks the operating system to run it through a shell, which interprets anything that looks like a shell operator, so an unquoted filename can turn into somebody else’s command. This is a quoting bug waiting for a filename with a space in it.

Process cleanup is ps | grep | awk — the code runs a pipeline that lists processes, filters for the ones matching a name, and prints their process IDs. That is not a design, it is an admission that the previous design leaked processes. The same leak is why is_listening is a global mutated from the Tk thread: click twice during a scan and you can orphan a sudo process.

And then the platform moved. airport provides sniff but no monitor mode, so this tool can capture but cannot inject or deauthenticate — it cannot perform the attacks aircrack-ng is famous for. Monitor mode is the state a Wi-Fi adapter can be put into where it captures raw radio traffic, and it is what injecting and deauthenticating require. On macOS Sonoma (through 14.4) Apple removed the airport CLI’s command surface entirely, and the README’s first line is a warning about exactly this.

A 143-line wrapper around a binary that the OS vendor deleted is not a design decision; it is a deadline.

The lesson

Both tools wrap an external capability rather than reimplementing it, and both are worse for it — but not for the same reason.

CameraHack’s problem is that it wrapped too loosely. It used ffmpeg as an oracle, threw away its diagnostics, spawned a process per attempt, and had no rate limit. The wrapper was the whole design, and the wrapper was where all the fragility lived.

The GUI’s problem is the mirror image: it wrapped airport tightly enough to depend on a binary Apple was actively removing, and it did not notice.

The rule I took from both:

If you can own the protocol, own it. CameraHack’s RTSP probing should have been a real RTSP client that parsed the response and reported the actual reason for failure — the port pre-filter should have been a first-class step, not an accident of exception handling, and the artifact should have been a structured finding plus a snapshot, not a text line.

If you must wrap, wrap at the narrowest point that still works. The GUI’s Terminal handoff is the good version of wrapping: it delegates one specific, slow, hard-to-render job to the tool that already does it best, and keeps everything else in-process.

And check that the thing you wrap still exists. airport was deprecated by the OS vendor on a schedule. A wrapper needs a tripwire — a version check, a --doctor that fails loudly, a CI job on the target OS version — because the thing you are wrapping has its own roadmap, and it is not yours. A tripwire is a check designed to fail loudly rather than a test that is allowed to pass quietly, and a CI job is one that runs automatically on every change.

None of this is a case for not writing small tools. CameraHack turned three days of manual triage into an afternoon, and that is worth a hundred lines of ThreadPoolExecutor. It is a case for knowing which parts of your tool are load-bearing and making sure those parts are yours.

References

  • aircrack-ng — the command-line tool suite the second project wraps with a tkinter front end
  • WIPWN — another wrapping-vs-owning case study, this time for the WPS protocol
  • NVD — the National Vulnerability Database, where the CVEs referenced by both tools are published
  • RFC 4253 — the SSH transport protocol specification, a worked example of a framed request/response protocol