HyperSonic CTF 2026 EasyQuiz Writeup
HyperSonic CTF 2026 EasyQuiz WriteupHyperSonic CTF 2026 EasyQuiz WriteupHyperSonic CTF 2026 EasyQuiz 문제 풀이HyperSonic CTF 2026 EasyQuiz writeup
Contents
개요
EasyQuiz는 세 개의 독립된 입력 분기에서 flag 조각을 얻는 문제입니다. 제공된 코드는 처음에 inp를 입력받고, 값이 1, 2, 3일 때 각각 다른 검증을 수행합니다.
확인한 조건은 다음과 같습니다.
- 카테고리:
crypto - 목표: 세 분기에서 출력되는 flag 조각을 모아 최종 flag를 복원합니다.
- 입력은
ast.literal_eval또는 정수 파싱을 거쳐 각 분기의 검증식에 들어갑니다.
각 분기가 요구하는 값은 다음과 같습니다.
N: 1번 분기에서 매번 제시되는 정수입니다.x,y,z: 1번 분기에서 제출하는 양의 정수입니다.M: 2번 분기에서 Sage의Matrix(ZZ, M)로 변환되는 입력입니다.number: 3번 분기에서 Python 객체 목록으로 파싱되는 입력입니다.hash(i): 3번 분기에서 모든 원소가 같은 값인지 비교하는 Python 내장 해시입니다.
문제 분석
제공된 코드는 세 분기를 다음처럼 나눕니다.
inp = int(input())
if inp==1:
score = 0
for i in range(5):
N = random.randint(4, 50)
print(f"🍎/(🍌+🍍)+🍌/(🍍+🍎)+🍍/(🍎+🍌)={N}")
x, y, z = map(int, input().split()); assert x>0 and y>0 and z>0
if x/(y+z)+y/(x+z)+z/(x+y)==N: score+=1
if score==5: print("HS{?")
if inp==2:
M = ast.literal_eval(input())
n = len(M)
M = Matrix(ZZ, M)
minkowski_bound = sqrt(n)*abs(det(M))**(1/n)
if norm(M.BKZ(block_size=30)[0]) > minkowski_bound: print("?")
if inp==3:
number = ast.literal_eval(input())
theanswertolifetheuniverseandeverything = 42
assert len(set(number))>theanswertolifetheuniverseandeverything//2 and len(set([hash(i) for i in number]))==1 and hash(number[0])==theanswertolifetheuniverseandeverything and all([abs(i)<2424242 for i in number]) and all([int(i.real)==i.real and int(i.imag)==i.imag for i in number if isinstance(i,complex)])
print("?}")1번 분기는 N이 다섯 번 주어지고, 매번 양의 정수 x, y, z가 다음 식을 Python의 float 비교로 만족해야 합니다.
x/(y+z) + y/(x+z) + z/(x+y) == N정수 방정식처럼 보이지만 실제 비교는 Python의 / 연산 결과, 즉 float 값으로 이루어집니다. 따라서 같은 환경에서 계산했을 때 결과가 정확히 N으로 반올림되는 큰 정수를 만들면 됩니다.
2번 분기는 입력을 Sage 정수 행렬로 바꾼 뒤 BKZ를 호출합니다. 조건식은 격자 문제처럼 보이지만 예외 처리가 없습니다. 그래서 Matrix(ZZ, M)이 만들 수 있는 행렬 타입과 그 타입의 메서드 목록을 먼저 확인합니다.
3번 분기는 number의 서로 다른 원소가 22개 이상이어야 하고, 모든 원소의 해시가 하나로 같아야 합니다. 첫 원소의 해시는 반드시 42입니다. 복소수는 실수부와 허수부가 모두 정수여야 하며, 모든 원소는 절댓값 조건도 통과해야 합니다.
len(set(number)) > 21
len(set(hash(i) for i in number)) == 1
hash(number[0]) == 42
abs(i) < 2424242핵심 아이디어
세 분기는 서로 독립적이므로, 각 분기에서 flag 조각을 얻은 뒤 그대로 이어 붙이면 됩니다.
1번 분기에서는 y = z = s로 두면 식이 한 변수 형태로 줄어듭니다. t = x/s라고 하면 다음 식을 얻습니다.
t/2 + 2/(t+1) = N양의 해는 다음과 같습니다.
t = ((2N - 1) + sqrt(4N^2 + 4N - 15)) / 2이 해 자체는 일반적으로 정수가 아닙니다. 대신 s = 10^k를 크게 잡고 x = round(t*s), y = z = s로 만든 뒤, 코드와 같은 float 식을 로컬에서 다시 계산합니다. 결과가 N과 같아지는 k를 찾으면 같은 입력이 서버에서도 통과합니다.
2번 분기에서는 Sage의 sparse 행렬 타입을 만들 수 있는 입력을 넣습니다. 딕셔너리 형태의 입력은 sparse 정수 행렬을 만들고, 이 타입에는 BKZ 메서드가 없습니다. 예외가 그대로 노출되면 traceback에 실행 중이던 소스 라인이 포함되고, 그 줄에 있는 flag 조각을 읽을 수 있습니다.
3번 분기는 Python의 숫자 해시 규칙을 이용합니다. 정수 42는 hash(42) == 42입니다. 복소수는 실수부 해시와 허수부 해시가 sys.hash_info.imag 상수로 결합됩니다. 또 float 해시는 유리수 값을 sys.hash_info.modulus에 대해 계산하므로, 2의 거듭제곱 분모를 갖는 값을 열거해 hash(value) == 42인 값을 찾을 수 있습니다.
풀이 과정
Step 1. 세 분기의 출력 조건 분리
처음 입력한 inp 값에 따라 코드 흐름이 완전히 갈라집니다. 한 번의 연결에서는 한 분기만 실행되므로 각 분기를 따로 호출해 결과를 모읍니다.
분기별 출력 조건은 다음과 같습니다.
inp = 1: float 비교를 다섯 번 통과하면 첫 조각 출력
inp = 2: Sage Matrix와 BKZ 조건을 지나면 두 번째 조각 출력
inp = 3: hash가 42인 서로 다른 숫자 22개 이상을 넣으면 마지막 조각 출력이후에는 각 분기를 독립적으로 풀었습니다.
Step 2. 1번 분기에서 float 비교 통과
y = z = s로 두면 원래 식은 t = x/s에 대한 식으로 정리됩니다.
x/(y+z) + y/(x+z) + z/(x+y)
= t/2 + 2/(t+1)이제 주어진 N마다 양의 해 t를 계산한 뒤, s = 10^k를 바꿔 가며 정수 x를 만듭니다.
def part1_triple(n):
root = ((2 * n - 1) + math.sqrt(4 * n * n + 4 * n - 15)) / 2
for k in range(10, 90):
s = 10**k
x = round(root * s)
y = z = s
if x / (y + z) + y / (x + z) + z / (x + y) == n:
return x, y, z
raise ValueError("no float triple found")필요한 것은 Python이 계산한 float 결과가 n과 같다고 판단되는 정수 세 개입니다. 실제 검증식과 같은 식으로 로컬에서 먼저 확인하므로 찾은 값은 그대로 제출합니다.
Step 3. 2번 분기에서 sparse 행렬 예외 유도
2번 분기의 입력은 ast.literal_eval을 거쳐 Sage 행렬 생성자에 전달됩니다. 리스트 형태로 넣으면 BKZ 조건을 직접 만족해야 하지만, 딕셔너리를 넣으면 다른 타입의 행렬이 생성됩니다.
{(0, 0): 1, (1, 1): 1}이 입력은 sparse 정수 행렬로 해석됩니다. det(M) 계산은 진행되지만, 이후 호출되는 M.BKZ(block_size=30)에서 BKZ 메서드가 없어 예외가 발생합니다.
문제 서비스는 traceback을 숨기지 않았습니다. 예외 메시지에 실행 중이던 if norm(M.BKZ(...)) 줄이 그대로 출력되었고, 이 줄 안의 print 문자열에서 두 번째 flag 조각을 얻었습니다.
Step 4. 3번 분기에서 hash == 42 집합 구성
3번 분기에서는 서로 다른 원소가 22개 이상이어야 한다는 조건이 까다롭습니다. 단순히 42를 여러 번 넣으면 set(number)의 크기가 1이므로 실패합니다.
먼저 정수 42를 넣습니다.
hash(42) = 42그다음 복소수 해시식을 이용합니다. y를 작은 정수로 잡고 실수부를 다음처럼 맞추면, 복소수 전체의 해시가 42가 됩니다.
complex(42 - sys.hash_info.imag * hash(y), y)y = -2, -1, 1, 2를 쓰면 네 개의 서로 다른 복소수를 얻고, 절댓값 제한도 통과합니다.
나머지는 float 값으로 채웁니다. Python의 float 해시는 2의 거듭제곱 분모를 갖는 값을 모듈러 연산으로 처리합니다. 따라서 지수 d를 훑으면서 다음 조건을 만족하는 mantissa를 찾습니다.
mantissa = 42 * 2^d mod sys.hash_info.modulus
2^52 <= mantissa < 2^53
value = mantissa * 2^-d
hash(value) = 42이 방식으로 17개의 float 값을 찾았습니다. 정수 1개, 복소수 4개, float 17개를 합치면 서로 다른 원소가 22개가 되며, 모든 원소의 해시가 42입니다.
Step 5. flag 조각 결합
각 분기는 flag의 앞, 중간, 뒤 조각을 따로 출력합니다. 1번 분기와 3번 분기는 검증을 통과하면 마지막 줄에 flag 조각이 출력됩니다. 2번 분기는 traceback에서 print("...") 형태의 문자열을 추출했습니다.
세 결과를 순서대로 이어 붙이면 최종 flag가 됩니다.
Exploit / Solver
아래 코드에는 풀이 루틴만 담았습니다. 메뉴 번호와 payload를 보내고 결과를 받아오는 입출력 코드는 생략했습니다.
import math
import re
import sys
def part1_triple(n):
root = ((2 * n - 1) + math.sqrt(4 * n * n + 4 * n - 15)) / 2
for k in range(10, 90):
s = 10**k
x = round(root * s)
y = z = s
if x / (y + z) + y / (x + z) + z / (x + y) == n:
return x, y, z
raise ValueError("no float triple found")
def part2_payload():
return "{(0,0):1,(1,1):1}"
def extract_part2(output):
return re.findall(rb'print\("([^"]+)"\)', output)[-1].decode()
def part3_numbers():
nums = [42]
imag_const = sys.hash_info.imag
modulus = sys.hash_info.modulus
for y in [-2, -1, 1, 2]:
nums.append(complex(42 - imag_const * hash(y), y))
for d in range(0, 1075):
mantissa = (42 * pow(2, d, modulus)) % modulus
if (1 << 52) <= mantissa < (1 << 53):
value = math.ldexp(mantissa, -d)
if abs(value) < 2424242 and hash(value) == 42 and value not in nums:
nums.append(value)
mantissa = (42 * pow(2, 1074, modulus)) % modulus
value = math.ldexp(mantissa, -1074)
if hash(value) == 42 and value not in nums:
nums.append(value)
assert len(set(nums)) > 21
assert len({hash(x) for x in nums}) == 1
assert hash(nums[0]) == 42
assert all(abs(x) < 2424242 for x in nums)
assert all(
int(x.real) == x.real and int(x.imag) == x.imag
for x in nums
if isinstance(x, complex)
)
return nums실제 실행에서는 1번 분기의 다섯 질문마다 part1_triple(N)의 결과를 제출하고, 2번 분기에는 part2_payload()를 보냅니다. 3번 분기에는 repr(part3_numbers())를 제출하면 됩니다.
결과
세 분기를 실행해 얻은 조각을 합치면 다음 flag가 됩니다.
HS{70927730afde31916a2f22a1385a5d2343377937a7c3cf1a796b78d05b7d070e50b8c5526395e3d968da2ca26f198476}Overview
In EasyQuiz, three independent input branches each give a flag fragment. The provided code first reads inp, and when the value is 1, 2, or 3 it performs a different check in each case.
The conditions I confirmed:
- Category:
crypto - Goal: collect the flag fragments printed by the three branches and reconstruct the final flag.
- The input goes through
ast.literal_evalor integer parsing into each branch's validation expression.
The values each branch takes:
N: the integer presented each round in branch 1.x,y,z: the positive integers submitted in branch 1.M: the input in branch 2 that is converted via Sage'sMatrix(ZZ, M).number: the input in branch 3 that is parsed into a list of Python objects.hash(i): the Python built-in hash compared in branch 3 to check that all elements are equal.
Analyzing the challenge
The provided code splits into three branches as follows.
inp = int(input())
if inp==1:
score = 0
for i in range(5):
N = random.randint(4, 50)
print(f"🍎/(🍌+🍍)+🍌/(🍍+🍎)+🍍/(🍎+🍌)={N}")
x, y, z = map(int, input().split()); assert x>0 and y>0 and z>0
if x/(y+z)+y/(x+z)+z/(x+y)==N: score+=1
if score==5: print("HS{?")
if inp==2:
M = ast.literal_eval(input())
n = len(M)
M = Matrix(ZZ, M)
minkowski_bound = sqrt(n)*abs(det(M))**(1/n)
if norm(M.BKZ(block_size=30)[0]) > minkowski_bound: print("?")
if inp==3:
number = ast.literal_eval(input())
theanswertolifetheuniverseandeverything = 42
assert len(set(number))>theanswertolifetheuniverseandeverything//2 and len(set([hash(i) for i in number]))==1 and hash(number[0])==theanswertolifetheuniverseandeverything and all([abs(i)<2424242 for i in number]) and all([int(i.real)==i.real and int(i.imag)==i.imag for i in number if isinstance(i,complex)])
print("?}")Branch 1 gives N five times, and each round the positive integers x, y, z must satisfy the following expression under Python's float comparison.
x/(y+z) + y/(x+z) + z/(x+y) == NIt looks like an integer equation, but the comparison is done on the result of Python's / operation, i.e. on float values. So it is enough to produce large integers whose result, computed in the same environment, rounds to exactly N.
Branch 2 converts the input into a Sage integer matrix and then calls BKZ. The condition looks like a lattice problem, but there is no exception handling. So the first thing to check is which matrix types Matrix(ZZ, M) can produce and what methods those types have.
Branch 3 requires number to have at least 22 distinct elements, and all elements must have the same hash. The first element's hash must be exactly 42. Complex numbers must have integer real and imaginary parts, and all elements must also pass the absolute-value condition.
len(set(number)) > 21
len(set(hash(i) for i in number)) == 1
hash(number[0]) == 42
abs(i) < 2424242Main idea
The three branches are independent of each other, so we get a fragment from each branch and concatenate them.
In branch 1, setting y = z = s reduces the expression to a single-variable form. Letting t = x/s, we get the following expression.
t/2 + 2/(t+1) = NThe positive solution is as follows.
t = ((2N - 1) + sqrt(4N^2 + 4N - 15)) / 2This solution is generally not an integer. Instead, we pick a large s = 10^k, set x = round(t*s), y = z = s, and recompute the same float expression as the code locally. If we find a k where the result equals N, the same input passes on the server too.
In branch 2, we feed an input that produces a Sage sparse matrix type. A dictionary-form input creates a sparse integer matrix, and this type has no BKZ method. If the exception is exposed as-is, the traceback includes the source line that was executing, and we can read the flag fragment on that line.
Branch 3 exploits Python's numeric hash rules. The integer 42 satisfies hash(42) == 42. For complex numbers, the real-part hash and the imaginary-part hash are combined with the sys.hash_info.imag constant. Also, float hashing computes the rational value modulo sys.hash_info.modulus, so by enumerating values with power-of-two denominators we can find values where hash(value) == 42.
Solution walkthrough
Step 1. Separate the output conditions of the three branches
The first inp value decides which branch runs. One connection runs only one branch, so you call each branch separately and collect the results.
The output conditions per branch:
inp = 1: passing the float comparison five times prints the first fragment
inp = 2: getting past the Sage Matrix and BKZ condition prints the second fragment
inp = 3: supplying at least 22 distinct numbers whose hash is 42 prints the last fragmentAfter that, I solved each branch independently.
Step 2. Pass the float comparison in branch 1
Setting y = z = s, the original expression becomes an expression in t = x/s.
x/(y+z) + y/(x+z) + z/(x+y)
= t/2 + 2/(t+1)For each given N, we compute the positive solution t, then vary s = 10^k to produce the integer x.
def part1_triple(n):
root = ((2 * n - 1) + math.sqrt(4 * n * n + 4 * n - 15)) / 2
for k in range(10, 90):
s = 10**k
x = round(root * s)
y = z = s
if x / (y + z) + y / (x + z) + z / (x + y) == n:
return x, y, z
raise ValueError("no float triple found")We need three integers for which Python's computed float result equals n. Since we check them locally with the same expression the server uses, the values can be submitted directly.
Step 3. Induce a sparse-matrix exception in branch 2
Branch 2's input goes through ast.literal_eval and is passed to the Sage matrix constructor. A list-form matrix would have to satisfy the BKZ condition directly, but a dictionary creates a different matrix type.
{(0, 0): 1, (1, 1): 1}This input is interpreted as a sparse integer matrix. The det(M) computation proceeds, but the subsequently called M.BKZ(block_size=30) raises an exception because there is no BKZ method.
The challenge service did not hide the traceback. The exception message printed the executing if norm(M.BKZ(...)) line, and the print string on that line held the second flag fragment.
Step 4. Build a hash == 42 set in branch 3
The tricky part of branch 3 is the requirement of at least 22 distinct elements. Simply putting in 42 many times fails because the size of set(number) is 1.
First we put in the integer 42.
hash(42) = 42Next we use the complex-number hash formula. Taking y as a small integer and setting the real part as follows makes the whole complex number's hash equal to 42.
complex(42 - sys.hash_info.imag * hash(y), y)Using y = -2, -1, 1, 2 yields four distinct complex numbers, which also pass the absolute-value limit.
The rest are float values. Python's float hash handles values with power-of-two denominators via modular arithmetic. So, scanning over the exponent d, we look for a mantissa satisfying the following condition.
mantissa = 42 * 2^d mod sys.hash_info.modulus
2^52 <= mantissa < 2^53
value = mantissa * 2^-d
hash(value) = 42This found 17 float values. Combining 1 integer, 4 complex numbers, and 17 float values gives 22 distinct elements, and all elements' hashes are 42.
Step 5. Combine the flag fragments
Each branch prints the front, middle, and back fragments of the flag separately. Branches 1 and 3 print a flag fragment on the last line once the check passes. For branch 2, I extracted a print("...")-style string from the traceback.
Concatenating the three results in order gives the final flag.
Exploit / Solver
The code below has only the solving routines. I left out the I/O code, which just sends the menu number and payload and reads the result.
import math
import re
import sys
def part1_triple(n):
root = ((2 * n - 1) + math.sqrt(4 * n * n + 4 * n - 15)) / 2
for k in range(10, 90):
s = 10**k
x = round(root * s)
y = z = s
if x / (y + z) + y / (x + z) + z / (x + y) == n:
return x, y, z
raise ValueError("no float triple found")
def part2_payload():
return "{(0,0):1,(1,1):1}"
def extract_part2(output):
return re.findall(rb'print\("([^"]+)"\)', output)[-1].decode()
def part3_numbers():
nums = [42]
imag_const = sys.hash_info.imag
modulus = sys.hash_info.modulus
for y in [-2, -1, 1, 2]:
nums.append(complex(42 - imag_const * hash(y), y))
for d in range(0, 1075):
mantissa = (42 * pow(2, d, modulus)) % modulus
if (1 << 52) <= mantissa < (1 << 53):
value = math.ldexp(mantissa, -d)
if abs(value) < 2424242 and hash(value) == 42 and value not in nums:
nums.append(value)
mantissa = (42 * pow(2, 1074, modulus)) % modulus
value = math.ldexp(mantissa, -1074)
if hash(value) == 42 and value not in nums:
nums.append(value)
assert len(set(nums)) > 21
assert len({hash(x) for x in nums}) == 1
assert hash(nums[0]) == 42
assert all(abs(x) < 2424242 for x in nums)
assert all(
int(x.real) == x.real and int(x.imag) == x.imag
for x in nums
if isinstance(x, complex)
)
return numsIn the actual run, for each of the five questions in branch 1 you submit the result of part1_triple(N), and to branch 2 you send part2_payload(). To branch 3 you submit repr(part3_numbers()).
Result
Combining the fragments obtained by running the three branches gives the following flag.
HS{70927730afde31916a2f22a1385a5d2343377937a7c3cf1a796b78d05b7d070e50b8c5526395e3d968da2ca26f198476}