SecureMyi.com Security and Systems Management Newsletter for the IBM i                 June 12, 2013 - Vol 3, Issue 30
Live Online Training from The 400 School
Security software



Security? See how SKYVIEW PARTNERS can help!























What is an Exit Point? And How do I Write Exit Programs?

By Dan Riehl

Is There a Security Problem with IBM i?

The IBM i security architecture is VERY robust when we take the time to properly configure our user applications and system settings.

However, security exposures can be introduced by network-data-access tools like FTP and ODBC, but these do not indicate a failing on the part of IBM i security. Rather, the object level authority(i.e. permission) you provide to a user for "green screen" access using menus and textual screens is usually not the same authority you want to allow using network tools like FTP and ODBC.

The same object-level authority that enables a user to view the contents of the Payroll file is the same authority needed to download the file to a PC and post the content on the Internet. IBM recognized the potential areas for abuse and has provided an "Exit Point" facility to let you audit and control these sensitive network access points.

One important point on the need for Exit Programs is that without an Exit Program in place, the IBM i operating system provides NO LOGGING of access when tools like FTP are used. There is no FTP Log. So, Who downloaded your most sensitive file today? There is no way to know. Given that fact, Exit Programs that can audit and control server activities are an essential requirement for security and compliance.

This article describes how you can audit and control access using Exit Programs. I'll specifically show you an Exit Program, written in Control Language, that you can use to audit and control the FTP server Logon process for the IBM i.

What exactly is an Exit Point?

An exit point is simply a point in an application at which the application can optionally call an external program to perform customized processing. The IBM i FTP logon server application includes an exit point where you can hook your own program into the FTP logon processing logic to control who can log on and what will occur when a logon attempt is made. To tell the FTP server that you have an exit program, you use the WRKREGINF (Work with Registration Information) or ADDEXITPGM (Add Exit Program) command. We'll see the actual ADDEXITPGM command shortly.

Once you have registered your exit program, whenever a user attempts to log on to the FTP server, the server finds your program that's registered for the exit point, then calls your exit program, passing as parameters information about the user who's logging on. Your exit program then processes that information and takes the appropriate action, according to the security rules you implement in the exit program. Upon return, your exit program passes back a flag to either ACCEPT or REJECT the logon attempt.

Exit Point Names and Interfaces

Each exit point has a name and an Exit Point Interface. The Exit Point Interface is a list of input and output parameters the IBM server program exchanges with your exit program. The QIBM_QTMF_SVR_LOGON exit point occurs immediately after a user enters a user ID and an authentication string (i.e., password) to log on to the FTP server. This exit point typically uses the TCPL0100 interface. Figure 1 lists some of the FTP logon exit point interfaces. I use the TCPL0100 interface and SVR_LOGON exit point for my exit program, which I explain a bit later.

Let's look at an example to help clarify what a simple exit program can actually perform and how it interacts with an IBM supplied server process.

Using an exit program attached to the QIBM_QTMF_SVR_LOGON exit point, the FTP server calls your custom program so you can use your own logic to validate the logon attempt. With this or any exit program, your program must accept and return certain input parameters and output values. Among the input parameters in Figure 1 are the client's IP address, the user ID, and the authentication string. Among the output values your program must return is a flag indicating logon acceptance or rejection. If your program returns an "accept" indication, the normal IBM i security logon process continues. Thus, you aren't circumventing the FTP logon processing; rather, you are using an exit program to better control the logon attempt. If your program returns a "reject" return code, the FTP server notifies the user that the FTP logon failed.


Figure 1 - Parameters for the exit program

Parameter Format for the TCPL0100 exit point interface

Parameter  Description                Input or Output       Type and length   
1	   Application identifier          Input                Binary (4)
2	   User identifier                 Input                Char (variable length)
3	   Length of user identifier       Input                Binary (4)
4	   Authentication string           Input                Char (variable length)
5	   Length of authentication string Input                Binary (4)
6	   Client IP address               Input                Char (variable length)
7	   Length of client IP address     Input                Binary (4)
8	   Return code                     Output               Binary (4)
9	   User profile                    Output               Char (10)
10	   Password                        Output               Char (10)
11	   Initial current library         Output               Char (10)

The following Parameteres were Added in OS/400 V4R4 for the TCPL0200 Interface:		
12	   Initial home directory          Output               Char (variable length)
13	   Length of initial home direct   Input/Output         Binary (4)
14	   Application-specific data       Input/Output         Char (variable length)
15	   Length of applic-specific data  Input                Binary (4)

Again, let me reiterate, the exit program does not replace the server logon security protection, but rather is a logon preprocessor that intercepts the logon attempt before it reaches the IBM i logon processor. In the exit program, you can direct the FTP server to either accept or reject the logon attempt, and you can override certain FTP logon options as well.


Figure 2 - Return Code and Return Values

Output Parameters for the TCPL0100 Interface

Return Code  User Profile      Password           Initial (Current) Library Used
0 Reject     Ignored           Ignored            Ignored
1 Accept     Original User ID  Original Password  From User profile
2 Accept     Original User ID  Original Password  Return value
3 Accept     Return value      Return value       From User profile specified in Return value
4 Accept     Return value      Return value       Return value
5 Accept     Return value      Ignored            From User profile specified in Return value
6 Accept     Return value      Ignored            Return value

As you view the allowable values for the accept/reject return code flag in Figure 2, it's easier to understand how to use the user profile, password, and CURLIB return values. Depending on the value set for the accept/reject return code flag, you may need to place a value in one or more of these return parameters. The return parameters let you override the values that would normally be used in the FTP logon attempt. For instance, by using the value 3 for the accept/reject flag, you tell FTP you are overriding the values for the user profile and password with the values that your exit program places in the user profile and password return parameters. With other accept/reject flag values, you decide whether the value of CURLIB for the FTP session should come from the CURLIB value specified in the user profile object or from the CURLIB return parameter. Using the accept/reject flag with the other return parameters, you can dictate not only the profile and password for the FTP session but also the library used as the CURLIB for the session.

You should be aware that if your program returns a 5 or 6 value in the accept/reject flag, no further password validation occurs. Although this seems really scary, it can actually be a good method to enable anonymous FTP (i.e., letting an unnamed user access files you want to make publicly available via FTP). The authentication string for an anonymous FTP user typically is the user's e-mail address. However, an e-mail address typically cannot be used as a valid IBM i password. Thus, you can use values 5 or 6 to bypass password-checking. If, or when you decide to enable anonymous FTP, there are several issues that you must deal with, such as restricting anonymous users to accessing only your public files and to prohibit use of certain FTP subcommands, such as RCMD (Remote Command).

The Server Logon Exit Program and special handling for "Hacker Traps"

Now that you know what exit points are, let's explore a sample FTP logon exit point program. CL program FTPLIEXIT (Figure 3) does a few functions you may find useful. (To learn how to install this program, see "Installing Exit Program FTPLIEXIT," below.)

First, it records all FTP logon attempts by sending a formatted message to a message queue(i.e. The Audit Trail). There is no other easy way to see this Logon information on the IBM i, so FTPLIEXIT lets you easily see who's attempting to access your FTP server.

Second, I have added what I call "hacker traps." The first hacker trap checks to see whether someone is trying to log on as user ANONYMOUS. A hacker will routinely try anonymous FTP to a host to determine what operating system is in use, look at file structures, and so on. Since I want to disallow all use of anonymous FTP, I'll treat this anonymous logon attempt as an attempt to break into the system, and send a message to the system operator advising of the intrusion attempt.

The second hacker trap consists of testing the user's IP address to see whether the request is coming from outside my local subnet. If the user's IP address is not in my subnet, I again treat it as an intrusion attempt, and send a message to the system operator.

Figure 3 - The FTPLIEXIT program Source code


 /* Program Name: FTPLIEXIT                                           */
 /* Purpose: This is the FTP server Login exit point program to       */ 
 /*          record all FTP login attempts to a message queue,        */
 /*          and to send attempted intrusion messages to the          */
 /*          System Operator.                                         */
 /*          Exit Point IS QIBM_QTMF_SVR_LOGON.                       */
 /*          Parameter format is TCPL0100.                            */
 /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
 /* Copyright - Dan Riehl 2000-2013   ALL RIGHTS RESERVED             */
 /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
 /* Security:     Place in a secure library (I.E., PUBLIC(*EXCLUDE)). */
 /*               Place source code in a secure library.              */
 /*               Do not allow retrieval of CL Source.                */
 /* Compilation:  CRTCLPGM PGM(ASECURELIBRARY/FTPLIEXIT)    +         */
 /*                        SRCFILE(ASECURELIBRARY/QCLSRC)   +         */
 /*                        LOG(*NO)                         +         */
 /*                        ALWRTVSRC(*NO)                   +         */
 /*                        AUT(*EXCLUDE)                              */
 /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
     Pgm                (  &P_AppID   + 
                           &P_User    + 
                           &P_UserLen + 
                           &P_Pwd     +  
                           &P_PwdLen  + 
                           &P_IP      + 
                           &P_IPLen   + 
                           &P_RtnOut  + 
                           &P_UserOut + 
                           &P_PwdOut  + 
                           &P_LibOut  )
                                          
  /* Paramaters for exit point interface format TCPL0100            */
                                    
  /* Input Parms */                                     
   DCL       &P_AppID     *CHAR   4    /* Application ID (%BIN)     */
                                       /* 1 = FTP                   */ 
   DCL       &P_User      *CHAR 999    /* User ID                   */
   DCL       &P_UserLen   *CHAR   4    /* User ID Length (%BIN)     */
   DCL       &P_Pwd       *CHAR 999    /* Password                  */ 
   DCL       &P_PwdLen    *CHAR   4    /* Password length (%BIN)    */
   DCL       &P_IP        *CHAR  15    /* Requester IP Address      */
   DCL       &P_IPLen     *CHAR   4    /* IP Address length  (%BIN) */
                                                             
  /* Output parms */                                              
   DCL       &P_RtnOut    *CHAR   4    /* Return Code OUT           */
                                       /* Values are:               */
                                       /* 0=Reject                  */
                                       /* 1=Accept, W/USRPRF CURLIB */
                                       /* 2=Accept, W/ &P_LIbOut    */
                                       /* 3=Accept, W/USRPRF CURLIB */
                                       /*          AND &P_UserOut   */
                                       /*          AND &P_PwdOut    */
                                       /* 4=Accept, W/ &P_LibOut    */
                                       /*          AND &P_UserOut   */ 
                                       /*          AND &P_PwdOut    */
                                       /* 5=Accept, W/USRPRF CURLIB */ 
                                       /*          AND &P_UserOut   */
                                       /*          PASSWORD BYPASS  */ 
                                       /* 6=Accept, W/ P_LibOut     */ 
                                       /*          AND &P_UserOut   */ 
                                       /*          PASSWORD BYPASS  */
   DCL       &P_UserOut   *CHAR  10    /* User Profile OUT          */
   DCL       &P_PwdOut    *CHAR  10    /* Password OUT              */
   DCL       &P_LibOut    *CHAR  10    /* Curlib OUT                */ 
 /* END OF FORMAT TCPL0100                                          */ 
                                                
 /* VARIABLES FOR BINARY CONVERSIONS */           
   DCL       &AppID       *DEC   (1 0)  
   DCL       &UserLen     *DEC   (3 0)   
   DCL       &PwdLen      *DEC   (3 0)                 
   DCL       &IPLen       *DEC   (3 0)   
                                        
 /* MISC. WORK VARIABLES             */  
   DCL       &Time        *CHAR   6    
   DCL       &Date        *CHAR   6       
   DCL       &Message     *CHAR 256   
   DCL       &Accept1     *DEC    1   VALUE(1) 
   DCL       &Reject0     *DEC    1   VALUE(0)   
   DCL       &MsgQ        *CHAR  10   VALUE('FTPLOG')  
   DCL       &MsgQLib     *CHAR  10   VALUE('FTPLIB')    
                                      
 /* MESSAGE-HANDLING VARIABLES */              
   DCL       &MsgID       *CHAR   7                         
   DCL       &MsgF        *CHAR  10  
   DCL       &MsgFLib     *CHAR  10      
   DCL       &MsgDta      *CHAR 100  
                                     
   Monmsg    (CPF0000  MCH0000)  Exec(GoTo Error) 
                                             
          ChgVar    &AppID    %BIN(&P_AppID)     
          ChgVar    &Userlen  %BIN(&P_UserLen)        
          ChgVar    &Pwdlen   %BIN(&P_PwdLen)              
          ChgVar    &IPLen    %BIN(&P_IPLen)               
                                               
          RtvSysVal  QTIME    &Time  
          RtvSysVal  QDATE    &Date                    
                                               
/* Send Intrusion Message if ANONYMOUS FTP Attempted */ 
   IF     (%SST(&P_User 1 &UserLen) = 'ANONYMOUS')   Do      
          Chgvar    &Message                            +         
               ('FTP INTRUSION by'                      +        
                *BCAT %SST(&P_User 1 &UserLen)          +         
                *BCAT 'at IP Addr'                      +  
                *BCAT %SST(&P_IP 1 &IPLen)              +        
                *BCAT 'at'                              +        
                *BCAT %SST(&Time 1 2)                   +    
                *CAT  ':'                               +         
                *CAT  %SST(&Time 3 2)                   +         
                *CAT  ':'                               +            
                *CAT  %SST(&Time 5 2)                   + 
                *BCAT %SST(&Date 1 2)                   +  
                *CAT  '/'                               + 
                *CAT  %SST(&Date 3 2)                   + 
                *CAT  '/'                               + 
                *CAT  %SST(&Date 5 2)                   +           
                *CAT  ': PWD='                          +      
                *CAT %SST(&P_Pwd 1 &PwdLen))                   
                                                         
          ChgVar    %Bin(&P_RtnOut)   Value(&Reject0)  /* Return "Rejected"*/   
                                                          
          SndPgmMsg      MsgID(CPF9897)                 + 
                         Msgf(QCPFMSG)                  + 
                         MsgDta(&Message)               + 
                         ToMsgQ(QSYSOPR)                       
                                                             
          SndPgmMsg      MsgID(CPF9897)                 +           
                         Msgf(QCPFMSG)                  +        
                         MsgDta(&Message)               +  
                         ToMsgQ(FTPLIB/FTPLOG)                
                                                 
          GoTo       EndCLPgm      
   EndDo                          
                                                             
/* Send Intrusion Message if Attempt made from Outside Submet IP range */ 
   IF     (%SST(&P_IP 1 6) *NE '10.0.8')   Do            
          Chgvar    &Message                            +  
               ('FTP INTRUSION by'                      +  
                *BCAT %SST(&P_User 1 &UserLen)          +  
                *BCAT 'at IP Addr'                      + 
                *BCAT %SST(&P_IP 1 &IPLen)              +  
                *BCAT 'at'                              +  
                *BCAT %SST(&Time 1 2)                   + 
                *CAT  ':'                               + 
                *CAT  %SST(&Time 3 2)                   +   
                *CAT  ':'                               + 
                *CAT  %SST(&Time 5 2)                   + 
                *BCAT %SST(&Date 1 2)                   + 
                *CAT  '/'                               +   
                *CAT  %SST(&Date 3 2)                   + 
                *CAT  '/'                               + 
                *CAT  %SST(&Date 5 2))                
        /* Don't include the password here. It may be a valid user and password */ 
                                                                            
          ChgVar    %Bin(&P_RtnOut)   Value(&Reject0)  /* Return "Rejected"*/ 
                                                               
          SndPgmMsg      MsgID(CPF9897)                 +  
                         Msgf(QCPFMSG)                  +  
                         MsgDta(&Message)               + 
                         ToMsgQ(QSYSOPR)                
                                                       
          SndPgmMsg      MsgID(CPF9897)                 + 
                         Msgf(QCPFMSG)                  + 
                         MsgDta(&Message)               + 
                         ToMsgQ(FTPLIB/FTPLOG)  

          GoTo       EndCLPgm     
   EndDo             
                                            
/* Looks like a valid request. Let IBM i check UserID and Password */ 
/* Here we log the attempted Login to the FTPLOG Message queue     */ 
          Chgvar    &Message                            +   
               ('FTP Logon'                             +   
                *BCAT %SST(&P_User 1 &UserLen)          +  
                *BCAT 'From IP Addr'                    + 
                *BCAT %SST(&P_IP 1 &IPLen)              + 
                *BCAT 'at'                              +       
                *BCAT %SST(&Time 1 2)                   +         
                *CAT  ':'                               +  
                *CAT  %SST(&Time 3 2)                   + 
                *CAT  ':'                               +  
                *CAT  %SST(&Time 5 2)                   + 
                *BCAT 'on'                              + 
                *BCAT %SST(&Date 1 2)                   + 
                *CAT  '/'                               + 
                *CAT  %SST(&Date 3 2)                   +   
                *CAT  '/'                               +    
                *CAT  %SST(&Date 5 2))                
                                                      
          SndPgmMsg      MsgID(CPF9897)                 +  
                         Msgf(QCPFMSG)                  + 
                         MsgDta(&Message)               + 
                         ToMsgQ(&MsgQLib/&MsgQ)        
                                                     
          ChgVar    %Bin(&P_RtnOut)   Value(&Accept1)  /* Return "Accept" */
                                                
EndCLPgm:                                       
          Return     /* Normal end of program */
                                               
Error:  /* if the exit program bombs, a message will be sent to the JobLog */ 
          RcvMsg     Msgtype(*LAST)               +  
                     MsgDta(&MsgDta)              +  
                     MsgID(&MsgID)                +  
                     MsgF(&MsgF)                  + 
                     SndMsgFLib(&MsgFLib)    
                                          
/* Prevent loop, just in case           */
          MonMsg     CPF0000            
                                        
          SndPgmMsg  MsgID(&MsgID)                +       
                     MsgF(&MsgFLib/&MsgF)         +       
                     MsgDta(&MsgDta)              + 
                     MsgType(*ESCAPE)
/* Prevent loop, just in case           */    
          MonMsg     CPF0000
          
          EndPgm

Detail of the Exit Program

As you can see, it's a simple CL program that accepts the QIBM_QTMF_SVR_LOGON parameter values shown in Figure 1 and assembles messages that are sent to a message queue. In hacker trap situations it rejects the log-on request, otherwise the log-on is accepted, and normal IBM i log-on validation proceeds.

Note: For the purpose of portability to various OS/400 and IBM i releases, the CL code used in the exit program is written to be a CLP source type, using older language constructs such as using the %BIN function, where with the advances in CL, we could have used the newer *INT data type.

Figure 4 below shows the FTPLOG message queue - a record of all FTP server log-on requests. This information is useful when you want to know who's been logging on, or attempting to break into your FTP server. The displayed information includes the requester's user ID, IP address, and time and date of the log-on attempt. For Anonymous log-on attempts, the authentication string(password or e-mail address) is also shown. Keep in mind that TCP/IP's limitations mean none of this information is guaranteed to be true; a hacker can, for example, forge the originating IP address on the log-on packet to make the log-on attempt appear to originate from another location. However, for nondevious users, you can expect the information to be accurate.

Figure 4 - Screen shot of the FTPLOG Message Queue (Audit Trail)

                              Display Messages                                
                                                       System:   SECURMYI       
 Queue . . . . . :   FTPLOG               Program . . . . :   *DSPMSG        
   Library . . . :     FTPLIB               Library . . . :                  
 Severity  . . . :   00                   Delivery  . . . :   *HOLD          
                                                                                
 Type reply (if required), press Enter.                                         
   FTP Logon DRIEHL From IP Addr 193.135.191.111 at 12:28:54 on 04/12/13        
   FTP INTRUSION by PROGRAMMER1 From IP Addr 191.145.191.115 at 12:30:38 on 04/12/13   
   FTP Logon DEVELOPMENT From IP Addr 188.145.191.116 at 12:31:50 on 04/12/13   
   FTP Logon TESTAPP From IP Addr 192.101.181.112 at 12:33:17 on 04/12/13       
   FTP Logon BOSS From IP Addr 190.125.191.124 at 12:34:03 on 04/12/13          
   FTP INTRUSION by ANONYMOUS From IP Addr 10.1.2.1 at 12:34:42 on 04/13/13: PWD=jim@hack.me     
   FTP Logon AS400DEV From IP Addr 111.135.291.111 at 12:35:36 on 04/14/13      
   FTP Logon DRIEHL From IP Addr 111.135.191.111 at 12:44:31 on 04/15/13        
                                                                
                                                                         Bottom 
 F3=Exit              F11=Remove a message                 F12=Cancel           
 F13=Remove all       F16=Remove all except unanswered     F24=More keys        

Security Warning!!!

Whenever a user Logs-on to the FTP server, an exit program attached to the FTP server Logon process, as this program is, will receive, in plain text, the UserID and Password. An exit program registered at this exit point could be used improperly to harvest passwords.

The current issue (June 12, 2013) of the SecureMyi Security Newsletter contains an article on further security exposures of the FTP Logon Exit program, and how it can be improperly used to compromise the integrity and security of your system. The article is entitled "Logon to IBM i with No UserID and No Password"


Registering your Exit Program

Once you've written and compiled an exit program for a TCP/IP exit point, you must tell the system the name of the program and library in which it resides. To do this, you must register the exit program with the WRKREGINF command. For our sample program here, Page down to the QIBM_QTMF_SVR_LOGON exit point and enter option 8 (Work with Exit Programs). You then enter the name of the exit program and the library in which it exists on the screen. In order to activate the new exit point program, you must restart the FTP server. Then, each time the FTP application reaches the exit point, your program will be called.

If the exit program encounters an error, an error message is written to the joblog of the FTP server job.

Time to Exit

The FTP server Log-on exit point lets you customize your FTP environment to a considerable extent. But in order to audit and control the activity of a user once logged on to FTP, you will need to examine the use of the FTP Server Request Validation exit point and exit program.

If you have any questions or comments about this article, or the exit program, please let me know.




Sidebar: Installing Exit Program FTPLIEXIT

1. Sign on as QSECOFR, or some other powerful profile with *ALLOBJ and *IOSYSCFG special authority. (Note: Users who have *USE authority to this powerful profile can potentially modify your exit program.)

2. Create library FTPLIB with AUT(*EXCLUDE).

CRTLIB LIB(FTPLIB) TEXT('FTP Log Library') AUT(*EXCLUDE)

3. Grant user QTCP *USE authority to the library.

GRTOBJAUT OBJ(FTPLIB) OBJTYPE(*LIB) USER(QTCP) AUT(*USE)

3. Create source file QCLSRC with AUT(*EXCLUDE).

CRTSRCPF FILE(FTPLIB/QCLSRC) TEXT('FTP CL Source code') AUT(*EXCLUDE)

4. Enter the source in the main article's Figure 4 into CLP member FTPLIEXIT.

Be sure to replace the subnet IP address with your own subnet requirements.

IF (%SST(&P_IP 1 6) *NE '10.0.8') Do

(Note: If the subnet you specify is different than 6 characters long, as in '127.125.13', which is 10 characters(including dots), you must change the %SST function to indicate the length of the subnet, in this case 10. Here you would need to use %SST(&P_IP 1 10)… the 10 indicating the length of the subnet string.)

5. Create the program in library FTPLIB.

CRTCLPGM   PGM(FTPLIB/FTPLIEXIT)   SRCFILE(FTPLIB/QCLSRC)  +          
          SRCMBR(FTPLIEXIT) LOG(*NO) ALWRTVSRC(*NO)  AUT(*EXCLUDE)

6. Create message queue FTPLOG.

CRTMSGQ MSGQ(FTPLIB/FTPLOG) TEXT('FTP Login Log') AUT(*EXCLUDE)

7. Grant user QTCP *CHANGE authority to the FTPLOG message queue.

GRTOBJAUT OBJ(FTPLIB/FTPLOG) OBJTYPE(*MSGQ) USER(QTCP) AUT(*CHANGE)

8. Register the exit program with the ADDEXITPGM (Add Exit Program) command.

ADDEXITPGM EXITPNT(QIBM_QTMF_SVR_LOGON)   FORMAT( TCPL0100)    +
        PGMNBR( 1)   PGM(FTPLIB/FTPLIEXIT)

9. Activate the new exit program. This requires resetting the FTP Server. (Note: When you modify and recompile the exit program, you must always restart the FTP server to have the changes take effect.)

ENDTCPSVR SERVER(*FTP)
STRTCPSVR SERVER(*FTP)


Live Online Training from The 400 School

About the Author

Dan Riehl is the Editor of the SecureMyi Security Newsletter and a Security Specialist for
the IT Security and Compliance Group

Dan performs IBM i security assessments and provides security consulting, remediation, forensic evaluations, and other customized security services for his clients. He also provides training in all aspects of IBM i security and other technical areas through The 400 School, Inc.

Dan Riehl on LinkedIn




   
      Training from The 400 School