snowpick is a ServiceNow exposure scanner that finds misconfigured widget ACLs, REST API access controls, and unauthenticated data exposure.
ServiceNow exposes data through two common public surfaces: Service Portal widgets and the Table REST API. When access controls on either surface are misconfigured, unauthenticated users may be able to query internal records such as incident tickets, user directories, knowledge base articles, and service catalog items. snowpick tests both surfaces, separates row exposure from count-only evidence, and produces reproduction details for authorized validation.
snowpick obtains a session token from the ServiceNow login page without any credentials, then uses it to query the platform's internal APIs. It checks two attack surfaces:
Widgets are small applications that power the ServiceNow Service Portal. Each one has its own access control settings. When those settings are too permissive, an unauthenticated session may be able to retrieve data from the widget. snowpick first asks the instance for a list of installed widgets where possible, then probes each one and checks whether it returns records or count-only evidence.
Table API is a REST interface at /api/now/table/ for querying ServiceNow tables directly. It has separate access controls from widgets, so a target can be locked down on one surface and open on the other. snowpick probes high-value tables like sys_user, incident, and oauth_entity.
Count oracle. Sometimes a widget or table confirms that matching records exist without returning the rows. snowpick flags this as a count_oracle — not full exposure, but not clean either, since counts can be probed with filters to infer data blindly.
❯ go install github.com/BishopFox/snowpick@latestOr build from source:
❯ git clone https://github.com/BishopFox/snowpick.git
❯ cd snowpick
❯ go build -o snowpick .Requires Go 1.21 or later.
Point snowpick at a ServiceNow instance. No credentials needed.
❯ snowpick -target prod.service-now.com
snowpick v1.0.0 | 1 targets | 6 widgets | proxy direct
[!] prod.service-now.com
| ticket-attachments 272 records [internal_operations:inferred]
[*] 1/1 targets complete, 1 with findings
RESULTS
1 targets scanned in 8.2s | 1 reachable | 1 with findings
EXPOSED prod.service-now.com 272 records [internal_operations:inferred]
| ticket-attachments 272 records [internal_operations:inferred]
| ServiceNow Overview and Open House 'How To.... Videos'
| How to - JIRA and Confluence Access Process
| Updating Your Personal Time Zone in ServiceNow
ATTACK SURFACE
ticket-attachments #################### 272 across 1 target
DATA CLASSIFICATION
OPERATIONS 1 finding across 1 targetsnowpick automatically discovers widgets installed on the instance and probes each one alongside its built-in defaults. Findings are ranked by severity.
Progress markers (left of each host):
[!] findings [+] clean [-] unreachable or no session [*] progress
Summary buckets:
EXPOSED data or a confirmed count came back CLEAN nothing reachable
SKIP no public session (e.g. SSO-only login) DOWN host unreachable
Counts:
272 records total the instance reports as matching
272 records, 10 sampled total, plus the rows snowpick pulled as evidence
Tags:
[pii:observed] a hint, written as category:basis
basis observed = value seen in returned data
inferred = from a table/field/widget name
category pii, credentials, infrastructure,
internal_operations, review_required
[blind oracle] count confirmed but no rows returned (a count_oracle)
[API] finding came from the Table REST API, not a widget
[stats.do] the /stats.do diagnostic endpoint is publicly reachable
Pass a file with one hostname per line. snowpick scans them in parallel.
❯ snowpick -targets hosts.txt -concurrency 5
snowpick v1.0.0 | 6 targets | 6 widgets | proxy direct
[!] prod.service-now.com
| ticket-attachments 272 records [internal_operations:inferred]
[*] 1/6 targets complete, 1 with findings
[+] staging.service-now.com clean
[*] 2/6 targets complete, 1 with findings
[!] dev.service-now.com [stats.do]
| ticket-attachments 61 records [internal_operations:inferred]
[*] 3/6 targets complete, 2 with findings
[+] sandbox.service-now.com clean
[*] 4/6 targets complete, 2 with findings
[-] internal.service-now.com bootstrap failed: /login.do returned 302
[+] test.service-now.com clean
[*] 6/6 targets complete, 2 with findingsServiceNow also exposes a Table REST API at /api/now/table/ for querying backend tables directly. This is a separate access control surface from widgets. Use -table-api to check both.
❯ snowpick -target prod.service-now.com -table-api
snowpick v1.0.0 | 1 targets | 6 widgets | proxy direct
[!] prod.service-now.com
| [API] cmn_department 1,234 records, 10 sampled [infrastructure:inferred]
[*] 1/1 targets complete, 1 with findingsFor Table API findings, record_count represents the total exposed records when ServiceNow returns a total count. sample_count shows how many records snowpick collected as bounded evidence.
By default, snowpick checks a curated set of high-value tables (incident tickets, user records, attachments, etc.). With -discover, it tries to enumerate table names from the instance and checks those too.
❯ snowpick -target prod.service-now.com -discover -discover-limit 200Use -fingerprint to identify which ServiceNow release the target is running. This helps determine what security patches are in place.
❯ snowpick -target prod.service-now.com -fingerprint
snowpick v1.0.0 | 1 targets | 6 widgets | proxy direct
[!] prod.service-now.com [Zurich (glide-zurich-07-01-2025__patch7-02-19-2026)] [stats.do]
| ticket-attachments 61 records [internal_operations:inferred]
[*] 1/1 targets complete, 1 with findingsRoute traffic through Burp, ZAP, or a SOCKS proxy. Use -rate to throttle requests on production instances.
❯ snowpick -target prod.service-now.com -proxy socks5://127.0.0.1:1080 -insecure -rate 200msSuppress progress output with -quiet. JSON goes to stdout by default, or to a file with -output. Exit code 2 means findings exist, 1 means one or more targets could not be tested, and 0 means the scan completed without findings.
❯ snowpick -target prod.service-now.com -quiet -output results.json
❯ echo $?
2The JSON output contains finding details, including impact hints, request details, bounded sample records when rows are returned, and reproduction material where applicable.
{
"target": "acme.service-now.com",
"reachable": true,
"token_obtained": true,
"stats_do": false,
"widgets": [
{
"id": "ff26f2b1db12220099f93691f0b8f5ce",
"name": "ticket-attachments",
"status": "exposed",
"record_count": 847,
"sample_count": 10,
"hints": [
{
"category": "pii",
"basis": "observed",
"reason": "email pattern in sample content",
"signals": ["content:admin@example.com"]
},
{
"category": "internal_operations",
"basis": "inferred",
"reason": "widget name associated with internal operations",
"signals": ["widget:ticket-attachments"]
}
],
"reproduction": {
"method": "POST",
"url": "https://acme.service-now.com/api/now/sp/widget/ff26f2b1db12220099f93691f0b8f5ce",
"headers": {"X-UserToken": "...", "Cookie": "JSESSIONID=..."},
"body": "{\"payload\":{\"start\":0,\"end\":1}}",
"status_code": 200,
"curl": "curl -i -sS -X 'POST' ..."
}
}
]
}Table enumeration findings appear under table_findings, and Table REST API findings appear under table_api_findings. They use the same hints structure. Table API findings also include both record_count and sample_count when a total count is available.
List all targets with findings:
❯ jq -r '.findings[] | select(any(.widgets[]?; .status == "exposed" or .status == "count_oracle") or ((.table_findings // []) | length > 0) or ((.table_api_findings // []) | length > 0)) | .target' results.json
prod.service-now.com
dev.service-now.comGet reproduction curl commands for exposed findings (copy-paste to reproduce):
❯ jq -r '.findings[] | (.widgets[]?, .table_findings[]?, .table_api_findings[]?) | select(.status == "exposed") | .reproduction.curl' results.json
curl -i -sS -X 'POST' 'https://acme.service-now.com/api/now/sp/widget/ff26f...' -H 'X-UserToken: MOCK_TOKEN' ...Show PII findings with target, vector, status, count, samples, and hint basis:
❯ jq -r '.findings[] as $f | ($f.widgets[]?, $f.table_findings[]?, $f.table_api_findings[]?) | select(any(.hints[]?; .category=="pii")) | [$f.target, (.name // (.table + (if .field then "." + .field else "" end))), .status, ((.record_count // .count // 0)|tostring), ((.sample_count // 0)|tostring), ([.hints[]? | select(.category=="pii") | .basis] | unique | join(","))] | @tsv' results.json
acme.service-now.com ticket-attachments exposed 847 10 observed
acme.service-now.com sys_user exposed 250 10 inferredGet sample titles for a finding:
❯ jq -r '.findings[] | select(.target == "acme.service-now.com") | .widgets[] | select(.name == "ticket-attachments") | .samples[]?.title' results.json
Synthetic ticket attachment title
Example onboarding guideCount total exposed records:
❯ jq '[.findings[] | (.widgets[]? | select(.status == "exposed") | .record_count), (.table_findings[]? | select(.status == "exposed") | .count), (.table_api_findings[]? | select(.status == "exposed") | .record_count)] | add // 0' results.json
12896TARGETS
-target string Single ServiceNow hostname
-targets string File with one hostname per line
OUTPUT
-output string JSON output file (default: stdout)
-quiet Suppress informational output
-version Print version and exit
CONNECTION
-proxy string Proxy URL (http, https, socks5, socks5h)
-timeout duration HTTP timeout per request (default: 15s)
-insecure Skip TLS certificate verification
-rate duration Minimum interval between requests (default: unlimited)
-vendor string X-Vendor header for engagement tracking
SCANNING
-concurrency int Max parallel targets (default: 3)
-table-concurrency int Max parallel table probes per target (default: 5)
-samples int Sample records per exposed finding (default: 10)
WIDGET PROBING
-widgets string Comma-separated widget IDs (overrides defaults)
-widgets-file string JSON file with additional widget candidates
TABLE ENUMERATION
-tables Enumerate via widget-simple-list (default: true)
-tables-file string JSON file with additional table/field candidates
-table-api Probe /api/now/table/ REST API
DISCOVERY
-discover Discover tables dynamically from the instance
-discover-limit int Max tables from discovery (default: 100)
-discover-widgets-limit int
Max widgets from discovery (default: 50)
-fingerprint Detect ServiceNow version and release
snowpick ships with 6 default widgets and 26 default table/field pairs. You can add more without modifying the code using JSON files:
Widget candidates (-widgets-file):
[{"id": "WIDGET_ID", "name": "custom-widget"}]Table candidates (-tables-file):
[{"table": "TABLE_NAME", "field": "FIELD_NAME"}]These merge with the built-in defaults. Duplicates are removed automatically.
This tool is provided for authorized security testing and research purposes only. You are solely responsible for ensuring you have explicit permission to test any ServiceNow instance you target. Unauthorized access to computer systems is illegal. The authors are not liable for misuse.