협력사 포털에서 고객 정보가 새는 길을 막는 법

동딩
2026.10.12·4분 읽기·

API마다 인증을 보고 자기 고객만 연다

비밀번호만 거친 대출모집인 토큰으로 GET /api/customers/1001을 보냅니다. 두 번째 인증을 로그인 화면 순서에만 걸어 두면 이 요청이 통과합니다. 포털 API 전부에 미들웨어를 걸어 요청마다 봅니다. 아래 sessions는 로그인 때 토큰마다 모집인 번호와 두 번째 인증 통과 여부를 넣어 두는 저장소입니다.

js
app.use('/api', (req, res, next) => {
const token = req.get('authorization')?.replace('Bearer ', '');
const session = sessions.get(token);
if (!session?.mfaVerified) {
return res.status(401).json({ error: 'unauthorized' });
}
req.brokerId = session.brokerId;
next();
});

이제 같은 요청에 401 Unauthorized가 돌아옵니다. 두 번째 인증까지 거친 모집인 토큰으로 내 고객 1001과 남의 고객 1002를 차례로 보냅니다. 번호만 바꿨는데 남의 고객이 열리는 구멍을 IDOR라고 합니다.

zsh고객 조회
$ H="Authorization: Bearer $TOKEN"
$ API=localhost:4000/api/customers
$ curl -s -H "$H" $API/1001 | jq .name
"홍길동"
$ curl -s -H "$H" $API/1002 | jq .name
"이영희"
다른 모집인의 고객이 열린다(IDOR)
소유자 조건을 넣은 뒤
$ curl -s -H "$H" $API/1002 | jq .error
"not found"
모집인별 상한을 건 뒤, 1분에 31번
$ for i in $(seq 31); do curl -so /dev/null \
-H "$H" -w '%{http_code}\n' $API/1001; done \
| sort | uniq -c
30 200
1 429
모집인 토큰으로 고객 번호를 바꿔 보낸 요청과 고친 뒤

고칠 곳은 두 군데입니다. 조회 쿼리에 소유자 조건을 넣고, 고객 조회 경로에 모집인별 상한을 겁니다. 상한은 express-rate-limit의 rateLimit으로 인증 미들웨어 뒤에 두고, 키는 모집인 번호로 잡습니다. 그래야 훔친 계정 하나로 여러 IP에서 나눠 긁어도 걸립니다.

js
app.use('/api/customers', rateLimit({
windowMs: 60 * 1000,
limit: 30,
keyGenerator: (req) => String(req.brokerId),
}));
app.get('/api/customers/:id', async (req, res) => {
const { rows } = await db.query(
`SELECT id, name, phone FROM customers
WHERE id = $1 AND broker_id = $2`,
[req.params.id, req.brokerId],
);
if (!rows[0]) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});

limit은 모집인이 1분 동안 실제로 여는 고객 수보다 넉넉하게 잡습니다. 없는 고객과 남의 고객을 똑같이 404로 돌려주면 그 번호가 있는지도 새지 않습니다. 상한에 걸리지 않게 천천히 긁어 가는 계정은 조회 기록과 알림이 잡습니다.

조회가 평소보다 튀는 계정에 알림을 걸려면

로그인 성공·실패, 권한 실패, 민감한 데이터 조회를 시각·계정·IP와 함께 남깁니다. 알림 기준은 고정 숫자보다 그 계정의 평소 값에서 잡습니다. 아래 쿼리는 오늘 고객 조회 수가 지난 28일 하루 평균의 $1배를 넘은 모집인을 찾습니다.

sql
SELECT broker_id, count(*) AS today
FROM access_log
WHERE action = 'customer.read' AND at >= current_date
GROUP BY broker_id
HAVING count(*) > $1 * (
SELECT count(*) / 28.0 FROM access_log p
WHERE p.broker_id = access_log.broker_id
AND p.action = 'customer.read'
AND p.at >= current_date - 28 AND p.at < current_date
);

같은 IP의 로그인 실패는 broker_id 자리에 ip, 행동에 login.fail을 넣어 같은 식으로 봅니다. 배수는 넉넉하게 시작해 헛알림이 줄어드는 선까지 낮춥니다.

비밀번호만으로는 샌 계정에 뚫린다

대출모집인이 협력사 포털에서 자기 고객 연락처를 열면 화면은 GET /api/customers/1001을 보냅니다. 이 요청을 데이터에 닿기 전에 멈춰 세울 수 있는 곳은 넷입니다. 한 곳이라도 그냥 통과시키면 그 길로 고객 정보가 샙니다.

샌 비밀번호는 인증에서, 남의 고객 번호는 소유자 조건에서 막힌다

외부노출두 번째인증모집인상한소유자조건고객DB함께 배포된 .git·.map 파일닫힘비밀번호만 거친 토큰401같은 계정의 1분 31번째 조회429남의 고객 1002404내 고객 1001200
멈춘 곳데이터에 닿음
포털 API에 네 곳을 모두 건 뒤 보낸 요청

비밀번호에 더해 휴대폰 확인 같은 두 번째 인증을 받는 방식이 다중인증입니다. 같은 공격을 받은 금융사 사이에서 침해 여부를 가른 조건 하나가 다중인증이었습니다. 다른 곳에서 샌 비밀번호로 들어온 요청은 비밀번호만 봐서는 진짜 모집인과 구별되지 않습니다.

필요 없는 주소와 파일은 밖에서 닫는다

고객용이 아닌 포털도 꼭 필요한 주소만 밖에 엽니다. 2026년 10월 금융권 유출도 대출모집인·직원용 시스템에서 났습니다. 앞 점검에서 뚫린 곳을 바로 고치기 어렵다면 고칠 때까지 포털 자체를 밖에서 닫아 둡니다.

포털을 밖에 열어 둬야 한다면, 같은 서버로 딸려 나간 개발 파일이 함께 열리지 않았는지 봅니다.

경로200이면 새는 것막는 설정
/.git/config.git째 받아 되살린 소스nginx location ~ /\.git { deny all; }
/actuator/envSpring Boot 설정 항목management.endpoints.web.exposure.include=health
/main.js.map압축 전 프런트 코드와 API 경로Vite build.sourcemap: false

main.js.map은 페이지가 불러오는 번들 주소 뒤에 .map을 붙인 경로입니다. 아래를 내 주소에만 돌려 200이 하나라도 나오면 그 줄의 설정부터 넣습니다. 남의 서비스에 보내면 허가 없는 점검입니다.

bash
SITE=https://partner.example.com
for p in .git/config actuator/env main.js.map; do
echo "$(curl -so /dev/null -w '%{http_code}' "$SITE/$p") $p"
done
Comments

댓글

powered by giscus ↗