#!/usr/bin/env python3
"""IP-DB Sensor - multi-profile honeypot for https://ip-db.com
Usage:
ipdb-sensor.py run the sensor
ipdb-sensor.py --check verify the API key
ipdb-sensor.py --config print the effective configuration
ipdb-sensor.py --list-profiles list the decoy profiles (add --json for machine output)
ipdb-sensor.py --version print the version
Environment:
IPDB_KEY API key (required)
IPDB_SENSOR_NAME sensor name, default: hostname
IPDB_PROFILES decoys to run: keys or @bundles, each optionally key:port
e.g. "exchange,fortigate:10443,@databases" (default: @classic)
IPDB_PORTS extra raw TCP ports
IPDB_IGNORE comma-separated IPs/CIDRs to never report
IPDB_TLS_CERT certificate for TLS decoys (default /opt/ipdb-sensor/tls/sensor.crt)
IPDB_TLS_KEY its key (default /opt/ipdb-sensor/tls/sensor.key)
IPDB_FAIL2BAN_LOG path to fail2ban.log
IPDB_LOG_LEVEL debug | info | warning | error
IPDB_ENDPOINT report URL
Privacy: decoys never store or send passwords, user names or payloads. A login
attempt or a known exploit pattern is reported as a tag on the event, nothing more.
Licence: MIT
"""
import argparse
import asyncio
import email.utils
import html
import ipaddress
import json
import logging
import os
import random
import re
import signal
import socket
import ssl
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
VERSION = "2.1.0"
DEFAULT_ENDPOINT = "https://ip-db.com/api/v1/sensor/events"
REGISTER_URL = "https://ip-db.com/en/account/register"
USER_AGENT = "ipdb-sensor/%s (+https://ip-db.com/en/honeypot)" % VERSION
DEFAULT_CERT = "/opt/ipdb-sensor/tls/sensor.crt"
DEFAULT_KEY = "/opt/ipdb-sensor/tls/sensor.key"
FLUSH_SECONDS = 60
MAX_BACKOFF = 900
POSTS_PER_FLUSH = 3
MAX_PENDING = 20000
MAX_BATCH = 2000
READ_BYTES = 2048
READ_TIMEOUT = 8.0
SESSION_SECONDS = 30.0
MAX_HEADER = 16384
MAX_BODY = 16384
MAX_CONCURRENT = 256
MAX_PER_SOURCE = 4
STATS_SECONDS = 3600
log = logging.getLogger("ipdb-sensor")
CATEGORIES = [
("edge", "VPN gateways & firewalls"),
("mail", "Mail & collaboration"),
("web", "Web applications & panels"),
("devops", "DevOps, cloud & APIs"),
("db", "Databases & caches"),
("remote", "Remote access & file transfer"),
("iot", "IoT, routers & industrial"),
]
def field(label, name, kind="text"):
return (label, name, kind)
USER = field("Username", "username")
PASS = field("Password", "password", "password")
EMAIL = field("Email", "email", "email")
def http(key, name, cat, port, title, server="nginx", tls=False, login=None, routes=(), headers=(),
home=None, fields=(USER, PASS), button="Sign in", accent="#1f6feb", note="", basic=None,
notfound=None, desc="", action=None, layout="card", subtitle="", accent2=None, brand=None):
if action is None and login:
first = login.split("|")[0]
action = "/" if "(" in first else re.sub(r"[\\^$?]", "", first)
return {"key": key, "name": name, "cat": cat, "port": port, "kind": "http", "tls": tls, "title": title,
"server": server, "login": login, "action": action or "/", "routes": list(routes), "headers": list(headers), "home": home,
"fields": list(fields), "button": button, "accent": accent, "note": note, "basic": basic,
"notfound": notfound, "desc": desc, "layout": layout, "subtitle": subtitle, "accent2": accent2, "brand": brand}
def tcp(key, name, cat, port, dialogue, desc=""):
return {"key": key, "name": name, "cat": cat, "port": port, "kind": "tcp", "tls": False,
"dialogue": dialogue, "desc": desc}
IIS = "Microsoft-IIS/10.0"
APACHE = "Apache"
JSON = "application/json"
XML = "text/xml; charset=utf-8"
PROFILES = [
http("exchange", "Microsoft Exchange (OWA)", "mail", 443, "Outlook", server=IIS, tls=True,
login=r"^/owa/auth\.owa$", home="/owa/auth/logon.aspx?url=https%3a%2f%2f{host}%2fowa%2f&reason=0",
fields=(field("Domain\\user name", "username"), PASS), accent="#0f6cbd",
layout="stacked", brand="Outlook", subtitle="Sign in to your account",
headers=(("X-OWA-Version", "15.2.1544.4"), ("X-AspNet-Version", "4.0.30319"), ("X-Powered-By", "ASP.NET"),
("X-FEServer", "EXCH01")),
routes=(("*", r"^/owa/?$", ("redirect", "/owa/auth/logon.aspx?url=https%3a%2f%2f{host}%2fowa%2f&reason=0")),
("*", r"^/owa/auth/logon\.aspx", ("login",)),
("*", r"^/ecp/?", ("redirect", "/owa/auth/logon.aspx?url=https%3a%2f%2f{host}%2fecp%2f&reason=0")),
("*", r"^/(autodiscover/autodiscover\.xml|ews/|mapi/|rpc/|microsoft-server-activesync|oab/)", ("basic", "{host}")),
("*", r"^/autodiscover/autodiscover\.json", ("status", 401))),
desc="OWA sign-in, ECP, EWS, Autodiscover and ActiveSync endpoints; flags ProxyLogon/ProxyShell probes"),
http("fortigate", "Fortinet FortiGate SSL-VPN", "edge", 10443, "Please Login", server="xxxxxxxx-xxxxx", tls=True,
login=r"^/remote/logincheck$", home="/remote/login?lang=en", accent="#da291c", button="Login",
layout="console", brand="FortiGate", subtitle="SSL-VPN Portal", note="FortiOS 7.2",
routes=(("*", r"^/remote/login", ("login",)),
("*", r"^/remote/fgt_lang", ("status", 403)),
("*", r"^/api/v2/", ("json", 401, '{"http_method":"GET","status":"error","http_status":401}'))),
desc="SSL-VPN portal and REST API; flags CVE-2018-13379 and CVE-2022-40684"),
http("globalprotect", "Palo Alto GlobalProtect", "edge", 4443, "GlobalProtect Portal", server="", tls=True,
login=r"^/global-protect/(login|getconfig)\.esp$", action="/global-protect/login.esp", home="/global-protect/login.esp", accent="#fa582d",
layout="console", brand="GlobalProtect", subtitle="Please enter your credentials",
button="LOG IN",
routes=(("*", r"^/global-protect/login\.esp", ("login",)),
("*", r"^/(global-protect|ssl-vpn)/prelogin\.esp",
("text", 200, XML, 'Success '
'false '
'Enter login credentials '
'Username Password '
'1 EU ')),
("*", r"^/ssl-vpn/hipreport\.esp", ("status", 512)),
("*", r"^/php/login\.php", ("login",))),
desc="Portal, prelogin and HIP report endpoints; flags CVE-2024-3400, CVE-2024-0012, CVE-2025-0108"),
http("netscaler", "Citrix NetScaler Gateway", "edge", 8443, "NetScaler AAA", server="", tls=True,
login=r"^/(cgi/login|p/u/doauthentication\.do|nf/auth/doauthentication\.do)$", action="/cgi/login",
home="/logon/LogonPoint/index.html", accent="#452170", button="Log On",
layout="console", brand="Citrix", subtitle="Please log on to continue",
routes=(("*", r"^/logon/logonpoint/", ("login",)),
("*", r"^/vpn/index\.html", ("login",)),
("*", r"^/oauth/idp/\.well-known/openid-configuration",
("json", 200, '{"issuer":"https://{host}/oauth/idp","authorization_endpoint":"https://{host}/oauth/idp/login"}'))),
headers=(("X-Frame-Options", "SAMEORIGIN"),),
desc="Gateway logon point and OAuth IdP; flags CVE-2019-19781 and Citrix Bleed (CVE-2023-4966)"),
http("ivanti", "Ivanti Connect Secure", "edge", 8444, "Ivanti Connect Secure", server="", tls=True,
login=r"^/dana-na/auth/url_default/login\.cgi$", home="/dana-na/auth/url_default/welcome.cgi",
accent="#d9272e", layout="console", brand="Ivanti", subtitle="Welcome. Please sign in.",
routes=(("*", r"^/dana-na/auth/url_default/welcome\.cgi", ("login",)),
("*", r"^/api/v1/", ("json", 403, '{"result":"Forbidden"}'))),
desc="Pulse/Ivanti sign-in pages and API; flags CVE-2019-11510, CVE-2023-46805, CVE-2024-21887"),
http("cisco-asa", "Cisco ASA AnyConnect", "edge", 9443, "SSL VPN Service", server="", tls=True,
login=r"^/\+webvpn\+/index\.html$", home="/+CSCOE+/logon.html", accent="#049fd9", button="Login",
layout="console", brand="AnyConnect", subtitle="SSL VPN Service",
fields=(field("Username", "username"), field("Password", "password", "password"), field("Group", "group_list")),
routes=(("*", r"^/\+cscoe\+/logon\.html", ("login",)),
("*", r"^/\+cscot\+/", ("status", 404))),
headers=(("X-Transcend-Version", "1"),),
desc="WebVPN logon and translation tables; flags CVE-2020-3452"),
http("f5-bigip", "F5 BIG-IP TMUI", "edge", 8445, "BIG-IP®- Redirect", server="BigIP", tls=True,
login=r"^/tmui/logmein\.html$", home="/tmui/login.jsp", accent="#e4002b", button="Log in",
layout="console", brand="BIG-IP", subtitle="Configuration Utility",
routes=(("*", r"^/tmui/login\.jsp$", ("login",)),
("*", r"^/mgmt/", ("json", 401, '{"code":401,"message":"Authorization failed: no user authentication header or token detected.","errorStack":[]}'))),
desc="TMUI login and iControl REST; flags CVE-2020-5902, CVE-2022-1388, CVE-2023-46747"),
http("sonicwall", "SonicWall SSL-VPN", "edge", 4433, "SonicWall - Virtual Office", server="SonicWALL SSL-VPN Web Server",
tls=True, login=r"^/cgi-bin/userlogin$", home="/cgi-bin/welcome", accent="#ff791a",
layout="console", brand="SonicWall", subtitle="Virtual Office",
routes=(("*", r"^/cgi-bin/welcome", ("login",)), ("*", r"^/sslvpnlogin\.html", ("login",))),
desc="Virtual Office login"),
http("checkpoint", "Check Point Mobile Access", "edge", 8446, "Check Point Mobile", server="CPWS", tls=True,
login=r"^/clients/mycrl$", home="/sslvpn/Login/Login", accent="#e9479a",
layout="console", brand="Check Point", subtitle="Mobile Access Portal",
routes=(("*", r"^/sslvpn/login/", ("login",)),),
desc="SSL VPN portal; flags CVE-2024-24919"),
http("pfsense", "pfSense / OPNsense web GUI", "edge", 8447, "pfSense - Login", server="nginx", tls=True,
login=r"^/(index\.php)?$", action="/index.php", home=None, accent="#212121", button="Sign In",
fields=(field("Username", "usernamefld"), field("Password", "passwordfld", "password")),
routes=(("GET", r"^/(index\.php)?$", ("login",)),),
desc="Firewall admin login"),
http("vcenter", "VMware vCenter / ESXi", "devops", 9443, "VMware vSphere", server="envoy", tls=True,
login=r"^/(ui/login|websso/saml2/sso/.*|sdk)$", action="/ui/login", home="/ui/", accent="#1d428a", button="LOGIN",
layout="split", brand="vSphere", subtitle="VMware vCenter Single Sign-On",
routes=(("GET", r"^/ui/?$", ("login",)),
("*", r"^/(sdk|mob)", ("basic", "VMware HTTP server"))),
desc="vSphere client, SSO and SDK; flags CVE-2021-21972, CVE-2021-21985, CVE-2021-22005"),
http("proxmox", "Proxmox VE", "devops", 8006, "pve - Proxmox Virtual Environment", server="pve-api-daemon/3.0",
tls=True, login=r"^/api2/(json|extjs)/access/ticket$", action="/api2/json/access/ticket", home=None, accent="#e57000", button="Login",
layout="console", brand="Proxmox VE", subtitle="Proxmox Virtual Environment",
routes=(("GET", r"^/$", ("login",)),
("*", r"^/api2/", ("json", 401, '{"data":null}'))),
desc="Web UI and API ticket endpoint"),
http("wordpress", "WordPress", "web", 8080, "Log In ‹ Site — WordPress", server=APACHE,
login=r"^/(wp-login\.php|xmlrpc\.php)$", action="/wp-login.php", home=None, accent="#2271b1", button="Log In",
layout="stacked", brand="WordPress",
fields=(field("Username or Email Address", "log"), field("Password", "pwd", "password")),
headers=(("X-Powered-By", "PHP/8.1.27"), ("Link", ' ; rel="https://api.w.org/"')),
routes=(("GET", r"^/$", ("text", 200, "text/html; charset=UTF-8", "
Site Just another WordPress site
")),
("GET", r"^/wp-login\.php", ("login",)),
("*", r"^/wp-admin", ("redirect", "/wp-login.php?redirect_to=%2Fwp-admin%2F&reauth=1")),
("GET", r"^/xmlrpc\.php", ("text", 405, "text/plain", "XML-RPC server accepts POST requests only.")),
("*", r"^/wp-json/wp/v2/users", ("json", 200, '[{"id":1,"name":"admin","slug":"admin"}]')),
("*", r"^/wp-json", ("json", 200, '{"name":"Site","namespaces":["oembed/1.0","wp/v2"]}')),
("*", r"^/readme\.html", ("text", 200, "text/html", "WordPress › ReadMe WordPress "))),
desc="wp-login, xmlrpc, REST API and readme; xmlrpc brute force counts as login attempts"),
http("phpmyadmin", "phpMyAdmin", "web", 8180, "phpMyAdmin", server=APACHE,
login=r"^/(phpmyadmin/)?index\.php$", action="/phpmyadmin/index.php", home="/phpmyadmin/", accent="#6c78af", button="Go",
layout="minimal", brand="phpMyAdmin", subtitle="Welcome to phpMyAdmin",
fields=(field("Username", "pma_username"), field("Password", "pma_password", "password")),
routes=(("GET", r"^/(phpmyadmin|pma|mysql)/?(index\.php)?$", ("login",)),),
desc="Login page under the usual paths"),
http("cpanel", "cPanel & WHM", "web", 2083, "cPanel Login", server="cpsrvd", tls=True,
login=r"^/login/?$", home=None, accent="#ff6c2c", button="Log in",
routes=(("GET", r"^/$", ("login",)),),
desc="cPanel login (use 2087 for WHM)"),
http("plesk", "Plesk", "web", 8443, "Plesk Obsidian", server="sw-cp-server", tls=True,
login=r"^/login_up\.php", home="/login_up.php", accent="#52bbe6", button="Log in",
fields=(field("Username", "login_name"), field("Password", "passwd", "password")),
routes=(("GET", r"^/login_up\.php", ("login",)),),
desc="Panel login"),
http("webmin", "Webmin", "web", 10000, "Login to Webmin", server="MiniServ/2.105", tls=True,
login=r"^/session_login\.cgi$", home=None, accent="#3c763d", button="Sign in",
fields=(field("Username", "user"), field("Password", "pass", "password")),
routes=(("GET", r"^/$", ("login",)), ("*", r"^/password_change\.cgi", ("status", 200))),
desc="Login and password change; flags CVE-2019-15107"),
http("tomcat", "Apache Tomcat Manager", "web", 8081, "Apache Tomcat/9.0.85", server="",
basic="Tomcat Manager Application", home=None,
routes=(("GET", r"^/$", ("text", 200, "text/html;charset=UTF-8", "Apache Tomcat/9.0.85 Apache Tomcat/9.0.85 If you're seeing this, you've successfully installed Tomcat. Congratulations!
")),
("*", r"^/(manager|host-manager)/", ("basic", "Tomcat Manager Application"))),
notfound="tomcat",
desc="Manager and host-manager behind Basic auth; flags CVE-2017-12617"),
http("jenkins", "Jenkins", "devops", 8082, "Sign in [Jenkins]", server="Jetty(10.0.18)",
login=r"^/j_spring_security_check$", home="/login?from=%2F", accent="#335061", button="Sign in",
fields=(field("Username", "j_username"), field("Password", "j_password", "password")),
headers=(("X-Jenkins", "2.426.3"), ("X-Hudson", "1.395")),
routes=(("GET", r"^/login", ("login",)), ("*", r"^/(script|manage|asynchpeople)", ("redirect", "/login?from=%2Fmanage"))),
desc="Login, script console and CLI; flags CVE-2024-23897"),
http("gitlab", "GitLab", "devops", 8929, "Sign in ยท GitLab", server="nginx",
login=r"^/users/sign_in$", home="/users/sign_in", accent="#fc6d26", button="Sign in",
layout="split", brand="GitLab", subtitle="A complete DevOps platform",
fields=(field("Username or primary email", "user[login]"), field("Password", "user[password]", "password")),
routes=(("GET", r"^/users/sign_in", ("login",)), ("*", r"^/api/v4/", ("json", 401, '{"message":"401 Unauthorized"}'))),
desc="Sign-in and API; flags CVE-2021-22205"),
http("grafana", "Grafana", "devops", 3000, "Grafana", server="",
login=r"^/login$", home="/login", accent="#f46800", button="Log in",
layout="console", brand="Grafana", subtitle="Welcome to Grafana",
fields=(field("Email or username", "user"), PASS),
routes=(("GET", r"^/login", ("login",)), ("*", r"^/api/", ("json", 401, '{"message":"Unauthorized"}'))),
desc="Login and API; flags CVE-2021-43798"),
http("kibana", "Kibana", "devops", 5601, "Elastic", server="",
login=r"^/internal/security/login$", home="/login", accent="#00bfb3", button="Log in",
layout="console", brand="Elastic", subtitle="Welcome to Elastic",
headers=(("kbn-name", "kibana"), ("kbn-license-sig", "x")),
routes=(("GET", r"^/login", ("login",)), ("*", r"^/api/status", ("json", 401, '{"statusCode":401,"error":"Unauthorized"}'))),
desc="Login and status API; flags CVE-2019-7609"),
http("confluence", "Atlassian Confluence", "devops", 8090, "Log In - Confluence", server="",
login=r"^/(dologin\.action|rest/tsv/1\.0/authenticate)$", action="/dologin.action", home="/login.action", accent="#0052cc", button="Log in",
fields=(field("Username", "os_username"), field("Password", "os_password", "password")),
routes=(("GET", r"^/login\.action", ("login",)),),
desc="Login; flags CVE-2021-26084, CVE-2022-26134, CVE-2023-22515, CVE-2023-22527"),
http("jira", "Atlassian Jira", "devops", 8091, "Log in - Jira", server="",
login=r"^/(login\.jsp|rest/auth/1/session)$", action="/login.jsp", home="/login.jsp", accent="#0052cc", button="Log In",
fields=(field("Username", "os_username"), field("Password", "os_password", "password")),
routes=(("GET", r"^/login\.jsp", ("login",)), ("*", r"^/rest/api/", ("json", 401, '{"errorMessages":["You are not authenticated."]}'))),
desc="Login and REST API"),
http("teamcity", "JetBrains TeamCity", "devops", 8111, "Log in to TeamCity", server="",
login=r"^/loginsubmit\.html$", home="/login.html", accent="#000000", button="Log in",
routes=(("GET", r"^/login\.html", ("login",)), ("*", r"^/app/rest/", ("text", 401, "text/plain", "Authentication required"))),
desc="Login and REST API; flags CVE-2023-42793, CVE-2024-27198"),
http("spring-boot", "Spring Boot Actuator", "devops", 8888, "", server="",
home=None, routes=(("GET", r"^/$", ("json", 404, '{"timestamp":"{now}","status":404,"error":"Not Found","path":"/"}')),
("*", r"^/actuator/?$", ("json", 200, '{"_links":{"self":{"href":"http://{host}/actuator"},"health":{"href":"http://{host}/actuator/health"},"env":{"href":"http://{host}/actuator/env"}}}')),
("*", r"^/actuator/health", ("json", 200, '{"status":"UP"}')),
("*", r"^/actuator/", ("json", 401, '{"status":401,"error":"Unauthorized"}'))),
notfound="spring", desc="Actuator endpoints; flags Spring4Shell and Spring Cloud Function RCE"),
http("docker", "Docker Engine API", "devops", 2375, "", server="Docker/24.0.7 (linux)",
home=None, routes=(("*", r"^/(v[\d.]+/)?version", ("json", 200, '{"Version":"24.0.7","ApiVersion":"1.43","Os":"linux","Arch":"amd64","KernelVersion":"5.15.0-91-generic"}')),
("*", r"^/(v[\d.]+/)?_ping", ("text", 200, "text/plain", "OK")),
("*", r"^/(v[\d.]+/)?(containers|images)/json", ("json", 200, "[]")),
("POST", r"^/(v[\d.]+/)?(containers/create|images/create|exec)", ("json", 500, '{"message":"no space left on device"}'))),
headers=(("Api-Version", "1.43"), ("Ostype", "linux")), notfound="json",
desc="Unauthenticated Docker API; container creation is flagged as abuse"),
http("kubernetes", "Kubernetes API server", "devops", 6443, "", server="", tls=True,
home=None, routes=(("*", r"^/version", ("json", 200, '{"major":"1","minor":"28","gitVersion":"v1.28.5"}')),
("*", r"^/", ("json", 403, '{"kind":"Status","apiVersion":"v1","status":"Failure","message":"forbidden: User \\"system:anonymous\\" cannot get path \\"{path}\\"","reason":"Forbidden","code":403}'))),
desc="Anonymous API access"),
http("etcd", "etcd", "devops", 2379, "", server="",
home=None, routes=(("*", r"^/version", ("json", 200, '{"etcdserver":"3.5.10","etcdcluster":"3.5.0"}')),
("*", r"^/v2/keys", ("json", 401, '{"message":"Insufficient credentials"}'))),
notfound="json", desc="Version and key-space endpoints"),
http("elasticsearch", "Elasticsearch", "db", 9200, "", server="",
basic="security", home=None,
routes=(("GET", r"^/$", ("basic", "security")),
("*", r"^/(_cat|_search|_cluster|_nodes|_all)", ("basic", "security"))),
headers=(("X-elastic-product", "Elasticsearch"),),
desc="REST API behind Basic auth; flags script-injection exploits"),
http("couchdb", "Apache CouchDB", "db", 5984, "", server="CouchDB/3.3.3 (Erlang OTP/24)",
home=None, routes=(("GET", r"^/$", ("json", 200, '{"couchdb":"Welcome","version":"3.3.3","vendor":{"name":"The Apache Software Foundation"}}')),
("*", r"^/_session", ("json", 401, '{"error":"unauthorized","reason":"Name or password is incorrect."}')),
("*", r"^/", ("json", 401, '{"error":"unauthorized","reason":"You are not a server admin."}'))),
login=r"^/_session$", desc="Welcome and session endpoints"),
http("winrm", "Windows Remote Management", "remote", 5985, "", server="Microsoft-HTTPAPI/2.0",
basic="WSMAN", home=None, routes=(("*", r"^/wsman", ("basic", "WSMAN")),),
desc="WS-Management endpoint behind Negotiate/Basic"),
http("synology", "Synology DSM", "iot", 5000, "Synology DiskStation", server="nginx",
login=r"^/webapi/(auth\.cgi|entry\.cgi)$", action="/webapi/auth.cgi", home=None, accent="#2a2a2a", button="Sign In",
routes=(("GET", r"^/$", ("login",)),),
desc="DiskStation Manager login"),
http("hikvision", "Hikvision IP camera", "iot", 8000, "", server="App-webs/",
login=r"^/isapi/security/sessionlogin", action="/ISAPI/Security/sessionLogin", home="/doc/page/login.asp", accent="#c8102e", button="Login",
routes=(("GET", r"^/doc/page/login\.asp", ("login",)),
("*", r"^/isapi/", ("text", 401, XML, '401 Unauthorized '))),
desc="Camera web login and ISAPI; flags CVE-2021-36260"),
http("router", "Home router / ONT admin", "iot", 8089, "Login", server="Boa/0.94.14rc21",
login=r"^/(boaform/admin/formlogin|goform/login|cgi-bin/luci|login\.cgi)$", action="/boaform/admin/formLogin", home=None, accent="#3b5998", button="Login",
routes=(("GET", r"^/$", ("login",)),),
desc="Generic router admin; flags GPON, TP-Link and Huawei exploit paths"),
http("admin-panel", "Generic admin panel (Basic auth)", "web", 8083, "", server="nginx",
basic="admin", home=None, routes=(("*", r"^/", ("basic", "admin")),),
desc="Any path asks for Basic credentials"),
http("weblogic", "Oracle WebLogic", "web", 7001, "Oracle WebLogic Server Administration Console", server="",
login=r"^/console/j_security_check$", home="/console/login/LoginForm.jsp", accent="#c74634", button="Login",
fields=(field("Username", "j_username"), field("Password", "j_password", "password")),
routes=(("GET", r"^/console/login/", ("login",)), ("*", r"^/wls-wsat/", ("status", 500))),
desc="Admin console and WLS-WSAT; flags CVE-2020-14882"),
http("activemq-web", "Apache ActiveMQ console", "devops", 8161, "", server="Jetty(9.4.53.v20231009)",
basic="ActiveMQRealm", home=None, routes=(("*", r"^/", ("basic", "ActiveMQRealm")),),
desc="Web console behind Basic auth"),
http("openfire", "Openfire admin", "devops", 9090, "Openfire Admin Console", server="",
login=r"^/login\.jsp$", home="/login.jsp", accent="#11779e", button="Login",
routes=(("GET", r"^/login\.jsp", ("login",)),),
desc="Admin console; flags CVE-2023-32315"),
http("roundcube", "Roundcube Webmail", "mail", 8084, "Roundcube Webmail :: Welcome to Roundcube Webmail", server=APACHE,
login=r"^/(\?_task=login|index\.php)$", action="/?_task=login", home="/?_task=login", accent="#37beff", button="Login",
fields=(field("Username", "_user"), field("Password", "_pass", "password")),
routes=(("GET", r"^/(\?_task=login)?$", ("login",)),),
desc="Webmail login"),
http("zimbra", "Zimbra Collaboration", "mail", 8085, "Zimbra Web Client Sign In", server="nginx",
login=r"^/(\?loginop=login|zimbra/\?loginop=login)?$", action="/?loginOp=login", home=None, accent="#0d7ec5", button="Sign In",
routes=(("GET", r"^/(zimbra/)?$", ("login",)), ("*", r"^/service/", ("status", 401))),
desc="Web client login and SOAP; flags CVE-2019-9670 and CVE-2022-27925"),
tcp("ssh", "SSH", "remote", 2222, "ssh", desc="OpenSSH banner; records client versions"),
tcp("telnet", "Telnet", "remote", 23, "telnet", desc="Login and password prompts; tries count as login attempts"),
tcp("ftp", "FTP", "remote", 21, "ftp", desc="vsftpd dialogue; USER/PASS counts as a login attempt"),
tcp("rdp", "RDP", "remote", 3389, "rdp", desc="Records RDP negotiation (mstshash cookies, scanners)"),
tcp("vnc", "VNC", "remote", 5900, "vnc", desc="RFB 3.8 with VNC authentication challenge"),
tcp("smb", "SMB", "remote", 445, "smb", desc="Records SMB negotiation; off by default (Windows uses 445)"),
tcp("smtp", "SMTP", "mail", 25, "smtp", desc="Postfix dialogue with AUTH; flags open-relay tests"),
tcp("submission", "SMTP submission", "mail", 587, "smtp", desc="Same as SMTP on the submission port"),
tcp("pop3", "POP3", "mail", 110, "pop3", desc="Dovecot dialogue; USER/PASS counts as a login attempt"),
tcp("imap", "IMAP", "mail", 143, "imap", desc="Dovecot dialogue; LOGIN counts as a login attempt"),
tcp("mysql", "MySQL", "db", 3306, "mysql", desc="Sends a MySQL 8 handshake; client auth counts as a login attempt"),
tcp("postgres", "PostgreSQL", "db", 5432, "postgres", desc="Asks for a cleartext password; answers 28P01"),
tcp("mssql", "Microsoft SQL Server", "db", 1433, "mssql", desc="Records TDS pre-login and login packets"),
tcp("redis", "Redis", "db", 6379, "redis", desc="NOAUTH replies; flags CONFIG/SLAVEOF/MODULE abuse"),
tcp("mongodb", "MongoDB", "db", 27017, "raw", desc="Records wire-protocol probes"),
tcp("memcached", "Memcached", "db", 11211, "memcached", desc="Answers stats/version probes"),
tcp("ajp", "Apache JServ (AJP)", "web", 8009, "ajp", desc="Flags Ghostcat (CVE-2020-1938)"),
tcp("activemq", "Apache ActiveMQ OpenWire", "devops", 61616, "activemq", desc="Flags CVE-2023-46604 payloads"),
tcp("mqtt", "MQTT broker", "iot", 1883, "mqtt", desc="CONNECT with credentials counts as a login attempt"),
tcp("modbus", "Modbus/TCP", "iot", 502, "modbus", desc="Records industrial-protocol scans"),
tcp("adb", "Android Debug Bridge", "iot", 5555, "adb", desc="Records ADB CNXN attempts (botnet spreaders)"),
tcp("winbox", "MikroTik Winbox", "iot", 8291, "raw", desc="Records Winbox protocol probes"),
tcp("sip", "SIP (TCP)", "iot", 5060, "sip", desc="REGISTER/INVITE scans; REGISTER with auth counts as a login attempt"),
]
BY_KEY = {p["key"]: p for p in PROFILES}
BUNDLES = {
"classic": ["ftp", "telnet", "telnet:2323", "mssql", "ssh", "mysql", "rdp", "postgres", "vnc", "redis",
"admin-panel:8081", "elasticsearch", "memcached", "mongodb"],
"recommended": ["exchange", "fortigate", "globalprotect", "netscaler", "ssh", "telnet", "ftp", "rdp", "mysql",
"redis", "wordpress", "admin-panel:8081"],
"edge": ["fortigate", "globalprotect", "netscaler", "ivanti", "cisco-asa", "f5-bigip", "sonicwall", "checkpoint",
"pfsense"],
"mail": ["exchange", "smtp", "submission", "imap", "pop3", "roundcube", "zimbra"],
"web": ["wordpress", "phpmyadmin", "cpanel", "plesk", "webmin", "tomcat", "weblogic", "admin-panel"],
"devops": ["jenkins", "gitlab", "grafana", "kibana", "confluence", "jira", "teamcity", "spring-boot", "docker",
"kubernetes", "etcd", "vcenter", "proxmox", "activemq", "activemq-web", "openfire"],
"databases": ["mysql", "postgres", "mssql", "redis", "mongodb", "memcached", "elasticsearch", "couchdb"],
"remote": ["ssh", "telnet", "ftp", "rdp", "vnc", "winrm"],
"iot": ["hikvision", "router", "synology", "mqtt", "modbus", "adb", "winbox", "sip", "telnet:2323"],
}
def sig(tag, path=None, method=None, header=None, body=None, raw=None, test=None):
return {"tag": tag, "path": re.compile(path) if path else None, "method": method,
"header": (header[0], re.compile(header[1])) if header else None,
"body": re.compile(body) if body else None, "raw": re.compile(raw) if raw else None, "test": test}
SIGNATURES = [
sig("cve-2021-44228", test=lambda r: bool(re.search(r"\$\{(jndi|\$\{|lower:|upper:|env:|::-|date:)", r["all"]))),
sig("cve-2014-6271", test=lambda r: any(v.startswith("() {") for v in r["headers"].values())),
sig("cve-2021-26855", header=("cookie", r"x-(beresource|anonresource-backend)=")),
sig("cve-2021-34473", path=r"/autodiscover/autodiscover\.json.*@"),
sig("cve-2022-41040", path=r"/autodiscover/autodiscover\.json.*powershell"),
sig("cve-2018-13379", path=r"/remote/fgt_lang.*\.\./"),
sig("cve-2022-40684", path=r"^/api/v2/cmdb/system/admin", header=("user-agent", r"report runner")),
sig("cve-2024-3400", header=("cookie", r"sessid=[^;]*\.\./")),
sig("cve-2024-0012", header=("x-pan-authcheck", r"off")),
sig("cve-2025-0108", path=r"/unauth/(\.\./|%2e%2e/)"),
sig("cve-2019-19781", path=r"/vpns?/(\.\./)?vpns/|/vpns/portal/scripts/"),
sig("cve-2023-4966", path=r"^/oauth/idp/\.well-known/openid-configuration", test=lambda r: len(r["host"]) > 1000),
sig("cve-2019-11510", path=r"/dana-na/\.\./|guacamole/\.\./"),
sig("cve-2023-46805", path=r"/api/v1/totp/user-backup-code/\.\./"),
sig("cve-2024-21887", path=r"/api/v1/(license/keys-status|totp/user-backup-code)/.*[;|`]"),
sig("cve-2020-3452", path=r"/\+cscot\+/(translation-table|oem-customization).*(\.\./|\+cscoe\+)"),
sig("cve-2020-5902", path=r"/tmui/login\.jsp/\.\.;/"),
sig("cve-2022-1388", path=r"^/mgmt/tm/util/bash"),
sig("cve-2023-46747", path=r"^/tmui/control/form", method="POST"),
sig("cve-2024-24919", path=r"^/clients/mycrl", method="POST", body=r"cshell/\.\./"),
sig("cve-2023-20198", path=r"^/webui/logoutconfirm\.html\?logon_hash="),
sig("cve-2021-21972", path=r"^/ui/vropspluginui/rest/services/uploadova"),
sig("cve-2021-21985", path=r"^/ui/h5-vsan/rest/proxy/service/"),
sig("cve-2021-22005", path=r"^/analytics/telemetry/ph/api/hyper/send"),
sig("cve-2019-15107", path=r"^/password_change\.cgi", method="POST", body=r"(old|expired)=[^&]*\|"),
sig("cve-2017-12617", method="PUT", path=r"\.jsp/?$"),
sig("cve-2024-23897", path=r"^/cli\b", method="POST"),
sig("cve-2021-22205", path=r"^/uploads/user", method="POST"),
sig("cve-2021-43798", path=r"^/public/plugins/[^/]+/\.\./"),
sig("cve-2019-7609", path=r"^/api/timelion/run", body=r"constructor|prototype"),
sig("cve-2021-26084", path=r"(createpage-entervariables|doenterpagevariables)\.action", body=r"queryString|\\u0027|%5cu0027"),
sig("cve-2022-26134", path=r"\$\{.*(@java|#|runtime|getruntime)"),
sig("cve-2023-22515", path=r"/server-info\.action\?.*bootstrapstatusprovider"),
sig("cve-2023-22527", path=r"^/template/aui/text-inline\.vm", method="POST"),
sig("cve-2023-42793", path=r"^/app/rest/users/id:\d+/tokens/rpc2"),
sig("cve-2024-27198", path=r"\?jsp=.*;\.jsp"),
sig("cve-2022-22965", test=lambda r: "class.module.classloader" in r["all"]),
sig("cve-2022-22963", header=("spring.cloud.function.routing-expression", r".")),
sig("cve-2020-14882", path=r"/console/(css|images)/(\.\./|%2e%2e%2f)"),
sig("cve-2023-32315", path=r"/setup/setup-s/(%u002e%u002e|\.\.)/"),
sig("cve-2021-41773", raw=r"/(cgi-bin|icons)/(\.%2e|%2e%2e|%%32%65)/"),
sig("cve-2024-4577", raw=r"(%ad|-)d\+?allow_url_include"),
sig("cve-2017-9841", path=r"phpunit/src/util/php/eval-stdin\.php"),
sig("cve-2018-20062", path=r"invokefunction.*call_user_func_array"),
sig("cve-2023-34362", path=r"moveitisapi/moveitisapi\.dll"),
sig("cve-2021-36260", path=r"^/sdk/weblanguage", method="PUT"),
sig("cve-2018-10561", path=r"^/gponform/diag_form"),
sig("cve-2023-1389", path=r"/cgi-bin/luci/;stok=/locale"),
sig("cve-2017-17215", path=r"^/ctrlt/deviceupgrade_1"),
sig("cve-2019-9670", path=r"^/autodiscover/autodiscover\.xml", body=r" 10 else "(set)" if self.key else "(missing)"
plan, clashes = self.plan()
decoys = ["%s/%s%s" % (port, prof["key"] if prof else "tcp", "+tls" if prof and prof["tls"] else "")
for prof, port in plan]
lines = [
"version %s" % VERSION,
"sensor name %s" % self.name,
"api key %s" % masked,
"endpoint %s" % self.endpoint,
"decoys %s" % (", ".join(decoys) or "-"),
"tls cert %s%s" % (self.cert, "" if os.path.exists(self.cert) else " (missing: TLS decoys answer in plain HTTP)"),
"ignore %s" % (", ".join(str(n) for n in self.ignore) or "-"),
"fail2ban %s" % (self.fail2ban_log or "-"),
"log level %s" % self.log_level.lower(),
]
if clashes:
lines.append("not started %s (port already used by an earlier profile)"
% ", ".join("%s:%d" % c for c in clashes))
return "\n".join(lines)
class Systemd:
def __init__(self):
path = os.environ.get("NOTIFY_SOCKET")
self.addr = None
if path:
self.addr = "\0" + path[1:] if path.startswith("@") else path
usec = os.environ.get("WATCHDOG_USEC", "")
self.watchdog = int(usec) / 2e6 if usec.isdigit() and int(usec) > 0 else None
def notify(self, *lines):
if not self.addr:
return
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.sendto("\n".join(lines).encode(), self.addr)
except OSError as exc:
log.debug("sd_notify failed: %s", exc)
async def watchdog_loop(self):
while self.watchdog:
self.notify("WATCHDOG=1")
await asyncio.sleep(self.watchdog)
# Shared styling for every decoy login page. One themeable stylesheet drives a
# handful of layout archetypes (card / stacked / split / console / minimal) so
# each profile can look like a distinct, plausible enterprise portal instead of
# the same plain card. Accent colours come from the profile; no real vendor logo
# or pixel-for-pixel clone is embedded -- a generated wordmark stands in for one.
LOGIN_CSS = (
"*{box-sizing:border-box}html,body{margin:0;height:100%}"
"body{font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2937;"
"background:#eef1f5;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
"a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}"
"h1{font-size:22px;font-weight:600;margin:0 0 4px;color:#111827}"
".sub{margin:0 0 18px;color:#6b7280;font-size:13px}"
".wm{display:inline-flex;align-items:center;gap:10px;font-weight:600;font-size:16px;color:#111827}"
".mark{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:8px;"
"background:var(--accent);color:#fff;font-weight:700;font-size:14px;letter-spacing:.5px;flex:0 0 auto}"
".fld{margin:0 0 14px}label{display:block;font-size:13px;margin:0 0 6px;color:#374151;font-weight:500}"
"input{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:6px;font:inherit;background:#fff;color:#111827;"
"transition:border-color .15s,box-shadow .15s}"
"input:focus{outline:0;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}"
"button{margin-top:6px;width:100%;padding:11px;border:0;border-radius:6px;background:var(--accent);color:#fff;"
"font:inherit;font-weight:600;cursor:pointer;transition:filter .15s}button:hover{filter:brightness(.94)}"
".err{margin:0 0 14px;padding:9px 12px;border-radius:6px;background:#fef2f2;border:1px solid #fecaca;color:#b91c1c;font-size:13px}"
".links{margin:14px 0 0;font-size:13px}.foot{margin:18px 0 0;font-size:12px;color:#9ca3af;text-align:center}"
".card{width:100%;max-width:380px;background:#fff;border-radius:12px;box-shadow:0 10px 30px rgba(16,24,40,.12);padding:32px}"
".card .head{margin:0 0 20px}"
".stacked{width:100%;max-width:400px}.stacked .logo{margin:0 0 22px}"
".stacked .box{background:#fff;border:1px solid #e5e7eb;border-radius:4px;padding:40px;box-shadow:0 2px 6px rgba(0,0,0,.08)}"
".split{display:flex;width:100%;max-width:860px;min-height:460px;background:#fff;border-radius:14px;overflow:hidden;"
"box-shadow:0 20px 50px rgba(16,24,40,.18)}"
".split .hero{flex:1;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;display:flex;"
"align-items:center;justify-content:center;padding:40px}"
".split .hero .mark{width:48px;height:48px;font-size:19px;background:rgba(255,255,255,.2)}"
".split .heroin{max-width:260px}.split .heroin h2{font-size:24px;margin:16px 0 8px}.split .heroin p{opacity:.85}"
".split .pane{flex:1;padding:48px;display:flex;flex-direction:column;justify-content:center}"
"body.scene-console{background:#0b1220}"
".console{width:100%;max-width:380px;background:#121a2b;border:1px solid #1f2a3d;border-radius:10px;overflow:hidden;"
"box-shadow:0 18px 40px rgba(0,0,0,.5);color:#e5e7eb}"
".console .bar{padding:18px 28px;background:linear-gradient(90deg,var(--accent),var(--accent2))}"
".console .bar .wm,.console .bar .mark{color:#fff}.console .bar .mark{background:rgba(255,255,255,.22)}"
".console .body{padding:28px}.console h1{color:#f9fafb;font-size:19px}.console .sub{color:#9aa6b8}"
".console label{color:#c3ccda}.console input{background:#0b1220;border-color:#2a3850;color:#e5e7eb}"
".console .foot{color:#5b6b85}"
".minimal{width:100%;max-width:340px;background:#fff;border:1px solid #d9d9d9;border-radius:4px;padding:26px}"
"@media(max-width:720px){.split{flex-direction:column;max-width:420px;min-height:0}"
".split .hero{padding:32px}.split .pane{padding:32px}}"
)
LAYOUTS = ("card", "stacked", "split", "console", "minimal")
def _shade(hexc, factor):
"""Lighten (factor>0) or darken (factor<0) a #rrggbb colour for gradients."""
try:
h = hexc.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
except Exception:
r, g, b = 31, 111, 235
if factor >= 0:
r, g, b = (int(c + (255 - c) * factor) for c in (r, g, b))
else:
r, g, b = (int(c * (1 + factor)) for c in (r, g, b))
return "#%02x%02x%02x" % (r, g, b)
def _wordmark(prof):
brand = prof.get("brand") or prof["name"]
inits = "".join(w[0] for w in re.findall(r"[A-Za-z0-9]+", brand)[:2]).upper() or "S"
return '%s %s ' % (html.escape(inits), html.escape(brand))
def login_page(prof, action, error=False):
layout = prof.get("layout") or "card"
if layout not in LAYOUTS:
layout = "card"
accent = prof["accent"]
accent2 = prof.get("accent2") or _shade(accent, -0.22)
title = html.escape(prof["title"] or prof["name"])
brand = html.escape(prof.get("brand") or prof["name"])
sub = prof.get("subtitle") or ""
subhtml = '%s
' % html.escape(sub) if sub else ""
note = html.escape(prof.get("note") or "")
foot = '' % note if note else ""
wm = _wordmark(prof)
fields = "".join(
'%s '
'
'
% (i, html.escape(label), i, html.escape(name, quote=True), kind, html.escape(label))
for i, (label, name, kind) in enumerate(prof["fields"]))
err = 'Invalid credentials. Please try again.
' if error else ""
form = ('%s'
% (err, html.escape(action, quote=True), fields, html.escape(prof["button"])))
if layout == "split":
body = (''
% (wm, brand, html.escape(sub or "Secure access portal"), title, subhtml, form, foot))
elif layout == "stacked":
body = ('%s
%s %s%s%s '
% (wm, title, subhtml, form, foot))
elif layout == "console":
body = ('%s
%s %s%s%s '
% (wm, title, subhtml, form, foot))
elif layout == "minimal":
body = '%s %s%s%s ' % (title, subhtml, form, foot)
else:
body = ('%s
%s %s%s%s '
% (wm, title, subhtml, form, foot))
root = ":root{--accent:%s;--accent2:%s}" % (accent, accent2)
return (' '
' '
'%s '
'%s'
% (title, root, LOGIN_CSS, layout, body)).encode()
NOT_FOUND = {
"iis": ("text/html", '404 - File or directory not found. '
'404 - File or directory not found. The resource you are looking for might have been removed. '),
"tomcat": ("text/html;charset=utf-8", 'HTTP Status 404 - Not Found '
'HTTP Status 404 - Not Found Apache Tomcat/9.0.85 '),
"spring": ("application/json", '{"timestamp":"{now}","status":404,"error":"Not Found","path":"{path}"}'),
"json": ("application/json", '{"message":"page not found"}'),
"apache": ("text/html; charset=iso-8859-1", '404 Not Found '
'Not Found The requested URL was not found on this server.
'),
"nginx": ("text/html", "\r\n404 Not Found \r\n\r\n404 Not Found "
"\r\nnginx \r\n\r\n\r\n"),
}
REASONS = {200: "OK", 301: "Moved Permanently", 302: "Found", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
404: "Not Found", 405: "Method Not Allowed", 500: "Internal Server Error", 512: "Not Extended"}
METHODS = {"GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "PATCH", "PROPFIND", "CONNECT", "TRACE"}
def unquote_twice(text):
once = urllib.parse.unquote(text)
return urllib.parse.unquote(once) if "%" in once else once
class Conn:
def __init__(self, sensor, reader, writer, ip, port, prof):
self.sensor, self.reader, self.writer = sensor, reader, writer
self.ip, self.port, self.prof = ip, port, prof
self.tags = set()
def tag(self, prefix, name=None):
tag = tag_of(prefix, name or self.prof["key"])
if tag not in self.tags:
self.tags.add(tag)
self.sensor.record(self.ip, self.port, tag)
async def send(self, data):
self.writer.write(data)
await self.writer.drain()
async def read(self, n=READ_BYTES, timeout=READ_TIMEOUT):
return await asyncio.wait_for(self.reader.read(n), timeout)
async def exactly(self, n, timeout=READ_TIMEOUT):
return await asyncio.wait_for(self.reader.readexactly(n), timeout)
async def line(self, timeout=READ_TIMEOUT):
data = await asyncio.wait_for(self.reader.readline(), timeout)
return data.replace(b"\xff", b"").strip()[:512]
async def http_session(c):
prof = c.prof
for _ in range(4):
try:
head = await asyncio.wait_for(c.reader.readuntil(b"\r\n\r\n"), READ_TIMEOUT)
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
return
text = head.decode("latin-1")
lines = text.split("\r\n")
parts = lines[0].split(" ")
if len(parts) < 3 or parts[0] not in METHODS:
c.tag("probe", "non-http")
return
method, target = parts[0], parts[1][:4096]
headers = {}
for raw in lines[1:]:
if ":" in raw:
k, v = raw.split(":", 1)
headers[k.strip().lower()] = v.strip()[:8192]
body = b""
length = headers.get("content-length", "0")
if length.isdigit() and int(length) > 0:
try:
body = await asyncio.wait_for(c.reader.readexactly(min(int(length), MAX_BODY)), READ_TIMEOUT)
except asyncio.IncompleteReadError as exc:
body = exc.partial
status, extra, payload = respond(c, method, target, headers, body)
await asyncio.sleep(random.uniform(0.05, 0.25) if status != 401 else random.uniform(0.3, 0.9))
out = ["HTTP/1.1 %d %s" % (status, REASONS.get(status, "OK")),
"Date: " + email.utils.formatdate(usegmt=True)]
if prof["server"]:
out.append("Server: " + prof["server"])
host = (headers.get("host") or "localhost")[:255]
for k, v in prof["headers"] + extra:
out.append("%s: %s" % (k, v.replace("{host}", host)))
keep = headers.get("connection", "").lower() == "keep-alive" and method != "POST"
out.append("Content-Length: %d" % len(payload))
out.append("Connection: " + ("keep-alive" if keep else "close"))
await c.send(("\r\n".join(out) + "\r\n\r\n").encode("latin-1", "replace") + (b"" if method == "HEAD" else payload))
if not keep:
return
def respond(c, method, target, headers, body):
prof = c.prof
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", headers.get("host") or "localhost")[:255] or "localhost"
path_raw = target if target.startswith("/") else "/" + target.split("/", 3)[-1]
path = unquote_twice(path_raw).lower()
body_text = unquote_twice(body.decode("latin-1")).lower()
req = {"method": method, "path": path, "raw": path_raw.lower(), "headers": headers, "host": headers.get("host") or "",
"all": " ".join([path, body_text] + [v.lower() for v in headers.values()])}
c.tag("http")
for s in SIGNATURES:
if s["method"] and s["method"] != method:
continue
if s["path"] and not s["path"].search(path):
continue
if s["raw"] and not s["raw"].search(req["raw"]):
continue
if s["header"] and not s["header"][1].search(headers.get(s["header"][0], "").lower()):
continue
if s["body"] and not s["body"].search(body_text):
continue
if s["test"] and not s["test"](req):
continue
c.tag("exploit" if s["tag"][:4] in ("cve-", "dock") else "probe", s["tag"])
def fill(text):
return (text.replace("{host}", host).replace("{path}", path_raw[:200].replace('"', ""))
.replace("{now}", time.strftime("%Y-%m-%dT%H:%M:%S.000+00:00", time.gmtime())))
if "authorization" in headers and (prof["basic"] or prof["login"]):
c.tag("login")
route_path = path_raw.split("#")[0].lower()
route_path_noq = route_path.split("?")[0]
if prof["login"] and method == "POST" and (re.search(prof["login"], route_path_noq) or re.search(prof["login"], route_path)):
c.tag("login")
if "json" in headers.get("content-type", "") or "api" in route_path_noq:
return 401, [("Content-Type", JSON)], b'{"error":"invalid_credentials","message":"Invalid username or password"}'
return 200, [("Content-Type", "text/html; charset=utf-8"), ("Cache-Control", "no-store")], login_page(prof, prof["action"], True)
for m, pattern, action in prof["routes"]:
if m != "*" and m != method and not (m == "GET" and method == "HEAD"):
continue
if not re.search(pattern, route_path):
continue
kind = action[0]
if kind == "login":
return 200, [("Content-Type", "text/html; charset=utf-8"), ("Cache-Control", "no-store")], login_page(prof, prof["action"])
if kind == "redirect":
return 302, [("Location", fill(action[1])), ("Content-Type", "text/html")], b""
if kind == "basic":
return 401, [("WWW-Authenticate", 'Basic realm="%s"' % fill(action[1])), ("Content-Type", "text/html")], b"401 Unauthorized"
if kind == "json":
return action[1], [("Content-Type", JSON)], fill(action[2]).encode()
if kind == "text":
return action[1], [("Content-Type", action[2])], fill(action[3]).encode()
if kind == "status":
return action[1], [("Content-Type", "text/html")], b""
if route_path_noq in ("/", "") and prof["home"]:
return 302, [("Location", fill(prof["home"]))], b""
if route_path_noq in ("/", "") and prof["basic"]:
return 401, [("WWW-Authenticate", 'Basic realm="%s"' % prof["basic"]), ("Content-Type", "text/html")], b"401 Unauthorized"
if route_path_noq in ("/", "") and prof["login"]:
return 200, [("Content-Type", "text/html; charset=utf-8")], login_page(prof, prof["action"])
style = prof["notfound"] or ("iis" if prof["server"].startswith("Microsoft") else "apache" if prof["server"] == APACHE else "nginx")
ctype, text = NOT_FOUND[style]
return 404, [("Content-Type", ctype)], fill(text).encode()
async def d_raw(c):
data = await c.read()
if data:
c.tag("probe", "payload")
async def d_ssh(c):
await c.send(b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10\r\n")
data = await c.read(256)
if data.startswith(b"SSH-"):
c.tag("client", "ssh")
async def d_telnet(c):
await c.send(b"\xff\xfb\x01\xff\xfb\x03\xff\xfd\x18\r\nUbuntu 22.04.4 LTS\r\n")
for _ in range(3):
await c.send(b"login: ")
user = await c.line()
if not user:
return
await c.send(b"Password: ")
await c.line()
c.tag("login")
await asyncio.sleep(random.uniform(1.0, 2.0))
await c.send(b"\r\nLogin incorrect\r\n")
async def d_ftp(c):
await c.send(b"220 (vsFTPd 3.0.5)\r\n")
for _ in range(8):
cmd = (await c.line()).decode("latin-1").upper()
if not cmd:
return
verb = cmd.split(" ")[0]
if verb == "USER":
await c.send(b"331 Please specify the password.\r\n")
elif verb == "PASS":
c.tag("login")
await asyncio.sleep(random.uniform(0.8, 1.6))
await c.send(b"530 Login incorrect.\r\n")
elif verb in ("AUTH", "PBSZ", "PROT"):
await c.send(b"530 Please login with USER and PASS.\r\n")
elif verb == "SYST":
await c.send(b"215 UNIX Type: L8\r\n")
elif verb == "FEAT":
await c.send(b"211-Features:\r\n EPRT\r\n EPSV\r\n MDTM\r\n PASV\r\n REST STREAM\r\n SIZE\r\n TVFS\r\n UTF8\r\n211 End\r\n")
elif verb == "QUIT":
await c.send(b"221 Goodbye.\r\n")
return
else:
await c.send(b"530 Please login with USER and PASS.\r\n")
async def d_smtp(c):
await c.send(b"220 mail.localdomain ESMTP Postfix (Ubuntu)\r\n")
for _ in range(12):
line = await c.line()
if not line:
return
cmd = line.decode("latin-1").upper()
verb = cmd.split(" ")[0]
if verb in ("EHLO", "HELO"):
await c.send(b"250-mail.localdomain\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-AUTH PLAIN LOGIN\r\n"
b"250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250 SMTPUTF8\r\n" if verb == "EHLO" else b"250 mail.localdomain\r\n")
elif verb == "AUTH":
mech = cmd.split(" ")
if len(mech) > 1 and mech[1] == "LOGIN" and len(mech) == 2:
await c.send(b"334 VXNlcm5hbWU6\r\n")
await c.line()
await c.send(b"334 UGFzc3dvcmQ6\r\n")
await c.line()
elif len(mech) == 2:
await c.send(b"334 \r\n")
await c.line()
c.tag("login")
await asyncio.sleep(random.uniform(0.8, 1.6))
await c.send(b"535 5.7.8 Error: authentication failed: authentication failure\r\n")
elif verb == "MAIL":
await c.send(b"250 2.1.0 Ok\r\n")
elif verb == "RCPT":
c.tag("probe", "smtp-relay")
await c.send(b"554 5.7.1 : Relay access denied\r\n")
elif verb == "STARTTLS":
await c.send(b"454 4.7.0 TLS not available due to local problem\r\n")
elif verb == "QUIT":
await c.send(b"221 2.0.0 Bye\r\n")
return
elif verb in ("RSET", "NOOP"):
await c.send(b"250 2.0.0 Ok\r\n")
else:
await c.send(b"502 5.5.2 Error: command not recognized\r\n")
async def d_pop3(c):
await c.send(b"+OK Dovecot (Ubuntu) ready.\r\n")
for _ in range(8):
cmd = (await c.line()).decode("latin-1").upper()
if not cmd:
return
verb = cmd.split(" ")[0]
if verb == "USER":
await c.send(b"+OK\r\n")
elif verb in ("PASS", "AUTH", "APOP"):
c.tag("login")
await asyncio.sleep(random.uniform(0.8, 1.6))
await c.send(b"-ERR [AUTH] Authentication failed.\r\n")
elif verb == "CAPA":
await c.send(b"+OK\r\nCAPA\r\nTOP\r\nUIDL\r\nRESP-CODES\r\nPIPELINING\r\nAUTH-RESP-CODE\r\nUSER\r\nSASL PLAIN LOGIN\r\n.\r\n")
elif verb == "QUIT":
await c.send(b"+OK Logging out\r\n")
return
else:
await c.send(b"-ERR Unknown command.\r\n")
async def d_imap(c):
await c.send(b"* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ AUTH=PLAIN AUTH=LOGIN] Dovecot (Ubuntu) ready.\r\n")
for _ in range(8):
line = (await c.line()).decode("latin-1")
if not line:
return
bits = line.split(" ")
tagid, verb = bits[0][:16], (bits[1].upper() if len(bits) > 1 else "")
if verb in ("LOGIN", "AUTHENTICATE"):
c.tag("login")
await asyncio.sleep(random.uniform(0.8, 1.6))
await c.send(("%s NO [AUTHENTICATIONFAILED] Authentication failed.\r\n" % tagid).encode())
elif verb == "CAPABILITY":
await c.send(("* CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ AUTH=PLAIN AUTH=LOGIN\r\n%s OK Pre-login capabilities listed.\r\n" % tagid).encode())
elif verb == "LOGOUT":
await c.send(("* BYE Logging out\r\n%s OK Logout completed.\r\n" % tagid).encode())
return
else:
await c.send(("%s BAD Error in IMAP command received by server.\r\n" % tagid).encode())
def mysql_handshake():
salt = bytes(random.randint(33, 126) for _ in range(20))
body = (b"\x0a" + b"8.0.36-0ubuntu0.22.04.1\x00" + struct.pack(" 36:
c.tag("login")
await asyncio.sleep(random.uniform(0.3, 0.8))
msg = b"Access denied for user"
err = b"\xff" + struct.pack(" 8:
await c.exactly(min(length - 8, 4096))
break
await c.send(b"R" + struct.pack("!II", 8, 3))
kind = await c.exactly(1)
if kind == b"p":
c.tag("login")
await asyncio.sleep(random.uniform(0.5, 1.0))
fields = b"SFATAL\x00VFATAL\x00C28P01\x00Mpassword authentication failed\x00\x00"
await c.send(b"E" + struct.pack("!I", len(fields) + 4) + fields)
async def d_mssql(c):
data = await c.read()
if data[:1] == b"\x12":
c.tag("client", "mssql-prelogin")
reply = bytes.fromhex("0401002500000100000015000601001b000102001c000103001d0000ff0e0007d00000000000")
await c.send(reply)
data = await c.read()
if data[:1] == b"\x10":
c.tag("login")
async def d_redis(c):
for _ in range(10):
data = await c.read()
if not data:
return
text = data.decode("latin-1").upper()
if re.search(r"\b(CONFIG|SLAVEOF|REPLICAOF|MODULE|EVAL|SCRIPT)\b", text):
c.tag("exploit", "redis-abuse")
if "AUTH" in text:
c.tag("login")
await c.send(b"-WRONGPASS invalid username-password pair or user is disabled.\r\n")
elif "PING" in text and "AUTH" not in text:
await c.send(b"-NOAUTH Authentication required.\r\n")
else:
await c.send(b"-NOAUTH Authentication required.\r\n")
async def d_memcached(c):
for _ in range(5):
line = (await c.line()).decode("latin-1").lower()
if not line:
return
if line.startswith("version"):
await c.send(b"VERSION 1.6.14\r\n")
elif line.startswith("stats"):
await c.send(b"STAT pid 1\r\nSTAT uptime 864211\r\nSTAT version 1.6.14\r\nEND\r\n")
else:
await c.send(b"ERROR\r\n")
async def d_vnc(c):
await c.send(b"RFB 003.008\n")
version = await c.exactly(12)
if not version.startswith(b"RFB"):
return
await c.send(b"\x01\x02")
await c.exactly(1)
await c.send(os.urandom(16))
await c.exactly(16)
c.tag("login")
reason = b"Authentication failed"
await c.send(struct.pack("!I", 1) + struct.pack("!I", len(reason)) + reason)
async def d_rdp(c):
data = await c.read()
if data[:1] == b"\x03":
c.tag("client", "rdp")
if b"mstshash=" in data:
c.tag("probe", "rdp-cookie")
await c.send(bytes.fromhex("030000130ed000001234000209080002000000"))
await c.read()
async def d_smb(c):
data = await c.read()
if b"\xffSMB" in data:
c.tag("client", "smb1")
elif b"\xfeSMB" in data:
c.tag("client", "smb2")
async def d_ajp(c):
data = await c.read(4096)
if data[:2] == b"\x12\x34":
c.tag("client", "ajp")
if b"javax.servlet.include" in data:
c.tag("exploit", "cve-2020-1938")
async def d_activemq(c):
data = await c.read(4096)
if data:
c.tag("client", "openwire")
if b"ClassPathXmlApplicationContext" in data or b"FileSystemXmlApplicationContext" in data:
c.tag("exploit", "cve-2023-46604")
async def d_mqtt(c):
data = await c.read()
if data[:1] == b"\x10":
flags_at = data.find(b"MQTT")
if flags_at > 0 and len(data) > flags_at + 5 and data[flags_at + 5] & 0x80:
c.tag("login")
else:
c.tag("client", "mqtt")
await c.send(b"\x20\x02\x00\x05")
async def d_modbus(c):
data = await c.read()
if len(data) >= 8 and data[2:4] == b"\x00\x00":
c.tag("client", "modbus")
await c.send(data[:7] + bytes([data[7] | 0x80, 0x01]))
async def d_adb(c):
data = await c.read()
if data.startswith(b"CNXN"):
c.tag("client", "adb")
if b"shell:" in data or b"busybox" in data:
c.tag("exploit", "adb-shell")
async def d_sip(c):
data = (await c.read(4096)).decode("latin-1")
first = data.split(" ", 1)[0].upper()
if first in ("REGISTER", "INVITE", "OPTIONS"):
c.tag("client", "sip-" + first.lower())
if first == "REGISTER" and "authorization:" in data.lower():
c.tag("login")
call_id = re.search(r"(?im)^call-id:\s*(.+)$", data)
reply = "SIP/2.0 401 Unauthorized\r\nCall-ID: %s\r\nWWW-Authenticate: Digest realm=\"asterisk\",nonce=\"%08x\"\r\nContent-Length: 0\r\n\r\n" % (
(call_id.group(1).strip()[:80] if call_id else "0"), random.getrandbits(32))
await c.send(reply.encode())
DIALOGUES = {"raw": d_raw, "ssh": d_ssh, "telnet": d_telnet, "ftp": d_ftp, "smtp": d_smtp, "pop3": d_pop3, "imap": d_imap,
"mysql": d_mysql, "postgres": d_postgres, "mssql": d_mssql, "redis": d_redis, "memcached": d_memcached,
"vnc": d_vnc, "rdp": d_rdp, "smb": d_smb, "ajp": d_ajp, "activemq": d_activemq, "mqtt": d_mqtt,
"modbus": d_modbus, "adb": d_adb, "sip": d_sip}
class Sensor:
def __init__(self, config):
self.cfg = config
self.pending = {}
self.own = set()
self.slots = None
self.per_source = {}
self.servers = []
self.listening = []
self.by_port = {}
self.active = set()
self.systemd = Systemd()
self.disabled = False
self.stats = {"connections": 0, "sources": set(), "reported": 0, "reports": 0, "failures": 0, "tags": {}}
self.started = time.time()
self.retry_after = 0
@staticmethod
def normalise(ip):
try:
addr = ipaddress.ip_address(ip.split("%", 1)[0])
except ValueError:
return None
mapped = getattr(addr, "ipv4_mapped", None)
return mapped or addr
def reportable(self, addr):
if addr is None or not addr.is_global or str(addr) in self.own:
return False
return not any(addr in net for net in self.cfg.ignore)
def record(self, ip, port, proto):
addr = self.normalise(ip)
if not self.reportable(addr):
return
ip = str(addr)
now = int(time.time())
key = (ip, port, proto)
entry = self.pending.get(key)
if entry:
entry[1] = now
entry[2] += 1
elif len(self.pending) < MAX_PENDING:
self.pending[key] = [now, now, 1]
level = logging.WARNING if proto.startswith("exploit:") else logging.INFO
log.log(level, "%s -> %d/%s", ip, port, proto)
self.stats["sources"].add(ip)
kind = proto.split(":", 1)[0]
self.stats["tags"][kind] = self.stats["tags"].get(kind, 0) + 1
def tls_context(self):
if not os.path.exists(self.cfg.cert) or not os.path.exists(self.cfg.cert_key):
return None
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.options |= getattr(ssl, "OP_NO_COMPRESSION", 0)
ctx.load_cert_chain(self.cfg.cert, self.cfg.cert_key)
return ctx
except (OSError, ssl.SSLError) as exc:
log.warning("cannot load TLS certificate (%s); TLS decoys answer in plain HTTP", exc)
return None
def handler(self, prof, port):
proto = prof["key"] if prof else "tcp"
async def handle(reader, writer):
task = current_task()
if task is not None:
self.active.add(task)
task.add_done_callback(self.active.discard)
peer = writer.get_extra_info("peername") or ("", 0)
ip = peer[0]
self.stats["connections"] += 1
self.record(ip, port, proto)
if self.slots is None or self.slots.locked() or self.per_source.get(ip, 0) >= MAX_PER_SOURCE:
close(writer)
return
self.per_source[ip] = self.per_source.get(ip, 0) + 1
conn = Conn(self, reader, writer, ip, port, prof or {"key": "tcp"})
try:
async with self.slots:
if prof is None:
await asyncio.wait_for(d_raw(conn), SESSION_SECONDS)
elif prof["kind"] == "http":
await asyncio.wait_for(http_session(conn), SESSION_SECONDS)
else:
await asyncio.wait_for(DIALOGUES[prof["dialogue"]](conn), SESSION_SECONDS)
except Exception as exc:
log.debug("%s:%d session ended: %r", ip, port, exc)
finally:
left = self.per_source.get(ip, 1) - 1
if left > 0:
self.per_source[ip] = left
else:
self.per_source.pop(ip, None)
close(writer)
return handle
def exception_handler(self, loop, context):
exc = context.get("exception")
transport = context.get("transport")
if isinstance(exc, (ssl.SSLError, ConnectionError, OSError)) and transport is not None:
try:
peer = transport.get_extra_info("peername")
local = transport.get_extra_info("sockname")
prof = self.by_port.get(local[1]) if local else None
if peer and prof and prof["tls"]:
self.record(peer[0], local[1], tag_of("tls", prof["key"]))
except Exception:
pass
return
loop.default_exception_handler(context)
async def listen(self):
plan, clashes = self.cfg.plan()
for key, port in clashes:
log.warning("profile %s not started: port %d is already used by another profile", key, port)
tls = self.tls_context() if any(p and p["tls"] for p, _ in plan) else None
if any(p and p["tls"] for p, _ in plan) and tls is None:
log.warning("no TLS certificate at %s; TLS decoys answer in plain HTTP", self.cfg.cert)
for prof, port in plan:
started = False
context = tls if prof and prof["tls"] else None
for host in ("0.0.0.0", "::"):
try:
server = await asyncio.start_server(self.handler(prof, port), host=host, port=port, ssl=context,
reuse_address=True, backlog=128, limit=MAX_HEADER)
self.servers.append(server)
started = True
except OSError:
continue
label = prof["key"] if prof else "tcp"
if started:
self.listening.append(port)
if prof:
self.by_port[port] = prof
log.info("listening on port %d (%s%s)", port, label, ", tls" if context else "")
else:
log.info("port %d is in use or not permitted - %s skipped", port, label)
if not self.servers and not self.cfg.fail2ban_log:
log.error("no port could be opened and no fail2ban log is set - nothing to do")
raise SystemExit(2)
def learn_own_addresses(self):
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
self.own.add(str(self.normalise(info[4][0])))
except OSError:
pass
for family, target in ((socket.AF_INET, "192.0.2.1"), (socket.AF_INET6, "2001:db8::1")):
try:
with socket.socket(family, socket.SOCK_DGRAM) as sock:
sock.connect((target, 9))
self.own.add(str(self.normalise(sock.getsockname()[0])))
except OSError:
pass
self.own.discard("None")
async def follow_fail2ban(self):
pattern = re.compile(r"\[([\w.-]+)\]\s+Ban\s+([0-9A-Fa-f:.]+)")
path, position, warned = self.cfg.fail2ban_log, None, False
while True:
try:
size = os.path.getsize(path)
if position is None or size < position:
position = size
if size > position:
with open(path, "r", errors="replace") as fh:
fh.seek(position)
for line in fh:
m = pattern.search(line)
if m:
self.record(m.group(2), 0, "fail2ban:" + re.sub(r"[^a-z0-9._-]", "-", m.group(1).lower())[:23])
position = fh.tell()
warned = False
except OSError as exc:
if not warned:
log.warning("cannot read %s: %s", path, exc)
warned = True
await asyncio.sleep(5)
def post(self, events):
body = json.dumps({"sensor": self.cfg.name, "version": VERSION, "events": events,
"profiles": sorted({p["key"] for p in self.by_port.values()})}).encode()
req = urllib.request.Request(self.cfg.endpoint, data=body, method="POST", headers={
"Key": self.cfg.key, "Content-Type": "application/json", "User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8") or "{}")
async def flush_once(self):
items = list(self.pending.items())[:MAX_BATCH]
events = [{"ip": ip, "port": port, "proto": proto, "first": f, "last": l, "count": c}
for (ip, port, proto), (f, l, c) in items]
def drop():
for k, _ in items:
self.pending.pop(k, None)
try:
result = await asyncio.get_event_loop().run_in_executor(None, self.post, events)
except urllib.error.HTTPError as exc:
detail = exc.read()[:200].decode(errors="replace").strip()
self.stats["failures"] += 1
if exc.code == 429 or exc.code >= 500:
retry = exc.headers.get("Retry-After", "")
log.warning("report deferred (HTTP %s), will retry", exc.code)
return int(retry) if retry.isdigit() else 0
if exc.code == 403 and "disabled" in detail.lower():
if not self.disabled:
log.error("this sensor is paused in your IP-DB dashboard; events are discarded until it is resumed")
self.disabled = True
else:
log.error("report rejected (HTTP %s): %s", exc.code, detail or "-")
drop()
return MAX_BACKOFF if self.disabled else 0
except Exception as exc:
self.stats["failures"] += 1
log.warning("report failed (%s), will retry", exc)
return 0
drop()
if self.disabled:
log.info("sensor resumed in the dashboard; reporting again")
self.disabled = False
self.stats["reported"] += len(events)
self.stats["reports"] += 1
log.info("reported %d events (accepted %s)", len(events), result.get("accepted", "?"))
return None
async def flush(self):
for _ in range(POSTS_PER_FLUSH):
if not self.pending:
return True
wait = await self.flush_once()
if wait is not None:
self.retry_after = wait
return False
return not self.pending
async def report_loop(self):
delay = self.cfg.flush_seconds
while True:
await asyncio.sleep(delay)
self.retry_after = 0
if await self.flush():
delay = self.cfg.flush_seconds
else:
delay = max(self.retry_after, min(delay * 2, MAX_BACKOFF)) * random.uniform(0.9, 1.1)
self.update_status()
def update_status(self):
self.systemd.notify("STATUS=%d decoys, %d pending, %d events reported"
% (len(self.listening), len(self.pending), self.stats["reported"]))
async def stats_loop(self):
while True:
await asyncio.sleep(STATS_SECONDS)
s = self.stats
kinds = ", ".join("%s %d" % kv for kv in sorted(s["tags"].items())) or "-"
log.info("last hour: %d connections from %d addresses (%s); %d events in %d reports, %d failed",
s["connections"], len(s["sources"]), kinds, s["reported"], s["reports"], s["failures"])
self.stats = {"connections": 0, "sources": set(), "reported": 0, "reports": 0, "failures": 0, "tags": {}}
async def run(self):
loop = asyncio.get_event_loop()
loop.set_exception_handler(self.exception_handler)
self.slots = asyncio.Semaphore(MAX_CONCURRENT)
self.learn_own_addresses()
await self.listen()
tasks = [asyncio.ensure_future(self.report_loop()), asyncio.ensure_future(self.stats_loop())]
if self.systemd.watchdog:
tasks.append(asyncio.ensure_future(self.systemd.watchdog_loop()))
if self.cfg.fail2ban_log:
log.info("following %s", self.cfg.fail2ban_log)
tasks.append(asyncio.ensure_future(self.follow_fail2ban()))
stop = loop.create_future()
for sig_ in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig_, lambda: stop.done() or stop.set_result(True))
except NotImplementedError:
pass
self.systemd.notify("READY=1")
self.update_status()
log.info("ready: %d decoys open, reporting every %ds", len(self.listening), self.cfg.flush_seconds)
await stop
self.systemd.notify("STOPPING=1")
log.info("stopping - sending what is left")
for server in self.servers:
server.close()
for task in list(self.active):
task.cancel()
if self.active:
await asyncio.wait(list(self.active), timeout=2)
await self.flush()
for task in tasks:
task.cancel()
def current_task():
getter = getattr(asyncio, "current_task", None) or getattr(asyncio.Task, "current_task", None)
try:
return getter() if getter else None
except RuntimeError:
return None
def close(writer):
try:
writer.close()
except Exception:
pass
def check(cfg):
try:
result = Sensor(cfg).post([])
except urllib.error.HTTPError as exc:
print("FAILED: HTTP %s %s" % (exc.code, exc.read()[:200].decode(errors="replace")))
return 1
except Exception as exc:
print("FAILED: %s" % exc)
return 1
print("OK: IP-DB accepted the key for sensor '%s'" % result.get("sensor", cfg.name))
return 0
def catalog():
return {
"version": VERSION,
"categories": [{"key": k, "name": n} for k, n in CATEGORIES],
"profiles": [{"key": p["key"], "name": p["name"], "category": p["cat"], "port": p["port"], "kind": p["kind"],
"tls": p["tls"], "description": p["desc"]} for p in PROFILES],
"bundles": BUNDLES,
"signatures": sorted({s["tag"] for s in SIGNATURES}),
}
def list_profiles(as_json):
if as_json:
print(json.dumps(catalog(), indent=1))
return 0
for cat, title in CATEGORIES:
print("\n%s" % title)
for p in PROFILES:
if p["cat"] == cat:
print(" %-14s %5d%-5s %s" % (p["key"], p["port"], "/tls" if p["tls"] else "", p["name"]))
print("\nBundles: " + " ".join("@%s" % b for b in BUNDLES))
print("Example: IPDB_PROFILES=@edge,exchange:443,ssh")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(
prog="ipdb-sensor", description="IP-DB Sensor - a multi-profile honeypot that reports to IP-DB.",
epilog="Settings come from the environment; see the top of this file.")
parser.add_argument("--check", action="store_true", help="confirm the API key works; sends no events")
parser.add_argument("--config", action="store_true", help="print the effective configuration and exit")
parser.add_argument("--list-profiles", action="store_true", help="list decoy profiles and bundles")
parser.add_argument("--json", action="store_true", help="with --list-profiles: machine-readable output")
parser.add_argument("--version", action="version", version=VERSION)
args = parser.parse_args(argv)
if args.list_profiles:
return list_profiles(args.json)
fmt = "%(levelname)-7s %(message)s" if os.environ.get("JOURNAL_STREAM") else "%(asctime)s %(levelname)-7s %(message)s"
logging.basicConfig(level=logging.INFO, format=fmt, datefmt="%H:%M:%S", stream=sys.stdout)
try:
cfg = Config(os.environ)
if args.config:
print(cfg.describe())
return 0
cfg.validate()
except ConfigError as exc:
print("ipdb-sensor: %s" % exc, file=sys.stderr)
return 2
logging.getLogger().setLevel(cfg.log_level)
if args.check:
return check(cfg)
log.info("IP-DB Sensor %s starting as '%s'", VERSION, cfg.name)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(Sensor(cfg).run())
finally:
loop.close()
return 0
if __name__ == "__main__":
sys.exit(main())