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/[email protected]/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)
}
}
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.
nice