Scan Node WASM POC Template Development
Write vulnerability detection templates in WASM: scan_* host functions, VulnFinding reporting, meta.yaml and signing.
This document is written for third-party developers: with nothing more than the official distribution package plus this document, you can write a WASM vulnerability POC template from scratch that the scan node can load and execute.
This version splits every public function/type into its own section, explaining item by item "what it does / signature / where each argument comes from / what it returns / how to use the return value / code snippet / what goes wrong if you misuse it". All parameter names, field names and types are taken verbatim from the
scan.goshipped in the official SDK package, so you can copy them with confidence.
1. What It Is
A WASM POC template is a class of vulnerability template in the "POC template list" whose PocType = wasm. It compiles detection logic into a WASI module (output.wasm), which the scan node loads and runs as host. Its runtime model is passive scanning:
扫描任务启动(扫描节点)
├─ 先把任务里的流量包去重(按包 Hash)
└─ 加载 / 编译一个插件(先校验签名,只加载 verified 的模板)
──▶ 依次对该插件扫全部去重流量包(每包一次执行)
──▶ 卸载该插件 ──▶ 加载下一个插件
- One packet = one WASM execution: the host serializes the packet and writes it to the WASM stdin, where the plugin retrieves it through
scan.LoadFlow();SCAN_*environment variables are set at the same time as a fallback. - One execution corresponds to one
Flowstructure. Insidefunc main()(the WASI export_start) the plugin performs detection, sends requests withscan.HTTP, reports findings withscan.Report, and writes logs withscan.Log. - Each plugin is loaded/compiled only once and is unloaded only after all packets have been processed this way — so thousands of 2~3MB plugins never reside in memory at the same time.
A WASM POC is a passive scanner: it does not decide which targets to scan; instead the scan node feeds it deduplicated packets one by one. Your plugin's job is "given one packet, decide whether extra requests are needed, how to judge, and how to report a hit".
How it differs from the neighboring forms (one-liner version):
| Form | Carrier | Entry point | Question it answers |
|---|---|---|---|
| YAML template | Declarative YAML | Engine parsing | Detection expressible with rules |
| Go hot-reload POC | Pure Go source (interpreted in-process) | @meta + func Run | You need a Turing-complete script but do not want a WASM build chain |
| WASM POC template (this document) | output.wasm | _start / func main | Detection that needs cross-language support, binary distribution and strong signature control |
| Application plugin | scan.wasm (c-shared) | Exported functions prefixed with 应用_ | Resident hooks into tasks/packets/MITM |
2. Applicability Boundaries (Important): scan_* and app_* Are Not Interchangeable
The scan node injects two completely disjoint sets of host functions for the two kinds of WASM module. The scan_* host functions are injected only on the "POC template execution" call path.
| Dimension | WASM POC template (scan_*) | Application plugin (app_*) |
|---|---|---|
| Host function namespace | scan_http / scan_log / scan_report / scan_call / scan_t / scan_config / scan_set_timeout | app_log / app_send_tcp / app_send_tcp_json / app_tcp_received_data / app_call / app_t / app_config / app_node_info / app_set_timeout |
| Entry point | _start (func main), processes one packet per call | Exported functions prefixed with 应用_, invoked by hook |
| Build mode | Ordinary wasip1 command module | Must use -buildmode=c-shared |
| When it takes effect | Only entries with PocType=wasm in the POC template list | Scan node application plugins installed from the plugin store |
| Directory | poc/<lang>/wasm/<VulnIde>/ | poc/plugin/<uuid>/ |
| SDK file | scan.go | app.go |
| Use case | Vulnerability detection | Task preprocessing / packet processing / TCP command extension |
Both SDK files in the table (
scan.go/app.go) are included in the official SDK package you download; just take the one for your target language.
How to decide: if your artifact is scanned as a vulnerability entry in the POC template list → use scan.*; if it runs as a resident plugin in the plugin store / application plugin directory → use app.*.
Writing scan.HTTP(...) into an application plugin, or app.SpawnTool(...) into a WASM POC, will cause instantiation failure because the host does not export the corresponding function (instantiate module: ... unknown import). This is not an error that appears only at runtime — the plugin simply cannot start at all. Likewise, controller plugins use ctl_* and AiAgent external tools use stdin/stdout JSON — the four SDKs are mutually incompatible.
Choose one of three: how to pick YAML / Go hot-reload / WASM
| Your situation | Recommendation |
|---|---|
| Expressible with a request + match rules + regex / timing / callback | YAML template (first choice: no SDK, no build) |
| Complex algorithms, loops, custom parsing, and your team writes Go | Go hot-reload POC (import "scan", interpreted in-process) |
| Cross-language implementation, binary distribution, strong signature control, size/performance sensitive | WASM POC (this document) |
3. Where the Entry Point Is: From the POC Template List to _start Being Called
This chapter answers the question third-party developers ask most: "Who actually calls the func main() I wrote? What happens in between? Why does it run fine locally but do nothing after I upload it?" The real flow is described step by step below.
3.1 The Complete Flow (Step by Step)
- Select the WASM type in the GUI: open "Add Vulnerability / POC Template List", create or edit a vulnerability, and set the template type to WASM. This step determines that the vulnerability will eventually be written as
PocType: "wasm"and will follow the execution path described in this document (rather than YAML or Go hot-reload). - Source code goes to the build service for compilation: submit your source (
source/, multiple files including the SDK file) together with metadata to the TestSecVulnService build service (the GUI upload/build entry). The build service picks the toolchain according tometa.yaml.SourceLanguage(go/c/cpp/rust/tinygo) and produces a WASI command moduleoutput.wasm. Key requirement: the artifact must export_start(Go'spackage mainprovides it automatically; do not use-buildmode=c-shared). - Ed25519 signature
sig.json: after a successful build, the server signs theoutput.wasmbytes with Ed25519 using the private key uploaded by the controller and writessig.json(userSignature/userPubkey), or the POC marketplace public key producesserverSignature/serverPubkey. In the endsignStatusbecomesverifiedorofficial. Artifacts without a valid signature will not be loaded later (see Chapter 2 and Chapter 11). - Controller loads it: the controller syncs the POC from the POC marketplace/upload directory into its own
poc/<lang>/wasm/<VulnIde>/directory (<lang>is the language directory, e.g.cn;<VulnIde>is the unique vulnerability identifier) and parsesmeta.yamlto create a vulnerability entry withPocType="wasm". - Task is dispatched to the scan node: when a scan task starts, the controller sends the matched vulnerability list (including the WASM entries and their template directories) to the scan node together with the task.
- The node loads only
verifiedplugins: after receiving the template directory, the scan node first readsmeta.yaml+output.wasm+sig.jsonand re-verifies the signature; only plugins whosesignStatusisverifiedproceed to execution — all others are skipped after a log entry is recorded (they are not even compiled). - Batch streaming execution: for each plugin that passes verification, the node loads it once and compiles it once, then runs the plugin against all deduplicated packets in sequence, unloads it, and moves on to the next plugin. Only one plugin's compiled artifact resides in memory at any moment.
- What a single execution does (one packet per execution):
- Serialize the packet to JSON and write it to a temporary file as the WASM stdin (the
Flowfields are also set asSCAN_*environment variables as a fallback); - Create a brand-new module instance (resetting wasm global state) and complete initialization;
- Take the exported function
_start— this is yourfunc main(); - Call
_start(); your code starts running:scan.LoadFlow()reads stdin →scan_*host functions perform detection →scan.Report/scan.Logsend results back; - After
_startreturns, the host collects the findings, logs and request counters of this execution, then parses stdout[LOG]/[FIND]lines as a fallback, and finally destroys the module instance.
- Serialize the packet to JSON and write it to a temporary file as the WASM stdin (the
- Results are sent back: findings from this execution are reported to the controller (which applies the 404-baseline false-positive gate and deduplication), logs go to the task log, and request counts are counted toward progress.
func main() is the entry point. The host will not call any other function of yours, and you do not need to read anything other than stdin — the packet JSON is already in stdin and scan.LoadFlow() has read it and populated scan.Flow for you. Inside main you just read values from scan.Flow.* and call scan.* to send requests / report findings.
Every execution is a brand-new instance. The host creates a new module instance for each packet, so wasm global variables are reset. Therefore: the Cookie session within one plugin instance spans its entire batch (it comes from the Cookie session the host maintains for the plugin instance, not from wasm global state); but package-level variables you define in wasm are not preserved when the next packet executes.
4. Directory Structure and meta.yaml / sig.json
poc/<lang>/wasm/<VulnIde>/
├── source/ # 编译前源码(多文件,含 SDK 文件)
├── output.wasm # 编译产物(WASI 模块,必须导出 _start)
├── meta.yaml # 漏洞元数据(多语言)
├── sig.json # 签名信息
├── plugin.config.json # 可选:插件配置(Key/Value),供 ConfigGet 读取
└── build_history.json # 构建/签名历史(最多 100 条,工具生成)
What each file is for:
| File / directory | Purpose | Required |
|---|---|---|
source/ | Human-readable source (Go/C/C++/Rust/TinyGo all acceptable), distributed with the package for auditing | Recommended |
output.wasm | The file that is actually loaded and executed; _start is the entry point | Required |
meta.yaml | Vulnerability metadata; after loading it becomes a vulnerability entry with PocType="wasm" | Required |
sig.json | Signature and verification status; without a valid signature it is not executed | Required |
plugin.config.json | Key/value configuration, read inside the plugin via scan.ConfigGet(key) | Optional |
4.1 meta.yaml Field-by-Field Table
It shares the same multilingual convention with YAML POCs: Languages + DefaultLanguage are the single source of truth, while flat fields (Name/Description, etc.) are runtime-parsed fields. For every field the table below gives "type / required / meaning / example value / consequence of a mistake".
| Field | Type | Required | Meaning | Example value | Consequence of a mistake |
|---|---|---|---|---|---|
VulnIde | string | Yes | Unique vulnerability identifier, globally unique | 048d825b807f4215a705d42a18dba527 | Colliding with another vulnerability → overwrite/dedup anomalies; if empty it falls back to the directory name and may not match the controller index |
Name | string | Yes | Vulnerability name (flat field, can be overridden by Languages) | WASM-go-反射型XSS检测 | If left blank, the vulnerability list/card name is empty |
Languages | map | No | Multilingual text map (key → text), the single source of truth | {cn: {...}, en: {...}} | Keys that do not match DefaultLanguage fall back to flat fields; providing only Chinese makes the English UI show Chinese |
DefaultLanguage | string | No | Default language key (e.g. cn / en) | cn | Falls back to cn when missing; a non-existent language key cannot resolve text |
Level | string | Yes | Severity level: critical / high / medium / low | high | Illegal values such as High/严重 → level mapping fails and the vulnerability card shows an abnormal level |
PocType | string | Yes | Fixed to wasm; decides which execution path is taken | wasm | A wrong value (e.g. yaml) → it never enters the WASM execution chain at all |
CVEId | string | No | CVE identifier | CVE-2021-44228 | No impact (may be empty) |
CweId | string | No | CWE identifier | CWE-79 | No impact (may be empty) |
CvssScore | string | No | CVSS score | 9.8 | No impact (may be empty) |
CvssVector | string | No | CVSS vector | AV:N/AC:L/... | No impact (may be empty) |
SourceLanguage | string | Yes | Source language go / c / cpp / rust / tinygo; the build service selects the toolchain from it | go | Mismatch with the actual language in source/ → build failure or unusable artifact |
Description | string | No | Vulnerability description | 反射型 XSS 载荷回显检测 | If blank, the detail page has no description |
Fingerprint | string | No | Fingerprint | — | No impact |
AffectedProducts | string | No | Affected products | — | No impact |
References | string | No | Reference links | — | No impact |
Solution | string | No | Remediation advice | — | No impact |
Confidence | int | No | Confidence (0~100) | 80 | Out-of-range values may be displayed as anomalies |
Author | string | No | Author | TestSecScan | No impact |
Enabled | bool | No | Whether the template is enabled | true | If false, the template is not dispatched |
Minimal usable meta.yaml:
VulnIde: "048d825b807f4215a705d42a18dba527"
Name: "WASM-go-反射型XSS检测"
Level: "medium"
PocType: "wasm"
SourceLanguage: "go"
Description: "反射型 XSS 载荷回显检测"
Enabled: true
Author: "TestSecScan"
Confidence: 80
TestDepth / Verification / AddTime and the like are extra fields on the build/controller side (a real marketplace meta.yaml carries them). A third-party hand-written template only needs the minimal set in the table above; when in doubt, take the meta.yaml exported by the build service as the reference.
4.2 sig.json Field-by-Field Table
| Field | Type | Required | Meaning | Example value | Consequence of a mistake |
|---|---|---|---|---|---|
userPubkey | string | Depends on the signing method | Public key for the private signature (base64, the controller-local controller_ed25519.pub) | MFkwEwYH... | Truncated / not base64 → verification verify_failed, not loaded |
userSignature | string | Depends on the signing method | Ed25519 signature (base64) over the output.wasm bytes made with the private key above | PZydTvki... | Change one character by hand → verification fails |
serverPubkey | string | Depends on the signing method | Public key for the official signature (base64, the POC marketplace public key TestSecScanPoc.pub) | +napZUOk... | The official path fails to verify, so it falls back to the private path or is judged a failure |
serverSignature | string | Depends on the signing method | Official Ed25519 signature over the output.wasm bytes | PZydTvki... | Same as above |
wasmHash | string | Recommended | SHA-256 hex digest of output.wasm | 2ce8970f6e30... | A mismatch with output.wasm means the artifact was swapped; the verification flow will fail |
signStatus | string | Yes | Verification status (see the table below) | verified | Anything other than verified/official → the node skips it directly |
updateTime | string | No | Signing/update time | 2026-08-15T21:05:34+08:00 | No impact |
Values of signStatus:
| Value | Meaning | Executable |
|---|---|---|
verified | Verification passed (either the official or the private path) | ✅ |
official | Verification came from the POC marketplace public key | ✅ |
unsigned | No signature | ❌ |
verify_failed | A signature exists but verification failed (treated as tampered) | ❌ |
Verification trusts only the public keys held by the host (official takes precedence over private); the public key bundled inside the package is never trusted. A self-signed key pair will be judged verify_failed and will not enter execution at all — it is not "seen but refused". Executing only verified is a hard gate. The decision values of signStatus are exactly verified / verify_failed / unsigned, and the scan node only lets verified through.
5. Quick Start (Go, Complete and Runnable)
5.1 Project Structure
my-wasm-poc/
├── go.mod
├── main.go
└── scan/
└── scan.go # 从官方 SDK 包里复制 scan.go 而来
go.mod:
module my-wasm-poc
go 1.22
require scan v0.0.0
replace scan => ./scan
The
scan/directory simply holds thescan.gofrom the official SDK package (it carries//go:build wasmand only participates in compilation whenGOARCH=wasm). Itspackage scanname is aligned with yourimport "scan"through thereplacedirective.
5.2 main.go
package main
import (
"strings"
"scan"
)
func main() {
// 1) 设置本插件超时(毫秒);不调用默认 30s
scan.SetTimeout(60000)
// 2) 必须先调用:从 stdin 读取当前流量包,填充全局 scan.Flow
scan.LoadFlow()
if scan.Flow.URL == "" {
return // 没有有效流量包,直接结束
}
scan.Log("当前目标: " + scan.Flow.URL)
// 3) 发起检测请求(自动 Cookie 会话、经任务代理、限时、响应上限 1MB)
resp := scan.HTTPGet(scan.Flow.URL)
if resp.Error != "" {
scan.Log("请求失败: " + resp.Error)
return
}
// 4) 命中判定
if strings.Contains(strings.ToLower(resp.Body), "root:x:0:0") {
scan.Report(scan.VulnFinding{
Name: "敏感文件泄露",
Detail: "响应中命中 /etc/passwd 特征",
Level: "high",
URL: scan.Flow.URL,
Evidence: resp.Body[:min(len(resp.Body), 500)],
})
}
}
5.3 Build
GOOS=wasip1 GOARCH=wasm go build -o output.wasm .
The artifact must be a WASI command module and export _start (Go's package main provides it automatically). Do not use -buildmode=c-shared — that is how application plugins are built.
5.4 Placement and Signing
Place output.wasm / meta.yaml / sig.json according to the directory structure in Chapter 4, then complete signing through the GUI "Sign" entry or the TestSecVulnService build service, so that sig.json.signStatus becomes verified. Unsigned artifacts are not loaded by the scan node.
5.5 Debugging
- In the POC "Test Verification / Debug" panel of the GUI, choose a target URL or a packet and run it; you will see the
scan.Logoutput, the findings fromscan.Report, and the real requests/responses; - The debug panel is equivalent to the scan node running a single-packet execution with one
Flow, so the results match production. For detailed steps see Chapter 10, "Debugging Handbook".
6. Public API: Function-by-Function Guide
All symbols below come from the scan.go in the official SDK package. Each section gives, in order: what it does → signature → parameter table → return → code snippet → notes. The snippets can be copied directly into your POC project.
6.1 Types and Globals
Flow
What it does: the Flow type describes "the single packet currently being fed in". It is the input of the passive scanning model, and you must call scan.LoadFlow() before using any field. The SDK also declares a package-level global variable with the same name, var Flow Flow, so you read it as scan.Flow.XXX.
Signature:
type Flow struct {
URL string `json:"url"`
Method string `json:"method"`
Host string `json:"host"`
Scheme string `json:"scheme"`
Path string `json:"path"`
Query string `json:"query"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
RawHeaders string `json:"rawHeaders"`
}
Field table:
| Field | Type | Meaning | Where it comes from / format | How to use it |
|---|---|---|---|---|
URL | string | Full request URL | The host orchestration side serializes the packet to JSON and writes it to stdin; field name url | Most common: use it directly as the detection target scan.Flow.URL; if empty, no packet was received |
Method | string | Request method | Same JSON, field name method, value GET/POST/PUT… uppercase | Replicate the original request method, e.g. scan.HTTP(scan.Flow.Method, u, "") |
Host | string | Target host name | JSON field host, in the form example.com:8080 (may include a port) | Build virtual host headers, filter by domain |
Scheme | string | Scheme | JSON field scheme, value http or https | Build URLs, test whether it is https |
Path | string | Request path | JSON field path, starts with /, excludes the query string | Replace paths, filter by suffix (e.g. only handle .php) |
Query | string | Query parameters | JSON field query, without the leading ? | When appending parameters, decide between ? and & |
Headers | map[string]string | Request headers | JSON field headers, keys are already lowercased | Read scan.Flow.Headers["cookie"], ["content-type"]; remember the keys are lowercase |
Body | string | Request body | JSON field body, raw text (may be a form or JSON) | Extract parameter values from a POST body and rewrite them |
RawHeaders | string | Raw request header text | JSON field rawHeaders, multi-line text (each line Name: Value) | Use when the original casing/order must be kept; use Headers in ordinary cases |
Code snippet:
scan.LoadFlow()
// 判空:stdin 没数据或流量包为空时直接结束,避免后续空指针式逻辑
if scan.Flow.URL == "" {
scan.Log("没有拿到流量包")
return
}
// 组合使用各字段
scan.Log("方法: " + scan.Flow.Method)
scan.Log("协议: " + scan.Flow.Scheme + " 主机: " + scan.Flow.Host)
scan.Log("路径: " + scan.Flow.Path + " 查询: " + scan.Flow.Query)
// Headers 的 key 是小写
if ct := scan.Flow.Headers["content-type"]; ct != "" {
scan.Log("Content-Type: " + ct)
}
// 遍历所有请求头
for k, v := range scan.Flow.Headers {
scan.Log(k + ": " + v)
}
Notes:
- If you do not call
LoadFlow(), every field is empty —scan.Flow.URL == "", and every later request goes nowhere or to an empty URL. This is the most common trap. - The
Headerskeys are lowercase;scan.Flow.Headers["Cookie"]returns nothing. - The global variable shares the type's name (both are
Flow) and is the package-levelscan.Flowvariable;LoadFlow()reads stdin once and overwrites it each time.
HTTPResponse
What it does: the return structure of scan.HTTP / scan.HTTPGet / scan.HTTPPost / scan.HTTPJSONPost, carrying the result of one HTTP request. It is what you use to judge whether a request succeeded and to run feature matching against the response body.
Signature:
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
Cookies []string `json:"cookies"`
URL string `json:"url"`
TimeMs int64 `json:"timeMs"`
Error string `json:"error"`
}
Field table:
| Field | Type | Meaning | How to use it |
|---|---|---|---|
StatusCode | int | HTTP status code; 0 means the request never succeeded | Check Error == "" first, then whether the code equals the expected value (e.g. 200) |
Body | string | Response body text; the host reads at most 1MB and discards the rest | Use for keyword/regex/JSON checks, e.g. strings.Contains(resp.Body, ...) |
Headers | map[string]string | Response headers, lowercase keys, multiple values joined with , | resp.Headers["content-type"], resp.Headers["server"] |
Cookies | []string | List of raw Set-Cookie strings from the response | Iterate when you need an explicit cookie value; the session is maintained automatically by the host |
URL | string | Final request URL (including the address after redirects) | Detect whether it was redirected to a login page |
TimeMs | int64 | Duration of this request (milliseconds) | Time-based blind injection decisions, performance logging |
Error | string | Failure reason; empty string on success | Always check resp.Error == "" before checking the status code |
Code snippet:
resp := scan.HTTPGet(scan.Flow.URL)
// 1) 先判错误:网络失败/构造失败时 StatusCode 为 0、Error 非空
if resp.Error != "" {
scan.Log("请求失败: " + resp.Error)
return
}
// 2) 再看状态码
scan.Log("状态码: " + scan.TimestampToStr(resp.TimeMs, true)) // 注意:时间格式化见 TimestampToStr
if resp.StatusCode != 200 {
scan.Log("非 200,放弃")
return
}
// 3) 取响应头(key 小写)
ct := resp.Headers["content-type"]
scan.Log("content-type: " + ct)
// 4) 做判定
if strings.Contains(strings.ToLower(resp.Body), "root:x:0:0") {
scan.Report(scan.VulnFinding{Name: "疑似命令执行", Level: "high", URL: resp.URL})
}
Notes:
- Reading
Bodywithout checkingresp.Error: when the connection fails,Bodyis an empty string, which is easily misread as "no vulnerability", causing a missed report or silence. - The response body limit is 1MB, so trailing features may be unavailable; keep checks near the beginning of the body, or switch to streaming-friendly features.
Headerskeys are lowercase and multiple values are merged into a single string, so do not treat them as arrays.
VulnFinding
What it does: the reporting structure used by scan.Report. Construct it and report when a vulnerability is hit, and the GUI generates a vulnerability card; the three fields such as SensitiveText are dedicated to highlighting "sensitive information discovery" findings.
Signature:
type VulnFinding struct {
Name string `json:"name"`
Detail string `json:"detail"`
Evidence string `json:"evidence"`
Level string `json:"level"`
URL string `json:"url"`
CVEId string `json:"cveId"`
Request string `json:"request"`
Response string `json:"response"`
// 敏感信息高亮(vuln-000007 专用,GUI 纯文本查看器按关键字高亮)
SensitiveText string `json:"sensitiveText"` // 原始文本片段(命中内容前后各 200 字符)
SensitiveKeywords []string `json:"sensitiveKeywords"` // 实际命中的关键字列表(GUI 高亮用)
SensitiveMatchRule string `json:"sensitiveMatchRule"` // 实际匹配的公式/规则
}
Field table:
| Field | Type | Meaning | How to use it |
|---|---|---|---|
Name | string | Vulnerability name (card title) | Fill in a Chinese default name or use scan.T("vuln_name") to read from the language pack; leaving it blank produces a blank card |
Detail | string | Vulnerability detail (card body) | Explain the evidence hit and the decision logic |
Evidence | string | Evidence of the hit (response snippet / packet text) | Truncating to a few hundred characters is recommended, e.g. resp.Body[:min(len(resp.Body), 500)] |
Level | string | Level: critical / high / medium / low | The value must be exact; if left blank it falls back to the template's meta.yaml.Level |
URL | string | Hit URL | If left blank, the current packet's Flow.URL is used; when detecting across targets it is better to fill it explicitly |
CVEId | string | Optional CVE identifier | If blank, the template metadata is used |
Request | string | Optional: the triggering request message (text) | Lets the GUI display the raw request |
Response | string | Optional: the hit response message (text) | Lets the GUI display the raw response; truncation is recommended |
SensitiveText | string | Sensitive information highlight: raw text snippet | For sensitive-information findings only; take 200 characters before and after the hit |
SensitiveKeywords | []string | Sensitive information highlight: list of keywords hit | The GUI plain-text viewer highlights the hit positions from it |
SensitiveMatchRule | string | Sensitive information highlight: the formula/rule matched | Shows the basis of the decision |
Code snippet:
resp := scan.HTTPGet(scan.Flow.URL)
if resp.Error == "" && strings.Contains(resp.Body, "AKIA") {
evidence := resp.Body
if len(evidence) > 500 {
evidence = evidence[:500]
}
scan.Report(scan.VulnFinding{
Name: "云密钥泄露",
Detail: "响应体中出现疑似 AWS Access Key",
Evidence: evidence, // 截断后的证据
Level: "high", // 只能是 critical/high/medium/low
URL: resp.URL, // 留空也会回退 Flow.URL
Request: scan.Flow.Method + " " + scan.Flow.URL,
Response: evidence,
// 敏感信息三件套(普通漏洞可留空)
SensitiveText: resp.Body,
SensitiveKeywords: []string{"AKIA"},
SensitiveMatchRule: "strings.Contains(resp.Body, \"AKIA\")",
})
}
Notes:
- A wrong
Level(such asHighor高危) makes level mapping fail and the card level abnormal; always use the four lowercase English values. - If
Name/Detailare left blank, the card title/body is empty and it looks like "something was reported but there is no content". Evidence/Responsewithout length truncation inflate the report payload; truncate them consistently.
6.2 Host API
LoadFlow()
What it does: reads the current packet JSON from stdin and populates the global scan.Flow; this is the only entry point through which the plugin and the host exchange input, and it must be called before reading scan.Flow.*.
Signature:
func LoadFlow()
Parameter table: none.
Return: none. It writes the parse result directly into the package-level variable scan.Flow (on parse failure, Flow keeps its zero value).
Code snippet:
func main() {
scan.LoadFlow()
if scan.Flow.URL == "" {
return // 空包直接退出,避免后续对空 URL 发请求
}
scan.Log("收到流量包: " + scan.Flow.Method + " " + scan.Flow.URL)
}
Notes:
- It must be the very first thing you call; if you omit it,
scan.Flowis entirely zero-valued and the detection logic silently fails. - Calling it repeatedly within the same instance overwrites
Flow(usually unnecessary). - It reads stdin only and does not read environment variables —
SCAN_*is the fallback the host provides, and the SDK does not parse it for you.
SetTimeout(ms int64)
What it does: dynamically sets the execution timeout for this plugin (this batch), replacing the default 30s. Suited to plugins that need long polling or multi-step requests.
Signature:
func SetTimeout(ms int64)
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
ms | int64 | Maximum runtime of this plugin in milliseconds; must be > 0 | Estimate it yourself from the detection complexity | 60000 (60 seconds) |
Return: none.
Code snippet:
func main() {
scan.SetTimeout(60000) // 本插件最多运行 60 秒(不调用默认 30s)
scan.LoadFlow()
// ... 多步登录 + 检测
}
Notes:
- What it resets is the timeout timer of the entire batch, not one per packet; leave enough total time within the batch.
- Passing
0or a negative number is ignored by the host, which still uses the default 30s. - Setting it too large while the plugin hangs occupies that plugin's batch time for a long while and eats into the task's time budget.
T(key string) string
What it does: reads the language-pack text of the current plugin in the current language; used to make UI/log strings bilingual (part of the four-component language packs).
Signature:
func T(key string) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
key | string | Key in the language pack (without the plugin ID prefix) | Your language pack file | vuln_name |
Return: string. Internally the host assembles the key as <current language>.<plugin ID>.<key> and looks it up; if it is not found, the host falls back to returning key itself (so a failure does not raise an error — it just displays the raw key name).
Code snippet:
scan.Report(scan.VulnFinding{
Name: scan.T("vuln_name"), // 命中语言包 → "反射型XSS"; 未配置 → "vuln_name"
Detail: scan.T("vuln_detail"),
Level: "high",
URL: scan.Flow.URL,
})
Notes:
- Pass only
key— do not pass the plugin ID or the language; the host already prefixes<language>.<plugin ID>.automatically. - When the language pack is missing, what is returned is the key name itself (e.g.
vuln_name), not an empty string; do not treat it as "having a value". - The key space includes the plugin ID, so keys with the same name in different plugins never collide.
ConfigGet(key string) string
What it does: reads a configuration value of the current plugin; used to pull tunable parameters (switches, thresholds) out of the code.
Signature:
func ConfigGet(key string) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
key | string | Key of the configuration entry | plugin.config.json (Key/Value; values are all strings) | enable_deep |
Return: string. If the key does not exist or the configuration is empty, an empty string is returned.
Code snippet:
if scan.ConfigGet("enable_deep") == "1" {
scan.Log("深度检测已开启")
for _, p := range []string{"1", "2", "3"} {
r := scan.HTTPGet(scan.Flow.URL + "?id=" + p)
_ = r
}
}
depth := scan.ConfigGet("depth") // 取不到就是 "",自行给默认值
if depth == "" {
depth = "5"
}
Notes:
- If the value cannot be read, an empty string is returned and no error is raised; test for the empty string and supply your own default.
- Configuration values are all strings, so numbers must be converted yourself (e.g.
== "1"rather than== 1). - The host reads the configuration when the plugin is loaded; when running a single execution from the debug panel without a packaged configuration file, the value may not be readable.
HTTP(method, url, body string) HTTPResponse
What it does: the only outlet through which a plugin accesses the network. Use it when you need something other than GET, or a custom method/request body; underneath it goes through the task proxy, with time and size limits.
Signature:
func HTTP(method, url, body string) HTTPResponse
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
method | string | HTTP method (uppercase) | You specify it, or use scan.Flow.Method | "POST" |
url | string | Full target URL (with scheme) | You build it, or base it on scan.Flow.URL | "https://a.com/api" |
body | string | Raw request body text; pass "" for GET | You build it | {"user":"admin"} |
Return: HTTPResponse (field by field in 6.1). On request failure, StatusCode=0, Error is non-empty and Body is empty.
Code snippet:
resp := scan.HTTP("POST", scan.Flow.URL, `{"user":"admin","pwd":"123456"}`)
if resp.Error != "" {
scan.Log("请求失败: " + resp.Error)
return
}
if resp.StatusCode == 200 && strings.Contains(resp.Body, "token") {
scan.Report(scan.VulnFinding{
Name: "登录接口弱口令", Level: "high", URL: scan.Flow.URL,
Evidence: resp.Body[:min(len(resp.Body), 300)],
Request: "POST " + scan.Flow.URL,
Response: resp.Body[:min(len(resp.Body), 300)],
})
}
Notes:
- Custom request headers are not possible — neither the SDK nor the host provides an entry point for setting headers. When a specific header is required, put the information into the URL/query string or the request body.
- The response body limit is 1MB; anything beyond is truncated.
- When the method name is invalid or the URL cannot be parsed, an
Erroris returned instead of a panic — always check for errors.
HTTPGet(url string) HTTPResponse
What it does: a convenience wrapper for GET requests, equivalent to HTTP("GET", url, ""). The most commonly used probing method.
Signature:
func HTTPGet(url string) HTTPResponse
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
url | string | Full target URL | You build it | "https://a.com/robots.txt" |
Return: HTTPResponse, the same as HTTP.
Code snippet:
resp := scan.HTTPGet(scan.Flow.URL + "/.git/config")
if resp.Error == "" && resp.StatusCode == 200 && strings.Contains(resp.Body, "[core]") {
scan.Report(scan.VulnFinding{Name: "Git 泄露", Level: "high", URL: scan.Flow.URL})
}
Notes:
- It follows redirects automatically (at most 10 times), and
resp.URLis the final address. - It is equivalent to
body="", so do not expect it to send a POST. - There is no TLS-skip option; a self-signed target shows up through
resp.Error.
HTTPPost(url, body string) HTTPResponse
What it does: a convenience wrapper for POST requests, equivalent to HTTP("POST", url, body).
Signature:
func HTTPPost(url, body string) HTTPResponse
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
url | string | Full target URL | You build it | "https://a.com/login" |
body | string | Raw request body | You build it | user=admin&pwd=admin |
Return: HTTPResponse, the same as HTTP.
Code snippet:
resp := scan.HTTPPost(scan.Flow.URL, "user=admin&pwd=admin")
if resp.Error == "" && resp.StatusCode == 200 {
scan.Log("POST 完成, 长度=" + scan.TimestampToStr(int64(len(resp.Body)), false))
}
Notes:
- The host does not add
Content-Typeautomatically; form/JSON encoding must be expressed by you in the body. - It is likewise part of the automatic Cookie session, so the login state is preserved.
- When the body is empty it degenerates to an empty-body POST — be aware of the difference.
HTTPJSONPost(url, body string) HTTPResponse
What it does: a semantic "POST JSON" convenience call that makes code more readable.
Signature:
func HTTPJSONPost(url, body string) HTTPResponse
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
url | string | Full target URL | You build it | "https://a.com/api/user" |
body | string | JSON text | You build it | {"id":1} |
Return: HTTPResponse, the same as HTTP.
Code snippet:
resp := scan.HTTPJSONPost(scan.Flow.URL, `{"id":1}`)
if resp.Error == "" && strings.Contains(strings.ToLower(resp.Headers["content-type"]), "json") {
user := scan.JSONGet([]byte(resp.Body), "data.username")
scan.Log("拿到用户名: " + user)
}
Notes:
- It is equivalent to
HTTPPostin implementation: the host does not additionally inject aContent-Type: application/jsonheader (the SDK comment describes it that way, but the host does not set that header automatically). If the target insists on that header, verify it yourself or switch to a form that can express it. - The limitation on custom headers applies here as well.
- Return handling is exactly the same as
HTTP.
Log(msg string)
What it does: outputs a debug log line, visible in the GUI debug panel. The most direct way to find out "whether the plugin ran at all and how far it got".
Signature:
func Log(msg string)
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
msg | string | Any readable text | You build it | "开始检测: " + scan.Flow.URL |
Return: none. Logs are collected by the host and sent upward with the result of this execution to the task log and the debug panel.
Code snippet:
scan.Log("=== 开始检测 ===")
scan.Log("目标: " + scan.Flow.URL)
resp := scan.HTTPGet(scan.Flow.URL)
scan.Log("状态码: " + scan.TimestampToStr(int64(resp.StatusCode), false))
scan.Log("响应长度: " + scan.TimestampToStr(int64(len(resp.Body)), false))
Notes:
- Logs accumulate per batch and are reset before each single-packet execution; multiple calls within the same execution are all kept.
- Do not emit huge numbers of log lines inside very high-frequency loops — it slows things down and floods the log panel.
- Log content is plain text, and embedded newlines are rendered as-is; single-line short messages are recommended.
Report(f VulnFinding)
What it does: reports one vulnerability finding (internally serialized to JSON and passed to scan_report). The final action when you hit something.
Signature:
func Report(f VulnFinding)
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
f | VulnFinding | A filled-in vulnerability structure | You build it (fields in 6.1) | scan.VulnFinding{Name:"XSS", Level:"high"} |
Return: none. After a successful parse, the host appends it to this execution's finding list and sends it upward; a JSON parse failure is silently dropped.
Code snippet:
// 一次执行可以上报多条
scan.Report(scan.VulnFinding{Name: "反射型XSS", Detail: "参数回显", Level: "medium", URL: scan.Flow.URL})
scan.Report(scan.VulnFinding{Name: "SQL注入", Detail: "报错回显", Level: "high", URL: scan.Flow.URL})
Notes:
- The fields of
Reportmust serialize to JSON properly; the field values themselves are fine, but a semantically wrongLevelis not caught here — it affects how the card is displayed. - Reporting several findings from the same execution produces several cards/detail entries; keep deduplication semantics in mind (the host deduplicates by vulnerability hash).
- If you hit something but
Nameis empty, the card title is blank — always fill in the name.
6.3 Built-in Function Wrappers
All wrappers below are implemented by the scan node itself (the host's built-in subset of safe functions); the plugin only passes arguments, so the compiled size is smaller and behavior is consistent. Unless stated otherwise, a failed underlying call always returns an empty string — no panic, no error.
Base64Encode(data string) string
- What it does: Base64 encoding; used to build Basic authentication headers and to turn binary data into text that fits in URLs/JSON.
- Parameter:
datastring, the raw text to encode; example"admin:admin". - Return: string, the encoded result; an empty input or a failed underlying call returns an empty string.
enc := scan.Base64Encode("admin:admin") // "YWRtaW46YWRtaW4="
scan.Log("Authorization: Basic " + enc)
- Note: a failed underlying call returns an empty string (no error), so do not use it as proof that there is content.
Base64Decode(data string) string
- What it does: Base64 decoding; restores encoded payloads/credentials.
- Parameter:
datastring, valid Base64 text; example"YWRtaW46YWRtaW4=". - Return: string, the decoded plain text; invalid input returns an empty string.
plain := scan.Base64Decode("YWRtaW46YWRtaW4=") // "admin:admin"
if plain == "" {
scan.Log("解码失败或原文为空")
}
- Note: a decode failure and "the plain text was empty to begin with" both return an empty string and cannot be distinguished.
HexEncode(data string) string
- What it does: converts a byte string into hexadecimal text (no spaces), which makes binary data easy to compare/display.
- Parameter:
datastring, any byte string. - Return: string, a lowercase hexadecimal string; an empty string on failure.
scan.Log(scan.HexEncode("AB")) // "4142"
- Note: encoding works per byte, so a Chinese character becomes 6 hexadecimal characters rather than 2.
HexDecode(hex string) string
- What it does: restores hexadecimal text to a byte string.
- Parameter:
hexstring, hexadecimal text of even length. - Return: string, the restored byte string; an odd length or illegal characters return an empty string.
scan.Log(scan.HexDecode("4142")) // "AB"
- Note: the length must be even, otherwise decoding fails and returns an empty string.
URLEncode(input string) string
- What it does: URL encoding; puts parameters/payloads safely into a query string or path.
- Parameter:
inputstring, the raw text. - Return: string, the encoded result; an empty string on failure.
scan.Log(scan.URLEncode("a b&c")) // "a+b%26c"
u := scan.Flow.URL + "?q=" + scan.URLEncode("'; SELECT 1--")
- Note: spaces are encoded as
+, while some targets expect%20— be aware of the difference.
URLDecode(input string) string
- What it does: URL decoding; restores encoded parameters from responses/requests.
- Parameter:
inputstring, URL-encoded text. - Return: string, the decoded result; an empty string on failure.
scan.Log(scan.URLDecode("a%20b%26c")) // "a b&c"
- Note: an illegal percent sequence fails and returns an empty string.
HTMLEncode(data string) string
- What it does: HTML entity encoding; builds XSS payloads or display text that needs escaping.
- Parameter:
datastring, the raw text. - Return: string, the entity-encoded result; an empty string on failure.
scan.Log(scan.HTMLEncode("<script>")) // "<script>"
- Note: this is entity encoding — do not mistake it for URL encoding.
HTMLDecode(data string) string
- What it does: HTML entity decoding; restores entity text in a response before matching.
- Parameter:
datastring, text containing HTML entities. - Return: string, the decoded result; an empty string on failure.
scan.Log(scan.HTMLDecode("<title>")) // "<title>"
- Note: decoding before matching avoids missed detections caused by entity-based bypasses.
CharsetConvert(data, targetEncoding string, autoDetect bool) string
- What it does: character encoding conversion (for example GBK responses to UTF-8); common with Chinese-language targets.
- Parameters:
datastring, the bytes to convert;targetEncodingstring, the target encoding (e.g."UTF-8");autoDetectbool, whether to auto-detect the source encoding. - Return: string, the converted text; an empty string on failure.
body := scan.CharsetConvert(resp.Body, "UTF-8", true)
if strings.Contains(body, "管理后台") {
scan.Log("命中中文关键字(已转码)")
}
- Note: passing
trueforautoDetectis easier; a wrong source-encoding detection produces garbled text, so you canscan.Logit first before making a decision.
MD5(data string) string
- What it does: computes MD5 (lowercase hexadecimal) for fingerprint/signature matching.
- Parameter:
datastring, the raw text to hash. - Return: string, a 32-character lowercase hexadecimal string; an empty string on failure.
if scan.MD5(resp.Body) == "098f6bcd4621d373cade4e832627b4f6" {
scan.Log("MD5 指纹命中")
}
- Note: keep the case consistent when comparing (the SDK outputs lowercase).
SHA1(data string) string
- What it does: computes SHA1, used by some fingerprint/signature checks.
- Parameter:
datastring. - Return: string, 40 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA1("hello"))
- Note: the output is lowercase hexadecimal;
SHA1is no longer suitable where security strength matters and is only used to match existing fingerprints.
SHA224(data string) string
- What it does: computes SHA224.
- Parameter:
datastring. - Return: string, 56 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA224("hello"))
- Note: the output is lowercase and 56 characters long.
SHA256(data string) string
- What it does: computes SHA256, the most commonly used content fingerprint.
- Parameter:
datastring. - Return: string, 64 hexadecimal characters; an empty string on failure.
sum := scan.SHA256(resp.Body)
scan.Log("SHA256=" + sum)
- Note: this is a different concept from
WasmHash(the host's SHA-256 overoutput.wasm) — do not confuse them.
SHA384(data string) string
- What it does: computes SHA384.
- Parameter:
datastring. - Return: string, 96 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA384("hello"))
- Note: the output is lowercase hexadecimal.
SHA512(data string) string
- What it does: computes SHA512.
- Parameter:
datastring. - Return: string, 128 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA512("hello"))
- Note: the output is lowercase hexadecimal.
CRC32(data string) string
- What it does: computes a CRC32 checksum (hexadecimal) for lightweight consistency checks.
- Parameter:
datastring. - Return: string, the hexadecimal checksum; an empty string on failure.
scan.Log(scan.CRC32("hello"))
- Note: CRC is not a cryptographic hash and must not be used for security verification.
CRC64(data string) string
- What it does: computes a CRC64 checksum (hexadecimal).
- Parameter:
datastring. - Return: string, the hexadecimal checksum; an empty string on failure.
scan.Log(scan.CRC64("hello"))
- Note: like
CRC32, it is a non-cryptographic checksum.
RandStr(mode, length int) string
- What it does: generates a random string; used to build unique markers or payloads for blind (no-echo) probing.
- Parameters:
modeint, bit flags (1 lowercase / 2 digits / 4 uppercase / 8 special; combinable);lengthint, the length. - Return: string, a random string; an empty string on failure.
marker := scan.RandStr(2|4, 12) // 数字+大写,12 位
scan.Log("本次标记: " + marker)
resp := scan.HTTPGet(scan.Flow.URL + "?cb=" + marker)
if strings.Contains(resp.Body, marker) {
scan.Report(scan.VulnFinding{Name: "参数回显", Level: "low", URL: scan.Flow.URL})
}
- Note:
modeis a bitwise OR combination; passing0may yield an empty string, and an excessive length may be limited downstream.
LeftOf(s, sep string) string
- What it does: takes the content on the left of a separator (including how the original text is handled when there is no match); extracts fields from a response.
- Parameters:
sstring, the source text;sepstring, the separator (e.g."\"token\":\""). - Return: string, the content to the left of the separator; what happens when the separator is absent is up to the underlying implementation (may return an empty string or the original text).
token := scan.LeftOf(resp.Body, "\"token\":\"")
scan.Log("token 前缀: " + token)
- Note: the argument order is
(s, sep)— do not swap it; whensepcontains quotes/escapes, write them out in full.
RightOf(s, sep string) string
- What it does: takes the content on the right of a separator.
- Parameters:
sstring, the source text;sepstring, the separator. - Return: string, the content to the right of the separator.
val := scan.RightOf(resp.Body, "\"token\":\"")
scan.Log("token 之后: " + val)
- Note: it splits at the first match; with multiple matches the result may not be the segment you expect.
MiddleOf(s, left, right string) string
- What it does: takes the content between two markers; the most commonly used extraction method.
- Parameters:
sstring, the source text;leftstring, the left marker;rightstring, the right marker. - Return: string, the content between the two markers; if either marker is missing it usually returns an empty string.
title := scan.MiddleOf(resp.Body, "<title>", "</title>")
scan.Log("页面标题: " + title)
- Note: it searches for the left marker and then the right marker in order; multi-line/nested cases must be handled by yourself.
TextBetween(source, start, end string, startPosition int, offset string, fallback bool) (int, string)
What it does: a more fine-grained "take content between two texts" than MiddleOf; you can specify the starting search position and an offset, and it returns the position as well.
Signature:
func TextBetween(source, start, end string, startPosition int, offset string, fallback bool) (int, string)
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
source | string | Source text | resp.Body and the like | <title>首页</title> |
start | string | Start marker | You specify it | "<title>" |
end | string | End marker | You specify it | "</title>" |
startPosition | int | Byte offset in the source text where the search starts | You specify it (pass 0 the first time) | 0 |
offset | string | Result offset (in string form) | You specify it | "" |
fallback | bool | Whether to fall back to the source text when not found | You specify it | true |
Return: (int, string) — two return values: pos is the hit position (int) and text is the extracted content (string). You must accept both, or at least ignore one with _. On failure text is usually empty and pos is 0 or -1 (depending on the underlying implementation).
Code snippet:
// 两个返回值:pos 是位置(int),text 是内容(string)
pos, text := scan.TextBetween(resp.Body, "<title>", "</title>", 0, "", true)
scan.Log(text)
scan.Log("位置: " + scan.TimestampToStr(int64(pos), false)) // 将 int 转成字符串展示
// 只关心内容时用 _ 忽略位置
_, title := scan.TextBetween(resp.Body, "<title>", "</title>", 0, "", true)
if title != "" {
scan.Report(scan.VulnFinding{Name: "标题回显", Level: "low", URL: scan.Flow.URL, Evidence: title})
}
Notes:
- Do not accept only one of the two return values:
pos, text := ...is the correct form; writingtext := scan.TextBetween(...)will not compile. posis an int, so it must be converted before it can go into a string log (as withTimestampToStrabove).- With
fallback=true, the original text may also be returned when nothing is found, so before making a decision it is best to confirm thattextreally lies between the markers.
JSONGet(data []byte, path string) string
What it does: extracts a string value from a JSON response by path; the most convenient way to make field-level decisions.
Signature:
func JSONGet(data []byte, path string) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
data | []byte | Raw JSON bytes | []byte(resp.Body) | []byte(resp.Body) |
path | string | Path expression; supports a.b[0].c | You write it according to the response structure | "data.user.name" |
Return: string, the value obtained (stringified); if the path does not exist or cannot be resolved, an empty string is returned.
Code snippet:
// 注意第一个参数是 []byte,要显式转换
name := scan.JSONGet([]byte(resp.Body), "data.user.name")
if name == "" {
// 换个可能的路径再试
name = scan.JSONGet([]byte(resp.Body), "user.name")
}
if name != "" {
scan.Report(scan.VulnFinding{Name: "未授权读取用户数据", Level: "high", URL: scan.Flow.URL, Evidence: name})
}
// 数组下标语法 a.b[0].c
first := scan.JSONGet([]byte(resp.Body), "data.items[0].id")
scan.Log("首个 id: " + first)
Notes:
- The first parameter is
[]byteand cannot takeresp.Body(a string) directly; use[]byte(resp.Body). - A non-existent path returns an empty string without raising an error; testing the empty string is enough, but keep several candidate paths as a fallback.
- The path syntax supports dots and
[n]indices, e.g.a.b[0].c; field names containing special characters may not resolve.
JSONGetRaw(data []byte, path string) string
What it does: extracts a raw value from JSON by path (preserving JSON literals such as objects/arrays/quoted strings); suited to pulling out a sub-structure as-is.
Signature:
func JSONGetRaw(data []byte, path string) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
data | []byte | Raw JSON bytes | []byte(resp.Body) | []byte(resp.Body) |
path | string | Path expression | You write it | "data.items" |
Return: string, the raw JSON fragment (objects/arrays remain JSON text); an empty string when nothing is found (or when the underlying conversion fails).
Code snippet:
raw := scan.JSONGetRaw([]byte(resp.Body), "data.items")
scan.Log("items 原始值: " + raw)
if strings.HasPrefix(raw, "[") {
// 是数组,可以再用 JSONGet 取下标
first := scan.JSONGet([]byte(resp.Body), "data.items[0].id")
scan.Log("首个 id: " + first)
}
Notes:
- As with
JSONGet, the first parameter is[]byte. - What comes out is raw JSON text, so strings keep their quotes — be careful when testing.
- When nothing is found it mostly returns an empty string; do not
json.Unmarshalthe result without checking for emptiness first.
TimeToTimestamp(timeStr string, isMilli bool) int64
What it does: converts a time string into a timestamp (integer); used for time-based blind injection / time comparisons.
Signature:
func TimeToTimestamp(timeStr string, isMilli bool) int64
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
timeStr | string | Time string | You build it | "2026-10-05 12:00:00" |
isMilli | bool | Whether to output milliseconds | You specify it | false (seconds) |
Return: int64 timestamp; a parse failure returns 0 (the zero value when json.Unmarshal fails).
Code snippet:
ts := scan.TimeToTimestamp("2026-10-05 12:00:00", false)
scan.Log("时间戳(秒): " + scan.TimestampToStr(ts, false))
if ts == 0 {
scan.Log("时间解析失败")
}
Notes:
isMillidecides the unit; mixing seconds and milliseconds yields absurd differences.- A format mismatch returns 0, so use 0 to detect failure.
TimestampToStr(ts int64, isMilli bool) string
What it does: converts a timestamp into a readable time string; it is also often used temporarily to turn an integer into a string (as in the logging examples above).
Signature:
func TimestampToStr(ts int64, isMilli bool) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
ts | int64 | Timestamp | A TimeToTimestamp result or a time difference | 1759646400 |
isMilli | bool | Whether ts is in milliseconds | You specify it | true |
Return: string, the time string; an empty string on failure.
Code snippet:
ts := scan.TimeToTimestamp("2026-10-05 12:00:00", false)
scan.Log(scan.TimestampToStr(ts, true)) // 按毫秒解释 ts
// 也常用来把 int 拼进日志(非时间语义)
scan.Log("响应长度: " + scan.TimestampToStr(int64(len(resp.Body)), false))
Notes:
isMillimust match the actual unit ofts, otherwise the time is scrambled.- Using it for "int to string" is only a convenience; semantically it is a timestamp, so do not use it for strict formatting.
FormatTime(now string, param int) string
What it does: performs offset arithmetic on a given time (N days/hours/minutes/seconds forwards or backwards, etc.); the semantics of param are described in the host tools documentation.
Signature:
func FormatTime(now string, param int) string
Parameter table:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
now | string | Base time string | You build it | "2026-10-05 12:00:00" |
param | int | Offset parameter (encodes unit/direction) | You specify it according to your use case | 1 |
Return: string, the offset time string; an empty string on failure.
Code snippet:
next := scan.FormatTime("2026-10-05 12:00:00", 1)
scan.Log("偏移结果: " + next)
Notes:
- The semantics of
paramare defined by the host tools (days/hours/minutes, etc.); different values differ greatly, so do not guess — determine them from the documentation or experiments. - A mismatched time format returns an empty string.
7. Host Functions (env Namespace) Low-Level Table
Hand-written languages that do not use the Go SDK (Zig / C# / AssemblyScript, etc.) need to import the functions below directly. All string parameters are i32 (a linear-memory pointer + length), and the return value is the number of bytes written into the out buffer.
| Function | Parameters (all i32) | Return | Description |
|---|---|---|---|
scan_http | methodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, outPtr, outCap | Bytes written | Sends HTTP (automatic Cookie session); the response JSON is written into out |
scan_log | msgPtr, msgLen | — | Outputs one debug log line |
scan_report | findingPtr, findingLen | — | Reports a vulnerability finding (VulnFinding JSON) |
scan_call | funcID, argsPtr, argsLen, outPtr, outCap | Bytes written | Calls a built-in function (arguments are a JSON array; returns a JSON string) |
scan_t | keyPtr, keyLen, outPtr, outCap | Bytes written | Reads this plugin's language-pack text in the current language (key space <language>.<plugin ID>.<key>) |
scan_config | keyPtr, keyLen, outPtr, outCap | Bytes written | Reads this plugin's configuration value |
scan_set_timeout | ms | — | Sets this plugin's execution timeout (milliseconds); the default 30s applies when it is not called |
Why the plugin must pass "pointer + length" itself (WASI ABI)
WASM is a linear-memory sandbox: the memory of the host process and the memory of the WASM module are two completely isolated address spaces. When a host function receives an int, it neither knows whether it is text or a number nor can access any byte in the plugin's memory — unless the plugin tells it explicitly:
- Pointer: the start address of the string/bytes in WASM linear memory (i32);
- Length: how many bytes starting at that address make up this content.
Based on these, the host reads the content out of WASM linear memory by pointer and length, then decodes it as UTF-8 (the Go SDK wrappers automatically split a string into pointer + length). Likewise, the output buffer must be allocated in advance by the plugin (the Go SDK prepares 4KB for string-returning wrappers and 64KB for HTTP responses and built-in function calls); the plugin passes outPtr + outCap to the host, the host returns the actual number of bytes written n after writing, and the plugin takes out[:n]. If the content exceeds outCap, the host truncates it — which is also why the response body limit and the buffer size must match.
Hand-written C calling example (consistent with scan_sdk.h):
/* 底层导入声明:import_module("env") + import_name("scan_http") */
__attribute__((import_module("env"), import_name("scan_http")))
extern int scan_http(int mp, int ml, int up, int ul, int bp, int bl, int op, int oc);
static inline int scan_ptr(const char* s) { return (int)(long)s; }
static inline int scan_len(const char* s) { return s ? (int)strlen(s) : 0; }
char out[65536];
int n = scan_http(scan_ptr("GET"), scan_len("GET"),
scan_ptr(target), scan_len(target),
scan_ptr(""), scan_len(""),
scan_ptr(out), (int)sizeof(out));
out[n] = 0; /* 宿主返回的是写入字节数,需自行补 '\0' 再当 C 字符串用 */
Direct-call example with //go:wasmimport in Go (without the SDK HTTP wrapper):
package main
import "unsafe"
//go:wasmimport env scan_http
func scanHTTP(method, url, body string, out unsafe.Pointer, outCap uint32) uint32
//go:wasmimport env scan_log
func scanLog(msg string)
func rawGet(target string) string {
var out [65536]byte
n := scanHTTP("GET", target, "", unsafe.Pointer(&out[0]), uint32(len(out)))
scanLog("原始响应字节数: " + string(out[:n]))
return string(out[:n]) // 这里拿到的是响应 JSON 文本
}
func main() { _ = rawGet("http://example.com/") }
Go's string parameters are automatically split by the compiler into "pointer + length", so the signature can simply say method, url, body string without manual splitting.
8. Full Built-in Function ID Table (scan_call) and Direct Calls
Built-in functions are implemented by the scan node itself, and the plugin only passes arguments: the benefit is a smaller plugin and consistent behavior (the same ID has exactly the same semantics in the Go / C / C++ / Rust SDKs). Arguments are always a JSON array string, and the return value is a JSON string.
| ID | Function | Arguments | Return |
|---|---|---|---|
| 1 | to_str | value | Converts any value to a string |
| 2 | to_int | value | Converts any value to an integer |
| 3 | to_int64 | value | Converts any value to int64 |
| 4 | to_uint32 | value | Converts any value to uint32 |
| 5 | to_uint64 | value | Converts any value to uint64 |
| 6 | to_float | value | Converts any value to a float |
| 7 | to_bool | value | Converts any value to a boolean |
| 8 | to_bytes | value | Converts any value to a byte string (string form) |
| 10 | base64_encode | data | String |
| 11 | base64_decode | data | String |
| 12 | bytes_to_hex | data | Hexadecimal string (no spaces) |
| 13 | hex_to_bytes | hex | Byte string |
| 14 | url_encode | input | String |
| 15 | url_decode | input | String |
| 16 | html_encode | data | String |
| 17 | html_decode | data | String |
| 18 | charset_convert | data, targetEncoding, autoDetect | String |
| 20 | md5 | data | Hexadecimal string |
| 21 | sha1 | data | Hexadecimal string |
| 22 | sha224 | data | Hexadecimal string |
| 23 | sha256 | data | Hexadecimal string |
| 24 | sha384 | data | Hexadecimal string |
| 25 | sha512 | data | Hexadecimal string |
| 26 | crc32 | data | Hexadecimal string |
| 27 | crc64 | data | Hexadecimal string |
| 30 | rand_str | mode, length | Random string (mode bit flags: 1 lowercase / 2 digits / 4 uppercase / 8 special) |
| 40 | left_of | s, keyWord | Content to the left of the separator |
| 41 | right_of | s, keyWord | Content to the right of the separator |
| 42 | middle_of | s, left, right | Content between the two markers |
| 43 | text_between | source, start, end, startPosition, offset, fallbackToSource | {"pos":n,"text":""} |
| 44 | csv_to_line | fields, lineBreak | One line of a CSV string |
| 45 | csv_clean | fields | Cleaned JSON array string |
| 50 | json_get | jsonData, path | String |
| 51 | json_get_raw | jsonData, path | Raw JSON value |
| 60 | timestamp | timeStr, isMilli | Timestamp (string form) |
| 61 | timestamp_str | ts, isMilli | Time string |
| 62 | format_time | now, param | Time offset result |
The Go SDK does not wrap the type-conversion functions with IDs 1~8 individually; in hand-written languages, or whenever needed, they can be called directly with scan_call. Not exposed: dangerous capabilities such as file I/O, process/system commands, DNS/arbitrary networking, direct HTTP connections (everything goes through scan_http) and concurrency pools.
How to call scan_call directly without the wrappers: the arguments are a JSON array, and the return value is a JSON-encoded string (so the result carries quotes and must be deserialized or stripped of quotes by string handling). Below is a complete example that calls it directly with //go:wasmimport instead of the SDK MD5 wrapper:
package main
import (
"encoding/json"
"unsafe"
)
//go:wasmimport env scan_call
func scanCall(id uint32, args string, out unsafe.Pointer, outCap uint32) uint32
// rawCall 直调内置函数:argsJSON 形如 `["hello"]`,返回反序列化后的字符串
func rawCall(id uint32, argsJSON string) (string, bool) {
var out [65536]byte
n := scanCall(id, argsJSON, unsafe.Pointer(&out[0]), uint32(len(out)))
if n == 0 {
return "", false // 宿主写入 0 字节 = 失败(或结果为空)
}
var s string
if err := json.Unmarshal(out[:n], &s); err != nil {
return "", false // 返回值不是 JSON 字符串
}
return s, true
}
func main() {
// 等价于 scan.MD5("hello"),ID=20,参数为 JSON 数组
sum, ok := rawCall(20, `["hello"]`)
if ok {
scanLogRaw("MD5=" + sum)
}
// ID=30 rand_str,参数是数字(JSON 里直接用整数)
r, _ := rawCall(30, `[6,12]`) // mode=2|4, length=12
_ = r
// 未知 ID 时宿主返回 {"error":"未知的内置函数 ID"}
if _, ok := rawCall(9999, `[]`); !ok {
// 处理失败
}
}
//go:wasmimport env scan_log
func scanLogRaw(msg string)
Three key points for direct calls:
- The return value of
scan_callis the number of bytes written into out;n == 0counts as a failure/empty result. - The returned content is a JSON string literal (with quotes); it must go through
json.Unmarshalor manual quote stripping before it can be used as the original text — that is exactly what the Go SDK wrappers do. - Strings inside the JSON argument array must be escaped by yourself (text containing
"or\); hand-written languages can refer toscan_json_escapeinscan_sdk.h.
9. HTTP Capability Details
scan.HTTP is the only outlet through which a plugin accesses the network. It is implemented by the host's env.scan_http proxy and constrained by the following policies:
- Automatic Cookie session: the host maintains a
cookiejarfor each plugin instance; a responseSet-Cookieis saved automatically and sent with subsequent requests. Suited to login-state / multi-step request scenarios. - Through the task proxy: if the task has a proxy configured, requests go through it; otherwise they connect directly.
- Time limit: constrained jointly by the plugin-level timeout (default 30s, adjustable with
SetTimeout) and the HTTP client timeout. - Response body limit 1MB: the host reads at most 1MB with
io.LimitReader, and anything beyond is discarded. Do not rely on the tail of a very large response when making decisions. resp.Errorcheck: when building the request fails or a network error occurs,StatusCode = 0andErroris non-empty; always checkresp.Error == ""before checking the status code.- Redirects: at most 10 are followed; beyond 10 the last response is returned (no further redirection).
- Certificate policy: the default TLS verification is used and certificate validation is not skipped; a self-signed target shows up through
resp.Error.
resp := scan.HTTP("POST", target, `{"user":"admin"}`)
if resp.Error != "" {
scan.Log("请求失败: " + resp.Error)
return
}
if resp.StatusCode == 200 && resp.Headers["content-type"] != "" {
scan.Log("content-type: " + resp.Headers["content-type"])
}
The Cookie session is maintained per plugin instance and persists across packets (the entire batch of one plugin shares a single session). This is both an advantage (multi-step logins carry Cookies automatically) and a risk of cross-contamination between packets — see the symptom table in Chapter 10.
10. Debugging Handbook
This chapter provides a debugging path you can follow step by step, plus a "symptom → cause → fix" quick-reference table.
10.1 Watching Logs: scan.Log and the stdout Fallback Protocol
- Prefer
scan.Log(msg): logs are collected by the host and sent upward with the result of this execution, displayed line by line in the scan node's task log and in the GUI debug panel. The most direct way to find out "whether it executed and how far it got". - stdout fallback protocol: besides
scan_report, the older line protocol is also supported (C/C++ hand-written implementations commonly useprintf/write):[LOG] <消息>→ recorded as one debug log line;[FIND] <VulnFinding JSON>→ recorded as one vulnerability finding.- After
_startreturns, the host inspects what the plugin wrote to stdout and matches the prefixes line by line. Usingscan_report/scan.Logis still recommended; stdout is only for compatibility and as a fallback.
/* C 手写:不引入 SDK 时的兜底输出 */
write(1, "[LOG] 开始检测\n", 17);
char buf[1024];
int n = snprintf(buf, sizeof(buf),
"[FIND] {\"name\":\"命令注入\",\"detail\":\"命中回显\",\"level\":\"high\",\"url\":\"%s\"}\n", target);
write(1, buf, n);
A [FIND] line must be single-line valid JSON, and the prefix must be exactly [FIND] (optionally followed by a space). A JSON parse failure is silently ignored (the host discards that line outright), so you will only see "nothing was reported" and no error.
10.2 Five-Minute Minimal Verification Flow
- Edit the source: in your POC project, edit
main.go(or the C/Rust source) and add a line such asscan.Log("我跑到这里了"). - Rebuild:
GOOS=wasip1 GOARCH=wasm go build -o output.wasm .(confirm the artifact exports_start). - Upload/build: upload
source/at the GUI's WASM vulnerability build entry, or replaceoutput.wasmin the build service directly. - Sign: sign through the GUI signing entry or the TestSecVulnService build service, and confirm
sig.json.signStatus == "verified". - Single-packet trial run: pick a packet (or fill in a URL manually) in the debug panel and run it once; this is equivalent to one single-packet execution by the host.
- Check logs and hits: your
scan.Logoutput should appear in the debug panel, and a vulnerability card should appear when something is hit; if there is no output, troubleshoot according to 10.3.
As long as GOOS=wasip1 GOARCH=wasm go build succeeds locally inside source/, the artifact is basically usable; the real differences usually lie only in "the signature" and "whether LoadFlow was called".
10.3 Symptom → Cause → Fix (10+ entries)
| Symptom | Cause | Fix |
|---|---|---|
| The plugin does nothing and there are no logs | scan.LoadFlow() was forgotten, so scan.Flow is entirely empty | Call scan.LoadFlow() on the first line of main, and return early when scan.Flow.URL == "" |
| The debug panel shows "not loaded / unsigned" and it never executes | No signature, or signature verification failed (unsigned / verify_failed) | Sign through the GUI/build service so sig.json.signStatus becomes verified; confirm output.wasm was not modified after signing |
Instantiation failure: instantiate module ... unknown import | app_* (or ctl_*) host functions were used by mistake | A WASM POC uses only scan_*; only application plugins use app_*, and the two sets cannot be mixed |
The result Error says "the wasm module is missing the _start entry" | Built with -buildmode=c-shared by mistake | Build vulnerability POCs with GOOS=wasip1 GOARCH=wasm go build -o output.wasm . |
| Trailing features cannot be detected and the response looks cut off | The response body was truncated by the 1MB limit | Keep checks near the beginning; or switch to features that do not depend on a very large response; if necessary change approach (e.g. look only at the status code/headers) |
The result Error says "plugin execution timed out (default 30s…)" | The default 30s timeout was reached and the plugin did not finish | Call scan.SetTimeout(60000) at the start of main to raise it reasonably; avoid pointless long polling/sleeping |
| There is a vulnerability card but the title/body is blank | Key Report fields were left empty (Name/Detail) | Fill in at least Name (you can use scan.T("vuln_name")) and Detail; a blank URL falls back to Flow.URL |
| The card level is displayed abnormally or is missing | A wrong Level value (e.g. High/高危) | Use only the four lowercase values critical / high / medium / low |
| Results of multiple packets contaminate each other and the login state is scrambled | The Cookie session persists across packets (maintained per plugin instance) | When isolation is required, clear the session yourself (change strategy) or split into separate plugins; do not assume "a brand-new session per packet" |
scan.T(key) / scan.ConfigGet(key) returns an empty string or the key name | The language pack/configuration is missing or the key is wrong | T returns the key name itself when it cannot find one; ConfigGet returns an empty string; verify the key name and whether the language pack/plugin.config.json ships with the package |
It stops executing after sig.json was edited by hand | Hand-editing sig.json made the signature/hash mismatch | Do not hand-edit signature files; run the signing flow again to generate sig.json |
scan.JSONGet(...) does not compile | A string was passed as the first argument | It needs []byte; use scan.JSONGet([]byte(resp.Body), "a.b[0].c") |
Reading resp.Body right away yields empty and it is misjudged as no vulnerability | resp.Error was not checked | Do if resp.Error != "" { return } first, then check the status code and content |
| Built-in function results/logs are truncated | The out buffer is too small (T/ConfigGet 4KB, HTTP/scan_call 64KB) | The Go SDK already provides enough; hand-written languages must supply a sufficient outCap; remember the host truncates beyond it |
| Reading files / connecting to databases / opening sockets directly all fail | These capabilities are not exposed (an anti-RCE allowlist) | Use only scan_http and the controlled APIs listed in this document; networking always goes through scan_http |
10.4 Platform-Independent Self-Testing: Extract Logic into Pure Functions and Run go test
WASM can only run inside the host, but your decision logic does not have to depend on the host — extract it into pure functions that "take strings in and return bool/structs", iterate quickly locally with ordinary go test, and simply call them from main at the end.
package main
import (
"strings"
)
// 纯函数:只依赖标准库,可在普通 GOARCH 下用 go test 跑
func IsPasswdLeak(body string) bool {
return strings.Contains(strings.ToLower(body), "root:x:0:0")
}
// POC 入口:把「取数据」与「判逻辑」分开
func main() {
scan.SetTimeout(30000)
scan.LoadFlow()
if scan.Flow.URL == "" {
return
}
resp := scan.HTTPGet(scan.Flow.URL)
if resp.Error != "" {
return
}
if IsPasswdLeak(resp.Body) {
scan.Report(scan.VulnFinding{Name: "敏感文件泄露", Level: "high", URL: resp.URL})
}
}
The corresponding test (file name leak_test.go, without //go:build wasm, so it can be run directly with go test on your machine):
package main
import "testing"
func TestIsPasswdLeak(t *testing.T) {
if !IsPasswdLeak("<pre>root:x:0:0:root:/root:/bin/bash</pre>") {
t.Fatal("应命中 passwd 特征")
}
if IsPasswdLeak("<html>hello</html>") {
t.Fatal("不应命中")
}
}
Because main.go imports the scan package (which carries //go:build wasm), that package does not participate in compilation for a non-wasm build on your machine and will be reported as missing. It is recommended to split the pure functions and main into different files (for example, logic.go contains only pure functions while main.go depends on wasm), and to run tests only against logic.go; alternatively, isolate main with a build tag when testing locally. That way you can quickly verify the decision logic on a machine without a scan node, and then compile the whole thing to wasm for the platform.
11. Security Policy
- Only
verifiedWASM is executed: the scan node loads and executes only templates that pass signature verification; the rest are skipped and recorded. - Host function allowlist: only
scan_http/scan_log/scan_report/scan_call/scan_t/scan_config/scan_set_timeout; file I/O, process/system commands and DNS/arbitrary networking are not exposed. - No files / processes / direct network connections: a plugin cannot access the host filesystem, cannot execute external programs and cannot initiate arbitrary network connections on its own; HTTP always goes through
scan_http(via the proxy, with time and size limits). - Crash protection: a WASM execution error is returned as an error, and the host adds a
recoverfallback, so a trap/panic cannot bring down the scanning process. - Timeout termination: the default is 30s per plugin; on timeout that plugin is terminated automatically and the next one continues, without affecting the whole scan task.
This is a "capability minimization" design (anti-RCE, similar to the remediation of the historical Lua plugin issue). Your plugin can only perform detection with the APIs listed in this document — this is not a restriction, it is a security boundary.
12. Pitfall Checklist
| Pitfall | Symptom | How to avoid it |
|---|---|---|
Forgetting LoadFlow() | scan.Flow.URL is empty and the plugin does nothing | Call LoadFlow() at the start of main and return early on the empty value |
| Forgetting to sign | signStatus=unsigned and the plugin is never executed at all | Sign through the GUI / build service and confirm verified |
Writing app_* into it | Instantiation failure unknown import | A WASM POC uses only scan_*; only application plugins use app_* |
| Building with c-shared by mistake | The module is missing _start | Build vulnerability POCs with GOOS=wasip1 GOARCH=wasm go build -o output.wasm . |
| Response body truncated | Trailing features cannot be detected | Remember the 1MB response limit; keep checks near the beginning and rethink large-content checks |
| Output buffer too small | Log / built-in function results are truncated | The Go SDK already gives 4KB / 64KB; hand-written languages must provide enough buffer |
| Timeout set too high/too low | A large plugin is cut off at 30s, or the task's time is used up | Raise it reasonably with SetTimeout at the start; avoid pointless long polling |
| Multiple packets sharing a session | Login state / Cookies contaminate each other across packets and results drift | The session is maintained per plugin instance and persists across packets; clear it yourself or split plugins when isolation is needed |
Not checking resp.Error | Reading Body directly yields an empty string and is misjudged as "no vulnerability" | Check resp.Error == "" first, then the status code and content |
| Looking for file/network APIs among the built-ins | An error or an empty string is returned | File/network/process capabilities are not exposed; use scan_http and the controlled APIs instead |
Passing a string to JSONGet | It does not compile | The first parameter is []byte; use scan.JSONGet([]byte(resp.Body), path) |
Hand-editing sig.json | The signature/hash no longer matches and it stops executing | Do not hand-edit; run the signing flow again |
13. Complete Examples
13.1 Backup File Exposure Detection (Go)
Scenario: append common backup file names to the target directory and request them; report a hit.
package main
import (
"strings"
"scan"
)
func main() {
scan.SetTimeout(60000)
scan.LoadFlow()
if scan.Flow.URL == "" {
return
}
// 取 URL 中最后一个 '/' 之前的目录前缀
base := scan.Flow.URL
if i := strings.LastIndex(base, "?"); i >= 0 {
base = base[:i]
}
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[:i]
}
targets := []string{"/backup.zip", "/www.zip", "/db.sql", "/.git/config", "/config.php.bak"}
for _, t := range targets {
u := base + t
resp := scan.HTTPGet(u)
if resp.Error != "" || resp.StatusCode != 200 {
continue
}
low := strings.ToLower(resp.Body)
// 命中特征:备份文件/配置文件的典型内容
if strings.Contains(low, "db_host") || strings.Contains(low, "[core]") ||
strings.Contains(low, "create table") || strings.Contains(resp.Headers["content-type"], "application/zip") {
// Evidence 取响应体前 500 字节(Go 1.21+ 内置 min)
evidence := resp.Body[:min(len(resp.Body), 500)]
scan.Report(scan.VulnFinding{
Name: "备份/配置文件泄露",
Detail: "发现可访问的备份或配置文件: " + t,
Level: "high",
URL: u,
Evidence: evidence,
Request: scan.Flow.Method + " " + u,
Response: resp.Body[:min(len(resp.Body), 200)],
})
break
}
}
}
min is a Go 1.21+ built-in; if your toolchain is older, replace it with if len(s) > 500 { s = s[:500] }. Truncate Evidence / Response as a rule to keep the report payload from getting too large.
13.2 Unauthorized Access Detection for JSON APIs (Go)
Scenario: enumerate an id parameter against an API that returns JSON; if it returns another user's data in an unauthenticated state, it is judged as broken access control.
package main
import (
"strings"
"scan"
)
func main() {
scan.SetTimeout(45000)
scan.LoadFlow()
url := scan.Flow.URL
if !strings.Contains(strings.ToLower(url), "json") && !strings.Contains(strings.ToLower(scan.Flow.Headers["accept"]), "json") {
// 只处理疑似 JSON 接口,避免无效请求
// 这里按 URL 是否含 /api 判断
if !strings.Contains(strings.ToLower(url), "/api") {
return
}
}
sep := "?"
if strings.Contains(url, "?") {
sep = "&"
}
for _, id := range []string{"1", "2", "100"} {
u := url + sep + "id=" + id
resp := scan.HTTPGet(u)
if resp.Error != "" || resp.StatusCode != 200 {
continue
}
if !strings.Contains(strings.ToLower(resp.Headers["content-type"]), "json") {
continue
}
// 读取关键字段,判断是否泄露了用户/订单数据(注意 JSONGet 第一个参数是 []byte)
user := scan.JSONGet([]byte(resp.Body), "data.username")
if user == "" {
user = scan.JSONGet([]byte(resp.Body), "username")
}
if user != "" {
// 命中:未授权即可读到用户数据
scan.Report(scan.VulnFinding{
Name: "JSON 接口未授权访问",
Detail: "未携带认证信息即可读取 id=" + id + " 的用户数据",
Level: "high",
URL: u,
Evidence: scan.LeftOf(resp.Body, "\"username\""),
SensitiveText: resp.Body,
SensitiveKeywords: []string{user},
SensitiveMatchRule: "json_get(data.username) != empty",
})
return
}
}
}
Both examples follow the same skeleton: SetTimeout → LoadFlow → build the request → judge resp.Error / the status code → run feature/JSON checks → Report. Once you have this skeleton down, most passive-scanning POCs can be written quickly. If you cannot remember how to use a symbol, go back to Chapter 6 and look up its section.
Related documents: Plugin Development Overview, Application Plugin Development, Go Hot-Reload POC Development.