Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ky28059/e8fc98d5b0be999b1409cc80a9077c54 to your computer and use it in GitHub Desktop.
Save ky28059/e8fc98d5b0be999b1409cc80a9077c54 to your computer and use it in GitHub Desktop.

San Diego CTF 2024 — calculator

I made a calculator! I'm using Python to do the math since I heard it's strongly typed, so my calculator should be pretty safe. Download the source code by clicking the download button above!

We're given a TS server and expression parser looking like this:

import { serveDir, serveFile } from 'jsr:@std/http/file-server'
import { parse } from './expression_parser.ts'

const decoder = new TextDecoder()
const resultTemplate = await Deno.readTextFile('./result.html')

Deno.serve({ port: 8080 }, async (req: Request) => {
  try {
    const pathname = new URL(req.url).pathname

    if (pathname === '/' && req.method === 'GET') {
      return serveFile(req, './static/index.html')
    }

    if (pathname === '/' && req.method === 'POST') {
      const body = await req.formData()
      const expression = body.get('expression')
      if (typeof expression !== 'string') {
        return new Response('400 expression should be string', {
          status: 400
        })
      }

      const parsed = parse(expression)
      if (!parsed) {
        new Response(
          resultTemplate
            .replace('{success}', 'failure')
            .replace('{result}', 'syntax error'),
          {
            headers: {
              'Content-Type': 'text/html'
            }
          }
        )
      }

      let success = false
      let output = ''

      const result = await new Deno.Command('python3.11', {
        args: ['calculate.py', JSON.stringify(parsed)]
      }).output()
      const error = decoder.decode(result.stderr).trim()
      const json = decoder.decode(result.stdout).trim()
      if (error.length > 0) {
        output = error
      } else if (json.startsWith('{') && json.endsWith('}')) {
        try {
          output = JSON.parse(json).result
          success = true
        } catch (error) {
          output = `wtf!!1! this shouldnt ever happen\n\n${
            error.stack
          }\n\nheres the flag as compensation: ${
            Deno.env.get('GZCTF_FLAG') ?? 'sdctf{...}'
          }`
        }
      } else {
        output = 'python borked'
      }

      return new Response(
        resultTemplate
          .replace('{success}', success ? 'successful' : 'failure')
          .replace('{result}', () => output),
        {
          headers: {
            'Content-Type': 'text/html'
          }
        }
      )
    }

    if (pathname.startsWith('/static/') && req.method === 'GET') {
      return serveDir(req, {
        fsRoot: 'static',
        urlRoot: 'static'
      })
    }

    return new Response('404 :(', {
      status: 404
    })
  } catch (error) {
    return new Response('500 embarassment\n\n' + error.stack, {
      status: 500
    })
  }
})
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts'

export type Expression =
  | { op: '+' | '-' | '*' | '/'; a: Expression; b: Expression }
  | { value: number }

type ParseResult = Generator<{ expr: Expression; string: string }>

function * parseFloat (string: string): ParseResult {
  for (const regex of [
    /[-+](?:\d+\.?|\d*\.\d+)(?:e[-+]?\d+)?$/,
    /(?:\d+\.?|\d*\.\d+)(?:e[-+]?\d+)?$/
  ]) {
    const match = string.match(regex)
    if (!match) {
      continue
    }
    const number = +match[0]
    if (Number.isFinite(number)) {
      yield {
        expr: { value: number },
        string: string.slice(0, -match[0].length)
      }
    }
  }
}
function * parseLitExpr (string: string): ParseResult {
  yield * parseFloat(string)
  if (string[string.length - 1] === ')') {
    for (const result of parseAddExpr(string.slice(0, -1))) {
      if (result.string[result.string.length - 1] === '(') {
        yield { ...result, string: result.string.slice(0, -1) }
      }
    }
  }
}
function * parseMulExpr (string: string): ParseResult {
  for (const right of parseLitExpr(string)) {
    const op = right.string[right.string.length - 1]
    if (op === '*' || op === '/') {
      for (const left of parseMulExpr(right.string.slice(0, -1))) {
        yield { ...left, expr: { op, a: left.expr, b: right.expr } }
      }
    }
  }
  yield * parseLitExpr(string)
}
function * parseAddExpr (string: string): ParseResult {
  for (const right of parseMulExpr(string)) {
    const op = right.string[right.string.length - 1]
    if (op === '+' || op === '-') {
      for (const left of parseAddExpr(right.string.slice(0, -1))) {
        yield { ...left, expr: { op, a: left.expr, b: right.expr } }
      }
    }
  }
  yield * parseMulExpr(string)
}
export function parse (expression: string): Expression | null {
  for (const result of parseAddExpr(expression.replace(/\s/g, ''))) {
    if (result.string === '') {
      return result.expr
    }
  }
  return null
}

Deno.test({
  name: 'expression_parser',
  fn () {
    assertEquals(parse('3 + 2'), {
      op: '+',
      a: { value: 3 },
      b: { value: 2 }
    })
    assertEquals(parse('3 + 2 + 1'), {
      op: '+',
      a: {
        op: '+',
        a: { value: 3 },
        b: { value: 2 }
      },
      b: { value: 1 }
    })
    assertEquals(parse('3 * (4 - 5) + 2'), {
      op: '+',
      a: {
        op: '*',
        a: { value: 3 },
        b: {
          op: '-',
          a: { value: 4 },
          b: { value: 5 }
        }
      },
      b: { value: 2 }
    })
  }
})

The server sends the parsed expression to a simple Python "calculator", sending back the result in JSON format:

import json
import sys


def evaluate(expression):
    if "value" in expression:
        return expression["value"]
    match expression["op"]:
        case "+":
            return evaluate(expression["a"]) + evaluate(expression["b"])
        case "-":
            return evaluate(expression["a"]) - evaluate(expression["b"])
        case "*":
            return evaluate(expression["a"]) * evaluate(expression["b"])
        case "/":
            return evaluate(expression["a"]) / evaluate(expression["b"])


print(json.dumps({"result": evaluate(json.loads(sys.argv[1]))}))

If we can get this Python result to be invalid JSON, the server will give us the flag:

        try {
          output = JSON.parse(json).result
          success = true
        } catch (error) {
          output = `wtf!!1! this shouldnt ever happen\n\n${
            error.stack
          }\n\nheres the flag as compensation: ${
            Deno.env.get('GZCTF_FLAG') ?? 'sdctf{...}'
          }`
        }

This challenge is pretty trivial if you know about how Python's json.dumps is JSON spec noncompliant. In particular, Python will successfully serialize NaN and Infinity,

>>> json.dumps({"a": float('nan')})
'{"a": NaN}'
>>> json.dumps({"a": float('inf')})
'{"a": Infinity}'

despite neither of those values being valid JSON.

> JSON.parse('{"a": NaN}')
Uncaught SyntaxError: Unexpected token 'N', "{"a": NaN}" is not valid JSON
> JSON.parse('{"a": Infinity}')
Uncaught SyntaxError: Unexpected token 'I', "{"a": Infinity}" is not valid JSON

Then, we just need to get the calculator to parse either NaN or Infinity to get the flag.

Unfortunately, the TS server only parses a number literal if it is finite, so a simple

1e400 - 1e400

payload won't work:

    if (Number.isFinite(number)) {
      yield {
        expr: { value: number },
        string: string.slice(0, -match[0].length)
      }
    }

image

Luckily, we can just get infinity with

1e200 * 1e200

instead. Using a similar payload (I did

1e200 * 1e200 - 1e200 * 1e200

to get NaN), we get the flag.

image

@shuban-789
Copy link

nice

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment