← Back to home

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:

Code (ts):

1import { serveDir, serveFile } from 'jsr:@std/http/file-server'
2import { parse } from './expression_parser.ts'
3
4const decoder = new TextDecoder()
5const resultTemplate = await Deno.readTextFile('./result.html')
6
7Deno.serve({ port: 8080 }, async (req: Request) => {
8  try {
9    const pathname = new URL(req.url).pathname
10
11    if (pathname === '/' && req.method === 'GET') {
12      return serveFile(req, './static/index.html')
13    }
14
15    if (pathname === '/' && req.method === 'POST') {
16      const body = await req.formData()
17      const expression = body.get('expression')
18      if (typeof expression !== 'string') {
19        return new Response('400 expression should be string', {
20          status: 400
21        })
22      }
23
24      const parsed = parse(expression)
25      if (!parsed) {
26        new Response(
27          resultTemplate
28            .replace('{success}', 'failure')
29            .replace('{result}', 'syntax error'),
30          {
31            headers: {
32              'Content-Type': 'text/html'
33            }
34          }
35        )
36      }
37
38      let success = false
39      let output = ''
40
41      const result = await new Deno.Command('python3.11', {
42        args: ['calculate.py', JSON.stringify(parsed)]
43      }).output()
44      const error = decoder.decode(result.stderr).trim()
45      const json = decoder.decode(result.stdout).trim()
46      if (error.length > 0) {
47        output = error
48      } else if (json.startsWith('{') && json.endsWith('}')) {
49        try {
50          output = JSON.parse(json).result
51          success = true
52        } catch (error) {
53          output = `wtf!!1! this shouldnt ever happen\n\n${
54            error.stack
55          }\n\nheres the flag as compensation: ${
56            Deno.env.get('GZCTF_FLAG') ?? 'sdctf{...}'
57          }`
58        }
59      } else {
60        output = 'python borked'
61      }
62
63      return new Response(
64        resultTemplate
65          .replace('{success}', success ? 'successful' : 'failure')
66          .replace('{result}', () => output),
67        {
68          headers: {
69            'Content-Type': 'text/html'
70          }
71        }
72      )
73    }
74
75    if (pathname.startsWith('/static/') && req.method === 'GET') {
76      return serveDir(req, {
77        fsRoot: 'static',
78        urlRoot: 'static'
79      })
80    }
81
82    return new Response('404 :(', {
83      status: 404
84    })
85  } catch (error) {
86    return new Response('500 embarassment\n\n' + error.stack, {
87      status: 500
88    })
89  }
90})

Code (ts):

1import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts'
2
3export type Expression =
4  | { op: '+' | '-' | '*' | '/'; a: Expression; b: Expression }
5  | { value: number }
6
7type ParseResult = Generator<{ expr: Expression; string: string }>
8
9function * parseFloat (string: string): ParseResult {
10  for (const regex of [
11    /[-+](?:\d+\.?|\d*\.\d+)(?:e[-+]?\d+)?$/,
12    /(?:\d+\.?|\d*\.\d+)(?:e[-+]?\d+)?$/
13  ]) {
14    const match = string.match(regex)
15    if (!match) {
16      continue
17    }
18    const number = +match[0]
19    if (Number.isFinite(number)) {
20      yield {
21        expr: { value: number },
22        string: string.slice(0, -match[0].length)
23      }
24    }
25  }
26}
27function * parseLitExpr (string: string): ParseResult {
28  yield * parseFloat(string)
29  if (string[string.length - 1] === ')') {
30    for (const result of parseAddExpr(string.slice(0, -1))) {
31      if (result.string[result.string.length - 1] === '(') {
32        yield { ...result, string: result.string.slice(0, -1) }
33      }
34    }
35  }
36}
37function * parseMulExpr (string: string): ParseResult {
38  for (const right of parseLitExpr(string)) {
39    const op = right.string[right.string.length - 1]
40    if (op === '*' || op === '/') {
41      for (const left of parseMulExpr(right.string.slice(0, -1))) {
42        yield { ...left, expr: { op, a: left.expr, b: right.expr } }
43      }
44    }
45  }
46  yield * parseLitExpr(string)
47}
48function * parseAddExpr (string: string): ParseResult {
49  for (const right of parseMulExpr(string)) {
50    const op = right.string[right.string.length - 1]
51    if (op === '+' || op === '-') {
52      for (const left of parseAddExpr(right.string.slice(0, -1))) {
53        yield { ...left, expr: { op, a: left.expr, b: right.expr } }
54      }
55    }
56  }
57  yield * parseMulExpr(string)
58}
59export function parse (expression: string): Expression | null {
60  for (const result of parseAddExpr(expression.replace(/\s/g, ''))) {
61    if (result.string === '') {
62      return result.expr
63    }
64  }
65  return null
66}
67
68Deno.test({
69  name: 'expression_parser',
70  fn () {
71    assertEquals(parse('3 + 2'), {
72      op: '+',
73      a: { value: 3 },
74      b: { value: 2 }
75    })
76    assertEquals(parse('3 + 2 + 1'), {
77      op: '+',
78      a: {
79        op: '+',
80        a: { value: 3 },
81        b: { value: 2 }
82      },
83      b: { value: 1 }
84    })
85    assertEquals(parse('3 * (4 - 5) + 2'), {
86      op: '+',
87      a: {
88        op: '*',
89        a: { value: 3 },
90        b: {
91          op: '-',
92          a: { value: 4 },
93          b: { value: 5 }
94        }
95      },
96      b: { value: 2 }
97    })
98  }
99})

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

Code (py):

1import json
2import sys
3
4
5def evaluate(expression):
6    if "value" in expression:
7        return expression["value"]
8    match expression["op"]:
9        case "+":
10            return evaluate(expression["a"]) + evaluate(expression["b"])
11        case "-":
12            return evaluate(expression["a"]) - evaluate(expression["b"])
13        case "*":
14            return evaluate(expression["a"]) * evaluate(expression["b"])
15        case "/":
16            return evaluate(expression["a"]) / evaluate(expression["b"])
17
18
19print(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:

Code (ts):

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

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,

Code (py):

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

despite neither of those values being valid JSON.

Code (js):

1> JSON.parse('{"a": NaN}')
2Uncaught SyntaxError: Unexpected token 'N', "{"a": NaN}" is not valid JSON
3> JSON.parse('{"a": Infinity}')
4Uncaught 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

Code (js):

11e400 - 1e400

payload won't work:

Code (ts):

1    if (Number.isFinite(number)) {
2      yield {
3        expr: { value: number },
4        string: string.slice(0, -match[0].length)
5      }
6    }

image

Luckily, we can just get infinity with

Code (js):

11e200 * 1e200

instead. Using a similar payload (I did

Code (js):

11e200 * 1e200 - 1e200 * 1e200

to get NaN), we get the flag.

image