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 }
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.