pax_global_header00006660000000000000000000000064145013164040014510gustar00rootroot0000000000000052 comment=7a2756a8f367c69c261544ae83d52e86b1ca52d0 ffuf-2.1.0/000077500000000000000000000000001450131640400124365ustar00rootroot00000000000000ffuf-2.1.0/.github/000077500000000000000000000000001450131640400137765ustar00rootroot00000000000000ffuf-2.1.0/.github/FUNDING.yml000066400000000000000000000000211450131640400156040ustar00rootroot00000000000000github: [joohoi] ffuf-2.1.0/.github/pull_request_template.md000066400000000000000000000006511450131640400207410ustar00rootroot00000000000000# Description Please add a short description of pull request contents. If this PR addresses an existing issue, please add the issue number below. Fixes: #(issue number) ## Additonally - [ ] If this is the first time you are contributing to ffuf, add your name to `CONTRIBUTORS.md`. The file should be alphabetically ordered. - [ ] Add a short description of the fix to `CHANGELOG.md` Thanks for contributing to ffuf :) ffuf-2.1.0/.github/workflows/000077500000000000000000000000001450131640400160335ustar00rootroot00000000000000ffuf-2.1.0/.github/workflows/codeql-analysis.yml000066400000000000000000000050251450131640400216500ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. name: "CodeQL" on: push: branches: [master] pull_request: # The branches below must be a subset of the branches above branches: [master] schedule: - cron: '0 9 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] language: ['go'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository uses: actions/checkout@v2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # If this run was triggered by a pull request event, then checkout # the head of the pull request instead of the merge commit. - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ffuf-2.1.0/.github/workflows/golangci-lint.yml000066400000000000000000000015511450131640400213070ustar00rootroot00000000000000name: golangci-lint on: push: tags: - v* branches: - master pull_request: jobs: golangci: name: lint runs-on: ubuntu-latest steps: - uses: actions/setup-go@v3 with: go-version: 1.17 - uses: actions/checkout@v3 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. version: v1.29 # Optional: working directory, useful for monorepos # working-directory: somedir # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 # Optional: show only new issues if it's a pull request. The default value is `false`. # only-new-issues: trueffuf-2.1.0/.gitignore000066400000000000000000000000141450131640400144210ustar00rootroot00000000000000/ffuf .idea ffuf-2.1.0/.goreleaser.yml000066400000000000000000000012651450131640400153730ustar00rootroot00000000000000builds: - id: ffuf binary: ffuf flags: - -trimpath env: - CGO_ENABLED=0 asmflags: - all=-trimpath={{.Env.GOPATH}} gcflags: - all=-trimpath={{.Env.GOPATH}} ldflags: | -s -w -X github.com/ffuf/ffuf/v2/pkg/ffuf.VERSION_APPENDIX= -extldflags '-static' goos: - linux - windows - freebsd - openbsd - darwin goarch: - amd64 - 386 - arm - arm64 ignore: - goos: freebsd goarch: arm64 archives: - id: tgz format: tar.gz replacements: darwin: macOS format_overrides: - goos: windows format: zip signs: - artifacts: checksum ffuf-2.1.0/CHANGELOG.md000066400000000000000000000271041450131640400142530ustar00rootroot00000000000000## Changelog - master - New - Changed - v2.1.0 - New - autocalibration-strategy refactored to support extensible strategy configuration - New cli flag `-raw` to omit urlencoding for URIs - New cli flags `-ck` and `-cc` to enable the use of client side certificate authentication - Integration with `github.com/ffuf/pencode` library, added `-enc` cli flag to do various in-fly encodings for input data - Changed - Fix multiline output - Explicitly allow TLS1.0 - Fix markdown output file format - Fix csv output file format - Fixed divide by 0 error when setting rate limit to 0 manually. - Automatic brotli and deflate decompression - Report if request times out when a time based matcher or filter is active - All 2XX status codes are now matched - Allow adding "unused" wordlists in config file - v2.0.0 - New - Added a new, dynamic keyword `FFUFHASH` that generates hash from job configuration and wordlist position to map blind payloads back to the initial request. - New command line parameter for searching a hash: `-search FFUFHASH` - Data scraper functionality - Requests per second rate can be configured in the interactive mode - Changed - Multiline output prints out alphabetically sorted by keyword - Default configuration directories now follow `XDG_CONFIG_HOME` variable (less spam in your home directory) - Fixed issue with autocalibration of line & words filter - Rate doesn't have initial burst anymore and is more robust in general - Sniper mode template parsing fixes - Time-based matcher now works properly - Proxy URLs are verified to avoid hard to debug issues - Made JSON (`-json`) output format take precedence over quiet output mode, to allow JSON output without the banner etc - v1.5.0 - New - New autocalibration options: `-ach`, `-ack` and `-acs`. Revamped the whole autocalibration process - Configurable modes for matchers and filters (CLI flags: `fmode` and `mmode`): "and" and "or" - Changed - v1.4.1 - New - Changed - Fixed a bug with recursion, introduced in the 1.4.0 release - Recursion now works better with multiple wordlists, disabling unnecessary wordlists for queued jobs where needed - v1.4.0 - New - Added response time logging and filtering - Added a CLI flag to specify TLS SNI value - Added full line colors - Added `-json` to emit newline delimited JSON output - Added 500 Internal Server Error to list of status codes matched by default - Changed - Fixed an issue where output file was created regardless of `-or` - Fixed an issue where output (often a lot of it) would be printed after entering interactive mode - Fixed an issue when reading wordlist files from ffufrc - Fixed an issue where `-of all` option only creates one output file (instead of all formats) - Fixed an issue where redirection to the same domain in recursive mode dropped port info from URL - Added HTTP2 support - v1.3.1 - New - Added a CLI flag to disable the interactive mode - Changed - Do not read the last newline in the end of the raw request file when using -request - Fixed an issue with storing the matches for recursion jobs - Fixed the way the "size" is calculated, it should match content-length now - Fixed an issue with header canonicalization when a keyword was just a part of the header name - Fixed output writing so it doesn't silently fail if it needs to create directories recursively - v1.3.0 - New - All output file formats now include the `Content-Type`. - New CLI flag `-recursion-strategy` that allows adding new queued recursion jobs for non-redirect responses. - Ability to enter interactive mode by pressing `ENTER` during the ffuf execution. The interactive mode allows user to change filters, manage recursion queue, save snapshot of matches to a file etc. - Changed - Fix a badchar in progress output - v1.2.1 - Changed - Fixed a build breaking bug in `input-shell` parameter - v1.2.0 - New - Added 405 Method Not Allowed to list of status codes matched by default. - New CLI flag `-rate` to set maximum rate of requests per second. The adjustment is dynamic. - New CLI flag `-config` to define a configuration file with preconfigured settings for the job. - Ffuf now reads a default configuration file `$HOME/.ffufrc` upon startup. Options set in this file are overwritten by the ones provided on CLI. - Change banner logging to stderr instead of stdout. - New CLI flag `-or` to avoid creating result files if we didn't get any. - New CLI flag `-input-shell` to set the shell to be used by `input-cmd` - Changed - Pre-flight errors are now displayed also after the usage text to prevent the need to scroll through backlog. - Cancelling via SIGINT (Ctrl-C) is now more responsive - Fixed issue where a thread would hang due to TCP errors - Fixed the issue where the option -ac was overwriting existing filters. Now auto-calibration will add them where needed. - The `-w` flag now accepts comma delimited values in the form of `file1:W1,file2:W2`. - Links in the HTML report are now clickable - Fixed panic during wordlist flag parsing in Windows systems. - v1.1.0 - New - New CLI flag `-maxtime-job` to set max. execution time per job. - Changed behaviour of `-maxtime`, can now be used for entire process. - A new flag `-ignore-body` so ffuf does not fetch the response content. Default value=false. - Added the wordlists to the header information. - Added support to output "all" formats (specify the path/filename sans file extension and ffuf will add the appropriate suffix for the filetype) - Changed - Fixed a bug related to the autocalibration feature making the random seed initialization also to take place before autocalibration needs it. - Added tls renegotiation flag to fix #193 in http.Client - Fixed HTML report to display select/combo-box for rows per page (and increased default from 10 to 250 rows). - Added Host information to JSON output file - Fixed request method when supplying request file - Fixed crash with 3XX responses that weren't redirects (304 Not Modified, 300 Multiple Choices etc) - v1.0.2 - Changed - Write POST request data properly to file when ran with `-od`. - Fixed a bug by using header canonicaliztion related to HTTP headers being case insensitive. - Properly handle relative redirect urls with `-recursion` - Calculate req/sec correctly for when using recursion - When `-request` is used, allow the user to override URL using `-u` - v1.0.1 - Changed - Fixed a bug where regex matchers and filters would fail if `-od` was used to store the request & response contents. - v1.0 - New - New CLI flag `-ic` to ignore comments from wordlist. - New CLI flags `-request` to specify the raw request file to build the actual request from and `-request-proto` to define the new request format. - New CLI flag `-od` (output directory) to enable writing requests and responses for matched results to a file for postprocessing or debugging purposes. - New CLI flag `-maxtime` to limit the running time of ffuf - New CLI flags `-recursion` and `-recursion-depth` to control recursive ffuf jobs if directories are found. This requires the `-u` to end with FUZZ keyword. - New CLI flag `-replay-proxy` to replay matched requests using a custom proxy. - Changed - Limit the use of `-e` (extensions) to a single keyword: FUZZ - Regexp matching and filtering (-mr/-fr) allow using keywords in patterns - Take 429 responses into account when -sa (stop on all error cases) is used - Remove -k flag support, convert to dummy flag #134 - Write configuration to output JSON - Better help text. - If any matcher is set, ignore -mc default value. - v0.12 - New - Added a new flag to select a multi wordlist operation mode: `--mode`, possible values: `clusterbomb` and `pitchfork`. - Added a new output file format eJSON, for always base64 encoding the input data. - Redirect location is always shown in the output files (when using `-o`) - Full URL is always shown in the output files (when using `-o`) - HTML output format got [DataTables](https://datatables.net/) support allowing realtime searches, sorting by column etc. - New CLI flag `-v` for verbose output. Including full URL, and redirect location. - SIGTERM monitoring, in order to catch keyboard interrupts an such, to be able to write `-o` files before exiting. - Changed - Fixed a bug in the default multi wordlist mode - Fixed JSON output regression, where all the input data was always encoded in base64 - `--debug-log` no correctly logs connection errors - Removed `-l` flag in favor of `-v` - More verbose information in banner shown in startup. - v0.11 - New - New CLI flag: -l, shows target location of redirect responses - New CLI flac: -acc, custom auto-calibration strings - New CLI flag: -debug-log, writes the debug logging to the specified file. - New CLI flags -ml and -fl, filters/matches line count in response - Ability to use multiple wordlists / keywords by defining multiple -w command line flags. The if no keyword is defined, the default is FUZZ to keep backwards compatibility. Example: `-w "wordlists/custom.txt:CUSTOM" -H "RandomHeader: CUSTOM"`. - Changed - New CLI flag: -i, dummy flag that does nothing. for compatibility with copy as curl. - New CLI flag: -b/--cookie, cookie data for compatibility with copy as curl. - New Output format are available: HTML and Markdown table. - New CLI flag: -l, shows target location of redirect responses - Filtering and matching by status code, response size or word count now allow using ranges in addition to single values - The internal logging information to be discarded, and can be written to a file with the new `-debug-log` flag. - v0.10 - New - New CLI flag: -ac to autocalibrate response size and word filters based on few preset URLs. - New CLI flag: -timeout to specify custom timeouts for all HTTP requests. - New CLI flag: --data for compatibility with copy as curl functionality of browsers. - New CLI flag: --compressed, dummy flag that does nothing. for compatibility with copy as curl. - New CLI flags: --input-cmd, and --input-num to handle input generation using external commands. Mutators for example. Environment variable FFUF_NUM will be updated on every call of the command. - When --input-cmd is used, display position instead of the payload in results. The output file (of all formats) will include the payload in addition to the position however. - Changed - Wordlist can also be read from standard input - Defining -d or --data implies POST method if -X doesn't set it to something else than GET - v0.9 - New - New output file formats: CSV and eCSV (CSV with base64 encoded input field to avoid CSV breakage with payloads containing a comma) - New CLI flag to follow redirects - Erroring connections will be retried once - Error counter in status bar - New CLI flags: -se (stop on spurious errors) and -sa (stop on all errors, implies -se and -sf) - New CLI flags: -e to provide a list of extensions to add to wordlist entries, and -D to provide DirSearch wordlist format compatibility. - Wildcard option for response status code matcher. - v0.8 - New - New CLI flag to write output to a file in JSON format - New CLI flag to stop on spurious 403 responses - Changed - Regex matching / filtering now matches the headers alongside of the response body ffuf-2.1.0/CONTRIBUTORS.md000066400000000000000000000040701450131640400147160ustar00rootroot00000000000000# Contributors * [adamtlangley](https://github.com/adamtlangley) * [adilsoybali](https://github.com/adilsoybali) * [AverageSecurityGuy](https://github.com/averagesecurityguy) * [bp0](https://github.com/bp0lr) * [bjhulst](https://github.com/bjhulst) * [bsysop](https://twitter.com/bsysop) * [ccsplit](https://github.com/ccsplit) * [choket](https://github.com/choket) * [codingo](https://github.com/codingo) * [c_sto](https://github.com/c-sto) * [Damian89](https://github.com/Damian89) * [Daviey](https://github.com/Daviey) * [delic](https://github.com/delic) * [denandz](https://github.com/denandz) * [Ephex2](https://github.com/Ephex2) * [erbbysam](https://github.com/erbbysam) * [eur0pa](https://github.com/eur0pa) * [gserrg](https://github.com/gserrg) * [fabiobauer](https://github.com/fabiobauer) * [fang0654](https://github.com/fang0654) * [haseobang](https://github.com/haseobang) * [Hazegard](https://github.com/Hazegard) * [helpermika](https://github.com/helpermika) * [h1x](https://github.com/h1x-lnx) * [Ice3man543](https://github.com/Ice3man543) * [JamTookTheBait](https://github.com/JamTookTheBait) * [jimen0](https://github.com/jimen0) * [joohoi](https://github.com/joohoi) * [JoshuaMulliken](https://github.com/JoshuaMulliken) * [jsgv](https://github.com/jsgv) * [justinsteven](https://github.com/justinsteven) * [jvesiluoma](https://github.com/jvesiluoma) * [Kiblyn11](https://github.com/Kiblyn11) * [l4yton](https://github.com/l4yton) * [lc](https://github.com/lc) * [mprencipe](https://github.com/mprencipe) * [nnwakelam](https://twitter.com/nnwakelam) * [noraj](https://pwn.by/noraj) * [oh6hay](https://github.com/oh6hay) * [penguinxoxo](https://github.com/penguinxoxo) * [p0dalirius](https://github.com/p0dalirius) * [putsi](https://github.com/putsi) * [SakiiR](https://github.com/SakiiR) * [seblw](https://github.com/seblw) * [Serizao](https://github.com/Serizao) * [Shaked](https://github.com/Shaked) * [Skyehopper](https://github.com/Skyehopper) * [SolomonSklash](https://github.com/SolomonSklash) * [TomNomNom](https://github.com/tomnomnom) * [xfgusta](https://github.com/xfgusta) ffuf-2.1.0/LICENSE000066400000000000000000000020571450131640400134470ustar00rootroot00000000000000MIT License Copyright (c) 2021 Joona Hoikkala Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ffuf-2.1.0/README.md000066400000000000000000000375501450131640400137270ustar00rootroot00000000000000![ffuf mascot](_img/ffuf_run_logo_600.png) # ffuf - Fuzz Faster U Fool A fast web fuzzer written in Go. - [Installation](https://github.com/ffuf/ffuf#installation) - [Example usage](https://github.com/ffuf/ffuf#example-usage) - [Content discovery](https://github.com/ffuf/ffuf#typical-directory-discovery) - [Vhost discovery](https://github.com/ffuf/ffuf#virtual-host-discovery-without-dns-records) - [Parameter fuzzing](https://github.com/ffuf/ffuf#get-parameter-fuzzing) - [POST data fuzzing](https://github.com/ffuf/ffuf#post-data-fuzzing) - [Using external mutator](https://github.com/ffuf/ffuf#using-external-mutator-to-produce-test-cases) - [Configuration files](https://github.com/ffuf/ffuf#configuration-files) - [Help](https://github.com/ffuf/ffuf#usage) - [Interactive mode](https://github.com/ffuf/ffuf#interactive-mode) ## Installation - [Download](https://github.com/ffuf/ffuf/releases/latest) a prebuilt binary from [releases page](https://github.com/ffuf/ffuf/releases/latest), unpack and run! _or_ - If you are on macOS with [homebrew](https://brew.sh), ffuf can be installed with: `brew install ffuf` _or_ - If you have recent go compiler installed: `go install github.com/ffuf/ffuf/v2@latest` (the same command works for updating) _or_ - `git clone https://github.com/ffuf/ffuf ; cd ffuf ; go get ; go build` Ffuf depends on Go 1.16 or greater. ## Example usage The usage examples below show just the simplest tasks you can accomplish using `ffuf`. More elaborate documentation that goes through many features with a lot of examples is available in the ffuf wiki at [https://github.com/ffuf/ffuf/wiki](https://github.com/ffuf/ffuf/wiki) For more extensive documentation, with real life usage examples and tips, be sure to check out the awesome guide: "[Everything you need to know about FFUF](https://codingo.io/tools/ffuf/bounty/2020/09/17/everything-you-need-to-know-about-ffuf.html)" by Michael Skelton ([@codingo](https://github.com/codingo)). You can also practise your ffuf scans against a live host with different lessons and use cases either locally by using the docker container https://github.com/adamtlangley/ffufme or against the live hosted version at http://ffuf.me created by Adam Langley [@adamtlangley](https://twitter.com/adamtlangley). ### Typical directory discovery [![asciicast](https://asciinema.org/a/211350.png)](https://asciinema.org/a/211350) By using the FUZZ keyword at the end of URL (`-u`): ``` ffuf -w /path/to/wordlist -u https://target/FUZZ ``` ### Virtual host discovery (without DNS records) [![asciicast](https://asciinema.org/a/211360.png)](https://asciinema.org/a/211360) Assuming that the default virtualhost response size is 4242 bytes, we can filter out all the responses of that size (`-fs 4242`)while fuzzing the Host - header: ``` ffuf -w /path/to/vhost/wordlist -u https://target -H "Host: FUZZ" -fs 4242 ``` ### GET parameter fuzzing GET parameter name fuzzing is very similar to directory discovery, and works by defining the `FUZZ` keyword as a part of the URL. This also assumes a response size of 4242 bytes for invalid GET parameter name. ``` ffuf -w /path/to/paramnames.txt -u https://target/script.php?FUZZ=test_value -fs 4242 ``` If the parameter name is known, the values can be fuzzed the same way. This example assumes a wrong parameter value returning HTTP response code 401. ``` ffuf -w /path/to/values.txt -u https://target/script.php?valid_name=FUZZ -fc 401 ``` ### POST data fuzzing This is a very straightforward operation, again by using the `FUZZ` keyword. This example is fuzzing only part of the POST request. We're again filtering out the 401 responses. ``` ffuf -w /path/to/postdata.txt -X POST -d "username=admin\&password=FUZZ" -u https://target/login.php -fc 401 ``` ### Maximum execution time If you don't want ffuf to run indefinitely, you can use the `-maxtime`. This stops __the entire__ process after a given time (in seconds). ``` ffuf -w /path/to/wordlist -u https://target/FUZZ -maxtime 60 ``` When working with recursion, you can control the maxtime __per job__ using `-maxtime-job`. This will stop the current job after a given time (in seconds) and continue with the next one. New jobs are created when the recursion functionality detects a subdirectory. ``` ffuf -w /path/to/wordlist -u https://target/FUZZ -maxtime-job 60 -recursion -recursion-depth 2 ``` It is also possible to combine both flags limiting the per job maximum execution time as well as the overall execution time. If you do not use recursion then both flags behave equally. ### Using external mutator to produce test cases For this example, we'll fuzz JSON data that's sent over POST. [Radamsa](https://gitlab.com/akihe/radamsa) is used as the mutator. When `--input-cmd` is used, ffuf will display matches as their position. This same position value will be available for the callee as an environment variable `$FFUF_NUM`. We'll use this position value as the seed for the mutator. Files example1.txt and example2.txt contain valid JSON payloads. We are matching all the responses, but filtering out response code `400 - Bad request`: ``` ffuf --input-cmd 'radamsa --seed $FFUF_NUM example1.txt example2.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/FUZZ -mc all -fc 400 ``` It of course isn't very efficient to call the mutator for each payload, so we can also pre-generate the payloads, still using [Radamsa](https://gitlab.com/akihe/radamsa) as an example: ``` # Generate 1000 example payloads radamsa -n 1000 -o %n.txt example1.txt example2.txt # This results into files 1.txt ... 1000.txt # Now we can just read the payload data in a loop from file for ffuf ffuf --input-cmd 'cat $FFUF_NUM.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/ -mc all -fc 400 ``` ### Configuration files When running ffuf, it first checks if a default configuration file exists. Default path for a `ffufrc` file is `$XDG_CONFIG_HOME/ffuf/ffufrc`. You can configure one or multiple options in this file, and they will be applied on every subsequent ffuf job. An example of ffufrc file can be found [here](https://github.com/ffuf/ffuf/blob/master/ffufrc.example). A more detailed description about configuration file locations can be found in the wiki: [https://github.com/ffuf/ffuf/wiki/Configuration](https://github.com/ffuf/ffuf/wiki/Configuration) The configuration options provided on the command line override the ones loaded from the default `ffufrc` file. Note: this does not apply for CLI flags that can be provided more than once. One of such examples is `-H` (header) flag. In this case, the `-H` values provided on the command line will be _appended_ to the ones from the config file instead. Additionally, in case you wish to use bunch of configuration files for different use cases, you can do this by defining the configuration file path using `-config` command line flag that takes the file path to the configuration file as its parameter.

## Usage To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`-u`), headers (`-H`), or POST data (`-d`). ``` Fuzz Faster U Fool - v2.1.0 HTTP OPTIONS: -H Header `"Name: Value"`, separated by colon. Multiple -H flags are accepted. -X HTTP method to use -b Cookie data `"NAME1=VALUE1; NAME2=VALUE2"` for copy as curl functionality. -cc Client cert for authentication. Client key needs to be defined as well for this to work -ck Client key for authentication. Client certificate needs to be defined as well for this to work -d POST data -http2 Use HTTP2 protocol (default: false) -ignore-body Do not fetch the response content. (default: false) -r Follow redirects (default: false) -raw Do not encode URI (default: false) -recursion Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it. (default: false) -recursion-depth Maximum recursion depth. (default: 0) -recursion-strategy Recursion strategy: "default" for a redirect based, and "greedy" to recurse on all matches (default: default) -replay-proxy Replay matched requests using this proxy. -sni Target TLS SNI, does not support FUZZ keyword -timeout HTTP request timeout in seconds. (default: 10) -u Target URL -x Proxy URL (SOCKS5 or HTTP). For example: http://127.0.0.1:8080 or socks5://127.0.0.1:8080 GENERAL OPTIONS: -V Show version information. (default: false) -ac Automatically calibrate filtering options (default: false) -acc Custom auto-calibration string. Can be used multiple times. Implies -ac -ach Per host autocalibration (default: false) -ack Autocalibration keyword (default: FUZZ) -acs Custom auto-calibration strategies. Can be used multiple times. Implies -ac -c Colorize output. (default: false) -config Load configuration from a file -json JSON output, printing newline-delimited JSON records (default: false) -maxtime Maximum running time in seconds for entire process. (default: 0) -maxtime-job Maximum running time in seconds per job. (default: 0) -noninteractive Disable the interactive console functionality (default: false) -p Seconds of `delay` between requests, or a range of random delay. For example "0.1" or "0.1-2.0" -rate Rate of requests per second (default: 0) -s Do not print additional information (silent mode) (default: false) -sa Stop on all error cases. Implies -sf and -se. (default: false) -scraperfile Custom scraper file path -scrapers Active scraper groups (default: all) -se Stop on spurious errors (default: false) -search Search for a FFUFHASH payload from ffuf history -sf Stop when > 95% of responses return 403 Forbidden (default: false) -t Number of concurrent threads. (default: 40) -v Verbose output, printing full URL and redirect location (if any) with the results. (default: false) MATCHER OPTIONS: -mc Match HTTP status codes, or "all" for everything. (default: 200-299,301,302,307,401,403,405,500) -ml Match amount of lines in response -mmode Matcher set operator. Either of: and, or (default: or) -mr Match regexp -ms Match HTTP response size -mt Match how many milliseconds to the first response byte, either greater or less than. EG: >100 or <100 -mw Match amount of words in response FILTER OPTIONS: -fc Filter HTTP status codes from response. Comma separated list of codes and ranges -fl Filter by amount of lines in response. Comma separated list of line counts and ranges -fmode Filter set operator. Either of: and, or (default: or) -fr Filter regexp -fs Filter HTTP response size. Comma separated list of sizes and ranges -ft Filter by number of milliseconds to the first response byte, either greater or less than. EG: >100 or <100 -fw Filter by amount of words in response. Comma separated list of word counts and ranges INPUT OPTIONS: -D DirSearch wordlist compatibility mode. Used in conjunction with -e flag. (default: false) -e Comma separated list of extensions. Extends FUZZ keyword. -enc Encoders for keywords, eg. 'FUZZ:urlencode b64encode' -ic Ignore wordlist comments (default: false) -input-cmd Command producing the input. --input-num is required when using this input method. Overrides -w. -input-num Number of inputs to test. Used in conjunction with --input-cmd. (default: 100) -input-shell Shell to be used for running command -mode Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork, sniper (default: clusterbomb) -request File containing the raw http request -request-proto Protocol to use along with raw request (default: https) -w Wordlist file path and (optional) keyword separated by colon. eg. '/path/to/wordlist:KEYWORD' OUTPUT OPTIONS: -debug-log Write all of the internal logging to the specified file. -o Write output to file -od Directory path to store matched results to. -of Output file format. Available formats: json, ejson, html, md, csv, ecsv (or, 'all' for all formats) (default: json) -or Don't create the output file if we don't have results (default: false) EXAMPLE USAGE: Fuzz file paths from wordlist.txt, match all responses but filter out those with content-size 42. Colored, verbose output. ffuf -w wordlist.txt -u https://example.org/FUZZ -mc all -fs 42 -c -v Fuzz Host-header, match HTTP 200 responses. ffuf -w hosts.txt -u https://example.org/ -H "Host: FUZZ" -mc 200 Fuzz POST JSON data. Match all responses not containing text "error". ffuf -w entries.txt -u https://example.org/ -X POST -H "Content-Type: application/json" \ -d '{"name": "FUZZ", "anotherkey": "anothervalue"}' -fr "error" Fuzz multiple locations. Match only responses reflecting the value of "VAL" keyword. Colored. ffuf -w params.txt:PARAM -w values.txt:VAL -u https://example.org/?PARAM=VAL -mr "VAL" -c More information and examples: https://github.com/ffuf/ffuf ``` ### Interactive mode By pressing `ENTER` during ffuf execution, the process is paused and user is dropped to a shell-like interactive mode: ``` entering interactive mode type "help" for a list of commands, or ENTER to resume. > help available commands: afc [value] - append to status code filter fc [value] - (re)configure status code filter afl [value] - append to line count filter fl [value] - (re)configure line count filter afw [value] - append to word count filter fw [value] - (re)configure word count filter afs [value] - append to size filter fs [value] - (re)configure size filter aft [value] - append to time filter ft [value] - (re)configure time filter rate [value] - adjust rate of requests per second (active: 0) queueshow - show job queue queuedel [number] - delete a job in the queue queueskip - advance to the next queued job restart - restart and resume the current ffuf job resume - resume current ffuf job (or: ENTER) show - show results for the current job savejson [filename] - save current matches to a file help - you are looking at it > ``` in this mode, filters can be reconfigured, queue managed and the current state saved to disk. When (re)configuring the filters, they get applied posthumously and all the false positive matches from memory that would have been filtered out by the newly added filters get deleted. The new state of matches can be printed out with a command `show` that will print out all the matches as like they would have been found by `ffuf`. As "negative" matches are not stored to memory, relaxing the filters cannot unfortunately bring back the lost matches. For this kind of scenario, the user is able to use the command `restart`, which resets the state and starts the current job from the beginning.

## License ffuf is released under MIT license. See [LICENSE](https://github.com/ffuf/ffuf/blob/master/LICENSE). ffuf-2.1.0/_img/000077500000000000000000000000001450131640400133515ustar00rootroot00000000000000ffuf-2.1.0/_img/ffuf_juggling_250.png000066400000000000000000000661171450131640400172740ustar00rootroot00000000000000PNG  IHDRDimiCCPICC profile(}=H@_S"Q ~:Yq*BZu0 4$).kŪ "%/-=B4mtLĢb**^G?z0^YƜ$9]gyst>xMAw;%ٹSv7&s9-[ <#<#<#<#<#<#<#<#<#<#<#Y@G+;LݿDkkX?J)&O Y,D=?j/ZSQ ΋.?^}MtLS ҚyvJ)ϗRxXY|,|JJyR)eM:B5RZVe}$ ex_'uj:C?3@Gýԁի |2Pbލ>7Bϐ< PWWC&2{ vwq]tId |4U^u6uq)\:swg쫤ŔQ8xP=Xj#O1>0q,"}|@c3KATV HF^H?>Vqqbl> $|LJY5Ton6%} };Kҹ1i}L I'0^1}} thvs?o10'U̓)@p-BlF.xFJy7p"Lvov'fj};89X 8:>fy BDJ؎X(#|̇RK\2xw2ϟm'zUN&$f@[!ĶQNCHt2xp[e4J7D֭~TA6`XYlt)]J#`%puIg+VI)Qg_U?-bUk [#uq!f4F a`˿3QeP~ji&^[^~oÜ{ bF>N B %s@Kλ`2NvK6茶oXgv>_ > o}h@ {GP}ׁyB}o۝б)3* Ο6d4n~'{o~^uz>mk{ u[м"-3& gK~uu~khIuߋDVWDP a٥x @x5,*!ۀe.A,\Q3.uǒ2a |`x+)1ZK [n?@j1eb?V+\!g^gN[1y1ZWF'G=튓<>Z8G&Q:Fz_]!cs 2" wmg2B9cVQ0"u`ÍFu"] `\qDƛD`ϙ 2D 6_J rf\ Ԍea_K(uAY:ZR;^z-iUނQj ,4uZB8$tnM},<3yBIH6ӾjZ12RC" 6 il$44]K֌]7}Ã9;( h]2x,72u)RI)gQ %Ćibt?tebNS/0%6z[Sa {kH~tN\\Յ3 p+HJ؀l'"dSzys21թY++H)?J<5R܆-ZTA4ΟŘ@=hR>* e(ʘ]V&Bܝ+jНݛͨ>c6T GR] >57dv3x*0B|H)z i.0ᦎu zIc/&N@7Ŗ=AK6-NW<#`(J&sJ5sn%Ry8d`'^wEa*O?I)' ᇌEw<Ĉdtaln%Q_j( v/]NޒRfl٘ 4ߌ&k/]c1? Rf-5y?$epRo Z\k ")sbZsמM a@(Å«` FܠOf`lq' C&f@8Kspb : KС;@ֿc,N{5Eo7{ϏBc詋OO ]}NfR E_;>T`T(4B qǠ)sw:續>p4ץ^Jwaҋj,רeg3{NbM7=^7YE0%ޞZso;N)SPyӵ=hC~ \Al"%LebW OEqK[6[q JQeS1~ccZGڌ† Phxfppu9 xH2u~.6/K$ Yc;aӎ7Ckq =bAXw9[cma%dR^5MCf_8 NȮz"ثqbI$OOўWePيCY/hK!剞:~ӜH3(*)YgG{Nf>wbTI fn1wWY|;}Ҝr}2Ci΂o;V|]L9&хRgԋYpȯ |hyh- /4&nJU C֔}1wGpk vL0`Lj{=L Qu-]^WTL2Á@Llfk 7TPuш]G*O qN( 9H)ɞ4 ?x'lNl~ROrHm}Hu>~^6IxaڑCvn4t̸mtX5p0qS@|c9/Lߌ%sP8yܽ_K4u}de_7Fໃ|^:Wu}I9P ї_\CHuH>)/e. &'6Gc_A$Hy] H-jrKtR= 76[y$.P6XCz ov1p?%ffR>"hJ,HHR MN t4J7ޤN!1.=٫ fuO rAwBy}RcĞ.e7’׌h/{Jص7`Pl:ww@Jm`)fB_c~D W6snK}oyɄ`9V[zxyqZv?3֔3M BRʻE sb~,VLI V Ol!> 3Cɼa4N)i=Iln%M3T^;5e^J6jS͓rC|BVe} "CVl`tyHJy5XFV5B(p16O,iw-2\}q0L4krF - |vƗyBρ\c(o_a$J :'Hn1VX8jR牾o7aw#2/$oύCcr2ak $qڭB=87^6npzz[wzo&N9Ԝcvel>J4Wb'%&m6=xϼ%cnjx?L~ĭ5 bVq)z6RkBt%G$ ,%]OP=3>&jvHTPJYfRq "%s>Jw{~'|d* p(u|lkl6gR -9 2g#CVZ߳GvI?yFHmΗi"( _z {Oi%:dJ866 ʬO]v5a +OχNXKs'[O!D`]2/*ffjFH@1Dݱk=|&S&SUYHqJh=v {tmKrs Xgdb*l&l/fw-|҃b#Eb[vN?49J =Ad- =6"\&&$D b7~gL'/kYL<"颉C6QYԖyi tn\b5%x)?n"r pF{ qhx E]oI0NC:yg]VLJ({,Ы4&;.*l?jz2עrqbNK?30sUgeu"2!K,”b7٪bO.*ĩ;E; Rz!+. cX8M2D7&z(奥l K6T^d)DNie2wF6Z)Ā$IPiH4t>뭿H;}:{+IvcH wm XEe>.?b&Tcf pN~KO0ʈ>bvAo1>|ø(FK#Č>5AR`ˊanfs#`$v:3%vSilThB*276PMO խ֜0px+YedKT&y 4~X H ]bHP-.\PdޔLHx+W)aBm%с7gY|ΒZhs}.5ςҹY]|x(*NJ Xq#iK(W$Z SyNg/ض:WM){ ?޷xSN=4a>tt*ڭ*vk8AJAv#hv?>!?V'B/feRʓD4Bm!TeC>ZΒC.F;gL'ZT\9!mo%WOTyrpHKO&5XTfM vʌn]rΛ+xUzd!֘&zT!Juxt]]rOZ?2n!~nj/1_\䥬ȼUs8Ep/ߘ) g&HXu+4.a=$n5B'؝]S4H7Q}L:|~τ7BqӠsp"CxiU_BװqU, 9wOlG"՛yC@H!%E-;/7Mu: > ]9ع,5P01>HP;(l:,6B]SKCMOmWyR TTGJE f7~㠨My%4ò ؜bM(Q'f)|f.Ǜl՜Υ-XE*}ȱQ+ilKZrߐjyewH۾WtJBO|F[_'ih:i?cea,Ztt"b:OҐ1FeySks;m6yUTg\X1-ǜ2'eֆ=cTrMq{{CRh| w#=h^ 9Hﯮؗ.ʜ"Խw̟S˕^y*8t?Vja@`I%,t@nilqzU8bBTK^eOrO]u?+jG|Aޱ;hlXw%'Pi~!ďGOLflmu>ja+?(K{} t`ZTŢf`%GWqB_qh _,WkՉR/a[p֏sB@їi!d; 6f|"k+0K-{BA  2 ]9dC{jkU#벡qJq循9MנMq]| 6Jc| WȡWg@R:m<n͂*H)p?LGwN7SGvA%u_1Wv_hLXƧveA2M;ډ_,\ˑ3'q2|4ZT9j/CYG+xzN|c ׯ5jT ы}NN,Þ݆I(Ѻ9Q]' olw:m*˼x,vF-7u ''dv F653lrơ3\j 03R!z)Ј7 G@s!+w}${|/»_]+!̵Iie]gk~1)-G !a0oZ:-lA(GxUU9"Ϩ"z$~w;3cΒmDu9>'}43e5+BGGr@X\LhWlⶅkJQ \&TqM: -&RA}wW91T5m/EQ(/+Kvv4tKZʊQCt¤ eLvyGP嶳;ij]Av2k|ډQAV !9D1MrCVh]|QVCa[:㥏/^*2ݒ6eBYI.$&jh".jvZqxvjKRSK|N&O,ÑsBUהP\f]]p)%jU 6na(+̇cg݄Rm!fĹd쩨|:OzAֈy||}='wr{0㠢ȕԚWe/r3}je2ݐ::DF0B&UQUYh:`'b4s;߭()PT6U#.,_[߮K'h:'l4 !)(a:,W/gt n;>>2{0{r9%%`QbBݚ$0m]ABXRSUU5BwNC%w+nZzuzfJTU6%X59ukc;z؁ _=ii=BOFBu7(:=Z⓱ecBwqd;w`Bu1^FQI@8.YS+9EL-!aD%F0%  -݌,d\eaNe^[q)t& lijOF!:rMáT;,!Vi O{x`\fjvtpzI>cg|c`oorЬjSjf^졸؃11މxPkϭm=lꢮ3TL !_]D}vg½qf;2Do3ug'ò XnfP0"2H&"tZ]v+eb::z3,I ˎe] hzE>ӆ?ujQBXyog+ߕ)|L4nLEގ(5W  fnQo0¤3!)1ov55]g#>`L-i4nj@sBP]U'n6}䥥kjZ&xxΈ_8u5D7h*h鞲f J<:I2f.N"R_ߞwo@E.9U{4w=,gDBĀҺZuS]Pj:#ƹl&X- Ld䊜3kr]Bќ\a 5e^[葴 TܐTOqNE^YdTX?.؃Tu;<˨ښGPPb\]$aF5Ez@+P1Z.x%ͨe03p9B|{)mm7J{NfTŃG6QSUD[g0}ӆg`f0Ǵxm1/?Mm\HT2K0JKNĖnCjKA0NPTZݻcQd˗>Y%nJVtpj\.[q#]q8?P%v0Ժg}<"7\|">i~S\Htu5SJFĂVݱIjOɥJ'I> PV%jhy>؏y夡l\jDYcm2@ȄJ:%@F&l6x? |q{":!'~U% INy/ϲ:\a UCe|eեsU;Xtؑq{=7DBHՌ`I& 3I7'OᄹwA0 =" ڠj썂S{jr2B9iV?oV 2!\yr/cHSReY}4+VB҃Sc~Z(zgw2''26X ӧT"ijO. ]l#ǗmifB\@JkZSJYh=t6f^U7Ieȱ؝8%D1.,vRX-FE"!h8.qd!M-6́Rh:>󾣄ө؃ug+x'@ m|f3qN\دO_:jE̞x!}8)(Q,VH5cG EɊo!]6;Z98ȌH+mElzخ:Ƿ,R':FUU(/PldY*0ʯk6].n$&뛹r|D%.J|FPG) zbBQqd@.b)%ݎjP3 7'{):38*XAXl 4,7<#h-DRZOЀ墨h \B$V徤JEScيUV*K>)fYcsy tDJK|oelE"OguYTZM1M7C >K;8_456l% $/sXSUDr`$f*idA66<ɇ2|U>a[:UŝGV. ƽ;FLlv[gbH﹗]5o>b;,-U2m\!VK8mL_ZeAvlR3jP| k}t$ׇʸ#EtSf[}维|01;^WLKPS5|vs.=j)F)ع];嗇2&Ce , !Ӳ~LԌU+Wr=iZNem8T%E̚T$ O 6P T *SY6m_.sǓ9+4Ѹi=51G>_&DCcڵX~p]8,&UFeRsI5sL+Q,D646sobmD1446DOcP'W%6iTbF-q &`w8\ηŃ<Y^x-+Wj=v eE0{j&ΝR㳁]-6>McSk}m۱=AqcѣFn:LJi9х:(8 ^~xiȢ7VoﵟƕIojbSضzb]zb1::D̗ JI>Lˆ˹D >}~ӓUOxI*{MbsQf ),,3ݔkU갏lݍ\/8/kDSsIA$m?N L8qAͣjY'WnwiB Bmuz>j"uNmJH>Yf Fgwk[;߼^x(p{i6o\Gǜ;NK/m5 ^^)їad=K'VOO)FM#C%5&E Gk>OJ/tLP;&K|_Yg{^T)'zmv۫;M=íQ !v+{)0mƌ!Z,TUUee;zko7Uȓֹt$NԔ裸(,IuN|Au] Miuzzq$ǔT/*Ln]6LMi-DOfzg!6}ƌnť`o'AWxJCԈfk_Q%{  BQ%ljfDcv7jYh4uJY-3iC_7.lى]-c i-$TI3d/JSzs}R(DB9VQze{yIW˞$!tj%HQ┘j~ӦLkjFYdQ5Ղ/ۥG9g+c !D|kgEfo0l\.~rO_.JKKC8LTEƫZi|GFO8ҒQ9#(]i&x'q<A"Cqia.-W5kQMtͨ1?+e7g!+ƝbUK/EQ &/."[9Ve9 N$ߣ x)gr, SrF6@wcLjCo$UU=xu9!Ux\ D%21xcTLXja҄;$&pġeB(Kauy Vpwm`#գhCba* u,as)o5 1wI%ҤhCԙj梮|k3'FC(2a;y\gl=[Ix 'z {o֤nj#3otV)H#.BrTVm_qG>/W+[>/P9$؈w8 ޑ0fz|0%V+W^yZ,G}{=mN_ݶn-m=ϧ@ IDATÊ= u=g΄j=xOTOy [ۭ>VZGGPQ 5 ƿT cngMrSA4~8 9;~v;]]Ԛ][0.i + ( !%w;&[D׬Cknh4kWQQBuUe7E{%ettn+(J8:m\n)}]YB[rŨga9ԪJp ;]2,6QRH2ad5t8`ٵvId9 !Y49spշ /︓6d>2xk]{hLP(uA5ȥ7$YhUŐMMyt}<HmH촎<.*N~T=Nv!Sn w7;Ħm70׬TD$퀩zB·mf̞\ulٲ%7xԬ\[kGD]45aTgU(Ztdw٫SP m wdzr mjΞkk0wr)EvjF dg~nJC95KH8xӳ_Q.?GUUV)w>=,hze޾x=E&a#BfTC"`{- z*{V5'6zh*\pp{<͵\zj{% ']5LQB8<l!+t-FONA?:+F|:q-Mz I;˨!z/`p 'sr܉'9{[Ydɰ#際x7wqUu{L&MK"k( ~RAAQA) (>*EA*RJBK%if3w9?M:LIr?׼2Mz9|{}.RN ]J~;Գc].#EK{,{=av,BPETޙq6Z LR~x PPѬY{Yd ߻tw1?!=X&MD0@( lh4ݻYrt)cU#.>jĺ'SYaٙnʬ,h?,"Fзx#%S=KG}ѝ21$J !vJ)?H@ ?A:,z)~񳟳wnڸ'wirڬL*,7!iJWS鍥zpV(<GR61.cu;'thSKgQTE`X%Q!}昔9d_**͸/EfjVZų˗g;l " S̛;߯XbII:cTڻ4V^m^bv Ȓl%4~U$YtwvK er"lLy383yg#I<4M~?Hj^'Ϙ/n,!eӛ̫ q̠z$(}F"-!ckX9 y !,)mf*QDUU***\{Y8#Dz+ZۣyQN 'C\ێGd=EvZr$lP-&,Fɕ'Bg(Tzd[XN7튦χߥYlr{:I%k![G]T}T-@S%z?[ dcwGk_G;I>!R*HZe8S}qCǬI\;V2~YX}UCujEZAԳۚM0TF )%=1{d ӑǗ>Ck`tTE7DB–]K`59\z2F]z*AmW C(=4 JE/'2d>٬?yUa{wD!ha8un{iƖ}utR /즞!odeν$=ڏ)M* ѹ&rUmDwn !ΦimIwSYo[Fr, -d^]Ht4HtgH`EKj&#?=L~ܴ\ԙYޑjAx0ץd㽤PEdRJZtcOwi5{z=5O?e^^ !6wFJa<qnz?ܳwUAA*S zڀ=9=p7X7iҲ&z{[Zc3m\ۼk6l SYYɼyhh( b27ϼФ6࡮<@8E=BP*x5E؛&"8ƃ68+1N|rv 3qsG/L&C&~~ףX¡CKepfٙ.<(:!<0D(ccF:I6:l"h𺷌fDX<ž8iIںlI#U!~xљLǟ !'5$`|kO~}0߽,\02:nភ6Tн~U%Qh $YK[ဗrfNeL'ӍD QKIrۅ36R46LӢ#iZ3DDZ?diI4ۺ$u|S]*d( !)eq 7ضe+^~ zj/on[>8OcMpqxG$IS=08rjv+Cռ#+4tL=IG QEo%4:/uX$&슥VӓfpY'd,M !N x~sRzϝ?c^j%FKo`C[/<MH,qГk 'NBfL|r4iNtӠG YUyU T EQY_JLeb&eӘLH =ry+ bR&Yq/RB0]R,u:R:ClVsNĦbWqm7KDlers~>E~wǏiIt$LLd ˒ ;O[0%B8E?YHHIdA'}7!BZҋD96+T: [yF?go7[[&/5w\ SpyR%Og1v;r͵ײ}> ַ_Ņ^wtaʪ@SC)yhI2'Œ[8M hCBb'F,q+ChҾ8#ϦU/[ 0z+tY~WIRr gO寱V7ٗiHe $3:c|1*>Z>WՖt#d3$#E@,e.GUu3MWah߾;,ZgdIdmua;K4UaVc55'T(ӴhӅHi;$Һp ;.;!<7sڼaɔ{X$60fػ+BJ%nɓG6S*e|ca!E_/ !ۥ b{֭|YE[OᦛoFRN---\Oz.oZq@BOU x{5E&%,0`CB0eRJS ӶȲvVj&4;zSagr'hX/rR[[xL&O?׿z#D!|6.C&4,P x4~vk^?~x42?Wã)(B`IaZddyhT8vGQQTHӲLeM OKôh9s2?1hG7_3mB!{a?֭k?W}ꓼ;wmt]gݺu?e? qykn濽mÊ4UT8H/}~o~֎qW:cJy2fN"\NQǸsH)9J) 0 V\=oSٳЇ/OJ~!T8cÆ nbV ˲R3k:+xY?\{qB̌*j̹!(aӖ!~~Ϡʦ%'_b2\1ch+^Kg?9\c*K,w}@0Hee%S>sx<֭[ٻ{XW?oQc_zv-loXB@Cm߃ףT$ѬIG.#jCTgux==^ʜyT!l-K7%Yݤ'=t7fp;ObDYeBJ:tBJ$ Fooo+䒿IˤӜw眳&2̉4o_G+¤OC4&%%aM:Yz&aBo5!j Fg4UAUK>l֠=;cSUu{$'k[ɳۢ8|af.q RXbD,b(۶og֭l߾ֽ{b躁#0}t̙cM%k6-< drןs+¼lj=l6#X)97=3k9ؙ̛Z[lp?R9O8~:M`B]F)B(ʈ L m{s ϶tDo Q,)M94-yIr+lj>xlQ?$Yvwd*n^ ϩr!?e̬(D GéΏUɿ7WܳϜf ǾDP_ٓ+i,x0M1[:tMґГ1]!g~m=H(p 0H_W^ɪV./'dԩ=m)UOhH)H)7IaڵrFT9ktnݺےR%꥗䜙MrniTYR{%_MDB?=呈]}Gbe~BT'G?~iӦј$p ɕ=e@KqeeeNI4-E(\ve~,~Öe5B!=Bk,}:5uBfaTJ)K)wBKKق /R?鼐R*ؙ@ p` ˹DˎvPRTT`&G#3(//gt /k9da"YRʯ !s>qo`)e9p7Rժ"y4b%N[+}=wL& #/R|])9ri9ROav\H`DӬYj&5;S%&߀X}>3tǒ=J:DbK.G23hm-@ ]6p>}\~,+cVVvyCxxuxӒDnRk| XTRӒ&mr~#vuFt-7_ީ6#r_B00]bz I)!}|K rrIb7 l&CwGhL&KV덒L&#?d>ݍ"-xp<了RwW_^;5 Aas\/&s}#=}<gN<䅧{gAF7:J^ƲHFk\cb" V XEg[QU{iYWV㏧q Ni h s~˖t/g`(Xj֯])o9rr63j617R.PD7Bσb=dR)Li`P(Dyy9e TU)ZQszi޹=--iiaMm[؄*E`#IJ,3#̼B8BW\?!=aHj-RMH&d2 @:Qt@ @0>1ǯ(T5_BltU}Ú!%iRiDC!yx#c{3ĿS!@z.6C̿GQ*J1TBKk؅ :_"S~қ<[T##|uZƱhH) ht$s5sٟ`I-P׾8acG?R~I 57־Ck9+_NΫ.GWb(1[\n YT0vYx\~97?]\SB0 w%' <\|F 8yRv\ +|ƄSEBQoƺԎ*D7v75lLP)%ȓ}pqǀEB)}t.Npgd\!|d4c3z>1,pbS)]\Ovp ylCL PFIk* ..nv[ǀ?!#J>>g8?sge/b[)u}JY-s~wv qGj<< $FKI|/pfl`` xvc,%%TM)T]2MP"ǀ6`0]OM@u^(9B$ʜ#BY }%@y+v9$&]즠8 Ұ2M^Xzݻp… .\p… .\p… .\p… .\p… .\p1RȾ:IENDB`ffuf-2.1.0/_img/ffuf_mascot_600.png000066400000000000000000001756731450131640400167630ustar00rootroot00000000000000PNG  IHDR TiCCPICC profile(}=H@_S"Q ~:Yq*BZu0 4$).kŪ "%/-=B4mtLĢb**^G?z0^YƜ$9]gyst>xMA!i4> 8 62ܣ3#i45e>\Zk9H5 """"s2  j@DDDDN8 EjfD@DDDDw}Z[S$ """"sYem :E> """"sH&qƘt/Հ̍U:[ """"2m%2k:] """"2-(l1S:] """"2 x֗Yk7O!9K/K)ҸE^ZΗa=&[T/y,z~1+1b>t.ԓk94Z 4 Z{ p'Wn&""""RBbkm=UkzHN*aZ{ZLs2+b#b xxCKqaeѥep F7@; ux6UDDDDdK;K)FG ;hy򖙬!ֺ(l, h]+WDDDDd6c ?Nܢ4v,Oz gxƁk1sywZkOӕ""""Rñ֮>5(x0gb81JX^d=KDDDDD5x< |\mAiF1gsAk>VZ<)PG&Ӂ; 0:-NVp> fOWj)Q[ZFY{/+1> NF """"2ScIYޮ_5dz ƣJgeHܷ[kOQR遽@B닁7(H Î wQ3f[=_nn+^Vd^ )ZH ÷gPyE㸲 ;+&> BnKnVB02PP@DDDDvx> (||!d7Z{2r~ΧDHx9Nu巁 v}f8 E<.^ 8rw"#HG+ LwBOo;zCVZDžYXƭVcCDDDD pмvd.U^S' l"7ZrҔkk[-@DDDDdJ{qmX{aOf] Bo7r 0`?_\$>܆B.r|N@DDDD?xw(6sl}=/ֆ<-d }b܆s+Oxm ^@3 ZˏTλs7.~ ߡ:zak:h:d0/> """"%'W1 :{0k:ԭ7cۚz> """",|Yh,s[ZHCBq4| ~Z=?j* lGç_T;> """"s<|>> ?`? ϫ݀~ @@DDDDjwkx ysW?72|" ^hlᄓ0Ok.II+hl7aBp͎l&C_M$* 6҆ ݍK|hCƹ4#oq385>SǬ'W,| v~29 V;gsO?7-#SUUx(Q \Td~ 4(XNX7빵tcp,[ rΧϗ~ / Sנf $e5̭ / ~>3 e """"dM{OB{W3FG$xOo'{}x7іk6 @DDDD!k?deOO|@(8_v`fmా܄k'vl[3d 0\U~b>}@DDDDrc9# \5AӃ' ,7!x#DkJyZ ځ[k$Ag'A[+~sg<ݷz! ^gB@DDDDWp >;[&ι`ai u@~c z{}}>l?6ĦRLi:@00"r!b{zwAG6t05eqz;@DDDDwg=|3tyL.Hj. oFZl`IVfw$6? QgC^REc ]DDDd7$c')av%z҂؈3x?FVH%1ώh95'>O(Ro9~ ヅ]9Dg̮̓]do#{O>^ 0.s.xuk Կ)* ;#aX0#R)۶~~7:!`0Paf&%ITz(EU'g6| X&X2L<q1| kl8=3{$^kS~Joo {]ߟ (\Qa J#s*#D\n5tη;ڕE[A$M3V^|ev=H1jA^32eoTTzϟG_EY?g"y@^]?n0araHac02xY_%VDDDDd~\XöX6XrM}z}_G`P3a}<}fdX3=籠V*bہ(}ūWhQY +yܻ CPr`AAb=*N֬^b,gƘ`>~@DDDdް֮X={`JSͰJZ>~{wAMMx ;u<𑛊@PDDDD擯Pڏ O}hTq&<-fۤy1v(g02g boz+E}6RyZb~%(mŰ_ R9~c6whZYӑoh5j=+z#6cW """2|b~t턦]jvGA@-髠cZMPDCVYƈb-AT9Zk(F 鯁Mng_GȨ/,{o~&7GD MfC_KR%z)k>>gIƑzL*8QǴ]5T1p1 |0:\o~uNSXk>R)v|H|8 ~q{Q4aS!~U c@DDDDPڏ ȷ#aH׿+hpo, @]$zIZ|'u}@DDDdk:[_o*NOPl} v~[]˂Îx~ƥ^Z⯸PcC """9d3!` ȿ|^+ #'y7tUgqw:jx;1U""""4p8@XlN gP7\nyp&?14}kG E spVtH|#xj?@DDDdv%i|T:3Pjw$iO9x1hj uYǨTUs5_'YDDDDfGXx=`.6'rys;sM1п kxQZ3>$8^ی1D+Ho(/ͻvX"TN_6+c  XS⸄_&* Xk1V}@DDD|re<j}5218_ngs4eZ1B3TlfEƈGpzg}@DDD """RЈUˀ/e&:OE8˭89[GR&:w?p!}HfVIJaB/{5TWʟoꓮ""""388ܐ94gI@ì 5pXc\gɅR5*6jwɧ@d1> """Rq:pf>hTfVX1f?_^=[vwLhMF 8 Ǜ߀x?\FWՕ ˌ1{tPD.cD¶35#E7:Δp=zzLV"6Êp_xWڵ`R_!6ܮ7x8f&GQ& c;͂)`Y+5 #T=xgVAG^j›„f#S#(Hq r澁r67t/پ~Z 8fP9:\8aM AU:K/p-VP(f? ;'A@w_]lΫ SF=Cbo?n„1{FwYG k\&u1 RgMhR1Qx1&;>x8 N9$lݵ[X^~J Uom~ 3jpYxgV1:ߩd@e5gyy='QRP"̣ڸٗO[dk?s j7-{ &|(aSKaHT§Ip6tm‡p.p.sG*-O{TKz ~ٟ]XK*G{O֞~ܫH(uĨn: aC.1Db ȯ;X73Z61pB1 ^p6!n=n]]9~"""" 2Ynac؅@ayxǚ:./;,HfEoDS]Q-]JhD''Ibyy^^Y<܆d2&D"H S]SYS] ˡyx>"""" _smr#O;ckvE1,2Fe<8lgkyízk A۠m$!#2bst 22d s΢bfb˗YPUq\1FOwE?~n>®+;>|->"nxO߾un]cXQX$4j.0vn;% 88uXWS}1T^GS ߵ`=8ˎ'tTVK2e_[-]&x>? :$aqVo!xj߈0T£ͬ$mDMA1Py,l9"PN k:|5ZWNk! [񟽟`M(l)fKd={wnkxQCkH8[3ސXWgD|5, ==ma/]DwW:jacaBan{ tcP'q O6K8x_✇w7q눆g[B`mCд`Gk5sVOeӁ-n{)ǾN7mY`<1l^\Em":uߓ߇3fXmA5㰸5`RPύ:fX&5uzn=z dCnKs'wscò#/Bfm~> n㸇ҐwQ7{d0~Mc{;-{;Mb;c}8+%~~Tv6ut[;hr G/*>q\0k+3y3ό L~r es ,2;suYWvN?O Aogq2CDDDD/|&V[^ۂm ~{졕06?Ǡ_t^Eo'q+>_br  Xߔ+Qlo,x@w 9<., nt<鍬y˪T#ģ!BȒj He|R,-}Vǧ>GcLYQ,c۬m܍yv<a#C=p̱Dv*Ƣ4rqSJ.IL%0ZLA[jHAZ. > IDATg*0(܌1]Зb1G &kZ?(ݹ7x?ۼ7"a}]u͙]aDEw3yw #źΡ2;Cx {vq׼bzc |^ر :}Yɇke.sz?cB;tPX,k KOeHg PГLQG},EDmsN \﮺fʹ%D0߭loU?2ha :lwHc/𒎝\,} fn4p1(T0h)c OwuPj߀J^?M}_ i_b ,1U+ W9ıGt-+fk!aPPeͰL_0E!Ow)}O3oܛZ( fMS [8#V/Ӆ㽙e߼lvN&|I1Չ+>L9ν)^vլow)ԧ,jheġѩʮVd ?%uiVsC ""QM=7˱KkXG1]c6.eԬM,z'ivR?!{+uN:_ drEXtԲ9z<7nLnN1[~1fnPqq ;Yn=ዾ`y.~j@DDD^n-ex>]yXXKqrF.U(ӟMvؗ1iтnǾ"o6_EȦ}k=N{;N9tN*;Q۠U${9놯DQa մaMveAl |qm,'|r?s kZF8riMQ' tCCm UTUjF){RC!**ӡFj͋[gw|{Ǒr2z ,]DF}N{:d{7Ú\Մ_-ܕGVG1>:"""sʿrۺ-.:l*rrܦen583搹*CMuEAwV_ކ 9CQ2BA M 1fX\"3Pe֮>fa;a͋jYClZFe"6<3u.0\s6^͟!93;Cו{Old0 98fʅiݐfjh]pֿaUON`#7@nọmCAexh\u2xA`IОAkB֥tipĝNV#{_a]3(yd++F#$ȒY5iL]MO|E/ZrcLRw<>Mf8GJsp i ,kW-,a-xOoo]ib-lG>%cٽ|Gڹe[%N9gZ+p#wʦ^ة͟qX2aId#TmN}N"JTuc̟uQ2 wԚ^918A`Z ش~1[-}?>ں6OU!Gl<.`us3Ƅ!m#-^0pQѰƙE}p־I|#8%;;N"siʉpRh}@訊<:bv&llᖾC.p"! 1ppx|L'ΒJe"s@2C[{/zd}9&%ơ/IJ"!/aaKRi2ne"]p>wzpJz[Ƙ^q@$, $BgךKG^GׁFC˙>kyQ~M8 ,"fʅċ\c%3eس~ ;RU% \\8ǀX? jy2,=}i:{Sd21lizCM.TX'uX" 0_er؁@5wf9͐,1X„qXpoȇw&?nOj]CdB‚|H`H(_04 9Wbj=~qV|`c '#Y\u x ,dd*C2!d>A` OJa%rFBD"a"p.Y=;ҍ?Njb&L|c-'tu'in/v$n}.~^ I0?V'ѺTZLTDɄc½x8Oͦe3ļ,1#ҽ{mg$^~B ^.Ę 79c`M(iqjv8 4: 1y\3JT@y (iyJw8 BBBBIO1HѿD cu zΞu_*U/j!=Zj!a"C"_lyC5kV-)ЄZzҴO: 'U7rCE,L]U8UU1B@bm?FcH5;Z{Rhٗr^.旿êIl}t*KkscAO"dZ? RC)\IK44vs ӛ*޴O]|*+f \d[w'o.*$Ƙ~!_dբ.j=u@n/<$&TW@9Z8f&ߗ_dxG1Q[#v-t7%ظnqA‡~tݗε2;j86Hg<|?Zod R_QCVLxIںGݮNVj ޾[7ogo}$Wɑz)nLc;ly@L}8訔)!7*7Lu%QV3r 'S}F??HrHdTCY}ŝ|~wo W,H Oz$rѫ *ʲΞ$ՕQ$*ccaB;f kfZ]=)zSd`@b 4$x,[ZK<yH6ҙ%TW ]4*Ȳ~^\[ÏDxRL;1wΣ2&/B!3x?ScWaq@u"ߣ85E)fnPPrp'p/4V ̇ \󧇸Cj>GWR_ts\Fuu0@WS]a4u5ղIG*;ATriD,+dv;˽6oXBmMEA& vw'ڇ?/r ׾x=v‡rrGȬ!$ka !7)ؚQ^;h&-0мpɊBL2rMf2pL6~yH02}-;3cy;51ٴj ou 뫊24m4#vYe5mojv`q A-m='w7_"cUgp[> &7\b??<>k!|pyu?\9Ir#]ii:±3CWpҪz)`~e=˖ZbY穧ٛC.hx4Le탖>|߁gq`-Yg_[x\c8mM?1TWFjea{>\#C1,KP_IUe|~k-ݝSJ=)6 ݝ/=؇x1A]A -p">Hm׫5eho p7.r}C,=}UQҗʰmO3=>{,AGrUM +\N$bq5U5,G&뱫 ?d|s1xKkMLZK:;nȞ^vv~Ȑ_.b3R(F@e5x19Yw1:"#~R} xnp]?ivX*ˁ`v:b>eaJV._@$UXٰfOف{3~c:c͊TUƧ?w}-=vÓ{;hKXbb>~Y6rj|#!\p9pnqdtYr~3䚒Xm\\~|& 5ͻ5 ,Bs'x~@wO~Z:\8ưU S\6\ =)m*`cǐ "t$ż-] 3<܄X"sA *TP߀ 7L!r},kjr_U[w_z9$p1CK<䰼~{1lްZuz}46uJg&Jl\ʩ=:7Wǹ+E[p1f xH|\Ȝ'7c(q%;{y߳\Ms 8?ʟӌ=qaQ"JU,L<":ònZ@j]e{_k|heI i+s %sqG\oyxP t$d>|o|(|LO!Ƙ2<ǛȍTWR,C|FFi{ U1*Iu2^T`%jv5?PY=҃c5hX8Q{R昀ܰcg<rg+|;uй]cPW/5Ǯ"Y%/ٺm} +ٸn񴗕Lfx}d :iJz<{i-o{f^)꼬Q)k <p !!;(x㄄Tt -d &Y[KHM v3=3dB$o'7s]On7\KexogC~u G-*>o!S_IeT:#O%9a _==)ݾo¹B5w+6-MG]eQS-H ``u'}ᮏDdvR{O$ݫ(Eg!bMRW4d%K|36 >;sCjF c TrĺEw{}wcWϼYǞ>@}mG,9e5t}cFlil'3UW_c.YW)â(HZmЙ!RUkkٔHb.JcLZ(Roħz<<ߧ}$~r !8,p]]Ph:ƅR^<]1"Bu"J<!vϝeFYqle8>N;n5üN?DS[OgI7Ez8UꊵK1M: |XT]Z^sa!Xj @$Tꕛ IDATW[KmAG|o5(=Dt+gzAL4;ived/z\r*a]$H( (% CQ[k"c_+_{ S!_^K|pȥU1\)Zs05^?Ǥ;Y"l|"4tlWǍ)>QkQ__uX-7NSz h㹞RWcAUQ&|1: |Xljyi܌3W9)0\őH 9<|VAMV&YJ^ZlO&y|_R C1l2/ <Ƙ"[r~p7ӎ!cTS3A{|$-u:GZva]P&*2IDa!0_77<^o`m?uzkc,1 @VJ&wsGU03}~{==:CVVYE-@ (F4$gUa [p4hĮ'J/2֚kr2^* }މe>|_Bśǂ:+223F' IS}Nq󘫇$*e֋et[]Iect&qhX*\τ![_n5K%)9N:փ vuq}2!Z?GB0 y{>\g2q.+L&3V13ʤ%/l|Q#,ANJҚ~pȯ0KzlRtqN%2"y㨝f҈3xi8C&vm\}dSo_dexddes/^SGz1*ZdΌQ0 ybΰ6eaֿPXC׼)Q>ʥWdd~Y!$}T Q+9;i3Aް!GFŴW_䀛#%Uaq_2٩ ^>1ơ,872ѹ c9K_wxnj |" Ea0dx:͙%R-xXd[-,j* ͌y%KRYP"ߜԆE.BpG&Cيr83V~nLp0ef_vM>=c-Uޣp5A7DžZH@a(" )" =]J$Vu y.MM!BH֟ !ܰ9R~Ěq|_kJc'z{9ɀh:=17]a[&v/h<sGi}^a1HA$M4݃aZPH%\gdkD"*R;~԰sر$>L;Ben8ڮSf;͈cʌ.;) a`K W6T஁'yO?tEB|*\BHFa A0,T[@jͳ"Rg 5 C͐5,Zf S)ՅF!YZ8 JWRM1_a n:t)|񊼫Ê&IL+rtr\D) abŒD]vt,Ӛ^x\˃Qz?\':CKkOʣ.g-W] B.\BtgZ@082 K+B'ScwbNHdrCG-$k|g=Pu8Ggo8p?Q].EQQ gnv,Igò[y.&_B"PzFUKT科ŊWw]EY7ϋ(V֏Jȩߟ_@З_q.# 8`JJWPBe'&8Q.Ql}.']?kƱU.!E,ߛqkZYW3 W]^}x)#%/!>}{Z|aUk޼K Ž%I'ٷH2a -0-aӒ*X\s{}reeU+%mMk<شL&}tc 6Jř۹= <@nĻiBݪR|ifeCo|{kbwGl6Z݉bc@Q#JF?wFtC~MJ&ɾ=2!V;$"=t!I[=Qgά9I- Cl3.0! >5G(5sRoZ<0#1R{ iZ${(Nbky8݋Z!۶M3::*f 3gipGMBa f1bRSRXBHK9V2$!!Vj˜q]fC j!Y_HH]"8Mm05Ea:!bnp@ 'yLKv}052^Nt)d"t:ָt&,2\0hH>Ͱ%I RԚ+/@|Y#1+ u32_sRZ&_/*V2҇]N!U)JmF_3zq g}xj;2^NRO&ā|s~>,ýf.il9Ʋ&JSѥoqfҩôH-IfMrx)Snx&P(uŴ)!(V\Sf]ʻ|!y!, ls{f,7$ T)}==$wF3$|ꄤ "胞TB1K0ԟV93>GY1jMoX/#x!dKxԮA2 34ڙIif$Q-ymuiNFҜ2T  i$HRi&Z !ra FqCwHfp1`@ж@m>kcFV䮞¡S!PZqɘU53{R)IZkf^Z2,1`dfpF^,Iz`I|V+%Ws\# gD(Qa%;6v'9y~n՚5?zMɲo$t6ݵwJ7dCO7|쇺8/Rj<)?+|_FI 4Z*D$hb"4 ^)^Qh؜6m״t"|QseQ_&j< yAvl<2Ri>LA %#ЀflldQy2%}p9y|~juuqMj ֜p0JSu}f犸%2 N٥8Z%/ֵL/ D n{vʼnu%Noqn!|ʧ>?6J-gfT9ߊ+]~-܎0憗~o=s @k2W(fmY63a1! Q'8uۧ;F&7;ao Ű7ȃZB02_ͥޮĮhiI)bnP!Y{+)s%'s19W&/қ, aҫ@A ͿO #)z{cQ=fS[~q Dt94.$3 6 3ۀWe}; qifM6ngG S މِb `Sqzc$%9&2gPv5x@2I(Va,-jbN+,%iBӌTȫr=qr~kio2ڠ.1>n!~:%-P7][& x3WOX. v%jjc@k{93:w9&?~ ?z[;ab8FZ8K@llT"Gfy SUG3iTy_t%GzNTm59)Η'lWWGԳmCpp567)_Xaوm<]4aoxm-cS'Ãt%y۝rL~Gwx/xa>HuJQm+_#Bdۗ!fdl<ƫ3E RLʳϝ#j|n7PMj?Rxt4XyU!\s)V~fb86ມɇa.̦dH@B.q&` VI(Vn~,afS/bjKw!h^T}_Թ Qp&1}sMjͰX僤Se+,,dz& IDAT7ߋRmbp/A@ i j !Y/͉ӨuHi&yari,Ǹ5WEk-+,|V-Vآ%x0ݲ՝iK{_%~8:ߒf% j ּڦ'CGyMEn^ܹyۉcWt"Mm]2 c{!=r~돽L8lFųA ePz1E8-?یyKBO{  Œ÷[s ]џZt8rXt{4` fh4~S|wsS~-η qq_ïK0JO^ގ'W+92 Cp=:{1-3|m|؋CҰyG@ހEmF>8_|I^򗞞me';{{ ۟:?'Dii Q/WW~S<䳼7]"nUV[SܓN̦]&ZDL|BlU/ jVkHUJZ5w<!Nq|7q Z9U\.5o5YhJU%ϩ{t:5ML IHz~ImG;|tgCX+6hR0L3$K{_kB6 Lk93Վ/ˮk ŗ\Ч1!Y]/ [EP JkzUKN[އ 9~0?r=4ihך\XZ`YhiMM- 4]g$4[j~/ٶϖަ_ihPWcL6^5Zo* Io?9ɱRi +EA)zZ8$Fέ6 Ffp+I-$-$l|:ϩ|׏XS?WwIJJO!-|eu̎{ſ&߸"327cXFB2j24~3A:Z`k] 3rlitϜod$R|ivfgqW [7Mb-UT3;ڧs#45/spyIN4T!aX}݉5aZu_q}/Pa(ސ Ǔ0d H2;W6WGJ$uҡJr K %=x޾IEK_tíz c2&a2K$rZkM)b|x!t=Nri #*Ŋv4 @kQRa-o%x=}ٗ g=99su?JӚ\j093%M3J6MZ)}HbxO)y4J7WY+yb:ݑk o.H#Dbv.y !Zk"ͯS^"ɮ56D$or;cN״+<7!i^HuƷF@r%OCp@).k j I5u.IIAkHP/)żRB\l׍Q\ cЗ>b Ikɓrg"W \51Fh_BVB"k΃$ӽs%DRk#%o/\:AݲĚy DtB49ֆk3@`۹bq+J׈l]Uyٳm#M45MMgjހx0oK9w^=$ײIf5ZgzZʲ<b\9J=y>&DqF%ON/y{eֿ*E!Ϗ<OAɚil+1zE632(.j7J%\T 3aZT3% %>\8ͨX~!P]ada4 J}Z!"b(^o]=Tƫ'h6Pr҆Aa0' j1jeҼDG%Pѝ+3u#NR17laFKat*S)}7ѹ7~T`b,27pe^=^i+H/EE̊WJۋZYd$v rTG2nfN)zZTA|+(\ZUfU Uc-էiCp`okGyh˒gJ%wDP6 Z)yuG\х=GS7R \;t;y#} H̀_m~&*I>?k5ȦVT5ONjc|}9ҝ!k_kà C^3/m+$Whŏ"R `Ebt""oy# Sk z;1vn|/k敓s >̭osSH"K8UI sNU0 D,!iy% UW'F{$AI!&u!:IL2M, bDLekQ$fDmaQ-Ө 2jbab Ǧ~x|#˾w_a`3s*kTpuH;l/N_MDKB%K9 WJc/w+ rFyAsSujo$Q=|_C65zd"O/g9sv5DGXV0L.jߩ,~~:clu5G]M䐀4}AlEU+U}o;@:F6A)aA2Mk0`TL[ iMF6 L6$S3: b)jZZ:&0P'*%cjԸ8Rߵ z׵\ٛ!b!jX *T$"h SE Lnsgavj2i{Dn'5= rqQ"woWwyqe 7\{5=}^Iumj܉T+ DEf 'I)[25@u7 Tⷸb[6yUCKW:@DDi2')gT: ju"2VLY.r{o8D:Bs{zHYlwfS\J>5SDgxkǼ4 X=y:?IooB` Нw;i S1t J+LIޡ> i%)M Fecv[w+W]we&K2$ !i \U4xbK>NεOFy-W۝Z)j`^Ԍَ!Rf}UΟhWĨ=Yы-Cצr <_rvbfꡜ;hyj+q=I6q=I!DrTjRBz.ᆗHUXGw<)0:6)UCDvP\_iԋήDInؙV݁j[̴HOWZzzRzij媧v{nO$ykI|71.PRIg<ْ{,oi*#!>=ͷN{^`rr h&x~n]&dS_=3NFx(֏+aqл aieM3$uT 9Dڰ2ڗ+TNc}|؛[C͘jm R>xc׹1Qt]ڮB85uL=ZE Amn5IӱVUWujMp\_bG48OZtz(dyox8x$ږC<Ϧ.U'(*xA\ap"uN-_MUj ;"''l全6pV߰'ggv s}} r*m&e}H5EkyCB쥶9}Ao@b!jC: +xrt%é(Ed>Y013˃_O|cǎUx Ė; 2YX܀9vjbqVxAVZ P-̢Zw Q\k\ZF3QiI`S]o jI:+diIvZHP\CB쩯 $\KΫ(PR>U)s~k`ٙ_'o~9<]s|ren yLLv^Ra4Bw)mwjM©JZLأ[C-v>٢%xRX3 ٖ-LvY 9KP QjW_55WHU3}A-QRvBi8|FBf[H;Zk+7_jIWdf%/|+|_ ҷx㭷q+_ZԿw]wԄw\?u/J*d6Hu*ƥUԆ\agv 7Z \o{ɨZ(\ʳ${Ҵ T )/R(cN1XcwN?;?ok@ kZ/tҲE_<}Rx^ÿzw }P&v3p/>=UUJzի_ʊ#}KQ)Q|f|kDm{McS EkEgF@Ҕ{H;(j$YxB[}d$VW)R%." 4n)_]ɰ9m<!4X٤/)URh`3@أBhl5@-i\j(3RRj/JFIV%kZ8~OJ*Hϯ _Jҵz e9Uj{%ou[O~MvhW=\4~C=[iPJRYm$s3]#J'"H\N@$P\[֚Bes@soVR %32rKhBq"s醻٢Au=# 㳥lO[-ϕ]5$rD41 40 Ӵ0 w0cteHR$ 2 Xh4F4%FMĶbDl {t FK݉=rˋ&@"ay]9TnX$1#ŋVL1W ABC#$ "X 5rAkѪI9'K? {% IDAT6|ղ@*[=)s:c3E.K6 Xl>ٿ?mcY+,nXmHulώãW-8W{B~6t=))ώ֡'Q-f_fy4y@vb^5&>`HEtB6týx-LnbԒ^&tVJa9=eqkx{n疷M-Kkt$1/ RaNH<7[-^4luLJd 1"$ !0 5sRbU+kh D,˼ љ WJ[Ʀ(̣J`͗w\}R)Lv2c%pD4e$2?VX&7tx8>ܖ晴|VIDCΦfht[3"&GZ4\)craHHݒ|h(i_ {& ˃>"^6:](| G-X  WJYfi$[/#DvK_7!$qٲ9uC(R mJR z~jR0q8lu攤,xL5Sjpн#Ay!Dw+I拪u9 z-b%x[5\6:P$xf5 j6 h {,cv//猝Y27Iq<o''='?diMBwFf._frfYr~E|5D7, & }AY'[COlc 6Nd=h]knjl$i̷M57,a5[8uxw+(L)̮"f{R:" ݗ㧦~?1՘sD_wNVEm R&{" e$7 j B7+5w^d~WƗWK"_ݕUu /Zw]z gOS( q;QRcgǯz1d,QMUkc<ߧNZoL(P>^C,b}Ynrc[7N|Yf6 E5@d&f[$ J" B^7Rspo^WZVvY~4aߢN ֋W*rxn۵jV^0c1\B(.! !IB{CL fl˶lY}S?fvU2:y֖vWgo?m m׶Mo7:&~>1sRyfe@,I0vìƐ* jQ~eP 3Ux>0XtqՎ />ii[quT ZKq<ґ^ٳ zR)}YOݏ*lcw=5ho*XTH[0Yp2&$aP6> <$a \DZqR0N@,? ux u6E93PF1Y$m'"F0QʳP+Pw:1Ź/%I|CsD~%/]ү3' ݭ؈G[V~NUB@2"fʪc%Pϋ,`[*eYJrBZ\.m٘ik5ϐ U %F GQ;7}zztgw"B\mnLjLU+ceIFbHtdd&,caP9h:Xdm—^~Wm Aē[ ">VHr认Xf- :5@ycV+&x٤c:Q#wȃ?FnuuAC8B,#DHuvI3GؼTWEPh )"p7\in䈹V#Z%j`-EF|4-)%QŬUy?/*~8'=#e~Շ_Kq  ! @5@w;M5@|r4@jR+K-Hgj)X˙QluF a=@ @,' e]ƍ[.2 LGo}|lLKA(pHn{rzP`l!"EE' f*TQ]鞌ԅ@уhZ05 UB"%RF ^ŮUp]{AAEтxl){?[>BKb;.f(>{9 ˹U ]UղWwkgmuTuitME=0Ȗ ]]IO)Px8@>s !Y4*Ұ%Y1͚U#h DSngJDY1Fٲ+ߐ Lljżb3A:-k*B_f1<*2@tGDv"%V윙ؽg7۶ogݺűf!PZiJ7]̗yLUU-I.BB{Stu&дs_EU@ Á$qfUbY۩ʥ)A&mb5߀j:/UC(tMpZDIA:6c7 c[叛)4@2xuZFq>UDI@TӛIczYQ‡O@lX*Kݣg >`48C]!Psի Qmep$0YfA4:/7$)t*EA W|iAW# 'bF4.~ׯl6K6EӴE3VL[B/ 1g. 쌑fjqyD#E&48E`5&+l4t,y5H 0֨]T(jCWEQ%SubO;9c7-] p6uW0E9'xB#<(P0Nϊі)Bķ2|2<T} ۺ *!]EW*PT)֦ }y^!kדK1(ٮdh5L U ƉD(I=/@tYo׸*cWHBe J,%{֖l,08xJQR43rhN\SFO@梥J^0kqe5m:F*B+JH&m>q1iwj8mXp8LWw7]v6n.fxTCW1NpXgj9% ΋Y"c%"!tD"LIFyvT=z IԵkltUnudeM+jQ--q-LM(t0י͙UuS|[J@lۣZ0EF鴣}ɺÊD6Tg]:B<{^WJ5rD* xD4D( i~ XH!?@E.NN]'It `g?ИPC0Ǵjx=b̉-QG' V^,t8{5@iA 9TA@U h z3]WNvHh TMEQTU5S)3 3cXg4" \YĐ=lH4hizW;ѱ\_\R< G>Ν;i^xܕC?|(X['I5afc*%026D*N8UTEAQTM!s+it* Y-.]Iku,$]t@ir"rodTFY)AJQ5(tyV=X`M Z c% mjD3N\׈*FXS !M#(45BHךBc'Vib"tjGAO=9fJt]Z)C׽千?Svq^ `cw҇=ͽu wڐ\&_/T+D$Ab!nK#qm ˦8tXho"\*U-q D \EµKttHMJTK@m P%lR%E,TΤc6o./̛ȽɛO@ݭذ"o/^3oyA1o"*EOxT8nm;:4@oוT)w /?/GXA[j=*Bu XX'X1fdF jL 0$W5@{ǼFYxSǢtMeKod ɕ*Hr;^$oL.î\TH/?FKAq "Ra;w:rܰ?>j=.8a1 ny߶lO d丒rݛ$<HhyHt/~28QssYSWU?v^!TAW*NW*7[RbZ6 **c*K|b8DmvS<6\;Ĝsj">YY[M;6YE0*s=Ʉ4bBur}v{26},BX`}g#!DJ(ۧŔ+-fڕLtu$yMXJuv7_#2n--=˲={BoJNYG5@A [JRV.GGf75izUDU5/O<#َokG' aC+/6Q#q UÝ3G֘ IDATp,L]JJRQUZ:^mJ^qsR|vb^(<W2] F6xu AJQ\fT oF4jsmەn w5@ԵM6qj~6B%-!)'YZ [QUܝ|GNwDNGbfX`N2oQ /s5)hq~ޫnrG;n B4 ;Y(Hz-[M)SU).%]IAJmvgg7!(ya[sOs2qbhJѼR}'mgeb!}v=qy(:k_AT|X 8ˉhF Rʛ!yơ*jLӫJxrhRᤔ-2/MG6+h9!%B#'rTٯUg+^tb3+ǹ[g5@LvmA ^vy&]-YI%\JyȒ@?*f؞ta_r o !Uq!DEaI~EpK.Eoicf}Zk#:U_dyPPkl |K>zin:bxNpmپ#\t"2Bɚ.U20'#gۘJ=>19{6>@MoLc1E"A1䬬rG<=D"A|T *5%elRJCTD隠'n˶f b+z5@vBVi@ 'Ͷlۋ'-Y@u AZQ*Cv=}d;D*5Z +{׺Bh-S:-ǡQ}&/)%j{g}d)q6|k2U)ab0R5&„_EݰԠ/rP=DYpΤ,64W#KdȄGӀ$0a o{k9LPSUsZ1!I ~ǸisCLj ̫K0{vwǸ\.s]w6"i֮n^җM7sG==3RrXx u)}:e}*T欅"Q $fvLiYTmz49۹m~' Ek5@9S27.rA UOd3-i+I>tv ~Ʊ^w^>O#pNo^n:fEo}ܶw?zf3R`)Y,4!*F,*ʼZ ELPmSѨu6Z3)VNRbt4e\)qe^ri[O]]ϯ_T|Uѷe~HbC-Ok2Jɡ+xaDœͱQU,e99v d{vw'[:q+Li|徽Ϋ_[6{&ӿH/./;6m=Wr<ޙm)4m&B@!v*? !DL)UM=5}=)?r؉b0XY]Cvt޼gL_6j'ũM?e+%UnDY 3slD|י&"Bщrym;S[Rx`'&8ޢ(.yM@ڊ'7{2nR֎ ۶y衇}~sL8f~zwrKn ɜ֓* {6?|Ϙ6# (q$3[MuY[p;`\2dѵtX=YelN3HUu-:yuC,\yPY,mvZCпqp det}]'[{;81/c<]5]Ɩ.=Rc:.:F9*u'IXߛ& Dģg?I AB@DD vޕbKw]i"ʙK7v}5&bJxXѻbGҢLdَİ}Cv~)($fC0y*ȡR:2ĹR{" 4^3_@kY //~6) ]n NڐWA"bz꺌M91>``̉bjÕRyRwbR I6 yΉbL2L_wD'"^#צP1 j* Aұ0pE4RK=v|>Y*ZjR=#4!`-x7G$OJSg2%0moveEvE|3[qD?꽇ގR^*R4RV>Q>r|'7x"K%#җMҗM6検HǥR7o䧝zEᑁ<;:NQxJxJnF<#N&k(P,x(ڴOζUvkfh~O@mskw7x0p*E/MWBs4b^k^W6öl -%8muuuqO 2OEQ<|:(\ ҩh-:vi(1&mp]G{=lFnf4b T*߿t~>06d:#E.HJGu͚KD&u:dL*J"FS=am]|B2MCc%Γrbn:_yֳ+ r]ibJXg}(dT1 }||YdmS\d;L@[Rڛ.צlj.2"zBC/tUYUwFH:juw W**mO}⎚d*F^W?ՙF8}7?=f+`xbKgHP;oeFreTE! G:̎4ٱmiS*u5Lۥ33'sl)XW(c;Xyn~_}\mU!:d]0ϣM ksCDUV(efc!|FVs1K)y|w=/~ p_y ٹs'zvj«$Çyda@ͩ]SGCf/kPUMSVqqzB$ѕJFDmi*X{_E3JHlӴ1 b`\ǰEre sRK[l> dB䥔m xKW?hPOF@˰a+%vp] ͠eq42$g4sۋkgk4)4~ "[BD/kdU/ )+6j'U.Hz{sm|/R2)%{/?fh``s/rUWY1!z#?!>v|js{q`pdc:B*\|Jr۵1{fqx 2T  БE(XUJr`pXJ@Sؗ#o92T5F?ٿ+gR+9$ƒsI,Fo5PN7 t[M\RBq-jg4d`Iqgz冇ևByY5e)gvNW?[{v"3hF$%5ǡGUsڽgl.:lr"t V<3"z2ߜXZי@bQgtfSeoJay%Aw Ѷ/6_I)Oy?KӼ+n`0xa0Ž?izAl";$k^Kpf)!W-Zxh$@4ɩӟR4vͤRkͤlCeTEIDÁ5@(]q],Ib2Y6lgQ*B6Y68RgG;_̕{u ?pc6`o }[\[(|<1#"dCC gMVô[FfZ- hpiH+-6-xw;31Č4#EU?_|V5u9t}6nWV*ܰxMWpɕWã|CjP▒P4kQCC"J,TB4ҏt$ˡb9T-[4+:'JFwY'㄀^/ʺlҋmO6>Y!l)x͚>%@or7U]  , IDAT%Mc¶*%K&|R^`7N.ižm=yF3f#{XU7ku;؈}G~"7K\u^zKϪ6<<>Yt1y`GW'o{۹馛>~ fܓ\H P"%WRCR V"* %B\ݗeSQ~6y)HDء쀥*MR"v%Ha, u ӕ u婉 Bxd,H8NsF7RdTgfHؠ*d5[6t-}\D4푗H<~ |k I}dԥohpLN6|X\qN5@4,ykRRmUT5!Sr!-H@z5к(f}0y^zVxׁ#*m=B^ڑEwvrW @9[gSͼH$pm`.jy\tef×˺[."~` nUM?|ߘE>2 tXgr}7xd׼7{%hʺ:R`ɕ jJJİmLAStM% IE# éԿm뺸];<<9iēQb6SEy+O Aa/*dD@3 ԗu*InQ[T j y~H畛Ud9m]DUhhddllpAX4<>xpM7SF 6@ejH`qH( fd-d͓^Tt5idi'rY4W_w^+io(\NqP? 4@漞U.])ˮD!ؚhh;~k~7ϊ+qWoI^wcݨڲ9Tx$Dʮvf~P=4wOL0h9uEϦ"% #oh;S|DDđa7lp@%5L IWb.U˥b,gy5I"sHIE)k&'#w' ^"!>3k#Ptu|N!пp s4@앭.MclmRUd55@t =W?Nhņku;y0me6^G|+xuGB3 WI#p3"-Mѷ;}[,<;%B3#CP7dԶPU ))]J\h'6MۢiĶQՄy _OxDTMoW;OiMKbw&yKUBov٤3H#eZN ֊93m]@\(*Lۋo\a oxg(!vo=̕u)ULU U|Nٰ()xP' ӕГӗIy߂5;7rc$S'WsvYiۦYSya6w9֓MFX$K#;$wwU_L' g >) yJ"|# kl t^BcCD:TudMYrI@4u3W"8?˧M@~6G^ PU~]l?|LbAJSXg4ٮ$<* =FDod0ݯ^k=-0MO|c uۿig!E%IпT]}UQ=Y~{81^OP'uj*%u)7?+DADQ ruo;ړ#utl%"9)[_HJ]]ܙϳZ]Ԁs\bf bHVWundA~<Ƕ=^J~yQ*ïz!W}?9T!ؚ k;6pi"A`EB.×%|ﱃ UA8BG"̖q:q qh IGmrR<[ ps+y}}#?Oy(sZ^_>AZb)k,{\ĕJ`RT36e#]S pd4L<DJu.k^ӫ|r.mπ/ dY6B|PX THzh6iEqs;tCM'ӯtR7n-xõ#pf!E#;m, ҂H@ouCy>6"j(!FX,ӓ!DXSQ%L w#WJFF[2& 6N g?öYz8`s瑏x2G?12 >Zp )A2& ,?B>9WIR%ǜtFf.AӜrT[xHl}dv4"+݉rh;<5x-(;^\3C6VmT_C|_̶0i{'Wu}s3u$$!$!@X406&)Ɣ؀6a@\8%$`'lyǸ.1E BP ui{{?]iT_}I;Z9sCSc!wDN'm5\) $EW{7[zKw_d2M#Nx<991D_#Xt30ϣ.Fko确wS揸DŽ= H{cP $foWG|Ro<cupubd{hCdy;`ᨪ]ײS)֬^=~Y+S+<ݝ8u]c*P{*4]`E39gI7[#y16T&8Yo ^m;y<>Z>!k+ Ap U%L-㈆j62%%TǼ.}MoQb)B,b#@YXJ,[)meRezyַYSpT6K/ĦG=ӢSoHkh\iPAd}8%/R)YqU<ݭQF=wҫ?X[y]0_FP|#D0=+ke[$gyiYeuNc(sSoG(/hG-H~N\9݇O3O|}+Vx㏏|gymf-6o{J֒BOrᔩF%/LbL:.ѐ>L=<ڎHKim &=9Iv)Y-i J[{՛h^:>1mɏezM/-wuאPV^NSS:/,¢@"ڳoR=CwZZ}?)-8<@Aƃ Z| pN͜7SjkZRo[Zh/hH`VڷlΜTڧ,eovwٕ%̩}0Wkm|rzL'`AY_xEHnfiP~k _$|`yV]^Z˚"{5Q-O!Rnn掖CoT'_ןd0ݾOo8n$s$J߮i CG0 n68,rwXQ[WGEEE_ 6v!ctᐷxJS@dB֞| q? L$X<x),xa&qkdqEIU_?*=lltv2Pt/{͖J s 8ʀQE01}=s"Q"8H0uT]0do}{{,ծ r`AZ{%.EqFwtpog'Na$4Yk=vɗ;8Nkb8$\_]ͤD߶+]]10Sznu)j~"X # ? s6Fk.^\nw^_;k-˖-Fe#?r#y7@0=qQ9[koMQ;)oia{{ ŀޖk7[]}I:L[z1|Sr19daω1}JJX̒V%߭fK9xyY+|γ > !(s3ȗWT0C"F]Z{,GI̙m[W 2zuuqW{;yUǯ)q)l`;𦑵x`D}ĉE+hmRu]-Z䇡ck:n' WkP_.noo/cYj8, ?n%ѓ;*+6gN:[#b~`^: j&*NG<~G."j[dd݊rjjjta;R0RN>`T3*OC@B|p4f4@VV2)--lLw$[{ı<l/3lhJۚHpEs3jm剞r9ZK{Oqo\sѳQk- Ïaa.>N{M6qzLF,8z꩑~|g^HbM4 ,Y22%vL  /)Y5'|Z[y'S [SS'[rXe(OuuLK$c[=9Ktq@?),IݎgN °VUUEccc_m۶j#F@?|]] Z!; !7n٣q8K(bE YO҆SwOC]Ta~E)h˾K`MHx\h<>Z Z_tfQZ# n)B^jyTC@;Ƙ\Co^y9W773;*dg@͟oކύ14ԕooV6c1>**>_;gsduU.WakѻH JR߽K#]EZc>:bSPQf ,~@Pxdp1Fc  28%*?gH.FPWW @'T>.k׀@T;vQ3SkkHV:OȢj=bnn:_7،"<_f-C:r 'D5L<7O|rWN? }fXvШ(2`x`ϐh ظZv?EdqԙSSʧE^҆-2hEDw*ȯfqu<]--?yl[Zo]=@Scq}7DUu55~_}U6^˼6pI',?\um| A -aIicLZN@oB{'Qee,ǹQ'p]sVyop r-fnDgjg`7 /!BsUōUz[nBp֤\ui}ԒHY u$hz Р7e)VXZ 4~ U2>Hv1Ry\v$&E{LΝG;$WoD; "_"(pn,adp~sWFhlFwypbu5S nkiaWBq[b N#gԫ:֭]3_rIXE"q8 %^Ƶlk %% $ڛG Q;)ome{ )=@ʩGQt]>Nz&@#p63U $v ~`ŮRcRoo`&=%%,nnVVWe kSkֻ.GUJ 1Yf Zb1.\NJOx.tp0'ÏPxZqB,Z{Ak3wo{py^2QtSKZ[yf?;jC3lk+:/:s6'{7$0rg@8X9JP1s ~, l"`s2J0 Be˹LΎq}3sMUE]u|b9.sψ*R55TUUEy?t u]^xЋ;~\K0,% c38h/fN WyR}5/O_ X4_溜W_.noo?󆁎 sf꠿ ܜv&X.v'@a|/VTz\־7B\]c8ɉmia>HJ\ 15pt?-4G"u;O.>˩Va1/25 ;waG##26k[kF `ddob;6a>Ͱ^Ep 5LCK sJVy{ (]YwMkg/:ڬ@S hnO<'dNwO?5չ  e?.H?ok-V_yF䃓0+m^~fhQFRw,sIZk#0S:͉cG-S@*]E2Eiu`B|γpJr)YGy$3ꘖHǶ6z2җL#H1|)|] UQcIDATSnpbz?{vJ$O$7~wWW+W<3~]HO9 g7XDDDrQ󭵿 AWWޥO8g3߷66.iucVyNoKixZ{*Hȥ c +*e[[7Q}Z9+G?#ϣZ|ZK?JHR` (//uqc sϐP[[Kyyyϓ+Vׇ1*bvl]D |`dDAD@$/u`J-/x;ZZxy`-;e6c༙|c8,w v!Eyf!{U]<ǚy=@J3rRS^~!}`Zs^>~g箝lܸ^{s=-[H&$MT]q8x9s2om>***CA?Zko5 Ј䪣ntb1.ildi{;bo1?u1`riIF?޺m߄!~IPJ8+ƌ=LK$]k+avgH1,jcfr)4&h|W5qIR['-?=F_o~_}{k~>Z˚Vx^$y "S^i٤" UZq856ikKή,rTz9?%_Ck?}o֞ | ʸ:VV^\:,P͇YNfji NHczY.ֲtO{u[Z6{iʑ[}9X `?Ǭ4] n:,cS:~HD %?fyeYGwvc7<I8'p;fq֌IX;Z$x{R> mfzdF5 Z{1GGak;{ư#pLړI6w7Z;yzG;:ziꧻ/Ie^)QMuҗL%NӓLҟLӟJXsK(KĨ(\*< u"7mT+YNYkHMCa\v{8/{kSu׌x}hĦKIM|p2B11fcc(vcL7Ofw@C{.j?tDJ`w1ikm9 }W;o[8MD$G?)>Ţ||5>pp3*]cdjy w?#vk_\eI(H:j51Pm{kC&|5r}$;v`-uXիWG_o/ .z1DI)DDi wg{. ;.R_}C9$JJTTVFf s2}EZ " RCphJWjGAw3!QRE>]3BhHk-[KGg:iim6z::ꢫd2@?payqbWVPVQAUm-UUUTVVR]]MYUU3Y߳Zt?&L1|_=_qǐM %z;\Ј"c JրHa._->[51ԾYk?@gW8h-Д')'e-)kI[(I{; #j}|ߧ;vs6۷mcmlܰ6oo2L2֮e(-QV"p`0 %na_ "P{\|!9hۆ`CΛДx.#cD}ޮ.^{U^ZO๕+ ?}`3a**+)^{vJm@UDG*Ej,kyyZϺ^(Hyw>0u:XGp>d1K)$]_U >Rky pHwԶq|(RZGѷTwLIk!{x}}l1ٱe wlTnڹC'Բ(8%[))4a"c1ն1fpzQ)|͋Y?X|#3d֫r6|6o#RϫyDcL,"R ʘ0䌇qp1waپ{17HP%Xޤcn9౻@p*<~wIS+ץ0Wx^.B{Х:jχXN1_jiU@x`jJ?SZ5f }{_1ٿjjt"9' Ƅ;upE`ǵ""2<$ac@Hr6U ˁWr:?ikI~Q쀞N5ϧ˯4^qDB'm7/l5hHQw >EP7VD;ȷ0WU!JN!( 3Bhդ>tàa> '饵8|JYO?ksp\`ŨēO .d1 q=M(Οg 17~i^MW )hj~`J;3?ji```X hsُ6RXq'G8aHL"3KE>~c qckIadBX,Feu&MSOe…L> <"6cR3&߁cF@d ;c18xWH憝)+li#fACd% |l t:۹kGnJq+)+/g,8(f̙Ih8/qT&a#Hfcր@&Ad>0hCI 0'6e6ݝw[7 F9V&1^ :Q0ly}RT p8c :b1hlnb̙L< h4z)qG:m18;d8E_c.+) 2;5s *iΧ5!su{w*}<& Ѳs'}}O?t.! ""R,!Xz=Jf(^֨({dP#J 7lT)RDP`B30,0JPt%ԌxF물]~wP :N/3<b !켮 3&FY)b?86V*cLRC2._f" `‡z-!үcD-|QB l&*"nQD? :"" \p>|8\AD$o*} xUծDDDD[) ""9b7Š=/? .IENDB`ffuf-2.1.0/_img/ffuf_run_logo_600.png000066400000000000000000001263021450131640400173020ustar00rootroot00000000000000PNG  IHDRXֈ-iCCPICC profile(}=H@_S"Q ~:Yq*BZu0 4$).kŪ "%/-=B4mtLĢb**^G?z0^YƜ$9]gyst>xMAJTyE) c {!JEP{!T e Kazp,A ؂&[([(g~ `/٘yiu#bkdO'3Ԟ :PE;'TJz Kx1K0-*׏,7n X6 Tyd<)"A+Jo 8~xR`.gF%Ooړsь]$+Y,AD\e'/31[ |pRz!݇hD*P X~xŒ],A sZ`}&3!뵽/"?-96jâK!XS +Ӆ0A sQ\]@OJ#G?HPzC@G`k_9ӹRjiP,A暸Gv(,q V~bfvMş93X f;R m4%RX(_2S.R)JҠX qu٭ADdp?YG%7;jX:q''$n KY/Α.h}Zw*q\> Rs҈X fqr)Iǡ姐[B37kvToF$a6 ojAж:4 5ԼNz+x3:DfA " ȣ`p!һiM E<5OSU34Y綷): "A(euD ' =not8k·Sy3mߕR1iD,AJQ`}lXa:p`ųax@cz&kT:XU\99n'<#G%B+\ 1+|v@~0g` [ju1#u("QdпvB& d 罰Ήa%k(S=JgA(6+4Jttl $ rT,+RQ)O,AbX~`'$ .:F3%mpmDc(n4$4@()(`cL3LsJd]Bn1*EwBk 4)\"AfR`3[~_PZhEbcmۆ&tijJ ZG4O{=uUQ e eK 6૭@pHǠnh/_mQ5)A]@%0kVH~:n(7Wt:wM8{vڎ> 3eʋGRC3uwຫѶ-I_O`9œ+/+=;aڟiȁa*~WN A -B_ $86_K]7`64ǃ;[`?$ν ^Av\t`"f2=ێ^ |_@|xU\ uVy{z}[}0U_Or_<'C@!+X Z' v_>ăe+R]:~Lu%m(z2BWF( ) ( S;x}.x.oe?[1^Oꕑ@ Bַ"M^(FspTJ`(9\3Mj EhmwV=5L$|^z>o)W:l P*P >|ͬ|Vj"oįyU=m%fpbWN\)Ug.62, g Jru^N6PBO$Z" [H|YYQV#xa_gz\GYuuSg.|N/#?z$[n!8 N}Z.*㳯9QPsҼ,/UZ),,A| >UOD`y4*7͸I>xxM5?ρ/)IK <諲yڹ{P7(#vP(ېCN a+co嫸bHon្<:ۺ>Fo:R Z=$N_p&>YFX0HɆxp p*b"n>j[5܋ǚG$Ɯ( ~ەYmۈ25F6_QCLYN (ˆh#׃6L\eLZ`aaPWSFmu6V'= =>єq}'9'T[{Mg-% 0swuHYVG.>i?joRJFF!X35 |VM>*^ h+:eydo(9u"ǟ' ;\P5\u[+lB5 E祾0ARo?~vRq ,W'>UQ\[8VvkkiM1x ~fQtW} 2YZ" =)\ 5 .vK7=HvKhcmdO;> h\"vz͜+L"KWָ#x'w󩦬xՌ4X>qKeWk} ]w_2͠"1ea~᷼cЗg.";9`ZH/@'*߈6Q]DF7}x'MǣO|3i!i$ɮ|u4˦'FGmRͽ[WU[/8WJuII0wx"WPJuJqL&'^"q CꑇHm`zmhe/|Nb`y՚`,YP~k#,[\3qU>Ϊ/3ѧVT蝷*?##v^Qrs؆gG 9xؔ>xHzZx3+;=sZk9ɋ[#Z5;Jk23"F4 B%wU> Sj|/U7 %QR5cĐAaȠ*SUnQ>vqU ==E9\wpG?3ct3R&Y1wvI+X:,vHgfZ>/YY9V$N-̛/+z8;uƋ`Kqwl𭡺r kÿd0WSvΧvpsN`-* >BֵiLu(,LQs-ɉ"I"j-k8t:kzI{Ol\ wwwP|; <ƒw݁ϦgjO'HъkK&T{9Xy`V/X^W vCd&K B8^]ߓ>dq]d{ҿ kV/|1V#pxVsj;.O8[mR͞ݼ<~ՎA\2/5N9f+yKi^ }XVZ'Y@:ޙ)֯pxCvS͟'l:AXܲZJͦL[j۶XveC ^9ӡujgd,5.0WJJQ n'5|o2ۋ}0QN3ܗcr:5k1jkQePx-{ӻIW «->nmjk?U.jN&5\HLP` QEz7{Ͻdpt}:ǝ0mX}S?G;sԬ立3 ޾m40q\ҩ !e<;P|鬕4Vx kHO5R hUJ=2U-;$)(:ōЩDJ2?14Q * aD"H#@;˷6uWXYm?Iup9\414^5gmz\=ab80-:׭m>q|+p H24顥3v\ll&a9CT\sZ.n}q Dz(a|R*>\k/ f7oȕnW8yg®E\5'cQ jl>۱"k^N#v4kAupo䛞PQvpC@>c9מ*Aߌ.QD&oj&bԾ9$O q#Rl⹍p}7:;Q=NH "7 M` |Nk+& /v}x_ sFҚs4OaY&2thKZ>U_׮|u? IDAT9eL6[5ڄOT$Qmg{|EBg/᧿rw*A1]` YXZ /# _$8>YsG?Ng;kȻciiTVʌ(^ÃO}mU^-miUi#y4PaWJ "KֱׁK SiFd>9[Oh+ERݳVAcD[0"R!NXC;BCzw￈ʎ{ ˾}Ͽ3ySJ=-IЉcZT[+Rg I>ܞ6m+P C;kྣ hRDOBvv>1R <]Wf۟`Ζ1?֓`W.dq{+ԘKp_&s?xaNZ5TW S Li KKa]8cyc@;G:^ lqr+s8*VJ헎'sEvcp>ckMuI.7;le0GIh1=eX.ĉh`[h;$n.} 8Ipу'G0d O(&wBV_)hzLϯb\vJZk:9;lI) TUUzUU(CMzSlӆ8gG9Mpɋt+G)ut9,r^(a6q`kMuIiMqhM"sq9).΀ʉ(w=\u, <\WG#=bt3K Rp+{pF  ߉݌n݅ۺGVd@qKȉ)Y`14Q@z5|2\3kFöH{?ESrʃYрg뮫芲PנQJlA ZcY1zR(,H؟ݒ]WwD2sXj`̆%j2U1͙ kJ]P`ѓq],RZ~p]R/;'l윸 +;;;)DuH˔R)VR~^P^IϺyXM`ml=j/'&"NP2|zV,r"#sDNdA}uXC'(Yʅz^(Eղe86-dF YpŒ'WkCzEA a6M|1vkjOQ`t/K9ֱ7╴W5bh=}Mnyjk˞C]#pueB~>PdfR4-Qay&i˕Nl}x5[{O;#O"˗.1IŸe1P~\1Sym:2Z23Zݎ3h;Z1 |,zYSQYnA2 &6&xG%\Ӯ qpQ]+( I-);mGׯ uۗ`V\7{yϫ,g<^ 4GzX4P?ųcΰx&Жw qk1isS`]@v&lIw7 *Ea̭WWUqr$BM  P RxS)AYhoq8J@?Gl;I E" zKr>2)ts[yX:$;_w ]g^ >!HG?uz: ŵ{l触*tlogG?.kSc\WSC|y<qCYi rsʨ;wL>7+!3S}}##>}4Zo z.-X/X';5NoX#'Nص";Z:"m`AacfO腂><C)Օcsb 6issI$**#] WJE6-(|O_=|Q&RT&Ͷ=g 5弤bgN8)u)80/xx]Ub*mJZyr.Wc$׎u8G#8̌R\V^Ι%ٷt[p~ ݓŸ|bXyeb˲S*9aqya4XXI00-t'yr0 k˔RsC`\4M4y#|c4v Yx%6G 5"C88ZQ*Q]Z^΋~3@4W\峵-tf2"Kt._ PQ`1NIGXu7ԲSpLߨ'=f)Hej(/ dh.{tKd'3ڶK36XO9u BRcR[sG`=W qk--1; Jq]M Al_tvrвf1i`55DY괈tt6yD)ZW|4 ױΖ! 1kpr(DZ$5?<<6w ՛`qc% `N4iStuKʌS]]\uZ;P)eI- UEvo*Zhmeg=@Cm3 %"U$>x{m-d / q;npyEgえq - {?ph+d5Q!#Vu*֜b{qmo0R:8:j1`?CwWWJ)uDjfnnlۖpK[ۄ+`Km0VUZXZVZ҈Jac!/H1>bqA`pCN:fP#;+.7xxTX^:p]]nw2|weןYd%J)Gje :A+;q0hqXyV ȣeR0PIU*Zc3A\$Z=XMwŴԬ$ j\vU㡧7ަ.^t F?ɖ]-ljQs/.-y2R#[` =>ߦ0rIY˚9JϕS-[%w\P⫚| D"8>9i9v'>7ߍRUWk=AҁZzb=]Jʬ9=Jo' 0\:P'<ΐmDfcsUJ Y6xO7z_uVkj6|pxxeNC49!oC~NYsۏ u<=mz Z_&!tFgH)3Om,7MX3zPD*%s#Y(^mr!5< 63FmVFq8&#̖?Gk]]dJ67wF{Gڣ~^kMZ\[*eBd'nK<Qw 36%X88ؖk98nՃ96¥/[S4ϸ5+i$0VY=l\ksnnk^av{<ȀiP5; X^vNW:Q<4TiPϞ&EӔH,6$RO*nd'ZdԘ& | 5lrvlqqe(Wv]q\RCr咲] t\. ۡv,~`Womm0;8Z73ObgW}}d|fmܪDk zZòöP'WDC}9qSlY..Z?|I)7XI#Yy%: 48ԛ&?咴lbd\Ųlz363ΉE$q#a_6?/u55M(4= ڝo f;6R1 OpD1d_k+TW3o?5Lc^T~*E4E\Ma|^v }{{??p]4K98_k("`gm2$}7Q)e'}|݆Sd ֚KA.vdšQ{:z͡uQ*c~e6̢rA?dڸ"X:{4o7|^P!@S_p{?JT}RCEmk~p7JXB$9Q}63#uΓuvUVP,r[GF"_Y9d\CEjD Z}δMq'ְ%fK< 1J 5ص[;/"sFG#w-SzEBڡN[V{UsKalHI"H(+֗(5$tYRiPZCy7$-QOMMۤ_*ET&eAj"5a+'Zs8NuHsk/obooDO@rvίʶ *Ft{$xPŇ4I.LQᤸ/<Ea*0@Vn=$kKƴjMJY"#(2+X p\[)Mr&8w>u@wAՕq`a7o^5$E$ڻcltщXT-RqiKf<(v6j`t/Njǚ8 '?ҪԐg6oc|̕'c ?p:|'pl `9? d,'cओq5Gxr{ b&k7<Cw?h·wOkιx8&Iٱzf'-%o_]onΘ)L?'Y}4y#lW^:/K.J7r;b;B27r:+M&ޏcrR8©N6yƟ_1h{+3W?k7Kb]d_Z[wJ9OI ,u#w~۞F0 ڮִp8eMMPKt)-g碁]E}#־i65 3]q.=<>j"8i33},<'ȋ#ɰ܎sj݉XOJZ֜${$l'~{)Ue;E`M/HquI#vY<0e}e>g0NΨ*)a̭ΰ4$fd4ӿJKyWRo(bAaa#].Hrr:|U2-WNp[T)Z1[Q^̕as ۉX FZW]0UNJ} Ω]'XQ7(d}A`I^W WdE'bJIwړw=/\7\&)r Cn!v:IcsUt?[l ְjiM ](EGw*;|`'!0pZXRXٷYlɜdX_c+Q۝2Қ e& j]f\Y]o - 5; N&9U55Fb٦{Nj͏xe:xGZ,NLǡOmm\YYiee#6ۦq4 * !6I9bԚ&&3'=ijn3ȵy!se$I0KKJ U5  FWF}B)\>$^+$[Yb"Mϑ keoS'vD++-}&Xm{3g.me뗗 IDATBJRi梁_YDQ`SoV?z- !-] f%-XPp͝04[ ӧRj4]kR_БqV9_29MČl߁p?G0\O h>?5[Rbp]B1$ӲCS00e+`hմ#OF<[)6fB%:0kbs[宝nN@<Нb}&)+ $Cc- Ļ6m9M !Z=Pw)i&=/)꽻V0] \ dJa;R|=t%ueAmWm- 2G)NajixqVmi `鞟W( ?CMtTqN`׉p} co|뙡# =q6k[Ѿ $/!<­]S>6u-TgF0BW%}i`ǶnRs+0>̛Z-6Vb!QEFRd,wuMz"R?G;e>78ȠmS>]`4b>E\G_ϲf%w] c T\a!h]g,Pt}is*s= dLpdJI&YmcT= +ھ=-puo}dMSkbs+fk c )mDFj^f֓Lg'ѺpAdٌ@9W)M#qسe{/-\0Ύ_%n%Y ۺVUEVq5+O,d+6gд57k_g9.?hj 6L>$gRMjn%q!_EF9;Gw*\Sx>[:Nh1Vm"R2&% cjĥZ9xuXR*1>HpýMJF0}r=?_ ~dh7tA呔Gx\TT`,EՔ`}W|_#54m_@b/)G `eJ#¨j1>a{iOJnfȕ:os/ۮSGHQ6x&dͩ뙃Kc)|)ů7M\kSh޵ʮl^M˶VtG2ձ )vMn\1}[E="bJ$& MÏx'b_|ΜB6ʙ./sktJgG}i,;n1u-4qq(2 {!N͆ >$~ , C60?!ZmG y㈌zA!uF/rH$~GƊFgtS,f8F{Go\2qx?+w|?X5BbvVvOBu80OĒݫRtg8S첆V] "P\J)WoWNYO2Z%FOZXmF%+%B\~fvU:6M t7Ӧ5.%AQyplGRii]K:?^ K/Nиnmek$c8RGZSx_pm6:R #b*:D;˝?xK8m8<Z#5cVg|yDbs*݋_½-[֊TJKhEGڲJ]׈|FZc'ߔ7Wڮ!9M'zR1)`K.ؗ<2ReG֡T0cNdlp+&B-(GGE:]s5e#,k9qӖ2%Й\6g̘3!@vHp JAzE'>W܈庒_:2aMW#{  >~֔gRn]o}"K2bu2 (68htLٷr)뎨y>MdFPRضM\T*aYRD+`[1e)%B|荽N_[d3-MPS=W&^[{h38}_ۿ]~ӟ6rh_ެ WZ!Ƀ>,IY-&`Θ2,%۷q6RJ=*OɐljITAB~;ׁ ДBϗ}"T=o;|R)NLėlQ*(Ju];X,.`Xضhˮ"m >zA 6`&7}&FU a`-.oS@T!A9Ds(g/,(WHnG=qh4t!K֭Z4,E@x؎xm{Tv.P!h*ZT^{ܧ~RO))\#H&%sW$&\1)IiYFr%Ḳ<*ʮIJJUD*=>}M G#|[K/hIf{ڞu~<}XO|47go86'B QĪQ=,ףn씢uu RRYG ;WqZRy'+By%JI' e{#);%sВ;2.%>~7ȱ18ݮ<9T.t$[fI Oe2n&n3a~;k.SմlJL-;~|7?93}p\{4%X,c_r><\6[=B0NWbL[؜-lRa `sƹV6?<@{Ow_\.GKKK#F8*Z@ X+P"?*'"RQ *+Gu@ַg=gO}Z;<[>яxl0n9 YnEu}P_S0|LH֧"ϖ"+A1 ^۵kU4^~6>Nk &LtЦDt.R?bw~ٰnPmD0  rΙ'sҎwuI֊Vȣfs7\F'vkS)7uZ:;N6}k\RsXvDOV9vjr=$'d8XS8xHpSrkEIV>9j 6_c9ԀoZɤRmϷԖ+(cl_Q)^Ӎr)y{^֫B3^3<ɟ~— ,hoD"ak @bHJewbOldžyc;LT ַy _(YJi҄+ `uҀ69k`mDF5=ݍJpQ G**%lpSUУc}Z_7 O 0Fi:78ӈ'hF\&?1i 7w/wW_=}X4YV},12j# eXKy׿؎ q Spdyxں8p880Ν;Kg4;ZIBq~#XJOjMhs/;xBJL0` RR`5%""oOozs]^}k+`0iqzzz8㬳9:u VisNyp! E{hm+-;:ҜAI`4A8Թ5{kxfQgJRFfzE+ `׈>c˵])*jFKa o["5]M|$?<<5IEkXwy'o}fO) n&1;!xh<%vhɖy0t~;<.oE)^XŘ2YV4ݿ@9Rre;TJ1Tb 1_h^UzFU0[r2O)ҿڎ `y˷o{`PZF]`nxb{{lΝ;y4;Rmǭ./x OC<- i2zlC$/~b*ɋhwni'VjbungzAL'Smo#^ R|*%guZRjDC֜G6W\ϗc.Yt|̿TИS;Zȯ xx7Ms;7-!)i O?s_u)@Y*ڴiyLy,n~K}-^&8qgy7p {I[kjE*Y%u_ N;Sf[/~K$͟y4}6ӻZ)$p#ՖHr?iM<K)(-ߪR: /jKog.xE|H$5w(7N(#jH{;}.Zf,F֭6s Dtj8Չ.;9w;X%L]_\sm$q"*._Y6GpMS0NرwJoP׵*\v4`.:?=\}wx_X ѓ i=Mt՚$ dKA&ju\W/G@C+_1w7M׳9W7MikNyݑ$y^3M6_u'o{lh{sVWa % [>!Dd~ ܷP$4ۦ\(fdtdd`5L $\`՚aB*J6BRf )Bϓ(S$$ףly+pz){_B~qtuC pN5k~GM4[cl'Ti"M,{ %_㸻'wiԈsp{--(%4cA}'W0JR\W /ؿ?ؖM%Ś5kx`h:t|~e0:bh9I}% 9m> ,hV&MeHϓe4)h`Y떕Z֙ATC70+jko?nebS+Dxnz0ȩx xӟ&h<* HBeKmͲ0Vב)4wIr(AF}-W҃E^R E `˖-GC қ9OIyϖRNQ+_Y_5 ӘlJK*ZǓOA$"o)WU$G&y^o^J>ETuOZ.op\yMO>>9fܩc)1p" ksl.CԂܚQsJ0|:*^݈`tr9B1V/Qclɷ =եj~ aхx{/g:S+uﵭ%vywlN{k81<lp_n-=53nƼjZTk#V TIJM~4 `.}Y)œHyK2:JY!7⢽~!,۫I#l2gtQp-ә_Z/BX%VV\Dx^7Q?=zݕONJĆW*&~lF׭e u*O[-]!WZ-ˀ lVP|T.0=V K_+?g)[tuu6U9_-yPW Mjm,(Z͖D-Ayߴ#:, ݕBU>޽{ ˚%R07nQ-?zZvRXv%+ђ'N'{ɉPC13D8DߊG@DLM`up"+͚+shO-Wcļ^o=w=㿝qsv9XPT}& ˬZsZx٢5;;t-`h*z IDATL7:<#>6n\J hbuO>;h|uхC*W_#fQڕ{RRL_ޏK$%KUkX~hAvV0-Nܔ4Eڗ|l2Ljfƿ5߰X*ϒ4<_)Q9/XyTw3[5D Br}cݪ?[2W?ƒy]4pQ³FJ'nFU ]?݇m O~m#4^y٢9DJvW}ht,_FKǓ/-R/}9!2 .h+op!G!\ce~]blS N*%)P& ""DH-OR BszGJ)[G?6|5װq'VV}+<*;*Ͻm>}\hx Ml$ʧ"R*i rQu|SZ[[9 Z sՙ*hsc!p=90MMNsz4zYibq晧BFKr]+]\@G,Mn5uѥi0>>'?I>ˁ#Jwa[-g`g7`JWW-` C[+ ִϻDb^ٮ_46 :5LC'P4tb'~yo[(^S6q8ÓY$'kCZ3ĩĪg;l-v0Fۗ&TOGq?1&fW====}aݶS=-)ĪZwӾTϷ~dm=H ͚۲H8;I 肐24 #wTk,-$i~p'- 㡴02t^s|k/Y Bn_ u\7F@0tL4uLC#0:y\-5L `(hgnW[?#ߟV;Cy}G,,)%=Y/r^'u"/aAS5bc9ĵ׈US4 l]u4 ҫdH_8Cat,[͕'۔v򋁽c1ii.bqyzBӴ lG>$kef{+`t"y>iySZYS{&R2)ֻ[7}eTNۤ#X _@Ii#6MnDNlK4NP:MCڡ k2%\B8E4 -On[$#Ds}I=ScGO9?ʍSΌNx;(j ?%-M̝c!}O7pHK.d&!Dө6;jM;],9*)Mk֖RBCLb^p4H) eǧ9hO.?O=hTAXp\M]p0[m6KӴA?!j)o U!r[Q4(PRJrƇ+~]^#}o`gUPHR},q7Ƕi}/W\q͉f|NjE|`A_EDr՞~?)Y/¸5ȋ0.H|m؍`\\?KՀXh[駟OQͼ%M f Lf;5 i065t(gF!PU1ʙԂRp?ރc.b$Sd`4Cۓ;1fNDJI!\|ŜD"σd-rV :(2Z6:֨<%Jۮϙmct*l"4 2Qfd ¿69Ň˶ BB1vuq>3OP)ų/>z'-{}gxLpUyrf3q-ՉC+<lPx2=+e~u2JBTbBLUOJJ(Xy#۶oM7srI'arM Kє`i@@W54^U/ctR]ڂƢ)J)tBL$*/`)Y  [l?jaPmoy3G =3>E(_%# @Rh$gTG=³U5A\4/=l㧑iR*FyzfdB`8S≑^ω?5w |L&C:&P*)˔e& wtFKK hMu}KdBn+وK. .ƹ5Jo۾ vAEiq)% 4]:ҪT}}jby|+d69!_={{η<7UW??**ΕxӞ Xz.d ys, dE. uULbx$ǁqSh(J,T^4^FK tttT`mh1/g؎de8EyРhurRb/ G3CKA%[ tOaӂǂ>^y6nKoxUWq׭(yxyWX \D J U66!3;V+wE`K#-1:cdHٙ.-9<=#oOłK.#;چ=B=M R`R_78TXʀt3} 拀/ H$F=V]SqY_ zKOQnkQ#9Kk w", 8%n߾%p9p9e}Ri#~(,LXGMnJLSt!wF7֔PLq=l*;-E:Wu\ /9zRo}/T,{d?d3G5c>r<Ǯ:!M )p?,6yK#bW]G׈G̹=5;XK1cEw&Yɦi3S<\ճP*ŵJΫ *d(OK!nm$Mv<ŲxHfCv%eaoH֚Zۅvp'7MaR-B4}ȴl>,)%Yǿ\誣דTR"vX4V!ca0yA HLD5˕lJL.M?[MyViBMr!Bi.ŃF_FrAiSI:fDz0q>) Ox&@#p>H9b4R(-%P\'|aMmRiV,;pšF"ݫ!4 6 TӪhla7`h_o G&.v!tb %G9=]qx8i3#=dٷ,%ˣw4: \ssζu%GxBnJ*[p]J~J4J'm] F_ [+I1f(0Qy5YJquu,6/Bϳϛzyq@Ҡ{V(;I_DF4tzQ K{ZTV\\W6\+? wS&'k(mh%fy.8 @bOyj[N*&֦hcu=I a_^U,*)%gJ#{`_T.sXғ֚HmrKF\PK@K<g Ӄ-CE`Y֯i^=6 $g'xrԘ*bqJ##4p'us2nHʓ߷mMFkT/8i#[׬xAEj|xJW lR}"u3E_ɺ/MUz du(6s䎧boeWD*FhSFbMd4l2A΅X"ĠeL١z ͗I%WHGr[ .+8\hf!VQ()q%Q! h"@ѕ]liW;k]|_?*>Po',:[*O<=Y.yBp U܏D+a6'])إ] AYh(t ?bS ϓFsmy'sPJ񅳶rimѪہB k9X5&ՔccW*-Ѭ:諊bK[%Hdȝ 9)'DUL|zWlYBt! 4̶xM?&1:]YLKM9-ƽK%"G5wn]G |sx~-Y/[fbK2Lwnz'B"j&Y2ӗP*zac;UPL'Rn5ƀ]_w !,akxu#Sv^J^]zт&.ѐm3y:)MC_(EJ_?VmmN^E4I{,fK`fXњ'w`#9^cALE֞]ɗqU`"8/R*;X+՜`PV',)W3{(Ǧ+W>b!{?>4qKRji8C!<<̐2x4@J>`iqͥ0c o@{ى[a:lɞGVwQM :"swד*܅m ?F>d.4%i3gQ0 IDATT{±hz%m 8'k $Չ]I8З@zpRCJreMdWu(#q;SJ}Kwu49a4y}O?ŅU*_u+aFCK)9k a[* ])vрdxa]YZM掱1(P ݏc5Qۜ-Q&kht ̒yԃr[{CUA (ng #=wr>]du2մۓu kSnf.>O0bJVϹ;k MnT.رЏ!Ҁ/^-+`}oE@<#ktnW$p .IM#ieif)m? b h 56F,8eb1\g;~]~rXL <GåT(?S'Ґ^5*8GF~JM0~I<E!;W3PjA}Ügu7)1zÊOx_|ZW\m=G õJmBݫ31ԭ@vɶT7:ʓu[/Xj`mNEgX,#%aMca.^Vͬ| idx(oulܹ[uC=k;zeg08 ]w=,rcv8)QB SRJJ͓>[ȜH/=%\>UPLL9kz^T-q7WX[hB0.anXVYc͐(.g(NѶuDM5$;R$"catJ !>VB\_La.~N|~^`" d|lq}.P^)Yk$fQX}SU~7% &gɁz}2;{3;d;9@hMq"LzJ UE`)YƂZCn5v B8HK,?/XdDC&B@PvپJ[ժ6Om3EK3l섾UJvI\s .TxǴiͿ@`zj%--ԼN\JR`U\O)T'@r7rvܯX\BI]jm>kֵ>D|gΚddnK8G8wg rΏ\'5w2>- !r=d3}j$?ͩO*RLJE6Wb9ЗTϓ(* Jzo>3s{پM4R( D B>#,<($Q4V5!Hzl͜珹&νwW;3|k&G2aN}e+ϾlmW\YR=KzCwˏTmBGc9͞8Pʋrus3?_.Z"aEؕ Z&'\l+@! tEG;E6#g-g۝ nMiܼV0 c_2<vazu1^c!];;EsLqs0yrH1 OD9Z4T7ok2 y{&Fm sò9,D 1Eq;pƜ|Ofo`At![☙M4x\} jHd&'z0L x=|}Gs܃O-iԄ n#%ibˮ2YŸ1U4ևLDZD:IR\mi-0!%eg,ӤYm q岹n:::uP(FAEˆBloXk%Ba&zfbOfx/͆!HimEq̪u0]9vj3l,EnTN4!nk|G3a?7%C!ޞ? &U\dnS!v=/Lee$)ΛFJ4x{)7i/;ơ/mՖ3~j]bY-h XEh]vK]iLzΤT [ZٸύcryKnޭ5Ifr$RYLS:r,vKcmagNc{Igg'az+"@J $&< (yu&C{;kI)fIޥՁᗇHHI4u:LtI!ȤsE4bA:׮8־3 TK |\cӛ8q85҇\1Mj8ItRDI2 2#cO\f.ev^wZ: )&e,R]czck!Η;eՄಓ$rݺ ov$0O`ə;[֩ ݸ&,i˚Ē:)LOܾ'HDSNJZMq.QӤ+4zii(2:Veb V,;Ԇq'%Qnև<W&TJOqߗīizⵃl+ZicŒ9f.D%Uޗ98/rߴEz&˻#h)aLЃk@Q1veI JiΟ+&M*TS,FEO!yC`E%9oWT~.aOĕY D~HD2C2dc{!CGՊnot Kq/} qZB SsWG\Kgrx`w'm;%9ه#Xoool]mԕlOϛ9 ^L 2>z7T)nXE"7F[A^!g[{zLUkT)1(qR,jme^O%%e$%_~ln8vn)clzq{;׆^kWJב v!DCx;j԰ ;M5ac&.b1m0f|1?kS(z,+|XzJ)-FQUU5莎Vz*- -gwgv]M4ʃhBxq[@IWW}B.,ˢC85KJm6dqpʕNn G7&ag\d+iPpT8̪Fؐ_ބZ:3*hxdSs TPvj\5o_ڡϽ8gTT5\5a1t ]RXl:TWW+4ksϱg:kjXhS[ ,4!ǰw~AM"^8(х_ W9i m $\p2ゎ _;G[=&iڨXݸnH4BgG~EL{dK_1k&y\ ]Ux7e*Mcy].% (51)SU.076R?d)ٕ,1xV=ɀDO}R-F(v3B~?uuux<3 mmm$~c=+/ =$q͵LDFLJQ!D \Jϼ U@ ˶6fÏYx7$%.M@eW65h{;N&tdsd'_8q.V~SE~VT_{D<ΜsYhV٧q}c]4NFXDQ,`ǭhY?O0?#XpS"T8Fݰ>)؅IVҽ׸\\:f x|XH"Cܔ@?SWi43HgEYX?fNv2^سԀoRd)%7n+oq?G̜9,秔4)+KYxpDQRIRdٞJ/OCpR:+}X!`1j Z[%J5x^ AnjPLJ e l+gL+srs=ٰn~uvn%ֲ_9RQajjj 7MCW5\xNBISB v@WE;0&& qJ%|qlW TXgٜ#e?CsӏXۄ4Xϩ? n$Β߹};X,Vz\.=)% 3||%IxEeg!,!G*Cʦ&N mƋS=-,Jip3Tݫ4򱺺!Z%UxdB~pyW9s_n*ywޯSRVdՀ/iMP߲e omxs͇?ɬQ5%ڄgjfBpBu5˰=,ٵTC]`5c0OH {:<SŚun駹'nڱ~ ˲\5>'Zpojjs!FL }-J)ya㥄/g.ONSrFc%Xd1#ϟo;UEI_(dH>=N_O>hHfϝ"F6[Wi3v3<3Na%;(Wd= rNHck>$Sw)k\ ?=N^ʿp+sSwri}=^xkYcGs{x7! ^w׮]%]L:5)?wh$}G_fbi|_w2 a!DBXdTX-MbUCCmvnM

c>ppl >673&3y+up [O?Λ>@g⋬ɤ;~K,5E-:4b*9} t]=Zpg|,^K%#L!*F83U7*u~iz `0x=>J`׮]x;4[`ɒ%N_'J` { ˱c&9dA`bL_{e[`(%Ij8 =l !n\ xp;C-c7~ WK)(Е˱+Jk,I,%ʐ͙!ix.^UA}^j\r1>nv;˷-n7Ї(xrc|馛dd2bK7|>#V4ikk띶h,[oIYjϲxXnt"ad8mo5lYYt:$7~^T"-w9:)c8ef/ /̉<&<;3V*p}RS,+ضey7|̼X,F2t`0) ;rTWW捩 l ]IDATA׵R,BPsa, qZ!@`x]mmmw1u 9i^q9^{|nL&3zjmm퓽X[[KRއ}n/OKy1MG}sճ+9餓QG,RۊX#Qh#G2 CZN9eJ&յ5~,]tX/\.G$9P4|>~B+LF|f]]SmAkV H X2 \|;v$8:[ K?P[X#]h}]=f !vyx-:vxxꩧ (@8_}%+/CL&C4=L4<ϰb|n(\Y[wh밓:R  !Tk%FZV/ׂ!#tbeqHlBDx; lذnp5E6&}~:f*&IĀ.Jǃ%H5WYLaB|Wm+J`&V~z{mBF\r8TRbŎFٴih*N7*HH&ZB~?n]lr9:::x'+oƎKiOV2Y`T[XMdJk@X=!sG \B\QH#]! ,nc۴6q1` *IAH+-th߃p\P[[K r U(rZ\*3,~8 ZB|`ߊ18yB|?TCN"?+xXAu NaP]]d`7:$ __"b !P|#]H)'a;F=iB+hxŎăf|R, O64M!0 yr JqJ`UئRAl ?+喁BDd REQ-d]^t׏tCM\Q5թWaӬHm힙_*?nJJn,[0RFuڕJlM1\ D%R؋BSyw+mv([x ,B:'ET<#(c8RۂPK 5ؕG;q *yB\ ,Fm\_Ͼۢ 2_xħ_!NzHy4+FoA*ny0۪5Rل]By#9y$hk9J\)܂~>nkE!ďz'toB5yX Q3qL옡y(빪X/_#6w_)V2ܷc+׬&q 2.>:N>G pHH) l9e^6hXa,hL~LgdEq5a6;73(% R_J9Qny sd ;VD|WJ`)}Q{ 4jupvwcw~n7 < .+Kx:4Pf\YϠscvaEUB=j )(v.. j_a=ou*T`Vbg%Wٞmخ77*i|3bVQB;J󱭷NW !~X ϝ^BRRNRVJ#]j( B+xMAҼ^ē<>(I(Vx=pn.Lj<xqǹdTLX*!jW<4za!O| z|IŠK%{MRt>vzE7Joxx{2X |~ڞƍgr݅RrO{{2X^oo.i65%'ݯ' |3; 6| wjw*^{g K†oCצ^#t}|4WiT[t^xb2G9]o!4=5>> Bˡ"(]J}5y#TJG6:fN^_NFnC5TZ4}~+7 bgAB hh<[w !p@/N;wS'~#_{fe~l}S@^ѳd(Y*E&kXmpa9\^^K,g5/2(]E \1ٴ}!Cv#<|1+iIyj| {m*! J΄%s=>R[k u-(K~}k@BJ΅3hDkN>\MC-Sva ST\Av[ <=Ї\W;w¦5ݮE z.T_ Kp?6!DH7jz[> z Pu&cuc | Ex(zw{}aDAh#475P@ 0Zf{ Pn]B6(m}]]BtxH+gYӶ7}M 0Z Y?: qHX땑:qA>3J=gB |.{7Ɓ?/x?iC :?Cnhyf}nBہ݀3<<1ImB|=[r22RP}q|qe3_q\z YL-ю}@! j=Pyxy[}~<&H{cbfܽsOLAݐ ms$-RPv-4|DF:g<L?|tog!8>EI(~(wB^O]ouʌrHHd-\y.hڄ@tw㴵"фlهAvu"PD"PRHJ)%*DUZm-Ɠ66⯭_QЏcB?r|CA`4\^=]R$u="+"}xфZPmPă"VAr1cBKt)`5P4c2 fMB<ħ`ɯ~ @hhM-Z (iه%=ڱ u4·k_)(+֙\:O=#9 _a~`pd{ q?!{']"|~/:QTjy1s-[6a=/B6HW!` uma#eo/*ݔ/y$j gOxYGno'quJQsVamL2X e,Gvu!ٹNpQ!s0ć>3@ʃ?s%|MXq4'^|7l*d C+Oۑdw^?>A_[sqH|8ӱS|A`sdm-]I5hiRIK-X!Ro'`0ܧ_qm^#h:QA*Zq -^tEc W^Aυ6n[t9iJ;57R^gAY>짔G9d?E #'*SP˽?FtoAnX3q] C;a?hױ'+/X k;<ȂVv󙹿'4HS?/D?Dz%tEngq!{I2Ф*x0xhζ9r bse|~T}Ԗ >U{t,*~v>EWpëaYဟh(@%<,>9A?(LyJ ˫@rC=xy xoFq$ãH|,{-9s7^Y9GDueQ.[< Э5'7~*rX[A2>)Ar,珰5zM3ox zͻ'GP=U`..'LBƊ4ULߋ猛L3{w)4q`tߊ?8F.Y7GS7cVkrˌ'Wj,fqڌR4 ~m{Z'{;:L~ 5B5Wr((#`~<7,s~siv,%;  ခ:KK y6gчgg[7==t3qMwЧb^򚲷׿<)!f6:~vyۿwٲ2~wv>o=" HfNrp9R-NGbv@hˢWqΜΜ@eIt<>cBmK?- tvWpЏ4s8RJRJr`%&z`gn*Cϵz;߅Uͼ Wrg̣Lf>0t ]vEy\|;:pR~k:& }7MZk?¾Fϝ˞hct[PBKIб)m){XDg2فh#{V6^;ivisgPx! ccP, 0 J ]olsl+2(hOuy~q(fP{;{\;%Qϗ=s`ioQ[=~=_M1%/ckwK*sF9RBzOAzf^X۶/< =sXI\͝ .S)X^_B(` J#hGiJ椇JC2sēsm7u.=gY7C ƍ({* ~&OdKag'1ǒ9|@8M;f͒{i.}-s7kBױɗ$ :Q&h, 86BJnc~Dp톃J5gq' .?y=Cv[ֆq2صξᯖz^LR),Y=^ |VaNeЍ)>H2~1SJ]o K'~dWN[ۃf25 ~?"P -CFֲJm_'?~j#ݵkNVZ[Y`onFWs>S`WC֡wy|%i\F&p mp<z"v}b>Cgq^j t~PŚwˆ5ri膆?m;d] ں8ҵ-Gao/)[rceױjljʊƫmCwbi'nzq4XX6?~We6sNT.2a]qV?ofӬ=suP 乏:a"kng[0f wuι{97'pA-+pY#~c\RN'˄OzO )S]R,_(k ˾GvT[_x<,×|w|e:(!Xf GG"zgb~Ty=N 1R96lkŴT}v 9!Yxrsv}ڔ VJ-̘/ ֝t=ͿŐa J{Y/A \C?Ƕ͢OٯȮ+yiUM@T;93YAgˮC4Mi ~F޴/q=N%5z)Jވ_'8hy}t0C7l]e;x7b>Tо`p BH5ˣՔ L[HsYI]i6wkDs~$s9qRYCu1fͨ8(hGJRġbX'2MLˑ5,ֶQ-/puI©bZ>n;6N6UJ*хm>T`n|kУjWa+=L[vcǧGJ5RjbqaْsfVΘ32&:4M)/ }/4mzmG*6txom ϭ崹4TB<}bj|35->x©vXpQ@gt NL02޲ ii]EYORY7wO0tmi,8$3tRdIQ4HMU]VIzqMCo}a?w1~_;#_Czx 6"]Z֝rg rjesI"cwk&X4㯙aYM:OS^h$xĕT*K*cRUQ4,-aVTض$OMXPˍ-dLB}r^p3Ps*z'm. +F*6˯bIgQT3Hw%He-Ω|`8$цa;$kDBK4gXY:34dzHxSI_v& UW!=':p!nw8; p@?Tt)V/gҜ$ö4"5A(# ЪTlBo"{uTd{W*ןI U~˅xO.qk+dO#w drd'(f(Ϻ_A۬xWn;#O"AuUEɒ wIJg_ͭ}iya{[Xe5&{7dUcrٱ{/P]ʺM=RЏiI"EK9/5!Z{l+ =bYLLƤ?C*{Xri-mvB.Bl@|1E6T`wҏ?:"z*OeWvб5ilʜ2JKoL֤3AkW 4^dn!ot$2%$ 8nO(j]#؊Iv4w*Ei̝U7.G* |b+m4RUo !aowx>nޥ y.ٳcq]`^U!&,Ң u5af \)5}|Mಫ7f`ɟBPU~DUoQVF5ׂRK䠡+IcNQ F79|8sqfX 1 UO9~ IDATxMAY`ksmS+y2N];c$)./t(8VJiB];{|:+Dg-YﱐrX\z={ qrpLj,VR'P t!z:8HBJ6i9J]It|(X)1ؼQ8E.هc*`9W mK<KdiIu$t\8Vi+Fuz2?B]7cZ|OvJ:ZÓ RDC`I'V6?)u@!4 0!tʦumVjx>PrLsp$I܌Tt'ig)X0^!p~Q "P }} Gx5$9~M@MIh!+99V[J_X F 8ayF.T$ͪlװBˢ,WUWExnEMl-)@rCqHꖽ<1)#YSLyYty)H6S O~D T,2sd1EE!TiOgvC=$RZ{CǀS'J_NBAY? )M)pJQdy]˓͔Kbu ZB8ZLNnD--0ڝJ|tLk (@> |LKHx|w'B@l6o)m*'Ǜ5&DńkԏLݧ`q9QL;CjjK ]:!xqwǐfIjqxa8ʚlLdk>{UJX֙#ZYOrZh-v4?rJ'[#;oԔG݃/cM LٖeJ8`qvh4\>8,2$(ɨҢи7?->7F 4-בW+V7?H0cycڸ(CКaˣ׿uw~BYOW>&cP~h=-ݣxMmS;77n#0yk\SUQrk=#>Ԋm >̙8S56mm¶I5HCai}m}&y%9ǽPiGH m _|E>/|!m]MKNJ<9#WRB/~ɣ{z iYc>LǠLO]eO{ ;1Msc]_ Y̯;;y#J}]MM<쳅tV.ȇwƨRuž{77LQy*Rv'14gA3~=|hxkH65m楟\| e}<+-}PYQF8|AIdEIsa٬T =?yUD"tMwGLSr\^)L{ceر[? [[׾W^I p̭=4}$ ~{"0@hϚ+ng|VP?)KTc)WP͍@@ll7ײF@OmM%+XF/dg((i׎=Bjfqկ[?Y3)*.+z=8ܕ+ma ˲Q |l@o_z OCCC25=AWJE3 x=}QBJ;غu눮7t.UTOœqiK6K@)2]Xc="|uh2Dpdt%Ӄl7g'& H[.C3J"~VY4̛7`$V/eIƍ퓟砟w(A Agv$ݽq+j1ٺ%|U%`ВFt\Ƕ(RY) T.Kz۷뮣`C7(c9>OR|ߦЮya DbDv54/{R%stD";.yAMl33&acoWXⴵ_ ik[&D/zÃ55l,G$lt6K/{7u\qh+W1کg>_f ?`  ͽiCޞ1+y#j/;ХXq"4])EGJ^VfxۑR9NХ_>)zϏ}B6#T e-⑿3r3f8l?t8⤙|tNiGܙ'yo7Rɲ0}/(Dv6>_Ց.wIv9FSJǿ]l۲.Bjs x|jK,4?wڙg {=|`~8`t&3$TB4J9)ۍ'V:I.CFPͰ$IdrdMI\x>"JO?u׿kERuBaH&9bpF(fŊ|PW_s&ܷMYJHU:; QꘜBzq?& 6tXf0#I&g̘MޗrW>tl# E"!z{QDutMQp59gJV\ɜ9s w=JJ p `…OI$܃.~]#0Mt x:XάD?hE?0!nE:6[e+X>HvhIwO5Bp.hL s kjbख1 tid:2 $mr>PPsƑd=IںbJ)iIi~po+.D}>bciSY@1NލAYKu$w!m [h Vxm;H.sM:cNOfIe'M{_0zΩ;/Buq%Saෝ.'42 lr 7?5ѥmL'EJX}^6'9-GbEO*GW=6WТz9d*Kb3*:WN }W̚'6ӭI28 @xt6d?dqPn_1iT"1Ĵ$퐰%)l:7ί_WQc, sJg*Q.PJ{LKP$wVEL.t8׍cfIu.Ln\:CV`lbCعEƴKs|7xMCgϩaZJSp'; z^>TO-9otȸE6k OŖK<!ׁ1&GD0SqTGhld!Au$QJί* S[$Fo`St$.9}0Jষ '& ,/+A&gk޾5bp< &Mz]GV6?R49WJ\3ΦPk`АA0٬E{wG*9?pi9o\l$Z "DWv^ DK) 8w%sDC>uĩ(v<J&x7ұho|:v.MaeRHq`[hQiGj{[{lL"n;5*2[7!6$< ,\S:뼅3~79*ÀŞ}=̙U1i[mG.rnt~ [^Y)ϭnfgZcf#4qں( Ju3ʩ(N p}!5])U\ 8_q@UprnI{_(-]q|>q}?<+NT0D)5^.уhG BBs@IͽjV$J*cX&6cGLme ^=V |14cGC;;lӍRHhMU$,c q= < `^Oˠ:xӽO-Y\$@.2@`)(f;I=(戃Ds!r_pryWe]uy~`m3R rOrԜJ>u|SǼ P!t +9amIbg6r15TYoPS,J(4~ ~ze'a)q2@ $#i7B1"tj~59"1Kіwl =N{⼐!EA~0p,4>Oug@4(I"mo( lndC$<,&M{|Tյǿ3L/ BEDk4U{G/Z- +x}[{mEjoZQ^QĪROz@5B{2d}8'1 I L239g>3 9{Z{ oBxڂn_v!?|m]W7ФNGUP`HaCrgf$) 6`&^֞^JI\7F4ZkT.T5٪Fr崓R>e$>%\G<}ua(ՆnyyڣA\7k_>l ?렱RJZZc-T7GJ*xTMU S]3^v|0MqaŨ0bQH}гDtk.}Uu\r-G˅?"$ө QYD4n#]̉^IDATQ)iYgMA]744"FQjJ-8U-TP%#o_00\zL2T.Htn }xG;pݟ>`SLȧ1"?@A(BQA?l>=aDq"4#kmۛҺJIuHLPR5(8Lj\H&Yu˟g׎H)QUq'+N#???Oyp2`5(_`i~~ՄP%ӫy- ğ%9ƍ "qLOOLSԇ"T4G*jdyT4E %D A(f`EY ecYl,5khf=h;a<󮹆SO=TjOSMi}@e0E31jnr^Vκt `Ņ\9x{q]7L7Т R#: "E $1H8Q 1>j. ,/Q6zËzvYv-˗/g_ףiRJ&Mܫ찪`0Hyy9 qرNU ,OgcV0 hϮ( jR<+jhf_cHnDun8^U%/%ëYa6i Ĩ 0B|iH),Ͳ̆&QC~K)>ӏ(;Gq̰"­Q5l&#Aː,J( =j8 [g. <Ls3K0a>ڵky_uuyW̘1Éڅ^K)w<%LaDhii!zsFUUx<~~Bݜ~oM#ܲ;UT1yyp7F30;+x=, =A;aRQ6 FQHH9莪%OO]}fϙñ{ml&Wn%@ƅ]ލ5 ڗuP(D80Ȯdee=h7MI]S_VqE ib{S+{G280csҨ -t{u=2˘2[d|^'ŤTWWzjXH)),*Wr2dȐ^ׯ 64v,7/_s].s oP(DSSaUG `00Mt0L$Gv䪪FYc\7>oH$aײ{n>ݺ!ߘ0!SW.{ VJ9-i#ur/Ҋð,0enmFh-ٳ;︃Uxώ} O=>7>s|4M6n܈7KǸqHuig/~KFYgOgNjkq(qE$9 8 ٿ? 0***?u=L﹇)SpEG8zhb/|9ߟgֱA) B0)(( $`ĉ,>;6b87,\HNe !Zq8ښTCJIKK [KC}=,9gj*GvvM0MӦzI'M2[ 0; 5XiԒFa 9NN:-0~G8˖-VX5: 0#GbsgvBMYY*+#Į;ig7O)))M;tGKu?19rۭdggBƎ˔S)'sռ)++seR!}}p0UVV&uZNNΥY쳾C]=r4dl ƌsЁ% vZZ-|h̼BwŌ?SWG+]ߎ!(BL_*ugVYY`СiG;C a3~CϫW쯬!x<|wLN<ăɳ>ˢCY+L8EU% "ؾ}_2 ]$4M***xyjɓ45y1i?c'N[OY_*s`RPTHII {*H){ +O\7! )ښQTTT[{0PU5$NB|Hw~?-~뺞2ihB7!+UX5 ]Sbcvp,mNNNZXu{X!RVwI)t=lqB<O|#R\U$/Evؠ ! ,MHS…XmJ\2YIg!c:"46!qk SUUS}7zjkmY:;ɋ2 ;x(,,աK݆\5/4M P_E$%a:=.c ]yDpt=؀;*qKTU̔֔iޓ]۶׃Co;T]+:k,M#KU]Y AW=|Vl5!{JBz)5@ۥz|n]97z7ߞ m}\<]wޯOQc.ҝ^ !P;jĀ'H"-v$I2 "mUhk{!]aVl;FũW|MQ7X,:v+RZ 8SgO8`pl&ƺV pv^(Ʋ+=NX8G{%,%,@?ہx}sB{8%Btd>/WiXT QEpVB)ިoWp2Bu/)Ԡ1`pRJu= Z[iv(xi$ !b'K$oHXL !*18n`V+Y!Pź+B YdGCBXkOJΌ P +]`2P@/ŀy`B?ȵaAׁ.~>08daf@9iSE V덶POJ9@( B$ '.wՖRt$-\Irtw\ )4 vq)jO]wA]0x+\vn?J{r2N=[h.PˁYcp5[gL%7=9aի;v]C-6 !\Xӱm ܻoVk[t=yHh(SL.IA ]`wl/aR{EXq@{ε@Ȇc?cV L\.9(+'lZ]k@;VbǃN&v`vV~ D~rAw*A[pTk3" m m  Fʕ+W\rʕ+W\rʕ+W\rʕ+W\rFwXIENDB`ffuf-2.1.0/_img/ffuf_waving_250.png000066400000000000000000000651011450131640400167510ustar00rootroot00000000000000PNG  IHDROpiCCPICC profile(}=H@_S"Q ~:Yq*BZu0 4$).kŪ "%/-=B4mtLĢb**^G?z0^YƜ$9]gyst>xMA?f;l6;gfwg~ 9C9C9C9C9C9C9I8[0z$TP ; BK94q o)R^)%)lRMJ!)58/R,:)ei !*`/J.RʻEn:Pa)e=#,qv! gI)e~RJ9T %lC+t"94 H)_GQ)"g)ئRJ939=iJGb!ĮynWbBOY3{ @~@_9:x0T;G B*˱{)RǤg\JK WҡNR 'rp"PJv8ơ 3I;os)6vb?& 8\Dby˒?,\%pXȃGRH)ã? ݌cYSrF^>?j!^3gQAwuO$,L8%wB2RUp Ri8@j{ ;_}WR? N]<*`J8\}"l/B!=nVyPgA*F?Vta#,U?Eer恵z4](*yX*GDpz485kixTug2s3Gb@~;@/hNՃ9lډnTf)E6A~8`J UmY_g$xPǛ\&6-66sUQ lڃsCK=L&ޏi׎s̋!etzb;(k+_#qc.$_H>F,7a 8|UvVpz9L1^X: Ȫe "&hn%ep s#:x$CM~&,sxS;,ny fľ5B?yfJ˝ж*[|a3M@oVWqi>]7b!?rjǓl/ҏ;n\oZE߯n9dc^t{.e,B;@w0YX ^֡,So9s'du/s rUo~Bd#^㗛'9}( <^^~c@~o8}{=?p{S=hf7yB?)Ocp|B𝕻7.ٓuǃT\| @.iYGr'0dPJg^}a;c-ͿZQ_{{N 2oҠ7ɜW1ߗ FU;},$Egzz!Ɏ4(S-`þvZJ1IenDB}ӈI7zIPuMW)d*?e{Y IVC{VXh}re` =qu_Fwvtn?.9C}, d]|Q߈ 18Q<_~r΍ȥsNyxwa[нΕ6t>Td7lHbm}k߽$_X磌>1FQ{ﺛtc X])tq>&=$!_֦[H`wz|dXlAΡ (=>>>|&59{zÆG΄#L_'=J:&́ ?;,Wy+JF?V('luC3L7R:*#P7YyvT h2't>ϕl K yWOL M^^x* 8g#\J(+uSX0#ߦQRo!u$H)iI]g!PN$ ]JF;'t=v:ޢ< W6NLE+%imAKZ0kMi`rЇ"H߬UAqAjKG t&ѷxrM 6.O~iywv,]fUN{ݍaZI)p@^w*eGr'[8tM|yL!x}S}DžH r)A2Jj7EX94lj^yh~)3 OI~@U5!-$J?9ow=uǝ1SW tPA `I۽2)RܸO?)%Aκ!@.XFI]K rUt'C1nrbh"RQ!!ԤzZ!c J:u>q_\wzF%Ze%9| E0L"VfA>ȥ+gj0w\ƄŵlMȰm_.3{&)B3N#%xaZPd3IEJr!$h /-K)1%DNGZۆΌvgv~B&XO uW ]<_2sC%G r)!YU PYQDE}_ Nd"҃GWQY$64LE=Ԗ*ƞLw5\ |ő2_vJ)o~+9@1 ' vv͝i(WK\G0%\fܫI m6>D6bKhR;Ehef%(h 'w$_\?`~*Yu)L*Da!D,[H䀩-'w:@%ץO<^L(揩D(iރK J+bOXªK;_\g!*P GB(eJm@{e($)ݣol|sV-;suŹ〫{xۀ#O?R?!Dr,_9F@i'BK,PJy6ry7iS_i)>r@|}]!!\3OJ i7T99pz:Ӌ.123\5_/޴ݷbZn=R^@~;!NHê^?$UE [&ce.rWGT f}b_{L82pad {a/^3Sr)44֣wDK } knTi+1d1 UEPW1eII,n#aIJ[]p\V<?"59tR~Qa~\_կm{Y\_q Tyt }8# ,fϹB8磊i hyE#TId7ڿ]9F6NG"Rn'ν/+?R#? | sTHYi'mŒn_dI[_RĆR+x#[~^W|3LR vlXmiq?+$aQSM|aA.Scf$6ǎSQlo8PQcv_ŵVJ)=tXT={Ingbzq5YP~sKQtMeִ*4MeOq5s4dwa0PY©tSyÂ\Jh;a{h,}7`y%r3kz5Enʋ}L*JabaH)?;1˞|z iھl%y5|rVijZ)tP;4 w0ǝ=WƆ_ I~\:XCíL߷3{XUetL2O @Yf}*R*UUaƔJ4 ,[MncSSk.,#"{FHK,@\(R?Wn[0 ,QW)iGEyѐkiyClT7 [-+={qW= e>.#C㢾2h{uMar0uoa Oc` =*v8o5)'mM|uQ⣪okQEtMҘP8A/Ϋ3ޏxr+sMt6d)eH3xs pY]e>ERUY[W5[ǭGܟ~öȃ[ l/4{E4~0 ?b oOIS"Y\J.\. EUP@B]nޜ4}J O$ ~MI*FCE >4M!&j},>71ͧ(۱4?$g0(eb"<.D,>Hs1T"XގPe4@$+"*:~Lz=dRN[,IѓgMh~; ^۷m?|>[R"hu>Xn· ϕyS*\QVQQ\_Fq;b.>^O`zG8q4y -}x=St=ꆉEi]@T*->8eYg>.5!]S-Ϧ-E>@Voƴ,Ι;"{oڵ ?@O>-Z'OTslv/!vuW7bՂYac"X9Fd**k&~PaU`TKz23pv5^=ahYuu_z"}ϭ>ˊͻm޼y?aݺuv^J:@O*ωI??!!&8T{t̩%X?z?~V6arݕ鱍=D7tע`qI,CGB{fIا?DULti*JTdd%>=-W4QXth&q/o'.TNt)e A z ۲?Pn[Es}ۧoc9Cz;'xA^l{׹xd:w!PLፒOtQ{FGgJ!(RsK9jOJ/KWvkiK}˗:HrN*a݋ڻgVqW{W5]W vSvT|y 0Iwg\%OP~1!5!Ҧb^*ZLg+-ʋL3LuGfZj~rynN9C<(H+pgt]7^'tW?/T* uk0kbG IDATDttmC!)a_$U6Cq-R& Vϩ 9OŊջHcd}SIW.?Sj͋l޼_~~nG[U >[Ixf펼0 o mBKK Y8 c+ljjkw@fTQ\[BQ7$MZ+Bv蚊$ x3 >|"dMU%(䞨 )}t⇍n\j)63_6]׹袋X֪a?DػwM<^gYzS|{.jkJ z?,)iia羮C*{]yz]))THIʊ 3WsI?k67Ӵ˒BcNdC=dW6;=466~/8[ZJ,m_rAUqʂly4y{>{", ̘V'F+M7Աq~BeE^KڈbY}m}E:C"ϰ@ytfו=Į{,h*̛R.{^i_t E<:ϮϦnTt,3{F b7¦m"*SQ^DQ3!fŌabIp>wt~{x ;ClN s4LTh_uּMäFy1JJX "P V E%U!nOtyj&Ğ]Y~e9w |-E"1^sfH ޾鍰iG+.MYLR{UB#UU(/"7r^+rʂɜ0k@㊿DkڎSOEUS- 8z׀'5X}/_o`EmD`6ns4&׃8cڔJkdJm aE02akاL J9hבر2MZ.xUI;btE07D/=:j)>N#KS~CҘV[ y1#n775zǶm޽2Ox Vn=ǓyfW-Hk:LUs45@cMzX$F;:CV[ۡC2fM8쪪P.yiӦv믿ngJlu Х>RO斔啷&$ZETRBO_/y=2ȌC"K<4L,rY tR,ڹp|v# P2m5f&E>ׄiEuQuU1u\J; >;/ߥ*'z v2s'$qOڑ=!UQ%C[Z?&T^n;6ީ UUA^;ol+49bz`zc9S&UkjX7^d3GB;dRnga͆tKZ(VMWݽ=l)vli

z +OIJ3$3J]2D[aT~+L:Xy=m/=m]b•{*$F rUw-Bsᯨ##ۉ~^PTtw&#݆eEcdAMѮzدQtbB!+}ǂTyMJ4$.)K2+AԊSfXqDti" qMVVxkxU|X#g|d)Ix~$ջZټ$Kb Zkj@J0LK_77 tpy8X3C& P]4^⽝$½H9C23JefB*PdzD4ѥuHO(Aw{1$N>9&f$zxWKA2Y%۲cd,ɛ{<`i~VZ . ̌.*Oy],B<$jxKp!McX#M#SMt_ׇ _Y @ vxdWFyWJWq^3t]>2{Z~`ׇ[]]}L}Y$ܗ&=p~dA3x$._oqŸݏT c#$ax$g EEt4CuQPsyT֓icCYo5Śh@oa]zB_UM\_L7",uPm;PB2 EAн4$2MeB 5 n EQTuyF<^]m$!wPjy@O#%nz5})W5ڵ )%e`gDz;1!UE &$8㝘BAfPPT%z(6mj:xXO5FB;y?jv$q+;)%Æ^֋OfT{erJ**+n6N,2; z#/-ϣO؆a2=X)Z"-'.q⯨CQ5 紬a L)󳰮tO .U_-%Ԧ6~-]%k-:;R K/T{vOcY]xdu} ͬʀΫUAHXsᯬGuyYI. ܲfYh´ RŒ||^Zc& x~Ŝr)vno2an{Pn^E,#,<5)Y . '<+- 0wF͠tH+wߒ\[߽D._ ˯®}"9рD}{ AI!iYN]]US6ˆpQd|Yp|=U5 BP_>An2,t ~JUOdW!TELWy3:PE+DuYӫb3IUE<e:L;dͪU|gƌvn xa"=+7 u@SL =#B8cHljO!TwTEI4۴wD4Muo8ӹBΉJRqYm7uD!jLǑP8`pJpIwEV3eљZףXvP[2߀n;_rv=:8 5~k %fȡ,G&8!їZ5%L>ć7m6wzOO6?2M[5kݯ kLUUU׾wܑ+%\peeVeInW;a4"T%܃z RJKx_zNy1fќﳪ*8r8Ϟ7=0))-^kl3a<ݜ|nEꊢ Ժ4 \Ó< }0+V}{5DnL/A 9>x%QL˨z#qbiɨZA}ЙNKcOq̷b~y}j%O%@7HƐ*}$M(^t^+$3|>r2OTq߂z,o8gde$9-28 ৰoCԯ־6@4B8u4ҵLMO0->Kʄzfwa9M[I5 \uyVѢimWV )x:JKx@*9EҖ%wZswZ\*h-xڟ̈+ = Mk磽[xHI(4$z:x,hf٧-Fw宽)PTJgJӴk/&}?d KGk ^,=.\ |Bm d#n&zp1TsiD8l#5/lƹg s8M=Qj 40ɴ,tZ4>~ !K^i6jX3(q^i>>ݳmcNP ;X~ Hɤa t9gPS=zRC g~R~XdnHf{)sZ3mW6Y..(K-$fp1`!X!-n%PՈ"S??|A׵uU{_joPY&B( H8%%iKZLqhcK=@VEMU8?vE<:XaH drO$hnRtveZuU `ڔIGUg܉`H1;NHkWՔPY1*^wҔm NӤi IM -$P՘vkkt)8ڨ8g8vry"aEJW7w^#]Xw=M+`Nc5'S {&]WpLp351G\}QIqnYH{b8i33mM^l9Zuͧoy_W);B4VXz"bCB>,կU4׎mbH@Cyz|Dl&M-XdO@ANog/$9Å g0iNv[XP"M9`@V\ӝcX8@uqͿd_s+_z^}٨Vo 16lܲىPD]=ڼpzjMD& !=:I&=Q);..Zd3y6\NE7TJp*sgPZ/  69`VhD|P$#PQ^: ,.'v$ky g4Dt^N0'3XC?q|.Z<ֹBD\uBXv^6:df(y|4IS{фƭcIS(+~6/ܼn7O,|R)Zwl#4I&M:<6KUڻOc/=7AJ2dfO/NWohu5c+-Ύhm瞃2eʰ/g`!eta|m lwY=*!%p>r^ƒt']%ǭY[Z| DeMj] E7}M[a(-E2m^ϕE uHwx[-iX daIz<{K n=@Bߡ+PP&(:i&rR$NUԃX6y :*3[i]W?zƍÛ'\Vt{G[E IDATo/UQ7B]ƿ}qZOQ)maģֹm@걑-tNIj h4(XQl|޴o4,vk\E֤L`9*g8R/-UW;iZjɌu&Ec .$%9KSR"H>rWZ4s3#"`MB@Bg0x5նף*k>`F(^Tb}$!ucmKI'k>6 +GuU*ն~72ff H<!Am[D:[0 }cwa&bHym9‘P:t+[);UO"KeG_[hPh[L3H$o>#ޛA6iӣӒt cY@`fB ۚnRTv$قqWmܲ,zb*|,{"o6u)DgU*&/<UIIٟШDZ1q$6~L$ VCg4l@Ϛ)"mK wd&Gb$#lݮb^s^G3;f:[KzK)R[HͰ"86eHUX6pw#]E-F,L_n'[.~LR- 2_+\%9 iY0_8Vbcr܀. _%`zʇכ41фA_z\(HhZo K{u!eh$x.C.m"Mx_CZ^";ӉR4'HfI%lLu,{Pg,3R!BNXGo߾g1,Wc8]TںDGdXv-wGe`>iVٳg4l߽Kκ**W!|4kTU@qF GNOV~|r<𸔊>OkqaZ6z03Dlk"QUUô0L˒XReIRsRiÛ!(7X޾culN'iMTVX<ZqR 8MTJ_,wExu}Z0~뜱 4mp+;BwJy3tDby^DwWsM]]݈~D&;mls{y#ٔˤxϬjTUT4m郅GJ, z*3M2PƀC(T?ZZjQTv"66vv|.?{XuŸ 9 #VJ1CՅP_Z'9@$38u $td;}6ucI{}T= O,NӅ`:|H-z3Gr<>i!D % H!0PH*R.KEѦxL3qv{L X<%H͛-{.gyhUUltV+ڸ9F4 )TAS#$$ -"A4 Ie#-T)Q @:PQ+*)* VTQZ0H)ijX.;#ՓgSW^6MHqNr/ܗ+8Qx H樊NWܺO!3kSB%gdї: vێCUa?=ю !x(L 877 z 3gdɒ1o9SyОl:r쁄n;ʹǏx<αsO;oi@rӒnKT3oPŗBs'ɇ:::ظ~[z5a^]lC:S7:kn=D˂%Z E(bN]7f$Y!ӒiӜ'`= X6oh:G!0d;p.Ͷl綫c|h<ܸ:9ӪžT<*U H@Ћϫ9B0&˒ص˒ݒôY]'7ٖ\tƢj]{Zu}ܹ+fn[nhA23傧70-0SB<]]AanC 6iIٹOm0-IW<Î43x6pN;m +!Ą*Nn_佮w_>7x㠓WF ?i.7C;by|k˼~ 6Immb"KD0LZZ{3F Kfϒ4ߠ*Yl!-L`#ྉBX #LvBQdՅc."f:?ӫ|`M̊*hB0:Br|>ϰcZ$1ĴhHPׅ%@Ua{>i"NCRy?pO?S`] _z/Nc5PӨx =<*j{sF u!j}$Lܑ@gP^*qeɢ% }J~nK-}|Ĺ1Qjǰ]\/ !CV7:ZZYn+WKW !ϥu`Qe]҆:~-ns-۳ZU<`^M/"oOiD~"!~ͣKJL$I$t6moZۻIrC(g@SXYc'fټ酖?:q_`gXjY)Y_8] / YპQ8{X]7xsw3n}QׄG6&) t^˲a]7 Rv\VGGRakR/;Yw^Ʃglt>Kys{sޅ@$o&hU s&;⦲:NS{53Lxw{4Y~ro1+FӲYQl$gY:;:w>}nnUDp֢9XRh$ oH`WmiI2YDt~pǚQG !^u)>JDsMT>enCd1,ql1Ms{=\%,=?&w5ۏ${>٣RznIR$wKFuPځ:ڒiII.oij:ݺOFgo2KO*#T;\LbIiML әI>)^/m`(jLޣHtUUN51!8~fy9\c?.w)͌a$es0,ɿL֙4۲zqUPS*dNuK$9K3GJh9' " >E8{ib,OŇΣ<2%XQU]} /X0yҋ3^Mɩlo[y=  9 MSX8H˲X_6v-mX"}A/^EK21gi\1OlyC.v!ֻM!k|+. mZSTV73QǍs)%ld&G, o恵(,Tɷڪ#i bO[{glݖ@}];JU;SLT]4D) K4,'6 DSLbΔ*.^~"Lxdd6nxT{{Ļ7zY4~[#"[Zy 57Yt +2u*~Gj=]nMN`5cp*KNZGS'_UP^N!,/ w $y`PdŊ_^[~{L^x{ Y8p=: L${Bkx<%;@zzb',)X*%YtC $<֭GQJztcBUS w9Q 논QCCC1|A*V!ZI)r`ވXd_(BY4ʅ]Th& Ta:c1<Qƚ:<ێGQt!oH)8X-{lذ׿(\sݵYZ[핍iE`oo9Ƥ]q):vDovfUq݌|'EE)xlUӰCi!; 2Z夔ۻxh9!+[JDwbΣpB h6Cל'RJYǿ \x]qfG1juv;!iXft9!sbd,:E+QzN}Lǖ:7^}U @Jɪ+\#oP7%JPWWW [)#뛜ہUi\]aH)]a4Ά~ <>JV쬷ӁeQ:<4U,.A&!{"Xti^.'avx:u΢X<ől)T;9x/Zv{{;/_8oJH8kUUU#v>lϩǷ{m)eǥ(_ Fp]~k}p1pcG;v@u3JHD˲Pe,&'ɩSJg3g#cX#ѾxamvYڝE$R.RzH`֭!0t珛蘇 ˀ;I)WH)8#It;VQD/q^젘Q ,-0}Q_,zz_#cz8.R.G{vW`W:H1H^;RF!?,wŋ O.[LUR#zxRV!;d7aG]ADPR#m"˱c-S_?m\ӞkUBn^R.w=EuN}N)G(sϞ=VyS̈7frŲ]]jǑH ';IDATbtkF0/=xJJyq#D\gEX7vً+>H}̞3Si2)e.GcV $g֏p˃/EзHB 8\X^Ϝ5s C_J!.'Ӏ2:zk`MC: l]J[ޯW xX̎MVV,~L3~-m2R3&C!d`GV-C֜ 0M6yLR@&%Dť2y[.+ <^/)P8?Sf"8;k.'5o BRb86,|>OX,F2' ׵B&uqB2DQw<^Q<^/լxY,9T?D͘wfBS k'w !&+"w5{ m#,Ӥ-6q:^~Eu,1C3f?QUkoϭ2SØ͓kgd,aJe xb(d-y&.]t29cቋ[8smĺٻs'oŏuضe+M1hdђsx+yRU|CnB=1I1t"y}Wum SOŋYd sg̙qN/[e˸䓟$ڛ줫D"A:Q-rx}18x)$ TwvZ[IbdRiziYh~P8LhJ**D"E.lPa^B_DD_l,ޜe1M,?%UsϺEv!G[98JDf[ RaD.sfޒݹ9(=R(͌rʴKb"Å} b(VK&>DuqʸfNf5w86Nj8Is-No[ L w~)'"Y |w2|BYtǪcYXr@J2^u倘D80і`z {=}b}>ZD7l!RK )'kpp1Y Blpb$^Kœ{U.'82n7O*< O7you{B ͱk#k N>B}nsLr#| vū5 ~BMHbׅ̐%M7#bs>yH?XLn9wa~:n!6Kb?Ouev61RsbtL6cCjlY铀 |8:[-ݛEC:d0s^b+:v=5rM ` <I0-]BDHS3 }=va l g`v_u}YxUb s:v=y};ܴ!ᮣ]$;A <Q x ;uw]p… .\p… .\p… .\p1?GIENDB`ffuf-2.1.0/_img/offsec-logo.png000066400000000000000000000545011450131640400162670ustar00rootroot00000000000000PNG  IHDR,YiCCPkCGColorSpaceGenericRGB8U]hU>+$΃Ԧ5lRфem,lAݝi&3i)>A['!j-P(G 3k~s ,[%,-:t} }-+*&¿ gPG݅ج8"eŲ]A b ;l õWϙ2_E,(ۈ#Zsێ<5)"E6N#ӽEkۃO0}*rUt.iei #]r >cU{t7+ԙg߃xuWB_-%=^ t0uvW9 %/VBW'_tMۓP\>@y0`D i|[` hh)Tj0B#ЪhU# ~yhu fp#1I/I"0! 'Sdd:J5ǖ"sdy#R7wAgdJ7kʕn^:}nWFVst$gj-tԝr_װ_7Z ~V54V }o[G=Nd>-UlaY5V}xg[?k&>srq߀].r_r_qsGjy4k iQܟBZ-<(d=dKO a/zv7]ǰod}sn?TF'|3Nn#I?"mzv~K=گsl<b|_|4>?pߋQrib 2* (Ѧh{28oIyes8';Z9h6g>xRx'b8ՃWOϫ[xn%|^z}%x cxeXIfMM*JR(iZHH,YL pHYs  YiTXtXML:com.adobe.xmp 1 ^@IDATx]xў;I-wrM7)Đ HHZJBcp[woO+*mVk[ggggfgg^^"@@ bD "D @@`uT4"+D m !Xݦ"@ @tDVHE#@ B"8@m*R"D @@`uT4"+D m mj(j;'Ikg!AuH|h7΢=d:ptB;Wj7#X$2Nz:KoX\I[݀MHrRA,ms;ő'/ iUSgFHLFbi8Rb<[V'&7G#NaMz5k;u.qV:A!}T83Qn /;!1'P׈@[bI•bZ1Ĉ6`6:|)?o)r?kEpl87OW'EDpD=1G78{ WSĎ&U0=dMB!Rۀz='{>Xv='qe":H B+M$Jg 9d&o'bk{4o%F +JŠ5"t80'tb`6U)]/QIFM4巙O,p0Ih<5 UatINUcB/M;,Aºޭp%fq >C1TC X7I8r^G?b%YQJ9?@\"7+2eL9{& 5?$QE<vvT#=3e¥J Xq >,#ά$i*#[='ϐA'Ǎ)@ 7o|(o!b)8@ζ||ثx0\YqҐCz] :F3:J|@@^I|9=FY{ui_ UJܨa2xC ޝ+?(WrQi.)\ɚx\|=neg@i?IMaTJѦI)ɡA)!DP'.ZR w(쟽ϼ$^܏ZpK5&~_l4qW]%iÇߋvG\R}x?g1nLȸ<KoHBHqIMI3LrJO-Z0hu+@mv%&GEFgsD<<̰{t-Q11䴒#_Az%K]nɘ4YN{ntم5RFo۲9; 9YVI- 2{H*7,q&JC^I2Nf6mBn}l4+ٳt,NX\b3s@j%IFr&*+p%ˤfv/%6MKFٺe$ed LJ[aoF3\]xA[QCyv)ٿ pOz, G*I[=.E%tH$eup=JQgi%r'$P(xܵd/omS|v\8<g\{2lT%VqApٻoR!#'B˶6Hˏߕx#~S=Fx<@j:CN*uBx=.DܔY!Ǽ~L+ S/se *%SbeZ</~s%'$rGjˑ t:l4YyRikR٣B5LK[e#6U pwSvmAN38LMpv'?3ZںMrWw!)Ow~qy~pV*+vYιgKZv Tf7Κr td)րb=}?{Jzs~N͙N 5EPys"{'1̻m=SH:ʣc|}uag2N4 }e>+R;dgM` Oø =y!Izdʌ?" &8]Ԁ7[aH>+r4;ԜZz̡M jZ-yCaRgB=kP4RzMZӔ`Anh^X+q8='QťHu WUҀq&]X@(:C <7?eOJJ'IC}lג:@nM]v}6R[QvWߡҘӗ& /@H5 6s bU`T/뫫LWOG'8l.iiAՒR[ZSxa$~)_W҈8i**LMz~ <6>lH?6L!9;r뮑AgL]KL0=`v+,EX QmI,A`W=Kmz/ ?WRU %d^DJ 8(r֗No#o$# q)PFA_뤭{f)2-Ƶi 8b~cSmNOyZ% {kڝ pf ݍy.'AC꤬*Qv[Iu]~Hj]b'nLCXEIJ "Bv~1|$g`|ミýо:Vdё@UO ُ+z=S3cv/Ck8T ^pō~UsٝAg;\lH̙ XI-xͲ 0Y ,,Hdk5lQ>uǠ c=`R:o)ű#9BN>2jeT ޟ'臺I82NwNXw2z-/kH7-peKM $ %[jd/_8 UTH&FLCXl8=+jnU+`DːP%w_E6:,Q\Уt2Hk>P jȫl5|Ii06I[AϨn)ftliVyCi1̖q/ GÁzvy쑓hnH日օz R=a%yޑ݋iSpvUAaEz,||<,Y׫u'8XDV} ƩRv4`6@`_\STbic^6ѭ9T"%~Aza|O;a^&Vbzx!| =RGq@AcVT޽A .Y3p# =Ajm @u0സe2Q?Rza8ZȋiޟpB[/>+`pcqÔ~Ee~uU|󃝥JA~B{ue,B0~YS4_mAh_fKtL_ oWR;7~<:9(ࣹݡL_f2>\P!\M0K8i*rOh$eM~ɮ6aG-={ ڟOQѬxOl9f.-d+ѹu$B@ BI ^ӇSnfR3I8ݶxPMp&%xG2QC]uF:p=u3383] gG(kaDIsSPGe)7\<_^96=swoϷg?'I9\'`htXqTa#xd7iw͙_a]Rt u*3V=Rd8WQ:[n7 4mm:Dpƭ/+?rX x HXߤxVoFnCmpě6 `[moJQ.$rXG-UՒa 1M9` z=+;jZ_G^ulzI9[eZWSg lۢqq"HhPKX0|jbbMϦ+Ol:X%iU3۟wW yaەׂ`k=;o}X3RYhcXV 82/a_%[{-}#| LT 1X°#!wbKjzGqmp~_hSO`hAk$T\haܲsGSpa#ndf$NɠN 9C6]=qݒ +Dίq c͛`R@hE~0@be.[BkJKnf3=kCؾOz-1L;0*,\$/L9] \uW`fHMY z6Eu\&DQWT P`&T&9ECcke^NF1vC1մ#F 6SyVɺā[6z9@JxtQ=of-,0Ȏ@ bpܸa!濍Fműc _ ,]Q .{*ϡZZmd[G="/ 's3DփikNe}7=Kjub_Po6E=¥˺gwAlŸb%=R3$olD#cԾv^|m;N$Ab ʱu::Y}h2uQ`i??l_dEXy[Ψܕ_rz Uw $Xr C>|2+-.'aCk9hyH`jQdO~. ɿft7}Xa1|C5t/NJM˫,ԥLsk_fb,.hiLβI:NjjeWzD$qs<8F6oSY?xEEw/^)q?~9_ +qe0M\kiߊ?q߶.2-HGғkPwwǙ깶C'x-[:V!ctva[NlJ hn>;K'軠WV$'i&G7-s*Q"t' vId?,F5;VֱV mذlE6Q KV01ǯx1𺟫8BKbks@,8lXFKQ}_Y[ؑx8l_@LkK62COEj=KۘLVWc,@oZFͮ3*}htX! h1!uS'ߙI?2:Ӂc:bbj !ȟKїE*5V-]/.h !̴Ky2plzX?6﹣{ǎT Wwn׿y-۰g7" g9">N8W7)8n+Vq^B }v ;4u˿Yrl"ԙrij`1-S ˇp`P)z"j7T6[KuYi= aoUA}a?T_0oݞt'.qYd* x<~ѭ@H2P?gF^kφ9."H~ilctv./v2Էuı =z(7Ru@0fNgY#x Vb6]yax9陿ȳi>Y>dAZXAocY)i:k1N r|$S>j`BSE0puvKD.$Aeh{f})1xF #觠$ \3f|%JD&%>8;S+oZ2[UIzHxT9'h T.vXFV鯒ޛ8 goT2@p (+7l_4%k?%$?.x {Av3v E*V5|+LoL.?7(GEq$;_~NfËQN8F\mIҺ8.âE_}gA iؙ}eiu𥷥p',3$*F)R+i'J0lg3O)Zm,t}&JIx,x;(.zRXV߈wΨ>k/ݓ`B{+ 8}s/5.=pC9T_ +2 1Q@УOq?M/ 8 GqA&hHUh^\D+ ^\@Pyؑ#X7AYpV-ݱ댻6a<ԏ~ԭxhŧ=g uO_U>0}yC@ cWwi8m6cY5Jȶ3t'>AB-dm?Y.`:_%u>% 5 -|hsg/fǭ (ϓD{٘Za6A_!\]o!&f&BgLC i X?jr ?U؇jӓH_ny*ccD$ kcg&@F}9L %;#B9ĊgͻN)c&}C6A1ڤK\fpՂwlx$!ӯds8gET[2|GeW^'՗HS +.Fڕ= \#G fM; /VݯKnD ֥'PZqmc T=;[nd9Q߽JFP"E86mr|ꠀ7, W-0H&=?_ʔ{8&6hxn.#T@J>=# ˟"+OiTX"ښ NSཁ`#,,Jڃt W[!eGCNsϤdZ+>f SOәxP1H,AnAݳT}iv@aDc:-*ݵȇ Õks4lL,߁#E TFD+L:PI Oɺ` DBPA(GA6F,-XD/zA@|͏1c%}@(Q+Kl":VU ].Y.oȜHb*w 9癕$ŹR߈GI΅?ѣl?@x),5PHBѹnk $UkJuVaAwa rd u(h9`܋A܈3j/e>&`E,H4wY2 D; 0*oL㟰[w]qR}>Cvˢ6(C\!/8_`3ewQ|A%䄈cIs©8([b6c_ ˝o񲸰=|-ːO۰40kއE6?NglRo u$eyWsYzu@pLUވG$у@XJM pmk ;ZԂXx1"Q/tVk𴓚y VM &Xž6W\$zF[if;{ V n'E@@YtCK Gzʩ-,f mL>YrdM0!8{8~ku 6!'lP%`@DٞvIUx8w 1'['0(?nl$PD"l^[3ɡO?zˈ[vʻTxHpI{g *YÇxYuQYw"Q|] 2{W@8(Ư.pO$V{LT{wFJ-Ӽ)Y;{rH ܪߑ |W.ù}}ZZLb$V/Df

@S?Gi8* ƕ7X אiSwsY>3o2Hr%WGTΝyr,44Džu_DƒM  ! j U9ik2nXA<_zdKd<6EVwYz -?47Dryp L: !^PUC&XA@z~\fY` -ek/J2f쨼2d_B@$¬AcrDlW7O,,c_bΌx%V~vf#Yܒ3Zf>|iY Ʀ /A|PN }$BZADK;loR^ |B)N Hh7I%ܡ-y;$ܧo&'Ƥ u Y'>[d`=1-DYPRGۦ*Eǂ~&aSS![zP,ܲPDiɃHSN翔+tE/8]\~M_&O:}䖬!ee8宿=C5 =h9&e%Jk/Iڤӄ {*; I?(!sc%ۡ=U(݆Rup]ycDpEiMyY}ۯ$ r;$c ;Q0]v́wWb ?DQ j)r=cɣ"Χ `ɽ 9[mHnV5P*}뻤᤟\\"P twU@Tlwޕ )Nm~ 6h)ӝk݋ˎ d$jd׉$@ ~SɃ8yc.+;n|pB+@F׸oO-xo #qV`gIˮRkҸ50g0RvePBSVVMj IH=ma@t&^}e!m@Y}e{ɢ[#k@zTFؑxZsW_ׂ(6ȮE9)n7 =< Ύ*V/kPUhodȽpdO'*\%>]Gq|xCzs`iܮ,mt̳"Q bU%g}z y lMb;3 9*c*~`5TO Nqqjr;®w0y? XicʄU' xW&ϔ۰< "\"P:Yq 2^>f,|$jgqwVW)yl p@h Nl<,P< ObJ@pgXJ&"MFF! @WzWɘYXs>zo:zIO8lN\Ҭ؈\&1j/zgmt菿'w|ü݈>c.m\R&Fم/XTo2rrd)z¡,GM$!jInQq3I)U:yw>wg,tknߠhHzS/Yx$<"ԈgɊ8(2F}0nhp/% o^r(̤n KK>Uk7s@lMIVѢ~~H  H 2Lo,ͥ{ 1On HxF@R/+% 0cCU2BKO8ōvͱl9L x('r@x<|ΝRn=8%6 $xr}Wa}BIks墹ۀm :pUа<=3 u({/9, 7w+/pۊ>o IL+k! ZJ.yf i 'Enƕ2ˬK-@FIæN:]'۟z^r~qB ԕ$^>G#١.\qr7 ec0ew y uP+)J vMmSя~>K\GDB2+-z%i+?y'e iӥb|p8n|v]2p5cwRr$G@ξBŀ{Bj͗օ ýg@b&s޳d+EI(01+Y5D9v]ee`&f@IO(oc<1`ΔiF\-Y;Eo=uN]Ɓee7.R< E|-19A!XBm^Ƅa ?'09WiF~ I9+fX/ abf ǮčPD+RgvaJeñϘ:xio-Aѐ,Q)X?OyrmEmg{)nq1}$XHnX/7ԓLҳGʸ"{T=6F6u+䦜Xq.nG24?GW} y{o9X=soI? NpV'[>? Ƴ;{kq7]7t \ De7JlF? 8(=)qZ<`kRv&DQșz|9g0QM}̅>TmCP &f[U ,hIDAT^XG'H%2hO? 7ێqt"-=4,qʚc50:`0 RCɏΖs{+o>vjjQwKq%X>冟I?N Q1i8=$t=rIl8aUNW8Ǝ& n?aX~v2nIuv>}?;Boy8}rgަm? /Ul +ᤑDIL*-JSz-0n&仮 C.Ϡ XCI]Wm=4k{wPhXq} [m9{XoS&~J+dr(s`yNIN'.i+ʆJApKȬCI=!Xy_Gx+qOrn=o+bLʏ.* +x]y`-a4@iןjBiDi3l̠TTx| 7zX'.lЕJ#(Ku&Mj#I@Ha'd [uXZ/y&/8$|aCzVVΐT5ko R]7[u:4H֯k"yʉPTIu.GteFV},rI}Z0B(QZ PFVάg\)(r ~!NIXĄC # k֏ݴSj76S+XmJ|i"Ά%u=xͺʰp@1-u_= If#hvöD vf\d=83w X,8T I*}=^ls! l~ENPR}fI:jiZ->M4o TX{MD ?[lʖo[ zfrtu8µĬ[!4|& h:L|z4spjpQgf7/MM5:A-iNsuBpE–׈pQ QIAcptu7EO?+Pk B Aa y{)܈h & $%NԲܲR9wTAtΫO%O>-gL_}CgiLbŻw2Cp_R'[^{S(cs!Ⲝx qVwo]"sxRNdϢYU ݷUsyw'ո`Ł&o >R -]tgT֐`if;1BJ:M[3`{] 1PkXBl8[2ͷ/_y8Cg}&*"&ⱼ<r5oy=98l矑f4$yh@%vZ9] ,Eɀ8a˷lKʮwޗTC2$לӉu  O˖c/9S}':nfBݦC|x7lW.\SZ̋u?֝BJ-}aN2oJc &aĊRۘE,|\&uL;[5&D1nC Ҙ_bt d IiPcӻj=.peHՁ~y7tZ0V)`(ɻ,t>ywaɰ:-q3ÅƐ{P:G==2 \Xv!=4~=u=ͳfȨTFk93Mze- C86Dq0{] u86^c֨$N?KrfϑqW]!S{V_OB2xZb^Bz%~Tq4%V 6i< eu?Ѹyx{UmQ (pL1z@/] o\-9_T{C;N* ='IB(o7I2\i}0%7)vkaz`~>&{Y:x/Xѯ]Q8 zq ?<`c1Է;JMd F%RV,8ֈVe:P%&H E<6%°҉Ζ*[Eܥˠ'm9Gxk @D{E:s/B DM42>.-AD(^p[j "ODGթsΕtp)e6&DPpyRq\D@էR̵8ec_O(јnhuNL/}uى? jkk+_q#.YN5\ `"Q}`_rrUڃr*}òSEˁ#f!0;a+liB X#a U 3qȂ!#x6aIĽoEVix`8;h] {8juK=D^ǗQ9K%R @\ m*?=TGN1* w#QT7( @ QT4lŁ-8QSNe;p'N{nɀtytZh oa}@t DVH%#@D @@`uT4"+D m !Xݦ"@ @tDVHE#@ B"8@m*R"D @@`uT4*ѫlIENDB`ffuf-2.1.0/ffufrc.example000066400000000000000000000035031450131640400152670ustar00rootroot00000000000000# This is an example of a ffuf configuration file. # https://github.com/ffuf/ffuf [http] cookies = [ "cookiename=cookievalue" ] data = "post=data&key=value" followredirects = false headers = [ "X-Header-Name: value", "X-Another-Header: value" ] ignorebody = false method = "GET" proxyurl = "http://127.0.0.1:8080" raw = false recursion = false recursion_depth = 0 recursion_strategy = "default" replayproxyurl = "http://127.0.0.1:8080" timeout = 10 url = "https://example.org/FUZZ" [general] autocalibration = false autocalibrationstrings = [ "randomtest", "admin" ] autocalibration_strategy = "basic" autocalibration_keyword = "FUZZ" autocalibration_perhost = false colors = false delay = "" maxtime = 0 maxtimejob = 0 noninteractive = false quiet = false rate = 0 scrapers = "all" stopon403 = false stoponall = false stoponerrors = false threads = 40 verbose = false json = false [input] dirsearchcompat = false extensions = "" ignorewordlistcomments = false inputmode = "clusterbomb" inputnum = 100 inputcommands = [ "seq 1 100:CUSTOMKEYWORD" ] request = "requestfile.txt" requestproto = "https" wordlists = [ "/path/to/wordlist:FUZZ", "/path/to/hostlist:HOST" ] [output] debuglog = "debug.log" outputdirectory = "/tmp/rawoutputdir" outputfile = "output.json" outputformat = "json" outputcreateemptyfile = false [filter] mode = "or" lines = "" regexp = "" size = "" status = "" time = "" words = "" [matcher] mode = "or" lines = "" regexp = "" size = "" status = "200,204,301,302,307,401,403,405,500" time = "" words = "" ffuf-2.1.0/go.mod000066400000000000000000000006711450131640400135500ustar00rootroot00000000000000module github.com/ffuf/ffuf/v2 go 1.17 require ( github.com/PuerkitoBio/goquery v1.8.0 github.com/adrg/xdg v0.4.0 github.com/andybalholm/brotli v1.0.5 github.com/ffuf/pencode v0.0.0-20230421231718-2cea7e60a693 github.com/pelletier/go-toml v1.9.5 ) require ( github.com/andybalholm/cascadia v1.3.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect golang.org/x/net v0.7.0 // indirect golang.org/x/sys v0.5.0 // indirect ) ffuf-2.1.0/go.sum000066400000000000000000000123151450131640400135730ustar00rootroot00000000000000github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ffuf/pencode v0.0.0-20230421231718-2cea7e60a693 h1:fdlgw33oLPzRpoHa4ppDFX5EcmzHHychPrO5xXmzxqc= github.com/ffuf/pencode v0.0.0-20230421231718-2cea7e60a693/go.mod h1:Qmgn2URTRtZ5wMntUke1+/G7z8rofTFHG1EvN3addNY= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ffuf-2.1.0/help.go000066400000000000000000000121711450131640400137170ustar00rootroot00000000000000package main import ( "flag" "fmt" "os" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type UsageSection struct { Name string Description string Flags []UsageFlag Hidden bool ExpectedFlags []string } // PrintSection prints out the section name, description and each of the flags func (u *UsageSection) PrintSection(max_length int, extended bool) { // Do not print if extended usage not requested and section marked as hidden if !extended && u.Hidden { return } fmt.Printf("%s:\n", u.Name) for _, f := range u.Flags { f.PrintFlag(max_length) } fmt.Printf("\n") } type UsageFlag struct { Name string Description string Default string } // PrintFlag prints out the flag name, usage string and default value func (f *UsageFlag) PrintFlag(max_length int) { // Create format string, used for padding format := fmt.Sprintf(" -%%-%ds %%s", max_length) if f.Default != "" { format = format + " (default: %s)\n" fmt.Printf(format, f.Name, f.Description, f.Default) } else { format = format + "\n" fmt.Printf(format, f.Name, f.Description) } } func Usage() { u_http := UsageSection{ Name: "HTTP OPTIONS", Description: "Options controlling the HTTP request and its parts.", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"cc", "ck", "H", "X", "b", "d", "r", "u", "raw", "recursion", "recursion-depth", "recursion-strategy", "replay-proxy", "timeout", "ignore-body", "x", "sni", "http2"}, } u_general := UsageSection{ Name: "GENERAL OPTIONS", Description: "", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"ac", "acc", "ack", "ach", "acs", "c", "config", "json", "maxtime", "maxtime-job", "noninteractive", "p", "rate", "scraperfile", "scrapers", "search", "s", "sa", "se", "sf", "t", "v", "V"}, } u_compat := UsageSection{ Name: "COMPATIBILITY OPTIONS", Description: "Options to ensure compatibility with other pieces of software.", Flags: make([]UsageFlag, 0), Hidden: true, ExpectedFlags: []string{"compressed", "cookie", "data", "data-ascii", "data-binary", "i", "k"}, } u_matcher := UsageSection{ Name: "MATCHER OPTIONS", Description: "Matchers for the response filtering.", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"mmode", "mc", "ml", "mr", "ms", "mt", "mw"}, } u_filter := UsageSection{ Name: "FILTER OPTIONS", Description: "Filters for the response filtering.", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"fmode", "fc", "fl", "fr", "fs", "ft", "fw"}, } u_input := UsageSection{ Name: "INPUT OPTIONS", Description: "Options for input data for fuzzing. Wordlists and input generators.", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"D", "enc", "ic", "input-cmd", "input-num", "input-shell", "mode", "request", "request-proto", "e", "w"}, } u_output := UsageSection{ Name: "OUTPUT OPTIONS", Description: "Options for output. Output file formats, file names and debug file locations.", Flags: make([]UsageFlag, 0), Hidden: false, ExpectedFlags: []string{"debug-log", "o", "of", "od", "or"}, } sections := []UsageSection{u_http, u_general, u_compat, u_matcher, u_filter, u_input, u_output} // Populate the flag sections max_length := 0 flag.VisitAll(func(f *flag.Flag) { found := false for i, section := range sections { if ffuf.StrInSlice(f.Name, section.ExpectedFlags) { sections[i].Flags = append(sections[i].Flags, UsageFlag{ Name: f.Name, Description: f.Usage, Default: f.DefValue, }) found = true } } if !found { fmt.Printf("DEBUG: Flag %s was found but not defined in help.go.\n", f.Name) os.Exit(1) } if len(f.Name) > max_length { max_length = len(f.Name) } }) fmt.Printf("Fuzz Faster U Fool - v%s\n\n", ffuf.Version()) // Print out the sections for _, section := range sections { section.PrintSection(max_length, false) } // Usage examples. fmt.Printf("EXAMPLE USAGE:\n") fmt.Printf(" Fuzz file paths from wordlist.txt, match all responses but filter out those with content-size 42.\n") fmt.Printf(" Colored, verbose output.\n") fmt.Printf(" ffuf -w wordlist.txt -u https://example.org/FUZZ -mc all -fs 42 -c -v\n\n") fmt.Printf(" Fuzz Host-header, match HTTP 200 responses.\n") fmt.Printf(" ffuf -w hosts.txt -u https://example.org/ -H \"Host: FUZZ\" -mc 200\n\n") fmt.Printf(" Fuzz POST JSON data. Match all responses not containing text \"error\".\n") fmt.Printf(" ffuf -w entries.txt -u https://example.org/ -X POST -H \"Content-Type: application/json\" \\\n") fmt.Printf(" -d '{\"name\": \"FUZZ\", \"anotherkey\": \"anothervalue\"}' -fr \"error\"\n\n") fmt.Printf(" Fuzz multiple locations. Match only responses reflecting the value of \"VAL\" keyword. Colored.\n") fmt.Printf(" ffuf -w params.txt:PARAM -w values.txt:VAL -u https://example.org/?PARAM=VAL -mr \"VAL\" -c\n\n") fmt.Printf(" More information and examples: https://github.com/ffuf/ffuf\n\n") } ffuf-2.1.0/main.go000066400000000000000000000437251450131640400137240ustar00rootroot00000000000000package main import ( "context" "flag" "fmt" "io" "log" "os" "strings" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/filter" "github.com/ffuf/ffuf/v2/pkg/input" "github.com/ffuf/ffuf/v2/pkg/interactive" "github.com/ffuf/ffuf/v2/pkg/output" "github.com/ffuf/ffuf/v2/pkg/runner" "github.com/ffuf/ffuf/v2/pkg/scraper" ) type multiStringFlag []string type wordlistFlag []string func (m *multiStringFlag) String() string { return "" } func (m *wordlistFlag) String() string { return "" } func (m *multiStringFlag) Set(value string) error { *m = append(*m, value) return nil } func (m *wordlistFlag) Set(value string) error { delimited := strings.Split(value, ",") if len(delimited) > 1 { *m = append(*m, delimited...) } else { *m = append(*m, value) } return nil } // ParseFlags parses the command line flags and (re)populates the ConfigOptions struct func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions { var ignored bool var cookies, autocalibrationstrings, autocalibrationstrategies, headers, inputcommands multiStringFlag var wordlists, encoders wordlistFlag cookies = opts.HTTP.Cookies autocalibrationstrings = opts.General.AutoCalibrationStrings headers = opts.HTTP.Headers inputcommands = opts.Input.Inputcommands wordlists = opts.Input.Wordlists encoders = opts.Input.Encoders flag.BoolVar(&ignored, "compressed", true, "Dummy flag for copy as curl functionality (ignored)") flag.BoolVar(&ignored, "i", true, "Dummy flag for copy as curl functionality (ignored)") flag.BoolVar(&ignored, "k", false, "Dummy flag for backwards compatibility") flag.BoolVar(&opts.Output.OutputSkipEmptyFile, "or", opts.Output.OutputSkipEmptyFile, "Don't create the output file if we don't have results") flag.BoolVar(&opts.General.AutoCalibration, "ac", opts.General.AutoCalibration, "Automatically calibrate filtering options") flag.BoolVar(&opts.General.AutoCalibrationPerHost, "ach", opts.General.AutoCalibration, "Per host autocalibration") flag.BoolVar(&opts.General.Colors, "c", opts.General.Colors, "Colorize output.") flag.BoolVar(&opts.General.Json, "json", opts.General.Json, "JSON output, printing newline-delimited JSON records") flag.BoolVar(&opts.General.Noninteractive, "noninteractive", opts.General.Noninteractive, "Disable the interactive console functionality") flag.BoolVar(&opts.General.Quiet, "s", opts.General.Quiet, "Do not print additional information (silent mode)") flag.BoolVar(&opts.General.ShowVersion, "V", opts.General.ShowVersion, "Show version information.") flag.BoolVar(&opts.General.StopOn403, "sf", opts.General.StopOn403, "Stop when > 95% of responses return 403 Forbidden") flag.BoolVar(&opts.General.StopOnAll, "sa", opts.General.StopOnAll, "Stop on all error cases. Implies -sf and -se.") flag.BoolVar(&opts.General.StopOnErrors, "se", opts.General.StopOnErrors, "Stop on spurious errors") flag.BoolVar(&opts.General.Verbose, "v", opts.General.Verbose, "Verbose output, printing full URL and redirect location (if any) with the results.") flag.BoolVar(&opts.HTTP.FollowRedirects, "r", opts.HTTP.FollowRedirects, "Follow redirects") flag.BoolVar(&opts.HTTP.IgnoreBody, "ignore-body", opts.HTTP.IgnoreBody, "Do not fetch the response content.") flag.BoolVar(&opts.HTTP.Raw, "raw", opts.HTTP.Raw, "Do not encode URI") flag.BoolVar(&opts.HTTP.Recursion, "recursion", opts.HTTP.Recursion, "Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it.") flag.BoolVar(&opts.HTTP.Http2, "http2", opts.HTTP.Http2, "Use HTTP2 protocol") flag.BoolVar(&opts.Input.DirSearchCompat, "D", opts.Input.DirSearchCompat, "DirSearch wordlist compatibility mode. Used in conjunction with -e flag.") flag.BoolVar(&opts.Input.IgnoreWordlistComments, "ic", opts.Input.IgnoreWordlistComments, "Ignore wordlist comments") flag.IntVar(&opts.General.MaxTime, "maxtime", opts.General.MaxTime, "Maximum running time in seconds for entire process.") flag.IntVar(&opts.General.MaxTimeJob, "maxtime-job", opts.General.MaxTimeJob, "Maximum running time in seconds per job.") flag.IntVar(&opts.General.Rate, "rate", opts.General.Rate, "Rate of requests per second") flag.IntVar(&opts.General.Threads, "t", opts.General.Threads, "Number of concurrent threads.") flag.IntVar(&opts.HTTP.RecursionDepth, "recursion-depth", opts.HTTP.RecursionDepth, "Maximum recursion depth.") flag.IntVar(&opts.HTTP.Timeout, "timeout", opts.HTTP.Timeout, "HTTP request timeout in seconds.") flag.IntVar(&opts.Input.InputNum, "input-num", opts.Input.InputNum, "Number of inputs to test. Used in conjunction with --input-cmd.") flag.StringVar(&opts.General.AutoCalibrationKeyword, "ack", opts.General.AutoCalibrationKeyword, "Autocalibration keyword") flag.StringVar(&opts.HTTP.ClientCert, "cc", "", "Client cert for authentication. Client key needs to be defined as well for this to work") flag.StringVar(&opts.HTTP.ClientKey, "ck", "", "Client key for authentication. Client certificate needs to be defined as well for this to work") flag.StringVar(&opts.General.ConfigFile, "config", "", "Load configuration from a file") flag.StringVar(&opts.General.ScraperFile, "scraperfile", "", "Custom scraper file path") flag.StringVar(&opts.General.Scrapers, "scrapers", opts.General.Scrapers, "Active scraper groups") flag.StringVar(&opts.Filter.Mode, "fmode", opts.Filter.Mode, "Filter set operator. Either of: and, or") flag.StringVar(&opts.Filter.Lines, "fl", opts.Filter.Lines, "Filter by amount of lines in response. Comma separated list of line counts and ranges") flag.StringVar(&opts.Filter.Regexp, "fr", opts.Filter.Regexp, "Filter regexp") flag.StringVar(&opts.Filter.Size, "fs", opts.Filter.Size, "Filter HTTP response size. Comma separated list of sizes and ranges") flag.StringVar(&opts.Filter.Status, "fc", opts.Filter.Status, "Filter HTTP status codes from response. Comma separated list of codes and ranges") flag.StringVar(&opts.Filter.Time, "ft", opts.Filter.Time, "Filter by number of milliseconds to the first response byte, either greater or less than. EG: >100 or <100") flag.StringVar(&opts.Filter.Words, "fw", opts.Filter.Words, "Filter by amount of words in response. Comma separated list of word counts and ranges") flag.StringVar(&opts.General.Delay, "p", opts.General.Delay, "Seconds of `delay` between requests, or a range of random delay. For example \"0.1\" or \"0.1-2.0\"") flag.StringVar(&opts.General.Searchhash, "search", opts.General.Searchhash, "Search for a FFUFHASH payload from ffuf history") flag.StringVar(&opts.HTTP.Data, "d", opts.HTTP.Data, "POST data") flag.StringVar(&opts.HTTP.Data, "data", opts.HTTP.Data, "POST data (alias of -d)") flag.StringVar(&opts.HTTP.Data, "data-ascii", opts.HTTP.Data, "POST data (alias of -d)") flag.StringVar(&opts.HTTP.Data, "data-binary", opts.HTTP.Data, "POST data (alias of -d)") flag.StringVar(&opts.HTTP.Method, "X", opts.HTTP.Method, "HTTP method to use") flag.StringVar(&opts.HTTP.ProxyURL, "x", opts.HTTP.ProxyURL, "Proxy URL (SOCKS5 or HTTP). For example: http://127.0.0.1:8080 or socks5://127.0.0.1:8080") flag.StringVar(&opts.HTTP.ReplayProxyURL, "replay-proxy", opts.HTTP.ReplayProxyURL, "Replay matched requests using this proxy.") flag.StringVar(&opts.HTTP.RecursionStrategy, "recursion-strategy", opts.HTTP.RecursionStrategy, "Recursion strategy: \"default\" for a redirect based, and \"greedy\" to recurse on all matches") flag.StringVar(&opts.HTTP.URL, "u", opts.HTTP.URL, "Target URL") flag.StringVar(&opts.HTTP.SNI, "sni", opts.HTTP.SNI, "Target TLS SNI, does not support FUZZ keyword") flag.StringVar(&opts.Input.Extensions, "e", opts.Input.Extensions, "Comma separated list of extensions. Extends FUZZ keyword.") flag.StringVar(&opts.Input.InputMode, "mode", opts.Input.InputMode, "Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork, sniper") flag.StringVar(&opts.Input.InputShell, "input-shell", opts.Input.InputShell, "Shell to be used for running command") flag.StringVar(&opts.Input.Request, "request", opts.Input.Request, "File containing the raw http request") flag.StringVar(&opts.Input.RequestProto, "request-proto", opts.Input.RequestProto, "Protocol to use along with raw request") flag.StringVar(&opts.Matcher.Mode, "mmode", opts.Matcher.Mode, "Matcher set operator. Either of: and, or") flag.StringVar(&opts.Matcher.Lines, "ml", opts.Matcher.Lines, "Match amount of lines in response") flag.StringVar(&opts.Matcher.Regexp, "mr", opts.Matcher.Regexp, "Match regexp") flag.StringVar(&opts.Matcher.Size, "ms", opts.Matcher.Size, "Match HTTP response size") flag.StringVar(&opts.Matcher.Status, "mc", opts.Matcher.Status, "Match HTTP status codes, or \"all\" for everything.") flag.StringVar(&opts.Matcher.Time, "mt", opts.Matcher.Time, "Match how many milliseconds to the first response byte, either greater or less than. EG: >100 or <100") flag.StringVar(&opts.Matcher.Words, "mw", opts.Matcher.Words, "Match amount of words in response") flag.StringVar(&opts.Output.DebugLog, "debug-log", opts.Output.DebugLog, "Write all of the internal logging to the specified file.") flag.StringVar(&opts.Output.OutputDirectory, "od", opts.Output.OutputDirectory, "Directory path to store matched results to.") flag.StringVar(&opts.Output.OutputFile, "o", opts.Output.OutputFile, "Write output to file") flag.StringVar(&opts.Output.OutputFormat, "of", opts.Output.OutputFormat, "Output file format. Available formats: json, ejson, html, md, csv, ecsv (or, 'all' for all formats)") flag.Var(&autocalibrationstrings, "acc", "Custom auto-calibration string. Can be used multiple times. Implies -ac") flag.Var(&autocalibrationstrategies, "acs", "Custom auto-calibration strategies. Can be used multiple times. Implies -ac") flag.Var(&cookies, "b", "Cookie data `\"NAME1=VALUE1; NAME2=VALUE2\"` for copy as curl functionality.") flag.Var(&cookies, "cookie", "Cookie data (alias of -b)") flag.Var(&headers, "H", "Header `\"Name: Value\"`, separated by colon. Multiple -H flags are accepted.") flag.Var(&inputcommands, "input-cmd", "Command producing the input. --input-num is required when using this input method. Overrides -w.") flag.Var(&wordlists, "w", "Wordlist file path and (optional) keyword separated by colon. eg. '/path/to/wordlist:KEYWORD'") flag.Var(&encoders, "enc", "Encoders for keywords, eg. 'FUZZ:urlencode b64encode'") flag.Usage = Usage flag.Parse() opts.General.AutoCalibrationStrings = autocalibrationstrings if len(autocalibrationstrategies) > 0 { opts.General.AutoCalibrationStrategies = []string {} for _, strategy := range autocalibrationstrategies { opts.General.AutoCalibrationStrategies = append(opts.General.AutoCalibrationStrategies, strings.Split(strategy, ",")...) } } opts.HTTP.Cookies = cookies opts.HTTP.Headers = headers opts.Input.Inputcommands = inputcommands opts.Input.Wordlists = wordlists opts.Input.Encoders = encoders return opts } func main() { var err, optserr error ctx, cancel := context.WithCancel(context.Background()) defer cancel() // prepare the default config options from default config file var opts *ffuf.ConfigOptions opts, optserr = ffuf.ReadDefaultConfig() opts = ParseFlags(opts) // Handle searchhash functionality and exit if opts.General.Searchhash != "" { coptions, pos, err := ffuf.SearchHash(opts.General.Searchhash) if err != nil { fmt.Printf("[ERR] %s\n", err) os.Exit(1) } if len(coptions) > 0 { fmt.Printf("Request candidate(s) for hash %s\n", opts.General.Searchhash) } for _, copt := range coptions { conf, err := ffuf.ConfigFromOptions(&copt.ConfigOptions, ctx, cancel) if err != nil { continue } ok, reason := ffuf.HistoryReplayable(conf) if ok { printSearchResults(conf, pos, copt.Time, opts.General.Searchhash) } else { fmt.Printf("[ERR] Hash cannot be mapped back because %s\n", reason) } } if err != nil { fmt.Printf("[ERR] %s\n", err) } os.Exit(0) } if opts.General.ShowVersion { fmt.Printf("ffuf version: %s\n", ffuf.Version()) os.Exit(0) } if len(opts.Output.DebugLog) != 0 { f, err := os.OpenFile(opts.Output.DebugLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Fprintf(os.Stderr, "Disabling logging, encountered error(s): %s\n", err) log.SetOutput(io.Discard) } else { log.SetOutput(f) defer f.Close() } } else { log.SetOutput(io.Discard) } if optserr != nil { log.Printf("Error while opening default config file: %s", optserr) } if opts.General.ConfigFile != "" { opts, err = ffuf.ReadConfig(opts.General.ConfigFile) if err != nil { fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err) os.Exit(1) } // Reset the flag package state flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) // Re-parse the cli options opts = ParseFlags(opts) } // Set up Config struct conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel) if err != nil { fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) os.Exit(1) } job, err := prepareJob(conf) if err != nil { fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) os.Exit(1) } if err := SetupFilters(opts, conf); err != nil { fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) os.Exit(1) } if !conf.Noninteractive { go func() { err := interactive.Handle(job) if err != nil { log.Printf("Error while trying to initialize interactive session: %s", err) } }() } // Job handles waiting for goroutines to complete itself job.Start() } func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) { var err error job := ffuf.NewJob(conf) var errs ffuf.Multierror job.Input, errs = input.NewInputProvider(conf) // TODO: implement error handling for runnerprovider and outputprovider // We only have http runner right now job.Runner = runner.NewRunnerByName("http", conf, false) if len(conf.ReplayProxyURL) > 0 { job.ReplayRunner = runner.NewRunnerByName("http", conf, true) } // We only have stdout outputprovider right now job.Output = output.NewOutputProviderByName("stdout", conf) // Initialize scraper newscraper, scraper_err := scraper.FromDir(ffuf.SCRAPERDIR, conf.Scrapers) if scraper_err.ErrorOrNil() != nil { errs.Add(scraper_err.ErrorOrNil()) } job.Scraper = newscraper if conf.ScraperFile != "" { err = job.Scraper.AppendFromFile(conf.ScraperFile) if err != nil { errs.Add(err) } } return job, errs.ErrorOrNil() } func SetupFilters(parseOpts *ffuf.ConfigOptions, conf *ffuf.Config) error { errs := ffuf.NewMultierror() conf.MatcherManager = filter.NewMatcherManager() // If any other matcher is set, ignore -mc default value matcherSet := false statusSet := false warningIgnoreBody := false flag.Visit(func(f *flag.Flag) { if f.Name == "mc" { statusSet = true } if f.Name == "ms" { matcherSet = true warningIgnoreBody = true } if f.Name == "ml" { matcherSet = true warningIgnoreBody = true } if f.Name == "mr" { matcherSet = true } if f.Name == "mt" { matcherSet = true } if f.Name == "mw" { matcherSet = true warningIgnoreBody = true } }) // Only set default matchers if no if statusSet || !matcherSet { if err := conf.MatcherManager.AddMatcher("status", parseOpts.Matcher.Status); err != nil { errs.Add(err) } } if parseOpts.Filter.Status != "" { if err := conf.MatcherManager.AddFilter("status", parseOpts.Filter.Status, false); err != nil { errs.Add(err) } } if parseOpts.Filter.Size != "" { warningIgnoreBody = true if err := conf.MatcherManager.AddFilter("size", parseOpts.Filter.Size, false); err != nil { errs.Add(err) } } if parseOpts.Filter.Regexp != "" { if err := conf.MatcherManager.AddFilter("regexp", parseOpts.Filter.Regexp, false); err != nil { errs.Add(err) } } if parseOpts.Filter.Words != "" { warningIgnoreBody = true if err := conf.MatcherManager.AddFilter("word", parseOpts.Filter.Words, false); err != nil { errs.Add(err) } } if parseOpts.Filter.Lines != "" { warningIgnoreBody = true if err := conf.MatcherManager.AddFilter("line", parseOpts.Filter.Lines, false); err != nil { errs.Add(err) } } if parseOpts.Filter.Time != "" { if err := conf.MatcherManager.AddFilter("time", parseOpts.Filter.Time, false); err != nil { errs.Add(err) } } if parseOpts.Matcher.Size != "" { if err := conf.MatcherManager.AddMatcher("size", parseOpts.Matcher.Size); err != nil { errs.Add(err) } } if parseOpts.Matcher.Regexp != "" { if err := conf.MatcherManager.AddMatcher("regexp", parseOpts.Matcher.Regexp); err != nil { errs.Add(err) } } if parseOpts.Matcher.Words != "" { if err := conf.MatcherManager.AddMatcher("word", parseOpts.Matcher.Words); err != nil { errs.Add(err) } } if parseOpts.Matcher.Lines != "" { if err := conf.MatcherManager.AddMatcher("line", parseOpts.Matcher.Lines); err != nil { errs.Add(err) } } if parseOpts.Matcher.Time != "" { if err := conf.MatcherManager.AddMatcher("time", parseOpts.Matcher.Time); err != nil { errs.Add(err) } } if conf.IgnoreBody && warningIgnoreBody { fmt.Printf("*** Warning: possible undesired combination of -ignore-body and the response options: fl,fs,fw,ml,ms and mw.\n") } return errs.ErrorOrNil() } func printSearchResults(conf *ffuf.Config, pos int, exectime time.Time, hash string) { inp, err := input.NewInputProvider(conf) if err.ErrorOrNil() != nil { fmt.Printf("-------------------------------------------\n") fmt.Println("Encountered error that prevents reproduction of the request:") fmt.Println(err.ErrorOrNil()) return } inp.SetPosition(pos) inputdata := inp.Value() inputdata["FFUFHASH"] = []byte(hash) basereq := ffuf.BaseRequest(conf) dummyrunner := runner.NewRunnerByName("simple", conf, false) ffufreq, _ := dummyrunner.Prepare(inputdata, &basereq) rawreq, _ := dummyrunner.Dump(&ffufreq) fmt.Printf("-------------------------------------------\n") fmt.Printf("ffuf job started at: %s\n\n", exectime.Format(time.RFC3339)) fmt.Printf("%s\n", string(rawreq)) } ffuf-2.1.0/pkg/000077500000000000000000000000001450131640400132175ustar00rootroot00000000000000ffuf-2.1.0/pkg/ffuf/000077500000000000000000000000001450131640400141455ustar00rootroot00000000000000ffuf-2.1.0/pkg/ffuf/autocalibration.go000066400000000000000000000202341450131640400176550ustar00rootroot00000000000000package ffuf import ( "fmt" "log" "math/rand" "strconv" "time" "encoding/json" "path/filepath" "os" ) type AutocalibrationStrategy map[string][]string func (j *Job) autoCalibrationStrings() map[string][]string { rand.Seed(time.Now().UnixNano()) cInputs := make(map[string][]string) if len(j.Config.AutoCalibrationStrings) > 0 { cInputs["custom"] = append(cInputs["custom"], j.Config.AutoCalibrationStrings...) return cInputs } for _, strategy := range j.Config.AutoCalibrationStrategies { jsonStrategy, err := os.ReadFile(filepath.Join(AUTOCALIBDIR, strategy+".json")) if err != nil { j.Output.Warning(fmt.Sprintf("Skipping strategy \"%s\" because of error: %s\n", strategy, err)) continue } tmpStrategy := AutocalibrationStrategy{} err = json.Unmarshal(jsonStrategy, &tmpStrategy) if err != nil { j.Output.Warning(fmt.Sprintf("Skipping strategy \"%s\" because of error: %s\n", strategy, err)) continue } cInputs = mergeMaps(cInputs, tmpStrategy) } return cInputs } func setupDefaultAutocalibrationStrategies() error { basic_strategy := AutocalibrationStrategy { "basic_admin": []string{"admin"+RandomString(16), "admin"+RandomString(8)}, "htaccess": []string{".htaccess"+RandomString(16), ".htaccess"+RandomString(8)}, "basic_random": []string{RandomString(16), RandomString(8)}, } basic_strategy_json, err := json.Marshal(basic_strategy) if err != nil { return err } advanced_strategy := AutocalibrationStrategy { "basic_admin": []string{"admin"+RandomString(16), "admin"+RandomString(8)}, "htaccess": []string{".htaccess"+RandomString(16), ".htaccess"+RandomString(8)}, "basic_random": []string{RandomString(16), RandomString(8)}, "admin_dir": []string{"admin"+RandomString(16)+"/", "admin"+RandomString(8)+"/"}, "random_dir": []string{RandomString(16)+"/", RandomString(8)+"/"}, } advanced_strategy_json, err := json.Marshal(advanced_strategy) if err != nil { return err } basic_strategy_file := filepath.Join(AUTOCALIBDIR, "basic.json") if !FileExists(basic_strategy_file) { err = os.WriteFile(filepath.Join(AUTOCALIBDIR, "basic.json"), basic_strategy_json, 0640) return err } advanced_strategy_file := filepath.Join(AUTOCALIBDIR, "advanced.json") if !FileExists(advanced_strategy_file) { err = os.WriteFile(filepath.Join(AUTOCALIBDIR, "advanced.json"), advanced_strategy_json, 0640) return err } return nil } func (j *Job) calibrationRequest(inputs map[string][]byte) (Response, error) { basereq := BaseRequest(j.Config) req, err := j.Runner.Prepare(inputs, &basereq) if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while preparing autocalibration request: %s\n", err)) j.incError() log.Printf("%s", err) return Response{}, err } resp, err := j.Runner.Execute(&req) if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while executing autocalibration request: %s\n", err)) j.incError() log.Printf("%s", err) return Response{}, err } // Only calibrate on responses that would be matched otherwise if j.isMatch(resp) { return resp, nil } return resp, fmt.Errorf("Response wouldn't be matched") } // CalibrateForHost runs autocalibration for a specific host func (j *Job) CalibrateForHost(host string, baseinput map[string][]byte) error { if j.Config.MatcherManager.CalibratedForDomain(host) { return nil } if baseinput[j.Config.AutoCalibrationKeyword] == nil { return fmt.Errorf("Autocalibration keyword \"%s\" not found in the request.", j.Config.AutoCalibrationKeyword) } cStrings := j.autoCalibrationStrings() input := make(map[string][]byte) for k, v := range baseinput { input[k] = v } for _, v := range cStrings { responses := make([]Response, 0) for _, cs := range v { input[j.Config.AutoCalibrationKeyword] = []byte(cs) resp, err := j.calibrationRequest(input) if err != nil { continue } responses = append(responses, resp) err = j.calibrateFilters(responses, true) if err != nil { j.Output.Error(fmt.Sprintf("%s", err)) } } } j.Config.MatcherManager.SetCalibratedForHost(host, true) return nil } // CalibrateResponses returns slice of Responses for randomly generated filter autocalibration requests func (j *Job) Calibrate(input map[string][]byte) error { if j.Config.MatcherManager.Calibrated() { return nil } cInputs := j.autoCalibrationStrings() for _, v := range cInputs { responses := make([]Response, 0) for _, cs := range v { input[j.Config.AutoCalibrationKeyword] = []byte(cs) resp, err := j.calibrationRequest(input) if err != nil { continue } responses = append(responses, resp) } _ = j.calibrateFilters(responses, false) } j.Config.MatcherManager.SetCalibrated(true) return nil } // CalibrateIfNeeded runs a self-calibration task for filtering options (if needed) by requesting random resources and // // configuring the filters accordingly func (j *Job) CalibrateIfNeeded(host string, input map[string][]byte) error { j.calibMutex.Lock() defer j.calibMutex.Unlock() if !j.Config.AutoCalibration { return nil } if j.Config.AutoCalibrationPerHost { return j.CalibrateForHost(host, input) } return j.Calibrate(input) } func (j *Job) calibrateFilters(responses []Response, perHost bool) error { // Work down from the most specific common denominator if len(responses) > 0 { // Content length baselineSize := responses[0].ContentLength sizeMatch := true for _, r := range responses { if baselineSize != r.ContentLength { sizeMatch = false } } if sizeMatch { if perHost { // Check if already filtered for _, f := range j.Config.MatcherManager.FiltersForDomain(HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(HostURLFromRequest(*responses[0].Request), "size", strconv.FormatInt(baselineSize, 10)) return nil } else { // Check if already filtered for _, f := range j.Config.MatcherManager.GetFilters() { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddFilter("size", strconv.FormatInt(baselineSize, 10), false) return nil } } // Content words baselineWords := responses[0].ContentWords wordsMatch := true for _, r := range responses { if baselineWords != r.ContentWords { wordsMatch = false } } if wordsMatch { if perHost { // Check if already filtered for _, f := range j.Config.MatcherManager.FiltersForDomain(HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(HostURLFromRequest(*responses[0].Request), "word", strconv.FormatInt(baselineWords, 10)) return nil } else { // Check if already filtered for _, f := range j.Config.MatcherManager.GetFilters() { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddFilter("word", strconv.FormatInt(baselineWords, 10), false) return nil } } // Content lines baselineLines := responses[0].ContentLines linesMatch := true for _, r := range responses { if baselineLines != r.ContentLines { linesMatch = false } } if linesMatch { if perHost { // Check if already filtered for _, f := range j.Config.MatcherManager.FiltersForDomain(HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(HostURLFromRequest(*responses[0].Request), "line", strconv.FormatInt(baselineLines, 10)) return nil } else { // Check if already filtered for _, f := range j.Config.MatcherManager.GetFilters() { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddFilter("line", strconv.FormatInt(baselineLines, 10), false) return nil } } } return fmt.Errorf("No common filtering values found") } ffuf-2.1.0/pkg/ffuf/config.go000066400000000000000000000137311450131640400157460ustar00rootroot00000000000000package ffuf import ( "context" ) type Config struct { AutoCalibration bool `json:"autocalibration"` AutoCalibrationKeyword string `json:"autocalibration_keyword"` AutoCalibrationPerHost bool `json:"autocalibration_perhost"` AutoCalibrationStrategies []string `json:"autocalibration_strategies"` AutoCalibrationStrings []string `json:"autocalibration_strings"` Cancel context.CancelFunc `json:"-"` Colors bool `json:"colors"` CommandKeywords []string `json:"-"` CommandLine string `json:"cmdline"` ConfigFile string `json:"configfile"` Context context.Context `json:"-"` Data string `json:"postdata"` Debuglog string `json:"debuglog"` Delay optRange `json:"delay"` DirSearchCompat bool `json:"dirsearch_compatibility"` Encoders []string `json:"encoders"` Extensions []string `json:"extensions"` FilterMode string `json:"fmode"` FollowRedirects bool `json:"follow_redirects"` Headers map[string]string `json:"headers"` IgnoreBody bool `json:"ignorebody"` IgnoreWordlistComments bool `json:"ignore_wordlist_comments"` InputMode string `json:"inputmode"` InputNum int `json:"cmd_inputnum"` InputProviders []InputProviderConfig `json:"inputproviders"` InputShell string `json:"inputshell"` Json bool `json:"json"` MatcherManager MatcherManager `json:"matchers"` MatcherMode string `json:"mmode"` MaxTime int `json:"maxtime"` MaxTimeJob int `json:"maxtime_job"` Method string `json:"method"` Noninteractive bool `json:"noninteractive"` OutputDirectory string `json:"outputdirectory"` OutputFile string `json:"outputfile"` OutputFormat string `json:"outputformat"` OutputSkipEmptyFile bool `json:"OutputSkipEmptyFile"` ProgressFrequency int `json:"-"` ProxyURL string `json:"proxyurl"` Quiet bool `json:"quiet"` Rate int64 `json:"rate"` Raw bool `json:"raw"` Recursion bool `json:"recursion"` RecursionDepth int `json:"recursion_depth"` RecursionStrategy string `json:"recursion_strategy"` ReplayProxyURL string `json:"replayproxyurl"` RequestFile string `json:"requestfile"` RequestProto string `json:"requestproto"` ScraperFile string `json:"scraperfile"` Scrapers string `json:"scrapers"` SNI string `json:"sni"` StopOn403 bool `json:"stop_403"` StopOnAll bool `json:"stop_all"` StopOnErrors bool `json:"stop_errors"` Threads int `json:"threads"` Timeout int `json:"timeout"` Url string `json:"url"` Verbose bool `json:"verbose"` Wordlists []string `json:"wordlists"` Http2 bool `json:"http2"` ClientCert string `json:"client-cert"` ClientKey string `json:"client-key"` } type InputProviderConfig struct { Name string `json:"name"` Keyword string `json:"keyword"` Value string `json:"value"` Encoders string `json:"encoders"` Template string `json:"template"` // the templating string used for sniper mode (usually "§") } func NewConfig(ctx context.Context, cancel context.CancelFunc) Config { var conf Config conf.AutoCalibrationKeyword = "FUZZ" conf.AutoCalibrationStrategies = []string{"basic"} conf.AutoCalibrationStrings = make([]string, 0) conf.CommandKeywords = make([]string, 0) conf.Context = ctx conf.Cancel = cancel conf.Data = "" conf.Debuglog = "" conf.Delay = optRange{0, 0, false, false} conf.DirSearchCompat = false conf.Encoders = make([]string, 0) conf.Extensions = make([]string, 0) conf.FilterMode = "or" conf.FollowRedirects = false conf.Headers = make(map[string]string) conf.IgnoreWordlistComments = false conf.InputMode = "clusterbomb" conf.InputNum = 0 conf.InputShell = "" conf.InputProviders = make([]InputProviderConfig, 0) conf.Json = false conf.MatcherMode = "or" conf.MaxTime = 0 conf.MaxTimeJob = 0 conf.Method = "GET" conf.Noninteractive = false conf.ProgressFrequency = 125 conf.ProxyURL = "" conf.Quiet = false conf.Rate = 0 conf.Raw = false conf.Recursion = false conf.RecursionDepth = 0 conf.RecursionStrategy = "default" conf.RequestFile = "" conf.RequestProto = "https" conf.SNI = "" conf.ScraperFile = "" conf.Scrapers = "all" conf.StopOn403 = false conf.StopOnAll = false conf.StopOnErrors = false conf.Timeout = 10 conf.Url = "" conf.Verbose = false conf.Wordlists = []string{} conf.Http2 = false return conf } func (c *Config) SetContext(ctx context.Context, cancel context.CancelFunc) { c.Context = ctx c.Cancel = cancel } ffuf-2.1.0/pkg/ffuf/configmarshaller.go000066400000000000000000000070541450131640400200220ustar00rootroot00000000000000package ffuf import ( "fmt" "strings" ) func (c *Config) ToOptions() ConfigOptions { o := ConfigOptions{} // HTTP options o.HTTP.Cookies = []string{} o.HTTP.Data = c.Data o.HTTP.FollowRedirects = c.FollowRedirects o.HTTP.Headers = make([]string, 0) for k, v := range c.Headers { o.HTTP.Headers = append(o.HTTP.Headers, fmt.Sprintf("%s: %s", k, v)) } o.HTTP.IgnoreBody = c.IgnoreBody o.HTTP.Method = c.Method o.HTTP.ProxyURL = c.ProxyURL o.HTTP.Raw = c.Raw o.HTTP.Recursion = c.Recursion o.HTTP.RecursionDepth = c.RecursionDepth o.HTTP.RecursionStrategy = c.RecursionStrategy o.HTTP.ReplayProxyURL = c.ReplayProxyURL o.HTTP.SNI = c.SNI o.HTTP.Timeout = c.Timeout o.HTTP.URL = c.Url o.HTTP.Http2 = c.Http2 o.General.AutoCalibration = c.AutoCalibration o.General.AutoCalibrationKeyword = c.AutoCalibrationKeyword o.General.AutoCalibrationPerHost = c.AutoCalibrationPerHost o.General.AutoCalibrationStrategies = c.AutoCalibrationStrategies o.General.AutoCalibrationStrings = c.AutoCalibrationStrings o.General.Colors = c.Colors o.General.ConfigFile = "" if c.Delay.HasDelay { if c.Delay.IsRange { o.General.Delay = fmt.Sprintf("%.2f-%.2f", c.Delay.Min, c.Delay.Max) } else { o.General.Delay = fmt.Sprintf("%.2f", c.Delay.Min) } } else { o.General.Delay = "" } o.General.Json = c.Json o.General.MaxTime = c.MaxTime o.General.MaxTimeJob = c.MaxTimeJob o.General.Noninteractive = c.Noninteractive o.General.Quiet = c.Quiet o.General.Rate = int(c.Rate) o.General.ScraperFile = c.ScraperFile o.General.Scrapers = c.Scrapers o.General.StopOn403 = c.StopOn403 o.General.StopOnAll = c.StopOnAll o.General.StopOnErrors = c.StopOnErrors o.General.Threads = c.Threads o.General.Verbose = c.Verbose o.Input.DirSearchCompat = c.DirSearchCompat o.Input.Extensions = strings.Join(c.Extensions, ",") o.Input.IgnoreWordlistComments = c.IgnoreWordlistComments o.Input.InputMode = c.InputMode o.Input.InputNum = c.InputNum o.Input.InputShell = c.InputShell o.Input.Inputcommands = []string{} for _, v := range c.InputProviders { if v.Name == "command" { o.Input.Inputcommands = append(o.Input.Inputcommands, fmt.Sprintf("%s:%s", v.Value, v.Keyword)) } } o.Input.Request = c.RequestFile o.Input.RequestProto = c.RequestProto o.Input.Wordlists = c.Wordlists o.Output.DebugLog = c.Debuglog o.Output.OutputDirectory = c.OutputDirectory o.Output.OutputFile = c.OutputFile o.Output.OutputFormat = c.OutputFormat o.Output.OutputSkipEmptyFile = c.OutputSkipEmptyFile o.Filter.Mode = c.FilterMode o.Filter.Lines = "" o.Filter.Regexp = "" o.Filter.Size = "" o.Filter.Status = "" o.Filter.Time = "" o.Filter.Words = "" for name, filter := range c.MatcherManager.GetFilters() { switch name { case "line": o.Filter.Lines = filter.Repr() case "regexp": o.Filter.Regexp = filter.Repr() case "size": o.Filter.Size = filter.Repr() case "status": o.Filter.Status = filter.Repr() case "time": o.Filter.Time = filter.Repr() case "words": o.Filter.Words = filter.Repr() } } o.Matcher.Mode = c.MatcherMode o.Matcher.Lines = "" o.Matcher.Regexp = "" o.Matcher.Size = "" o.Matcher.Status = "" o.Matcher.Time = "" o.Matcher.Words = "" for name, filter := range c.MatcherManager.GetMatchers() { switch name { case "line": o.Matcher.Lines = filter.Repr() case "regexp": o.Matcher.Regexp = filter.Repr() case "size": o.Matcher.Size = filter.Repr() case "status": o.Matcher.Status = filter.Repr() case "time": o.Matcher.Time = filter.Repr() case "words": o.Matcher.Words = filter.Repr() } } return o } ffuf-2.1.0/pkg/ffuf/constants.go000066400000000000000000000007061450131640400165130ustar00rootroot00000000000000package ffuf import ( "github.com/adrg/xdg" "path/filepath" ) var ( //VERSION holds the current version number VERSION = "2.1.0" //VERSION_APPENDIX holds additional version definition VERSION_APPENDIX = "-dev" CONFIGDIR = filepath.Join(xdg.ConfigHome, "ffuf") HISTORYDIR = filepath.Join(CONFIGDIR, "history") SCRAPERDIR = filepath.Join(CONFIGDIR, "scraper") AUTOCALIBDIR = filepath.Join(CONFIGDIR, "autocalibration") ) ffuf-2.1.0/pkg/ffuf/history.go000066400000000000000000000043331450131640400162000ustar00rootroot00000000000000package ffuf import ( "crypto/sha256" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" ) type ConfigOptionsHistory struct { ConfigOptions Time time.Time `json:"time"` } func WriteHistoryEntry(conf *Config) (string, error) { options := ConfigOptionsHistory{ ConfigOptions: conf.ToOptions(), Time: time.Now(), } jsonoptions, err := json.Marshal(options) if err != nil { return "", err } hashstr := calculateHistoryHash(jsonoptions) err = createConfigDir(filepath.Join(HISTORYDIR, hashstr)) if err != nil { return "", err } err = os.WriteFile(filepath.Join(HISTORYDIR, hashstr, "options"), jsonoptions, 0640) return hashstr, err } func calculateHistoryHash(options []byte) string { return fmt.Sprintf("%x", sha256.Sum256(options)) } func SearchHash(hash string) ([]ConfigOptionsHistory, int, error) { coptions := make([]ConfigOptionsHistory, 0) if len(hash) < 6 { return coptions, 0, errors.New("bad FFUFHASH value") } historypart := hash[0:5] position, err := strconv.ParseInt(hash[5:], 16, 32) if err != nil { return coptions, 0, errors.New("bad positional value in FFUFHASH") } all_dirs, err := os.ReadDir(HISTORYDIR) if err != nil { return coptions, 0, err } matched_dirs := make([]string, 0) for _, filename := range all_dirs { if filename.IsDir() { if strings.HasPrefix(strings.ToLower(filename.Name()), strings.ToLower(historypart)) { matched_dirs = append(matched_dirs, filename.Name()) } } } for _, dirname := range matched_dirs { copts, err := configFromHistory(filepath.Join(HISTORYDIR, dirname)) if err != nil { continue } coptions = append(coptions, copts) } return coptions, int(position), err } func HistoryReplayable(conf *Config) (bool, string) { for _, w := range conf.Wordlists { if w == "-" || strings.HasPrefix(w, "-:") { return false, "stdin input was used for one of the wordlists" } } return true, "" } func configFromHistory(dirname string) (ConfigOptionsHistory, error) { jsonOptions, err := os.ReadFile(filepath.Join(dirname, "options")) if err != nil { return ConfigOptionsHistory{}, err } tmpOptions := ConfigOptionsHistory{} err = json.Unmarshal(jsonOptions, &tmpOptions) return tmpOptions, err } ffuf-2.1.0/pkg/ffuf/interfaces.go000066400000000000000000000061021450131640400166160ustar00rootroot00000000000000package ffuf import ( "time" ) // MatcherManager provides functions for managing matchers and filters type MatcherManager interface { SetCalibrated(calibrated bool) SetCalibratedForHost(host string, calibrated bool) AddFilter(name string, option string, replace bool) error AddPerDomainFilter(domain string, name string, option string) error RemoveFilter(name string) AddMatcher(name string, option string) error GetFilters() map[string]FilterProvider GetMatchers() map[string]FilterProvider FiltersForDomain(domain string) map[string]FilterProvider CalibratedForDomain(domain string) bool Calibrated() bool } // FilterProvider is a generic interface for both Matchers and Filters type FilterProvider interface { Filter(response *Response) (bool, error) Repr() string ReprVerbose() string } // RunnerProvider is an interface for request executors type RunnerProvider interface { Prepare(input map[string][]byte, basereq *Request) (Request, error) Execute(req *Request) (Response, error) Dump(req *Request) ([]byte, error) } // InputProvider interface handles the input data for RunnerProvider type InputProvider interface { ActivateKeywords([]string) AddProvider(InputProviderConfig) error Keywords() []string Next() bool Position() int SetPosition(int) Reset() Value() map[string][]byte Total() int } // InternalInputProvider interface handles providing input data to InputProvider type InternalInputProvider interface { Keyword() string Next() bool Position() int SetPosition(int) ResetPosition() IncrementPosition() Value() []byte Total() int Active() bool Enable() Disable() } // OutputProvider is responsible of providing output from the RunnerProvider type OutputProvider interface { Banner() Finalize() error Progress(status Progress) Info(infostring string) Error(errstring string) Raw(output string) Warning(warnstring string) Result(resp Response) PrintResult(res Result) SaveFile(filename, format string) error GetCurrentResults() []Result SetCurrentResults(results []Result) Reset() Cycle() } type Scraper interface { Execute(resp *Response, matched bool) []ScraperResult AppendFromFile(path string) error } type ScraperResult struct { Name string `json:"name"` Type string `json:"type"` Action []string `json:"action"` Results []string `json:"results"` } type Result struct { Input map[string][]byte `json:"input"` Position int `json:"position"` StatusCode int64 `json:"status"` ContentLength int64 `json:"length"` ContentWords int64 `json:"words"` ContentLines int64 `json:"lines"` ContentType string `json:"content-type"` RedirectLocation string `json:"redirectlocation"` Url string `json:"url"` Duration time.Duration `json:"duration"` ScraperData map[string][]string `json:"scraper"` ResultFile string `json:"resultfile"` Host string `json:"host"` HTMLColor string `json:"-"` } ffuf-2.1.0/pkg/ffuf/job.go000066400000000000000000000356751450131640400152660ustar00rootroot00000000000000package ffuf import ( "fmt" "log" "math/rand" "os" "os/signal" "sync" "syscall" "time" ) // Job ties together Config, Runner, Input and Output type Job struct { Config *Config ErrorMutex sync.Mutex Input InputProvider Runner RunnerProvider ReplayRunner RunnerProvider Scraper Scraper Output OutputProvider Jobhash string Counter int ErrorCounter int SpuriousErrorCounter int Total int Running bool RunningJob bool Paused bool Count403 int Count429 int Error string Rate *RateThrottle startTime time.Time startTimeJob time.Time queuejobs []QueueJob queuepos int skipQueue bool currentDepth int calibMutex sync.Mutex pauseWg sync.WaitGroup } type QueueJob struct { Url string depth int req Request } func NewJob(conf *Config) *Job { var j Job j.Config = conf j.Counter = 0 j.ErrorCounter = 0 j.SpuriousErrorCounter = 0 j.Running = false j.RunningJob = false j.Paused = false j.queuepos = 0 j.queuejobs = make([]QueueJob, 0) j.currentDepth = 0 j.Rate = NewRateThrottle(conf) j.skipQueue = false return &j } // incError increments the error counter func (j *Job) incError() { j.ErrorMutex.Lock() defer j.ErrorMutex.Unlock() j.ErrorCounter++ j.SpuriousErrorCounter++ } // inc403 increments the 403 response counter func (j *Job) inc403() { j.ErrorMutex.Lock() defer j.ErrorMutex.Unlock() j.Count403++ } // inc429 increments the 429 response counter func (j *Job) inc429() { j.ErrorMutex.Lock() defer j.ErrorMutex.Unlock() j.Count429++ } // resetSpuriousErrors resets the spurious error counter func (j *Job) resetSpuriousErrors() { j.ErrorMutex.Lock() defer j.ErrorMutex.Unlock() j.SpuriousErrorCounter = 0 } // DeleteQueueItem deletes a recursion job from the queue by its index in the slice func (j *Job) DeleteQueueItem(index int) { index = j.queuepos + index - 1 j.queuejobs = append(j.queuejobs[:index], j.queuejobs[index+1:]...) } // QueuedJobs returns the slice of queued recursive jobs func (j *Job) QueuedJobs() []QueueJob { return j.queuejobs[j.queuepos-1:] } // Start the execution of the Job func (j *Job) Start() { if j.startTime.IsZero() { j.startTime = time.Now() } basereq := BaseRequest(j.Config) if j.Config.InputMode == "sniper" { // process multiple payload locations and create a queue job for each location reqs := SniperRequests(&basereq, j.Config.InputProviders[0].Template) for _, r := range reqs { j.queuejobs = append(j.queuejobs, QueueJob{Url: j.Config.Url, depth: 0, req: r}) } j.Total = j.Input.Total() * len(reqs) } else { // Add the default job to job queue j.queuejobs = append(j.queuejobs, QueueJob{Url: j.Config.Url, depth: 0, req: BaseRequest(j.Config)}) j.Total = j.Input.Total() } rand.Seed(time.Now().UnixNano()) defer j.Stop() j.Running = true j.RunningJob = true //Show banner if not running in silent mode if !j.Config.Quiet { j.Output.Banner() } // Monitor for SIGTERM and do cleanup properly (writing the output files etc) j.interruptMonitor() for j.jobsInQueue() { j.prepareQueueJob() j.Reset(true) j.RunningJob = true j.startExecution() } err := j.Output.Finalize() if err != nil { j.Output.Error(err.Error()) } } // Reset resets the counters and wordlist position for a job func (j *Job) Reset(cycle bool) { j.Input.Reset() j.Counter = 0 j.skipQueue = false j.startTimeJob = time.Now() if cycle { j.Output.Cycle() } else { j.Output.Reset() } } func (j *Job) jobsInQueue() bool { return j.queuepos < len(j.queuejobs) } func (j *Job) prepareQueueJob() { j.Config.Url = j.queuejobs[j.queuepos].Url j.currentDepth = j.queuejobs[j.queuepos].depth //Find all keywords present in new queued job kws := j.Input.Keywords() found_kws := make([]string, 0) for _, k := range kws { if RequestContainsKeyword(j.queuejobs[j.queuepos].req, k) { found_kws = append(found_kws, k) } } //And activate / disable inputproviders as needed j.Input.ActivateKeywords(found_kws) j.queuepos += 1 j.Jobhash, _ = WriteHistoryEntry(j.Config) } // SkipQueue allows to skip the current job and advance to the next queued recursion job func (j *Job) SkipQueue() { j.skipQueue = true } func (j *Job) sleepIfNeeded() { var sleepDuration time.Duration if j.Config.Delay.HasDelay { if j.Config.Delay.IsRange { sTime := j.Config.Delay.Min + rand.Float64()*(j.Config.Delay.Max-j.Config.Delay.Min) sleepDuration = time.Duration(sTime * 1000) } else { sleepDuration = time.Duration(j.Config.Delay.Min * 1000) } sleepDuration = sleepDuration * time.Millisecond } // makes the sleep cancellable by context select { case <-j.Config.Context.Done(): // cancelled case <-time.After(sleepDuration): // sleep } } // Pause pauses the job process func (j *Job) Pause() { if !j.Paused { j.Paused = true j.pauseWg.Add(1) j.Output.Info("------ PAUSING ------") } } // Resume resumes the job process func (j *Job) Resume() { if j.Paused { j.Paused = false j.Output.Info("------ RESUMING -----") j.pauseWg.Done() } } func (j *Job) startExecution() { var wg sync.WaitGroup wg.Add(1) go j.runBackgroundTasks(&wg) // Print the base URL when starting a new recursion or sniper queue job if j.queuepos > 1 { if j.Config.InputMode == "sniper" { j.Output.Info(fmt.Sprintf("Starting queued sniper job (%d of %d) on target: %s", j.queuepos, len(j.queuejobs), j.Config.Url)) } else { j.Output.Info(fmt.Sprintf("Starting queued job on target: %s", j.Config.Url)) } } //Limiter blocks after reaching the buffer, ensuring limited concurrency threadlimiter := make(chan bool, j.Config.Threads) for j.Input.Next() && !j.skipQueue { // Check if we should stop the process j.CheckStop() if !j.Running { defer j.Output.Warning(j.Error) break } j.pauseWg.Wait() // Handle the rate & thread limiting threadlimiter <- true // Ratelimiter handles the rate ticker <-j.Rate.RateLimiter.C nextInput := j.Input.Value() nextPosition := j.Input.Position() // Add FFUFHASH and its value nextInput["FFUFHASH"] = j.ffufHash(nextPosition) wg.Add(1) j.Counter++ go func() { defer func() { <-threadlimiter }() defer wg.Done() threadStart := time.Now() j.runTask(nextInput, nextPosition, false) j.sleepIfNeeded() threadEnd := time.Now() j.Rate.Tick(threadStart, threadEnd) }() if !j.RunningJob { defer j.Output.Warning(j.Error) return } } wg.Wait() j.updateProgress() } func (j *Job) interruptMonitor() { sigChan := make(chan os.Signal, 2) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { for range sigChan { j.Error = "Caught keyboard interrupt (Ctrl-C)\n" // resume if paused if j.Paused { j.pauseWg.Done() } // Stop the job j.Stop() } }() } func (j *Job) runBackgroundTasks(wg *sync.WaitGroup) { defer wg.Done() totalProgress := j.Input.Total() for j.Counter <= totalProgress && !j.skipQueue { j.pauseWg.Wait() if !j.Running { break } j.updateProgress() if j.Counter == totalProgress { return } if !j.RunningJob { return } time.Sleep(time.Millisecond * time.Duration(j.Config.ProgressFrequency)) } } func (j *Job) updateProgress() { prog := Progress{ StartedAt: j.startTimeJob, ReqCount: j.Counter, ReqTotal: j.Input.Total(), ReqSec: j.Rate.CurrentRate(), QueuePos: j.queuepos, QueueTotal: len(j.queuejobs), ErrorCount: j.ErrorCounter, } j.Output.Progress(prog) } func (j *Job) isMatch(resp Response) bool { matched := false var matchers map[string]FilterProvider var filters map[string]FilterProvider if j.Config.AutoCalibrationPerHost { filters = j.Config.MatcherManager.FiltersForDomain(HostURLFromRequest(*resp.Request)) } else { filters = j.Config.MatcherManager.GetFilters() } matchers = j.Config.MatcherManager.GetMatchers() for _, m := range matchers { match, err := m.Filter(&resp) if err != nil { continue } if match { matched = true } else if j.Config.MatcherMode == "and" { // we already know this isn't "and" match return false } } // The response was not matched, return before running filters if !matched { return false } for _, f := range filters { fv, err := f.Filter(&resp) if err != nil { continue } if fv { // return false if j.Config.FilterMode == "or" { // return early, as filter matched return false } } else { if j.Config.FilterMode == "and" { // return early as not all filters matched in "and" mode return true } } } if len(filters) > 0 && j.Config.FilterMode == "and" { // we did not return early, so all filters were matched return false } return true } func (j *Job) ffufHash(pos int) []byte { hashstring := "" r := []rune(j.Jobhash) if len(r) > 5 { hashstring = string(r[:5]) } hashstring += fmt.Sprintf("%x", pos) return []byte(hashstring) } func (j *Job) runTask(input map[string][]byte, position int, retried bool) { basereq := j.queuejobs[j.queuepos-1].req req, err := j.Runner.Prepare(input, &basereq) req.Position = position if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while preparing request: %s\n", err)) j.incError() log.Printf("%s", err) return } resp, err := j.Runner.Execute(&req) if err != nil { if retried { j.incError() log.Printf("%s", err) } else { j.runTask(input, position, true) } if os.IsTimeout(err) { for name := range j.Config.MatcherManager.GetMatchers() { if name == "time" { inputmsg := "" for k, v := range input { inputmsg = inputmsg + fmt.Sprintf("%s : %s // ", k, v) } j.Output.Info("Timeout while 'time' matcher is active: " + inputmsg) return } } for name := range j.Config.MatcherManager.GetFilters() { if name == "time" { inputmsg := "" for k, v := range input { inputmsg = inputmsg + fmt.Sprintf("%s : %s // ", k, v) } j.Output.Info("Timeout while 'time' filter is active: " + inputmsg) return } } } return } if j.SpuriousErrorCounter > 0 { j.resetSpuriousErrors() } if j.Config.StopOn403 || j.Config.StopOnAll { // Increment Forbidden counter if we encountered one if resp.StatusCode == 403 { j.inc403() } } if j.Config.StopOnAll { // increment 429 counter if the response code is 429 if resp.StatusCode == 429 { j.inc429() } } j.pauseWg.Wait() // Handle autocalibration, must be done after the actual request to ensure sane value in req.Host _ = j.CalibrateIfNeeded(HostURLFromRequest(req), input) // Handle scraper actions if j.Scraper != nil { for _, sres := range j.Scraper.Execute(&resp, j.isMatch(resp)) { resp.ScraperData[sres.Name] = sres.Results j.handleScraperResult(&resp, sres) } } if j.isMatch(resp) { // Re-send request through replay-proxy if needed if j.ReplayRunner != nil { replayreq, err := j.ReplayRunner.Prepare(input, &basereq) replayreq.Position = position if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while preparing replayproxy request: %s\n", err)) j.incError() log.Printf("%s", err) } else { _, _ = j.ReplayRunner.Execute(&replayreq) } } j.Output.Result(resp) // Refresh the progress indicator as we printed something out j.updateProgress() if j.Config.Recursion && j.Config.RecursionStrategy == "greedy" { j.handleGreedyRecursionJob(resp) } } else { if len(resp.ScraperData) > 0 { // print the result anyway, as scraper found something j.Output.Result(resp) } } if j.Config.Recursion && j.Config.RecursionStrategy == "default" && len(resp.GetRedirectLocation(false)) > 0 { j.handleDefaultRecursionJob(resp) } } func (j *Job) handleScraperResult(resp *Response, sres ScraperResult) { for _, a := range sres.Action { switch a { case "output": resp.ScraperData[sres.Name] = sres.Results } } } // handleGreedyRecursionJob adds a recursion job to the queue if the maximum depth has not been reached func (j *Job) handleGreedyRecursionJob(resp Response) { // Handle greedy recursion strategy. Match has been determined before calling handleRecursionJob if j.Config.RecursionDepth == 0 || j.currentDepth < j.Config.RecursionDepth { recUrl := resp.Request.Url + "/" + "FUZZ" newJob := QueueJob{Url: recUrl, depth: j.currentDepth + 1, req: RecursionRequest(j.Config, recUrl)} j.queuejobs = append(j.queuejobs, newJob) j.Output.Info(fmt.Sprintf("Adding a new job to the queue: %s", recUrl)) } else { j.Output.Warning(fmt.Sprintf("Maximum recursion depth reached. Ignoring: %s", resp.Request.Url)) } } // handleDefaultRecursionJob adds a new recursion job to the job queue if a new directory is found and maximum depth has // not been reached func (j *Job) handleDefaultRecursionJob(resp Response) { recUrl := resp.Request.Url + "/" + "FUZZ" if (resp.Request.Url + "/") != resp.GetRedirectLocation(true) { // Not a directory, return early return } if j.Config.RecursionDepth == 0 || j.currentDepth < j.Config.RecursionDepth { // We have yet to reach the maximum recursion depth newJob := QueueJob{Url: recUrl, depth: j.currentDepth + 1, req: RecursionRequest(j.Config, recUrl)} j.queuejobs = append(j.queuejobs, newJob) j.Output.Info(fmt.Sprintf("Adding a new job to the queue: %s", recUrl)) } else { j.Output.Warning(fmt.Sprintf("Directory found, but recursion depth exceeded. Ignoring: %s", resp.GetRedirectLocation(true))) } } // CheckStop stops the job if stopping conditions are met func (j *Job) CheckStop() { if j.Counter > 50 { // We have enough samples if j.Config.StopOn403 || j.Config.StopOnAll { if float64(j.Count403)/float64(j.Counter) > 0.95 { // Over 95% of requests are 403 j.Error = "Getting an unusual amount of 403 responses, exiting." j.Stop() } } if j.Config.StopOnErrors || j.Config.StopOnAll { if j.SpuriousErrorCounter > j.Config.Threads*2 { // Most of the requests are erroring j.Error = "Receiving spurious errors, exiting." j.Stop() } } if j.Config.StopOnAll && (float64(j.Count429)/float64(j.Counter) > 0.2) { // Over 20% of responses are 429 j.Error = "Getting an unusual amount of 429 responses, exiting." j.Stop() } } // Check for runtime of entire process if j.Config.MaxTime > 0 { dur := time.Since(j.startTime) runningSecs := int(dur / time.Second) if runningSecs >= j.Config.MaxTime { j.Error = "Maximum running time for entire process reached, exiting." j.Stop() } } // Check for runtime of current job if j.Config.MaxTimeJob > 0 { dur := time.Since(j.startTimeJob) runningSecs := int(dur / time.Second) if runningSecs >= j.Config.MaxTimeJob { j.Error = "Maximum running time for this job reached, continuing with next job if one exists." j.Next() } } } // Stop the execution of the Job func (j *Job) Stop() { j.Running = false j.Config.Cancel() } // Stop current, resume to next func (j *Job) Next() { j.RunningJob = false } ffuf-2.1.0/pkg/ffuf/multierror.go000066400000000000000000000010311450131640400166730ustar00rootroot00000000000000package ffuf import ( "fmt" ) type Multierror struct { errors []error } //NewMultierror returns a new Multierror func NewMultierror() Multierror { return Multierror{} } func (m *Multierror) Add(err error) { m.errors = append(m.errors, err) } func (m *Multierror) ErrorOrNil() error { var errString string if len(m.errors) > 0 { errString += fmt.Sprintf("%d errors occured.\n", len(m.errors)) for _, e := range m.errors { errString += fmt.Sprintf("\t* %s\n", e) } return fmt.Errorf("%s", errString) } return nil } ffuf-2.1.0/pkg/ffuf/optionsparser.go000066400000000000000000000556001450131640400174120ustar00rootroot00000000000000package ffuf import ( "bufio" "context" "fmt" "io" "net/textproto" "net/url" "os" "path/filepath" "runtime" "strconv" "strings" "github.com/pelletier/go-toml" ) type ConfigOptions struct { Filter FilterOptions `json:"filters"` General GeneralOptions `json:"general"` HTTP HTTPOptions `json:"http"` Input InputOptions `json:"input"` Matcher MatcherOptions `json:"matchers"` Output OutputOptions `json:"output"` } type HTTPOptions struct { Cookies []string `json:"-"` // this is appended in headers Data string `json:"data"` FollowRedirects bool `json:"follow_redirects"` Headers []string `json:"headers"` IgnoreBody bool `json:"ignore_body"` Method string `json:"method"` ProxyURL string `json:"proxy_url"` Raw bool `json:"raw"` Recursion bool `json:"recursion"` RecursionDepth int `json:"recursion_depth"` RecursionStrategy string `json:"recursion_strategy"` ReplayProxyURL string `json:"replay_proxy_url"` SNI string `json:"sni"` Timeout int `json:"timeout"` URL string `json:"url"` Http2 bool `json:"http2"` ClientCert string `json:"client-cert"` ClientKey string `json:"client-key"` } type GeneralOptions struct { AutoCalibration bool `json:"autocalibration"` AutoCalibrationKeyword string `json:"autocalibration_keyword"` AutoCalibrationPerHost bool `json:"autocalibration_per_host"` AutoCalibrationStrategies []string `json:"autocalibration_strategies"` AutoCalibrationStrings []string `json:"autocalibration_strings"` Colors bool `json:"colors"` ConfigFile string `toml:"-" json:"config_file"` Delay string `json:"delay"` Json bool `json:"json"` MaxTime int `json:"maxtime"` MaxTimeJob int `json:"maxtime_job"` Noninteractive bool `json:"noninteractive"` Quiet bool `json:"quiet"` Rate int `json:"rate"` ScraperFile string `json:"scraperfile"` Scrapers string `json:"scrapers"` Searchhash string `json:"-"` ShowVersion bool `toml:"-" json:"-"` StopOn403 bool `json:"stop_on_403"` StopOnAll bool `json:"stop_on_all"` StopOnErrors bool `json:"stop_on_errors"` Threads int `json:"threads"` Verbose bool `json:"verbose"` } type InputOptions struct { DirSearchCompat bool `json:"dirsearch_compat"` Encoders []string `json:"encoders"` Extensions string `json:"extensions"` IgnoreWordlistComments bool `json:"ignore_wordlist_comments"` InputMode string `json:"input_mode"` InputNum int `json:"input_num"` InputShell string `json:"input_shell"` Inputcommands []string `json:"input_commands"` Request string `json:"request_file"` RequestProto string `json:"request_proto"` Wordlists []string `json:"wordlists"` } type OutputOptions struct { DebugLog string `json:"debug_log"` OutputDirectory string `json:"output_directory"` OutputFile string `json:"output_file"` OutputFormat string `json:"output_format"` OutputSkipEmptyFile bool `json:"output_skip_empty"` } type FilterOptions struct { Mode string `json:"mode"` Lines string `json:"lines"` Regexp string `json:"regexp"` Size string `json:"size"` Status string `json:"status"` Time string `json:"time"` Words string `json:"words"` } type MatcherOptions struct { Mode string `json:"mode"` Lines string `json:"lines"` Regexp string `json:"regexp"` Size string `json:"size"` Status string `json:"status"` Time string `json:"time"` Words string `json:"words"` } // NewConfigOptions returns a newly created ConfigOptions struct with default values func NewConfigOptions() *ConfigOptions { c := &ConfigOptions{} c.Filter.Mode = "or" c.Filter.Lines = "" c.Filter.Regexp = "" c.Filter.Size = "" c.Filter.Status = "" c.Filter.Time = "" c.Filter.Words = "" c.General.AutoCalibration = false c.General.AutoCalibrationKeyword = "FUZZ" c.General.AutoCalibrationStrategies = []string{"basic"} c.General.Colors = false c.General.Delay = "" c.General.Json = false c.General.MaxTime = 0 c.General.MaxTimeJob = 0 c.General.Noninteractive = false c.General.Quiet = false c.General.Rate = 0 c.General.Searchhash = "" c.General.ScraperFile = "" c.General.Scrapers = "all" c.General.ShowVersion = false c.General.StopOn403 = false c.General.StopOnAll = false c.General.StopOnErrors = false c.General.Threads = 40 c.General.Verbose = false c.HTTP.Data = "" c.HTTP.FollowRedirects = false c.HTTP.IgnoreBody = false c.HTTP.Method = "" c.HTTP.ProxyURL = "" c.HTTP.Raw = false c.HTTP.Recursion = false c.HTTP.RecursionDepth = 0 c.HTTP.RecursionStrategy = "default" c.HTTP.ReplayProxyURL = "" c.HTTP.Timeout = 10 c.HTTP.SNI = "" c.HTTP.URL = "" c.HTTP.Http2 = false c.Input.DirSearchCompat = false c.Input.Encoders = []string{} c.Input.Extensions = "" c.Input.IgnoreWordlistComments = false c.Input.InputMode = "clusterbomb" c.Input.InputNum = 100 c.Input.Request = "" c.Input.RequestProto = "https" c.Matcher.Mode = "or" c.Matcher.Lines = "" c.Matcher.Regexp = "" c.Matcher.Size = "" c.Matcher.Status = "200-299,301,302,307,401,403,405,500" c.Matcher.Time = "" c.Matcher.Words = "" c.Output.DebugLog = "" c.Output.OutputDirectory = "" c.Output.OutputFile = "" c.Output.OutputFormat = "json" c.Output.OutputSkipEmptyFile = false return c } // ConfigFromOptions parses the values in ConfigOptions struct, ensures that the values are sane, // and creates a Config struct out of them. func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel context.CancelFunc) (*Config, error) { //TODO: refactor in a proper flag library that can handle things like required flags errs := NewMultierror() conf := NewConfig(ctx, cancel) var err error var err2 error if len(parseOpts.HTTP.URL) == 0 && parseOpts.Input.Request == "" { errs.Add(fmt.Errorf("-u flag or -request flag is required")) } // prepare extensions if parseOpts.Input.Extensions != "" { extensions := strings.Split(parseOpts.Input.Extensions, ",") conf.Extensions = extensions } // Convert cookies to a header if len(parseOpts.HTTP.Cookies) > 0 { parseOpts.HTTP.Headers = append(parseOpts.HTTP.Headers, "Cookie: "+strings.Join(parseOpts.HTTP.Cookies, "; ")) } //Prepare inputproviders conf.InputMode = parseOpts.Input.InputMode validmode := false for _, mode := range []string{"clusterbomb", "pitchfork", "sniper"} { if conf.InputMode == mode { validmode = true } } if !validmode { errs.Add(fmt.Errorf("Input mode (-mode) %s not recognized", conf.InputMode)) } template := "" // sniper mode needs some additional checking if conf.InputMode == "sniper" { template = "§" if len(parseOpts.Input.Wordlists) > 1 { errs.Add(fmt.Errorf("sniper mode only supports one wordlist")) } if len(parseOpts.Input.Inputcommands) > 1 { errs.Add(fmt.Errorf("sniper mode only supports one input command")) } } tmpEncoders := make(map[string]string) for _, e := range parseOpts.Input.Encoders { if strings.Contains(e, ":") { key := strings.Split(e, ":")[0] val := strings.Split(e, ":")[1] tmpEncoders[key] = val } } tmpWordlists := make([]string, 0) for _, v := range parseOpts.Input.Wordlists { var wl []string if runtime.GOOS == "windows" { // Try to ensure that Windows file paths like C:\path\to\wordlist.txt:KEYWORD are treated properly if FileExists(v) { // The wordlist was supplied without a keyword parameter wl = []string{v} } else { filepart := v if strings.Contains(filepart, ":") { filepart = v[:strings.LastIndex(filepart, ":")] } if FileExists(filepart) { wl = []string{filepart, v[strings.LastIndex(v, ":")+1:]} } else { // The file was not found. Use full wordlist parameter value for more concise error message down the line wl = []string{v} } } } else { wl = strings.SplitN(v, ":", 2) } // Try to use absolute paths for wordlists fullpath := "" if wl[0] != "-" { fullpath, err = filepath.Abs(wl[0]) } else { fullpath = wl[0] } if err == nil { wl[0] = fullpath } if len(wl) == 2 { if conf.InputMode == "sniper" { errs.Add(fmt.Errorf("sniper mode does not support wordlist keywords")) } else { newp := InputProviderConfig{ Name: "wordlist", Value: wl[0], Keyword: wl[1], } // Add encoders if set enc, ok := tmpEncoders[wl[1]] if ok { newp.Encoders = enc } conf.InputProviders = append(conf.InputProviders, newp) } } else { newp := InputProviderConfig{ Name: "wordlist", Value: wl[0], Keyword: "FUZZ", Template: template, } // Add encoders if set enc, ok := tmpEncoders["FUZZ"] if ok { newp.Encoders = enc } conf.InputProviders = append(conf.InputProviders, newp) } tmpWordlists = append(tmpWordlists, strings.Join(wl, ":")) } conf.Wordlists = tmpWordlists for _, v := range parseOpts.Input.Inputcommands { ic := strings.SplitN(v, ":", 2) if len(ic) == 2 { if conf.InputMode == "sniper" { errs.Add(fmt.Errorf("sniper mode does not support command keywords")) } else { newp := InputProviderConfig{ Name: "command", Value: ic[0], Keyword: ic[1], } enc, ok := tmpEncoders[ic[1]] if ok { newp.Encoders = enc } conf.InputProviders = append(conf.InputProviders, newp) conf.CommandKeywords = append(conf.CommandKeywords, ic[0]) } } else { newp := InputProviderConfig{ Name: "command", Value: ic[0], Keyword: "FUZZ", Template: template, } enc, ok := tmpEncoders["FUZZ"] if ok { newp.Encoders = enc } conf.InputProviders = append(conf.InputProviders, newp) conf.CommandKeywords = append(conf.CommandKeywords, "FUZZ") } } if len(conf.InputProviders) == 0 { errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required")) } // Prepare the request using body if parseOpts.Input.Request != "" { err := parseRawRequest(parseOpts, &conf) if err != nil { errmsg := fmt.Sprintf("Could not parse raw request: %s", err) errs.Add(fmt.Errorf(errmsg)) } } //Prepare URL if parseOpts.HTTP.URL != "" { conf.Url = parseOpts.HTTP.URL } // Prepare SNI if parseOpts.HTTP.SNI != "" { conf.SNI = parseOpts.HTTP.SNI } // prepare cert if parseOpts.HTTP.ClientCert != "" { conf.ClientCert = parseOpts.HTTP.ClientCert } if parseOpts.HTTP.ClientKey != "" { conf.ClientKey = parseOpts.HTTP.ClientKey } //Prepare headers and make canonical for _, v := range parseOpts.HTTP.Headers { hs := strings.SplitN(v, ":", 2) if len(hs) == 2 { // trim and make canonical // except if used in custom defined header var CanonicalNeeded = true for _, a := range conf.CommandKeywords { if strings.Contains(hs[0], a) { CanonicalNeeded = false } } // check if part of InputProviders if CanonicalNeeded { for _, b := range conf.InputProviders { if strings.Contains(hs[0], b.Keyword) { CanonicalNeeded = false } } } if CanonicalNeeded { var CanonicalHeader = textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(hs[0])) conf.Headers[CanonicalHeader] = strings.TrimSpace(hs[1]) } else { conf.Headers[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) } } else { errs.Add(fmt.Errorf("Header defined by -H needs to have a value. \":\" should be used as a separator")) } } //Prepare delay d := strings.Split(parseOpts.General.Delay, "-") if len(d) > 2 { errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) } else if len(d) == 2 { conf.Delay.IsRange = true conf.Delay.HasDelay = true conf.Delay.Min, err = strconv.ParseFloat(d[0], 64) conf.Delay.Max, err2 = strconv.ParseFloat(d[1], 64) if err != nil || err2 != nil { errs.Add(fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5")) } } else if len(parseOpts.General.Delay) > 0 { conf.Delay.IsRange = false conf.Delay.HasDelay = true conf.Delay.Min, err = strconv.ParseFloat(parseOpts.General.Delay, 64) if err != nil { errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) } } // Verify proxy url format if len(parseOpts.HTTP.ProxyURL) > 0 { u, err := url.Parse(parseOpts.HTTP.ProxyURL) if err != nil || u.Opaque != "" || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "socks5") { errs.Add(fmt.Errorf("Bad proxy url (-x) format. Expected http, https or socks5 url")) } else { conf.ProxyURL = parseOpts.HTTP.ProxyURL } } // Verify replayproxy url format if len(parseOpts.HTTP.ReplayProxyURL) > 0 { u, err := url.Parse(parseOpts.HTTP.ReplayProxyURL) if err != nil || u.Opaque != "" || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "socks5" && u.Scheme != "socks5h") { errs.Add(fmt.Errorf("Bad replay-proxy url (-replay-proxy) format. Expected http, https or socks5 url")) } else { conf.ReplayProxyURL = parseOpts.HTTP.ReplayProxyURL } } //Check the output file format option if parseOpts.Output.OutputFile != "" { //No need to check / error out if output file isn't defined outputFormats := []string{"all", "json", "ejson", "html", "md", "csv", "ecsv"} found := false for _, f := range outputFormats { if f == parseOpts.Output.OutputFormat { conf.OutputFormat = f found = true } } if !found { errs.Add(fmt.Errorf("Unknown output file format (-of): %s", parseOpts.Output.OutputFormat)) } } // Auto-calibration strings if len(parseOpts.General.AutoCalibrationStrings) > 0 { conf.AutoCalibrationStrings = parseOpts.General.AutoCalibrationStrings } // Auto-calibration strategies if len(parseOpts.General.AutoCalibrationStrategies) > 0 { conf.AutoCalibrationStrategies = parseOpts.General.AutoCalibrationStrategies } // Using -acc implies -ac if len(parseOpts.General.AutoCalibrationStrings) > 0 { conf.AutoCalibration = true } // Using -acs implies -ac if len(parseOpts.General.AutoCalibrationStrategies) > 0 { conf.AutoCalibration = true } if parseOpts.General.Rate < 0 { conf.Rate = 0 } else { conf.Rate = int64(parseOpts.General.Rate) } if conf.Method == "" { if parseOpts.HTTP.Method == "" { // Only set if defined on command line, because we might be reparsing the CLI after // populating it through raw request in the first iteration conf.Method = "GET" } else { conf.Method = parseOpts.HTTP.Method } } else { if parseOpts.HTTP.Method != "" { // Method overridden in CLI conf.Method = parseOpts.HTTP.Method } } if parseOpts.HTTP.Data != "" { // Only set if defined on command line, because we might be reparsing the CLI after // populating it through raw request in the first iteration conf.Data = parseOpts.HTTP.Data } // Common stuff conf.IgnoreWordlistComments = parseOpts.Input.IgnoreWordlistComments conf.DirSearchCompat = parseOpts.Input.DirSearchCompat conf.Colors = parseOpts.General.Colors conf.InputNum = parseOpts.Input.InputNum conf.InputShell = parseOpts.Input.InputShell conf.OutputFile = parseOpts.Output.OutputFile conf.OutputDirectory = parseOpts.Output.OutputDirectory conf.OutputSkipEmptyFile = parseOpts.Output.OutputSkipEmptyFile conf.IgnoreBody = parseOpts.HTTP.IgnoreBody conf.Quiet = parseOpts.General.Quiet conf.ScraperFile = parseOpts.General.ScraperFile conf.Scrapers = parseOpts.General.Scrapers conf.StopOn403 = parseOpts.General.StopOn403 conf.StopOnAll = parseOpts.General.StopOnAll conf.StopOnErrors = parseOpts.General.StopOnErrors conf.FollowRedirects = parseOpts.HTTP.FollowRedirects conf.Raw = parseOpts.HTTP.Raw conf.Recursion = parseOpts.HTTP.Recursion conf.RecursionDepth = parseOpts.HTTP.RecursionDepth conf.RecursionStrategy = parseOpts.HTTP.RecursionStrategy conf.AutoCalibration = parseOpts.General.AutoCalibration conf.AutoCalibrationPerHost = parseOpts.General.AutoCalibrationPerHost conf.AutoCalibrationStrategies = parseOpts.General.AutoCalibrationStrategies conf.Threads = parseOpts.General.Threads conf.Timeout = parseOpts.HTTP.Timeout conf.MaxTime = parseOpts.General.MaxTime conf.MaxTimeJob = parseOpts.General.MaxTimeJob conf.Noninteractive = parseOpts.General.Noninteractive conf.Verbose = parseOpts.General.Verbose conf.Json = parseOpts.General.Json conf.Http2 = parseOpts.HTTP.Http2 // Check that fmode and mmode have sane values valid_opmodes := []string{"and", "or"} fmode_found := false mmode_found := false for _, v := range valid_opmodes { if v == parseOpts.Filter.Mode { fmode_found = true } if v == parseOpts.Matcher.Mode { mmode_found = true } } if !fmode_found { errmsg := fmt.Sprintf("Unrecognized value for parameter fmode: %s, valid values are: and, or", parseOpts.Filter.Mode) errs.Add(fmt.Errorf(errmsg)) } if !mmode_found { errmsg := fmt.Sprintf("Unrecognized value for parameter mmode: %s, valid values are: and, or", parseOpts.Matcher.Mode) errs.Add(fmt.Errorf(errmsg)) } conf.FilterMode = parseOpts.Filter.Mode conf.MatcherMode = parseOpts.Matcher.Mode if conf.AutoCalibrationPerHost { // AutoCalibrationPerHost implies AutoCalibration conf.AutoCalibration = true } // Handle copy as curl situation where POST method is implied by --data flag. If method is set to anything but GET, NOOP if len(conf.Data) > 0 && conf.Method == "GET" && //don't modify the method automatically if a request file is being used as input len(parseOpts.Input.Request) == 0 { conf.Method = "POST" } conf.CommandLine = strings.Join(os.Args, " ") newInputProviders := []InputProviderConfig{} for _, provider := range conf.InputProviders { if provider.Template != "" { if !templatePresent(provider.Template, &conf) { errmsg := fmt.Sprintf("Template %s defined, but not found in pairs in headers, method, URL or POST data.", provider.Template) errs.Add(fmt.Errorf(errmsg)) } else { newInputProviders = append(newInputProviders, provider) } } else { if !keywordPresent(provider.Keyword, &conf) { errmsg := fmt.Sprintf("Keyword %s defined, but not found in headers, method, URL or POST data.", provider.Keyword) _, _ = fmt.Fprintf(os.Stderr, "%s\n", fmt.Errorf(errmsg)) } else { newInputProviders = append(newInputProviders, provider) } } } conf.InputProviders = newInputProviders // If sniper mode, ensure there is no FUZZ keyword if conf.InputMode == "sniper" { if keywordPresent("FUZZ", &conf) { errs.Add(fmt.Errorf("FUZZ keyword defined, but we are using sniper mode.")) } } // Do checks for recursion mode if parseOpts.HTTP.Recursion { if !strings.HasSuffix(conf.Url, "FUZZ") { errmsg := "When using -recursion the URL (-u) must end with FUZZ keyword." errs.Add(fmt.Errorf(errmsg)) } } // Make verbose mutually exclusive with json if parseOpts.General.Verbose && parseOpts.General.Json { errs.Add(fmt.Errorf("Cannot have -json and -v")) } return &conf, errs.ErrorOrNil() } func parseRawRequest(parseOpts *ConfigOptions, conf *Config) error { conf.RequestFile = parseOpts.Input.Request conf.RequestProto = parseOpts.Input.RequestProto file, err := os.Open(parseOpts.Input.Request) if err != nil { return fmt.Errorf("could not open request file: %s", err) } defer file.Close() r := bufio.NewReader(file) s, err := r.ReadString('\n') if err != nil { return fmt.Errorf("could not read request: %s", err) } parts := strings.Split(s, " ") if len(parts) < 3 { return fmt.Errorf("malformed request supplied") } // Set the request Method conf.Method = parts[0] for { line, err := r.ReadString('\n') line = strings.TrimSpace(line) if err != nil || line == "" { break } p := strings.SplitN(line, ":", 2) if len(p) != 2 { continue } if strings.EqualFold(p[0], "content-length") { continue } conf.Headers[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1]) } // Handle case with the full http url in path. In that case, // ignore any host header that we encounter and use the path as request URL if strings.HasPrefix(parts[1], "http") { parsed, err := url.Parse(parts[1]) if err != nil { return fmt.Errorf("could not parse request URL: %s", err) } conf.Url = parts[1] conf.Headers["Host"] = parsed.Host } else { // Build the request URL from the request conf.Url = parseOpts.Input.RequestProto + "://" + conf.Headers["Host"] + parts[1] } // Set the request body b, err := io.ReadAll(r) if err != nil { return fmt.Errorf("could not read request body: %s", err) } conf.Data = string(b) // Remove newline (typically added by the editor) at the end of the file //nolint:gosimple // we specifically want to remove just a single newline, not all of them if strings.HasSuffix(conf.Data, "\r\n") { conf.Data = conf.Data[:len(conf.Data)-2] } else if strings.HasSuffix(conf.Data, "\n") { conf.Data = conf.Data[:len(conf.Data)-1] } return nil } func keywordPresent(keyword string, conf *Config) bool { //Search for keyword from HTTP method, URL and POST data too if strings.Contains(conf.Method, keyword) { return true } if strings.Contains(conf.Url, keyword) { return true } if strings.Contains(conf.Data, keyword) { return true } for k, v := range conf.Headers { if strings.Contains(k, keyword) { return true } if strings.Contains(v, keyword) { return true } } return false } func templatePresent(template string, conf *Config) bool { // Search for input location identifiers, these must exist in pairs sane := false if c := strings.Count(conf.Method, template); c > 0 { if c%2 != 0 { return false } sane = true } if c := strings.Count(conf.Url, template); c > 0 { if c%2 != 0 { return false } sane = true } if c := strings.Count(conf.Data, template); c > 0 { if c%2 != 0 { return false } sane = true } for k, v := range conf.Headers { if c := strings.Count(k, template); c > 0 { if c%2 != 0 { return false } sane = true } if c := strings.Count(v, template); c > 0 { if c%2 != 0 { return false } sane = true } } return sane } func ReadConfig(configFile string) (*ConfigOptions, error) { conf := NewConfigOptions() configData, err := os.ReadFile(configFile) if err == nil { err = toml.Unmarshal(configData, conf) } return conf, err } func ReadDefaultConfig() (*ConfigOptions, error) { // Try to create configuration directory, ignore the potential error _ = CheckOrCreateConfigDir() conffile := filepath.Join(CONFIGDIR, "ffufrc") if !FileExists(conffile) { userhome, err := os.UserHomeDir() if err == nil { conffile = filepath.Join(userhome, ".ffufrc") } } return ReadConfig(conffile) } ffuf-2.1.0/pkg/ffuf/optionsparser_test.go000066400000000000000000000134611450131640400204500ustar00rootroot00000000000000package ffuf import ( "strings" "testing" ) func TestTemplatePresent(t *testing.T) { template := "§" headers := make(map[string]string) headers["foo"] = "§bar§" headers["omg"] = "bbq" headers["§world§"] = "Ooo" goodConf := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[§0§]=§foo§", Method: "PO§ST§", Headers: headers, Data: "line=Can we pull back the §veil§ of §static§ and reach in to the source of §all§ being?&commit=true", } if !templatePresent(template, &goodConf) { t.Errorf("Expected-good config failed validation") } badConfMethod := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[§0§]=§foo§", Method: "POST§", Headers: headers, Data: "line=Can we pull back the §veil§ of §static§ and reach in to the source of §all§ being?&commit=§true§", } if templatePresent(template, &badConfMethod) { t.Errorf("Expected-bad config (Method) failed validation") } badConfURL := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[0§]=§foo§", Method: "§POST§", Headers: headers, Data: "line=Can we pull back the §veil§ of §static§ and reach in to the source of §all§ being?&commit=§true§", } if templatePresent(template, &badConfURL) { t.Errorf("Expected-bad config (URL) failed validation") } badConfData := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[§0§]=§foo§", Method: "§POST§", Headers: headers, Data: "line=Can we pull back the §veil of §static§ and reach in to the source of §all§ being?&commit=§true§", } if templatePresent(template, &badConfData) { t.Errorf("Expected-bad config (Data) failed validation") } headers["kingdom"] = "§candy" badConfHeaderValue := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[§0§]=§foo§", Method: "PO§ST§", Headers: headers, Data: "line=Can we pull back the §veil§ of §static§ and reach in to the source of §all§ being?&commit=true", } if templatePresent(template, &badConfHeaderValue) { t.Errorf("Expected-bad config (Header value) failed validation") } headers["kingdom"] = "candy" headers["§kingdom"] = "candy" badConfHeaderKey := Config{ Url: "https://example.com/fooo/bar?test=§value§&order[§0§]=§foo§", Method: "PO§ST§", Headers: headers, Data: "line=Can we pull back the §veil§ of §static§ and reach in to the source of §all§ being?&commit=true", } if templatePresent(template, &badConfHeaderKey) { t.Errorf("Expected-bad config (Header key) failed validation") } } func TestProxyParsing(t *testing.T) { configOptions := NewConfigOptions() errorString := "Bad proxy url (-x) format. Expected http, https or socks5 url" // http should work configOptions.HTTP.ProxyURL = "http://127.0.0.1:8080" _, err := ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected http proxy string to work") } // https should work configOptions.HTTP.ProxyURL = "https://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected https proxy string to work") } // socks5 should work configOptions.HTTP.ProxyURL = "socks5://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected socks5 proxy string to work") } // garbage data should FAIL configOptions.HTTP.ProxyURL = "Y0 y0 it's GREASE" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected garbage proxy string to fail") } // Opaque URLs with the right scheme should FAIL configOptions.HTTP.ProxyURL = "http:sixhours@dungeon" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected opaque proxy string to fail") } // Unsupported protocols should FAIL configOptions.HTTP.ProxyURL = "imap://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected proxy string with unsupported protocol to fail") } } func TestReplayProxyParsing(t *testing.T) { configOptions := NewConfigOptions() errorString := "Bad replay-proxy url (-replay-proxy) format. Expected http, https or socks5 url" // http should work configOptions.HTTP.ReplayProxyURL = "http://127.0.0.1:8080" _, err := ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected http replay proxy string to work") } // https should work configOptions.HTTP.ReplayProxyURL = "https://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected https proxy string to work") } // socks5 should work configOptions.HTTP.ReplayProxyURL = "socks5://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if strings.Contains(err.Error(), errorString) { t.Errorf("Expected socks5 proxy string to work") } // garbage data should FAIL configOptions.HTTP.ReplayProxyURL = "Y0 y0 it's GREASE" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected garbage proxy string to fail") } // Opaque URLs with the right scheme should FAIL configOptions.HTTP.ReplayProxyURL = "http:sixhours@dungeon" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected opaque proxy string to fail") } // Unsupported protocols should FAIL configOptions.HTTP.ReplayProxyURL = "imap://127.0.0.1" _, err = ConfigFromOptions(configOptions, nil, nil) if !strings.Contains(err.Error(), errorString) { t.Errorf("Expected proxy string with unsupported protocol to fail") } } ffuf-2.1.0/pkg/ffuf/optrange.go000066400000000000000000000032011450131640400163070ustar00rootroot00000000000000package ffuf import ( "encoding/json" "fmt" "strconv" "strings" ) //optRange stores either a single float, in which case the value is stored in min and IsRange is false, //or a range of floats, in which case IsRange is true type optRange struct { Min float64 Max float64 IsRange bool HasDelay bool } type optRangeJSON struct { Value string `json:"value"` } func (o *optRange) MarshalJSON() ([]byte, error) { value := "" if o.Min == o.Max { value = fmt.Sprintf("%.2f", o.Min) } else { value = fmt.Sprintf("%.2f-%.2f", o.Min, o.Max) } return json.Marshal(&optRangeJSON{ Value: value, }) } func (o *optRange) UnmarshalJSON(b []byte) error { var inc optRangeJSON err := json.Unmarshal(b, &inc) if err != nil { return err } return o.Initialize(inc.Value) } //Initialize sets up the optRange from string value func (o *optRange) Initialize(value string) error { var err, err2 error d := strings.Split(value, "-") if len(d) > 2 { return fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"") } else if len(d) == 2 { o.IsRange = true o.HasDelay = true o.Min, err = strconv.ParseFloat(d[0], 64) o.Max, err2 = strconv.ParseFloat(d[1], 64) if err != nil || err2 != nil { return fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5") } } else if len(value) > 0 { o.IsRange = false o.HasDelay = true o.Min, err = strconv.ParseFloat(value, 64) if err != nil { return fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"") } } return nil } ffuf-2.1.0/pkg/ffuf/progress.go000066400000000000000000000002631450131640400163410ustar00rootroot00000000000000package ffuf import ( "time" ) type Progress struct { StartedAt time.Time ReqCount int ReqTotal int ReqSec int64 QueuePos int QueueTotal int ErrorCount int } ffuf-2.1.0/pkg/ffuf/rate.go000066400000000000000000000041501450131640400154270ustar00rootroot00000000000000package ffuf import ( "container/ring" "sync" "time" ) type RateThrottle struct { rateCounter *ring.Ring Config *Config RateMutex sync.Mutex RateLimiter *time.Ticker lastAdjustment time.Time } func NewRateThrottle(conf *Config) *RateThrottle { r := &RateThrottle{ Config: conf, lastAdjustment: time.Now(), } if conf.Rate > 0 { r.rateCounter = ring.New(int(conf.Rate * 5)) } else { r.rateCounter = ring.New(conf.Threads * 5) } if conf.Rate > 0 { ratemicros := 1000000 / conf.Rate r.RateLimiter = time.NewTicker(time.Microsecond * time.Duration(ratemicros)) } else { //Million rps is probably a decent hardcoded upper speedlimit r.RateLimiter = time.NewTicker(time.Microsecond * 1) } return r } // CurrentRate calculates requests/second value from circular list of rate func (r *RateThrottle) CurrentRate() int64 { n := r.rateCounter.Len() lowest := int64(0) highest := int64(0) r.rateCounter.Do(func(r interface{}) { switch val := r.(type) { case int64: if lowest == 0 || val < lowest { lowest = val } if val > highest { highest = val } default: // circular list entry was nil, happens when < number_of_threads * 5 responses have been recorded. // the total number of entries is less than length of the list n -= 1 } }) earliest := time.UnixMicro(lowest) latest := time.UnixMicro(highest) elapsed := latest.Sub(earliest) if n > 0 && elapsed.Milliseconds() > 1 { return int64(1000 * int64(n) / elapsed.Milliseconds()) } return 0 } func (r *RateThrottle) ChangeRate(rate int) { ratemicros := 0 // set default to 0, avoids integer divide by 0 error if rate != 0 { ratemicros = 1000000 / rate } r.RateLimiter.Stop() r.RateLimiter = time.NewTicker(time.Microsecond * time.Duration(ratemicros)) r.Config.Rate = int64(rate) // reset the rate counter r.rateCounter = ring.New(rate * 5) } // rateTick adds a new duration measurement tick to rate counter func (r *RateThrottle) Tick(start, end time.Time) { r.RateMutex.Lock() defer r.RateMutex.Unlock() r.rateCounter = r.rateCounter.Next() r.rateCounter.Value = end.UnixMicro() } ffuf-2.1.0/pkg/ffuf/request.go000066400000000000000000000124721450131640400161720ustar00rootroot00000000000000package ffuf import ( "strings" ) // Request holds the meaningful data that is passed for runner for making the query type Request struct { Method string Host string Url string Headers map[string]string Data []byte Input map[string][]byte Position int Raw string } func NewRequest(conf *Config) Request { var req Request req.Method = conf.Method req.Url = conf.Url req.Headers = make(map[string]string) return req } // BaseRequest returns a base request struct populated from the main config func BaseRequest(conf *Config) Request { req := NewRequest(conf) req.Headers = conf.Headers req.Data = []byte(conf.Data) return req } // RecursionRequest returns a base request for a recursion target func RecursionRequest(conf *Config, path string) Request { r := BaseRequest(conf) r.Url = path return r } // CopyRequest performs a deep copy of a request and returns a new struct func CopyRequest(basereq *Request) Request { var req Request req.Method = basereq.Method req.Host = basereq.Host req.Url = basereq.Url req.Headers = make(map[string]string, len(basereq.Headers)) for h, v := range basereq.Headers { req.Headers[h] = v } req.Data = make([]byte, len(basereq.Data)) copy(req.Data, basereq.Data) if len(basereq.Input) > 0 { req.Input = make(map[string][]byte, len(basereq.Input)) for k, v := range basereq.Input { req.Input[k] = v } } req.Position = basereq.Position req.Raw = basereq.Raw return req } // SniperRequests returns an array of requests, each with one of the templated locations replaced by a keyword func SniperRequests(basereq *Request, template string) []Request { var reqs []Request keyword := "FUZZ" // Search for input location identifiers, these must exist in pairs if c := strings.Count(basereq.Method, template); c > 0 { if c%2 == 0 { tokens := templateLocations(template, basereq.Method) for i := 0; i < len(tokens); i = i + 2 { newreq := CopyRequest(basereq) newreq.Method = injectKeyword(basereq.Method, keyword, tokens[i], tokens[i+1]) scrubTemplates(&newreq, template) reqs = append(reqs, newreq) } } } if c := strings.Count(basereq.Url, template); c > 0 { if c%2 == 0 { tokens := templateLocations(template, basereq.Url) for i := 0; i < len(tokens); i = i + 2 { newreq := CopyRequest(basereq) newreq.Url = injectKeyword(basereq.Url, keyword, tokens[i], tokens[i+1]) scrubTemplates(&newreq, template) reqs = append(reqs, newreq) } } } data := string(basereq.Data) if c := strings.Count(data, template); c > 0 { if c%2 == 0 { tokens := templateLocations(template, data) for i := 0; i < len(tokens); i = i + 2 { newreq := CopyRequest(basereq) newreq.Data = []byte(injectKeyword(data, keyword, tokens[i], tokens[i+1])) scrubTemplates(&newreq, template) reqs = append(reqs, newreq) } } } for k, v := range basereq.Headers { if c := strings.Count(k, template); c > 0 { if c%2 == 0 { tokens := templateLocations(template, k) for i := 0; i < len(tokens); i = i + 2 { newreq := CopyRequest(basereq) newreq.Headers[injectKeyword(k, keyword, tokens[i], tokens[i+1])] = v delete(newreq.Headers, k) scrubTemplates(&newreq, template) reqs = append(reqs, newreq) } } } if c := strings.Count(v, template); c > 0 { if c%2 == 0 { tokens := templateLocations(template, v) for i := 0; i < len(tokens); i = i + 2 { newreq := CopyRequest(basereq) newreq.Headers[k] = injectKeyword(v, keyword, tokens[i], tokens[i+1]) scrubTemplates(&newreq, template) reqs = append(reqs, newreq) } } } } return reqs } // templateLocations returns an array of template character locations in input func templateLocations(template string, input string) []int { var tokens []int for k, i := range []rune(input) { if i == []rune(template)[0] { tokens = append(tokens, k) } } return tokens } // injectKeyword takes a string, a keyword, and a start/end offset. The data between // the start/end offset in string is removed, and replaced by keyword func injectKeyword(input string, keyword string, startOffset int, endOffset int) string { // some basic sanity checking, return the original string unchanged if offsets didnt make sense if startOffset > len(input) || endOffset > len(input) || startOffset > endOffset { return input } inputslice := []rune(input) keywordslice := []rune(keyword) prefix := inputslice[:startOffset] suffix := inputslice[endOffset+1:] var outputslice []rune outputslice = append(outputslice, prefix...) outputslice = append(outputslice, keywordslice...) outputslice = append(outputslice, suffix...) return string(outputslice) } // scrubTemplates removes all template (§) strings from the request struct func scrubTemplates(req *Request, template string) { req.Method = strings.Join(strings.Split(req.Method, template), "") req.Url = strings.Join(strings.Split(req.Url, template), "") req.Data = []byte(strings.Join(strings.Split(string(req.Data), template), "")) for k, v := range req.Headers { if c := strings.Count(k, template); c > 0 { if c%2 == 0 { delete(req.Headers, k) req.Headers[strings.Join(strings.Split(k, template), "")] = v } } if c := strings.Count(v, template); c > 0 { if c%2 == 0 { req.Headers[k] = strings.Join(strings.Split(v, template), "") } } } } ffuf-2.1.0/pkg/ffuf/request_test.go000066400000000000000000000146121450131640400172270ustar00rootroot00000000000000package ffuf import ( "reflect" "testing" ) func TestBaseRequest(t *testing.T) { headers := make(map[string]string) headers["foo"] = "bar" headers["baz"] = "wibble" headers["Content-Type"] = "application/json" data := "{\"quote\":\"I'll still be here tomorrow to high five you yesterday, my friend. Peace.\"}" expectedreq := Request{Method: "POST", Url: "http://example.com/aaaa", Headers: headers, Data: []byte(data)} config := Config{Method: "POST", Url: "http://example.com/aaaa", Headers: headers, Data: data} basereq := BaseRequest(&config) if !reflect.DeepEqual(basereq, expectedreq) { t.Errorf("BaseRequest does not return a struct with expected values") } } func TestCopyRequest(t *testing.T) { headers := make(map[string]string) headers["foo"] = "bar" headers["omg"] = "bbq" data := "line=Is+that+where+creativity+comes+from?+From+sad+biz?" input := make(map[string][]byte) input["matthew"] = []byte("If you are the head that floats atop the §ziggurat§, then the stairs that lead to you must be infinite.") basereq := Request{Method: "POST", Host: "testhost.local", Url: "http://example.com/aaaa", Headers: headers, Data: []byte(data), Input: input, Position: 2, Raw: "We're not oil and water, we're oil and vinegar! It's good. It's yummy.", } copiedreq := CopyRequest(&basereq) if !reflect.DeepEqual(basereq, copiedreq) { t.Errorf("CopyRequest does not return an equal struct") } } func TestSniperRequests(t *testing.T) { headers := make(map[string]string) headers["foo"] = "§bar§" headers["§omg§"] = "bbq" testreq := Request{ Method: "§POST§", Url: "http://example.com/aaaa?param=§lemony§", Headers: headers, Data: []byte("line=§yo yo, it's grease§"), } requests := SniperRequests(&testreq, "§") if len(requests) != 5 { t.Errorf("SniperRequests returned an incorrect number of requests") } headers = make(map[string]string) headers["foo"] = "bar" headers["omg"] = "bbq" var expected Request expected = Request{ // Method Method: "FUZZ", Url: "http://example.com/aaaa?param=lemony", Headers: headers, Data: []byte("line=yo yo, it's grease"), } pass := false for _, req := range requests { if reflect.DeepEqual(req, expected) { pass = true } } if !pass { t.Errorf("SniperRequests does not return expected values (Method)") } expected = Request{ // URL Method: "POST", Url: "http://example.com/aaaa?param=FUZZ", Headers: headers, Data: []byte("line=yo yo, it's grease"), } pass = false for _, req := range requests { if reflect.DeepEqual(req, expected) { pass = true } } if !pass { t.Errorf("SniperRequests does not return expected values (Url)") } expected = Request{ // Data Method: "POST", Url: "http://example.com/aaaa?param=lemony", Headers: headers, Data: []byte("line=FUZZ"), } pass = false for _, req := range requests { if reflect.DeepEqual(req, expected) { pass = true } } if !pass { t.Errorf("SniperRequests does not return expected values (Data)") } headers = make(map[string]string) headers["foo"] = "FUZZ" headers["omg"] = "bbq" expected = Request{ // Header value Method: "POST", Url: "http://example.com/aaaa?param=lemony", Headers: headers, Data: []byte("line=yo yo, it's grease"), } pass = false for _, req := range requests { if reflect.DeepEqual(req, expected) { pass = true } } if !pass { t.Errorf("SniperRequests does not return expected values (Header value)") } headers = make(map[string]string) headers["foo"] = "bar" headers["FUZZ"] = "bbq" expected = Request{ // Header key Method: "POST", Url: "http://example.com/aaaa?param=lemony", Headers: headers, Data: []byte("line=yo yo, it's grease"), } pass = false for _, req := range requests { if reflect.DeepEqual(req, expected) { pass = true } } if !pass { t.Errorf("SniperRequests does not return expected values (Header key)") } } func TestTemplateLocations(t *testing.T) { test := "this is my 1§template locator§ test" arr := templateLocations("§", test) expected := []int{12, 29} if !reflect.DeepEqual(arr, expected) { t.Errorf("templateLocations does not return expected values") } test2 := "§template locator§" arr = templateLocations("§", test2) expected = []int{0, 17} if !reflect.DeepEqual(arr, expected) { t.Errorf("templateLocations does not return expected values") } if len(templateLocations("§", "te§st2")) != 1 { t.Errorf("templateLocations does not return expected values") } } func TestInjectKeyword(t *testing.T) { input := "§Greetings, creator§" offsetTuple := templateLocations("§", input) expected := "FUZZ" result := injectKeyword(input, "FUZZ", offsetTuple[0], offsetTuple[1]) if result != expected { t.Errorf("injectKeyword returned unexpected result: " + result) } if injectKeyword(input, "FUZZ", -32, 44) != input { t.Errorf("injectKeyword offset validation failed") } if injectKeyword(input, "FUZZ", 12, 2) != input { t.Errorf("injectKeyword offset validation failed") } if injectKeyword(input, "FUZZ", 0, 25) != input { t.Errorf("injectKeyword offset validation failed") } input = "id=§a§&sort=desc" offsetTuple = templateLocations("§", input) expected = "id=FUZZ&sort=desc" result = injectKeyword(input, "FUZZ", offsetTuple[0], offsetTuple[1]) if result != expected { t.Errorf("injectKeyword returned unexpected result: " + result) } input = "feature=aaa&thingie=bbb&array[§0§]=baz" offsetTuple = templateLocations("§", input) expected = "feature=aaa&thingie=bbb&array[FUZZ]=baz" result = injectKeyword(input, "FUZZ", offsetTuple[0], offsetTuple[1]) if result != expected { t.Errorf("injectKeyword returned unexpected result: " + result) } } func TestScrubTemplates(t *testing.T) { headers := make(map[string]string) headers["foo"] = "§bar§" headers["§omg§"] = "bbq" testreq := Request{Method: "§POST§", Url: "http://example.com/aaaa?param=§lemony§", Headers: headers, Data: []byte("line=§yo yo, it's grease§"), } headers = make(map[string]string) headers["foo"] = "bar" headers["omg"] = "bbq" expectedreq := Request{Method: "POST", Url: "http://example.com/aaaa?param=lemony", Headers: headers, Data: []byte("line=yo yo, it's grease"), } scrubTemplates(&testreq, "§") if !reflect.DeepEqual(testreq, expectedreq) { t.Errorf("scrubTemplates does not return expected values") } } ffuf-2.1.0/pkg/ffuf/response.go000066400000000000000000000040411450131640400163310ustar00rootroot00000000000000package ffuf import ( "net/http" "net/url" "time" ) // Response struct holds the meaningful data returned from request and is meant for passing to filters type Response struct { StatusCode int64 Headers map[string][]string Data []byte ContentLength int64 ContentWords int64 ContentLines int64 ContentType string Cancelled bool Request *Request Raw string ResultFile string ScraperData map[string][]string Time time.Duration } // GetRedirectLocation returns the redirect location for a 3xx redirect HTTP response func (resp *Response) GetRedirectLocation(absolute bool) string { redirectLocation := "" if resp.StatusCode >= 300 && resp.StatusCode <= 399 { if loc, ok := resp.Headers["Location"]; ok { if len(loc) > 0 { redirectLocation = loc[0] } } } if absolute { redirectUrl, err := url.Parse(redirectLocation) if err != nil { return redirectLocation } baseUrl, err := url.Parse(resp.Request.Url) if err != nil { return redirectLocation } if redirectUrl.IsAbs() && UrlEqual(redirectUrl, baseUrl) { redirectLocation = redirectUrl.Scheme + "://" + baseUrl.Host + redirectUrl.Path } else { redirectLocation = baseUrl.ResolveReference(redirectUrl).String() } } return redirectLocation } func UrlEqual(url1, url2 *url.URL) bool { if url1.Hostname() != url2.Hostname() { return false } if url1.Scheme != url2.Scheme { return false } p1, p2 := getUrlPort(url1), getUrlPort(url2) return p1 == p2 } func getUrlPort(url *url.URL) string { var portMap = map[string]string{ "http": "80", "https": "443", } p := url.Port() if p == "" { p = portMap[url.Scheme] } return p } func NewResponse(httpresp *http.Response, req *Request) Response { var resp Response resp.Request = req resp.StatusCode = int64(httpresp.StatusCode) resp.ContentType = httpresp.Header.Get("Content-Type") resp.Headers = httpresp.Header resp.Cancelled = false resp.Raw = "" resp.ResultFile = "" resp.ScraperData = make(map[string][]string) return resp } ffuf-2.1.0/pkg/ffuf/util.go000066400000000000000000000055411450131640400154560ustar00rootroot00000000000000package ffuf import ( "errors" "fmt" "math/rand" "net/url" "os" "strings" ) // used for random string generation in calibration function var chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") // RandomString returns a random string of length of parameter n func RandomString(n int) string { s := make([]rune, n) for i := range s { s[i] = chars[rand.Intn(len(chars))] } return string(s) } // UniqStringSlice returns an unordered slice of unique strings. The duplicates are dropped func UniqStringSlice(inslice []string) []string { found := map[string]bool{} for _, v := range inslice { found[v] = true } ret := []string{} for k := range found { ret = append(ret, k) } return ret } // FileExists checks if the filepath exists and is not a directory. // Returns false in case it's not possible to describe the named file. func FileExists(path string) bool { md, err := os.Stat(path) if err != nil { return false } return !md.IsDir() } // RequestContainsKeyword checks if a keyword is present in any field of a request func RequestContainsKeyword(req Request, kw string) bool { if strings.Contains(req.Host, kw) { return true } if strings.Contains(req.Url, kw) { return true } if strings.Contains(req.Method, kw) { return true } if strings.Contains(string(req.Data), kw) { return true } for k, v := range req.Headers { if strings.Contains(k, kw) || strings.Contains(v, kw) { return true } } return false } // HostURLFromRequest gets a host + path without the filename or last part of the URL path func HostURLFromRequest(req Request) string { u, _ := url.Parse(req.Url) u.Host = req.Host pathparts := strings.Split(u.Path, "/") trimpath := strings.TrimSpace(strings.Join(pathparts[:len(pathparts)-1], "/")) return u.Host + trimpath } // Version returns the ffuf version string func Version() string { return fmt.Sprintf("%s%s", VERSION, VERSION_APPENDIX) } func CheckOrCreateConfigDir() error { var err error err = createConfigDir(CONFIGDIR) if err != nil { return err } err = createConfigDir(HISTORYDIR) if err != nil { return err } err = createConfigDir(SCRAPERDIR) if err != nil { return err } err = createConfigDir(AUTOCALIBDIR) if err != nil { return err } err = setupDefaultAutocalibrationStrategies() return err } func createConfigDir(path string) error { _, err := os.Stat(path) if err != nil { var pError *os.PathError if errors.As(err, &pError) { return os.MkdirAll(path, 0750) } return err } return nil } func StrInSlice(key string, slice []string) bool { for _, v := range slice { if v == key { return true } } return false } func mergeMaps(m1 map[string][]string, m2 map[string][]string) map[string][]string { merged := make(map[string][]string) for k, v := range m1 { merged[k] = v } for key, value := range m2 { merged[key] = value } return merged }ffuf-2.1.0/pkg/ffuf/util_test.go000066400000000000000000000011031450131640400165030ustar00rootroot00000000000000package ffuf import ( "math/rand" "testing" ) func TestRandomString(t *testing.T) { length := 1 + rand.Intn(65535) str := RandomString(length) if len(str) != length { t.Errorf("Length of generated string was %d, was expecting %d", len(str), length) } } func TestUniqStringSlice(t *testing.T) { slice := []string{"foo", "foo", "bar", "baz", "baz", "foo", "baz", "baz", "foo"} expectedLength := 3 uniqSlice := UniqStringSlice(slice) if len(uniqSlice) != expectedLength { t.Errorf("Length of slice was %d, was expecting %d", len(uniqSlice), expectedLength) } } ffuf-2.1.0/pkg/ffuf/valuerange.go000066400000000000000000000017051450131640400166300ustar00rootroot00000000000000package ffuf import ( "fmt" "regexp" "strconv" ) type ValueRange struct { Min, Max int64 } func ValueRangeFromString(instr string) (ValueRange, error) { // is the value a range minmax := regexp.MustCompile(`^(\d+)-(\d+)$`).FindAllStringSubmatch(instr, -1) if minmax != nil { // yes minval, err := strconv.ParseInt(minmax[0][1], 10, 0) if err != nil { return ValueRange{}, fmt.Errorf("Invalid value: %s", minmax[0][1]) } maxval, err := strconv.ParseInt(minmax[0][2], 10, 0) if err != nil { return ValueRange{}, fmt.Errorf("Invalid value: %s", minmax[0][2]) } if minval >= maxval { return ValueRange{}, fmt.Errorf("Minimum has to be smaller than maximum") } return ValueRange{minval, maxval}, nil } else { // no, a single value or something else intval, err := strconv.ParseInt(instr, 10, 0) if err != nil { return ValueRange{}, fmt.Errorf("Invalid value: %s", instr) } return ValueRange{intval, intval}, nil } } ffuf-2.1.0/pkg/filter/000077500000000000000000000000001450131640400145045ustar00rootroot00000000000000ffuf-2.1.0/pkg/filter/filter.go000066400000000000000000000105261450131640400163240ustar00rootroot00000000000000package filter import ( "fmt" "sync" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // MatcherManager handles both filters and matchers. type MatcherManager struct { IsCalibrated bool Mutex sync.Mutex Matchers map[string]ffuf.FilterProvider Filters map[string]ffuf.FilterProvider PerDomainFilters map[string]*PerDomainFilter } type PerDomainFilter struct { IsCalibrated bool Filters map[string]ffuf.FilterProvider } func NewPerDomainFilter(globfilters map[string]ffuf.FilterProvider) *PerDomainFilter { return &PerDomainFilter{IsCalibrated: false, Filters: globfilters} } func (p *PerDomainFilter) SetCalibrated(value bool) { p.IsCalibrated = value } func NewMatcherManager() ffuf.MatcherManager { return &MatcherManager{ IsCalibrated: false, Matchers: make(map[string]ffuf.FilterProvider), Filters: make(map[string]ffuf.FilterProvider), PerDomainFilters: make(map[string]*PerDomainFilter), } } func (f *MatcherManager) SetCalibrated(value bool) { f.IsCalibrated = value } func (f *MatcherManager) SetCalibratedForHost(host string, value bool) { if f.PerDomainFilters[host] != nil { f.PerDomainFilters[host].IsCalibrated = value } else { newFilter := NewPerDomainFilter(f.Filters) newFilter.IsCalibrated = true f.PerDomainFilters[host] = newFilter } } func NewFilterByName(name string, value string) (ffuf.FilterProvider, error) { if name == "status" { return NewStatusFilter(value) } if name == "size" { return NewSizeFilter(value) } if name == "word" { return NewWordFilter(value) } if name == "line" { return NewLineFilter(value) } if name == "regexp" { return NewRegexpFilter(value) } if name == "time" { return NewTimeFilter(value) } return nil, fmt.Errorf("Could not create filter with name %s", name) } //AddFilter adds a new filter to MatcherManager func (f *MatcherManager) AddFilter(name string, option string, replace bool) error { f.Mutex.Lock() defer f.Mutex.Unlock() newf, err := NewFilterByName(name, option) if err == nil { // valid filter create or append if f.Filters[name] == nil || replace { f.Filters[name] = newf } else { newoption := f.Filters[name].Repr() + "," + option newerf, err := NewFilterByName(name, newoption) if err == nil { f.Filters[name] = newerf } } } return err } //AddPerDomainFilter adds a new filter to PerDomainFilter configuration func (f *MatcherManager) AddPerDomainFilter(domain string, name string, option string) error { f.Mutex.Lock() defer f.Mutex.Unlock() var pdFilters *PerDomainFilter if filter, ok := f.PerDomainFilters[domain]; ok { pdFilters = filter } else { pdFilters = NewPerDomainFilter(f.Filters) } newf, err := NewFilterByName(name, option) if err == nil { // valid filter create or append if pdFilters.Filters[name] == nil { pdFilters.Filters[name] = newf } else { newoption := pdFilters.Filters[name].Repr() + "," + option newerf, err := NewFilterByName(name, newoption) if err == nil { pdFilters.Filters[name] = newerf } } } f.PerDomainFilters[domain] = pdFilters return err } //RemoveFilter removes a filter of a given type func (f *MatcherManager) RemoveFilter(name string) { f.Mutex.Lock() defer f.Mutex.Unlock() delete(f.Filters, name) } //AddMatcher adds a new matcher to Config func (f *MatcherManager) AddMatcher(name string, option string) error { f.Mutex.Lock() defer f.Mutex.Unlock() newf, err := NewFilterByName(name, option) if err == nil { // valid filter create or append if f.Matchers[name] == nil { f.Matchers[name] = newf } else { newoption := f.Matchers[name].Repr() + "," + option newerf, err := NewFilterByName(name, newoption) if err == nil { f.Matchers[name] = newerf } } } return err } func (f *MatcherManager) GetFilters() map[string]ffuf.FilterProvider { return f.Filters } func (f *MatcherManager) GetMatchers() map[string]ffuf.FilterProvider { return f.Matchers } func (f *MatcherManager) FiltersForDomain(domain string) map[string]ffuf.FilterProvider { if f.PerDomainFilters[domain] == nil { return f.Filters } return f.PerDomainFilters[domain].Filters } func (f *MatcherManager) CalibratedForDomain(domain string) bool { if f.PerDomainFilters[domain] != nil { return f.PerDomainFilters[domain].IsCalibrated } return false } func (f *MatcherManager) Calibrated() bool { return f.IsCalibrated } ffuf-2.1.0/pkg/filter/filter_test.go000066400000000000000000000022141450131640400173560ustar00rootroot00000000000000package filter import ( "testing" ) func TestNewFilterByName(t *testing.T) { scf, _ := NewFilterByName("status", "200") if _, ok := scf.(*StatusFilter); !ok { t.Errorf("Was expecting statusfilter") } szf, _ := NewFilterByName("size", "200") if _, ok := szf.(*SizeFilter); !ok { t.Errorf("Was expecting sizefilter") } wf, _ := NewFilterByName("word", "200") if _, ok := wf.(*WordFilter); !ok { t.Errorf("Was expecting wordfilter") } lf, _ := NewFilterByName("line", "200") if _, ok := lf.(*LineFilter); !ok { t.Errorf("Was expecting linefilter") } ref, _ := NewFilterByName("regexp", "200") if _, ok := ref.(*RegexpFilter); !ok { t.Errorf("Was expecting regexpfilter") } tf, _ := NewFilterByName("time", "200") if _, ok := tf.(*TimeFilter); !ok { t.Errorf("Was expecting timefilter") } } func TestNewFilterByNameError(t *testing.T) { _, err := NewFilterByName("status", "invalid") if err == nil { t.Errorf("Was expecing an error") } } func TestNewFilterByNameNotFound(t *testing.T) { _, err := NewFilterByName("nonexistent", "invalid") if err == nil { t.Errorf("Was expecing an error with invalid filter name") } } ffuf-2.1.0/pkg/filter/lines.go000066400000000000000000000031061450131640400161450ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type LineFilter struct { Value []ffuf.ValueRange } func NewLineFilter(value string) (ffuf.FilterProvider, error) { var intranges []ffuf.ValueRange for _, sv := range strings.Split(value, ",") { vr, err := ffuf.ValueRangeFromString(sv) if err != nil { return &LineFilter{}, fmt.Errorf("Line filter or matcher (-fl / -ml): invalid value: %s", sv) } intranges = append(intranges, vr) } return &LineFilter{Value: intranges}, nil } func (f *LineFilter) MarshalJSON() ([]byte, error) { value := make([]string, 0) for _, v := range f.Value { if v.Min == v.Max { value = append(value, strconv.FormatInt(v.Min, 10)) } else { value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) } } return json.Marshal(&struct { Value string `json:"value"` }{ Value: strings.Join(value, ","), }) } func (f *LineFilter) Filter(response *ffuf.Response) (bool, error) { linesSize := len(strings.Split(string(response.Data), "\n")) for _, iv := range f.Value { if iv.Min <= int64(linesSize) && int64(linesSize) <= iv.Max { return true, nil } } return false, nil } func (f *LineFilter) Repr() string { var strval []string for _, iv := range f.Value { if iv.Min == iv.Max { strval = append(strval, strconv.Itoa(int(iv.Min))) } else { strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) } } return strings.Join(strval, ",") } func (f *LineFilter) ReprVerbose() string { return fmt.Sprintf("Response lines: %s", f.Repr()) } ffuf-2.1.0/pkg/filter/lines_test.go000066400000000000000000000021631450131640400172060ustar00rootroot00000000000000package filter import ( "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewLineFilter(t *testing.T) { f, _ := NewLineFilter("200,301,400-410,500") linesRepr := f.Repr() if !strings.Contains(linesRepr, "200,301,400-410,500") { t.Errorf("Word filter was expected to have 4 values") } } func TestNewLineFilterError(t *testing.T) { _, err := NewLineFilter("invalid") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestLineFiltering(t *testing.T) { f, _ := NewLineFilter("200,301,402-450,500") for i, test := range []struct { input int64 output bool }{ {200, true}, {301, true}, {500, true}, {4, false}, {444, true}, {302, false}, {401, false}, {402, true}, {450, true}, {451, false}, } { var data []string for i := int64(0); i < test.input; i++ { data = append(data, "A") } resp := ffuf.Response{Data: []byte(strings.Join(data, "\n"))} filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/filter/regex.go000066400000000000000000000024711450131640400161510ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "regexp" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type RegexpFilter struct { Value *regexp.Regexp valueRaw string } func NewRegexpFilter(value string) (ffuf.FilterProvider, error) { re, err := regexp.Compile(value) if err != nil { return &RegexpFilter{}, fmt.Errorf("Regexp filter or matcher (-fr / -mr): invalid value: %s", value) } return &RegexpFilter{Value: re, valueRaw: value}, nil } func (f *RegexpFilter) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { Value string `json:"value"` }{ Value: f.valueRaw, }) } func (f *RegexpFilter) Filter(response *ffuf.Response) (bool, error) { matchheaders := "" for k, v := range response.Headers { for _, iv := range v { matchheaders += k + ": " + iv + "\r\n" } } matchdata := []byte(matchheaders) matchdata = append(matchdata, response.Data...) pattern := f.valueRaw for keyword, inputitem := range response.Request.Input { pattern = strings.ReplaceAll(pattern, keyword, regexp.QuoteMeta(string(inputitem))) } matched, err := regexp.Match(pattern, matchdata) if err != nil { return false, nil } return matched, nil } func (f *RegexpFilter) Repr() string { return f.valueRaw } func (f *RegexpFilter) ReprVerbose() string { return fmt.Sprintf("Regexp: %s", f.valueRaw) } ffuf-2.1.0/pkg/filter/regexp_test.go000066400000000000000000000021421450131640400173630ustar00rootroot00000000000000package filter import ( "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewRegexpFilter(t *testing.T) { f, _ := NewRegexpFilter("s([a-z]+)arch") statusRepr := f.Repr() if !strings.Contains(statusRepr, "s([a-z]+)arch") { t.Errorf("Status filter was expected to have a regexp value") } } func TestNewRegexpFilterError(t *testing.T) { _, err := NewRegexpFilter("r((") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestRegexpFiltering(t *testing.T) { f, _ := NewRegexpFilter("s([a-z]+)arch") for i, test := range []struct { input string output bool }{ {"search", true}, {"text and search", true}, {"sbarch in beginning", true}, {"midd scarch le", true}, {"s1arch", false}, {"invalid", false}, } { inp := make(map[string][]byte) resp := ffuf.Response{ Data: []byte(test.input), Request: &ffuf.Request{ Input: inp, }, } filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/filter/size.go000066400000000000000000000030241450131640400160040ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type SizeFilter struct { Value []ffuf.ValueRange } func NewSizeFilter(value string) (ffuf.FilterProvider, error) { var intranges []ffuf.ValueRange for _, sv := range strings.Split(value, ",") { vr, err := ffuf.ValueRangeFromString(sv) if err != nil { return &SizeFilter{}, fmt.Errorf("Size filter or matcher (-fs / -ms): invalid value: %s", sv) } intranges = append(intranges, vr) } return &SizeFilter{Value: intranges}, nil } func (f *SizeFilter) MarshalJSON() ([]byte, error) { value := make([]string, 0) for _, v := range f.Value { if v.Min == v.Max { value = append(value, strconv.FormatInt(v.Min, 10)) } else { value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) } } return json.Marshal(&struct { Value string `json:"value"` }{ Value: strings.Join(value, ","), }) } func (f *SizeFilter) Filter(response *ffuf.Response) (bool, error) { for _, iv := range f.Value { if iv.Min <= response.ContentLength && response.ContentLength <= iv.Max { return true, nil } } return false, nil } func (f *SizeFilter) Repr() string { var strval []string for _, iv := range f.Value { if iv.Min == iv.Max { strval = append(strval, strconv.Itoa(int(iv.Min))) } else { strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) } } return strings.Join(strval, ",") } func (f *SizeFilter) ReprVerbose() string { return fmt.Sprintf("Response size: %s", f.Repr()) } ffuf-2.1.0/pkg/filter/size_test.go000066400000000000000000000017261450131640400170520ustar00rootroot00000000000000package filter import ( "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewSizeFilter(t *testing.T) { f, _ := NewSizeFilter("1,2,3,444,5-90") sizeRepr := f.Repr() if !strings.Contains(sizeRepr, "1,2,3,444,5-90") { t.Errorf("Size filter was expected to have 5 values") } } func TestNewSizeFilterError(t *testing.T) { _, err := NewSizeFilter("invalid") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestFiltering(t *testing.T) { f, _ := NewSizeFilter("1,2,3,5-90,444") for i, test := range []struct { input int64 output bool }{ {1, true}, {2, true}, {3, true}, {4, false}, {5, true}, {70, true}, {90, true}, {91, false}, {444, true}, } { resp := ffuf.Response{ContentLength: test.input} filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/filter/status.go000066400000000000000000000037211450131640400163610ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) const AllStatuses = 0 type StatusFilter struct { Value []ffuf.ValueRange } func NewStatusFilter(value string) (ffuf.FilterProvider, error) { var intranges []ffuf.ValueRange for _, sv := range strings.Split(value, ",") { if sv == "all" { intranges = append(intranges, ffuf.ValueRange{Min: AllStatuses, Max: AllStatuses}) } else { vr, err := ffuf.ValueRangeFromString(sv) if err != nil { return &StatusFilter{}, fmt.Errorf("Status filter or matcher (-fc / -mc): invalid value %s", sv) } intranges = append(intranges, vr) } } return &StatusFilter{Value: intranges}, nil } func (f *StatusFilter) MarshalJSON() ([]byte, error) { value := make([]string, 0) for _, v := range f.Value { if v.Min == 0 && v.Max == 0 { value = append(value, "all") } else { if v.Min == v.Max { value = append(value, strconv.FormatInt(v.Min, 10)) } else { value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) } } } return json.Marshal(&struct { Value string `json:"value"` }{ Value: strings.Join(value, ","), }) } func (f *StatusFilter) Filter(response *ffuf.Response) (bool, error) { for _, iv := range f.Value { if iv.Min == AllStatuses && iv.Max == AllStatuses { // Handle the "all" case return true, nil } if iv.Min <= response.StatusCode && response.StatusCode <= iv.Max { return true, nil } } return false, nil } func (f *StatusFilter) Repr() string { var strval []string for _, iv := range f.Value { if iv.Min == AllStatuses && iv.Max == AllStatuses { strval = append(strval, "all") } else if iv.Min == iv.Max { strval = append(strval, strconv.Itoa(int(iv.Min))) } else { strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) } } return strings.Join(strval, ",") } func (f *StatusFilter) ReprVerbose() string { return fmt.Sprintf("Response status: %s", f.Repr()) } ffuf-2.1.0/pkg/filter/status_test.go000066400000000000000000000020241450131640400174130ustar00rootroot00000000000000package filter import ( "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewStatusFilter(t *testing.T) { f, _ := NewStatusFilter("200,301,400-410,500") statusRepr := f.Repr() if !strings.Contains(statusRepr, "200,301,400-410,500") { t.Errorf("Status filter was expected to have 4 values") } } func TestNewStatusFilterError(t *testing.T) { _, err := NewStatusFilter("invalid") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestStatusFiltering(t *testing.T) { f, _ := NewStatusFilter("200,301,400-498,500") for i, test := range []struct { input int64 output bool }{ {200, true}, {301, true}, {500, true}, {4, false}, {399, false}, {400, true}, {444, true}, {498, true}, {499, false}, {302, false}, } { resp := ffuf.Response{StatusCode: test.input} filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/filter/time.go000077500000000000000000000026741450131640400160050ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type TimeFilter struct { ms int64 // milliseconds since first response byte gt bool // filter if response time is greater than lt bool // filter if response time is less than valueRaw string } func NewTimeFilter(value string) (ffuf.FilterProvider, error) { var milliseconds int64 gt, lt := false, false gt = strings.HasPrefix(value, ">") lt = strings.HasPrefix(value, "<") if (!lt && !gt) || (lt && gt) { return &TimeFilter{}, fmt.Errorf("Time filter or matcher (-ft / -mt): invalid value: %s", value) } milliseconds, err := strconv.ParseInt(value[1:], 10, 64) if err != nil { return &TimeFilter{}, fmt.Errorf("Time filter or matcher (-ft / -mt): invalid value: %s", value) } return &TimeFilter{ms: milliseconds, gt: gt, lt: lt, valueRaw: value}, nil } func (f *TimeFilter) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { Value string `json:"value"` }{ Value: f.valueRaw, }) } func (f *TimeFilter) Filter(response *ffuf.Response) (bool, error) { if f.gt { if response.Time.Milliseconds() > f.ms { return true, nil } } else if f.lt { if response.Time.Milliseconds() < f.ms { return true, nil } } return false, nil } func (f *TimeFilter) Repr() string { return f.valueRaw } func (f *TimeFilter) ReprVerbose() string { return fmt.Sprintf("Response time: %s", f.Repr()) } ffuf-2.1.0/pkg/filter/time_test.go000077500000000000000000000020471450131640400170360ustar00rootroot00000000000000package filter import ( "testing" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewTimeFilter(t *testing.T) { fp, _ := NewTimeFilter(">100") f := fp.(*TimeFilter) if !f.gt || f.lt { t.Errorf("Time filter was expected to have greater-than") } if f.ms != 100 { t.Errorf("Time filter was expected to have ms == 100") } } func TestNewTimeFilterError(t *testing.T) { _, err := NewTimeFilter("100>") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestTimeFiltering(t *testing.T) { f, _ := NewTimeFilter(">100") for i, test := range []struct { input int64 output bool }{ {1342, true}, {2000, true}, {35000, true}, {1458700, true}, {99, false}, {2, false}, } { resp := ffuf.Response{ Data: []byte("dahhhhhtaaaaa"), Time: time.Duration(test.input * int64(time.Millisecond)), } filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/filter/words.go000066400000000000000000000031051450131640400161700ustar00rootroot00000000000000package filter import ( "encoding/json" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type WordFilter struct { Value []ffuf.ValueRange } func NewWordFilter(value string) (ffuf.FilterProvider, error) { var intranges []ffuf.ValueRange for _, sv := range strings.Split(value, ",") { vr, err := ffuf.ValueRangeFromString(sv) if err != nil { return &WordFilter{}, fmt.Errorf("Word filter or matcher (-fw / -mw): invalid value: %s", sv) } intranges = append(intranges, vr) } return &WordFilter{Value: intranges}, nil } func (f *WordFilter) MarshalJSON() ([]byte, error) { value := make([]string, 0) for _, v := range f.Value { if v.Min == v.Max { value = append(value, strconv.FormatInt(v.Min, 10)) } else { value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) } } return json.Marshal(&struct { Value string `json:"value"` }{ Value: strings.Join(value, ","), }) } func (f *WordFilter) Filter(response *ffuf.Response) (bool, error) { wordsSize := len(strings.Split(string(response.Data), " ")) for _, iv := range f.Value { if iv.Min <= int64(wordsSize) && int64(wordsSize) <= iv.Max { return true, nil } } return false, nil } func (f *WordFilter) Repr() string { var strval []string for _, iv := range f.Value { if iv.Min == iv.Max { strval = append(strval, strconv.Itoa(int(iv.Min))) } else { strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) } } return strings.Join(strval, ",") } func (f *WordFilter) ReprVerbose() string { return fmt.Sprintf("Response words: %s", f.Repr()) } ffuf-2.1.0/pkg/filter/words_test.go000066400000000000000000000021621450131640400172310ustar00rootroot00000000000000package filter import ( "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestNewWordFilter(t *testing.T) { f, _ := NewWordFilter("200,301,400-410,500") wordsRepr := f.Repr() if !strings.Contains(wordsRepr, "200,301,400-410,500") { t.Errorf("Word filter was expected to have 4 values") } } func TestNewWordFilterError(t *testing.T) { _, err := NewWordFilter("invalid") if err == nil { t.Errorf("Was expecting an error from errenous input data") } } func TestWordFiltering(t *testing.T) { f, _ := NewWordFilter("200,301,402-450,500") for i, test := range []struct { input int64 output bool }{ {200, true}, {301, true}, {500, true}, {4, false}, {444, true}, {302, false}, {401, false}, {402, true}, {450, true}, {451, false}, } { var data []string for i := int64(0); i < test.input; i++ { data = append(data, "A") } resp := ffuf.Response{Data: []byte(strings.Join(data, " "))} filterReturn, _ := f.Filter(&resp) if filterReturn != test.output { t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) } } } ffuf-2.1.0/pkg/input/000077500000000000000000000000001450131640400143565ustar00rootroot00000000000000ffuf-2.1.0/pkg/input/command.go000066400000000000000000000037001450131640400163230ustar00rootroot00000000000000package input import ( "bytes" "os" "os/exec" "strconv" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type CommandInput struct { config *ffuf.Config count int active bool keyword string command string shell string } func NewCommandInput(keyword string, value string, conf *ffuf.Config) (*CommandInput, error) { var cmd CommandInput cmd.active = true cmd.keyword = keyword cmd.config = conf cmd.count = 0 cmd.command = value cmd.shell = SHELL_CMD if cmd.config.InputShell != "" { cmd.shell = cmd.config.InputShell } return &cmd, nil } // Keyword returns the keyword assigned to this InternalInputProvider func (c *CommandInput) Keyword() string { return c.keyword } // Position will return the current position in the input list func (c *CommandInput) Position() int { return c.count } // SetPosition will set the current position of the inputprovider func (c *CommandInput) SetPosition(pos int) { c.count = pos } // ResetPosition will reset the current position of the InternalInputProvider func (c *CommandInput) ResetPosition() { c.count = 0 } // IncrementPosition increments the current position in the inputprovider func (c *CommandInput) IncrementPosition() { c.count += 1 } // Next will increment the cursor position, and return a boolean telling if there's iterations left func (c *CommandInput) Next() bool { return c.count < c.config.InputNum } // Value returns the input from command stdoutput func (c *CommandInput) Value() []byte { var stdout bytes.Buffer os.Setenv("FFUF_NUM", strconv.Itoa(c.count)) cmd := exec.Command(c.shell, SHELL_ARG, c.command) cmd.Stdout = &stdout err := cmd.Run() if err != nil { return []byte("") } return stdout.Bytes() } // Total returns the size of wordlist func (c *CommandInput) Total() int { return c.config.InputNum } func (c *CommandInput) Active() bool { return c.active } func (c *CommandInput) Enable() { c.active = true } func (c *CommandInput) Disable() { c.active = false } ffuf-2.1.0/pkg/input/const.go000066400000000000000000000001261450131640400160320ustar00rootroot00000000000000// +build !windows package input const ( SHELL_CMD = "/bin/sh" SHELL_ARG = "-c" ) ffuf-2.1.0/pkg/input/const_windows.go000066400000000000000000000001251450131640400176030ustar00rootroot00000000000000// +build windows package input const ( SHELL_CMD = "cmd.exe" SHELL_ARG = "/C" ) ffuf-2.1.0/pkg/input/input.go000066400000000000000000000136311450131640400160500ustar00rootroot00000000000000package input import ( "fmt" "github.com/ffuf/ffuf/v2/pkg/ffuf" "strings" "github.com/ffuf/pencode/pkg/pencode" ) type MainInputProvider struct { Providers []ffuf.InternalInputProvider Encoders map[string]*pencode.Chain Config *ffuf.Config position int msbIterator int } func NewInputProvider(conf *ffuf.Config) (ffuf.InputProvider, ffuf.Multierror) { validmode := false errs := ffuf.NewMultierror() for _, mode := range []string{"clusterbomb", "pitchfork", "sniper"} { if conf.InputMode == mode { validmode = true } } if !validmode { errs.Add(fmt.Errorf("Input mode (-mode) %s not recognized", conf.InputMode)) return &MainInputProvider{}, errs } mainip := MainInputProvider{Config: conf, msbIterator: 0, Encoders: make(map[string]*pencode.Chain)} // Initialize the correct inputprovider for _, v := range conf.InputProviders { err := mainip.AddProvider(v) if err != nil { errs.Add(err) } } return &mainip, errs } func (i *MainInputProvider) AddProvider(provider ffuf.InputProviderConfig) error { if provider.Name == "command" { newcomm, _ := NewCommandInput(provider.Keyword, provider.Value, i.Config) i.Providers = append(i.Providers, newcomm) } else { // Default to wordlist newwl, err := NewWordlistInput(provider.Keyword, provider.Value, i.Config) if err != nil { return err } i.Providers = append(i.Providers, newwl) } if len(provider.Encoders) > 0 { chain := pencode.NewChain() err := chain.Initialize(strings.Split(strings.TrimSpace(provider.Encoders), " ")) if err != nil { return err } i.Encoders[provider.Keyword] = chain } return nil } // ActivateKeywords enables / disables wordlists based on list of active keywords func (i *MainInputProvider) ActivateKeywords(kws []string) { for _, p := range i.Providers { if ffuf.StrInSlice(p.Keyword(), kws) { p.Active() } else { p.Disable() } } } // Position will return the current position of progress func (i *MainInputProvider) Position() int { return i.position } // SetPosition will reset the MainInputProvider to a specific position func (i *MainInputProvider) SetPosition(pos int) { if i.Config.InputMode == "clusterbomb" || i.Config.InputMode == "sniper" { i.setclusterbombPosition(pos) } else { i.setpitchforkPosition(pos) } } // Keywords returns a slice of all keywords in the inputprovider func (i *MainInputProvider) Keywords() []string { kws := make([]string, 0) for _, p := range i.Providers { kws = append(kws, p.Keyword()) } return kws } // Next will increment the cursor position, and return a boolean telling if there's inputs left func (i *MainInputProvider) Next() bool { if i.position >= i.Total() { return false } i.position++ return true } // Value returns a map of inputs for keywords func (i *MainInputProvider) Value() map[string][]byte { retval := make(map[string][]byte) if i.Config.InputMode == "clusterbomb" || i.Config.InputMode == "sniper" { retval = i.clusterbombValue() } if i.Config.InputMode == "pitchfork" { retval = i.pitchforkValue() } if len(i.Encoders) > 0 { for key, val := range retval { chain, ok := i.Encoders[key] if ok { tmpVal, err := chain.Encode([]byte(val)) if err != nil { fmt.Printf("ERROR: %s\n", err) } retval[key] = tmpVal } } } return retval } // Reset resets all the inputproviders and counters func (i *MainInputProvider) Reset() { for _, p := range i.Providers { p.ResetPosition() } i.position = 0 i.msbIterator = 0 } // pitchforkValue returns a map of keyword:value pairs including all inputs. // This mode will iterate through wordlists in lockstep. func (i *MainInputProvider) pitchforkValue() map[string][]byte { values := make(map[string][]byte) for _, p := range i.Providers { if !p.Active() { // The inputprovider is disabled continue } if !p.Next() { // Loop to beginning if the inputprovider has been exhausted p.ResetPosition() } values[p.Keyword()] = p.Value() p.IncrementPosition() } return values } func (i *MainInputProvider) setpitchforkPosition(pos int) { for _, p := range i.Providers { p.SetPosition(pos) } } // clusterbombValue returns map of keyword:value pairs including all inputs. // this mode will iterate through all possible combinations. func (i *MainInputProvider) clusterbombValue() map[string][]byte { values := make(map[string][]byte) // Should we signal the next InputProvider in the slice to increment signalNext := false first := true index := 0 for _, p := range i.Providers { if !p.Active() { continue } if signalNext { p.IncrementPosition() signalNext = false } if !p.Next() { // No more inputs in this inputprovider if index == i.msbIterator { // Reset all previous wordlists and increment the msb counter i.msbIterator += 1 i.clusterbombIteratorReset() // Start again return i.clusterbombValue() } p.ResetPosition() signalNext = true } values[p.Keyword()] = p.Value() if first { p.IncrementPosition() first = false } index += 1 } return values } func (i *MainInputProvider) setclusterbombPosition(pos int) { i.Reset() if pos > i.Total() { // noop return } for i.position < pos-1 { i.Next() i.Value() } } func (i *MainInputProvider) clusterbombIteratorReset() { index := 0 for _, p := range i.Providers { if !p.Active() { continue } if index < i.msbIterator { p.ResetPosition() } if index == i.msbIterator { p.IncrementPosition() } index += 1 } } // Total returns the amount of input combinations available func (i *MainInputProvider) Total() int { count := 0 if i.Config.InputMode == "pitchfork" { for _, p := range i.Providers { if !p.Active() { continue } if p.Total() > count { count = p.Total() } } } if i.Config.InputMode == "clusterbomb" || i.Config.InputMode == "sniper" { count = 1 for _, p := range i.Providers { if !p.Active() { continue } count = count * p.Total() } } return count } ffuf-2.1.0/pkg/input/wordlist.go000066400000000000000000000077411450131640400165650ustar00rootroot00000000000000package input import ( "bufio" "os" "regexp" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type WordlistInput struct { active bool config *ffuf.Config data [][]byte position int keyword string } func NewWordlistInput(keyword string, value string, conf *ffuf.Config) (*WordlistInput, error) { var wl WordlistInput wl.active = true wl.keyword = keyword wl.config = conf wl.position = 0 var valid bool var err error // stdin? if value == "-" { // yes valid = true } else { // no valid, err = wl.validFile(value) } if err != nil { return &wl, err } if valid { err = wl.readFile(value) } return &wl, err } // Position will return the current position in the input list func (w *WordlistInput) Position() int { return w.position } // SetPosition sets the current position of the inputprovider func (w *WordlistInput) SetPosition(pos int) { w.position = pos } // ResetPosition resets the position back to beginning of the wordlist. func (w *WordlistInput) ResetPosition() { w.position = 0 } // Keyword returns the keyword assigned to this InternalInputProvider func (w *WordlistInput) Keyword() string { return w.keyword } // Next will return a boolean telling if there's words left in the list func (w *WordlistInput) Next() bool { return w.position < len(w.data) } // IncrementPosition will increment the current position in the inputprovider data slice func (w *WordlistInput) IncrementPosition() { w.position += 1 } // Value returns the value from wordlist at current cursor position func (w *WordlistInput) Value() []byte { return w.data[w.position] } // Total returns the size of wordlist func (w *WordlistInput) Total() int { return len(w.data) } // Active returns boolean if the inputprovider is active func (w *WordlistInput) Active() bool { return w.active } // Enable sets the inputprovider as active func (w *WordlistInput) Enable() { w.active = true } // Disable disables the inputprovider func (w *WordlistInput) Disable() { w.active = false } // validFile checks that the wordlist file exists and can be read func (w *WordlistInput) validFile(path string) (bool, error) { _, err := os.Stat(path) if err != nil { return false, err } f, err := os.Open(path) if err != nil { return false, err } f.Close() return true, nil } // readFile reads the file line by line to a byte slice func (w *WordlistInput) readFile(path string) error { var file *os.File var err error if path == "-" { file = os.Stdin } else { file, err = os.Open(path) if err != nil { return err } } defer file.Close() var data [][]byte var ok bool reader := bufio.NewScanner(file) re := regexp.MustCompile(`(?i)%ext%`) for reader.Scan() { if w.config.DirSearchCompat && len(w.config.Extensions) > 0 { text := []byte(reader.Text()) if re.Match(text) { for _, ext := range w.config.Extensions { contnt := re.ReplaceAll(text, []byte(ext)) data = append(data, []byte(contnt)) } } else { text := reader.Text() if w.config.IgnoreWordlistComments { text, ok = stripComments(text) if !ok { continue } } data = append(data, []byte(text)) } } else { text := reader.Text() if w.config.IgnoreWordlistComments { text, ok = stripComments(text) if !ok { continue } } data = append(data, []byte(text)) if w.keyword == "FUZZ" && len(w.config.Extensions) > 0 { for _, ext := range w.config.Extensions { data = append(data, []byte(text+ext)) } } } } w.data = data return reader.Err() } // stripComments removes all kind of comments from the word func stripComments(text string) (string, bool) { // If the line starts with a # ignoring any space on the left, // return blank. if strings.HasPrefix(strings.TrimLeft(text, " "), "#") { return "", false } // If the line has # later after a space, that's a comment. // Only send the word upto space to the routine. index := strings.Index(text, " #") if index == -1 { return text, true } return text[:index], true } ffuf-2.1.0/pkg/input/wordlist_test.go000066400000000000000000000006371450131640400176210ustar00rootroot00000000000000package input import ( "testing" ) func TestStripCommentsIgnoresCommentLines(t *testing.T) { text, _ := stripComments("# text") if text != "" { t.Errorf("Returned text was not a blank string") } } func TestStripCommentsStripsCommentAfterText(t *testing.T) { text, _ := stripComments("text # comment") if text != "text" { t.Errorf("Comment was not stripped or pre-comment text was not returned") } } ffuf-2.1.0/pkg/interactive/000077500000000000000000000000001450131640400155345ustar00rootroot00000000000000ffuf-2.1.0/pkg/interactive/posix.go000066400000000000000000000001721450131640400172250ustar00rootroot00000000000000// +build !windows package interactive import "os" func termHandle() (*os.File, error) { return os.Open("/dev/tty") } ffuf-2.1.0/pkg/interactive/termhandler.go000066400000000000000000000227041450131640400203750ustar00rootroot00000000000000package interactive import ( "bufio" "fmt" "strconv" "strings" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type interactive struct { Job *ffuf.Job paused bool } func Handle(job *ffuf.Job) error { i := interactive{job, false} tty, err := termHandle() if err != nil { return err } defer tty.Close() inreader := bufio.NewScanner(tty) inreader.Split(bufio.ScanLines) for inreader.Scan() { i.handleInput(inreader.Bytes()) } return nil } func (i *interactive) handleInput(in []byte) { instr := string(in) args := strings.Split(strings.TrimSpace(instr), " ") if len(args) == 1 && args[0] == "" { // Enter pressed - toggle interactive state i.paused = !i.paused if i.paused { i.Job.Pause() time.Sleep(500 * time.Millisecond) i.printBanner() } else { i.Job.Resume() } } else { switch args[0] { case "?": i.printHelp() case "help": i.printHelp() case "resume": i.paused = false i.Job.Resume() case "restart": i.Job.Reset(false) i.paused = false i.Job.Output.Info("Restarting the current ffuf job!") i.Job.Resume() case "show": for _, r := range i.Job.Output.GetCurrentResults() { i.Job.Output.PrintResult(r) } case "savejson": if len(args) < 2 { i.Job.Output.Error("Please define the filename") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"savejson\"") } else { err := i.Job.Output.SaveFile(args[1], "json") if err != nil { i.Job.Output.Error(fmt.Sprintf("%s", err)) } else { i.Job.Output.Info("Output file successfully saved!") } } case "fc": if len(args) < 2 { i.Job.Output.Error("Please define a value for status code filter, or \"none\" for removing it") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"fc\"") } else { i.updateFilter("status", args[1], true) i.Job.Output.Info("New status code filter value set") } case "afc": if len(args) < 2 { i.Job.Output.Error("Please define a value to append to status code filter") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"afc\"") } else { i.appendFilter("status", args[1]) i.Job.Output.Info("New status code filter value set") } case "fl": if len(args) < 2 { i.Job.Output.Error("Please define a value for line count filter, or \"none\" for removing it") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"fl\"") } else { i.updateFilter("line", args[1], true) i.Job.Output.Info("New line count filter value set") } case "afl": if len(args) < 2 { i.Job.Output.Error("Please define a value to append to line count filter") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"afl\"") } else { i.appendFilter("line", args[1]) i.Job.Output.Info("New line count filter value set") } case "fw": if len(args) < 2 { i.Job.Output.Error("Please define a value for word count filter, or \"none\" for removing it") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"fw\"") } else { i.updateFilter("word", args[1], true) i.Job.Output.Info("New word count filter value set") } case "afw": if len(args) < 2 { i.Job.Output.Error("Please define a value to append to word count filter") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"afw\"") } else { i.appendFilter("word", args[1]) i.Job.Output.Info("New word count filter value set") } case "fs": if len(args) < 2 { i.Job.Output.Error("Please define a value for response size filter, or \"none\" for removing it") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"fs\"") } else { i.updateFilter("size", args[1], true) i.Job.Output.Info("New response size filter value set") } case "afs": if len(args) < 2 { i.Job.Output.Error("Please define a value to append to size filter") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"afs\"") } else { i.appendFilter("size", args[1]) i.Job.Output.Info("New response size filter value set") } case "ft": if len(args) < 2 { i.Job.Output.Error("Please define a value for response time filter, or \"none\" for removing it") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"ft\"") } else { i.updateFilter("time", args[1], true) i.Job.Output.Info("New response time filter value set") } case "aft": if len(args) < 2 { i.Job.Output.Error("Please define a value to append to response time filter") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"aft\"") } else { i.appendFilter("time", args[1]) i.Job.Output.Info("New response time filter value set") } case "queueshow": i.printQueue() case "queuedel": if len(args) < 2 { i.Job.Output.Error("Please define the index of a queued job to remove. Use \"queueshow\" for listing of jobs.") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"queuedel\"") } else { i.deleteQueue(args[1]) } case "queueskip": i.Job.SkipQueue() i.Job.Output.Info("Skipping to the next queued job") case "rate": if len(args) < 2 { i.Job.Output.Error("Please define the new rate") } else if len(args) > 2 { i.Job.Output.Error("Too many arguments for \"rate\"") } else { newrate, err := strconv.Atoi(args[1]) if err != nil { i.Job.Output.Error(fmt.Sprintf("Could not adjust rate: %s", err)) } else { i.Job.Rate.ChangeRate(newrate) } } default: if i.paused { i.Job.Output.Warning(fmt.Sprintf("Unknown command: \"%s\". Enter \"help\" for a list of available commands", args[0])) } else { i.Job.Output.Error("NOPE") } } } if i.paused { i.printPrompt() } } func (i *interactive) refreshResults() { results := make([]ffuf.Result, 0) filters := i.Job.Config.MatcherManager.GetFilters() for _, filter := range filters { for _, res := range i.Job.Output.GetCurrentResults() { fakeResp := &ffuf.Response{ StatusCode: res.StatusCode, ContentLines: res.ContentLength, ContentWords: res.ContentWords, ContentLength: res.ContentLength, } filterOut, _ := filter.Filter(fakeResp) if !filterOut { results = append(results, res) } } } i.Job.Output.SetCurrentResults(results) } func (i *interactive) updateFilter(name, value string, replace bool) { if value == "none" { i.Job.Config.MatcherManager.RemoveFilter(name) } else { _ = i.Job.Config.MatcherManager.AddFilter(name, value, replace) } i.refreshResults() } func (i *interactive) appendFilter(name, value string) { i.updateFilter(name, value, false) } func (i *interactive) printQueue() { if len(i.Job.QueuedJobs()) > 0 { i.Job.Output.Raw("Queued jobs:\n") for index, job := range i.Job.QueuedJobs() { postfix := "" if index == 0 { postfix = " (active job)" } i.Job.Output.Raw(fmt.Sprintf(" [%d] : %s%s\n", index, job.Url, postfix)) } } else { i.Job.Output.Info("Job queue is empty") } } func (i *interactive) deleteQueue(in string) { index, err := strconv.Atoi(in) if err != nil { i.Job.Output.Warning(fmt.Sprintf("Not a number: %s", in)) } else { if index < 0 || index > len(i.Job.QueuedJobs())-1 { i.Job.Output.Warning("No such queued job. Use \"queueshow\" to list the jobs in queue") } else if index == 0 { i.Job.Output.Warning("Cannot delete the currently running job. Use \"queueskip\" to advance to the next one") } else { i.Job.DeleteQueueItem(index) i.Job.Output.Info("Job successfully deleted!") } } } func (i *interactive) printBanner() { i.Job.Output.Raw("entering interactive mode\ntype \"help\" for a list of commands, or ENTER to resume.\n") } func (i *interactive) printPrompt() { i.Job.Output.Raw("> ") } func (i *interactive) printHelp() { var fc, fl, fs, ft, fw string for name, filter := range i.Job.Config.MatcherManager.GetFilters() { switch name { case "status": fc = "(active: " + filter.Repr() + ")" case "line": fl = "(active: " + filter.Repr() + ")" case "word": fw = "(active: " + filter.Repr() + ")" case "size": fs = "(active: " + filter.Repr() + ")" case "time": ft = "(active: " + filter.Repr() + ")" } } rate := fmt.Sprintf("(active: %d)", i.Job.Config.Rate) help := ` available commands: afc [value] - append to status code filter %s fc [value] - (re)configure status code filter %s afl [value] - append to line count filter %s fl [value] - (re)configure line count filter %s afw [value] - append to word count filter %s fw [value] - (re)configure word count filter %s afs [value] - append to size filter %s fs [value] - (re)configure size filter %s aft [value] - append to time filter %s ft [value] - (re)configure time filter %s rate [value] - adjust rate of requests per second %s queueshow - show job queue queuedel [number] - delete a job in the queue queueskip - advance to the next queued job restart - restart and resume the current ffuf job resume - resume current ffuf job (or: ENTER) show - show results for the current job savejson [filename] - save current matches to a file help - you are looking at it ` i.Job.Output.Raw(fmt.Sprintf(help, fc, fc, fl, fl, fw, fw, fs, fs, ft, ft, rate)) } ffuf-2.1.0/pkg/interactive/windows.go000066400000000000000000000004571450131640400175630ustar00rootroot00000000000000// +build windows package interactive import ( "os" "syscall" ) func termHandle() (*os.File, error) { var tty *os.File _, err := syscall.Open("CONIN$", syscall.O_RDWR, 0) if err != nil { return tty, err } tty, err = os.Open("CONIN$") if err != nil { return tty, err } return tty, nil } ffuf-2.1.0/pkg/output/000077500000000000000000000000001450131640400145575ustar00rootroot00000000000000ffuf-2.1.0/pkg/output/const.go000066400000000000000000000003721450131640400162360ustar00rootroot00000000000000// +build !windows package output const ( TERMINAL_CLEAR_LINE = "\r\x1b[2K" ANSI_CLEAR = "\x1b[0m" ANSI_RED = "\x1b[31m" ANSI_GREEN = "\x1b[32m" ANSI_BLUE = "\x1b[34m" ANSI_YELLOW = "\x1b[33m" ) ffuf-2.1.0/pkg/output/const_windows.go000066400000000000000000000003151450131640400200050ustar00rootroot00000000000000// +build windows package output const ( TERMINAL_CLEAR_LINE = "\r\r" ANSI_CLEAR = "" ANSI_RED = "" ANSI_GREEN = "" ANSI_BLUE = "" ANSI_YELLOW = "" ) ffuf-2.1.0/pkg/output/file_csv.go000066400000000000000000000034261450131640400167050ustar00rootroot00000000000000package output import ( "encoding/base64" "encoding/csv" "os" "strconv" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) var staticheaders = []string{"url", "redirectlocation", "position", "status_code", "content_length", "content_words", "content_lines", "content_type", "duration", "resultfile", "Ffufhash"} func writeCSV(filename string, config *ffuf.Config, res []ffuf.Result, encode bool) error { header := make([]string, 0) f, err := os.Create(filename) if err != nil { return err } defer f.Close() w := csv.NewWriter(f) defer w.Flush() for _, inputprovider := range config.InputProviders { header = append(header, inputprovider.Keyword) } header = append(header, staticheaders...) if err := w.Write(header); err != nil { return err } for _, r := range res { if encode { inputs := make(map[string][]byte, len(r.Input)) for k, v := range r.Input { inputs[k] = []byte(base64encode(v)) } r.Input = inputs } err := w.Write(toCSV(r)) if err != nil { return err } } return nil } func base64encode(in []byte) string { return base64.StdEncoding.EncodeToString(in) } func toCSV(r ffuf.Result) []string { res := make([]string, 0) ffufhash := "" for k, v := range r.Input { if k == "FFUFHASH" { ffufhash = string(v) } else { res = append(res, string(v)) } } res = append(res, r.Url) res = append(res, r.RedirectLocation) res = append(res, strconv.Itoa(r.Position)) res = append(res, strconv.FormatInt(r.StatusCode, 10)) res = append(res, strconv.FormatInt(r.ContentLength, 10)) res = append(res, strconv.FormatInt(r.ContentWords, 10)) res = append(res, strconv.FormatInt(r.ContentLines, 10)) res = append(res, r.ContentType) res = append(res, r.Duration.String()) res = append(res, r.ResultFile) res = append(res, ffufhash) return res } ffuf-2.1.0/pkg/output/file_csv_test.go000066400000000000000000000014251450131640400177410ustar00rootroot00000000000000package output import ( "reflect" "testing" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestToCSV(t *testing.T) { result := ffuf.Result{ Input: map[string][]byte{"x": {66}}, Position: 1, StatusCode: 200, ContentLength: 3, ContentWords: 4, ContentLines: 5, ContentType: "application/json", RedirectLocation: "http://no.pe", Url: "http://as.df", Duration: time.Duration(123), ResultFile: "resultfile", Host: "host", } csv := toCSV(result) if !reflect.DeepEqual(csv, []string{ "B", "http://as.df", "http://no.pe", "1", "200", "3", "4", "5", "application/json", "123ns", "resultfile"}) { t.Errorf("CSV was not generated in expected format") } } ffuf-2.1.0/pkg/output/file_html.go000066400000000000000000000166671450131640400170710ustar00rootroot00000000000000package output import ( "html" "html/template" "os" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type htmlResult struct { Input map[string]string Position int StatusCode int64 ContentLength int64 ContentWords int64 ContentLines int64 ContentType string RedirectLocation string ScraperData string Duration time.Duration ResultFile string Url string Host string HTMLColor string FfufHash string } type htmlFileOutput struct { CommandLine string Time string Keys []string Results []htmlResult } const ( htmlTemplate = ` FFUF Report -



FFUF Report

{{ .CommandLine }}
{{ .Time }}
|result_raw|StatusCode{{ range $keyword := .Keys }}|{{ $keyword | printf "%s" }}{{ end }}|Url|RedirectLocation|Position|ContentLength|ContentWords|ContentLines|ContentType|Duration|Resultfile|ScraperData|FfufHash|
{{ range .Keys }} {{ end }} {{range $result := .Results}}
|result_raw|{{ $result.StatusCode }}{{ range $keyword, $value := $result.Input }}|{{ $value | printf "%s" }}{{ end }}|{{ $result.Url }}|{{ $result.RedirectLocation }}|{{ $result.Position }}|{{ $result.ContentLength }}|{{ $result.ContentWords }}|{{ $result.ContentLines }}|{{ $result.ContentType }}|{{ $result.Duration }}|{{ $result.ResultFile }}|{{ $result.ScraperData }}|{{ $result.FfufHash }}|
{{ range $keyword, $value := $result.Input }} {{ end }} {{ end }}
Status{{ . }}URL Redirect location Position Length Words Lines Type Duration Resultfile Scraper data Ffuf Hash
{{ $result.StatusCode }}{{ $value | printf "%s" }}{{ $result.Url }} {{ $result.RedirectLocation }} {{ $result.Position }} {{ $result.ContentLength }} {{ $result.ContentWords }} {{ $result.ContentLines }} {{ $result.ContentType }} {{ $result.Duration }} {{ $result.ResultFile }} {{ $result.ScraperData }} {{ $result.FfufHash }}


` ) // colorizeResults returns a new slice with HTMLColor attribute func colorizeResults(results []ffuf.Result) []ffuf.Result { newResults := make([]ffuf.Result, 0) for _, r := range results { result := r result.HTMLColor = "black" s := result.StatusCode if s >= 200 && s <= 299 { result.HTMLColor = "#adea9e" } if s >= 300 && s <= 399 { result.HTMLColor = "#bbbbe6" } if s >= 400 && s <= 499 { result.HTMLColor = "#d2cb7e" } if s >= 500 && s <= 599 { result.HTMLColor = "#de8dc1" } newResults = append(newResults, result) } return newResults } func writeHTML(filename string, config *ffuf.Config, results []ffuf.Result) error { results = colorizeResults(results) ti := time.Now() keywords := make([]string, 0) for _, inputprovider := range config.InputProviders { keywords = append(keywords, inputprovider.Keyword) } htmlResults := make([]htmlResult, 0) for _, r := range results { ffufhash := "" strinput := make(map[string]string) for k, v := range r.Input { if k == "FFUFHASH" { ffufhash = string(v) } else { strinput[k] = string(v) } } strscraper := "" for k, v := range r.ScraperData { if len(v) > 0 { strscraper = strscraper + "

" + html.EscapeString(k) + ":
" firstval := true for _, val := range v { if !firstval { strscraper += "
" } strscraper += html.EscapeString(val) firstval = false } strscraper += "

" } } hres := htmlResult{ Input: strinput, Position: r.Position, StatusCode: r.StatusCode, ContentLength: r.ContentLength, ContentWords: r.ContentWords, ContentLines: r.ContentLines, ContentType: r.ContentType, RedirectLocation: r.RedirectLocation, ScraperData: strscraper, Duration: r.Duration, ResultFile: r.ResultFile, Url: r.Url, Host: r.Host, HTMLColor: r.HTMLColor, FfufHash: ffufhash, } htmlResults = append(htmlResults, hres) } outHTML := htmlFileOutput{ CommandLine: config.CommandLine, Time: ti.Format(time.RFC3339), Results: htmlResults, Keys: keywords, } f, err := os.Create(filename) if err != nil { return err } defer f.Close() templateName := "output.html" t := template.New(templateName).Delims("{{", "}}") _, err = t.Parse(htmlTemplate) if err != nil { return err } err = t.Execute(f, outHTML) return err } ffuf-2.1.0/pkg/output/file_json.go000066400000000000000000000052361450131640400170640ustar00rootroot00000000000000package output import ( "encoding/json" "os" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type ejsonFileOutput struct { CommandLine string `json:"commandline"` Time string `json:"time"` Results []ffuf.Result `json:"results"` Config *ffuf.Config `json:"config"` } type JsonResult struct { Input map[string]string `json:"input"` Position int `json:"position"` StatusCode int64 `json:"status"` ContentLength int64 `json:"length"` ContentWords int64 `json:"words"` ContentLines int64 `json:"lines"` ContentType string `json:"content-type"` RedirectLocation string `json:"redirectlocation"` ScraperData map[string][]string `json:"scraper"` Duration time.Duration `json:"duration"` ResultFile string `json:"resultfile"` Url string `json:"url"` Host string `json:"host"` } type jsonFileOutput struct { CommandLine string `json:"commandline"` Time string `json:"time"` Results []JsonResult `json:"results"` Config *ffuf.Config `json:"config"` } func writeEJSON(filename string, config *ffuf.Config, res []ffuf.Result) error { t := time.Now() outJSON := ejsonFileOutput{ CommandLine: config.CommandLine, Time: t.Format(time.RFC3339), Results: res, } outBytes, err := json.Marshal(outJSON) if err != nil { return err } err = os.WriteFile(filename, outBytes, 0644) if err != nil { return err } return nil } func writeJSON(filename string, config *ffuf.Config, res []ffuf.Result) error { t := time.Now() jsonRes := make([]JsonResult, 0) for _, r := range res { strinput := make(map[string]string) for k, v := range r.Input { strinput[k] = string(v) } jsonRes = append(jsonRes, JsonResult{ Input: strinput, Position: r.Position, StatusCode: r.StatusCode, ContentLength: r.ContentLength, ContentWords: r.ContentWords, ContentLines: r.ContentLines, ContentType: r.ContentType, RedirectLocation: r.RedirectLocation, ScraperData: r.ScraperData, Duration: r.Duration, ResultFile: r.ResultFile, Url: r.Url, Host: r.Host, }) } outJSON := jsonFileOutput{ CommandLine: config.CommandLine, Time: t.Format(time.RFC3339), Results: jsonRes, Config: config, } outBytes, err := json.Marshal(outJSON) if err != nil { return err } err = os.WriteFile(filename, outBytes, 0644) if err != nil { return err } return nil } ffuf-2.1.0/pkg/output/file_md.go000066400000000000000000000054061450131640400165120ustar00rootroot00000000000000package output import ( "html/template" "os" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) const ( markdownTemplate = `# FFUF Report Command line : ` + "`{{.CommandLine}}`" + ` Time: ` + "{{ .Time }}" + ` {{ range .Keys }}| {{ . }} {{ end }}| URL | Redirectlocation | Position | Status Code | Content Length | Content Words | Content Lines | Content Type | Duration | ResultFile | ScraperData | Ffufhash {{ range .Keys }}| :- {{ end }}| :-- | :--------------- | :---- | :------- | :---------- | :------------- | :------------ | :--------- | :----------- | :------------ | :-------- | {{range .Results}}{{ range $keyword, $value := .Input }}| {{ $value | printf "%s" }} {{ end }}| {{ .Url }} | {{ .RedirectLocation }} | {{ .Position }} | {{ .StatusCode }} | {{ .ContentLength }} | {{ .ContentWords }} | {{ .ContentLines }} | {{ .ContentType }} | {{ .Duration}} | {{ .ResultFile }} | {{ .ScraperData }} | {{ .FfufHash }} {{end}}` // The template format is not pretty but follows the markdown guide ) func writeMarkdown(filename string, config *ffuf.Config, results []ffuf.Result) error { ti := time.Now() keywords := make([]string, 0) for _, inputprovider := range config.InputProviders { keywords = append(keywords, inputprovider.Keyword) } htmlResults := make([]htmlResult, 0) ffufhash := "" for _, r := range results { strinput := make(map[string]string) for k, v := range r.Input { if k == "FFUFHASH" { ffufhash = string(v) } else { strinput[k] = string(v) } } strscraper := "" for k, v := range r.ScraperData { if len(v) > 0 { strscraper = strscraper + "

" + k + ":
" firstval := true for _, val := range v { if !firstval { strscraper += "
" } strscraper += val firstval = false } strscraper += "

" } } hres := htmlResult{ Input: strinput, Position: r.Position, StatusCode: r.StatusCode, ContentLength: r.ContentLength, ContentWords: r.ContentWords, ContentLines: r.ContentLines, ContentType: r.ContentType, RedirectLocation: r.RedirectLocation, ScraperData: strscraper, Duration: r.Duration, ResultFile: r.ResultFile, Url: r.Url, Host: r.Host, FfufHash: ffufhash, } htmlResults = append(htmlResults, hres) } outMD := htmlFileOutput{ CommandLine: config.CommandLine, Time: ti.Format(time.RFC3339), Results: htmlResults, Keys: keywords, } f, err := os.Create(filename) if err != nil { return err } defer f.Close() templateName := "output.md" t := template.New(templateName).Delims("{{", "}}") _, err = t.Parse(markdownTemplate) if err != nil { return err } err = t.Execute(f, outMD) return err } ffuf-2.1.0/pkg/output/output.go000066400000000000000000000003411450131640400164440ustar00rootroot00000000000000package output import ( "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func NewOutputProviderByName(name string, conf *ffuf.Config) ffuf.OutputProvider { //We have only one outputprovider at the moment return NewStdoutput(conf) } ffuf-2.1.0/pkg/output/stdout.go000066400000000000000000000333211450131640400164320ustar00rootroot00000000000000package output import ( "crypto/md5" "encoding/json" "fmt" "os" "path" "sort" "strconv" "strings" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) const ( BANNER_HEADER = ` /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ ` BANNER_SEP = "________________________________________________" ) type Stdoutput struct { config *ffuf.Config fuzzkeywords []string Results []ffuf.Result CurrentResults []ffuf.Result } func NewStdoutput(conf *ffuf.Config) *Stdoutput { var outp Stdoutput outp.config = conf outp.Results = make([]ffuf.Result, 0) outp.CurrentResults = make([]ffuf.Result, 0) outp.fuzzkeywords = make([]string, 0) for _, ip := range conf.InputProviders { outp.fuzzkeywords = append(outp.fuzzkeywords, ip.Keyword) } sort.Strings(outp.fuzzkeywords) return &outp } func (s *Stdoutput) Banner() { version := strings.ReplaceAll(ffuf.Version(), "<3", fmt.Sprintf("%s<3%s", ANSI_RED, ANSI_CLEAR)) fmt.Fprintf(os.Stderr, "%s\n v%s\n%s\n\n", BANNER_HEADER, version, BANNER_SEP) printOption([]byte("Method"), []byte(s.config.Method)) printOption([]byte("URL"), []byte(s.config.Url)) // Print wordlists for _, provider := range s.config.InputProviders { if provider.Name == "wordlist" { printOption([]byte("Wordlist"), []byte(provider.Keyword+": "+provider.Value)) } } // Print headers if len(s.config.Headers) > 0 { for k, v := range s.config.Headers { printOption([]byte("Header"), []byte(fmt.Sprintf("%s: %s", k, v))) } } // Print POST data if len(s.config.Data) > 0 { printOption([]byte("Data"), []byte(s.config.Data)) } // Print extensions if len(s.config.Extensions) > 0 { exts := "" for _, ext := range s.config.Extensions { exts = fmt.Sprintf("%s%s ", exts, ext) } printOption([]byte("Extensions"), []byte(exts)) } // Output file info if len(s.config.OutputFile) > 0 { // Use filename as specified by user OutputFile := s.config.OutputFile if s.config.OutputFormat == "all" { // Actually... append all extensions OutputFile += ".{json,ejson,html,md,csv,ecsv}" } printOption([]byte("Output file"), []byte(OutputFile)) printOption([]byte("File format"), []byte(s.config.OutputFormat)) } // Follow redirects? follow := fmt.Sprintf("%t", s.config.FollowRedirects) printOption([]byte("Follow redirects"), []byte(follow)) // Autocalibration autocalib := fmt.Sprintf("%t", s.config.AutoCalibration) printOption([]byte("Calibration"), []byte(autocalib)) // Proxies if len(s.config.ProxyURL) > 0 { printOption([]byte("Proxy"), []byte(s.config.ProxyURL)) } if len(s.config.ReplayProxyURL) > 0 { printOption([]byte("ReplayProxy"), []byte(s.config.ReplayProxyURL)) } // Timeout timeout := fmt.Sprintf("%d", s.config.Timeout) printOption([]byte("Timeout"), []byte(timeout)) // Threads threads := fmt.Sprintf("%d", s.config.Threads) printOption([]byte("Threads"), []byte(threads)) // Delay? if s.config.Delay.HasDelay { delay := "" if s.config.Delay.IsRange { delay = fmt.Sprintf("%.2f - %.2f seconds", s.config.Delay.Min, s.config.Delay.Max) } else { delay = fmt.Sprintf("%.2f seconds", s.config.Delay.Min) } printOption([]byte("Delay"), []byte(delay)) } // Print matchers for _, f := range s.config.MatcherManager.GetMatchers() { printOption([]byte("Matcher"), []byte(f.ReprVerbose())) } // Print filters for _, f := range s.config.MatcherManager.GetFilters() { printOption([]byte("Filter"), []byte(f.ReprVerbose())) } fmt.Fprintf(os.Stderr, "%s\n\n", BANNER_SEP) } // Reset resets the result slice func (s *Stdoutput) Reset() { s.CurrentResults = make([]ffuf.Result, 0) } // Cycle moves the CurrentResults to Results and resets the results slice func (s *Stdoutput) Cycle() { s.Results = append(s.Results, s.CurrentResults...) s.Reset() } // GetResults returns the result slice func (s *Stdoutput) GetCurrentResults() []ffuf.Result { return s.CurrentResults } // SetResults sets the result slice func (s *Stdoutput) SetCurrentResults(results []ffuf.Result) { s.CurrentResults = results } func (s *Stdoutput) Progress(status ffuf.Progress) { if s.config.Quiet { // No progress for quiet mode return } dur := time.Since(status.StartedAt) runningSecs := int(dur / time.Second) var reqRate int64 if runningSecs > 0 { reqRate = status.ReqSec } else { reqRate = 0 } hours := dur / time.Hour dur -= hours * time.Hour mins := dur / time.Minute dur -= mins * time.Minute secs := dur / time.Second fmt.Fprintf(os.Stderr, "%s:: Progress: [%d/%d] :: Job [%d/%d] :: %d req/sec :: Duration: [%d:%02d:%02d] :: Errors: %d ::", TERMINAL_CLEAR_LINE, status.ReqCount, status.ReqTotal, status.QueuePos, status.QueueTotal, reqRate, hours, mins, secs, status.ErrorCount) } func (s *Stdoutput) Info(infostring string) { if s.config.Quiet { fmt.Fprintf(os.Stderr, "%s", infostring) } else { if !s.config.Colors { fmt.Fprintf(os.Stderr, "%s[INFO] %s\n\n", TERMINAL_CLEAR_LINE, infostring) } else { fmt.Fprintf(os.Stderr, "%s[%sINFO%s] %s\n\n", TERMINAL_CLEAR_LINE, ANSI_BLUE, ANSI_CLEAR, infostring) } } } func (s *Stdoutput) Error(errstring string) { if s.config.Quiet { fmt.Fprintf(os.Stderr, "%s", errstring) } else { if !s.config.Colors { fmt.Fprintf(os.Stderr, "%s[ERR] %s\n", TERMINAL_CLEAR_LINE, errstring) } else { fmt.Fprintf(os.Stderr, "%s[%sERR%s] %s\n", TERMINAL_CLEAR_LINE, ANSI_RED, ANSI_CLEAR, errstring) } } } func (s *Stdoutput) Warning(warnstring string) { if s.config.Quiet { fmt.Fprintf(os.Stderr, "%s", warnstring) } else { if !s.config.Colors { fmt.Fprintf(os.Stderr, "%s[WARN] %s\n", TERMINAL_CLEAR_LINE, warnstring) } else { fmt.Fprintf(os.Stderr, "%s[%sWARN%s] %s\n", TERMINAL_CLEAR_LINE, ANSI_RED, ANSI_CLEAR, warnstring) } } } func (s *Stdoutput) Raw(output string) { fmt.Fprintf(os.Stderr, "%s%s", TERMINAL_CLEAR_LINE, output) } func (s *Stdoutput) writeToAll(filename string, config *ffuf.Config, res []ffuf.Result) error { var err error var BaseFilename string = s.config.OutputFile // Go through each type of write, adding // the suffix to each output file. s.config.OutputFile = BaseFilename + ".json" err = writeJSON(s.config.OutputFile, s.config, res) if err != nil { s.Error(err.Error()) } s.config.OutputFile = BaseFilename + ".ejson" err = writeEJSON(s.config.OutputFile, s.config, res) if err != nil { s.Error(err.Error()) } s.config.OutputFile = BaseFilename + ".html" err = writeHTML(s.config.OutputFile, s.config, res) if err != nil { s.Error(err.Error()) } s.config.OutputFile = BaseFilename + ".md" err = writeMarkdown(s.config.OutputFile, s.config, res) if err != nil { s.Error(err.Error()) } s.config.OutputFile = BaseFilename + ".csv" err = writeCSV(s.config.OutputFile, s.config, res, false) if err != nil { s.Error(err.Error()) } s.config.OutputFile = BaseFilename + ".ecsv" err = writeCSV(s.config.OutputFile, s.config, res, true) if err != nil { s.Error(err.Error()) } return nil } // SaveFile saves the current results to a file of a given type func (s *Stdoutput) SaveFile(filename, format string) error { var err error if s.config.OutputSkipEmptyFile && len(s.Results) == 0 { s.Info("No results and -or defined, output file not written.") return err } switch format { case "all": err = s.writeToAll(filename, s.config, append(s.Results, s.CurrentResults...)) case "json": err = writeJSON(filename, s.config, append(s.Results, s.CurrentResults...)) case "ejson": err = writeEJSON(filename, s.config, append(s.Results, s.CurrentResults...)) case "html": err = writeHTML(filename, s.config, append(s.Results, s.CurrentResults...)) case "md": err = writeMarkdown(filename, s.config, append(s.Results, s.CurrentResults...)) case "csv": err = writeCSV(filename, s.config, append(s.Results, s.CurrentResults...), false) case "ecsv": err = writeCSV(filename, s.config, append(s.Results, s.CurrentResults...), true) } return err } // Finalize gets run after all the ffuf jobs are completed func (s *Stdoutput) Finalize() error { var err error if s.config.OutputFile != "" { err = s.SaveFile(s.config.OutputFile, s.config.OutputFormat) if err != nil { s.Error(err.Error()) } } if !s.config.Quiet { fmt.Fprintf(os.Stderr, "\n") } return nil } func (s *Stdoutput) Result(resp ffuf.Response) { // Do we want to write request and response to a file if len(s.config.OutputDirectory) > 0 { resp.ResultFile = s.writeResultToFile(resp) } inputs := make(map[string][]byte, len(resp.Request.Input)) for k, v := range resp.Request.Input { inputs[k] = v } sResult := ffuf.Result{ Input: inputs, Position: resp.Request.Position, StatusCode: resp.StatusCode, ContentLength: resp.ContentLength, ContentWords: resp.ContentWords, ContentLines: resp.ContentLines, ContentType: resp.ContentType, RedirectLocation: resp.GetRedirectLocation(false), ScraperData: resp.ScraperData, Url: resp.Request.Url, Duration: resp.Time, ResultFile: resp.ResultFile, Host: resp.Request.Host, } s.CurrentResults = append(s.CurrentResults, sResult) // Output the result s.PrintResult(sResult) } func (s *Stdoutput) writeResultToFile(resp ffuf.Response) string { var fileContent, fileName, filePath string // Create directory if needed if s.config.OutputDirectory != "" { err := os.MkdirAll(s.config.OutputDirectory, 0750) if err != nil { if !os.IsExist(err) { s.Error(err.Error()) return "" } } } fileContent = fmt.Sprintf("%s\n---- ↑ Request ---- Response ↓ ----\n\n%s", resp.Request.Raw, resp.Raw) // Create file name fileName = fmt.Sprintf("%x", md5.Sum([]byte(fileContent))) filePath = path.Join(s.config.OutputDirectory, fileName) err := os.WriteFile(filePath, []byte(fileContent), 0640) if err != nil { s.Error(err.Error()) } return fileName } func (s *Stdoutput) PrintResult(res ffuf.Result) { switch { case s.config.Json: s.resultJson(res) case s.config.Quiet: s.resultQuiet(res) case len(s.fuzzkeywords) > 1 || s.config.Verbose || len(s.config.OutputDirectory) > 0 || len(res.ScraperData) > 0: // Print a multi-line result (when using multiple input keywords and wordlists) s.resultMultiline(res) default: s.resultNormal(res) } } func (s *Stdoutput) prepareInputsOneLine(res ffuf.Result) string { inputs := "" if len(s.fuzzkeywords) > 1 { for _, k := range s.fuzzkeywords { if ffuf.StrInSlice(k, s.config.CommandKeywords) { // If we're using external command for input, display the position instead of input inputs = fmt.Sprintf("%s%s : %s ", inputs, k, strconv.Itoa(res.Position)) } else { inputs = fmt.Sprintf("%s%s : %s ", inputs, k, res.Input[k]) } } } else { for _, k := range s.fuzzkeywords { if ffuf.StrInSlice(k, s.config.CommandKeywords) { // If we're using external command for input, display the position instead of input inputs = strconv.Itoa(res.Position) } else { inputs = string(res.Input[k]) } } } return inputs } func (s *Stdoutput) resultQuiet(res ffuf.Result) { fmt.Println(s.prepareInputsOneLine(res)) } func (s *Stdoutput) resultMultiline(res ffuf.Result) { var res_hdr, res_str string res_str = "%s%s * %s: %s\n" res_hdr = fmt.Sprintf("%s%s[Status: %d, Size: %d, Words: %d, Lines: %d, Duration: %dms]%s", TERMINAL_CLEAR_LINE, s.colorize(res.StatusCode), res.StatusCode, res.ContentLength, res.ContentWords, res.ContentLines, res.Duration.Milliseconds(), ANSI_CLEAR) reslines := "" if s.config.Verbose { reslines = fmt.Sprintf("%s%s| URL | %s\n", reslines, TERMINAL_CLEAR_LINE, res.Url) redirectLocation := res.RedirectLocation if redirectLocation != "" { reslines = fmt.Sprintf("%s%s| --> | %s\n", reslines, TERMINAL_CLEAR_LINE, redirectLocation) } } if res.ResultFile != "" { reslines = fmt.Sprintf("%s%s| RES | %s\n", reslines, TERMINAL_CLEAR_LINE, res.ResultFile) } for _, k := range s.fuzzkeywords { if ffuf.StrInSlice(k, s.config.CommandKeywords) { // If we're using external command for input, display the position instead of input reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, strconv.Itoa(res.Position)) } else { // Wordlist input reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, res.Input[k]) } } if len(res.ScraperData) > 0 { reslines = fmt.Sprintf("%s%s| SCR |\n", reslines, TERMINAL_CLEAR_LINE) for k, vslice := range res.ScraperData { for _, v := range vslice { reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, v) } } } fmt.Printf("%s\n%s\n", res_hdr, reslines) } func (s *Stdoutput) resultNormal(res ffuf.Result) { resnormal := fmt.Sprintf("%s%s%-23s [Status: %d, Size: %d, Words: %d, Lines: %d, Duration: %dms]%s", TERMINAL_CLEAR_LINE, s.colorize(res.StatusCode), s.prepareInputsOneLine(res), res.StatusCode, res.ContentLength, res.ContentWords, res.ContentLines, res.Duration.Milliseconds(), ANSI_CLEAR) fmt.Println(resnormal) } func (s *Stdoutput) resultJson(res ffuf.Result) { resBytes, err := json.Marshal(res) if err != nil { s.Error(err.Error()) } else { fmt.Fprint(os.Stderr, TERMINAL_CLEAR_LINE) fmt.Println(string(resBytes)) } } func (s *Stdoutput) colorize(status int64) string { if !s.config.Colors { return "" } colorCode := ANSI_CLEAR if status >= 200 && status < 300 { colorCode = ANSI_GREEN } if status >= 300 && status < 400 { colorCode = ANSI_BLUE } if status >= 400 && status < 500 { colorCode = ANSI_YELLOW } if status >= 500 && status < 600 { colorCode = ANSI_RED } return colorCode } func printOption(name []byte, value []byte) { fmt.Fprintf(os.Stderr, " :: %-16s : %s\n", name, value) } ffuf-2.1.0/pkg/runner/000077500000000000000000000000001450131640400145305ustar00rootroot00000000000000ffuf-2.1.0/pkg/runner/runner.go000066400000000000000000000003521450131640400163700ustar00rootroot00000000000000package runner import ( "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func NewRunnerByName(name string, conf *ffuf.Config, replay bool) ffuf.RunnerProvider { // We have only one Runner at the moment return NewSimpleRunner(conf, replay) } ffuf-2.1.0/pkg/runner/simple.go000066400000000000000000000143441450131640400163560ustar00rootroot00000000000000package runner import ( "bytes" "compress/flate" "compress/gzip" "crypto/tls" "fmt" "io" "net" "net/http" "net/http/httptrace" "net/http/httputil" "net/textproto" "net/url" "strconv" "strings" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/andybalholm/brotli" ) // Download results < 5MB const MAX_DOWNLOAD_SIZE = 5242880 type SimpleRunner struct { config *ffuf.Config client *http.Client } func NewSimpleRunner(conf *ffuf.Config, replay bool) ffuf.RunnerProvider { var simplerunner SimpleRunner proxyURL := http.ProxyFromEnvironment customProxy := "" if replay { customProxy = conf.ReplayProxyURL } else { customProxy = conf.ProxyURL } if len(customProxy) > 0 { pu, err := url.Parse(customProxy) if err == nil { proxyURL = http.ProxyURL(pu) } } cert := []tls.Certificate{} if conf.ClientCert != "" && conf.ClientKey != "" { tmp, _ := tls.LoadX509KeyPair(conf.ClientCert, conf.ClientKey) cert = []tls.Certificate{tmp} } simplerunner.config = conf simplerunner.client = &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, Timeout: time.Duration(time.Duration(conf.Timeout) * time.Second), Transport: &http.Transport{ ForceAttemptHTTP2: conf.Http2, Proxy: proxyURL, MaxIdleConns: 1000, MaxIdleConnsPerHost: 500, MaxConnsPerHost: 500, DialContext: (&net.Dialer{ Timeout: time.Duration(time.Duration(conf.Timeout) * time.Second), }).DialContext, TLSHandshakeTimeout: time.Duration(time.Duration(conf.Timeout) * time.Second), TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, Renegotiation: tls.RenegotiateOnceAsClient, ServerName: conf.SNI, Certificates: cert, }, }} if conf.FollowRedirects { simplerunner.client.CheckRedirect = nil } return &simplerunner } func (r *SimpleRunner) Prepare(input map[string][]byte, basereq *ffuf.Request) (ffuf.Request, error) { req := ffuf.CopyRequest(basereq) for keyword, inputitem := range input { req.Method = strings.ReplaceAll(req.Method, keyword, string(inputitem)) headers := make(map[string]string, len(req.Headers)) for h, v := range req.Headers { var CanonicalHeader string = textproto.CanonicalMIMEHeaderKey(strings.ReplaceAll(h, keyword, string(inputitem))) headers[CanonicalHeader] = strings.ReplaceAll(v, keyword, string(inputitem)) } req.Headers = headers req.Url = strings.ReplaceAll(req.Url, keyword, string(inputitem)) req.Data = []byte(strings.ReplaceAll(string(req.Data), keyword, string(inputitem))) } req.Input = input return req, nil } func (r *SimpleRunner) Execute(req *ffuf.Request) (ffuf.Response, error) { var httpreq *http.Request var err error var rawreq []byte data := bytes.NewReader(req.Data) var start time.Time var firstByteTime time.Duration trace := &httptrace.ClientTrace{ WroteRequest: func(wri httptrace.WroteRequestInfo) { start = time.Now() // begin the timer after the request is fully written }, GotFirstResponseByte: func() { firstByteTime = time.Since(start) // record when the first byte of the response was received }, } httpreq, err = http.NewRequestWithContext(r.config.Context, req.Method, req.Url, data) if err != nil { return ffuf.Response{}, err } // set default User-Agent header if not present if _, ok := req.Headers["User-Agent"]; !ok { req.Headers["User-Agent"] = fmt.Sprintf("%s v%s", "Fuzz Faster U Fool", ffuf.Version()) } // Handle Go http.Request special cases if _, ok := req.Headers["Host"]; ok { httpreq.Host = req.Headers["Host"] } req.Host = httpreq.Host httpreq = httpreq.WithContext(httptrace.WithClientTrace(r.config.Context, trace)) if r.config.Raw { httpreq.URL.Opaque = req.Url } for k, v := range req.Headers { httpreq.Header.Set(k, v) } if len(r.config.OutputDirectory) > 0 { rawreq, _ = httputil.DumpRequestOut(httpreq, true) } httpresp, err := r.client.Do(httpreq) if err != nil { return ffuf.Response{}, err } resp := ffuf.NewResponse(httpresp, req) defer httpresp.Body.Close() // Check if we should download the resource or not size, err := strconv.Atoi(httpresp.Header.Get("Content-Length")) if err == nil { resp.ContentLength = int64(size) if (r.config.IgnoreBody) || (size > MAX_DOWNLOAD_SIZE) { resp.Cancelled = true return resp, nil } } if len(r.config.OutputDirectory) > 0 { rawresp, _ := httputil.DumpResponse(httpresp, true) resp.Request.Raw = string(rawreq) resp.Raw = string(rawresp) } var bodyReader io.ReadCloser if httpresp.Header.Get("Content-Encoding") == "gzip" { bodyReader, err = gzip.NewReader(httpresp.Body) if err != nil { // fallback to raw data bodyReader = httpresp.Body } } else if httpresp.Header.Get("Content-Encoding") == "br" { bodyReader = io.NopCloser(brotli.NewReader(httpresp.Body)) if err != nil { // fallback to raw data bodyReader = httpresp.Body } } else if httpresp.Header.Get("Content-Encoding") == "deflate" { bodyReader = flate.NewReader(httpresp.Body) if err != nil { // fallback to raw data bodyReader = httpresp.Body } } else { bodyReader = httpresp.Body } if respbody, err := io.ReadAll(bodyReader); err == nil { resp.ContentLength = int64(len(string(respbody))) resp.Data = respbody } wordsSize := len(strings.Split(string(resp.Data), " ")) linesSize := len(strings.Split(string(resp.Data), "\n")) resp.ContentWords = int64(wordsSize) resp.ContentLines = int64(linesSize) resp.Time = firstByteTime return resp, nil } func (r *SimpleRunner) Dump(req *ffuf.Request) ([]byte, error) { var httpreq *http.Request var err error data := bytes.NewReader(req.Data) httpreq, err = http.NewRequestWithContext(r.config.Context, req.Method, req.Url, data) if err != nil { return []byte{}, err } // set default User-Agent header if not present if _, ok := req.Headers["User-Agent"]; !ok { req.Headers["User-Agent"] = fmt.Sprintf("%s v%s", "Fuzz Faster U Fool", ffuf.Version()) } // Handle Go http.Request special cases if _, ok := req.Headers["Host"]; ok { httpreq.Host = req.Headers["Host"] } req.Host = httpreq.Host for k, v := range req.Headers { httpreq.Header.Set(k, v) } return httputil.DumpRequestOut(httpreq, true) } ffuf-2.1.0/pkg/scraper/000077500000000000000000000000001450131640400146565ustar00rootroot00000000000000ffuf-2.1.0/pkg/scraper/scraper.go000066400000000000000000000076041450131640400166530ustar00rootroot00000000000000package scraper import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/PuerkitoBio/goquery" ) type ScraperRule struct { Name string `json:"name"` Rule string `json:"rule"` Target string `json:"target"` compiledRule *regexp.Regexp Type string `json:"type"` OnlyMatched bool `json:"onlymatched"` Action []string `json:"action"` } type ScraperGroup struct { Rules []*ScraperRule `json:"rules"` Name string `json:"groupname"` Active bool `json:"active"` } type Scraper struct { Rules []*ScraperRule } func readGroupFromFile(filename string) (ScraperGroup, error) { data, err := os.ReadFile(filename) if err != nil { return ScraperGroup{Rules: make([]*ScraperRule, 0)}, err } sc := ScraperGroup{} err = json.Unmarshal([]byte(data), &sc) return sc, err } func FromDir(dirname string, activestr string) (ffuf.Scraper, ffuf.Multierror) { scr := Scraper{Rules: make([]*ScraperRule, 0)} errs := ffuf.NewMultierror() activegrps := parseActiveGroups(activestr) all_files, err := os.ReadDir(ffuf.SCRAPERDIR) if err != nil { errs.Add(err) return &scr, errs } for _, filename := range all_files { if filename.Type().IsRegular() && strings.HasSuffix(filename.Name(), ".json") { sg, err := readGroupFromFile(filepath.Join(dirname, filename.Name())) if err != nil { cerr := fmt.Errorf("%s : %s", filepath.Join(dirname, filename.Name()), err) errs.Add(cerr) continue } if (sg.Active && isActive("all", activegrps)) || isActive(sg.Name, activegrps) { for _, r := range sg.Rules { err = r.init() if err != nil { cerr := fmt.Errorf("%s : %s", filepath.Join(dirname, filename.Name()), err) errs.Add(cerr) continue } scr.Rules = append(scr.Rules, r) } } } } return &scr, errs } // FromFile initializes a scraper instance and reads rules from a file func (s *Scraper) AppendFromFile(path string) error { sg, err := readGroupFromFile(path) if err != nil { return err } for _, r := range sg.Rules { err = r.init() if err != nil { continue } s.Rules = append(s.Rules, r) } return err } func (s *Scraper) Execute(resp *ffuf.Response, matched bool) []ffuf.ScraperResult { res := make([]ffuf.ScraperResult, 0) for _, rule := range s.Rules { if !matched && rule.OnlyMatched { // pass this rule as there was no match continue } sourceData := "" if rule.Target == "body" { sourceData = string(resp.Data) } else if rule.Target == "headers" { sourceData = headerString(resp.Headers) } else { sourceData = headerString(resp.Headers) + string(resp.Data) } val := rule.Check(sourceData) if len(val) > 0 { res = append(res, ffuf.ScraperResult{ Name: rule.Name, Type: rule.Type, Action: rule.Action, Results: val, }) } } return res } // init initializes the scraper rule, and returns an error in case there's an error in the syntax func (r *ScraperRule) init() error { var err error if r.Type == "regexp" { r.compiledRule, err = regexp.Compile(r.Rule) if err != nil { return err } } return err } func (r *ScraperRule) Check(data string) []string { if r.Type == "regexp" { return r.checkRegexp(data) } else if r.Type == "query" { return r.checkQuery(data) } return []string{} } func (r *ScraperRule) checkQuery(data string) []string { val := make([]string, 0) doc, err := goquery.NewDocumentFromReader(strings.NewReader(data)) if err != nil { return []string{} } doc.Find(r.Rule).Each(func(i int, sel *goquery.Selection) { val = append(val, sel.Text()) }) return val } func (r *ScraperRule) checkRegexp(data string) []string { val := make([]string, 0) if r.compiledRule != nil { res := r.compiledRule.FindAllStringSubmatch(data, -1) for _, grp := range res { val = append(val, grp...) } return val } return []string{} } ffuf-2.1.0/pkg/scraper/util.go000066400000000000000000000012001450131640400161530ustar00rootroot00000000000000package scraper import ( "fmt" "github.com/ffuf/ffuf/v2/pkg/ffuf" "strings" ) func headerString(headers map[string][]string) string { val := "" for k, vslice := range headers { for _, v := range vslice { val += fmt.Sprintf("%s: %s\n", k, v) } } return val } func isActive(name string, activegroups []string) bool { return ffuf.StrInSlice(strings.ToLower(strings.TrimSpace(name)), activegroups) } func parseActiveGroups(activestr string) []string { retslice := make([]string, 0) for _, v := range strings.Split(activestr, ",") { retslice = append(retslice, strings.ToLower(strings.TrimSpace(v))) } return retslice }