├── .gitignore ├── LICENSE ├── README.md ├── crs.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | dl/ 2 | competitions/ 3 | cache/ 4 | api_token.txt 5 | .*.sw* 6 | *.pyc 7 | output.ppm 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018, Andrew Fasano 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleCRS 2 | This Python 3 script is an example consumer of the Rode0day API. It will automatically play in Rode0day competitions using AFL in qemu-mode. At the end of each competition, the script will load the next competition and switch to fuzzing those binaries. 3 | 4 | ## Installation 5 | 1. `git clone https://github.com/AndrewFasano/simple-crs.git` 6 | 1. `cd simple-crs` 7 | 1. `mkvirtualenv --python=$(which python3) crs` 8 | 1. `pip install -r requirements.txt` 9 | 1. Save your API key provied at https://rode0day.mit.edu/profile into `api_token.txt` 10 | 1. Run with `./crs.py` 11 | 12 | To enable afl-support you must also build AFL in qemu\_mode as described in [AFL's README](https://github.com/mirrorer/afl/blob/master/qemu_mode/README.qemu) and place the afl-fuzz binary is on your $PATH. 13 | 14 | 15 | ## Features 16 | * Get competition status 17 | * Get competition files 18 | * Run challenges with sample input 19 | * Try to find bugs with afl in qemu mode 20 | * Submit bug-triggering inputs 21 | * Caching to minimize rate-limited requests 22 | 23 | ## Planned features 24 | * Additional fuzzing backends 25 | -------------------------------------------------------------------------------- /crs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import shutil 4 | import yaml 5 | import pickle 6 | import os 7 | import tarfile 8 | import logging 9 | import glob 10 | import time 11 | import shlex 12 | import threading 13 | import subprocess 14 | from datetime import datetime 15 | import requests 16 | 17 | API_TOKEN = open("api_token.txt").read().strip() 18 | CACHE_DIR = "cache" 19 | COMP_DIR = "competitions" 20 | API_BASE = "https://rode0day.mit.edu/api/1.0/" 21 | AFL_PATH = "/home/andrew/git/afl/afl-fuzz" # Change to the location of afl-fuzz on your system 22 | 23 | if not os.path.isfile(AFL_PATH): 24 | raise RuntimeError("You must update your AFL_PATH to the the location of afl-fuzz") 25 | 26 | logging.basicConfig(format='%(levelname)s:\t%(message)s') 27 | logger = logging.getLogger(__name__) 28 | logger.setLevel(logging.INFO) 29 | 30 | requests_log = logging.getLogger("requests.packages.urllib3") 31 | requests_log.setLevel(logging.WARNING) 32 | 33 | for x in [COMP_DIR, CACHE_DIR]: 34 | if not os.path.exists(x): 35 | os.makedirs(x) 36 | 37 | assert(len(API_TOKEN)) 38 | 39 | def get_status(force_reload=False): 40 | """ 41 | Get status from /latest.yaml, save to CACHE_DIR/latest.yaml locally 42 | Only reload when we're past the end date of the local file 43 | """ 44 | 45 | latest_path = os.path.join(CACHE_DIR, "latest.yaml") 46 | if os.path.isfile(latest_path) and not force_reload: 47 | try: 48 | data = pickle.load(open(latest_path, "rb")) 49 | if not data["rode0day_id"]: 50 | logger.warning("No rode0day_id cached- refresh") 51 | return get_status(True) 52 | if 'end' in data.keys() and datetime.utcnow() < data['end']: 53 | logger.debug("Using cached status becaue %s < %s", datetime.utcnow(), data['end']) 54 | return data 55 | except pickle.PickleError: 56 | logger.warning("Cached latest.yaml was corrupted") 57 | raise 58 | 59 | r = requests.get(API_BASE+"latest.yaml") 60 | try: 61 | r.raise_for_status() 62 | except requests.exceptions.HTTPError as e: 63 | logger.error("HTTP Error loading status: %s", e.response.text) 64 | return None 65 | 66 | try: 67 | data = yaml.load(r.text) 68 | except pickle.PickleError: 69 | logger.error("Could not load status, got message: %s", r.text) 70 | return None 71 | 72 | if 'rode0day_id' not in data.keys(): 73 | logger.error("Invalid response from api (missing rode0day_id): %s", data) 74 | return None 75 | if not data["rode0day_id"]: 76 | if data["next_start"]: 77 | return data 78 | 79 | logger.warning("No Rode0day id or next_start provied, returning None") 80 | return None 81 | 82 | with open(latest_path, "wb") as f: 83 | pickle.dump(data, f) 84 | return data 85 | 86 | def get_competition(status=None): 87 | """ 88 | Get the .tar.gz for this competition, extract it into competition_X where X is the rode0day id 89 | """ 90 | if not status: 91 | status = get_status() 92 | dl_gz = status["download_link"] 93 | dl_path = os.path.join(CACHE_DIR, (os.path.basename(dl_gz))) 94 | extract_dir = os.path.join(COMP_DIR, str(status["rode0day_id"])) 95 | info_yaml = os.path.join(extract_dir, "info.yaml") 96 | 97 | if os.path.isfile(info_yaml): # already downloaded and extracted 98 | logger.debug("Already have info.yaml") 99 | return 100 | 101 | if not os.path.isfile(dl_path): 102 | logger.debug("Download %s into %s", dl_gz, dl_path) 103 | dl_tar = requests.get(dl_gz, stream=True) 104 | try: 105 | dl_tar.raise_for_status() 106 | except requests.exceptions.HTTPError as e: 107 | logger.error("HTTP Error getting competition binaries: %s", e.response.text) 108 | return None 109 | 110 | with open(dl_path, "wb") as f: 111 | shutil.copyfileobj(dl_tar.raw, f) 112 | 113 | logger.debug("Extracting %s into %s", dl_path, extract_dir) 114 | tar = tarfile.open(dl_path, "r:gz") 115 | tar.extractall(path=extract_dir) 116 | tar.close() 117 | 118 | 119 | def parse_info(status=None): 120 | """ 121 | Parse info.yaml for the current competition, return parsed yaml object 122 | """ 123 | if not status: 124 | status = get_status() 125 | yaml_file = os.path.join(os.path.join(COMP_DIR, str(status["rode0day_id"])), "info.yaml") 126 | if not os.path.isfile(yaml_file): 127 | raise RuntimeError("Missing info.yaml file: {}".format(yaml_file)) 128 | 129 | info = yaml.load(open(yaml_file)) 130 | if info["rode0day_id"] != status["rode0day_id"]: 131 | raise RuntimeError("Comeptition and latest disagree about the rode0day_id: {} {}".format(info["rode0day_id"], status["rode0day_id"])) 132 | 133 | return info 134 | 135 | def test_run(challenge, status=None): 136 | """ 137 | Run the program on the sample input - Just useful to make sure everything is working (note programs may have no output with sample inputs) 138 | """ 139 | if not status: 140 | status = get_status() 141 | 142 | local_dir = os.path.join('', *[COMP_DIR, str(status["rode0day_id"]), challenge["install_dir"]]) 143 | library_dir = None 144 | if "library_dir" in challenge.keys(): 145 | library_dir = os.path.join(local_dir, challenge["library_dir"]) 146 | binary = os.path.join(local_dir, challenge["binary_path"]) 147 | input_file = os.path.join(local_dir, challenge["sample_inputs"][0]) 148 | args = challenge["binary_arguments"].format(input_file=input_file, install_dir=local_dir) 149 | if library_dir: 150 | command = "LD_LIBRARY_PATH={library_dir} {binary} {args}".format(library_dir=library_dir, binary=binary, args=args) 151 | else: 152 | command = "{binary} {args}".format(binary=binary, args=args) 153 | logger.info("Locally running with sample input: %s", command) 154 | os.system(command) 155 | 156 | 157 | def submit_solution(file_path, challenge_id, status=None): 158 | """ 159 | Submit a solution 160 | Abort if competition has ended 161 | Skip if file already submitted 162 | Save bug_ids in cache and only print when we find new bugs 163 | """ 164 | 165 | if not status: 166 | status = get_status() 167 | 168 | if challenge_id not in status["challenge_ids"]: 169 | raise ValueError("Can't submit for challenge with id {} since it's not a part of the current competition ({})".format(challenge_id, status["challenge_ids"])) 170 | 171 | cache_pickle = os.path.join(CACHE_DIR, str(challenge_id)+".pickle") 172 | if os.path.isfile(cache_pickle): 173 | try: 174 | cache = pickle.load(open(cache_pickle, "rb")) 175 | except (EOFError, pickle.PickleError): 176 | logger.error("Couldn't write to %s. Skipping submission of %s for now", cache_pickle, file_path) 177 | 178 | else: 179 | cache = {} 180 | 181 | if "submitted_files" not in cache.keys(): 182 | cache["submitted_files"] = [] 183 | 184 | if file_path in cache["submitted_files"]: 185 | logger.debug("Skipping %s since we already submitted it", file_path) 186 | return 187 | 188 | with open(file_path, "rb") as f: 189 | input_file = f.read() 190 | r = requests.post(API_BASE+"submit", data={"challenge_id": challenge_id, "auth_token": API_TOKEN}, files={"input": input_file}) 191 | try: 192 | r.raise_for_status() 193 | except requests.exceptions.HTTPError as e: 194 | error = yaml.load(r.text) 195 | logger.warning("API Error %d: %s", error["status"], error["status_str"]) 196 | if error["status"] == 7: 197 | logger.warning("Sleeping for a minute...") 198 | time.sleep(60) 199 | return submit_solution(file_path, challenge_id, status) 200 | else: 201 | logger.warning("API Error %d: %s", error["status"], error["status_str"]) 202 | time.sleep(10) 203 | return None 204 | 205 | result = yaml.load(r.text) 206 | 207 | cache["submitted_files"].append(file_path) 208 | 209 | 210 | 211 | new_bugs = [] 212 | firsts = [] 213 | if result["status"] == 0: 214 | for bug in result["bug_ids"]: 215 | first = False 216 | if bug in result["first_ids"]: 217 | first = True 218 | firsts.append(bug) 219 | if bug not in cache.keys(): 220 | cache[bug] = {"first": first, "found_at": datetime.utcnow()} # Storing local timestamps in UTC as well 221 | new_bugs.append(bug) 222 | 223 | if len(new_bugs): 224 | firsts_str = "" 225 | if len(firsts): 226 | firsts_str = "(firsts: {})".format(', '.join([str(first_id) for first_id in firsts])) 227 | 228 | logger.info("Found new bug(s) for challenge %d: %s %s",challenge_id, ', '.join([str(bug_id) for bug_id in new_bugs]), firsts_str) 229 | logger.info("Score is now %d", result["score"]) 230 | 231 | elif result["status"] == 1: 232 | logger.warning("No crash with input %s", file_path) 233 | if result["status"] > 1: 234 | logger.warning("Error: %s", result['status_s']) 235 | 236 | # Update cache 237 | with open(cache_pickle, "wb") as f: 238 | pickle.dump(cache, f) 239 | 240 | 241 | logger.debug("%d API requests remaining", result["requests_remaining"]) 242 | return result["bug_ids"] 243 | 244 | 245 | def _start_afl(challenge, extra_args=None): 246 | """ 247 | Launch subprocess running afl-fuzz in qemu mode 248 | Translate file input and stdin into the right syntax for AFL 249 | """ 250 | 251 | status = get_status() 252 | now_ms = int(round(time.time()*1000)) 253 | 254 | local_dir = os.path.join('', *[COMP_DIR, str(status["rode0day_id"]), challenge["install_dir"]]) 255 | library_dir = None 256 | if "library_dir" in challenge.keys(): 257 | library_dir = os.path.join(local_dir, challenge["library_dir"]) 258 | binary = os.path.join(local_dir, challenge["binary_path"]) 259 | input_dir = os.path.dirname(os.path.join(local_dir, challenge["sample_inputs"][0])) # Assuming all input files are in the same directory 260 | output_dir = os.path.join('', *[COMP_DIR, str(status["rode0day_id"]), challenge["install_dir"], "outputs_"+str(now_ms)]) 261 | 262 | use_stdin = challenge["binary_arguments"].endswith("< {input_file}") # "< {input_file}" must be at end 263 | 264 | if use_stdin: 265 | args = challenge["binary_arguments"].replace("< {input_file}", "").format(install_dir=local_dir) # Remove input_file redirect entirely 266 | else: 267 | args = challenge["binary_arguments"].format(install_dir=local_dir, input_file="@@") # Input file name @@ is replaced by AFL with the fuzzed filename 268 | 269 | bin_command = "{binary} {args}".format(binary=binary, args=args) 270 | fuzz_command = "{afl_path} -Q -m 4098 -i {input_dir} -o {output_dir} {extra} -- {bin_command}".format(afl_path=AFL_PATH, library_dir=library_dir, 271 | input_dir=input_dir, output_dir=output_dir, bin_command=bin_command, 272 | extra=extra_args if extra_args else "") 273 | 274 | logger.info("AFL started with command: %s", fuzz_command) 275 | 276 | # We'll copy these all into the subprocess env, but this way we can print the things we've changed if there's an error 277 | custom_env={} 278 | if library_dir: 279 | custom_env["QEMU_SET_ENV"] = "LD_LIBRARY_PATH={}".format(library_dir) 280 | custom_env["AFL_INST_LIBS"] = "1" 281 | 282 | 283 | # AFL_INST_LIBS=1 QEMU_SET_ENV=LD_LIBRARY_PATH=$(pwd)/lib ~/git/afl/afl-fuzz -m 4192 -Q -i inputs/ -o output_test -- bin/file -m share/misc/magic.mgc @@ 284 | 285 | my_env = os.environ.copy() 286 | for k,v in custom_env.items(): 287 | my_env[k] = v 288 | 289 | try: 290 | subprocess.check_output(shlex.split(fuzz_command), stderr=subprocess.STDOUT, env=my_env) 291 | except subprocess.CalledProcessError as e: 292 | logger.error(e.output) 293 | print("Error while running:\n\t {} {}\n\n".format(" ".join(["{}={}".format(k, v) for k,v in custom_env.items()]), fuzz_command)) 294 | raise 295 | 296 | def _submit_loop(path, challenge_id): 297 | while True: 298 | for filepath in glob.glob(path): 299 | submit_solution(filepath, challenge_id) # Will only submit new filepaths so we can call repeatedly 300 | time.sleep(30) 301 | 302 | def compete(): 303 | """ 304 | Start a fuzzing and submission thread for each binary. Run until the competition ends 305 | """ 306 | status = get_status() 307 | get_competition(status) 308 | info = parse_info(status) 309 | fuzz_threads = [] 310 | for challenge_name, challenge in info['challenges'].items(): 311 | logger.debug("Processing %s", challenge_name) 312 | #test_run(challenge, status) 313 | 314 | # Start fuzzer thread 315 | extra_args = "" 316 | if "jq" in challenge_name: 317 | extra_args += "-t 10000" 318 | t = threading.Thread(target=_start_afl, args=(challenge,extra_args,)) 319 | t.daemon = True 320 | t.start() 321 | fuzz_threads.append(t) 322 | 323 | # Start submission thread watching AFL output 324 | output_path = "./competitions/{}/{}/outputs_*/crashes/*".format(info["rode0day_id"], challenge["install_dir"]) 325 | t2 = threading.Thread(target=_submit_loop, args=(output_path, challenge["challenge_id"],)) 326 | t2.daemon = True 327 | t2.start() 328 | 329 | for thread in fuzz_threads: 330 | # Join threads with timeout corresponding to end of competition 331 | # The join is blocking so we calculate the delta_t right before the call to join 332 | delta_t = status['end']-datetime.utcnow() 333 | thread.join(delta_t.total_seconds()) 334 | 335 | def main(): 336 | # While there's a competition happening, try to find solutions until we finish or time runs out 337 | # Then move on to the next competition 338 | finished = [] 339 | 340 | while True: 341 | status=get_status() 342 | if not status: 343 | logger.error("Could not get status, will retry in 1 minute") 344 | time.sleep(60) 345 | continue 346 | 347 | if not status['rode0day_id']: 348 | if "next_start" in status.keys(): 349 | logger.info("No active competition, sleeping until next starts at %s UTC", str(status["next_start"])) 350 | delta_t = status['next_start']-datetime.utcnow() 351 | time.sleep(delta_t.total_seconds()) 352 | continue 353 | else: 354 | logger.error("No active competition and unknown next start. Sleeping for 1 hour") 355 | time.sleep(60*60) 356 | continue 357 | if 'end' not in status: 358 | logger.warning("Provied status is missing end time, will continuing bug-finding forever") 359 | 360 | if 'end' in status and datetime.utcnow() > status['end']: 361 | logger.debug("Provied status is for an ended competition, retrying in 1 minute ") 362 | time.sleep(60) 363 | continue 364 | 365 | if status['rode0day_id'] in finished: 366 | delta_t = status['end']-datetime.utcnow() 367 | logger.info("Finished with competition %d, sleeping until next starts at %s (in %d seconds)", status['rode0day_id'], str(status['end']), delta_t.total_seconds()) 368 | time.sleep(delta_t.total_seconds()) 369 | continue 370 | 371 | compete() 372 | finished.append(status['rode0day_id']) 373 | 374 | if __name__ == "__main__": 375 | main() 376 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML >= 3.12 2 | requests >= 2.9.1 3 | --------------------------------------------------------------------------------