pax_global_header00006660000000000000000000000064152503275530014521gustar00rootroot0000000000000052 comment=256c1b02499f5f8672af965d4ad7a321d606bc65 ffuf-2.3.0/000077500000000000000000000000001525032755300124515ustar00rootroot00000000000000ffuf-2.3.0/.github/000077500000000000000000000000001525032755300140115ustar00rootroot00000000000000ffuf-2.3.0/.github/FUNDING.yml000066400000000000000000000000211525032755300156170ustar00rootroot00000000000000github: [joohoi] ffuf-2.3.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001525032755300161745ustar00rootroot00000000000000ffuf-2.3.0/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000034471525032755300210770ustar00rootroot00000000000000name: Bug report description: Report a bug in ffuf labels: ["bug"] body: - type: markdown attributes: value: | Before filing, please skim the [wiki](https://github.com/ffuf/ffuf/wiki) and the [CLI flag reference](https://github.com/ffuf/ffuf/wiki/CLI-flags). Many "it doesn't work" reports turn out to be configuration questions the docs already answer. Found a security vulnerability in ffuf itself? Do not file it here. Report it privately through the [security policy](https://github.com/ffuf/ffuf/security/policy). - type: input id: version attributes: label: ffuf version description: Output of `ffuf -V`. If you built from source, give the commit. placeholder: "e.g. v2.1.0" validations: required: true - type: textarea id: command attributes: label: Command line description: The exact ffuf command you ran. Redact anything sensitive. render: shell validations: required: true - type: textarea id: expected attributes: label: What did you expect to happen? validations: required: true - type: textarea id: actual attributes: label: What actually happened? description: Include the relevant output. Paste text rather than screenshots where you can. validations: required: true - type: input id: environment attributes: label: Operating system and Go version description: Your OS, and `go version` if you built from source. placeholder: "e.g. Ubuntu 24.04, go1.22" validations: required: false - type: textarea id: extra attributes: label: Anything else? description: Config file contents, wordlist characteristics, target behavior, anything that helps reproduce. validations: required: false ffuf-2.3.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000004121525032755300201610ustar00rootroot00000000000000blank_issues_enabled: true contact_links: - name: Documentation and usage (wiki) url: https://github.com/ffuf/ffuf/wiki about: The full CLI flag reference, configuration, and feature guides. Check here first, most usage questions are answered in the wiki. ffuf-2.3.0/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000024041525032755300221220ustar00rootroot00000000000000name: Feature request description: Suggest an idea or improvement for ffuf labels: ["enhancement"] body: - type: markdown attributes: value: | Please check the [wiki](https://github.com/ffuf/ffuf/wiki) and the [CLI flag reference](https://github.com/ffuf/ffuf/wiki/CLI-flags) first: ffuf may already do what you want. The [Input methods](https://github.com/ffuf/ffuf/wiki/Input-methods), [Recursion](https://github.com/ffuf/ffuf/wiki/Recursion), [Scraper](https://github.com/ffuf/ffuf/wiki/Scraper), and [Autocalibration](https://github.com/ffuf/ffuf/wiki/Autocalibration) pages cover features people often miss. - type: textarea id: problem attributes: label: What problem would this solve? description: Describe the use case, not just the feature. What are you trying to do? validations: required: true - type: textarea id: proposal attributes: label: Proposed solution description: What would you like ffuf to do? A flag, a behavior, an output format? validations: required: true - type: textarea id: alternatives attributes: label: Alternatives considered description: Existing flags or workarounds you tried, and why they fall short. validations: required: false ffuf-2.3.0/.github/pull_request_template.md000066400000000000000000000016201525032755300207510ustar00rootroot00000000000000# 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) ## Checklist - [ ] Branch is based on the latest `master`. - [ ] `go test -race ./...` passes, and `gofmt` and `go vet` are clean. - [ ] If this changes help, flag, or config output, the characterization goldens are regenerated (`go test -run Characterization -update-golden .`) and committed. - [ ] If this is your first time contributing to ffuf, add your name to `CONTRIBUTORS.md` (alphabetically ordered). - [ ] Add a short description of the change to `CHANGELOG.md`. New to the codebase? The [wiki Contributing section](https://github.com/ffuf/ffuf/wiki/Contributing) covers the architecture, and the [Testing page](https://github.com/ffuf/ffuf/wiki/Contributing-Testing) covers the test layers. Thanks for contributing to ffuf :) ffuf-2.3.0/.github/workflows/000077500000000000000000000000001525032755300160465ustar00rootroot00000000000000ffuf-2.3.0/.github/workflows/codeql-analysis.yml000066400000000000000000000050251525032755300216630ustar00rootroot00000000000000# 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.3.0/.github/workflows/golangci-lint.yml000066400000000000000000000015631525032755300213250ustar00rootroot00000000000000name: 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.20" - uses: actions/checkout@v3 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: # golangci-lint < v1.45 cannot parse Go 1.18+ compiler export data, # so it is pinned to the last v1 release that supports modern Go. version: v1.64.8 # 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.3.0/.github/workflows/release.yml000066400000000000000000000016621525032755300202160ustar00rootroot00000000000000name: goreleaser on: push: tags: - 'v*' permissions: contents: write jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: go-version: stable - name: Import GPG key id: import_gpg uses: crazy-max/ghaction-import-gpg@v6 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} fingerprint: D4060D6A12CE425E19693F3EF860001E7E0108E9 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} ffuf-2.3.0/.github/workflows/test.yml000066400000000000000000000046021525032755300175520ustar00rootroot00000000000000name: test on: push: branches: - master pull_request: jobs: # Build, vet, and run the whole suite with the race detector across the # supported Go floor + latest, on Linux and macOS. Windows is intentionally # not in the matrix yet: several existing tests are not Windows-clean (the # help-golden test deadlocks on Windows' small stdout pipe buffer, and a couple # of output tests assume POSIX terminal control), so enabling it would need # those fixed first. test: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] go: ['1.20', 'stable'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: go vet run: go vet ./... - name: go test (race) run: go test -race ./... -count=1 # Gating: wall-clock timing and rate-limit tests behind the `timing` build tag. # The margins are wide (500ms/800ms) so the required gate stays stable while # still protecting -rate and the time matcher. timing: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: stable - name: go test -tags=timing run: go test -tags=timing ./... -count=1 # Informational coverage. Not gated; ratchet a threshold in once it settles. coverage: runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: stable - name: coverage run: | go test -coverprofile=coverage.out ./... >/dev/null go tool cover -func=coverage.out | tail -1 # Characterization goldens (help output, etc.) must be regenerated and # committed with any change that affects them. golden: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: stable - name: characterization goldens are current run: | go test -run Characterization -update-golden . if [ -n "$(git status --porcelain -- testdata/)" ]; then echo "::error::characterization goldens are out of date. Run: go test -run Characterization -update-golden ." git diff -- testdata/ exit 1 fi ffuf-2.3.0/.gitignore000066400000000000000000000000221525032755300144330ustar00rootroot00000000000000/ffuf .idea /dist ffuf-2.3.0/.goreleaser.yml000066400000000000000000000026301525032755300154030ustar00rootroot00000000000000version: 2 builds: - id: ffuf binary: ffuf flags: - -trimpath env: - CGO_ENABLED=0 ldflags: >- -s -w -X github.com/ffuf/ffuf/v2/pkg/ffuf.VERSION={{ .Version }} -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 # Go no longer supports 32-bit ARM on Windows (unsupported GOOS/GOARCH pair # as of modern toolchains), so windows/arm can't be built anymore. - goos: windows goarch: arm archives: - id: tgz formats: [tar.gz] # v2 removed archive `replacements`; reproduce the darwin->macOS naming # (e.g. ffuf_2.2.0_macOS_arm64.tar.gz, ffuf_2.2.0_linux_armv6.tar.gz) here. name_template: >- {{ .ProjectName }}_{{ .Version }}_ {{- if eq .Os "darwin" }}macOS{{ else }}{{ .Os }}{{ end }}_ {{- .Arch }}{{ with .Arm }}v{{ . }}{{ end }} format_overrides: - goos: windows formats: [zip] checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" signs: - artifacts: checksum args: - "--batch" - "-u" - "{{ .Env.GPG_FINGERPRINT }}" - "--output" - "${signature}" - "--detach-sign" - "${artifact}" release: prerelease: auto ffuf-2.3.0/CHANGELOG.md000066400000000000000000000334211525032755300142650ustar00rootroot00000000000000## Changelog - v2.3.0 - New - Added preflight/postflight requests: raw HTTP request files run before/after each fuzzing request (`-preflight`/`-postflight`), with regex variable extraction (`-preflight-var "NAME:regex"`) injected into the main request, a per-request or amortized per-thread mode (`-preflight-mode`), and abort/ignore error handling (`-preflight-error`) - Changed - Security: the response snapshot taken for `-od` and `-audit-log` read the whole response body with no size limit, before the `MAX_DOWNLOAD_SIZE` cap was applied, so a scanned target could exhaust the scanner's memory and disk with a response of its choosing. The body is now bounded before anything reads it, response headers are bounded as well, and `-ignore-body` now also applies to chunked responses. This path was not covered by the fix for CVE-2026-73232 - Fix interactive console being scrolled off screen by inflight results - Fix terminal control characters being written to stdout/stderr when they're redirected to a file or pipe - Fix scraper `FromDir` reading the global scraper directory instead of the dirname it was given - Fix concurrency defects in MatcherManager, the recursion queue and the pause gate - Fix autocalibration mutating the caller's input map - Fix Stdoutput result accumulation not being synchronized - Count response words and lines without allocating an intermediate slice - Internal: split `pkg/ffuf` into a kernel plus `pkg/engine`, extract recursion policy into a recursion manager, and move to a declarative flag/config architecture where adding a flag is a single tagged field - v2.2.1 - Changed - Derive the release version from the git tag; builds from a source checkout report `git--` - CI: migrate goreleaser to v2, add the tag-triggered release workflow, and pin the GPG signing subkey fingerprint - v2.2.0 - New - Added audit logging functionality - Changed - Security: bound the decompressed response body size to prevent a gzip-bomb OOM (CVE-2026-73232) - Fix a bug in autocalibration strategy merging, when two files have the same strategy key - Fix a bug in -or, causing output to not to be written in any case - Fix panic when setting rate to 0 in the interactive console - 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.3.0/CONTRIBUTING.md000066400000000000000000000037771525032755300147200ustar00rootroot00000000000000# Contributing to ffuf Thanks for taking the time to contribute. This file is the short version; the detailed engineering documentation lives in the wiki. ## Before you start - ffuf needs Go 1.20 or newer. - Build: `go build` at the repo root produces the `ffuf` binary. - Test: `go test -race ./...`. The race detector is what CI gates on, so run it before you push. ## Engineering documentation (the wiki) The [wiki Contributing section](https://github.com/ffuf/ffuf/wiki/Contributing) documents ffuf from the inside: - [Overview and architecture](https://github.com/ffuf/ffuf/wiki/Contributing): how the packages fit together (the `pkg/ffuf` kernel, the `pkg/engine` scan engine, the provider interfaces, and the `assembly.BuildJob` wiring seam). - [Testing](https://github.com/ffuf/ffuf/wiki/Contributing-Testing): the test layers (unit, property, characterization goldens, integration, end-to-end), how to run each, and which layer a new test belongs in. - [Flags and help declaration](https://github.com/ffuf/ffuf/wiki/Contributing-Flags-and-Help): read this before adding or changing any CLI flag. Read the architecture and testing pages before a large change; read the flags page before touching a flag. ## Submitting a pull request - Base your branch on the latest `master`. - Keep each pull request to one logical change. - Run `go test -race ./...`, and make sure `gofmt` and `go vet` are clean. - If your change affects help, flag, or config output, regenerate the characterization goldens and commit them: `go test -run Characterization -update-golden .` - If this is your first contribution, add your name to `CONTRIBUTORS.md` (the file is alphabetically ordered). - Add a short entry describing your change to `CHANGELOG.md` under the `master` section. ## Reporting a security issue Please do not open a public issue for a security vulnerability in ffuf. Report it privately through the repository's [security policy](https://github.com/ffuf/ffuf/security/policy). Thanks for contributing to ffuf :) ffuf-2.3.0/CONTRIBUTORS.md000066400000000000000000000042061525032755300147320ustar00rootroot00000000000000# 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) * [munzzyy](https://github.com/munzzyy) * [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) * [sitiom](https://github.com/sitiom) * [Skyehopper](https://github.com/Skyehopper) * [SolomonSklash](https://github.com/SolomonSklash) * [TomNomNom](https://github.com/tomnomnom) * [xfgusta](https://github.com/xfgusta) ffuf-2.3.0/LICENSE000066400000000000000000000020571525032755300134620ustar00rootroot00000000000000MIT 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.3.0/README.md000066400000000000000000000413521525032755300137350ustar00rootroot00000000000000![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 Windows with [Scoop](https://scoop.sh/), ffuf can be installed with: `scoop install ffuf` _or_ - If you are on Windows with [Winget](https://learn.microsoft.com/en-us/windows/package-manager/), ffuf can be installed with: `winget install ffuf.ffuf` _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` 1. Ffuf depends on Go 1.20 or greater. 2. A go build from a checkout shows `git--` rather than the tag. A local build isn't an official release even when you're sitting on a tag, and Go's embedded build info gives us the commit but not the tag name — so we surface the exact commit it was built from. The authoritative versioned binaries are the ones on the releases page. ## 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.

## Contributing Bug reports, feature requests, and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) to get started, and the [wiki's Contributing section](https://github.com/ffuf/ffuf/wiki/Contributing) for the architecture and testing documentation. ## License ffuf is released under MIT license. See [LICENSE](https://github.com/ffuf/ffuf/blob/master/LICENSE). ffuf-2.3.0/SECURITY.md000066400000000000000000000031161525032755300142430ustar00rootroot00000000000000# Security Policy ## Reporting a vulnerability ffuf is a security testing tool, and we take vulnerabilities in ffuf itself seriously. Please report them privately so a fix can be prepared before the issue is widely known. Use GitHub's private vulnerability reporting: open the [Security tab](https://github.com/ffuf/ffuf/security) and click **Report a vulnerability**, or go straight to [the advisory form](https://github.com/ffuf/ffuf/security/advisories/new). Please do not open a public issue, pull request, or discussion for a security vulnerability. Include as much of the following as you can: - The version of ffuf affected (`ffuf -V`), or the commit if you built from source. - A description of the vulnerability and its impact. - Steps to reproduce, ideally a minimal command line and input. - A suggested fix, if you have one. ## Scope This policy is for vulnerabilities in ffuf's own code. For example: a crafted target response, wordlist, or config that makes ffuf write outside its output directory, run an unintended command, leak data, or crash in a way that could be exploited. ffuf sends the requests you tell it to. A finding about a *target* you scanned with ffuf is not a vulnerability in ffuf, and belongs to that target's owner. ## Supported versions Fixes are made against the latest release. Before reporting, please confirm the issue reproduces on the most recent release or on current `master`. ## Disclosure We will acknowledge your report, work with you on a fix, and coordinate the disclosure. Please give a reasonable window for a fix to ship before disclosing publicly. ffuf-2.3.0/_img/000077500000000000000000000000001525032755300133645ustar00rootroot00000000000000ffuf-2.3.0/_img/ffuf_juggling_250.png000066400000000000000000000661171525032755300173070ustar00rootroot00000000000000‰PNG  IHDRú™Dim…iCCPICC profile(‘}‘=HÃ@Å_SĨ"Q ~āĄ:YqÔ*ĄB¨Zu0šô š4$).Ž‚kÁÁÅǃ‹ŗŽŽ‚ øâčä¤č"%ū/-´ˆņā¸īî=îŪBĩČ4ĢmĐtÛLÄĸb*Ŋ*^áG?z0ˆ^™YƜ$Åá9žîáãë]„gyŸûstŠ‹>‘x–ĻMŧA<Ŋiœ÷‰C,/ĢÄįÄc&]ø‘ëJß8į\xfČL&æ‰CÄbŽ…•fyS#ž"̚NųBĒÎ*į-ÎZąĖ÷ä/ fô•eŽĶF ‹X‚ Ę( ZuR,$h?ęárũšrĀČą€4ČŽü~wke''ęIÁ(Đūâ8#@`¨UįûØqj'€˙¸Ō›ūR˜ų$ŊŌÔÂG@÷6pqŨԔ=ārx2dSv%?M!›ŪĪč›Ō@ß-ĐšVī­ąĶ I]Åo€ƒC`4GŲëīîhííß3ū~FCr•‡%ĒÖbKGDųCģ pHYs × ×B(›xtIMEæ),ē„ĪtEXtCommentCreated with GIMPW IDATxÚėwœ\UŨ˙ßįŪém{ɖô„P¤H E)ĸ (XáAxĀ(*bËEéHīM!…$¤—MŨd{Ÿ>÷žßw˛;ŗ%ŲšSv7ÎįõÚ×&ŗsë9Ÿķ-į[ <ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#<ōČ#ËY@G+Ą;LĪŨŋDkkËÄũX€?J)§ä‡&Oô˙ ÄY,ņD“=?j†/ZSŸQ ΋.§á?Ķ^}ŽžëžMtũúLÜS đˆ”ŌšŸyĸī¯ävJ)Ī—RūxøX˙Y|,Ĩ|JJyģ”ōR)eM:äBČø5R†ZVŽeÁš}÷žŊ˙÷Ž$ôÎÛ eē¯âāų‘âxæ_Á¨'¸Ãuj–×:āCā?Ā3@GœĀÃŊöųĀĻԁÕĢ |û2P”ÄbûŪ¸Î>7ųķÔBŦÉΐ<Ņ÷’Ÿ üP›ĶW€W‡Cøøūõ& 2å{éēø ČÎūöšÄvÅwq]tIēd ø|ž4U^uë6øuq)\›ĄĶ:s—w¤”gė̤“ÂüŔąÛQ8xPŲųË=ūõXējüņįÉ#Oô1‹īŋÂÄ>ö0q,đ"đŽ”ō´}|÷×@cę3KATV ŠHFüÁ—^H—ė?ÎŌä‰>VĨųqq’įbl> ŧ$Ĩ|LJY5„Ton6%Õ}žŊŠ „};áĨKŌš˙ƒ1‚iōČ}L‘Ü ü °åđ˛øđIÜ'0^1}æ} tį­hģvĨs˙?Ėoˇí–10ų'ĮU̓)@ĸ˜čĀp-ŪBlããņF.‘¤xFJy7pĢ"’đˇÃLŅīß÷øv´â˙ÃođŪv'Âfj};8Ã9—ĮX 甞8:>ąf“€y ûBDJš؎ąˇŧX(„č#ŌÜ|ˇ›€™RĘK„¸Ãî\„Ū2ŧxwũã÷Ŋú2ÎΟmöžŋž'zÚĘUN&ųÔø$ŋđfę´@đđ[!ÄļQNôCHît2’x¸p[”ōÂe4J×7¯DÖ­Ūžŧ~ĩÜTAØ6`ōXYÔ˙ëlt)Ĩ]Jų#`%puIžg+ŽVI)¯åļÜQŖˆägÅß_ĘÅôŽäÆUÃ? §“ ų-ˇbāä<G!ŅĨ”ĀŗĀídŋ\‘ø-đĪQ\Ä`î(ŧ§3™90ļa¨Š­[ąįŸ VŋĶėŊ^š§ķ(#zœä/`Č%Ξ–RVÂą¨Ūo戔D?\˜ēe‹zūßfīķØ¸'Ņ@t)åÉĀRŒœ#SÕRĘ˙eĒ|ę%Žĩ´| K {'Ăq×ÄČBīî&öŪ›ĻŽŊōzGģ™C‹€ųyJ0Ņãm}nÂđŽŽtNqđŒâƒŖE•īNųÕ%ŗAę°ōëđņE°üØų„;FŒô‘Aw‡ÉˇĐAdåĘtüyŒŅã[4?îdx[ešÂ9Ā RJ×(¸—:SG *‡Ųŋ†îW`Ë-°ø‹°æ~čŪ‘SÂËH„ČŗO‚0ģĄ#ˆžöržčcTĸ ø!Ŗ3[îxāá,nHŸ¤utŅt˜đŋ Œ BëŖ°übXû'ĩįä!"+W ¯^–Ö9´•KŅ;Liŗōąī#Dt)åĀ]ŒîpÛ/ßá{Xˆ‰ŌMI¨:‡ôhų;|r54-ËîX‡Ã„˙öWiuw'ÚnSaą^Ll扞>ÉUā€k ŧ‹[¤”å#uq!ÄfŒ€™4FĶ ÕŽĒĮęaũ÷`Ëŋ3QåeP„~€žji&^ą­[Íú^~žoÜ{‰ūŒ Šą€bF>õN ŊB %s@ŲKÜŅÎģ`ķŋ2NvŊ­đũŋKÃ6ī§žoXgv>_ õå<ŊsGôīdüĄvØö[ŗqŋ_’R:Fp<ŪĀ( a6/Øfėũ;ģī…¯eîŽ5ĀÃ![3ˇpŦOĢJT)F͇_J)ķšŲ$ē”ōŌÍÖĸP˙>øú>Ģļß õoô}h‚ú÷@ §{Û€ÃGP}ׁĢķy›B×Ô}oÛĐą)3*û‡ ‰ũûņĖΟö6d4šÖën~“'{–ˆoŒ—~^uûz¨ģ>Ŋ‚m‘Ûk{Ë uĀĒ[ î‡Đŧ"ˇ˙‡‘ėĪ-„¨ĮČ3ī&ˇ• g…Í‚Kë~Ŗuu~kæíūhÔøIķuߋ›D˙ÕČÖVÄWÉDPŒ§„ ĸ›aŲĨ x §@x5,ū*č!Đۀ¸ÆeâŪ.A˛,Ĩ\€Q3.uĮ’2Ėa |`xâ+Í)1ZK [n‚ ?ãī@j1¤Ļeb?V˙+Ĩ\!„øg^ĸgNš[˙1y°1ņZWƒŽŌFÄ'­ŪŲGō=ˆíŠ“<>žZČ8Gû&ØũQ:ŌęFz_]ņ!đcs Ÿ¯›’Æ2"đ‡ß wmËÖķg2ęBŋĨ9cVĸ„Q0"u„Ú`ÍF„—u"č] ÷ ķ`Ö\–qŲDÁũøÆ›š“šĀD`ķĪ™ĻŽ ϐÖõ2Dŋ 6_J rā‰ĮĐūķföž\ĩ ԌŽĩeaĪ_ĘKôĖā(ĶįuAŅY€Ņ:ĐZR;^ī‚ČzÃūô-ˇiUŪ‚QžjÄ Ĩ,4uZBŠ8Ų$tnMéá}čėžĢ,–ūĸ”ōč<Ņ3ķyÕBI€Hˇ6ĸĶžjZå ›“12˛RC¤"Š„Í 6 il$ø‹Ÿ‰4Ų4]ŠK֌]7íĢž}žčÃ9;(Ü Áh]2šūxļ,7Î2Åõu)åûRĘI)gŽĀä¸ĖÔQ ŗ%ĆiÅbūt?tļebNŸ™­S/āŋ0ų%6zę[S›aÕÕ {âkHŸčÛ~ÛtN˜ķĨ\\Յ‘3 p+°HJš؀ŅËlĨĸ'ƒ"Ĩœœdęā–÷Súđz˜‡Ūyí­—s21ÕŠY+†+€H)?ŸJē<Ņ5úRĩĮ܆-Z—áņTA­4ΟĻŘ@ú=h”R>ü* eĻĪĮ(ʘĸÚŪ]ī›ŸûV‘ĩĻ&B÷ܝ+jÍН¨ôŨ›ŨͨĶ>c6äöŒ¸T˙ ¯ē›GęRÎ] ‡>57dvŊŠēũ3xǞņî*0B|×H)īzš i.0ĒáĻŽļu › ļz÷Iŧāc˜/&‘˛Nč@7ô˜Å–=AôõKĐ6-NWĒ‹<ŅÍ#`ę(‹J&s›§Jįƒ5ë‰snŒčĢ%RĘy8ßd`ŽŠįŨũĸ‰÷'Á^ŧwEaõ*ĸO?šŗI)Ē'  ᇌEĐw<Ĉž÷dĀt…įĪalĄæ‰n%ŠĪQÃú_“šj( Ŧŋ v/Šåâ]NŪ’Ržšflõؘ 4ßŒ&ǁk/]‘c1‚?„ Rf-Ã5yÍ?î$„epĢRoÚ Z\k ­"ļø)ŗsÆbZsĘŊמM ­Ģaã˙@(ÅÂĢ`Ķ FÅÜ ŖOŲËņ‚f`Žlqķ' C&f@8K‡üspébž‹”Ō : Į ŸÎK‰õĐĄ;@ÅÖŋc,N{–Š5ŋEo­7{ĩĪ‚ĘBc–芋OO ”]Å€}NfîÂR E_„ŌËĀ;>—īT`Tšũ(Ū4B ŽËqĀô”¯¨Į á)swë:”ÁįēŒÅ>úp¯4×ĨŒ^öJw™aŌ‹Šj,רeg3úēß÷{öNb‹Mį×ĪÆ˙7=^÷Ô7YE0튏î€%—‚ŪžŪZsĀoĀ;ĸĄÍNŒË)å˙ÆSP÷…y˜ŠÆĶĩ=hžéC–~ ­\Alé"Ŗ×ųž%Lîąėeüŋb€W Ú–OEqžK[û6č‹åę[ū‚Ūq JQeĒ—S1˜~™—čŠccZG‡ÚŒÂ†é ÕPķhxŋ¸ŖfŪpp‚šĨu9Ļ Ķx†H2Ôu˙~.É6ī/ÁK$†”×ûĢöŠ YŠc;ađđŲĶŽļâ7CÜkÚĶq÷ŸåŋŲ ēé=bAXw§9[ŗ˙cm¸ÅČa%d—R^5ŒīžšēMĢCķ‹f_8 NôČŽz"¯ŊØĢĻëqbëI$ī˙ĶO­OŅžWæŠeâāųPą•¯؊CY/ŸhÄĖK˜!Ĩ´å‰ž:~͜°‚ëH˜ô3(ũ*)YÅÁ¤ÛÁŊg‘ÖGË{•NfîÅ>wbT¸I fˆn1wWÖYā|“Ä˙Î;}ԜÁír}2÷—ōCiƒŪ΂ŗo×;ŅVü|īë]ĪĐÛL9å&žũčˇŅ…ÍRĘg€Ô‹ķYpȝú Ŋđ|hyh˜’- “/4&nõą†ƒJU ĩ ŖĄäųCüŊ֔}î¯1wGžÃĩĪõp˜Đk¯ô’˛Īä– vú↏L0åÅ`Įˆäĩ{=¯L…ãŗĮ ļûčQˆuô-™ƒ]^ØĐëWŖTL2Áņ@ËūLôlÕŌúfk” Ņ7úÁϏTPūu¨šŅˆ]ßãGŠž*ފO üqÕN(Š“Üß Öū9›ûîįH)‡Ę™­Éž4 ?x'lßNlŨÚ~RēOrëƒHm}Hu>ņ¸~^û¸6 Ixaڑ<ļt#ī­ÚĖĻ]ÍøCÆôŅļ}Šžūˇ EÂO|ZČäEC¯_nöMį%ē9ŠžZJyp}Z'R ”Bé`úĨÆØ m˙‚‚3aōŲq‰aĻ‡Á2Œ˜vŠCOŊQ-Õ^Ø÷yĶ"¯„đ'ø<¸+údTwŊ/īHģY§ŠQwü!D˙ō7ĨĻÎØŊÚä­Hđ Œ'olīĻųĨWņ)†Úž$Ĩqü߯ßd’•"ˆ=ƒ[_{ŧöË&Î×dá6F´ĐLĢĘ}\ŲūB D’Ē ԇ>ĩCvn4tÕˏämt“¸XÖŧ5päĶ0ũĢqS@ņ|c9/ũLߌ›ú%øĖsP8yßįÜŊ–_K¯4ŋ‡ü­īöŊ’Žu}dØöŠņũe_7’FŌĮāģƒ|^–:Wuđ›õ}ęI9ūP„žˇ‚ãūú Ņ—_īį\“CHuHũ>)/‡eĪī.žČ ŗ&œ°'6Gc˛ų_ˆö÷A˙$Í÷Hy] ÃH-jrÕËKtŗR= Ĩŧ¸7ŊĨ¨Ÿ6[y$”ŧ–Ã.P•Ö6´XõC¨Ŋzę ¸¸oáØv¯1Ģ‚Đp? %¤Đff”R>"„hJø,õHĄHčæR °ÍéÍęÛŌĐĘMĪÄãN đt4„JÜ7’Ū¤N!’¨1Đ.ßķ=ŲĢ fĶšuO r‡”ōAāwBˆŨyÕ}øRŊ¸cĞ.ę‡e7’īÂö׌Úhõ/™{ėĐJØĩÚ7ÂÚ`ņ•P÷l:wwĨ”˛@Jųm`)fB_c~ĖD WË6ÕsŪķ÷’ŧÄnáK}oĒy˙ŋɄ`9ØV[˙ãīŋzę—xyÜĀčĶqZˆëv?Œ3֔ ž3Đ— ˛÷ūMõ ”´ĖíBāĩRĘ줔Eû ŅsŅbö~ā,ĖVLIą „V ĀļO÷Ė“žĢlž!>Ëâ …3CÉŧa 4Nđ)i=ŸIėlnå˛%ŗM3TŨņ^;5e^J×ė6„˛ėĶ„j‚īS͓rCĒô–Ŋ€‡ĻÜ|¨ÖBÜV˙•Áe}æ ņ"CŸĀV–ŒlŠ`tyšHJy5đŌX¯F“õV5Bˆ(p1°6įOį,…iwÆÉ-2ā\}¯Ėq0Lŋ’4ķįĶkr‘F Ē-Í |—äŧvƗyBÁ×ŅÖ΁–ā\c(ŠŪoŽĄ“_ęæÁīũŦa$ J ņŗĨ:°¨×ņÖ'Å÷Hn1¸„˙VĘČX“Į8j€“Rį‰žo˛7a„wŽĘų–Ž#2ŪĘ/$oĪôˆéCcņ¤ēr‡•š2¯akŖãņ÷ $q‚Ú­Bú=˙ė8Ŋ7^Ō6n÷ŋ€pŋzí…z„[wŦzˇo&Nú9Ԝcĸ›ŸÔv›…ŲelÖ>éĒJ4­Wb'% &mĩ‘°Õ6¸=ŸxŒ‚—Īŧ˜%Åc‚njx›é¯ĩ?—đ™L~ėÄ­5 bßū¤‰ŋVÔqÜÜ)Ųzã6āĀRĘk…áąBt%G$Ÿ ,Âč%–]OĻPĀ=3>&jvHžôúTPJĀYžûŅæ‡ĪfˇRāq "é%s˛š>´Jŋw{~Í'ōø´ŲŽõõļ•|ĻéądéŨ˙÷ *ģ é—p(u¨|ķlklË6gŽž•RēÆ Ņ-9 ųĀķ˜‰ü2g#ĀŦ¯C×éÆVZËßŗGváI?„˛yFHmΗiķÃįˇå"ŠŠ´Ú( ‰áĒôÛ_zĪÁ {Oi%˙:îdĒJŊ866 ‹ĘŦöœĩâO]öÅ×ö÷˛vōŪīô5aŒ +O·NXĶøéKsßÅ'á´[ŗųöO‹“ũ!D`´]É2Éį/æŒä‰*Ÿ¯ÖČfËfĒjáéFĻÜH@1ŋ•äDëÔ•°ŨąÜķÁĨö öŧĸ°ėēk¨=|&S&•SUYHqą‡JœûņŸ°häĐÖÄ=ōūvú ß÷{ƒįtļ”ŒŖÆmŧ˙×ŌÍķKr˛És đøXČgˇd™äĪbĻ*l&l…í/fw-ë|Ž„ŌƒĀbĪú#Eb[vˇ˛Ŗĩ“ÆN?áõœė49đJ =AdÚ- =•åøę6"ŸãÂ\&&¸$DČ bĪ7ž~›gLÁ˙Ü'/âkYL<œ­÷‰Ą­"éĸ‰ęC˛ē6ūQöY„Ԗyi ĩÔtn\´‘šb5%”x˛)Ũ?nü¯"ē”r pF{ÚČ ’°éqhx šE]oƒ×ÁÖI0ûNC‹Čš:ēygõî]VĮ‡Ą({ŌŪî,ę⤿žĐĢ4ĸ&č;.‡•ŲĶ*‰l›üā?Ŋjz2ą×ĸ“rqbęNK?˙ųûÚ3ˇ0séUÁãgeŋuŖīĸ"‰ôŸĮ2ģ!K,Ē”b7̚ģŲĒéķÂbė€O.Ž*âÄŠã˜;Ą’ņåEŊ; ÂõRĘz!Äū+ˆ.Ĩŧ ¸đŒØé´ŊcôX8M2ŋ¨D7šķ&z(åĨĨëšūÃõlŅš KÂ6¤T^ŨÉd)­¸DŒNiÅeˇ2wF6›…ŽÚZÆë)ĀôĶ$ŽI†´ĖPÖįiލHļ4tã>‚ØĶë­ŋĄŸHú;}€¤ŖĮ:‘{+ŽIļĻŧvŠģ‚´…cH „¤äwõmüŽž ¯XÍEe>.?b&ķĻTcˇf„ pˇ”˛NņÂ~KôøžâO0ŧęʈ>‘bšˇ÷Ĩ v×Aũo1>|§Ã¸ (FK#ßȞžšŖ›Ÿ>˙÷5ôĩAR„`œËŠĪaÅnŗĐfs#ą`$vĻ:3%ĶÕv–SÉėi•ØlÆThŠŽBęÔūÉ*2Á76PMO Õ­֜0pĢņˆēå”ėx+YeādKT˙–œš&Đyąō 4ĒÎ~žXÁÔ áH ]“„bĄHŒĻPŒ°ĻĶ-áÁĻ.ūüüĮ\PäæúãdŪ”ęLHx+đW)åaBˆmû%ҁ7gY|ΒžZhĨs í}.Ęė5ĒĪ‚ŌšYšũ]­|ã‰÷xąÛ(’Š*‚ÉNJ œXŌq#ØiÕK(WÍ$Z ޞĩĶSyNgŸ/ŠĢ¨ˆđÄņØļī:ŦŧWM)–{îšãÆ æķ?ŪˇxôSÁ÷Núßß=ūķŧ4a>tt*Ú­*vk˛Õ8AJĄ­ŨAvú#hēäąv?Ī>ķ!?žVÉÕ'B‰/ífœeĀÃRʓ…‘ŅDô´Ĩ¯”ōœ¸4ŅBmë!´<ķįm|ģ/6ƒhī đ'ßī%y‰ŨÂüš"*‹ŨI$ß#•wÅˎžUĻ´$yĮ jˇĶyđÜa”…ęį…§/BnûÉ' °ÍŪž–‚†û"ܒŧįbßņė ?Qk9¯õĻLĢĸØ7TeīC´>ZžšC.ē†ęčĀF;gL'ZT˜\ž9!mošįáÉéō%WOö„üT­yrpéHúŊ•ŠŠKúOŽú‹&÷5­ĩXTfM­ í v›ĘŒĒĻšĀn]rΛ+xúÃUčzÚdŸŧ!Ĩü֘&zT!J¸äƒu<õáĒL$ČØ{¤”׏t"L:}™ŠxküBhŦģŲ ~¯Ŋ&ų6ÛßL°ĨQåĨų!ˆl„wCŦ_—Õfԋī]Tv÷O­„š čŧdƒuÔŪž„ŦÄŽ% ŗC5ę­ep/võļžÛåĨv ÷đp"Reudžyąãģ¨Im>ņxt]]rOæZ?ŌûĢĢũ2n׿!ííá’~ĮŒ/ōė1ĸß_\äĨŦČŧ†UāąsĐ8Ež˛p¯/ߘ)Žũ’Á ‚Ž ĸgÎū°&H¤ØXu+4.ú÷aû=É$Œn5÷BĶ'°á؝Đ]S­4H¸7ėQņ•˜}L:æ|ėöŲņĶ~Ī„š7BŅųqĶ spĶ"CxiU_ˇ•ĒB×°”qUØ, ĩ9ĻÕwOlG"՛Į×ŌyęņC–…Šô‘Ōä@H!%E-;÷š‚ē/Ō7M<§N眍jŲ‹d†‰5%(ŠųÅ×í´q`Ĩ!ŲCĀĨo­dEŨŽLņėn)åé#EtSÛkRĘZŒ.”™§–ŪęŦ`¨ęŽIp9õwM?íOü›g>¨ûuŦ: ܁͎ō> ]ö9Øš,5P01>ôHPôŊ;Š(l:÷,æŊō6BŊ]SKCMüˇŪOmW¤ŽŖyRī¯ ¯TTGåJEų fđäŲ7Đé~ Ĩ×ã ¨ĐMy™ņĩ%4ģ†ÃœĀœŠûœÃ˛ļ øØœŽ‰‚šĒbĻM(ÔQ'ÔģąĮÜf)š|Éf.˙Į›lÚ՜ÎĨ-ä°XE*âĸŖ }ø—ÃŌČąŅQÎ+ĻiüęõĨlˆKōZÚrߐjyeŠwHĮÛžāWėüĨüëėtš¯JëÔųBãO¸°í|ņFŽ[›ĪÎī_'ĩŽŒih:ĒĒi˜?ŸúcŽe÷a‡Ŗ,ZŽíÛéøtÂt"Žâb‹¤īŦ:”įOüԐ1ŲFeySkŽs;m6ą„yUTÄågē‚\øÄXŋŖ1-Įœ”2'eֆ=“¤”‡cTrMq†‡ {{ÜãĸCëRh| w#¨–Âøī=ŪhÃ^ žņ9ŸHī¯ŽãØ—–.ĢƜš"ÔŊw˟SƒË•^y*ĢÔ8ŋíæt?–Vja@­`Iá%,t@—nŖėéilqzØíöU”8bB‚T›•ŸŸ×ÁĨK^eîûĸOrÆëO]u?›+jG|AŪąŗ•­ģ;öÂhl÷ŗąŨXüŽwÚøį%'Pi~Ÿũ!ďGŅOŪLéėēflmuŋ>ja+Ėū?(ž•ŗKęēä{žÁ}ņ t`Ĩ‚Ŋ„¸ZT…ÏÅĸf`€%GûWqBÛ_qh æî_¨,WĪá¯úáŦkՉÄR/âa“’Ķ[ļpÂ֏™˛ëŪsB@ŨŅ—ķøiŠé!ĨdũĻš;öŽ 6ļûŲĐf|įÚę"îēāŦæÆk+0K-Ē{ęBA ƒ Ŗ 2 ū]9ŊdC{÷íjĀkUņí#ŽŨë˛ĄĒ™qJ˙qĪåžĒÛØč9M¤Ũ× Mã×ūīq]ÃĘË|Øí– ÷ „6ÕËßJĪc’īŽī|‡ W°ČĄĨ‡Wgđ@đRā ō:mø<ŧn‡›Í‚Ē*H)đĶpŋ?LGwN¸7íSGđvA%u_Ų1ŸĶWüƒvßčę_hą¨L›XƧvīeA€Ú2M;ډé’_,\ˑ3'āqš2ˇž|4ZT÷ų€9ņj‡/CãŸã•YGŽĪĀÄ+ xzNˇÚüĄ|c ׯ5jØTé Ņ‹}NĻN,ÞŨ†I(Ņē9˛įĻûQ]‰ē' Ø›Ãoēđ‰–lw:m*Ëŧ”•xąŲ,ÃvFŖ-­ŨÔ7u ''äī dv Ņō F6Ö5ŌĐÚ3lūÅærÆĄ3Í\j 03›å§R!z)ЈéŒ7 †ÆG@sÁ!€+w}$Ú{ŧļ|÷/ßÂģÁžņ›_]ˆ+!ĖĩĸØÍÔIie]Ĩgģék~χļ1)¸Ž-ŋé™G !Ûaŗ0ĄĒˆŌoZ÷Šë:-­ŨlÛÕA(ŌGxUU˜9Ќâ"΍"z$Ŗ~w;­‚‘Á3cšÎ’ímDuÉ9>'}í43•e5ā+BˆGGƒęĸÉôæÖÛČôr@XŒä”\LŽhŒ×Wlâļ…kų¸Ÿô˛ĢJQ Üöœ‘\&TqM”Ä: -Ē—&×ūŅRA}wW’91ĄĒŠ˛T5ũm/EQ(/+ ¤ØKũîvv4tĸK‰ĻéŦŲÜÄÔZĘŠ‚QCt›Í¤ eL¨•´´vŗyGë€ä‹ĒPåļŗ­;Äŗ]Aęvˇ2k|ĘډŠQAV !ū9ŌD¯1MrŠC×Vhø×™æ]°ų˜|QVCaë[:¸ãĨšŋą/Ž^‚*ˇ2Ÿ‡Ũ’´Ĩ6eBYÆI.Ĩ$ŽŅŨ¤Į&Šjhē‘ˆ"„Ņ.ŲjĩāvZqģėxÜvėjKRSK|N&O,ÑsBUÆ×”P\čf]]Áp)%›ļˇĐŲÄjUąĒ 6̊ÍnÅå´aĩĒ(ĘČėą+Š ŧˇĮcgÕúŨ„ŖÉÉRŸƒmņĸ!īŦŨf†čÄšõ˙¤”dƒėЍîĮīĻ|…Ž:ØöOčz•ŦöAֈy ø|˜|Ø}=õ'›wrõ‹‹{Ĩ¸0Áã ĸȕԚ¸We/r3}jeæÖ2Ũ:ģš:éD’œF0üžáB&UQUYh:`'Äb›ļ4ís;Ëß­()ōPTä6U#.Â,_[Ÿ”ߎKÉ'ÛÚh:'ģlŧđé4†ˆ !ž)ĸŸŧ‘Ú(†aÉŨ:ēŧ,WÃô/gėt ×nåü—–°;>ø>ĢĘÔ2ī Ą­{0{r9%%™ŠÜŲ`ķļÂQbŸ“BŸ‡Ũš$“ŧá0m]ABáXRÅSUU˜5šœĸBwN‡C×%ÛwŠ+nZôzuzfúĢËãJŊTUö6žČ%ššģXŋ59üukc;zÂØ _=™ņåiiŽ=ĀąBˆOFBu7šŒ(Œ:ˆĖ=öōÍõœũâZ⓱ĘecBšwŸĮíqdäú;wĩŅŌ`Bu1ŽŊ^×éFQI@8Ãßĸš­›.„YS+đ¸9EL¨-Ĩ˛ŧ!„aΐēD×%ą˜F0% ĶÜ ‰˛Ŗą“Ũ-ŨŒ¯,d\eaN™eĨ^[ēéčé‹q)tŲØŅ& lijO—čŒFĮ!:rMôÔÃĄT;Ėū´,Ё‚Öį!VŸËi ĀO{´x`\f’jvļtpŲķ‹zI>Ūcg|ųžcÔ`ˇĨoûî¨oÃé°rĐŦjSjļŨfÁ^ėĄ¸ØƒŽËķüī11ŽĄŪ‰Įx™Pk¨Ī­m=ėlęĸŽžļÎ3ĻTäLē !_]DĮúž}vgÂŊ¯ØŪÄq¤f;¸¸2×Do3ußãgü'Ã˛ XnfPåÕ0í"2ŨH&ņķ—ŗ"tZŠÉ]v+eÅî´b::”•z3æ,I’§ —ËŽËe§˛ĸ] íėhėäĶõģzEæ>¯ˇĶ†?žujQėĒBXĶyog+ߕ)|š”ōŲL4nLE¯ŪŽąŊ–æ(•ƒ5WÉ  f‘nQo­ÜÄũņĘ0‹Â¤ŠĄ3Ņ!¨)÷1ov5ķĪøšôļ5]ĮãļgÅ#>–`ĩĒL¨-å€iãˆÆ4Önj@ĶsãđBP]ŪįĐU'n6}ÔäĨĨk‰jZ&øųÛxŠxΈŪ¤_8Ģu5D7įh*Øũh™í‘ÖŅāæ˙Ŧíéž˛—f ãJ<:ˇ–IĘ2f˙NJ’‘„—ũ….æN¯"ŽR_ߞŗë¸’woŧ@EŊ.9ķíUÜüä{4w¤ú=¸,gDBŽŌēZÔu÷S]×ËP÷™ŦjķöĒ:–Æ#ĨÆšlƒ&ŠX- L­dę䊜†ļūˇÂíļ3krõÍ]„BҜ\Ķaˇâ´÷™ 5e^Ŧđáąķû—Û[¸č‘´ TܐŽTO•qNŖņÆE^“ûYĐø´däT‘XŒ?.Ģë“ØƒTu;Ŧ<˨ښGîPPāb\Š—Ļ–Žœ]ŗ$aüFŠčš5EÔzŒÅ˙Í@„ŗūõëŌ+P1øZ.‰žxÆôÕė%ôͨe0î˜ô3p–9›ŧøB˜|‡ņ{Īã)…Ém™ŌĀęmŧæ7˛žJė–{åNģ•fTíќG6QSUD[g0}͆¯g`Ûf‹Ē0ĄÂĮ´xmøĩ1/?ŊíMmé\ęûéHõ”ŊTņ2ĩK0JKĨNĖÕnƒ˛Cújšõė‚å—ÄKA§×Ņ0īNPTãZŨģ cŊQd™Ļ¯ŊĩŒË—>†Y%nJVt‹ĒpĐĖj\.[žq#ˆú]í”ģq8˛?ĄP„%Ģv0ÔēŌÔîg}<ģíÂ"7\|">—iž~SņĮ\Ht„uũŸ5SëJéFČÍV‰Ũą–ÄIŋ–ˇjOĖÉĨ”ŧąĨ¯J‹§Ą'×įI> PVę%ÉÍö­ÅjÁļĮhy‘›‰>ƒØĩûyčŨå¤Ąl\jļD´Y¯Øcm™2ƒ@ȄJ:Â%—@ŲåFō&Āl¨ü6xú? |šq{":!‰'~ØU%Š IÛNy™/΁:\Îa÷¤Ë ŅU…C¨e|eÁąÕĨŠâsåÚU;XąÅtĀØ‘q{=7DBHāՌŊ­`IŨú&Ũŗŋ 3¯€I7'OᄹwA0Ũ ‰=Į" æĪÚ îjíė‚ØSë{j̊r’2–ā–įúëŠÕB9ŊޜÎÜiV‹ąŸ?oVõ  2!˜\æéíy˙ûĢĖörĀ…š”č/cĸHSÁReÖY}4+VÂBԃë–Sc~æ…Z(zŋg•Įúw2'ÔĘ'Ž26XŨøÅūđŖĒ ͧT"ęijOÖ.Ë ]lī#Į—mäĐiĩfB\@JĒkZŗSJYŦÂh›=tÖÁÆûĀ6f^•UŠŊ7<úŪr.Y´€šå^ Ŋ†“å Uø|Î덅RâBÃ/T4˛ŋ˜ø¤ÆÁáæ„[ņčÉ%˂ЅnÅN§j'$,čBA‘:N=JąÂŖG°Éd­ÅáÂbsęjíŗ°„J—j§Są*tÂBág刨ôŲ€ŽëŦ^ŋ‹Žžäœu Ôû÷ēũ˛“Š-3•åvē╜Hô¸TīR~co={KtÁd8ôn`dĨf$ÁŽRŞēäĮžĩkVk!N ÔŖ i´¸ŲaqŗÅâĸ[Xˆf@‹PxtŠąfD:Јõ`í×IG(*‡ ‚=8c~ʇã쎇¯!BQ v6ƒ”ؤFi,@)$§Ũn‹‹åÖũÃĄŠ( Ķ&WđÉęIöxŠ×ŅKô˙ŦÛÎEæˆ~;ĸĮ'úķRĘw’ÕØÖ‘W Âp9Ŧˆ,´rI“‚MLˇĄÄå\‘ŗc!ĸø…ŋbŖMuĐ­ØhRm´*6üŠ…‘$•xCe—Ô(ŅÂTiaŠ´ĨZ¯Ō~ļ8ܸŠĘQ,VôX”ˆŋ‹ˆŋ]Ķ˧.ĢĶ‹Ũ[ˆjí[íŪB,vÁŽfbáЀcEå8= ^; Ē}ŋ ģÃneRuˇ÷i3n§ ‹"ˆé’777pÁŅsQS/‘u Q^NȜ=Žģ0j^ŨʨŦ4‘!5:!qEÆ'Ē+ 5ØgFũœ¨ĮW‹GA)6§„@‹†ąēņøģ(û“–B ĄB}Ö!%*ĢÔPŪ–—jĩã((Åęė ã5îŖGA1Z4Š‹ k1Á‹ÕjC(ƒ+wḖ§ŧ-F‹„Đ5 Ą(¨VĒÕFOĶÎíŲÆŪI´(ûGdayyģ›ģč ÛžĒ"/Rņ—Ļ.~ؤėr¨ôŠQv*wDBH)åĪ€đķũ•ėŽ„:`ąxŲ¨LÖ.ŗ 9%ØÄėP‹!ŅĀá-Æî+JŌTĢg‡¯-$  vŖĮb€Ä"u,čƒ2Zî}ą8=ØÜXÎŊhQÕjCĩšÛÂR­ö$iß+íJĮĄ7îā‚î-ŧčŠe›ęķsF‚šŠBÖ%”žō9­Đwĩ˜!z5FdjcN‰ž@ö€Û÷Gĸ‡#1Ö5õUõ‰i2ŽrfFœę1>īßIeĖČąØ8‹Ę%D1í.,vÎÂRôX-F‹EĐ"!´h8.qå dĒ!M-6ḖÅîRį„ž˛hŠįŧî:>ĩ—ķžŖ„ÛĶŠ¸Øƒug+Ņx'ŨÄ@Ģ m|fæ3§­ĸĮqN\ĨØ¯ĐØŪÅO_øˆû:÷j¯›EæĖžíxõ°!Å}Å8ŧÅ)—(Q,V‹ĩßH¤Ļõö5ßcG EɊo!]¨6;ŪōZü­ģ98ÔȌH+›mElļzØŽ:Įˇå,R':FUU(/ōPßldÕY­*Š0úƝk6].Ĩn$™&ú‚ũä뛹æßōr|›D‚%.J|FP„ާGø)ą zļb—BQq•ŒÃęČdđ@¨ÆŌ.ŋbąâ)¯%ÜŨŽčjã€P3„ Õ7'÷{)¯:ËĮĖ38ûˆŽ*XAX“lë Ķ4,Š7Õ<#h-ˇD—RZãOšĻŅĐЀËåĸ¨¨hÔ Îú\úôBĮ$ŧV…é垤–Ją˜ųŌEScÎė؊Uę¨VîŌ*K>)fYâđcsy ÷tééDJK|ëoģelE"öOguYTÂZŒMū1M7CôǔΠ>KŠĩŊ­­Žŋž;ŽŖŽ8‚_˙ú×456ŽøĀlŪŨÂ% $/sX˜SU”Dr€`$f*iĸdAĪ6ƒä6î˛Ú<ɇîÎÂ2|U“’>ßa[Ž:ĢUŝīŒGV. ÅĖÆŊ;FŠčLlvŋũÖ[üû™gŅbŅH”ûīš—¯]ū5ļoß>bƒŌÚåį;Ī,ė-Uî°2m\!VKōëō8mL_’ōöZŠeAĪvlR3ļœĘjPÔ| ¸ŊkŒ}˙Ž•ātĐ$”ŗŠĸׇŒ˜Ę¸#EtSfāŸ­[ŗ†ī}įģ´ĩĩå|0ĸ1;^øˆWĒČLįKę™PSîã Ų5ø|ŠŠvŠs–.=Šjĩã)ĢF˜č)æ÷ØšĢ]×˙;ˆŽ÷嗇…2&Ceí õå, Ō!͞~íLŨÔŒĢŠŠôķU+Wrī=÷ iZNã•eëųmŊąĀ8T…Š•ž¤Č%E˚TÎ$“ O 6P T îŌ*SÛY6má‹_ũ.§œsũĮ“9+›4˛ŊīĨҏiĖ=ƒ51G>áöõŒ_&‰ĀDĄˆđđCcíÚĩŦXą‚ŽŽŽŦŋŒæŽ~ŧp]ī8Ė,÷&Uą¨Fe×ŌRsI5sŖŨLˇ¸Š+Q,ŠīD646sųˇobÃæmDŖ1îüõ446ī÷DO¤ĩŠcPĻ'W%6iTÍÅbøFŠč-q˛§„ &`w8†\Å˙īžûøÎˇžÅƒ<Y^ųžúx-+ãWj=vŧ eĄE0{j&ķΝRãŗ]†Ļā-6Ŋ…öˇ>McSkŌ}ÕmÛą˙=AķqęcŅŖ‘¨Fˆnŗ˜Úü:LJiË9Ņ…:°(ÕãĘĘĘ8õ´Ī ų÷×^~…Ũõģxäá‡iČĸ7žĩËĪVoīĩŸÆ•¸“¤ÉĖIåøĖoéjÁˇËžbSįØļŖžzbĀį]Ŗz‚Įb1::ģˆDĖ—ų ÎJIŠãĻČā’>LˆŽËšDĮ ŅÎ>įœ}~ĮßĶ“UOüŌÍõŦī‰×xíI*{M…’béséQf† )ė,,3Ũ”kéōUƒ갏Ūl¯úŨ\ķ/8ō”/ņkDSsĢIAĸ$m?NÆšƒĢ ö” ‚ģL÷8q¤ˆžØĖAķæÍŖ˛jßû˙ŨŨŨY„'WnéũwiB ‡ÍBmuzŊŌ>jÁ"uŦN‘×mJH>YšfĐĪ Fgwk[;ßŧî^xåĸŅ(˙ųp{ėiķ6ŽŊo\ÆGģĮœ;NKØ/÷ēmŊ5 ^^ĩÅė)įŅ—ad°Ĩ„‚‚Žģūûûüž=K’ĢŊ'ĀÛņęŽV‡­OšOŽ)FM#CÍ%5&E GĸŨk>ęOJÉÛī/tōLšP;ę&ĩŽK|č_ŦY—ÜgīĪ{’Žîž´‰^íĄTŒ)ĸ'z×m•ĸøvÛĢ;Û„M=ËÁí—Qĸ !v+Ė{Ę)§0mƌ!˙ŽZ,TUUeeš;z¨‹¯ļkoũ7ģUĨ¨Č“Öš‰töÆ$NԔ‰ėŦčŖ¸āÜĶ(,đŽēIŊuûN|čņAėu] MĻÎiuzzq$Į›Į”T/*Ln—]ę6L‘‚ÛMiĢå€-įDãOfōzŊüôgˇĄ!6}ƌŊnÅĨƒļž`o€'AšW–xĶJCĀԈ‘ņf÷ώˆk€”_đšãQ”Ņ%öÖ{Ē ą¨šæ BQ°%ŧĮ‰‘fDũc†čvģĩ7jY¸įĨėhé4uJ†YÂ-3äiĀ”øĐCåÚī_7čß.ûÚåØlŲ‰„ûŧÁ‰Õ]Ķ-áėĶcĮ „iۓŲî-ė$TœØI‰3d/J˜Sęzs}ØR(ãDB´˜9VQžzŲeœ{ŪyIŸWŒĢä¸ãŽËž“$!Œtj%„HģQ┘‰jĩŖ¨–´‰~æįúœŦĶĻLāÆk¯ÂjĩŒēɉFY´dåĪQ5ŽÂôšՂŗ /ÛĨG9ŋg+Ĩc„ėî„Ø !Dī|kķgˇÉEļfÉo0šˇ§l”ē\.~rÛOņøøû_˙ŋ¸ë.JKKŗöC÷8LTE¤­ׯĢĨZ™iŸ|âąGōĮßŨFOŸŖŽ8„ōŌ’Q9™#‘(í]¸i&xÖé'āq§§ŨØ<ÄÂA"Cqôia.ėŽã-W5kŦžQMtĢͨ1Ø?ö+ĸe7g!+Ɲbđ¨éUĪíæŌK/EQ¤”„ÃŲ Žđ&¤îÉ/§.‰ˇ"Ú[9¨”Ve‹ĘÉĮÅ9 Nĩ$ßŖ™ Ãxú)ĮgäÎâr, ¨SrFĪ6Îėĸ@ÚwcĮˆ¸Co$¯UUÆŅ㸚=xüøņœuî9!øķŸū”U˛—x\¸ã¤î “D×%2ę1ÖxcĀTLûX†ÕjaŌ„;$“&ÔpÄĄeäB(¸KĮauy VÉôp—wmāŒ`#ÕŖhCb‘a* u,Ãaˇsú)Į°Íoûá5øŧž Î1wI%ނԤhCĢԙjæĸŽ|­k3'…š™”ãFCÁ(2aĐ;”ĐÚy\gl=Ž[ĶIåx 'žz ÷Ũ{oÖ¤ēËnãėj#ūŧ3ljo˙t÷˜ŸV)ąH#Ą’˙˛.ĢBžrášTV”Æm…_ßqG>/WÃá+Æ[>‹ŨÕī/Pĸ9$؈w”8ëĸ ‰ēãĩŪ‘0žŦĀÔéfz¸’åß|0%­V+W^yZ,ÆÂ÷˙ÃG}”ĩ{=mN_ÉŨļnƒā-m=ĻΧ@ŧ—ę‘ī IDATÊ= u=g‹Î„ÚjžøÛ=üũģxëßįŦĶOĘęõT›Oy î˛ę[™Û­>VZGGPQ é5 ÆŋļŠT™ cng˜MrĄSū“A4‡~8 Î9€;~v;]]ŲÉԚ][ÁĖøžæÎî0š.iī §+ äč( !%ŨwüŠž;ī&øÚ[D×ŦCknh4käWQÆQ‡BuUeîü7ž˛œEå{„%‹eŒttn+(J8î:m\n‡)}]YB[˛”°ņrÅŨŗga9đÔĒJÔŌŸÆp ;]‹ę2,Æ6ÕÉÖQRH2‰ŅÜadŨ5tôųŠĪ8`‚ŲĩvØId9ņ !‚ĀY€Š49sæpÕˇž Ā/6då>2šxČk]{€hL§žŠ‹P(uŠA5ČĨŒ7$YhƒUĄ‰ÅMMÄŪy—ĐîÃĶÍt}û<ûÂöHmHÍđŗė´Žž<€Ļæ.¤”øƒã*üņNķ§˜í~T=Növā!Sön öĪw7´ĶÔî§Ŋ;ÄϏmŽ7Ÿ0×ŦāņTúŖD$Į퀩zBĒÅ¡žũmfĖž…Žë\ŨulŲ˛%ã7xÔŦ‰\[kėĢGĨD“]—45§–aTgņÄU÷(ZtdÕwÅëŲĢSPáēú „m w‹‘d˛zr¤ƒÂ ‰mjîĸŽžÎžkšûļk˙0wr)ŊEņŠÂv̊Įj˜ŲÕF dĘg~nJC95KHŒ8xĶŗ_Q.ųō—šũ?GUUVú)œw>Ī=û,ŅhzĄĢeŪžxé=E&Âa#B°ŅfT…ƒŖÃŋßC"„`§ÕĮ{ŽŌ- éķöųz*âę{—„ē†Vŗ§Ü5ψ'û6āzŌh¤ĨĒ*\püķŸp{<žÍĩ\˙ũīŗzõjĶ{í% ´']5ÕLõ˛ūĀQB8<ęl!ąû+t-FOĶNÂA?ī:+F|Ũíļ÷:qļžˆķ-MĻz ÚIĄ;˨!z/¤š`p 'đôsĪr܉'€ŧøīį9{Á™üäÖ[Y˛dɰ#際šxķÍ7šī˙ˇwæņqUu˙Ÿ{īė“L–&M“ĻKē"kĸ( ˆüÜ~RAåAQAĀíų)Žˆ (Ęâ‚>*EA* ĩR JĄ´ĐBK÷%i›fĪė3w9ŋ?îM:éšĖL’Ir?¯×ŧ2Mzīœ9÷|Î÷{žë}÷õ˙.æŖR’N ]åJ•õ~;ÅÔŗ¤c].#‡EK’{ąŒ,{=avĢŖ,BPîôE÷åTŪŪ™ˇq6¯æZ L„”R~x PPšŅŦYŗ¸įŪ{Y˛d ßģõģôtwķā‹ųũâß1ŠĻ†‹?ü!Ž=öX&MšD0@( Ųl–h4ĘîŨģYūĖrūĩt)ēŽcU×#.>ŠjôäÄē'SYÂa˙ĮöŦ¯šŲ™nĘŦ,éhš?„æõģä,"˛ÉFÆŪСx#%Sķ=ä÷ĐKãņ¨G}ŨŅĀ’2Û1äŅŠ$Jˆ !vJ)?âHö‚ļá@ Ā?øAÎ:ë,žzę)~ņŗŸŗw÷nÚÛÚ¸ī'węĻirÚŦŠLķ*,7!iJ˛†‰WSéĨ¨­zîpV(<ŦႸGō´R6š1¯ūč.cЊu;'thSKgíĢÔëQTE`X’­ą–%QÔ!}昔č9d_*Ĩü*ļ°ā͸ļļ–Ë/ŋœE‹ąfõjV­ZÅŗË—ķŸgŸĮë÷ĄĒ;lŌĐ Ę"åüŸ ÎįÔS˛āäĖ›;—߯XĮō—ļbII:cāÕTÚģ“4™V^mš^÷„™ã­bvļ ËȒėl%4Š~ÂU Žŗšé$YĸtĘwåvKõŠ †eōr"“¯ũ¨lLŨÁyĀÕÅ3†„8ãĖ3yÛgđšëŽ#‹ŅŲŲI<Į4Mü~?‘H„ęęjŧ^ī€Ī'Θ/nŊ‰,å!ĻeŅĶ›ĖĢģĒž Öq…™ ÜĖ §¤zÚû‹$¸(Œč}FÎ"đ-˛Ë!œc˜ÛkXĻ9 Ôø ĄŒyĸ !,)åĩØmfŽ*ļQDUU***¨¨\{¤Yõ“8#āáų´ÎîD†z+ŒĒZÛŖyˇQN …'C\ێGšdâ=EÅŠvŲZr¤Ŗ‚$l™´•ˆPĪ-&,ķûáFɕ'BĀg( üTąđz¸dļŽ[’XÂN7íŠĻˆÆō·ßĨúYlĀr{:ÚIēˇĶ%k!ëæ[G]‰T}TÖûØ-@SÅÄ%zŲ?[ d×ņûcĀw÷ĻúGķžî‚Üákŧåŧœ˛_ŌG;Iõ¸>öü„ų¤!‰ĖR*HįZeœ8ŒS}”üėŲqCôČū‹ŅĮŦúI\;ÅVõģ2ąä~ŠŪŅYXĄĘį}UŦėīC–‰u“čjEZ–ËŪAÂÔŗÄۚíM0…āšĐTžõ—Fņ )%=1{Íd Ķ‘čĮ—ōíÔÛ>ވžCök€û`t‚œTEáŠ7Īī˙÷ŽÎDB–]¤Ōųį™K`™ŋ†59\z2Fŧ­ËĐ]z*AŧmWą C(ü=4•Ū JE/Š'2dœ>ŲŦŅ?ŽyUaÔü{ŦwDĪ!ûį€ë€ähŒaÁŦž8uƒ‡ön{†iņƖ}ut‘ĀR /ëûĪėĻž!ļoŲdĖeķáÎŊé$‰Î=ũڏ)Mãĩ* ĐŅš˙&r¯æ×Uåmâ—DwČn !îÎĻ€<öB¤úgĪ>‘ˆŖimîIwŠīĮSYÖoÚ[ŲûÔøĨáFģr, -“dį^’]­HËt™Ë4HtîíˇgH`E ×KŦ“j&Ŗŗˇ#Ö?Æ=ņL˙î~Ü´ŧ\ĒĐĄŽÔ™YŪ‘jĨAxŒÁŽĀޘ0ũ×Ĩ”dãŊ¤œœūŒPųEd鑿RJZötŗcOw˙ī’i5{zû=5OžãÎ?e^^ˇæ !6wĸ°ËÜFJa<Ŋqnzü?Üŗwŋ‡ŠUAÂīA¤Ŧ*÷Sō zņûŧøũڀ¸úŖ=¤“ŗŊŧ9ĩ•=Đp7XŽ7ˇi¯Ō˛Č&zÉÄ{¸[<åü!<­$Ə§ŲŪÜIONĢíTÆ`ũŪŪ~•ũŋĢšõâˇŖåwôZ œ>”Zîc•č3mų\Ûŧk6l SYYÉŧyķhh(ŧ b2“å7ĪŧÂįÖîФē6āĄŽ<@8čE=BP„Ē*x5E؛” ›&Ē"8ų¸Æƒ6ŋ´8+ŨÁ1™Nŧ‡ˆüŌ|ŧĄr<đ¸H5õ,ŲD”lĸ÷ž‡ĩūžÎ 8ņ5 Äb)ZÛĸ´uÅøîcÉ,Úĸdœzƒ­ ķãKÎĸĒ,”īĮŨ(„¸-ߋĮŅOū=ÔëV­ZÅ'>vų€ 3šĻqŲå—sŲG/ŖŠŠ‰L&C&“Ąŧŧ<qÁ˛›ší™Wy´wāš\SSC>Â~~Ÿ†×Ŗ‘øšXđĻÂĄCKčˆepfĻƒŲ™.<ōā(:!<Á0ÍD(cĮc™F:I6ÅČ:l"ĨhŦķÕđ玌Žî­f˜ÉD†X<ÅžÎøß8€iIÚē“líIö˙#U!~xŅ™LŽĖÛĮŸæ !š'ŅĪū5$‰`š|îÚkųû“OōÛ~đ}Âá0ßŊåžûí,\¸0¯ņĨ2:ËÖnភ6ķTâĐŊÖ~U%äQh š¨ŠĀ´$YKĸ[’úŠá€—†šršfšāNšeđļL'ŗ˛ŨĶDæã QŊĨKIrۅ3ã6šīŸR46øĒųˇ¯ŠÔāLĶĸŊ#ŠiZ¤3ŅD†DZ?dšiIĸņ4Ûē“$ũ›īuõ•|ķŊ§S]*d(÷ !Ž)äãšč–eqà 7đˇŋžĒ´‚™ŗšØļe+^¯—~˙ §žzjŪãĖč/onæŸ[¸Û>š‡˜‹8ŖÜOcMšĒpĘqxŊGĪ$öI‹Sŗ=ĖÍö0É8rđ ĸjv+ŸÅãCÕŧ#+ņĨÄ4tL=ƒ™IĄ§G ÷íQũŧáĢäEo%™4ŧĨŌ:/­ÛuX’ô$‰&˛ėŠĨûVúˆõĶ“fpųY'đd,MĮ !ļNĸŋ x~¨×ũséRŽúø•¨ƒ´zĪ?Ÿ‡ūc^jüã%‘ÎōFKoėé`C[/ëģâ<MåH,qГk 'NĢB‚ÆÉfL|r†4˜iNÎtĶ G Yƒˆ—UķØūyUķ T EQŠ’×Y_J‰´L¤eb™&–‘ĩûĪe͘ŲLŋüHČ …=žrÖy+Ųė bŽÂRí&Yģq/RBÖ0‘–]R,Ģ›ôĻu:R:ŲClä—V†øīsNäÄφbēWqmĄ7KDŸlēúerĮíˇs˙Ī~>čEú“ûîåŨī~wąĮiItĶ$–L“Lëd ˒ ā;OŋÜīŽ[0%B8čE‚Š?ėYũHđHI“‘džĨAļ†ĩ'ÂÎõ}ÄŠũ7!ö̰ŌBZö̝ԋD9å6+T:´ [ŧÖyĘFŨ?žgo7[[ēČč&/5wõë\ đÉSįpÎņŗđyŠRĶ%œOg–Ą1v°ģˇú;ĒĒrÍĩײ}Į–>õô Öˇā_ËūŅ^˜wtÛaĪĘĒ@S•CöŨúÄ)ŗyhéĢöëI2'čŒ’[Û8ņM hCôÃëB°ŅbŖ'„F“Ŧ,ķŗqõ+CāhŌžŸŦ8‰#ÅĪĻŗÄU/û´[Ô Ãá0ˇŪz+™tšįžY~Ô˙ŋöÕWIĨRƒÁûr gOå„å¯ąV7Ų—ŌiHe ŧ$3:ˇîcūœē|Ķ1´*>Zũ>đWã•ՖÎt#Éd3Å$#E@ęø,e˜–‰.€G€Uu3MûķŸųÚWžŠahßžų;,Z´gdI’Ėdšũ‰˙đm‡ėuÕaüžĄ;K4UaVc55“Ę'T(Ķ´hŲĶ…Ž›Hi˙;­$ŌēÃp ¨Ŋ;ÉÆ.;Ž!<ķž7sÚŧaɔ{X$„Ø6œķ0f𔞸° ØģũŠ+¸áēëééęBJÉ%š”n¸É“G6S*ĢüāŅe|c×Āģa“Ęü„Ū!“ļĒ<ŒÆjBA߸'y2•eķļ6ĸÉĖ ¯ŅM‹Ũqšã’~{Úl>ööE_ÂØîŗ/ !†Ŋčé˜ŪÛĨ” ĀāÄbß{ëÖ­|íÆYĩōE[šÖOáĻ›oæėŗĪFĄRN---\ũÉOąēz.oZˆq@BŠOU¨ xđ{5üEŨą&%˜†……¤˛,0`CB0eR˜†ēJüūŌSį ĶļßįĶȲīúÖÖvļöVj´Š&ŨŅ4;zSũa­đŗ“grÅŲ'h”Xėž<’o~ų„"ēCözāoÃAöh4Ę/īŋŸ{rw˙9ũ’K?Ė՟ū4ͧOv’ßpũõŧōōjTUåŽ{î#YVĮM/mfƒ1´S‚^Ļהá9Ā/„ Ž*ÄäÚrʁŸCqšÂÆãi6moÃ0-ękËŠŽ đÎ’LféęŽĶŌ֋~˜9˛¤´mĻ…Ą›¤˛=)}Ёgô™ĒÂĪĪ9žsOšSL\øpĢ"1’<§ĩá$ģeYŦXą‚›žõ-vlŗŖUMã+7~•÷Ŋ˙ũTWŋ9âúõëųÂį?Ī–›Bpë÷žĮŗ\Œ‚ŪDŠ6îâ‰õģTˇ9ŧĒÂüIa"áCĢėဗÉÕaĘË„ƒūA­ ˒DŖ)Ú:ctö&Š­ ) ų QSˆĮĶė눲ˇ#vP¸i8āĨĒ<€ßīÁëŅPU‰Ä2-˛ēI*­Ķ՛<¤Amŋa-ƞŖ×ņķߝßĀGŪz,õՑbü7Ā…fĄMhĸ;dŸ<9dhooįÁäĮw܉ĒŲÆ°Ēęj>˙Ĩ/rîšįR[[xëãL&ÃĶO?Í×ŋz#ÉD!ˇ|ī6.žøâC–ęŒ&ØÚÚÉŪîņ4ŊŠ,Ļ´P„ čÕx4~˛vk˛ûƒ‚^ĒÃøŧ‡?~x4•Ę2?á ¯WÃŖ)(B`I‰aZdŗņd†ÎŪ†yhÉéķ¨T8v¯GÃãQQTáH͞ī“LeéėM’Ę OąKôhÉ9sî´üî2?—ÛČķ§1ŊļhõG7‹_3ĘmBŨ!{đā´aē?ëÖ­ã—÷ßĪųk?ųüW}ę“ŧķŧķ˜;wmt]gŨēuüė§?eŲ?– šãŽqŪyįäŪknīææŋŊČũmûũÊ4U˜T8H/ôÄŌø}~o~éģ–ú֎q§š†¸ĒŽ‚°WŖ:čcJy€†Ę2fNŽĸąĻ"ī\‚ÃāāĢN§ĄQĮ¸s´H)ÃÎ9čJ†) Č0 VŽ\Éâāéŋ=ŲoœS…ĻŲŗøĐ‡/å„O žžžĘĘJü~˙!TÕ8ûöícÆ ünņbVŋ´ ˲Rōļ3Īäkßø:ķæÍ+ĘxĶY?¯\Ī˙{qķ€BĒˌøŠ*ķãķjĖší!(äũ›žaąģĶ–ĀŠ€Š!Õå~‚~Ī ÎĘĻ%‰'ŗėéŲ_bė2Ë÷\¸›ęíčŊáõ1ļc‹h+^ŒKĒãg?¸˜9\Ÿc¯žú*K–,áwŋ}ë€Øų@0Hee%S™>sŋŸx<ÎÖ­[Ųģ{˜ÆūÅXW?…ëo¸÷ŧ÷ŊÅīQąc_ŋzv-ˇloXķB@ĪCmšŸßƒ×Ŗš”ņT–æž$ŅŦI™GĨ.ė#đāķjC˛Tgu“îxší=Šũ=ÄØ“^ƜņyT!l-K7%YŨ¤'•Ĩ=Ĩtũ7fÖpí;OĄļbDú˛YĀeBˆ‡J‰ã:tBJÁ™ũ$ FĨoooįß+ūÍōåĪđä’ŋ‘I§å†Ë¤ĶœwÁųŧīũī᜺Ī&2ˉ4–”ŦßŲĘo_ØĀÍG+‚Ǥ€— OCĶ4Å&•%%†a‘ŅM:“Yz˛&æaBo5!j ŸFØgĐ4UAUŠKÚ>ëlÖ =™Ĩ;cãõSU…íĻuÄÅ{$ßÔ'kËųÔ[ßÄÉŗĻæÛĸ8Ü|a¤Üf.Ņqđ āR†X¸bČD˛,bŅ(ÛļogëÖ­lßžÖŊ{‰Åbčēßī#‰0}útæĖ™ÃücŽĄŽŽMŲŌ–%ŲŲÖÅãk6ķ§-­<—Ö dŠr÷™ēןs“+Âŧ°ą™ljáņŊ=l6Ŧ#¯X)97äã=3k9˛Z[l˙÷Ņp?đŲR9—O8ĸį~:đMāãŒ`B]FĘ)B (ʈĮĪ ÉL– Ímü{s ĪļtōDo’ô äQ•,Ē)ãĮMį´94ˇ÷đŸ-ģyŽšƒåŊIö˜rĐ+l–ĒđĄÆj>xōlŽŸQ?€ –”$ĶYvwö˛dÅ*n^ņ Ļ×ĪŠ•rÕ!?õ•eĖŦ­¤ļĸŦ(ļ‚ņDō GôœķûÕĀÃŠÎUč†Éŋž˙7WÜŗŊρΜųf ÍĮžDŋĒP_ ŠēœŲ“+iĒĢĸ˛,xĩÚ0M1[:Ŗ´tÅØMŌ‘ĖГŅ1Ĩ]ķž!ėg~m„ųõ“˜=ĨšH(pÔ 0‘Hđ_W^ÉĒV./ã‰'ŸdęÔŠŖ=mŋŽ)U’OhH)H)7IaŨÚĩrFÃT9kÚtšnŨēĸŪےRš–%­îąęĨ—䜙MrÖôōĻ›n’ĻiŽÖTYRĘ{Ĩ”%_’M™¨DBŦÁöˇ?„›˛=呈]}GbąâöeØ~üBTÉ'ŸĖG?~ŋũÕ˙°iĶĻҘĻ$p𹱠ɕ‰ŧ …=ĀeĀ@ˇKqeee˜NI4-ŊEĢ(\vŲeøü~„,~āÃ–eģ5ØB!Ä=Bk,}:5u“B°fõęaôĀT‡ôĪJ)—K)ĪwBĢKšKŲ‚ /R?éŧR*ؙ–õ@ p`å ËšæDāˎvPRˆTT`&šG#—ô3(//gŪüųt´ĩķ /đŲkŽ9dé­a"ũYĀ™ĀķRƝ !žs‰>qČo`ģķ)e9p7đŅRÕĒ"‘†ĄŖy4b%Nôž[+ž}Ž=ģw“L& ‡Ã#úȲ/“RŪ|]‘)Š9ri9˛RÖO——ōüÛa°v\HŦÃ`DĶŦY´ėj&•5;Іíų“SŌĖ%ú&ų߀ˇ”úX}>3ōtĮ’=Ŧ¨ŽŽļËJé:‰Db´‡ķāK‰ė.ŅGŽäėŽ2§Ž…ņĒĒĘô™3hm-ũÁ@ €”]6ģp>đ”Ōį}â\~,+cVĨŋVũŽvŦ<āeát>Éy™Cxx­uˆĪĘx‘€Ķ’ĒDđnāļRˆkŒ| XTR›ķĶ’Ō&mŅr~†#vĄĘu¯ŊFŽt-7‘_øŪŠ6#r_B 00¯]‚ŦŽĶ×b¤zã ×I)—!ūé}|Kķđ­Ņ rŸ„ĩrŪ÷Ibéŧ7 ƒl&CwGŅh”L&ƒ”¯×KVĪ덒L&ņƒ#˛ųôŋ?”d>Œ´ŪÛŨ"„­-xžpã×Đ<ž’œäē†ÂRšwėäķW_ÍÆ×^;âĻ5 Ļ™¯§Aas\ĸ/&åsā}ęé#=ÔßĪ}¨¨™<™Ģ¯ŋŽūügN<õ”’ä…§ŸÎÛĪ{gA÷ˆõFųÁÍ7“:JĻ^Æ˛Hä¯FkŽ\cÜđbČ"°Ī V X–Eg[ÛQU{iY”WVđļŗÎâ˜ã§qÆ ĻN›ÆÔiĶđ ą×ûh sĶ~ĀŗË–ątÉ–/ũgŋ÷`(XûōjÖ¯]Ë)o9r„r6˙į3j61×7ŒR.ÆÎPšD7ŒB΃ũčíęb÷ž=dR)ôL°ûŋiū`P(Dyy9e‘ TU)ZŖÄQœszģģiŪš“=--ėiiaÛÆMŦm[ŪØ„ĸ*ļEŅ`#ĨIJ,šë—÷3į˜#ÛĖŧŠB8ŋ œBˆW\ĸ?ĸ˙¸!õ=a…Hj-R’MĨH&“d2 Ã@:Qt‡@ @0>ę1Į¯(T5_âĖBltU÷ņ‡×ķŨ}چ!%iįŧîRžiæDÄųC!üyx„#ÅũŠ‚šŋÆc{Ü3úøÄŋœœ—ŅS‚°Ļ!ą@zž.6C‡âĖŋGQđ*J1TßÍBˆ˜Kôņ‰Āk؅ :_ų‰"SĘ~Ō›Ø<[TĀ#š#š‹|Žu“ZÆąĘhH) ü¤h÷t$æƒ¤s5sãŨ؟…æâ`I-œPמ8÷ž¤—aÆcŖēŨG?ŧRõĀôũܲ÷å~÷I˙žŋ 5įģä7Öžķ¸CčŲk9‰+Ŗ°č_NÍÎĢ.ŅG†ėWbž(1í[\n Ģ•ŗYTâ0vY¤x¸\ô~ĸæ97?]ä\SB0 …w%úø'ē <ŧ\|ŸßÁF ģØ8Ŗ…yˇâēRĐv\Œ Ų+ąģ|œėÎÆ„ÁSĀEBˆQoßåÆēÔŽ*D7v7—5îlLüøP)Ü%úȓ}p°ÂqĮ€EBˆ’)ˆī}tČū.āNĀpgd\!|¸d4ƒcÜ3zéÛĪÄöąŸč>‹1,đpģbS)Đ]\ŖOvp đylCûLÆÁ üPņFIk’îŗ* ė..ŽÄnÅėĸv[ėĮ€?!ļ‰#ŖûÜJ’ô>ė–>g8?sgeÔĪŪ/b[Ō)uéí}ėJúYĀšØ-ŽsÎô~wv† qGjŋ<< $FŗƒKô‰I|/pđfįįl`ŽĢîį…` °xøvc,Û%úø%ŋTMĀ)ĀT ˜ƒ]¤2ÔMP"Į€6ėûĀ`ĩķŗ0ĮŠ]ĸOÜM@u^ (æ9›BÄŲ$Ɯ#BāËY  ēˇ}%瀭@ÛŪãy+v9$ˇ&™]ĸģėĻ 8¤Ī ¤Ō°ë‘×2ōåŠM‡¨ģ€^æČXŽzíĻŨģpá… .\¸pá… .\¸pá… .\¸pá… .\¸pá… .\¸p1ūđ˙RČž:ÃėčIENDŽB`‚ffuf-2.3.0/_img/ffuf_mascot_600.png000066400000000000000000001756731525032755300167760ustar00rootroot00000000000000‰PNG  IHDR čąT‹˜…iCCPICC profile(‘}‘=HÃ@Å_SĨ"Q ~āĄ:YqÔ*ĄB¨Zu0šô š4$).Ž‚kÁÁÅǃ‹ŗŽŽ‚ øâčä¤č"%ū/-´ˆņā¸īî=îŪBĩČ4ĢmĐtÛLÄĸb*Ŋ*^áG?z0ˆ^™YƜ$Åá9žîáãë]„gyŸûstŠ‹>‘x–ĻMŧA<Ŋiœ÷‰C,/ĢÄįÄc&]ø‘ëJß8į\xfČL&æ‰CÄbŽ…•fyS#ž"̚NųBĒÎ*į-ÎZąĖ÷ä/ fô•eŽĶF ‹X‚ Ę( ZuR,$h?ęárũšrĀČą€4ČŽü~wke''ęIÁ(Đūâ8#@`¨UįûØqj'€˙¸Ō›ūR˜ų$ŊŌÔÂG@÷6pqŨԔ=ārx2dSv%?M!›ŪĪč›Ō@ß-ĐšVī­ąĶ I]Åo€ƒC`4GŲëīîhííß3ū~FCr•‡%ĒÖbKGDųCģ pHYs × ×B(›xtIMEæ.cũöę IDATxÚėŨw˜deūũīsNÅŽŽĶĶ“ķ Ã0’$#I@eDeuõÕŀy ĢŽaŨÅE]Å5íΰēkBńAEĸ HŌ 3LOęœģŌ9įy˙¨šžÎiĒĒĢģīĪuĩ2]Õ'×9Ī]O™ŋŒˆˆˆČüf­ !`°H zš8´ž1ÆĶ™Nč8¸88¨œaeD ųō pđ;`ÂˆˆˆˆˆˆL<"ÖÚWXkoąÖ&­ĩßZÛg­ũšĩölkmHGUDDDDD†c­=ÍZ{‡ĩ6k #m­ũ…ĩvƒĩV-kDDDDD$×ÜĘZ{…ĩļ×GģĩöÍÖZGG[DDDDd~‡„ĩöWĶhj5Užĩö˙åû–ˆˆˆˆˆČ< ÖÚ?ÛŌ ŦĩW[k#:ú"""""ķ+|¸ÖÚßŲŌ ŦĩßS™_ä*;skí7­ĩŽÎ„ˆˆˆˆČܧæG¨˛3B>Ŧŗ!Ãi¸4‘š> °8ļ 6ĮÎ2ÆÜŖ3#i¨4‘šå5e>\āZkí9H5 """"sˆĩö‘2 ũ¸Äč ‰j@DDDDæNø8Ē ÃĀEäjfD@DDDDæw—ņļ}ÅZ[ŠS$ """"sĮYeŧm Ā•:Eĸ> """"s„ĩļH”ņ&ĀqƘĮtļæ/ՀˆˆˆˆĖđą´ĖÃĮÁ˛įU:[ """"2ûm˜%Ûų2kíņ:] """"2ģ-(ėâl1ˇõS:] """"2ģ… ē´žŊxÅÚ֗Yk7é”ÍO!‘9Ą¯ K‹/„×ÃúK)Ō¸E—˙^ÎÔZΗ—€ÅĀđa„= Ø¤€Ŧ&[T™/öļ”ƒÎûĄy,z~1ļ÷­ÖÚ+Œ1ļœb>tŦ.Ξԓk94Z €4° ¸ĮZ{ p'ĐWnû&""""R¨Bķbkí÷mĄ=öUk˙zĄĩũ­ļHN*§āa­ŊČZ{ŖĩļßZLsŸ2ÖÚÖÚ+Ŧĩ‹Ŧĩšöbõ™ŊÁ#b­Ŋ xxCáKŠqēaûˇĀĨeŅĨep ĩöāFā7Ā…@œéˇ; ëČu´xĩ6ĒĢUDDDDdļK;€īK)FG ›ī„Ūũ;hy´ģō–™Ŧ!°ÖēĀŋäã‹(l÷,ž Üh­]Ģ+WDDDDd6…c­ ?NÜĸ­4Ûv¨,ũė—ĀOz ‹€gčxƁk€Ī1˛syĄËÛįwZkOĶ•Ŧ""""RîÁÃąÖŽ>Ü5(x0ĄūĮ…‘gĄųĄbŦå‚8Ļ1ā—ĀëJX^Üd­=KDDDDDĘ5xŦž< |–\mAiF1íŨŲgũÂĀs˙AļĐkē¤ÔĮø>šžĨVüÆZû<)ĮāąøPG‘&ã́;Á ›Û0ģ:Ÿ-ôšNąÖV•pĪ> ŧfOņāWÖÚj)ˇāQ[ōāšZŽ–FYĩ{Ž/Ə+Ņ1>¸˛ N÷Fāķ """"2SácIYƒ˙ŪŽŅ_ëú5dz ŊÆŖJ´gßĘeHܡ[kOQ‘Rˆĩöäæņ˜ųāéŊß§˜č@ĪîB¯õČ닁”Ņé7äúõ(€ˆˆˆˆHŅ ÃŽĩö āĀwÉÍQ3f[Øū=đ÷_nn¸Đ+^V‚ûd^ įå¯)ZøHä ÃˇgPĖy<ĻjĪĐqŨÄY¨õˇ…^ķ†"틁Ęô’ø×ųö0ē ˆˆˆˆ”$x8ĀéĀׁcËŽÖū$<ņ~°}“Ø™4œ~„+ ĩvclŽģ(ã°Ėŗž|T""""Rüđ‘>EŽÖã¸˛ ;āŠ+&>Œ ™îBnKnVôB÷0đ™2PâšP@DDDDævøx>šĖ˙ ¨(ŗ­Ë×||üĻŠ!ŊdĄ7ĻļÁÃZ{2đrķ~”ģ—ΧĪDHˇ‘ĸx9šNæu厁ėģ v}‚ŠÖf8āĨ ŊE‘Ã<Ū.^ ŧ8—ōrw"ķĒ#ēˆˆˆˆHáÃG¸ø+ģ LwÁŗ×BëOoš;ézĢĖޝCŽŲVˆÜZ§äƒĮ…ĀÂYXÆ­ˇÖVcēįÃįCDDDD¤°áŖ ø påÖ×ÃúĐŧvũdž.U^˜ŦSŦĩõŧ' l"7“øZrųŽŌ”kƒkŒ[đÕ-@DDDDdJáãā{ų‚qmXŊ{aįO ûf°™Ã] ¸‘Boå7r ž0õ¸Ė`?æū_\‡÷đƒ$>ø܆†B.ērž|N@DDDD?xā•Āw(§ū6€ūØs´ūl}=Æ€/ôֆËũ<û-Ídūįķ “}ōb܆sæÕū+€ˆˆˆˆ”Oøxđm ^@3ėŊ Zރ ŊđˏTÎģsžķČ7Ŋ˛Š”.~‘ ߥ:›ÛzaßíĐök:Šŗ…hÍü:×ũũdú0ųž/™´> """"%¯'Wķ1ŗáÃúĐņ ėģ :˙ëĢû{0ķk:šÔ­7cۚöÛzž> """"ķ,|øYhöŪŊ›,ÅŪÃâsæÕųö[ZHķĢCB—q4ŸˇˆˆˆˆČ| ~Z=?…äÃÕj* Ôl˜Géç˙›_‡T˙Đß;Ž> """"s<|>´> ?ƒäŊ`ŗĨ? —ĪĢčÉßŨ€û‡ú~ ”ĸ@@DDDDæjø°ēwÁŽkĄįÖ×x ŗęĸysÎĶ÷ūôW?72|Ļ"Ą…ˆˆˆˆČ ™^hŧölĪĖˆÅī€ŠÅķᄓŲō0ÉOük.üōēI<€ØųōyR)Įđah{žũ*¤Ÿœųōix5ŦŊt^„ô=w“üôNŽynLŦāSž$įËgJDDDDdüđņĀˇJ>˛IØõ+hēlĖ7aBp䕞Û͎l&Cōú_“ųö×§Mā$* Ŋú6ųûŌ† Ũ°ũK|ˆōh•ãĀęCŨÆšœ4ņöī#ųŨoãßq3Áøī÷˛8Õ5>ņėS™ĮŦĩ'W—,|Øü v~üÖ29 –žVœ;gĪsĐßOúÎ?“ųö7°-“ü#SUUČÍxÜ(€ˆˆˆˆĖßđQ \T•d…~žų 4(—ļXōNX÷Ēš7ëšĩØtŠôƒ’ūņ÷ąÛŸ¸Öcp,[ģ r‹öÎ§Ī—ˆˆˆˆČĐđ~ Ŧ/É SđÔ× įĘf $†eīƒ5—Ė­ ÷‚€ Ģ“Ėũ÷‘ųÍ/ ž~üŠ>÷´3 ŊeŨ """"ķׁ•dMŊ{á‰OBú‰ōŲ{“€ĩWž3æFÍG$“xOo'{˙}x7˙ÛŅ–kō6ŨôæŖ Ŋ•ģ@DDDDæ!kíĀ?•deíOÁļO€ˇ§|@ä(8ō_Ąv`fmā°žß܄ˇk'ūãáũųvl[3d 0ĸ˜īãŽ\Uč­~b>}Î@DDDDrác9đ# \ä5A̓đĖ' č,7!¨ŊŽx#DkJyĐ“ąZŸ Ÿ…Üځ÷[kąÉ$Ag'A[+~sūÎgņˇ<„Ũˇúz!đ ģÍ^gņĸB‰‡@DDDDæWøp€Ÿ ‹>öß;Ž[&ķΚ‹`ÍaÉiā„ u@~Ŧīcûû z{ą}}Øū>l?6™ÄĻRØLŌiŦįå:‚¸Î@ø0á0„"r!›Åöõb{z°ģwAG6“tĒ05eĩÕqëzą;@DDDDæ—wg=|ėų3ėútyL.Hj.‚ o„ŠÃøFßZl`“IüÖV‚–füĻØũûwėØŠ$6›? ž—ëøĖÎQgC^RčEļcÔ ]DDDdžČ7ŊēĒ$ácį')‹avŨ%°úŊ°ôā„§z°ž×Ԃרˆ÷ė3x?FđÄVH%1éÆĪŽh95'>áãO(ôRīšoŸ9™ĪáÃ~ ąãƒ…Ŋ…]˙9ķáÃDĄî•°îĩ¯gŌ̓ŋ¯ĖŽ]dļo#{ĪŨO>É^Œį „Ü˙0š.s.ƒxĄuk ŊÔŋ)€ˆˆˆˆĖīÎ*ęöß ;¯›™Éä‘#aíģĄáX0“˜Û#R)’Ûļ‘~ā~Ō7ũ:Û!Â`0æP˙đáaÃf&Đ%¯ÁITzą(€ˆˆˆˆĖÖÚEģéUÛđė'g6|˜ Xü&Xũ2ˆL<ąģõ…Īq1–|ˇˆˆˆˆČüđ ŠŲôĒklû8=3´{$^kßĩˇS†ĩøÉ~úŨJßoo {Ī]ĐߟĢå —(Ŧŧˆ\ĖQķaÁ J#s*Œ„#DŽÚ\čĨn5ÆtΡžˆˆˆˆĖ;ÖڕĀÅE[Aēžú$øM3ŗƒĻ–ŋV^ĄčøÁŖ¯îģîĸį‡×ė|ãevã=Hä˙1jŗĢA˙^32ëķĮeoÅTTząˇĖĮΟˆˆˆˆĖG_ĸEY˛ŸĮ?™§g"y@üØđ^¨]?néßīī§ûîģéēæ‚íÛ0arÁaHƒacŦ02xYĸįž_Œ%˙VDDDDdŽË×~\X¤ĨÃļĢĄīÎČXü6XķrũMŊõ}z}”ö˙ũ_ŧGļ`‚āP3ĢáÁa}<ū}°fdX3ŦšĀ=īĨ„VŽ*ôbہŋ(€ˆˆˆˆĖ}ÅĢũØûWhûQé÷ČYë˙Ÿ Æ+y‘Üģ—Öũ˜Ô ×CúPĮrŒÖĮã`¸°˜AAbā=ų*Ą¯NŽŠÖŦãûÄ^ûúb,ųgƘ`>~@DDDdŪ°ÖŽĸXĩ={`×įJŋS‘ͰųJ¨Z>ö~{íwÜAÛŋMMƒúxŒ†’é ĩ;†ãuĪ<đ‘›Š@æëįPDDDD擯PŒÚ O}‚hTq&<īŖĢķ-éæföëÛ¤Žŋãyšđ1Á°šŖv(gė02g‡ã boz+E˜Ö}Ū6ŋR‘yÃZģ”bÕ~ėø%¤(m‘ģęŰų_ R9ÖĶũøãė˙ä§ž~cÆ6whZYĶ‘oh5jÍČÃņÚŲ=+zč˙á#6cŅ×Î׿W """2Ÿ|ŠbÔ~tí„Ļī”ļˆ]÷jØôvÅGĪA@ķ-ˇĐöéĢ §cĀZ‹ôMūĐPÃÉDCíĒŠVYŠÆˆŋū˛b-ũēųüAT‘9ĪZk(Fí‡ ā鯁M•ngę_GžÜȨ/Ų,{Žžšîo~ãû&7GĮDÁĄ MĩfĪCė_ūˇĄĄKßĪRđ%z)Øķ͆ņk>ŧž>žũĖgIũöƑķzL*8ØQ‡ã­—Į´†ãĩÅčË]•5TŧįŸ1áp1–Ū üß|˙0:ē‰ˆˆČ\–o~uNÁüÜīĀÛSšXđÚņk>R)v|öŋHŪđû|‰˙ā˙Ž8 ~qø{ė°ØQŪ4ęߨaÃøSļŒ!~åU¸ ‹Šĩ†Īcē@DDDDæļõēų•—„ĻŸP’ڏę áȡŽŨį#“aĮžHō×ŋ+hp°Ŗ¤ˆáËķoė, @ä]$zâIÅZ|'đu}@DDDdޞÖkí:ā[_øî›Āo*ūNÄOÍī‡Plô} v~ķ[ô]û˂‡‘‹ąÃŽīx¯~ŗÆĨœ„^ņZ⯸´˜ĢPíĮÁc­C """ŗ9däË3!` °¨Čŋ|đ^ Ļ°+  åˇŊö#ŧŽū'ÆÚyöūú7tũßÕŖUęgq¨ÆČå…Žw:Ŗj•÷œ—xÛ;1ĄĸUûĄ""""ŗ4p8@XœlNŽË gP7\”ânÛãyp‹ˇŖ&›?ąÚ1ßŌúĀ4}âŗkG E spVt÷ŒķH|đ#˜xŧ˜Ģųœj?@DDDdv…Žā%ĀųĀiĀų€á–|ƒößTäÕ:°á3PŊjĖw$››iüOį9Ķx1‚ÃhŖj uĖYŅĮ¨‘™ņđņááTUs5Ī_Õ'YDDDDfGđXŧx=°`ÆË.6€žûŠģŽĨī†Å'ųrāyė¸ęsø;sMŠ…ŒÉĪŅ1´ÖĐŋÖ kœxQˆĻZ3>ūî$ūéŊ8••Å^Õی1ũúD+€ˆˆˆHų÷oĒ(—/Íģv‚ßXŧ"TŰö•ãN˜ŅøË_‘ŧņ6Œ“+ų˜ƒc äįõ˜ ĖXS­™â¸„_û&*ūņ ˜XŦØkûŊ1æV}Ē@DDD¤|ÃĮrāŖe<j}ĸ5ŋ218ę_Æn g×s4áËeôZŠ1B3×TĢlfEÆˆūķG‰ŋøp‹Ūz¯øg}Ē@DDD¤<ƒG8:ŽRŽ}–ûvoĶÖ\cO€gƒ€gŋøßØî~ŒŖ–bœā0ŧšÔøÁaôYŅsīZį2ô=#kdFĻ™Š 1kŽ ūĄ9jsŠĻd˙°1f‡>Ũ """RÃáЈUˀ¯/e&:•OEļĨ8Ë­8–9î[öŨūGRø&äŒ:w Đ?ÎpŧŖœƒ!}Hf¤ŠVIJģaB/{5—Ŋ§ļŽTWƟ€oꓮ""""38Âäæį8™Üšë94gĮI@ÃŦدŊ 5pÄûĀģX–íëcß\gĘŋ‡’É…‚R5Õ*6ŗjąwŧ—čɧ@¨dÅŪũĀŒ1ž>ų """RēĀųĀq:pf>hTåËfVîX1f?_đ¨^=î[v˙ō×øÛwëLŠã÷h¤°MĩF 8 Į›ß€ĸ†‘x‚đ?\Fü’WāÔՕō €ËŒ1{tP‘â‡D¯.˜õcDņ˛“Âļ3°ö5ãž#ŨÕEë7¯Æ:Δįč˜ÎpŧÃß=zËķzLĻV†"6ÊÆp_xąWŋ†đÚĩ`œR_!Ÿ6ÆÜŽģˆˆˆˆ7x8äf&˙GāÕĀQä& œc;€Í‚)`Yđ¨ŋõŲîŸ˙+Æ5‡ ũ#šTėú=­áxgĸŠVA‚G÷ôŗ‰^új›ŽÂ„f¤ˆû#āSē#(€ˆˆˆHqÃĮ rÃæžr6ˇ ûŒ7žĶt–Ģ/÷Ųž~Z¯ų 8fĖāPˆ9:Æ\î8ÁaúMĩ AU„Î:ŸČK/žÉāp-đVõûP‘â(đfā?É ›;'ųA@w_Š]ÍílßßÎĢ S°F=ąįCbé¸oŲķ‡?ėn„œĸÍŅ1ū{FŽwâYŅG™ ‘‰k\&Åu1 ēøRĸgMhųŠRĖé1Qøx“1&­;ƒˆˆˆˆ>x8Ā ˙Nœ9¸ô$ĶlŨĩŸ[ŸØÍīöļŗßķŲX^~Jˆ˜ ŗĸUoˇômƒ€æë~ Ž3jpYxgVô1:†ÜߊĮË‡ÚŗŠÖd•ã@e5îÉgyáy„='Q™ûũĖRøP‘"ĖŖäÚ¸ŋˆÍÅāŅŲ—äO[ŸåíāĄd–k‡ŧ§?ˆsú °ļj7ŒûŽ–-Ŋ{ &ĢË÷’|Áįč(aS­ŅKŠaHTážō§ŸIø¨Ŗp6Ėtm‡Â‡ˆˆˆˆ”¨pžø.p.s°ŸG*“åÖ-OķÅ{ˇķˇT–ĖīKz ~؟Šãš]œ¸ž’ëXK*ãŅŪĶG{O’Öž~ÜĢH(uĨЇÃnĒå: aC.Ž1šD‘ī—bŒ ȝ;ČũX›ī73üZ6¯1˜pB‡1õ „^p6ĄŖ!´n=n]]9…Žƒ~ĻđĄ""""Å ¸¸šŲ2YāÔö§÷ļđųÛæÚÖnúėČ÷ac¨‹‡É؅@ķa¯÷Īí›y˙–xÃōŗ¸ŽĮš:¸ĢĨ›./ ;úûøá,HŅfEíoœDSĮ]¸ČQ›-]JhÁÜD§ĸˇĸ'Å'×ôÉÉÍIbëyšyÖķ°^˛^ūßYđ<đũ܆d2&ÅD"˜H§Ē S]SY…S] ‡ËĄyÕx>ŠđĄ""""Å —_âsm˙‚ĀrÛ#OķĄ;ckväāEŽ1,Ēŗ°2Fe<Œë8lgkyœÃ­zĒk ÍAĀÛ ąmÄëÛņ$!Œč#2bŽst í2ŪĻ2ģd ņsÎĸbķfb˗Yŧ˜PUÆq˜\í†1…ē FOwE?Ņ~n>ÂŽ+Ļ;„ˆˆˆˆ>|ŧ->"ŗnû‡x‡Oļįųßžíūuënē‡Œ]cXQĄĄē‚X$4¤Üú§Ėj.ˆî0˛–ģ“ãįšãvn;% 8šŸī8˜uëXøēWS}Ė1TŦ^æGąSĘ ›îĮßĩ…`×=8ˎ'tė‹T†V…ÂK2“e_[-]Ŋ´õ&Éx>? ė:$ĸaęq×V˛ Ē‚oũņ!Žxj߈žõą0ĢëTÄÂŖ–ãīÍŦ$mŖDMö°ļ÷AŖūŪ1Páy,lÜ9"PNß ŗkéî:ōūļ|5Zą‰ķW­Á‰ĮæN‡k!đ ē[ņŸŊŸ`ë°ũ‚M÷(l) ËfKøČd={îwnkäúÍėÍx´–ÎQšûÔCƒkH8[3ېđáXWgņ‚DŽ“õ|Úü5, ==ũmļaÖ/]DwW’į:û‰†jŖaĒcaBa—n{œ°į tŧŊæcP'îq†ã ÜO¯ŨĖī6ĀíõK8¯éxí_Ÿâœ‡wōĄĶ7qÎŅ눆g[‘ĐB`Áæ‡mÛCĐ´`÷ØîGÁk5ĢsVœ¨Oĩˆˆˆˆ”eąÎÚ́˙-÷đáų÷n{Ž˙Ŋį)ūÔŲĮžĀNø7mÖŌæY`č<Ž1l^\Em":ŠuߓŪÄ߇ļ3ŨfXmA5Žã°¸Ž‚…5ņÜ`RæPĪã÷íÆ:‹ĩfX&5šŽĄuízn=įÅüŽz ģúŗdüCûníKsßíōĻ'wķîsŽcÃ˛…#šĒå/ŠüBíĄfmÅ~>ä únã¸‡ŌÉwQ7ƒū{Ęd0°~›Mc{;°Ũ-{°­;°M÷b“;Áī›c}8Ģ+ø%¨ģ…ˆˆˆˆ~ø¨~T”ķv6utķå[ä;ģÛhĩö°–r G/ŠĻ*>ųŧõŊäq\’¸‘0ū´ÖŲk+ŠâŽ3˛“yÍ3Ό L~rŋ eËsũ §Ō‰Đ,đēû2ėéę§;ãsđ¨uYËWövđģŸŨÉįN?’‹O<’¨kú{ą-íÍØî6lW A×čkÃöĩ`“Í€—̉1ųĨ9¸L´âõ˜ĘLe=TÔ`*āT.ĀTÕa՘hEn¨^rCõâe!›ÁzH÷aĶŊęÁöļa{`ģžÅö>‘ Aoîg’ĖŌËqę–ú2ėÕCDDDD/|¸ä&V[^ļÛ<˛c/žénë}ĻĮ€‹!â:„ŨÜxQ~`ņ‚/;đ%ž1pĖ’j*ãSĢėéļ1öf7ą&üø´ö#6[ų`ŽīSĩķšiOî×Û°ˆë_ķYēœ _a€ë° :FmU”ŽŪ4ĪuôĶ“õq°Ŧŗ›Ķ­ôÜō8ÛȲ>ۂm ‚~đ{€ėĄ•ŦČ06?‡Į æ_×tæ^čEo'‚q+ĀÎ˙>_ŗbr• Ø ÂāXߔ+Ql†ĐÉo,Æåx@w 9<.,į ŧÛnŪtã<éŦyˆģËĒĸTÆ#ÄŖ!BÎȒjÆ He|R™,-}–VĮ§>ēĻ˙üGÍcL§YQ„,cÕÛŦm܍ëyvĒ<ša#C‡ã=pĖąüø˙Då˜ëvŒĄŽ*ÆĸŠË4rqķS•ÚÂJī.IL—%0ƒZL™A[jÆH…¯™AŋūZ. Ŋˇú> IDATāgĩ€*˙0(܌šŽÉ1õ¯Ā]ąšĐ—bĢ1ĻGˇ ™&kíZā?(ãÎŨš7Üx?ÛŧĄũ7"Ža}]uÕņ͙†‹†]ĸa—šD„EĩŖ÷w˜¤Û3Ģy—ŋ‚wī”˙ļÆéÖ å#öīÅēÎĄ2;Cûx Ÿ„đā{vq×ŧäbzŖã÷cŠ |^Øą“íũ ë:ˇâ}šYɇ‡ŋųû‰Âˆ×ke•É.sŧí÷ÂÎzÁ?cBÃ;t×P‘éXü¨,õúk KÆķčOeHgõë šš°PĐ“LķÁQÂG},ÄúEÕDÃî”×msN \×īŽēfĘš­Îé%D0ęß­loËU?Œ2Ôîha ė:ķlžwÁËH…Ãc‡/𒎝\˛ã,í} ĮfÆnŌ4Šp1(T0hæõ)…‹ƒc Ođw͝—îīu÷P‘Їjā߀ĒJĩ^?čėM˛}_ íiåŅ<ØŪG2† +ä!cˆ9†ˆ1ü)9´ĪĮĘĒ(+VMXëQL×Ĩ6ōęŠÕ,rwO­`Œe‘“¤-Ų×?ÖŌ2˛P?F§s 4žöžÁÅc†lėoãmĪÜÄĻ–{plvœā‘fôyE&.&h†5ŠešŅļĻß Ë'tÖûÁq‹qęoÔDDDDD&< p:p5°‘5ģōü€§›øÃÖgųÅÎfžÉø´OsĢ%V5T;_GĄ9&×#r …ã؀īe.á#ņ¯įFƒšBY?Ją@¸Ŋ}ԒúhÃņļ­ßĀ/ŧ„dxô~,ađúũqņļŸĪv ƒ‚„!aö5ÃrÖwųÆb\ 1Æ4éNĸ""""“ aā­Āį(Q­G`-Û›¸úŽĮšn_'ģƒā°–uÖ.*MøšĩU1TWP‘ˆ‹…qgH‡đ.ģ‚]­O˛ļ˙ö)-{“ÛÅ}^ũȂs25j™|xÍG瞊ŸžîtĮbŖ.?dųđ“ŋį¤ŊˇbŦwM­Æ{­ͰĻŅņw1á3./VļūŽî$ """2šđQ|xšNEחJķŗģãĒGvąÃ?xF)hZFÔ+ĩ¨Šëm›ŠX˜e‹jXP— rĮī/bāįõ˙ĀûR Ú'ŊžįEˇā§×ãÛC;xFōQ:ÛüFŪø†ˇ°ˇĒzÔe/đ’|rËĩŦkŊwDP˜8\”I3Ŧén–Đš_ÆT-(ÆåŅ üRw™8|ÔN.Õ:w7wđŋ˙?níuĘhĮ@Ôq¨‰†¨‰†‡ŒqpœƒÃĘZßâŊŽtn)Ķ6w2ĸ‘k–ÕQ__‰;…‘“ēÜjnĢ;ĩüפŋs?1ō(1s Y;´( ęË1´ā}¨Ö֋/á5kG]n—âŗ\Êö‡GöõČ:3šaMu;ōopÖ€ĐÆĶ‹u‰\eŒéĶEDDDD&ˇĮ•jO56ņO×˙mDĮq€°cX^Ŗļ2B"ƘĄs\ŒĻ>_b ,Ŗå•1°´žŠU+ę ‡§W9ôˇÄąŲGôŨ<Š÷GŒĮĄū," S EpĒ+Æ(ZįĘäéUËxæ ¯b“Âķ|ú“Yú’ú’B^–O>t-+ÚŲfkŌ!aPP˜eͰLíų„_ø0EŠ!ëūOw)ŗđą}O3oúõŨܛZīáÃęę( ĩšfMS ų‚Ž[„đá8†#V/¤Ąžú°ÂÅđĶ…¯ãŊ™ŨÔeŸœÔßŧlŅv’‹N&|°I™1¸Õ‰… Ŧåņ+>LŧĄŽøĀ9ÎŊ)đ^vÃÕŦožwŒ)Ô§×,jheÄĄŅŠĘŽVdá ?ƒ‰%ŠuiV“đs§C ""ĸđQM=ŧã7÷Œ‰°ËąKkXž°ŠČÃG1…]‡c6.eŅÂę‚ÔŦ¤M„Ÿ,z'i§vRī?!{+uN:_ drEöXtÔ˛9zŸ˙<š7nÎLnN““ŸŲÂ1[~1ôíč…ũĄƒ™‰˙fÂ×ĖūnPŖ˛qļqüå ;Yn=ዾ…ŗ`yą.Õ~˜j@DDDæ^ø¨n-eøČx>ûÍ]üyXŗĢúX˜KĒ‹ÚqrÃäFÂ.U‰(•ņáđĄÎãžī͟ĘMvؗĖāûĮ1ģiŅ‚nĮžđ"Žoø—6_EČĻĮ}¯k=Nę{Œ;ĒN9tîĸ‘ąN*;ŪųæQÛ U${9놯ä†̟DQ›a ũÕ´›aMēveđAl |á÷q—m,æåĨÚ'|„€ë€į—rŊ?žs ßkZFĢ‹†8riMQ' tCCm‚Å UTUÆjF)ŋš™×{ûR„C!**ŠĶĄũ‘øFjëß͋[ŋ‚Á÷Ŋgw|—{Į‘rĸų2z Ę,]DķF}íÂÛNŦ{ž:dŧ‚{™7Úę¨\áՄ_ú-ܕGķŌVíG1>ˇ:"""sĘŋ”r…ûÛēøĪ-ģ†ü.ę:l*rø¨ĒˆrÜĻeąn5Õ8Ž3搚›*šŽCMuEŅÂĮAwVžÄ_ëŪ† ¨ ē9Ģ÷žCåíQˆĩ°ķũī2BÖA MŦģ÷ē‘Ĩ÷ š1ÍöfXĻæ\"—ū¸Øáā3ĒũP‘ąĘeÖŽ>Ĩëfa¯Ũū;Íķa€Í‹‹ÛėjYCĮlZFe"6ū<3u.0Ü\s6÷Õ^†ÍŸ‹!Āø9øû3;žCƒ×•{Old0 ĸöė¨ë9˙Ī×ãūĀš8ôfʅú‘¯™iũŨí°fjë›h]ÄpÖŋ›Čß§aUąOãNāēŗžš`‰ˆˆĖūā#7Áā•@ĸ”ënéėáģmCÃAe”Ęx¸hë\簊uĢĘ2xäĪA`IĨŗüОAkB™ÖĨßæĻt€¸ņip˛ÄNVī˙#{_Œ‹a]3(ĐíyĪåd++FĖ#™$ËūíȒY5ÃëĩiLŽ]MčėOÚ|¸E/ÂZārcLRw<>M‰f8ėGŨJsp¨ i ŦŦ¯,ÚúņkW-,Ģđa-xžOooŠžŪ]ŊiúúĶøÖb-lĩGŒųĨ>€ƒ%ÜcņšŲŊ|GŧíŦÚšƒe[%ŪÚNã9gŽZ+pę#wĘĻōķ^ØŠÍŸqX“2aI˙Ũdļ#T‹ģém„N}NŨ"JTÁ÷uc˟u—Q‘2 đũ€›wˇųũōĘáĐԚ^9ųɍ18ųŽÔA`ąÖæZää ōØ´~1Ž[-Č}? ŗĢŸöÎ>ÚēúņüÜ6OU€!Ā˛ŋdžũGÖāl<’ø‹.`us3ƄŠņƒ!ûm#ˇü…á­é-ƒĀ^Ë0­pQˆŅ°Æ™E}ŧå…ępÖžŠĐIģ|#8%;ī;éNŖ"sįĄiōʉžžpĀR úāŊhö}@Ö荊Č<ē‡:ĀųĀ‘bˇäÁÃķöļvōØî&llᖾĄCÍ.Ģ̘pÆ"!‡úÚ Ē1âņ‘pˆpØōxđ|ŸLÆ'Î’Jeņ­Ĩ"™és@2C[{/ÚzČd}Ļ‘9&%ÆĄ/äđIJå˜ÍÄ"!–/ĒĻaaĄKČËRģëŪi†‹2n†e"]†ģép>wŲzpJz™[āÍÆ˜^Ũq@¤$,™ $Ô›ōBgךKáG^ķ€āGĀׁF™ƒ÷û˙Cî˙’>ģkyĒą‰¯ũųQ~ŲÜMË8å ,Ŗ¤"fŨʅÔÖċÖãā\c%3Íeöõ§Øŗ¯“ļŽ~‚ Ē;Į‹„¨ĒˆRU% ‰„‡\\×Á8Į€X? ëåjy2™,=}i:{Sd2ū¸ë1ĀĒlēīŦizCûĨÉüM.TX'„uÃXĮÍÕ" č0šÆ_Æúųeš˙įĐrũ؁@5äwfĐ9Ęī͐×,1X„ëq—Ÿ‰ģüXœ›p—oȇw&?nO§jØ]…„ŅCÂÆüĪdB‚|HˆÎ`H(Éáē€ī_0Æ4é ‘9đ‰Wīb†j=~q÷VŽ|`ĪøÁ¸c '­Ŧ#Y€\ŧ ’uĢ… ÷xą€ ,Éd†d*C2™!ãųd˛>A` ŦÍOJa×%rˆFBD"a"Ņąp.Œ†˛Y=û;ŲßŌ?N‡ëjĢbÔ×&¨ĒŠ‹†ú´Lņ|c-ô'Ķtu'inëĨ/•ŗv$n}.ö~^ôØĪ I0?VËūõ'ŅēņTZĢĒčLT’ŠDɄcųÂŊx8OØËÍωe3Äŧ,1Ī#æÄŌŊÍ{ąmû‰gģ‰ųŊ$ŧ^~’°ÍæB ^.Ę 7ģ9øc°Æ`Mœ(íÄiqjčvČ8 4: 1yäą\ūâ3¨JT@y (ĐœiŒyJwŲ8âĀąĀ ƒBB•BBIƒČāŖĀOŒ1ž‰HŅŋD €ĀcuÔ züŋΞ‰įu_*ÍUŋŊ‡/íj!=ZĄÛjĸ!âa"‘ĄC"_đlyC5kV-Ä)Є„ÖZzúŌ´ĩ÷ŌÖŲO:ë Ü'UđÉ7‰rCE,L]UŒĘĘ8UU1Bų@b­ĨĩŊ—m¤ŗŖ?Fc¨Ŧˆ°¸ž’ēÚ‘H¨ā5;ÖZē{RhîŗŲ—Ár^Û.⤗­åæ—ŋĻēüÃĒIČÕÎl}ŧ‘t*K‹ksíöcA†„×Oĩ—"dˆZ?˙ RŽCˇĻ)\IK¤†Ö44vôs åĶ›‹*Ū´°ŠO]|*+ęfúãÖ \dŒš[wŲô'o.“ûœ*$ĖøđĪÆ˜~‘!÷Ŧƒ_d„ČÕĸ.j=Āķ€u@ãķnķīŊäš/<—$äŒč&×TŌW@™Ō9Zü8f&ÖߗĘđŅ_ÜÁ˙îīdxŊGØ1ŦŽŽQ[#vĮ-t7Ô%ظnqA‡ĩ–ŽŽ~tŌŨ—Îĩ2ä;¯įj86˛ōƒĪHg<|?ĀZÆoŌd Ž“ëŸR_— ŖĢŸĻöŪQCãÖV°¤Ą†ęĒŌL‚x°˙IãūÚēúGŨŽĩÉNVŗęj ļŪŪž[žÜ7­‘Ŋ†ogoší}$ŊĄWÔɑßzŲ)ŋnųL†—cūĒ;Čly@„ōßL}8ˆč¨”×)!7šÅ™Į÷*7ڜLŽÆu%šQ”V3rĐ “'S}FøųŸƒŸ??HķårƒHdTCYžáÃķ}ŽøÅ|~wëoĢ š W,H Oüíz$ėrâŅĢ Ōė*™Ę˛ĢąÎž$ՕQęĒâ$*cÄcaBŽ;f kÁķüfZ]=)ēzSdŊ`Ė@b î4$xÃâ•,[ZK<ž‘yHŦĩ´ĩ÷ōėž6Ō™‘Ÿ°ë°ųˆ%TWÅ ļÎ]­4č*Ȳ˛~ĀžÖ^ööχ\[ĢÏ˙îDÎxŪÚRL;‹1wéÎŖ2&˙˙/āBÆīŗ!3¯x?đScŒWŸaėq@u–—"ßŖĸä•85˙EÉ)ų°f†nP˛ųPrp'p/đ4V ɡ \ķ§‡¸üCj>GÖWR_ŸtsũãŽ\Fuuü0´ĩ÷°į@WŽŠS]‚ča4uĘ5Õ˛ôöĨéîIŌŌŪG*;AíÔ×T°ri‰DŦ,ē+d˛ģvˇŅÜŪ;ĸËŊë6oXBmMEAÖ–ˇî&•ņ vĩw'ŲÖڇ?č¸/r מøxÎ=vƒÂ‡ˆŒrķr€ˇįÃGŽČŦáŋ!×$kīa —ĄÃŽųĒ!7)ؚQ^;¨h&÷-đ0Đŧúāīp¨ÉŠBŠLåúŒ‘ëö2ārM¨f2pL6ė~›˙yHÍĮ02Ķáā™}-œûĶ;Ø3¨¯cāy‹ĢŠÍ…;5•1ŽŲ´ė°j ŦĩėoęÂu ë̊2ų ĩ–žū4­mŊ4ĩ÷’Öß#vYˇ˛žú•e5ķúÁmojéæŲÆvü`čŖÂq Įąô°āA-m=<õlsAˇŋ7™áņĻ2ƒ7Xh ŋy鉜ąymą_pÂ‡Čly8D€¯äˆúwĖN{7š¨pŸobʉ“‹Ŗō?ņQއÉÍĄš/‚<iÉĩīÍʞ!×|å~ iŒÉęĘ(Ą¸¸xKžđĨģ“Î_÷××OĪ—fZųđņ§ü}fFåōīßÄÚz‡žˇ¨ŠēĒŠ ĀuÂæ$‡wļwöQY%)ū”eđ=Ÿļö^ö6u‘L{,]XÅĘå ‡Ũ˛žvz{S<šŖiD …ë8wÔ2…š<´u7}ŠÂ>‚’'÷wŅ7¨_Č"c¸ũUgpôšĨÅüø[>ôŠ ũŧ›÷¯&7ÚŪ\ųbÄæ?§?ž<>—kË!|<đt#'˙æoC~ˇĻ&ÆĘ…USZN8ėręqĢĢÆĀ÷ƒü|ĨÜAŽV¤˛Lš[MF*åɧĐ›Ė ų}$ėrüQËBÜᜓæ–nļíj)üˇYŸĮöuŌ?(„–p™æ’›€[€n…‘yu?Ú\‘ņ9žËIr#Ú]iŒiƒįŗ:˙ÅÂą3ŧŧũˇđ–îC…W×pŌĒzÜ)Ž`ĩ~e=˖ÔęÃZbYĪįŠ§ĐŲ›C.ąhˆx4LeÉü탖>|Ų“œįõ߁˙œŠgq`-YĪg_[xäŪũčî×\c8mMũ„?×1TWæį‰F…jĒãe×a{>ņŧ\éîŸÜ#ĀCČ1,ŦKĐP_IUe|Ō~k-žĐŨ¤ĨŊ—ļŽäˆņSJ=)ļ6÷ ųŨ/=‘ŗžˇŽØ‡­x‘1æA]A åō€ø-pąŽ„Ė">°…ÜHm×ĢãúŦŋ5äĪåehČoČ Ôp7đ.r}Cė,=¯Č}šUQâõԗưmO3÷=ģŸß>{€Š,ĪųÉAGrUMœÕ +Į\N$ä˛bq5õõU‡5,ŽG&ëąĢą ?ČĪČd|<ßĮZ°Œ>sŧ1†Ēx„KkŠĢMLŠæÁZK:íŅÜŌÍž–n˛ūô‚ČžÖ^vvú~áȐÃ_.ŋ€†šĘbļŊĀŠ‡3RĻ(€ęFũ@íeļōČ5īx¯1æ9ŽYw˙1Āށ:"#ôī~RĒų} xnÔp¸]?°ėiéā¯Ûvķũ­ĪņX*ˁ`ėėvōĘ:bŖŒ>eŒaéÂJV._@$ŌUXŪ×Ų°įfˆO§˛¤Ō؁‰{ú3øō~c :c͊TUƧÜ?ņØw ƒ}-=âvÓ{;hKúXbŨb>~éYĨ”ā6rÃķj|ũđŪœ¯#!ŗ\p9p“nĒŗæŪã’ëëqš‘ŲdtYrÃõ~ĀĶ3‹Îī䚒ũXËÎũm\˙ĐĶ\ũô~žô|&ē ā´5õ„†ÍģáÃÆ5 ,Ŧ¯Bs'¤x~@wO’ÎÎ~Z:ú†Ô\8ư´ĄŠUË šS\6ôöĨØņ\ =ũ™)ũm*ëķ`cĮ "ēôtžŋž$ßÅŧÃķ-] 3õĄ<žÜ„X"sA ø*đéŲTP›Įáã߀˙ 7Lė!rķû”}“,kíjr_ŊéU[w×ß˙_zô9žđÆ$Īp1ÄC‰ˆK<ä°ŧ~čÄ{Ž1lŪ°˜ēZu‡œģ÷Čzí}46u‘Jg&ŠJÄÂl\ģˆĘĘŠâûûÚŲĶÔ=Ĩ‘ŗš:úŲŪŪ7đī—WĮšîōŋ+EÍ[pŒ1f§Ž ™xH|\õžČœšŦ'7cîĶá(ëđq%å;{yšęÍßŗ\ŽM˛ōs ũ8§˜ëņƒ€ûˇīæãˇ?Ɵú͌=q×aQ"JU,L<"ė:ĀčÃ˛nZÛ@ÃÂj]eķ„ī´ļ÷°{_į‡ޓĢk¨Ÿú|hīčeûÎŧIö ąļîi§+sč ží%ĮsūqG”â\oŒy…ސ™xP´ t$dę>|oļĩŸáãßÉÕ|(|LOø!đ¯Æ˜–2<Į›ČTW´ÁR™,ßųãC|úņFšFi{īÃ⊠U1*ãáIu2^T—`ãú%jv5?PYŸ=ûÚŲÛԃĩcāČ5‹hX8ŊQĀ{ûR<ųĖČYÛĮԓ˞e_įĀŋģüõ‘ˆ•dÔü͌1÷ę*P)åCâh`ĢŽÄ4ßđ˙ÎWš˙}`-Ū°ßZ˛˙dō˙íÃæDWOĀBņkwŠI–ÂĮ´‡Üœ!7—K“Ŧ|íĮĀ™ÅZGkwŸŧánžšŋ“áCß9ưĸ2ĘĸÚ8ąphŌaÂ1†SŽ[M8ŦËrū۟ Ģ쟧wĩĘxc8fãjǧ׊0™ĘōÄöũô§'7@ãĶû;9ĐčŊ7ŋč8^ōüĨØõ[Œ1č P)åƒâĀ÷įMHÔ&s2!!› ÃCBÖZ2A0đßéüī3ųŸ´ĩ¤ƒ€´ĩô[K`-6ŋ;h6ŋMvØö昀ܰš—cšgđ<rĩg+|ŧëē;øu÷Đš]cØPWÁÂÚø´†/5ĀÉĮŽ"ĒYÍ%/›õŲēm}É ‹ë+Ų¸nņ´—•LfxäŠ}dĮ á íû:iJz<Ŋ{i-o{ÁfŽ^ŗ×)ęŧŦĒQ)éÃâ§Āk ĩ<pķĸ ‡!!;(x㄄TūĩtūßũÖ åŠ-d į&œY[KHM˛ v™’›3ä=š3¤d÷¸BáŖ$öo'7ĩs]On’ŗ‚7\īKexËoågũC~u G-ŠĻ*>ũo‰Ã!‡S_ŖIeˆT:Ë#Oî%“õ9aķ ‰é_Ö==)ŨžošBē“Ų×5äw+Ã6-įMįG]eQ•S-ˆHɝ˙ãęč ē‚``uø'}ᎏD¸dÁęÃúvŽ€šÉÍōÍRÔ{ŒŪüOš…ƒ÷$†ŨĢüü—(ƒEgā!bČMRWÆ•4‡šd%K|ž˙36 Ęķ>ūË;øÜs­CžņÃŅËj‰Fŋ c ‹TrÄēE…ŋw{}ėwcôWĪŧYĒŖŗĮž>@}m‚ÍG,9Ŧe5ĩtŗ}×ÄcFlil§'3˛ļäUĩüŋWžÁŠ…ĩÅÚŨ_c.ÕYW)ÅÃĸ(HŖ×ĀZūmĪLJЙ!RUŽÃËkkŲ”Hčb.œđāJcLZ‡Ŗ(÷˜“€ŋR„oħzō­ĨËķčđ<ē<ß§Ë÷éõ}’ÖŌ$ķī~īr€ Į!á8,p]Ē]—ēPhā§:ƅRĖ^<é]îŪŒ1¸ŽĄ˛"Bu"J<!vЍˆļĪƒe™ŸâĸŪFö‡ĢØŠâYˇ‚Ŧqôlœeļī8ö>N;n5‘ÃŧNļ?ÛDS[ī¸īéOgéčIŗ§7EÆzĩœđũ8›U‹ęŠĩģKŒ1M:ë Å|XT]…Z^¯įņéũûsa !ĸÃ÷ÉXŨj ÷@ƒ$Tꕛ IDATœW[K´¸mAįÕGÜ|o5ÆėŌá(č=Ļ’ÜDt+gâ¤zAL†Ŋé4;ĶiveŗdōÃ/Đz\rũ*Œa]$ÂĘH„Ņ( Ņ(Ąō¨%éŪ ü¨ØCQ[kĪ"×˙Ŗ ģŊc_+į_{Ī šS!äŽ_^K|’…ÁpČĨĄŽ‚ēšÕU1\×)ZsĢ0–5^?Į¤;Y•í"lƒ|"4tģÃÕlWąĮ)ŒĖéŒĮũ>ĮQkQ__uXËōü€-7NĒSzÆ hęč㚞ƒ‹RWÅøÁëĪcAUQ&Ë|Ģ1æģ:ë Å|XlļjyŌižŌ܌3č€W9šÚ)œ•á0¯\°€Å‘Hé¯ 9<|€ÁÍVÜA…¯ƒMVĘüƒ¨&Y…ŊŋāËĀûJš^ĪZö§ĶlO&y¸ŋŸž|_ŗR CĨ1lŽÅ؏ŗ2›é/ <āāƘî"žķ[€r™~pŲÕ7ņĶŽž!ķc–TS3A{|$â–-ŽĻžŽ×u‹:ŋGÔZŽËvņ˙ŗ÷Ūa’]õ÷įÜPšĒģĢķŒ&*2IDa!ĸ0ā€_¯7ŧ¯7<ûîž^o`m?āuÄûėz×kcÖöÚ,‹ƒ1 Ë’@VJŖ™‘&wŽ•ë†sÎûGU‡ę03Ũ}ĢģĒú~•ĒģēæŪ{î=á÷=ŋđŊŽ:CVVYE-„@­ (˜FŦ4§Ŧ$gėUa [p†4ĐhÄŽ'J/ŋ2ŽÖškŽŪōąrų2ĪģäīĪ^š* —åü›}ŊüÆĮŪ‰e>ˇ|_ņB„¤‰‹Å›€Į‚:Ū+å223ŗęF' ƒ˜ĖIîôˆ¸ŧŋĢ‹SŠĻ…}¨úNqŪķ˜Ģ‡Ŧ¤$īû‹á*eĨ¨Ö‹¨e†žâõ•”t[]ĻIˇeŅcÛt™&qĶÄh­Xú*ĩ\…Ī„š![ž_nļĨ–´§5ĮK%)9īûÛN:փ ¤ ƒ÷vuq}2šĶ!ZĪ?GB˛´Ö0 ôyÜ{ž>Æûž÷\Ãg2qö÷§.+L&­3V‚13ʤ%/l|Q#,ANJ‚š—ÃԚ~åpȝ0ėöKØzélRœŗŌ‹tqÂNáą%2"€´ōy­—㨝fԈ´üÜöØ3§šųÆũ˜xģi8C&ąvč•m™\}¨ŸžîdSožæ°_æĮĘãdeĨ‘x‘d‘dĻesą˜/­^šˆSœGzŦ1ī*ZĒÂdΌQ0ĸ” ›yÃbΰÉ6eaÖŋˇēũōPXC×ŧ)å‘Q>ŊĘĨWšdd•~Y!ĸ$}ÁžŠT ‹Q+Å9;Éi3AŪ°‘!GFũŪÅ´â°Wâ_䀛#ĸ%Uaq_ō2ŽŲŠ–ļ ^>1ÆĄũ,872ËéŅš ũ›c9ÆËK¤õ_w˜xÛëƒnj†Ļ |"¨ãũī‰ ŽēîEŋ‚´a0†dŠˆüx:Í͙Ė%îŽR-•x´XdÜ÷ņ[¤-ļėą,ŪßĶÃđÎjø*đ ͌ī°y%K­RŧYįPĀËÅ"ߜŸ§Ô†E.ĸBpG&ÃëŌé&Ų#Ô4Cî؊§¯Žķr8Ø3Vš~ánžšLp0e›ŧf_vM›>ĩ¸îĒ=ÄcÍ-UŪŖpķ5A7÷Į…÷†ĢZH@ša(Ļ"ë ’)ÃĀ" É =ĻÉ]ŨŨJ$Vuü ĮáŪųyŽģ.ĒMîMąīëé!šŗ†ÚBH֟ !ܰ—­9§ôRķ~Ěq|_káÉJĨcæ‹Ãā'z{9°Éđɀ €ûŸŨhâŠÖÚîžä:ûĢųŸ=ĩ17č]ĩa[&¯Ŋv/Ņhķ<ísGi”}^a1ĀHA$ÕM4ŨƒaZÍPHĪÁĢ–ņĢ%¤ë\’gdkŽĀ´ŖD’"‰ô*Rå;œÂ~ĩÔ°ŋŅsØą$ąŽ>L;Ben§8ŋÚŽSfœ;͈cƌ.†š¸;°)åû a€`—įKŽŸœ W¨6TšēāŽ'yâėėâīīˆÛÜ÷O?tEŦßB|*\ŲB´Ą¨HŪ÷ųõēČFa AÖ0˜–’Đ,āÖTŠ[ēģą„@jÍŗÅ"ßÉåÚR›Ĩˇžį˛g 5 üđĪC͐5į•ĪŋØ,ōņ×ĶĶü°Zí¸ûf ÁģS)ŪÜՅÕF!YZë8đÔÄË –JņÎß˙W—R¯ŽęM1Ø_aĮ n:˛‡tĒ)|ņŠŧĢÊ&°ãIL+r‘܍tÜr¯\D)˙â× †abŒDĶ]˜vtÉ,͚ŌĖ^Ĩx†\˃QÜzšâ?É\':CKkãøŒOæ™ĘŖ.ˆg˛â-ŪÃWö]î ō˛žBŧ.\ŲBtgŋšZĨŸ@0æ8üŪ2 Í<œnè•| C˛íô—G"ŧ¯ģ›' oķc[ŪS7Ôv8v~ øGĀŨĄfČâœŌ4ī‡Ōš{fgy¤\îčązU4ʇzzč˛íŧøOĀį.’UŪÜB„¯øŌ ÷æMzą­ÆæđeYö7G ÚDķöę ¯ĢL,æzÃ$ŪŨH7MÄp#„Dk…ô\”ī"}í{(éŖ•ŦŊtœa„ab˜†ecX6ĻÅ´#a°‘­5Ęs ‘ō=´Vh­†aX‘–ŌÆkįY§=•ÜtÍr‰„ęäe<éîŦy(Ģ?9IŲšp퓙|•—Ļ–ĒÅå­WķņˇŨčÔ XaØqH@‚6Ū <ÔņN”Ëüé EŌ0° C˛ša¸{ĸH/€Ģ#>ØÛK×ÎĮÎ˙đ+B'œSšãũĐĀcšwįķģbNHéîæędr§C˛žG-$k|g=P˙ûuÍ8ųÜû˙ėGg™o8ØÛp?âQ›×]ŋÃū.EQÜQã gnņœv,IŧgÃ˛[y.&_č‹BÍ"PzĩøâFāUKTį§‘žËŊžW…Éw]EY˜7¸ŽĪ‹ĮĮ(V֏ö•JķČŠéÅßߟŽņí_ø@З˛_q.´ĸļ#ŧ 8äÁæũ`ŌÄJJ‘WŠËÚéP„ŽB§……õe×å'&8Q.ī¤QĨ–l}ÖúĐ.']Ā?kÆąĪUĢüí.!EĨøōė,ߛŨÉqkīžŌZßYWĩßōĄĩæŅķ3 Ÿ ĻW‡]^}x )ä#Ž%/œáĘō!ąŽ>’}{Zœ|Ôé…‹aUkŊūŪŧK¯ Ž%Iî'Ųˇ‡H2ƒaŲ ×-„Ā0-„aĶ’Û*Š•‰X\så{}re‚ŽeÉđßÉU˜+î%ūm­ĩMˆ€ˆkƒ<Ø´œL‚&}Ÿ„tác ą6ōJņřÅÛš°=Ü <Žĩū@ŊénÄģiBŲŨĒR|ifĻeĘCo|ā{Ĩ˙kb‚wGëėžüĸÖÚl6ų€Z¸Ũ‰bcžĪ@ēQ÷#ˆ’JF?wFûütáC~MĪJ&Éž=Ä2Ųæí!V‘;ž$‘"=tôđ!ŌûIė[ü=‘ŦQg–›ÜÎŦ‹Ú9I‰- Cđęøl3.ãŸ˙0ė! Œ>ī5G(ēĸ5sRŌošÄ¨ëǧ</LMņÂÎjm’Ĩĩõmû/ë"žƒĀåA_ÛhĩĘŗ•ĘŽīß­ųęÜ÷ĖÎRŨ92f˙žŲäāÔ ĘD4”<ĩLƒdĀŪ¸–üdáŨ˛–ĘeFb¤öalg•ĢF";Œ0LL­ų@ņ4WzĨŽlįÁ}Ŋë†&ĸvCĒĖķŖ3MáĀÕZßöēÍÁ oÁĸŅa×uZ<ģčų0#1Rũ{ ŅÔiZ${‡(NbkÅЧy8ą‡§ĸŨ‹Z!ÛļęM3:ĩ:ŧ*f› 3÷Ŋįgi’pGøĻÖúMBˆķaī Čf1äbRö›¯Sǁ‚RXBHKˆ9ŋV2$!!Vj•Ëœq]îĘfŪC j!Y_ūĨÖúHH]´Íēëã°ë"ũ8Mm×ų05ŅĐčEČĪaā˛:!¸˛bnpú@ øŽÖúËĀĀ'ƒžy¯–ËLKvč÷}ž05Åû2^›Nīt)ęĻāčdŖ"t:Ö¸t÷&ļŽî,2\Īų0íhH>ÚͰ‹%Iô Rž›ĀԚ+˛ß/ņ@|€Y#Ō1Å+ö u32•_sRZ&Ž_›/*VņĨÄ2›Ō‡÷Ļĩž]NĐ!ŲUĒ)JšmFž_3āzņēq– gˆ•ķ}ūxjŠ;2^—N¯ROŪ&āß<­õw;woØIõšĢ,MąŒ<} øpē>ƒã|s~>,ÃŊ­ųf.ĮiĮáŽl–¤Ų9Æ˛&J­SŅĨĨÛËŽŊoqfšŌŠôH†äŖ-If¨ĖM€ÖrįŲãx)ÚĮSŅnō†Õö‘xĖ&“ˆP(¯ÎuŒ›‚Å´)!(V\ēSņf]Ęģ¨åƒ|!ėy!Ų,‹ ”€ls°‹ ļ{œf€,Ą7$Ä Të†Ú)Įá}==$wF3$|đꄤ“Ŧ ¸"胞­TB1ŌK0ԟŠV93>ÎGŗYÄã1jMÅoÜX/#‰x¤!dK‹ ŦōĻĘxŊÔŽA˛˙2 34ڙ„ĻIif­$Q-ymu‚ëœiÎÚNFԜ2T  čúˆi§Žžžäš$ļŧHƒ€RÕi&Zą•¯ !raĪ ČFqC›÷wŦHfp1`ˆ°Đ@Đļ@m>kcËFĪV̜ŸœäŽžöīŒĄåÂĄS!ęPZķĐûqɘUŠ˙53Ã{R)ŪÔÕÕÖÚIZkÆfōŒŦ^‹Zæ2,1 ų`ņ–ŽŨdīf˜pŪūF^,Iz`åšI|§V+ĸ%W¸s\îÎ#… gD(˜QaņŠâ%;Ũ6íËv'9y~n՚5?¨zMˇÉ˛ĀoĐ$í§€t6Ũĩœöw´J„š7dšĨđŨC–ũO7|¸ė‡ē8Ž/Rj<)ë?+|_ĸ¤FI‰’ ­4Z*D$œšhbé"‰4Õü ^)^QžŊh؜6ãm×´t"Â|ĄQ¨seQ¸_ž÷‡üö&×ėjæ< ¨yAŪv¸€l‚ļæ´æ<ō2Ri>ūļļLā×Ač Č%#Ѐ×Ōö—āŊäÅflŽldQžyË2%}ļp9íy|~jŠuuqM‹jģ žÖœpŨŨ0¤ĐJSu}f኏%‡ņų2ų NŲĨāø8Zãë%/ĢÖĩųL/‹“œ D„ nâĸ™{ēôvʼnu%čNÆøĘėoqnëé!ļƒžž|šĘ§žņ>?6ĪJŋ‡-—gôfâT9ߊÕ+]˛~-܎§0Ŧ憗~ëoîįüČäēíųÖ=÷sã ×@čõk2ÁW(ŲØãfĖŽmY63ĩaā1!ũŨ Q›'ō8uÛ§¤áį;F&áŽ×7đū;āÃao ČŰ7ȃZĶB02_ŸöÍĨŪŽÄŽhiI)ūbnÉP‹†!YÛ{˙Ĩėčđ+)Ķs%Ļ'sĪ19W&/žŌ›đú,å¤ų€ƒĻ aŌĢ@žÂĶįįˆAÄė‹Ûô ¤ęæ…ÁķÍŋįO§ äÂ#ũ)zŌë{câQ‹ũÃ=ôfS[ö~q Dtí94ģė.ĀØø$3ŗš – 6 3üۀ•áWeŗ}Ģ; qifMÔ6šn¸‹gGæņë âSŽĪįūö ūĶĮŪ‰üø˙ؐ€„äb ō`ã­S‚ˇq‚ŠzĄīc‹č‹íÜ$­ĩÆķ%9Æ&ķŧ2‘gļäP’ĨõævË5x@ĩ˛2åI(Va,‡-jbNũ–ÁŪŪ†ģë+ōŽÁ,īíí%i…ÆB͌TĨČĢÎČÕr=ŸŽōČņqr~kļio2Ú .ŪĖ1üį>Ëīžn¯–!¸~¨‹ô:%ž-ĶāĀP7ƒƒ][& xŊ3ŗ¸ŅėĘWĮOžŠXû. ˙vŦ%jõ€jcã@kÍû{93:‡w‘9&ą¸Ļ?ÍķųÅĪ~ëė ī?zš[Ž;ôĨŨ†a…äb8äÁFZ”€8ŽKČ@ļ†llû‡ŒTŠÉé"įGfyūė SUąĸG3áiđ¤âŒTœįņŅyâĻÁŖÉėį_Üt%G†zÃNŅT•Âmķü­5Ŗ9ž÷ä)ǜ–'ûņĢ÷l‹Wī™WGøÔŗ§ÎmCpÃpÉ567„¨)_žŋX€›šŽēōĩa؈mđ<ĖĖĖ]4aūÍoxm˜€ž-cS­Ņ'ÚÃŨôt%yõĖŗųĘŋ۝Šr ãL~Šņŋš˙GÜwx/ŠxāaÆ>ö琀ŦĩHĀuųJQmŅ+_#B˛d’Û—˙!•fdlŽž<ÆĢ3Eĩŗ ŗŠRņLžĘŗĪ#jš|n¨7ėPMĀ™jĩ­ķ?¤Rŧxt„īž4ļXyĻU!€\s°éį)V~éīžfbŲ86„āēĄĩɇa.ŋŦ—ūĖĻķ<ÖC—ō‰ÕïĸŠŽmšĪĻyq’sÛ;ŪūméķĢûĶB8^;#ŗšæĒaΏĖrv<ˇJé}9ööϘ(ģTë“ĮŸ{Ÿ=Á]oš>čËúdH@B˛.q&` VŨI(–ĐVÜę—n~,­afŽČS/œįšņÜbŦjKwāÚ!ųh^ĒTÚöÚ}_ōĀã¯đÔššļ QƒĻÁp&Ųä1­ųß}Šī•ŨâsMjͰĢXÄâČ僤SÍŅe¸Â+,,Įŗ& IDATąíŠę7Øß‹RšõÂėmËbp /üÛA@Ö ƒiéĐ j !ØY/ą˜Í‰Ķ͍uHˆiŽČ&yari,üâŖĮ¸ũ5WíšEkŨ-„+,Ŧ|Vá-V¤Øĸ%x0–āŨ˛Õi˛ĄįKž{ņ_ē˙%~8:ߒäŖfĩŧ% ŋj ”ÖŧÚĻúž'ųöCGy˛MȁˆEĖnî^ÜšŠyūۉą†ĪöĻcô¤WŒt"ĘM×îmų¸Ō]2ē c{ö!¯=rå˙~ëŊ™ŽŽL8l‡áˇFÅŗAŋŗĒî ôe¸úP˙ÃūzŌ1ē–E8-?یËyKØëB˛‚ÕņũÖŠ„O{‹ ¤™ Å’Ãˇ<ĘŨ/ŒPjņ°•Ģ.O$Úüi.Ŋ”Ö‹/šėå)EEJ žĪœī3ãyëî¨ xmīûŠīĮå[Ŗs Ÿ]ҟZåt8r¸ŸXt{4†`Ô ‘Øfáŋh4Âį~íS|õwsüÕSÜ~ë-ÜöΡ’ˆĮ qqüå_ßïü§˙Š”KŗâßŪ÷0•J•ß˙ŨO‹^ē‡ŪŽ'W+Ü9ēãƒäŒÎ2 CpåĄž=:˛æß{Ō1Č-…¨ų…3|ėmĮĀā|؋CŌ°y°‘õ€¸ŽGČ@ļ†Ū€EįķūōûmF>€Á˜Í­8›Ī_|<ärœrîĖfI^bČŌB¸ÔŦÖôš&žÖˎ (ŸŖ5ĪsŌuš3›%u í“Z3#%†d ƒŧRkŒĒÖxRŌmšä¤¤U dēžäîG^iŨb ÁÕũéĻ >øÂI^ō—žžmŅÆeˇ'§§;ĩŊ{‹{ ۟:ØßË?˙'ŸDiiÕ QÃķ/įWWÅ~öđS<öäŗŧķ–7]ōņ"ŠnĒųŲUVō[ĢSܓî¸û—NÅĖϘ˜]&šˆZDLˇž˙Õ|™ŲB™l°U/¯ ČjVļk´HUJZ5‚ßw<ō­!“NÄq|žņĐËäŊöĢŊž7Ŋä°q ü¨ZåÆĮ9UŠ\Ō.š¯5Ēūo§Ĩ¤ĸ5YÃhÉÉJĪUĢüá%ļĪŠ{tŧ:Šę5MâëÜL ĖÖIH—Ņz­×~đÔIĻŋmĮôG¯;ĐĖõ…ŋ|ūtÃgCŠXƒāĄ‚+öŗŨ6¸hRÜÃÃ0°L3$čKŋķ{_Ā—k¯BĀīüŪ6ö LkÍĖ×93ė“ÕŽŧû/ËŽk ŗ­ī†āėÔ\Чŋ1ėÉ!YŽ]Ŗ’/… [EP JkzōUÆKN[Ū‡Ąî ˙›9Ĩøŗ™~0?Ņër=4iŅhך\ŨXˇZÔ`YhßÃißô˛MM-”  ß4×]gę$Ŧ×4[j~™™/ņôŲŲļĪ–ŧëđŪĻ_iÍhĨąœōPWcõĄžLŒč6…^5įZoĶ* Io<ņôs<ôđ“üÎ /ŊÂÔĖÆÆcŧĢo r w”ÎīaÂUkxÔf ģļæO*Ūč™<98š"ėÉ!YŽŨĄ"`t.ÔŲ⊘ČÉ3S?9ÉąRiÍü‡ņ ˆđÍ+EA)zZ8$ë¸ëō““ŧŧFûέĶ6 Ffp+I-$-Ų$l|:ĪŠ|Ĩ­×ƒÉXSķ?Š—W–åwIJJOĻ!ļ-ņ|e•uĢ”ēķŒĖŽ‚€{îûūÅŋ&Ō߸×"Ņ3¸Ļ2ú7ĮûËcXFB2Šņčj24~öÕķ3AŸ:Ŧ´Zæ`Ök] ‡3šr¸ĘlitĨļŽō÷ΜĻęˇoØÃūd$¸í‚R|iv–ûfgqW„œšˆ ¸[Éę7Mb-’UTŠ˙3;ËũËÚ§s˜#45/s‘pĢyĨČÕIØNĐĨ4T!ÖaáX}Ũ‰5aÛZšu_q}/Pa(üېė Į“āĘ0d H 2;WâÉ6Ž™ČW­G•Jüéä$uŌĄš´Jr ˜Kú Ŗ%=x°ŪžI×EÖK_tŧÖíz cŨ2…ū˛ŧ˜í&aŗš2įÚ<§Lw]×\ÂâŠü•áWŊŨÉiģüEęĒŅĒķbũ; ¯ž>K$rņ‚ĨZkŒM̰ã)b™ĩÃģķ|ĸx†!étĖ=íéNŽrüi ĩÜ#*Ŋv€4 –@kQRŠēaŪ-ĸo‹%xĩ†ī=}ĒíŲ—íë ü˜g=˙99És…îu?ŠJ‘Ķšîō\jûž09É3…Â%ˇM3J‘6M˛ëŠZģ)}HbÔxÕđƒ§O)ÕæyČ4J7WņY¯í+yb:Ũ‘ļk o.åžHß#Dëbv.‡y !—Zk"‘ͯSąŽ^"ÉŽ5˙6ā—ųDá$oræ°;Āc–NÅ×´‰ĸ+<’7!i^ˇH¨˛uôÆˇF@ær%ŽOÛúCpŲ@Ļ)Į.kÍ׿įųæĖ jƒ Üō’ļIŖ5§´˛Öüu.‡ˇÁļÍIIAk˛HP/)ÅŧRÛBÂ\ĪįälŠí׍Q‹˜Ũ\Ŧø cЗĪ>bīœ ×Ikɓé–rģg"W íû¨\51‰ššF—Ëh_B‹VBŦ"ŗk΃ž$ÛĶŊĨs%˛ƒDRk#ĸ%o/ōĶÅĶ\î—Ú:AŨ˛Äšy ‘åDÔtŽB4ņ9ėֆk­3@`Ûšåēbq+îÂJ×׈l]ņ­UËyøŲŗmöÄ#˜M4đ5MMgjŪ€xŨ0ŗĸ”oKĖ9›üw^=$ĢײČIšf—¯5ķZĶgšzZʲ†ĻÄ÷<b\9Jˇ=yÃū>Ŧ&“ÕD´qÎđ•Fë%OČNĻ/ˆ¤y{eŦÖŋ*E´Ö¯É!Īā<üūOĄįæAɚ•i™ˆlķę+ąŽŋûĘË1zēĄE63ēģ2(Š.j­Ũúö7’J%ˇ\T Ņ3€aZTķ3ĢŽ%Đ ø%>\8ͨâŠX§Ė~›õ­!P]ĄadŽđ€¸a•¸€4 J}–ZļĖĒ!ųØ"béͰ(–^o˙]Æ=™­žÎT´ÆĢ'hį6ÎÕĘPÔrĖŌ†A—a0ŊÁĒ'ą jží1jeŗĸ”ŌŧđĘDGĖ%Ÿ¸áPĶĪŅŠĶ+3u#NRĶ1ëƒČņüŅ˜7laÕ´FKaŲt*üS§)}ö7ŅšüÚéü<ęäIŧŋŊ—j*…uË[‰Ũū.Ė}—ąĶ“Ū‘Ģãē>Ņč…7Á~áũT`Íb™,†Ą27‰–ĢpÍe^=^‘i+ÎņH/EŌäEûÔˊ¯‘WJۋŨ‚ÕäÁZYd$ÔŲâv ¤ˇ rüôTG2în‹ëôĩfN)zZTA|+(\Z¸Učf¨Uø UĮcŧęˇŨ-ęÕ§„¨iCpÃ`oĶĪk™G–…yh­Ņ˒gJ%wĮî‰DP6– Zˇ”īč)ÜyđáuÉGã\¯Ņ…Ū=GáSŸĻō7÷Âī‚§R Ūúæ×\đ;™t’¯;čy#ņé}Øņä H̀_æmå~&˙*IŨ>ƒČä?ŦˇŊØÍk‚¸Ā5¯ČĻVT­5ĮONĩôjÜc›|ėÆ}üôĩ9Ō!ąkž…Á_kàš C^3á/—mæ+‹$W¨îhîŏ"ŨÜRĮÔ ­žį`Ebt""oy#ŪíīÆûîũ SŌįkß zÛ;1úvnæŖ|/˙įkßæ•“Ģs î§>Ė­osSÎH"Kāķ8…ŲUĨ›I sĖNĩUŸ0 ƒ•†‘ˇ‚D,“!iöy°É% UW‚'F{đå$AÔI!&u!¯:ILĶĀ2M,Ë bDL“ˆeŗkīQË$f›DmaĶŋ›ĻQˇ-͍ Û2jbabš ĮĻË~x|ŧ#Ëž€w’šĀ_ˇa`3˛s*›”kÆTĩpĢîuæôHũ;Ķõīl¸ĸ‚/Në†_íMDøĩ÷ž–ÜKōB%Kˇšŋ÷ēƒüō‰Ĩ9 Wõ‘JcŠž/ąw¨–+ rF”ŦŦyAĒsS¤÷uæjo$˙ņĪQ=|įË_Cį6“§5z‡×÷d"ÎūõOņ/˙Ũg9sv­5ÉDœŸ˙äGųŋîXVķû0LĸŠ.üjߊ,~~:ŌÅcŅlûu‰5æGdž]ž¨Ŋ›M䐀4}Al´EUĐ+U¯Ĩ}o;ÔĮ@:FÔ6A„)ωaˆA¨“Ķ2°Mk‘0ˆåü`é‡åÜąŖžTüđėL[ †´iMFčÍÄéŽÛÚ6 ąLÃ6žŌ$Síŋ3:¯‘ Éb)ÄjœZūĮZÉ:&0P'*˛ūķ%cjŽÔ¸8ˇRßĩ îz×ĩ\Ų›!Úb!×âjËāXŨ *ĩĻTõČ$"h SŗEö îLn•÷sgąļĢîģ¤īavj2ēi{÷­DnŧįŅ'đî5= îrqĸQ"wžŗŋoįûŌÕWđŋ˙čwyņčq×eĪĐ 7\{5Ļš=}^IŸŌôŌu–mîžjË܉ĩTã+ D“ˆEfāĻ 'ōI)[2ŖŋĨ5@´æu7 ī˝TŦ⡸b[Ú6yûUCØßKW:ļ@ÚDDˆi2ĸ”˜įÖķ'˛Ļ)g¤T:Ĩķ j•ũĒu"2ĀÚáVŊ€LY.ąrøķ¯Œ{´āŊĀo8D:ãžBsžĮû{zHY­ą´Ål‹wfS›\J€>5SäÆDœ›gx kĮŧ‹Įė4ˇ ‹X=y¸:?I˛ooįŽúB` ˙НÄŪw;ōäiüą ÔÉS¨Ņ1tš ωčåŊúJė+ˇLIŪĄ>†ļŸ i%)M ŊFeđįcũĖv[Æw+­W]weÅ&K2$ !iķ \¤U4éx­ĢbK>NÎĩôõíOFyī-WŌ۝Zå)ōjų`^ÔŒŲŽÉ!RĀ´”fŊ}UÖΟhWĨ=ģ‚ąÖŗYō˜ôŅ‹-Ôã…–¨C‰‡ö×Ļr <_­rvb‚fŗŠÅꡜ;÷h…üė¯âķßyjņŗ‚+q=IÔ6q=IąäŪ!ŖDđr´‡×T§jãžRBz.ĻŨᆗˆHëČUXGŽ‚wŧ­–˛<)Ā0:6)ƒĨéŅUäCĪDģÛvâôũÆP\_i´Ô‹Ī˙ÎŽDĐIčnؙV˜€ģ´Ũj€[ļĖ´°HOÜîčųũéW§ZöÚzŖ¸õúzRzijåǧëv{§Ąģn¤O$ykÖIÕ|ˆŦ7÷īŠ°1.PRIĨg<Ų’{,oŋi˙*#!§>=͡NŸæž{īåā…^`rrĪķÖ Ŋh&Ūxå~n]&dǁSĶÅÅß_=3ÍNÖFx(֏+–aåŲqĐģŦ Ša€i‚eÖŪM3$uT ŗ 9‹DÚ°˜2ڗ¨ú+TÎËNc¸úÛöô}Ę|؛ą[C°͘jm RË>„Áx¤cךŠã1QŦļėõŨū†ƒt]ÚŽëBķ85uíL‡=ĒZû´Eˆ ŅAmë§n5IÍĶąV¸UWŊÍĶu˛šjMp\_´būGÜ48°Ž‘āOZ…tš§ūÛŖzô(Éd’ĢáŨīyoxÃ8xđāļ$īږÉ˙ķšC<đčąÅĪĻË.U×'ą(”ŠÅ*éôÎxA\ap"ŌÍuN-_MēUŧj ;ž"Ä'ŋļ'ÂlīŠ˛ę6pV¨ĸß°'đĒggÃĩ‚÷īŌvĒØĶĘ įs•–}}ÉÎ ŋr*­ëmŊ&›āāe}ëH5¯ĄE­œk§yCBėĨļ9}Ao@b!ÜjŠõCė: ›Š+˙xrtž%ŨÊ(ÖEĘdĻ÷íãĮ>ûYö˙ĖĪ013˃÷_ū÷˙ŸūÄOōŲĪ|†cĮŽĄUķģķßx ˇÄ–æ; ŧ2YX܀9vjĩƒųb÷ÅqVxAV–Z ąûP-ĖĸĩZ‡¸ļw‰ÚĘ ÂQ\ūģ†ũkôŧö¨€\äÁZĩŦ§F3QĶiI¤“ąŽí`ŖS…–Ŋļ×]ŋoŗŅ jŪÃI:+diI–ĘÕvZHÖP\Œ¯C°BėŠ¯ $ėÔČ\KÎĢ Ņ(ĩüĨP˛ö’Ráē>U)ØsĮšü—~k`€Ų™žôį_ä˙úä'ųĢo~Ųä9<ąų•ˇ]Ķā‚šs|re§n yLLåvė^ēÂāŠØRa4­åšÉĐBÚåđĢë‹w)ˇmwj„€âŠMŠę˛JĻZĶŧ°ãLØŖą[C°-v>Ųĸ%xĢŽRĩĻˆ†XĒ3 ˆđâųŲ–ŧļž˜Íūá-•ũ´ŠåLÖÎvØãŗY 9KP QÚjW_ūŋ5ÂõÂ5ŖŲWHU3 }Аū‚A-QR×vė•Bi8|ųāF’ŦŗÔB˛f¨…[™ëĖHũ;ŨZk+7_jIWåädžf‹¸žÂõ%Ž/ņ|‰ī+|_Õ ĩŌˇĐÄxã­ˇqú+_Z˛ĻĻųÔŋũwøžĪ]wŨÕԄõw\˜?u‚¯/ķJŸ˜*ō†dœ6ÎHuÍū*ŗ“ÆĨ‰†âÎUÂԆ‰\aÄgĸvà 7Z \öoã{Øã•ɨZ(–ô\Ęŗã${‡ÃŌ´ģ ‘T÷ ˆ­)žá/R‡(ícNĻĶ1ôXcwNÄė˜?;?Ëok@ÅÃĩË k€”Z´/ĀtžŌ˛ĢE_<‚ }øĮRxŠ^Ãŋz›ģĐÂåwŋņėš }˙P&vŠ3p/āœ>=UUžŅJÕúŽzÕĢ“_Ɗ#}åKÕQ)ĩQö|f|‰§kŪDŸ‹‡m{McSļ“’ §EkE÷gˇF@”Ō”äîŅ{HĨĸøķ;(j˜č$YâxB´ņ„ā[Š}üdá$VŊ’W)RÍĪëę%Ä." ‰4n)_]ģœɰ9ímģ<îކ!4ĸ–‰XöŲ¤/)URņh`3@ØŖBhŌlĄĨ5@Ę-ûÁi€\j(Ôō3¤R‹ņ÷Rj¤/‘JĄ¤FI‰V%k‰žZéÅ8~OJ*ž¤čHΝÅņû _J”Ōĩz eŲ9Uj{ĸÖ%÷o­uô[Ožĸ¸~ûMvhW=‘Ų\ÁĨ4~‹ÚčC=[ĶiPJëRíYíŠmíÁž$sšÔ3­šā]Ã<Ŋtë ÃØQÕöå3c<âmåŅÅÎQÍĪ “hē›ģÉŪaJS#ønĨaU}5ŌÃwƒEû™‘B2É(š›…qË ŧr*ĨĒ$ Ž ō`­Ŧrnžu H6nŖ”jā‹„@ŠZ(ŽŌĩšTZSĢúĢ^ ¨JT Éņ¤Z 'r%žZÅQu‚ĄJŖ–Åđ+@.‹á×ėļwÔâŗJež¯pZ4÷Ĩk“áJŽįŖZ°#!čJmmaÔZ uō^[1ŗCCLĶĈDŅÆ2! ģĩ"Ū‹fô+\é.y*ķSà ’Ėbw@Éū=”ĻGņ x,>Ä#ąžļ„J'"ĢHĖ\N@Āņü°„$P\äÁĻ[´–Öš™Bĩes@žĪsæoŸÃ÷•VRĄ” %3Õ2r KhÖBqÉÁļ"Ķsé†ģ”ŲĸAuũ=›# ãŗĨ–lOÔ[Ū-Ī•œ]5ŽŦķ§Ö$rņDÃ41 Ã40 Ķ´0 ÃõwÃ0ˆÅcteēHĻR$“ 2™ ąXŒh4F4%F‰ØėˆMÄbDl ‹{t„˛ F‹•K×ĀŨ‰=üŒrč÷ˋŸ–į&@"ayŪ]é9TķŗønÍX$1Ė#ŅöŋĮV×L1W AĨBC#$ Á"X ŋ5rÅņAkŅĒIƒ9×'įú°KÂ? {% IDATÚú6æŖ|Õ˛í@÷æ*ŽÍ[˛=)sëá:c3EÅ.‰K6„ üXņ¸l˙>ūßũ¯Ųŋ?ļmcYÖâ+‰,ũnšXļmÛŅHüuÖølåĪŽįÃŖëWÚ-8W{BđĩÔ~6’t=)­)ĪŽƒÖĄ'¤ÃQ-ĖáäfÕĐ_Žfy4Úy@vÄbå­^5&Ã>`¨ČH̐āEtBė6čštÃŊâx-é˙°LƒˆŊšŠnbĻԒ^ÄÔ&“ęČtÁĨVJšãa9=Íeû÷qäškxĪ{nį–ˇßÂĀĀĀļūÛMÕ-ĘŪKÂäkŠütá$1í/‘š ´RaNH‡ĸ<7[Ė-ū^4lîuLáJÃŦd Ūō1ĒŲôē"$ ë!0 Š5sRbĩâÎU+k€„h D,Ëŧô ōŅ™Ö WJ[ÆĻ(„ōųÖĖŖJÆí­‡`͗wē\Öüá§…Ŋ}Ŋ¤R)LĶÜvƒš2žc…%įļpŧųŒáëéƒüDá4Ņe$¤2?‰V’X&Ž7„Ęütųx8>ܖĒįëÎīĒ‘|VIDCÎĻ’ĀŨÔØfh€ˆtŊĒöî[3Ú"&ąÜGZ4\)c››raHЍHŨ’<ŊE Ĩ5åŠk<`\qøŨŨŨX–ĩ#ķļ! öë“ŋ…CFÍ•:€#ģM5?Ciļæ Ņūđ2NĄą\uɰy1ŌY9?žßXÁTęZ!•†y6 ;DH@C  Å֙ČUÂŪbk$jmhSsĸEļ3öævģ[Y¤/ ]”`Ų‰ī¸§ZC–ŲĀ=ô2ĸiŨ0Ŧœŗâ|3}ęŠŌĢ^9Oqj%ÃĒAíŽĘÜ$+Ũs/F{Q–Žé8›´Ĩŋ˙ãĄŽ  C„qņģœ€ĒRle .Áĸ=Еˆ\ēAĢ5š’Ķ’íH¤;P$ģ5 ­´.ËŨÃ@úŗIŒöV ČŽ(ĩģÜi 5ĩŌä-Žŗfœ¯§R^Ą|-Ũ …‰ŗøÕpíiWx•ŌsWįWíTĮĩ5ŋbŊ*•Û}륥ĀOö°ŨM@ՙ÷ŧÖÕɕÃúq!ļf¸w]zēī+Üĩg3§=Š­…Ô5@vMĐūkģw|ŽBĐŗ‚€”\ų3i›˛ŸŖfŒ¯Ļ“7ĩh´ô)NPÍĪļŧ7'ÄsۊŧL‘Žj§ÖŦŌ_NH4\ŋ¯?pîö°ŨM@ՙiŅ XJirE'ėŨ!ļ„ė´3|?ÔŲ.Dęú[Á\ĄēĢ4@nÚųŌĄ†ôÅ šÜŠ\Ŋbš}æí)3—2‡™°’ĢŦģjnšŌÔĘsÛ ŌĢŽúŦ*LĒĸŗŌÅrų2R.M€%Į§āʆ>|h(ôi_ {Øî& ûƒ<ØD‹˛ãÕ4@B„Ø,4 lĀp×RŅĒ›ˇ›$ ŖŦ2:S”ģĨ+ !8ÜÕ!$7_ÖH„æW„}LLÚęŪ…É_¤p,š]ĩũā;e gqK9BåØö˜ô×Ęá)&‚Îz~c“š†üÆé9ŗŋrų`3ĐöąŨM@Žy°‘UA¯V=B}ŋ[‚)Č$Ŗ—üõRĨ5ķëLC‹lŽÚødM¤õˆĩu ņéüŽŲšŽ‚žhk„Ü|¸ąJÅW a~sšJK†ũ].ßI ķX|E™V­$åŲ J3cĄ7¤Õ釔°Ņč4KĸęxL/ˑu|ÉšåáX>ōÚ˃>­"ô€ė^ĸĩ6€ëƒ:ž¯ų](|Į ųGˆ-ĄÛŪX ŪŅ WJYfĮi€$ĸ[/#›Ÿ¯DvK_î7ļŲ!$—÷qŲ˛đ9ĨuC(ˆR ĪmŋJR ÁßĮzų›ä~ĘÆjí¯R¤0q§8ŋ¨ĒĸÅl¤u攤ę,âxæüLãÚ5SjpĐŊ#áēÃAŸöy!DØņw+öIæ‹Ēu‹Ō9Ą zˆ-bŖ%x[5\ŠË6:P$ąĨ¯´Ļėxģfîī5 j6ËâõË h ŋĖ{¨‰Šö,–ŖcvŠ/Ļ/įŒYĩ—Ž•¤27Iqō<ž–‰oĨ''='?ˇĻ‰diMBwFÄf._frŲfYĄėr~Ežė|Û5DŦĀ7,î ûŲî& }AŦäû­Y‚Ÿ'÷[COlc °ĄČ6Nd=h€Č]¤’Œî¸Čâ‚knčjøl$×či™ĖˇM5Ŧĩ7,žžÚĮãņaÜ5’—Ĩ[Ĩ8užŌĖx–ĩÃÄÃw+”ĻĮ(LœÁ)ĖŽ"”ŗfŒ{R:" Ũ—Šã§Ļ~?1՘sõîD„ˇ_w¨§˙NØßVÃÚEmŨäÁ Rļ&{"Ô ąe$7 ŅŌ ŠÍj€¨–Õ B¤Ļ7ą+5÷öĨw^d~âæĢųåWƗÖWâKĩčĨņĨ"_¨ĐŨ•ØäķU ë‡uí ‹/¯ZÅw]z ĩgOSÚ(ü ÖËq;ÅíåQũRcg͝œĮ¯ˆ¤zˆĻģ1ĖŨdŽė,”īQÉMãUŠkđ…Ác<Íâˆöß§ÖNœœ ZoÔÎL(­P>˙ĩÛ^C,b}úYāá°×ínrc›÷[7N÷ė|Yf„Ø6ĸáų ¯EõôĻ5@dË&÷fâ[$ Jė" B^7ØĶR×sųpoŲü ^‚WéZVvY~åĖ4¯ŋaßĸ÷N× ĩ֋ŖW*áärxĨnšŒô<Ür™ōä$åņqĒĶ3Tϧq dĩŠv]”ķ˙ŗ÷ŪarŨõŊ˙ë{Ęô>ÛĩjVˇä^0Ącƒ1„\áB(Ļä.! !íæIBō„’{CL ÁôfĀļŒ›lËļlY}ûîĖėôSŋŋ?fvĩUÚ2ŗ:’Îûy֖vWgÎųžoųŧ?ím m×ļšđMoäĸ7ŊŠ­Ī:ĸųĪø&~Ĩ>ÁĨõ1sRy¤”Ĩfe’@,I0æ‘vÃŦŠƐîü´* ŒjQ~éeP 3Ŋ¯ŽŸUx>˜Ģ0X™Ēū—tqՎ íøø/ųõ>ii[ƒqĮuŠT đų‡•Z¨ËKķq<ŦŌ‘^™Ųŗ Ē‚ēĘz†‰RķŠ)ę}YOŨĻ*ŧlcw=5Øho*áČX‘THŖŽ[­0YĢp´2„&$ļaPĻ6>Ö š<õâ$ŽaāÖ \ĶDZŌqωĘR0ô‹ûÚN@,? uđx Áuĩ6šE”93PēF1‡Yž$mÍ'"­F­0ŽQĘŗPˇ+‰āąPw†:1Åš‘/%˜āÄČIÅá|•Cs˛D~%¤ķû/š˛]‘Ō¯ø3Ī' Ũ­ŧ؈G[đVë~N­UB@į2"ޝ˛fˆĒĘĒc›ƒã%PĪ‹Š,`[*ž†œ÷eYÕJ…rĨBĩZĨ\.“™Žmؘ†išÔk5ΐŠųƒĸ U %F GQ;ēĐû7Ąõõŗ}Įz˛ëz‰ötŖčēgĸĢ…ōŠÎw"Ŋ¤B\mäØnæLÍjđL‰U+cÕĘeŦIFbčĄH“tųdd&,ca›ā‘Pûõø9÷ŧņhņÚ:‰€ÆXídmėĒ—^~WmÛĐîĨķūėķ ´Aē[œ ú"„>V‰Hr莤XõfÔ- ŽĖ :ˇ5@¨y c€V+Ķķŗ˙&xøņŲ¤c´Ž:Q#ēwČƒ?FŖÛ×nüÜuĀuA狎Cå‚Į8†B,#ŽDHuvķ‹I§Ŗ3‘ÁGØŧšŸTWE€P˜hôööĄ…ÎÍ ē)"ōƒp7÷ŗ\iØn䈚V#˛ĩČZĩĒ%ŦjĄ´`-EFí|ĪĶ4-)%ŽQÅŦU°ĒĨy?/*~ę8'Ÿ=#e~úՇ‚ŗÍÜ_īKqõö 힍 !îõ­ Ÿ€@‹5@ō–w;M™ô5@|Ŧ™Ėr4@Īj€ÄR+K-đ˛Hgj•)XŽË™ÖQl‹ußųÚđáåîF aœ=–@ @,' ĶŲÕÅe—]ÆÆé[ˇŽŽŽ.2™ ™LGÂo}ūû|ģlLĖę‚KA(p˛HŧnÚė{r€ŨÛzˆ¯P`ķl!"EEã'Ąî f¸Đ*ąĮČŅåTQ]—鞌ŒÔ…@҃hĄZ0Œ5 ØĪUB"%RēØF Ģ^ÅŽUp]{A‘A€ŸEû°ÎŅ‚ūxlļã)Ô“Ēųę˙{°Ā‡Ú?…ßī[>™BǨŧĒb;.fÕ(>˙đąō­ŗ{9 Ž‹Ëš•ŽäU ]UĻÕ˛WŧwkgümuTā’uitM™Eø=0Ȗū ]]IO)ŧˇ†Px8äą@‚>§Îs’ Ė!×Y4*Ō°Į%ŽYĮ1ë͚Uĸ#h ĒD¨SĩngáJ‰Dâ˜ļYĮ1ĒØFŊŲ˛ųÔ+ŧĸč<­EĪŲ9ŖŠ ēŽbۍČj8 ÎzÅΘ6•ēA4l×-|Zņ€oLød ]­ŧ˜§5@|îác5PąđŌk@ŧŦŌ}Îi€ˆÕk€L”Îč3(ŽMėąŊgŨ˛˜͸čĸ‹Ø´yķ4ŅČfŗd2t]Ÿ&ËŠ?ÚŪßÅ?Ŋ`ŋųĶũLÅxjļËãƒv÷ĨĖ !Ž+9xl‚|ąÆĻõ„Cį~S3Á15Ėņp˜p¨“ ­XEúŦ2štOŊ,š]ÎlŖ†mÔ0hčô UG „PõĀô—PõÆĩŧDė¤DŌ,&ˇÍ“¤ÃŦ#Ĩģh”c1ėfqĪaCAJˆ4ŠM"€€Ļ`Úî”×€bĩmäđ>ߐđ ČLljåÅŧĢbâ3ĢA: -k•*ŪÔB_f1ũ<Ģĸ*ĢîØ2ē@tGDķĪvĨ"%ĸVöėœ™ÍØŊg7ÛļogŨēuôôôĖ‹f!PZÔiJ7]ą‹Ė—y÷žŖLUU•-‡ĮōėęI™‘Ë.ņB•Bé{Stu&Đ´s_ÚEUĄō@ ÅÁ$q×fUbƒY¤ÛŠĸĘĨ˜Ö)AÚ&Žmb5߀€ĸĸj:ĘĖ/UC(ÚtM‰˜špZņDōäŗI×A:6Žc7 ‡…c[¸–Ņø™œúÍ叛)4@2xŽuŊZĄ Fq†>UDI@ T­Ķ›I´czžYQ‡O@lX*Āî–ųŽKŨŖgû >–`čĐ48•ĻC]!Pî°žŦsÕĢ QmeŨp$0YôfA4 Ž:åĻ/Ą7$¨)„tĄ*EA¨ô Wē|iīAÚW#á '‚bąF4ŖĢĢ›Ë.ŋŒūū~úׯ§ŖŖƒl6K6›EĶ´E3VLÎÁ[Žŋ’BŨä/ 1g¯Ú.û ėėŒ‘Š…fÍjÛqyöDŽ#EÖ÷&éČÆŅ4õŧ8Eįî`5˜&îÚė°+l4‹ôØtéĸ,yŽ5H ŽíØ0ŗÖ¨ųî]ĮT„ĸĸ(jCWEQĩ%SŠubŠĘOũ;9cē7-] ˛Ąpīē6Ōu‘ŽƒëØāēČ­¤WģŌĄ0¨E9ĸ'xB#û<(Ė×įņ°ĻP0NžĪŠŅ–Æ)ŸBüġ2|2ëĖå<ŅŠT} ŗŲÆõÛē é*!]EW•Ų§*PTĩ)ÖĻŠ }y^Ô!¯ļāÕÕk€ÔדK<šXŊįōŌ ×M1Đ÷ąJÕ õ÷.Šf{Eû„ĸ‹Į‡ÃD"Qö\´‡mÛļąaãF:::čęė"Iˇ%šąĒYUxīMĪ%¨ŪĮŸ>1ĀÔ(ŲŽd˙h‰5‹žL Uũö Ëæāą ŽæéÉÄčČÆ‰D‚(įI=āš/â@’ tYo׸Ā*ĶcWHģBĘe’™–ü ÁJ×,œ%ģ{֖lģ,Ą0¨Į8ĄÅxJQR4œķĖ3Šëŗ×rhNí\ąÖōČũSŸøF‡O@æĸĨŊæJ^Õ0čk€ø˜ˆŽqÕe›é5m:Fŧ*B¨Ģ+J‘đ˛H&mÁ>qę1iwžĢj8ëĐŸmÉõ‚ĄŅX”p8LWw7—]v6n¤¯¯ŽŽ.Ōéô‰fŦĘxŌTūŋŸCWü1ūāūƒŒNĀp¤XgŦj˛9% ΋ˆYļËņŅ"c%"!Ît”D"L´IFÄyāũvT…Ę=ÆĶz IÔĩéˇkl´ĢtØUŌnuĨ„déĖeM‡+jˆQ-Ę-Ęq-LM(įtĮé0י Í™÷UŖåuŧīöS¯|˛ú[J@lÛŖËZ0āEF†é´ŖŠĘ}ÆÉēÊí˛¤D6Tg]:B<ŦĪ{^WJĘ5“rÍD* Š ÉxD4D(¤ ´iŽ~Ž XÍčH! ŗ?@E’.ŨNõN´]'åÔIēæt ‰đ`g?‚šĐ˜PCÔ0Į´j˜šĸâxōŽ=bˉÖ-§ÕQđGŲ' áĸV^,īŅt8{5@ÄiČA ą9ĐTA@U h z3¯]WN’­™v„ŌHąhäē TMEQTU 5SNJĻ)3Š §ū3cXgĨŽ4" ų\YōĐįÆ=līōö˛H4ĩ˛hizW¤;ÕūâŅą\Ĩí_í\‡ąõR‚Ī<´ĸ ‡ųđG>ÂΝ;Īižĸ^xŅžÜ•æC?|Ī¨Î(X¯[ä†'I5úaŅf”cŽá*%Ļí0–¯2–¯6ŌÍDƒä*Ą N8¨ĐUTEAQTM!Ÿsã+itÔ* •˛åY-Š”¨€.]˛ŽI—ku,ĸŌ$æ˜Ä]“t@Ęir"Úroâd…”T…FYŅ)ĒAŠJQ5¤(tÁy—Vĩė=ĶÆX`Møđ ČZ Ĩ c% –íāÖmĪj€ėÎDš°3N\׈é*é€FXS Ē !M# („4•€ĒĐ5ĒBHך‘•€ĻņĨÉBÃc'ØVÄĖ˙‰ibĶ"Štj×GšîÉAO´š=į9Š’÷fJ™ĻtŊũ]ŽĒĨZÛĪ)C×ŊŠūâÚČŅ千Žķū?ûSvėØq^¤ `cw†ü­ķŌ‡đ÷=ÍŊu wڐ‚\Ũ&_/T+ôD$ŖAb!Ŋ™nļĀøK‰#Áqm ËĻ8Ŗ“Ũt´XŽēhĀšo"¸\Ā*U-Ėq ˆD \âŽEÂĩ‰K›¨tģētHMJTéĸK­Ģ@mēŽÍ…Ā –P°„Š%lĄR%EĨ,TŠŠÎ¤ĸcĄā6‰†o./ļãĖ›ķŗČŊđɛO@ÖŨ­ŧذ¯˛"oÄë/ŪČ^ą‹š°3oyŽA1ķoĻë"*EOÖßxĩT8nīÁm;žÕé:į4@”ļ¯oוTíĩ)Āwô /Ŋ?ū/GŸX’–A廙÷ũéûšå–[™‹–j€”=*Bču XX'X1îŅÚĶōfdF×ĀÚ īj€LĢ € 0$€ØéˆW5@Ōéö{˙Įŧ°‡ˆFĢŅY›xSĨŨĮĸžtMeKoôdšųĒ É•*ÉņėHž‡rÜ;^$oģLē.ÃŽ\TH/?Ŗ­ļFĮKô÷ĨũAöqÖ —¯Ė"R‚a;ŗØw:Örįܰ?ō>™‹–j€=Ģ‚.8áa˛1 Ĩ­nĒyĩøßļlOÖ díí˜ä¸’rŨ›ī$Ė<€Hh­yHt/öžŧ­Ō~28QļŨssYSWU?v^‘!ĐTAW*NW*ÎÕÛ7đ[RbZ6ãÅ ãÅ*ãĨ*cĨ*åÆK|bø¤„é¸8ŽDmvS<6\ ˇ;‰ĒúãīãėĀĜČüÜsjŗŽ’Œļ”€Œû"„>Yˇ”Y[ŪMķ;6YķŦČEņ0ú*‰›Ūŧs=Ʉ4bŠBŲuÛr}Ûv{2š6ˆ},Ÿ¨BXß`}g#š!ĨDJ(ÕęüÛ§žÅ”ú‹+-ŨÕfڕã¸LäËtu$üÁôáy˜–MŽX›õŊJuvŗ”7_ĐÕ憸#ŋ2œën--= ŊÚ˲Á´={BoJ­NÁYûŧG5@’A [JRŠŌVęØ.ŽG›Gf7Ö5i™ãĀäÜzUDUÁ5ČÃ/OÖ<Ááų‚ä#ÕŲŽãįoékõĮņGŪ' aC+/6ėQ#¸æq žUÝßŲÅ3žGÖ˜•į IDATp,L]JJR’QUZå:^“í™möJ™˛­ē^Õq<ë…w 5ë“Ģ# ^!”‰|UzņÆ2ÉčŦû,¸.5)I*JKũĀ„7‘Ē.ĮXWN LLNzŗ‘CLWmށî´ˆˇ(!ؐ8kõ?Āę^žÉõ]i~35ģnęŲņōŦK såR­×˙øš˙|˛vĩōb^Ö(Ô<ûļÆÄWŠ2ä] FĶõ´6xŲu AJQ˜\f‘ŋãēT oFĨ4ą˛j¨ƒĮsžmÁەnŋ ēw5@ÔĩĶ‘Mōā6ÉÆq⑿˙Ÿj~6‰†ØBˆ%-!„)Ĩü'āđYZė [õēQUŪķܝ|īGûNw–ÃDŠNGbļŅfXûž`ûĻN2éÂoQæã —/sāđöœÚ5)áĐhqÖ~ŪĢnžrG;nãÛū›đ ČBØŨŌÉîŅ4  Ŋ̞;Yõõ(ņĒHz‰-[M)तSU)ē.õ%ā–í]ÁžIËAJšŦĸmÛv¸g˙€g7˛ĩ!™(y’€¤âaÔÖ[¸sŖOs2šqb‘hÆJŅŧÆíRĘ}Ā'ŧäū’mŧáágų÷‰“ŅÍãebá!}v=ÛqyōĐ(ë:kŦ_—AĶT|øX 8ŽË‰Á<ĮG&‘ œYĮĮKLĖq}æ…ģÉÄÛ˛úÄ' ĸĨÉ~^MĒ›XŽĀ‹B]úWY€îHɄãx˛XŠPŠ{˛†*Ō–ķŠuĸBĐĄĒL,!%ËĩĪj€ Ö,jukYé!ûŸbÂŖĒîB"Áöķ‚Rąôâķ÷fã(­% wŸ§‘>ĩĸhF ŪéŗRʛˇ¤!’yÆĄ*‚ŋēéjîũŸōLĶĢėJxrh’‹ûĶķRᤔœ-2–¯°ą/MG6î+ĻûhŸ9!%ųB…#'rTŲ¯‡ķUŽgžŋĩ+Á¯^ļ­ˇt—bÄ3+Įšŧ[´´Ųķ g5@LīvāmA ^¯vĀĖyŗö&]ž-Y‘’I×%­Ē§Ũ\ĮõŦˆãJîyčȒīīØ@Žī?îŨč‡*€öˇāĨf؞ta_ÜĶr o !ūUqâ!DEaŽų˜ĢI~EpÛK.ãEoiįcũƒ˙f}˛ZÚâ°äk€Ŧ›#:éU_dyPŅPklƇ´ĸ |ĶK>zi‚n:bâxNpüäm›Ųž#ã\´Ŋ‡t"ŧˆšē2‘Bšîɚž.U ˇļī”2ų™0ô'#gŧۘ”’JĨÂ=÷ÜÃį>ķô1Ŧ9{­6>@ß˙ĘđMoˆLc1—ũŖEú"Aú˛‚ēļŦķą1×äŦŦ‚ÉrGž<Áî­=D"A|œ¨T *5Ģ%elRJĒĻCžTįDŠŽĩ€隠Æ'nēŠËļöˇĶfûĄâūÛõ Čb¸°•+z”€€ā„‡5@vĮB„Vi@ ˜Ļ'ŸÍļlۋë'ĄĢ-­Y–@Îu AZQ˜°*ĻĩCÉvø¯íįÚ=}d;“D›ž*5“ZÕ —+ŗ{×ēˇBôh-S‡Ļļ:-ĮĄQü}&đ/)%Õj•Ŋ{÷ō™ūg}dÎ)ĸĖÚØqöüč6ļŋæ­|ß’k2U)a b0R5éÉ&„ę˛Ú_ĪEŨ°Ų÷Ô ¯/rĄP¨Žø=ĘæDĢYĩšÅpŠÎ¤é,˜6đļž4ôĢW˛Ž#ÕîĮúK˙ÍúdÍȄGĶ€$0âa íŠÕįo{ĩû˜k9žķLPŖSUsZĄ1šš!I ~ƒ›ĩĮ¸isûCĮˆ¨ ÁæĖĢK0šųÕ{v­<×yxŧäI’ЇZ­RŒ3`ôo>wĻĮ¸\.s÷]wķšĪ6"öiöÖŽžn^÷ú×ķŌ—ž”M›7s÷Gø‹Ÿ=Î=3ēŲRr¤XįxÉ Ō芉†u‚ēēĸĸķ)}‘ūŽ:ë×eü琺}ß*TæŦ…Ļĩ"įQ $ÔfĶv°L‡˛i“ĢYTm÷”ĩz—4ūâ9Ûšņōí„mį÷~ôÃ' §Ek5@9S‡Šã27.ŠŠĢrA UOæŧd3-×ųi+—I>tāv ~ĻÆą^¯ŗwī^>ų‰OđØ#ûpNãœčéëãõoø^ö˛—ąnŨ:”fûEoåË}Üļw?ŸzfˆŖ3R`)¯YŒ×,4!ˆę*АF, Ąé*ēĻ ĢĘŧZ EL¤”™¤PĒąmSҍŸ’u6ĸZ3)VNōũRÍbt˛†ĸ4ôe\)qeŖ^ĪrŨi[—Æ÷O‡]šĘī]ØĪ¯_Ŋ“îT|UҎeĀ~øä´H´ōbƒ­C¨Õ-Ok€ôŽ2âJÉĄæØ+xĢaūĀDœ‘§ÍąQUååŲ,Ëež99‰Ņ…ô°ČųŠ˜v˛›Đ ddžĨ{ņšvw'[ŊĀž:q+°ûLŒŸišÜ˙ũ|îŗŸåžŊ÷ÎĢņ˜Ãč_ßĪ[ßö6Ž{ņ‹ééé™&ĶŋŦëHņ‡/./;6Âm÷=ÅWr<ëĖŪ™m)™4m&›ŠšB€‚@Ą!v¨*? !čŒčÍDLĶ)UMö=5Āúž}=)?r–á؉‰Ųīŗb0XY]˛Cv‡tŪŧg×íŲL_6Ųj'ÅŠđM?úáĨ eĒ+%ãŽëÉĘUŗnáŲDY ë3ĢsúlD“|ˆæ×™&"BĀ҉rˆymØ;S[R…āŌxœž`Û'&8Ūĸ(žô¨.ËyM@ڊáĻˆ'7‘Ë{2­žäčšnR†€˙Ŋ֎ Ûļy衇ø—Ī}Žģ~ösLÃ8åfļ~ÃzŪūŽwrÃKn “ɜ֓Ŧ* {6õōõŨüîđ?|ė˙ųĖΘ6# ¸ŽĨŲ(Āq$3[ęMĮuY×[p;ŽäČ`žą\™ë2dŌŅĩōtûXÍŲ=YelNƒœĸąü3HU…uēĘ-›:yūļuėŲĐC,\ëyPŪãŋYŸ€,Ņümv÷šáģßúĩZíŧCĐŋqˇžũíÜpà dŗŲetĒǰ}]'[{;ø81Ę/ņ“Ŗc<]5ļ]Ɩ.=RŦc:.›:ã‹F9*u‹'’I„Xߛ& ųDÄŖ¨ÎŨg?I AB@DD…ą —v§ØŪ•bKw†]i"ÁʙK7¯â˙v}˛´Ŧ5”&¯ëėäŪb‘•JxŠųęXŅģ°bĢÔą¤äGĨŌĸĖōLĻdŲŽÄ°œĮ}Cv~Ô)¨(ŧ$fC0Č×ķy*ĢČĄR˙Đ÷:2ĢÄšRØŌ{€"č ļ4÷ßž^3_@ŖöãŊkôY ņ•/™/~á6ōšÜ)ũÆ ŧķ]īâún N¯ÚWA"âĒí¸bëzŪęēŒM–91>É`žÄ`ĄĖ‰b•Á˛Á‘jÕRōˆyRāw°bRļ ėėI6 ØyΉÉųbL2L_wŠDÜ'"^#ûŸš×ĻžP1æ‰ Ūū‚ j*ēĒ ˆ‡ÄÂAŌą0ąpE4RK=đv|Öģ>Y*Zj×…¤RŦš=—#į4!`ĀÃ-x7G$ĩ•O¯ĮJĨSĘg2%Ë0mov°e‘ÂEvEŖô|3—[qDĪ?ęŊ‡ŪŽ•RÍ^ŦéÉ*‚¨ÖŌãÉÖR4é ŗŨR­VųÁ÷ŋĪĮ>úQŽ>rĘßíéëåíī|'7ŪxãŠ"K%#ŠĸŌ—MŌ—M6検™“HĮĨR7ø›oũ‚䧝zEĶᑁ<;:ã¤NQxîJÉxĄJn˛F<¤§#N&k(Ģûԁ”P,Õxúđ(õÚ´OÎļUūvk¯ēf÷ŲđhĀ…~îąO@–Œƒm°íØskw7ßÎåxĖ0Îp*Šā„‡E/M„WÜBsÔ4ųībņô›^ķk^Wŋ6Ãļl ļ-% Ō8õmĨu×uuqOĄĀ• ö2­OEQ<×ā|†:(\ ŧĒŌŠŠh­-:vi(Ą¯1&mŽũp]—ĮœüČGØ{÷=¸§člÕÕĶÍŪøFnžųfúúúÖ4b ÄT§Ģ“ŸŠĢ*Ą€Îßŋú…t~÷>ūūā0•æ6d:’ũ#E.H†éJGįu͚KD&ËuŠå:ú‰™d˜L*J"FS•=įa˛m]×|Bŗ„ņ2M‹áCc%ÜΓrŨbĸnĪ:¸_yųÖŗáņĘĀ+…ū›ö Čr°¯]ŽiŋŅŲÉæb‘ī”JXgČ}(ĨdĸT÷ŦČÆÔōŊ˛1 ū}||Y†ądmS˛\Ûņd;–L@[Rڛ.×ĻĶl…øj.Įä2"zBCŲ/ņtUYUw ĄF§įH:juw›ĩĶ W´Í**—ųâmˇņéO}ŠŌä⎚d*ÅŪôF^õĒWŅßß?¯ĢՙF8ā}7?í÷íįî=‰fģ+á`ĄÆxÍbKgœHP;íūoÚÃeFreTE! ’Š…G„:Á †ĸĖŽ4Ųąm‡ēiS¯™”*uō“5LÛĨ3Ą3'ûЧsl)ĄXŽ‘ĪWž(c;îĸåđXyÖ÷nЇØŅßíųĮ~_ņ€˙Æ}˛\ėmįÅU!¸:™d]0Číų<Ãg@'¤jXāJj€,Ŋ¯lnheĮa_šĖOĘeː:wkˇm\Ŧž\7[Bú’ĶVpA8Ė;ēģųn>ĪŖõú’ÆM¨ ksōą¨CDUVå‚(ĒžŦeĘfc­!|FąVõs€–Ë1K)yúé§ųģ|€ŊwŨ=/§~ Ąp˜›_y ¯ũëŲšs'ĒĒzvūjĒÂĢŸģ‡Ų$ôÇøyídĢā‚aķČ@ÍŠ]ŠSGCfŧļã’/ÖČkĶPUMSšÄVâēËqqš­Åįzī„ĻB$¤Ņ•‰‘JF‰Dmiû*Ĩ¤XŦ‰Ņõĩ{_Ž”äķE ëēŽĸĒĘô3JŲH™ŗlĶ´1 ‹bŲ`˛\Į°œEįāre sR˛ūęúK[Ũl> ü›ÂødŲBäĨ”÷ĪmįįŦ …xKW?hƒčÛéP÷´ôĨOF@ęŽË°a +%vķËp] ļÍ eqĐ4Н2š$g×í4sۋkgk4¸ė)×4~ŊŖƒ Å"ß[BD/ kdUÁ¨í/ Ž)+6ˆ¤”ÔjĻ'UŪ.čH´z{ģsm|/RĐą2)%÷Ū{/īûŖ?fh``ąs‹/ģ”÷ūárÕUWĄëúY1‡!¸zĮūŗ#É?ũč!>v|œjs{q¤ä`žÆpŲdc:B*\Ö|ŸJŠrÛĩ1Ŧå{Ĩfqx 2T  БŽ…‰Eƒ(ŠXUJ›”’rÅ`p¸ĀXĄJ@SØØ—Ļ#oĢîÉá92ŖT5€FąˇÍ?‰Ųŋ+gŒåR‘+Õ9<§Nõ=ëŌ\že—§Ŗžŧg­EK}rnáķí& aUåæl–M• ß,VmD/–áa !Ø0ŖúH­Æ‹E,)ÉģîÉ ­MDa*/­ęBĀáŅR H{mØģ“+~Ԅā9͈Ū×r9ÆN‘OŽ(‚ qF‡&ũŨĶˆ†´@M )Îî)\Ņz ą5ēõļ¤_ũôΟōž˙õnĘÅŌ‚?īčęâ=īũ^ö˛—‹ÅÎʹܛMōׯ~!Ûô Ūļī8Žr2Pļž-‘)ÖY— oS$â4ë…RÕ T5PDUUHĂ$ŖAÂá ĄN0 ÍIך—oDZ\ĒU“rÅ`,_Ļ\1§Ŗ/†åđĖŅqNŒLŌߝ$“‰ĄŠjˎzוLNV$_ĒĪ r:%mĩ§˛&&k˜˜zĩCSøã_ģÚ˂’øwāV!„…Ÿ€Ŧ_>$׃sI,FoŗÃĐá5PN7ëÖ étÎđž[ƒļM\RŠBÎqÚ-jg4d`˛ęI¯qgz冇ևBÜÚŨÍ÷ķyY5„e)åŦÕgvüNW?ÉįŲ[­ļÕȝŦ{vėģ"Ō3ČhŗFĻ$%5ĮĄGUsÚŊĘgl€.:õl°Ķr"žt ëVŠ<3"zå2ߜœÄXā Zי@bQg˜tfŖSÄeĪoéJayđ%Aw Øę­āŅļ/Á6¤_I)ųįOšą‘‘y?KĨĶŧīũÂ+nš…`0xÖÎaÃ0øÅŊŋāŸ?ũizđAlË"Ŧ;ú$åk^ĘÄÖKpfĖ)!WˇČ×-ÂZ…ūxˆh$@4¨­š€ÉŠË͟Râ4ŖvͤRkͤĩl—ÁąCãeTEˆIDƒ„Á“5Š@(ĸ]q],ËÁ˛ę†Išb2Y6°lgQÂĶ*B6Y68œĢRągGĐ;„ā‹×_Ė•ÛÖ{u æ×?đÛíú¤•øŋ­†ĩúĀ ĸpc6ËÆ`o Ģ}[œ\Á€‡[đÍ(|<1#"dCŽCĻųķ gM¤•Vô[FfZ­- Đh˛pi˜ķĻH—Ļ +-íä°6-xwŌâôĢ;īŧ“ņ‘1Č4#EUøä§?ÅÅ_|VŽ5×u9tčˇ}á6n˙ęWŠV*§Ü°÷ô¤xûMWpɕWķËÃŖ|úáCüĸjPš▒ĸéP4kQCCŌ"ēJ, T›ĒÚB4Ō¤Ät$ËĄb9T-[ž4Ú+Å:'J™FwĢ„Y'đ㄀ˇ^¸ž/ŨĘēlԋЁđmāO€§ũ6ģ>Y!l)å›x͚>°%áÖ@€oįrė7ŒUŲõēé]  á§, IDATũ™“¤ļÂ%‚ã‚McÂļ×*%K–Ĩ&|ĸRÂ^ö‘`Û7üŒŽķÚÎNî.øiĨžm=yŲFžČ3fĪ×íˆ#š{ŲüŋXČŠUˆŸĢ7ku‹Õ;×ĀØˆŊ­ŧā}÷ŨG~"7KÁ\Ķu^zãKĪĒĩ6<<Ėį>ûYžtÛ1ŒÅy`GW'o{ûÛšéĻ›čîî>í~ĻŠ ôfŲܓá–į\ČHžÄÁĄ žÎņĐPžķĒŽ¤"%WRCR• V‚¸€¨"ˆ* %B\Ũ—e÷ēēSQ~ņ˟ߌ‡ë6Å‘y)Á”’‘ĒÉHÕDšš°Ļ¨Äô†ØĄÚė€Ĩ*MR"Áv%ļŨHaĘÕ, u Ķ• ÚuĮ劉 ĄBūxˆd,H8 ŽNs„F7ŽRÕd¤TgŦfޏ‘HØ *d5…[6tđÜ-}\ēš—D4ŧæí‘—H<~ |¸k I}ødš„ÔĨ”ohŽÃ×pLø„ĻņšÎN6‹|ˇX\qN€§5@4…ūøÉĒã,y kRRˇmēU•’”TÜ5Ą!Sr!Ęéį-ŊH@z“Ņ5™Đēŧ(f}0Č×ōy^zÍVžxׁŗĒ#–*×míâ=īB^ڑE‚×wvrW @é9[¸ížgĻSÍŧŒH$pĒm`Ē.Äjîyŗ\æŽteÅöf×Ëē[."~` nûUM˛×?Ž”|ãëߘE>2Ų étúŦXgŽërß}÷ņˇũ7xōÉÅĪÅd’×ūöëø×ŧ†7Î{æ%œíhĒĘēŽë:Rŧ`ĪŧÉ•Ô “ÉjÉJJŨİmLÛAStM% ˆIE#Ä ÃŠÔŋmëē¸åę]ė;<Čž<Î9žĩiÄ“ë¨Qb™6ÅŠßSEâ‚Ųyŋ§+O AaÆ/Ô—ƒ…*ędD@Ĩ3 ԗu™*°ˇI­nQĒ[ŒT jļģ éy~Hį•›ģÉUä¤d9”m]DU•îh€îhˆžd”ŪdŒūl‚žl’p°‘’æAX4ę<>䟀x‰„€ˇpēM‰žõ7S˛Fėå¯ Ŗîá6ÕáŲŠ@šejĸH`ØqH( ™fúĨd-ŠéĐd͓^ãŽTtíÖ°5i¤di'rÜųėØYąÄ4•W_ž‘w^˛•+“‰ioŽ(\—NĶq§Påß?×ã Šė’4@ôæŧžU .])ËŽ÷D!ؚhšŪh;īšŲ~÷ļōšŽãđČÃĪûūk~ķ7Ίô+ĮqøĘWžÂūúo¨×îIĢé×^wīúŊßc÷îŨ¨ĒÚ˛9¤¨T­x$DĮĘŽ‘ІšvĪžáf~¯PâÉãŖė=4ĖwOL0h9uÜEĪĻé"ņ% #ģ‚oģh›;S|ūū§ųÖD™ąDđ’ŧa“7l‚p@%ĒĢ„5… ĸL× IWb¸.UËĨbŲÔ,ûÄg§ĻđîŨëyÍ5ģIĮ"sHŌIE­)ĸŖáõÎk&'#wûÄÃ' ^"!†”ōĀ>Â3kތ#€ūPˆˇtuņũ|ž‡N!úļĐŋöp ŪÍs4@Æė•­ũĸë‚.MclmR˛ĻœU‹Ļd55@âžt =éĩW?NhŋŲÕņkuŪ;y÷—ŊģÉ0ŋõÜmŧeë6„Ãķ^˛ļGŖ|øú+č‚xüuGBē3ąå<ūTzRzW¤7Ørûņvß6pi+/øä“OrėȑYŅEQ¸å•¯ôüëē.Ÿüä'ųø˙ũ'œEÚŦoØ´‘÷ũɟpí‹^D(ōô퍊Bo&Io&ÉĩmáŨ†ÉąŅ‡FōÉs÷`Žcu“˛#)¸’qyję‘‚nUĐĢĢÜŧŠ‹k.čåĸMŊÄÍ4Ú̎oāmĪđՇōÍĄįW‚‰Ä4l&•¯pĄŽō֝ëxÅåÛčīLĪH:+Û:[Āđ-ā Ā>ņđ ˆWIˆ#ĨüpđĀŽ3ąę"ĒĘ-MҎ;}[āæ,Ô<;ļ—%Bč3ŧ#CĢP…7¤dÔļéPU ))Ž]J–\h'6MÛĸ…i­Äļė™Q˜Õ„āyŲ ˙ųëĪį_ģ›īOx„DT…—ėėáM—oãW;ŗ„OãiMüéKŽbw&Áûîy’ÃKUĒBovŲ¤3HŖ#”eZNČõ šŠĢ‚Î֊ę9Ā3mží]@˛•\(ũ*‘LŌÛۋ×ņío›ūŸ˙ƒ\€ā†ÂaŪøæßå oxg†‰Ē(ÄÃ!voėá=ŧĖ•ŧÛu)UëL”ĒäËU Uƒ|ĨNŲ°(Ļãĸ)‚xP' Ķ•ŒĐ“ŽĶ—IÔõy߂ēÆ5;7rÕöõŧc$ĮũøÆS'ØWŽsØvYiÛϰYSya6ÆËw­į9Û֓MFŊXŗą$žK#Ē;Ü$wwU_LĐ' g ‘Ā>)å €ĶĄ¯yJ–"—Åãô|#ŸįØ ök€l“ tÜ^BcŽCD:Tu­ēdM‘YŅÛrŧI@4…uŅ3W–"€ÍÉ8˙ų?ŽãĪīØË§ŽMā…@‚‹ēâŧōōÍüöæ~6G–^ PU~ãĒ]lëÉđ?|˜īåËØōLžbAJSXßgįē4ŲŽ$‰čŠ<Į*  Œ—æŸ=…ž€FDoéądíŪ0ˇĨ–•ëōĐŨ¯^đĸk=-0M“O|ėc ’žuëøÛŋûŋōŧįĄigŋé!ĄE%“ˆ’IĖĐŋšŠ‚ȇČT]Čí}UQØŌÛÁ=Y~ãš{81^āāđOæøåPž'Ëujޤ*%u)Š7?+„DADQØ ruoš‹ú;ØÚ“Ĩ¯#‰Ļ¨œ…ŧÁ‡€Ÿ÷uŸtøäl%"9)å[_ŌHÉJž‰ûčųŸ]]ܙκˇZ]Ԁs\—bÅđfē„Ū –ëbHŲVW•ËuénĻd­A~ū<ÍĮļ=š^ô€J‡~æyQ*âïz!Wî}Œ?đî™9T!ؚ ķk­įĩ;6pi"A`E’B.ŨĐ×į%|īąƒŧûž U­…ˇÚA8ĒBG"Ė–îq:ŗqēÚ  qh įIŌÛGmíŪærR<ģõ[ž” ps+¯™ĪįyâņĮæ}˙ĩ¯}­į#?ũéOyúÉ(sZĮ^ķŧ_á>øAúúúÎZåöåîbæ)˛ĘkéšĘæž,›{˛\ąÄ•’JŨ`˛RŖT3¨™6Ļe#]S é‰pd4L<DJĢĩuÖ.đkĀ^ĀöĶĢ|r.mŽ”ōĶĀĪ€/æ ¤d……ŗY6…B|ŊPX° TÕËčHzįh€´–”ŒØ6iEqsŽ;tŽÁCM¨—ĢĻ'͝ļ†tRē7n-ĐxÃĩ—˛ģŋ“ŋüÉ#üp˛ļfÅÜ!E°Ŗ#Æõ;ûøõmëš,™ Ō‚ÂÖH@ãÅoáu™Cyž>6Ááá"“ŽËjë(!ˆFšX,¤Ķ“ް!“‘DXĻSQ%ÔÆL w#WJFF‹žœË[ú2­& 6ĐNąąĐŅĘ ūėg?ÃļėY†z8öŧø`Ŗs××į‘x2ÁG?ö12™ >Zp )A2& Ÿ,?BüÄû>9WIˆžRŪü%đĮœtėŠFéøf.ĮA͜•rT¯[xšHl}ædví4"„+Ũ‰rŽĢĐh…;Ú<ü•5x-›(;^\3ÅC­6ÚVmT_ĩĩŸ¯ŽīâûžåCŋ|š_ÖĖļ¤0i˙{÷'Wuß}üsîŊ3ŗŊu$$!$!@X406&€)Ɣ؀6aœ@\ã8ļĮ%ąũ$`'lãøyĮ¸….Š1E B€PĄ ui{Ÿ™{ž?î]iļTĻÜŲũž_¯}I;ZíÎŪ9÷ÎųŪsÎīCSÂcŅ!õŧwæDNŌÄá•Ä'Ģm¯5™Äķ\™ÚĀÔ)õ $ĶėÚÕEW{7[ÚzŲŅŅKw_’d2MÚÚŨ#˜Nx<<×!s)KĨ,QSŖŽĸ„XIŒDY‚ęŠqŒœ&ļ¨ĪE{K%ĶėčOEî"b€“Ļ5dûšņRŽī–d­„ĩ–%÷.1JĐÔÜLyy9QÖŲŲÉŖ?2âņk>÷9…9˜ˇüŸ”Ԑ1DŦĩ_#XĐt30ĨĪŖ.ã˛ÆFkoįĄŽŽŨw‘SũÉčæ¸Į„’= H{÷cP† Ā$Ūf×įƒo˜WļļGō|™R čo§<įücįōŪ‡ōøËšuÕë<ŊŊ“×Ķit“c eŽaBYœ#&×pėäzNž6Ų•å4Æã9 b[3Öf9ÆP÷˜<ą&Öp˜ ËVZKÚˇ$Ķ>épôĪubá†dÁƒųã{ŽhCdy¤īÅõ;ˆ`Ŧ×á¨ĒŦ™û]ŽŸöײS)ÖŦ^=âņ~üãY+S›+<ōŨ8ĪĶu]Î<ķLu,ä@ÃĮ/ĪcŌ: ã%„øĀŊáõ[€÷P€ę1ĮáÔÚZI$ø][mé4ŊŊũŲũ]w÷‰‚9ĢŽ ~Q×< n‚ÍįJ€¸įsƒų¨qĪ%î9á‡K<æRS§>c*P{*įĶ4]ā •`šE39ŠgIŸ7[ē#y‰ž1Ą6ÂįT—&8ëČYŧoū ^méāåÍ;yøÍ<ĩ­–Ž>ZĶ>­–!k+ AģĢp ĩ‰õU%L­-㈆jæ6Õ2ĢŽŠÉ%%TĮŧ .}ŽMoQbĪ)ƒãB,–ĩŽb#Áú…@YXŗ‘Jû,[Ŋ)’meRÜezyÖˇŲYšŗSĪÚpT6ŋįK/ŊÄĻG”ß=í´ĶĸŨS´–ûīģoHø¨kh ąą‘˙\iŒĐáPAd}8%ëī/R )Y‡–•qU<ÎŨ­­<ÕŪKÜ1¸ÆålLPõ"a cđÜ`ēGÂsņ<‡¸ë’ˆ…a!æwęã1Ēã.Uq2Ī#¸%žK‰į÷ŧāīqX¸klIĖ%îēxŽ!f žqđLđw×1Čl”ņÆš3•Ę×!Ē#¨tŗƒMÉęęŒhéc 'ÕÅųs]oŦeNc-ī_0‹ūtš–ļwõĐŲ;@w2ĩ;„ĮP–ˆSUš Ļ$NĨįQķˆ;N^Įpo ė=0F0k[øįôY Ī­ŲĎūhŽßœ3šŽDvīōįēoÕmÛīēķΑåwkĒihhˆôšL&yrŲ˛_šø*bą"ûyŪūøŧ‡Čx!ÖÚ ˜’u š’Uåy\ÜФŖgĶ2g Ī%ķHx.1/ × ‚Á°āš`4Å5Θ`>QFī=ãwŌĢ?X[“y]0_FPę|#ĐD0=+kže[$ÛgyiŒYåeÅuNŽc(s‚’ĢSÂįoGųē(đ­ĨĮ/h•G˜´-ĀHŸĩ°~ãN\ģ9˛íâŨ‡OĘöē 3‡OųƒŲ|õ}Ÿ+VŒxüØã|ųŨgžy†m›ˇāf”×-†‘‰œ6āoŸĢ´Žˆ°{J֒BOÉrá”ŠFß%/‚ŪLæŊb—L:€.‚Ґƒ>L=Ŋ<ūڎHãĒKiĘîÆm &=˙9Ĩŧœ‡ēģIvķž‚)Y-áß÷i¸ •JķŌË[š{՛¤ühîė^ę:œ>Ą1Ûm Čåŧɏeķ›õôô°zÕČMÛ/-‚ōģwßuאđPV^NSS“:˛/,Áž—ëÂĸ@ĸ"‘õÖÚŗĢ€oR =CŠĄšwÚZZ}?˙)-8<Õ@A•Ŧƃ ‹ÖZž|ö ŦæõpᔌN͜7¨Sjk™ZRÂo[Zh/ėhHŒ`ĘáV ÚˇļläΜ–TÚ§ĩŖ—Ö,eovõåwôŲ•%ĖŠĒČöˇ}0Wkm˜ŸÍīš|ųrzēģ‡LÁŠ'ą`A¤Īß÷YūÔō_xņE‘š‘HčnžfŒiĶáP‘Ŋ‡~kí Ÿ_ķŠ$ä÷Šâ|ô´„`yĖV‚šÚû]^ĮZ˚—ļđÄģ"{œŸ5Q-O!äĐŌRŽnn掖Öô÷˛Co€‰ŠTēįÁ'_ĩÖÚŨןždšîž¤íî0ŨžOoē8n$žsŪ$JœŦߎøi‚† CŸG0 n6°8Œ,Ž˙°Ör÷ŨwX˙Q[WGEEE¤_ģ 6°võę!ģ›c¸đĸ‹tᐡ’îūxJSŽ@dßBˆžˇÖž|¸ qŗ? ËŽL$XŽÁ<đ‹x÷),ö¤xaÍ&îqkdqEIŒU_?Ÿ*=ŋllä鎖tv2PĀ‘ątÚ/{æÍ–ŅJÕ ‘×áÃsĻ î’ÍÎÍķ8ʀぁ“ÂĐQE0íÍËö1ö}ŸĩŖ”ß=įÜs"ŋˆûûīQ"8žH0uęT]0doįæ}{{,ÕŽæ r`A¤ÅZ{%°ø.š’EqĮáâÆFĻwtpog'ŠÂNaĒ$Ų4YkĄŽ=€–övíčāɗˇąš;Ú8NkŽb’Ļ8$\_]ͤD‚ßļ´°+]˜õí]ũØ10Sznu)‹j˛~éė"X ļ¯Ą# ŧ? īĘs6Fŗk×.^\ģnÄãįw^¤_;k-˖-ąFeŪķ#?r#y7@0âņ=āqQ9øâ[koūMÉŌQ;Ąēš)‰ŋoia{ē { ŀæÎÎŪ–•k7īŽ[ëû–îū]}I:ģûčL[zĶÅ1|ęáSr1ėöŋĀ9daΉ1}ŪĶJJXÜÜĖ’ÖVžéí%ß­fK÷Ĩ9€ãxūĸ™”yY+ēĮ“|›Îŗ Ė> œĖ!(sœ÷ë÷Ãū3žīéȗWT0íC"ũúõõõąüЧF<ū‰Ë/üÂyɋ$°¸“`_g]ĀŽZæĶéī¨{,ĸ­öGIÂãĖ™“ŗũm[€W —Ēŋĩ2×åŧúzĻuuqW{;ũy’ØŪÚUôĮ¯)áqéĖ)ščņßōÁÃl`ú‚Ē;åđĻ‘ĩ–x`D‡}Âĉ”•EģÄöŠ+čhmRËu]-Z¤‹Ãøä‡Ąck:n'Ø ´WkP_ĪôŽ.noo/ØÜųŊcâļÜY“j8,û“ Ãä?žn%Ņ“ˇāÃ;*+™ķģ–6įgN:[‹äĖ#Ļäb÷ķ~`å^:û ājāČō&‚*NķâēG<~éG.ąļ"jî[˛ddųŨŠrjjjta;R0ęRNŽ>`ÁTôĩĀ3Ā* OĄC@ōB|āžp4äfā4´@ĮVV2)į--lLåw$֛[{ÆÄą<áĖl/Ú¸3Ŗléō°ČĀÅhJÖۚHpEs3jm剞r9áĐZK{Oqo\sųĖŅŗ‚ÍQŗk-Á ÏŲaa¸.Č>N{Mũ6°qũzLF,ã8œz꩑~ũ|ßįŲgŸņø^HbŒėM4Î ,˙Y22%ÃĮvLąę’ ĸ ō†ĩöāī€/Ŗ)Yģ5'|ĸš™‡Z[yŧ§'S˛ ´´™[SÆŲS'äâ[˙rXŪe­ũ(đš’ĩOŽÃuuLK$øc[=9éKû–žtq¯@?ûˆ),ŦÉIŨޝgî˙Nšēø õČôāƒ°VUUEccc¤_ŋmÛļąjåŗ#F@Î?˙|]Š×∯]Ā ĀZŗ!šĸ;ķš !Ā7õnÔŲŖÄq8ŖžžKęę(ËĶbE ´ˇ÷ũąģäčéÔĮãYīO¯ŒŌ†SwOž´CíÛ]Ta~E‹››™™ũ×)hËžĨˇˆK`MHx\ˇhąėīũŅ<œ>āZ‚°‘ ÖZ_ētÄú™ŗfQZíŧ˙Č# ĩ ünÔÎË) ‡ BÕįĻŨ^jŒyTáC@Š;„øÆ˜û€“Ã\C”o^y9W773;Īų*ĐdĘgÛ@ē¨Ų䊗͟‘‹oũØŪ†Ī1Öķ4Áԕo…oVō6ęc1>ÚÔÄû**˛>ÍČú–”_œÄøø;gsduU.žũîéWaøøk‚ŅģHÎ JĨRŖ–ßŊä˛K#]EĘZËc>:bãÄÉSĻPŠōģQfÃĀŅ ,~@Pxdpļ1æFcĖ ’š‚•ŋ 28%ë*‚?ĩgH¨.ãŌÆF–ļˇķPWWÎæÎ ¤Šž@ō'™ÁŒÜTÆųö>´á.kí׀˙ūø@T;vQ3†Skk™šHđģÖV:üėÜčéOâéČĸæjŽ=bnn:Ø_7ÆØŒđņ"<ũõå—_fë–-C:ōŽër 'Dú5L§Ķ<7ĘúO|ōrWËÅōņŦŅØN°Ŗ? ŖŊ}fŽ×XŦžvŦÛШļ(€Œƒ2`­Ŋxˆ`ΐųhĪ ظđÔÚZĻ…ĩv?ûEŠdqßԙSSʧĖĘEƒŲÆ^ĒŌ†-đ‚ĩö2āhāķEDŌwũ*Čķȝf••qu<Î]--Ŧîī?čyl[ZŠŗo]ĖåĢī=’‰šŲ@ŗøS¸æcqÔÃĀ}÷Ũ7ĸ˜DUu5ĩ5ĩ‘~_}õU6ŧū^ĖËŧ6pŌI'éÍ,?\‚Íu§„m| AéÛõĀ Āáį-aIicLZ‡N@ÆoėĀB°{ú'Q…ĄŨĩ™ee,ŽĮšģĨ…˛ĐQËä'ĶÅÜp¸ö]s™V–“yáíoõ’pˆūéŒ r-ÁfnåDgjgš`7ėû ęŅ/!˜ãüåBœsUžĮōŌŅÁũUŠz[nBčÃâ“æpÖ¤æ\ŨušŅĶi­}ÁԒH‡ß÷YūÔō Đu$‰’hįųî×z •”–ŌĐĐ 7˛üžeē¤đ‚)VéđÃļĪ”XŅZû Á4Å~ ĨĒUĸ2>ƒHĢĩv1ÁÂŪīÁ’…Råy\vÔîíė$•ĨŠ&ŨũE{LΝŅĀG;$WˇoD; "—_"(¸pđn ,Īadp~sWFč¸hœĶl­ũFøæüw…¸ūyÆpbu5S nkiaWúĀBq[‘íb€ æNäú#gËÍÔĢāģÖÚ:ā×ÁԁÖ­]3âņ_rIä×ŦXņôˆįøŽE‹"ŋqâ8 %^ÆĩíĐđöl؆§­ĩköí %š’% ã$„øÖڛGĻ ŧ•ëŨŨQ;Ąēš)‰ŋome{ö )Ö=@Ļ”ĮųĘŠGQååät]Ī>Nŋz›ļœ&¨“ĩöį@#p6ÁÂõãØ3U ›$žĄvĪ” ~‚`ÅŽŅRcRÖÚoo˙‡`Ä&ī=„Š%%,nnæžÖVVôöîWe kĄŗŗ¯¨ÚđÉSkųÖģĸ.GUÁJ†ļ˙ ÔÃ1Yŗf ­ģZ†Ŧ˙đb1.\éįŨ××ĮŠåOxüŌË.‹tpœ0˜'€ÏĖP˛xÜZ쭐qB,°ÎZ{AĨŦk€3æwæúîąoģæ{pˇĶy^¯2ØQûtSKZ[yf?;jC3lk+žâ:†/:Ÿãës6'ü{Ų†7Æ$Ã0rŗĩög@8Xū9˜JPî1s”ÄėĨũ ~´, Ü l"˜÷ŧœ`žs2üšûüJ0úø āČBŧļeŽËšõõLëėäΎú÷q¤Ī÷}ēĶÅ3sâMUüāŒEĖĘ]u¤žđÎ|āĖb9.÷ÜsΈ*RÕ55TUUEúy?˙üķt´ĩ Ų˙Ãu]Ž^x´ŪЋ;”œ~\K°Āũų0,–Ŋš˛% c3ˆ¤€‡ŦĩĶϞŧ8h/f”NšÍ ģ€WyëRŋ}‹5ˇ/ėũ°ˇ¯O‡_ŋ ¸øX4_æēœW_ĪôŽ.noo?Āšķ†Žâ ‹™Îåsfäę ŋ ܜãv&¸ģļX.vÃö'˜ģÜ@°Éa|/íĩ— ÚËVöTzņæÎ\øŸĩÖžø7āŌB\]c8ĻĒŠÉ‰ŋmiaķ>ŒôųžĨ§HJđÛ\Å įĪ1ĩ5šŧpÜt?-–÷4ß÷G­"uâ;OŒü.â>đˆÍËĘËŠŽVaĮ1Ô/ô2É5á Ÿ;­ĩw„a¤G##ĸ26ƒČkĀŋ[kžF Ā ’`ddob;û6ĸaÃÜ>ßͰÖ^Epú °€×5†…••LŠĮųCK ÷sJVÚ÷yĨ{ ¨ÚÂųŗ›ųę‰ (Ë]YËÎwŊ÷đMkđg/……:ßÚŦĩŸĨ@S˛š ŽhnæO­­<ŅĶķ–'ąĩ–dĈN›ŪĀwO?†…5Õš ™Ŗ ‹åÜîííeŨę‘ë?.žøâH?ok-ĢV­ņø_œyF䃓°0ø+‚m^~f­ŊhQąFRwĒß,đsIZk¯ž#¨0S:‘͉—īcG-S˙@*ˆ]E2Eų´iuüËéī`BîĒáä|ôŖ˜ÎŗpJÖr‚)YGây$‡3ęꘖHđĮļ6zö2Ō—LĻ#ŊHÜ1|øˆ)|õ] ˜UQžë ôúĻcIDATSnpôã–bz?{våJēģē†ė$O$˜7~¤ŸwWW+W<3âņ~đ]HÆO9 øg‚›‘7†ëXDDDrÚQķ­ĩŋ ØAõWåWķŪĨO8gÔ×3Ŋ¤„ߡļîĩŖ6¤Ķ6.šđņžiuüûûcVyNoÆKģŨiÛxÎZ{*ÁHČĨ  „c ķ+*˜ŽôŊ–šŦe[[7Q}§ŸZį¯Ū9‡+æšËįƒG?#ĪŖÖZ|ßĮZK?ŨŨŨôöö’JĨH§R` žį‹Å(//§ŧŧ×uqc ÷ÜsΐđP[[Kyyy¤Ī“+VĐßׇ›1*ëÅbvØl]DƘ |‹`dDAD@$/ĩuÖÚ÷„ŸÅ`J–Ė-/įęxœ;ZZxy`ā-;e6ũžļcāŧ™Í|÷ôc8,w v!ũ¸E­yÔöŨf­Ŋ¸ø!{ęëįU]<ÎĮš›y´­‡ģ쇔ĸŪÁ=@JÃ3›øëįrRS^~Ē!}—`Zč¯sũ^æû>~ÚgįŽlܸ‘×^{õ¯ŋÎsĪ=ĮÖ-[H&“$“ÉāëÂMT]ĮÅqâņ8ņxœ9sį2oŪ<ĻNĘĶ˗ø§ū>âšmåÁ>***¨¨ŦÔÅCAäŗĀ?Zko5Æ čЈˆäĒŖÖn­Ŋ– ’Đ÷ē‚tÔb1.ildi{;îębo1Ŗ¯?Ú¤Ôuøä1‡đåã`riIŽœF?Ūēm§€ß„!~IPJ8ī›+ƌá=ĩĩLK$ø]k+aĮvg„HÜ1,jŽâōcfrŪĖ)4&ųh|øW‚5qīĘÕIĨRŦ[ˇŽ'—-ãĄ?=Äúõ¯ĶÖÚF_oī~¯×_}{īŧk¯˙~Ū>éķÂZ˚Vxüƒ^ųā$y "S€°Ö^iŒŲ¤Ã" ’ĢŽÚā”ŦĮĻ­œUˆŽZÜq85ė¨ũ6ŖŖ–ik„KđÎŽ,áú“įréœéTz9?%_Ck?öĩ}oąÖž |ø ØÜÎŗĘʸ:įŽÖVV÷õŅŨ^øļ\î:,šP͇œÎY‡Nfji Nūö€HczŦĩĮY.´Ö˛ūõõÜtĶO¸÷î{čėčČu[ÃZ‹ĩ6˛{i´ĩĩņÜʑ[}Î9ēXČ —`?ŗĮŦĩ4Æ<ĢC" ’Ģ7N ŧb­Ŋø[āŊœjÎ;j3ËĘXsgK kûûwOÉ26Gpįč2×áŧÃ&đ×'ĖeQ}-nî;)āʰ4Žė[ûîĩÖū#°¸‰MÉĒō<.nh`Z[÷9†7“÷JX ĮpHYœ“f6qÎĖÉ7Š‘‰%‰|A7”tžü,ácŲ˛e|ų‹_âÍ ōō YkųĢĢ?à ?ūĮw\$Ī…eO<ŸNãdLÁŠĮãLŸ>] n:đ ĩö,cĖS:˛ˇ~›HļŪD ÁŪ%?fęy¤ŦeYG÷wv’˛cā7÷<ĪËI8†ã'Öpå;fq֌IųXŦ;čĀåZ$xĀí{Á”Ŧ÷R€‘> šmėėfÍæ<ši'ŋš‹Í­=lLĨéĩ–lĮ2@Ė14¸SkĘX4ĨŽ“§6qä¤f”•’pŨBŊy,¯1Ĩ“įdûŦ\š’+¯¸‚Ö]-y˙åšš¸ûŪ{¨ĢĢ‹\û˙ÂįŋĀīoģmČcMM<úøR–zdF5 ¸ÅZ{ž1Æ×áÉGGíakí;ŽĻ{†¸Æ° ŠŽ#šę¸pÁLړI6ļwŗąĩƒ7Z;yzG;:ziëę§ģ/I§īĶeƒ^Ÿņ†<ØÁķ€rc(w e šŠš*K8˛ž’9 ÕĖh¨fry‰8žãú é)āsZ÷‘“öŨn­ũ4ÁZ„§ĀS˛Æ¨4Aa‹Ÿ†qę?su ioogëĻÍAՊĒŠŠÁķŧ!ĀĪū°Pá6F %ĘZ˲ĮąqbcsešŨ¯čāŸûÛũÛh#âû1JnöhœđķÁQÄ˰2ø˜3ūBËŲ‡€˙ÖeM@$ŸĩĪėō ´gˆ<ĮĄ>‘ ž)ÁQMuÁŽÆÖŌ—öéL%éNû´÷ôĶ“LԟL͟JīŪXĖsâžKÂķ(KĨˆĮ(ņ\*<— ×Ãu"7mĨ¸Č“T+ĖYÛNYkļíHMÉCáãģaÉīëú\vü›{8/­{ąí‰kŽŋŽ‚ÍS§Ėōîĩ–uĢ׌xüĸ}hÄĻ„ãŨK IŋM˜É|p2B‹“1õŅ1f÷cc(¤ü›ĩövcL7ĸ÷OÉã™f‡w@CûŗÅ{Âô…áã.ĩŧŧĩīj‚ŨĶ?ÄtDJŠ`ũØwŒ1ikm9° ¨Îå}å•W¸ãŽ;¸oÉÚ[Ûččč8 MDĸ$ÁG?õ)>ųŲĪâÅĸĶ|Ū|í5>pÚûp3*]cøŸģîdúėŲjŠyč  †w”?#vĶk_\eŒšI¯Ž(€HĄ:j›’5ô‹1ˇęPäŊm{ĀųkC&ëˆ|øë5ĶÜrÎ÷}’É$;vė`Ëæ-ŧžūuŪXŋžÕĢWŗūõõô÷÷Ņ×ÛG_o/Éä .z1DI)‰D‚Di Įwgœ{. ;.Ráāļ_ū’ī}ã›CÖ9$JJ¸įņĨTTVĒĩF įfŒ– Žs2Ļ}EĖĀĄZ " R¨ŽšCp§ø‡hJÖWˇjŨGAÛw3Áž!§Q RÔE>§]Ĩ3BŨhHk-žīã[KGgģ:ÚiimĨ­ŊŽļ6z::čęęĸĢŗ“d2ÅĀ@?ŠpaļįyÄãqbą•”WVPVQAUm-UUUTVVR]]MYUUÁĻ3YßßŗĻÄZüt?&L‚1|ũ‹_âáō=‹_ŨqĮM %z;Į\ö§ˆĐˆÉ"cĖ ŊJã›Ö€Ha.ŽÁœî_Ü->[5…1ÔžˇYk?@°gČW„ŽĘÛęžÜ8ėîh-Дī'ã)ß'e-)kI[‹Š(§Ąĸœ†I“ĶÉîŽ{æįÃ;‚ƒ Û#jĄģõ}|ß§Ŗĩ•;v°sĮ6ŊņÛˇmcûļmlܰļļ6ŦoąžļoŲ2âûLž2…—ÖŽeúĄ‡’(-QúV"p„`¯ 0Å %Æā…naÉ_ É"žP{†\|ƒîĸđ!9hۆ`CΛДŦˇ˛¸¸xûĩÖ.ū#ļ”ī“ĖcáDō}ŸŪŽ.^{õU^ZŊš•O¯āš•+ééîĸģĢ ?}`3a**+™Æø)’^ūø{õvœJ€m@UļžDG*Ej,…kyyíZūéĢĪē^(Hđäyw>ō0u:ÉĮXGąĘķpŗģ>d1æŨņKˇ)$š–Ŧ]ĩŠë_ÍÚUĢ >RНŒ˛kēyÆúüŦ¯Ō p‘HwÔļ†—–q|(RĀí¸”ZGҎíÁTŦwLIk!{xđ¸ŲĶ}}Ÿlķ1ÕëŲąe wũõlĨTnĄėÚšC'÷äĒÔ˛(€Č8ė¨%[€€§ŗŨ))‚āņ4đaā"cĖĩˆ1Õļ­1fpzØQ)|͋Y?°X|Á#3€díÖĢĘrˇ¨6¯|ßį‡ßū6oŽ#RĪĢyâDØcL,ŦŒ•ƒĸ"Rĩ— ʘū0ė䌇āqp’1æwa“ąŲž{17ĮūHP%ĒXŪ¤ûÕށ÷§cn9ˆāąģŸí@–p*<¯ČīčŽ~æŧwI¤žSĸ¤„™ķæé„+×Ĩ0°Wx^.B{ˇŽđøĻĐĨØ:jÖÚ·´ķÆXNĪ˙ü¯1Ļ_¯ú¸jßŨ­ĩŋę÷‡úãiUáĸĐŪĶ@x•`jāĀJ ?Ëå SĨĻŗZÛ5f žį‘ô}ú‹°¯ĩ–˙{ë­_ķ1ŦíōŲŋųĒjjt"9' ę ĮÉ寄;u¤pEŠ’ĩļ’`šĮĩ¤""–āō2‚Ÿ<$ŗģađ˜‘“cÃ@âÜHrķ6ē€UĀ ‚ĐˁWr:†˙ū?ŽĖiš˛–kIú~Qė€žāė“NĻ­ĩ5Χ´ŦŒË¯ü4—^qņDB'm‘†Ž˜ã7/ˇ›l5ÆhŽžˆHQwÎ ÁŸ>EP˛7VDĄã‚;ȡė‰0 WUö!ÄJĶNŽæ†!ŧ( ¯í3BĘhÕ¤ûŲ>Át¯àa> Ø Ŧ ÃÆëáŋ'ķšéĨĩö8āÉ|ũŧÁJYÉđO?‚äõĩkšøėsp\ˇ`ĪÁ‹Å¨Š­åēOâÜ .dÁ1 q=MĒ(ĻΟg ž1Ä7ŋ~iŒų˜^…ņMW )î‹hĐjąÖ~Ÿ`JÖ;3Ã?jįi`€`Žü`čX hsŲ6ŸÛR°Xq'üėG8aHLõŊĨˇ„á"3œø€ļųÁčKE>~˜c qcˆkI‡adđĪBŽŽČķ–X,Feu&Mâ”SOeŪ…LŸ>† đ<"6c‚áRĮÁ3&߁c¸ÛõLjF@dĖ ;c1 8xWH憝°Á)+šl˙i‚ųëƒķäŸ#˜fĩAĄCdŋÎį%• ˙|Ļl “tÆįų:ĄģÚÛšøŦ÷ŗkGnJŪÆâqĘ+Ę)+/gū‚,8ę(fĖ™ÃäI“hš8/ĮqTÃ&ŠēŨa#ķƒH• öfcŒÖ€¨ŊŠŒ›@’&‡Ad>0hCI 0'üģķ6į†eĪâ6‚Ũ“w“[7 F9V›îŪ&1ž^ ‘:‡įĪŅQûŨ„0ˆøĄÄüēlyüĄ‡¸éÆą}ëRŠTđ‘ ū´žĸpķ8c Žëâ:Žë‹Åđb1ŧ˜‡įÅhlnbæĖ™Lš<™Ļ hœ4‰†úzę)̍ĀqŒG¤:mŽ18°;d8ƒE¯›_c.Ķ+) 2ž;5™sã‚…ž“ *ŸĻčļ…i†Î§÷5ē!’õsu°¨čž{đüwœė*ÃūĖüú}<& ôõҞs'ũ}}ô÷÷Ķ×ßO?étß÷ņˆãāš.žįķŦ–! ""R,Ä!Xŧzö¸=ÃÂJf(ąÃņų^Ö¨(Äė{Éd„†åąÁéP™#Jã¸ĶÕ Ė7ÆlTË)ĻRŧDP`Bö3¸0,Ā0JPtöø¯%Ԍöŗ˛xFëŦŧ]Į~wíéŒPų˙Œĩģ̉ Œōš:NÕ/3Æüˇ…č<‘b !§ėŧŽīŠ ė¨3Ŗ&üøŧÖFŠÎY)Öb€Ī?¤86ĪáãVā*cLR‡C2ŠŽžˆˆđ.ę_f迉"­đņ `ąÂ‡Œz-×!‘ĸëŨ‹Ō¯žƒĻc‰D-|܆Q‘ąB đ—Āl&*"…•nž ‘Q‘ąDæ?Ū ¸:""Ņ \üÂãëpˆˆˆˆŒõ’>|8\AD$o*Ķ} xUÕŽDDDDÆ[) ƒČõ ""9÷˙–b7ļ­ÃÃŪŠĻ=‰ˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆˆČ˙Īô/? .IENDŽB`‚ffuf-2.3.0/_img/ffuf_run_logo_600.png000066400000000000000000001263021525032755300173150ustar00rootroot00000000000000‰PNG  IHDRX€Öˆ-…iCCPICC profile(‘}‘=HÃ@Å_SĨ"Q ~āĄ:YqÔ*ĄB¨Zu0šô š4$).Ž‚kÁÁÅǃ‹ŗŽŽ‚ øâčä¤č"%ū/-´ˆņā¸īî=îŪBĩČ4ĢmĐtÛLÄĸb*Ŋ*^áG?z0ˆ^™YƜ$Åá9žîáãë]„gyŸûstŠ‹>‘x–ĻMŧA<Ŋiœ÷‰C,/ĢÄįÄc&]ø‘ëJß8į\xfČL&æ‰CÄbŽ…•fyS#ž"̚NųBĒÎ*į-ÎZąĖ÷ä/ fô•eŽĶF ‹X‚ Ę( ZuR,$h?ęárũšrĀČą€4ČŽü~wke''ęIÁ(Đūâ8#@`¨UįûØqj'€˙¸Ō›ūR˜ų$ŊŌÔÂG@÷6pqŨԔ=ārx2dSv%?M!›ŪĪč›Ō@ß-ĐšVī­ąĶ I]Åo€ƒC`4GŲëīîhííß3ū~FCr•‡%ĒÖbKGDųCģ pHYs × ×B(›xtIMEæ zņétEXtCommentCreated with GIMPW IDATxÚėwœdU™÷ŋįŪĘUĶôäČ$† ID °¨ŧ+ €ŠbNģŽīkXW×5 Šâēĸ`VEÉf&įžžé+× įũŖĒ{ēgē{:TuWu?ßΧgēoWŸ{î‰ŋ{ÎsžAAAAAAAAAAAAAA˜()A„ŌAkíj€2Ā4ú”R=RB‚ KA8ࠊKŽNÍ ĢcĮo āŸOÍJ)[JQAUZ+­uĩÖú?ĩÖ]ZkKOœ¤ÖzŋÖú Zë€”Ē ‚ sY\•i­Ŧĩî×ųŖIk}ŊÖZv-AA˜sâęZëV]8ŅZ/•’Aa.+Sk}Ģž’Zë+ĨÔAA˜Íâ*Ŧĩ~PO/ŽÖúũRú‚ ‚ ĖFqŅZ?ŖgWkũaŠAAf“¸ j­ŸŌ3‹Ŗĩ~ŗÔ† ä9Q"‚0ũâĘî.,‚ėā JŠũR3‚? )A„iįļ"Waā.­ĩWĒED` ‚ ”$Zë7ī,˛lŧEjGō‡l ‚ LŸ¸ĒvuE˜Ŋn`šRĒOjJĻŽŦ` ‚ L¸2ßЏ¨.—š„ü +X‚ Ķ#°^ üŊČ_l[€…J)WjLφŦ` ‚ ^\…€Ÿ”Ā˜[œ"5&"°AJK†ȧ ŧTĒKD` ‚ ”ŸĨtL2ސꄊ#6X‚ Dk}:°Š”˛ ė~ ü8 ô(ĨĸR›‚ KĄXÖ÷€÷”đ#¤°øđ ¤•RZjXD` ‚ Lˇ¸j‘ĩmš-8žĢ\?~Ĩ”ĘHm ‚,A„éX˙|t–?fØ<ÜÜĢ”JJí "°A„Bˆ+xX[°›ô€ŠĨÅøøw“ŨNŧW)uDZƒ0‘S„‚ …Ąž â*Ų Í.Ögŋø1ĐŦĩž_kũ:­õi‚,AaĒŧŠ`)k ;ŋŪōb/\üØŖĩ~Fkũz­uun…OD` ‚ âĸ‚Ĩ|ø!ˆū”ˇTĘB⌜ØÚ<Ŗĩ~ĢÖ:$bK%‚ œ­ĩXTÄ]˙đ€/Õ"*N~´÷i­oĐZĨõ"°A„ŅđæDDū9ō0dļeŋOš eUŧø.Đ­ĩūĄÖúÅŌ„X‚ ÂąøÚŧ§ęZpøNĀ“ũ9ö08ŗĘUx;đ¸ÖzGn 1"ÍI%‚ @v{0˙Û]é(XŽūŦãĐōČl-ÃÕĀíĀ­õuZë2iV‚,A„šÍú‚¤Úõ<čô hš+ģ˛5;Q@ p°Okũv­u@š— Kan2ŋ Šv?qüĩÔ&8đįŲ^žŠė–ëŨZë›ääĄ Kaî.HĒņÍ#ãGnŽmsĨl7{ĩÖī×ZKDA– ‚Œ­“Eŗ=8ôWiØöhß4—Ęx)p đ„ÖzŊ49A– ÂėGį?I*4ö-w~öüė9kųl`‹ÖúcZkŋ4=A– Âė%VTƒĢO,ÂZn§ßŨ;@;sĨŧđŸdŊÃ/“æ'ˆĀA˜´F`-ßįė°õØøqč;ڝ+"ëd`ĢÖúŗZk¯4CaϤ ‚G´Ö§›ķžđ‘Į`ī'&˜Â@Ãk ņ%`úæJ5l^Ģ”:,-R%‚0;V%ĐBÖ3yūH´ÃÆ+ÉlũŲ`TBÕë úEPš ÕŗŊ*:€×+Ĩ“V)ˆĀA(}öyMØNÁĶīģiŠ š 3ā]U¯‚ĒS ÔÁZđĖ:?žiā&ĨÔ÷Ĩe "°AJ[`€Ā’<§ ›? ąû k” áķ îPĩ|eā ͖jų)pŊR*--T%‚PšË<ŦÍ{â]/ĀļweQAg/Uā]ķ.‡úŗ˛bË(YÛq <\Ŧ”JJ+D` ‚ L]đ˜dOM/Võ ?EۀĀUJšSŧįũĀy׆gn„ôÖi,AT|+!¸æ]5ëÁđ”â4ō`NdÉJ– KaĮ^\Cöøū2`<Ž(;ŨdW;~ ”Ú5Éû˙đų‚<Üū?BķPđUŦ‘Ÿ ´F”]Uį@ũ¨*Ĩæqp™Rʒž"ˆĀAŸ°i>ŧ‹ėJÕTy¸E)õë æc°Ģ éÚđô{!ŗŊJܝ„đEP{!TŸ e Kazųpšˆ,A– ÂØ‚&Üüã[К([÷(Ĩg~ `/ؘyų§õiØu#¨bķkådO'Î3Ԟ Á:PE;Õüø'ĨTJz Káx1ŗøKÁÄĖ0õĀ-Āŋ*ĨãČ׏€ˇ,7Ûn…ŽŸņ ãĪXđļė6ĸ¯ TŅy¸d<õ)"°A˜+ÂJ‘ĩąúo 8ˇ~xĨRĒëųģ¸ŋ`š°đôÛÁ.gåF%øO‚ÅoÚ“sōÅҌČÚÛ]$+Y‚,AD\eÅÕ'/31[÷˙ |p”Rz„ü­ž!ßŨ‡’h‡ÍD*Î˙ÉPõ X~x‚Å’ŗ]É›,A– sZ`}ø&3´Ū!ëĩŊ°/"ëÂ?-9ˆ6Ãķ„jĐ€úˇÃĸK!XS ™ú+ŲĶ…Žô0A– sQ\]@ö˜OJ#GŦļũ?HŋPzCģÎ@ãG`ņkĀ_9Ķšų•RęjiP‚,A暸šGvÅ(,Ĩq Vļ~ĸbfvM§ˆÅŸ„ųį9ŖÚų3Ā—ŽŨöX‚ Ėfõ;āõRŖ mĪĀž˙§ĩ4§%ĪRX÷(_2S.RĀ)JŠŨŌ X‚ ĖquŲ­AáDdĸpđĪĐö?YG %7; æjXũö™:q¸''˛$nĄ K„Y/°žÎ‘’˜É.h}šŋZ‚™wÁ*œôq¨\>íÍ øœRęsԈX‚ Ėfqõrā)‰I’îƒÖĮĄå§ŲĘ[B3•7k›ĩčĸévTęoū¤”ę‘F$ˆĀa6 ŦŸo”’˜jAēĐļ:‚žŋƒîŖ4 â5ÔŧNz+x3‘‡Č:´ŊûDfA– Ĩ"Ž  ¨“ŌČŖ`ą’pä!čŧŌģĀiËM E<5øOSūU3•ƒ4Yįļˇ)Ĩ:Ĩ "°A(euđ„”DĄ Ø' =ģ ånˆo§tŧ8ķk·Sŋáy3™‹đmāߕR1iD‚,AJQ`}™lXa:pŌ`Åŗaxēˇ@īcz´&k–TŽÎÍ:X÷U¨\9Ķ9én'<#G%BЈ+Ü\ Ĩ1“ĸ+™ūŦą|Ēúv@ī~”0g` Áú[ ju1”Đā#Āã˛u(ˆĀĄ–Øœ"ĨQ„dĸĐŋúvBß&ˆŨ Ędú įŊ°áŋ‹a%k(ŊĀSĀŊĀ=JŠgĨĄˆĀA(6åļ+Ĩ4J€ttl‚ŽĮ ö$¸íĶ ļœrT,+ÖRŲÜüQ)ĩO‰,A„bX~`'°$¯ ģ.:F3Ž%ß…ļmp´ã€máDc¸ą(n4ŠĶßÉ$Áõ4@(„)Ã(‹`–•c–—ŖL3ûåõĸLsJųÁÍd]Bų¤ļƒŽnÚ1*áEwB°Ē˜kÉū Ü4)Ĩ\éá"°AfR`íį3ŨÔũ÷‘ūī[ņßđ~į_PZ…âēh×EĮbØöcmۆķÜ&tÄŗĸJ÷ö  ­ŗZGį4Oö{=ōuŋUQ eå¨ō ĖeK ŧč Â6āĢ­Ã@“pôŠHĮ ųnhģÜč€ÄË/ÁÃ_ÉmQ5)āAā]@ŗ•%‚0kVúé§H~âũā:ønø(Ą7žŠ¨W˛t:ŊwÖæM8{váîڎ>¸ 3÷eäʋÁ˙GRCŽ3ÂuwāēĢŅļƒļ-ÔIĢņŦ_O`ÃĘÎ9‡Đâœ+/+=;aß×ĀڟįiȁēëaõõĨ˛*™~ŧWN ŠĀA˜ •÷-BˇŋŸūĢ_ é$86æĢ_Kč]7`64Įƒ;ÖÁØ[ļ`?ķ$ÎŊĶ ^˙˜âAįū™ŠĀõķ€v\t<‰yî9„/š„ōŗÎ$˛ræqÚ…Ž­°īģŪLūN!z`Ũ­PŗŽ”šųÁœČē[zŧ,A„éX>`°"¯éf2ôā=čŨێ^ „|ė_ņŋä@|đx§U\ uVy{ĨžôŸz}å[Á}ö0U_œ­O–rģ_<Ŗĩž'C@é!+X‚ ”Zë'ŗ ‘vô_>ķăŗ§Ŧ˛e§õˆ+R]Á:~đ˜´ĮųųLu%mü(zņ2ęĘBÔWF( ú) ( úéŽÆųŨS;xįÆ} ˇ.xž÷.ēoōeá?÷Ė[1’^OØœĄ”ę•‘@– BžÖˇ€"íô“üôMŗ‰š™ōĘí^Ž(°FsápTJ`¸(î9å\žņĸķé3Mj EšĄhđ˜´Ųģmw˜Ÿ÷ģV=Ę5ĪL˛$îČ|„Ģ^öz>o)Wé˙¯—đ:Ĩƒl ‚P*ü˛P ûÎ>ĩ|ÍŦ|ƒVj"oŌęįßy¸Ž´æUĪ=Æmû%™­Žf—íōpĘbWN\)Ug.Ēâ6÷2ēœų“,“ ņ‡šņg˙ žJ—ru^N6†ĄPBũOĄ$ĐZ"íĖ [H|đŨYƒõYQV#xa×Į_g”ëz\×GYē‚uĖuŽšŪSŨĀg.šŠĄŖūÆĘ|NĒ/#ä?zōđ$ŗŸ[Ēn!8 œÍéN}öZ.­*ãŗ¯9ƒÖŪÍŨQú’PösŌŧÖ,Ē/öUްZ)Õ,Ŗ,A„| Ŧ˙>UÄ›ūO÷™ĮD`ōy4¸‚*÷7÷͸‚Iį>xėõxM5?šîœ¸Ā΁/)ĨļIKŠ ¯ž <Ãč̞yÁÚš“ø{Ž›ūP7ų(#ÆvP(ېCN ģaĩ+›ãc¯oå̏ãŌ×bäŠH€Š˛oôn្ˇģ<:ÄÛē>FĢöûo:úRėč§ –ĢųčâZŽ=û$N_ąp&ĒÜ>Ē”ēYFXÅ0H‡É†Ūxp p*bø"n>¯”j“ĸÆč[5Āĸ܋ĮšÜËGå$Ɯ(Đ ėö“ ~ەĶΧúYŦmۈüũŒĪ25šŋĒãŽFģ6Đ_QCĸ˛ŽLYN „ëõĸŅ(ˈĮđöõęh#Ü׃6L\eLZ`aŲüõŨīaëšįPWSFmuÃČ6ĄųV'ī=ō =>ŗŅ”öq}÷'9äú'TŽ[š{čMg-% Ĩ0s÷w´ÆuõHŸYVĪG.>“šōi?‘joRJũFF!X35øĪŪ |ŒėVžŦäMŒ>ā*ā^ Û h­+€:āeĀÕĀydíŖŒ<ŋ°¸š/;'Ž×MËŦučņO}ŨŌTRÃŀXqGXŖ„ËÉÕ+1o˜DE=/Ŧ\ÉæĨ+Ųĩ`)–iâäJ Ú%ŠÜ `‘Dœ3vžĀŲûvSqđūŽN´2† )Nė;Ë yä—?$S^vŒ€Ô|ēųã”Ų‡ĮUiíåúîOŅ4AÕOcģPōfãņeģ.} ‹îū$]{ˆā:Įīá§WžŒĩĶ]íāÅJŠÍ2:‰ĀšÎ‰ ø¯Ü ”ęŸYcøk”Rũ#”ĩ‡ėÉE?° XKÖ~l2n*ˇrņŲ#ú)˛'ÅÖ`fú‘Âdm?CvK=øfķsģũũÄ>˙o¸›ž(9u"ĮŸƒ' ;\Á–P5ŋ\u[Ģæ¯+ŖąēlB÷5 EĀįĨžŽŒúĒ0AíRˇo?~üÂÛv Réq ,´Ļ÷˛WđÔ'>Œ˛UĢQ\Öķ[Îëûõ8VvkĸkÜĸĢiîŠŅM1 ŗÖx ~÷†—˛fQÃtW} ø2YÃ÷¤ŒZ"° =)\ üé5Ÿ .vK€7’=šØHvKhācžˆmdO;>ü Øh\Ķ"ŦÎūƒėvz͜+ƒLšäīî"ķũ›KÂWÖ¸Ũ#xŊøŽ¸ŠČëŽāæ'wķŠĻŦņxĨĪää…ÕŒ÷œĮ4Xž¨†ĒŠ>ßæqŽKeWkôęī}÷Ä ãķņč]w’_û2îļÍ Œ"ގ1–eaœ~áˇŧĀēõ˜éęcŨí÷Зg.Ē"č;ņ9¯Į`åâZĒĢŽÚH™/ ö@'ßö*߈6ŒQ]D€Fģšļ7üĪ}x¸Ø'ÆMÍīĮŖO|Ō3iÔđ!ũiŽ$ÉŽĒ|˜ĨĀu4ËĻŖ'FG÷ÔmíR›ÍŊƒ[ŗWU†øņ[/šÉ8‡ŽWJuI¯IÁü”ŦŊ0wx"WīŋPJuJqLŠŊ&'Ŧ^"Ĩq ŽCꑇHßömôá`zаūŽņmåēheāŊæí/|“Nb`yĘ՚Īüúūã`ļË,ŠøYÚP~Âôk#,[\‡Į3qĄŠU>ÎĒ/3žҧVŽâT—ķ蝷‘*?š§°›ä#Í#āvŸđ^QĪržļās؆gÔÅGĨ •ļ9x¨‹ļîØ”Ę>–˛xöHīāŊîzŲZŽxņÉ3Ųúˇ+Ĩî’Î;=ĖÚsZkøˆĢ9ɋ[€#ZëĪå5ãī;Jkũ2­õ3ĀŨ"ŽFÁ4 ŧüBĘīø%ūũwŒĩ§€U¤>ŗŦ ÆÚSÜøjū|/U7ŧ—ĀęÕ Ũû‹%ĶüųČQŋR5ácĐÂAaĄČ đ*—S—UąnQ>å€vq•ÂU =Î=Eė9ī\üáwˆžėpG?3ct÷3īņ§‡åŲR&ŽŸ‡õ”Y‡­Ė1wvĩŋĪÃI+X:ŋŠŠøŧ,Œõ…ûŅĮv’Hgf˛”ŋÕZ>ˇŨ/˜YYČ9Vß$N“Ų-Ė›/+Ĩz¤8Æė;uĀ‹ČƋã`íŨKęÎÛqwŧ€îlƒõđ­Ąēĩr kŽÃŋūd0‡WŠãēÄSvîäΧvpsN`-* °ŧ>B…“ĄÖĩiLuŗ(ēĨą,L´Q—Šâs-”áɉ"I"Ąģj-k8˛ät:k÷zI{Ol†Š\— wüœ…wūwĐP|¨; ģ<ƒwŨëĪĻgj›Oū„íŊ'HŨåҊkøKÕë&T‚{öˇĶŌ9ųX„ŽĢyú`VîÁī/XĪëÎ^W ­õvāŨŠCÖd&‰K€˙• B8†^˛Û]ߓå¸>ŖČú‚ûŲČÂdq]Üd{īŌŋũÎķ› kV/|1V¯#põĩxV¯Ás”j;.›öâOėāąî8[mÍRí˛Ōíâ͞Ũŧ<~€€ÕŽ×ęÃë¤Aį\2č/5äûá×N˙9f+´€öųĢyėœKi^° Û}XVZŗčą'Y˙ų¯@Ę:Ū™)°ũÖ¯pxCvĢÍĀáS͟'lī:AÁXܲāZŊŦJÍĻ‘L[šĀÕj˜›†ÛļąØ×ÖĪáXveķC Ēøú›^9ĶĄujņgĀÛd,5‘‰ĸØ.“„0¯WJõJQ n§˙'đ˛Ž5„|–o2Ûۋ}ø0ö–į°ēŊ×Qãø‰Nļƒ3ē¯ķܗcžr:Ū5k1jkQeŒPx´ēæ-{ųáĶģøIW ”ÂĢ->nmåjk?Uö.jœNĐ&5\HšLP` ŊĻQŽE´z7ŧš{ĪŊ”dpt×ķļīāÔ÷}å:Į0ŒmXËãˇ}åēÚáS‡?GØ;đsÔŦįĢ‹ž3 Ģ˜ŪžŦm˜ßīÅ4Ę0˛†ņŽÆq\ŌŠ Ííũôöė !eŲ<;•šØP|éŦ•ŧâäå4V—ĪxĶî küîHO5žÉâĀõRĩ hŽUJ=2ĮÅU-đ;āĨŌ$ĻąÜĶ)Üū(:ōÅĐŠ¤ĶčDJ2¸?æ1Á4Q *Ęū aD"¨H#ˇ@;ÜŲˡîŨÄ6u˛ų‚Ŋ‡WXÛYm?IČupĩ9\41á4^5ôgmzŲūō÷đˇ—\‚=ĘöaũîŊœúîb8ö0ĨŌ›ü-:×­ÁŖm>qøßÛÆ|ö‘+¸Ŗļpæ¸ H¤24éĄĨ3v\•llę&a9CĘTķ™åõ\sÎZÖ.n˜éæø}āqã ëD“Åz˛Î(aŧ|øŦR*>×\kŊœŦ/ąŌ f7oŨĮųŲȕnW8ģyĩõgÂŽEÖ\5'ˆÜcQĄ ŽjĨü¸ōŗl>é´ėöÛą"kĪ^Nšū#ÚvǰûęËŲôč4kūA§uŒ§ĪpÛüoĐ䛞PąQvčôˆp ­ŸCą@¸š˙>c9מ*Aߌ.¸QD–Ŧą&Œo•j&ČābĨÔž9$Ž–O’ q#ĖRlĮåöûŸâšĪp}7§:;QÚ=NH "7Ī ĀM` |NąãŧkøÛ+¯&å /˛víæ”÷}•ļŽîŽöĨxä_ãĢ đáæãsûFŌš‚įđŊ†s4¨Oáéí‹ķÂîÖaöYŽÖô&2t÷§hKZƒ>˛ŪU_Î׎|u?ļë IDAT9eĄĀL6—[÷‹Č5ڄąOĒT˜$QāmĀīg{|E­õB˛ąü¤Úg/–•á§ŋŊƒ‹öũrˇwČ*ÕAÄ1ÂÉ]` YãXZ ŋ/# ļîųøŲõ_$8>ŠYãŗĪsōG?‹Ng;kÃāČģ¯c˙ķŌæÛiļĢiĒTœõŪVƌ(^ÃÅOį}mUĶ^ū-mŊėięUøîië§#y4PõĩÕaūûēWôĪčJÖ˙ž "KÖą“Æ×K• SiFd>ß9[OÖh­+įERŨŗVAcíŪDęū[0ģ˛"Ĩ†ŦR!°ÆN“XCī;ŠĀBCŦzwŧīŋˆ†ĘŽ{Ļø Ëžų}ļŋøÅ<ũĘWĶ ķûŅ ;Ø@šĘp’‘â¤P7V^€×3ũĘŗ'›HĻíQ›Öž8{ēƒ×>ŗ´ŽĪŋáå3yĘĐÎSJ=-IÖЉc°ZĒTČ[+•RÛg™¸ §IĪ>ܞ6ėmãîŧŨú+Pà C‘;kāžŖ ŦėĩhÍRîŧņëDƒÃOBvvõĶúü>ēĘĘ1ÆR“ <]WfÉÂüžéõļß۟`ËΖ1?Ķ֓`W.džãČŨqŦ{+÷ĘԘKp_&ŸŪs—ŧ?ŧäŸxaĮŠŖNZ5ā÷˜TWŠĒ S ŽLi ™Ė‹ĨčéKĐK‘ļœa]8čcíĘyĶcë´÷@;G:Æ^ ˛l—§›ēqr+s¯8—Íė*–ŦVJ헎'ëĀåsöEvˆ—c­õp‹Üī>ckMÚuIģ.7;le0ˆGIhĒ1ˆ‘=eøX‰öĨĀ.ĉhé`[h;™$nË.œ}áø 8Ipú҃'G0d ËO(°&¸õwBĨšīV¨‘ë_ķ)’ęhŗõzLΝ¤ąžb\vJZkÚ:ú9ÔŌ;lI)Åú• TUŽîUzUU(CMz˛ŒÆSlß͆í¸8ÎčįgöļG9Mpķɋ¸é’Īt+ŧG)u‘tÆ9,°r^¨ŸÎ(ēŧĶaģ6ô­q€”ã`kMÆuIiMĘqČhM"÷sŌqˆ9)­‰ē.΀€Ę‰(wāûÜ=\úÎáu, <Ū\WG™Į#=btŌĀÉÆ3´KŦÜ ŧRĒpÆ+{pŌF  Ũ߉ÛŨŒnŨ…ÛēGĐVdš@§†ˆqēKȉ)íæY`14ÍQÖ@zîØË5|¸Ū2\3Œk˜˜ŠFēÃļH{ËųđĢ?ES°r°øĒʃŦYŅ€gëŽĢé芞÷P× ČQJąlA ĢĐZcYŨ1zú¤RÃŨ(†ĸ, ąž‚H؟Ũ’œāũ]W‹§Øw¨‹D2sÜXœļžję`ĄĄøĖ†%ŧjÃ2×Uá1͙ kųĪJŠģ¤ķÎ]åļP`÷Ņ“q],×ÅRšŸĶZ~Ÿp]Rš/;'l뜏øŲĘ +÷;;÷Ŋ;ÎĘ)DĨ…”âu••œ‰H¯›ûË”RÉéž)ÕV‹RÛÆ~ôķāôƒáŗÜ¤ˇŽ^P^˛ģ¸ęøˇĩIŦÁĪēyXĮúËM`˜žĸmÁÉl=÷jšį/Ŗ'&饕"˜NP‹2īĐîíķņ|¨z°V,ĒĄąĄrĘ"#“ąŲs ƒŽžDNdA}u„XCŒ'Õ(Yʅz^(ÄEÕÕ˛e86-dƒ¤ūŊČûF YßpåŒ'WkŽCzā…EëAģø ƒ a6M|†1ĢŽv“ūųkÁjšÄOQ`åétā Āę/ëK9ēÖąķÜ7ķÂ╴W5bh=ĒãĪ}Mnëüyíōjkōûâ×ŌÚ˞C]#ŠŸ˛p€ųue„B~ü>ĻĮÄPdãfRÉ4-QÛaõŠy&i˕NÛlŨ}„xΖĢ5[š{čO;#O"ŽË—×.äÍį<ŨBëMJŠ_Ȱ?7VОīt;2žŅÖÆ<Ķ$žÛŽ›+’ŖÁ4ySm-u>Ÿô1´đIā›Å¸e¨ŗ1P~\1Syčˇm:2Z2š3ZŨŽ3h;ŖÆŌZ1 –û|,ōzYā÷SãķQYâÛØnûA2ž ›&6üŽ&ˆāxGžĶ%°†Ū÷Ĩ\—´ĮÃÁĶŽåÉ įqpūĒQ]+( •ąI§-’)‹Ũ;mGׯ¨§ĻēŦ uŅۗ`ëžV\7{¯yÕæĪ̤,âg<^ â‰4ÍGzX4ŋŠPČ?šų´ÅŗÛcŲΰ•ŦxÚ&™ĖЖČĐwŒ­ ĨøÉË×qÉk1i™•žÎįŖsS`]@vÛ&¯l‹ĮųIw7 *EšaĐæĖ­€ãWWUqr$Bž×ļMė đ ĀP Rx•šļãÔSā)˛AŖ÷YŋhöÁéŧoÜq8˜Jņ@?Gl;īÁIĒ ƒ—E"Ŧ …¨ōzKrĶņ>2˙û)tįīs[‚yXĮ:ĒĀ‚ã’Ž$°Ž;•čŌ_w ģÖ]Āg^‚å ŒŌ>!•ĘĐŅãHG?–uüØzŌŌ:ę ģø§ŲßÜÅōÅĩ„‚“{Ąlíč§Ļ*—ītīlogGúøčįĄÜ?Ė.k–Scš\WSCŊ|ËāļÖėŠĮy<ŖĶqčĪCYi  •†ÁrŋŸsĘʨõųŠŠ;wLÅ>ˇĩ§ÆŅ7āÖ+€Ę!ŋ3€¯ĩÕĀS}}ü##>}ĸÎ4šŽŽŽZo z Đ.ÎÁ-X/XÍ'ūŧ;5NoéãX#Ä'ÔÃN†Øĩæ";īZ:"•¸ĘĢmŗ÷`­ąAaĨ”ÂcfO腂><C)æÕ•cšs…­b 6­ięŒs¨˙čsI$ĀĪŪü**#]ŦŪ ŦWJĨE6Í-õ(đ’|O_=|˜žQ&Œ RT›&Íļ=gė˛ ā5ååŧ¤ĸbÔgN8÷õą)™¤ßu)Ôđ80ž/đxx]U‹b*ĒmĀĨJЃZëyĀr .W„cą$׎ÉĨŸču8ĖGē#á8üĻŗ“™ĖŒöŸR\V^Ιåå%ŲˇtŦëū[p~”˙ ˙¨Ũ“Ÿ|bņãX÷´ĢÉøËyúeb˲Sé*Ģ9ačÛqyaįĸņ4¨ŒXÜXI0čĮī÷0Š´ÅÆ-‡†š†Ģtõ'ŲŪŧöŪyÜríĢ0 ˇšk˔RÍsC`å\4ČM4y#ãē|õČc4v Yx×%6Gļ 5°Áīįōš"CÂ88ZŗąŋŸģŖQŌĄ*ĻQø]Z^΋Į~3@4W\åŗĩ-tf2ü¨ŗ“ž"ąKtŗ.ĢŠÁ_ĸ§ũ›ąū ē˙ū‘…ÖPņãæQ`Á1NIGX†§uÁųŧđĸ7ōÔ˛SpLߨ'‡=—ãōėļf)‹ŠH€e‹j(/  Ådúhš.{tKdˆ'3ãÚļėčK˛Ŗ36XOß9u īŊčŦBŠūšRęcR[sG`•‘=†žW qĮák--¤Į1”Đ;‡ļ ÃJq]M ‹ƒAúl›_tvrвfŦ1i`­ĪĮ55DÄY괈Ģttå6yD)ŪZWĮ|ŋŋ4 ׹ąžûΖÛ!ú ¨ā1kˆprķ(°ôDåZ$ũ5´Ŧü?<˛ū<6Ŧw åė Û÷´Đ՛`qc%‹Ô `N4žiúûStuĮčėKĘŒžS˛ŋŊŸæčŅ]ģ]×\ĀĒu…ĘZ;°P)eI-Í UEvo¸*ŸéöZßhmeŧgī=@ĮCģm3—Ξ ˛%•"U$¯ĸ>Ĩx{m-‹d / qĮá;­­ŖnŸpyEg”•えq - {Ë?pļūŨûh+ûdņ…5Q•ŠÚ!ãĢ#Vu*›ÖœĮæÕb™“{qémoËÕ0ŋĄR:ĐÄį8Ú:ĸjé1`´ž?ÔCîwW”ųÅõ¯ÁW˜Í°J)uDjfnŦ…ĀnōlĖۖÉpK[ۄŽ˜+`×K§mā˜‹˜Āųá0VU•îäZ¤XZķŊÖVZíԈ´ĘëåõõJŲaŠcãö!ķÔ/Hīũ1>b¨qA`Ápį CN:f‰P#;–žˆ+.¤ģŦޤĮ7ĻŅúxxōŲÔT„XšŦ^:Đp]—ƒÍ]nwō2–˛Ø|¸wđe˙ą×ŸÃYĢdĘ%âãJ)Gjeö ŦŗÉ:AË+;âqîėîžÔß”ĸĘ0hq¤ũÍXģ–yŊŧŠļVļ ķČŖŊŊüeˆë’R Ė0¸˛ĒŠĄPI—ũŗûķ–ģîåU*ÁZc3ûž§Öęí ´A\$°Z =XžM§ŗwÅÅ´ÔŦ Į$î j\vUãĄ§7ÎŪĻ.^tōĸ ĮîFϝ?ɖ]-Į‰ŦũíQšsĸ/ûųãģ.Åį-ØØ÷˛y2R#ŗ[`Ŋ ¸=ßé>ÔÛËßĻ0‘ørŽIģY˚9JņĪ••Ŧ“øŠSĻ-æ[íí%™w\Pâ̚Ÿūåũ|šŠ€ĩ åD"ĒŨ8Ģí>9iæ9‚v'>7†ßRUWkø°=AԁZ’Ązb=ū]ÁJĸÁĘŦĪ9=đJ’öėo' 0¯Ž\:P‰'Ō<ˇũΐmúDÚfcsUJąņē Y6¯ĻŲx¸°ÔßO7Ĩūz_uĐVkj6|­épxŧâxeNƒC4‘9š!o„C~NYĶČsۏ ēu<”û=ô§mz´æîįöđžÂ Ŧ—ÔZ_&!tF§Ôgœ•…H´)3õ•Oąm,­Š7MX3¸zņP<Îmmmt[røe2ėK&ؚ.}˙‚û3žŪÖÆŽDĸ¤ōũäŽCsēYōēŨVösÆēŦ\R‹Ō:û5,NÂôŠÚ˛°ŋ.æ4‘p€•Kj‡ø˛ĒŖÛā7>ßDÆ*øâŌ%Ā—s‘#„Y(°^īm­ķęr!Ĩ5ŽÃ<¯ 63FĢmķÍÖVž‹FqĨ8&Ä#ũũĖ–ĩ?Gkîčęâ]]dJĀĩŠ67wū<¯rd[˛õ圞ve‘âpē›JYT–¤ķúÚrdž”qĀīģĩšŗw:˛ņ!ā%ZëĘcžĘ´Ösž”동큕÷sŋɧk Åļ)3 "ŗp›Jëėqb­5ŽÖ¸îĐ/×-Ždøuo/ŋho/‰ÉĩčĖd؞™]ļŦx2‘ā{­­ôųĒĻ֚?7wā3 Ūã 7֖ą|I]Q­ŲŽK(ä—T@”‚UCâ5ú<!Ģ›{Zģ§#>˛Fī{GøÚŖĩ~^kũM­õ™ZëĀ\[í*eŦōBÄd'ŪnĮÁ¯K<áQw ¤36Û%•ąÉX–íÉØ8ļ‹í8ؖƒk9¸Žƒ›ąą—´ŖÉ8nöËՃ˙§—´Ģ9Š6ÂĨ/[S4Ώ5Ļŗ­+ĢĢiôË$0VY=Ø×Įlĩ\ksnnkãĩååœ^¤avÚ{Ŗ<•Č€iPá5;– ųX^¤vNÁ€W:Qņû<4T…ië‰Pö‘čĪž&ÜÕÖÃEĶ”Ü×H,6$ë¨ô­õį”RO‰Ā*nādĸĀ'˙ŌZĶdÛԘ& ×ÍúĖ| 5æ›lÚr°Ëv°lĮqqeģ(WŖĮv°]—Œåq\RļCŌr°—¤å’˛] ĮÅt\´ãâÚ. ÛĄËvˆŲúø,¨~˜`ŠWû‹o mŗmžŨŪÎåš0;Âņ8Zŗ73ģObg´æW}}ėĪd¸´ēēč|fímé‚ÜĒDØk ëzZÃ˛…ÕÃļ…Š…PĀ'öWĶDC}9­Ũq”‚ǐŸæœĀÚÔŌSlY­..ÕZ?|I)õ7XÅI#Yŋ’y%: ūĢ4Đå8ԛ&?ģŸ¤å’´l’¯ãbd\ĮŞlz36‡3ΉEĪ$„Īq˜…#a_Ņ6ĸ?ö÷ŗ/æu55„Má(­é4= ڝo āf;îā×Ā6ĩRā1 ŧOÎpÜDŗ1™d_k+×TW3ŋˆ‚‡o?Ō5øLūc^TĘÃ~Ē*ÃEĶĪ4E\Måa|^Ëv œ}{{??p]Œâ4K98_kũ(đĨÔ"°Š‹•`‹°gm2$Ōü}ËáÁ7ÔQ)e'}|ÁâŨ†Sdˇ ´ļō֚æK˜A.ãvĮÕdŌ™´ÅĄÎũQŌŅ{ûô:z˜ßÍĄuîQ°*čc~eŗ6ĖĸúrŌA?ˇdÚ¸˛ĻšĶ"‘ĸX:Ø{4˜oĀ7|¸^ĐP!“@S_æp{?JĄTÖ}ƒÖÄRĘCEmk~pŸÖú7ĀJŠXÅÁĒB$Ú9ļQ}ąŗ6×Ã3#°˛ŽuΓuv˛UšVĄP†,ū¸ër[GF"œ_Y9įÃėd\—CEjîæDÕ ģZŲ}°“δMÂq'ÔÖ°%žfK< ‡ģ1€JĶ č5Øĩ š—­[Ä;—/"âķÎčsFĶGĮ#Ÿw¸Ü-—SzEBÚĄŅN°Į[VĐ{U”sKaĒlHI”"–HģĀ(Ē+‹ĩ֗(Ĩ5ķ$¸ÕtÆY‹ÅRiPŗÛĘZCyŽëœ7ęžū$-QčOŅMMÛ¤‡Ũ_đ*EĀT„Ŋ&eAjĒ"5aę+Ã܍˛'æšÚZÂs8ĖNÚu‰áōHsk/›Ÿoboo’DO§ē@ˇã‚ãr×ŪvîÚÛίĘļōČ —ĪØ*žF“tŽēņ{Žæ$đâx‘PŇ4ÚI.LļQᤸ/<EaĢ*0ä@ŠVîn=ņ$ķkKÆÆ´œėjÖMJŠī‹ĀšYÖ"Ņ#Ķ(°2ņ”šõ+X Ę p\[)ččMĐr°“Ŋíũ´÷&čĖ8ŲwĄ‰Ŧ>uÄ@wšų“ųAæÕ•q`a7o^Ŋ„5áđœœ$§ĶØE$°ÚģclÜt€Í҉ÕīXTžŅ-RÛqiKfûŅĐÁĸē<(âęÄīvĶ6ŧj`“äĖt/ķėNjđî—Įš8Ī đ‚ŋš'üÕšŋ?ˇŌĒԐg6oūũc|ęĖ•üķš'c–†‹ ?pĢÖ:Ŧ”ú†Ŧ™ãŦ|'˜pl `9? ÉdÆĮ,'öcę¸ā¤“Åq5Gšģxr{ ģēbÃíĶ&kĢ–Û7<â¸éŽCwœ?íhá·wō­‹Oįõk–ΚÉéųxŧ8&I­Ųąķzî–fÚÄĀÕ'-˜ąįî%ųØoäũÉ_]šÕíoĀnΘÄß)ā”L?'Y}4y#lņ–W^:/‚K.ŠJ7Ãr;Éb;ÎB̟27ƒŧÁrü‘:+M&ŪcĨŠrRœŸ8ŠŠN6ęyƟ_Ž1h{åķƒ+Ÿ›3W?ē“kļ7ķšKĪbåü’]d_ÕZ[ĀwJ9OI ,­u#wãˆ~۞ÖĨÎøėFž0 ¯ÚŽÖ´ˇ÷ķˇĮ÷p8eMMPKt)Ĩ-‚ášgįĸ]EāžÁļ}÷#ģô–Öžiõ65 Õ3ōÜ]ũq.˙Ņ=<ž>jˇ¤"8´iâ÷Íîíë33},´ĸ<ë¯á'€Ãȋũ#‹ĻÁɰ܎sjĒˆ›Ũ‰X–éãåĀOˆũŪJZŸ‰›Öœ˛°ŠÎū${$lüŦ'ÎĪ~ú{Õ)ŧōÔUĨ°šeßú€;E`M/ĩ…H´ĪqĻuIš#žvY¸Ą<0e}eÛ÷>ą‡g÷0­NáũΨĢ*)a”ö›Ė­äΰ¸â˙Øę4õ$fdŒĒ4ÕåĶŋJKĻyĮĪîWĻRŦo(ŖbØA…aÎÎa#¨].Hļrr:ë…|UĻ—Œ2Ųã-ŖÉWN¯áĮp[áēTģ)Z1–[QŧÚÍÅ^Ė•’a sŽŖįÛ æÛ‰ÜĘXöīû ŊĻ¨áÅFáZƒWģ„]›0UNŋëäJ}ø ˜ÎŠ]Ķë'XQ7(ŦŽ›d}A<ĩA\Į!ŲĶŽŠQã¤yCt?;|•ÜšGB™Sîũ^Ķ$•ëšĩåAjĘėŒŅM ž6ŊėŪįųōá.nzÍ9x=Eī–Æ~¨ĩnVJŨ/kú(ČŦ×kY͎‚ĨĄ9‘qfŊĀ* LI_õD“üņžíúp23íÖj¯¨Q™ĮSdCËa`ËTķ;}ôd´&ãē¤Kk’ŽKĘuIkMÂuŗ†čŽCÜu‰įœÖžēĸ‚uáđ”ÚqÜqpfĐūJk͟ŪÉL‰+€yAķĘÂĶūÜ_ūĶãü1šŨ4 Åéķ+ ú=ĮŦVPŦž&ˇúBVԜšéåÜd~4Ļ7+*••f}Ļ—õ™Ū!}D SĮP°ŧ_¤‚t´‡T´gā$LÖD3÷wUnš*wrAĖ•2đ†Ë ”Wc˜æ¸^m Ķ$\ۈëØ$ē[ąS ÖfzXjEųCd1‡Ęô`ÅûIöļž”¨ã^WŽâ WdE'ûb¨¨ÅŠÂJÅIõwáړw=ĸ”/\Ž7\ŽéņĄ&)r ĶC¤n!v:IĸĢ… csUt?[ĩÜl˜Ô ŠÖ°jiû›ģčęM û](āáE‹ĢŲÕÚGw*;˛|å`'üö!žø†ķņŋƒå0pđZXĶÃęRXŨŲˇÔYīlɜd˜œžXŠ_üc+Q۝™2Ԛļǎĩĩ Že´&‘ j]f\Y]ÍęĐØoÛ š- ­5Ž;î Ÿėú„žN&9ÜÚĘU55ÔûF¸ĪĮb<ŸLŌ›{žĄy *ŏ‡ÃĶ3ķ@s—ûОvcĻ{Æ˙YŗdZī×Õį_ŸØ9¨œÖÔFųGžVXNš×śŽ“Izˆ‰Ô/IžHŪPv:•Šã¤“¸šąÚ0MLoO „'Â0G¤Ļ‰/\Ž/\Žcep2)œL ;“Âĩ3h×–e˜(ĶÄôú0=~ ŸĶëĮôæ÷L’Į¤Ŧq)Šž.ŌŅNMuPédø]x–šø úXˇĒ‘X<́æ.ēû’ƒBÜkŦ_ÉžÖ~ZPđ•ĻNūúŧäœRXŊ\kũĨÔE`ž‚īé™FÕ՗ÔĖ~' øũ”Ōi‹ŸŨ앍íÎhŪ+*h>ÆŲĻ™{ģNj͏ģēxe:Íų••xG™đZ,‹NĮÁŖķL“ĮĄĐOĨ€Ûæ›mm\YYÉiee#6´ÛĻŨq¨4 * ƒļ!6ˆI­9bÛԚ&ą˜™&ąšį™ũ3Ū'=‘iģŸëjnūû3ėČĩ÷y!ĩåÅse$ĀIË0KÔËÎKĩēJ㠄U5 ŠĢŖĸĮĀŒā F†´ōŅW˛F}áķú˛B)\>$Öî ¸Ŗā^̆¯Š+ëđøƒ$ē[YbõņϘÅī"‹‰ĒÉMĪ‘°Ÿ kæĶÖeoS'vîDĄĄ++Đ-}´&˛‡Xūm{3g.māe뗗šŅß IDATBķųŠÖúJŠŽRiīĨē‘_YˆDģĻQ`õõÄSĖoáÁāÄV°´†ŋ?š—žÔĖz¯- <ÁI-Ü‹ņũÖVzGYéiĘ 4[kÚ‡z‡ā4Õģ~ĶÛËoÚÛIŽĐļäōÖëēt8ķLs˜PÔ@§ã1 B͐į‡6Čt¤ėī+|ū?{ī&ŲU{˙ö •SįîÉQåœ1Ę$0ļd0Ø&aLƒ ž|€…ī5ÄEŖ‹I& d"K @ 4HŖ<šŅĖôt\'íũũQ5f:TuwŽžéõ<#õôtŸsjŸŊ×~÷ZīzW4䟂ĘhŽĀ—ö W7\Á†Ž™ÁŨڎ8§ėXK(¸r+OtrlĩĮ1ÃqĖP´ „0#qbë‰uŦ;\Íâ)–Š  #&ĸFūķÍpŒx÷&4#@—[äÅšŊDÕÂ÷#Ĩ ŗ=ÎY'¯'>…Ļ!€-] âf%-XPp͝04ž[ ͧøŽRjÅ4]ŠkĮR_Б’qV9_29ĘMĶČ’šl߁õpßø˛?ûņíĩG0ú\—O đh>?Í5[Rbųp]Bô1$ŋ͞øĖĀCS¤00ež+`ČķhÕ´#ôOF<[)6™fÃBŽų‚%:0Ökbs[œˆéˆšåŽ¨–ĮnN†Î@<ŪНbëĻÎ}&‹)+ ŊŨ$ÚŪC´c-ņî Äģ6mëÁ†9–MĶ âŨĐ!Z=‹—äöP‹‹w§ž°Žļ)ēiē&ØŅ=É/Í)øęŊģVĘ0] \ļ °dJаaН;îēŸŧ—RŠ|Ų=ęû°t%ÃueAmWōß÷īmŠ-¤ģ­ž 2G)žžNķáaœjixqĀžŠVũmi `™éžŸäWãã( ?CÄMƒÕtæēÃÚšTĸqēN`‰×‰pīÎ}ļåÉå÷ žˇcoˇËË|뙡É#ú ĸ=íq6Ŧk[Ņž ¨$/Íí!¤<­]Sß>Ģ6u-TøgF0BĢWæ%ų}‹Œ¨iš`ĮļnÚR“s+0ØŪ>éãŪņčöô¯ˆĖ›žŦ”Z-6VbĢ!ÉâQEFĨRd,÷¨wŨuĒMīz´Įõ–˙Á•"ŲR?G;Ëe>78Č mS”ŗŸ>]`ãĐ4b>Eŗ\āGš_¤Ī˛fī%Ĩčw]ē c ˜’Tĸ\a!h]ÂgÎ,īĄƒéæPtđœíūŦ}ƒis*sž= dL×pĐdËÆöšJI‡—įž&åY„m˜ĄcŗũT= +Úž=ĸË-puĄoņ}dMÕSk…ébâ”sĮÎŨ+fkžŗ °c )īąm˙DFjÔ^fˇ֓ŠLčĪĖg–ãōã'šÅÃŅŅēp’ķ€ëō™ÁAîÉdæũ،”@›ĻųÂüĀļÍ×Ķé9ŋWũ)M#qØŗeĒ {–¨ŧģˇ/-\ŲŨ0Ύ‰üã_Ũųđ%n×%ÃĶÆYÁ ÛēV¤æUEôVqĄ5Æ+ŗO‘ō,ŅĄd+Ģ6égĐ´57„Ļk_‹Ļg§9ŲÎ.Ú?hĸ˛Œj„Ļ 6´L‚ŨĪ>Ū$ÜÚÜØg•RMßjn%ŦķqŅ!_EF9Į;ęũGw*\ŗSxčą>ĨœæĀœ[:âNh1ĖÃVm"†ãR2&%ë cÖjÄĨļZ9xĐuXĘĐRŠž*1>žHpĮÃŊMŗÎJF0}Ōr=?Ų_Ũü ˜~ßöd˜hĨ7ÂtA呔›Ũ—”Gxũø\TėÃT’`,E¤ĩƒÕ”`ÅöîīåÆĪ}‰Wŧö|æķ_áĀÁ#54…Žm_‡@đœb/)šøŦG `°eJēš#¨j1>a{üiOßJÂnā’fȕČ:Ч­ŧôos/ÛŽĀSGĩHƒĒQĸÁ“’Įö6Íxœ´&ĩdͩ뙃ŊŽK›Žc)åë|œĪ)ů7M†\kŠāã€į‚¸Ļ‘—˛îSöāhŪĩĘŽšlī^M˙ËļžVtŸÄG2˛Õą ę)ävMļnę\1ë}­[æEš=" bJ$&œę MŽĸĒęĸųĶÏņ˛x'Åb€_˙îÜ|Ë×ųâÂķΜÆÖBÉ6ʙ./õsktũĸ—JgG‚}ũi,ģÂ;n1u†-4Áīööqöqëëˆ(2™ {öė!NĶŨŨ͆ ˆĮã>Œ$¯~˛ °–,đŖ čC¨”ĸāã†60–?ęũˆ!ZmG ôŦĻyöŽÖå㈌zA!čĐuF<fj#Āqˆ !…)´¨†Rl4Mö9N]Ī<28Žû ŽšāÜT„“7wqNwŠu‰(Ž” -.ęōL>–/rHŒ$ž~‰GƊŠFgŸÆŌtÂSĸ,f8F¤ĩ{ÁŠįGŖŽĻų‡ˇžo\2Ûqxã?˙+w|û?XĶŨ5íßBņėb–­v†íÁVv‹ķOBĀ–u­<ļgÄÂf`{ĮōHĨj˛õ<¯|ų+Üō…/0Ø?[ģ~oxĶ›¸îēëĐ ~‰RęBˆƒĢkéPë’+úÁLfŗ čĄ/Ĕī‰jũŸ ’ž0 P@Į4t"Aƒ ŠD‚&ŗōŊŠŠ~O×Ņhm鍝ö6MĶkM@$ļŧåâ–RŒyŪ„’z3Ŧ‚R„ĒU†ŊS*]āĮĄMͰ\ëéûĮĀ7.Ek,Ä7^põt]æ!Ù‡rŖ-Sô│žÎäŠ#ļ?lãŧŌ`eAĸíkVÕaČæwxˆ‘Ņ™ehršīø_æ+Ÿ˙čôö5Bm[Cnā., ōT|ËĸĪ$ąX!J)BSôūF˕ŽĩLž¯~õĢ|čƒ<ĸúūā^ūå]īϝ¯ˇŧå-ŦÎĀõĀVÖŌŲ’ĮÍU’œ\Į .ø°ÎŪØÆēÖ(!ŗŒt]TúiBM éZõīēĻĄë•˙ē6IMÛbļãŌ;Zđ\ŠJŸÄíqÖGƒXē hh¸RoŪ‹p]Z4€ yÍÃŲ++Å×ĨK×ÉWOO8h)‰jQM/H9§ °ãzL}K†C&o{îŠ\Øā `(7Ų3.:E/N×m­1V’ `ŋįÜŌ Ž…RĘ7雕°žôõÛæü‘z”žž6ŦŸŪŦD7˜Ą(Ũå<[<{ĖÅ͏`ĀD׎WX•ĒF-§&_>80ĀģßöOlj™ŨĢRÜtãg8õÔSšė˛†ĘV]ģ °–Î"Ā’×Pį\ĮĮJ)WöoWŠķNYO2ZÄ%ęFOÕåZÃXmF%+Ö%B\~ÖfÖvUĸ:‚6M› t7ĶĻ5.%AQyžąpœųlƒžGR×iĶõi]ĒéöĐK…Œ:ŖŸŲ×?^ōƒøŦ ÁK/ÚNÉиąŋŸŋnmek$˛Ŧc8RéGZ™ƒSx_Ņpm6:RôĒŋđ #bÕ*æ:÷ūæDæˆė;ŽË?ģ‡xõK8ĩ“m8å<Z#ė5c‹ōšV™g™|yŽõšũDbs§*Ĩ”üŨ‹_ÂŊ÷˙–-[֊įĨTĸŋßųJKŽīhÄEG˙Ú˛¸ž¤čJ˙Æ]׈†|ĪFŠZčņcøÉ'ߔķ7WĘÚŽä!ü9­MÂ'š„zĖRŠ1)Ų`K.ōšØ—œõ<2RŌečGŪÖĄT0cNdlpÜô+ļ& Ŗ­Bŧ-(ÅGGųE:]s5e#,k9ÕqĶ–Ė2Ŧ×%ąĸЙÚÎ\ēĢ6€ŽgŠĨ‡ķũ÷˙˘ĸ3!ô@˜vˇHp‘ īJA°šœz€Ž§Šš–•‰EųÔ'>‰×ØüĩÍúÎWĀÚ܈‹ú¨åē’‚į_:2aMĻWĀ#{‡ũ  ÁÕĪ>~֔gŋëR’n]oē…Ą€}Žģä"ŸK2§•bĐué2 (6æ€8•h–œtđŠĄLŲˇrŪ)뎨üy>ĪMdÜÆFP•RØļMš\ĻT*aYžįRžšēDÃ+`Ų’ĢŸÛ[1e)%ųB‘|ĄØčŊNß_[ˇŊd3Ų˙-MP’Síô’=WŲņ&^[{hū3ã8ŧ˙}˙_ÍöÛŋû]~ú͟6rh_ŪŦũ WZа!ŦɃ>,éIYŦ´ņå–-&`ËΘ2,–Ō%Ûˇ…qņĻ6Rķ¤J=*ÚOĻɐãĐlj°‡„I×ũŽÛTĪ×įēAĀBŠũą~Ę;ׁā °ДBíĪ—}á"篯îTåÄ=ųĐo;|睟ëR)NLėlQ*Ĩ(•JÜu×]üčÎ;ĄX,âš.Á`X<ÎS#ÚãäO:å%A×*‘…āĘX‡G4”ôÜė;p˙ũ‰›yôņ§Ņ„āøí›yëë_ʼnĮo[öņ‰E#x5Ž…€ņLŽDüHž•ŠRNĩŌü>ØļđhģËŽāō”Ž"mĄĀŧ đûß˙>zđA Ŗ6ø`š&7}ö&ŽŧōĘFUž œüa`-Î.o„Sđ“@Ŧ¤ŽTž!ž– AĐ9DÕs(Ôäg/—,å(åWHļnëĒéG= ßqhÕ4t!ņšKÖ­€Z4Ŧ”,Eœ@NJšžíx؎‡åx¸ž‡m{¸Ž‡ëTū¯§œ°v.ŽP!ēžęh*ČZŒT‚^Ē{ܧ~œšRüū÷Oã)…í)а\‰#H…&%sęôW$&œŲ\ƒ1)IiÃYFr%¤įḲŒ<‰ã*ĘŽ‡åIJއíJÔUD*ÎÜÔ=ß>Ė}ŋūMŨ÷ G#|û[ßâŌK/­œÕh§Ifá{ŽŦڞuÉË~<Ĩ°}XčO|4Š7g°o8įÛũ6'B ¤QžÄĒ‚ĸãQļ=,×Ŗč¸šÆįnîė”ĸĪuéĐu RRœ€YG Ī;žWqZRáy˛ōĩ'+”By%JI”'ą¯ō އe{”—’#);%ĮãęsˇĐ’˜;ĸ2.%ŋūĶ>~7´ČąŦ1ĖŽëÉ8¯ûŨŽŲß<9 T‡.t$€[f‹I OÍe2nēé&nžņ3‚Á…õ‚aūđ‡ôŪ~;k.Ŋ”SūūÕ´lÛÆJ°°’L-š´˜;~|7?ō€9Ģ3üđ§˙ƒĪ}â†åp•’—\{õ4€%ĨĸX,cš—_r>—<ë\6Ŧ[Ãúĩŗ=B0N—WbL[؜-—l”R”íĘa `ŗŠsŌÆšV6›åá?<@{Owũ‡_ũŠ\.GKKK#Fˇ8øŲ*ĀZā@ ũXč+”ŸP"‘ˆøč?*'"ĨRQ Īõ*Ž+G ‰Ŋu÷@ÖˇgÛ=’g÷ŨOĖ}Z;ûÂö<Č[ô>Ņãx”l—‚å0n9 YnEu}ęP“_ĢÉČS­“Ũ0´šÆ|ÜöLœHÖ§"Ī–"+A1ķŠč‚ öīßĪ^˙ÛĩkūˆU­ŽÂ4éŋ÷^~ķ6>˙Ník &›ģŨLģtĐĻDątÃ.™RŠß?øĐāĒbwũę~úŲ°nųPˇmŲD0  rΙ'sŌŽíœwöéœuúIÂ֊V׎ȪfĸîsŠĐ7\ņŊûF'ģŠŧvkæāS)Å7žūuZ:;ęŠNâĩ×6Ŧčī}Ģká \RsüX†Ķ˙vÉDOVļų‰9vĒ˙™ōņŨj„Įr=ŠÖ$'§dģ8އí¸XއSåæ8ļƒįxHŠpĢĀĘSārčkEIVž>9”jÎ 6_‹œƒc9žōԀožZĒɤRämΎųԖǰ+Ĩ(íácÎlĢ_¸Q)Åũ÷ßĪ^÷Œ§Ķr)yæžĪÁ{îå”×^ĪÖĢŽB3›¯€­Îø´Í^3ũ‘ûķ<ɟ~ŧϧü—ŋÅ ˙ëí,—ęhoåŽī•D"†aĶÛkö •Ŗ@‹ˇ°öbĨ˛ÃHē€ãJŌewbO¸lĮ†yÆŲcįƒ;LT× ƒ˙ūÖˇyá _Ø(˛ûYJŠ”ĸiŌ„+ `uŌ€69žk`•m˙DF5Á=ŋŨāJp¤ÂQ G*å*%lŠpĨÂSUĐŖĻƒ­c­ņ}Ī<ęŲvŲöíYbĄĻŽĪ+ō*”B¸~Õ*:ZĸõĖw•w䊘IĮĨęw-ßŊí6ŪûŽwãÎ#ķ „ ‰TøžįMČ6ÔēŅ;é4ü?˙NīŊ÷rū;ßI¸­­ŠÆ/ĸZ_õā7žņ ŪõOī 0ĪF­i:7ũĮį8ũ´Ķˆ'hšFš\&ŸĪķôžŊüíģ?ˆšī1¨…¯¤i ũú7üāw/âĖwžƒ­W_=}Xî4ėYV}Ęé,ķ1Š˜ŋ2¯j#Ŗã ÅeXKˇy×ŋ™ØŽĮūÁ ļãq SšpųžødŒyxƒēŽĶÚē8p880ČΝ;šä’K…g֏4Ë;ZIBŖqŅ~#XŽíJOąjMhĻĄĄës/‡á‚Ģ;ĻÆÖxÁBJL0` ęĐüRŠR`5Ŋ%"ĩĶ"ŋũ­oņOozķœāĘs]^}ũkøõīîãŠ+Ž ŊŖƒ`0ˆišÄãqzzz8ãŦŗû‹ë9đō÷’ŋø:… †ž¨Ōuųũ ˙ÆũŸž‘ōøø˛ƒĢVisNypÚ!Í Eũ{ąhm÷+•-×;:œÖԜĪėAIÅ`Ļ4AÛ8ŲÔšđ„5ā{k¯ģ–ÜxfQĪüîūgJĨRŖFå„fzE+ `׈‹>ãcËĩ])Õ*ÂjFKúŧāa oųöōē["ĩ5]M|‹ĩôē$?<ĪĶ<5ßIEkXwŪy'o}ÉÄfO)Æ nŧų&Ūũž÷ĐŅ1;!xh<‡%v˛ôŠįĶũÁ‰ŋčhɖy–0tö~į;<ūŨī.oE)^XčŘ2YƒņV4Ũŋäˆ@°Ļ§ŖÖ9‰Rre;Ģęķ—ëŦŌTJ1T­bŸ 1_ļĨ‹h¨ļČô…^ČËūūU‹züŅáüņFÎúU€ĩ0[r…2O)˛ŌŋÅæÚŽą ¯š`™úŧĨõƒyˡŖo{ŧļ”Õ`ēPōë™Z‚F]`nxŧ¸bĻ{{lū”ėΝ;yëß4;¸RŠmĮĮ­ßû./xÁ æOC™<‡âį-‘ šiŋč2zŪûīl˙ģŋC‡į$×/ß~b*ɋķûhw‹“ŠnЎúģ‰i'îØVķĪjbĨuˆ›n˛ēgåĩzAŦ˜¨L'÷÷ƒĩS–„ŧõmo#^¸ ¯RŠ|˙ûžø*ĀĒ˙…„%guZRúÖjDC™Ģ֜–GôĒ›6W\Ī—cš.ēYÛét|ĖŋĒÔTИSåųđųŪ;Zȝˆ— œ§Äxx˜7ŋņMsöļ;ëŧķøú7ŋÁ–-ĩé!”)i OŪ?–ŒsÆëūĢŋū_´uæŦ)@Y*‘Ú´iy†Lyŧ,ˇ—ĩn~ęîK´}-Âį^™š&8õÄãqgy7áp ë{¸üâķųđŪI[kjEû*Y%šéu‚ÁĒ_ N‘€š;Sšˇ˜fĒ­[ˇŽ/~ųK‹’$ųÂ͟Ģy4Ā}6ĶģZ)$÷p#žÕ–H– ¯rĸ?†iäM<ÁæôËúŽš¨ŠK)(-ßĒRƒŅ:äÛ Žô•đî/j‰Ėé•KĨoÛÛčëíõg.xÖE|öĻ›H$5ßw(7ų‰N‰(Ä#•jÃH{;—}ôŖėģë.î˙׎Zf,FËÖ­ž×6ˇĀs ŊDåtūj8Չ.Ë;ŧú9—đÖw˙;ąX%LÆøķį]Î_\sím$q"‘*¯ąÁ._Y6îŽGpžÜMāôS0NØą¤×w­JšoP×ĩ™*\ĶĘv4ų›`ģ.Á:ä?Î=÷\ž}ëwxåË_ÁøØXũ Ņ“ °i™Ģë°=§ČÔ΁ņĸMĨ”t՚É$įŠ ĖdKžAã&ju\’įW/GˆÔÕ@ÉC+áõ_Ø1wÅÛ7ŋņMîģ÷×ŗūû9įŸW7¸ČMi˛kNyŨ‘đ$y^3M6_uí'ŸĖoŪ˙Æ{lh­{ŪsšVWôa¯ŸĪâōŌ ė%”ę [>!ÔD<Æ×nų-Ém-)6n¨FŌĻŽÍRŽ“ *›ÃŨû ŪĀŪî=x=†Üŗt4 ō…ĨXåÁ°^˙ō:ĨĸŌ‰B°]IŊ=ÆO>ųd~ö‹ŸķķŸũŒũë ܡŸP$ŒĐ4ÛĻ\(˛ũÄŒŽŒņģfĀdtdd`5‰ĨL ž‡Ÿ$‘á\Ų`՚aĩ§æŽB*äJ6BøRÛÖf )BĪ“Ē(ũĒŌS$ĩ§$×Ŗly+‚‚pzįė)ŖĮ{Œ÷ŋ÷_ĖBŪ~üqÜtķÍuƒĢC…ÉŅ pøN§ņ5k¸â˙~†GžųMûü€Žŗî⋎4Ũ§[clŗĮ'ĀÖT ié"M,û{ŧâ⠏ˇ%_ļƒķØã¸ģÅũÃČ'wƒiÂԈspø¸{÷--¸˛Ę(%ą4cA}'Wâ0øŧĀąJĨR\÷WÅ ˙â/Øŋ?ÃÃÃØ–MĒ%Ś5kx⁸û—ŋ‚`h:Ôt|~e0Ž€ĩĨĪ:âú×b¤h9āI}%´ 9m> ,ģhųļVÂҚ&æMeHĪ“eŠ4üˆ­)h­`Yļ떕ZūåøÖ™ÁAšTâC7Ü0+¸jkoį?nšeÁŊÕbS€”+ÕD°Ųxnz0ČŠ¯xŠ xā͟&˜h<¨‰*Éķ ŽHBĨeK´mͲĨ—ë0VūōבûĒĀ)4wIår(ÛA–F}ß-WԃEĀ^ĀRŗŧ E Ã`˖-GđCÁ Ō›9OäIyÔĪ–•RNąŖöQ+_´YĨ_5§ ͘§Œ—lßÖJK*Z“Į“ŽôOA$"ĩo¤Žã)WŅôU„†$ƒG&‡‡‡yŨë^Įoũ›™Į^Jūõß>ÄúETņuOéZ˜’.œoãŲpéĨ\yĶMėûÉO>>9ĄķíøfÜŠ›šc)âŨ1p" ōúëk×slđ–ŽøØ.åĀCÁԂ×ÄܚōËQsņJŊ–Į0|Ē:*Ä^€ÕŨˆ‹ô`•‹–ŗŠ¯štr…Í9ũĨB1Vö/ÚŲQcŋŋálɡ‹Ôį =ĮÕĨj~ Ŧ¤€āaŅ…x€žāÜ{÷/gÕũ:ņ”S¸âŠ+uīĩ­“åã%ûvÁŽĄywlÍN{íkÉ8Đđ1Ņ<l›p_‘ÖnÂ-=ņŽ53ļnÆŧüŌÚŌjžZ˛TĨk‘Ž#V éTŖIJMž~4 `.}°Y)ÅœHyŽK2•:ęįĘJY!§7âĸŊ~ö!,ÛĢđĒI­#l2gtQÁpŲņ-Ķ™š_•Z/úBëX%ËŅV‚V\DϤęxā^ū7ËĐĀāœQŠ?=đˇŪzëĸîŨ•ŠO¤ĮŠöĆW*Õ&~lFŖÄ×­ķeœî u’Õ*O[Ā-]‡!‘WūZ-Ņˀ úŌlŗVļŌP|T.0=VŧÎŨ K_đ+ĨäūßŨ?ķg)[tuu­Ŧå6U9_¸Ô×-yŽŸŖP´W îMjm‘ĀÜ,Ĩ(Zū͖ļD-ÕA‚ƒŖyß´#:Ŗĩ, ŒŨ•đîãBU>ÛŪŊ{ųëëū ˚ŋ%R0âķ7nQ-?zZ‚vŽRXv%•”+ÖŅ’É'N§'{ɉõPéCēÎ1ë3D8DôŸßŠ˜GÚ@DŖķūLM`ÅupŦ"ÅÁÖĨ+͚+sŦhOĻ-ŸWcÄŧ^ûíoË=wŨ=ãŋqÎŲsv9XĖP­ŦúŦ•T}&Ø åËŦZsZxžÆŲĸ5;;t‰-`hķ*€˛‘z€čĘ IDATLŅ7ŠÖ:š<#ŊŖ>6n\ĖįJ„ hÅb‘ˇŋííuũîĶO>ÉøÃß;h|čÄuŅ…CĀ*W´›˛_Ū#fQŨڕ’ä‡{RЎRL_ģ†č‡Ū˜K$%KžU‘kXė~•ĨČhAvVÜ0ž-NŧŽÜ”4ôEëڗ|lĘå2˙čĮˆÄfŽÆŋú5ß°XÆ*ĀĒĪ’4€ž÷<_)Qƒ9ëč/™X‰ĻĄyÚŌTúwų3[â5ôD„Š BŅr}c‰ÃõŨĒ?[2WÂë?ŠŊƒēũöÛyčÁëú]Ã4šíÖÛpQüÂŗļ“ĒFĄžJ'đööĨ›nŦFôĀ´U ]§”?ĻŨ‡ąm ąO~mûļ#4ˇ´íÛ^yŲĸīá9ÖDJvW¨}Ážht,_‘īFK“ŅĮ“×/-Ā’RōĨ/}‰‡Ü9ķ!2ā‚ .hÔ+éoĒųąæpŧ!ËG‰!ā™\Ųceõ~Ü]bũøįČlķôS Ŋđų‹N*%)ŒôP÷& "Ĩ"›¯DH-ORŽĻ “B°ĨsézGJ)šíÖ[ųäG?6ĢĪ|î5×°ŽqÜÁ'VV}ļŠ+ãų‚÷<…*;Ú*ĪŊm>}§\Ļhž†x M§lËõ$ʧĄ"RĀ*”œĘiž Ļģ˜rČҁQu|ē€SģZšíÖ[ĢĪ[˙įķš9ûÎ Z ˙sՙüøļû*ģÃhsc!p=ž90ĘÖMMŗNŽsŽŒäzļÕ4īzYį™ibžq晧BFKr]+Ÿ™āēŨ]ƒ\ā@ģžGК,Má“n5u’ŅĨi¸0>>Î'?ųIžō˙žˆ>ˁ#™JņŽwŧŖa[-°g`ÕgĮ7`ųÁJWøWĢčĒ-` čŗúCĨĨĸå[ē+ Ö´ĪģļŖ¤ŦDŦbÉ^ūŲŽ_Ŋō46ĩÄ :ÃĀĐ5LC'Pũ4tbšÆ'~ôŖšyo‡[(^đī˛S6­áÕqž8œÃ“ŠŊƒYļ÷$éÉŅŲ'kŽŽC§ZŖ3ÄŠÄĒg;lŪ-•šv™ōø0ƒF˜‡Íø‚¯Õۗž¨Ė&ĢTO‰‡ˆ…GqäĮ?ū1Ÿģé&ûfWžįņę×ü====}›ÕaŨļŠõSÅ=“-)ÄĒZ‡wÄĶžėŠæô‰žTäΎ~Ņdm=Žíú×HŲÔ ÍšÛ˛HŠ8š;IČÔ č‚Ą24 –#šwTķk,˙üÄ-$æi°ÖÛÛËĪ~p'- ãĄ´´¤0™2t^s÷|í<åJŠ6ąą"=­~˛ŸŗNZO0¸|ŽZW–čđJGŦ@$žęc1}ĨGq¤‚$‚EÖ/kˆŠû€Ģ/Y BĢnÃ_ë œu\7F@Į0tLÃĀ4uLC#0˜:Ąyö¤”ä\ŋę-5”L `(ãŸČhgŦžnW[Œ?ŋä„#žßߟVŋÜ;ęĪCŒyÁĀ}÷ŨG,ĩ°Ē,)%=ëYƒ‹/r^×ŅÂ'žu"/¸ëa‚ŨéASŖ5bįc9ũÄĩË㎰׈˛UË“S4ē„ ˜l]u4 ņÂčŌ̤˙îdH_8Cat,[͕'ÔÜ۔ĮÁĮvō‹ŊÄc1‚Á†i ižįáē.Ĩb‘ņņq†‡‡yzĪîøūBĶ´  ”lŸļíøãøđG>˛$keû÷f{“+`ŋÔt•"íy>iČØyË<ÚãWBTÚ-čôę¸ ĀÔĻAM#¨ ŌļGÚöÜŽi‰rÆI‹#S*ŠT`ųō;jé÷'` [R~=͚D˜Ĩ 1ģļã[ÔíōÖhM?÷Ā˜QiēĻqYˇ–WŊúÕKöĖלs7åøĮ‡*}îĘąŨSt&Ãüq×NŨą†hdyZĶSZšÁˇŖYŒ ĐS{‡&RûŖš2™)‡ÛÖģŋ[7¸Ē÷Đ}ņe—ņąŒTãÛâėšNÛ¤Ų#X ŧãŽë_@IŠiš#6MnŧôDNlKĐ4‚ēNP×č:MCĶÚĄ˙ ŽkÜ2É%—\B8ŧôE×4ūéš ›ŋį-ģöOn[$ŧŧ#Á˙D×s}æIŧū=„ScGO9?ĖʍSΌNxā;ĸ(j ?›īī%-MĖŊc“œ‘ą!‚}O7îp›Hđ‘ŒK.šdŅÅí&!DĶŠķ6;ĀjČĘM;ūö],9•*ņ†›)āĒMkŲ֖ŦéįĨR…BCÔLb^p4H) eĮ§9§hO.ū¤?˜öO¸=hÖTAX˛pĨî—ė‡\ŠM]p0[ömŲ×6KĶ´ēA’’ŠķĪ?ŋ!ājÂ)ëo U–Ė!îār[Qčü4ēŽĢķû(Ĩ‡PRJŦrąŽōã”Æ‡Ē+~]Į^#˛ā}Ēo`œgúĮU°öôP–ōĄHŧR´ß},q7ĮļiëėāŨī}/W\q­­žÍ‰đåf|¯Í°NjÄEĮ|`AĻė_ĢEDƒĩŸr՞Œ~ ?)YĮ/¸ 5ž¸Č‹0”.H|ˆm› Ø`í\‘ž\Ņ?K´ŠüՀXhū[éēÎ駟ÎOîüQÍŧ’ßøŠ%M Îf Lf;5â –…i0Ŗí6ĸ”5“°t(gFĐ!ĖP„UĢßĘŲ1ʙ‘‰ŋßîæÔ‚œŽRp°?Íۃc.b$Sd`Š4Cۓ;1ûŸfąNDJI!›ŖĩŖŋôĨ\|ņŜÁD"žĪƒŸŌd-rV Ā:ĄÍú(2Z´đü‹6´ęá:Ö¨Ņ<%•JÛŽôĪ™m‘cŽ€tēā›*l"4ą Ĩ2Qfdˇ–Š– Âŋ69ÁŇøËļ Ž§ųąBBĒ1ęvíu×qã§>3OĒP)Åŗ/Ŋ„į>÷šžŒųĪz'Ģ-ĩǚ{ĐĐ}ãŽÎgļĐxÂLpēUyÎrf3´q-Չ†ŠãCœ+<lãžPû‚Žãx<ągp"-ÎY<>2‰=Ž+eņ~öuĘÅ2ĸJBTšēbBÖLUŸOJJ¤”(ŠXŋy#Ûļo§ģģ›M›7sá…rŌI'ašærÍM ŧKŅ”­š`­i@@ÉW€54^ôUˆ/ĒŖĘcØĪtŠRä]éËíڂÆĸŧë)J–ë×)t¨BLˆ$ā*Ø/`íá€Ô)YžŠĒ áĐ⇠[°l„?jøaŅPmˇęėėäoy3˙đGæ =íŒ3øä§>E(Ôøē×ķØ_%# @¯‚RĶhžĀ$gTÅG=ÛÂŗËčU5šÚ›’ÂđA\ģ4ą/=l㧑úiĮR*FĶyžzfdB‚`8S≑É^‘Ήųæ?ü5Ąwū ų|žL&C:&“ÉP*)•Ë”ËeÛ&‰ ‡ ´wtĐÖÖFKK ŅhMĶĐu}ŅģKdßBėnÖ÷Üė+؈‹ŽK˙ú.Ļ .߯š­5J Ž‰ßoÛžŦ˛í ”ōåv­AƒĻÁE€iĮq)û%—Ž 4]ąÛ°č:˜ŌǧT´}}Ņjĩčbųyų|Ųņ+ę‚d¨69!×_={{ųΡž<Ė7čēÎUW?Ŋá?*Ą*ķΕxĶžĄ °ĖæXÃzÉäųŅ.d ¯ŦyÍs, ÑŪdEßũá.î uÔUL¤”bx$ĮqŠSh(JÁžá,ĻTí^4ųÚß^FKĸÂí ‡ÃtttŦôĄTĀûšų›`mhÄķ1‚•/ēÕögžØŽödÍáeŒû8ƒãE˙yĐ ×ķhĶurRb/ ¸Ž‡ĨüëG3CKA%‚•[† ūÉtĻ–†Oa—üĶ‚‹Į‚„ëˆö>đÁō—×^ËíßûˇÜüy6nŨĖK˙æoxÎUWqüņĮ×­ũŗ(€åyxÕyW‘XŠ \“D ĻųJ‹ U6Ģ6Ī!3;V­œô+wEÖōû`KÍË#“-1:–cdŧHŲ™.â-9<=’#oOúõįłüįK.Ĩ#;چķ=Bˆ=Íü€M °”R`ÛR_7īē8øTŌX…˛¯Ę€įtˇÔ3Æ}Œæ Œæ‹€/ ČH$ˆF=V]ĮSŠqY_ŗ åzK§OQÃn•ŠĪĘkQ#’ƒ9Kķk w§",Å 8%˙ĸn§´Õßž%pÎ9įpÎ9įđĄûˇeõ}RĒi#~(,šLŦXGM€ĢĘnJLS˜tíĒ€čä!ĶwF7ō¤Ö”üPLĐq=lÛÅ*;”-›ņœE:WÂuåÄ\¨Ę ’/9ô—čŸzøRЎ¯oã}/¸Tė¨Ķ,{ødŗ?d3G°ļ5âĸc>rŽ<ŠČųÕĮŽē:ˇÕ!M ĸōĪ)åũ‘p?,Ũ6æy„ K×Ē#bWļ]ŋÆG׈GĖš=5íŅ;œˇēüXK1cE˙ĸ§w&YÉĻi3Sė<Ų\ĻÕŗĐP¨ęĶ*×ÅĩJŌÕÎĢ Į*d(OKŗé!n‹m$­MvĨ”<úÔŞƒíxHĨfÆCāĘv%eËaoēH֚îZÛ…āæ‹vpŨ'7MaÄRÆ-€ŋB4}Č´™Öú†lō>,)%YĮŋ\ĐĐčǪדTŠ‚ŦRžė“ĩ"vX4ČVŠ!Īca0æy”įA Hû—ŌLD5™’åŦ˕lüĒJíL.MĀą?[öMyū´ŽV˛iB MÖr!•B×i.ŃF„_FÖrAiSI¤į:€f˜DÚz0Žq>–į”) ÷OôŦx&ÁŪ@‚Û#ëp>ž§ČäËH9™Žb4¤R(Ĩđ<ÅXÉf0[Ļā¸Žš[…ā•k[x×ķÎŖķčK Š ŧCņäJxØfXŨ¸č€ãŸ&UE–ĀSøTæßcčÄĩgbĘRbÍrJZjsļÉÉä-ō%›Ø­P\×ãū'|ÛaMmRƒiąV,;žp°ÂšF"āŨĢ!4 6 öTĶĒŽ”˜h”lˇ˛a7`Éh_oåŠō §”G&Â.v!‹tbëŽ %ĨG9=Œ]Ęqx8úi3ÉĪ#=d´ŲˇÜūĄ,%ËŖwŧ4į:€ \“ķÚsļsÎļu´%ĸGûxĩBŽ”nJ€Ĩ*Ĩü[p]J~J4äJõ'm]Ņ Fâí_ [ēŌ+IĨû1f(0Qy5›YJqĀuéÔu,ĨČ6/ÉúÁBĀÚĪŗĪ›™zčy’ŸüōqøØ@§Ō {ąV(;āI_DFģ4ŅtzQ ąK{Z¸ëéÁŠÆTõVļ\\W6\€+? wS&į•ú'šk—(Œömīáh%fyŽ•Ã.ä8ŧ ¤ ™ÜîáĄ@bÎOīy’‘j[Ž’íN*Š&€õÖϏhcĮuˇ˛Ž=I× ÖÖa_^ą’ĀUĶ,*)ĩ%gĒJ ä#Š{`Ė_õū–T´.æs¯XŌ“ēį“֚HmrKö<âšFĢŽO“ī(YũŽŋkųŪ=#tĮÂlŨ҃ahžut$Į}ŋßÃÃ>FÔ\ĨPK@ŦÍKš<ˇŖŲÕgæˇ ˇöĀ̓-CE`ÃYÖ¯i^ŽŲ=Ą6 $g—'žį”ōxļrԘ×*båĮqJš#ĀŖ#4ļņ“p'’ųusų2nH—Ę“ߡŦmáM—œFkT¨/8i#[×ŦxAĐEėj|xųJWÍ °ōlŽR}"uä3E_įâ‰ÉēÆÂ/MôUzúų˛ķĩĮę ÷€ƒŽËĶdÜu(ģË6ņsžäާ‡šbū÷ožâeWœD*FhĨŌSFŠ¸bžōMd4Æl2A΅X"âÄ ÁŊe‡LŲĄėz ’í’Í—I%šW×HˇGÖrŊ[ .+‡8ˇ\ĀŗËhf!VŌûQ()qË%ĘŲQ<×fĻr_Oh4ĸü8˛†ŒfÖu ÍQĒĻū]O’=$ĒāŒ-kepu;đR!„ģR?„q,Ŋą1#6R*öįũSÜFvtÔŪĘÃU ßf­€Ãųq ÍÛuDCSŠÜëŗ>Į! äĒh"@Ņ•Üō“]lˆi–”ŒŲWžží›;kē†]°|Ģ×_ß?*˜>‘P€įoėāŪ'úČä,:[*’O<=ČY§Ŧ¯ā.‡yBp¸‹Ë ŊUč‘܏ĻÁÁD+ēa6'ų])¤ô°ķãØĨ<ĘuQj抂G-Üę`TĢŋ¸ĸl9<šoxâīF'{ž~MŠ–XäXu=ŸŪš’ÁÕ1°†}$uÛŽGŅĪM:`Ш=rs¨ EŖÂMk4ÂĘ|éC‡‚įw´rV[_]ˆ´•bwžÄĒU7KŠØ››~Xh×†™”Rdʎ?ę NXá"ŖSíī.:‰wWÖŪL‰Ž–ĸę[öėḭ]Mũü;IūŦØG` 8‘ž‹]Ėbŗh†‰n1CQĖH Ą-`ô ˇ\Ä-ņ\éÎ}Īk&;Cåá]Ąá AYh”… (t Õ?ļĐÕ™˜ábļíōčîĮÃS Ī“ÕÖFsmĶy‹'†s“…PJņ…ŗļrÅiĮmŅĒہ¯ŋB kž˛™9X÷×5ú&ŨՔáccÜW*Ą-ŅŦ:č̊ģbK[%HđdąČ™ 9)'DUL|Žz‰Wl‹YBt! øŌ4ëĖļxM?ˇ&äõŨŨÜ1:Ęåōī]‹ŽŦY×LK´æM9Ÿ-ųÆŊK%"G5wn]G ŋ|õsxĪ÷~Í-ƒY°/[fÎbK2LËüĄĩwØöĖī­nz“ĨÃ'B"Čj&YŸ2Ķ—˜¨P*Ųėz˛Ÿōac;UãPņLēО'RņŸįnã—žšŌ5ÜÆ€]_w !,Žakæxú˙øu#SĶøķöv^’J^]ļzõŅ‚&ŨáŠ.Ґm3ây´é:)MC_‚—ė(E J€_ˆģÜ?Vôm‘mNÖ^EÖ4ūĒŖƒë’IĖÃŪ{,fKĐ`ÕfX­Ņš'wš`ų#9^cšŧA–žLEąĄÖžŒņų—]ɗÎŲĘqU`¤”âéņ"8æ÷ûĮč/R*;Xއ+՜`¸P´V'ô,“)ņāŖW3™”Оí°{(ĮũûĮρ̭篗ŸĘ+W>¸ø¨âb!Ä{…?>ÖÁ4qKņ”Rjā›Čiņ8C!ž<<ːˇđ2ũ˛åâĢx4@ĒJė>¤õÕīē…`ŗi˛ĪqÍĨ˛ĢŅ0¨ˇÜc oųÅ@{¸ūČĶŲ‰[Âaž:åŊëlßÖɞGúVw“ŠQM :"sáwד*ī—Ü…‚Ím ?‡ÂF€˙>d€.ā4¸%”ĻiŧüŌ3šúŒã¸ņgđũŪQū†0Ė IDATTį˛ãą{´ÂąÔhˆ‰˙z%mĨ 8Š'…ŽkŒĻ $âáՉ]ŖIĨ8Зæ@zp•ÕR“CJre—ąĸMēdW€îÔuŧ(å#që;ŽíļSJ}KąwuĻ49ĀĒÚíĀÛüŧaĘ4y}O?ã×ŅU*Û_u˙+’aš†F§CK)ö9k ƒaĪ[´˜¨Ēî*õ¤ ])ÁvŖøĀŅ€dxa]YZM“ėîæŽą1(•P ŽÛØÁŨõc5Qۜ-QöĨ—¯đ&ŦkhÖt ’Ė’y–žô˛ŽøÔƒr[{Cšô‡Ļøđƒ‡UA (ĨnôÃg  #ãƒ×=›wËür×>ņûŨėŗ]žņdu͉Ē2ŊÕ´Šۓ„uŪÁ kģSĩËnÃfŲ.>ŲOū0bˇJVĪšß;—k į†Mn¸äT.ØąĶЏĻ!Ō€/^-+`}Áo€E@<¯­ã#ūkt´nÉ×öWŌãŦÔ$Áũpų ôē.IM#Ēiŗ’ēëŲeif)ĨæmÍå­‚°?ī b‹Đ hŲŪÎ …˙56F,æ´ö8÷e›b1\´­“gš‰;~˛‹]~rüĻXL×Đô <•ĸĐĖGÃĨTĸ(•?™S§'Ԑ–‡^5*õ8ĐėGŗįFāÍ~JM’Ņ0~ŪI<īŦ¤ķEŒŒķĐū!îÚ;ÄWĮō3ūŪPŽĖÆjAČî}ܸŊgu7œÍį)ÅūŪ1z‡˛ČÊĄOōx_†ė|‘Z˙´žW\°ƒm=„ƒG­ ÃĩJŠmBˆŨĢ3§É–â1ĨÔ­ø@vŸÉļTĩ“ž7:ʓu´[/Xžj`mNEgX‡,#%aMc­aĐë.^åĨVÍŦ|Ąœá Ā i‚dxņäôŅ(o¸ulŒÜš[ØuįC=šŦkáš;zøÔeg0č8 Ÿž]w=ļ,rc†v8)Ú’QBŠ SR†JJ͓>[ȜH“/ą=%„ø\>ëĨÔÍUPæīÁĐĐéLÅéLÅ9kÛz^ü§T-ÛqŲ7”æŦīŪWXë[ŖhB0’.ōôžaļnėXŨVƒÃYö÷c͐•(Û.ôg(NŅļú§u­DMƒĄŅ°ž5ÆÚÖ$ë;R$"ĄcaØtā“JŠ !äą>‡VB\øƒĀ_˛L„ü”ađōŽ.~•Nķ“|~^`" d|lŦ ŊÚq}.€P’’^)Ykä¤$ˇČÍīfQX}ĻSģU˛~ą7“ą‘% šˇ˜&¯ęėägĻɁz¸}×2ĩĮŌ¯;{3ī;˙dÖƒŦ;9@h´ĀMđũq"‘ĀL•zJ…Ф ŖUĐEĻ`)ĨüYˇƂZCn5ŧ€ßųßĀĢÁŋîŗîvš B8HK,Âå‘?/ÚXž¤dšDC&B@ßPËvŲžšķ¨J[ÕĒ×6ÍO–mÆ3EžéKãē3ûČlÁæáĄė„žUJžvņI\sÎ ĢČ.âTx‰Į´iÍŋ@Äç`ųzšjĀ%--ŧŽŖƒÔŧN\Íų¨…ĸ­JR`Užú\O)šļTØ'@…Ÿr„7rËvܯ‘X“Š\BĄI]ŽjmåŖĪ>kÖĩú>īÖDƒ|üš§ņ‰gÎšāddnK8ÂGŽ8›wŋÆ÷gŠĨæÄ ŠęrÉΏųõ\'ĮÃ5w2¨Ķę>- !úĢÃæröšā=ûdŒĒ3}j$?ÍŠŽŽš˙OûĘĖ*ë°RLJE6WbįŖ9ЗĻT˛°Ī“(Ĩ* ëJázû˙oīŧãė¨ęū˙>3s{ŲžŲMī4R( ‘D”Ļ€ Bŗ  >Â#ū,€<(Š$ŠQ4V•é5!HzŨlŊŊÍœįšģė&›ÍîæÎŊw÷ž÷ë•WŲÜ;3§Ėį|k&G2™aĮîNž}e+Īž˛˙lmëW\YR˛­=ÎKģģzÄÕCįwˏTâęmBĀGÔc9͞˙8˜Pʋ˜čõrus3Ģ÷îå?™Ė_.ZÜ"ŖaÛEØ•Ë Z…&ōÕÚ'ē\l+@–!āÉ ¸¸´­tÄŌEÆG×;“E6#ā—g-ãōįgÛ× nMđîiÜŧėV‡û­í0 žüžc_ķ2ˇ<ķģvazu1^ƒŠcšņ¤!];;ĸEsL­qŦÉsË0˙ŨĀyĀÂrÚHš1žŖ˙ū OϞDĶ9Zģ’4Tųú“7ˇ´ĸokÃí2¨¯ yņ{ŨųÖ<Ļi‘Le‰'3ÔÕđ(ž(gZtv&¨­ Ģúŧ”’\Îbįž.vīÉGb)ļėlGMô-jɡÛŲlŖJerŦÛÕEŦ—đZęŅšë=ķ™=mœR}šJJyWĨģ G„ĀBĤ”Ë€°ŗ–J†G͏°ą‘įĸQéÚßjZ¯ÅRŋ14öؚZp}.Ÿeبë$,‹XJ:KÛ-b’¯oēĩ3ŠbūYÔXíØg\?8{GũũE>ũâ&˛ČFĖm sÃ˛9,ßDø 1E†Ļqå;pƜŠ|âˇOąfo¤`åAt!đ[☙M4Öņx\ƒ}ņ „¨jíHdО¤¤š&č”'zã0÷ŦŒ”ōLėāø˛ ž x=|÷}Gs܃O—°ž-†ĄiԄ<ûėc’d:ËļŨ°ģgm÷ĖĶn6īh§š>Ää õÃnÉ#%ėiébËŽ2Yŋן1U4ևüL™˙ĮąD†–Ŋ:ŖIR™\ŋmi¤´-üæ0!Ļ%ŲŌeg,ĶͤYŽm qå˛šøŊn:::ĐuP(„ÛíFAļEģŊ’ˆÉÍBlĘoXkē­%ĨB‚ŖÃa&z<ÜÛÖFG¯ĖŧHfbOfxīŗĶÄ/͆ÁŽÜ!ģēĶōŗHim§EqæĖĒuļ’Į0¸ō]‹9vj3įūößl,”E“ę¸nŅTNž4–Æ!nĐãkÃ|ęũG3aûŪÚÚÆ?7î%•ĖØC!ūŪž?¸ &Uû™\dnSËī!đâvé=/•ĄLee$™)ΛFÂįJ4ŧx{Ö)åõĀ7Ëi/;š™oĖ›Ė/māՖ3˛~šj]ØûbY’-ö´ÅhŦ ĐØXEh]vKŸ–Ŋ]ėi‘LåzΤ‰T– [ZŲ¸­€ĪßcāryKnŪ­—5Ifr$RYLS:ržÍä,övÄŲKcömaāgĮNã¨écŅ{…Œ˜ĻIgg'†aā÷ûņz+"¨ũ@ķJ Ŧ$˛ū&Ĩ< ¸(yuŧ&‡ĢššøC{;k“I§)fIŪĨՁž¸ŖíŲᗇHHIÆ4Š×u:L“”‡tI!ȤsEŠūč×4šũžbĖAŽœ:Ž×Ž8ŋžļ‰›ÖžÁ3 †TįKÚ |\cˆ•Ķ›8qú85ÔŌ‡\1MjĢÔÕ8ęˆItÅRDŖI2É 2›#“ŗcO\††fčč.Ũeāvƒ‚^wZ‘Æč: )‡&eÂ,R‰]cz­ckã!Η;¤”큋ĘeՄ➓$žÉrŨēí ov$ØĪ0šÖOØī˛`ə;[ŖėØÅíÖŠ ųųŨ¸Ũš&,i‘˚Ē:Ŗ)âÉLOÜÜžß'ō–ŖHDëSNJZM“q.Qͤ+ß4z¸ėi‹iéÅ(2:V׹ļŒ×e°bÁ V,˜Áķ;÷ōԆíŧŌŌÉãqÖ'ŗë%QŨÕnƒŖƒև˜<φųã˜W&T€Ōˆ™Ŋ‹JĒdžĐOqߗÄĶÄĢiÔézŸâĩƒå¯l+ZáicŒõ9fÔ.Dúč%ØUŪ——ČŌŸ9í8šÂ/ráŋß´Ez&ĮËģ#hš )āaLЃŽkļ@ęQ1vŧ’eI÷éĪ JÄiēΟû+“&M*ôTúĻâS•,°FE‡O!Äķy‘õC`E%¤9ˆž€Ão–…WÆįÛėėTē~Ã.ūļaOŅĕáŌY ųė˛D~ŗHŌ“D2C2’ā¯īdc{ŧ¨÷Ŋŧ!ÄCžŒG×Պâšŋnoįĩtēāķ KqįŖ/–Å} žš„åĶqZĀáBˆ íSs€WGú\KgrüũĩˇxđåÍü`w'mđ;¯%9­ĘĮŲ‡į”#ĻĶXęo¯oŨÍĶolåĨ]mŧԕæŸÉlOĪ›9 Â^Ļ„LŽ 2Ĩ>ĖôæzÆ7T)n̘X–E"‘č7FĢ[ƒAŧ^ī!Åg­[ˇŽŸ{ŅČāÔë†Áŋžz’††‚‡LUŧkTŠ)å1(qå÷Rą,jmåe^ŽŨėÜÜÂOžŪØįķ%%ęe$%_~Įln8vnå)ęƒę˙ŅŅÁcą…lz˛q{;ŋø×†˛¸ĮÉ^kWžJŖ×‘ Âv!DCû“x;ËjÔ°Ŗĩ‹­ß ;ÚØM˛5™ac&Į.ĶbŒŽ1Ãm0Áįf|ĐËė1Õ?kS›ë(Šēēēz,+đ|ž‚XzJ)´ĸŅč-FšĻQUU…Ë5ôŒčŽŽVœz*-ģ÷ éß-gwˇžÉgÕv×]ųÄMƒ˛4™ŊʃhBŒxqÕ[@™ĻIWWš}B.,ËĸŖŖĮC8´5KJÉmˇŨ6dqpÉʕNŨnœ G­7&„ˆagë\d+iPpT8ĖĒÆFĒؐöÆĘĮ_Ū„ZĻ:ƒ3*höxødSs TėPv•‡Ājō\5oē“_ąÚĄĪŊ8gTŋT„ĀĐ5\ų5—a—Ē1t ]”¸’RöXŨâl´ ë:ĩĩĩTWW÷+Ķé4­­­¤’ƒëkûÜsĪąúg÷ų:ĒkjX´h‘Sˇš[ ŦŅ,4„Č!ž†ŨĮ°ĸ¨wģšĻš™Ĩ~A­MŲ"•^8(–äė…ĶŅ…˛_ „W×9ˇĄŗĒĒiÁ må °$\°p2ゎ ëđŸ‚_€;Gûž[=&›í̤iÚ¨XŨ¸ŨnęęęƒũŠĖH4BgGį~EL{“Éd¸éK_Öķ™1k&ĩĩŽū¯Ģôy\ ]ąÛUx7eâŲ*Mcy]į×Ôā.Ä% “(5ĩ1Ä)SUûÁ.ô…á0—76R?Ėd€œ)Ų•,Ŋ1xV•—Ī=ĮɀæDĄOßųš}R-žFûÆ(æv3Bü~?uuux<ûģŧ3Ų mmm$“É~˙ũc=Æ+/ =ņ$—ËqÍĩ×ŧLDˇFæÛLŒJšQ!D ¸\JųĪŧĐ UŌ@Ī ëņđËļ6ļfŗÃY’đŸxĒô7$%ˇ.M@e‰ąW65ņh{;˙N&‡tÂĘds¤déĪ'_8q.õÎVí˙•ĸĐ~đSEÅ~VŠTŠ×_{įžŽD<ΜšsY´h‘“V‹‚Ų§q}Âc´Ņ]€4NFûX­¤”DŖQ˛Ų,Á`°Į­hY?ũÉO0†?īˆ#X¸pĄSˇ"T8FĨŨ°â>)å؅IįVŌŊ׸\\:f ččāÉx|XĻŧH"CܔŖŋā@?Ą–SĻWŠi¸43ë뙋ņHgį E“•ŗČYĨXŸ˜?ķfNv2ŠÁž^Øŗ€Ô€oRÄd)%7näĒ+¯äÍõoôqˇ?ŽūčG˜9ŗ,į§”˛¸Đ4Í)+KYâņxpģŨDŖQRû´ˇIĨRdŗŲžJđ/ŧđũáOĢÂC›äĻÉō÷­pR¸î:+}¯­ČX!Ä`1đ˙ķjå ¸œZ[Ë%õõø‡ĨJž“Ô5îxįüš˛^ ķƒAŽnj´ËPæLJНŽ ķÅe œŽšÛl+đgžL+ÖsĘårÜsĪ=œųūĶŲ°nũ~u—vnßÁ%œÖÖÖ˛œ—ûÆ_9ŲRόßQ„Ãajjjö ‚7M“ŽŽ‰üú×CW5ĩĩ\xá…NŪÂíBI…SąÁ–Bˆ v—û@WEŨ;0ŲįãęĻ&& qķŌJ%|qŲlŠW ŠT̚šXôgĶ؜#e?CsĐÃęĶŖŲųXœÛ„…4ģĒXĪŠĩĩ•Ë?ņ nųŌÍ$âΒ߹};XŗĻ,įäžVzä\.ĩĩĩû=)%íííüãņ ų3Ĩ”||å%࿇IxEegŗ!,!Äķ§ËG*íūCēÎĘĻ&N …mƋĮS=-,JÁiĶÆpÅ3TŨĢâÖ4–×Öōąēē!Zē’%É™ō˛æœ˜UåxØdđķB~ ”ōpėĮyõÕW9īÜsųÛ_ūŠnÜ*y÷wīŪ¯SŠ‘RVdüՀ/iMŖēēšP¨īü߲e omxsĸÍ͇Ī?ßÉŦĖ×Q5°”Āę%´Ú„gŸ§ÂjféBpBu5—Õׄ˰=–,ŲĩžÔTÅ—C ]Å`†ßĪ5cÆ0ÃíŪOH {:‹<öŽSŚČuÕÅøÆß…nŋ¸žöé§šđÃįŗų­Á'níÚąƒ–––~ûä• Ë˛ú\‹Žë5>Ÿ¯ËđÉ'žĀåZpģ”’›oųjjíŨús!„ĨFL Ŧ}…Ö-ųĶæŽJģ÷)y—áa˜ãĨ„ļĨé/æûg.ĨĄÂOŗNSírņ‘ÆFŪėc%XŅd1#—ĪŸĀoĪ;‘ŲUEIøÍ_(dÜH>¸ũ=N_ø“O>ÉÅģhHũįfĪ‹Ëå"ĶÖÖF6[úŗåžņWÃi3šévęēÎ3Ī<3äĘö“§NaÅûŪįä%ϰ;(”ĀęWd= Ėū†ŨđĩrNHēÎųœcôk>–$SÅw)ŧk\ ?=ûĻN•Ž^Ęŋp+sS‚w×Öri}=Ą^xk‘ÚäYåcõGsį{ŽĄĄxņ7÷! Ü^Ėwōĸ׎]Ë%]L:5ôŌ)žāüž?wˇhé돍ĨĶ$}į˜G¨ö_ŸšfˇŅb „Ļi|õë_w2ö āa!DB’X‰ŦādāũTX-MޝĒbUCC˙mvŌÅĶnM°ęčŠcø>pplĨ ­‰>×673Ëí&“3y+•upÍÁ Ÿ‹[OžĮ?úΛ>Ŗ¸Ĩ@žáĀgžëäŋôâ‹ŦüøĮɤ‡îÕ;~K–,ŲĪ5˜ÉØE-÷:ÅĀ4ÍũbÂ*9Ā} t]Į=ÄZpg|đ,^ŧØéKûŽ%°#˛L!Äī€ŲØå*ŠF‡Ģšš8˛ˇ°‘°3U7ÂÔ*œußÕĀŖŅč~íiz `0čx=ĒÎÎÎ>‚ŽĒĒJš`׎]œxü;ú4Åî[•üüŋ`ɒ%N_Ō'ōŊJ` {ã Ëącĩ&ā9îd˙ǎA`bšÜ÷ŪL†Û_{‹¯ūņe[`ę…(%ÁIãj8˙đ Ŧ˜=™ĻŌlĒ× !nâ\Ũ āxāp ģ“›ũ­Ã;C-´c7~ģ ųĻWK)īŽ(×õЕ˱ĩ+Jk,I,%‘ʐ͙!š†ĄixŊ.‚^ÍUA}^j\Žr1Ëü¸ßæĀ>áÁnv;ĨĀŸËˇŋõ-n˙ú7Ї(xršščc|éĻ›úĩdd2bąØK7ø|>€#VĶ4ikkëķ˙ęëëļ¸Œh,Ëâļ[oåŽ˙ũ†Ģ˙šI§šî†ëYĩj•ĶΞ˜x ĒXŠĄnt"˙üÜĀaĀd ŠÁõ8ŒåmÍoÂũŲų5ālĢYYt:ĩ$Ŧīčâą7ˇŗ~ë^žīŒķT"™ÜÛ-´Å¯˛îŋwéĖķģ9Ē:œ)c8eÖf…‚Ŧ/ /‹…šˉÁ<†’­&Ĩ<;3§V­ŧ‚ō*p¤}‘Rú°Ģ´ŸS,ã´+ØļeëĖyœĪÍ7ß|ĐĖŧX,F2™ėˇtƒ‚`0ˆĮã)č ;‹õ‰ûrģŨTWW—Ë捊Ą ˛lŖ ]IDATŲãĄÎãA×ĩRŠĒŪ,BŧPæsa,ļ qēZˇ!æ@æ`÷xųą­”]ˇmmmwô1u õ9Ši—^q9×^{í Ũ|ŨnÃL&3 ĐōzŊ‰“jmmí“ŊX[[ë”KRÉŪ‡}ūÎÂnŸ–Öŋū”Ÿ/éŪŖŧøŊĀOKyø1M“G}”ŨsííÔÕÕŗęĒ+9餓ŠQGŦ˜,„čRۊX#QhÕįņŠ#æšGÎäúšâ‚2üĀõĀ ¨•CåZāN§úĨ9eÁJ&“œõÁōÆëëõķÕĩ5Ü~Į,]ētX/Ú\.G$9 ÛPĶ4|>~ŋØB+™LFû|f]]SîmĀúīAkæV H ļXĻ”2 \|;v˛$¤Ķéž8:¯×[Ė ĖK…?P[ŠX#]h}¸]=‰‚f !v°y°xÛ-­:Ģ…vxŒ‰Áxꊧ¸āŧ (@ą8—_}%+/Ŋ”ĻĻĻCūÎL&C4= åLĶ4<ΰb´ÚÚÚú|n(Âį\Y–[„ŸwhĖ밓 ž‚:R ėĻ !Tk%°F…ČZŦV/ׂđ!ÄÃ#tÔbĮeĄ†qHlŽBDx; ĸālذûîģ›n牆p5†ÛE6•&‹đå¯}÷Ÿ~:ŗfÍ*ø÷&“I‰Ä€.JĮƒĪį”%‘H‹Åú5­WYėLáíũaųƒđаžŽB|Wm+J`&‘ÆîV~˛zÃ{„ĀmBˆëFÁ\¸ør˜…8°TņR‘ÆæėbŎFŲ´iŅh”ĒĒ*ĻNŠßīŧ—*™HH&ZBü~?nˇ]×÷ŗlår9:::úÄx'+oƎģKiüOž‡VĖ2áY`‰ÂT[‹XŖMdéĀJāk@X=‘!‰Ģ‡€sGËÆ Ĩ\īB\ĸQ™H#]! Ņ,nŖ˙ÔcÅÛ´§Ž6q•Ÿ1`đ *ŦIųAH+Š-Žōtæũh߃pš\ÔÔÔP[[K r U(rZ\Ŋė*á3Ú,~8 †ŧ øœÚZ”ĀĒ‘•B|ø`ū…ĸčߊņū‘–18ÄyB|?ŋ‘īTCN"?æ+ŅxXØÖåŠA×u ÔÔÔN†aP]]ídÖ`7–:$ _Čøëô_"b¤ !ÚP|ĖÕ#]H)'aĮ;F=ĸĀiBˆĮ+hx˙Ŏũ¨ÄƒÔfāė|ĶîRŽÃ,ė āO6›Å4M¤”!0 Ãņū†yŌråŪūę üJqŽšÍJ`U˛ČØĻčRAŠĀlˇāē  §?Ē+喁ßBDĘd ū‰ŨREQž-„¸ĒŒÖd]^t׏ĀŊtĄĸCMŠÁĄ\„ŖQ5ÛÕŠW§aĶŦHmíž™_Šâ*?ÁnJūJÜnø,ļ[0RF×u§Ú•JĘ÷ËlMļį1˛\… āD%Ž”ĀR؋ØBŦæ¯Syđwī+ŗmŠæÂvā(ā[ŒÎx ŧ,BÜ:Ø'Eä”›°T<#„(ĮÃÅcųõ8R¸ۂĨPKŅëåē ģ„Ã5ؕŒG;qė íŸĘ•*ėyB\ ,F“čÜœmŠ\_ĻĪžÛĸŦ 2_xĨŒįħ_Œ€įø!ÄNõíÕûŽz´ÛHy4đ+FoAƁ̅*ƒnāy0¸ÛĒ5RŲ„]ĀņĮBˆėyî#9¸y$˛ģ×hēŒį„Ü”kāøĀ9J\)Ĩ܂Ž~œ>ŠnkđE!ďÔzā‹Ā'ĒtéoßBÜ5ŸyX ĖQ3ĐqLė˜Ą€yĄ÷(ÃëšĒŽXŠĄ/ęĪ_ŧ#ø6öw_)VŒ2ܡc÷Ė+×Ŧ&‰˙q đŖ2ŒąĘķŽÃ.>:NÍ>Gų pÆHąŧH) lĢō9eđ^6ģąŊĒhąXŠa,hL~LgdÅãE€›ģ…q5š™aė6;73ķÂÛ(á%Ĩą‹Ãū ģ™õ†ŅR_J9ģQnƒšyް ģŠsd„Í ;VöĢ€ĢD—Ņ|øŗWJ`)}Q{°Ÿŋ 4—ņœØĢ€‡…íjô›uųšpvw€cw~nĮ7 < ŦÆ.+ąK‘ĨĪx:đ4P§f\ÁYžĪ ŠscvėĶaEūęUBˆ=j )Ĩ(ėĸvī.Î.á j_ļaĘü=đouĒ*ÉÜĀT`Vū× bg¨ÖŨ%WíØŲžmØŽŋ7€7×*i|Ĩ”3€ĮķbVQîBœ;Jöãķą­ˇN쓎W !~­ĻX įˇŧģ§ŨņĀĪ‘^žB<ĨF¤ėįK˜€mņŅ×ĩ˜Ė˙jļĒ€ŲũžŨāaāXõ4 r›'„čEķÃ..ÅŽiXH^Á.Âzˇ*iŖ–ĸ´ Ŋ8x7vĖV;NĮ 418‹— ėrų?į°+wbūŽūĸ^Š [[p[ūEęQOdØŗeBˆgFņ<9X…]^eB~ ‘ŧ}¸k4?+%°#yĄkØAņŨ190Û}Ԙ_øŨsĢ Ûô&v5ë,oW•—Ø•Å-UgEQákĒÛ ģÛíZ }x¸;ļq#3Ģ8…Ũĩá¯$Č]ųŊv6v]Ã`?c—Ân•ļ x ۊœUÖ*…BĄPTĸĐ2¤”sĨ”J)SRJKKJŲ!ĨŧQJ9>€”R“RN–RūVJ™•#‡¤”ō]jæ( …BĄŦØ+Ĩ‘x–ĻMŧA<Ŋiœ÷‰C,/ĢÄįÄc&]ø‘ëJß8į\xfČL&æ‰CÄbŽ…•fyS#ž"̚NųBĒÎ*į-ÎZąĖ÷ä/ fô•eŽĶF ‹X‚ Ę( ZuR,$h?ęárũšrĀČą€4ČŽü~wke''ęIÁ(Đūâ8#@`¨UįûØqj'€˙¸Ō›ūR˜ų$ŊŌÔÂG@÷6pqŨԔ=ārx2dSv%?M!›ŪĪč›Ō@ß-ĐšVī­ąĶ I]Åo€ƒC`4GŲëīîhííß3ū~FCr•‡%ĒÖbKGDųCģ pHYs × ×B(›xtIMEæ!ûaļYtEXtCommentCreated with GIMPW IDATxÚėwœ]UĩøŋûœsûŊĶûLz2$!„„!HŠ* Ø@AĐįûŲå= ŠĪb÷=Ņ'úėTP)6¤„š@Ō'ɤL¯ˇßSöūũqn&“2ÉLæNÍYŸĪ|’)÷”Ŋ÷w¯ŊÖ^{-đÄO<ņÄO<ņÄO<ņÄO<ņÄO<ņÄO<ņÄO†)Âk‚ņĨ”U@5Î˙Ęē ä„Ōk1O<ЧÜ ¸xp*Pšo÷Ám¯Ø< ü xBŅëĩĸ'žLbĀ•R—)ĨVJeÕņÉ6ĨÔ­JŠZ¯E=ņdōAžX)uŸRĘR…‘&ĨÔ ųĨŋ'žx2Á€kJЎ+ĨÚTáÅVJũ\)Uéĩ´'žLäēRę6ĨTF­ŦVJÍöZÜO&F“Z)eĒņ‘į•R ^Ë{âÉø‚~Ã8hōCåĪJŠ"¯õ=ņd| ?E)ÕŽ&F>¯”Ōŧ^đēą…<œß>›(I(ĨVx=áÉpÄĶĮšn.™ĀĮˆŸôļŨ<Žx‘qĮšdūTLđŖXĀ*!Äj¯W<ņ4za!÷ˇOČ|Ā ž­î‰záå|ā˛IôÅ ŽK%Ą{MūRt>vzĒĩE¸7įëJŠoxx {2X¤ ‰|Ÿ~ڞƍg™rÁŨ…ø‡RęrOģ{ {2XŖįļÜüģ˙ē^žĘoĩ¸¸ËĶîčž ũāo.ØúiØ÷˜ģí65%ŧ'¯Ũ¯đŧķč'ļ |3˙šĶ;ū 6| úwššęj÷ßw*ĨÂ^‡{ Ÿ¸ g ņKâ†÷ÁÆoC˙×ϟšÚũāĮ^ĻÚÉ#Æt}ą|4W×iT‡[Åtđ^xčĀ­bš2G9ˇ]o™Đ!4Ë=5>”Č>čų ôūBËĄō"(]ĄJĐ}î5φy#TJŊGŅâĄ6Á:fN^œ“_NFŽō‘n‚†§€ûį…‰CŽšˇĘéč5TĮZØōž4}~+Î7 bgAŦŠæB¸Œ h“ũh<[€w !žōpķ@/āÅĀ›N;ÎwS¸'šî~#„čĪ_{fūįeŖ~ĐlŦšœļã}S@‚^Ņŗ čd(Yá*đE&kXmpƒâa9ôã\^›°ņĖ^úā“ĀßōKūíâ,ē’°õgĐņŊ5ŋđ/č2(]Eķ \1Ų´}đ!ÄCvč#…<|øČ1–įĮ+iāųIäĪĀyšj|Ŧ˙ {Įā‘mĐ*!ŧ J΄Ō%­s—ų“öë=Íî>R[ükÛŨ ŦÆuæ-(ĖÃKØ~´}kŒģ@‚ˆB J΅Ę3ĄhæDkúNāõžÍî>\MūCā-Sv˛ĘÅaÃį ũôøŨS‹T\§A´vĸŧø[ „û<=Ї‚\žˆģW;ĩãúwÂĻÛĀÚ5ūŨŽE z.T_ åKĀīáp?đ6!DĘÃĐũH ŋ7újzÔëŲ[>öî z ‹ĄîÍPu&øcãuc | ¸Eáx(z †ŧwŋ{Á´ę…ū°õ}aģDAāh¸ĒÎ#475ë„ŋõPô@ ú§Ī0“Zf{ĄųĐų P™n]Bä˜ũ6(m}ų]¸Į]ŸBtxHžā +ĨĒ€gYĶļ7”}M°÷Đ÷ 0ąîZ ĒŽ‡Y—ƒ?:Ë šqËHũX땑:qAŋ×Ķ>ũ3’JË=ÅÖōgčũ¨‰ĘBŖ |.Ė{7”˝›Æ?âÆ/xŸ€ ?ŧö„ęiCĻ :ž…Î?CnãÄhyŊf}ęÎĪí¸nB‹ÛŨ€ô Ÿæ į3—<Ė<1ģImB|ôŧ=š[r2éÚÔã2R‚Pû˜}čūq|qŌųeũ3Ā_q‹\özžúé úYĀãL—-ĩŅŽ}é@ēģ!ą Āj§ß=æŠŖŽPyĖëxyå[ã}¸į~<&„H{cbú€ūfÜŊsO†‚ßLAޞŨÜ ‰ms¨$…‹-RPv-4ž|šDFâ:gŋ<ėŪLĐ?|Ķ뎊•‚tôo…žg!ũ8íŒ>EŊ‚˛ˇÂIīœ(Í~(đĪwBØ^ĮO]ĐoÅuƌrHHd<ŽV\ bōŧēĘåP™ 2›EeŌČL™É r&jrM‡`-D‹DŅ‚A´p=‹˙-×\y.hڄ@ítwã´ĩ"÷ėÆŲŅ„lŲ‡ęëAvu"ģÚPŽD"P”RHJ)”%*ƒĒDUZm-ūƓ66⯭Å_QĐžcBĒÚ‡Ž?€Ķr|CAø`ö§ĄáüÉ4\^Ä=÷žÎÃû]ĻR$Ūu=Ēŋ˙õī"øē+Đ"‘ą}xĮÁéęÂŪŅ„õâZäËëPmûPŨųŠÄƒ"Ī€V ÔūīAĄr˙˙ūī…†˜1cáBÂįžKté)„ŽŊ’ë‡ö§Ąå§`īųûé5°äP4c2 ™fāMBˆį<ħčŸČÛ`ŖŊɯ~ ûáû@hh§ŸMđēëņ-ZŒđû õ°(ĮÁiŲ‡ĩî%Ŧ=ŠÚą ÕÛuĖ4·‚Ž”k€î׿î˙üÍØ_)(+Ã֙”\ū:ŠO=#9Ššĸ Ķģîƒî_‚a~ųĸË`ÉŽįļÛpdđöŠú{ģ q­ė?!{û']"|~´ĶĪÆ˙ÚËņ/:QTäjÁáÚđy°1sØ-­Ø[6a=úäË/B6ÃHČ€Wú!`üŊ ÷ĀÁ īuma#eoģŽ˛•įā/*úŨ”í/ĀÎ˙ģy$jæß ĩgOļĄŗxŖâYôŠú•¸į—Gŋ’no'qķuJŌÂQôsVaœ´mÆL´˛2´X ဎešŽã,Gvu!÷íÅŲšųŌķ¨žNpœQŧį! s0ć‚>đ3@ʃ?s˜–ô%‹Š|÷M”¯Xq4'^|7lų*d× ˙áC+āÔĪOô–Û‘dw^ŗ?ī>ųA_†[ŊsôŖČqH|å‹8ũĶąŠSĘÕČęĐƒū|ĪA`sdm-‡]I5hiäåŧR IđōKŠģų-X€Â!ŠR˜ëo'`Ŋ0ܧ‡_‡š“qm^#„h:QAŸ*Zšq -Œ^tĀE—c ŽW^ĶAôĨéîĪ…6n[tâ9ĸĪi™˙JĶ;˙­ŋü5›ˇ7ŗĢŊ‡ŽžŠŦ‰RŠŽžŋ^ĶĘgļœAY>ė§”íG9Öd?¸E #'*čSéPËŊ¸Õ?F­tšÄĮoAnX3ÉŪq—] C;aš?´¯h˙ũ׹öÕÎá'+/ĄŊļŽÆXą k;<˜Č‚€Vvķ™šŋ'4ŒHS?/D?Į˛%¯Āī›tE€nĐÕg„qôĀ!{ęI2ŸüФ*x0xûėhÎļ°9Ęrūß úūŸĨbĨÜsîe|ˇ~ÎĀŗT}Ԗ„‰… >ŨĀU‘{Įt,*~Ûv>EWpÃĢ–aŲYĶဟh(€˜Ø@%<Ü,„Øę>9A?ø…(‹¨L†äŸÃyō“Jå ËĢŽ@rČ÷Cų=x•÷ÆyĨ †xôĩoāž‹ēFq$€ĻšÃŖHØ|ĩč,ô{´-9Ÿs7^ĘY‘9G‘–DueåQ.[<‹ŗĖ œĐ­¸Ā5'’7~*ŽįrķöX[ˇú÷A2>)Ar¯œŖ,į°ŧ—Ō5ŽzM–ßĪ3ox œzōÍģĘ×É'‹ŋGP=ÍUåúõ×ņ`.ˆ.ųš'˙LÅBđƊģč4æ×UL¤†ß‹ģõöŒįŒ›L3’{ūøwǞ)ā›ŋ˙ī™4qīĸ`tČߊĄ?8ø§F.ĮY÷ūŠ‹7ŽGS7ķcVkrįŗųˌ'Wj,ЊąŦž„Ķf”qڌR–Õŗ 4Œ­ ~Đįmŋ{‚õģZ'˛š€{ō;:č“L~ ôîí5B—Ŋã5Wrū((ōƒ#`~°Ļ<ęįÄÁ˙7,‹s~ųsÎiÚvČįĘ,%; ĮõĖâĨą ခßĐđ:‘ š˛KęK¨ ûy6gķ҇Ÿgg[7=‰Ũņ=‰‰tˡœ3që¸×MwЧb^÷Ÿ×ō𞎗Ô×ŋ<)ėõ!ƒfäĄ6ļ:ÆŌ~ûīûívyĀÛŋ˙wŲ˛2~ûÎwŗąēvāŲü>o=Ā"íŠŖ ŋHž™īfNrp9R˛Ŋ-NGÆbŽŽąÃv@h‚Ëĸ–WqΜΜß@eIt<šũ>ācBˆmč“ôK€?- ætv’úæW‘ĢpЏ4s¤č8ĨRJRĄОJrąœ`%˜&zŧŸ`gážn”Ú*î€CĪĩáŸzųÅ;߅¯ž’˛Ō€UšÍŧŽû ˆŖ ĶWrgęĖŖŽLÎfíž>Ĩ0t ]¸vŧíȇÅEŅ˙ī´y\˛|ĄĀ˜;ī:pR~k:&ą˜Š €Į é”Đė}Ŋ¤ž7ÎÃ÷MZĐkã¸?žÚFļĪËž™ŗhŅc¤t[ĶPB KIĐą)ąmĒĶ){ÛXĐÜDéęgĐ2؁hē#†×{ßųV6^­;iåvīiũāsĢøāÅgPķx‰›˛ė!Ä čûÕĀŊcácPŲ,™‡Äüŋģ ›ž0Đ š‘JĄ„ĩ č]ÆoēlîsÂl+Ž2ĢŽũ(ÅhČOuy”’’~ŋßq(éîfÎĶĪP{ĪīŅ;{†\Ū;%QÖüĪ—é™=s`iūÁÖoQ•[=äũū~=_M1ĒÁ%Ĩĸ/™ckwK*ūsF9ŸēâŠÂãRūš¸øžBz OčAÜzåĢÆf^—XÛļ’ųŅ÷‘/< Ž=ž sˆÍŽĸ¯XIč˛×\˛„͝ .ųũS´)X^_B(` øŒÚĘJ#hGˆiJëîæ¤‡˙JõŊ÷C2sēsm7Ŋ•uī¸.šāÍ=ŋgYü7C Åƍü({*ÅŅ ~ŋ&ļãOdéėKaŲÃg'™1ŲĐĮ’Šģ—ÍæœŽĄ‹9|øœÂō@Ÿ8ØĪÆM˙;fŪ•Í’{öĖßũšiØÖøŽ€Úø.}-ÁsĪÃ7kB׹ɗ˙ô$ŸÜÖÆėĸ •ąƒ:Qš&h¨,ĸĄŽ Ã86BJęļncá÷~Dp톃ģJ…5Ģ–gŋq'ŠĘ .‰?Éy=CÕv—Ü[õֆq×2›ŗØĩģ‹Îžá¯–zâ^îLR)Ŋų•,šY=^ÃĖž |VaNeЍ)üėĪß>HĨÔį§ę2~Ę×1SJ]ü”oˇ K'“Čū~dWN[˛ŊÕۃĘfŨė2–5 ~?"‚P­ĸ­˛ ­˛­ŧ-C‹FÖ˛Jąm_'?~j#ŋŨĩ—kœNVZ[Y`oĄŌnFWäÁŪs>S•â`ĐķŋWŠCūÖĄwÖyŧ|Æ%Ŧi\F&p mŗp<öŗŋz"™v}ŽbÛ×>CķŲgq^j —t~úžPŚâwđÛŌˆ5°rĻŽi膆Č?žm;dŗ] Úē“8Ōĩį-G˛ao/)[rceŒ‹į׹jņljƊÆĢģmāCĀwĻbi¨éēˇįÛ'Ɂ”nzЁq4ØļũXX6ž°™?¯~†We6sļũNēT.Ü2ķađ]qV?ôŸ…ŽšįņäųofĶŦ“˛Ûį=ņsžđuČäP ’­äšŨÂųÖ:Îīūęa Û"ČĪkng[ 0fũņ ÛwuÎšÎĐæŽ{“97'pAØĪ-+špY#~c\RN'€Ë„Oz O ėõĀÃĀ)Sũ]R™,ûû_(ÛđÛk Ëžƒ–ãGvT ģß[ūÖ_x˙<ķ,××ė’ų˙|ŒŲw|e:(!X˙Ŋ¯ŗ˛f ûîGG"Ÿœzgøb~Ty=N 1ĻR96lkÅ´¤Tų}v‹ļū Ũ9›!øâŌYÜxūrãsūā‚ŠvĻ}ڔ VJ-ū˘’/ Ė֝t=úĸÍŋŐšƒa čJ{Y/A ƒíį\ËCž™Ŧ?āÚĮļÍĸßŪOŲ¯˙ČŽ•+yiéŠôU—ĸŲM@T˜Ė×;93ĐÆæúķØ›YđAÕŅgËŽÎCü–ŠŽū4MŊiü ~ôŠFŪ´ōôąĪÕ/q=ņˇNĨ%ü´Ē5Ž”zîÁ—ú)ņĀŽƒJõáėۈŗå_Čŋģ'Ĩ8hy}tĐķ0ūÛC7lû]°eÕ;xđÂ7b>¤Tô´õĐžŊ•Ū`p BîHÉĐ5ĒËŖÔՔø ØL’õ›[H¤s‡YI]ũi6w§˜Ŗk<øĻsY4c\ļŪâĀĢ„k=Đ'ö{q!N˛‡“(3‹Šwá´lEî|šë~°ēŨ Šęhšyė@gˁ˙ ž}Ķ­<˛ôššģhīMå}Š‚ _' €Ā˛Ri“TÖ8uđéĖi(Ŗŧ,6ŧb´ŌŌÖGĶžîÃÕĢRėlKВÎņšy5|üĘsđéãb¯˙¸vĒhõiú Øī™Đeŧ’(+š *Ū…ėŪl߂jyŲûOPšûu$č†úQ–āĮũH&”ųßši­ģĘų¯‹>Ds~ã $¤ĄĻ„ĸĸĐ÷°s9‹žž­qRY÷Cu1fͨ8(hGJ÷ŽRîÄĄëbXÉ'2“ÍMíäLˑ‡ø5,ÖļöQ-/ÜpuåÅãŅÃIāŠ’¸bZ‚>n°;6Në6ČÄķUJ*҅Ўĸm¨ä>Tßķ`n |kĐŖjW÷ŦôĄ´ũa +Ą“Ŧ=ƒŪÚųô•áŗLĒ[vc´íāĮ§ž…GJëŅ5ÁŦÚRjĒ‹‡¤bŲqö´÷aŲ’ōĸsfVÎ˜ôĮ3¤2&Ļí:Ö4Mô锇)/ }š/ĨÄ4mzûŌėmīĪįŖG*6íëĨ×txom įĪ­å´šĩ4T”āü¯ëĻB<ü´}ėŋbŒj”™Å|ā3¨–ģ5ĨĪ->xčŗŠvXpËQ@ĨgŨũŦ¤ŋútšŋ’Í NŖĨ¸ŒLđĀŠ0Ũ2éŪ˛› i…ĪĐiœ]EYéČOĨRYļ7wOå0tmāøi,ė§8$ōã3t”Rd˛ЌIQ4HMUņ°–ûšœÅö]ôÄŨÔVģÚãėIæÚzŽĄqMCo‰ëÎ}âa?wŸũĖ1š~_;æ#_CĩũzxūÚ Ũ6"ė]ôZ֝rģĒg’ „rjesšŦI"•cwkš&X4¯š’â㯙aYMģ:čęOS^ĻŽĻ˜h$xĕ”ŠT*K*cRUQ4,Ø-ËaãļV⊎TØļ$•ŗčO›´ĻMЍĶXPˍį-ĨĒdLĸéîBŧĮ}rĀ^üp3PđsŽ*Įzō'Čmß•. čŖŲ+¸ĻF*6‹í˯bíIgŌQTŽ3¨–šíHēģ´w%He-œÛΊĸĸ|ô`8Ž$•΍҆a‹;Ž$kÚDBÃK4gXˇĩõ YĨĀ´:û34ĮŗHĨxSI˜Ī_v& U…ôģ€W!Ú=Đ'ė:p!n‘…wĮÆŪü8öę; ˇåč ėp@?TÛt)‚ôV/gã˛×ņԜĨ$áÃļÅúûĶ4íé"ĩŅ5A(ā#ôĄë‘ęĒâ ËĐĒ”öŊĨTlÜÚBo"{øu€TÚd{W’„å°*äįîןÍI U…~ä˅x O.āËqk­ŋ‹‚ĮĮ+dOöę#w˙ drd '(f(ĪēË_AÛŦķxņ”Wŗšn–î;Ō#ŌŪŲOĶŪ"AuUE…ņųŒÉ’ wIJg_Í­}iõƒėyËa{[œĶæōXīŊe5Ĩ‘˙Ąâ&ôÉ{7döUcrĮÂŲą{í/P]ƒĘēM=RЏiŋģI"ŌEKØ9/5žÍî˛ú!ƒZē{’lßŨÅĖÚĒ+‹ ļĪ=‘bYéLŽLƤˇ?C*{Xr‹ŦiŗŠĩŸ¤-šmvŸēęÜBÆĮī.Blô@Ÿ|°˙đ™1Ŋ‡™E6ŋ„ŊņTË`w‚Ō˛?öŌÆ:éč"z*OeëŧW°švņĐą5”iÚlØÚƜ†2JKĻoŊÁLÖ¤Ŗ3AkWü āãŠÚ”ŋģô4^šdn!oģˇ<ķfôÉúЏÉ+c~3Į"Ķž›Ũ7s­T°…J4Pų•ÃĩŊ›ËIH K‘(^F_Å)ėŠœE[Å<:Â1úCE#ęÂŊ-Ŋ蚠ļĻä„čãT:ĮŽŨ]ôåíw4įˇánŦˆņíˇ^Pč랁̅[<Đ'čā~āŌņ¸ßs[wŗęĪRm8ŧļ&ĀLŲCŊŲNŊĻØÎĄĢ Á’b”Ā2"$üúC1ē‹ëéTŌįö‡ģĶGŌÔÜÉŧY㖝er,ëm‡ģ:éȇņfr6/îëŖøķ•+8cAÁãŠÖ¯BėLí`œ¨  !rJŠo¯fŒ“V(Ĩøķ†]d”"Xc?Ė:JPÁš(ĀĐ •1fחČ|…בTf9–ØļCQ4xBAā3tæÎŽÄ˛zY‚ƒŠļ´É#›ĮôĨĀ”RŸĀ=üŌ¤5‘qņ'ļü÷hë˜J*Ë#-Ŋø4A,8ÜōXĶ50ŗĄĨé(áf[)ôRË´\ĐODņ:sgVāËg˛Šˆē}đt[}ÉĖXÜō"ÜōĖ/Íyņ›JŠË”RQĨ”đ@_­nŸÄ­Ŧ9f˛§ŗ—­9‹¨ĄôđôGœ4¯†PČ?.{ÖÁ‰-U<Ą¨¯rģD‚>t$2´÷%ÆĘ$  8øn…Ą—€O(ĨjÆø]Ŗ#„ØŠ›IvĖĘđ4wõĶ*EÁ–’ŽiĖUĪ7.G* |Ķb+m4RUÃočø ˆĪ !aow˙x>‚ĖnžŪĨ” y Ÿü.¯Ųŗcqņ]î`Šú€^U!ŋĨ´œ&”É,€Ōĸ ˆųuĐÜ5aĄf˙ ü\)5Ã}|´ēž…›Mļā˛Ģ7åf`ɟËBPUķ~DUÄoQVFˆäûĸ5žžØ×‚́•RK<ĐĮIé1'Ü)éČ䂏wĐ¯ ûŊ†K›Wäz9=×[0[]ĶZž/úrÖ@Jé ”S€û”RKĮę†7”äB āŗj_2CibĀFŽũcaüWE¸)…ˆr˜g§™kÆi°“„¤…)tfÛ)^”ŗĶ÷Ŋ~]Čjķ§Ö^Nų×K\yæBŠ#Ą‰lÂųĀī•RW !Öy \C÷ĶÛzãÜņā3ü=™ÃЁ Lę†ĐPT:&]ēį( fÅ ;Ëō\ĨN–ūbļų"thlá&>Öä Ąđ+Iĩ“c–ĄÆNQå¤  FŠ”7ā˜9|é8s˛qfX ö1ž U˛OŽĢŋ9~ IDATxMøũá AYĀ`kÎæÆįļsmS+˙yÁ2N™];Ŧã´c$ķ€ß)Ĩ./t(­8ŅéVJiĀšĀīōB]ˇ;žâÖûžä{ũÄ|:ķ+ŖDķgŦÍ-ĖYīąˆrX•íä¤\Ũzˆ=ž{ qá r¨pLj,ĩV‚R'įÚP ‰ §étë!z€”đ‘:Ž8üHBJ‘6i“9J]I××t|á(X)ē1ØŧQ8–E.Ų‡•Žc*بā‰`9éä‘W ÖmÚK<å‚čKdięI“u$ķt¯œŗËĪ\8Vi†+ĪáF×uz ō2ā?€áB]7cZ|éOĢųėŽvJü:ÕÅüÎŌÆZŠ‹Ã“ĸ †RD”CŖ•`IŽ›'‡îĸéV6•?ū)€u@€Ļ!4 ŨĀ„0!tŸÄÃŅĘĻČöu‘ąmV‡jx>PrLsÜpāŽî$ûÚûIį܌ĩŽT´t'iŽg)‚īŦX0^!†’pâ~ôãQū ø"Pđ }} ŪôŗGx5$ŗ9~°žM@MIh!ŗ+ŠŦ˜×99VĨ[ˆÆJ_X F 8ayâFˇ.‹Ę—T˛$×ÍĒl×°—ĒBĘËĸ,žWßĐŨUWE”€Žņ‡x–‡ÖnEMl-–›)@ēríÜČCūqÆđHę–Ŋ<—1)õ„#˜YSLyYtâyæÛ)ŽH6S āOĢ~D‹ —T°,ÛÉ2sd1ėEE!įTĸi‚€Ogv‰ëCųųÆ=ô$RųZĀ{ōģCčĮ€üSĀ'ÆJ“ī_ļŋØÜNBAYÄ?° ųŠĢ)™M)p÷ēũJQīdy]ē•˓͔ü„KĢbúu ZB8ZĖĘLÕNnDŸ--‰0ŖÚüJŖ|šāīŠ›ötLôkŊ (õ@>ß ›|L“KHĨx|w'B@l6o¨)Á˜ mš*'Į›ÍÜ߯5ņ&šŊDĸńËkÚԏĖËåLįđŨ§`q9QÃāüL;úCjjĢKø ]§:ä!xqwĮfĮIjq¤x a“8¯ÍĮÜé˜ĘšlLdņk>Ÿ{ģßUĨ“ŅJæŖXšî֙á#ZYO¨¤rZhō-Ûvđīų4?ūÕīÉær‡ØŨáŌJę'[#;oîķéԔGŨƒ/ųcÅMŊ LëØÛŲ–e‘JĨŽ8ų`qvõh–īÚ4…\¯>Ë8í,ô§2¤¤$(žüɨŌĸи7?âĀ->×7 F 4-úב’īūđWüëŠįšũ+ßå‘ĮVö7ē?H0cyļc„Ú¸¸(ŒČCКĘaËŖÃÛŲŲÉ×ŋūuŪũîwķŨī~—ŪŪŪBŋö…ŖYžOW>ø&cP~éh=-ŨŖ¨ûxMÕmôS€;Ŗ¨7Ĩ7nä#ú0ŋúŲĪyÍ—ķũü€kŽš†šššĶ\ēŽSUQÉō“—ĐrîkˆÜôŠŽš‰ĀŦš´=öžīũŦųŋ˙#ÕŪ>ŽŗÔŠ•Žmę >ÖŪĖ™8ÎûS×56mmÂļI5HCŠaiķ}m}ÄĶ&‹yēÆ%§Ė9ĒĮŊ´ŦŒPäč•i˙ņ÷GH§ šmöÜÆ—ˇËīĀ úČ_|ņE>ôđĖęÕÜōņņ…/|ÆÆÆ!m]MäKĮЉŧâ<Îøėį9õ#ÁWRBĶ/~ÉŖ˙{žz iYcŪ>ĨŌæŒLĮ ôL…O]eäO{ –í;š1Ms˛÷ßcü]_šŽū íũđöY˝;ēŌØØČ;Ūy#J}õ]MM<ûėŗ…tV.ȇwŸũƨĒRŠuëÖņˇÜžŨ{øÚ7ŋÁÍ7ßLQŅĐy*RŲĪv'14ßg¸A3ዎ~=|ëhxÍkH65ņômŸæĨŸü„\|ė „•Ãeé}ÄōÚ<+-¸}PYQF8|°˙AI…šdEIsaŋŲŦ…T =?yUD‚ĮØ"õûũÜtĶMœwÁųGLךÆĪúSrš\Ą^)”L{ĐķÚüŖce—īØąƒ[?ū Ú[[ųōמĘW^I pôĖ­=‰4}Ž$ ~Ÿ{”"0@hΚÅ+nųg|ōVôPˆ­?ų)ëųKTácĸ)“WĨöPŸˇÍ@ˆ@llęĄ7ÔײôäF„€@ĀOmM%+ÎXFĀī/dgÃ(ļ§””(i׎=ŅüîÉBjfq‡ššžōÕ¯đą[?ÁŒY3)*.Ļ´ŦŒ+Žz=ß˙á8ûܕ<÷ėsė.ܞēˇļÛ´wÆŊúxė”áHoo/ˇá lßļĪßū.ģė˛a9QR9“´TøtÍŨC‚HxP†™@€š_LéŧyŦųÎwDĸàPY |Jq˛•ā™ļMŽû„ËjÆė‹Ī0øā{nā W\Ęė™ Ė=ƒ` püBJ#QļĶֆĶŪ‰ŗ­ ÕĶCøĻw "áãŨAÚŨūĀ1mtŨpˇ uŨũ˄eãH9ŦŧqÜtķÍ\ûÖˇĮ øũ—”`Ö¯įŠ=ÎΝ;ill,DĶk#UrSôüŠ´ŒÅŗÛļÍđCô1Ū˙ĄōúĢŽļ§Ô‘ ö'„ lū|ÎŊí6rņDA (IŖbYļ›j;•ß3Ũ"R^ƒfŒ­§ųŌÅ,_ēxt}ÚĮÚքĶچŗa#Îļí¨T ”BDÂČkۈ~ ÛfG)ēôc{B¸%ĢķhK…”šIŸĻF‰Fv|Κ=)%(Ĩ&$7ÁTÔ荌QˆëęÕĢųŅ~Ā…—\Ė;n¸˙– ~CĮ/ŠŧĒ †ėĐ`i)ÁŌŌ‚+Ā–’mžĸa ˲Q ė|l@ĖoŒēĻŧ”’_z ŸßOCCCĄ–í2˙5=AWJE3 x=}ôQŪ˙Ū÷˛æšįBJϏķŽ;Øēu눎å7t.U…TOģ°Å“ŲqiK6ûK@)2ũ]X™ÄÔc="|Ãuhķæūģ˛2Dpdõę”tČ%ûč̓l7†gÛ'Ķ& H[.C3J"Ŗ~¯ÖÖVžYũ4•Ė›7¯`î$ÜĶVŖ/e”IōËÆší“Ÿ¤ˇ§į Ÿw´ĩķĀŒ(ĀAÁĘõ AgĘÄv$ŨŊŠq+ÉÛj–š™žî1Ųēķ%|U%‘ŧũôĶ`ВŲ÷ęķŅËËFt­\˛ĮļØ(ÖēRų‰Y)– æTŒ.KŽ”’‡zˆÛˇķÖëŽŖ´´`C7Œ(cĒ9ãĘ>OĨR|įÛßĻŊĩíˆĐŽya ‰DbDvÕâ™5ŧļ4Ė/{Rô%søtD";.yãôA“’´Ml33&a¯co¯WŊåũX›ļâ´ĩŖ•–ā_ēä đik[&šD/ũzũÃ5›5Éäl,G’ļ$Ĩš Ąlt6õK/žČ÷ž{7uõõ\qå•h…+ī´W1ĸ˜ÚŠĻŅgĘ>_ŗf ?đāĐŸÉ`ĐĶ øøˇ ÍŊi˛–CķŪž1ũĀ+y˜#jNJđ/;…ĐĨXq"4üš])EϝGJ^V’fĨÕūxۑ¤ŗŽRŦûŠ9NĐĨ”Ŧ_ŋžÛ>õ)úzûøĪ}”úúúB6ŅĶ#ũĀTŊ e-Ëâ‘ŋũũČ3ŦrŊŽ3fÎ8l?t8˛â¤™|tNiGŌܙ '‘Ąyo7RŽžÎɲ0×}úē/‰(šDv6Å>_Œ—üÃՑ’ö.wĸėIēvú9ĩĨ”ÆFļSJŅ××Įũ÷ŨĮŋŊë]lÛ˛•Ũō.Ŋė˛Bjs x|¤šjK÷‚,ÛĶé4?ôĐwڙg˛øäÅà {=’|īš`~û8ŋīĪ`t&¸Á3ëËÆ$ŊTB4J9)ۍŽÂŽĄûƒ'äV:I.ŪCFķņPͰę¯ôô$IdrdM›ŽŒI…\ŧxæ°>­”"•JŅŅŅÁĶO?Íũ÷ŨĮķΊ”ŸĪĮu×ŋkŽŊ–E‹R“ī—uBˆôaH&“9bpF(fŊŖ‚|ŋœÔPÅW_s&×ܡšM‹YJ‘H™ŒU:; Ŧ”Q昜ÜÕB¨ŦzÚäq?&äš éž6”tXŦfÃ0—ėļ#ŲŅÜI&g“Ė˜ėMæÅŪ—×ríW>Œtl¤# E"„Â!úz{QŽDĶutÃMQ…pĪ5œáĢ9gåJVŽ\ɜ9s ­Åwķ=ĮķÁôĘĘJŠJŠĘÚ pũ ī`á…ģOI$ˆŠÜƒ.~]#0ŽšMt´â x:XÎŦD?˛ģ…hEē?0ũ!īnE:6[eü+X>ŦHÛvhÚÕIw<ƒeKvuĨ°Ĩâ瘟ĶO›A|ÎŋĶÛĶC:&N#Ĩ$ ŽD¨,¯ ŦĸœšęjfĖ ĄĄōōrBĄĐX¯¤ZûNĐ âJ.//į˙Ŋ÷ŊÜyûË­ĢŪôFn~×ģ •î€ūT–íļCiž Āā Ģc%šŸV#Ę\ŗLҊ:˜ĻËx+“$ŨĶŽ’;ũÅü5T3Ŧ=ķtƤiW'}É,Ž#Ųՙ ×´97čãöĢÎŖĄĸĨÔ@täūxŠũkÚūSnãfŦ€ģ…]'č ųŌukŽŊ–3f˛¯e Y~ÚrBNâØ—v#ãBē[ āŸæŪg„™göagĶ8ļ…îķO/•"›č%īF*Å.1†ëČrËrh§­ۑ˜ļÃŽŽí‹…†Æ7.=™Ue“u^lî>ŪO5Đ÷ęBáp˜‹.žhL Ļs đkj˛šb⤖Â1ŗĶ tiÛdú:ą2 $‚mrūĒ>P¨âPsƑd˛Ũ=IÚēŨb‰J)i‹ŨI–Ãi~ƒ˙žp§Ío˜Ŧ+ā. ķD}î>bÁÖĀciSYų@Ũå1N™ŪAYĻKu”ÂĖ$Éöw!m [hŦ Vņx°m;Hé.ˇsĻM:c‘NįčOfIeŨĀ'Š™ŦM{š–”ģ_ūĻ’0ˇžz§ÎŠ›Ė;/ßB¨ôu¸qž%Sáa÷įÛīāˇņ.'42 ¨lr 7ŗ?›˛5ŅĨm’íīÁL'EJķķX¸–—}^6íę¤'ž9Ŧ-GbÛ‰ŒEO*GWÎ=Ą6W×øĐĸzŽ9ûd*Kb“ųõ3‡…Ŗ*Ī:—îëWN…‡ ų}€ÂĖWã˚ã–ēÁŖ'6‡ķĶ­ÔŲI2Ŋ˜Š8Ą’ Œ@xĘtļ’6šd?šdĘqPĀn_1†ĢéĐü­œ’i“TÆ"‘1ą¤Ä´$–퐰%)Û˜l:7ίáōåķ™_WQķæc,˙ üsÔJg*Q.„PJŠ{€•LđŨō˜ëÜKÛP$ĶãwVŧEđģčL.Čt°8׍cfIuĩ.ĢÁŠLnĀ›\:™ėCÚVŪī`đl¨†ĩžbŦC–ØšŦEÆ´éKåØÚs |˛Čá7xMCgĪŠaéėZĒJĸSp€'€;„Î z^î>TOö-‰„9Õo°ŅtÜȸœE6k ŽOŖŦĐøK¸šœĻŗ<ĶŌ!ĶׁîŸ1&éŸGŋDˇ0SqĖT˙ĀĄGhl÷—đd°’î!ŌAuö$QJ‘ί˜ūŊē˜‹ëŠ* S[ŖĒ$FĐo`čSĘtŲÜ$„č.ˆ9ų}0Jāώ 'á&žč™  —ÅÂ,/ŗŽ+A&gŖk‚Ūžĩ5ãįbp<Ŧ &Mäz]GV6?R49úWJė\3ĀÎĻPųŊk‰ ÕåŠ`ģĐA0ŲŦE{wG*9?pÍiķ9oÉ\ϰlŪ$„ØZ¨ “î"ÜDWāĻv^ DōKö) ø8ģĄœw%ˆ§sDC>öuÄŠ(áķŸvą<ŦĻÖJ•&Ųx7Ōąņ…ĸh†o|ŊÍų:ív.‹MaeRHįĀq`[h´QÖĘiō…‘GéjŠ{[{ąl‡LÖ"n;ĖŌ5ë*Ļ2ä[7!6ōĸÆ$< ,Ū\’×āS:Ŧëŧ…3™ģ~7û9*‹Ã€Åž}=Ė™U1Ž€õi[üĨœžmGÚŲū.rņntŨŠ ~Đ [^Y)•Ī­nã˜ėœûĨäÁfgZķąĮcŋ”f#4ŦŦqÚē(íņ JÁu3ĘŠ(ŽNÕáō p}!5ų¤])U\ ŧ8“Š_üq@æÕUpíŒrnßÕI{_šúŠ(-]q|>†ēŌq…}Ģ?Æ<+NTæ0”D)5^.҃Ļh†ÍįG÷Đ B×Bs‹@¸IĪÍŊj–V $J*¤cáX&Ō6‘ļ…c›GLme ^=ČV ›|1ú4cØGC;;ãlßĶRHįhM›ĀUËæŗŒŌ$”,đcāļã q=–ˆ < ŧø`^“OË ė­û:xĶŊOđ˛-Y\Ŗ$@¨.‹2ŗžŒ@`üĘ)(ꝍfœ;I‰“=(ĀæˆƒDsë‡!„ôŗrÚ_ŧpryĖ”æŖWąËe—Ą]÷uy~Øä`ÚėmíĨĨ3R ĶrØÔÚOÜrøÔœJ>uåš|SĘĮŧ øđP!ŧë“tĨÔ ā+Ā9ŒamķI˛báūg6rũ15ÁÂĘÅ÷TYĐoPSŖĸ,J(4~ĄĒ ˇ~z…´™e'™a§)q2ÄŊ@• $‚„æ#Ąi7Bė1"tj~ú59‚Á§”"“1ééKŅ–wl‚ ŗŗ=N{ÆâŧŸžųĨč•!EA~ŋŽĪĐ0„–Īp,Đ4Áüåãē˙>ŪŌOŗugį@˜°4›(Ĩ°I"mŌĪĐoēŲāŽ( ņé —ąlnũd<b'ŋņņŌāēRj)đ[`ž¸Ú+gņÜļ=üqũNíˆķBÎčÁĐ5NŸYFQ$ĀŠ‹ëĮ"ŲÄN|ޤ­ŊŸ]­Ŋ™rãŠ=É0Ĩ"i9¤m7ē`EĀↅõŧqÅâÉŧ•ļ8{´‡QĻ čäGۑ´õÄijīaKk7Ûēôe-’ļÃĶņ ͎äô†RÂAƒš eÔՔN‹÷VJ‘HfiŪÛC_žt•ú“Y6w&ą¤:(¯ßRŋÁŠŌįĪ­aåI3™QY:ŲįHāBˆŸL{Đķ˙˜ī!=ĖŅĄ9Ëu<}ûoĪņ‰-­4–…Š. k‚3+¨¨(š˛ŲĄ¤T¤RYZ;úéėKhqĨ]ũļõ¤q”âļ9•ŧjA=>C§$ĸ<Ļ,&č÷MĨ×ũ˙í{|TÕĩĮŋûœ3“ÉLŪ/ ĸBEDką4UĐ{īG‹/ŠZ-ā Å+Šx}[{­mEŊÚjoĢ•ZQ‘^QÄĒÕRŠOzĄĩ@•‡5BČ{2™dįœ}˙8'1 I äL239ŋĪg>3Ė ųœ9{÷Z{īĩ×ú đoBˆxڂnĪÉ_v!?|mŲ]ÁôW7ФN‘GUP„`HaĨCrņgf$đĻ) 6ĩ`&^¯Öžŋ^JI\7‰Fã4…ZŠkĶÔëT×.Ž›TÖ5ŗ§ŲĒøĢFr崓øR>ąe˜$„Ø>Đĸ%ōŖ°Ū\Čû ãGå˛á<´§Ž}uaŽ(ÎÂöՆ¨nđyČÉōá÷yÚŖÁâēA\7k_>ĶlĄ ?ë ąøRJZZcƒ-T×7ĶÜGJ‰Ļ*xTMU SĸĻ]3^vųÖ|ü‹ú0MqaŒ­ØÅ¨ãŗ0bQH}ĐŗąâDt‘ČĀkĀ.Ē}×įUu\ēr-īGâ•›ÉĐ˅?˜<šBiQ%ÅŲø2z>¸"Ĩ$ĶŠŠ QYŨD4n#]ˉ^IDATáQ)ΐ“iYgMAš]7‰Åâ4‡Ŗ4†"´Fã‡QjJ-‘8U-TĩÄP%”Ä#ČÍ˙‹oË_Đ0™0ņ›\zéĨLž2…ÜÜÜT.Hņ´âĒtũnāĮ Â}ō„xG‡;÷pŨŸ>`SL§Č§1"?@ĀįA(BQA–?ƒül>Ÿˇ=¯ŧa˜Dĸq›"4…#ŨkmåۛųŌē–žJIĮu“HLĮīĶPėĸRĸ’æÖ5Ą(ĩ‘8Ļ”Lđj\ÂH&YĀÆu˙Ëķ˟g׎H)QU•q'Œįō+Žā´ĶN#???OŠyēpō2`5ƒ(â­_`—điÅ~~ūΖՄĐŋĻPœé%ĶĢÚyÆ- Ũğé%é9äÆÅ "qƒŦLO¯‚OLSԇ"T4Gˆ*ČķjdyT4E %D “ϏA(f`ØEY†ÆÅĮ eæÄcYl,Ĩ¤ļļ–5kÖđÂōįųhķf„=hŒ;a<ķŽš†SO=•ÜÜÜTjžO€S…Miē}@e0ÍE31jn˛ūĶr^ÛVÎēÚĮtž Ÿë`ŅāČ\Å9™x{qî]7L›Ŗ”7ļĐĸ› ÍôRœã#āĶ: "ØE $­1–HœēÖ8Q øœ•É­1>jģŽ.žÉÄ ß,đ/Ŗ‡Q6zËzvÉëëëYˇv-˗/gÃ_×ŖiRJ&MžĖÜ̝ĸŦŦė°ĒŨö¤`0Hyy9 äįåqėØąN•Uú ˜,„¨OĐgc…øõ‹ËŪVđÎ0 ÃhĪĪŽ( ĒjÕÅRÕô<+‹ëÔ›Šjhf_cˆæHŒ¨nĄŠDuƒßnũ’õ‘8^UĄÔī%/ā%ÃĢYaļ6´Ļiü ļĨĮë 0B|iH )‚,Í˛Ė†”´&QC~åžKÉ)>Ķ(â;ãGq˰"­Qö5„Øl&‰#AŽĪːÜ,Jō˛(Ę ā=„j8 Ŧ[ˇŽg—.åƒ Ņ<LÃäŦsĻ3įŌK™0a>ßᇆÁÚĩkyüą_ķūēu¨ĒuŸyôW˘1ÉæÚ…ĩō^—ō K)ŗwąŌ<%L†a‰Dhii!ŖëzsFUUņx<øũ~ü~ÚBßŨœ~oM#ܲƒ§ūž—ėˆ;UøTŠĀ1ÍöyyĻ€ë†pá7F3ŧ0‡;+xķŗ=,Ģ Ē=Aˇž;ÉįaRQ6Ŗ ŗųÆČF—Q˜HH9čŽĒĢ­ãŨ÷Ūåé%OąãĶOÛ]úī}˙fĪ™ÃąĮ{Čmlš&ĢW¯æÎÛn§%î´@ųāĪƅ]ä‚Ū5˙ ڗ×uP(D8Æ0ũČŽĒĒdee‘=h€7MI]S˜­_VņqE ¯ib{S+{ãŖŧG28Ž0›c‡äsŌ¨Ą -ČétŽ{ãĻ™uÛ=Ä2ŗ¸īšË˜2é[dųŧøŧ|^¯Öī'Ť”TWWŗzõjžXŧ˜ęĒũH)),*âōšWrá…2dȐ^˙­÷ׯᆠ64vú,7/įž_ÎØãŽs]÷.s펰ö oØP(DSSĶaŪUšĻ‘——G `0É0MtÃÄ0L$öG<šÚcÚã+Vp×mˇSPTÄķ/Ž`ôčŅI4ˆ™Tî­äÅ_äé'Ÿ$ąŽĩžpŌ×Y°`§MzĐųuyy9W͝Ëģ??āŗSž=…'—,éĶ” ŲãœđˇžLJ„›^[[KCCƒ#ˇyuuuÔ××÷čō§ŖTE!ÃŖá÷y ø2đgX–š'ČĨ”lūp3BQ(*.Ļ´´4Š~ĸ(Œ8b o\Č /­dúšį‘‘ÁļOūƍ7,äîģīĻŧŧŧĮ6ŽFŖ<ņ›ßt š‚sĪ;Ī)Ȱǭ˛ ÛŲZg:Ŋ§ë:ÕÕÕ´´´$Äũ …BÔÕÕĩ—ÅuÕYėÚĩ€‰eßÂīOÎę.šĻ1~üx|č!~ö‹G8ö¸ãˆEŖŧüâJ.›3‡UĢVFø›6mâ•ß˙žÛŋYTRÂäɎ:§ģ’!ÖŊ¯€úŗļä555Äb‰­j‡ihhT–ŊˇĒŦŦdÛ'CÁÉ'ŸœôA*™™™œsÎ9üvɓ˛-ی öîŠāæ…7ōČÃS]]ŨūŨH$ÂŌg–uĪŪĖ .`ذaN^ŪûIá9āļÍIk[__ŸpČÛÔÜÜL(JŽ^+%ûöíã7Ū`ũúõÔÖÖöû%lÛļh4JĻ?“ącS'OČđáÚųæ›ylņãŒ<†a°ä‰ßrã…lŨē)%ÛˇogãûŨŗįø9ÆųNžų˙—Ō Ûnûš8˜Ø1 %Ä]˙g ƒũ6°LąXŒ÷ÖŦáęšs™õ<.žyķ¯Ŋ–uë֏÷÷ĮųāƒPU•á#FpÄG¤”7âņx8ãŒ3x|ņb.™= Ã0Ø´a#×ĪŋŽ5īŊĮÛoŋMk}ė˛+ŽäčŖ­đRn?\ĸ kXéqĻ85/¯ĒĒrláíæ~?EEEęĸƒA–,Y“‹ƒŽwŽēęÍČ`îŧĢ™={vÂÆĒĢĢ™=k˙Øĩ›ų ŽįĻ›oNŲŦ6áp˜W_y…ûî]D,%@×uĸ‘įí%C†đüŠ8rÔ('/a‰b^ĒģîXe’ŗæ9@kkë€ZõŊ{÷ōŖũˆÅ>väĒĒ‹FYüčc\7>oŊõ‘H$aײ{÷n>Ũē!ߘ0!ĨSW.žä{ü× VJ¸9Ü-äĻi˛đĻ˙āˆ‘#uŽ€•Ér/úŌŠÃ°ãú,Ã0úŨeīnm ļļ–ÆÆFĸŅhŋ-ŌíŲŗ‡;Uxí€ĪŽ} O=ģ”ĖÄãõđˇ>æÆ7đŖģīæŗĪ>s|×Ā4M6n܈7ÃK†ĪĮ¸qãHuišÆégœÁ/~ųKFôōYgOgúôéNjŸkĶôãq(qE$9Ā’ Äü8 Ųŋ?ĩĩĩ ÷0***¸ë?āũu=āŗLŋŸģīš‡)SĻpīĸEüė‘G8zĖhb‘/¯|‰9ߟÅŌgžĄļļÖąA) ˛æŨ÷B0ũœŗ)(( $„`âĉ,ē˙>ōō;įÜ6b87,\HNŽãÕe— !ZĶôq8´×ÚښTCJIKK ÕÕÕ €ĒĢĢšõ–[ē…Üđŗčû™Ëü“—KC}=,ē9ŗgŗjÕ*GvvėØÁæM›0M“ŠĶĻáõzI'M™2…[ī¸ €Ąäæåņƒū0; 5XiԒFęa‚ €9ĀÉN¸‹N…¸:-Ã0ˆĮãøũ~Gęâņ8˖-ãĨVX5Í: 0#GbŅũ÷sæ™gvŠËBMYYß*+#ØÔÄŽ;i¨ĢgõĒ7øôŗO)))Ą¸¸M;tGK×u–?÷ô19ššÜrÛ­dgg§čBƎ˔SŋÍ)“'sÕŧĢ)++sÚe—ĀR!Äō¤úí}}p‘0UVV&u”ZNNųųÎĨYŽ­­åėŗžC]‡=rĶ4šdölޏō ƌsЁ% ąvíZžZ˛„-|hŸĖŧčBžwņŌ?ūÎSWėŲÃųįG°Ą‘+į]ÍíˇßŽĮãÁÕ!Ģ(BėLĐ_Á*uÜgëVYY™ô–`čĐĄŽš˛ĻiōŅGņî;ī˛C† aÚé§3~üøCŽąŽŠŠáĪĢWŗøņÅė¯Ŧ!đx<œ÷Ũ|wæLN<ņㆰšĻÉŗĪ>Ëĸ˙C×Yųę+Lœ8ŅEöđŦų¯…U‘% ŋ œįÄ"Øž}û’žŗŗŗ_œ2 ]×ûœ$Ã4M***xũĩ×yjɓ45­y™Ļ1õôiœ?c'N¤¸¸¸[OĄŧŧœY_ÂūĒ*žsÎŲ<øĐCIߞäjž)„؝tÆĘŊwŌ4aÆ%uܡaėŲŗ‡×_åŋûjkjÚWä3†ŠĶĻrÚÔŠuÔQísųP(Ä÷ßĪK+^DÕTžYļĖéCƒI˙ ÜŌß úJā‚ž^€ŽëTVVĻÄá’ŌŌRŧ^o§kMFđMͤ˛˛’wŪ~›•+W˛kĮô¸Ū>`RPTHII {*øĮîŨH)™{Í +O\Ÿ7!ƒÁ )ßښĻQTTäTšā„[{Ã0PU5•Ģ $ƒžNB|žĖŲHwŽøÛ~ŋ?-ę~ëēž2iĒ„hšæBŪĮņ¸7Ų!ī+čUX5 ûŦļ´Ėé ]×SbŊÁ•cņ™vŊÁ´Ŋp,…mNNNZXu¯×{X!¨ŽRVwI)Õt=lqęB<OĒÕÔę|#…œœœƒR\ĨĩUŋ‰$/EvØ  ! Ŧę,ŽMHŗŗŗS…īXúŠmĩŊ´´”üüü´đJ\˛2¤”YIÛgû´!åācû‡:"Ķ4ŠŠŠđí6!ĒĒļ×qk SUUĩS}7×zģjÃk‹mY:‚îÁА;ßɋ2 ƒēēē;§îņx(,,Äãņ¸ÚÕĄčŦōKá´Ũ†ũ\ā5§/Ė4M‚Á ĄP¨_ˇĢEĄ¤¤$%öÂa’:=˙“û.ŗc Ë]ęôyųDp‘â•t=؀ƒųŨ;*‰´įqK´TUĨ°°ĖĖĖ”֔iŋ–öëîŪ“] •Ûļ׃Co;Tč]ŋ+:<‹ļu¯ ØĪßKBŊ \˜ĮTģũvāÁD]¤išD"𛛉D"ŊļđmqčĒĒŌÔÔÔcnt!@€ÜÜܔØkƒÖ”ŖÃk;°Á†¸7–7Õ$:´™Å.ŠØAûķĀ uĀ!DC:‚^ ü8*ĄÜیFŖDŖQt]Į0 ¤”í+Ꮿ×KFFF§Å˛ļÃ(m‰(Ĩ”¨ĒŠ×ëÅįķ%-↔<Ė4…ØéN­*VĄIÕŦ0]ņNځnCxđpOšēZ÷T_7Ĩ$n?tĶÄ-élgW…@ŗEIDg•ĀBˆG“éˇ;iƞÂĒŦúí~mŧ4ØŪ’€nšDĨ$nšŽĨNä}–Ũ6Â0đ* Š‚æ\?Ā‘ÉöہknÅJ§ãę:_Ø01ō~ŋ÷QĶ$¤ë´:›œ´0mAˇaßü\ŗˇŠ›&1ˇNû€1 LįļqŊi ē­G€ßãŽõnî¤(xÜčēW†ĸ 8׹´Ũ.Cs-°Éí>Ŋk€,M#KU]āûYđ AŽĻáW=|VŸlŋ5!{JBˆz)å5Ā›@‰ÛĨzŅáĸ|ĩęnščöÃ]97°zėÅ7Ŋߞ• Đm}\<ä¸]ŦwĀĢöŪ¯OQÚcô.ûčŌõę^ !PÍž§j˙ŌĀŋ'ãũHÜ"‡•-vđ$I¸™Ē2íĀŌ "˛˙mōUhk{¤!Ā]aVl ;FÅŠW|MQ7X,:vŧī+RĘZā ā8SgÜOÅú8`píģlƒŋÃû&ÆēˇVģ ¤pvû^(쯞+=ŊNŪX÷ĩÉ8Gīˇ{%Ĩ,Î%­,Ĩ‡Ú@?āہíx¨ƒ}ˇŽĶsōB{8ÎÖ%Bˆ•ƒtģ“d÷×>/WiĻíX•T›“Ņ ėŋQEˆpVBŊí)Ū¨ģ˙ūėöoWļķķp2BŪīŊ‹u/îŽ)Ô 1`pĸRJųuā= ßíëƒZ[€iv(xŌiĀō$ !Ēąbã'Kæ$oHXLæ !*íßņ1đ8nØī`V¸+Y!P‹ŪÅē+ĀņĀŦí¸B YōdG€ĀCĀÛBˆXkĸŸOîšJ—¸ĶΌė‚Ū āP ü+đ]`2P@˙/ÜŀŊöüûy`ŊB?ČĩąaíöũAĨׁŲöú.č‡~>08˜daŌâfĮėé@9‡Š›i˙ŋŽSĢE Vëļ‹žáPOJ9ë@Ī(ˇ˙ Ŋ ĖBÔ$û…ĻÔöĨ”Ō äÚūŸÕ'ŽÛ.w°ÍՖRæt$ē‚š„-\ãIĀrÜā tw×˙\• §č)4 ÎvīqÚ)jˇíO“Ũ]wAīØũĀ•Ā]Ā0÷ޤ…ßüx+™Ū\Đûv”˙n?JÜ{žr2ąNŖ=ŧ”Ė[h.čÉüPŦā ËYcp5đŠ[ąÎgŦL%7Ũ=9€§aÕĢ;Ķvë]č“C-Āį°ļÍ6 !âéđÃ\ĐüŦXĶą‚mŽÁ ŌÜģĶoVģØük[õÃtÛ=y­ũHāhŦ(ÁS°ęŲØķüL÷.õIA  ¨ĩ]ōõĀ`wĒl‘š §/üÖa™R{ŒÆEXqĨ@ļ{ˇĀļÎĩ@ȆúÛßc?öcÅV ēL\.čŠ9(€+ø'Į†ŲĀûløÃZå]k@‘Ü;V”bĮƒN&v`°ĪvˇV Áūū~ D~rAw•*ƒA[¨p×TkŦ3Ãí"ĻĻ mŊ mŖ ŧėōˆ FĢėʕ+WŽ\šråʕ+WŽ\šråʕ+WŽ\šråʕ+WŽ\šrF˙ĶwXŧļéIENDŽB`‚ffuf-2.3.0/_img/ffuf_waving_250.png000066400000000000000000000651011525032755300167640ustar00rootroot00000000000000‰PNG  IHDRúOŠp…iCCPICC profile(‘}‘=HÃ@Å_SĨ"Q ~āĄ:YqÔ*ĄB¨Zu0šô š4$).Ž‚kÁÁÅǃ‹ŗŽŽ‚ øâčä¤č"%ū/-´ˆņā¸īî=îŪBĩČ4ĢmĐtÛLÄĸb*Ŋ*^áG?z0ˆ^™YƜ$Åá9žîáãë]„gyŸûstŠ‹>‘x–ĻMŧA<Ŋiœ÷‰C,/ĢÄįÄc&]ø‘ëJß8į\xfČL&æ‰CÄbŽ…•fyS#ž"̚NųBĒÎ*į-ÎZąĖ÷ä/ fô•eŽĶF ‹X‚ Ę( ZuR,$h?ęárũšrĀČą€4ČŽü~wke''ęIÁ(Đūâ8#@`¨UįûØqj'€˙¸Ō›ūR˜ų$ŊŌÔÂG@÷6pqŨԔ=ārx2dSv%?M!›ŪĪč›Ō@ß-ĐšVī­ąĶ I]Åo€ƒC`4GŲëīîhííß3ū~FCr•‡%ĒÖbKGDųCģ pHYs × ×B(›xtIMEæ%áŧātEXtCommentCreated with GIMPW IDATxÚėŊwœ\uŊ˙˙üœ2}g{ßMo„$„ŪTŠ((RDåĒč՟ŨëU¯Äû•Ģĸ‚(ĸQĀB¤h¨Ą$’ŪË&›ímúœs>ŋ?f˛;›l6;gfwg“ķ~įũúŧûû 9äC9äC9äC9äC9äC9äC9äĐI8[0z$ĨTP ”¤÷; ´íBˆˆŗK94qŪ Ĩüo)åËRĘ^)Ĩ%“)Ĩl’RūMJų!)ĨĪŲ5‡š8/‘RŪ,Ĩė–ŲŅ:)åei Ā!‡*`/JÖ.™RĘģĨ”EÎn:äPa‚ü)e›Ė=#Ĩ,qvÕ!‡ ägI)Ûe~éRJ¯ŗģ9T Ÿ%Ĩl–ŖC˙+Ĩtĸ"94Î ÷H)_ŖGQ)å"g§Ę)ÎØĻĪįŒâúāÛRJį9”39ĒĄ=iŪŦJGųĢbĀ!ÄŽynWú€bBˆ„Ã…OšŗY3ģū{ @~@Ē_Ü9ŽĪ:¸x0TĻß ‡¤”;€€G BËᇎ Ÿ*ĨŒËąŖ{Æá)åéRĘĮ¤”ąŪg\Jų„”ō‡Kšč WŌĄ¯ą¤—ĮĘN—RŠ €'rp"ūPJév8ÆĄ‰ ô3¤”I;ÜoÅãŌėė´sé)Ĩ6ĪvbŽ?˜ū&Ĩ 8\ãĐD”æÚby˒Ą?,‘Ũ×\%­pXÚȃ×GņšüRĘH)ÃŖ Ü?š÷îĐČÉ ŨŒœĻĻcYSrãF’^>?¨j!^3gīŖQAwđ‡u O$ú,āĘLņ8Ņ%wB2RUpš ä–§â×āģRĘiû8@Ÿj{ đ ;ׯ_}ëWR?øũ ˛N]Ŗđ<×*Į`ûJ€¯8\ä}"Đ€ōl/˛B!â÷Ū=nĶVˆyPgßA*F?Vt•”˛Ôa#č…,ÍUā?°‘EerĮæĩúzĀ4ŗ]Ļ(­ū*yųX‡ž*GŲDpČzÎ485ki“xāƒTušg2ˇsŸū3Gbœ@~€ ;@/hēN¸äšÕƒ¤9€lډŲŅnįTāf)åE6A~É8ƒ`†ÃJĐ UmĀšY_g$žxėPĮ›ĸ\ˇÎî퀇¤”ßĖ&6-Ĩœü6¯6š•´sUąÃQĐ •Āģ˛ŊČlڃųĘsCKú=…L&íŪøiĀ׎äđsōåĖ‹´Áî§!ÖeëÜtØÉzĄŌb;’(ņúk‡†+_#šqc.÷$€÷̤”_H—ŽŽ>FĒō,7Š÷ÂÖaÕį 8|UvVépØÉzĄŌ9Ųî‘LÄ1^X:î´ ČĒeĢ "&ņŦ‚h˜ÄīnŖī‹Ÿ%ļėe¤‘p…Š s#ú:ėx$—CM~&Ĩ,sØĖúxS;•,Ün”y ÆåfåžíÄžû5B?šŗyßáæÍfąJ˝Đļ*—[ž|Ãa3čãM­@oVWļāÄqŊiķŲĮéûü§‰>ˇôČŌ]ÕÁ7ĮæÉbĀļ›!ŧ?—Ûũ‚”ržÃjĐĮ“’ĀĘl/Ō;n\oZ˛ĢčžE߯nÃė9‚ŗ­dļcãÆ^Øt{.öē¸e,ÆB;@wč0ÂYXĀ Ų^§ÖÖĄ,So9sÔ 'ŊdŨÄÜuęŠā/s¤ räUžƒŪo~ž§ŸBœd#Ô^”ã—›°ũ'šØë§9ėæ}ŧ( <ĩú^^~é•c‡ņá@~āo‰8}˙{=?„˜ëpôąˇ{Sæ=ØhfŽÕ7 øÚÂyÆīB?˙)ŨOücpŽ|ÕBđ•ģũ°û7ŠÉ.Ų“ø’ÃuĐĮƒרTđ\| ĸĒŽ @.iYôũøGôŧür'č0éŖdŲķPJî„Ļgí^}Š”˛Âa;čc-ÍŋŨūč€ZQûŗ_Ė{ū{N —2õoŌ û7ɜW1Šß—û ļüĸļF­U—;Üį},Џ$×EÜgž…zÁĨōz̎—ļ˙ũ!Ɏ4(…SŽĘ-Ž`ļÞĨv¯žZJéđĨô1ŖķIenåDB×ņ}ō͈ēIō×7Ōz÷Ũé˛ÁIPuMîģ×úWģļú)Ād‡ũ ž¯…ÔĘ*ŧ˙õ?āōČeúƒŅ¤{YÆ ÄIƒVŸÛC{ĄÕV‡ÔXh‡ ē}ސrÄeО` =qÅuÂÜ_úFÁüĀ˙wÜvÉÎtn§ę?•Ģ.ûŗÛ9öCŽúî},Č d]’|ķQßˆŒ 18Q<į_ˆ~Ũį˛r΍ČĨsĮNÚyxāŪęÎwŽaíØ[ĐŊŨΕ§“6t€>ÚTd7lĖHbm}kßŊ$_ūX‡öŦNJīĒĢŅ>ô1F’ƒĶÄQų{īē›čŪtcÍ šæXĐöŠ]õŨ)tq€>&=Ģ$™Œ!ģ_ÖĻ[HžņČĐ`wšđęz´|dX°lžAÎĄ ‘(í=>đ‹ĒÅā>>ˇė|&5Š9{z—ÆĐG›˛Î„“ŅČ#™LĖ_'ųŌ=J:ė&Í ?;é,žŨWyåü+ˆJFäé?Vî˜ø˛Ė('‘‹šluC´3ŸščLā7Ā›RĘ÷:*Ŋ#ŅíP7YˇyvŖTœŒĩûí  ōū˙h2Îų't>Ī•Ũl œÅKÅ yÖWOL ˜M^ŋ^x* ŗįķõĢ8áågĐ#Ąŧƒ\J(ËūĐ+uöS˜Ļ¯ąÍX0#ßĻįāQā÷RĘo!ēöu$úHėķ)iI‘]g!PNą$Īų Û]JŧF;'t=ÂįvŪÄīļŪÆ:ŪĸÎ< ĨÉëãW‹Īā§˙ņ6ŸōNLEÍ+Č%āimAK§ņZĀŽääÜ0kMõi`Š”rĄÃÅЇ¸"Ĩü°‚ÔHßŦUAĨqAjKG ōƒt‡¨õ&•ҎšxīíürãMüĪū—˜– ˛6ķãķ.æO×~Ž†iyš” wvâ˛,˜]fUN{îŨaZŖųĪI)ßīp´ôĄ@^œ–âwļ›*eĩˆęËGrōƒ˙ķ'[8ĢųütÃM|ŗyõķÎL!xŽŽ‘}øSŦ}Į…HĄä r)A‡Ņ2Jj7EX94lj^Áy™ļžĐhžÎā~)Ĩ3Ũú OžIĢ~šĩ@U5Ôų—!-™“$Jĩ?đ9o˛wėũ=ˇŦ˙×uŦĮ1ķŦÃíæöŗŪÍSWũ tĪäP’ÉA˛Õ `IÛŊ2)RãܸŊ…O?đ<ģZ:Gķĩúû¤”—:w€Ž”ōŨĀ+¤ú”å…Ô‹eįärD’üđ&@qlWlûˇlžŸņîAŌũá™ĮņĀ5Ÿ!VRž~Îá@ΐ '=đ!“z,71i(…&Rë=Úås-Ŗšŗw´ÁūG)å™Ėa §í¸‡š|Ž+t7úY˙Hmxābä6ŧÅôÎįųŪÛ?ázš™ eÔW úŨŧVßȟ>ō)ĸ%åƒAÎÎē!@.‘XēFIŠ]Ką‰ „r¨UĄ§t„'C1nūįrb‰ähžæ"RŪøĘQâ!!ĨÔ¤”zÆZ!†ú´cäīū ŒJĩ:u>ęq_Æ\wËáËÔzFæĖķ%ZšôÕ˙eĒō%ž9ņ| EÅ0L"‘–V…÷üøf”žžA€>ČĨ+āgĘôjŽî0ûöw—ö\Ƅ—ÅĩÅlÜ×MȰ¸m_ŧŊ÷.ž3š¯{&đ)å•B3ŧãN#Õ%xaZPdÚ3I EJšøđr!$ôhĮ Č/î-K)1%DNž’øúGZۆ–ÎŒĀvgv~¨B&XđâO ôuōČŲW‚Ļ úč]<Ÿõ_û2sŋ˙C¤%G r)!YU šĻPYQDE™}_ •Ndƒ"ԃGW™QYÄęæ$đŗ×6‘4LEÁ­Š”ú=ԖŠ*āŌķÆžŽLŋwģī5\ |˜Å‘Ŗ2_vJ)o~+„9@ü1ß ' vĩv˛ĄŠĩ͝ŦiëåÕŪ(WËKųĻ\‚GöŒČ‘ú0ũÍ%\fšüũÜĢIĻ m6´˜ŠĢ>DåŸ˙6bKĀh¨ÃRŦ;EhĒečŊf%(ōš¨÷ģh 'øw$Áŋ_\?`ú~ī*ōōYuŧķø)LĒ*íĪĖÁDũŽ”ōa!D,[õœÔHįŸŗŗĩ䀩Ā-Ā'¤”Ÿ–Ĩ‡w:@%×ĨOôŠ<­ĮūŽ^–ŽŨÁũëöđL(ÆÁÖæŠĨDų(˙iۃK† Ģ ë…ĪJĩüšŠ+īábOžų¤XŠÂĒKßĪ;_\†ļgß!*üP GB´ļŠ(ƒ¸ÖeŲJmÖ@ž{eą—ĻđĄÍ($’)‡ŨŖolŖôÍí|sV-;suåÅšŧ˛ã€Ģ€{˛xĮđāۀž#ËĖOĢō?’Rū?!Dr,ų_9F@äžėëčáļžÎiŋ–Ŋž…'šĻüĒB‰KãöĀ,ūP÷âJyĄ6rËÍyå7œąi …S_i)ģ>rÕ@|}Į]!Ũ!\3ØOŠJ Ÿi7ÃT˛99pÆzŨ:Ķ‹ŊĖ.õ1§ÜĪė23‹ŊÔú\ø5Ĩ_/˙Ū´÷Ũˇ”Ĩ̎bZļnđ´=R˙ø^@~€ÜĀ÷;¤Ė!NéHôÃĒ^?$Uņ”Eâ [žŪØĘ&c€áe.rŋ›€WG×T´ f}ˆb_á“{–Lî82pad įđ{a™œúô/Ų^û3š‹SÛrÚ)44ÖŖíŪwDK } ƒkĐËĖnTiĢŖ+1d…1 ŅUEPW1´eII,nĐŠĶÔ#aIŪJœ˙Ė[üŽŖ‡ŗ]ŗ•ōp\ÚVŋ÷<Ŗ?"5Æ9ßtü“R~QaŽ‰~đų\ŲŪÜÎî_ĘÕ¯mĻ{Y\_Ęq ĨT•úđytô  §}õÜ8í‹ėôŋ# Ú,’f˛ųĪšBû8īĨ‡iņ hy˙E#šTŨIôÆd7njÛÚŋ]É9ė´F6ĘNŸG§ž"Āĸ†RnŠđŪ'ßÜÎŊ/Ŧ˛+Ųđ?RĘ#šų? |•Ņë× €˙ŽsT÷üHķYi'ŠmÍŒ’×nã˛_dI[_ŋŸRäáĆRę+xÜ#[~^ĖW§|”Ĩ3žLR¯Î ävlÖ<ĖĖũģúīĄiņ‰X–äR]pąĸĸA÷?)ļƒlĢwSK ^2N´…—Ž2š*Ȝr?Júą?ŗr;ĪžĩÅî+ŧ”ąÅ33ŌŌ\ėũtŦŠp”ŖāŠ”ōĶĀĢ€íiĻeņĐĢkšøŠ•ũŨ_Š4•5Å4VáŌŗW5ŋŸ—Î|?÷ā6öNšėé>XmiqĖá?+Œ$‹ßaÜQSM|îĖaA.ŪScf„ˇ$“ŖļÚ6ĶåšĮާSQlo8­PQâcvšŋ_˛åÅĩš¤ŅVJ)‡šÅū= tŒXĩ„TŒßí={ëĀIŠØngb˜zq5Yļ‘P€õ~sëKøėųQtMeÖ´*4MeOq5÷Ŋûsŧöސđ4d—wČa0üPˇūYĘŠtSÃãĻ÷„yÂ\Jh;aū {Ÿ’hĨ,™}ß7‰`yņ%ār3kz5ĨEönʋ}L*JabŖaņÛÖäâœķ÷H)?”Á;Į—1˞|Âzö ˙iÚž˛ũl–%yāå5|rÅV’iƒjZą—)ÕÁūtP;4Ŋą ¯wā0ĨĮÍ=WŪÆ†Å_ éŽI~į\æ:XCĐíLߡŗ˙€č3{XUetL2čŪO ­@‘Y÷f§Õ}*¯úRķ*UUaƔJ4ÕŪ  ļ,€[MˇîncSSk.,ã#•"{FúįëHĩĢKĀéđ¯ô,@ū…\œ(RÂ?Wnäúå[0Ō Í,õQWČ)iŖ˛ÄGEyҐkķ—ōđi—ņûÜÁ›įüá’yŠæClTĀŠÔ7 ´[î-+Ķä=ī{qßŋW=Ė eßŌ>ž.ģ#ŖCŽĮãĸž2h{uMar0uoa OŊŊc` •=*–¤vņ8ąo5)ŋô€ü'š‚ā­mM|ö…u¨öžQâŖĒĖokQEtMÁãԘÔPŽ8ÂAŅî/áé…īæÎĢÂ3ŧũĶŪĨxr+sM˙žtß6dú)ĸeHÃđŧ3x’sķ™§ õîžpY]ŲīeņĮØä>ÔERUY„[Wņš5Ÿ[Į­ĢGܟ~ÃļȃšūėŊ[š Įš˛Đlā/Ā4{ E4ÍõĀų””˛~´0ĸ ?ķübŽ oîėáĢOŊISēĢĘä"ÕY€\×JŠŧ”Ŋøŧ.\. EUP„@ÍB]énۜ<—•“ާąëjm|ikžÄĶŗ5u6Û¨e÷õõ ¤Bt{°4 œ<=ãDÚĻMí˙ų¸xsBdŊ—MŪķx˛øė!˙æņ¸X<Š"B¤ h,+]ˆ“ Ģ'L[W„„1t Š[W)sk´Å’lLšėjíbî䜋m•ŗ655q×īîâíˇ×pö9įpíĩ×RVfË5T–æá˙v€>4}øJŽ O$ ~öôŧMI‡*FCE€‘™’€‡ēĒ ÅÅ>4MÍß!&ģËjØ}Æ,>Ÿųģ71īͧ(Ûą4?’$gā0É(ĒebĒĻ"°<.D,>äHØså1õT"XŠņŪŽûPe4ĢûîÔįķ@ÅÕ$ÄáŲ+ķā"õŗĒ*¸Ũ:ĨĨ~LÚÚzŲÕÜ=dûŠR¯N[,IØĶŅ“ gMŅh”ī~į;ŧôü Ŧ^ųÛˇmãæ˙û?|>[†˙RŪ"„hu€>XšĪn· ōĪ•›ųyS*\ãQĻVQ”áQôģ™\_FqĐ;bĩĶ.…Ü>^šˆåĶO`zëG8qõ‹4Ŧy -Ö}x=ŋStŦô=ꆉE‘iŊ—]@ĶÂT–°*->Ôų8e‰ÕYŨg>›Ēū“.5ˇē!]SŠĢ-Ĩ´ÄĪĻí-ôEĢį>÷@Vęí¯oÆ´,Ι;"¯{ĖøoíÚĩũ ?@O>ū-ZÄ'ŽģÎOT—ŋslôÁvų/É!„v€öuôđũW7bĻՂYacäĒ"˜ŅXÎü9õ”ûFä™d**›k&ķĀ×đĮĪü†ĩį~‘„¯îĐPƒ÷aĨ§¤¸ēģëë€U`퇝ĀŌT’Kz–23ôpv’Ü5ŸûĒŋÄ^=ah¯×ÅÜYuŌÔuĨ_Ûz"ã}Ī­åã÷>ˊÍģąä؇mŪŧyČß˙ôĮ?aŨēuv—Ŋ^JŠ:@ O*ūΉâIƒ?ŧ´Ļ?!Ļ!ā&8ŧTđ{tĖŠ§ļĻ䈴Š%XÎ?Îz?÷~öV6ŧķķ˜ŽāaÃrŨ•õ鱍āîé=Dš7æãt××ĸ`qI÷ķ,îūC˙įGB{ŧīfIõ—اå?×D×UæL¯Úti*ŗJũTdd%>Üå=-įūW“4ĖQß˙Xth“&‹qû/o'ˇ•.ŧ€TNžt)e ŠōA‘ Ā˙Ŋz ×Ũķ ßÛ˛?ÅPŠ nį[Eą—ųsęņûŨĩí}įøÛ§oĨcÚ9Czį;ę'xĸŅA^õøâšl{ךxd‚Ģ:ūÁÉŨw!PLáፒOŗ¤ęãtŠūQ{FGgJŨĀ!ĸ(‚ĒRsęK9ąž„jOJ•ī‘’/ßÂŨKWŽvkiŧÃØáKŸ}–å˗Û:×Hr€N*ąa’Ũ‹›ÚģųúƒĪņîgVqW¸ŸĨ§{̞W—ų™5Ŋ]W vSvTÕķį|–yÁ ØŅ0āIwˇĩg\%ØöéOṖš~˙Ėëģ1Â!5íŽÅĸ!ŌϰĻčb^*ZL¯â—g+-öŗĢš›ÃųÛʋŊL3LļuGŲfZÜņÂj~rÕyļĶn‡Ŗ††N9íÔC<ī(™Hōę+¯püņĮgģt]šĮ7åë^'ÔĘtWŽåØč͉ņŲ?/åūŽTã„*Îôšā° āukœ0§—kbÔGƒ IDATœ‡ˆDâtt„ÍmėC!)Ôa_˛$U™6C‰qĻĢ™ĶÜ˜Ž¯Â-RŽ& •VĪŠŦ œÃž9„•ņõO˜ĻŊÕģHc›–d}SŨI“€€W.?‹ųSjķ͋lŪŧ™—_~™ŨôÃÃ~nūÂ<ø—ŋāre]u‘âŠcUĸŸNĒ÷VÖ/åž—ÖôƒŧXWrEfO­š —@oo”ŊÍ]töÅRvŖĐGt’ Rf[,/[bĶøClĮŠīāƒžÍ”T–˛ļøvš*° ÄĘSU¯G'>ŧG[U Ĩ>ē[ûIxfíŽŧŨ0 žõÍoōöĒáķ ļmŪBKK Y›8 cč×Úņ+ljjå‡kw§˜@fTQ•›\[BQ‘ˇā7$M°ĢЃöžÃįZ+BāvŠčšŠĒ¤”$ ‹xŌŌ3ŊÁ °>|"î„ĘdËMU%(äž¨Ģ ĸ)}t‡â‡™ũn\jˆ„)ųĮÎ6ū3žĀįÎ_›6]ךčĸ‹XķÖĒa?‰DØģw¯ ĪĪįžM §<^˜ĩĒgYÜķĘzšĶųëS‚|žá{ũŧ.jkJ z?,)iiéaįžŽCĀ*{]”–yņz]Š))‚ūÜōT“HI¯‹ĘŠ 3§WsŌüI?íĐæŊ‘k67Ķ´ˇ˒ąB¤ŠcĻNŽdūŦÜC„=Ŋédš„W6íĻ;Íë=466ōƒ›~€Ž/8Â[ƒZJŌ,ĮĐßmįĸŦÚÖ_rÚô ĀAŋ›ãĻUqʂÉÛly4ŽÄy{ã>Ú{"ƒ˜žĻ,Āĸš ˘V…'ÕF›+MĄŧ<ĀÜŲuœ0Ģ–’Āā-K˛c_ˇ6“Lšĩ'ÅAķf×vWÆģūčë[¸xÉĶÜķÜĘŧŽm~÷{ŪÃmŋēōĘĘaíy›tEēítîãQÛ¯–å„•ÎŪ0ī^ō4o%M„€“Ępģԃ$ƒ`Z})5Õ%Bë틲~ë~’­Ļ^ĶË }ä;å^JIgW˜{:ˆ&ŒŒīԙ3Ŗ¯Į5ėĩcYĐĶáíÍûûÕøžpœMí!ĸÆ`Ķæd—ÆˇNÅ…‹fáuëyŲ§}{÷ōĐÃŗäwwŅ×;xJėŒY3yđ¯Ĩ¸8ë°X࿍zä÷`ŖÅĪ‹kˇķŽž @ĩWgf]É B0gjåa;ŋŒ‹ímYÄâąX‚xÜ ™v˜I)ií ÷ĢęB@}eI Ŗof$ “={;Ų×Ú۟ũîqiĖžV…eIÂá8‘X‚h,I"ibZŠ”<‘ŽÃwë~¯ŽĪë"āwãķšGíØžŗŊmŊƒ|ҘAg_ŒĻp#Ãôølu1˙}ÁIyŗŨĨ”´ˇˇķƊŦZŊšuk×ōÚ˛TŌ¯{'įŸoĢ4ã!čG5Đs9ĀMŊČ÷ļĩp|E€ōâÁËL­/ĨĄŽlܟ3‘0čęĶÕĄ''™öáHUŗĻTR^^4f/PJčė ąeW[ŋF‘rėegŗ ^—NMeUÁŧ§ĮbIŪ\ģgHo|Â0i뎲ŗ7Ö˙÷Sܡž"§ÎžœWHJISS_x!‘p„ŗŪņ~ûģßډ§¯NBG%ĐĨ”“jīcË`Eã\r÷S<—n$qʤ˛A6\IĀÃÜŲuãĒŽ‡#qš÷wĶÚNKÁÃŋ uë*sĻW'x$gÃÖ"ņäĄÎ1—FĐįÆįsáuëč熒nähYͰˆÅ“„ÂqēúĸH`J])U•ÁŧIx)aũæ}töŪņ‰%ŲŲĸ#žÂNŖ"XōŽŧkÁŒŧj–eņŖũˆß˙î.TUå¯?Ä 'œ0.ęģV  Ÿ üÁ.ČZģûx.’‘JÉ,qĻ6–ȓIƒ=ûēhnëë—,šĒPđā÷ēđzt\. UUJję‹eY˜Ļ…ÛŖãqëãön|>7ķæÔąqë~BŅeE^ĘKũ‹ŧ¸ŨڈbY}Ą­m}ôôE™:šŌî˜ĨCœâ"ΰ@÷ytfו°ˇ=ÄŽž{,ÉĮūŊšŋh*įĖ›–ˇŊR…‹.ēˆ{î^‚išüû_˙˛t ø ĐE‚Ü<œ‘Ë:ĪŽÚĖųĪϞ–nĻTt­,ņ3{F bžžˇ7ÂĻmÄĒ"¨*õSQ^DQĀ3!ŠfúŌabI™—æĄpŒÖö>ëĘōĸĘwtôą~{ëx š;ClëN s4…ŋčLŽŸ”ŋļTŅh”_uëÖŧMäFyė1JJ˛ÎŅX œ"„°îP äøVŽ ØŪÚĶ˙˙E%ČÔUĮä!ÖnŲOtyj&Ğ]ģYŋ~Ŋeæ9åđw |-×Eú"1^ÛÛŅ˙sfHÍīŅ ÆŪží鍰iG+.Må„YĩL›R‰{UđB#UU¨(/"7r^+ôrʂɜ0ģ–úĘák„€ēō@ãŠŋõDøĶËkōڎęÔSOEUSíŦ—Ŋü˛-‹ 8ų¨zēūö׀'‡5Xĩ}/×ūņ_üĄŊočę`EŠĖmķDÂ`ķŽ6ŧnųsęŊ˛‡4É&׃Ŗ8čcڔJĪkdJm ęaöE0š2€žæ‹ÖîakëØ§L™ÂņķæđĖĶĪ ŲJØ9ņh‘čבǨąįā2MūūęZ.xøUI;btE0ĢÔ7čD/‡Ŧˇ=û:Āņŗj)>N#—KŖąĄœįÖS~˜CÖíԘV’ú[ģ”üéõ y1#Ün7ž75ŽzĮļmėŪŊÛÎ2§Ox § Vžn÷=Į“ŋyfW-ÛHk:LUįsą¨Ą45€á@›cMÁįÛzęX$÷šČëFÕÆö‘;:CÔV[ÜÛĄCÕô†ú2fMŽ8ėĒĒPŸ.čé“đÜúyûŪiĶĻáv§„Ėë¯ŋng‰Jlu ĐĨ”>RĶOŗ–æ–”ü啎ųōē&$ŠZķš•ET•÷ãî­RBO_Œē/y=ŠĒ2ȌÆCŗ"KŠ<ũÃ4ŨŌL,‘ĖË÷•––rââÅŦYŊ†ˆŊŠļÆ tR,§ÚšpÅæŨ|vų–´#ŽĢ P2Ėümŋ×5Ļf&E>ׄiEuŦQuU1uƒë\ēJš;Ĩ >ÚĨŠŊ;/ßĨĒ*'Ÿz ģvî¤ŊŊŨÎ2s'$ĐĶqķOڑæí=!žũ¯U„ŌQ™%žCę¨ûŊ[Zŧ?ŌĖåá&Фá ųT^ŸnˇŽöÛé›;ûō6ŪŠŦŧ UUAš››Į¯ãô´Úū^;Ōüīolĸ+š49čbz‡`zc9S&UŽkjéX7^Ād3ÆGB;™īdRĸ‡õngaĸ͆t€KũZĸ(ÔVũėMWŨŊŨ=ėœöl)ā ¤“vlĒîļi<ŊDĨĀâl/Ú×ŅÃ-›S§Ą[T”#€é eßÜ1Ÿä•“Ė(ķã]LJö f€3`%xOx7‹c^ÖģËØ¤čQ4ĖøEt))–Ķ0Ķ=™ ŪöT°ŌULL(Gí––øMH)QWQcą"ž$žHâ÷äîČõų}ũ@īęėz +O‰ĢđI“J3Á$3J]2D…Ą[ņđ¸ŋaT‹Ē…ž~ +L”ĪĖ:ĖX–yô=mŸ/Ęöē=m]<–Îc/ÖU<.}°]>Šb•{Ž”*­$įFö rUwã-­Bs§œŠĒᯨ#é#Ūۉ™~^ˇPTt¯w Õå&é#ÖŨ†eĒŊEc˛‡ÆdįAâĄMķŅŽzدēéQtbB!‰Ā+}Į‚TŋyMJ4$.)ņK“2+A™™ ÔŠSfÄXqÜŌDŅt¤i"Ĩ…× qMßVVxkxÃU|Xŗ#gÚį|ędø…ōÄ÷„Ã)IîņxŽ~ “šõ$ŠÕģZúŲŧŌī$šĢKũb˛Š ZīíÆkøĘj@J’ą0–‘LK_7ē7€æņ étųŠpy˜É8ÉX3Cš& ІĒģP]4ˇĄ¨ƒŽĶÜ^âŊ$ÂŊH9¸ČC•’23J™eöfâBÅ*ĻPdzđ“‚D•š4ŅĨuHøOÍãÃ(Aw{1$ņžN‘>ŧ–Á9á&f$zxÖWKĢ’˙ä§Aĩ2ĶY—Ÿƒ%“ˆ§Û˛˛˛cčĩd´,ɛ{<•Ī`i~´Ļ™VZ . íĄĖŒâ.*ÅåOy‡],ŨB ē<¨Žė$‰ĸjxKĢp•õˆô!McXõÛ#M#SM…ĸ ęt_ēׇĸęŠ _Y Ž@ ąîvŒx„ēdWõFyÅWËJWq^3t]í×>2{øéZ~`Ō×ׇ•îâ[]]}L}YÆĪãÉ$˙ܗĒ&ԁÛ=pëĨ~ŋû¨¸‚dAĸ3ŖÍx­$._oqŸŨĒšđ–T⠖cÄ#$ŖaŒx$ĨÖgĄÚ EEŅt4ˇÍíCušQÔáŲPsyT֓ˆôíiÃcœŪC­áYo5ņŚh@oĖÖ×Ņa]zBˆ_UM\ŠĒā–€_šL7",ŒuPm„€;P‚§¤’Bđ2 EA÷ĐŊË4°Ė$–‘Ä2M¤eöˇ€B 5 n EÕQTuyFâōŅ<^ĸ]m$Ŗ!Ž‹wPjÆyĖß@O#Ļ%‰čnzâ5ž}Ņ)ÔWäĻ5îÚĩ )%–eŅĐĐ`g‰đDzÖ;ÖŌŨĮ…1!ÍUE ú&$°ëĖ8‹ã˜BA“ÅfœâŒP™PTŧ%•ũęzÁ‘(šŽĸé6ōmj:ĒŽŋŧ–x¨›XO5FˆĢB;yĖ?‰ũjîvģ$åq¯+ō°Ŋ;Š)%ŋŪ߯^ā֋OfūT{•ĸ–eąråJ**+Н¯ˇŗĖnÛû6N,2;Û Úz#ũö˜/Ŗ-°ĪŖO؆aĄ2=ŅÅÜXŗã)ĻõZ"-ŊŠĒ'.ČĮų€q•⯨CQ5ŠÍ í¤ÁŒį´Ŧa LÁŠ)ķŗ°Ž˜âtOƒįĸ .äUž_ŗ-ëĄŊŊŊ<ųø?X´øDĘËËíÜâʉôIY=4PŋĢe$ɔL`O{ĸUôAją;PB z2ž˛š”¤tčđę¨ĮG ĒUw°\ÚI]`Oƒw>Îņõ%ÔĻĢŲ6~ö-ž]ĩ%k°ŋĩō-:;R K/¸đÂTÎ{vÖO §cčYëÚ]‘x†Ķdāuø}Ž ÍŦŨʀÎĢęŧĨU¨ēËAņHXsá¯ŦGuyđYI. íĸܲ—ÉfY‡ĻūhĒÂ´ę “‹RīŠÅ’||é^ZˇcÄë& xā~‚ÅŜrĘ)vnoĐ2a€nį{Ĩ„PÆ Ėnž^ĪÄE,#,­ė<ذÛ5)°Y . īÁ'ŗĪ<+- 0wFÍ útHÅŅ+‹˜’îw°ß’\˙ė[ŦßŊDë._žœĨĪū €Ë¯ŧÂŽ}ūĒ"9Ņ€žĨƒDË}{ AĻŠšIĄ!iYNš]ŗ]US6ģæĸˆpQd˙ |ũšū”–úYp|=U5 BP_ >­An2,žõätö ßĒĢĢ‹Ÿũô¤”ø~ޏōJģUOŒ™dW°!éTELø”WyČ3:PĪE˛û+ęŠĘÔD§ÅíĩŌuYĶĢĶŨb3Ö‚IUE”¤‚<ÖeÉķĢÛ:™LōÛ;īdÍĒU|öķŸgƌvnŠ xa"=+7š´ uũ@ˆSĄLė =#B­Õ8cHŠlēj„œŨO™°§!ˆTˇØ“w‹ÕT…é•EũI4ßÛ´—ˇwÚDÂ4MūōāƒÜuįo8åôĶšúęĢížãĨBˆÎ‰ôJR™qYmē7ŖuķôD!˜đ•jŪLĮ‘P8`ĪųđôpJpI“wE›ŗVá3ŠĻǘeƒøĖįŅ™Zœ˛×ŖîXļvP…[2™äÁäÆī߀”’ęšn¸ņ;ÃĐ_rÖvÆá=ŧČ:8ėÉú 5~‚kēÅá %ŨfČĄÜÉ,GÕŨÔ&û8!Ņ—ĶZ5Õ%L>¨–ĸ˛Ä‡7m6ūžĨ—ˇwϤzOOŋŧí6ž÷?ßÁ2M‚ÅÅüüļ[™5k–Ũ¯ß ükLUčUUųōמĘwܑ+Č%đÃ\pãeŖ—“eVœeI–īnëWÛŪõļŦČ;a§“Î4"T%ÃŌ܃z ĢîRJžū÷K‡xĨŸ_özN‡šæō yŧ”˜1fҜīŗĒ*8¨r˛8ƒĪž7Ÿŋ=ō0Ÿûüį))-ÍõĢ^–äkĮčîlŋ3a<Ũœ’|nEāŌŦęŠĸ ÉÔē”œœĐáöŅ—4 –¯\ÓĪ<Īū–ļŖ č}Ą0+Vž}ČīŸ{ņ5â‰DnLį/A 9>‘ûx%—ŽQ™‘Lã˨¤÷ˇāĶĘz#qbiÁįɨZķēõAŌ}ĸЙņNæÅKcOqâˎbå~yį}ũjėŽŨûøņ­ŋ%O%@7HƐ*}$ËMē(Š^t^+É$3–ķŊú|Žū>rŠ2ûĐOæŖTáqāįųŪ߂z,‘ėo8ĄgdÁeÚę$įÅÚ9-28Ãå âōŽā§°øķßīoCÔ¯Öž´œĩ6@×4õ°ÉBų8Đu4‘Ü×ŌĩŒLM™O0->™Kņʄzföwæa9‘šM”[IŽ5ąø ģ\uyđ–VŅĸimëāĨWV )íŪxëíŖč••‡:°JKƒxķĐų@č˛*9EũĨŌ–%‘éwZéŅsÉwZ\*„hũ-x ëĒڟ“Ėˆ™+ Į= MŪkįŖŊ[™”ėĖxēģŋãHÔÖŪI(4´$zũÍÕ:Äx€ŠŠü,šhfôŲ§-FwåŽŊ)džPTJķ ēgJĶ´úk/&}ƒ?d ōK„ûGk ^,ú=.\ ąŒ|âBåm Éd#Æņ‰nĻ&zp1ÍTsûđ•׹ûiŋDīč8l¸Ŋ#5/lĸÄ蚯šgŸÆ ËŪôûsÎ8MÍ=÷Qĸj¸ ¯4‰æ0ŪÉ´,ŒtÅZ4>đ~§—íŧ‡­Ŗ ō !ŅK^iÕ6jXéã3ž(ŦqĀ^iņČ>>Ũŗ…ömcNŧãP ;X†ŋ˛~Ä HķŦɤa˜ tŪ9gPS=ĐŌzRC g~R~ĄĒ¨Xdnû•H˜fŠ{ĸ)sZ3ĢmÅÎW6ČĮčY–ĄøÜ..(KÅ-ã–$‘fúp´°ŧÍ1Ą`!X‰!-nÍí%PՈˇ¸"ëS?ā?|Ž‘×ëA×ĩŖčuĩUüč{_ŖļĻ’†újūīÆoPY‘ŋ‰&B( žÕÁH8Ž%%†iŅKŊZL¯ĩÕđqûØhšcK=@VEMU8ŊĄœ?v¤öEø<:‘X’¤aĸH‡ ŧčŠdrĸOŋ$hnîĸRtßveZuUĒĸ Ųā`ڔIGUûŲgœĖã܉‚â`žĸ„HÍË1ļŖ;•ŅØNHk˜WՔPôÛYØ×1•čé*œŊŲ^wŌ”šūm ĨNͤiÅ ŠIģMî ä-Š$P՘vkkĒŠ´™tÆ)‹8Ú¨¤8˜g8vry"aĐŅEJØ×íWÛß7wō ^†#]XwÔ=M+˛Ŋ`Nc5'§S {’&á´]ÔÚŅWpLúļ̏Œpŧ¯351Gōûŧ\ųÁ‹ų}QĀĮI‹æãĐqnYHÉš{´ļ÷b˜Ŋá8i3ōˇÆÉ3mM^Ųl9Zžuú€ĮͧŽoė˙y_W)ĄŊ;B4VXļzĢę"”žb™ŅîļœCB>øžķŠ­,Õ¯˙ÄU4Ö׎mbšHÃ@˜ÖCyz|”DlĻĀ&M-ŊX–dO×@AŌNĨØoĢōđųĖg/$āų´Ęâ9ŖÃ… g0iõNv[’ÖX’ęPŒ’"Mûē˜9­ē`øÉ@V\Ķc’ŅÉX8Ĩžį@uĩÕÜöãīqãÍŋd_s+—_z×^}Ų¨ÛįVoÉ 16lÆÜ˛ŲŲ‰PD]=ÚŧãpzjMõĀšDš& !›Ąĩ=û:I&í=Qē)Īũ;ŧ..ZdĢÕ3ŗy6\ŪNČöEũöŲ7øėęøT…Jpé*sgÔPZâ/ †Ž íĸ69`VhîÔD‘|P$#ŽPQ^:ē ˇ,â¯.'vīŸ‘íÃ$kyŊøŋũ ôãg4ĐÍDŒž–ŨtĒ^îNĪúúŽî0ëļî'3XĩˇCĻĻž?qŪ|.Z<ĮÖšĖBDĮâųĮ\uBXĀßí¨¯—Ÿv<įĨûjGL‹­}˜ĻdËÎ6bądA0”.%ƒ:š‰hjŧpČįõPYQ6ę’<ūæ*ĸŋ¸}xDŖ$^_AĄ“‘H9nģÔėĶiãņ$[vļa˜’­}iSėsĩ%œ;ēŨ[zdŦ@>^6:ĀŖdf(úšéŧĨy|4IS{Ņ„ÁÆ­ûûcėãISÍ(+~ˆ6’/ ™ÜŧŦØán7Ž“O,| ĮR)ÄÍZö“wˇíl#ž4I&M:Ō<6KUøÚģOÄc/=7A›J2Đ߯ædČĶfOæÖ§õ˙ŧĢ/NWoŒžh‚u››Į5cÎ+-ΎîŌšhmœŨįžƒ2eʰŽå¸ãđ÷›čķŽ/čg‘–‰`!Øeč‰tæaæ|m “Ēlw‘YŦË=—”*!„%Ĩüpļ‡ĸ>rÖ^ŪÕÆ’tņŋ']ɉ%Į­Y¤[Z| ÜDųeŠĒM¸¯j] E7}‡äúM[ļaíÛω(-E2múÔÆ„^øĪ•Œ†‘–EŸęÉË uHąwx[Ā-iöčúX dņaI‹á”zė×<é{ĐīÆíÆK Án=@B“ƒßĄ+P‚P&Ūč(áķá:iŽ“&rRŽ$NUîԃX6Āy :*3[iŠ]WöëĀ?ÆzƍûŌņÛĶ'\VÔÔŪÃŌtŽ{™G[ÃEę IDATīo/UQę7ŲB°Ė]Æŋ}qZ—ŋOQ)“maÄŖ˜ÖšŠmü@걑Ą-útÍNIj øĘh4–(X §éQlĖ|Ū´oĀĖč4,˙vÉk\EŦđÖ¤LŠ`9žŌ*gúĘ8Rŧ/Õ-šU÷ŗWÍžīœiZũ•jɌ‰žÅöÆu¯&ÕE†c č.˛Ī$%ŧŊˇŖß9âKĢę—ŠĮSöâĢîRšõ"âá’ąˆƒļņ’æąHĘ>ŪrWØZÃ4ÍūÚs3#°"`ĢëMģB‹@¯˛BgŌ0xŖ5Õļ×Ŗ*ũÕk>Ģ`F(›^÷Tb™áŽ}$Ŗ!ucm™KI´'Ĩųĩk>6čöĒ +­˛GuėUö*Õļ×~Œ72ff ôH<ÁéĒ!ŋŽôÛįAŋģ mģæĨ[õ‚”D:[0“ }cǞwa&bH¯yĢm9ᑁœˆP:t+€ę[)Í;U O"KßeG_„[īĪh¸Ph[Lûô3HË$ÖĶá oŦö>#Ū›˛Í÷čA6iöĶŖûŌŅĶ’„Ōņtŋ° ôŽcčYŋîĐ@Ö`fŸ÷Bė ۚ‘n™Œ…ōR˛ęĐTvË$ŌŲ‚”qĄ˛ÔWmģūܲ,zúbũ*|,í”{‰Ÿ"oÖ6ēÅu“)D gũũŊŅU*Ķ&/û<“UII؟†éШæDēZ1“q$đ†ˇ†6Å~‚L$’ šV×Cĩg4”ÛélÖĢ@Κĸ)Ž"mŸK ŗũķÁwd&ãG‘bŊ$#ŠlÉŨŽb^s—ä´^Gį€ĩ3<ācY4ŠĘÎr;ŽeÕ=ëc1ŗošČø×*Ā9éAË8HāXG‰â}]ÄŌvyâæ)_­m€a˜´¤žHšt¤Û:×)‚9 ļ€ūæx…Ö`œR`Ĩ”*đaāKYŸ Ф™ƒ ’Øō¸Ú\…(„â rT@ŪM´;JK•§ôŠÜXģŖ3D<™âŠîP3Ík×MĒ 4āŗŗäãšGÚ8€ŧÔšËėhŪ §›e=/ŧ2Đbķ U]ˆ´ĄádĘåM]īë"ÖŨH Ąđ/#ģÕÜF8†ÅžũŠ\ ˒ėëhBzáÜÉvĖDxy<÷Ic_F*đrģß]ėx‰™“[úÂą‚cÂĮü ėՋŠ—Ąļ}Ž÷=?ĖD´ģXw ąŧėĢcČyéæ–îū ,=Ą}i>;ßīfáÔ:[Këz K)ŊRĘ[H•ĨæÔͰ"8 6õeHņžž–UX6pĸņw#Û]E-F,L_Ën'[.~˛LÂÍÄûRž- Á2_+\%9¯ ÅúĨšiYėę0ŋŽ_8•€×VbÖãcÕr܀.Ĩ _%‹†‡Ŗō`€zʇכ41ŌųĮŅ„A_¨đ¤z\(<ę¯c‹ĢėFM„{ÔfIf"N¨ĩŠ˙ 4…‹ūz^sį^!˜LšlŪҊ™vėļuEûĨų;Ŋ.Ūu ;ËÚj6Ą€žųĀûķĩĻ×ĨķŪēÔKMX’HF|ŗšĨ§ ™Ķ@đ„¯–Ũ˙{g'WUæũīš÷Ö^ÕÕ{'é,•@ I@q! (Č" ̊4.ķŠ3žŖãŧŠōŠâ낈ȀbX_YL˛@ȞNīKuíUw9ķĮŊŨéÎÚ]]Ũ]Ũ}ŸÜO§—[uëœķ;ĪsÎyžßã‰p=ĶŨ­čŲ”ËŪAēęšdŒdûžžcĘŧPy&4Ŗ(–Ü4-ļíl%匧TVggĪkūĨå )”šøÛ„%z?’ŸWÔVgÎ9āũw&XņŽž =ņŌ JŅ…āĄiÄúoIIĻģ išĮnG5‰†NĒŖi@[%/†xĶ3üŠ.†aōΎēâ6ąuÃb{[ĸo§ũÖá‰@ū^‘D—R€û‹Mō^,m˜Ō—ÛڜÖÉ:A4RJvėé(ŲęĸIĄōlpfŋ]wËĐŅ3 —͇GäŨöžF?ΧŲáÁČvkÃ×Čdōl~§‰NĮz›–dgkœ¸s´v˛Wã‹Z†¯°B–iāĮĨЖ#uŧöÍbēëŊŪÜÜĖúuëxü‰'PĒ—@eĻ”4wĨi˜R†p\ŽíģÚY0ˇŽ$ŖåvjAŪõU˛0w ÉÅČfđ†ĸ.ŗû{@ŲŲžNĖüch"Øä¯á9 ú0Å<,KŌŪgWcēŗĪc˜ģZã´9î{TîüĀfÕ\ŅõΌRÉĨQ'ē”ō* „9ōų<›7mâŅGåņG#ÖŨ‚đ’<ąĢ@ö§rT$ŗ”;bíąĘŽVæ6Ԗd ü+ūjæåģû´åƛôˆîgäŗäz:ÁŽqqÕĮsÁiŧĢ…†U$QJIŦ'ÍžĻnzRârēÉÎÖ8šЍ?[~g.š]đ\Ü3–Ņpũ!ŠLōEĀKĀ°Í“Žëlxm÷ūō—<ûôĶ}GgBæĖáĸ‹/Ļĩb.wėŗB|Š`ņ´ō>ĄH€hØĮü†ZoÉ čK“{™ĄØu×ü!üe•hžĀä$x.C.Ņmģčũ"MīøĢxÎ_CZ^";›Ķ‰ÅR4ˇ'HfōũˇIˆ%ŗlīL‘u,{PĀŊg,äĸ3R!ĩĪBŠčNXëĀG†ëĸoßžŸūô§<úûGúîõų¸ø—°rÕ*N:é$‚Á ąT†kķūālĸ„5…ã§FûäŸÁޝŪ0­œÚšhIY÷åšnV¤÷Ōž@_¤Â!üĎ “RbdSä1Œ\†ƒ ˇi!^La—´—RbyŨ ›ÕIϞtĮ3¤˛ú!ųéŦNSWš–LžīõįŠ ?>g1į,™WˆøcßŌ8]ąŠTÚē˜D˙Gā†ąÁ—ÉdXģv-ßũöwˆ÷ØGe`ë>ũiVŸŋšŲŗgŖ4ÃîlîāĘßŊČKÎē*¨*W!|ø4ękŖTU†ņ–@îúqFš GNOVŊ~|ár<đ¸”Š>ĒOkčäĶqōŠøaŖŠõZ6zË03Dˇlk"ÕQUU¤´§ô0L˒XReIR™sØRŌiÅÛžÖųĘÉ!(Ÿ7X˙ÖŪž –cž¯”äu‹lN'‘ŅiMįÉTōšVîX<“ZqRĄåûŖ 8MŅTJí_,ĸßü¤ĐûwíÜÉŋđEŪxũuÛ}Z0Ÿ¯~í뜱ü 4mpÖ÷ŨũíÜöØ+<Ų/:.ęQ™Y¤,ė;Ŧ&„ĀĢ)„^‚^†ĻŠÔTGFü*’Ez’Ķ2mT˜™ŖvˆĸyđøCh0š×_ږ^JL=‘KŖg’ļ~ÛęÄĮU›}Uŧæ-'sŒ ŋÖļļíé@7,{ĶUQÂ.y%‘HËvßŗ†IÚ°Hé&ē”n^ ¸yFŸZq" ękŠŅįp™âŋJ­;DHtŦyAuswėØÁÍ7ŨĎmö)Ä~ŒžũvĻNzH|kw‚īūéī|ŋąk@Uˆ2Ę´ˆŸ˛°¯G=ę‡BđKgĸ 2lÂ/ÔSœ’í ÎH!Žą*Š‚ę āņQŊ~ToL‰/Ĩ…Ĩį1ōYŒ\3—Æ2ÍCÖŨ!hĶBŧåĢd‹'B~$ŗ,É[ö‘Ėčô$ŗŧŲZXüÁlUáú9u\pō<ΨΆÛÁø pĢœˆD?x˛{›››šéÆŲôƛ!¸ūƸåÖ[ … ķËé\˙6_e[ œQ”{5*B~^ŠWSí™Üū‡°lҌ1ÛŠW€if–“sŨ4ä{đËÁmÚ EAŅŧéŊ¨šEķ ¨šĶËÅđP$RJ¤i`–‘ĮÔķ˜ų,–‘?f„ŸtÜķ]ŪrŪōFiRũC*ĶĶŅ‘`ëŽ6ö´ÆŲ›œbOD@ƒĻrA}%gĪ›ÆŌŲ͍ކŠíĩŊœ#„(ɆbũQā‚ĄŪ—N§ųƚ5<ņčcÜpËÍ|öŗŸÅį+ŽlķūŽŧŧ…;ˇ5Ņt„õœ "đĒ‚°WcÁ”(B‰ķꨨyį¤Åņz’ųĻ ŠŦ;•BˆwJy3t¸Dŋ‘by÷ėŲÃÅ^DwWsįĪįžûM]]Ũˆ~ĐD&Į;mlØŨÂs{Úy#‘ĄŲ”ÄˤxĪŦjTUāÕT–4m”×郅GJęÍ,ĶÍ õzŠ*3MĀ2PƀøC(ÄT?íZ€ũZˆŨj€„ĸQŦT–ÖÛ÷v"ąŗĖ66v“vę Ũú|.?{éXuŸ 9 #VJÁÎ1ōėüĐCŅŨՅP_Zķå'9@$āãÔų38uū Žĩ$‰t†îd†;Ÿ}6ucI{}T=ä “–ļĻOĢ,ÉNĶ…`ˇ°“:|•¨HĘ-ƒz3GĨ™ĨĘĖļr„­<>i!¤D šÕ% H!0PH*RĒ.ÅKˇęŖEõŅĻxŅÅČL3íqvėëę{íöžLÉįĢ X<Į%ųH¸œÎ͛öīįŋø-įœ{.gžyæ¨hU”‡ƒ”‡ƒŦ˜UË›lĨ’tVī+Ú¸ˇ9F4 )ũTA§âĄSņ€#Ĩ$$ -"–A™4 I¯eâÅÄ#-T)Q @:ÖŲ‚ŧPĐQČ+*)Ą’* Ą‘VTŦQZ0H)ijŽąĢŠģīX.“ĶŲ;#ūՓgSW^6Mž¸Hąq­ĀNˇrīË/ŋܗ˜ōÉ+¯ÄëÛ8ôãëĢQx čH樊ÂNWÜēŖ•æO!ō3ŪĐk‰SB%ĨĒ´ŒŖgĪdōėÚ×Ņ—: vÔێļ†CúUa?=íøŅޞ€‡€Ī !ÚĮĶx(ôņL 877 ƒ§žz €é3g°dɒ1o€9SĢyОl:rŠė„‡œnōÖ;Í´ĩĮėáĸx<ÎąsO;¯oi@rĶ’ėnKĐíT3­‚oœģ´PŗBsŒÛ'Įɇãēĸ›:::ظÁŽ~[šz5áđØa…ü^Ž]ÜĀŗ¯lCû:S7ÍĶ:k˜īėn§Ĩ=ÎôŠåD˂%™úZĒČå E (bĀN¸”]7Éfķ$’YēâŠÜ!ËĶ’ėi‹Ķœļ'`¸ë= X6oúh:GΎ!Ū¯ũ0dĸ;›p.äÍļlŲŌįļ̊ŠŽëcîē|hÉ<Îܸ‹ŗ:9ƒ˛ŽĶĒÂÜžTŽží­ø<*UŅ ŅH€@ЋĪĢ9“‚ũĮB0&ąķĨ˒´´ÅØĩŋ˒¨Ē‚æŨ’ôŽY]'—7ØŲ–čËøÁâ™\tÆĸŅjã]ށ{ĮZÅu¸}ˆŅīÜš“+¯¸‚–Ļf‡‚›nŊ…[nŊĩhA2ÃÁ3ß傧7Ōë0Î-0ĩōØŅSB<š]§]AČīanC 6iIžÉäŲšˇŖOƒm¨0-IW<Îî4ē3x€ģ–6pÍûNÆ;ōmģ øđ+!ĄĐų*¤ÅNúėnņ‹_üĸäŊŽÛŨwũ_ŧ>7Ūx㠓WF ī?i.˙ŪÔÉį7īC;bŌyƒÕ|õ¨kËŧ~ Ô6ÕImmbūėĘ"“KDÂ0LZZ{ØÛŌ3 FŪ⠈ũKfŲĪ’4Üß *üāĖãYšl!ÚČ-›L`đ#āž‰BđáXôߗ åžũû÷ŗę#į‘LÚvBQøŪîdõęՅ¸cÁ.ą"„ķfķ:?øĶĢ|åæž`MˊøŠ*ķãõhƒŪåB0­:BũÔr|>ΰČcZ˛$ëŋ÷1Ä´hīHĐØPíļׅ%˛¨Ē@Ua{>‰iÚ"éŧNöĩĩCŽRy?pđOĀ?S`]ä “‡_z‹/¯ßNcŋ5¤Pí͍x ú=ø<*Ēj¯Í{sF— ĢŠ u•!jĢË}ƒŦ´¤$›ÕéčLĐܑ@ķgÕP^*Ęq’eÉĸė%äō šÚã}Jŧ~¯›ėnKô‰-į…}|æôŧīÄš†1QÆjĮ°ƒ]ūę\/ !ē™āĒ™đCVËãÍ7:¨ÚZZYŋn+W­ęKW !ŌĀĪĨ”ŋu˙`QËŧšĘeī]ÂŌ†:î~ū-înéés-ÛŗíŲZUš¨<Š`^M¤/āĻĪ"›ûÛėoOđiDÃ~"!~ŸÍŖĸKJLÃ$—×IĻōô$ŗtÍ6moĄĻė†Z¯ÛģIrC(gŊ@SXYåc'ÍfŲŧ酖?:äq€_`gXîļ—j†Y)Yô°¨ĘúüšĢ¯æĨį_8ęß]ã ÜūĨ/ ÕĘüYņპQ–˙8{XÖ]7xsw3ŧž‡ģØn}ŨéQĮׄ‰†G6Ā&ä÷ø) ûņųėtÛ^˲Ũa]7Čå R™<ÉtŽd&XUMUˆ†|D#~/^†Ē)}5įuÃ$Ÿ3HĻs$R9™üQwËsyƒÆÎMéU™€Ģ§V6L’ē‰)% ÔTfEƒĖ¯.cn]%sę*Њ††ŖÕv8üxŋc&-F|á—JĨhi>v\VGGRĘaģ“ŽęækRĘ/;ŽYÁįw^ÆŠķg°lŪt>K˛­ŠˇÛy­š›ŊŠģsÛ{sŪ…@ˇ$oĩ&hČčÔU†đ sãČ&š…Ī;ĐâϞ:ŠŦNS{âĶ5ÔÖ3L‹Îx†ÎwČ{Ą›ą4ģãYŒ~Árŋ‡oŋ1+ÍF͞úúYQ”‘–ŋlޚė$ĸgŗY:;:ŽųwååÅ>}¸¸nØnÔUD¨ĢˆpÖĸ9XR’ÉåíÜhÃ$“×ųų oņ­ŨíH`WmČ„iI2YÎD–Ļt~€øpČĮšŗąüø†Q—äęGō !^u)>JDˇsˆMTõđ>eĘnģíļCdœ‰Ø1,q§”ōl1ũMsō{=\ūŪ%,›=•?˙&w5ۏ–ˇ${“š>ŲŖ€ĻRzŧnIRĻ$wŒõKF§uŒŋ‡šˆPĀ‹Ļځ:ڒiIō†I.o’ĖéÄŗ:ŨēŲOFgo2KOŖ*ä#đāŅT;\ĩßLbIi¯ûM‹LÎ žÎĶ™ÕI‡>ī)^Ī/m`õ˛ã(ĮjL÷’ü—ŪŖHtUU‰”•‘Nž51!8~f˙yé9\žc?Ŋö.wīīĸ)͌a‘„$ÏĘesę0,ÉŋžÛL֙4Û˛zßqUPS*Ŋdˇ•NuK’ĩ$9K3GJhË´9' š"Š >E8{—’”ibĩûã,ŋ‡ëOšÅ‡—ÎŖļ<2–ãŲ%ųXŨįķQU]}ĸ æ/X0œ—yü”Ō‹­†3âņļ^M厛ɊķĻķšÎ¯ílæoģ[yĩ=Î ũ° ę9ĒÂŅ §M­ā´ŲSX8Ŋ†ŠH˲XŊ§…_ŋ˛•6vŅ˙Đ-mX¤‡ĸã"á}A/œ^EK2ÃŨÍ1úËg–¤Įœ€i•\1ĩœķOlāÔyõÅĐC.v—!Öģ´æūM!Įkų|ž+.ŋœ ëm˙šēZūøÔSTV¤äŌ7”3QĮŋõs)%Šlžd&G,•á• o°æĩ(ų,§T…ųɡū…ÚNJ#Ži ĶbO[{gĪėláņŽŨ–@}]õŅ;JU˜;ĩššSĢšôL“žT†öž]É4™ŧDĸ) ¯‡°ßK4ä§,č'āõ6 DSæLŠbΔ*.^~"ÉLŽÎxŠŽd†d6nšxT•Ŋ{÷˛æž{ņÄģøŌí7ŗzåYĨ4~[°#"[ŠZęã™čy 5Ô7Y˛t ÷ß+”š2u*įŸ~ĄGj=Ā]n­”ōMāN`5cp*õÂķĪķôŸū„‚K¯¸œĶNZžGSŠŽ†ŠŽ'Ÿ_UĸĄŅP€ƒØ^N´áã¯!¨,/™î ėŖĶw­øā  ‘$y`ĪPßdŊĖ_¸đ€Ë^[Ë~˙{LŸ^°xĀ…{ ŊYąø8p=Đ:š žL$¸įž{B¸öÚkņx<%;@zzb'Ĩ,)‚˙X*„¸Õ%ųČYt°C ‡$˙<Ö­GQŽŋáJz€tĮėcBUS Ņßw9“´‹Q úë…ŧQCCC1ĩ|A‘*V!Z€¯I)äũr`ۈXķd’_üüį(ŠBY4ʅ]ThüĄŗŗĶ&ēĒ ĢTÖa:žãc‹1ž<Qƚč¯:ëôĐ<īێGQt!šoH)˙8¸XĖ-Ö{lذ××ŋ†ĸ(\sŨĩÃYēŒZ[핍Ļi„‚EíōĮĪ`Ÿoöo9Ƥ]‘q):vDovfÚUŖüŦŌqŨŒ‘|'âEāE)åāxlUĶ°Cië€!¯; ÃāüÁÉ2ŦZš˛äĩ夔ėÛģ€úĶņx‹˛—îž…Ųh9!„+ŗ[JDwbČīÎŖpščB° øÍh6ŽCúלë'RJÅYĮŋ é\°ąą‘§žx€]üqfžG1jĐuŽv;!iöėŲÅXft9û!sųåbd,:ŽE˙+đūQzN¸}Ŧ‡”ŌLĮ–Ŧ:—Ä7^}õU Ã@JÉĒ•+Į\#oPĻ7›%•JPWWW ¤[˛)#ĨÜí¸ë›œ¯ÛUĘi\]aH)ŋ‰]Ča4Ά~ <>J¤V€ėŦˇĶeÁÃQ§ë:Ī<ũ4UÕÕ,.ÂƒA&“!Ūī{î"XtĢĘiÛ^ä€.'Æavxķ: ÕuëĮÎĸãXô˙Ü<ÂĪøđőėl)åTĮ;9x¯ŗ/Zđv{{;/ü÷_8oÕJĘËËĮÅāH§Ķô8ĮkUUU#ũv>lĄĪŠØĮˇ{Ķ÷m)e¯ų‚ĸĮĨí(Ŋ_ž÷ ĀûFp]~‘ĸk„Č}p1p–cąG;vė@×u„ŧįŒ3JūH­‰D˲Pe,&'áôÉŠÎõ SJųgė3õgŨ#ˇÁcX#ÎŅžžxažmvYڝE$ˇR.•RŪŧƒēzŪH’`ëÖ­!0tųķį›Á‘ˆĮûÖåŅ蘇ŋ ėĐˀß;¤”÷I)WH)ŨŌ8#It‡ėĀųØ;âVžÉĀôÛQD‚/q^÷렘Q ķ˛,‹-›ˇ0}æĖQŠ_,ÄzzúÂ_#cūz8Ōģ.ÚRĘå.áGčŲ{€Ģ°å–ßvÖW…`ķW:H1H^ü;ĐįRF!Ŋ?˛Ų,ģwīāÄŋ ÆOõ–î.[LUÕR#zx€•áī–RVē´!ĸ;d7…aG•]í¸ķųAŪĀŽ„ú°DņPąRĨ”̰#éŽm‚÷"—Ëąc›-„S_?m\ĢõĸŖĶžkUÍđ×Bán^‘R.wŠ=EuNØâ}NéĻ)ØG(sąĪž=ũČĮVyØSˈ7įˆėķĀ˙frĪŞčŨ]]øjĮ‘ÛĐH ';ôIDATŌbËtkšF0/=xJJy…âq—â#DôūØī\ŖgöE‡äęX7°ž×ûvŲ‹+>ŌíHãŪ}Ėž3§¤Siƒ2ā)åe.ŲG˜čcˆ˙V $ˇgŧ“ßā‰ÖpŊß˃/å×EōСøHB ø8čįš\ŽXĖ^ŖĪœ5sÜ öC¸_Jų!ÄË.Ņ'¤”Ķ€2:Ņzƒk`MC: Ēél]J[ŽúžĘŪ¯ŊWŋīĮ ņxœXĖŽM‰VV’´,Ô~‚âLŠ3õ~-ąmī2ā—RĘ3&C!ÅÉdŅ×`GVî˜-‡ĀĻC֜Ļ ‡0M“Ļļ6’ĻyLæR@&›%îDÅĨ2Ö˙ũīčy[ƒÖëķ.+ŖŦŦŒōŠ <^/Â)¯ÔP…­¯8?SĮf"8;Øæk.Ņ'†5oŽ B›Rb8Ä6,‹|>OŦģ›X,F2'›É Ĩ´×ĩBĄĻŽŽ–Ļ&šöîÃĐuÔq°ķŪÖŌB2™DQøw<ūđīü^Q<^/•ÕÕŦxīY,9õTŽ?ņDĻ͘Ņw˛ fBSœ āāk„'€›¤”w !š&+ŅÅ"úŋwķ5{ mô#ļ,ͤŖ­-›6ąqŨ:^~áEš÷Ąįu,ëČ1Cõ3fđĢ?QU•koš…Ī­ų2Sęë‘ØÜöíŪ͓k×g‰d,‹¤aJöŠ“•čež xb(÷d-‹´yėú–Ļ&.]ĩšt29œįcቋ¸î–[8ëœs†ĩömÄēģŲģs'ooŪˆuëØļe+M†1hĪdŅ’ÅÜs˙ũxũÅ+yRU|C÷ŒnBÜ=‰Ž1I1ØŽŗ­t"yˤ”}Wum S§OįÄŋY´d sįĪgúĖ™Ãáq×Nå”/[Æâe˸䓟$ëîĻŊĩ•ŽŽڛ›éęė¤ĢĢ‹D"A:ĀëõQ‰-rúōåx}ž1éŋÃ8îxĮũƒĀŸ‡Úã)Ķ$ ˇTwvÚÛZ[IÄbdRiōziYh~ŋŸP8L¤ĸ‚ōh”ŠĘJĘ**ˆD"EÜ.lø…Pa^ŅÕBˆ_šDŋD_l,äۜe‘1M,—?%UŠ‚ˇđÍĖs…ĪēŽûøEvå!GĸøœŖ[9ËÂ8Jĸˆ‹ąąDšøf[ RØōa¸DŋčĀ.åsfĄƒÉëŪ’ŨšŒŖšš9(š=ŠRĖ(šÍŒrĘ´KôbĪúļ"íÅ}Ā ŸøŠĒbÛû‡Áē(žÅVœĐ×ŪK™Ø÷û&ŗ>üDŠu¯qŦúˆĘ¸ôf¨õNfŋŒ5w86ĄNjéũŲŖ8Is-úøˇęíNéãoލ[é Lí w˛~ųÁ)ĢķĖ'"‰ōŌû§Ļö˙˙Y |w2“|BYtĮĒ—c—ōYXrĪÆ@ĩËųŪâčÂŊJ2Ŋ÷üšÅęô^u™Ä倘D˙˙›80Ņ–ę`z {ˇ=ë}b‘}đŖ¤Û>Z“DŋĪ7l˛‹ŽĐ!’RØõļģK§ )å'°kpp1YŅ ŦBlp›bâ$ĩŧ^ģKˇÛœ{U.É'8Ҟ˙ģ2ę‹n7O*< ŧOņēÛ“€čŲ7ÄÖyouģ{BŖ ¸ÍąäģÜæ˜kô#ŦÛk›œĢÎíú ƒNā>āûBˆ}nsLrĸ÷#| vąÅ̰5æÜ œãŊõú~ŦB´šMâũH„÷bׅģøļːĮ%Mî7€ĩĀ#ĀļbÖës‰>yH?XœãLõnûŒ9ąwa—ģ~ģ:īn!„î6Kôb?ėŦãOuÜûeØŅvĶÜ61RīsŽ×bŋtLöˆ6—čcCūjlY铀™Ā ųëąë|š8:Ō™[€-ØįŨ›°EC:ėdÎ0s‰^Ú€b+ŒÖ:–v=øØ5âƒØrÔÕ´ÍM  Č` <ėöIė0Ô-Îī›œŸå]BģDŸH“€ĀŪāSą3ĢÉ ā´}=va l gŋ`vé_u}YîxũûUb s¤†đč:víšØ=ÎÚyķ};Ü´Į!ŧáŽŖ]ĸģü$Ņ;A Ĩ<ŽQ x ÛîŨĘÛ;„uwĩ]¸pá… .\¸pá… .\¸pá… .\¸p1Ųđ?ŲųžGũIENDŽB`‚ffuf-2.3.0/_img/offsec-logo.png000066400000000000000000000545011525032755300163020ustar00rootroot00000000000000‰PNG  IHDR,Y„åŗiCCPkCGColorSpaceGenericRGB8U]hU>›šŗ+$΃ÔĻĻ’ū5”´lRŅ„ÚčūeŗmÜ,“l´AÉėŨi&3ãü¤i)>AÁ¨ā“ā˙[Á'!jĢí‹-ĸ´Pĸƒ(øĐúGĄŌ ëš3ŗģ“¸kŊËÜųæœī~įŪsîŪ ¸,[–Ū%,Ž-åĶâŗĮæÄÄ:tÁ}Đ }Đ-+Ž•*•&ãÂŋÚíī ÆŪ×öˇ÷˙gëŽPGˆŨ…ØŦ8Ę"âeūŞ]€AûČ ×bø Ä;lœ âÃĩWžđ˛Ī™‘2ˆ_E,(njūÄۈį#öZsđێ<5¨­)"ËEÉ6̚N#ĶŊƒûļEŨkÄۃOŗ0}߸ö—*r–ᇟUäÜtˆ¯.iŗÅ˙eši ņ#]ģŧ…r ņ>ÄcU{ŧčtŠĒ7ŅĀ+§Ô™g߃xuÁ<ĘÆîDüĩ1_œ u~Rœ æāâ*-°z÷#°Mi*ˆËWh6Įōˆø¸æf}î-gi:×Đ9ĨfŠA,î‹ãōV§>ÄWŠž—Bũ_-ˇÆ%=†^œ tČ0uüõúvW™â’9 Œ%/VĩņBČWĩ'¤_ļtâÜČMۓĘŸŋŸåP““í\>ʍÉ@Á„yė0`D i|[`Ŗ§ čhĄčĨhĄøÕāėßÂ)ųūˇTjūČëčŅ0BĻ˙#đĐĒÂīhU#ŧ ~yhĢuĐ fp#Ô1I/I’ƒø"“ä0!Ŗ ’'ÉSdŒdŅ:J5Į–"sdķšŅÔy#RŸ7‹ŧ‹čwAÆūgd˜ā´ĪÅJŸ7ØĪØĪkʕ×n^:}nW‹ģFVŸģÖsét$gj-tČÚԝrĪ˙Â_į×°_į7Z ū~ëÛVˇ5ë4ĖV }ēo[ÄGķ=Nd>Ĩ-UlaŗŖĸY5VúË}šxģg[üä÷É?’kÉ÷’&ãŪä>áÎsŸrŸq߀Č]ā.r_r_qsŸGjÔyī4kî¯Ŗą æi—QܟBZØ-<(d…=ÂÃÂdKO膄 a/zv7Ģ]ģĮ°odĢ}Ŧ€ŠsėnŦŗÖá?TF–'|Ļãī3Nnã„#I?"…mģz„íõĻv~K=Ú¯Æsņl<b|_|4>?ÂpƒߋžQėņÔré˛ËâŒi´ĩšęŠÃÉäãb ¯2* åĀ (ëēčģŅĻĩ—hå°{28ÂoIūũÛyĨesŸ8ü';÷Zļ9āŦĐûhË6€gãŊīŦ>ĻxöRx'Äbß8ՃÃÁWOĪĢëõ[xn%ŪØ|Ŋ^˙ûũz}ķÔ߸ ˙˙%x ÅcÖËxeXIfMM*JR(‡iZHH , YžüÛL pHYs  šœYiTXtXML:com.adobe.xmp 1 ^á@IDATxí]xœÅŅž;I§Ū-wËrī°M7¸)ĻИ đHHZ€„’JčBŗc÷ŪpÁŊŠ[Ŋ—ģûßwžoO§+*€m‰įVĪék[ggggfgg‘^^‰„"ˆ@ @ĀŲ ęŠbD  ˆŦ"D @ˇ@„`u›ŽŠT4"+‚D Đm !XŨĻĢ"@ ÁŠā@tDVˇéĒHE#ˆ@ B°"8@Ũ‚Õmē*RŅ"ˆŦD @ˇ@„`u›ŽŠT4"+‚D Đm ŨmjęĢ(÷j;ÅŲ'IîųĖkgŌ!Ŋ§ĻAŧåuH|´h7ęÎĸ=Ŧd:ĄptB¤ŽÜ;Wj7#X$2Nņz¤:ŋāKo’‡Xü\IũÅ[Ũ€ģÃM´ŧâHrá‡RA,ŊÍâ­mŠs‹Ä;ő'ēÄë/ iUSŊégFŧHL”ÆīŅFūžbi8RbÅĶų<Ú[V'Ū&7ŌG#Ÿ¸NæaMŪĘzņÖ5k;ÛÎu.ŠqV:A!}T”83Qn˜ ËÔ/ø;ú!1Æę'đ‰ũP׈ž@[bŅIč•bŗÛę‹ZÖ1ÔĈŧŌÃØ6`¨đŽÕ6:ŗŦ|õ)ô?o)āŠr­ō˜?ękáEpl8Ö7‰ˇĸŪîOW'ûEųáDpøîDÛ=1G78ē•{™ WSĨĎ&îēUĸâ0đ=dMB!Rۀõz='{>üXv=ú'qĨeƒĶ"Ņ:H— B•+Mģ$Jg  9Čd&ˆo‰ž'‰bkøŪÕ{ˆ4o%ëåßF Ž+JķÅ 5"t80'Ętb`6UįŠ)ŗÃØ]/QIâFĻMĪíîŖÍķ4厙O,ęėp0 ĪIh<5Ō Ua‹tIōNUĸcÁũB•/Mų;ĩ,AÂēĨĘņŪ­p%fq Ãđ—č>C1€TûC ųX7ÖåI8r†¤â¤Ž^ŠG?ˇb%YQ‰Jœ9™´—?Û@\"7•ī˙Âũ+Ā2ļļeL9{&ˆģ¨ 5?$Q™Eš<ŒíĩčĢûÎvv›ĀŲĪŨT#ą=3eÂĨßúJę펊•­ Xq ąâ>,Ë#ÎŦ$i*Ū#[='ΐAž'ŊĮ•Ôėū›ˆąĮÁį‘úš)ß@ 7o‘ũ|(ŋo!b¯Áâ)äĀ´8@Îļî†||íõ’ØĢ§xš0\üéYq ŦæúŲ÷âëԐģCz]Åâ :šF™3:J|ŧ@Ē—­@Ž^IÖ|ϊ§9 =äFÁYí{áuiÜ_ ÎøņÔUJܨa2üĸķ”x‘C Ū+ĩ?“(WšrQÎĖi.)–úÚ\ɚxĒä\|Ąô=nŧ¤egŖ@ÜĀąi?ÔÕIMa‘TäæJŅĻĪäāI)ÚÉĄƒAë)!ˇDŽũP›'.ŋZR‡ w(ĸîėŸŊĪŧ$^܏šúZ´ÛpK5&üûß~_ęļl—¨”4qW”ū—]%idžĖߋvGš\Rž}§xņ?Ōgöé1nLȸĨĩ<žŦãKoHĶūBāäëHqIMŅIî3LârÆJé˛O-ž”Z0hÉäČÜu+‚œÖ@ämÄāv%&ŠGEžFgs°ĐD<<äĀđÎ˰Ö{tĮ-Q11Ō䴒#ŧÉã‹_AŦz%K]ánɘ4YN{āntęÉĘŲ…Ęŧ€ô5RFĖúoÛ˛…šļō9ķž;Ãæŗ 9YV˙úIÎę- ûķ¤Īøą2ķū{ÃÆŗ H*7Ž’¨„,q&ĮJCÉ^I2Nfüé6mŠÄBöƒŒn}ēlŽ4Ūú+Ųŗt™,šåNŠXŋ\b3s@´jĀŠ%IÆíÄ˙ģFr&Ÿ*+ëpđé%ˤfÉ™vË/%6MK˙Ŋ†FŲēe­$ed č˛Lží—Ō“J[aĶoÉFŦ3¯ē\Æ]xA[QCÃyvå)Ųŋ péOz, •ä”Gū*™ƒIÆ[ܡČęŋ=.EīŊ%ŽtH$eŅuô„Îōpŧ=ÜJ›ÃQg’Ķi%r'$PÎ(ĖxøÂûÜĩëdŪ/o“mĖSîÅ|ˇŽv\›¨‘ã8<„ŗg’ĢÁßũĄ\ņņ{2lúT%VœÅÛûqÖ„ApŲģoĘčŸ˙Rķ!ņ#'BúËļ6ąH´Ûˏߕx#~S=ôFxÖ<@ŧÜāj:’‡‰CN€å*ĖuBčx=´.öDŖÜ”ÖéÉY!ļĮŧ~öLá+úœ“š S/seũøÁ îšÄ*ëĖķä˛%ËčķĪSbeâZ×ā</~s%'é$réGījĖˑ Ũt:lŋ™4YĪÖyR´iŧ˜k¤RíŲŖīBÅ5íLîŨK[¨e#6U páwSvņ–mÂAN™Ą3ũÉøū8áLMpv'?ö¸ô3ZöƒØÚēMrW¯‘ŠwŨ!)ãOwŲ~qÄy~įpVÂāˆ*+üęvYķĐōΚgKņŽZvî‘ Tf7‚Îšr–Ėú td)ÉրÄāb=}?°ãž{ŋ÷¤J´…zēs~ŋôĢßN͙Näĩ ‰5EšPyžs"žŸéŦ{'1ĖģĀøm=S‡ÄėH:ĪĘŖcõĐ|}uąęag2N4 }Āeô>ûˆ…Ÿ+áR‚Áļķį;­ëdgMœ` ĪųûŖ’šƒ™đõOÃ¸æ™ ã=áĢy!ŽģŠIzdƌ?ūŪ"— –&8]Ԁĩ¤7ų[aŒŧH>+rķ4ŽÂĪÎßÄį;†ÔœZ†§ŽzĖĄŸ’ĸīM<˙́MåJ°LŖũã´yīß6ŧÜšå’’=RúsŒŦ‡5ú›ŗĨ˙¤‰Ÿ‘!{—/—ņ?ŊN(Rx¤C ԏtÉ_qyœeęĢǤzûnIƒ’’88Ž#? °T–’_˜|ßo0›Cį‚Aĸē AGžĄG xO‰–ö4ˆBJrņŽØLܲņ+(Š˙ 3 s&6–Eŧ-xĩÄ |öûbĪäšķqŖ&øæZ‡đI}™0Žz_]Lž˜!n –IÂ0bÎ%Ē,gš^›đ„Há{E¯/Ú-#nŊ]2‡@ˆúGQ‡äßŦž)*̊€ßđÎđLõÛĐ{ėh蔮”ÆŌ}ÖĘ "iŋú"‡šąķ+ߡO#(ÜĸšwIYaņÍS =ŨāāđČY‡čo;ĪĻÚ:ŠÜąË"XūõÖTÁ˙q‚íRœ°›ė–"I9TņĨĄ¸DU/‰™™RSZ*•ķ$ĄgO%¨Ž(Sãā2כ¯ Á2ŗY\jŠô9w†”chԝwĢüíq[ĸÆáb`žŽdŦė€ę5ũɞ8Q?›™ĐÄĩˆ†ČöæËcI‰˛áĩ×õ“yoâ‘h1ô?Nú^ôMi€ōŪgÍč&N[WÎŽ-ĸ°•W,Dž×Đœ ˃âˇāG1Gqŧ<”°aСÔÃi T€ĸ¯iÛĶŋC§ĩI—EŧIX:HčûŸtbȘŦ‰W}y…ŧrÉeōÜÔŗĨĒ Pßé7˙T61Č=ĘZác |°ôx´„~ŲÎŨ֗6ęž‘ĶŦ+ŠņŊ{JtŒ=é¤1pĢ++“šõŸkūAuQĀū$lcĖĸâĮ@_Zōáģč›Œĸ­ī} īŊ_°@4üė3%ÍZ­ÍAŽt8:ėĮáh%ÂmĪžÃf+ë˙Ģ$b&`0\–8‡Ŗx˙<ņPŠcQ¯Īi'KT,$8” ¤ļ‘oë›oëÂüö×Ū’ņĪއŒ5=¸Ŧ^Į'ßzUâ ûâžĸĪÖyĒ~‚åŗũÔũ5ÕÖJSEĨÍ´‘(ęšsņeØ[œĘŨ{-ÄmCÜ60/Øöš\ˇ^bâãĐĖčĀĨX%ŌX ­ā€ĶpE/į'7ČĻG˙¨œg[iĖ7’æÔūũôŅ>ķ ™jŦZ-ûßyCaRđŲgB=’ųæ‹kßP4R¨zŲMZęĶ”`A×änh^X+q­8='âÁQÅĨįHu įˆ„ŅĀŅä§WģîUÅÅŌ€¸íq&]X@(§É:°ī€čCë ‹‘Ä<7úŦüû?eÚˇÉÎOJæņĮJ˙'IC}lŧķג:@<‡hËwdÃ׃`ŲGVŋ*/_6ŋđ˛¤ŽĢąŌ– …áĀiSdؙĶ%}`včŽ˙ĒaNe0ōL6TsæŊÁmS”ˆ9͕UB­”HĖåoՇØí1qMú”ԟ/I|pŅ{Éījŗ +;Ÿ`°ß8XL|Ö%Šį`­‹ ø‰q×˙ûiŲüôŋ`ՄzúEN”žš¯īü>ųnM]v}ôąĖĄĄ6ÆR…[QØvWßĄŌ˜ˇĶ—&ė —‘/@°HČ5˙° Ŧ6s ĮØbU`TÍ/ëĢĢÕ‹ßÕL„WäīOøØG'´ÖŨ8l.˜iÃiöAՒõR[ZSŪÚūxaîãĶĶ$~ė)_´WԈÉ8Ą¯đi*æ*LMz~ ė<6>û‚lā„ŽHĻ?™6ŖÆéL!Ē–¸9˛ī™Ë;råØëŽ‘AgL–]˜¸ÖßúKL0=`väš+ļŠÛ,EX QmI‰,¸īAŲûį`ļŽ‚öW=Kî‹˙‘møíŌzÎë/ŠŽ‚ ?ŦÁÆWR’U %d€^DJ 8(rė™Ö—ÄNo#o—⋨úÆŨØ$ņ#Ž•¨Ō qŌ›ŗ)PœFŗA_뤭žâ{f)Ą‰2á-Æĩ„Ŋäé i•ĀīÁ ĸčØ8áb~ŌđcÄSmëėĐNOy­ZËû% {kڝ pfßáŌ Ũy.Ë'øšAŧCę¤Ŧ*æQØvÅ[Iu]~‰HŦ¸j§]b'n¯LCXEIēÔÕ”JėŌ Á "Bv~1ā|ô×ú$g`ōÁĀļ|īžßÃŊŒËĖĐžˆ:V<ĩ°ŽWœ.TÛ8ŽëĖ`žQúÉ\y?܄”ū°üĮ”Ž1xfŦÞ 0ĸÍŌÚ?˙ˆĨdŅ‘@UĩOŋž Ų‰ë+Ūz’=S3ĀĸŸˇēcųv¤ė/įëC°Ēk™8ˇÜ‘ˇ…ēT ^pōŗ~UŦ—­˙sŲAg;\ÛãlHāúĖ™ ĶåäX†IųĻ-Ōx¨ŦͲ´ Ģ0éãÁæšYâ ,Í,Hdk”ęõÛ5ļęlžQäĘ>ušÄ÷Į Ā c=š`‚R:o)¤Åąũí#Ęö9BúN>Õ2Áje¸ĀąT û۟'§üč‡ē˛I8˜2™Ná‰wNŦXwųĨ2zŋ-īž/kîHĘ7¯‘ÄūÃ-“píäˆÁe’KÕMėš˙ĩŧ Ų$ %[ļjdĶ˙”Ŧ/Ą‘”Ũ_§Ü˜8„ ä‘ČųÕUTHõ&ÂFLĩCXl8÷„=Ûđ+ŋĢ÷jnøUīŪ+‡`¤ĨDːčP%Ũw_‚E6:,烈Q\ĐŖtŠ2HëŽką>P‰š jÚČĢšl“™šš5ûų§|I°iŠ06I[ü­ŒĀ´A΍ãôģnˇ‰)ftliâĀæVyCá”Æī…iß1—Ė–qŗ/ GÔÁz”Âvčyė‘“hnē…Hͧæ—Ĩօz¤ŗÎ RŒûĮã=aäĘ%yķŪ‘Ũ‹–ČĐiSÔp”vUūAaĸEÅz,Œ|ģ|ŽŒ<į,Y÷ü‹˛úĮ×̏uƒ'¯É8ÉXDVķ×ūč} ”ÆŠRļvŖ4`•6@`_›\ã“STbˆĩõi­Ęc^6Ņ­9T"ĩšÛ%~đˆūAļzađâ¤|Oøķ;a^ķ&Vbz‚xķÛ!|ū đŊ=ŊáRGqč@ĶŲAŲcVTŨÁŪŊAŸŽö .Y3pĻ#† ûŊ=Aj”°m Č@u0ā´¸’eĘ2̌QÃ?RÃzúįa8ÃđŠZĄČ‹¸ØiøŪŸpŅBū[/>+`pcŌqÔ¸~ãŗEe~žußÉU|ķƒĨJAÆ~īB•{´ßu‚e¤¸,ú”B0Ļ~ĨōYûS4ŋ¨_émĮ¤AŊ¯´h_fŦKā˙–útŽL_„ĩ oßÕWR;7Ļŧ~Ņ<:9(ÍāŖšŨĄ¸°L_šf‰ŧ—2Ÿ>ø\§ĀP’œ‰!\M0„K‰8Ęiˇß*ī˙ÔrO§h$eMö„~ÉŽŨúÔ6Á ģ›ÔãĮaG€-ēŠō={ˇ‰ã ڟūũĀ„ĻOŦQÔŅŦŽxŧîO°lõ9fœŊ.ȝ‡-d˜+Ņšđu$B@ BI¤á ß^°ąĶ‡SíÅ÷ûnfR3øøIšŋ8íŨļĘÃį¨xīPMæp&%x¨G2QC]u•Fļ:p=îÛu3”üôî3—8ė3] ČgGŸ(kaDIËsS§PÄGáeĨ)7ũ\úĖ<_ˇ^9ü6=‡ĒsČwčoē’ΎëgÂÄ?˜'ēžI9\'`÷‹htƒĨXqTa°#xd§7ũĀĢiw‹Í™Š_a]čÖR tĄ uļ*ĻÃ3V=…ģ ĘRdûĪ8˜ÉWQ¤ûā:[Čˆßán7õėĀ 4mm¯:DpÆ­/+—ų÷?¨žˆāœ…šrXøŋĨ ûšx íHXüčߤxëVŨoFnCÆmÛĨpěŌ6ŗ `Ų[°mËëoJŦāQŌû.÷$ĀÍrXâG¸°-UՒŋaŖ ›1M9´ö`ÁŊĒĘ z=õâŽ+‡;jĢēôZ_˛Gæ^u…lzâI9ųÎ[eøŒéZWSg˙†ą lģÛĸŽŊū‡ō”øqqđ"‹H´ĨëhPņ‘KáX0ė|§‘jbbčMĪĻëŋŌÅ+ĄČOļlĒ:X‰%điU¸é3۟wW¸¤ ÔķyaČەÃׂ`ąš÷kĖ=÷Ëú;oĶ}Xž3RüÎYhæcXVĘö 8ÜĶ2¨Â/ÔÃĖčaŦ_%[ōķ{ŲÆ-}#íúÃī|Ū LT 1XĒ÷°š#!wábŲõÆKējÅzōGÄqŅmp~ÛËä_hËSO`hAÁkŋ$T\ŽŪÔøšha¯Ü˛´sŪGęSĖpa#ëän¸ØÅd÷–¸ĮŲđfÉ$ęNÉ N Ÿįž9C6]õ=™qßŨ’ +ķDËίī¸q îŋc͛`RĶŅ@ãËh´šâŖEž”~0ķ@b†ØeĐ.Œ[ąBkJK¤nĪf‰†3=kCČØž—ĻœOŽŋæ™zÛ-đ1‘ēL;0*¸÷,\$/L9] ˙ķļ\˛u‰ô„W`fÂH„MYō “Ĩzå6ÍEu\&ÃöŽčDQWT P˜“`ŠūČ&T&9ECĖcČke^NF1ØvåCĩ1­—ĻÕ´§#ąŠËFí äüŽĻ¯ö6ĒŨęSĮyŲVÉē։ā[6zŸ9ŋ@Jx„tQ=ØofĄ-,–›0Ȏ@ bpܸaŗ¤!­—æŋFmÅąąŅc _ æ™,Â]Q .é{*Ēõ„ĪĄZŨZámĀĘd[åäG=š"/ 'šs€Û3ôDŸŽÖƒiŅæÁŊkĻžN¸Øe}ë7ī’=K–jíÂĐub_PŒåäå‚o˛Ø6™‰E=ŪÕÂĨËēgŸ×wA˙l˜Å¸b%Ž=RŦ´ļ3°ÔˇĪ$Ö‘÷ũoąlž˙ûDœ#cūÔžv^|m;›ƒ€ū¯ŗNš$A°bĀļ§ Ęąäuę:°Õŧ:ôYí}ŗhŅ2uQÍũ`i??÷lÉũû_dÄEXyÆÃ[Ψܕ_¸r•z đÂĪUw $XėržĄŽĒĸÅ÷ šļCƒŸ>ÜÉė|ķŋ2éĒ+-.'ŧüaCÎęk9´hyHâ`jīQŖdėO~.… ËÉŋüšf¨t7}Xī•ŸaÃ1|C5át™Î/NúŽJíMË̤縐,ÂČÔĨ­üLská_Ŋfåb,.õâæ°éhāiLĄë°ôŒÎ˛ŽI°:Į‹†jeWzĮŪDȇƒ$qĨs<ž8ÂF6oÉS×ĀüŪY•ƒĻéÄ?úxŠÁŠÍEEw/^Ē)šqŲ?ļ~Ü9_ ×+Ŗqe0īM\ãkiߊ•’?÷qÁßļú.2ēÁÕ-ę‹H¨GŌ“kPwžwĮ™ßęšļCÎÆ'x…īŋ-[­:˛V!Äctv–”a[NlĮŒŪJČÕ h¸n>å;ĢKö‰'čģ ž°WV‹ˇīŠ$ē'üŽi&˙G7ÚäËá-´sÁ*QŨ"Ŗt'ķ vËIû°d?đŪ,F5ÁũąöÅšØ;VÛÖąžVË m˛Ø°lŊˆ†ØE6˛Q ¤KÚV01íĮ¯üŽĸdžx1˛đēŸĢ8BKbkƒs@Ų,ô­8đølXFKüQ}úĶ_YÜŦĩ[ؑ€ŧ´x8ˆÛl_@Lk˙ôKĀį6ŗ2C”OĸEĢj=KŌÖۘŖĘLŌVWäcŠ×,ņā@o’ôīZđŠFõÁÍŽ3*ī—…}ßhítXöđŸ!ķ ė ›h1!uSš'ߙ€ŧIäŒ?2:øĶcĖ:ėbëbjŌŌ “!ŽČŸßK÷ėŅ—­ŌúEķŨ*Ø5VŽÆ-˛Ļ­ƒ]Ē/.h ãŊ!Ė´KĶā×ôÖyũ§¯Á2pĻlŠzXĮÂ?6īšŖ{ĮŽT —ŲWĒwn”×ŋy…”Á-ŠÛ°g7" g9"–á>øN8âWâģ7žũ)Ã8n+ņVq^ļB ‚™–ķ}‹ËŠvƒĮ ­Ļá;ü4uËŋđY™rl"ԙrij`1-S ņˇp`PÛ)Üz"jļë•7TÜ6[Küë­ņuđYi= ōąđaŋ˙•įåũ›oU×ĘA}aÃ?T_0oŨž‘tūŨ÷ÉÎ'ū.qYƒÔdÄ*ËÔŅ x<~žēãŊ…Ņ­@ˆģŦH2P?gŨF´^kûΆ9.Ė"H~ilĸcâ™tv.­/v2×Ôˇu¤ŽõÄąüĩ ņ=z(7Ruā Ä@ą›0fNgY#ô´xä Váb6ˆ]ļøyaĐx9é™ŋÉČŗĪÂi>Y>dŦAŊZXAo˙čcYöƒ›¤žö ÄõÄ)ĸi:ĐÜáÔk1¨NË r|$ÁS>ŋj`ޚBSéŊE0p‹ušvKD.č$Aehųä{f})Ē1xšF #š–č§ $ ô\Āē3f¸|Đ%ęíø€JD÷&%Ë>•ƒ8á;įÔSŦŊ€Ø+ČoZ2…ē˙õŗ[ķUIÄé­zHŠáâxôT9'Ÿ¤hà TĶ.vXŗFVũ鯒÷Ū›8—Īę go˜°T2†@ļÔp ŗ¨(įā+Å7lŲ_´ŗŗĘ4%kļö?ļˁņ%Ļ$Îî?–ž.ĄōĶÜđx {Aåōívõ3Ŗv‘āø‹ô˛ ŅE*ÔV5‰Ņđ|™+ЧL•oüžę‰í™L.ō?7(GE‡‹qŧö’‡ū$;_~NfËä¨ķÎQN†Ļ8˛F‚Ÿ\õmIÂŌēį°8.ĻĮÃĸģæŦE’œØ_ú}gļôÅA ŠũúéiÂŅØ™Ī}eĩāiũœuđĨˇĨ˛p',’3$*§Fƒø)ąR‘+i´Š'ę‰ŌJ0lčgūÃ3Oâ)›ŋZmŠ,tö}…&ŠüæJIšx,x;(ŋļ‚Ú.—„ĻzíRŠXÛVĘđ߈wΨ>ĒįkÕĪá“ņ/Ũ“`ģB{–+äØ œą†8ôÉ}sŧ/5.=pīCĢĀ™9Tž_úŗ +2ų• 1ŸšƒQ@ĐŖØO§â÷ˇq×?ëƒÕM/Ō𠾅8 æŗGĸáqAĶøƒ×&hˆHU–Øh^ˇ\D+ ^Ŗ\@ÕPyؑŊ•Ė#€Xé7äAĸåÄYĖpéÛV-åúŨąŽčëŒģ6ōa<ԏ~Ô­x’hŧŖî=‚ËõÅgŊ†´€ uÎ؉OÎĀŽ_ļņČUđĘ>0}yĢCõãÖķ@‡ ģŗ¨cWĶwi”8œŦm6ƒŗ×cŪY5íJČļĘä3ëîtĪ'ß„ā>AB-údŠmģ?˛˛Y.`Ųü:_î%•„–ļĸu>ē%†Ÿē 5 ŒĮ-|hÁsgô/fĮ­œžā (¯Ī“D{ؘZ†Š‡ŧžôa–Č6AÂ_ģ!\]đúo…!&íf&BgķéLŨC‰ô 苊i ­Ûī‘§X?j„œrûÍ û?U؇ÉjĶ“ĪHŅ_—Øny*ąĶccD$ kōcgŨá&Ķ@ęFŽ­}Éß­Ûų…ŗ9L ģ%‡å†;#ūųāB9ĊgÉÍģíN)†ŋĸŦc&ŠÕ}ƒC6A1Ú¤K×\Ļf×pķĢōēՂwl을Ãxã$!Ļ͝Ŧd–s8ķgET[ž2 |ąŒâ¨GĢ‘ÔŅÃeø§ËúW^ŗļĀ—'՗•ÉøËæHÉäSå –+.FĸąŅڕ= ¨\#G× fŦŪá›ÖôM; đäĶ/VŨ¯KĒnD° ÖĨ'ÂĐPä´¯ˇüZqĨ‰Ž Ü” ģ°mc T=á;×Čä[n–ĖÃd9ĐQģ°Š÷ßŊJFĖÚœŦôPĪ"ŽúĸE86×mrĸŽŒĢ§ž|ꠀ7›™, đķWŖ-0¨H&ąĀ=’Žî•ĀëÂ?ŋŅį_ʔ‰Ģ{Ãô8&Œ6hxn.Ú#įãôž¤ÔTЇÕ@‡’JžĐ>=§ôë# ˙ôˆėü˟ĄÔĸ"+ņ‚íOŧiT§X"ښ‘ NôSģéāŊĒˆ`Ž´#˙ž›,,Ķ÷J–ÚƒÛõtŪ W[!åĪeGC§N‘sū“ŦúÅΤdÕZ™ũö+>Ëf S¤ŌŅOĶ™xPžÁō1•H,AšnAŨŗÕT}ÅiÆv@ĸ‘™õaDcå:-*įŨĩ´Č‡ ī•ķÕks4ālL×,Á…ÁŽ߁#šÜųE˜Õ TFD+ûåė‹Lá:¸P­ŦIž OÉē¸Ō` ãD™B‚PA‡(úGAā6–î“F,˜ņ-ÛÁX†DĮ/ˆz˜ũAŦĒ‹÷č@|͏¤į1c%}ā@‰ƒ(ę‚Q+Kąl†Ü"ũ„Ģ‹:×VU ]ˆä.Y.šožĸȜHƒÅb*w 9ᙕ$ÍÅšR›ßˆŧGI΅?ŦŅŖ°l?@xŽļ)‡ģ,5P„HBŅšn‡k ˛đ$â‚Uk¤ž¨‰J‚ž…u÷VŠ˜ŠažAÅwaā î‡Ŗ¯úžr’d åuŠ–Ã(hđ9`ŠÜ‹A܈3øj°÷§/įÁÛe>&ō`‰ô”ÉE,H4ÃwYÚäé2ũϟ†´ü× ųD; 0*†Áåęë˙ÄoLû㟰Š×[w°ŪÁ‘â]qûŊRˇ}ƒ¤Ÿ>CĻ˙âÆvËĸŸŦÂĪ6ËâŸŨ(Cæ\!Ã/8_Ō`’3†Ōũûeáw¯QĒ“ˆ|Aæ%䄈c€IõžŌsŠ’8([b°Ņ6„ĩc_ úĩĩ˝•ĻoČõū„žņ˛¸°=ŦÄ|ô-ˇËŗĻÆčOÛ°40áŗkŪ‡˛ũE6?ˇNgŸ€lŠņRûĢoü÷ u…ŦÛ$Œe°yWŧísYzÍ÷u@õžpĢLÅU°ŪˆG$҃@õĢ•ĐXJŸķMŲ÷é ˛ũņŋÂĀpˆmk ų;Zˆ—Ô‚Xņx¨1ž"Q/tēV˛k—đ´“šƒyđ€ öŊĢVM &XˆÂž6W\ŧ$¨ŽzŗFŽ„[āiâžõfŲüÖ;˛éū{ ŪöV↠n'Eę@@’áYtúī—CK šG­ŗzĘŠ-,”f ŊmâL>úYrĄŦdÄMģ0Ø!8{Ļ8~ķkuˇ‡˛å¯ 6ūĸ!'°ē’™ôĢ¤lØP%ĸąô`@DŲžŽČŨvရûĨIUî™xß8ĩwĒę ‘1'ˇĨ['0(?šãnl$PD¸"l’­^ĩ[3ÉĄO?Ôã•zŽŲˈ”†[›v×’÷ĘģŌTxHœ‰pĐÖIŽŠ{gŠÅŸ —*YÇË˙øŊxņYåôˆž¨üņžüīÉuQYw"QŸ|] žņ2ņ{WË@8(üƯ.°üpO$Vƒž{LÅT{—­w¯ēFJ×-Ķŧ˜)‡ĻY;â{rHĻ ÜĒŽ¯īߑ ×|W.Ú}ī}īZŠZģLbŌĀĄ’$VŊ/ŧDf

ė@ޤī„S¤×?Gi8 * ƕ7ūXË ˜×iS”ˆčw–ĩõsYŒ>÷÷3oŌ2Hręíŋ‘Ņ÷Ū%ûWŽ–įGTΝyåĖú†r,44ĨĮ…uŋģ_ÅDļŅƒ†M›  įĐ!ųī¤ đj ŖUü9ŖáiÁ§k2ŠĐnXåĮA<_zũdžũúŠÅKdĮ<ø´Ÿ6EVßwˇâ‰Yėƒzü -•?¨ú47DrŠyôŋüž¤pũ ŋŠL:Ā‘ ü!^PŸŲUC&XôA@ŦzŸ~Ϝ–\fY˙`ˆ ß-üÃeßk/J2fė¨ŧ2ųüŪĮdĮ_ŸB§@$ÂŦÅAŗîŲäėīĶÁcrâėDlūŒWŸ’7§O•ÄÔ,,cå_bû÷Όx%VŖ~vŗúfüĒ#Y<Ŧ¤ãQ–r TÜÂ/’4y匧–ƒIôË˙øˆ”~đ_=ŠĘZH@Ũāšx< lÂīĘĢd"â,‡’7ļÚ%YSgĘô;-ß}Ÿl}ú_hiŒ$ĸÕÅĄ :lĀáŸ#ŽŋQÆCäe[×ūį9ŲûŋIļ¸ĐĖB÷ķĄŒĪ?-Ûņ›ŒMžđ´ŧzÚŲŌ|ˆ>¸Ü’3Zf>ô Ŧ|ōiY ø‘ˆÆĻ” Ú/Aī×|¨P’N˜ §Ũ}ģ$BįČZčÕAÅDÂKú;ūúˆlÂoúã˙”ķžR^Å Î|Bá—)N Hâ Ņh7Išé%ÜĄ-y;$ܧoī&'¸Æ¤Ąã¤ uÕÅ ˙‘Œ¨đ÷æY'>[dī‡`=1-DYPRGīÛĻ*„E€Į‚›~&™aSS¤īŖ!Ō[zPˆ¨,ų¸čķܲP¯”DiŪų™ļɃžHęŲSNųįŋ”ģŪ÷ė+ŌtđōĀEŅŅ/8]Ō\~§M_&ŊO:}ä–Ŧ!ƒeÁÜeôųįĘ8ôåŽŋ=‚C5üˡ =h9&e‘Ĩ×%—Jîk/IÚ¤Ķ„ {á*; ũåĀI?žō°(ã†!sÖäc%ÛĄų‹=āÜÂÚîųUō(Ũ†›ŽRuü‹…­p•]yÖcÖŠDÅpEŒiˆMÖüįyY}Û¯$ rģ;ƒŒ$cŠ Ģ;Q0]Āvиˁ˛åwŋ•ũËWbė ?DQ ƒjč´)rü=ŋÕcÉŖúĻ"ם `ŠÉŊ 9Į[Ŋ¯mH„n‰VŌÜūãáę”Ė5P°÷đí*Æ}úëģ¤᤟\¯\‹"‰įP twUŠ@TlŅæwŪ•Ū € ā)N†¨øļmąĘ~ 6ī÷h)ž×éĶá„k¯‘Ũ‹ˎų ädĖÜ$jd׉†ˆ$@™ø ~ņSɃ8yęcĄ.Ĩ˜š+ä¸;n–|pB+@Ŧ˛FŒ×¸´ŊōoO-x‡oŊ ôË#ŸŪqV`gIŋËŽR‚é­kŌ¸Ž5§0gâ0Šūī‡ēRvĖīū ŠeŠPėBœÄĘS„úVžVŋøMˇāj IHÔ=å€ma@Ļ×t•Ú&•^}e!m@Yî}eā{Éĸ[#k@Ŧz¯¨T§FؑxZęĢsëėW_ú¯×‚(Ę6ČŽE‹´¯9ųό)n7 ĒÁËãđ=Āđ<é Ύî…*V/•ƒkÖÉPˆÃėUęĖhšoĘdŨČŊ‘pÆdŊO'*\%>ƒâ ]GÁĒq|Ø÷˜xœöCĸzĻs`iÜŽø¯‹,ˆ‚ũąm¤tŋœūĖŗŌ"œŅQ bUŽ%ågÁ}zõˇ• y ¸lM”b€Ō;3 9žĢ…Ÿ*°âīœcĮ*~Ĩ€`5TÁOķ˙ NŦŊqqˆjrĶ;ÂŽw0yˇ?Ŋ XicÂđŊĘ„Ģž­Uņ÷›¤ú' xĢW&Ī”ęÛ°ė< ƒ"\ë "ũŠÛœPžę:Yqã 2^>š’f,ä‰|$ŒĖĘjĀōgČqw˙VW)Žyõl p@Ķhų NĢlÁ<,P< ĶO‘ę×÷Ģb™‹JŪ@p‚ƒ•gũXŦJ&"MFšą‚F!Š ĸŊ˙ŧí@Wąz…ËWɘŲYúX‚s>zoā:̐õŠz…IÍOôˆą8l’NĮņ\´žßÃŌŦ€ŠĢ؈Ŋ\É&1j/ãŗzč’ČãŅĐgųčmtčŋ'ëīžĸwŽž|Ãŧ¸Ũˆ“ŨÁ>‘c.žm‰Öđ\RĮÅ&F Ų…/XT‘o2rrdû‡)ązÅčÂĄË,GMĘ$!ēŸjIØnĘĀQŸąēq3I)ˆU:ŗyw>ûwˆ‰íg‚í,tk’”îËnžß ĨhHĸÕz•S˙ņ/Yxí$šŗĻ<˙"Ԉ™gɊûī•8(2ŖúĨÚF}0æŦn”hp/% o^rš˙̟É(ˤn äŊK—K>ôUk7ęsÜ@lØMIVŅĸ~÷~ËH ŧ Üáĸ ž–•Hîú 2øôĶL¯äoÚ,ÍĨÕŠ{ 1OÄn HøxÁF@R§¤Ÿ‘Žú/+øŦ%Ąũ 0ļcCU•œą†2BKO8ĮÁōåvÍąl‹9ĪäŽí‘Lœ§ßü xĪ('r@x<|ņΝR´n=8%ØØ6 $xėĐø¤rÔ}éäWa}€¤ŽēÁB‡Ięksåĸš¯Û€m :pUаá<=…3 uŽ(áÛÚōīķĄ{/9 ,õĶ΁ 7wâž+ûį/püĻۊšÄ>oÍ ˙¨ILč+k! ZšJ.yûųf ØiĄœ 'šēå…ÚEnÍÆ•2īŠËŦ•ēKŋ-Ų@FŠIÃĻN‘:ØåŽ]'۟z^r~¤q¨‹ŗB ‘Ô•$ī^>GÅĻ#ŲĄ .°…\qr7ĀúŨ Æeōc0˙ewÜ yļĨī “ž–ŪuPˆ+)üJ vMmžÄÕîęSŅĢūķŦŦ~ā>K\G–üDB2äŌ+-zņ%Ši+Š?ļyÕŽ˛ãą'eÜė Ĩ×iĶĨbņ|p˛8ąŲė„n|vņÎ]2pú5c wR…¯Ár$Gï@úΞBí×ĘĀšÅ€ŗūō{BÛj͗˙օ–Ÿ ÃŊ÷Ģígę@bÅ&sϧąåūˇŪŗdķũŸ+EIøŽ‚‚Ŋ•ī(ˆ˜01 +Y5ĐD§9ąŊĸv]ĩee`‡Íā÷&ˆ—áęf@I˙O(æÖocûŦ<1˜†š`čåíΔiīßF\ū-Y‚•;Eo=u¤¨N]ŖâƁeËeė7.R<Ū âE|â-ņ1ēĄ÷Äã…ūá9Aš°â!XBm^āƄaō ™ Ĩˇ?Ž'09WiF~÷ I9+fXŠË/”˛š aˇbf ĮŽÃčöPD+RgvĩσaĨŨJeÃąéƒũΘ:đxûioŊ-īAéŲҐļ,Q)™X‰Ģ•ˇÎ?OúŸyžŒũî•rúmˇˆâEáļm˛gūÉ{ö)ŠŽąnq1}$ē¸X›HÔnX/7ŦԓLŌŗGʸ›"ŗ{T–=6F6<ô€db9Ŋí ĸžî´ë¯“˛ü|Їõ|, SĄÛ°DbBƐ{đáBŗÚ‚B¸xŪcÁŒą8Q´] ī+ãEõJ”´œÉƒ……ļvcaą”¯[Ž$Ū—ÁŅž‰†>uˆŖž+ļäϜXqÛ.n¸G24š?œG€Wėõíī} ŋ˙Ŋ÷yÁ{o9õX=äāsoĘI?ü NpĮVĢũ'[Ē>? ÆŗÅ;°¸„{kqŠ7]7t \ āDe7JlFÄ?ĸ Ũ8(“ōūĘ=)ËqîZ<ˇ°`k‰ˇĄRÆŨv‡ŒĀ&įDœQșŸúŠ›z|9gČ0„QMđ}Ė…Č>ØTmųĶCPžÂ ž€&…f[U Í,häIDATú­^XØG'ĨHĨâŖ%2˙Ŗ÷ hO—ū?úļ ‚Ø7Ëێ›q´t"û-‘=°ŗ‚4,qƚc50:ö`Ø0 RãūCōɏ¯“ŊΖs°š{˙+oé>ĩvjjQwÖKĒqöû§Ÿ%ųXė>冟ÉI?šN ÁõQ1Ži8¨=$t=rIlˆŽŨ8aU¨ũNõWāĐ8Ā“ÆŽķŸü& n?aīļōŸėãXčåĄ~vÂņ Ą2nIĶęŽuv­>}•ô?‹˛Š;B…ļŅoyƒâĮėČ8øŸ}ųÕrā…gāŪĻŋm?… Ē/Œ­Á…UÃōŊl +ᤑûDIĖLā*-JSzõ’-0n&äģŽēÁĸ Cē.Ī  ÃXŦ†‘C‰‚Ā´‡…äôI]WŽmą=ÄČë4Āk‚{‡ÁĐŅwPhƒXųâÚqĻūúÉ}ûŠÛ [•äžm×9{X†ŖãoģS&~įJėŊ+‘Äædr(s›`šĀ“ˆyNŋI¨NáÁ'Á.iÃ+¯Ę†ßÜ„ßJApKČŦCÅIû§=īŧ!XyŨ÷åķį_Ö<ĩŽaĮ:& )ĶΐšųH&öXæXŊ ŊZ:֒ÜjĶíką’bō§—._!ąā0˜ž{ĩÂ$L0 %‘cښō2yuÂq˜õÁЍeI‡ÕÃ2éuūl™r˙=ÖËđؚDzõ'!žmÔÉį ܰ,ļ9,xŋ@ž_6‰ĸ[=¤„܍›pžåų˛ +f#Ö%¨)ŽZŧJWV,7×ÛZLG,D~ˆģi'ŸaíËügxB7æ­lŋlWúŖL°Ú7ahĢá†đšŋņ3yyü8Ųã ČöĀĩ0X,ĸô‘ˇĪœ.ŗūĢd“UÁȍžĘT…áŦüËdŪ$ ņ™2ã™Čë“OÅ #d}uŖmÅgęĀl9¤Zp –˜O™ĸËŌ,˜âVTbŧT-X&'>ūGx+qËÜë˙OŽũūĩrú¯n–=oŊ+ĩ‘ĸbĄLÅʏ.î˛* +”x¸Ļ]y`-a†4@ÜđŠiâųן÷jđBiDîi¤˛›3lĖ âTą–TÎx| 7ŌzÜX‰Ä'.lЕJØā#â(Ku&¨›ŲīæMÃļj#ÂIŠ@ĖHÔa'¤­døō [û•uX†ÕZîųŖ/yĩā&/Č8$|aÛŌęCˆz´úŪņ‡VÕōĩŗÕÛV™ŠÎ¸‚ˇÎTËãņŽ‹5ÜōôųkoČôßŪ ĀR]7[uĨ:‚æ†ö4HúÖ¯k"éyʉPTIuá.ŨáŸG̊tĄeFŽV},†rˆI}Z™0„ŅBÕĶ(‚šQ”‚ZōØ PˆF‹VęÎŦ‰ŽúĄšg\ĸ›ŸÉ)(ŌĄr ~§ĸ!á ĶN‘IŋXĒķĄÄ„‰ŠC #‘¤ Šk֏õĒŨ´Sj°7¯6SÕ+XmJ|i‚ƒŽ†"Ά˙›ŧĢ%uԝÅåß=Č„xĮÍēŸĘ°p@1-uú‰ˆ_= Ifâ‰#˜ĘÖhˆ•ĩÛvÃļD vf\dí=ŧ8å×ú›ƒõ3w đX,ž8T I“*Ņ÷ä­}‚=÷^ls! l~ëŽę“ENPāR›}ĘfčI:ājiZ‰->M4ô…įo ôTXČ{÷M…ŨčDëŨâĖ °?‘[lĘ–Ž’íoŊŖÄË[Å ÍäĨzĢfėrųtųĐuƒ˙ˆ8ÂĩÄÄŦ[!4ãŖį|& äh:L|z4så÷p„øŠÁ†ājĘđp­Qšg‹f7ū/ŋMM5:AŦŦ-iNƒŠīôsuŖŽŅBÎpäE–Ē׈ĘÉp›Q ęQIũÁ†÷AÛcÕptī‡ķu7ūEO?+—ÍũPkņģŸÉ Bų AaąŸ y‘{)ā܈h &Á $%ŽÜNÔ˛ŨûܲRą9wˇTAtÎĢOË%O>-ÃgL“Ī_}CgiúLb Åģŗw2ĖCâpō_‘œôČRž—'Ĩа•[^{S(cģšs!ⲜxÎũ ¨öĐqVßúßwå¸o]"sžxRN˙õ¯dĪĸÅYˇû•œUÔĀ ŠŨˇUËsūy˛õíwôž'Õ¸`—Ł­Ž&o ¸ÉØ>R ¯Ô-]Œtgŋø”T‚˜Ö`÷ĸif;Ä1B‡Jđ:MĀ[×Ä÷Ą3`ŸŅ{] 1PŧkēXŽ‚Blš8į[2į͡å/_y8Cžgˆ}&­*"Šƒ&âąŧ<§žũáĮr5üÉoy÷=9ŽĒ 8lÅĄōķįŸ‘øąf4¸$Ęyhãƒ@Ž%vZ9] —,—ęÄũŪE—ɀŽõāŪ8a’ĖĀĻčˡl•ŨK–ĘŽwŪ—Â¨ņTCŪš2̧$×œĶ‰ėu  ŧO…Ë–c/˙όœ9S}'Ŋ:nfíBŨĻãÎÃŪC˜Š|xņ7älĖöWŖœĪĀŨä.\S’÷ÁŅZÖØĖ‹ußōÔ˛?֝BJ-ėäøž÷}aN2úŌoJŋcŽ‘ ¯ž&̏aĊũRۘ‡E‡×å,ŗž|\žÔ&uöLåô;Ķûįąūáß[â5&ÚDŦč1°nąCˆĢ˙ Ԙ_¤btÎė d ôžIiPžcĶģ€õØjÕ=.peHՁ~áyę7­tËZā0Vžķ)`(ÛÉģāåČ,tŨë˛ā™>ŦŽÉÔyˆwõaĻɰ¯:-íqūŗ3ÅƐ{P:ßČĩG==2 \¸Xv˙ëī!=4Ōā’~´=u=äÍŗfȨ˙TFÁ–kä93ÁMzÕûe-–Õé ÕC8̌6DĄŽqāš’°¯0{]ØūŅ Įuô´°Ģ•8Æ6^ØcÖ¨$¸ŠN–˙ž?KrfĪ‘qW]!SáãÉ{×íVôö™_€O¨ģBČĪ2xZbī^ēB邈zĒ%~Tļ˙ëqÁ4%VŪ 6ļâi<‘ž÷ eÄu?‘Ņ¸ŽœyĻx{U¸ÚmQĨĨ ˙(pûLą1z@–”/] o\đ-9ņž_ËTŦ{īžC;˛NĀĮ* ­Å='IęBû‚(o7IŪįü™2õ–›°ú\¤i}đã˜0%7)vka€ŋz•`ŊÉåĄ~õ>×&{éYûÜķ:‘đx/ßXҝ]÷ßQ8ų™ Åzq ę?<`c1Ôˇ;JƒMdēĖėú šÚFû%ĐRØVņ,8ßֈVõ×eŗú§:ŦÄP‹—%éœ&ãÆH ˆE<6%ĮÂ°Ō‰Ζŗ*•ë‹Á[EŠ”íÜĨžéË Ŧ'—Čm9ĐGxk ī@DŠô{EŽ€:§Ės/BŖá D„‰šMÍ4ļŌ2>ß.ÅķÁ-A¯DŽ(^páņ[Ą´j "ODG–ÎÕŠŒsΕtpœ)ũûéĘe6ëęÖ&DēĻP•pyRēqŗ”Ā‹Ŋ\áD@‰îÕ§ÕęRčÉĖĩ“8Ąec_O(†“ąŅ˜n¯huNL/§ûž}uŲ‰Ā? Ąjkk+čåžü_qãę’#ũ.ŌYįNÖ5\ —`•‘"Q}`_†­ŪrrUáڃrŌá*ŽāˆčÍĐ}ų—Ã˛Sō™ƒEˁŖ#f!0;áa¨Áą+lĨâiB X#a Uæ Ŋ”3ūûqȂ!#x6°­ąĶa‡I§ÄŊė­×øoEViųx`á†8Ēš€;̆h] —Đ{ū8ģjuKã=D^×Į—Q—ž9ŠĢKÃ%Ršē @\° čm*įÉŨ­ƒ?éá=œĖTGN1* Žw#QT7(° ‘@Ũ QāTŽ4lŁøÎ-8äÄüƒQÚSáNe;ͨp'ĮNŽ´{nõÜɀtūytáûÁꝩZÁ€h¯ ÖĶā¯ūoČa}Ũõ‘@t DVˇčĻH%#ˆ@€ˆŦD @ˇ@„`u›ŽŠT4"+‚D Đm !XŨĻĢ"@ ÁŠā@tDVˇéĒHE#ˆ@ B°"8@Ũ‚Õmē*RŅ"ˆŦD @ˇ@„`u›ŽŠT4ū*ŅĢžžlĘIENDŽB`‚ffuf-2.3.0/characterization_test.go000066400000000000000000000220541525032755300173740ustar00rootroot00000000000000package main // Characterization ("golden master") harness for the CLI flag + config pipeline. // // It pins the CURRENT observable behavior of ffuf's option handling so the planned // flag/config refactor can be developed against a safety net: any change to help // output, flag parsing, aliases, config-file precedence, or the resulting Config is // surfaced as a test failure. // // Regenerate goldens after an INTENTIONAL behavior change: // // go test -run Characterization -update-golden // // A diff in any golden that you did not intend is a regression. import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "os" "path/filepath" "regexp" "runtime" "sort" "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) var updateGolden = flag.Bool("update-golden", false, "regenerate .golden characterization files") // withCleanFlagState saves/restores the global flag + os.Args state around fn, and // installs a fresh ContinueOnError FlagSet so a parse error can't os.Exit the tests. func withCleanFlagState(args []string, fn func()) { oldArgs, oldCmd, oldUsage := os.Args, flag.CommandLine, flag.Usage defer func() { os.Args, flag.CommandLine, flag.Usage = oldArgs, oldCmd, oldUsage }() flag.CommandLine = flag.NewFlagSet("ffuf", flag.ContinueOnError) flag.CommandLine.SetOutput(io.Discard) os.Args = append([]string{"ffuf"}, args...) fn() } func compareGolden(t *testing.T, path, got string) { t.Helper() if *updateGolden { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(got), 0644); err != nil { t.Fatal(err) } return } want, err := os.ReadFile(path) if err != nil { t.Fatalf("missing golden %s — run: go test -run Characterization -update-golden\n(%v)", path, err) } if got != string(want) { t.Errorf("characterization drift in %s\n--- got ---\n%s\n--- want ---\n%s", path, got, string(want)) } } var versionLine = regexp.MustCompile(`(?m)^Fuzz Faster U Fool - .*$`) // TestCharacterization_HelpGolden pins the exact `ffuf -h` output (sections, order, // flags, defaults, examples). The build-dependent version line is normalized out. func TestCharacterization_HelpGolden(t *testing.T) { var out string withCleanFlagState(nil, func() { opts := ffuf.NewConfigOptions() ParseFlags(opts) // registers every flag on the fresh FlagSet out = captureStdout(Usage) }) out = versionLine.ReplaceAllString(out, "Fuzz Faster U Fool - ") compareGolden(t, "testdata/help.golden", out) } type configCase struct { name string args []string toml string // optional config-file contents; when set, loaded before flags (file < CLI) requestBody string // optional raw HTTP request; when set, written to a temp file and passed via -request } // TestCharacterization_ConfigGolden pins the Config produced by the full parse // pipeline (ReadConfig/defaults -> ParseFlags -> ConfigFromOptions -> SetupFilters) // for a table of argvs covering every flag shape, aliases, precedence, and errors. func TestCharacterization_ConfigGolden(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("config goldens are recorded on unix; ConfigFromOptions applies windows-specific wordlist path handling that diverges from them") } cwd, _ := os.Getwd() cases := []configCase{ {name: "basic", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt"}}, {name: "alias_data_binary", args: []string{"-u", "https://example.org/", "-w", "/tmp/wl.txt", "-X", "POST", "-data-binary", "name=FUZZ"}}, {name: "alias_cookie", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-cookie", "SESSION=abc"}}, {name: "matchers_filters", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-mc", "200,301", "-fc", "404", "-fs", "42", "-ml", "5"}}, {name: "multi_wordlist_pitchfork", args: []string{"-u", "https://example.org/W1/W2", "-w", "/tmp/a.txt:W1", "-w", "/tmp/b.txt:W2", "-mode", "pitchfork"}}, {name: "headers_multi", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-H", "X-A: 1", "-H", "X-B: 2"}}, {name: "delay_range", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-p", "0.1-0.8"}}, {name: "extensions", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-e", ".php,.bak"}}, {name: "encoders", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt:FUZZ", "-enc", "FUZZ:b64encode"}}, {name: "autocalibration", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-ac", "-acc", "custom1", "-acc", "custom2"}}, {name: "acs_strategies", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-acs", "advanced,greedy", "-acs", "custom"}}, {name: "compat_data_alias", args: []string{"-u", "https://example.org/", "-w", "/tmp/wl.txt", "-data", "x=FUZZ"}}, // Locks the -ach behavior: a config-file -ac must NOT auto-enable per-host calibration. {name: "config_ach_no_autoenable", toml: "[general]\nautocalibration = true\n", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt"}}, // Locks the -cc/-ck behavior: a config-file client cert/key survives (is not wiped by the flag default). {name: "config_client_cert", toml: "[http]\nclientcert = \"/tmp/cert.pem\"\nclientkey = \"/tmp/key.pem\"\n", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt"}}, // Broad coverage of otherwise-unexercised flags through to Config. {name: "many_flags", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-x", "http://127.0.0.1:8080", "-replay-proxy", "http://127.0.0.1:9090", "-sni", "example.com", "-timeout", "15", "-rate", "50", "-recursion", "-recursion-depth", "3", "-recursion-strategy", "greedy", "-of", "json", "-od", "/tmp/out", "-maxtime", "60", "-json", "-r", "-raw", "-http2", "-ic", "-D", "-sf"}}, // Exercises the raw-request parse path (parseRawRequest). {name: "raw_request", args: []string{"-w", "/tmp/wl.txt"}, requestBody: "POST /submit HTTP/1.1\nHost: example.org\nContent-Type: application/json\n\n{\"q\":\"FUZZ\"}\n"}, {name: "precedence_cli_overrides_file", toml: "[general]\nthreads = 99\n", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt", "-t", "5"}}, {name: "precedence_file_used", toml: "[general]\nthreads = 99\n", args: []string{"-u", "https://example.org/FUZZ", "-w", "/tmp/wl.txt"}}, {name: "error_missing_url", args: []string{"-w", "/tmp/wl.txt"}}, {name: "error_missing_wordlist", args: []string{"-u", "https://example.org/FUZZ"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { args := c.args reqPath := "" if c.requestBody != "" { reqPath = filepath.Join(t.TempDir(), "request.txt") if err := os.WriteFile(reqPath, []byte(c.requestBody), 0644); err != nil { t.Fatal(err) } args = append(append([]string{}, c.args...), "-request", reqPath) } var snap string withCleanFlagState(args, func() { opts := loadOpts(t, c.toml) ParseFlags(opts) ctx, cancel := context.WithCancel(context.Background()) defer cancel() conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel) snap = snapshotConfig(conf, err, cwd) if err == nil && conf != nil { _ = captureStdout(func() { _ = SetupFilters(opts, conf) }) // swallow warning prints snap += "\n--- matchers after SetupFilters ---\n" + matcherSnapshot(conf) } }) if reqPath != "" { snap = strings.ReplaceAll(snap, reqPath, "$REQFILE") // temp path is machine-specific } compareGolden(t, "testdata/config_"+c.name+".golden", snap) }) } } func loadOpts(t *testing.T, tomlBody string) *ffuf.ConfigOptions { t.Helper() if tomlBody == "" { return ffuf.NewConfigOptions() } f := filepath.Join(t.TempDir(), "ffufrc") if err := os.WriteFile(f, []byte(tomlBody), 0644); err != nil { t.Fatal(err) } opts, err := ffuf.ReadConfig(f) if err != nil { t.Fatalf("ReadConfig: %v", err) } return opts } func snapshotConfig(conf *ffuf.Config, err error, cwd string) string { var b strings.Builder if err != nil { fmt.Fprintf(&b, "error: %s\n", err.Error()) } if conf == nil { return b.String() } j, _ := json.MarshalIndent(conf, "", " ") s := string(j) if cwd != "" { s = strings.ReplaceAll(s, cwd, "$CWD") } b.WriteString(s) b.WriteString("\n") return b.String() } func matcherSnapshot(conf *ffuf.Config) string { if conf.MatcherManager == nil { return "(no matcher manager)\n" } var b strings.Builder dump := func(title string, providers map[string]ffuf.FilterProvider) { reprs := make([]string, 0, len(providers)) for name, p := range providers { reprs = append(reprs, fmt.Sprintf(" %s = %s", name, p.Repr())) } sort.Strings(reprs) fmt.Fprintf(&b, "%s:\n%s\n", title, strings.Join(reprs, "\n")) } dump("matchers", conf.MatcherManager.GetMatchers()) dump("filters", conf.MatcherManager.GetFilters()) return b.String() } // captureStdout redirects os.Stdout for the duration of fn and returns what it wrote. func captureStdout(fn func()) string { r, w, _ := os.Pipe() old := os.Stdout os.Stdout = w fn() _ = w.Close() os.Stdout = old var buf bytes.Buffer _, _ = io.Copy(&buf, r) return buf.String() } ffuf-2.3.0/ffufrc.example000066400000000000000000000035031525032755300153020ustar00rootroot00000000000000# 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.3.0/go.mod000066400000000000000000000006711525032755300135630ustar00rootroot00000000000000module github.com/ffuf/ffuf/v2 go 1.20 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.3.0/go.sum000066400000000000000000000123151525032755300136060ustar00rootroot00000000000000github.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.3.0/help.go000066400000000000000000000101301525032755300137230ustar00rootroot00000000000000package main import ( "flag" "fmt" "os" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type UsageSection struct { Name string Description string Flags []UsageFlag Hidden bool Key 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() { // Section order and metadata; membership comes from the flag registry. sections := []UsageSection{ {Name: "HTTP OPTIONS", Description: "Options controlling the HTTP request and its parts.", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionHTTP}, {Name: "GENERAL OPTIONS", Description: "", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionGeneral}, {Name: "COMPATIBILITY OPTIONS", Description: "Options to ensure compatibility with other pieces of software.", Flags: make([]UsageFlag, 0), Hidden: true, Key: ffuf.SectionCompat}, {Name: "MATCHER OPTIONS", Description: "Matchers for the response filtering.", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionMatcher}, {Name: "FILTER OPTIONS", Description: "Filters for the response filtering.", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionFilter}, {Name: "INPUT OPTIONS", Description: "Options for input data for fuzzing. Wordlists and input generators.", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionInput}, {Name: "OUTPUT OPTIONS", Description: "Options for output. Output file formats, file names and debug file locations.", Flags: make([]UsageFlag, 0), Hidden: false, Key: ffuf.SectionOutput}, } byKey := make(map[string]int, len(sections)) for i, s := range sections { byKey[s.Key] = i } // Populate the flag sections max_length := 0 flag.VisitAll(func(f *flag.Flag) { section, ok := flagRegistry.SectionOf(f.Name) if !ok { fmt.Printf("DEBUG: Flag %s was found but not defined in the flag registry.\n", f.Name) os.Exit(1) } i := byKey[section] sections[i].Flags = append(sections[i].Flags, UsageFlag{ Name: f.Name, Description: f.Usage, Default: f.DefValue, }) if len(f.Name) > max_length { max_length = len(f.Name) } }) fmt.Printf("Fuzz Faster U Fool - %s\n\n", ffuf.FormattedVersion()) // 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.3.0/main.go000066400000000000000000000144121525032755300137260ustar00rootroot00000000000000package main import ( "context" "flag" "fmt" "io" "log" "os" "time" "github.com/ffuf/ffuf/v2/pkg/assembly" "github.com/ffuf/ffuf/v2/pkg/engine" "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/runner" ) // flagRegistry describes the registered flags for the segmented help output // (see Usage in help.go). It is (re)populated on every ParseFlags call. var flagRegistry *ffuf.FlagRegistry // ParseFlags registers every CLI flag from ffuf.RegisterFlags — the single source // of truth — onto the global flag set and parses the command line into opts. // Flag defaults are the current field values, so a value loaded from a config // file is used unless the same flag is given on the command line (file < CLI). func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions { flagRegistry = ffuf.RegisterFlags(flag.CommandLine, opts) flag.Usage = Usage flag.Parse() 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 := engine.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 := engine.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 := assembly.BuildJob(conf) if job.AuditLogger != nil { defer job.AuditLogger.Close() } 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() } // SetupFilters installs the matchers and filters onto conf.MatcherManager. The // matcher/filter construction itself lives in filter.FromConfig (pure, testable); // this shim owns only the one decision that genuinely needs the global CLI flag // set - whether to install the default status matcher - plus the -ignore-body // warning. func SetupFilters(parseOpts *ffuf.ConfigOptions, conf *ffuf.Config) error { // The default status matcher is installed unless another matcher was explicitly // passed on the CLI. Only the global flag set knows which flags were passed vs // defaulted, so this decision cannot move into the library. statusSet := false matcherSet := false // responseMatcherSet tracks the response-body matchers (ms/ml/mw) explicitly // passed on the CLI. The -ignore-body warning keys off CLI-passed flags (as the // original did via flag.Visit), NOT off the config value, so a config-file / // .ffufrc value does not spuriously trigger it. responseMatcherSet := false flag.Visit(func(f *flag.Flag) { switch f.Name { case "mc": statusSet = true case "ms", "ml", "mw": matcherSet = true responseMatcherSet = true case "mr", "mt": matcherSet = true } }) mm, err := filter.FromConfig(parseOpts, statusSet || !matcherSet) conf.MatcherManager = mm warningIgnoreBody := responseMatcherSet || parseOpts.Filter.Size != "" || parseOpts.Filter.Lines != "" || parseOpts.Filter.Words != "" 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 err } 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.NewSimpleRunner(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.3.0/pkg/000077500000000000000000000000001525032755300132325ustar00rootroot00000000000000ffuf-2.3.0/pkg/assembly/000077500000000000000000000000001525032755300150515ustar00rootroot00000000000000ffuf-2.3.0/pkg/assembly/build.go000066400000000000000000000040011525032755300164720ustar00rootroot00000000000000// Package assembly wires a ffuf.Job together from a Config: the input provider, // runner(s), output provider, audit logger, and scraper. It exists as its own // package because it must import the provider packages (input/runner/output/ // scraper), which themselves import pkg/ffuf - so this composition cannot live in // pkg/ffuf without an import cycle, and it cannot stay unexported in package main // without being untestable. Both main and the integration tests call BuildJob, so // the production and test wiring can never drift. package assembly import ( "github.com/ffuf/ffuf/v2/pkg/engine" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/input" "github.com/ffuf/ffuf/v2/pkg/output" "github.com/ffuf/ffuf/v2/pkg/runner" "github.com/ffuf/ffuf/v2/pkg/scraper" ) // BuildJob constructs and wires a Job from conf. It is the single source of truth // for how a Job is assembled. Matchers/filters are NOT installed here (they need // the CLI flag state); see filter.FromConfig and main.SetupFilters. func BuildJob(conf *ffuf.Config) (*engine.Job, error) { var err error job := engine.NewJob(conf) var errs ffuf.Multierror job.Input, errs = input.NewInputProvider(conf) // Only the http (SimpleRunner) runner exists today. job.Runner = runner.NewSimpleRunner(conf, false) if len(conf.ReplayProxyURL) > 0 { job.ReplayRunner = runner.NewSimpleRunner(conf, true) } // Only the stdout output provider exists today. job.Output = output.NewStdoutput(conf) if len(conf.AuditLog) > 0 { job.AuditLogger, err = output.NewAuditLogger(conf.AuditLog) if err != nil { errs.Add(err) } else { if err = job.AuditLogger.Write(conf); err != nil { errs.Add(err) } } } newscraper, scraperErr := scraper.FromDir(ffuf.SCRAPERDIR, conf.Scrapers) if scraperErr.ErrorOrNil() != nil { errs.Add(scraperErr.ErrorOrNil()) } job.Scraper = newscraper if conf.ScraperFile != "" { if err = job.Scraper.AppendFromFile(conf.ScraperFile); err != nil { errs.Add(err) } } return job, errs.ErrorOrNil() } ffuf-2.3.0/pkg/engine/000077500000000000000000000000001525032755300144775ustar00rootroot00000000000000ffuf-2.3.0/pkg/engine/autocalibration.go000066400000000000000000000175771525032755300202270ustar00rootroot00000000000000package engine import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strconv" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func (j *Job) autoCalibrationStrings() map[string][]string { // The global rand is seeded once in Job.Start; re-seeding here on every call // reset the sequence and could hand two near-simultaneous calibrations the // same "random" strings. 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(ffuf.AUTOCALIBDIR, strategy+".json")) if err != nil { j.Output.Warning(fmt.Sprintf("Skipping strategy \"%s\" because of error: %s\n", strategy, err)) continue } tmpStrategy := ffuf.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 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 { if _, ok := merged[key]; !ok { // Key not found, add it merged[key] = value continue } for _, entry := range value { if !ffuf.StrInSlice(entry, merged[key]) { merged[key] = append(merged[key], entry) } } } return merged } func (j *Job) calibrationRequest(inputs map[string][]byte) (ffuf.Response, error) { basereq := ffuf.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 ffuf.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 ffuf.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([]ffuf.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() // Copy the caller's inputs before overwriting the calibration keyword below. // SimpleRunner.Prepare sets req.Input to the map it is given, so the map the // caller passes here is the same one used for result attribution; mutating it // in place makes a later matched result report the calibration string instead // of the real payload. CalibrateForHost copies for the same reason. cinput := make(map[string][]byte, len(input)) for k, v := range input { cinput[k] = v } for _, v := range cInputs { responses := make([]ffuf.Response, 0) for _, cs := range v { cinput[j.Config.AutoCalibrationKeyword] = []byte(cs) resp, err := j.calibrationRequest(cinput) 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 []ffuf.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(ffuf.HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(ffuf.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(ffuf.HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(ffuf.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(ffuf.HostURLFromRequest(*responses[0].Request)) { match, _ := f.Filter(&responses[0]) if match { // Already filtered return nil } } _ = j.Config.MatcherManager.AddPerDomainFilter(ffuf.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.3.0/pkg/engine/autocalibration_mutation_test.go000066400000000000000000000034731525032755300231740ustar00rootroot00000000000000package engine import ( "errors" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // stubRunner is a RunnerProvider whose calls fail. It is enough to drive // Calibrate's loop without real HTTP: the mutation under test happens in // Calibrate before the request is ever prepared. type stubRunner struct{} func (stubRunner) Prepare(map[string][]byte, *ffuf.Request) (ffuf.Request, error) { return ffuf.Request{}, errors.New("stub") } func (stubRunner) Execute(*ffuf.Request) (ffuf.Response, error) { return ffuf.Response{}, errors.New("stub") } func (stubRunner) Dump(*ffuf.Request) ([]byte, error) { return nil, nil } // TestCalibrate_DoesNotMutateInput locks the fix for an input-map aliasing bug. // SimpleRunner.Prepare sets req.Input = input (the same map), so resp.Request.Input // aliases the caller's input map. Calibrate overwrote input[keyword] with each // calibration string in place, so a result matched and reported after // autocalibration showed the calibration string (e.g. ".htaccessXXXX") instead of // the real payload. Calibrate must copy the map before overwriting, exactly as // CalibrateForHost already does. func TestCalibrate_DoesNotMutateInput(t *testing.T) { job := &Job{ Config: &ffuf.Config{ Url: "http://localhost/FUZZ", Method: "GET", AutoCalibration: true, AutoCalibrationKeyword: "FUZZ", AutoCalibrationStrings: []string{"probe-a", "probe-b"}, MatcherManager: &fakeMatcherManager{}, }, Output: NewNullOutput(), Runner: stubRunner{}, } input := map[string][]byte{"FUZZ": []byte("realpayload")} if err := job.Calibrate(input); err != nil { t.Fatalf("Calibrate: %v", err) } if got := string(input["FUZZ"]); got != "realpayload" { t.Errorf("Calibrate mutated the caller's input map: FUZZ = %q, want \"realpayload\"", got) } } ffuf-2.3.0/pkg/engine/autocalibration_test.go000066400000000000000000000075131525032755300212530ustar00rootroot00000000000000package engine import ( "encoding/json" "os" "path/filepath" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // NullOutput is a dummy output provider that does nothing type NullOutput struct { Results []ffuf.Result } func NewNullOutput() *NullOutput { return &NullOutput{} } func (o *NullOutput) Banner() {} func (o *NullOutput) Finalize() error { return nil } func (o *NullOutput) Progress(status ffuf.Progress) {} func (o *NullOutput) Info(infostring string) {} func (o *NullOutput) Error(errstring string) {} func (o *NullOutput) Raw(output string) {} func (o *NullOutput) Warning(warnstring string) {} func (o *NullOutput) Result(resp ffuf.Response) {} func (o *NullOutput) PrintResult(res ffuf.Result) {} func (o *NullOutput) SaveFile(filename, format string) error { return nil } func (o *NullOutput) GetCurrentResults() []ffuf.Result { return o.Results } func (o *NullOutput) SetCurrentResults(results []ffuf.Result) { o.Results = results } func (o *NullOutput) FilterCurrentResults(keep func(ffuf.Result) bool) { filtered := make([]ffuf.Result, 0, len(o.Results)) for _, r := range o.Results { if keep(r) { filtered = append(filtered, r) } } o.Results = filtered } func (o *NullOutput) SetPaused(bool) {} func (o *NullOutput) PendingResults() int { return 0 } func (o *NullOutput) Reset() {} func (o *NullOutput) Cycle() {} func TestAutoCalibrationStrings(t *testing.T) { // Create a temporary directory for the test tmpDir, err := os.MkdirTemp("", "ffuf-test") ffuf.AUTOCALIBDIR = tmpDir if err != nil { t.Fatalf("Failed to create temporary directory: %v", err) } defer os.RemoveAll(tmpDir) // Create a test strategy file strategy := ffuf.AutocalibrationStrategy{ "test": {"foo", "bar"}, } strategyJSON, err := json.Marshal(strategy) if err != nil { t.Fatalf("Failed to marshal strategy to JSON: %v", err) } strategyFile := filepath.Join(tmpDir, "test.json") err = os.WriteFile(strategyFile, strategyJSON, 0644) if err != nil { t.Fatalf("Failed to write strategy file: %v", err) } // Create a test job with the strategy job := &Job{ Config: &ffuf.Config{ AutoCalibrationStrategies: []string{"test"}, }, Output: NewNullOutput(), } cInputs := job.autoCalibrationStrings() // Verify that the custom strategy was added if len(cInputs["custom"]) != 0 { t.Errorf("Expected custom strategy to be empty, but got %v", cInputs["custom"]) } // Verify that the test strategy was added expected := []string{"foo", "bar"} if len(cInputs["test"]) != len(expected) { t.Errorf("Expected test strategy to have %d inputs, but got %d", len(expected), len(cInputs["test"])) } for i, input := range cInputs["test"] { if input != expected[i] { t.Errorf("Expected test strategy input %d to be %q, but got %q", i, expected[i], input) } } // Verify that a missing strategy is skipped job = &Job{ Config: &ffuf.Config{ AutoCalibrationStrategies: []string{"missing"}, }, Output: NewNullOutput(), } cInputs = job.autoCalibrationStrings() if len(cInputs) != 0 { t.Errorf("Expected missing strategy to be skipped, but got %v", cInputs) } // Verify that a malformed strategy is skipped malformedStrategy := []byte(`{"test": "foo"}`) malformedFile := filepath.Join(tmpDir, "malformed.json") err = os.WriteFile(malformedFile, malformedStrategy, 0644) if err != nil { t.Fatalf("Failed to write malformed strategy file: %v", err) } job = &Job{ Config: &ffuf.Config{ AutoCalibrationStrategies: []string{"malformed"}, }, Output: NewNullOutput(), } cInputs = job.autoCalibrationStrings() if len(cInputs) != 0 { t.Errorf("Expected malformed strategy to be skipped, but got %v", cInputs) } } ffuf-2.3.0/pkg/engine/history.go000066400000000000000000000103411525032755300165260ustar00rootroot00000000000000package engine import ( "crypto/sha256" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type ConfigOptionsHistory struct { ffuf.ConfigOptions Time time.Time `json:"time"` } func WriteHistoryEntry(conf *ffuf.Config) (string, error) { if conf.Options == nil { return "", errors.New("cannot write history entry: config has no source options") } options := ConfigOptionsHistory{ ConfigOptions: historyOptions(conf), Time: time.Now(), } jsonoptions, err := json.Marshal(options) if err != nil { return "", err } hashstr := calculateHistoryHash(jsonoptions) err = ffuf.CreateConfigDir(filepath.Join(ffuf.HISTORYDIR, hashstr)) if err != nil { return "", err } err = os.WriteFile(filepath.Join(ffuf.HISTORYDIR, hashstr, "options"), jsonoptions, 0640) return hashstr, err } // historyOptions projects the Config's retained source options into the form // persisted for FFUFHASH history. Most fields ride the retained snapshot // unchanged, but fields the engine mutates AFTER parsing must be refreshed from // the live Config — otherwise a `-search` reconstruction shows stale values: // - HTTP.URL: recursion rewrites conf.Url per queued job (job.go prepareQueueJob), // so the frozen snapshot would report the base URL, not the recursed path. // - Filter/Matcher.*: autocalibration installs filters at runtime, absent from // the raw input. Rebuilt from MatcherManager exactly as the old ToOptions did. func historyOptions(conf *ffuf.Config) ffuf.ConfigOptions { o := *conf.Options o.HTTP.URL = conf.Url if conf.MatcherManager != nil { o.Filter.Mode = conf.FilterMode o.Filter.Lines, o.Filter.Regexp, o.Filter.Size = "", "", "" o.Filter.Status, o.Filter.Time, o.Filter.Words = "", "", "" for name, f := range conf.MatcherManager.GetFilters() { switch name { case "line": o.Filter.Lines = f.Repr() case "regexp": o.Filter.Regexp = f.Repr() case "size": o.Filter.Size = f.Repr() case "status": o.Filter.Status = f.Repr() case "time": o.Filter.Time = f.Repr() case "words": o.Filter.Words = f.Repr() } } o.Matcher.Mode = conf.MatcherMode o.Matcher.Lines, o.Matcher.Regexp, o.Matcher.Size = "", "", "" o.Matcher.Status, o.Matcher.Time, o.Matcher.Words = "", "", "" for name, f := range conf.MatcherManager.GetMatchers() { switch name { case "line": o.Matcher.Lines = f.Repr() case "regexp": o.Matcher.Regexp = f.Repr() case "size": o.Matcher.Size = f.Repr() case "status": o.Matcher.Status = f.Repr() case "time": o.Matcher.Time = f.Repr() case "words": o.Matcher.Words = f.Repr() } } } return o } 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(ffuf.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(ffuf.HISTORYDIR, dirname)) if err != nil { continue } coptions = append(coptions, copts) } return coptions, int(position), err } func HistoryReplayable(conf *ffuf.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.3.0/pkg/engine/history_test.go000066400000000000000000000127461525032755300176000ustar00rootroot00000000000000package engine import ( "context" "encoding/json" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestConfigOptions_SerializationRoundTrip locks the serialize/deserialize path // that FFUFHASH history (and similar features) rely on. A Config built from // options retains those options (conf.Options); serializing them to JSON and // reading them back must rebuild an equivalent request. This is the automatic // replacement for the former hand-maintained ToOptions reverse mapper — adding a // ConfigOptions field is picked up here for free, with no marshaller to update. func TestConfigOptions_SerializationRoundTrip(t *testing.T) { opts := ffuf.NewConfigOptions() opts.HTTP.URL = "https://example.org/FUZZ" opts.HTTP.Method = "POST" opts.HTTP.Data = "name=FUZZ" opts.HTTP.Headers = []string{"X-Test: FUZZ"} opts.Input.Wordlists = []string{"/tmp/wl.txt"} opts.General.Threads = 7 opts.General.Delay = "0.1-0.8" conf, err := ffuf.ConfigFromOptions(opts, context.Background(), func() {}) if err != nil { t.Fatalf("ConfigFromOptions: %v", err) } if conf.Options == nil { t.Fatal("conf.Options was not retained by ConfigFromOptions") } // Serialize exactly as WriteHistoryEntry does, then read back. blob, err := json.Marshal(conf.Options) if err != nil { t.Fatalf("marshal source options: %v", err) } var restored ffuf.ConfigOptions if err := json.Unmarshal(blob, &restored); err != nil { t.Fatalf("unmarshal options: %v", err) } // Rebuilding from the restored options must reproduce the request. conf2, err := ffuf.ConfigFromOptions(&restored, context.Background(), func() {}) if err != nil { t.Fatalf("ConfigFromOptions (rebuild): %v", err) } if conf2.Url != conf.Url { t.Errorf("url changed across round-trip: %q -> %q", conf.Url, conf2.Url) } if conf2.Method != conf.Method { t.Errorf("method changed across round-trip: %q -> %q", conf.Method, conf2.Method) } if conf2.Data != conf.Data { t.Errorf("data changed across round-trip: %q -> %q", conf.Data, conf2.Data) } if conf2.Threads != conf.Threads { t.Errorf("threads changed across round-trip: %d -> %d", conf.Threads, conf2.Threads) } if conf2.Delay.Min != conf.Delay.Min || conf2.Delay.Max != conf.Delay.Max { t.Errorf("delay changed across round-trip: %+v -> %+v", conf.Delay, conf2.Delay) } if conf2.Headers["X-Test"] != conf.Headers["X-Test"] { t.Errorf("header changed across round-trip: %q -> %q", conf.Headers["X-Test"], conf2.Headers["X-Test"]) } } // --- test doubles for the runtime-derived serialization state --- type fakeFilter struct{ repr string } func (f fakeFilter) Filter(*ffuf.Response) (bool, error) { return false, nil } func (f fakeFilter) Repr() string { return f.repr } func (f fakeFilter) ReprVerbose() string { return f.repr } type fakeMatcherManager struct { filters map[string]ffuf.FilterProvider matchers map[string]ffuf.FilterProvider } func (m *fakeMatcherManager) SetCalibrated(bool) {} func (m *fakeMatcherManager) SetCalibratedForHost(string, bool) {} func (m *fakeMatcherManager) AddFilter(string, string, bool) error { return nil } func (m *fakeMatcherManager) AddPerDomainFilter(string, string, string) error { return nil } func (m *fakeMatcherManager) RemoveFilter(string) {} func (m *fakeMatcherManager) AddMatcher(string, string) error { return nil } func (m *fakeMatcherManager) GetFilters() map[string]ffuf.FilterProvider { return m.filters } func (m *fakeMatcherManager) GetMatchers() map[string]ffuf.FilterProvider { return m.matchers } func (m *fakeMatcherManager) FiltersForDomain(string) map[string]ffuf.FilterProvider { return nil } func (m *fakeMatcherManager) CalibratedForDomain(string) bool { return false } func (m *fakeMatcherManager) Calibrated() bool { return false } func (m *fakeMatcherManager) Matches(*ffuf.Response, bool, string, string) bool { return false } // TestHistoryOptions_ReflectsRecursedURL locks the recursion fix: WriteHistoryEntry // serializes the LIVE Config.Url (rewritten per queued job by prepareQueueJob), not // the base URL frozen in the retained parse-time options. Without the fix, -search on // a recursive hit reconstructs the wrong (base) path. func TestHistoryOptions_ReflectsRecursedURL(t *testing.T) { opts := ffuf.NewConfigOptions() opts.HTTP.URL = "https://example.org/FUZZ" // parse-time base conf := &ffuf.Config{Options: opts, Url: "https://example.org/admin/FUZZ"} got := historyOptions(conf) if got.HTTP.URL != "https://example.org/admin/FUZZ" { t.Errorf("history URL = %q, want the recursed live URL %q", got.HTTP.URL, "https://example.org/admin/FUZZ") } } // TestHistoryOptions_ReflectsRuntimeFilters locks the autocalibration fix: filters // installed at runtime (e.g. by -ac) appear in the serialized history, rebuilt from // MatcherManager. The raw parse-time options never held them. func TestHistoryOptions_ReflectsRuntimeFilters(t *testing.T) { opts := ffuf.NewConfigOptions() opts.HTTP.URL = "https://example.org/FUZZ" conf := &ffuf.Config{ Options: opts, Url: "https://example.org/FUZZ", MatcherManager: &fakeMatcherManager{ filters: map[string]ffuf.FilterProvider{"size": fakeFilter{"4242"}}, }, } got := historyOptions(conf) if got.Filter.Size != "4242" { t.Errorf("history Filter.Size = %q, want the runtime-installed %q", got.Filter.Size, "4242") } } ffuf-2.3.0/pkg/engine/job.go000066400000000000000000000461461525032755300156130ustar00rootroot00000000000000package engine import ( "fmt" "log" "math/rand" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // Job ties together Config, Runner, Input and Output type Job struct { // 64-bit atomic counters. These MUST stay first in the struct so they are // 8-byte aligned on 32-bit architectures, which sync/atomic requires. Access // only through the helper methods below, never directly. counter int64 errorCounter int64 spuriousErrorCounter int64 count403 int64 count429 int64 // 32-bit atomic flags (0 = false, 1 = true). Access only through the helpers. running int32 runningJob int32 skipQueue int32 AuditLogger ffuf.AuditLogger Config *ffuf.Config Input ffuf.InputProvider Runner ffuf.RunnerProvider ReplayRunner ffuf.RunnerProvider Scraper ffuf.Scraper Output ffuf.OutputProvider Jobhash string Total int Rate *RateThrottle queue *jobQueue recursion *recursionManager startTime time.Time startTimeJob time.Time errorMsg string paused bool // guarded by pauseStateMutex timeMutex sync.Mutex // guards startTime and startTimeJob errMutex sync.Mutex // guards errorMsg inputMutex sync.Mutex // serializes main-loop input iteration vs interactive restart Reset calibMutex sync.Mutex // serializes autocalibration pauseStateMutex sync.Mutex // makes the pause-flag flip and the pauseMutex Lock/Unlock one atomic step pauseMutex sync.RWMutex // pause gate: workers RLock as a speed bump, Pause takes the write Lock } type QueueJob struct { Url string depth int req ffuf.Request } // jobContext carries the per-queue-job values a worker needs, passed BY VALUE so // no worker reads mutable Job/Config state during execution. basereq is the // immutable base request for the job; depth is its recursion depth. This is what // makes the recursion-depth and base-request reads correct by construction rather // than by drain timing. type jobContext struct { basereq ffuf.Request depth int } func NewJob(conf *ffuf.Config) *Job { var j Job j.Config = conf j.queue = newJobQueue() j.Rate = NewRateThrottle(conf) // Let the runner meter preflight/postflight requests against the same rate // limiter as the main dispatch loop, so -rate/-p bound total outgoing volume // rather than only the fuzzing requests. conf.RateLimitFunc = func() { <-j.Rate.RateLimiter.C } return &j } // The per-run counters and run-state flags are mutated by the execution loop and // the worker goroutines while the progress goroutine and the interrupt handler // read them concurrently. Counters use sync/atomic (int64), flags use atomic // int32, so there is no mutex to forget and no lock-ordering to get wrong. The // fields are private: the only way to touch them is through these helpers. func (j *Job) incCounter() { atomic.AddInt64(&j.counter, 1) } func (j *Job) setCounter(n int) { atomic.StoreInt64(&j.counter, int64(n)) } func (j *Job) getCounter() int { return int(atomic.LoadInt64(&j.counter)) } func (j *Job) getErrorCounter() int { return int(atomic.LoadInt64(&j.errorCounter)) } func (j *Job) getSpuriousErrorCounter() int { return int(atomic.LoadInt64(&j.spuriousErrorCounter)) } func (j *Job) getCount403() int { return int(atomic.LoadInt64(&j.count403)) } func (j *Job) getCount429() int { return int(atomic.LoadInt64(&j.count429)) } func boolToInt32(b bool) int32 { if b { return 1 } return 0 } func (j *Job) setRunning(v bool) { atomic.StoreInt32(&j.running, boolToInt32(v)) } func (j *Job) isRunning() bool { return atomic.LoadInt32(&j.running) == 1 } func (j *Job) setRunningJob(v bool) { atomic.StoreInt32(&j.runningJob, boolToInt32(v)) } func (j *Job) isRunningJob() bool { return atomic.LoadInt32(&j.runningJob) == 1 } func (j *Job) setSkipQueue(v bool) { atomic.StoreInt32(&j.skipQueue, boolToInt32(v)) } func (j *Job) isSkipQueue() bool { return atomic.LoadInt32(&j.skipQueue) == 1 } func (j *Job) setError(s string) { j.errMutex.Lock(); j.errorMsg = s; j.errMutex.Unlock() } func (j *Job) getError() string { j.errMutex.Lock(); defer j.errMutex.Unlock(); return j.errorMsg } func (j *Job) setStartTime(t time.Time) { j.timeMutex.Lock(); j.startTime = t; j.timeMutex.Unlock() } func (j *Job) getStartTime() time.Time { j.timeMutex.Lock() defer j.timeMutex.Unlock() return j.startTime } func (j *Job) setStartTimeJob(t time.Time) { j.timeMutex.Lock() j.startTimeJob = t j.timeMutex.Unlock() } func (j *Job) getStartTimeJob() time.Time { j.timeMutex.Lock() defer j.timeMutex.Unlock() return j.startTimeJob } // incError increments the error counter func (j *Job) incError() { atomic.AddInt64(&j.errorCounter, 1) atomic.AddInt64(&j.spuriousErrorCounter, 1) } // inc403 increments the 403 response counter func (j *Job) inc403() { atomic.AddInt64(&j.count403, 1) } // inc429 increments the 429 response counter func (j *Job) inc429() { atomic.AddInt64(&j.count429, 1) } // resetSpuriousErrors resets the spurious error counter func (j *Job) resetSpuriousErrors() { atomic.StoreInt64(&j.spuriousErrorCounter, 0) } // DeleteQueueItem deletes a recursion job from the queue by its index in the slice func (j *Job) DeleteQueueItem(index int) { j.queue.removeAt(index) } // QueuedJobs returns the slice of queued recursive jobs func (j *Job) QueuedJobs() []QueueJob { return j.queue.remaining() } // Start the execution of the Job func (j *Job) Start() { if j.getStartTime().IsZero() { j.setStartTime(time.Now()) } // Output is wired by the assembly before Start, so the recursion manager can // capture it here. j.recursion = newRecursionManager(j.Config, j.queue, j.Output) basereq := ffuf.BaseRequest(j.Config) if j.Config.InputMode == "sniper" { // process multiple payload locations and create a queue job for each location reqs := ffuf.SniperRequests(&basereq, j.Config.InputProviders[0].Template) for _, r := range reqs { j.queue.push(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.queue.push(QueueJob{Url: j.Config.Url, depth: 0, req: ffuf.BaseRequest(j.Config)}) j.Total = j.Input.Total() } defer j.Stop() j.setRunning(true) j.setRunningJob(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() { ctx := j.prepareQueueJob() j.Reset(true) j.setRunningJob(true) j.startExecution(ctx) } 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) { // inputMutex serializes this against the main loop's Input iteration, so an // interactive "restart" that calls Reset while workers are live cannot race // the input provider's internal position. j.inputMutex.Lock() j.Input.Reset() j.inputMutex.Unlock() j.setCounter(0) j.setSkipQueue(false) j.setStartTimeJob(time.Now()) if cycle { j.Output.Cycle() } else { j.Output.Reset() } } func (j *Job) jobsInQueue() bool { return j.queue.hasNext() } func (j *Job) prepareQueueJob() jobContext { job, _ := j.queue.advance() // Config.Url is set here on the MAIN goroutine only, so WriteHistoryEntry below // (FFUFHASH) and the queued-job banner serialize the current target. Workers // never read it; they use the immutable jobContext returned from here. j.Config.Url = job.Url //Find all keywords present in new queued job kws := j.Input.Keywords() found_kws := make([]string, 0) for _, k := range kws { if ffuf.RequestContainsKeyword(job.req, k) { found_kws = append(found_kws, k) } } //And activate / disable inputproviders as needed j.Input.ActivateKeywords(found_kws) j.Jobhash, _ = WriteHistoryEntry(j.Config) return jobContext{basereq: job.req, depth: job.depth} } // SkipQueue allows to skip the current job and advance to the next queued recursion job func (j *Job) SkipQueue() { j.setSkipQueue(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. The pause gate is a sync.RWMutex: while Pause // holds the write lock, every worker blocks at its pauseCheckpoint (an RLock). // // pauseStateMutex makes the paused-flag flip and the pauseMutex Lock/Unlock a // single atomic step, so there is exactly one Lock per Unlock and no goroutine // ever unlocks an unlocked mutex (a fatal panic), however Pause, Resume, and the // SIGINT handler interleave. // // Pausing is refused once the job is stopping (isRunning is false). Together with // Stop() calling Resume(), that guarantees the gate always ends OPEN after a Stop // regardless of ordering, so a Pause racing a Stop can never strand a worker at // the checkpoint with no Resume left to wake it. func (j *Job) Pause() { j.pauseStateMutex.Lock() defer j.pauseStateMutex.Unlock() if !j.isRunning() { return } if !j.paused { j.paused = true j.pauseMutex.Lock() j.Output.Info("------ PAUSING ------") } } // Resume resumes the job process func (j *Job) Resume() { j.pauseStateMutex.Lock() defer j.pauseStateMutex.Unlock() if j.paused { j.paused = false j.pauseMutex.Unlock() j.Output.Info("------ RESUMING -----") } } // pauseCheckpoint blocks while the job is paused and returns immediately // otherwise. Acquiring and releasing the read lock is a cheap speed bump when no // pause is in effect. func (j *Job) pauseCheckpoint() { j.pauseMutex.RLock() //nolint:staticcheck // intentional: RLock/RUnlock is a pause speed bump, not a critical section j.pauseMutex.RUnlock() } func (j *Job) startExecution(ctx jobContext) { 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.queue.position() > 1 { if j.Config.InputMode == "sniper" { j.Output.Info(fmt.Sprintf("Starting queued sniper job (%d of %d) on target: %s", j.queue.position(), j.queue.total(), 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 { // Advancing the input cursor is done under inputMutex so an interactive // restart (which calls Input.Reset) cannot race it. j.inputMutex.Lock() hasNext := j.Input.Next() j.inputMutex.Unlock() if !hasNext || j.isSkipQueue() { break } // Check if we should stop the process j.CheckStop() if !j.isRunning() { defer j.Output.Warning(j.getError()) break } j.pauseCheckpoint() // Handle the rate & thread limiting threadlimiter <- true // Ratelimiter handles the rate ticker <-j.Rate.RateLimiter.C j.inputMutex.Lock() nextInput := j.Input.Value() nextPosition := j.Input.Position() j.inputMutex.Unlock() // Add FFUFHASH and its value nextInput["FFUFHASH"] = j.ffufHash(nextPosition) wg.Add(1) j.incCounter() go func() { defer func() { <-threadlimiter }() defer wg.Done() threadStart := time.Now() j.runTask(ctx, nextInput, nextPosition, false) j.sleepIfNeeded() threadEnd := time.Now() j.Rate.Tick(threadStart, threadEnd) }() if !j.isRunningJob() { defer j.Output.Warning(j.getError()) // break, not return: fall through to wg.Wait() so the in-flight // workers finish before Start() advances to the next queue job, which // rewrites Config.Url / currentDepth / keyword-active state that those // workers still read (the -maxtime-job drain race). break } } 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.setError("Caught keyboard interrupt (Ctrl-C)\n") // Stop reopens the pause gate itself, so a worker blocked at a // checkpoint wakes and observes the stop. j.Stop() } }() } func (j *Job) runBackgroundTasks(wg *sync.WaitGroup) { defer wg.Done() totalProgress := j.Input.Total() for j.getCounter() <= totalProgress && !j.isSkipQueue() { j.pauseCheckpoint() if !j.isRunning() { break } j.updateProgress() if j.getCounter() == totalProgress { return } if !j.isRunningJob() { return } time.Sleep(time.Millisecond * time.Duration(j.Config.ProgressFrequency)) } } func (j *Job) updateProgress() { prog := ffuf.Progress{ StartedAt: j.getStartTimeJob(), ReqCount: j.getCounter(), ReqTotal: j.Input.Total(), ReqSec: j.Rate.CurrentRate(), QueuePos: j.queue.position(), QueueTotal: j.queue.total(), ErrorCount: j.getErrorCounter(), } j.Output.Progress(prog) } // isMatch delegates the match/filter decision to the MatcherManager seam, adapting // the Config fields it needs. The logic itself now lives in filter.MatcherManager. func (j *Job) isMatch(resp ffuf.Response) bool { return j.Config.MatcherManager.Matches(&resp, j.Config.AutoCalibrationPerHost, j.Config.MatcherMode, j.Config.FilterMode) } 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(ctx jobContext, input map[string][]byte, position int, retried bool) { basereq := ctx.basereq req, err := j.Runner.Prepare(input, &basereq) req.Timestamp = time.Now() 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 { req.Error = err.Error() } // Audit the request after sending to the runner so we get any changes if j.AuditLogger != nil { e := j.AuditLogger.Write(&req) if e != nil { j.Output.Error(fmt.Sprintf("Encountered error while writing request audit log: %s\n", e)) } } if err != nil { if !retried { // Retry once. The timeout messaging below runs only on the final // failure, so a request that recovers on retry does not also print a // spurious timeout notice. j.runTask(ctx, input, position, true) return } j.incError() log.Printf("%s", err) 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 } // audit the response after the error handling if j.AuditLogger != nil { err = j.AuditLogger.Write(&resp) if err != nil { j.Output.Error(fmt.Sprintf("Encountered error while writing response audit log: %s\n", err)) } } if j.getSpuriousErrorCounter() > 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.pauseCheckpoint() // Handle autocalibration, must be done after the actual request to ensure sane value in req.Host _ = j.CalibrateIfNeeded(ffuf.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.recursion.handleGreedy(ctx, 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.recursion.handleDefault(ctx, resp) } } func (j *Job) handleScraperResult(resp *ffuf.Response, sres ffuf.ScraperResult) { for _, a := range sres.Action { switch a { case "output": resp.ScraperData[sres.Name] = sres.Results } } } // CheckStop stops the job if stopping conditions are met func (j *Job) CheckStop() { counter := j.getCounter() if counter > 50 { // We have enough samples if j.Config.StopOn403 || j.Config.StopOnAll { if float64(j.getCount403())/float64(counter) > 0.95 { // Over 95% of requests are 403 j.setError("Getting an unusual amount of 403 responses, exiting.") j.Stop() } } if j.Config.StopOnErrors || j.Config.StopOnAll { if j.getSpuriousErrorCounter() > j.Config.Threads*2 { // Most of the requests are erroring j.setError("Receiving spurious errors, exiting.") j.Stop() } } if j.Config.StopOnAll && (float64(j.getCount429())/float64(counter) > 0.2) { // Over 20% of responses are 429 j.setError("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.getStartTime()) runningSecs := int(dur / time.Second) if runningSecs >= j.Config.MaxTime { j.setError("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.getStartTimeJob()) runningSecs := int(dur / time.Second) if runningSecs >= j.Config.MaxTimeJob { j.setError("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.setRunning(false) j.Config.Cancel() // Reopen the pause gate so no worker is left blocked at a checkpoint after a // stop. Resume is a no-op when the job is not paused, and Pause refuses to // close the gate once isRunning is false, so the gate always ends open. j.Resume() } // Stop current, resume to next func (j *Job) Next() { j.setRunningJob(false) } ffuf-2.3.0/pkg/engine/jobqueue.go000066400000000000000000000043111525032755300166440ustar00rootroot00000000000000package engine import "sync" // jobQueue owns the recursion/sniper job list and the active position, together // with the mutex that guards them. Worker goroutines append to the queue (from // the recursion handlers) while the main loop advances through it and the // progress goroutine and the interactive handler read it, so the slice and the // position must never be touched outside these methods. The fields are private: // there is no way to append without the lock. type jobQueue struct { mu sync.Mutex jobs []QueueJob pos int } func newJobQueue() *jobQueue { return &jobQueue{jobs: make([]QueueJob, 0)} } // push appends a job to the queue. func (q *jobQueue) push(j QueueJob) { q.mu.Lock() q.jobs = append(q.jobs, j) q.mu.Unlock() } // hasNext reports whether an unprocessed job remains. func (q *jobQueue) hasNext() bool { q.mu.Lock() defer q.mu.Unlock() return q.pos < len(q.jobs) } // advance returns the job at the active position and moves the cursor past it, // returning the new position (1-based marker matching the previous queuepos). func (q *jobQueue) advance() (QueueJob, int) { q.mu.Lock() defer q.mu.Unlock() job := q.jobs[q.pos] q.pos++ return job, q.pos } // position returns the active position marker. func (q *jobQueue) position() int { q.mu.Lock() defer q.mu.Unlock() return q.pos } // total returns the number of jobs currently in the queue. func (q *jobQueue) total() int { q.mu.Lock() defer q.mu.Unlock() return len(q.jobs) } // remaining returns a copy of the jobs from the active position onward. func (q *jobQueue) remaining() []QueueJob { q.mu.Lock() defer q.mu.Unlock() start := q.pos - 1 if start < 0 || start > len(q.jobs) { return nil } out := make([]QueueJob, len(q.jobs[start:])) copy(out, q.jobs[start:]) return out } // removeAt deletes the queued job at the given offset from the active position // (the 1-based offset the interactive queuedel command uses). It returns false // when the offset is out of range instead of panicking. func (q *jobQueue) removeAt(offset int) bool { q.mu.Lock() defer q.mu.Unlock() idx := q.pos + offset - 1 if idx < 0 || idx >= len(q.jobs) { return false } q.jobs = append(q.jobs[:idx], q.jobs[idx+1:]...) return true } ffuf-2.3.0/pkg/engine/pausegate_test.go000066400000000000000000000072401525032755300200460ustar00rootroot00000000000000package engine import ( "context" "sync" "testing" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestPauseGate_ConcurrentIdempotent regresses C8: the pause gate used a // sync.WaitGroup as a resettable barrier, which called Add concurrently with // Wait (a documented misuse the race detector flags) and had a double-Done // negative-counter panic path. The replacement is an RWMutex gate made // idempotent by an atomic CAS. Run under -race. func TestPauseGate_ConcurrentIdempotent(t *testing.T) { j := &Job{Output: NewNullOutput()} j.setRunning(true) // Pause refuses to engage the gate unless the job is running // Duplicate Pause/Resume must not panic or unbalance the gate. j.Pause() j.Pause() j.Resume() j.Resume() // A stray Resume with no matching Pause must be a no-op, not an unlock panic. j.Resume() var wg sync.WaitGroup stop := make(chan struct{}) // Workers hammer the pause checkpoint while another goroutine toggles pause. for w := 0; w < 8; w++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: j.pauseCheckpoint() } } }() } for k := 0; k < 500; k++ { j.Pause() j.Resume() } close(stop) wg.Wait() } // TestPauseGate_ConcurrentPauseResume drives Pause and Resume from two separate // goroutines. That is the real interleaving (an interactive pause while the // SIGINT handler calls Resume), and a gate that flips its flag and takes the lock // in two steps turns it into a fatal "Unlock of unlocked RWMutex". The flag flip // and the Lock/Unlock must be one atomic step. Run under -race. func TestPauseGate_ConcurrentPauseResume(t *testing.T) { j := &Job{Output: NewNullOutput()} j.setRunning(true) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for i := 0; i < 100000; i++ { j.Pause() } }() go func() { defer wg.Done() for i := 0; i < 100000; i++ { j.Resume() } }() wg.Wait() // Leave the gate unlocked regardless of which side won the last iteration. j.Resume() } // runningJobWithCancel builds a minimal running Job whose Stop() can call // Config.Cancel() without a nil panic. func runningJobWithCancel() *Job { ctx, cancel := context.WithCancel(context.Background()) j := &Job{Output: NewNullOutput(), Config: &ffuf.Config{Context: ctx, Cancel: cancel}} j.setRunning(true) return j } // TestPauseGate_StopReopensGate regresses the teardown-hang: a paused job that is // Stopped must reopen the gate so a worker arriving at the checkpoint is not // stranded with no Resume left to wake it. func TestPauseGate_StopReopensGate(t *testing.T) { j := runningJobWithCancel() j.Pause() // gate closed j.Stop() // must reopen the gate done := make(chan struct{}) go func() { j.pauseCheckpoint(); close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("pauseCheckpoint stranded after Stop: the gate was not reopened") } } // TestPauseGate_ConcurrentStopPauseNeverStrands hammers the exact race the review // found: a Pause landing around a concurrent Stop. Whatever the interleaving, the // gate must end open (Pause refuses once stopping, and Stop resumes), so a // checkpoint never blocks forever. Run under -race. func TestPauseGate_ConcurrentStopPauseNeverStrands(t *testing.T) { for iter := 0; iter < 300; iter++ { j := runningJobWithCancel() var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); j.Pause() }() go func() { defer wg.Done(); j.Stop() }() wg.Wait() done := make(chan struct{}) go func() { j.pauseCheckpoint(); close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("iter %d: worker stranded at checkpoint after concurrent Stop/Pause", iter) } } } ffuf-2.3.0/pkg/engine/rate.go000066400000000000000000000061341525032755300157650ustar00rootroot00000000000000package engine import ( "container/ring" "sync" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type RateThrottle struct { rateCounter *ring.Ring Config *ffuf.Config RateMutex sync.Mutex RateLimiter *time.Ticker lastAdjustment time.Time } func NewRateThrottle(conf *ffuf.Config) *RateThrottle { r := &RateThrottle{ Config: conf, lastAdjustment: time.Now(), } if conf.Rate > 0 { r.rateCounter = ring.New(int(conf.Rate * 5)) ratemicros := 1000000 / conf.Rate if ratemicros < 1 { // conf.Rate > 1000000 divides to 0; NewTicker panics on a non-positive // interval. Clamp to the 1us floor (~1M req/s). ratemicros = 1 } r.RateLimiter = time.NewTicker(time.Microsecond * time.Duration(ratemicros)) } else { r.rateCounter = ring.New(conf.Threads * 5) //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 { r.RateMutex.Lock() defer r.RateMutex.Unlock() 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 } // CurrentConfiguredRate returns the configured rate under the lock, for callers // (e.g. the interactive handler) that only need to display it. func (r *RateThrottle) CurrentConfiguredRate() int64 { r.RateMutex.Lock() defer r.RateMutex.Unlock() return r.Config.Rate } func (r *RateThrottle) ChangeRate(rate int) { r.RateMutex.Lock() defer r.RateMutex.Unlock() var period time.Duration if rate > 0 { period = time.Microsecond * time.Duration(1000000/rate) // reset the rate counter r.rateCounter = ring.New(rate * 5) } else { period = time.Microsecond * 1 // reset the rate counter r.rateCounter = ring.New(r.Config.Threads * 5) } if period <= 0 { // rate > 1000000 makes 1000000/rate integer-divide to 0; a non-positive // ticker interval panics. Clamp to the 1us floor (~1M req/s). period = time.Microsecond } // Reset re-periodizes the existing ticker rather than replacing it, so the // RateLimiter pointer is written once (at construction) and never reassigned. // That is what lets the execution loop read RateLimiter.C without a lock. r.RateLimiter.Reset(period) r.Config.Rate = int64(rate) } // 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.3.0/pkg/engine/ratethrottle_test.go000066400000000000000000000014641525032755300206130ustar00rootroot00000000000000package engine import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestNewRateThrottle_HugeRateNoPanic regresses P3: conf.Rate > 1000000 makes // 1000000/rate integer-divide to 0, and a non-positive ticker interval panics. func TestNewRateThrottle_HugeRateNoPanic(t *testing.T) { _ = NewRateThrottle(&ffuf.Config{Rate: 2000000, Threads: 40}) _ = NewRateThrottle(&ffuf.Config{Rate: 1000001, Threads: 40}) } // TestChangeRate_HugeRateNoPanic regresses the same on the interactive `rate` // command path, plus the zero/negative branches. func TestChangeRate_HugeRateNoPanic(t *testing.T) { r := NewRateThrottle(&ffuf.Config{Rate: 0, Threads: 40}) r.ChangeRate(2000000) // 1000000/2000000 == 0 r.ChangeRate(1000001) r.ChangeRate(0) r.ChangeRate(-5) r.ChangeRate(500) // and a normal value still works } ffuf-2.3.0/pkg/engine/recursion.go000066400000000000000000000044761525032755300170520ustar00rootroot00000000000000package engine import ( "fmt" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // recursionManager owns the recursion policy: deciding whether a matched response // should spawn a deeper queue job and enqueuing it. It was lifted out of Job so the // greedy/default recursion logic lives in one place and is testable without a full // engine. It holds only what it needs: the config (for the depth limit and to build // recursion requests), the queue to push onto, and the output for its messages. type recursionManager struct { config *ffuf.Config queue *jobQueue output ffuf.OutputProvider } func newRecursionManager(conf *ffuf.Config, queue *jobQueue, output ffuf.OutputProvider) *recursionManager { return &recursionManager{config: conf, queue: queue, output: output} } // handleGreedy queues a recursion job for a matched response (greedy strategy: a // match is enough), if the recursion depth allows. The match has been determined // by the caller. func (r *recursionManager) handleGreedy(ctx jobContext, resp ffuf.Response) { if r.config.RecursionDepth == 0 || ctx.depth < r.config.RecursionDepth { recUrl := resp.Request.Url + "/" + "FUZZ" newJob := QueueJob{Url: recUrl, depth: ctx.depth + 1, req: ffuf.RecursionRequest(r.config, recUrl)} r.queue.push(newJob) r.output.Info(fmt.Sprintf("Adding a new job to the queue: %s", recUrl)) } else { r.output.Warning(fmt.Sprintf("Maximum recursion depth reached. Ignoring: %s", resp.Request.Url)) } } // handleDefault queues a recursion job when a matched response is a directory // (default strategy: the response redirects to its own path with a trailing // slash), if the recursion depth allows. func (r *recursionManager) handleDefault(ctx jobContext, resp ffuf.Response) { recUrl := resp.Request.Url + "/" + "FUZZ" if (resp.Request.Url + "/") != resp.GetRedirectLocation(true) { // Not a directory, return early return } if r.config.RecursionDepth == 0 || ctx.depth < r.config.RecursionDepth { // We have yet to reach the maximum recursion depth newJob := QueueJob{Url: recUrl, depth: ctx.depth + 1, req: ffuf.RecursionRequest(r.config, recUrl)} r.queue.push(newJob) r.output.Info(fmt.Sprintf("Adding a new job to the queue: %s", recUrl)) } else { r.output.Warning(fmt.Sprintf("Directory found, but recursion depth exceeded. Ignoring: %s", resp.GetRedirectLocation(true))) } } ffuf-2.3.0/pkg/engine/recursion_test.go000066400000000000000000000044601525032755300201020ustar00rootroot00000000000000package engine import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestRecursionManager_GreedyQueuesWithinDepth(t *testing.T) { q := newJobQueue() rm := newRecursionManager(&ffuf.Config{Method: "GET", RecursionDepth: 2}, q, NewNullOutput()) resp := ffuf.Response{Request: &ffuf.Request{Url: "http://x/admin"}} rm.handleGreedy(jobContext{depth: 0}, resp) if !q.hasNext() { t.Fatal("expected a recursion job to be queued") } job, _ := q.advance() if job.Url != "http://x/admin/FUZZ" { t.Errorf("queued url = %q, want http://x/admin/FUZZ", job.Url) } if job.depth != 1 { t.Errorf("queued depth = %d, want 1 (parent depth + 1)", job.depth) } } func TestRecursionManager_GreedyRespectsMaxDepth(t *testing.T) { q := newJobQueue() rm := newRecursionManager(&ffuf.Config{Method: "GET", RecursionDepth: 1}, q, NewNullOutput()) resp := ffuf.Response{Request: &ffuf.Request{Url: "http://x/admin"}} // depth == RecursionDepth: at the limit, nothing more is queued. rm.handleGreedy(jobContext{depth: 1}, resp) if q.total() != 0 { t.Errorf("expected no queued job at max depth, got %d", q.total()) } } func TestRecursionManager_DefaultSkipsNonDirectory(t *testing.T) { q := newJobQueue() rm := newRecursionManager(&ffuf.Config{Method: "GET", RecursionDepth: 0}, q, NewNullOutput()) // A 200 with no redirect is not a directory, so the default strategy queues // nothing. resp := ffuf.Response{Request: &ffuf.Request{Url: "http://x/file"}, StatusCode: 200} rm.handleDefault(jobContext{depth: 0}, resp) if q.total() != 0 { t.Errorf("non-directory response should not queue a job, got %d", q.total()) } } func TestRecursionManager_DefaultQueuesDirectory(t *testing.T) { q := newJobQueue() rm := newRecursionManager(&ffuf.Config{Method: "GET", RecursionDepth: 0}, q, NewNullOutput()) // A 301 redirecting to url + "/" is a directory; the default strategy descends. resp := ffuf.Response{ Request: &ffuf.Request{Url: "http://x/dir"}, StatusCode: 301, Headers: map[string][]string{"Location": {"http://x/dir/"}}, } rm.handleDefault(jobContext{depth: 0}, resp) if q.total() != 1 { t.Fatalf("directory response should queue a recursion job, got %d", q.total()) } job, _ := q.advance() if job.Url != "http://x/dir/FUZZ" { t.Errorf("queued url = %q, want http://x/dir/FUZZ", job.Url) } } ffuf-2.3.0/pkg/ffuf/000077500000000000000000000000001525032755300141605ustar00rootroot00000000000000ffuf-2.3.0/pkg/ffuf/autocalibration_strategies.go000066400000000000000000000030221525032755300221160ustar00rootroot00000000000000package ffuf import ( "encoding/json" "os" "path/filepath" ) type AutocalibrationStrategy map[string][]string 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 } ffuf-2.3.0/pkg/ffuf/config.go000066400000000000000000000165301525032755300157610ustar00rootroot00000000000000package ffuf import ( "context" "regexp" ) // VarExtract names a variable to capture from a preflight/postflight response // using the first capture group of Regex. The captured value is substituted into // the keyword Name wherever it appears in later requests. type VarExtract struct { Name string `json:"name" toml:"name"` Regex string `json:"regex" toml:"regex"` // Compiled is the precompiled Regex. ConfigFromOptions sets it once so the hot // path never recompiles per request; nil when a VarExtract is built directly. Compiled *regexp.Regexp `json:"-" toml:"-"` } // PreflightConfig is one raw HTTP request file executed around the fuzzing // request, with optional variable extractions from its response. type PreflightConfig struct { RequestFile string `json:"request_file" toml:"request_file"` Vars []VarExtract `json:"vars" toml:"vars"` } type Config struct { AuditLog string `json:"auditlog"` 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"` Preflights []PreflightConfig `json:"preflights"` Postflights []PreflightConfig `json:"postflights"` PreflightMode string `json:"preflight_mode"` PreflightError string `json:"preflight_error"` // RateLimitFunc blocks until the shared rate limiter allows another request. // The engine sets it so preflight/postflight requests (sent from the runner, // outside the dispatch loop) also honor -rate and -p. Nil means unmetered. RateLimitFunc func() `json:"-" toml:"-"` // Options retains the raw ConfigOptions this Config was built from, so the // configuration can be re-serialized (FFUFHASH history and similar features) // directly, with no hand-maintained reverse mapper. Set by ConfigFromOptions; // nil for a Config assembled by other means. Options *ConfigOptions `json:"-"` } 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 "§") } // NewConfig returns a Config ready for ConfigFromOptions to populate. It sets ONLY // what ConfigFromOptions does not: the context, non-nil slice/map fields (which are // appended to or indexed during parsing), and the few defaults for fields that are // not copied from the options. Every other default lives once in NewConfigOptions // and flows in through ConfigFromOptions — listing it here too would just duplicate // that single source. func NewConfig(ctx context.Context, cancel context.CancelFunc) Config { var conf Config conf.Context = ctx conf.Cancel = cancel conf.AutoCalibrationStrings = make([]string, 0) conf.CommandKeywords = make([]string, 0) conf.Encoders = make([]string, 0) conf.Extensions = make([]string, 0) conf.Headers = make(map[string]string) conf.InputProviders = make([]InputProviderConfig, 0) conf.Wordlists = []string{} conf.AutoCalibrationKeyword = "FUZZ" conf.Method = "GET" conf.ProgressFrequency = 125 conf.RequestProto = "https" return conf } func (c *Config) SetContext(ctx context.Context, cancel context.CancelFunc) { c.Context = ctx c.Cancel = cancel } ffuf-2.3.0/pkg/ffuf/config_idempotency_test.go000066400000000000000000000030031525032755300214070ustar00rootroot00000000000000package ffuf import ( "context" "strings" "testing" ) // TestConfigFromOptions_Idempotent locks that ConfigFromOptions does not mutate its // input and produces the same result when called twice on the same ConfigOptions. // Previously it folded -b/-cookie into parseOpts.HTTP.Headers in place, so a second // call double-appended the Cookie header and corrupted the caller's options. func TestConfigFromOptions_Idempotent(t *testing.T) { opts := NewConfigOptions() opts.HTTP.URL = "https://example.org/FUZZ" opts.Input.Wordlists = []string{"/tmp/wl.txt"} opts.HTTP.Cookies = []string{"SESSION=abc"} c1, err := ConfigFromOptions(opts, context.Background(), func() {}) if err != nil { t.Fatalf("first call: %v", err) } c2, err := ConfigFromOptions(opts, context.Background(), func() {}) if err != nil { t.Fatalf("second call: %v", err) } // The caller's options must be untouched (no in-place Cookie fold). if len(opts.HTTP.Headers) != 0 { t.Errorf("ConfigFromOptions mutated its input: opts.HTTP.Headers = %v", opts.HTTP.Headers) } // Each call folds exactly one Cookie header, identically. for i, c := range []*Config{c1, c2} { if got := c.Headers["Cookie"]; got != "SESSION=abc" { t.Errorf("call %d: Cookie header = %q, want %q", i+1, got, "SESSION=abc") } n := 0 for _, h := range c.Options.HTTP.Headers { if strings.HasPrefix(h, "Cookie: ") { n++ } } if n != 1 { t.Errorf("call %d: retained snapshot has %d Cookie headers, want 1 (%v)", i+1, n, c.Options.HTTP.Headers) } } } ffuf-2.3.0/pkg/ffuf/constants.go000066400000000000000000000007061525032755300165260ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/flags.go000066400000000000000000000216551525032755300156140ustar00rootroot00000000000000package ffuf import ( "flag" "fmt" "reflect" "strings" ) // Section keys — the help groups, in display order (rendered by help.go). const ( SectionHTTP = "http" SectionGeneral = "general" SectionCompat = "compat" SectionMatcher = "matcher" SectionFilter = "filter" SectionInput = "input" SectionOutput = "output" ) // isKnownSection reports whether s is one of the defined help sections. func isKnownSection(s string) bool { switch s { case SectionHTTP, SectionGeneral, SectionCompat, SectionMatcher, SectionFilter, SectionInput, SectionOutput: return true } return false } // RegisteredFlag is one flag's help metadata. type RegisteredFlag struct { Name string Section string Hidden bool } // FlagRegistry is the single source of truth describing every CLI flag, used to // drive the segmented help output. It is produced by RegisterFlags. type FlagRegistry struct { byName map[string]RegisteredFlag } // SectionOf returns the help section a flag belongs to. func (r *FlagRegistry) SectionOf(name string) (string, bool) { f, ok := r.byName[name] return f.Section, ok } // --- custom flag.Value types, writing straight into the target []string field --- // multiStringValue appends each occurrence verbatim (e.g. -H "A: b" -H "C: d"). type multiStringValue struct{ p *[]string } func (m *multiStringValue) String() string { return "" } func (m *multiStringValue) Set(v string) error { *m.p = append(*m.p, v) return nil } // wordlistValue appends, comma-splitting a single occurrence (e.g. -w a.txt,b.txt). type wordlistValue struct{ p *[]string } func (w *wordlistValue) String() string { return "" } func (w *wordlistValue) Set(v string) error { if parts := strings.Split(v, ","); len(parts) > 1 { *w.p = append(*w.p, parts...) } else { *w.p = append(*w.p, v) } return nil } // csvReplaceValue drops any default on the first occurrence, then appends // comma-split values. Matches the legacy -acs semantics (replace, not append). type csvReplaceValue struct { p *[]string set bool } func (c *csvReplaceValue) String() string { return "" } func (c *csvReplaceValue) Set(v string) error { if !c.set { *c.p = []string{} c.set = true } *c.p = append(*c.p, strings.Split(v, ",")...) return nil } // extraFlag is the explicit supplement for flags that can't be expressed as a // plain tagged field: cross-section aliases, dummy compatibility flags, and // flags whose usage text contains a backtick (illegal inside a struct tag). type extraFlag struct { Name string Section string Hidden bool Bind func(fs *flag.FlagSet, o *ConfigOptions) } var extraFlags = []extraFlag{ // Dummy `copy as curl` compat flags: accepted and ignored. They have no backing // ConfigOptions field to bind, so unlike every real flag (and its aliases) they // can't be expressed as a tagged field and are declared explicitly here. {"compressed", SectionCompat, true, func(fs *flag.FlagSet, o *ConfigOptions) { _ = fs.Bool("compressed", true, "Dummy flag for copy as curl functionality (ignored)") }}, {"i", SectionCompat, true, func(fs *flag.FlagSet, o *ConfigOptions) { _ = fs.Bool("i", true, "Dummy flag for copy as curl functionality (ignored)") }}, {"k", SectionCompat, true, func(fs *flag.FlagSet, o *ConfigOptions) { _ = fs.Bool("k", false, "Dummy flag for backwards compatibility") }}, // Preflight/postflight files bind positionally: a -preflight-var attaches to the // preceding -preflight. That can't be a plain tagged field, so they are Func // flags whose callbacks append to the option struct in command-line order. Using // flag.Parse's own tokenization (rather than a separate os.Args scan) keeps them // consistent with every other flag and lets config-file values survive: the CLI // appends to whatever a config file loaded, it does not clobber it. {"preflight", SectionHTTP, false, func(fs *flag.FlagSet, o *ConfigOptions) { fs.Func("preflight", "Raw HTTP request file to run before each fuzzing request (repeatable, order matters)", func(v string) error { if v == "" { return fmt.Errorf("-preflight requires a request file path") } o.HTTP.Preflights = append(o.HTTP.Preflights, PreflightConfig{RequestFile: v}) return nil }) }}, {"preflight-var", SectionHTTP, false, func(fs *flag.FlagSet, o *ConfigOptions) { fs.Func("preflight-var", "Extract a variable from the preceding -preflight response: \"NAME:regex\" (repeatable)", func(v string) error { return appendFlightVar(&o.HTTP.Preflights, "-preflight-var", "-preflight", v) }) }}, {"postflight", SectionHTTP, false, func(fs *flag.FlagSet, o *ConfigOptions) { fs.Func("postflight", "Raw HTTP request file to run after each fuzzing request (repeatable, order matters)", func(v string) error { if v == "" { return fmt.Errorf("-postflight requires a request file path") } o.HTTP.Postflights = append(o.HTTP.Postflights, PreflightConfig{RequestFile: v}) return nil }) }}, {"postflight-var", SectionHTTP, false, func(fs *flag.FlagSet, o *ConfigOptions) { fs.Func("postflight-var", "Extract a variable from the preceding -postflight response: \"NAME:regex\" (repeatable)", func(v string) error { return appendFlightVar(&o.HTTP.Postflights, "-postflight-var", "-postflight", v) }) }}, } // appendFlightVar attaches a "NAME:regex" extraction to the last entry in a // preflight/postflight chain. It errors (so flag.Parse surfaces it) when there is // no preceding file flag or the spec is malformed, rather than silently dropping. func appendFlightVar(chain *[]PreflightConfig, varFlag, fileFlag, spec string) error { if len(*chain) == 0 { return fmt.Errorf("%s %q has no preceding %s", varFlag, spec, fileFlag) } name, regex, ok := parseVarSpec(spec) if !ok { return fmt.Errorf("%s value %q must be \"NAME:regex\" with a non-empty name and regex", varFlag, spec) } last := &(*chain)[len(*chain)-1] last.Vars = append(last.Vars, VarExtract{Name: name, Regex: regex}) return nil } // parseVarSpec splits a "NAME:regex" spec on the FIRST colon, so the regex may // itself contain colons. Both the name and the regex must be non-empty. func parseVarSpec(spec string) (name, regex string, ok bool) { idx := strings.IndexByte(spec, ':') if idx <= 0 || idx == len(spec)-1 { return "", "", false } return spec[:idx], spec[idx+1:], true } // RegisterFlags is the single source of truth for CLI flag registration. It walks // the `ffuf:`-tagged fields of ConfigOptions via reflection and registers each on // fs — using the field's CURRENT value as the default, which preserves the // config-file < command-line precedence — then applies the explicit supplement. // It returns a FlagRegistry describing every flag for the segmented help. func RegisterFlags(fs *flag.FlagSet, o *ConfigOptions) *FlagRegistry { reg := &FlagRegistry{byName: make(map[string]RegisteredFlag)} record := func(name, section string, hidden bool) { reg.byName[name] = RegisteredFlag{Name: name, Section: section, Hidden: hidden} } root := reflect.ValueOf(o).Elem() for i := 0; i < root.NumField(); i++ { group := root.Field(i) // an *Options sub-struct (addressable: o is a pointer) gt := group.Type() for j := 0; j < gt.NumField(); j++ { sf := gt.Field(j) name := sf.Tag.Get("ffuf") if name == "" { continue // not a flag } usage, section := sf.Tag.Get("usage"), sf.Tag.Get("section") if usage == "" { panic("ffuf: flag -" + name + " is missing a usage tag") } if !isKnownSection(section) { panic("ffuf: flag -" + name + " has unknown section \"" + section + "\"") } bindField(fs, name, usage, sf.Tag.Get("kind"), group.Field(j).Addr().Interface()) record(name, section, false) // Compatibility aliases bind the SAME field, hidden in the compat section. for _, alias := range strings.Split(sf.Tag.Get("alias"), ",") { alias = strings.TrimSpace(alias) if alias == "" { continue } bindField(fs, alias, sf.Tag.Get("usage"), sf.Tag.Get("kind"), group.Field(j).Addr().Interface()) record(alias, SectionCompat, true) } } } for _, ef := range extraFlags { ef.Bind(fs, o) record(ef.Name, ef.Section, ef.Hidden) } return reg } // bindField registers one reflected field pointer as a flag of the matching type. // It panics on an unsupported type or a slice field without a valid kind — those // are programming errors caught by the registration test, not runtime conditions. func bindField(fs *flag.FlagSet, name, usage, kind string, ptr interface{}) { switch p := ptr.(type) { case *bool: fs.BoolVar(p, name, *p, usage) case *int: fs.IntVar(p, name, *p, usage) case *string: fs.StringVar(p, name, *p, usage) case *[]string: switch kind { case "multistring": fs.Var(&multiStringValue{p}, name, usage) case "wordlist": fs.Var(&wordlistValue{p}, name, usage) case "csvreplace": fs.Var(&csvReplaceValue{p: p}, name, usage) default: panic("ffuf: slice flag -" + name + " needs kind:multistring|wordlist|csvreplace (got " + kind + ")") } default: panic("ffuf: unsupported flag field type for -" + name) } } ffuf-2.3.0/pkg/ffuf/flags_test.go000066400000000000000000000065351525032755300166530ustar00rootroot00000000000000package ffuf import ( "flag" "testing" ) // TestRegisterFlags_Wellformed runs RegisterFlags against the real ConfigOptions // struct so any malformed declaration — a slice field missing its `kind`, an // unsupported field type, an unexported tagged field, or a duplicate flag name — // fails here in CI instead of panicking at runtime. This is what buys back the // compile-time safety that reflection-based registration gives up. func TestRegisterFlags_Wellformed(t *testing.T) { fs := flag.NewFlagSet("ffuf", flag.ContinueOnError) opts := NewConfigOptions() var reg *FlagRegistry func() { defer func() { if r := recover(); r != nil { t.Fatalf("RegisterFlags panicked (malformed tag or unsupported field type): %v", r) } }() reg = RegisterFlags(fs, opts) }() validSection := map[string]bool{ SectionHTTP: true, SectionGeneral: true, SectionCompat: true, SectionMatcher: true, SectionFilter: true, SectionInput: true, SectionOutput: true, } // The full expected flag surface. Unlike a bare count, this catches a net-zero // add-and-remove, a rename, or a duplicate — update it DELIBERATELY when the CLI // surface changes. 78 visible flags + 7 hidden compat (4 aliases + 3 dummies). expected := map[string]bool{ // HTTP "H": true, "X": true, "b": true, "cc": true, "ck": true, "d": true, "http2": true, "ignore-body": true, "r": true, "raw": true, "recursion": true, "recursion-depth": true, "recursion-strategy": true, "replay-proxy": true, "sni": true, "timeout": true, "u": true, "x": true, "preflight-mode": true, "preflight-error": true, "preflight": true, "preflight-var": true, "postflight": true, "postflight-var": true, // General "V": true, "ac": true, "acc": true, "ach": true, "ack": true, "acs": true, "c": true, "config": true, "json": true, "maxtime": true, "maxtime-job": true, "noninteractive": true, "p": true, "rate": true, "s": true, "sa": true, "scraperfile": true, "scrapers": true, "se": true, "search": true, "sf": true, "t": true, "v": true, // Matcher "mc": true, "ml": true, "mmode": true, "mr": true, "ms": true, "mt": true, "mw": true, // Filter "fc": true, "fl": true, "fmode": true, "fr": true, "fs": true, "ft": true, "fw": true, // Input "D": true, "e": true, "enc": true, "ic": true, "input-cmd": true, "input-num": true, "input-shell": true, "mode": true, "request": true, "request-proto": true, "w": true, // Output "audit-log": true, "debug-log": true, "o": true, "od": true, "of": true, "or": true, // Compat aliases "cookie": true, "data": true, "data-ascii": true, "data-binary": true, // Compat dummies "compressed": true, "i": true, "k": true, } registered := map[string]bool{} fs.VisitAll(func(f *flag.Flag) { registered[f.Name] = true if f.Usage == "" { t.Errorf("flag -%s has an empty usage string", f.Name) } section, ok := reg.SectionOf(f.Name) if !ok { t.Errorf("flag -%s is registered but missing from the registry (no section)", f.Name) return } if !validSection[section] { t.Errorf("flag -%s has unknown section %q", f.Name, section) } }) for name := range expected { if !registered[name] { t.Errorf("expected flag -%s is not registered (dropped or renamed?)", name) } } for name := range registered { if !expected[name] { t.Errorf("unexpected flag -%s registered — add it to the expected set deliberately", name) } } } ffuf-2.3.0/pkg/ffuf/interfaces.go000066400000000000000000000077421525032755300166440ustar00rootroot00000000000000package 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 // Matches reports whether resp passes the configured matchers and filters. // perHost selects the per-domain filter set; matcherMode/filterMode are the // and/or combination modes. Matches(resp *Response, perHost bool, matcherMode string, filterMode string) 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) FilterCurrentResults(keep func(Result) bool) SetPaused(paused bool) PendingResults() int Reset() Cycle() } // AuditLogger is responsible for providing auditing output of every request/response // sent and recieved by FFUF type AuditLogger interface { Close() Write(data interface{}) error } 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:"-"` // Printed reports whether this result has already been shown to the user // (streamed live, or surfaced in the "N new matches" summary on resume). It // is the single source of truth for the interactive console's pending count, // so filtering a held result out of CurrentResults also drops it from the // count. Not serialized to any output format. Printed bool `json:"-"` } ffuf-2.3.0/pkg/ffuf/multierror.go000066400000000000000000000010321525032755300167070ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/optionsparser.go000066400000000000000000001046501525032755300174250ustar00rootroot00000000000000package ffuf import ( "bufio" "context" "fmt" "io" "net/textproto" "net/url" "os" "path/filepath" "regexp" "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"` } // The `ffuf`, `section`, `usage`, `kind` and `alias` struct tags are the single // source of truth for CLI flags (see flags.go / RegisterFlags). A field with an // `ffuf` tag becomes a flag; the help section, usage text, (for []string) the value // kind, and any compatibility aliases come from the tags. The only flags NOT backed // by a field are the dummy `copy as curl` compat flags (-i/-k/-compressed), declared // explicitly in flags.go because they have no value to bind. type HTTPOptions struct { Cookies []string `json:"-" ffuf:"b" alias:"cookie" kind:"multistring" section:"http" usage:"Cookie data \"NAME1=VALUE1; NAME2=VALUE2\" for copy as curl functionality."` Data string `json:"data" ffuf:"d" alias:"data,data-ascii,data-binary" section:"http" usage:"POST data"` FollowRedirects bool `json:"follow_redirects" ffuf:"r" section:"http" usage:"Follow redirects"` Headers []string `json:"headers" ffuf:"H" kind:"multistring" section:"http" usage:"Header \"Name: Value\", separated by colon. Multiple -H flags are accepted."` IgnoreBody bool `json:"ignore_body" ffuf:"ignore-body" section:"http" usage:"Do not fetch the response content."` Method string `json:"method" ffuf:"X" section:"http" usage:"HTTP method to use"` ProxyURL string `json:"proxy_url" ffuf:"x" section:"http" usage:"Proxy URL (SOCKS5 or HTTP). For example: http://127.0.0.1:8080 or socks5://127.0.0.1:8080"` Raw bool `json:"raw" ffuf:"raw" section:"http" usage:"Do not encode URI"` Recursion bool `json:"recursion" ffuf:"recursion" section:"http" usage:"Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it."` RecursionDepth int `json:"recursion_depth" ffuf:"recursion-depth" section:"http" usage:"Maximum recursion depth."` RecursionStrategy string `json:"recursion_strategy" ffuf:"recursion-strategy" section:"http" usage:"Recursion strategy: \"default\" for a redirect based, and \"greedy\" to recurse on all matches"` ReplayProxyURL string `json:"replay_proxy_url" ffuf:"replay-proxy" section:"http" usage:"Replay matched requests using this proxy."` SNI string `json:"sni" ffuf:"sni" section:"http" usage:"Target TLS SNI, does not support FUZZ keyword"` Timeout int `json:"timeout" ffuf:"timeout" section:"http" usage:"HTTP request timeout in seconds."` URL string `json:"url" ffuf:"u" section:"http" usage:"Target URL"` Http2 bool `json:"http2" ffuf:"http2" section:"http" usage:"Use HTTP2 protocol"` ClientCert string `json:"client-cert" ffuf:"cc" section:"http" usage:"Client cert for authentication. Client key needs to be defined as well for this to work"` ClientKey string `json:"client-key" ffuf:"ck" section:"http" usage:"Client key for authentication. Client certificate needs to be defined as well for this to work"` // Preflights/Postflights are not plain flags: -preflight and -preflight-var // bind positionally (a -preflight-var attaches to the preceding -preflight), so // they are appended by the extraFlags Func callbacks in flags.go rather than a // tagged field. The json/toml tags carry them through config files and history. Preflights []PreflightConfig `json:"preflights" toml:"preflights"` Postflights []PreflightConfig `json:"postflights" toml:"postflights"` PreflightMode string `json:"preflight_mode" toml:"preflight_mode" ffuf:"preflight-mode" section:"http" usage:"Preflight execution mode: \"per-request\" or \"per-thread\""` PreflightError string `json:"preflight_error" toml:"preflight_error" ffuf:"preflight-error" section:"http" usage:"Preflight error handling: \"abort\" or \"ignore\""` } type GeneralOptions struct { AutoCalibration bool `json:"autocalibration" ffuf:"ac" section:"general" usage:"Automatically calibrate filtering options"` AutoCalibrationKeyword string `json:"autocalibration_keyword" ffuf:"ack" section:"general" usage:"Autocalibration keyword"` AutoCalibrationPerHost bool `json:"autocalibration_per_host" ffuf:"ach" section:"general" usage:"Per host autocalibration"` AutoCalibrationStrategies []string `json:"autocalibration_strategies" ffuf:"acs" kind:"csvreplace" section:"general" usage:"Custom auto-calibration strategies. Can be used multiple times. Implies -ac"` AutoCalibrationStrings []string `json:"autocalibration_strings" ffuf:"acc" kind:"multistring" section:"general" usage:"Custom auto-calibration string. Can be used multiple times. Implies -ac"` Colors bool `json:"colors" ffuf:"c" section:"general" usage:"Colorize output."` ConfigFile string `toml:"-" json:"config_file" ffuf:"config" section:"general" usage:"Load configuration from a file"` Delay string `json:"delay" ffuf:"p" section:"general" usage:"Seconds of delay between requests, or a range of random delay. For example \"0.1\" or \"0.1-2.0\""` Json bool `json:"json" ffuf:"json" section:"general" usage:"JSON output, printing newline-delimited JSON records"` MaxTime int `json:"maxtime" ffuf:"maxtime" section:"general" usage:"Maximum running time in seconds for entire process."` MaxTimeJob int `json:"maxtime_job" ffuf:"maxtime-job" section:"general" usage:"Maximum running time in seconds per job."` Noninteractive bool `json:"noninteractive" ffuf:"noninteractive" section:"general" usage:"Disable the interactive console functionality"` Quiet bool `json:"quiet" ffuf:"s" section:"general" usage:"Do not print additional information (silent mode)"` Rate int `json:"rate" ffuf:"rate" section:"general" usage:"Rate of requests per second"` ScraperFile string `json:"scraperfile" ffuf:"scraperfile" section:"general" usage:"Custom scraper file path"` Scrapers string `json:"scrapers" ffuf:"scrapers" section:"general" usage:"Active scraper groups"` Searchhash string `json:"-" ffuf:"search" section:"general" usage:"Search for a FFUFHASH payload from ffuf history"` ShowVersion bool `toml:"-" json:"-" ffuf:"V" section:"general" usage:"Show version information."` StopOn403 bool `json:"stop_on_403" ffuf:"sf" section:"general" usage:"Stop when > 95% of responses return 403 Forbidden"` StopOnAll bool `json:"stop_on_all" ffuf:"sa" section:"general" usage:"Stop on all error cases. Implies -sf and -se."` StopOnErrors bool `json:"stop_on_errors" ffuf:"se" section:"general" usage:"Stop on spurious errors"` Threads int `json:"threads" ffuf:"t" section:"general" usage:"Number of concurrent threads."` Verbose bool `json:"verbose" ffuf:"v" section:"general" usage:"Verbose output, printing full URL and redirect location (if any) with the results."` } type InputOptions struct { DirSearchCompat bool `json:"dirsearch_compat" ffuf:"D" section:"input" usage:"DirSearch wordlist compatibility mode. Used in conjunction with -e flag."` Encoders []string `json:"encoders" ffuf:"enc" kind:"wordlist" section:"input" usage:"Encoders for keywords, eg. 'FUZZ:urlencode b64encode'"` Extensions string `json:"extensions" ffuf:"e" section:"input" usage:"Comma separated list of extensions. Extends FUZZ keyword."` IgnoreWordlistComments bool `json:"ignore_wordlist_comments" ffuf:"ic" section:"input" usage:"Ignore wordlist comments"` InputMode string `json:"input_mode" ffuf:"mode" section:"input" usage:"Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork, sniper"` InputNum int `json:"input_num" ffuf:"input-num" section:"input" usage:"Number of inputs to test. Used in conjunction with --input-cmd."` InputShell string `json:"input_shell" ffuf:"input-shell" section:"input" usage:"Shell to be used for running command"` Inputcommands []string `json:"input_commands" ffuf:"input-cmd" kind:"multistring" section:"input" usage:"Command producing the input. --input-num is required when using this input method. Overrides -w."` Request string `json:"request_file" ffuf:"request" section:"input" usage:"File containing the raw http request"` RequestProto string `json:"request_proto" ffuf:"request-proto" section:"input" usage:"Protocol to use along with raw request"` Wordlists []string `json:"wordlists" ffuf:"w" kind:"wordlist" section:"input" usage:"Wordlist file path and (optional) keyword separated by colon. eg. '/path/to/wordlist:KEYWORD'"` } type OutputOptions struct { AuditLog string `json:"audit_log" ffuf:"audit-log" section:"output" usage:"Write audit log containing all requests, responses and config"` DebugLog string `json:"debug_log" ffuf:"debug-log" section:"output" usage:"Write all of the internal logging to the specified file."` OutputDirectory string `json:"output_directory" ffuf:"od" section:"output" usage:"Directory path to store matched results to."` OutputFile string `json:"output_file" ffuf:"o" section:"output" usage:"Write output to file"` OutputFormat string `json:"output_format" ffuf:"of" section:"output" usage:"Output file format. Available formats: json, ejson, html, md, csv, ecsv (or, 'all' for all formats)"` OutputSkipEmptyFile bool `json:"output_skip_empty" ffuf:"or" section:"output" usage:"Don't create the output file if we don't have results"` } type FilterOptions struct { Mode string `json:"mode" ffuf:"fmode" section:"filter" usage:"Filter set operator. Either of: and, or"` Lines string `json:"lines" ffuf:"fl" section:"filter" usage:"Filter by amount of lines in response. Comma separated list of line counts and ranges"` Regexp string `json:"regexp" ffuf:"fr" section:"filter" usage:"Filter regexp"` Size string `json:"size" ffuf:"fs" section:"filter" usage:"Filter HTTP response size. Comma separated list of sizes and ranges"` Status string `json:"status" ffuf:"fc" section:"filter" usage:"Filter HTTP status codes from response. Comma separated list of codes and ranges"` Time string `json:"time" ffuf:"ft" section:"filter" usage:"Filter by number of milliseconds to the first response byte, either greater or less than. EG: >100 or <100"` Words string `json:"words" ffuf:"fw" section:"filter" usage:"Filter by amount of words in response. Comma separated list of word counts and ranges"` } type MatcherOptions struct { Mode string `json:"mode" ffuf:"mmode" section:"matcher" usage:"Matcher set operator. Either of: and, or"` Lines string `json:"lines" ffuf:"ml" section:"matcher" usage:"Match amount of lines in response"` Regexp string `json:"regexp" ffuf:"mr" section:"matcher" usage:"Match regexp"` Size string `json:"size" ffuf:"ms" section:"matcher" usage:"Match HTTP response size"` Status string `json:"status" ffuf:"mc" section:"matcher" usage:"Match HTTP status codes, or \"all\" for everything."` Time string `json:"time" ffuf:"mt" section:"matcher" usage:"Match how many milliseconds to the first response byte, either greater or less than. EG: >100 or <100"` Words string `json:"words" ffuf:"mw" section:"matcher" usage:"Match amount of words in response"` } // 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.Preflights = make([]PreflightConfig, 0) c.HTTP.Postflights = make([]PreflightConfig, 0) c.HTTP.PreflightMode = "per-request" c.HTTP.PreflightError = "abort" 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.AuditLog = "" c.Output.DebugLog = "" c.Output.OutputDirectory = "" c.Output.OutputFile = "" c.Output.OutputFormat = "json" c.Output.OutputSkipEmptyFile = false return c } // cloneStrings returns a copy of s with its own backing array (nil stays nil), so // the retained options snapshot can't be mutated through the caller's slices. func cloneStrings(s []string) []string { if s == nil { return nil } return append([]string(nil), s...) } // clonePreflights deep-copies a preflight/postflight slice, including each entry's // Vars slice, so the retained options snapshot shares no backing array with the // caller's options (matching the cloneStrings invariant for the other slice fields). func clonePreflights(in []PreflightConfig) []PreflightConfig { if in == nil { return nil } out := make([]PreflightConfig, len(in)) for i, pf := range in { out[i] = pf out[i].Vars = append([]VarExtract(nil), pf.Vars...) } return out } // 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 } // Effective request headers: the -H values plus any -b/-cookie folded in. Built // as a fresh slice so ConfigFromOptions never mutates the caller's options (it // stays idempotent) and the retained snapshot below shares no backing with it. effectiveHeaders := append([]string(nil), parseOpts.HTTP.Headers...) if len(parseOpts.HTTP.Cookies) > 0 { effectiveHeaders = append(effectiveHeaders, "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("%s", 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 effectiveHeaders { 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.AuditLog = parseOpts.Output.AuditLog 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 conf.Preflights = parseOpts.HTTP.Preflights conf.Postflights = parseOpts.HTTP.Postflights switch parseOpts.HTTP.PreflightMode { case "", "per-request": conf.PreflightMode = "per-request" case "per-thread": conf.PreflightMode = "per-thread" default: errs.Add(fmt.Errorf("-preflight-mode must be \"per-request\" or \"per-thread\", got %q", parseOpts.HTTP.PreflightMode)) } switch parseOpts.HTTP.PreflightError { case "", "abort": conf.PreflightError = "abort" case "ignore": conf.PreflightError = "ignore" default: errs.Add(fmt.Errorf("-preflight-error must be \"abort\" or \"ignore\", got %q", parseOpts.HTTP.PreflightError)) } // Validate that each preflight/postflight file exists and precompile every // extraction regex once here (invalid regex is a config error, not a runtime // per-request abort; the runner reuses Compiled so the hot path never recompiles). compileFlights := func(kind string, flights []PreflightConfig) { for i := range flights { if _, err := os.Stat(flights[i].RequestFile); err != nil { errs.Add(fmt.Errorf("%s request file #%d %q: %s", kind, i+1, flights[i].RequestFile, err)) } for j := range flights[i].Vars { re, cerr := regexp.Compile(flights[i].Vars[j].Regex) if cerr != nil { errs.Add(fmt.Errorf("%s #%d var %q: invalid regex %q: %s", kind, i+1, flights[i].Vars[j].Name, flights[i].Vars[j].Regex, cerr)) continue } flights[i].Vars[j].Compiled = re } } } compileFlights("preflight", conf.Preflights) compileFlights("postflight", conf.Postflights) // 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("%s", 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("%s", 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("%s", 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("%s", 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("%s", errmsg)) } } // Make verbose mutually exclusive with json if parseOpts.General.Verbose && parseOpts.General.Json { errs.Add(fmt.Errorf("Cannot have -json and -v")) } // Retain the source options so the configuration can be re-serialized later // (history / FFUFHASH) without a hand-maintained reverse mapper. Deep-copy the // slice fields so the retained snapshot shares no backing array with the caller's // options; a later mutation of either side can't corrupt the other. Headers holds // the effective set (with -b/-cookie folded in) computed above. optsCopy := *parseOpts optsCopy.HTTP.Headers = effectiveHeaders optsCopy.HTTP.Cookies = cloneStrings(parseOpts.HTTP.Cookies) optsCopy.Input.Wordlists = cloneStrings(parseOpts.Input.Wordlists) optsCopy.Input.Encoders = cloneStrings(parseOpts.Input.Encoders) optsCopy.Input.Inputcommands = cloneStrings(parseOpts.Input.Inputcommands) optsCopy.General.AutoCalibrationStrings = cloneStrings(parseOpts.General.AutoCalibrationStrings) optsCopy.General.AutoCalibrationStrategies = cloneStrings(parseOpts.General.AutoCalibrationStrategies) optsCopy.HTTP.Preflights = clonePreflights(parseOpts.HTTP.Preflights) optsCopy.HTTP.Postflights = clonePreflights(parseOpts.HTTP.Postflights) conf.Options = &optsCopy 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.3.0/pkg/ffuf/optionsparser_test.go000066400000000000000000000134611525032755300204630ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/optrange.go000066400000000000000000000032041525032755300163250ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/preflight_flags_test.go000066400000000000000000000077031525032755300207150ustar00rootroot00000000000000package ffuf import ( "flag" "io" "reflect" "testing" "github.com/pelletier/go-toml" ) func parsePreflightFlags(t *testing.T, o *ConfigOptions, args []string) error { t.Helper() fs := flag.NewFlagSet("test", flag.ContinueOnError) fs.SetOutput(io.Discard) RegisterFlags(fs, o) return fs.Parse(args) } // TestPreflightFlagsPositionalBinding locks the CLI semantics: each -preflight-var // attaches to the most recent -preflight, in command-line order, via the flag // callbacks (no os.Args scan). func TestPreflightFlagsPositionalBinding(t *testing.T) { o := NewConfigOptions() if err := parsePreflightFlags(t, o, []string{ "-preflight", "login.txt", "-preflight-var", "TOKEN:csrf=([a-f0-9]+)", "-preflight-var", "SESSION:sid=(\\w+)", "-preflight", "second.txt", "-postflight", "logout.txt", "-postflight-var", "OUT:done:(\\d+)", // regex contains a colon }); err != nil { t.Fatalf("parse: %s", err) } wantPre := []PreflightConfig{ {RequestFile: "login.txt", Vars: []VarExtract{ {Name: "TOKEN", Regex: "csrf=([a-f0-9]+)"}, {Name: "SESSION", Regex: "sid=(\\w+)"}, }}, {RequestFile: "second.txt"}, } wantPost := []PreflightConfig{ {RequestFile: "logout.txt", Vars: []VarExtract{{Name: "OUT", Regex: "done:(\\d+)"}}}, } if !reflect.DeepEqual(o.HTTP.Preflights, wantPre) { t.Errorf("preflights =\n %+v\nwant\n %+v", o.HTTP.Preflights, wantPre) } if !reflect.DeepEqual(o.HTTP.Postflights, wantPost) { t.Errorf("postflights =\n %+v\nwant\n %+v", o.HTTP.Postflights, wantPost) } } // TestPreflightConfigFilePreserved is the fix for the config-wipe finding: CLI // preflight flags APPEND to whatever a config file loaded, they don't clobber it. func TestPreflightConfigFilePreserved(t *testing.T) { o := NewConfigOptions() o.HTTP.Preflights = []PreflightConfig{{RequestFile: "from-config.txt"}} // as if loaded from a config file if err := parsePreflightFlags(t, o, []string{"-preflight", "from-cli.txt"}); err != nil { t.Fatalf("parse: %s", err) } if len(o.HTTP.Preflights) != 2 { t.Fatalf("got %d preflights, want 2 (config value must survive)", len(o.HTTP.Preflights)) } if o.HTTP.Preflights[0].RequestFile != "from-config.txt" { t.Errorf("config-file preflight was wiped: %+v", o.HTTP.Preflights) } if o.HTTP.Preflights[1].RequestFile != "from-cli.txt" { t.Errorf("CLI preflight not appended: %+v", o.HTTP.Preflights) } } // TestPreflightVarSpecValidation rejects malformed NAME:regex specs and orphan // vars with a clear error instead of silently dropping them. func TestPreflightVarSpecValidation(t *testing.T) { for _, bad := range []string{"NOCOLON", ":emptyname", "EMPTY:"} { o := NewConfigOptions() if err := parsePreflightFlags(t, o, []string{"-preflight", "f.txt", "-preflight-var", bad}); err == nil { t.Errorf("var spec %q: expected an error, got none", bad) } } o := NewConfigOptions() if err := parsePreflightFlags(t, o, []string{"-preflight-var", "X:y"}); err == nil { t.Error("orphan -preflight-var: expected an error, got none") } // empty -preflight value o2 := NewConfigOptions() if err := parsePreflightFlags(t, o2, []string{"-preflight", ""}); err == nil { t.Error("empty -preflight value: expected an error, got none") } } // TestPreflightTOMLLoads proves the toml tags wire config-file loading, including // the nested request_file / vars keys that the json tags alone did not match. func TestPreflightTOMLLoads(t *testing.T) { data := ` [http] [[http.preflights]] request_file = "login.txt" [[http.preflights.vars]] name = "TOKEN" regex = "csrf=(\\w+)" ` o := NewConfigOptions() if err := toml.Unmarshal([]byte(data), o); err != nil { t.Fatalf("toml unmarshal: %s", err) } if len(o.HTTP.Preflights) != 1 || o.HTTP.Preflights[0].RequestFile != "login.txt" { t.Fatalf("preflight not loaded from TOML: %+v", o.HTTP.Preflights) } if len(o.HTTP.Preflights[0].Vars) != 1 || o.HTTP.Preflights[0].Vars[0].Name != "TOKEN" { t.Errorf("preflight var not loaded from TOML: %+v", o.HTTP.Preflights[0].Vars) } } ffuf-2.3.0/pkg/ffuf/progress.go000066400000000000000000000002631525032755300163540ustar00rootroot00000000000000package ffuf import ( "time" ) type Progress struct { StartedAt time.Time ReqCount int ReqTotal int ReqSec int64 QueuePos int QueueTotal int ErrorCount int } ffuf-2.3.0/pkg/ffuf/request.go000066400000000000000000000125611525032755300162040ustar00rootroot00000000000000package ffuf import ( "strings" "time" ) // 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 Error string Timestamp time.Time } 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.3.0/pkg/ffuf/request_test.go000066400000000000000000000146121525032755300172420ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/response.go000066400000000000000000000040721525032755300163500ustar00rootroot00000000000000package 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 Duration time.Duration Timestamp time.Time } // 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.3.0/pkg/ffuf/util.go000066400000000000000000000106521525032755300154700ustar00rootroot00000000000000package ffuf import ( "errors" "fmt" "math/rand" "net/url" "os" "runtime/debug" "strings" "time" ) // 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. // // Release builds have their version injected via -ldflags by goreleaser: VERSION // is set to the git tag and VERSION_APPENDIX is emptied, so they report a plain // semantic version like "2.2.0" with no manual constant bump required. // // Builds from a source checkout (VERSION_APPENDIX still set) instead report a // "git--" identifier derived from the VCS metadata that // `go build` embeds into the binary, e.g. "git-20260613-aabbccdd". When that // metadata is unavailable it falls back to VERSION+VERSION_APPENDIX. func Version() string { if VERSION_APPENDIX == "" { return VERSION } if v := gitVersion(); v != "" { return v } return fmt.Sprintf("%s%s", VERSION, VERSION_APPENDIX) } // gitVersion assembles a "git--" string from the VCS metadata // that `go build` stamps into the binary. It returns "" when the metadata is // missing (e.g. a module build outside of a repository). func gitVersion() string { info, ok := debug.ReadBuildInfo() if !ok { return "" } var revision string var modified bool var stamp time.Time for _, s := range info.Settings { switch s.Key { case "vcs.revision": revision = s.Value case "vcs.time": stamp, _ = time.Parse(time.RFC3339, s.Value) case "vcs.modified": modified = s.Value == "true" } } if revision == "" || stamp.IsZero() { return "" } if len(revision) > 8 { revision = revision[:8] } dirty := "" if modified { dirty = "-dirty" } return fmt.Sprintf("git-%s-%s%s", stamp.UTC().Format("20060102"), revision, dirty) } // FormattedVersion returns the version prepared for display. Release builds are // prefixed with "v" (e.g. "v2.2.0"); development builds are returned unprefixed // (e.g. "git-20260613-aabbccdd") since a "v" reads as noise there. func FormattedVersion() string { if VERSION_APPENDIX == "" { return "v" + Version() } return Version() } 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 } ffuf-2.3.0/pkg/ffuf/util_test.go000066400000000000000000000011031525032755300165160ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/valuerange.go000066400000000000000000000017051525032755300166430ustar00rootroot00000000000000package 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.3.0/pkg/ffuf/valuerange_property_test.go000066400000000000000000000052451525032755300216510ustar00rootroot00000000000000package ffuf import ( "fmt" "math/rand" "strconv" "testing" "testing/quick" ) // qcfg fixes the seed so a failure is reproducible (quick.Check with a nil config // seeds from time.Now, which is unreplayable) and raises the sample count. var qcfg = &quick.Config{Rand: rand.New(rand.NewSource(1)), MaxCount: 1000} // The range/value parsers back every numeric matcher and filter (-mc, -fc, -ms, // -fs, ...). These property tests assert their invariants hold across a wide // input space via testing/quick (stdlib, no dependency), not just a few examples. // A "a-b" range with a < b parses to exactly {a, b}; a >= b is an error (the // regex matches but ValueRangeFromString rejects min >= max). func TestValueRange_RangeInvariant(t *testing.T) { f := func(a, b uint16) bool { lo, hi := int64(a), int64(b) vr, err := ValueRangeFromString(fmt.Sprintf("%d-%d", lo, hi)) if lo < hi { return err == nil && vr.Min == lo && vr.Max == hi } return err != nil } if err := quick.Check(f, qcfg); err != nil { t.Error(err) } } // A single non-negative integer parses to the degenerate range {n, n}. func TestValueRange_SingleValueInvariant(t *testing.T) { f := func(n uint32) bool { vr, err := ValueRangeFromString(strconv.FormatUint(uint64(n), 10)) return err == nil && vr.Min == int64(n) && vr.Max == int64(n) } if err := quick.Check(f, qcfg); err != nil { t.Error(err) } } // Arbitrary input never panics, and on success the returned range is always // well-formed (Min <= Max). func TestValueRange_NeverPanics(t *testing.T) { f := func(s string) bool { vr, err := ValueRangeFromString(s) return err != nil || vr.Min <= vr.Max } if err := quick.Check(f, qcfg); err != nil { t.Error(err) } } // A single float initializes optRange to a non-range delay whose Min holds the // parsed value. (Max is intentionally left unset for a single value: sleepIfNeeded // only reads Min when IsRange is false.) func TestOptRange_SingleFloatInvariant(t *testing.T) { f := func(whole uint16, frac uint8) bool { val := fmt.Sprintf("%d.%02d", whole, frac%100) want, _ := strconv.ParseFloat(val, 64) var o optRange if err := o.Initialize(val); err != nil { return false } return o.HasDelay && !o.IsRange && o.Min == want } if err := quick.Check(f, qcfg); err != nil { t.Error(err) } } // Arbitrary input never panics, and any non-empty value that initializes without // error must have HasDelay set (the contract of a successfully-parsed delay). func TestOptRange_NeverPanics(t *testing.T) { f := func(s string) bool { var o optRange err := o.Initialize(s) return err != nil || len(s) == 0 || o.HasDelay } if err := quick.Check(f, qcfg); err != nil { t.Error(err) } } ffuf-2.3.0/pkg/filter/000077500000000000000000000000001525032755300145175ustar00rootroot00000000000000ffuf-2.3.0/pkg/filter/concurrency_test.go000066400000000000000000000050561525032755300204450ustar00rootroot00000000000000package filter import ( "sync" "testing" "time" ) // TestMatcherManager_ConcurrentReadWrite regresses C1: the reader methods took // no lock while the writers did, so the write-side mutex protected writers only // from each other. With -ac at default threads a worker ranging GetFilters() // raced autocalibration's AddFilter, which the Go runtime aborts with // "concurrent map read and map write". Run under -race; before the fix this // crashes. func TestMatcherManager_ConcurrentReadWrite(t *testing.T) { mm := NewMatcherManager() if err := mm.AddMatcher("status", "all"); err != nil { t.Fatalf("AddMatcher: %v", err) } var wg sync.WaitGroup stop := make(chan struct{}) // writers: churn the maps like autocalibration and the interactive fc/fs // commands do. for w := 0; w < 4; w++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: _ = mm.AddFilter("size", "1337", false) mm.RemoveFilter("size") _ = mm.AddPerDomainFilter("host.example", "word", "9") mm.SetCalibratedForHost("host.example", true) mm.SetCalibrated(true) } } }() } // readers: exactly what every worker's isMatch does. for r := 0; r < 8; r++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: for range mm.GetFilters() { } for range mm.GetMatchers() { } for range mm.FiltersForDomain("host.example") { } _ = mm.Calibrated() _ = mm.CalibratedForDomain("host.example") } } }() } time.Sleep(200 * time.Millisecond) close(stop) wg.Wait() } // TestMatcherManager_PerDomainDoesNotAliasGlobal regresses F1: NewPerDomainFilter // stored the global filter map by reference, so a per-domain filter write leaked // into the global filter set (and thus applied to every host). func TestMatcherManager_PerDomainDoesNotAliasGlobal(t *testing.T) { mm := NewMatcherManager() if err := mm.AddFilter("size", "100", false); err != nil { t.Fatalf("AddFilter global: %v", err) } if err := mm.AddPerDomainFilter("host.example", "word", "5"); err != nil { t.Fatalf("AddPerDomainFilter: %v", err) } if _, leaked := mm.GetFilters()["word"]; leaked { t.Error("per-domain 'word' filter leaked into the global filter set (F1 aliasing)") } perDomain := mm.FiltersForDomain("host.example") if _, ok := perDomain["word"]; !ok { t.Error("per-domain filter set should contain its own 'word' filter") } if _, ok := perDomain["size"]; !ok { t.Error("per-domain filter set should inherit the global 'size' filter") } } ffuf-2.3.0/pkg/filter/count_equivalence_test.go000066400000000000000000000035571525032755300216300ustar00rootroot00000000000000package filter import ( "strconv" "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // The word/line counts are computed with bytes.Count(data, sep)+1 instead of // len(strings.Split(string(data), sep)) to avoid allocating a []string over the // whole response body. These tests pin the two to the same value across the edge // cases where an off-by-one would hide: empty body, only separators, and // leading/trailing separators. The filter is built with an exact [n,n] range so a // match proves the production count equals the old strings.Split count, and the // [n+1,n+1] miss proves it is not off by one. var countEquivalenceBodies = []string{ "", "a", "a b c", " ", " a ", "a\nb\nc", "\n\n", "abc", "a\n", "one two\nthree four\n", } func TestWordFilterCountMatchesSplit(t *testing.T) { for _, body := range countEquivalenceBodies { expected := len(strings.Split(body, " ")) resp := ffuf.Response{Data: []byte(body)} hit, _ := NewWordFilter(strconv.Itoa(expected)) if match, _ := hit.Filter(&resp); !match { t.Errorf("word count for %q: expected filter to match %d", body, expected) } miss, _ := NewWordFilter(strconv.Itoa(expected + 1)) if match, _ := miss.Filter(&resp); match { t.Errorf("word count for %q: filter matched %d, count is off by one", body, expected+1) } } } func TestLineFilterCountMatchesSplit(t *testing.T) { for _, body := range countEquivalenceBodies { expected := len(strings.Split(body, "\n")) resp := ffuf.Response{Data: []byte(body)} hit, _ := NewLineFilter(strconv.Itoa(expected)) if match, _ := hit.Filter(&resp); !match { t.Errorf("line count for %q: expected filter to match %d", body, expected) } miss, _ := NewLineFilter(strconv.Itoa(expected + 1)) if match, _ := miss.Filter(&resp); match { t.Errorf("line count for %q: filter matched %d, count is off by one", body, expected+1) } } } ffuf-2.3.0/pkg/filter/filter.go000066400000000000000000000165731525032755300163470ustar00rootroot00000000000000package filter import ( "fmt" "sync" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // MatcherManager handles both filters and matchers. // // Every access to the Matchers/Filters/PerDomainFilters maps goes through the // methods below under mu (an RWMutex). Readers RLock and return a *copy* of the // map: the request path (Job.isMatch) ranges the returned map after the method // has returned, so handing back the live map would let a worker iterate it while // autocalibration or an interactive filter command mutates it on another // goroutine, which the Go runtime aborts with "concurrent map read and map // write". Copying the map header (the FilterProvider values are immutable once // created) keeps the caller's iteration off the shared map. type MatcherManager struct { mu sync.RWMutex IsCalibrated bool Matchers map[string]ffuf.FilterProvider Filters map[string]ffuf.FilterProvider PerDomainFilters map[string]*PerDomainFilter } type PerDomainFilter struct { IsCalibrated bool Filters map[string]ffuf.FilterProvider } // NewPerDomainFilter copies the supplied (global) filter map. Storing the map by // reference aliased the global Filters, so a per-domain filter write mutated the // global filter set and leaked into every host. func NewPerDomainFilter(globfilters map[string]ffuf.FilterProvider) *PerDomainFilter { return &PerDomainFilter{IsCalibrated: false, Filters: copyFilterMap(globfilters)} } func (p *PerDomainFilter) SetCalibrated(value bool) { p.IsCalibrated = value } // copyFilterMap returns a shallow copy of a filter map. The FilterProvider values // are safe to share: they are replaced, never mutated, after creation. func copyFilterMap(in map[string]ffuf.FilterProvider) map[string]ffuf.FilterProvider { out := make(map[string]ffuf.FilterProvider, len(in)) for k, v := range in { out[k] = v } return out } 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.mu.Lock() defer f.mu.Unlock() f.IsCalibrated = value } func (f *MatcherManager) SetCalibratedForHost(host string, value bool) { f.mu.Lock() defer f.mu.Unlock() 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.mu.Lock() defer f.mu.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.mu.Lock() defer f.mu.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.mu.Lock() defer f.mu.Unlock() delete(f.Filters, name) } // AddMatcher adds a new matcher to Config func (f *MatcherManager) AddMatcher(name string, option string) error { f.mu.Lock() defer f.mu.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 { f.mu.RLock() defer f.mu.RUnlock() return copyFilterMap(f.Filters) } func (f *MatcherManager) GetMatchers() map[string]ffuf.FilterProvider { f.mu.RLock() defer f.mu.RUnlock() return copyFilterMap(f.Matchers) } func (f *MatcherManager) FiltersForDomain(domain string) map[string]ffuf.FilterProvider { f.mu.RLock() defer f.mu.RUnlock() if f.PerDomainFilters[domain] == nil { return copyFilterMap(f.Filters) } return copyFilterMap(f.PerDomainFilters[domain].Filters) } func (f *MatcherManager) CalibratedForDomain(domain string) bool { f.mu.RLock() defer f.mu.RUnlock() if f.PerDomainFilters[domain] != nil { return f.PerDomainFilters[domain].IsCalibrated } return false } func (f *MatcherManager) Calibrated() bool { f.mu.RLock() defer f.mu.RUnlock() return f.IsCalibrated } // Matches reports whether resp passes the configured matchers and filters. It is // the match decision that used to live in Job.isMatch, moved here so it sits with // the rule data it decides over and is unit-testable without an engine. perHost // selects the per-domain filter set (autocalibration -ach); matcherMode/filterMode // are the and/or combination modes. GetFilters/GetMatchers/FiltersForDomain each // take the read lock and return a copy, so the ranges below never touch the live // maps. func (f *MatcherManager) Matches(resp *ffuf.Response, perHost bool, matcherMode string, filterMode string) bool { matched := false var filters map[string]ffuf.FilterProvider if perHost { filters = f.FiltersForDomain(ffuf.HostURLFromRequest(*resp.Request)) } else { filters = f.GetFilters() } matchers := f.GetMatchers() for _, m := range matchers { match, err := m.Filter(resp) if err != nil { continue } if match { matched = true } else if matcherMode == "and" { // we already know this isn't an "and" match return false } } // The response was not matched, return before running filters if !matched { return false } for _, flt := range filters { fv, err := flt.Filter(resp) if err != nil { continue } if fv { if filterMode == "or" { // return early, as a filter matched return false } } else { if filterMode == "and" { // return early, as not all filters matched in "and" mode return true } } } if len(filters) > 0 && filterMode == "and" { // we did not return early, so all filters were matched return false } return true } ffuf-2.3.0/pkg/filter/filter_test.go000066400000000000000000000022141525032755300173710ustar00rootroot00000000000000package 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.3.0/pkg/filter/fromconfig.go000066400000000000000000000041741525032755300172050ustar00rootroot00000000000000package filter import ( "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // FromConfig builds a MatcherManager from the parsed ConfigOptions. It is the pure, // global-free core of what main.SetupFilters used to do inline, so matcher/filter // setup is now unit-testable without the CLI flag package. The one CLI-explicit // decision - whether to install the default status matcher - needs the global flag // set, so it stays in main and is passed in as addDefaultStatusMatcher. func FromConfig(opts *ffuf.ConfigOptions, addDefaultStatusMatcher bool) (ffuf.MatcherManager, error) { errs := ffuf.NewMultierror() mm := NewMatcherManager() if addDefaultStatusMatcher { if err := mm.AddMatcher("status", opts.Matcher.Status); err != nil { errs.Add(err) } } if opts.Filter.Status != "" { if err := mm.AddFilter("status", opts.Filter.Status, false); err != nil { errs.Add(err) } } if opts.Filter.Size != "" { if err := mm.AddFilter("size", opts.Filter.Size, false); err != nil { errs.Add(err) } } if opts.Filter.Regexp != "" { if err := mm.AddFilter("regexp", opts.Filter.Regexp, false); err != nil { errs.Add(err) } } if opts.Filter.Words != "" { if err := mm.AddFilter("word", opts.Filter.Words, false); err != nil { errs.Add(err) } } if opts.Filter.Lines != "" { if err := mm.AddFilter("line", opts.Filter.Lines, false); err != nil { errs.Add(err) } } if opts.Filter.Time != "" { if err := mm.AddFilter("time", opts.Filter.Time, false); err != nil { errs.Add(err) } } if opts.Matcher.Size != "" { if err := mm.AddMatcher("size", opts.Matcher.Size); err != nil { errs.Add(err) } } if opts.Matcher.Regexp != "" { if err := mm.AddMatcher("regexp", opts.Matcher.Regexp); err != nil { errs.Add(err) } } if opts.Matcher.Words != "" { if err := mm.AddMatcher("word", opts.Matcher.Words); err != nil { errs.Add(err) } } if opts.Matcher.Lines != "" { if err := mm.AddMatcher("line", opts.Matcher.Lines); err != nil { errs.Add(err) } } if opts.Matcher.Time != "" { if err := mm.AddMatcher("time", opts.Matcher.Time); err != nil { errs.Add(err) } } return mm, errs.ErrorOrNil() } ffuf-2.3.0/pkg/filter/fromconfig_test.go000066400000000000000000000026451525032755300202450ustar00rootroot00000000000000package filter import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestFromConfig_DefaultStatusMatcher covers the one behavior main used to drive // via flag.Visit: whether the default status matcher is installed. It is now // testable without the flag package. func TestFromConfig_DefaultStatusMatcher(t *testing.T) { opts := ffuf.NewConfigOptions() mm, err := FromConfig(opts, true) if err != nil { t.Fatalf("FromConfig: %v", err) } if _, ok := mm.GetMatchers()["status"]; !ok { t.Error("default status matcher should be installed when addDefaultStatusMatcher=true") } mm2, err := FromConfig(opts, false) if err != nil { t.Fatalf("FromConfig: %v", err) } if _, ok := mm2.GetMatchers()["status"]; ok { t.Error("no status matcher should be installed when addDefaultStatusMatcher=false and none configured") } } // TestFromConfig_FiltersAndMatchers checks that configured filters/matchers are // installed from the ConfigOptions values. func TestFromConfig_FiltersAndMatchers(t *testing.T) { opts := ffuf.NewConfigOptions() opts.Filter.Size = "100" opts.Matcher.Regexp = "admin" mm, err := FromConfig(opts, false) if err != nil { t.Fatalf("FromConfig: %v", err) } if _, ok := mm.GetFilters()["size"]; !ok { t.Error("size filter should be installed from opts.Filter.Size") } if _, ok := mm.GetMatchers()["regexp"]; !ok { t.Error("regexp matcher should be installed from opts.Matcher.Regexp") } } ffuf-2.3.0/pkg/filter/lines.go000066400000000000000000000033401525032755300161600ustar00rootroot00000000000000package filter import ( "bytes" "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) { // bytes.Count(...)+1 matches len(strings.Split(...)) for a single-byte // separator without allocating a []string of the whole body. See runner. linesSize := bytes.Count(response.Data, []byte("\n")) + 1 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.3.0/pkg/filter/lines_test.go000066400000000000000000000021631525032755300172210ustar00rootroot00000000000000package 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.3.0/pkg/filter/matches_test.go000066400000000000000000000033401525032755300175310ustar00rootroot00000000000000package filter import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // resp builds a minimal Response for the non-per-host match path (Request is only // dereferenced when perHost is true). func resp(status, size int64) *ffuf.Response { return &ffuf.Response{StatusCode: status, ContentLength: size} } // TestMatches_StatusMatcher covers the basic matcher path now that the decision // lives on MatcherManager instead of Job.isMatch. func TestMatches_StatusMatcher(t *testing.T) { mm := NewMatcherManager() if err := mm.AddMatcher("status", "200,204"); err != nil { t.Fatalf("AddMatcher: %v", err) } if !mm.Matches(resp(200, 10), false, "or", "or") { t.Error("200 should match status 200,204") } if mm.Matches(resp(404, 10), false, "or", "or") { t.Error("404 should not match status 200,204") } } // TestMatches_MatcherModeAnd checks the and-mode combination: every matcher must // pass. func TestMatches_MatcherModeAnd(t *testing.T) { mm := NewMatcherManager() _ = mm.AddMatcher("status", "200") _ = mm.AddMatcher("size", "10") if !mm.Matches(resp(200, 10), false, "and", "or") { t.Error("200 + size 10 should match in and-mode") } if mm.Matches(resp(200, 99), false, "and", "or") { t.Error("200 + size 99 should NOT match in and-mode (size fails)") } } // TestMatches_FilterDrops checks that a filter removes an otherwise-matched // response. func TestMatches_FilterDrops(t *testing.T) { mm := NewMatcherManager() _ = mm.AddMatcher("status", "all") _ = mm.AddFilter("size", "10", false) if mm.Matches(resp(200, 10), false, "or", "or") { t.Error("a size-10 response should be filtered out") } if !mm.Matches(resp(200, 20), false, "or", "or") { t.Error("a size-20 response should survive the size-10 filter") } } ffuf-2.3.0/pkg/filter/regex.go000066400000000000000000000024711525032755300161640ustar00rootroot00000000000000package 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.3.0/pkg/filter/regexp_test.go000066400000000000000000000021421525032755300173760ustar00rootroot00000000000000package 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.3.0/pkg/filter/size.go000066400000000000000000000030241525032755300160170ustar00rootroot00000000000000package 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.3.0/pkg/filter/size_test.go000066400000000000000000000017261525032755300170650ustar00rootroot00000000000000package 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.3.0/pkg/filter/status.go000066400000000000000000000037211525032755300163740ustar00rootroot00000000000000package 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.3.0/pkg/filter/status_test.go000066400000000000000000000020241525032755300174260ustar00rootroot00000000000000package 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.3.0/pkg/filter/time.go000077500000000000000000000027041525032755300160120ustar00rootroot00000000000000package 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.Duration.Milliseconds() > f.ms { return true, nil } } else if f.lt { if response.Duration.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.3.0/pkg/filter/time_test.go000077500000000000000000000020571525032755300170520ustar00rootroot00000000000000package 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"), Duration: 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.3.0/pkg/filter/words.go000066400000000000000000000033371525032755300162120ustar00rootroot00000000000000package filter import ( "bytes" "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) { // bytes.Count(...)+1 matches len(strings.Split(...)) for a single-byte // separator without allocating a []string of the whole body. See runner. wordsSize := bytes.Count(response.Data, []byte(" ")) + 1 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.3.0/pkg/filter/words_test.go000066400000000000000000000021621525032755300172440ustar00rootroot00000000000000package 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.3.0/pkg/input/000077500000000000000000000000001525032755300143715ustar00rootroot00000000000000ffuf-2.3.0/pkg/input/command.go000066400000000000000000000041401525032755300163350ustar00rootroot00000000000000package 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 cmd := exec.Command(c.shell, SHELL_ARG, c.command) // Pass FFUF_NUM to the child via its own environment rather than a // process-global os.Setenv, which would race any concurrent caller. cmd.Env = append(os.Environ(), "FFUF_NUM="+strconv.Itoa(c.count)) 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.3.0/pkg/input/const.go000066400000000000000000000001261525032755300160450ustar00rootroot00000000000000// +build !windows package input const ( SHELL_CMD = "/bin/sh" SHELL_ARG = "-c" ) ffuf-2.3.0/pkg/input/const_windows.go000066400000000000000000000001251525032755300176160ustar00rootroot00000000000000// +build windows package input const ( SHELL_CMD = "cmd.exe" SHELL_ARG = "/C" ) ffuf-2.3.0/pkg/input/input.go000066400000000000000000000136311525032755300160630ustar00rootroot00000000000000package 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.3.0/pkg/input/wordlist.go000066400000000000000000000077411525032755300166000ustar00rootroot00000000000000package 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.3.0/pkg/input/wordlist_test.go000066400000000000000000000006371525032755300176340ustar00rootroot00000000000000package 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.3.0/pkg/interactive/000077500000000000000000000000001525032755300155475ustar00rootroot00000000000000ffuf-2.3.0/pkg/interactive/posix.go000066400000000000000000000001721525032755300172400ustar00rootroot00000000000000// +build !windows package interactive import "os" func termHandle() (*os.File, error) { return os.Open("/dev/tty") } ffuf-2.3.0/pkg/interactive/termhandler.go000066400000000000000000000260231525032755300204060ustar00rootroot00000000000000package interactive import ( "bufio" "fmt" "strconv" "strings" "github.com/ffuf/ffuf/v2/pkg/engine" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) type interactive struct { Job *engine.Job paused bool } func Handle(job *engine.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 { // Suppress live result printing BEFORE pausing dispatch, so inflight // requests completing cannot scroll the banner off screen, then show // the banner immediately (no drain wait). i.Job.Output.SetPaused(true) i.Job.Pause() i.printBanner() } else { i.resumeJob() } } else { switch args[0] { case "?": i.printHelp() case "help": i.printHelp() case "resume": i.paused = false i.resumeJob() case "restart": i.Job.Reset(false) i.paused = false i.Job.Output.SetPaused(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() } } // resultProbe rebuilds the minimal Response needed to re-evaluate the active // filters against an already-collected result. ContentLines must come from the // result's line count, not its length: feeding ContentLength here made an // in-console "fl" (line count) filter match against the wrong number. func resultProbe(res ffuf.Result) *ffuf.Response { return &ffuf.Response{ StatusCode: res.StatusCode, ContentLines: res.ContentLines, ContentWords: res.ContentWords, ContentLength: res.ContentLength, } } func (i *interactive) refreshResults() { filters := i.Job.Config.MatcherManager.GetFilters() // Keep a result only if it survives every active filter. The filtering runs // under the output lock via FilterCurrentResults, so a match arriving mid- // refresh is not dropped (the previous GetCurrentResults/SetCurrentResults // pair had a read-modify-write gap that lost it, and it double-counted a // result once per filter it passed). i.Job.Output.FilterCurrentResults(func(res ffuf.Result) bool { for _, filter := range filters { filterOut, _ := filter.Filter(resultProbe(res)) if filterOut { return false } } return true }) } 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!") } } } // resumeJob restores live result printing and resumes the job. If any matches // arrived while the console was open, it reports the count first so the user // knows there are new (recorded but unprinted) findings to inspect with "show". func (i *interactive) resumeJob() { if n := i.Job.Output.PendingResults(); n > 0 { i.Job.Output.Info(fmt.Sprintf("%d new match(es) found while paused. Type \"show\" to display them.", n)) } i.Job.Output.SetPaused(false) i.Job.Resume() } 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() { // Surface matches that arrived while paused right in the prompt, so a user // sitting in the console sees findings queuing up. if n := i.Job.Output.PendingResults(); n > 0 { i.Job.Output.Raw(fmt.Sprintf("[%d new] > ", n)) } else { 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.Rate.CurrentConfiguredRate()) 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.3.0/pkg/interactive/termhandler_test.go000066400000000000000000000020271525032755300214430ustar00rootroot00000000000000package interactive import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestResultProbeUsesLineCount guards the fl-filter bug: the probe rebuilt to // re-evaluate filters against an already-collected result must take ContentLines // from the result's line count, not its length. Before the fix an in-console // "fl" (line count) filter compared against ContentLength. func TestResultProbeUsesLineCount(t *testing.T) { res := ffuf.Result{ StatusCode: 200, ContentLength: 4096, ContentWords: 100, ContentLines: 7, } probe := resultProbe(res) if probe.ContentLines != 7 { t.Errorf("resultProbe ContentLines = %d, want 7 (fed ContentLength instead of ContentLines)", probe.ContentLines) } if probe.ContentLength != 4096 { t.Errorf("resultProbe ContentLength = %d, want 4096", probe.ContentLength) } if probe.ContentWords != 100 { t.Errorf("resultProbe ContentWords = %d, want 100", probe.ContentWords) } if probe.StatusCode != 200 { t.Errorf("resultProbe StatusCode = %d, want 200", probe.StatusCode) } } ffuf-2.3.0/pkg/interactive/windows.go000066400000000000000000000004571525032755300175760ustar00rootroot00000000000000// +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.3.0/pkg/output/000077500000000000000000000000001525032755300145725ustar00rootroot00000000000000ffuf-2.3.0/pkg/output/audit.go000066400000000000000000000020121525032755300162220ustar00rootroot00000000000000package output import ( "encoding/json" "fmt" "os" "reflect" "sync" ) type AuditLogger struct { file *os.File lock sync.Mutex } func NewAuditLogger(filename string) (*AuditLogger, error) { f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return nil, err } auditLogger := &AuditLogger{file: f} return auditLogger, nil } func (logger *AuditLogger) Close() { logger.file.Close() } func (logger *AuditLogger) Write(data interface{}) error { logger.lock.Lock() defer logger.lock.Unlock() d := struct { Type string Data interface{} }{ reflect.TypeOf(data).String(), data, } j, err := json.Marshal(d) if err != nil { return fmt.Errorf("could not marshal json data: %s", err) } _, err = logger.file.Write(j) if err != nil { return fmt.Errorf("could not write json data to audit log: %s", err) } _, err = logger.file.Write([]byte("\n")) if err != nil { return fmt.Errorf("could not write newline to underlying io.Writer: %w", err) } return nil } ffuf-2.3.0/pkg/output/audit_test.go000066400000000000000000000102111525032755300172610ustar00rootroot00000000000000package output import ( "os" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func TestAuditLogger(t *testing.T) { file, _ := os.CreateTemp("", "prefix") filename := file.Name() file.Close() audit, err := NewAuditLogger(filename) if err != nil { t.Errorf("Error creating audit logger: %s", err) } if audit == nil { t.Errorf("Audit was nil, expected non-nil") } defer audit.Close() err = os.Chmod(filename, 000) if err != nil { t.Errorf("Error executing Chmod: %s", err) } audit, err = NewAuditLogger(filename) if err == nil { t.Errorf("Error was nil, expected non-nil") } if audit != nil { t.Errorf("Audit was non-nil, expected nil") } err = os.Chmod(filename, 6550) if err != nil { t.Errorf("Error executing Chmod: %s", err) } err = os.Remove(filename) if err != nil { t.Errorf("Error executing Remove: %s", err) } } func TestAuditLogWrite(t *testing.T) { expected := `{"Type":"ffuf.Config","Data":{"auditlog":"","autocalibration":false,"autocalibration_keyword":"","autocalibration_perhost":false,"autocalibration_strategies":null,"autocalibration_strings":null,"colors":false,"cmdline":"","configfile":"","postdata":"{\"quote\":\"I'll still be here tomorrow to high five you yesterday, my friend. Peace.\"}","debuglog":"","delay":{"Min":0,"Max":0,"IsRange":false,"HasDelay":false},"dirsearch_compatibility":false,"encoders":null,"extensions":null,"fmode":"","follow_redirects":false,"headers":{"Content-Type":"application/json","baz":"wibble","foo":"bar"},"ignorebody":false,"ignore_wordlist_comments":false,"inputmode":"","cmd_inputnum":0,"inputproviders":null,"inputshell":"","json":false,"matchers":null,"mmode":"","maxtime":0,"maxtime_job":0,"method":"POST","noninteractive":false,"outputdirectory":"","outputfile":"","outputformat":"","OutputSkipEmptyFile":false,"proxyurl":"","quiet":false,"rate":0,"raw":false,"recursion":false,"recursion_depth":0,"recursion_strategy":"","replayproxyurl":"","requestfile":"","requestproto":"","scraperfile":"","scrapers":"","sni":"","stop_403":false,"stop_all":false,"stop_errors":false,"threads":0,"timeout":0,"url":"http://example.com/aaaa","verbose":false,"wordlists":null,"http2":false,"client-cert":"","client-key":"","preflights":null,"postflights":null,"preflight_mode":"","preflight_error":""}} {"Type":"ffuf.Request","Data":{"Method":"POST","Host":"","Url":"http://example.com/aaaa","Headers":{"Content-Type":"application/json","baz":"wibble","foo":"bar"},"Data":"eyJxdW90ZSI6IkknbGwgc3RpbGwgYmUgaGVyZSB0b21vcnJvdyB0byBoaWdoIGZpdmUgeW91IHllc3RlcmRheSwgbXkgZnJpZW5kLiBQZWFjZS4ifQ==","Input":null,"Position":0,"Raw":"","Error":"","Timestamp":"0001-01-01T00:00:00Z"}} ` 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.\"}" req := ffuf.Request{Method: "POST", Url: "http://example.com/aaaa", Headers: headers, Data: []byte(data)} config := ffuf.Config{Method: "POST", Url: "http://example.com/aaaa", Headers: headers, Data: data} file, _ := os.CreateTemp("", "prefix") filename := file.Name() file.Close() audit, err := NewAuditLogger(filename) if err != nil { t.Errorf("Error creating audit logger: %s", err) } if audit == nil { t.Errorf("Audit was nil, expected non-nil") } // test attempting to log a bung object and erroring type A struct{ A *A } // cyclic struct to break JSON Marshalling a := A{} a.A = &a err = audit.Write(a) if err == nil { t.Errorf("Error was nil, expected non-nil") } // test writing the config and request objects err = audit.Write(config) if err != nil { t.Errorf("Error was not nil, expected nil") } err = audit.Write(req) if err != nil { t.Errorf("Error was not nil, expected nil") } audit.Close() // re-read and check the audit log f, err := os.ReadFile(filename) if err != nil { t.Errorf("Error reading audit log: %s", err) } if string(f) != expected { t.Errorf("Audit write did not produce the expected logfile.") } // writing to a closed audit log should fail err = audit.Write(config) if err == nil { t.Errorf("Error was nil, expected non-nil") } } ffuf-2.3.0/pkg/output/const.go000066400000000000000000000003721525032755300162510ustar00rootroot00000000000000// +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.3.0/pkg/output/const_windows.go000066400000000000000000000003151525032755300200200ustar00rootroot00000000000000// +build windows package output const ( TERMINAL_CLEAR_LINE = "\r\r" ANSI_CLEAR = "" ANSI_RED = "" ANSI_GREEN = "" ANSI_BLUE = "" ANSI_YELLOW = "" ) ffuf-2.3.0/pkg/output/file_csv.go000066400000000000000000000034261525032755300167200ustar00rootroot00000000000000package 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.3.0/pkg/output/file_csv_test.go000066400000000000000000000014551525032755300177570ustar00rootroot00000000000000package 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}, "FFUFHASH": {65}}, 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", "A"}) { t.Errorf("CSV was not generated in expected format") } } ffuf-2.3.0/pkg/output/file_html.go000066400000000000000000000166671525032755300171040ustar00rootroot00000000000000package 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.3.0/pkg/output/file_json.go000066400000000000000000000052361525032755300170770ustar00rootroot00000000000000package 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.3.0/pkg/output/file_md.go000066400000000000000000000054061525032755300165250ustar00rootroot00000000000000package 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.3.0/pkg/output/stdout.go000066400000000000000000000443711525032755300164540ustar00rootroot00000000000000package output import ( "crypto/md5" "encoding/json" "fmt" "os" "path" "sort" "strconv" "strings" "sync" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) const ( BANNER_HEADER = ` /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ ` BANNER_SEP = "________________________________________________" ) type Stdoutput struct { config *ffuf.Config fuzzkeywords []string resultMutex sync.Mutex // guards Results, CurrentResults and paused; Result is called from worker goroutines Results []ffuf.Result CurrentResults []ffuf.Result stdoutIsTerminal bool stderrIsTerminal bool paused bool // when set, Result records matches but does not print them } 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) outp.stdoutIsTerminal = isTerminal(os.Stdout) outp.stderrIsTerminal = isTerminal(os.Stderr) for _, ip := range conf.InputProviders { outp.fuzzkeywords = append(outp.fuzzkeywords, ip.Keyword) } sort.Strings(outp.fuzzkeywords) return &outp } // isTerminal reports whether f is an interactive terminal, as opposed to a // regular file or a pipe. This also treats other character devices (e.g. // /dev/null) as terminals, which is harmless here since it only affects // whether a handful of extra control bytes get written to a destination // that isn't going to be read back anyway. func isTerminal(f *os.File) bool { fi, err := f.Stat() if err != nil { return false } return fi.Mode()&os.ModeCharDevice != 0 } // stderrClear returns the terminal control sequence used to redraw the // current line, or an empty string when stderr isn't an interactive // terminal. Without this, redirecting stderr to a file or another process // leaves raw control characters in the output. func (s *Stdoutput) stderrClear() string { if s.stderrIsTerminal { return TERMINAL_CLEAR_LINE } return "" } // stdoutClear is the stdout equivalent of stderrClear, used when printing // results that overwrite the in-progress progress bar line. func (s *Stdoutput) stdoutClear() string { if s.stdoutIsTerminal { return TERMINAL_CLEAR_LINE } return "" } // ansiClear returns the ANSI reset code used to close a color started by // colorize(), or an empty string when -c wasn't requested. Without this, // result lines carry a trailing reset code even when nothing was colored. func (s *Stdoutput) ansiClear() string { if s.config.Colors { return ANSI_CLEAR } return "" } func (s *Stdoutput) Banner() { version := ffuf.FormattedVersion() if s.config.Colors { // Only colorize the heart when colors are enabled; otherwise a redirected // or non-terminal stderr gets raw ANSI escape bytes in the banner. version = strings.ReplaceAll(version, "<3", fmt.Sprintf("%s<3%s", ANSI_RED, ANSI_CLEAR)) } fmt.Fprintf(os.Stderr, "%s\n %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.resultMutex.Lock() s.CurrentResults = make([]ffuf.Result, 0) s.resultMutex.Unlock() } // Cycle moves the CurrentResults to Results and resets the results slice func (s *Stdoutput) Cycle() { s.resultMutex.Lock() s.Results = append(s.Results, s.CurrentResults...) s.CurrentResults = make([]ffuf.Result, 0) s.resultMutex.Unlock() } // GetResults returns the result slice func (s *Stdoutput) GetCurrentResults() []ffuf.Result { s.resultMutex.Lock() defer s.resultMutex.Unlock() out := make([]ffuf.Result, len(s.CurrentResults)) copy(out, s.CurrentResults) return out } // SetResults sets the result slice func (s *Stdoutput) SetCurrentResults(results []ffuf.Result) { s.resultMutex.Lock() s.CurrentResults = results s.resultMutex.Unlock() } // FilterCurrentResults keeps only the results for which keep returns true. The // read-filter-write runs under resultMutex, so a concurrent Result() append is // not lost through a stale snapshot the way a caller-side GetCurrentResults / // SetCurrentResults pair would drop it. func (s *Stdoutput) FilterCurrentResults(keep func(ffuf.Result) bool) { s.resultMutex.Lock() defer s.resultMutex.Unlock() filtered := make([]ffuf.Result, 0, len(s.CurrentResults)) for _, r := range s.CurrentResults { if keep(r) { filtered = append(filtered, r) } } s.CurrentResults = filtered } 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 ::", s.stderrClear(), 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", s.stderrClear(), infostring) } else { fmt.Fprintf(os.Stderr, "%s[%sINFO%s] %s\n\n", s.stderrClear(), 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", s.stderrClear(), errstring) } else { fmt.Fprintf(os.Stderr, "%s[%sERR%s] %s\n", s.stderrClear(), 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", s.stderrClear(), warnstring) } else { fmt.Fprintf(os.Stderr, "%s[%sWARN%s] %s\n", s.stderrClear(), ANSI_RED, ANSI_CLEAR, warnstring) } } } func (s *Stdoutput) Raw(output string) { fmt.Fprintf(os.Stderr, "%s%s", s.stderrClear(), 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 // Snapshot the results under the lock so a concurrent Result() cannot race the // file writers, then write outside the lock. s.resultMutex.Lock() all := make([]ffuf.Result, 0, len(s.Results)+len(s.CurrentResults)) all = append(all, s.Results...) all = append(all, s.CurrentResults...) s.resultMutex.Unlock() if s.config.OutputSkipEmptyFile && len(all) == 0 { s.Info("No results and -or defined, output file not written.") return err } switch format { case "all": err = s.writeToAll(filename, s.config, all) case "json": err = writeJSON(filename, s.config, all) case "ejson": err = writeEJSON(filename, s.config, all) case "html": err = writeHTML(filename, s.config, all) case "md": err = writeMarkdown(filename, s.config, all) case "csv": err = writeCSV(filename, s.config, all, false) case "ecsv": err = writeCSV(filename, s.config, all, 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.Duration, ResultFile: resp.ResultFile, Host: resp.Request.Host, } s.resultMutex.Lock() paused := s.paused // Printed is the single source of truth for the pending count. Mark it under // the same lock as the append, so a later filter change that prunes this // result from CurrentResults (via FilterCurrentResults) also removes it from // the pending count - the count can never contradict what "show" displays. sResult.Printed = !paused s.CurrentResults = append(s.CurrentResults, sResult) s.resultMutex.Unlock() // While the interactive console is open we still record the match above (so // show, savejson and -o output stay complete) but do not stream it to the // terminal - otherwise inflight requests completing after the user opens the // console would scroll the banner off screen. if !paused { s.PrintResult(sResult) } } // SetPaused toggles whether Result streams matches to the live terminal. While // paused (the interactive console is open) matches are recorded but not printed, // so inflight requests completing cannot scroll the console off screen. Leaving // a pause (paused == false) marks every held result as shown, since resume // reports their count to the user; a subsequent pause then only counts matches // that are genuinely new. func (s *Stdoutput) SetPaused(paused bool) { s.resultMutex.Lock() s.paused = paused if !paused { for i := range s.CurrentResults { s.CurrentResults[i].Printed = true } } s.resultMutex.Unlock() } // PendingResults returns the number of held matches not yet shown to the user: // results recorded while paused that still survive the active filters. It is // derived from CurrentResults, so a filter change that prunes a held result // drops it from this count in the same operation. func (s *Stdoutput) PendingResults() int { s.resultMutex.Lock() defer s.resultMutex.Unlock() n := 0 for _, r := range s.CurrentResults { if !r.Printed { n++ } } return n } 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", s.stdoutClear(), s.colorize(res.StatusCode), res.StatusCode, res.ContentLength, res.ContentWords, res.ContentLines, res.Duration.Milliseconds(), s.ansiClear()) reslines := "" if s.config.Verbose { reslines = fmt.Sprintf("%s%s| URL | %s\n", reslines, s.stdoutClear(), res.Url) redirectLocation := res.RedirectLocation if redirectLocation != "" { reslines = fmt.Sprintf("%s%s| --> | %s\n", reslines, s.stdoutClear(), redirectLocation) } } if res.ResultFile != "" { reslines = fmt.Sprintf("%s%s| RES | %s\n", reslines, s.stdoutClear(), 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, s.stdoutClear(), k, strconv.Itoa(res.Position)) } else { // Wordlist input reslines = fmt.Sprintf(res_str, reslines, s.stdoutClear(), k, res.Input[k]) } } if len(res.ScraperData) > 0 { reslines = fmt.Sprintf("%s%s| SCR |\n", reslines, s.stdoutClear()) for k, vslice := range res.ScraperData { for _, v := range vslice { reslines = fmt.Sprintf(res_str, reslines, s.stdoutClear(), 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", s.stdoutClear(), s.colorize(res.StatusCode), s.prepareInputsOneLine(res), res.StatusCode, res.ContentLength, res.ContentWords, res.ContentLines, res.Duration.Milliseconds(), s.ansiClear()) 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, s.stderrClear()) 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.3.0/pkg/output/stdout_pause_test.go000066400000000000000000000107631525032755300207060ustar00rootroot00000000000000package output import ( "fmt" "strings" "sync" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func resp(i int) ffuf.Response { return ffuf.Response{ StatusCode: 200, Request: &ffuf.Request{ Input: map[string][]byte{"FUZZ": []byte(fmt.Sprintf("w%d", i))}, Url: fmt.Sprintf("http://example/%d", i), }, } } // TestResultSuppressedWhilePaused locks the core fix: while the interactive // console is open (SetPaused(true)), Result must record the match but write // nothing to the terminal, so inflight requests completing cannot scroll the // console banner off screen. The recorded results must still be retrievable // (show / savejson / -o output stay complete). func TestResultSuppressedWhilePaused(t *testing.T) { s := NewStdoutput(&ffuf.Config{}) s.stdoutIsTerminal = false s.SetPaused(true) const n = 5 out := captureStdout(t, func() { for i := 0; i < n; i++ { s.Result(resp(i)) } }) if out != "" { t.Errorf("Result printed to stdout while paused: %q", out) } if got := len(s.GetCurrentResults()); got != n { t.Errorf("paused Result did not record: got %d results, want %d", got, n) } if got := s.PendingResults(); got != n { t.Errorf("PendingResults = %d, want %d", got, n) } } // TestResultPrintsWhenNotPaused is the negative control: with no pause in // effect, Result streams to the terminal and PendingResults stays zero. func TestResultPrintsWhenNotPaused(t *testing.T) { s := NewStdoutput(&ffuf.Config{}) s.stdoutIsTerminal = false out := captureStdout(t, func() { s.Result(resp(1)) }) if !strings.Contains(out, "http://example/1") && !strings.Contains(out, "Status: 200") { t.Errorf("Result did not print while unpaused: %q", out) } if got := s.PendingResults(); got != 0 { t.Errorf("PendingResults = %d while unpaused, want 0", got) } } // TestPendingReconcilesWithFilter is the fix for the correctness gap: a held // result that a mid-pause filter change prunes from CurrentResults must also // drop out of the pending count, so the "N new matches" report can never // contradict what "show" displays. func TestPendingReconcilesWithFilter(t *testing.T) { s := NewStdoutput(&ffuf.Config{}) s.stdoutIsTerminal = false s.SetPaused(true) s.Result(resp(1)) s.Result(resp(2)) if got := s.PendingResults(); got != 2 { t.Fatalf("PendingResults = %d, want 2", got) } // Simulate an interactive filter change that keeps only w1, as // refreshResults does via FilterCurrentResults. s.FilterCurrentResults(func(r ffuf.Result) bool { return string(r.Input["FUZZ"]) == "w1" }) if got := s.PendingResults(); got != 1 { t.Errorf("PendingResults = %d after filtering out a held result, want 1 (count must track CurrentResults)", got) } if got := len(s.GetCurrentResults()); got != 1 { t.Errorf("CurrentResults = %d, want 1", got) } } // TestUnpauseMarksHeldSeen proves resume acknowledges held results: after // leaving a pause a subsequent pause with no new matches reports zero, rather // than re-counting results the user was already told about. func TestUnpauseMarksHeldSeen(t *testing.T) { s := NewStdoutput(&ffuf.Config{}) s.stdoutIsTerminal = false s.SetPaused(true) s.Result(resp(1)) s.Result(resp(2)) if got := s.PendingResults(); got != 2 { t.Fatalf("PendingResults = %d, want 2", got) } s.SetPaused(false) // resume acknowledges the held results if got := s.PendingResults(); got != 0 { t.Errorf("PendingResults = %d after resume, want 0 (held results not marked seen)", got) } s.SetPaused(true) // pause again, no new matches arrive if got := s.PendingResults(); got != 0 { t.Errorf("PendingResults = %d on re-pause, want 0 (old results re-counted as new)", got) } } // TestConcurrentResultWhilePaused runs Result from many goroutines while paused // (as the engine's worker pool does) and asserts nothing is lost and nothing is // printed. Run with -race to catch a data race on paused/Printed directly. func TestConcurrentResultWhilePaused(t *testing.T) { s := NewStdoutput(&ffuf.Config{}) s.stdoutIsTerminal = false s.SetPaused(true) const n = 200 out := captureStdout(t, func() { var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() s.Result(resp(i)) }(i) } wg.Wait() }) if out != "" { t.Errorf("Result printed to stdout while paused under concurrency: %q", out) } if got := len(s.GetCurrentResults()); got != n { t.Errorf("got %d results, want %d (results lost while paused)", got, n) } if got := s.PendingResults(); got != n { t.Errorf("PendingResults = %d, want %d", got, n) } } ffuf-2.3.0/pkg/output/stdout_race_test.go000066400000000000000000000020161525032755300204730ustar00rootroot00000000000000package output import ( "fmt" "sync" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestStdoutput_ConcurrentResult locks the fix for an unsynchronized append: // Result is called from the engine's worker goroutines, and it appended to // CurrentResults without a lock, so under concurrency results were silently // lost (and -race flagged the write). Every result must survive. // // Run with -race to also catch the data race directly. func TestStdoutput_ConcurrentResult(t *testing.T) { conf := &ffuf.Config{} s := NewStdoutput(conf) const n = 200 var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() s.Result(ffuf.Response{ StatusCode: 200, Request: &ffuf.Request{ Input: map[string][]byte{"FUZZ": []byte(fmt.Sprintf("w%d", i))}, Url: fmt.Sprintf("http://example/%d", i), }, }) }(i) } wg.Wait() if got := len(s.GetCurrentResults()); got != n { t.Errorf("got %d results, want %d (results lost to the unsynchronized append)", got, n) } } ffuf-2.3.0/pkg/output/stdout_test.go000066400000000000000000000104141525032755300175020ustar00rootroot00000000000000package output import ( "bytes" "io" "os" "strings" "testing" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // captureStdout redirects os.Stdout to an in-memory pipe for the duration of // fn and returns everything written to it. func captureStdout(t *testing.T, fn func()) string { t.Helper() return captureStream(t, &os.Stdout, fn) } // captureStderr is the os.Stderr equivalent of captureStdout. func captureStderr(t *testing.T, fn func()) string { t.Helper() return captureStream(t, &os.Stderr, fn) } func captureStream(t *testing.T, stream **os.File, fn func()) string { t.Helper() orig := *stream r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %s", err) } *stream = w defer func() { *stream = orig }() fn() w.Close() var buf bytes.Buffer if _, err := io.Copy(&buf, r); err != nil { t.Fatalf("failed to read captured output: %s", err) } return buf.String() } func TestResultOutputOmitsControlCharsWhenStdoutIsNotATerminal(t *testing.T) { conf := &ffuf.Config{} outp := NewStdoutput(conf) // A pipe (as used by captureStdout, and as ffuf sees when its stdout is // redirected to a file with `>`) is never a terminal. outp.stdoutIsTerminal = false res := ffuf.Result{StatusCode: 200, ContentLength: 42, ContentWords: 3, ContentLines: 1} out := captureStdout(t, func() { outp.PrintResult(res) }) if strings.Contains(out, TERMINAL_CLEAR_LINE) { t.Errorf("result line written to a non-terminal stdout still contains the terminal clear-line sequence: %q", out) } } func TestResultOutputKeepsControlCharsOnATerminal(t *testing.T) { conf := &ffuf.Config{} outp := NewStdoutput(conf) // Simulate stdout being an interactive terminal, which is when the // progress-bar-clearing sequence is actually needed. outp.stdoutIsTerminal = true res := ffuf.Result{StatusCode: 200, ContentLength: 42, ContentWords: 3, ContentLines: 1} out := captureStdout(t, func() { outp.PrintResult(res) }) if !strings.Contains(out, TERMINAL_CLEAR_LINE) { t.Errorf("result line written to a terminal stdout is missing the terminal clear-line sequence: %q", out) } } func TestErrorOutputOmitsControlCharsWhenStderrIsNotATerminal(t *testing.T) { conf := &ffuf.Config{} outp := NewStdoutput(conf) outp.stderrIsTerminal = false out := captureStderr(t, func() { outp.Error("something broke") }) if strings.Contains(out, TERMINAL_CLEAR_LINE) { t.Errorf("Error() written to a non-terminal stderr still contains the terminal clear-line sequence: %q", out) } } func TestErrorOutputKeepsControlCharsOnATerminal(t *testing.T) { conf := &ffuf.Config{} outp := NewStdoutput(conf) outp.stderrIsTerminal = true out := captureStderr(t, func() { outp.Error("something broke") }) if !strings.Contains(out, TERMINAL_CLEAR_LINE) { t.Errorf("Error() written to a terminal stderr is missing the terminal clear-line sequence: %q", out) } } func TestProgressOutputOmitsControlCharsWhenStderrIsNotATerminal(t *testing.T) { conf := &ffuf.Config{} outp := NewStdoutput(conf) outp.stderrIsTerminal = false out := captureStderr(t, func() { outp.Progress(ffuf.Progress{StartedAt: time.Now()}) }) if strings.Contains(out, TERMINAL_CLEAR_LINE) { t.Errorf("Progress() written to a non-terminal stderr still contains the terminal clear-line sequence: %q", out) } } func TestResultOutputOmitsColorResetWhenColorsAreDisabled(t *testing.T) { conf := &ffuf.Config{Colors: false} outp := NewStdoutput(conf) outp.stdoutIsTerminal = true // isolate this from the clear-line behavior above res := ffuf.Result{StatusCode: 200, ContentLength: 42, ContentWords: 3, ContentLines: 1} out := captureStdout(t, func() { outp.PrintResult(res) }) if strings.Contains(out, ANSI_CLEAR) { t.Errorf("result line printed without -c still carries an ANSI reset code: %q", out) } } func TestResultOutputKeepsColorResetWhenColorsAreEnabled(t *testing.T) { conf := &ffuf.Config{Colors: true} outp := NewStdoutput(conf) outp.stdoutIsTerminal = false // -c output is opt-in and shouldn't depend on terminal detection res := ffuf.Result{StatusCode: 200, ContentLength: 42, ContentWords: 3, ContentLines: 1} out := captureStdout(t, func() { outp.PrintResult(res) }) if !strings.Contains(out, ANSI_CLEAR) { t.Errorf("result line printed with -c is missing its ANSI reset code: %q", out) } } ffuf-2.3.0/pkg/runner/000077500000000000000000000000001525032755300145435ustar00rootroot00000000000000ffuf-2.3.0/pkg/runner/preflight_hardening_test.go000066400000000000000000000133611525032755300221400ustar00rootroot00000000000000package runner import ( "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestHostPinBlocksHostChange is the fix for the host-hijack finding: a value // captured from a preflight response that would change the request's host makes // Execute refuse to send, so inherited credentials never reach another host. func TestHostPinBlocksHostChange(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "host=evil.example") })) defer srv.Close() reqFile := writeTempRequest(t, fmt.Sprintf("GET / HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) conf := newTestConfig(srv.URL) conf.Preflights = []ffuf.PreflightConfig{{ RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "HOSTVAR", Regex: `host=([\w.]+)`}}, }} r := newTestRunner(conf) req := &ffuf.Request{Method: "GET", Url: "http://HOSTVAR/main", Headers: map[string]string{}} _, err := r.Execute(req) if err == nil { t.Fatal("expected Execute to refuse a host-changing preflight variable") } if !strings.Contains(err.Error(), "changed the request host") { t.Errorf("unexpected error: %s", err) } } // TestSubstitutionDeterministic proves substitution is order-independent (longest // name first) and single-pass (a replaced value is not rescanned), so overlapping // names and value-contains-name cascades give one fixed result. func TestSubstitutionDeterministic(t *testing.T) { r := newTestRunner(newTestConfig("http://x")) vars := map[string]string{"TOK": "aaa", "TOKEN": "bbb", "AAA": "BBB", "BBB": "zzz"} wantURL := "http://x/bbb?a=BBB" // TOKEN wins over TOK; AAA->BBB not rescanned to zzz wantData := "v=aaa" for i := 0; i < 50; i++ { req := &ffuf.Request{Url: "http://x/TOKEN?a=AAA", Headers: map[string]string{"X": "TOK"}, Data: []byte("v=TOK")} r.applyVars(req, vars) if req.Url != wantURL { t.Fatalf("run %d: url=%q want %q (nondeterministic or cascading)", i, req.Url, wantURL) } if string(req.Data) != wantData { t.Fatalf("run %d: data=%q want %q", i, string(req.Data), wantData) } } } // TestPreflightHonorsRateLimit confirms preflight requests consume the rate // limiter (the main request is metered by the dispatch loop, not here). func TestPreflightHonorsRateLimit(t *testing.T) { ps := newPreflightServer() defer ps.close() conf := ps.config(t, "per-request") var calls int64 conf.RateLimitFunc = func() { atomic.AddInt64(&calls, 1) } r := newTestRunner(conf) const n = 3 for i := 0; i < n; i++ { if _, err := r.Execute(mainReq(ps.srv.URL)); err != nil { t.Fatalf("Execute %d: %s", i, err) } } if got := atomic.LoadInt64(&calls); got != n { t.Errorf("rate limiter consulted %d times, want %d (one per preflight request)", got, n) } } // TestPostflightRunsOnIgnoredBody confirms postflight runs when the main request // produced a response even though its body was not downloaded (-ignore-body), // rather than being skipped on that early return. func TestPostflightRunsOnIgnoredBody(t *testing.T) { var postHits int64 mux := http.NewServeMux() mux.HandleFunc("/main", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "3") _, _ = w.Write([]byte("abc")) }) mux.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&postHits, 1) }) srv := httptest.NewServer(mux) defer srv.Close() postFile := writeTempRequest(t, fmt.Sprintf("GET /post HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) conf := newTestConfig(srv.URL) conf.IgnoreBody = true conf.Postflights = []ffuf.PreflightConfig{{RequestFile: postFile}} r := newTestRunner(conf) if _, err := r.Execute(mainReq(srv.URL)); err != nil { t.Fatalf("Execute: %s", err) } if got := atomic.LoadInt64(&postHits); got != 1 { t.Errorf("postflight ran %d times, want 1 (must run for an ignored-body response)", got) } } // TestPreflightRejectsControlCharValue rejects a captured value containing a // control character (defense-in-depth against a poisoned token). func TestPreflightRejectsControlCharValue(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "token=aaa\rbbb") // bare CR inside the captured value })) defer srv.Close() reqFile := writeTempRequest(t, fmt.Sprintf("GET / HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) confAbort := newTestConfig(srv.URL) confAbort.PreflightError = "abort" confAbort.Preflights = []ffuf.PreflightConfig{{RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "T", Regex: `token=(.+)`}}}} if _, err := newTestRunner(confAbort).runPreflightChain(confAbort.Preflights, nil); err == nil { t.Error("abort mode: expected an error for a control-char value") } confIgnore := newTestConfig(srv.URL) confIgnore.PreflightError = "ignore" confIgnore.Preflights = []ffuf.PreflightConfig{{RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "T", Regex: `token=(.+)`}}}} vars, err := newTestRunner(confIgnore).runPreflightChain(confIgnore.Preflights, nil) if err != nil { t.Fatalf("ignore mode should not error: %s", err) } if _, ok := vars["T"]; ok { t.Error("ignore mode: control-char value should be skipped, not stored") } } // TestParsePreflightKeepsLastHeader locks the dropped-last-header fix: a request // file ending on a header with no trailing newline keeps that header. func TestParsePreflightKeepsLastHeader(t *testing.T) { r := newTestRunner(newTestConfig("http://example.com/")) f := writeTempRequest(t, "GET / HTTP/1.1\r\nHost: example.com\r\nX-Last: kept") req, err := r.parsePreflightRequest(f, nil) if err != nil { t.Fatalf("parsePreflightRequest: %s", err) } if got := req.Header.Get("X-Last"); got != "kept" { t.Errorf("last header dropped: X-Last=%q, want %q", got, "kept") } } ffuf-2.3.0/pkg/runner/preflight_test.go000066400000000000000000000217101525032755300201160ustar00rootroot00000000000000package runner import ( "context" "fmt" "net/http" "net/http/httptest" "os" "regexp" "strings" "sync" "sync/atomic" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // newTestConfig returns a minimal Config wired up for tests. func newTestConfig(targetURL string) *ffuf.Config { ctx, cancel := context.WithCancel(context.Background()) conf := ffuf.NewConfig(ctx, cancel) conf.Url = targetURL conf.Threads = 1 conf.Timeout = 5 conf.Headers = make(map[string]string) conf.PreflightMode = "per-request" conf.PreflightError = "abort" return &conf } // newTestRunner builds a SimpleRunner directly (bypassing NewSimpleRunner) so the // config can be injected after construction. It initializes the lane pool that // per-thread preflight mode borrows from. func newTestRunner(conf *ffuf.Config) *SimpleRunner { return &SimpleRunner{config: conf, client: &http.Client{Timeout: 0}, lanes: &lanePool{}} } // writeTempRequest writes a Burp-style raw HTTP request file and returns its path. func writeTempRequest(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "preflight-*.txt") if err != nil { t.Fatalf("could not create temp request file: %s", err) } _, _ = f.WriteString(content) f.Close() return f.Name() } // TestPreflightVarsSubstitutedIntoMainRequest checks the core mechanic: a value // extracted from a preflight response is substituted into the main request's URL, // headers and body wherever its keyword appears. func TestPreflightVarsSubstitutedIntoMainRequest(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, ``) })) defer srv.Close() reqFile := writeTempRequest(t, fmt.Sprintf("GET / HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) conf := newTestConfig(srv.URL) conf.Preflights = []ffuf.PreflightConfig{{ RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "CSRFTOKEN", Regex: `value="([^"]+)"`}}, }} r := newTestRunner(conf) vars, err := r.runPreflightChain(conf.Preflights, nil) if err != nil { t.Fatalf("runPreflightChain: %s", err) } if vars["CSRFTOKEN"] != "tok123" { t.Fatalf("CSRFTOKEN = %q, want tok123", vars["CSRFTOKEN"]) } req := &ffuf.Request{ Url: "http://x/submit?token=CSRFTOKEN", Headers: map[string]string{"X-CSRF": "CSRFTOKEN"}, Data: []byte("csrf=CSRFTOKEN"), } r.applyVars(req, vars) if req.Url != "http://x/submit?token=tok123" { t.Errorf("URL substitution: got %q", req.Url) } if req.Headers["X-CSRF"] != "tok123" { t.Errorf("header substitution: got %q", req.Headers["X-CSRF"]) } if string(req.Data) != "csrf=tok123" { t.Errorf("body substitution: got %q", string(req.Data)) } } // preflightServer serves a unique token per preflight hit at /preflight (so // amortization and freshness are observable) and records the X-Token header of // every main request at /main. It counts preflight hits. type preflightServer struct { srv *httptest.Server preflightHits int64 mu sync.Mutex mainTokens []string } func newPreflightServer() *preflightServer { ps := &preflightServer{} mux := http.NewServeMux() mux.HandleFunc("/preflight", func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt64(&ps.preflightHits, 1) fmt.Fprintf(w, "token=TOK%d;", n) }) mux.HandleFunc("/main", func(w http.ResponseWriter, r *http.Request) { ps.mu.Lock() ps.mainTokens = append(ps.mainTokens, r.Header.Get("X-Token")) ps.mu.Unlock() w.WriteHeader(200) }) ps.srv = httptest.NewServer(mux) return ps } func (ps *preflightServer) close() { ps.srv.Close() } func (ps *preflightServer) config(t *testing.T, mode string) *ffuf.Config { reqFile := writeTempRequest(t, fmt.Sprintf("GET /preflight HTTP/1.1\nHost: %s\n\n", ps.srv.Listener.Addr().String())) conf := newTestConfig(ps.srv.URL) conf.PreflightMode = mode conf.Preflights = []ffuf.PreflightConfig{{ RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "TOKENKW", Regex: `token=(\w+)`}}, }} return conf } // mainReq builds a fresh main request carrying the TOKENKW keyword in a header. func mainReq(baseURL string) *ffuf.Request { return &ffuf.Request{ Method: "GET", Url: baseURL + "/main", Headers: map[string]string{"X-Token": "TOKENKW"}, } } // TestPerRequestRunsPreflightEveryTime confirms per-request mode runs the // preflight chain once per Execute, so each main request gets a fresh token. func TestPerRequestRunsPreflightEveryTime(t *testing.T) { ps := newPreflightServer() defer ps.close() r := newTestRunner(ps.config(t, "per-request")) const n = 5 for i := 0; i < n; i++ { if _, err := r.Execute(mainReq(ps.srv.URL)); err != nil { t.Fatalf("Execute %d: %s", i, err) } } if got := atomic.LoadInt64(&ps.preflightHits); got != n { t.Errorf("preflight hits = %d, want %d (per-request should run every time)", got, n) } ps.mu.Lock() defer ps.mu.Unlock() seen := map[string]bool{} for i, tok := range ps.mainTokens { if !strings.HasPrefix(tok, "TOK") { t.Errorf("main request %d got token %q, want a fresh TOK*", i, tok) } if seen[tok] { t.Errorf("token %q reused; per-request tokens must be fresh", tok) } seen[tok] = true } } // TestPerThreadAmortizesPreflight confirms per-thread mode runs the preflight // chain once per lane and reuses the token across subsequent requests on that // lane, rather than re-running it every request. func TestPerThreadAmortizesPreflight(t *testing.T) { ps := newPreflightServer() defer ps.close() r := newTestRunner(ps.config(t, "per-thread")) const n = 10 for i := 0; i < n; i++ { if _, err := r.Execute(mainReq(ps.srv.URL)); err != nil { t.Fatalf("Execute %d: %s", i, err) } } // Sequential calls reuse the single lane, so the preflight runs exactly once. if got := atomic.LoadInt64(&ps.preflightHits); got != 1 { t.Errorf("preflight hits = %d, want 1 (per-thread should amortize to one per lane)", got) } ps.mu.Lock() defer ps.mu.Unlock() for i, tok := range ps.mainTokens { if tok != "TOK1" { t.Errorf("main request %d got token %q, want the amortized TOK1", i, tok) } } } // TestConcurrentPerThreadRaceSafe is the reconciliation guard: many goroutines // share one runner (as the engine does) in per-thread mode. It must be race-free // (run with -race), amortize the preflight to roughly the worker count rather than // the request count, and give every main request a valid token. func TestConcurrentPerThreadRaceSafe(t *testing.T) { ps := newPreflightServer() defer ps.close() r := newTestRunner(ps.config(t, "per-thread")) const workers = 4 const perWorker = 25 var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < perWorker; i++ { if _, err := r.Execute(mainReq(ps.srv.URL)); err != nil { t.Errorf("Execute: %s", err) return } } }() } wg.Wait() total := workers * perWorker hits := atomic.LoadInt64(&ps.preflightHits) if hits < 1 || hits > int64(2*workers) { t.Errorf("preflight hits = %d, want between 1 and %d (amortized per lane, not %d per request)", hits, 2*workers, total) } ps.mu.Lock() defer ps.mu.Unlock() if len(ps.mainTokens) != total { t.Fatalf("recorded %d main requests, want %d", len(ps.mainTokens), total) } valid := regexp.MustCompile(`^TOK\d+$`) for i, tok := range ps.mainTokens { if !valid.MatchString(tok) { t.Errorf("main request %d got invalid token %q", i, tok) } } } // TestPreflightErrorAbort makes a non-matching extraction fatal in abort mode. func TestPreflightErrorAbort(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "no token here") })) defer srv.Close() reqFile := writeTempRequest(t, fmt.Sprintf("GET / HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) conf := newTestConfig(srv.URL) conf.PreflightError = "abort" conf.Preflights = []ffuf.PreflightConfig{{ RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "X", Regex: `token=(\w+)`}}, }} r := newTestRunner(conf) if _, err := r.runPreflightChain(conf.Preflights, nil); err == nil { t.Error("expected an error when the extraction regex does not match in abort mode") } } // TestPreflightErrorIgnore makes the same failure non-fatal in ignore mode. func TestPreflightErrorIgnore(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "no token here") })) defer srv.Close() reqFile := writeTempRequest(t, fmt.Sprintf("GET / HTTP/1.1\nHost: %s\n\n", srv.Listener.Addr().String())) conf := newTestConfig(srv.URL) conf.PreflightError = "ignore" conf.Preflights = []ffuf.PreflightConfig{{ RequestFile: reqFile, Vars: []ffuf.VarExtract{{Name: "X", Regex: `token=(\w+)`}}, }} r := newTestRunner(conf) vars, err := r.runPreflightChain(conf.Preflights, nil) if err != nil { t.Fatalf("ignore mode should not error, got: %s", err) } if _, ok := vars["X"]; ok { t.Error("no variable should have been extracted from a non-matching response") } } ffuf-2.3.0/pkg/runner/simple.go000066400000000000000000000524111525032755300163660ustar00rootroot00000000000000package runner import ( "bufio" "bytes" "compress/flate" "compress/gzip" "crypto/tls" "fmt" "io" "log" "net" "net/http" "net/http/httptrace" "net/http/httputil" "net/textproto" "net/url" "os" "regexp" "sort" "strconv" "strings" "sync" "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 lanes *lanePool } // preflightLane holds the variables extracted by a per-thread preflight chain. // A lane is checked out of the pool for the duration of one Execute call, so its // map is only ever touched by a single goroutine at a time. type preflightLane struct { vars map[string]string initialized bool } // lanePool hands out preflightLanes for per-thread preflight mode. ffuf runs a // fresh goroutine per request (bounded to -t concurrent), so there is no // persistent worker to pin per-thread state to; instead a request borrows a lane // for the length of its Execute call and returns it. Lanes are reused, so a // per-thread preflight chain runs once per lane and its vars are amortized across // every request that later borrows that lane. The free list grows on demand, so a // checkout never blocks and cannot deadlock. type lanePool struct { mu sync.Mutex free []*preflightLane } func (p *lanePool) get() *preflightLane { p.mu.Lock() defer p.mu.Unlock() if n := len(p.free); n > 0 { l := p.free[n-1] p.free = p.free[:n-1] return l } return &preflightLane{} } func (p *lanePool) put(l *preflightLane) { p.mu.Lock() p.free = append(p.free, l) p.mu.Unlock() } 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.lanes = &lanePool{} 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, // Response headers are not covered by MAX_DOWNLOAD_SIZE, and Go's default // ceiling is 10 MB. A hostile target could spend that on every response, // and the headers are retained per response and copied into resp.Raw, the // scraper's source string and the audit log. 1 MB is far above anything a // real server sends (nginx and Apache cap a header line at 8 KB). MaxResponseHeaderBytes: 1 << 20, 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) (resp ffuf.Response, err error) { // Pin the target host: a value captured from an (untrusted) preflight response // must not change which host this authenticated request is sent to. targetHost := hostOf(req.Url) // Run the preflight chain first: it may inject extracted variables into this // request's URL, headers and body before it is built. appliedVars, releaseLane, pferr := r.runPreflights(req) defer releaseLane() // Postflight runs only when the main request produced a response (err == nil). // Deferred so it also covers the oversized / -ignore-body early returns, but is // skipped when the request itself errors. defer func() { if err == nil { r.runPostflights(appliedVars) } }() if pferr != nil { return ffuf.Response{}, pferr } if len(appliedVars) > 0 { if h := hostOf(req.Url); h != targetHost { return ffuf.Response{}, fmt.Errorf("preflight variable changed the request host from %q to %q; refusing to send (a captured value must not alter the target host)", targetHost, h) } } var httpreq *http.Request 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 || len(r.config.AuditLog) > 0 { rawreq, _ = httputil.DumpRequestOut(httpreq, true) req.Raw = string(rawreq) } httpresp, err := r.client.Do(httpreq) if err != nil { return ffuf.Response{}, err } req.Timestamp = start resp = ffuf.NewResponse(httpresp, req) defer httpresp.Body.Close() // Record the server-declared length first, so it is set before any early return // below and the size filters still observe it. if size, serr := strconv.Atoi(httpresp.Header.Get("Content-Length")); serr == nil { resp.ContentLength = int64(size) if size > MAX_DOWNLOAD_SIZE { resp.Cancelled = true return resp, nil } } // -ignore-body has to hold regardless of framing. Keeping this check inside the // Content-Length branch above meant it was silently skipped for chunked // responses, which carry no Content-Length. if r.config.IgnoreBody { resp.Cancelled = true return resp, nil } // Bound the body at the source, before anything reads it. The Content-Length // guard above cannot fire when the header is absent, which is the case for // chunked responses and for any response the transport transparently decoded, // and httputil.DumpResponse below drains the entire body with no bound of its // own. // // This does not replace the LimitReader further down, and which stream each of // them bounds depends on the path. Where the transport decoded the body itself, // both bound the same decompressed stream. Where Content-Encoding survived and // we decode manually, this one bounds the compressed bytes and that one bounds // what they expand to, so that case needs both. httpresp.Body = struct { io.Reader io.Closer }{io.LimitReader(httpresp.Body, int64(MAX_DOWNLOAD_SIZE)+1), httpresp.Body} if len(r.config.OutputDirectory) > 0 || len(r.config.AuditLog) > 0 { rawresp, _ := httputil.DumpResponse(httpresp, true) resp.Request.Raw = string(rawreq) resp.Raw = string(rawresp) } var bodyReader io.Reader = httpresp.Body switch httpresp.Header.Get("Content-Encoding") { case "gzip": if zr, zerr := gzip.NewReader(httpresp.Body); zerr == nil { bodyReader = zr } // else: fall back to the raw (undecoded) body case "br": bodyReader = brotli.NewReader(httpresp.Body) case "deflate": bodyReader = flate.NewReader(httpresp.Body) } // Bound the DECOMPRESSED read. The Content-Length guard above is not // sufficient on its own: a malicious server can send a small compressed body // (or a chunked response with no Content-Length, or one the transport // transparently decoded and stripped the headers from) that expands to // gigabytes. LimitReader caps the bytes we ever pull from the possibly- // decompressing reader, so a decompression bomb only materialises // MAX_DOWNLOAD_SIZE+1 bytes in memory instead of OOM-killing the process. limited := io.LimitReader(bodyReader, int64(MAX_DOWNLOAD_SIZE)+1) if respbody, rerr := io.ReadAll(limited); rerr == nil { if len(respbody) > MAX_DOWNLOAD_SIZE { // The snapshot taken above holds a body truncated at the cap, which would // be written out as a well-formed response that merely looks complete. // Drop it rather than store a misleading artifact. resp.Raw = "" resp.Cancelled = true return resp, nil } resp.ContentLength = int64(len(respbody)) resp.Data = respbody } // Count words and lines without materializing a []string. For a single-byte // separator len(strings.Split(s, sep)) == bytes.Count(s, sep)+1, but Split // allocates one string header (~16 bytes) per element. On a max-size body // (MAX_DOWNLOAD_SIZE) full of separators that is tens of MB of transient // garbage per response, times the thread count; bytes.Count is O(1) extra. wordsSize := bytes.Count(resp.Data, []byte(" ")) + 1 linesSize := bytes.Count(resp.Data, []byte("\n")) + 1 resp.ContentWords = int64(wordsSize) resp.ContentLines = int64(linesSize) resp.Duration = firstByteTime resp.Timestamp = start.Add(firstByteTime) return resp, nil } // parsePreflightRequest reads a Burp-style raw HTTP request file and builds an // *http.Request. Main-config headers are inherited (so auth carries over) and any // already-known vars are substituted into the request before it is built. func (r *SimpleRunner) parsePreflightRequest(filename string, vars map[string]string) (*http.Request, error) { f, err := os.Open(filename) if err != nil { return nil, fmt.Errorf("preflight: could not open %q: %s", filename, err) } defer f.Close() reader := bufio.NewReader(f) // First line: METHOD path HTTP/version firstLine, err := reader.ReadString('\n') if err != nil && firstLine == "" { return nil, fmt.Errorf("preflight: could not read request line from %q: %s", filename, err) } parts := strings.SplitN(strings.TrimSpace(firstLine), " ", 3) if len(parts) < 2 { return nil, fmt.Errorf("preflight: malformed request line in %q", filename) } method, path := parts[0], parts[1] // Headers, seeded with the main config headers so auth etc. is inherited. headers := make(map[string]string) for k, v := range r.config.Headers { headers[k] = v } var host string for { line, rerr := reader.ReadString('\n') line = strings.TrimRight(line, "\r\n") if line == "" { break // blank line: end of the header section } // Parse the line BEFORE honoring rerr, so a final header with no trailing // newline (as many editors save) is not dropped. if p := strings.SplitN(line, ":", 2); len(p) == 2 { k, v := strings.TrimSpace(p[0]), strings.TrimSpace(p[1]) switch { case strings.EqualFold(k, "content-length"): // let net/http compute it case strings.EqualFold(k, "host"): host = v // set via Request.Host, not a header default: headers[textproto.CanonicalMIMEHeaderKey(k)] = v } } if rerr != nil { break } } body, _ := io.ReadAll(reader) body = bytes.TrimRight(body, "\r\n") // trim trailing newline editors add // Substitute known variables into method, path, headers, host and body, in a // single deterministic pass. if len(vars) > 0 { rep := varsReplacer(vars) method = rep.Replace(method) path = rep.Replace(path) body = []byte(rep.Replace(string(body))) host = rep.Replace(host) for h, v := range headers { headers[h] = rep.Replace(v) } } // Build the full URL, deriving scheme/host from the main target when the raw // request uses a relative path. var rawURL string if strings.HasPrefix(path, "http") { rawURL = path } else { scheme := "https" if u, uerr := url.Parse(r.config.Url); uerr == nil && u.Scheme != "" { scheme = u.Scheme if host == "" { host = u.Host } } // A relative-path preflight whose host was derived from the target template // during host/vhost fuzzing would carry an unsubstituted keyword; fail loudly // rather than send the request to a literal "FUZZ.example.com". if kw, ok := r.hostHasKeyword(host); ok { return nil, fmt.Errorf("preflight %q: derived target host %q still contains fuzz keyword %q; give the preflight file an absolute URL or a Host header", filename, host, kw) } rawURL = scheme + "://" + host + path } httpreq, err := http.NewRequestWithContext(r.config.Context, method, rawURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("preflight: could not build request from %q: %s", filename, err) } if host != "" { httpreq.Host = host } if _, ok := headers["User-Agent"]; !ok { headers["User-Agent"] = fmt.Sprintf("%s v%s", "Fuzz Faster U Fool", ffuf.Version()) } for k, v := range headers { httpreq.Header.Set(k, v) } return httpreq, nil } // runPreflightChain executes an ordered chain of requests, accumulating extracted // variables. inheritVars seeds the map (e.g. with vars from an earlier step). The // result is returned; no shared state is touched. On error it honours // -preflight-error: "ignore" returns the vars gathered so far, "abort" errors. func (r *SimpleRunner) runPreflightChain(chain []ffuf.PreflightConfig, inheritVars map[string]string) (map[string]string, error) { vars := make(map[string]string, len(inheritVars)) for k, v := range inheritVars { vars[k] = v } ignore := r.config.PreflightError == "ignore" for _, pf := range chain { httpreq, err := r.parsePreflightRequest(pf.RequestFile, vars) if err != nil { if ignore { log.Printf("preflight ignored error building request from %q: %s", pf.RequestFile, err) return vars, nil } return nil, err } // Count this request against the shared rate limiter so preflight/postflight // traffic honors -rate/-p rather than firing unmetered. if r.config.RateLimitFunc != nil { r.config.RateLimitFunc() } resp, err := r.client.Do(httpreq) if err != nil { if ignore { log.Printf("preflight ignored error executing %q: %s", pf.RequestFile, err) return vars, nil } return nil, fmt.Errorf("preflight: request %q failed: %s", pf.RequestFile, err) } body, err := io.ReadAll(io.LimitReader(resp.Body, int64(MAX_DOWNLOAD_SIZE)+1)) resp.Body.Close() if err != nil { if ignore { log.Printf("preflight ignored error reading response from %q: %s", pf.RequestFile, err) return vars, nil } return nil, fmt.Errorf("preflight: reading response body from %q failed: %s", pf.RequestFile, err) } for _, ve := range pf.Vars { // Reuse the regex compiled once at config time; fall back to compiling // here only when a VarExtract was built outside ConfigFromOptions (tests). re := ve.Compiled if re == nil { var cerr error re, cerr = regexp.Compile(ve.Regex) if cerr != nil { if ignore { log.Printf("preflight ignored invalid regex %q for var %s: %s", ve.Regex, ve.Name, cerr) continue } return nil, fmt.Errorf("preflight: invalid regex %q for var %s: %s", ve.Regex, ve.Name, cerr) } } matches := re.FindSubmatch(body) if len(matches) < 2 { if ignore { log.Printf("preflight: regex %q did not match var %s in response from %q", ve.Regex, ve.Name, pf.RequestFile) continue } return nil, fmt.Errorf("preflight: regex %q did not capture var %s from response of %q", ve.Regex, ve.Name, pf.RequestFile) } val := string(matches[1]) // The captured value comes from the scanned target (untrusted). Reject // control characters: they can't legally go into a URL/header and would // otherwise corrupt or fail the outgoing request (defense in depth). if strings.ContainsAny(val, "\r\n\x00") { if ignore { log.Printf("preflight: var %s from %q contains a control character, skipping", ve.Name, pf.RequestFile) continue } return nil, fmt.Errorf("preflight: var %s captured from %q contains a control character; refusing to use it", ve.Name, pf.RequestFile) } vars[ve.Name] = val log.Printf("preflight: extracted %s from %q", ve.Name, pf.RequestFile) } } return vars, nil } // varsReplacer builds one deterministic replacer for the extracted vars. Names // are applied longest-first (then lexicographically) so a shorter name that is a // prefix of a longer one can't win, and strings.Replacer does a single // simultaneous pass, so a replaced value is never rescanned. That makes // substitution independent of map iteration order and free of value-contains-name // cascades. func varsReplacer(vars map[string]string) *strings.Replacer { names := make([]string, 0, len(vars)) for k := range vars { names = append(names, k) } sort.Slice(names, func(i, j int) bool { if len(names[i]) != len(names[j]) { return len(names[i]) > len(names[j]) } return names[i] < names[j] }) pairs := make([]string, 0, len(vars)*2) for _, n := range names { pairs = append(pairs, n, vars[n]) } return strings.NewReplacer(pairs...) } // hostOf returns the authority (host[:port]) of a URL, or "" if it cannot be parsed. func hostOf(rawurl string) string { u, err := url.Parse(rawurl) if err != nil { return "" } return u.Host } // hostHasKeyword reports whether host still contains an input fuzz keyword, which // means it was derived from an unsubstituted target template (e.g. vhost fuzzing // with a relative-path preflight). func (r *SimpleRunner) hostHasKeyword(host string) (string, bool) { for _, ip := range r.config.InputProviders { if ip.Keyword != "" && strings.Contains(host, ip.Keyword) { return ip.Keyword, true } } return "", false } // applyVars substitutes extracted variable keywords into a single request's URL, // headers and body. It mutates only the request passed in. func (r *SimpleRunner) applyVars(req *ffuf.Request, vars map[string]string) { if len(vars) == 0 { return } rep := varsReplacer(vars) req.Url = rep.Replace(req.Url) req.Data = []byte(rep.Replace(string(req.Data))) for h, v := range req.Headers { req.Headers[h] = rep.Replace(v) } } // runPreflights resolves the variables for this request per -preflight-mode // (fresh per request, or once per lane and amortized in per-thread mode) and // applies them. It returns the applied vars so postflight can chain off them, and // a cleanup func the caller must defer (it returns a borrowed lane, if any). func (r *SimpleRunner) runPreflights(req *ffuf.Request) (applied map[string]string, cleanup func(), err error) { cleanup = func() {} if len(r.config.Preflights) == 0 { return nil, cleanup, nil } if r.config.PreflightMode == "per-thread" { lane := r.lanes.get() cleanup = func() { r.lanes.put(lane) } if !lane.initialized { v, ferr := r.runPreflightChain(r.config.Preflights, nil) if ferr != nil { cleanup() return nil, func() {}, ferr } lane.vars = v lane.initialized = true } applied = lane.vars } else { v, ferr := r.runPreflightChain(r.config.Preflights, nil) if ferr != nil { return nil, cleanup, ferr } applied = v } if len(applied) > 0 { r.applyVars(req, applied) } return applied, cleanup, nil } // runPostflights executes the postflight chain after the main request, seeded // with the vars applied to it so chained extractions work. Postflight failure is // non-fatal: the main result is always kept, the error is only logged. func (r *SimpleRunner) runPostflights(seedVars map[string]string) { if len(r.config.Postflights) == 0 { return } if _, ferr := r.runPreflightChain(r.config.Postflights, seedVars); ferr != nil { log.Printf("postflight error (result still recorded): %s", ferr) } } 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.3.0/pkg/runner/simple_test.go000066400000000000000000000262171525032755300174320ustar00rootroot00000000000000package runner import ( "bytes" "compress/gzip" "context" "net/http" "net/http/httptest" "path/filepath" "runtime" "strconv" "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // gzipBomb returns a gzip stream that decompresses to `decompressed` bytes of // zeros. The compressed form is tiny (zeros compress ~1000x), so the test moves // almost no data over the wire while forcing a large decompression. func gzipBomb(t *testing.T, decompressed int) []byte { t.Helper() var buf bytes.Buffer zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) chunk := make([]byte, 1<<20) // 1 MiB of zeros, reused for written := 0; written < decompressed; { n := len(chunk) if r := decompressed - written; r < n { n = r } if _, err := zw.Write(chunk[:n]); err != nil { t.Fatalf("gzip write: %v", err) } written += n } if err := zw.Close(); err != nil { t.Fatalf("gzip close: %v", err) } return buf.Bytes() } func runnerFor(t *testing.T, url string) (ffuf.RunnerProvider, ffuf.Request) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) conf := ffuf.NewConfig(ctx, cancel) conf.Method = "GET" runner := NewSimpleRunner(&conf, false) req := ffuf.NewRequest(&conf) req.Method = "GET" req.Url = url return runner, req } // A malicious server returning a decompression bomb must not be read into memory // unbounded. The response is capped at MAX_DOWNLOAD_SIZE and marked Cancelled // rather than OOM-killing the process. Regression test for the gzip-bomb DoS // (CWE-409): before the fix, io.ReadAll on the decompressed stream had no bound. func TestExecute_GzipBombIsCapped(t *testing.T) { const decompressed = 64 << 20 // 64 MiB, well over the 5 MiB cap bomb := gzipBomb(t, decompressed) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Length", strconv.Itoa(len(bomb))) w.WriteHeader(http.StatusOK) _, _ = w.Write(bomb) })) defer srv.Close() // Path 1: default client. The transport adds Accept-Encoding: gzip itself, // transparently decodes, and strips Content-Encoding/Content-Length, so the // header-based guard never fires. The LimitReader is what saves us. t.Run("transparent transport decode", func(t *testing.T) { runner, req := runnerFor(t, srv.URL+"/") resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if !resp.Cancelled { t.Fatalf("expected cancelled response, got %d bytes of data", len(resp.Data)) } if len(resp.Data) > MAX_DOWNLOAD_SIZE { t.Fatalf("read %d bytes, must not exceed MAX_DOWNLOAD_SIZE (%d)", len(resp.Data), MAX_DOWNLOAD_SIZE) } }) // Path 2: client sends its own Accept-Encoding, so the transport does NOT // auto-decode. Content-Encoding: gzip survives and Content-Length reflects the // small compressed size, passing the guard, so the manual decoder + LimitReader // must catch it. t.Run("manual decode with preserved headers", func(t *testing.T) { runner, req := runnerFor(t, srv.URL+"/") req.Headers["Accept-Encoding"] = "gzip" resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if !resp.Cancelled { t.Fatalf("expected cancelled response, got %d bytes of data", len(resp.Data)) } if len(resp.Data) > MAX_DOWNLOAD_SIZE { t.Fatalf("read %d bytes, must not exceed MAX_DOWNLOAD_SIZE (%d)", len(resp.Data), MAX_DOWNLOAD_SIZE) } }) } // A normal, small response must still be read in full and not cancelled, so the // size cap does not regress ordinary fuzzing behaviour. func TestExecute_NormalResponseUnaffected(t *testing.T) { body := []byte("hello world") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) })) defer srv.Close() runner, req := runnerFor(t, srv.URL+"/") resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if resp.Cancelled { t.Fatal("normal response must not be cancelled") } if !bytes.Equal(resp.Data, body) { t.Fatalf("body = %q, want %q", resp.Data, body) } } // chunkedBodyServer streams `total` bytes with no Content-Length, so the response // is framed chunked and the numeric size guard cannot fire. func chunkedBodyServer(t *testing.T, total int) *httptest.Server { t.Helper() chunk := bytes.Repeat([]byte("A"), 1<<20) return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) flusher, _ := w.(http.Flusher) for written := 0; written < total; written += len(chunk) { if _, err := w.Write(chunk); err != nil { return } if flusher != nil { flusher.Flush() } } })) } func runnerWithConfig(t *testing.T, url string, mutate func(*ffuf.Config)) (ffuf.RunnerProvider, ffuf.Request) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) conf := ffuf.NewConfig(ctx, cancel) conf.Method = "GET" if mutate != nil { mutate(&conf) } runner := NewSimpleRunner(&conf, false) req := ffuf.NewRequest(&conf) req.Method = "GET" req.Url = url return runner, req } // allocatedBy reports how many bytes were allocated while fn ran. func allocatedBy(fn func()) uint64 { runtime.GC() var before, after runtime.MemStats runtime.ReadMemStats(&before) fn() runtime.ReadMemStats(&after) return after.TotalAlloc - before.TotalAlloc } // The -od and -audit-log snapshot path takes its own copy of the response body, // and it runs before the LimitReader that bounds resp.Data. Without a bound of its // own it reads an attacker-chosen amount into memory, so the size cap held for // resp.Data but not for resp.Raw. Regression test for that: with either output // feature enabled, a body far over the cap must not be materialised. func TestExecute_SnapshotIsBoundedWithOutputFeatures(t *testing.T) { const body = 64 << 20 // 64 MiB against a 5 MiB cap // The ceiling is a multiple of the body, not an absolute figure. The test origin // runs in this process and allocates the body it streams, so roughly one body's // worth is charged here no matter how little the client reads; the race detector // adds more on top. The signal is the multiple: bounded stays near that floor, // unbounded allocates several times the body because it buffers, copies and // re-serialises it. const allocCeiling = 2 * body cases := []struct { name string mutate func(*ffuf.Config) }{ {"output directory", func(c *ffuf.Config) { c.OutputDirectory = t.TempDir() }}, {"audit log", func(c *ffuf.Config) { c.AuditLog = filepath.Join(t.TempDir(), "audit.log") }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := chunkedBodyServer(t, body) defer srv.Close() runner, req := runnerWithConfig(t, srv.URL+"/", tc.mutate) var resp ffuf.Response allocated := allocatedBy(func() { var err error resp, err = runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } }) if allocated > allocCeiling { t.Errorf("allocated %d bytes for a %d byte body, want at most %d; the snapshot is reading past the cap", allocated, body, allocCeiling) } if len(resp.Raw) > MAX_DOWNLOAD_SIZE { t.Errorf("resp.Raw is %d bytes, must not exceed MAX_DOWNLOAD_SIZE (%d)", len(resp.Raw), MAX_DOWNLOAD_SIZE) } if !resp.Cancelled { t.Error("expected an over-cap response to be cancelled") } }) } } // A gzip bomb reaches the same snapshot path: the transport requests gzip itself, // decompresses transparently and strips Content-Length, so the numeric guard is // skipped and the snapshot sees the expanded stream. func TestExecute_SnapshotIsBoundedForGzipBomb(t *testing.T) { const decompressed = 64 << 20 bomb := gzipBomb(t, decompressed) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Encoding", "gzip") w.WriteHeader(http.StatusOK) _, _ = w.Write(bomb) })) defer srv.Close() runner, req := runnerWithConfig(t, srv.URL+"/", func(c *ffuf.Config) { c.OutputDirectory = t.TempDir() }) resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if len(resp.Raw) > MAX_DOWNLOAD_SIZE { t.Errorf("resp.Raw is %d bytes, must not exceed MAX_DOWNLOAD_SIZE (%d)", len(resp.Raw), MAX_DOWNLOAD_SIZE) } } // A response within the cap must still be captured in full, so the size bound does // not quietly truncate ordinary -od output. func TestExecute_SnapshotKeptForNormalResponse(t *testing.T) { body := []byte("hello world") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) })) defer srv.Close() runner, req := runnerWithConfig(t, srv.URL+"/", func(c *ffuf.Config) { c.OutputDirectory = t.TempDir() }) resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if resp.Cancelled { t.Fatal("a normal response must not be cancelled") } if !strings.Contains(resp.Raw, string(body)) { t.Errorf("resp.Raw does not contain the body: %q", resp.Raw) } } // -ignore-body used to live inside the Content-Length branch, so it did nothing for // a chunked response. It must hold regardless of framing. func TestExecute_IgnoreBodyAppliesToChunkedResponse(t *testing.T) { srv := chunkedBodyServer(t, 32<<10) defer srv.Close() runner, req := runnerWithConfig(t, srv.URL+"/", func(c *ffuf.Config) { c.IgnoreBody = true }) resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if !resp.Cancelled { t.Error("expected the response to be cancelled") } if len(resp.Data) != 0 { t.Errorf("read %d bytes of body despite -ignore-body", len(resp.Data)) } } // Moving the -ignore-body check must not lose the server-declared length, which the // size filters read. func TestExecute_IgnoreBodyKeepsContentLength(t *testing.T) { body := make([]byte, 32<<10) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", strconv.Itoa(len(body))) w.WriteHeader(http.StatusOK) _, _ = w.Write(body) })) defer srv.Close() runner, req := runnerWithConfig(t, srv.URL+"/", func(c *ffuf.Config) { c.IgnoreBody = true }) resp, err := runner.Execute(&req) if err != nil { t.Fatalf("execute: %v", err) } if !resp.Cancelled { t.Error("expected the response to be cancelled") } if resp.ContentLength != int64(len(body)) { t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len(body)) } } // Response headers sit outside MAX_DOWNLOAD_SIZE, so the transport needs its own // ceiling; Go's default is 10 MB. func TestNewSimpleRunner_BoundsResponseHeaders(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() conf := ffuf.NewConfig(ctx, cancel) runner := NewSimpleRunner(&conf, false).(*SimpleRunner) transport, ok := runner.client.Transport.(*http.Transport) if !ok { t.Fatalf("transport is %T, want *http.Transport", runner.client.Transport) } if transport.MaxResponseHeaderBytes <= 0 { t.Fatal("MaxResponseHeaderBytes is unset, response headers are bounded only by the net/http default") } if transport.MaxResponseHeaderBytes > 4<<20 { t.Errorf("MaxResponseHeaderBytes = %d, larger than intended", transport.MaxResponseHeaderBytes) } } ffuf-2.3.0/pkg/scraper/000077500000000000000000000000001525032755300146715ustar00rootroot00000000000000ffuf-2.3.0/pkg/scraper/scraper.go000066400000000000000000000101011525032755300166500ustar00rootroot00000000000000package 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) // Enumerate the directory that was passed in; the entries below are opened // relative to dirname, so reading a different directory (the global // ffuf.SCRAPERDIR) here would mismatch the two. all_files, err := os.ReadDir(dirname) 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.3.0/pkg/scraper/scraper_test.go000066400000000000000000000014721525032755300177220ustar00rootroot00000000000000package scraper import ( "path/filepath" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) // TestFromDir_UsesDirnameNotGlobal proves FromDir enumerates the directory it is // given rather than the package-global ffuf.SCRAPERDIR. Before the fix it read the // global for the directory listing while opening the entries relative to dirname, // so a valid dirname combined with a missing global still errored. func TestFromDir_UsesDirnameNotGlobal(t *testing.T) { orig := ffuf.SCRAPERDIR defer func() { ffuf.SCRAPERDIR = orig }() valid := t.TempDir() // exists, empty ffuf.SCRAPERDIR = filepath.Join(t.TempDir(), "does-not-exist") _, errs := FromDir(valid, "all") if errs.ErrorOrNil() != nil { t.Errorf("FromDir should read its dirname argument (a valid dir), not the global (missing): %v", errs.ErrorOrNil()) } } ffuf-2.3.0/pkg/scraper/util.go000066400000000000000000000015761525032755300162060ustar00rootroot00000000000000package scraper import ( "strings" "github.com/ffuf/ffuf/v2/pkg/ffuf" ) func headerString(headers map[string][]string) string { // Built with a Builder rather than repeated concatenation: the headers come // from the scanned target, and += in this nested loop copies the accumulated // string on every header value. var sb strings.Builder for k, vslice := range headers { for _, v := range vslice { sb.WriteString(k) sb.WriteString(": ") sb.WriteString(v) sb.WriteByte('\n') } } return sb.String() } 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 } ffuf-2.3.0/pkg/testtarget/000077500000000000000000000000001525032755300154205ustar00rootroot00000000000000ffuf-2.3.0/pkg/testtarget/server.go000066400000000000000000000174101525032755300172600ustar00rootroot00000000000000// Package testtarget provides a deterministic mock HTTP target for ffuf // integration tests. It models the response dimensions ffuf matches and filters // on (status code, body size, word count, line count, latency, reflection, // soft-404 baselines, redirects, request-gated endpoints, and a nested // directory tree for recursion) and records every request it receives. // // The server is hermetic: it binds to an ephemeral localhost port via // httptest, uses no randomness, and returns byte-identical responses for // identical requests. That determinism is what lets integration tests assert on // exact result sets. Point the real ffuf http runner at Target.URL and only the // target and the output sink are test doubles; the engine, runner, matchers and // filters are the real ones. package testtarget import ( "fmt" "net/http" "net/http/httptest" "strconv" "strings" "sync" "time" ) // Recorded is one request the target received, exposed via Target.Requests so a // test can assert on what ffuf actually sent (method, headers, body, timing). type Recorded struct { Method string Path string Query string Header http.Header Body string At time.Time } // Target wraps an httptest.Server with a thread-safe request recorder. type Target struct { *httptest.Server mu sync.Mutex recorded []Recorded } // New starts a new deterministic target server. The caller must Close it. func New() *Target { t := &Target{} t.Server = httptest.NewServer(http.HandlerFunc(t.handle)) return t } // Requests returns a snapshot copy of every request received so far. func (t *Target) Requests() []Recorded { t.mu.Lock() defer t.mu.Unlock() out := make([]Recorded, len(t.recorded)) copy(out, t.recorded) return out } // Count returns how many requests the target has received. func (t *Target) Count() int { t.mu.Lock() defer t.mu.Unlock() return len(t.recorded) } func (t *Target) handle(w http.ResponseWriter, r *http.Request) { body := drain(r) t.mu.Lock() t.recorded = append(t.recorded, Recorded{ Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Header: r.Header.Clone(), Body: body, At: time.Now(), }) t.mu.Unlock() p := r.URL.Path switch { // --- match/filter dimension endpoints ------------------------------- case strings.HasPrefix(p, "/status/"): if code, ok := tailInt(p, "/status/"); ok { w.WriteHeader(code) fmt.Fprintf(w, "status %d", code) return } case strings.HasPrefix(p, "/size/"): if n, ok := tailInt(p, "/size/"); ok { _, _ = w.Write(filled(n)) return } case strings.HasPrefix(p, "/words/"): if n, ok := tailInt(p, "/words/"); ok { _, _ = w.Write([]byte(words(n))) return } case strings.HasPrefix(p, "/lines/"): if n, ok := tailInt(p, "/lines/"); ok { _, _ = w.Write([]byte(lines(n))) return } case strings.HasPrefix(p, "/sleep/"): if ms, ok := tailInt(p, "/sleep/"); ok { time.Sleep(time.Duration(ms) * time.Millisecond) fmt.Fprintf(w, "slept %d", ms) return } case strings.HasPrefix(p, "/reflect/"): val := strings.TrimPrefix(p, "/reflect/") fmt.Fprintf(w, "reflected: %s", val) return case strings.HasPrefix(p, "/redirect/"): if n, ok := tailInt(p, "/redirect/"); ok { if n <= 0 { fmt.Fprint(w, "arrived") } else { w.Header().Set("Location", fmt.Sprintf("/redirect/%d", n-1)) w.WriteHeader(http.StatusFound) } return } // --- autocalibration: soft-404 baseline with one real outlier ------- case p == "/ac/real": // Distinctly larger/wordier than the soft-404 baseline so a calibrated // size or word filter lets it through while the junk is filtered. fmt.Fprint(w, "REAL content that is meaningfully larger than the junk baseline response") return case strings.HasPrefix(p, "/ac/"): // Any other /ac/* path (including ffuf's random calibration probes) // returns a constant small body: the classic soft-404. fmt.Fprint(w, "junk") return // --- request-gated endpoints ---------------------------------------- case p == "/needs-header": if r.Header.Get("X-Test") == "yes" { fmt.Fprint(w, "header ok") return } forbidden(w) return case p == "/needs-cookie": if c, err := r.Cookie("SESSION"); err == nil && c.Value != "" { fmt.Fprint(w, "cookie ok") return } forbidden(w) return case p == "/needs-method": if r.Method == http.MethodPost { fmt.Fprint(w, "method ok") return } forbidden(w) return case p == "/needs-body": if strings.Contains(body, "token") { fmt.Fprint(w, "body ok") return } forbidden(w) return // --- status decoupled from the payload ----------------------------- // The status code does not appear in the path or body, so a status matcher // can be told apart from a payload/body matcher. case strings.HasPrefix(p, "/map/"): switch strings.TrimPrefix(p, "/map/") { case "ok": w.WriteHeader(http.StatusOK) fmt.Fprint(w, "mapped alpha") case "bad": w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "mapped gamma") default: notFound(w) } return // --- recursion tree (greedy strategy) ------------------------------- case p == "/": fmt.Fprint(w, "root") return case p == "/admin": fmt.Fprint(w, "admin directory") return case p == "/admin/secret": fmt.Fprint(w, "the secret") return // --- redirect-based directory (default recursion strategy) ---------- // A directory that redirects to itself + "/", which the default recursion // strategy uses to detect a directory to descend into. case p == "/rdir": w.Header().Set("Location", "/rdir/") w.WriteHeader(http.StatusMovedPermanently) return case p == "/rdir/": fmt.Fprint(w, "rdir index") return case p == "/rdir/found": fmt.Fprint(w, "found under rdir") return } notFound(w) } func forbidden(w http.ResponseWriter) { w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, "forbidden") } func notFound(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound); fmt.Fprint(w, "not found") } // maxTailInt bounds every path-derived integer. It is far above anything the // tests use and keeps the value that reaches size/word/line allocation from // being attacker-controlled (a request like /size/99999999999 is rejected // rather than allocating from an unbounded, path-supplied size). const maxTailInt = 1 << 20 // tailInt parses the integer immediately following prefix in path. It requires // the remainder to be exactly an integer (no further path segments), so // /size/20 parses and /size/20/extra does not, and enforces 0 <= n <= maxTailInt. func tailInt(path, prefix string) (int, bool) { rest := strings.TrimPrefix(path, prefix) if rest == "" || strings.Contains(rest, "/") { return 0, false } n, err := strconv.Atoi(rest) if err != nil || n < 0 || n > maxTailInt { return 0, false } return n, true } // filled returns n bytes of 'A'. net/http sets Content-Length to n for these // small buffered bodies, which is what ffuf's size matcher reads. func filled(n int) []byte { b := make([]byte, n) for i := range b { b[i] = 'A' } return b } // words returns a body of exactly n whitespace-separated tokens. func words(n int) string { if n <= 0 { return "" } toks := make([]string, n) for i := range toks { toks[i] = "w" } return strings.Join(toks, " ") } // lines returns a body that ffuf counts as exactly n lines. ffuf's line count // is len(strings.Split(data, "\n")), so n "L"s joined by newlines (no trailing // newline) yields exactly n, matching the /size and /words convention. func lines(n int) string { if n <= 0 { return "" } ls := make([]string, n) for i := range ls { ls[i] = "L" } return strings.Join(ls, "\n") } func drain(r *http.Request) string { if r.Body == nil { return "" } buf := make([]byte, 0, 512) tmp := make([]byte, 512) for { n, err := r.Body.Read(tmp) buf = append(buf, tmp[:n]...) if err != nil { break } } return string(buf) } ffuf-2.3.0/pkg/testtarget/server_test.go000066400000000000000000000050171525032755300203170ustar00rootroot00000000000000package testtarget import ( "io" "net/http" "strings" "testing" ) // TestBodyHelpers grounds the size/word/line helpers against independently // computed values, NOT against ffuf's own counters, so a symmetric off-by-one in // both the mock and ffuf can't cancel out and hide a counting regression. func TestBodyHelpers(t *testing.T) { for _, n := range []int{0, 1, 37, 100} { if got := len(filled(n)); got != n { t.Errorf("filled(%d) length = %d, want %d", n, got, n) } } // words(n) is n tokens, i.e. n-1 single-space separators. for _, n := range []int{1, 5, 10} { if got := strings.Count(words(n), " "); got != n-1 { t.Errorf("words(%d) has %d spaces, want %d", n, got, n-1) } } // lines(n) is n lines, i.e. n-1 newline separators (no trailing newline). for _, n := range []int{1, 3, 5} { if got := strings.Count(lines(n), "\n"); got != n-1 { t.Errorf("lines(%d) has %d newlines, want %d", n, got, n-1) } } } // TestEndpoints checks the gate/status behavior the integration suite relies on, // so a mock bug is caught here rather than misattributed to ffuf. func TestEndpoints(t *testing.T) { tt := New() defer tt.Close() client := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } cases := []struct { path string status int }{ {"/status/418", 418}, {"/size/50", 200}, {"/map/ok", 200}, {"/map/bad", 500}, {"/needs-header", 403}, // gated: no header {"/rdir", 301}, // redirect-based directory {"/nope", 404}, } for _, c := range cases { resp, err := client.Get(tt.URL + c.path) if err != nil { t.Fatalf("GET %s: %v", c.path, err) } if resp.StatusCode != c.status { t.Errorf("GET %s: status %d, want %d", c.path, resp.StatusCode, c.status) } resp.Body.Close() } // /size/50 body is exactly 50 bytes. resp, err := client.Get(tt.URL + "/size/50") if err != nil { t.Fatalf("GET /size/50: %v", err) } b, _ := io.ReadAll(resp.Body) resp.Body.Close() if len(b) != 50 { t.Errorf("/size/50 body = %d bytes, want 50", len(b)) } // The gated header endpoint opens with the right header. req, _ := http.NewRequest("GET", tt.URL+"/needs-header", nil) req.Header.Set("X-Test", "yes") resp, err = client.Do(req) if err != nil { t.Fatalf("GET /needs-header (with header): %v", err) } if resp.StatusCode != 200 { t.Errorf("/needs-header with X-Test: got %d, want 200", resp.StatusCode) } resp.Body.Close() // Requests were recorded. if tt.Count() == 0 { t.Error("recorder captured no requests") } } ffuf-2.3.0/test/000077500000000000000000000000001525032755300134305ustar00rootroot00000000000000ffuf-2.3.0/test/e2e/000077500000000000000000000000001525032755300141035ustar00rootroot00000000000000ffuf-2.3.0/test/e2e/e2e_test.go000066400000000000000000000206551525032755300161540ustar00rootroot00000000000000// Package e2e is the black-box layer: it builds the real ffuf binary and runs it // as a subprocess against the in-process mock target (reachable over real TCP), // then asserts on its output. This is the only layer that exercises argv -> flag // parsing -> engine -> formatted output end to end, which is where CLI/flag wiring // regressions surface. package e2e import ( "context" "encoding/json" "fmt" "os" "os/exec" "path" "path/filepath" "reflect" "runtime" "sort" "strings" "testing" "time" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // ffufBin is the binary built once for the whole package by TestMain. var ffufBin string func TestMain(m *testing.M) { os.Exit(runMain(m)) } func runMain(m *testing.M) int { dir, err := os.MkdirTemp("", "ffuf-e2e") if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } defer os.RemoveAll(dir) ffufBin = filepath.Join(dir, "ffuf") if runtime.GOOS == "windows" { ffufBin += ".exe" } build := exec.Command("go", "build", "-o", ffufBin, ".") build.Dir = "../.." // go test runs with cwd = this package's dir if out, err := build.CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "go build ffuf failed: %v\n%s", err, out) return 1 } return m.Run() } // runFfuf runs the built binary with a hard timeout so a hung run fails fast // rather than burning the whole test timeout. env may be nil (inherit). func runFfuf(t *testing.T, env []string, args ...string) []byte { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, ffufBin, args...) if env != nil { cmd.Env = env } out, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { t.Fatalf("ffuf timed out; args: %v", args) } if err != nil { t.Fatalf("ffuf failed: %v; args: %v", err, args) } return out } func writeWordlist(t *testing.T, words []string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "wordlist-*.txt") if err != nil { t.Fatalf("temp wordlist: %v", err) } defer f.Close() for _, w := range words { fmt.Fprintln(f, w) } return f.Name() } func assertSet(t *testing.T, got, want []string) { t.Helper() sort.Strings(got) sort.Strings(want) if !reflect.DeepEqual(got, want) { t.Errorf("got %v, want %v", got, want) } } // jsonResult is the subset of a -json result line the e2e tests read. Input // unmarshals from base64 straight into []byte. type jsonResult struct { Input map[string][]byte `json:"input"` Url string `json:"url"` } func parseResults(stdout []byte) []jsonResult { var out []jsonResult for _, line := range strings.Split(string(stdout), "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "{") { continue } var r jsonResult if json.Unmarshal([]byte(line), &r) == nil && r.Url != "" { out = append(out, r) } } return out } // matchedPayloads returns the sorted set of last-path-segments of matched URLs. func matchedPayloads(stdout []byte) []string { seen := make(map[string]bool) var out []string for _, r := range parseResults(stdout) { p := path.Base(r.Url) if !seen[p] { seen[p] = true out = append(out, p) } } sort.Strings(out) return out } // TestE2E_JSONOutput: the CLI parsed the flags, ran the scan, and emitted the // matched set as JSON. func TestE2E_JSONOutput(t *testing.T) { target := testtarget.New() defer target.Close() wl := writeWordlist(t, []string{"200", "201", "404"}) out := runFfuf(t, nil, "-u", target.URL+"/status/FUZZ", "-w", wl, "-mc", "200,201", "-json", "-t", "1", "-noninteractive", ) assertSet(t, matchedPayloads(out), []string{"200", "201"}) } // TestE2E_DefaultMatcher: with NO -mc, the default status set // (200-299,301,302,307,401,403,405,500) applies. This is the only test that // exercises main.SetupFilters' defaulting; 404 must be filtered out. func TestE2E_DefaultMatcher(t *testing.T) { target := testtarget.New() defer target.Close() wl := writeWordlist(t, []string{"200", "403", "404", "500"}) out := runFfuf(t, nil, "-u", target.URL+"/status/FUZZ", "-w", wl, "-json", "-t", "1", "-noninteractive", ) assertSet(t, matchedPayloads(out), []string{"200", "403", "500"}) } // TestE2E_MatcherSuppressesDefault: setting -mr (any non-status matcher) with no // -mc suppresses the default status matcher (SetupFilters: statusSet || !matcherSet). // If the default were NOT suppressed, every 200 /reflect response would match and // "drop" would leak in. func TestE2E_MatcherSuppressesDefault(t *testing.T) { target := testtarget.New() defer target.Close() wl := writeWordlist(t, []string{"keep", "drop"}) out := runFfuf(t, nil, "-u", target.URL+"/reflect/FUZZ", "-w", wl, "-mr", "keep", "-json", "-t", "1", "-noninteractive", ) assertSet(t, matchedPayloads(out), []string{"keep"}) } // hashFor returns the FFUFHASH of the result whose URL ends in the given payload. func hashFor(t *testing.T, stdout []byte, payload string) string { t.Helper() for _, r := range parseResults(stdout) { if path.Base(r.Url) == payload { if h := r.Input["FFUFHASH"]; len(h) > 0 { return string(h) } } } t.Fatalf("no result for payload %q with a FFUFHASH in:\n%s", payload, stdout) return "" } // TestE2E_FFUFHashRoundTrip: take a FFUFHASH from a scan and feed it to -search, // asserting the original request is reconstructed. Exercises the retained-options // history serialization end to end. Config dir isolated via XDG_CONFIG_HOME. func TestE2E_FFUFHashRoundTrip(t *testing.T) { target := testtarget.New() defer target.Close() cfgHome := t.TempDir() env := append(os.Environ(), "XDG_CONFIG_HOME="+cfgHome) wl := writeWordlist(t, []string{"200", "201"}) out1 := runFfuf(t, env, "-u", target.URL+"/status/FUZZ", "-w", wl, "-mc", "200,201", "-json", "-t", "1", "-noninteractive", ) hash := hashFor(t, out1, "200") // Hermeticity: on Linux XDG_CONFIG_HOME is honored, so the history must have // been written under the temp dir. (Skipped on macOS where adrg/xdg ignores it.) if runtime.GOOS == "linux" { if _, err := os.Stat(filepath.Join(cfgHome, "ffuf", "history")); err != nil { t.Errorf("history was not written under the isolated config dir: %v", err) } } out2 := runFfuf(t, env, "-search", hash, "-noninteractive") s := string(out2) if !strings.Contains(s, "Request candidate") { t.Fatalf("-search found no candidate for %q:\n%s", hash, s) } if !strings.Contains(s, "/status/200") { t.Errorf("-search did not reconstruct /status/200:\n%s", s) } } // TestE2E_FFUFHashRecursion: a recursed result's history must carry the RECURSED // URL, not the base (historyOptions refreshes HTTP.URL from the live Config). The // "secret" match comes from the /admin/ recursion job, so -search must reconstruct // /admin/secret. func TestE2E_FFUFHashRecursion(t *testing.T) { target := testtarget.New() defer target.Close() cfgHome := t.TempDir() env := append(os.Environ(), "XDG_CONFIG_HOME="+cfgHome) wl := writeWordlist(t, []string{"admin", "secret"}) out1 := runFfuf(t, env, "-u", target.URL+"/FUZZ", "-w", wl, "-mc", "200", "-recursion", "-recursion-strategy", "greedy", "-recursion-depth", "1", "-json", "-t", "1", "-noninteractive", ) // "secret" only matches at /admin/secret, i.e. inside the recursion job. if !strings.Contains(strings.Join(matchedPayloads(out1), ","), "secret") { t.Fatalf("recursion did not find 'secret':\n%s", out1) } hash := hashFor(t, out1, "secret") out2 := runFfuf(t, env, "-search", hash, "-noninteractive") if !strings.Contains(string(out2), "/admin/secret") { t.Errorf("recursed hash did not reconstruct /admin/secret (history kept the base URL?):\n%s", out2) } } // TestE2E_FFUFHashRequestOptions: headers, method and body must survive the // serialize -> history file -> deserialize -> reconstruct round-trip. -search // dumps the raw request, so they appear verbatim. func TestE2E_FFUFHashRequestOptions(t *testing.T) { target := testtarget.New() defer target.Close() cfgHome := t.TempDir() env := append(os.Environ(), "XDG_CONFIG_HOME="+cfgHome) wl := writeWordlist(t, []string{"200"}) out1 := runFfuf(t, env, "-u", target.URL+"/status/FUZZ", "-w", wl, "-X", "POST", "-H", "X-Test: roundtrip", "-d", "body=FUZZ", "-mc", "200", "-json", "-t", "1", "-noninteractive", ) hash := hashFor(t, out1, "200") out2 := runFfuf(t, env, "-search", hash, "-noninteractive") s := string(out2) for _, want := range []string{"POST", "X-Test: roundtrip", "body=200"} { if !strings.Contains(s, want) { t.Errorf("-search reconstruction missing %q:\n%s", want, s) } } } ffuf-2.3.0/test/integration/000077500000000000000000000000001525032755300157535ustar00rootroot00000000000000ffuf-2.3.0/test/integration/behavior_test.go000066400000000000000000000176521525032755300211530ustar00rootroot00000000000000package integration import ( "reflect" "sort" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // assertSet fails unless got equals want as a set. Both sides are sorted here, so // callers don't have to pre-sort want and result order (nondeterministic under // concurrency) never matters. func assertSet(t *testing.T, got, want []string) { t.Helper() sort.Strings(got) sort.Strings(want) if !reflect.DeepEqual(got, want) { t.Errorf("matched set = %v, want %v", got, want) } } // TestStatusMatcherDecoupled uses /map, where the HTTP status is NOT present in // the payload or body ("ok" -> 200 "mapped alpha", "bad" -> 500 "mapped gamma"). // So a status matcher can be distinguished from a matcher that keyed on the // payload or body by mistake. func TestStatusMatcherDecoupled(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/map/FUZZ", []string{"ok", "bad"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"ok"}) } func TestStatusMatcher(t *testing.T) { target := testtarget.New() defer target.Close() // Non-redirect codes only: a bare 3xx interacts with the http client's // redirect handling in Go-version-dependent ways (a 301 matches on recent Go // but not on the 1.18 floor). Status matching is exercised just as well with // 200/403/404/500, and the test then holds on every supported Go version. got := runScan(t, target.URL+"/status/FUZZ", []string{"200", "403", "404", "500"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200,403") }, ) assertSet(t, got, []string{"200", "403"}) } func TestSizeFilter(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/size/FUZZ", []string{"10", "20", "30"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") mustFilter(t, mm, "size", "20") }, ) assertSet(t, got, []string{"10", "30"}) } func TestWordsFilter(t *testing.T) { target := testtarget.New() defer target.Close() // /words/5 -> "w w w w w" -> 5 words; filtering words=5 drops it. got := runScan(t, target.URL+"/words/FUZZ", []string{"1", "5", "10"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") mustFilter(t, mm, "word", "5") }, ) assertSet(t, got, []string{"1", "10"}) } func TestLinesFilter(t *testing.T) { target := testtarget.New() defer target.Close() // /lines/3 -> "L\nL\nL\n" -> 3 lines; filtering lines=3 drops it. got := runScan(t, target.URL+"/lines/FUZZ", []string{"1", "3", "5"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") mustFilter(t, mm, "line", "3") }, ) assertSet(t, got, []string{"1", "5"}) } func TestRegexpMatcher(t *testing.T) { target := testtarget.New() defer target.Close() // Body is "reflected: ", so only the alpha request's body contains "alpha". got := runScan(t, target.URL+"/reflect/FUZZ", []string{"alpha", "beta"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "regexp", "alpha") }, ) assertSet(t, got, []string{"alpha"}) } func TestAutocalibration(t *testing.T) { target := testtarget.New() defer target.Close() // The /ac/ tree returns a constant soft-404 body for everything except // /ac/real. Autocalibration should learn the junk baseline and filter it, // leaving only the genuinely different "real" response. got := runScan(t, target.URL+"/ac/FUZZ", []string{"junk1", "junk2", "real"}, func(o *ffuf.ConfigOptions) { o.General.AutoCalibration = true // Custom calibration strings so calibration is hermetic: this path in // autoCalibrationStrings() returns them directly and never reads the // strategy JSON files from AUTOCALIBDIR, which a fresh CI checkout does // not have (without them, calibration silently installs no filter). // Both probe /ac/ and return the junk baseline. o.General.AutoCalibrationStrings = []string{"calibrate-one", "calibrate-two"} // Single-threaded so calibration installs the junk-size filter before // any wordlist request is matched. With concurrency, requests race the // calibration and the result is nondeterministic. o.General.Threads = 1 }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) assertSet(t, got, []string{"real"}) } func TestRecursion(t *testing.T) { target := testtarget.New() defer target.Close() // /admin matches (200); greedy recursion queues /admin/FUZZ, where /admin/secret // matches. Without recursion, "secret" (i.e. /secret) is a 404 and never matches, // so this test fails the moment recursion breaks. got := runScan(t, target.URL+"/FUZZ", []string{"admin", "secret", "missing"}, func(o *ffuf.ConfigOptions) { o.HTTP.Recursion = true o.HTTP.RecursionStrategy = "greedy" o.HTTP.RecursionDepth = 1 }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"admin", "secret"}) } // TestRecursionDefaultStrategy covers the default (redirect-based) recursion // strategy, which is a different code path (handleDefaultRecursionJob) from the // greedy one: /rdir 301-redirects to /rdir/, which ffuf detects as a directory // and descends into, finding /rdir/found. Asserting "found" matched is the // version-robust core signal (it is a 404 at the root, so it can only appear via // recursion); the /rdir 301 itself is not asserted because a bare 3xx is handled // differently across Go versions. // TestAutocalibrationConcurrent runs autocalibration at the default concurrency. // It cannot assert an exact set (junk requests already in flight before the filter // installs may leak, which is inherent, not a bug), but it locks the #907 // invariant that survives concurrency: "real" always matches, and no calibration // probe string ever appears as a result payload (before #907 the calibration input // map was mutated in place and a probe could be reported). Run under -race. func TestAutocalibrationConcurrent(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/ac/FUZZ", []string{"junk1", "junk2", "real"}, func(o *ffuf.ConfigOptions) { o.General.AutoCalibration = true o.General.AutoCalibrationStrings = []string{"calibrate-one", "calibrate-two"} // default Threads (40) — the point of this test }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) if !contains(got, "real") { t.Errorf("'real' should always match; got %v", got) } for _, p := range got { if p != "junk1" && p != "junk2" && p != "real" { t.Errorf("non-wordlist payload %q in results (calibration string leaked): %v", p, got) } } } func TestRecursionDefaultStrategy(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/FUZZ", []string{"rdir", "found"}, func(o *ffuf.ConfigOptions) { o.HTTP.Recursion = true o.HTTP.RecursionStrategy = "default" o.HTTP.RecursionDepth = 1 }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200,301") }, ) if !contains(got, "found") { t.Errorf("default recursion did not descend into /rdir/ to match 'found'; got %v", got) } } func TestRequestRecorder(t *testing.T) { target := testtarget.New() defer target.Close() _ = runScan(t, target.URL+"/status/FUZZ", []string{"200"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) reqs := target.Requests() if len(reqs) == 0 { t.Fatal("recorder captured no requests") } if reqs[0].Method != "GET" { t.Errorf("recorded method = %q, want GET", reqs[0].Method) } } func mustMatch(t *testing.T, mm ffuf.MatcherManager, name, value string) { t.Helper() if err := mm.AddMatcher(name, value); err != nil { t.Fatalf("AddMatcher(%q,%q): %v", name, value, err) } } func mustFilter(t *testing.T, mm ffuf.MatcherManager, name, value string) { t.Helper() if err := mm.AddFilter(name, value, false); err != nil { t.Fatalf("AddFilter(%q,%q): %v", name, value, err) } } ffuf-2.3.0/test/integration/encoders_test.go000066400000000000000000000015531525032755300211470ustar00rootroot00000000000000package integration import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // TestEncoderApplied checks that -enc transforms the payload before it is sent: // with FUZZ:b64encode, "hello" goes on the wire as its base64 form, which the // recorder confirms. func TestEncoderApplied(t *testing.T) { target := testtarget.New() defer target.Close() runScan(t, target.URL+"/reflect/FUZZ", []string{"hello"}, func(o *ffuf.ConfigOptions) { o.Input.Encoders = []string{"FUZZ:b64encode"} }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) // base64("hello") == "aGVsbG8="; exactly one request, with the encoded payload. assertSet(t, recordedPaths(target.Requests()), []string{"/reflect/aGVsbG8="}) if n := target.Count(); n != 1 { t.Errorf("encoder scan made %d requests, want exactly 1", n) } } ffuf-2.3.0/test/integration/harness_test.go000066400000000000000000000124721525032755300210120ustar00rootroot00000000000000// Package integration runs the real ffuf engine end-to-end against the // deterministic mock target in pkg/testtarget. It assembles a Job exactly as // main.go's prepareJob does (real input provider, real http runner, real // matchers and filters) and swaps in a capturing OutputProvider so a test can // assert on the exact set of inputs that matched. Nothing here parses argv, so // the global flag.CommandLine state is never touched; ConfigOptions are built // directly. CLI/flag wiring is covered separately by the black-box e2e layer. package integration import ( "context" "fmt" "os" "sort" "sync" "testing" "github.com/ffuf/ffuf/v2/pkg/assembly" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/filter" ) // TestMain points ffuf.SCRAPERDIR at an empty temp dir for the whole package. // BuildJob loads scraper rules from that global dir; a fresh environment (a CI // runner) has no populated XDG config dir, and unlike main the in-process harness // never runs ReadDefaultConfig to create it. Redirecting to an empty temp dir // keeps these tests hermetic, mirroring how the ffuf package redirects AUTOCALIBDIR. func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "ffuf-scraperdir-*") if err != nil { panic(err) } ffuf.SCRAPERDIR = tmp code := m.Run() _ = os.RemoveAll(tmp) os.Exit(code) } // runScan runs a full ffuf job against url with the given wordlist. configure // tweaks the ConfigOptions (recursion, autocalibration, request options, ...) // and setup installs the matchers/filters under test on the MatcherManager. It // returns the sorted, de-duplicated set of FUZZ inputs that matched. // // Result order is nondeterministic (the engine is concurrent), so callers must // compare against an expected SET, never an ordered slice. runScan sorts for // exactly that reason. func runScan(t *testing.T, url string, wordlist []string, configure func(*ffuf.ConfigOptions), setup func(ffuf.MatcherManager)) []string { t.Helper() opts := ffuf.NewConfigOptions() opts.HTTP.URL = url opts.HTTP.Method = "GET" opts.Input.Wordlists = []string{writeWordlist(t, wordlist)} opts.General.Quiet = true if configure != nil { configure(opts) } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel) if err != nil { t.Fatalf("ConfigFromOptions: %v", err) } // Install matchers/filters directly rather than via main.SetupFilters, // which reads the global flag set (flag.Visit) to decide defaults. The test // states its matchers explicitly; SetupFilters' CLI-defaulting logic is the // e2e layer's concern. conf.MatcherManager = filter.NewMatcherManager() if setup != nil { setup(conf.MatcherManager) } // Wire the Job through the SAME assembly path main uses, then swap in the // capturing output provider. This is what stops the harness from drifting from // production wiring. job, err := assembly.BuildJob(conf) if err != nil { t.Fatalf("BuildJob: %v", err) } out := &capture{} job.Output = out job.Start() return out.inputs() } // writeWordlist writes words to a temp file (cleaned up with the test) and // returns its path. func writeWordlist(t *testing.T, words []string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "wordlist-*.txt") if err != nil { t.Fatalf("temp wordlist: %v", err) } defer f.Close() for _, w := range words { fmt.Fprintln(f, w) } return f.Name() } // capture is a test OutputProvider that records matched responses. Result is // called from worker goroutines, so access is mutex-guarded. type capture struct { mu sync.Mutex responses []ffuf.Response results []ffuf.Result } func (c *capture) Result(resp ffuf.Response) { c.mu.Lock() c.responses = append(c.responses, resp) c.mu.Unlock() } // inputs returns the sorted, de-duplicated FUZZ payloads that matched. func (c *capture) inputs() []string { c.mu.Lock() defer c.mu.Unlock() seen := make(map[string]bool) out := make([]string, 0, len(c.responses)) for _, r := range c.responses { v := string(r.Request.Input["FUZZ"]) if !seen[v] { seen[v] = true out = append(out, v) } } sort.Strings(out) return out } // --- OutputProvider no-ops (the engine drives these; tests ignore them) --- func (c *capture) Banner() {} func (c *capture) Finalize() error { return nil } func (c *capture) Progress(ffuf.Progress) {} func (c *capture) Info(string) {} func (c *capture) Error(string) {} func (c *capture) Raw(string) {} func (c *capture) Warning(string) {} func (c *capture) PrintResult(ffuf.Result) {} func (c *capture) SaveFile(string, string) error { return nil } func (c *capture) GetCurrentResults() []ffuf.Result { c.mu.Lock() defer c.mu.Unlock() return c.results } func (c *capture) SetCurrentResults(r []ffuf.Result) { c.mu.Lock(); c.results = r; c.mu.Unlock() } func (c *capture) FilterCurrentResults(keep func(ffuf.Result) bool) { c.mu.Lock() defer c.mu.Unlock() filtered := make([]ffuf.Result, 0, len(c.results)) for _, r := range c.results { if keep(r) { filtered = append(filtered, r) } } c.results = filtered } func (c *capture) SetPaused(bool) {} func (c *capture) PendingResults() int { return 0 } func (c *capture) Reset() {} func (c *capture) Cycle() {} ffuf-2.3.0/test/integration/outputformats_test.go000066400000000000000000000132651525032755300223040ustar00rootroot00000000000000package integration import ( "bytes" "context" "encoding/csv" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/assembly" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/filter" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) func indexOf(row []string, col string) int { for i, c := range row { if c == col { return i } } return -1 } // runScanStdout runs a scan with the real stdout OutputProvider (which // accumulates Results and can write them to files), and returns it so a test can // SaveFile each format and inspect the output. It mirrors runScan; only the // output sink differs. func runScanStdout(t *testing.T, url string, wordlist []string, configure func(*ffuf.ConfigOptions), setup func(ffuf.MatcherManager)) ffuf.OutputProvider { t.Helper() opts := ffuf.NewConfigOptions() opts.HTTP.URL = url opts.HTTP.Method = "GET" opts.Input.Wordlists = []string{writeWordlist(t, wordlist)} opts.General.Quiet = true if configure != nil { configure(opts) } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel) if err != nil { t.Fatalf("ConfigFromOptions: %v", err) } conf.MatcherManager = filter.NewMatcherManager() if setup != nil { setup(conf.MatcherManager) } // Same assembly path as production; the real stdout provider is what these // tests want to exercise (file writers, result accumulation). job, err := assembly.BuildJob(conf) if err != nil { t.Fatalf("BuildJob: %v", err) } job.Start() return job.Output } // TestOutputConcurrentResultAccumulation drives the real stdout OutputProvider at // the default concurrency with many matches and asserts every one is accumulated. // Before #908 the unsynchronized append lost results (and raced); this is the // multi-threaded coverage that was previously pinned to Threads=1. Run under -race. func TestOutputConcurrentResultAccumulation(t *testing.T) { target := testtarget.New() defer target.Close() const n = 60 words := make([]string, n) for i := range words { words[i] = fmt.Sprintf("w%d", i) } out := runScanStdout(t, target.URL+"/reflect/FUZZ", words, nil, // default threads (40) func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) if got := len(out.GetCurrentResults()); got != n { t.Errorf("accumulated %d results at high concurrency, want %d (results lost?)", got, n) } } // TestOutputFormats runs a scan with two known matches and writes each supported // file format, checking the writer succeeds and the output actually carries the // results. It is the regression net for the pkg/output file writers. func TestOutputFormats(t *testing.T) { target := testtarget.New() defer target.Close() // Single-threaded: the real stdout provider accumulates results without // synchronizing, so under concurrency a result can be lost. This test is // about the file writers, not concurrency, so run it deterministically. out := runScanStdout(t, target.URL+"/status/FUZZ", []string{"200", "201"}, func(o *ffuf.ConfigOptions) { o.General.Threads = 1 }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200,201") }, ) dir := t.TempDir() for _, format := range []string{"json", "ejson", "csv", "ecsv", "md", "html"} { path := filepath.Join(dir, "out."+format) if err := out.SaveFile(path, format); err != nil { t.Errorf("SaveFile(%s): %v", format, err) continue } b, err := os.ReadFile(path) if err != nil { t.Errorf("read %s: %v", format, err) continue } if len(b) == 0 { t.Errorf("%s: wrote an empty file", format) continue } // Assert the actual result FIELDS (status + input), not just that a file // was written or that "200" appears (which the URL would satisfy). switch format { case "json": var doc struct { Results []struct { Input map[string]string `json:"input"` Status int64 `json:"status"` } `json:"results"` } if err := json.Unmarshal(b, &doc); err != nil { t.Errorf("json: %v", err) continue } var statuses, inputs []string for _, r := range doc.Results { statuses = append(statuses, fmt.Sprintf("%d", r.Status)) inputs = append(inputs, r.Input["FUZZ"]) } assertSet(t, statuses, []string{"200", "201"}) assertSet(t, inputs, []string{"200", "201"}) case "ejson": // ejson base64-encodes the input; unmarshalling into []byte decodes it. var doc struct { Results []struct { Input map[string][]byte `json:"input"` Status int64 `json:"status"` } `json:"results"` } if err := json.Unmarshal(b, &doc); err != nil { t.Errorf("ejson: %v", err) continue } var statuses, inputs []string for _, r := range doc.Results { statuses = append(statuses, fmt.Sprintf("%d", r.Status)) inputs = append(inputs, string(r.Input["FUZZ"])) } assertSet(t, statuses, []string{"200", "201"}) assertSet(t, inputs, []string{"200", "201"}) case "csv", "ecsv": rows, err := csv.NewReader(bytes.NewReader(b)).ReadAll() if err != nil { t.Errorf("%s: not valid CSV: %v", format, err) continue } if len(rows) != 3 { t.Errorf("%s: %d rows, want 3 (header + 2 results)", format, len(rows)) continue } col := indexOf(rows[0], "status_code") if col < 0 { t.Errorf("%s: no StatusCode column in header %v", format, rows[0]) continue } assertSet(t, []string{rows[1][col], rows[2][col]}, []string{"200", "201"}) case "md", "html": // Human formats: both status codes must be present in the rendered output. for _, in := range []string{"200", "201"} { if !strings.Contains(string(b), in) { t.Errorf("%s: output missing status %q", format, in) } } } } } ffuf-2.3.0/test/integration/phase2_test.go000066400000000000000000000160751525032755300205340ustar00rootroot00000000000000package integration import ( "sort" "strings" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // anyRequest reports whether any recorded request satisfies pred. Used to assert // what ffuf actually put on the wire (method, headers, body), not just that a // response matched. func anyRequest(reqs []testtarget.Recorded, pred func(testtarget.Recorded) bool) bool { for _, r := range reqs { if pred(r) { return true } } return false } // recordedPaths returns the sorted, de-duplicated set of request paths the target // received. Order is nondeterministic (the engine is concurrent), so callers // compare as a set. func recordedPaths(reqs []testtarget.Recorded) []string { seen := make(map[string]bool) out := make([]string, 0, len(reqs)) for _, r := range reqs { if !seen[r.Path] { seen[r.Path] = true out = append(out, r.Path) } } sort.Strings(out) return out } func contains(hay []string, needle string) bool { for _, h := range hay { if h == needle { return true } } return false } func assertContainsAll(t *testing.T, got, want []string) { t.Helper() for _, w := range want { if !contains(got, w) { t.Errorf("missing %q in %v", w, got) } } } // --- request options, verified through the request recorder ---------------- func TestRequestHeaderSent(t *testing.T) { target := testtarget.New() defer target.Close() // /needs-header returns 200 only when X-Test: yes is present, else 403. got := runScan(t, target.URL+"/needs-header?p=FUZZ", []string{"h"}, func(o *ffuf.ConfigOptions) { o.HTTP.Headers = []string{"X-Test: yes"} }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"h"}) sent := false for _, r := range target.Requests() { if r.Header.Get("X-Test") == "yes" { sent = true } } if !sent { t.Error("the -H header was not sent on the wire") } } func TestRequestHeaderMissingDoesNotMatch(t *testing.T) { target := testtarget.New() defer target.Close() // Without the header, /needs-header returns 403, so nothing matches. got := runScan(t, target.URL+"/needs-header?p=FUZZ", []string{"h"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{}) // De-vacuum: the empty result must be because the request was made and got a // 403, not because the engine failed to send anything. if target.Count() == 0 { t.Fatal("engine sent no request; the empty match set is meaningless") } } func TestRequestCookieSent(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/needs-cookie?p=FUZZ", []string{"c"}, func(o *ffuf.ConfigOptions) { o.HTTP.Cookies = []string{"SESSION=abc"} }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"c"}) if !anyRequest(target.Requests(), func(r testtarget.Recorded) bool { return strings.Contains(r.Header.Get("Cookie"), "SESSION=abc") }) { t.Error("the -b cookie was not sent on the wire") } } func TestRequestMethod(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/needs-method?p=FUZZ", []string{"m"}, func(o *ffuf.ConfigOptions) { o.HTTP.Method = "POST" }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"m"}) if !anyRequest(target.Requests(), func(r testtarget.Recorded) bool { return r.Method == "POST" }) { t.Error("the -X method was not sent as POST") } } func TestRequestBody(t *testing.T) { target := testtarget.New() defer target.Close() // /needs-body returns 200 only when the body contains "token". got := runScan(t, target.URL+"/needs-body?p=FUZZ", []string{"b"}, func(o *ffuf.ConfigOptions) { o.HTTP.Method = "POST" o.HTTP.Data = "token=FUZZ" }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"b"}) // Verify FUZZ was actually substituted in the body (not just that "token" // from the template survived): the wire body must be "token=b". if !anyRequest(target.Requests(), func(r testtarget.Recorded) bool { return r.Body == "token=b" }) { t.Error("the -d body was not sent with FUZZ substituted (want \"token=b\")") } } // --- redirects ------------------------------------------------------------- func TestFollowRedirects(t *testing.T) { target := testtarget.New() defer target.Close() // /redirect/3 -> /redirect/2 -> /redirect/1 -> /redirect/0 (200). With -r, // the final 200 matches; the recorder proves the whole chain was walked. got := runScan(t, target.URL+"/redirect/FUZZ", []string{"3"}, func(o *ffuf.ConfigOptions) { o.HTTP.FollowRedirects = true }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) assertSet(t, got, []string{"3"}) assertContainsAll(t, recordedPaths(target.Requests()), []string{"/redirect/3", "/redirect/2", "/redirect/1", "/redirect/0"}) } // --- matcher/filter combination -------------------------------------------- func TestFilterExcludesStatuses(t *testing.T) { target := testtarget.New() defer target.Close() // Match everything, then filter out 404 and 500: only 200 survives. got := runScan(t, target.URL+"/status/FUZZ", []string{"200", "404", "500"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") mustFilter(t, mm, "status", "404,500") }, ) assertSet(t, got, []string{"200"}) } // --- input modes (asserted through the recorder) --------------------------- func TestInputModeClusterbomb(t *testing.T) { target := testtarget.New() defer target.Close() runScan(t, target.URL+"/reflect/KEY1-KEY2", nil, func(o *ffuf.ConfigOptions) { o.Input.InputMode = "clusterbomb" o.Input.Wordlists = []string{ writeWordlist(t, []string{"a", "b"}) + ":KEY1", writeWordlist(t, []string{"1", "2"}) + ":KEY2", } }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) // clusterbomb is the cartesian product: exactly every combination, no more. // Exact set catches extra paths; the count catches duplicates (recordedPaths // de-duplicates, so a doubled request would slip past a set check alone). assertSet(t, recordedPaths(target.Requests()), []string{"/reflect/a-1", "/reflect/a-2", "/reflect/b-1", "/reflect/b-2"}) if n := target.Count(); n != 4 { t.Errorf("clusterbomb 2x2 made %d requests, want exactly 4 (over-production?)", n) } } func TestInputModePitchfork(t *testing.T) { target := testtarget.New() defer target.Close() runScan(t, target.URL+"/reflect/KEY1-KEY2", nil, func(o *ffuf.ConfigOptions) { o.Input.InputMode = "pitchfork" o.Input.Wordlists = []string{ writeWordlist(t, []string{"a", "b"}) + ":KEY1", writeWordlist(t, []string{"1", "2"}) + ":KEY2", } }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) // pitchfork reads the wordlists in lockstep: exactly a-1 and b-2, no cross terms. assertSet(t, recordedPaths(target.Requests()), []string{"/reflect/a-1", "/reflect/b-2"}) if n := target.Count(); n != 2 { t.Errorf("pitchfork made %d requests, want exactly 2 (over-production?)", n) } } ffuf-2.3.0/test/integration/phase2b_test.go000066400000000000000000000037751525032755300207010ustar00rootroot00000000000000package integration import ( "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) func TestInputModeSniper(t *testing.T) { target := testtarget.New() defer target.Close() // Sniper fuzzes one marked position at a time, holding the others at their // literal text: /reflect/§a§/§b§ with [x,y] hits /reflect/{x,y}/b (first // position) and /reflect/a/{x,y} (second position). runScan(t, target.URL+"/reflect/§a§/§b§", []string{"x", "y"}, func(o *ffuf.ConfigOptions) { o.Input.InputMode = "sniper" }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) assertSet(t, recordedPaths(target.Requests()), []string{"/reflect/x/b", "/reflect/y/b", "/reflect/a/x", "/reflect/a/y"}) if n := target.Count(); n != 4 { t.Errorf("sniper made %d requests, want exactly 4 (2 positions x 2 words)", n) } } func TestMatcherModeAnd(t *testing.T) { target := testtarget.New() defer target.Close() // Two matchers in "and" mode: a response must be 200 AND contain "keep". // Every /reflect is 200, so the regexp is the discriminator. got := runScan(t, target.URL+"/reflect/FUZZ", []string{"keep", "drop"}, func(o *ffuf.ConfigOptions) { o.Matcher.Mode = "and" }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") mustMatch(t, mm, "regexp", "keep") }, ) assertSet(t, got, []string{"keep"}) } func TestFilterModeAnd(t *testing.T) { target := testtarget.New() defer target.Close() // Two filters in "and" mode: a response is dropped only if it is 200 AND // contains "secret". Every /reflect is 200, so only /reflect/secret is // dropped; with the default "or" mode the 200 filter would drop everything. got := runScan(t, target.URL+"/reflect/FUZZ", []string{"secret", "public"}, func(o *ffuf.ConfigOptions) { o.Filter.Mode = "and" }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") mustFilter(t, mm, "status", "200") mustFilter(t, mm, "regexp", "secret") }, ) assertSet(t, got, []string{"public"}) } ffuf-2.3.0/test/integration/recursion_concurrency_test.go000066400000000000000000000026541525032755300237730ustar00rootroot00000000000000package integration import ( "fmt" "testing" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // TestRecursionConcurrentQueue regresses C2: worker goroutines appended to the // shared recursion queue with no synchronization. Greedy recursion on // /reflect/FUZZ makes every one of the (default 40 thread) base matches queue a // recursion job at nearly the same instant, so many workers append concurrently. // Run under -race; before the fix the concurrent appends trip the detector and // can drop a queued job or panic on a torn slice header. func TestRecursionConcurrentQueue(t *testing.T) { target := testtarget.New() defer target.Close() words := make([]string, 25) for i := range words { words[i] = fmt.Sprintf("d%d", i) } _ = runScan(t, target.URL+"/reflect/FUZZ", words, func(o *ffuf.ConfigOptions) { o.HTTP.Recursion = true o.HTTP.RecursionStrategy = "greedy" o.HTTP.RecursionDepth = 1 }, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "200") }, ) // With every base match queuing a depth-1 job that then runs the whole // wordlist, the target must see far more than the 25 base requests. This both // proves recursion descended and is the workload that exercises the concurrent // queue appends under -race. if n := target.Count(); n <= len(words) { t.Errorf("expected recursion to append and process queued jobs (>%d requests), got %d", len(words), n) } } ffuf-2.3.0/test/integration/timing_test.go000066400000000000000000000032771525032755300206410ustar00rootroot00000000000000//go:build timing // These tests make wall-clock assertions (response-time matching, rate limiting), // which are inherently noisy on shared CI runners. They live behind the `timing` // build tag so they never gate the main suite; run them with: // // go test -tags=timing ./test/integration/... package integration import ( "fmt" "testing" "time" "github.com/ffuf/ffuf/v2/pkg/ffuf" "github.com/ffuf/ffuf/v2/pkg/testtarget" ) // TestTimeMatcher: /sleep/1000 responds ~1s, /sleep/1 near-instant, so a // "time > 500" matcher keeps only the slow one. The 500ms gap is wide enough to // stay stable even on a loaded CI runner. func TestTimeMatcher(t *testing.T) { target := testtarget.New() defer target.Close() got := runScan(t, target.URL+"/sleep/FUZZ", []string{"1", "1000"}, nil, func(mm ffuf.MatcherManager) { mustMatch(t, mm, "time", ">500") }, ) assertSet(t, got, []string{"1000"}) } // TestRateLimit: N requests at -rate R should take at least ~N/R seconds. A wide // lower bound absorbs runner noise while still catching a throttle that stops // throttling entirely. func TestRateLimit(t *testing.T) { target := testtarget.New() defer target.Close() words := make([]string, 30) for i := range words { words[i] = fmt.Sprintf("w%d", i) } start := time.Now() runScan(t, target.URL+"/status/FUZZ", words, func(o *ffuf.ConfigOptions) { o.General.Rate = 20 }, // 20 req/s func(mm ffuf.MatcherManager) { mustMatch(t, mm, "status", "all") }, ) elapsed := time.Since(start) // 30 requests at 20/s is ~1.5s; require at least 800ms to allow for noise. if elapsed < 800*time.Millisecond { t.Errorf("rate limiting too fast: 30 requests at -rate 20 took %v, want >= ~800ms", elapsed) } } ffuf-2.3.0/testdata/000077500000000000000000000000001525032755300142625ustar00rootroot00000000000000ffuf-2.3.0/testdata/config_acs_strategies.golden000066400000000000000000000034761525032755300220130ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "advanced", "greedy", "custom" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -acs advanced,greedy -acs custom", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_alias_cookie.golden000066400000000000000000000034611525032755300214270ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -cookie SESSION=abc", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": { "Cookie": "SESSION=abc" }, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_alias_data_binary.golden000066400000000000000000000034371525032755300224360ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/ -w /tmp/wl.txt -X POST -data-binary name=FUZZ", "configfile": "", "postdata": "name=FUZZ", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "POST", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_autocalibration.golden000066400000000000000000000034731525032755300221700ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": true, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [ "custom1", "custom2" ], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -ac -acc custom1 -acc custom2", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_basic.golden000066400000000000000000000033761525032755300200730ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_compat_data_alias.golden000066400000000000000000000034121525032755300224260ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/ -w /tmp/wl.txt -data x=FUZZ", "configfile": "", "postdata": "x=FUZZ", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "POST", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_config_ach_no_autoenable.golden000066400000000000000000000033751525032755300237640ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": true, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_config_client_cert.golden000066400000000000000000000034271525032755300226270ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "/tmp/cert.pem", "client-key": "/tmp/key.pem", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_delay_range.golden000066400000000000000000000034161525032755300212570ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -p 0.1-0.8", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.10-0.80" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_encoders.golden000066400000000000000000000034451525032755300206110ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt:FUZZ -enc FUZZ:b64encode", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "b64encode", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt:FUZZ" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_error_missing_url.golden000066400000000000000000000030311525032755300225420ustar00rootroot00000000000000error: 1 errors occured. * -u flag or -request flag is required { "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -w /tmp/wl.txt", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } ffuf-2.3.0/testdata/config_error_missing_wordlist.golden000066400000000000000000000030561525032755300236160ustar00rootroot00000000000000error: 1 errors occured. * Either -w or --input-cmd flag is required { "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } ffuf-2.3.0/testdata/config_extensions.golden000066400000000000000000000034451525032755300212060ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -e .php,.bak", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [ ".php", ".bak" ], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_headers_multi.golden000066400000000000000000000034641525032755300216350ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -H X-A: 1 -H X-B: 2", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": { "X-A": "1", "X-B": "2" }, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_many_flags.golden000066400000000000000000000040221525032755300211170ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -x http://127.0.0.1:8080 -replay-proxy http://127.0.0.1:9090 -sni example.com -timeout 15 -rate 50 -recursion -recursion-depth 3 -recursion-strategy greedy -of json -od /tmp/out -maxtime 60 -json -r -raw -http2 -ic -D -sf", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": true, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": true, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": true, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": true, "matchers": null, "mmode": "or", "maxtime": 60, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "/tmp/out", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "http://127.0.0.1:8080", "quiet": false, "rate": 50, "raw": true, "recursion": true, "recursion_depth": 3, "recursion_strategy": "greedy", "replayproxyurl": "http://127.0.0.1:9090", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "example.com", "stop_403": true, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 15, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": true, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_matchers_filters.golden000066400000000000000000000034501525032755300223410ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -mc 200,301 -fc 404 -fs 42 -ml 5", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: line = 5 status = 200,301 filters: size = 42 status = 404 ffuf-2.3.0/testdata/config_multi_wordlist_pitchfork.golden000066400000000000000000000036731525032755300241440ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/W1/W2 -w /tmp/a.txt:W1 -w /tmp/b.txt:W2 -mode pitchfork", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "pitchfork", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "W1", "value": "/tmp/a.txt", "encoders": "", "template": "" }, { "name": "wordlist", "keyword": "W2", "value": "/tmp/b.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/W1/W2", "verbose": false, "wordlists": [ "/tmp/a.txt:W1", "/tmp/b.txt:W2" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_precedence_cli_overrides_file.golden000066400000000000000000000034021525032755300250050ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt -t 5", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 5, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_precedence_file_used.golden000066400000000000000000000033761525032755300231260ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -u https://example.org/FUZZ -w /tmp/wl.txt", "configfile": "", "postdata": "", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": {}, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "GET", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 99, "timeout": 10, "url": "https://example.org/FUZZ", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/config_raw_request.golden000066400000000000000000000035241525032755300213460ustar00rootroot00000000000000{ "auditlog": "", "autocalibration": false, "autocalibration_keyword": "FUZZ", "autocalibration_perhost": false, "autocalibration_strategies": [ "basic" ], "autocalibration_strings": [], "colors": false, "cmdline": "ffuf -w /tmp/wl.txt -request $REQFILE", "configfile": "", "postdata": "{\"q\":\"FUZZ\"}", "debuglog": "", "delay": { "value": "0.00" }, "dirsearch_compatibility": false, "encoders": [], "extensions": [], "fmode": "or", "follow_redirects": false, "headers": { "Content-Type": "application/json", "Host": "example.org" }, "ignorebody": false, "ignore_wordlist_comments": false, "inputmode": "clusterbomb", "cmd_inputnum": 100, "inputproviders": [ { "name": "wordlist", "keyword": "FUZZ", "value": "/tmp/wl.txt", "encoders": "", "template": "" } ], "inputshell": "", "json": false, "matchers": null, "mmode": "or", "maxtime": 0, "maxtime_job": 0, "method": "POST", "noninteractive": false, "outputdirectory": "", "outputfile": "", "outputformat": "", "OutputSkipEmptyFile": false, "proxyurl": "", "quiet": false, "rate": 0, "raw": false, "recursion": false, "recursion_depth": 0, "recursion_strategy": "default", "replayproxyurl": "", "requestfile": "$REQFILE", "requestproto": "https", "scraperfile": "", "scrapers": "all", "sni": "", "stop_403": false, "stop_all": false, "stop_errors": false, "threads": 40, "timeout": 10, "url": "https://example.org/submit", "verbose": false, "wordlists": [ "/tmp/wl.txt" ], "http2": false, "client-cert": "", "client-key": "", "preflights": [], "postflights": [], "preflight_mode": "per-request", "preflight_error": "abort" } --- matchers after SetupFilters --- matchers: status = 200-299,301,302,307,401,403,405,500 filters: ffuf-2.3.0/testdata/help.golden000066400000000000000000000163531525032755300164140ustar00rootroot00000000000000Fuzz Faster U Fool - 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) -postflight Raw HTTP request file to run after each fuzzing request (repeatable, order matters) -postflight-var Extract a variable from the preceding -postflight response: "NAME:regex" (repeatable) -preflight Raw HTTP request file to run before each fuzzing request (repeatable, order matters) -preflight-error Preflight error handling: "abort" or "ignore" (default: abort) -preflight-mode Preflight execution mode: "per-request" or "per-thread" (default: per-request) -preflight-var Extract a variable from the preceding -preflight response: "NAME:regex" (repeatable) -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: -audit-log Write audit log containing all requests, responses and config -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