diff --git a/.github/workflows/kernelctf-submission-verification.yaml b/.github/workflows/kernelctf-submission-verification.yaml new file mode 100644 index 00000000..e5bec91a --- /dev/null +++ b/.github/workflows/kernelctf-submission-verification.yaml @@ -0,0 +1,205 @@ +name: kernelCTF PR check +on: + pull_request_target: + types: [opened, synchronize, reopened, labeled] + paths: [pocs/linux/kernelctf/**] + workflow_dispatch: + inputs: + prNumber: + description: 'PR number' + type: number + required: true +permissions: {} +env: + PR_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/merge', github.event.inputs.prNumber) || github.event.pull_request.head.sha }} +jobs: + structure_check: + # if labeling triggered the job then only run in case of the "recheck" label + if: github.event.action != 'labeled' || github.event.label.name == 'recheck' + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.check_submission.outputs.targets }} + submission_dir: ${{ steps.check_submission.outputs.submission_dir }} + steps: + - run: pip install -U jsonschema + + - name: Checkout repo content + uses: actions/checkout@v3 + + - name: Checkout PR content + uses: actions/checkout@v3 + with: + path: pr + ref: ${{ env.PR_REF }} + fetch-depth: 0 + + - id: check_submission + name: Check submission + working-directory: pr + run: | + echo "::stop-commands::$(uuidgen)" + ../kernelctf/check-submission.py ${{ github.event.pull_request.base.sha }} + + exploit_build: + runs-on: ubuntu-latest + needs: structure_check + strategy: + matrix: + target: ${{ fromJSON(needs.structure_check.outputs.targets) }} + fail-fast: false # do not cancel other targets + env: + RELEASE_ID: ${{ matrix.target }} + EXPLOIT_DIR: pr/pocs/linux/kernelctf/${{ needs.structure_check.outputs.submission_dir }}/exploit/${{ matrix.target }} + steps: + - name: Checkout PR content + uses: actions/checkout@v3 + with: + path: pr + ref: ${{ env.PR_REF }} + fetch-depth: 0 + + - name: List files + run: find . + + - name: Backup original exploit + run: mv $EXPLOIT_DIR/exploit ./ + + - name: Build exploit + id: build_exploit + working-directory: ${{ env.EXPLOIT_DIR }} + run: | + if make -n prerequisites; then + make prerequisites + fi + make exploit + + - name: Upload exploit (newly compiled) + if: success() + uses: actions/upload-artifact@v3 + with: + name: exploit_${{ env.RELEASE_ID }} + path: ${{ env.EXPLOIT_DIR }}/exploit + if-no-files-found: error + + - name: Upload exploit (original, build failed) + if: failure() && steps.build_exploit.outcome == 'failure' + uses: actions/upload-artifact@v3 + with: + name: exploit_${{ env.RELEASE_ID }} + path: ./exploit + if-no-files-found: error + + - name: Summarize result (success) + if: success() + run: printf '✅ Exploit was built successfully.\n\nIt can be found under the artifacts (`exploit_${{ env.RELEASE_ID }}`).\n' >> $GITHUB_STEP_SUMMARY + + - name: Summarize result (failure) + if: failure() && steps.build_exploit.outcome == 'failure' + run: printf '❌ The exploit compilation failed.\n\nPlease fix it.\n\nYou can see the build logs by clicking on `...` here and then on "View job logs". Or by selecting `exploit_build (${{ env.RELEASE_ID }})` under Jobs in the left menubar.\n' >> $GITHUB_STEP_SUMMARY + + exploit_repro: + runs-on: ubuntu-22.04-4core + timeout-minutes: 30 + needs: [structure_check, exploit_build] + strategy: + matrix: + target: ${{ fromJSON(needs.structure_check.outputs.targets) }} + fail-fast: false + if: always() && needs.structure_check.result == 'success' + env: + RELEASE_ID: ${{ matrix.target }} + SUBMISSION_DIR: ${{ needs.structure_check.outputs.submission_dir }} + steps: + - name: Checkout repo content + uses: actions/checkout@v3 + + - name: Install tools (QEMU, inotify, expect) + run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 inotify-tools expect + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Download exploit + uses: actions/download-artifact@v3 + with: + name: exploit_${{ env.RELEASE_ID }} + path: exp/ + + - name: Fetch rootfs + run: | + wget https://storage.googleapis.com/kernelctf-build/files/rootfs_repro_v1.img.gz + mv rootfs_repro_v1.img.gz rootfs.img.gz + gzip -d rootfs.img.gz + + - name: Download bzImage + run: | + if [ "$RELEASE_ID" == "mitigation-6.1" ]; then RELEASE_ID="mitigation-6.1-v2"; fi + wget https://storage.googleapis.com/kernelctf-build/releases/$RELEASE_ID/bzImage + + # ugly hack to make Github Actions UI to show repro logs separately in somewhat readable fashion + - id: repro1 + name: Reproduction (1 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 1 + + - id: repro2 + name: Reproduction (2 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 2 + + - id: repro3 + name: Reproduction (3 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 3 + + - id: repro4 + name: Reproduction (4 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 4 + + - id: repro5 + name: Reproduction (5 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 5 + + - id: repro6 + name: Reproduction (6 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 6 + + - id: repro7 + name: Reproduction (7 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 7 + + - id: repro8 + name: Reproduction (8 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 8 + + - id: repro9 + name: Reproduction (9 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 9 + + - id: repro10 + name: Reproduction (10 / 10) + continue-on-error: true + run: ./kernelctf/repro.sh 10 + + - name: Upload repro QEMU logs as an artifact + uses: actions/upload-artifact@v3 + with: + name: repro_logs_${{ env.RELEASE_ID }} + path: repro_log_*.txt + + - name: Reproduction // Summary + env: + STEPS: ${{ toJSON(steps) }} + run: | + env + echo $STEPS >> steps.json + ./kernelctf/repro_summary.py ${{ github.run_id }} diff --git a/kernelctf/check-submission.py b/kernelctf/check-submission.py new file mode 100755 index 00000000..968b35ca --- /dev/null +++ b/kernelctf/check-submission.py @@ -0,0 +1,220 @@ +#!/usr/bin/env -S python3 -u +import os +import re +import subprocess +import sys +import json +import jsonschema +import requests +import csv +import io +import hashlib + +PUBLIC_CSV_URL = "https://docs.google.com/spreadsheets/d/e/2PACX-1vS1REdTA29OJftst8xN5B5x8iIUcxuK6bXdzF8G1UXCmRtoNsoQ9MbebdRdFnj6qZ0Yd7LwQfvYC2oF/pub?output=csv" +POC_FOLDER = "pocs/linux/kernelctf/" +EXPLOIT_DIR = "exploit/" +DEBUG = "--debug" in sys.argv + +errors = [] +warnings = [] + +def error(msg): + global errors + errors.append(msg) + print("\n[!] [ERROR] " + msg.replace('\n', '\n ')) + +def warning(msg): + global warnings + warnings.append(msg) + print("\n[!] [WARN] " + msg.replace('\n', '\n ')) + +def fail(msg): + print("\n[!] [FAIL] " + msg.replace('\n', '\n ')) + os._exit(1) + +def run(cmd): + try: + result = subprocess.check_output(cmd, shell=True).decode('utf-8').split('\n') + return result if result[-1] != "" else result[0:-1] + except subprocess.CalledProcessError as e: + fail(f"executing '{cmd}' failed with exit code {e.returncode}") + +def subdirEntries(files, subdir): + return list(set([f[len(subdir):].split('/')[0] for f in files if f.startswith(subdir)])) + +def formatList(items): + return ''.join([f"\n - {item}" for item in items]) + +def printList(title, items): + print(f"\n{title}:" + formatList(items)) + +def errorList(errorMsg, items, warningOnly=False): + itemsStr = ", ".join(f"`{x}`" for x in items) + errorMsg = errorMsg.replace("", itemsStr) if "" in errorMsg else f"{errorMsg}: {itemsStr}" + if warningOnly: + warning(errorMsg) + else: + error(errorMsg) + +def checkOnlyOne(list, errorMsg): + if len(list) > 1: + errorList(errorMsg, list) + return list[0] + +def checkList(items, isAllowedFunc, errorMsg, warningOnly=False): + disallowedItems = [item for item in items if not isAllowedFunc(item)] + if len(disallowedItems) > 0: + errorList(errorMsg, disallowedItems, warningOnly) + return list(sorted(set(items) - set(disallowedItems))) + +def checkAtLeastOne(list, errorMsg): + if len(list) == 0: + fail(errorMsg) + +def checkRegex(text, pattern, errorMsg): + m = re.match(pattern, text) + if not m: + error(f"{errorMsg}. Must match regex `{pattern}`") + return m + +def fetch(url): + response = requests.get(url) + if response.status_code != 200: + fail(f"expected 200 OK for request: {url}") + return response.content.decode('utf-8') + +def parseCsv(csvContent): + columns, *rows = list(csv.reader(io.StringIO(csvContent), strict=True)) + return [{ columns[i]: row[i] for i in range(len(columns)) } for row in rows] + +argv = [arg for arg in sys.argv if not arg.startswith("--")] +print(f"[-] Argv: {argv}") + +mergeInto = argv[1] if len(argv) >= 2 else "origin/main" +print(f"[-] Params: mergeInto = {mergeInto}") + +mergeBase = run(f"git merge-base HEAD {mergeInto}")[0] +print(f"[-] mergeBase = {mergeBase}") + +prFiles = run(f"git diff --name-only {mergeBase}") + +checkAtLeastOne(prFiles, "There are no files in the submission") +prFiles = checkList(prFiles, lambda f: f.startswith(POC_FOLDER), f"The following files are outside of the `{POC_FOLDER}` folder which is not allowed") + +subDirName = checkOnlyOne(subdirEntries(prFiles, POC_FOLDER), "Only one submission is allowed per PR. Found multiple submissions") +checkRegex(subDirName, r"^CVE-\d+-\d+(_lts|_cos|_mitigation)+$", f"The submission folder name is invalid (`{subDirName}`)") + +print(f"[-] Processing submission... Folder = {subDirName}") +cve, *targets = subDirName.split('_') +submissionFolder = f"{POC_FOLDER}{subDirName}/" +files = [f[len(submissionFolder):] for f in prFiles] +printList("Submission files", files) + +exploitFolders = subdirEntries(files, EXPLOIT_DIR) +printList("Exploit folders", exploitFolders) + +validExploitFolderPrefixes = [f"{t}-" for t in targets] + ["extra-"] +checkList(exploitFolders, lambda f: any(f.startswith(p) for p in validExploitFolderPrefixes), + f"The submission folder name (`{subDirName}`) is not consistent with the exploits in the `{EXPLOIT_DIR}` folder. " + + f"I expected the subfolders to be prefixed with one of these: [{', '.join(f'`{x}`' for x in validExploitFolderPrefixes)}], " + + "but this is not true for the following entries: . You can put the extra files into a folder prefixed with `extra-`, " + + "but try to make it clear what's the difference between this exploit and the others.") + +reqFilesPerExploit = ["Makefile", "exploit.c", "exploit"] + +checkList(["metadata.json", "docs/vulnerability.md"], lambda f: f in files, "The following files are missing") +if "docs/exploit.md" not in files: + warning("docs/exploit.md was not found, expecting per-exploit exploit.md") + reqFilesPerExploit.append("exploit.md") + +for exploitFolder in exploitFolders: + checkList(reqFilesPerExploit, lambda file: f"{EXPLOIT_DIR}{exploitFolder}/{file}", + f"The following files are missing from exploit ({exploitFolder})") + +with open(f"{submissionFolder}metadata.json", "rt") as f: metadata = json.load(f) +print("\nMetadata:\n" + json.dumps(metadata, indent=4) + "\n") + +schemaUrl = checkRegex(metadata["$schema"], r"^https://google.github.io/security-research/kernelctf/metadata.schema.v\d+.json$", + "The `$schema` field of the `metadata.yaml` file is invalid").group(0) +if schemaUrl: + if DEBUG: + with open("metadata.schema.v1.json", "rt") as f: schema = json.load(f) + else: + schema = json.loads(fetch(schemaUrl)) + + metadataErrors = list(jsonschema.Draft202012Validator(schema).iter_errors(metadata)) + if len(metadataErrors) > 0: + for err in metadataErrors: + error(f"Schema validation of `metadata.json` failed with the following errors: {err}") + +submissionIds = metadata.get("submission_ids", None) or metadata["submission_id"] +if isinstance(submissionIds, str): + submissionIds = [submissionIds] +print(f"[-] Submission IDs = {submissionIds}") + +if DEBUG: + with open("public.csv", "rt") as f: publicCsv = f.read() +else: + publicCsv = fetch(PUBLIC_CSV_URL) + +publicSheet = { x["ID"]: x for x in parseCsv(publicCsv) } +# print(json.dumps(publicSheet, indent=4)) + +for submissionId in set(submissionIds).difference(publicSheet.keys()): + fail(f"submission ID ({submissionId}) was not found on public spreadsheet") + +submissionIds = list(set(submissionIds).intersection(publicSheet.keys())) + +flags = [] +for submissionId in submissionIds: + publicData = publicSheet[submissionId] + is0Day = publicData["0-day / 1-day"] == "0-day" + exploitHash = publicData["Exploit hash"] + archiveFn = "original.tar.gz" if len(submissionIds) == 1 else f"original_{submissionId}.tar.gz" + + if exploitHash != "": + if archiveFn not in files: + if not is0Day: + warning(f"The file `{archiveFn}` is missing, but submission is not a 0-day submission, so skipping exploit hash verification.") + else: + error(f"The file `{archiveFn}` is missing. Expected file with SHA256 hash of `{exploitHash}`.") + else: + with open(f"{submissionFolder}{archiveFn}", "rb") as f: originalTarGz = f.read() + calculated = hashlib.sha256(originalTarGz).hexdigest() + + if exploitHash != calculated: + error(f"Expected `{archiveFn}` with SHA256 hash of `{exploitHash}`, but the file's checksum is `{calculated}`.") + + flags.extend(publicData["Flags"].strip().split('\n')) + + if cve != publicData["CVE"]: + error(f"The CVE on the public spreadsheet for submission `{submissionId}` is `{publicData['CVE']}` but the PR is for `{cve}`.") + +flagTargets = set([checkRegex(flag, r"kernelCTF\{v1:([^:]+):\d+\}", f"The flag (`{flag}`) is invalid").group(1) for flag in flags]) +if "mitigation-6.1-v2" in flagTargets: + flagTargets = flagTargets - set(["mitigation-6.1-v2"]) | set(["mitigation-6.1"]) +print(f"[-] Got flags for the following targets: {', '.join(flagTargets)}") +checkList(flagTargets, lambda t: t in exploitFolders, f"Missing exploit for target(s)") +checkList(exploitFolders, lambda t: t in flagTargets, f"Found extra exploit(s) without flag submission", True) + +def ghSet(varName, content): + varName = f"GITHUB_{varName}" + print(f"[+] Writing {json.dumps(content)} to ${varName}") + if varName in os.environ: + with open(os.environ[varName], 'at') as f: f.write(content + "\n") + +def summary(success, text): + if warnings: + text += "\n\n**Warnings:**\n" + '\n'.join(f" - ⚠️ {x}" for x in warnings) + + ghSet("STEP_SUMMARY", text) + print(f"\n[+] {text}") if success else fail(text) + +if len(errors) > 0: + summary(False, f"The file structure verification of the PR failed with the following errors:{formatList(f'❌ {e}' for e in errors)}") + +ghSet("OUTPUT", "targets=" + json.dumps([f for f in exploitFolders if not f.startswith("extra-")])) +ghSet("OUTPUT", f"submission_dir={subDirName}") + +summary(True, f"✅ The file structure verification of the PR was successful!") + diff --git a/kernelctf/repro.sh b/kernelctf/repro.sh new file mode 100755 index 00000000..d8bd12e8 --- /dev/null +++ b/kernelctf/repro.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +TRY_ID="$1" +STDOUT_TIMEOUT=60 +QEMU_TXT=qemu.txt + +FLAG="kernelCTF{$(uuidgen)}" +#echo "::add-mask::$FLAG" +echo $FLAG > flag + +STOP_MARKER=$(uuidgen) +echo "::stop-commands::$STOP_MARKER" + +touch $QEMU_TXT + +START_TIME=$(date +%s) + +expect -c ' + set timeout -1 + set stty_init raw + + spawn qemu-system-x86_64 -m 3.5G -nographic \ + -monitor none \ + -enable-kvm -cpu host -smp cores=2 \ + -kernel bzImage \ + -nic user,model=virtio-net-pci \ + -drive file=rootfs.img,if=virtio,cache=none,aio=native,format=raw,discard=on,readonly=on \ + -drive file=flag,if=virtio,format=raw,readonly=on \ + -virtfs local,path=exp,mount_tag=exp,security_model=none \ + -append "console=ttyS0 root=/dev/vda1 rootfstype=ext4 rootflags=discard ro init=/init hostname=repro" \ + -nographic -no-reboot + + expect "# " + send "id\n" + + expect "# " + send "cat /flag\n" + + expect "# " + send "exit\n" + + expect eof +' | tee $QEMU_TXT | sed $'s/\r//' & +QEMU_PID="$!" + +while true; do + # check if qemu.txt modified within $STDOUT_TIMEOUT seconds + inotifywait -qq -t $STDOUT_TIMEOUT -e modify $QEMU_TXT & + + # wait for either QEMU or inotifywait to exit + if ! wait -n $QEMU_PID $!; then break; fi + + # exit loop if QEMU exited already + if ! ps -p $QEMU_PID > /dev/null; then break; fi +done + +if ps -p $QEMU_PID > /dev/null; then + echo "Repro error: no stdout response within the expected timeout of $STDOUT_TIMEOUT seconds" + echo "Killing QEMU..." + kill -9 $QEMU_PID +else + echo "QEMU exited cleanly" +fi + +echo "::$STOP_MARKER::" + +cp $QEMU_TXT repro_log_$TRY_ID.txt +# echo "QEMU_OUTPUT_B64=$(cat $QEMU_TXT|base64 -w0)" >> "$GITHUB_OUTPUT" +echo "RUN_TIME=$(expr $(date +%s) - $START_TIME)" >> "$GITHUB_OUTPUT" + +if grep -q $FLAG $QEMU_TXT; then + echo "Got the flag! Congrats!" + exit 0 +else + echo "Failed, did not get the flag." + exit 1 +fi diff --git a/kernelctf/repro_summary.py b/kernelctf/repro_summary.py new file mode 100755 index 00000000..bb0b045b --- /dev/null +++ b/kernelctf/repro_summary.py @@ -0,0 +1,78 @@ +#!/usr/bin/env -S python3 -u +import base64 +import json +import os +import re + +with open("steps.json", "rt") as f: steps = json.loads(f.read()) + +repros = [{ "idx": id[len("repro"):], **step } for (id,step) in steps.items() if id.startswith("repro")] +print(repros) + +success_count = 0 +for repro in repros: + success = repro["outcome"] == "success" + repro["icon"] = "✅" if success else "❌" + success_count += 1 if success else 0 + +result = f""" +# Reproduction summary + +Reliability: {'%d' % (success_count / len(repros) * 100)}% + +Runs: {' '.join(x['icon'] for x in repros)}""" + +for repro in repros: + result += f"\n\n## Reproduction {repro['idx']} / {len(repros)} - {repro['icon']}\n\n" + + run_time = repro["outputs"].get("RUN_TIME") + if run_time: + result += f"Time: {run_time}s\n\n" + + repro_log_fn = f"repro_log_{repro['idx']}.txt" + if os.path.isfile(repro_log_fn): + with open(repro_log_fn, 'rb') as f: repro_log = f.read() + + repro_log = repro_log.replace(b'\r\r\n', b'\n').replace(b'\r\n', b'\n').decode('utf-8') + + def split(pattern, last=False): + arr = repro_log.rsplit(pattern, 1) if last else repro_log.split(pattern, 1) + return arr[1].strip() if len(arr) == 2 else "" + + def getLastLine(pattern): + return split(pattern, True).split('\n')[0].strip() + + panic = getLastLine('Kernel panic - ') + if "Attempted to kill init!" in panic: + result += f"The kernel did not panic (init exited).\n\n" + elif panic: + result += f"Kernel panic: `{panic}`\n\nRIP: `{getLastLine('RIP: ')}`\n\n" + + repro_error = getLastLine('Repro error: ') + if repro_error: + result += f"Error during reproduction: `{repro_error}`.\n\n" + + expl_out = split('su user -c /tmp/exp/exploit') + + m = re.search(r"exploit.*?: (segfault at.*)", expl_out) + if m: + result += f"The exploit crashed: `{m.groups()[0]}`.\n\n" + + if expl_out: + result += f""" +
+ Exploit / QEMU output + +``` +{expl_out.replace('`', '')} +``` + +
+""" + +print(result) + +if "GITHUB_STEP_SUMMARY" in os.environ: + with open(os.environ["GITHUB_STEP_SUMMARY"], 'at') as f: f.write(result.strip() + "\n") + +os._exit(1 if success_count == 0 else 0) \ No newline at end of file